Skip to content

fix(mcp): defer schema strip to lifespan (asyncio.run regression)#20

Merged
caballeto merged 2 commits into
mainfrom
fix/asyncio-on-http-startup
May 6, 2026
Merged

fix(mcp): defer schema strip to lifespan (asyncio.run regression)#20
caballeto merged 2 commits into
mainfrom
fix/asyncio-on-http-startup

Conversation

@caballeto
Copy link
Copy Markdown
Member

Summary

v0.7.1 broke the cluster (mono v0.13.1 MCP Server deploy). Root cause: _strip_internal_schema_fields() called asyncio.run() at module import time. Uvicorn imports the user app from inside its own running loop, so every pod crashed with RuntimeError: asyncio.run() cannot be called from a running event loop.

This hotfix moves the strip into the right place per transport:

  • HTTP — wrap FastMCP's lifespan in a Starlette async lifespan that awaits the strip before serving.
  • stdio — call asyncio.run(_strip_internal_schema_fields()) inside _run_stdio() before mcp.run() boots its own loop.

The strip itself is unchanged behaviorally — api_token and managedBy are still hidden from the schema, just at the right time now.

Test plan

  • All existing tests pass (uv run pytest)
  • New regression test TestImportFromRunningLoopDoesNotCrash reloads the module from inside an asyncio.run and asserts api_token is gone after lifespan entry — this would have failed before the fix
  • Lint + mypy clean
  • Manual verification: python -c "asyncio.run(import devhelm_mcp.server; lifespan...)" succeeds

Cuts to v0.7.2 to supersede the broken v0.7.1.

Made with Cursor

caballeto and others added 2 commits May 6, 2026 10:49
v0.7.1 regressed by calling `_strip_internal_schema_fields()` at module
import time via `asyncio.run()`. Stdio mode worked because the entry
point hadn't started a loop yet, but Uvicorn imports the user app from
inside `asyncio.run(self.serve(...))` so the same code path crashed
with "asyncio.run() cannot be called from a running event loop" on
every cluster pod. The mcp-server deployment in v0.13.1 of the
monorepo restart-looped until rolled back.

Fix: split `_strip_internal_schema_fields` into an async coroutine and
call it from the right place per transport:
- HTTP: wrap `mcp_app.lifespan` in a Starlette lifespan that awaits
  the strip before delegating to FastMCP's own lifespan.
- Stdio: call `asyncio.run(_strip_internal_schema_fields())` inside
  `_run_stdio()` before `mcp.run()` boots its own loop.

Pinned by `TestImportFromRunningLoopDoesNotCrash` which reloads the
module from inside an async test and asserts `api_token` is gone
after lifespan entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@caballeto caballeto merged commit 2751393 into main May 6, 2026
4 checks passed
@caballeto caballeto deleted the fix/asyncio-on-http-startup branch May 6, 2026 09:42
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