Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/basic_memory/mcp/tools/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
9 changes: 8 additions & 1 deletion src/basic_memory/mcp/tools/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/mcp/tools/chatgpt_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/cloud_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
7 changes: 6 additions & 1 deletion src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/list_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion src/basic_memory/mcp/tools/move_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/basic_memory/mcp/tools/project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/basic_memory/mcp/tools/read_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/read_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/recent_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
6 changes: 3 additions & 3 deletions src/basic_memory/mcp/tools/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/basic_memory/mcp/tools/ui_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/view_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/basic_memory/mcp/tools/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/basic_memory/mcp/tools/write_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions tests/mcp/test_tool_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading