Skip to content

Fix stale request context in StatefulProxyClient handlers#3172

Merged
jlowin merged 3 commits intomainfrom
fix/stateful-proxy-elicitation
Feb 12, 2026
Merged

Fix stale request context in StatefulProxyClient handlers#3172
jlowin merged 3 commits intomainfrom
fix/stateful-proxy-elicitation

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 12, 2026

StatefulProxyClient reuses MCP sessions across requests so that stateful backends (like Playwright) maintain their state. But the receive-loop task that handles server-initiated messages (elicitation, sampling, roots, etc.) inherits a frozen request_ctx ContextVar snapshot from when the session was first created. On the second request, handlers like default_proxy_elicitation_handler call get_context() and resolve a stale request_id — the streamable HTTP transport routes the response to a closed stream, and the call hangs forever.

The fix wraps the default proxy handlers in StatefulProxyClient only (not ProxyClient, which creates fresh sessions per request) with a shim that restores the request_ctx ContextVar before forwarding. ProxyTool.run() stashes the current RequestContext before each backend call, and the shim detects staleness (same session, different request_id) to avoid corrupting concurrent sessions that share the ref via copy.copy.

stateful_client = StatefulProxyClient(backend)
proxy = FastMCPProxy(client_factory=stateful_client.new_stateful)

# Second call now works — elicitation routes correctly
async with Client(proxy_url, elicitation_handler=handler) as client:
    result1 = await client.call_tool("ask_name", {})  # fine
    result2 = await client.call_tool("ask_name", {})  # previously hung forever

Note: concurrent tool calls within the same stateful session can race on the shared context ref. This is a pre-existing limitation of the stateful session model (concurrent mutations to stateful backends like Playwright are already semantically broken), not something introduced by this fix.

Closes #3169

StatefulProxyClient reuses sessions across requests, so its receive-loop
task inherits a stale request_ctx ContextVar from the first request.
Server-initiated messages (elicitation, sampling, etc.) that depend on
related_request_id routing get sent to a closed stream and hang forever.

Closes #3169
@jlowin jlowin added the bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. label Feb 12, 2026
@marvin-context-protocol marvin-context-protocol Bot added server Related to FastMCP server implementation or server-side functionality. client Related to the FastMCP client SDK or client-side functionality. labels Feb 12, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

Walkthrough

The PR adds request context restoration mechanisms to the proxy module to fix context propagation issues in stateful proxy flows. It introduces internal utility functions _restore_request_context() and _make_restoring_handler() to manage RequestContext state across proxy boundaries. StatefulProxyClient now maintains _proxy_rc_ref to store request context before backend calls, and ProxyTool stores the current request context before forwarding to stateful clients. ProxyClient initialization conditionally wraps default handlers with restoration wrappers. Documentation is updated with explanatory notes on context restoration and session reuse behavior.

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive Description covers the problem, solution, and code example. However, the checklist items (especially testing and documentation updates) are not marked complete, leaving compliance unclear. Verify that all checklist items are checked off or explicitly confirm that testing and documentation updates have been completed before merge.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately summarizes the main change: fixing stale request context in StatefulProxyClient handlers, which is the core objective of the PR.
Linked Issues check ✅ Passed Code changes directly address issue #3169 by restoring stale request context in StatefulProxyClient handlers, allowing elicitation flows and ctx.set_state to work correctly when routed through a FastMCP proxy.
Out of Scope Changes check ✅ Passed All changes focus on fixing the stale request context issue in StatefulProxyClient. Documentation updates, internal utilities, and handler wrapping are all directly scoped to resolving the linked issue.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/stateful-proxy-elicitation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jlowin
Copy link
Copy Markdown
Member Author

jlowin commented Feb 12, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/fastmcp/server/providers/proxy.py (2)

814-826: Consider applying functools.wraps to preserve handler metadata.

The wrapper replaces the original handler's __name__, __qualname__, and __doc__. If any code path logs or introspects handler identity, this will show wrapper instead of the original name. functools.wraps is a low-cost improvement.

Suggested fix
+import functools
+
 def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable:
     ...
 
+    `@functools.wraps`(handler)
     async def wrapper(*args: Any, **kwargs: Any) -> Any:
         _restore_request_context(rc_ref)
         return await handler(*args, **kwargs)
 
     return wrapper

879-887: _proxy_rc_ref type annotation could be more descriptive.

list[Any] is used throughout for the ref container. A type alias or a narrower annotation like list[RequestContext[ServerSession, Any, Any] | None] would improve readability and catch misuse, though Any is pragmatic given the import landscape.

import mcp.types
from mcp import ServerSession
from mcp.client.session import ClientSession
from mcp.server.lowlevel.server import request_ctx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

mcp python SDK request_ctx public API documentation

💡 Result:

There doesn’t appear to be a documented public Python SDK API named request_ctx in the official MCP docs; the public, documented way to access per-request data is via Context (FastMCP) and its ctx.request_context property (and, for low-level usage, the server’s request context object). (github.com)

Where the official docs cover this:

  • MCP Python SDK docs home → links to the API Reference. (modelcontextprotocol.github.io)
  • API Reference (generated docs) for the SDK types and classes. (modelcontextprotocol.github.io)
  • “Request Context Properties” section (in the SDK README/docs content) describes what you can access via ctx.request_context:
    • ctx.request_context.lifespan_context
    • ctx.request_context.meta (including progressToken)
    • ctx.request_context.request
    • ctx.request_context.request_id (github.com)

If you meant “how do I get request context?”, the documented answer is: add a Context parameter to your tool/resource handler and then use ctx.request_context (FastMCP). (github.com)

Citations:


🌐 Web query:

mcp server framework request_ctx ContextVar anthropic

💡 Result:

In Anthropic’s Model Context Protocol (MCP) ecosystem, the server-side frameworks/SDKs typically use a contextvars.ContextVar (often named request_ctx) to hold “the current request” context for the lifetime of handling one MCP message. This makes the context available anywhere in the same async task without threading it through every function call. [1][2]

Where request_ctx shows up (Python MCP servers)

  • In MCP Python server implementations, you’ll commonly see something like:
    • request_ctx = contextvars.ContextVar("request_ctx")
    • a server property like request_context that does request_ctx.get() and raises LookupError if you call it outside an active request. [2]
  • The MCP Python SDK documents the public way you’re expected to use this: take a Context parameter in your tool/resource, then read request-scoped info via ctx.request_context (lifespan context, request metadata, request id, etc.). [3]

How to use it (recommended pattern)

  • Preferred (framework-supported): accept an injected Context and use ctx.request_context:
    • ctx.request_context.lifespan_context
    • ctx.request_context.meta
    • ctx.request_context.request
    • ctx.request_context.request_id [3]
  • Low-level/internal: reach for request_ctx.get() / server.request_context only when you’re building middleware-like plumbing or helpers that can’t easily receive ctx as a parameter—while handling the LookupError case (no active request). [2]

“Anthropic” connection

MCP is the protocol Anthropic documents and supports across Claude products (e.g., connecting to MCP servers and calling tools). [1]

Sources:
[1] Anthropic MCP docs (protocol overview) (docs.anthropic.com)
[2] Example MCP server code showing request_ctx: ContextVar("request_ctx") and .request_context using request_ctx.get() (codebase.md)
[3] MCP Python SDK docs/README describing ctx.request_context and its properties (github.com)

Citations:


🌐 Web query:

mcp.server.lowlevel.server module public API

💡 Result:

mcp.server.lowlevel.server public API (Python MCP SDK)

Re-exported “official” low-level API (what the package intends you to use)

From mcp.server.lowlevel.__init__, the low-level server package publicly exposes only: Server, NotificationOptions. [1]

Symbols defined by mcp.server.lowlevel.server

  • class NotificationOptions: controls whether the server advertises “listChanged” notifications for prompts/resources/tools. [2]

  • asynccontextmanager lifespan(server): default no-op lifespan context manager used for startup/shutdown resource management (can be overridden via Server(..., lifespan=...)). [2]

  • class Server[...] (core low-level server implementation). [2][3]

    • Constructor includes (at least) name, optional version, optional instructions, and optional lifespan context manager. [2][3]
    • create_initialization_options(...) -> InitializationOptions convenience builder. [2]
    • get_capabilities(notification_options, experimental_capabilities) -> types.ServerCapabilities capability derivation from registered handlers. [2]
    • request_context property to access the current RequestContext during a request. [2][3]
    • Decorator-based handler registration methods (register request handlers), including:
      • list_prompts(), get_prompt() [2]
      • list_resources(), list_resource_templates(), read_resource() [2]
      • list_tools(), call_tool() (with argument validation / tool cache behavior described in the SDK internals). [2][3]
    • Request/transport loop: the Server handles request dispatching and protocol compliance (implemented around an internal _handle_request pipeline). [3]
  • Likely-internal / not intended as stable public API

    • request_ctx (contextvars.ContextVar[...]) used to implement Server.request_context. [2]

request_ctx is an internal ContextVar; consider documenting the version dependency or using the public API.

Direct imports from mcp.server.lowlevel.server couple to implementation details not part of the mcp SDK's public API. The request_ctx ContextVar is undocumented and subject to change. If using this import is necessary, add a comment pinning it to a minimum mcp version (e.g., mcp>=1.24.0), or consider wrapping it defensively to handle potential future restructuring. Alternatively, use the public API pattern (ctx.request_context via the Context parameter) if the code structure allows.

Comment on lines +125 to +130
# StatefulProxyClient reuses sessions across requests, so
# its receive-loop task has stale ContextVars from the first
# request. Stash the current RequestContext in the shared
# ref so handlers can restore it before forwarding.
if isinstance(client, StatefulProxyClient):
cast(list[Any], client._proxy_rc_ref)[0] = ctx.request_context
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Concurrent tool calls on the same StatefulProxyClient will race on _proxy_rc_ref[0].

If two requests invoke ProxyTool.run concurrently against the same cached StatefulProxyClient (from new_stateful), both write to _proxy_rc_ref[0] before their respective call_tool_mcp. The second write overwrites the first, and a handler triggered by the first request's backend call could restore the wrong RequestContext.

This may be acceptable if stateful backends are assumed to serialize calls, but worth documenting explicitly or guarding with a per-call ref.

Also, the cast on line 130 can be avoided by narrowing:

Suggested simplification
                 if isinstance(client, StatefulProxyClient):
-                    cast(list[Any], client._proxy_rc_ref)[0] = ctx.request_context
+                    client._proxy_rc_ref[0] = ctx.request_context

@jlowin jlowin merged commit 5f941be into main Feb 12, 2026
14 checks passed
@jlowin jlowin deleted the fix/stateful-proxy-elicitation branch February 12, 2026 22:13
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. client Related to the FastMCP client SDK or client-side functionality. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ctx.elicit() not forwarded in FastMCPProxy + StatefulProxyClient, causing stateful tools (ctx.set_state) to fail behind gateway/proxy

1 participant