diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index 05ef7d3c..ad9bc213 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -112,6 +112,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str: @mcp.tool( + title="Build Context", description="""Build context from a memory:// URI to continue conversations naturally. Use this to follow up on previous discussions or explore related topics. @@ -131,6 +132,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str: - "json" (default): Structured JSON with internal fields excluded - "text": Compact markdown text for LLM consumption """, + tags={"navigation", "notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def build_context( diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py index ca5b85cf..0a54aeb2 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -17,7 +17,9 @@ @mcp.tool( + title="Create Canvas", description="Create an Obsidian canvas file to visualize concepts and connections.", + tags={"canvas", "notes"}, annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False}, ) async def canvas( diff --git a/src/basic_memory/mcp/tools/chatgpt_tools.py b/src/basic_memory/mcp/tools/chatgpt_tools.py index 53cbf14a..bd510ee8 100644 --- a/src/basic_memory/mcp/tools/chatgpt_tools.py +++ b/src/basic_memory/mcp/tools/chatgpt_tools.py @@ -105,7 +105,9 @@ def _format_document_for_chatgpt( @mcp.tool( + title="Search Knowledge Base", description="Search for content across the knowledge base", + tags={"search"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def search( @@ -167,7 +169,9 @@ async def search( @mcp.tool( + title="Fetch Document", description="Fetch the full contents of a search result document", + tags={"search", "notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def fetch( diff --git a/src/basic_memory/mcp/tools/cloud_info.py b/src/basic_memory/mcp/tools/cloud_info.py index 891018ef..03b1b1aa 100644 --- a/src/basic_memory/mcp/tools/cloud_info.py +++ b/src/basic_memory/mcp/tools/cloud_info.py @@ -7,6 +7,8 @@ @mcp.tool( "cloud_info", + title="Cloud Info", + tags={"cloud"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) def cloud_info() -> str: diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 348d4957..0c8076bb 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -182,7 +182,9 @@ def _directory_path_for_delete( @mcp.tool( + title="Delete Note", description="Delete a note or directory by title, permalink, or path", + tags={"notes"}, annotations={"destructiveHint": True, "openWorldHint": False}, ) async def delete_note( diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 5b3304cb..6d77d479 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -304,7 +304,9 @@ def _format_error_response( @mcp.tool( + title="Edit Note", description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.", + tags={"notes"}, annotations={"destructiveHint": False, "openWorldHint": False}, ) async def edit_note( diff --git a/src/basic_memory/mcp/tools/list_directory.py b/src/basic_memory/mcp/tools/list_directory.py index 22ca5dcf..68f471c0 100644 --- a/src/basic_memory/mcp/tools/list_directory.py +++ b/src/basic_memory/mcp/tools/list_directory.py @@ -11,7 +11,9 @@ @mcp.tool( + title="List Directory", description="List directory contents with filtering and depth control.", + tags={"navigation", "notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def list_directory( diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index 051f4438..67316ad2 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -357,7 +357,9 @@ def _format_move_error_response(error_message: str, identifier: str, destination @mcp.tool( + title="Move Note", description="Move a note or directory to a new location, updating database and maintaining links.", + tags={"notes"}, annotations={"destructiveHint": False, "openWorldHint": False}, ) async def move_note( diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index 6839dd46..cd022906 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -357,6 +357,8 @@ def _format_project_list_json( @mcp.tool( "list_memory_projects", + title="List Memory Projects", + tags={"projects"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def list_memory_projects( @@ -495,6 +497,8 @@ async def _resolve_workspace_routing( @mcp.tool( "create_memory_project", + title="Create Memory Project", + tags={"projects"}, annotations={"destructiveHint": False, "openWorldHint": False}, ) async def create_memory_project( @@ -636,6 +640,8 @@ async def create_memory_project( @mcp.tool( + title="Delete Project", + tags={"projects"}, annotations={"destructiveHint": True, "openWorldHint": False}, ) async def delete_project( diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index a8266aee..2e44710f 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -155,7 +155,9 @@ def optimize_image(img, content_length, max_output_bytes=350000): @mcp.tool( + title="Read Content", description="Read a file's raw content by path or permalink", + tags={"notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def read_content( diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 178fa325..6e06b60d 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -74,7 +74,9 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]: @mcp.tool( + title="Read Note", description="Read a markdown note by title or permalink.", + tags={"notes"}, # TODO: re-enable once MCP client rendering is working # meta={"ui/resourceUri": "ui://basic-memory/note-preview"}, annotations={"readOnlyHint": True, "openWorldHint": False}, diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index e980ab2c..c20a662e 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -26,6 +26,7 @@ @mcp.tool( + title="Recent Activity", description="""Get recent activity for a project or across all projects. Timeframe supports natural language formats like: @@ -36,6 +37,7 @@ - "3 weeks ago" Or standard formats like "7d" """, + tags={"navigation", "notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def recent_activity( diff --git a/src/basic_memory/mcp/tools/release_notes.py b/src/basic_memory/mcp/tools/release_notes.py index f325c47a..17ea4d92 100644 --- a/src/basic_memory/mcp/tools/release_notes.py +++ b/src/basic_memory/mcp/tools/release_notes.py @@ -7,6 +7,8 @@ @mcp.tool( "release_notes", + title="Release Notes", + tags={"cloud"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) def release_notes() -> str: diff --git a/src/basic_memory/mcp/tools/schema.py b/src/basic_memory/mcp/tools/schema.py index c3c3f999..ce5c046e 100644 --- a/src/basic_memory/mcp/tools/schema.py +++ b/src/basic_memory/mcp/tools/schema.py @@ -204,7 +204,9 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str: @mcp.tool( + title="Validate Schema", description="Validate notes against their Picoschema definitions.", + tags={"schema"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def schema_validate( @@ -317,7 +319,9 @@ async def schema_validate( @mcp.tool( + title="Infer Schema", description="Analyze existing notes and suggest a Picoschema definition.", + tags={"schema"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def schema_infer( @@ -438,7 +442,9 @@ async def schema_infer( @mcp.tool( + title="Schema Diff", description="Detect drift between a schema definition and actual note usage.", + tags={"schema"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def schema_diff( diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 08f87bc1..feef6417 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -612,7 +612,9 @@ async def _search_all_projects( @mcp.tool( + title="Search Notes", description="Search across all content in the knowledge base with advanced syntax support.", + tags={"search"}, # TODO: re-enable once MCP client rendering is working # meta={"ui/resourceUri": "ui://basic-memory/search-results"}, annotations={"readOnlyHint": True, "openWorldHint": False}, diff --git a/src/basic_memory/mcp/tools/ui_sdk.py b/src/basic_memory/mcp/tools/ui_sdk.py index b70f7d79..e45313d4 100644 --- a/src/basic_memory/mcp/tools/ui_sdk.py +++ b/src/basic_memory/mcp/tools/ui_sdk.py @@ -18,7 +18,9 @@ def _text_block(message: str) -> List[ContentBlock]: @mcp.tool( + title="Search Notes (UI)", description="Search notes and return an embedded MCP-UI resource (raw HTML).", + tags={"search", "ui"}, output_schema=None, annotations={"readOnlyHint": True, "openWorldHint": False}, ) @@ -92,7 +94,9 @@ async def search_notes_ui( @mcp.tool( + title="Read Note (UI)", description="Read a note and return an embedded MCP-UI resource (raw HTML).", + tags={"notes", "ui"}, output_schema=None, annotations={"readOnlyHint": True, "openWorldHint": False}, ) diff --git a/src/basic_memory/mcp/tools/view_note.py b/src/basic_memory/mcp/tools/view_note.py index 534ec791..b7613b0b 100644 --- a/src/basic_memory/mcp/tools/view_note.py +++ b/src/basic_memory/mcp/tools/view_note.py @@ -11,7 +11,9 @@ @mcp.tool( + title="View Note", description="View a note as a formatted artifact for better readability.", + tags={"notes"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def view_note( diff --git a/src/basic_memory/mcp/tools/workspaces.py b/src/basic_memory/mcp/tools/workspaces.py index 0ef2f202..fc54fad4 100644 --- a/src/basic_memory/mcp/tools/workspaces.py +++ b/src/basic_memory/mcp/tools/workspaces.py @@ -45,7 +45,9 @@ def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListRe @mcp.tool( + title="List Workspaces", description="List available cloud workspaces (tenant_id, type, role, and name).", + tags={"cloud", "projects"}, annotations={"readOnlyHint": True, "openWorldHint": False}, ) async def list_workspaces( diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index b423c6c7..6c88d9ce 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -26,7 +26,9 @@ @mcp.tool( + title="Write Note", description="Create a markdown note. If the note already exists, returns an error by default — pass overwrite=True to replace.", + tags={"notes"}, annotations={"destructiveHint": True, "idempotentHint": False, "openWorldHint": False}, ) async def write_note( diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 82864838..9194bb7b 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -6,7 +6,10 @@ from collections.abc import Callable from typing import Any, cast +import pytest + from basic_memory.mcp import tools +from basic_memory.mcp.server import mcp EXPECTED_TOOL_SIGNATURES: dict[str, list[str]] = { @@ -157,3 +160,16 @@ def test_mcp_tool_signatures_are_stable(): for tool_name, tool_obj in TOOL_FUNCTIONS.items(): assert _signature_params(tool_obj) == EXPECTED_TOOL_SIGNATURES[tool_name] + + +@pytest.mark.asyncio +async def test_mcp_tools_have_title_and_tags(): + """Every registered MCP tool must declare a human-readable title and at least one tag. + + This guards against regressions where a new tool is added without the Phase 1 + FastMCP metadata (title + tags) required by issue #826. + """ + tool_list = await mcp.list_tools() + for tool in tool_list: + assert tool.title, f"Tool '{tool.name}' is missing a 'title' annotation" + assert tool.tags, f"Tool '{tool.name}' is missing 'tags' annotation"