Skip to content

fix: FastAPI TestClient compatibility and lifespan re-initialization#3736

Open
kvdhanush06 wants to merge 1 commit intoPrefectHQ:mainfrom
kvdhanush06:fix/fastapi-testclient-compatibility
Open

fix: FastAPI TestClient compatibility and lifespan re-initialization#3736
kvdhanush06 wants to merge 1 commit intoPrefectHQ:mainfrom
kvdhanush06:fix/fastapi-testclient-compatibility

Conversation

@kvdhanush06
Copy link
Copy Markdown

Description

Closes #2375

FastMCP raised a RuntimeError when using FastAPI's TestClient across multiple test runs. The root cause was StreamableHTTPSessionManager enforcing a strict single-execution constraint on its lifespan. Since TestClient triggers the full application lifespan on every context entry, each subsequent with TestClient(app) block attempted to re-enter an already-consumed manager, causing the failure.

Root Cause

StreamableHTTPSessionManager is designed to be run once. Re-entering its lifespan raises a RuntimeError. Because TestClient spins up and tears down the ASGI lifespan on every with block, any test suite with more than one TestClient context would fail on the second invocation.

Fix

Introduced ReusableSessionManagerWrapper in src/fastmcp/server/http.py. The wrapper accepts a factory callable instead of a manager instance directly. Each time the lifespan is entered, it lazily constructs a fresh StreamableHTTPSessionManager via the factory, ensuring every test run — and every lifespan cycle in general — operates on a correctly initialized instance.

No changes to the public API or existing behavior for single-run scenarios.

Verification

Added a regression suite in tests/server/test_fastapi_testclient_compat.py covering:

  • Multiple sequential TestClient context entries
  • Correct teardown and re-initialization between runs
# Previously raised RuntimeError on the second block — now works correctly
with TestClient(app) as client:
    client.get("/health")

with TestClient(app) as client:
    client.get("/health")

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)
  • Documentation improvement
  • Enhancement (maintainers typically implement enhancements — see CONTRIBUTING.md)

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

Copilot AI review requested due to automatic review settings April 1, 2026 18:00
@marvin-context-protocol marvin-context-protocol bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. http Related to HTTP transport, networking, or web server functionality. labels Apr 1, 2026
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The static analysis workflow failed because ruff format found formatting issues in the new test file — 1 file was reformatted.

Root Cause: tests/server/test_fastapi_testclient_compat.py has code style issues that don't match ruff format output. Inspecting the diff, the likely culprits are:

  • Trailing whitespace on comments/lines (e.g. # 406 is expected from SSE if Accept header is missing, and assert response.status_code in [200, 405, 404, 406] )
  • Missing blank line between the two top-level test functions (test_fastapi_testclient_multiple_runs and test_fastapi_testclient_nested_lifespan)

Suggested Solution: Run ruff format locally before pushing, then commit the result:

uv run ruff format tests/server/test_fastapi_testclient_compat.py
# then commit the changes

Or run the full prek suite (as required by CLAUDE.md):

uv run prek run --all-files
Detailed Analysis

The workflow run 23863170677 ran the static_analysis job, which executes prek run --all-files. The relevant log excerpt:

prettier.................................................................[Passed]
ruff check...............................................................[Passed]
ruff format..............................................................[Failed]
- hook id: ruff-format
- files were modified by this hook

  1 file reformatted, 718 files left unchanged

ty check.................................................................[Passed]
loq (file size limits)...................................................[Passed]
codespell................................................................[Passed]

Only two files were changed in this PR:

  • src/fastmcp/server/http.py — passes formatting check
  • tests/server/test_fastapi_testclient_compat.pyneeds formatting fixes

In the test file diff, there's trailing whitespace visible on:

  • Line: # 406 is expected from SSE if Accept header is missing, (trailing space)
  • Line: assert response.status_code in [200, 405, 404, 406] (trailing space)

And there is no blank line between the two top-level test functions, which ruff format requires.

Related Files
  • tests/server/test_fastapi_testclient_compat.py — new test file with formatting issues (needs ruff format applied)
  • src/fastmcp/server/http.py — main code change (passes all checks)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a RuntimeError when using FastAPI’s TestClient multiple times against a mounted FastMCP HTTP app by making the underlying StreamableHTTPSessionManager effectively “lifespan-reusable” via a wrapper that recreates the manager per lifespan entry.

Changes:

  • Added ReusableSessionManagerWrapper to lazily create a fresh StreamableHTTPSessionManager for each ASGI lifespan cycle.
  • Updated create_streamable_http_app() to use a session-manager factory + wrapper rather than a single long-lived manager instance.
  • Added regression tests covering multiple sequential TestClient lifespan runs for a mounted FastMCP app.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/fastmcp/server/http.py Wraps the streamable HTTP session manager so the app’s lifespan can be entered multiple times (e.g., repeated TestClient usage).
tests/server/test_fastapi_testclient_compat.py Adds regression tests intended to validate repeated FastAPI TestClient lifespan cycles don’t raise the prior RuntimeError.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

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

Thanks for tackling this — the bug is real and the diagnosis is correct. StreamableHTTPSessionManager.run() can only be called once, so re-entering the lifespan (as TestClient does) fails on the second cycle.

The fix can be much simpler though. The root cause is that the session manager is created outside the lifespan but run inside it. Since the lifespan is already a closure inside create_streamable_http_app, it has access to all the construction parameters. Just move the creation into the lifespan:

# Create the ASGI app wrapper (session manager is set each lifespan cycle)
streamable_http_app = StreamableHTTPASGIApp(None)

@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
    streamable_http_app.session_manager = StreamableHTTPSessionManager(
        app=server._mcp_server,
        event_store=event_store,
        retry_interval=retry_interval,
        json_response=json_response,
        stateless=stateless_http,
    )
    async with server._lifespan_manager(), streamable_http_app.session_manager.run():
        yield

No wrapper class, no factory — just create a fresh manager at the start of each lifespan cycle and assign it to the ASGI app. ASGI guarantees the lifespan runs before any requests, so the initial None is never hit by a request handler (and StreamableHTTPASGIApp.__call__ already handles that error case with a helpful message).

The ReusableSessionManagerWrapper can be removed entirely.

The tests are good as a regression suite, but the status code assertion in [200, 405, 404, 406] doesn't verify anything meaningful — the test's real assertion is just "no RuntimeError." Simplify to:

with TestClient(app) as client:
    client.get("/analytics/mcp")  # Would raise RuntimeError before fix

@kvdhanush06 kvdhanush06 force-pushed the fix/fastapi-testclient-compatibility branch from 830f9e4 to fe0da26 Compare April 4, 2026 17:55
@kvdhanush06 kvdhanush06 force-pushed the fix/fastapi-testclient-compatibility branch from fe0da26 to 8ce795d Compare April 4, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. http Related to HTTP transport, networking, or web server functionality. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FastMCP breaks FastAPI Testclient

3 participants