Skip to content

Commit 9515130

Browse files
phernandezclaude
andauthored
feat: upgrade fastmcp 2.12.3 to 3.0.1 with tool annotations (#598)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b86dd6f commit 9515130

53 files changed

Lines changed: 917 additions & 777 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ dependencies = [
2929
"alembic>=1.14.1",
3030
"pillow>=11.1.0",
3131
"pybars3>=0.9.7",
32-
"fastmcp==2.12.3", # Pinned - 2.14.x breaks MCP tools visibility (issue #463)
32+
"fastmcp>=3.0.1,<4",
3333
"pyjwt>=2.10.1",
3434
"python-dotenv>=1.1.0",
3535
"pytest-aio>=1.9.0",

src/basic_memory/cli/commands/tool.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ async def _write_note_json(
104104
) -> dict:
105105
"""Write a note and return structured JSON metadata."""
106106
# Use the MCP tool to create/update the entity (handles create-or-update logic)
107-
await mcp_write_note.fn(
107+
await mcp_write_note(
108108
title=title,
109109
content=content,
110110
directory=folder,
@@ -155,7 +155,7 @@ async def _read_note_json(
155155
if entity_id is None:
156156
from basic_memory.mcp.tools.search import search_notes as mcp_search_tool
157157

158-
title_results = await mcp_search_tool.fn(
158+
title_results = await mcp_search_tool(
159159
query=identifier,
160160
search_type="title",
161161
project=project_name,
@@ -387,7 +387,7 @@ def write_note(
387387
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
388388
else:
389389
note = run_with_cleanup(
390-
mcp_write_note.fn(
390+
mcp_write_note(
391391
title=title,
392392
content=content,
393393
directory=folder,
@@ -468,13 +468,15 @@ def read_note(
468468
result["content"] = stripped_content
469469
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
470470
else:
471-
note = run_with_cleanup(
472-
mcp_read_note.fn(
473-
identifier=identifier,
474-
project=project_name,
475-
workspace=workspace,
476-
page=page,
477-
page_size=page_size,
471+
note = str(
472+
run_with_cleanup(
473+
mcp_read_note(
474+
identifier=identifier,
475+
project=project_name,
476+
workspace=workspace,
477+
page=page,
478+
page_size=page_size,
479+
)
478480
)
479481
)
480482
if strip_frontmatter:
@@ -560,16 +562,18 @@ def edit_note(
560562
)
561563
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
562564
else:
563-
result = run_with_cleanup(
564-
mcp_edit_note.fn(
565-
identifier=identifier,
566-
operation=operation,
567-
content=content,
568-
project=project_name,
569-
workspace=workspace,
570-
section=section,
571-
find_text=find_text,
572-
expected_replacements=expected_replacements,
565+
result = str(
566+
run_with_cleanup(
567+
mcp_edit_note(
568+
identifier=identifier,
569+
operation=operation,
570+
content=content,
571+
project=project_name,
572+
workspace=workspace,
573+
section=section,
574+
find_text=find_text,
575+
expected_replacements=expected_replacements,
576+
)
573577
)
574578
)
575579
rprint(result)
@@ -629,7 +633,7 @@ def build_context(
629633

630634
with force_routing(local=local, cloud=cloud):
631635
result = run_with_cleanup(
632-
mcp_build_context.fn(
636+
mcp_build_context(
633637
project=project_name,
634638
workspace=workspace,
635639
url=url,
@@ -712,10 +716,10 @@ def recent_activity(
712716
print(json.dumps(result, indent=2, ensure_ascii=True, default=str))
713717
else:
714718
result = run_with_cleanup(
715-
mcp_recent_activity.fn(
716-
type=type, # pyright: ignore [reportArgumentType]
717-
depth=depth,
718-
timeframe=timeframe,
719+
mcp_recent_activity(
720+
type=type, # pyright: ignore[reportArgumentType]
721+
depth=depth if depth is not None else 1,
722+
timeframe=timeframe if timeframe is not None else "7d",
719723
project=project_name,
720724
workspace=workspace,
721725
)
@@ -862,11 +866,12 @@ def search_notes(
862866

863867
with force_routing(local=local, cloud=cloud):
864868
results = run_with_cleanup(
865-
mcp_search.fn(
869+
mcp_search(
866870
query=query or "",
867871
project=project_name,
868872
workspace=workspace,
869873
search_type=search_type,
874+
output_format="json",
870875
page=page,
871876
after_date=after_date,
872877
page_size=page_size,
@@ -881,8 +886,7 @@ def search_notes(
881886
print(results)
882887
raise typer.Exit(1)
883888

884-
results_dict = results.model_dump(exclude_none=True)
885-
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
889+
print(json.dumps(results, indent=2, ensure_ascii=True, default=str))
886890
except ValueError as e:
887891
typer.echo(f"Error: {e}", err=True)
888892
raise typer.Exit(1)
@@ -916,7 +920,7 @@ def continue_conversation(
916920
with force_routing(local=local, cloud=cloud):
917921
# Prompt functions return formatted strings directly
918922
session = run_with_cleanup(
919-
mcp_continue_conversation.fn(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
923+
mcp_continue_conversation(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
920924
)
921925
rprint(session)
922926
except ValueError as e:

src/basic_memory/mcp/project_context.py

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,9 @@ def _workspace_choices(workspaces: list[WorkspaceInfo]) -> str:
9999
async def get_available_workspaces(context: Optional[Context] = None) -> list[WorkspaceInfo]:
100100
"""Load available cloud workspaces for the current authenticated user."""
101101
if context:
102-
cached_workspaces = context.get_state("available_workspaces")
103-
if isinstance(cached_workspaces, list) and all(
104-
isinstance(item, WorkspaceInfo) for item in cached_workspaces
105-
):
106-
return cached_workspaces
102+
cached_raw = await context.get_state("available_workspaces")
103+
if isinstance(cached_raw, list):
104+
return [WorkspaceInfo.model_validate(item) for item in cached_raw]
107105

108106
from basic_memory.mcp.async_client import get_cloud_control_plane_client
109107
from basic_memory.mcp.tools.utils import call_get
@@ -113,7 +111,10 @@ async def get_available_workspaces(context: Optional[Context] = None) -> list[Wo
113111
workspace_list = WorkspaceListResponse.model_validate(response.json())
114112

115113
if context:
116-
context.set_state("available_workspaces", workspace_list.workspaces)
114+
await context.set_state(
115+
"available_workspaces",
116+
[ws.model_dump() for ws in workspace_list.workspaces],
117+
)
117118

118119
return workspace_list.workspaces
119120

@@ -124,12 +125,12 @@ async def resolve_workspace_parameter(
124125
) -> WorkspaceInfo:
125126
"""Resolve workspace using explicit input, session cache, and cloud discovery."""
126127
if context:
127-
cached_workspace = context.get_state("active_workspace")
128-
if isinstance(cached_workspace, WorkspaceInfo) and (
129-
workspace is None or _workspace_matches_identifier(cached_workspace, workspace)
130-
):
131-
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
132-
return cached_workspace
128+
cached_raw = await context.get_state("active_workspace")
129+
if isinstance(cached_raw, dict):
130+
cached_workspace = WorkspaceInfo.model_validate(cached_raw)
131+
if workspace is None or _workspace_matches_identifier(cached_workspace, workspace):
132+
logger.debug(f"Using cached workspace from context: {cached_workspace.tenant_id}")
133+
return cached_workspace
133134

134135
workspaces = await get_available_workspaces(context=context)
135136
if not workspaces:
@@ -164,7 +165,7 @@ async def resolve_workspace_parameter(
164165
)
165166

166167
if context:
167-
context.set_state("active_workspace", selected_workspace)
168+
await context.set_state("active_workspace", selected_workspace.model_dump())
168169
logger.debug(f"Cached workspace in context: {selected_workspace.tenant_id}")
169170

170171
return selected_workspace
@@ -206,10 +207,12 @@ async def get_active_project(
206207

207208
# Check if already cached in context
208209
if context:
209-
cached_project = context.get_state("active_project")
210-
if cached_project and cached_project.name == project:
211-
logger.debug(f"Using cached project from context: {project}")
212-
return cached_project
210+
cached_raw = await context.get_state("active_project")
211+
if isinstance(cached_raw, dict):
212+
cached_project = ProjectItem.model_validate(cached_raw)
213+
if cached_project.name == project:
214+
logger.debug(f"Using cached project from context: {project}")
215+
return cached_project
213216

214217
# Validate project exists by calling API
215218
logger.debug(f"Validating project: {project}")
@@ -230,7 +233,7 @@ async def get_active_project(
230233

231234
# Cache in context if available
232235
if context:
233-
context.set_state("active_project", active_project)
236+
await context.set_state("active_project", active_project.model_dump())
234237
logger.debug(f"Cached project in context: {project}")
235238

236239
logger.debug(f"Validated project: {active_project.name}")
@@ -307,7 +310,7 @@ async def resolve_project_and_path(
307310
is_default=resolved.is_default,
308311
)
309312
if context:
310-
context.set_state("active_project", active_project)
313+
await context.set_state("active_project", active_project.model_dump())
311314

312315
resolved_path = f"{resolved.permalink}/{remainder}" if include_project else remainder
313316
return active_project, resolved_path, True

src/basic_memory/mcp/prompts/recent_activity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def recent_activity_prompt(
4646
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
4747

4848
# Call the tool function - it returns a well-formatted string
49-
activity_summary = await recent_activity.fn(project=project, timeframe=timeframe)
49+
activity_summary = await recent_activity(project=project, timeframe=timeframe)
5050

5151
# Build the prompt response
5252
# The tool already returns formatted markdown, so we use it directly

src/basic_memory/mcp/tools/build_context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def _format_context_markdown(graph: GraphContext, project: str) -> str:
192192
- "json" (default): Slimmed JSON with redundant fields removed
193193
- "text": Compact markdown text for LLM consumption
194194
""",
195+
annotations={"readOnlyHint": True, "openWorldHint": False},
195196
)
196197
async def build_context(
197198
url: MemoryUrl,

src/basic_memory/mcp/tools/canvas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
@mcp.tool(
1818
description="Create an Obsidian canvas file to visualize concepts and connections.",
19+
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
1920
)
2021
async def canvas(
2122
nodes: List[Dict[str, Any]],

src/basic_memory/mcp/tools/chatgpt_tools.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ def _format_document_for_chatgpt(
9292
}
9393

9494

95-
@mcp.tool(description="Search for content across the knowledge base")
95+
@mcp.tool(
96+
description="Search for content across the knowledge base",
97+
annotations={"readOnlyHint": True, "openWorldHint": False},
98+
)
9699
async def search(
97100
query: str,
98101
context: Context | None = None,
@@ -115,7 +118,7 @@ async def search(
115118
default_project = config.default_project
116119

117120
# Call underlying search_notes with sensible defaults for ChatGPT
118-
results = await search_notes.fn(
121+
results = await search_notes(
119122
query=query,
120123
project=default_project, # Use default project for ChatGPT
121124
page=1,
@@ -156,7 +159,10 @@ async def search(
156159
return [{"type": "text", "text": json.dumps(error_results, ensure_ascii=False)}]
157160

158161

159-
@mcp.tool(description="Fetch the full contents of a search result document")
162+
@mcp.tool(
163+
description="Fetch the full contents of a search result document",
164+
annotations={"readOnlyHint": True, "openWorldHint": False},
165+
)
160166
async def fetch(
161167
id: str,
162168
context: Context | None = None,
@@ -178,13 +184,15 @@ async def fetch(
178184
config = ConfigManager().config
179185
default_project = config.default_project
180186

181-
# Call underlying read_note function
182-
content = await read_note.fn(
183-
identifier=id,
184-
project=default_project, # Use default project for ChatGPT
185-
page=1,
186-
page_size=10, # Default pagination
187-
context=context,
187+
# Call underlying read_note function (default output_format="text" returns str)
188+
content = str(
189+
await read_note(
190+
identifier=id,
191+
project=default_project, # Use default project for ChatGPT
192+
page=1,
193+
page_size=10, # Default pagination
194+
context=context,
195+
)
188196
)
189197

190198
# Format the document for ChatGPT

src/basic_memory/mcp/tools/cloud_info.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from basic_memory.mcp.server import mcp
66

77

8-
@mcp.tool("cloud_info")
8+
@mcp.tool(
9+
"cloud_info",
10+
annotations={"readOnlyHint": True, "openWorldHint": False},
11+
)
912
def cloud_info() -> str:
1013
"""Return optional Basic Memory Cloud information and setup guidance."""
1114
content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md"

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ def _format_delete_error_response(project: str, error_message: str, identifier:
146146
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
147147

148148

149-
@mcp.tool(description="Delete a note or directory by title, permalink, or path")
149+
@mcp.tool(
150+
description="Delete a note or directory by title, permalink, or path",
151+
annotations={"destructiveHint": True, "openWorldHint": False},
152+
)
150153
async def delete_note(
151154
identifier: str,
152155
is_directory: bool = False,

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def _format_error_response(
125125

126126
@mcp.tool(
127127
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
128+
annotations={"destructiveHint": False, "openWorldHint": False},
128129
)
129130
async def edit_note(
130131
identifier: str,

0 commit comments

Comments
 (0)