Skip to content

fix(mcp): await fastmcp 3.x async get_state/set_state (closes #85)#86

Merged
zzstoatzz merged 1 commit into
mainfrom
fix/fastmcp-3-async-state
May 29, 2026
Merged

fix(mcp): await fastmcp 3.x async get_state/set_state (closes #85)#86
zzstoatzz merged 1 commit into
mainfrom
fix/fastmcp-3-async-state

Conversation

@zzstoatzz

@zzstoatzz zzstoatzz commented May 29, 2026

Copy link
Copy Markdown
Owner

Fixes #85.

fastmcp 3.x made Context.get_state / Context.set_state coroutines, but pdsx called them synchronously — so un-awaited coroutines were stored as credentials and every MCP tool failed at runtime:

'coroutine' object has no attribute 'startswith'   # reads → repo.startswith("did:")
'coroutine' object has no attribute 'endswith'     # auth  → client.login(handle, …)

changes

  • bump fastmcp>=3.3 (mcp extra + dev group) → resolves 3.3.1
  • client.py: make _get_credentials_from_context and get_repo_from_context async, await each ctx.get_state(...)
  • middleware.py: make _extract_credentials async, await each ctx.set_state(...), and await it from on_call_tool / on_read_resource
  • server.py: await get_repo_from_context() at both call sites
tests
  • new TestAsyncStateRegression: drives the credential helpers + middleware through a fastmcp-3.x-style context whose get_state/set_state are coroutines, asserting credentials resolve to plain strings. Verified these fail on the pre-fix sync code (reverting the awaits → 3 failures).
  • test_query.py: in fastmcp 3.3.1, @mcp.tool registers the tool and returns the original function (no wrapper with a .fn attribute). Registration confirmed via mcp.list_tools() (all 8 tools present); updated the query tests to call server.query(...) directly.
  • full suite: 161 passed; ruff format / ruff check clean. (ty reports one pre-existing possibly-missing-attribute in cli.py, unrelated to this change.)

🤖 Generated with Claude Code

@zzstoatzz
zzstoatzz force-pushed the fix/fastmcp-3-async-state branch 3 times, most recently from f35aa29 to 54c0d8f Compare May 29, 2026 05:36
fastmcp 3.x made `Context.get_state` and `Context.set_state` coroutines.
pdsx called them synchronously, so un-awaited coroutines were stored as
credentials and every MCP tool failed at runtime with
`'coroutine' object has no attribute 'startswith'/'endswith'`
(reads hit `repo.startswith("did:")`, auth hit `client.login`).

- bump `fastmcp>=3.3` (mcp extra + dev group); resolves 3.3.1
- await the async context-state API everywhere it's used:
  - `_get_credentials_from_context`, `get_repo_from_context`, and
    `resolve_pds_url` become async and await `ctx.get_state(...)` (client.py)
  - middleware `_extract_credentials` becomes async and awaits each
    `ctx.set_state(...)`; awaited from on_call_tool/on_read_resource
  - await `get_repo_from_context()` / `resolve_pds_url()` at the server.py
    call sites
- regression tests drive each helper + middleware through an async-state
  context, asserting creds resolve to strings (fail on the old sync code)

also, to get CI green on this branch:
- test_query: in fastmcp 3.3.1 `@mcp.tool` registers the tool and returns
  the original function (no `.fn` wrapper) — confirmed via `mcp.list_tools()`;
  call `server.query(...)` directly at all 7 sites
- cli.py: narrow blob ref union (`str | bytes | IpldLink`) with an isinstance
  guard so `.link` resolves cleanly under `ty`
- regenerate `docs/api-reference/*` so the mdxify pre-commit hook is a no-op

rebased on main (incl. the authenticated-query allowlist work).
full suite: 162 passed; prek (ruff/ty/mdxify) clean and idempotent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zzstoatzz
zzstoatzz force-pushed the fix/fastmcp-3-async-state branch from 54c0d8f to 90d008b Compare May 29, 2026 05:42
@zzstoatzz
zzstoatzz merged commit a0b38bb into main May 29, 2026
3 checks passed
@zzstoatzz
zzstoatzz deleted the fix/fastmcp-3-async-state branch May 29, 2026 05:44
zzstoatzz added a commit that referenced this pull request May 29, 2026
#86 awaited get_state/set_state unconditionally, which fixed local fastmcp
3.x but broke on FastMCP Cloud: the hosted runtime pins a fastmcp where
those are still sync, so `await ctx.get_state(...)` raised
`object NoneType can't be used in 'await' expression` on every tool call.

the installed fastmcp version doesn't govern the hosted runtime, so detect
the calling convention instead of assuming it:

- add `_maybe_await()` — awaits the return value only if it's awaitable
- route every `ctx.get_state(...)` (client.py) and `ctx.set_state(...)`
  (middleware.py) through it
- regression tests now parametrize over `_FakeAsyncContext` (3.x) AND
  `_FakeSyncContext` (2.x / hosted); the sync variants fail on the bare-await
  code and pass with `_maybe_await` (verified by reverting)

full suite: 166 passed; prek (ruff/ty/mdxify) clean and idempotent.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

MCP tools fail: fastmcp 3.x made get_state/set_state async, but pdsx calls them synchronously

1 participant