From 90c843eed6b391161450b550d837ac63fab37b9d Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 2 Jul 2026 12:43:53 -0500 Subject: [PATCH 1/2] fix(mcp): add directory-compliant tool annotations Anthropic directory review reads ToolAnnotations.title rather than FastMCP's top-level title kwarg, so every tool was flagged as missing a title annotation and write tools as missing readOnlyHint. - add "title" inside every tool's annotations dict - add explicit readOnlyHint: False to the seven write tools - flip edit_note to destructiveHint: True (find_replace and replace_section overwrite existing content, so the tool is not purely additive) - cite target API docs in build_context and read_content descriptions per the directory criteria for tools accepting freeform paths - add a table-driven contract test asserting the wire-level annotations for every registered tool, so new tools fail CI until they declare directory-compliant annotations Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- src/basic_memory/mcp/tools/build_context.py | 5 +- src/basic_memory/mcp/tools/canvas.py | 8 +- src/basic_memory/mcp/tools/chatgpt_tools.py | 4 +- src/basic_memory/mcp/tools/cloud_info.py | 2 +- src/basic_memory/mcp/tools/delete_note.py | 7 +- src/basic_memory/mcp/tools/edit_note.py | 9 +- src/basic_memory/mcp/tools/list_directory.py | 2 +- src/basic_memory/mcp/tools/move_note.py | 7 +- .../mcp/tools/project_management.py | 16 +++- src/basic_memory/mcp/tools/read_content.py | 7 +- src/basic_memory/mcp/tools/read_note.py | 2 +- src/basic_memory/mcp/tools/recent_activity.py | 2 +- src/basic_memory/mcp/tools/release_notes.py | 2 +- src/basic_memory/mcp/tools/schema.py | 6 +- src/basic_memory/mcp/tools/search.py | 2 +- src/basic_memory/mcp/tools/ui_sdk.py | 4 +- src/basic_memory/mcp/tools/view_note.py | 2 +- src/basic_memory/mcp/tools/workspaces.py | 2 +- src/basic_memory/mcp/tools/write_note.py | 8 +- tests/mcp/test_tool_contracts.py | 82 +++++++++++++++++++ 20 files changed, 153 insertions(+), 26 deletions(-) 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..6dbe93b89 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -20,7 +20,13 @@ 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, + "destructiveHint": False, + "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..5044adf19 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -120,6 +120,46 @@ } +# 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": {"readOnlyHint": False, "destructiveHint": False}, + "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": {"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 +203,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. From 8f658cc96304de979715f95ffe9687c82341ad52 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 2 Jul 2026 12:53:18 -0500 Subject: [PATCH 2/2] fix(mcp): mark canvas destructive, document non-destructive rationale Codex review follow-up: canvas falls back to PUT when the file already exists, replacing its content, so it warrants destructiveHint: True by the same standard as write_note and edit_note. move_note and create_memory_project stay non-destructive deliberately: move refuses to overwrite an existing destination and preserves all content, and project creation is purely additive and errors if the target exists. Rationale recorded in the contract test table. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- src/basic_memory/mcp/tools/canvas.py | 3 ++- tests/mcp/test_tool_contracts.py | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/basic_memory/mcp/tools/canvas.py b/src/basic_memory/mcp/tools/canvas.py index 6dbe93b89..acc7f186e 100644 --- a/src/basic_memory/mcp/tools/canvas.py +++ b/src/basic_memory/mcp/tools/canvas.py @@ -23,7 +23,8 @@ annotations={ "title": "Create Canvas", "readOnlyHint": False, - "destructiveHint": False, + # Falls back to PUT when the canvas already exists, replacing its content. + "destructiveHint": True, "idempotentHint": True, "openWorldHint": False, }, diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 5044adf19..c589706e9 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -140,13 +140,19 @@ "search": {"readOnlyHint": True}, "search_notes": {"readOnlyHint": True}, "view_note": {"readOnlyHint": True}, - "canvas": {"readOnlyHint": False, "destructiveHint": False}, + # 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}, }