mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): add title and tags annotations to all MCP tool decorators (issue #826 phase 1)
Adds human-readable `title` and categorization `tags` to all ~24 @mcp.tool decorators across the MCP tools package. FastMCP 3.3.1 (pinned in pyproject.toml) supports both fields natively. Tags used: notes, search, projects, cloud, schema, navigation, canvas, ui. Extends test_tool_contracts.py with an async test that asserts every registered tool has a non-empty title and at least one tag to prevent future regressions. output_schema is explicitly deferred as a follow-up (phase 2): it requires per-tool design decisions about which tools reliably return structured JSON and how to handle tools that return str|dict depending on output_format. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -17,7 +17,9 @@ from basic_memory.mcp.tools.utils import call_put, call_post, resolve_entity_id
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,8 @@ from basic_memory.mcp.server import mcp
|
||||
|
||||
@mcp.tool(
|
||||
"cloud_info",
|
||||
title="Cloud Info",
|
||||
tags={"cloud"},
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
def cloud_info() -> str:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -304,7 +304,9 @@ Error editing note '{identifier}': {error_message}
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -11,7 +11,9 @@ from basic_memory.mcp.server import mcp
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -357,7 +357,9 @@ delete_note("{identifier}")
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -26,6 +26,7 @@ from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@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 @@ from basic_memory.schemas.search import SearchItemType
|
||||
- "3 weeks ago"
|
||||
Or standard formats like "7d"
|
||||
""",
|
||||
tags={"navigation", "notes"},
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
async def recent_activity(
|
||||
|
||||
@@ -7,6 +7,8 @@ from basic_memory.mcp.server import mcp
|
||||
|
||||
@mcp.tool(
|
||||
"release_notes",
|
||||
title="Release Notes",
|
||||
tags={"cloud"},
|
||||
annotations={"readOnlyHint": True, "openWorldHint": False},
|
||||
)
|
||||
def release_notes() -> str:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ from basic_memory.mcp.tools.read_note import read_note
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -26,7 +26,9 @@ TagType = Union[List[str], str, None]
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -6,7 +6,10 @@ import inspect
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user