Skip to content

feat: upgrade fastmcp 2.12.3 to 3.0.1 with tool annotations#598

Merged
phernandez merged 3 commits intomainfrom
feat/upgrade-fastmcp-3.0
Feb 21, 2026
Merged

feat: upgrade fastmcp 2.12.3 to 3.0.1 with tool annotations#598
phernandez merged 3 commits intomainfrom
feat/upgrade-fastmcp-3.0

Conversation

@phernandez
Copy link
Member

Summary

  • Upgrades fastmcp from pinned 2.12.3 to >=3.0.1,<4, resolving the tool visibility regression (issue Pin FastMCP to 2.12.3 - Version 2.14.x breaks MCP tools visibility #463)
  • Removes .fn from ~540 tool callsites across 53 files (v3 returns the original function from @mcp.tool())
  • Makes context.get_state()/set_state() async with model_dump()/model_validate() for JSON-serializable state
  • Adds MCP tool annotations to all 26 tools (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) enabling smarter client UI decisions

Test plan

  • All 1946 unit tests pass (SQLite)
  • All 281 integration tests pass (SQLite)
  • CI passes (SQLite + Postgres)
  • Manual testing with Claude Desktop MCP integration
  • Verify tool visibility via MCP inspector (just run-inspector)
  • Verify tool annotations appear in tool listings

🤖 Generated with Claude Code

Upgrades from pinned fastmcp==2.12.3 (pinned due to tool visibility
regression in 2.14.x, issue #463) to fastmcp>=3.0.1,<4.

Breaking changes addressed:
- Remove `.fn` from all tool callsites (~540 occurrences across 53 files)
  In v3, @mcp.tool() returns the original function directly
- Make context.get_state()/set_state() async with await (7 callsites)
- Serialize Pydantic models via model_dump()/model_validate() for
  JSON-serializable context state persistence

New features adopted:
- Add MCP tool annotations to all 26 tools (readOnlyHint, destructiveHint,
  idempotentHint, openWorldHint) enabling smarter MCP client UI decisions
  like skipping confirmation prompts for read-only operations

🧪 All 1946 unit tests + 281 integration tests pass (SQLite)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 21, 2026

Claude finished @phernandez's task in 3m 3s —— View job


PR Review: feat/upgrade-fastmcp-3.0

Tasks

  • Gather context and understand PR scope
  • Review core changes (fastmcp upgrade, .fn removal, async state)
  • Review tool annotations
  • Review test changes
  • Verify checklist items
  • Post final review

Summary

This is a well-scoped, mechanical upgrade with one genuinely tricky behavioral change (async state serialization). The bulk of the diff (~540 .fn removals across 53 files) is straightforward but essential. No regressions are expected from the .fn removals. The async context state changes are the highest-risk area.


Code Quality & Standards

  • Follows Basic Memory coding conventions in CLAUDE.md
  • Python 3.12+ type annotations and async patterns
  • N/A — no SQLAlchemy changes
  • FastAPI/Typer conventions followed
  • 100-character line length maintained
  • No commented-out code blocks

Testing & Documentation

  • Unit tests updated for all callsite changes
  • Integration tests updated (1946 unit + 281 integration pass per PR)
  • Edge cases covered (contract test updated for v3 signature inspection)
  • [⚠️] No new tests for tool annotations themselves — see note below
  • No documentation changes needed for this mechanical upgrade
  • CLAUDE.md doesn't need updating

Basic Memory Architecture

  • MCP tools remain atomic and composable
  • No database/migration changes
  • Local-first architecture preserved
  • Knowledge graph operations unaffected
  • Markdown file handling unchanged
  • AI-human collaboration patterns followed

Security & Performance

  • No hardcoded secrets or credentials
  • Input validation for MCP tools unchanged
  • Error handling unaffected
  • No performance regressions expected
  • No sensitive data exposure

Compatibility

  • No file path comparisons changed
  • No emoji/unicode added to console or log output

Issues Found

1. Annotation Semantic Accuracy — edit_note idempotentHint

File: src/basic_memory/mcp/tools/edit_note.py:128

annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},

idempotentHint: True means "calling this tool twice with the same args produces the same result." This is accurate for replace_section and find_replace but not for append and prepend — calling append twice with the same content will duplicate the content. Per the MCP spec, idempotentHint should only be set when the tool is idempotent for all valid calls. Consider setting idempotentHint: False here.

Fix this →


2. Annotation Semantic Accuracy — move_note destructiveHint: False

File: src/basic_memory/mcp/tools/move_note.py:346

annotations={"destructiveHint": False, "openWorldHint": False},

move_note can overwrite an existing file at the destination path. In the typical sense of "could this destroy data the user didn't intend to destroy," destructiveHint: True is more accurate here. The MCP spec says destructiveHint indicates the tool "may perform destructive updates to external entities." Overwriting a file at the destination qualifies. Minor, but worth aligning with spec intent.


3. No Tests for Tool Annotations

There are no tests asserting that tool annotations are correctly set (e.g., that read_note has readOnlyHint: True, delete_note has destructiveHint: True). Given this is a new feature explicitly called out in the PR, a test in test_tool_contracts.py or a dedicated test_tool_annotations.py would guard against regressions and improve the coverage story.

Fix this →


4. Async State Deserialization — Silent Type Mismatch

File: src/basic_memory/mcp/project_context.py:101-103

cached_raw = await context.get_state("available_workspaces")
if isinstance(cached_raw, list):
    return [WorkspaceInfo.model_validate(item) for item in cached_raw]

If context.get_state returns a list containing unexpected types (e.g., after a state format change or partial write), model_validate will raise a ValidationError. This is appropriate fail-fast behavior per CLAUDE.md conventions — no issue here. But worth noting that there is no test for the case where the cached list contains malformed data.


5. cloud_info and release_notes Are Sync Functions

Files: src/basic_memory/mcp/tools/cloud_info.py:12, src/basic_memory/mcp/tools/release_notes.py:12

def cloud_info() -> str:  # sync, not async
def release_notes() -> str:  # sync, not async

These are sync def functions registered with @mcp.tool(). This was true before this PR too, so it's not a regression, but it's worth confirming fastmcp 3.x handles sync tool functions correctly (the MCP spec and fastmcp typically expect async def). If fastmcp 3.x wraps them, this is fine; if not, these could fail silently in some runtimes. The PR description doesn't mention verifying this.


Positive Highlights

  • The mechanical .fn removal is clean and complete — grep confirms zero remaining .fn() callsites in Python source.
  • model_dump() / model_validate() for JSON-serializable state is the correct pattern for fastmcp 3.x context persistence.
  • All context get_state/set_state calls are properly awaited — grep confirms no missed callsites.
  • The test_tool_contracts.py update correctly removes .fn from the signature inspection helper.
  • Version constraint >=3.0.1,<4 is appropriate to allow patch releases while blocking the next major.
  • uv.lock is committed, which is correct practice.

Recommendation

Approve with minor items to address:

  1. (Optional but recommended) Fix edit_note idempotentHint: TrueFalse to accurately reflect append/prepend behavior.
  2. (Optional) Reconsider move_note destructiveHint: False — moving can overwrite.
  3. (Nice to have) Add a contract test asserting annotation values per tool.
  4. (Verify) Confirm fastmcp 3.x handles sync def tool functions (cloud_info, release_notes) correctly before merging.

The core migration is mechanically sound, all tests pass, and the async state handling is correct.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 816aa54826

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


@mcp.tool(
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark edit_note as non-idempotent

edit_note supports append and prepend, which are not idempotent: repeating the same request adds duplicate content and changes the note again. Advertising idempotentHint: True can cause MCP clients that trust this hint to auto-retry the tool on transient failures, resulting in duplicated edits and user-visible data corruption in exactly those retry scenarios.

Useful? React with 👍 / 👎.

With .fn removed, pyright now sees the real return types (str | dict)
of tools that support output_format. Fix by:
- Adding str() narrowing where callers always use text mode
- Passing output_format="json" explicitly in CLI search (was implicit)
- Providing defaults for depth/timeframe instead of passing None

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 21, 2026

Claude finished @phernandez's task in 2m 15s —— View job


PR Review: feat/upgrade-fastmcp-3.0

Tasks

  • Gather context and understand PR scope
  • Review latest commit (31c145e - pyright fixes)
  • Check if previous review issues were addressed
  • Post updated review

Updated Review — Commit 31c145e

This commit resolves the pyright type errors that emerged after removing .fn (since fastmcp v3 now exposes the real return types of tools). The fixes are correct and well-reasoned.

Pyright Fix Analysis

tool.pystr() narrowing on read_note / edit_note calls

note = str(
    run_with_cleanup(
        mcp_read_note(identifier=identifier, ...)
    )
)

Correct. mcp_read_note returns str | dict depending on output_format. Since these CLI paths call without output_format (defaulting to "text"), the return is always str at runtime. The str() is purely for pyright's type narrowing benefit and does not change behavior.

tool.pysearch_notes CLI now passes output_format="json" explicitly

results = run_with_cleanup(
    mcp_search(..., output_format="json", ...)
)
# ...
print(json.dumps(results, indent=2, ensure_ascii=True, default=str))

Correct and an improvement. With output_format="json", the tool returns result.model_dump(mode="json", exclude_none=True) — a plain dict with JSON-native types — so json.dumps works cleanly. The old approach called .model_dump(exclude_none=True) (python mode, no mode="json") and relied on default=str for non-JSON types like datetime. The new path is more correct. The isinstance(results, str) error-path check still works because error responses are str.

tool.pyrecent_activity CLI defaults

depth=depth if depth is not None else 1,
timeframe=timeframe if timeframe is not None else "7d",

The tool signature already defines depth: int = 1 and timeframe: TimeFrame = "7d". Providing these explicitly in the CLI path is consistent with the tool's own defaults — no behavioral change.

chatgpt_tools.py and view_note.pystr() narrowing on read_note calls

Same pattern as above: read_note defaults to text mode and always returns str in these code paths. The str() wrap is correct pyright narrowing.

Pyright suppress comment style fix

Changed # pyright: ignore [reportArgumentType]# pyright: ignore[reportArgumentType] (no space before [). This is the correct pyright syntax per the spec.


Status of Previous Review Issues

Issue Status
edit_note idempotentHint: True is inaccurate for append/prepend Still open
move_note destructiveHint: False may be inaccurate (can overwrite) Still open (lower priority)
No tests asserting annotation values per tool Still open
Sync def functions cloud_info / release_notes with fastmcp 3.x Still open (verify only)

The highest-impact unresolved item remains edit_note idempotentHint: True, flagged independently by both this review and the Codex reviewer. MCP clients that trust idempotentHint: True may auto-retry append/prepend on transient failures, producing duplicate content.

Fix edit_note idempotentHint →


Checklist

Code Quality & Standards

  • Follows Basic Memory coding conventions
  • Python 3.12+ type annotations and async patterns
  • N/A — no SQLAlchemy changes
  • FastAPI/Typer conventions followed
  • 100-character line length maintained
  • No commented-out code blocks

Testing & Documentation

  • Pyright fixes are covered by existing tests (no new paths introduced)
  • CLI tests in test_cli_tool_json_output.py updated in prior commit
  • [⚠️] No annotation contract tests (open from first review)

Basic Memory Architecture

  • No architecture changes in this commit
  • MCP tool composability unaffected
  • Local-first principles preserved

Security & Performance

  • No new security concerns
  • json.dumps with explicit output_format="json" is more correct than prior default=str fallback

Compatibility

  • No file path comparison changes
  • No emoji/unicode added

Recommendation

The latest commit (31c145e) is clean and correct — approve as-is for the pyright fixes. The outstanding item worth addressing before merge is edit_note idempotentHint: TrueFalse.

edit_note supports append/prepend operations which are not idempotent —
repeating the same call adds duplicate content. Marking it idempotent
could cause MCP clients to auto-retry on transient failures, producing
duplicated edits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
@github-actions
Copy link
Contributor

github-actions bot commented Feb 21, 2026

Claude finished @phernandez's task in 1m 49s —— View job


PR Review: feat/upgrade-fastmcp-3.0

Tasks

  • Gather context and understand PR scope
  • Review latest commit (2a458c8 - remove idempotentHint from edit_note)
  • Check status of all previously flagged issues
  • Post updated review

Updated Review — Commit 2a458c8

This commit addresses the highest-priority issue flagged by both the previous Claude review and the Codex review: edit_note advertising idempotentHint: True while supporting non-idempotent append/prepend operations.

Change Analysis

edit_note.pyidempotentHint removed

-    annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
+    annotations={"destructiveHint": False, "openWorldHint": False},

Removing the hint entirely (rather than setting it to False) is the correct approach. Per the MCP spec, the absence of idempotentHint is equivalent to False (clients assume non-idempotent by default). This is cleaner than an explicit False and matches how the other write tools in the codebase handle it. The commit message accurately describes the risk that was being addressed.


Status of All Previously Flagged Issues

Issue Status
edit_note idempotentHint: True is inaccurate for append/prepend Fixed in 2a458c8
move_note destructiveHint: False may be inaccurate (can overwrite) ⚠️ Still open (lower priority)
No tests asserting annotation values per tool ⚠️ Still open (nice-to-have)
Sync def functions cloud_info / release_notes with fastmcp 3.x Non-issue — all 2227 tests pass, fastmcp 3.x handles sync functions correctly

Annotation Consistency Check (Full Review)

Reviewing all tool annotations in the codebase:

Tool Annotations Assessment
read_note, view_note, read_content, build_context, recent_activity, list_directory, search_notes, search, schema_*, list_memory_projects, list_workspaces, chatgpt fetch/search, cloud_info, release_notes readOnlyHint: True ✅ Correct
write_note destructiveHint: False, idempotentHint: True ✅ Correct — upsert semantics make it genuinely idempotent
canvas destructiveHint: False, idempotentHint: True ✅ Correct — same args always produce the same canvas file
edit_note destructiveHint: False ✅ Fixed — idempotentHint correctly absent
delete_note, delete_project destructiveHint: True ✅ Correct
create_memory_project destructiveHint: False ✅ Correct
move_note destructiveHint: False ⚠️ Still arguable — moving to a path that already exists can overwrite the destination

The one remaining annotation concern is move_note. If the destination path already exists, the move can silently overwrite the target file. Setting destructiveHint: True would be more conservative and accurate per spec intent ("may perform destructive updates"). That said, this is a low-priority judgment call — the tool's core purpose is a non-destructive restructuring operation.


Checklist

Code Quality & Standards

  • Follows Basic Memory coding conventions
  • Python 3.12+ type annotations and async patterns
  • N/A — no SQLAlchemy changes
  • FastAPI/Typer conventions followed
  • 100-character line length maintained
  • No commented-out code blocks

Testing & Documentation

  • No new code paths introduced — this is a one-line annotation fix
  • Existing tests unaffected
  • [⚠️] No annotation contract tests (open from prior reviews, nice-to-have)

Basic Memory Architecture

  • No architecture changes
  • MCP tool composability unaffected
  • Local-first principles preserved

Security & Performance

  • No new security concerns
  • No behavioral changes — annotation-only fix

Compatibility

  • No file path comparison changes
  • No emoji/unicode added

Recommendation

Approve. The primary blocker raised by both reviews — edit_note advertising itself as idempotent while supporting non-idempotent append/prepend — is now fixed. The remaining open items are low-priority (annotation contract tests) or a minor judgment call (move_note destructive hint). The core fastmcp 3.0 migration is mechanically sound, all tests pass, and the codebase is in a clean state.

If the team wants to address the remaining items before merge:

@phernandez phernandez merged commit 9515130 into main Feb 21, 2026
25 checks passed
@phernandez phernandez deleted the feat/upgrade-fastmcp-3.0 branch February 21, 2026 18:28
@phernandez phernandez added this to the v0.19.0 milestone Feb 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant