diff --git a/src/basic_memory/mcp/tools/build_context.py b/src/basic_memory/mcp/tools/build_context.py index e6c3da0b5..e1b90bf95 100644 --- a/src/basic_memory/mcp/tools/build_context.py +++ b/src/basic_memory/mcp/tools/build_context.py @@ -134,9 +134,12 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str: Format options: - "json" (default): Structured JSON with internal fields excluded - "text": Compact markdown text for LLM consumption + + Queries the Basic Memory knowledge base API — see + https://docs.basicmemory.com/concepts/memory-urls """, tags={"navigation", "notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Build Context", "readOnlyHint": True, "openWorldHint": False}, ) async def build_context( url: Annotated[ diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py index 0a54aeb29..acc7f186e 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -20,7 +20,14 @@ title="Create Canvas", description="Create an Obsidian canvas file to visualize concepts and connections.", tags={"canvas", "notes"}, - annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False}, + annotations={ + "title": "Create Canvas", + "readOnlyHint": False, + # Falls back to PUT when the canvas already exists, replacing its content. + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, + }, ) async def canvas( nodes: Annotated[List[Dict[str, Any]], BeforeValidator(coerce_list)], diff --git a/src/basic_memory/mcp/tools/chatgpt_tools.py b/src/basic_memory/mcp/tools/chatgpt_tools.py index bd510ee8a..1b0193b60 100644 --- a/src/basic_memory/mcp/tools/chatgpt_tools.py +++ b/src/basic_memory/mcp/tools/chatgpt_tools.py @@ -108,7 +108,7 @@ def _format_document_for_chatgpt( title="Search Knowledge Base", description="Search for content across the knowledge base", tags={"search"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Search Knowledge Base", "readOnlyHint": True, "openWorldHint": False}, ) async def search( query: str, @@ -172,7 +172,7 @@ async def search( title="Fetch Document", description="Fetch the full contents of a search result document", tags={"search", "notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Fetch Document", "readOnlyHint": True, "openWorldHint": False}, ) async def fetch( id: str, diff --git a/src/basic_memory/mcp/tools/cloud_info.py b/src/basic_memory/mcp/tools/cloud_info.py index 69af0b811..76b637f09 100644 --- a/src/basic_memory/mcp/tools/cloud_info.py +++ b/src/basic_memory/mcp/tools/cloud_info.py @@ -8,7 +8,7 @@ "cloud_info", title="Cloud Info", tags={"cloud"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Cloud Info", "readOnlyHint": True, "openWorldHint": False}, ) def cloud_info() -> str: """Return optional Basic Memory Cloud information and setup guidance.""" diff --git a/src/basic_memory/mcp/tools/delete_note.py b/src/basic_memory/mcp/tools/delete_note.py index 0c8076bb5..1887e0d93 100644 --- a/src/basic_memory/mcp/tools/delete_note.py +++ b/src/basic_memory/mcp/tools/delete_note.py @@ -185,7 +185,12 @@ def _directory_path_for_delete( title="Delete Note", description="Delete a note or directory by title, permalink, or path", tags={"notes"}, - annotations={"destructiveHint": True, "openWorldHint": False}, + annotations={ + "title": "Delete Note", + "readOnlyHint": False, + "destructiveHint": True, + "openWorldHint": False, + }, ) async def delete_note( identifier: str, diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 6d77d4796..79122a6a3 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -307,7 +307,14 @@ def _format_error_response( 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}, + annotations={ + "title": "Edit Note", + "readOnlyHint": False, + # find_replace and replace_section overwrite existing content, so the tool + # as a whole is not purely additive even though append/prepend are. + "destructiveHint": True, + "openWorldHint": False, + }, ) async def edit_note( identifier: str, diff --git a/src/basic_memory/mcp/tools/list_directory.py b/src/basic_memory/mcp/tools/list_directory.py index 68f471c0c..10bd559fc 100644 --- a/src/basic_memory/mcp/tools/list_directory.py +++ b/src/basic_memory/mcp/tools/list_directory.py @@ -14,7 +14,7 @@ title="List Directory", description="List directory contents with filtering and depth control.", tags={"navigation", "notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "List Directory", "readOnlyHint": True, "openWorldHint": False}, ) async def list_directory( # `dir_name` is unusual; models reach for directory/folder/path/dir. diff --git a/src/basic_memory/mcp/tools/move_note.py b/src/basic_memory/mcp/tools/move_note.py index 67316ad29..aa0d4440b 100644 --- a/src/basic_memory/mcp/tools/move_note.py +++ b/src/basic_memory/mcp/tools/move_note.py @@ -360,7 +360,12 @@ def _format_move_error_response(error_message: str, identifier: str, destination 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}, + annotations={ + "title": "Move Note", + "readOnlyHint": False, + "destructiveHint": False, + "openWorldHint": False, + }, ) async def move_note( identifier: str, diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index fd14d5651..94aa35586 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -359,7 +359,7 @@ def _format_project_list_json( "list_memory_projects", title="List Memory Projects", tags={"projects"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "List Memory Projects", "readOnlyHint": True, "openWorldHint": False}, ) async def list_memory_projects( output_format: Literal["text", "json"] = "text", @@ -503,7 +503,12 @@ async def _resolve_workspace_routing( "create_memory_project", title="Create Memory Project", tags={"projects"}, - annotations={"destructiveHint": False, "openWorldHint": False}, + annotations={ + "title": "Create Memory Project", + "readOnlyHint": False, + "destructiveHint": False, + "openWorldHint": False, + }, ) async def create_memory_project( project_name: str, @@ -647,7 +652,12 @@ async def create_memory_project( @mcp.tool( title="Delete Project", tags={"projects"}, - annotations={"destructiveHint": True, "openWorldHint": False}, + annotations={ + "title": "Delete Project", + "readOnlyHint": False, + "destructiveHint": True, + "openWorldHint": False, + }, ) async def delete_project( project_name: str, diff --git a/src/basic_memory/mcp/tools/read_content.py b/src/basic_memory/mcp/tools/read_content.py index 2e44710f5..3c3f0aa4d 100644 --- a/src/basic_memory/mcp/tools/read_content.py +++ b/src/basic_memory/mcp/tools/read_content.py @@ -156,9 +156,12 @@ 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", + description=( + "Read a file's raw content by path or permalink. Paths resolve against the Basic " + "Memory knowledge base API — see https://docs.basicmemory.com/local/mcp-tools-local" + ), tags={"notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Read Content", "readOnlyHint": True, "openWorldHint": False}, ) async def read_content( path: Annotated[ diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index 6e06b60d1..9fcb4fffc 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -79,7 +79,7 @@ def _parse_opening_frontmatter(content: str) -> tuple[str, dict | None]: tags={"notes"}, # TODO: re-enable once MCP client rendering is working # meta={"ui/resourceUri": "ui://basic-memory/note-preview"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Read Note", "readOnlyHint": True, "openWorldHint": False}, ) async def read_note( identifier: str, diff --git a/src/basic_memory/mcp/tools/recent_activity.py b/src/basic_memory/mcp/tools/recent_activity.py index c20a662e8..95e64db8c 100644 --- a/src/basic_memory/mcp/tools/recent_activity.py +++ b/src/basic_memory/mcp/tools/recent_activity.py @@ -38,7 +38,7 @@ Or standard formats like "7d" """, tags={"navigation", "notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Recent Activity", "readOnlyHint": True, "openWorldHint": False}, ) async def recent_activity( type: Annotated[ diff --git a/src/basic_memory/mcp/tools/release_notes.py b/src/basic_memory/mcp/tools/release_notes.py index 28a0d8ba1..9628033ed 100644 --- a/src/basic_memory/mcp/tools/release_notes.py +++ b/src/basic_memory/mcp/tools/release_notes.py @@ -8,7 +8,7 @@ "release_notes", title="Release Notes", tags={"cloud"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Release Notes", "readOnlyHint": True, "openWorldHint": False}, ) def release_notes() -> str: """Return the latest product release notes for optional user review.""" diff --git a/src/basic_memory/mcp/tools/schema.py b/src/basic_memory/mcp/tools/schema.py index ce5c046ef..862089258 100644 --- a/src/basic_memory/mcp/tools/schema.py +++ b/src/basic_memory/mcp/tools/schema.py @@ -207,7 +207,7 @@ def _no_schema_guidance(note_type: str, tool_name: str) -> str: title="Validate Schema", description="Validate notes against their Picoschema definitions.", tags={"schema"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Validate Schema", "readOnlyHint": True, "openWorldHint": False}, ) async def schema_validate( note_type: Optional[str] = None, @@ -322,7 +322,7 @@ async def schema_validate( title="Infer Schema", description="Analyze existing notes and suggest a Picoschema definition.", tags={"schema"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Infer Schema", "readOnlyHint": True, "openWorldHint": False}, ) async def schema_infer( note_type: str, @@ -445,7 +445,7 @@ async def schema_infer( title="Schema Diff", description="Detect drift between a schema definition and actual note usage.", tags={"schema"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Schema Diff", "readOnlyHint": True, "openWorldHint": False}, ) async def schema_diff( note_type: str, diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index ae674fd83..8ea52de2b 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -617,7 +617,7 @@ async def _search_all_projects( tags={"search"}, # TODO: re-enable once MCP client rendering is working # meta={"ui/resourceUri": "ui://basic-memory/search-results"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Search Notes", "readOnlyHint": True, "openWorldHint": False}, ) async def search_notes( # Accept common search-query aliases models reach for from training data. diff --git a/src/basic_memory/mcp/tools/ui_sdk.py b/src/basic_memory/mcp/tools/ui_sdk.py index e45313d49..f646fd4e9 100644 --- a/src/basic_memory/mcp/tools/ui_sdk.py +++ b/src/basic_memory/mcp/tools/ui_sdk.py @@ -22,7 +22,7 @@ def _text_block(message: str) -> List[ContentBlock]: description="Search notes and return an embedded MCP-UI resource (raw HTML).", tags={"search", "ui"}, output_schema=None, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "Search Notes (UI)", "readOnlyHint": True, "openWorldHint": False}, ) async def search_notes_ui( query: str, @@ -98,7 +98,7 @@ async def search_notes_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}, + annotations={"title": "Read Note (UI)", "readOnlyHint": True, "openWorldHint": False}, ) async def read_note_ui( identifier: str, diff --git a/src/basic_memory/mcp/tools/view_note.py b/src/basic_memory/mcp/tools/view_note.py index b7613b0bf..04c727b46 100644 --- a/src/basic_memory/mcp/tools/view_note.py +++ b/src/basic_memory/mcp/tools/view_note.py @@ -14,7 +14,7 @@ title="View Note", description="View a note as a formatted artifact for better readability.", tags={"notes"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "View Note", "readOnlyHint": True, "openWorldHint": False}, ) async def view_note( identifier: str, diff --git a/src/basic_memory/mcp/tools/workspaces.py b/src/basic_memory/mcp/tools/workspaces.py index fc54fad4a..384fc7390 100644 --- a/src/basic_memory/mcp/tools/workspaces.py +++ b/src/basic_memory/mcp/tools/workspaces.py @@ -48,7 +48,7 @@ def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListRe title="List Workspaces", description="List available cloud workspaces (tenant_id, type, role, and name).", tags={"cloud", "projects"}, - annotations={"readOnlyHint": True, "openWorldHint": False}, + annotations={"title": "List Workspaces", "readOnlyHint": True, "openWorldHint": False}, ) async def list_workspaces( output_format: Literal["text", "json"] = "text", diff --git a/src/basic_memory/mcp/tools/write_note.py b/src/basic_memory/mcp/tools/write_note.py index c618ef859..ae3d27986 100644 --- a/src/basic_memory/mcp/tools/write_note.py +++ b/src/basic_memory/mcp/tools/write_note.py @@ -58,7 +58,13 @@ def _compose_workspace_project_route( 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}, + annotations={ + "title": "Write Note", + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": False, + "openWorldHint": False, + }, ) async def write_note( title: str, diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 753801961..c589706e9 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -120,6 +120,52 @@ } +# Anthropic directory review requirements (connectors/building/review-criteria): +# every tool must set annotations.title, read-only tools must set readOnlyHint=True, +# and write tools must set an explicit readOnlyHint=False plus a destructiveHint. +EXPECTED_TOOL_ANNOTATIONS: dict[str, dict[str, bool]] = { + "build_context": {"readOnlyHint": True}, + "cloud_info": {"readOnlyHint": True}, + "fetch": {"readOnlyHint": True}, + "list_directory": {"readOnlyHint": True}, + "list_memory_projects": {"readOnlyHint": True}, + "list_workspaces": {"readOnlyHint": True}, + "read_content": {"readOnlyHint": True}, + "read_note": {"readOnlyHint": True}, + "recent_activity": {"readOnlyHint": True}, + "release_notes": {"readOnlyHint": True}, + "schema_diff": {"readOnlyHint": True}, + "schema_infer": {"readOnlyHint": True}, + "schema_validate": {"readOnlyHint": True}, + "search": {"readOnlyHint": True}, + "search_notes": {"readOnlyHint": True}, + "view_note": {"readOnlyHint": True}, + # canvas falls back to PUT when the file already exists, replacing its content. + "canvas": {"readOnlyHint": False, "destructiveHint": True}, + # create_memory_project is purely additive: it creates a new project and errors + # if the target already exists. + "create_memory_project": {"readOnlyHint": False, "destructiveHint": False}, + "delete_note": {"readOnlyHint": False, "destructiveHint": True}, + "delete_project": {"readOnlyHint": False, "destructiveHint": True}, + # edit_note's find_replace/replace_section overwrite existing content, so it is + # destructive even though append/prepend are additive. + "edit_note": {"readOnlyHint": False, "destructiveHint": True}, + # move_note refuses to overwrite an existing destination and preserves all + # content — it relocates and propagates links, so no data can be lost. Keeping + # it non-destructive lets clients allowlist bulk lifecycle moves. + "move_note": {"readOnlyHint": False, "destructiveHint": False}, + "write_note": {"readOnlyHint": False, "destructiveHint": True}, +} + +# The MCP-UI tools are disabled in tools/__init__.py but register onto the shared +# server whenever tests import their module directly, so tolerate their presence +# without requiring it — keeps this contract independent of test execution order. +OPTIONAL_TOOL_ANNOTATIONS: dict[str, dict[str, bool]] = { + "read_note_ui": {"readOnlyHint": True}, + "search_notes_ui": {"readOnlyHint": True}, +} + + TOOL_FUNCTIONS: dict[str, object] = { "build_context": tools.build_context, "canvas": tools.canvas, @@ -163,6 +209,48 @@ def test_mcp_tool_signatures_are_stable(): assert _signature_params(tool_obj) == EXPECTED_TOOL_SIGNATURES[tool_name] +@pytest.mark.asyncio +async def test_mcp_tool_annotations_meet_directory_requirements(): + """Every tool's wire-level ToolAnnotations must satisfy Anthropic directory review. + + The directory validator reads ToolAnnotations (not FastMCP's top-level title), so + each tool needs annotations.title, an explicit readOnlyHint, and — for write + tools — a destructiveHint. openWorldHint is False across the board because every + tool operates on the user's own knowledge base. + """ + tool_list = await mcp.list_tools() + tools_by_name = {tool.name: tool for tool in tool_list} + + required = set(EXPECTED_TOOL_ANNOTATIONS) + optional = set(OPTIONAL_TOOL_ANNOTATIONS) + registered = set(tools_by_name) + missing = required - registered + unexpected = registered - required - optional + assert not missing, f"Tools missing from server: {sorted(missing)}" + assert not unexpected, ( + f"Tools without annotation expectations: {sorted(unexpected)} — " + "add them to EXPECTED_TOOL_ANNOTATIONS with directory-compliant annotations" + ) + + all_expected = {**EXPECTED_TOOL_ANNOTATIONS, **OPTIONAL_TOOL_ANNOTATIONS} + for tool_name, tool in tools_by_name.items(): + expected = all_expected[tool_name] + # Assert on the protocol-level payload the directory review actually sees. + annotations = tool.to_mcp_tool().annotations + assert annotations is not None, f"Tool '{tool_name}' has no annotations" + assert annotations.title, f"Tool '{tool_name}' is missing annotations.title" + assert annotations.readOnlyHint is expected["readOnlyHint"], ( + f"Tool '{tool_name}' readOnlyHint should be {expected['readOnlyHint']}" + ) + if expected["readOnlyHint"] is False: + assert annotations.destructiveHint is expected["destructiveHint"], ( + f"Tool '{tool_name}' destructiveHint should be {expected['destructiveHint']}" + ) + assert annotations.openWorldHint is False, ( + f"Tool '{tool_name}' openWorldHint should be False" + ) + + @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.