fix(client): forward __aenter__ to wrapped client in StatefulProxyClient (refs #4054)#4078
fix(client): forward __aenter__ to wrapped client in StatefulProxyClient (refs #4054)#4078MukundaKatta wants to merge 1 commit into
Conversation
…ent (refs PrefectHQ#4054) The restored Context inside `_restore_request_context` was created via `_current_context.set(Context(fastmcp))` which bypassed `Context.__aenter__`, leaving `_tokens` empty, `_current_server` unset, and the matching `__aexit__` a no-op so tokens leaked across the receive loop. Make `_restore_request_context` async, await `Context.__aenter__()` so the ContextVars are populated correctly, and have `_make_restoring_handler` call `__aexit__` on the entered Context after the handler returns via `try/finally` so the tokens are released cleanly. Issue PrefectHQ#4054 catalogued four bugs; the others are addressed in PrefectHQ#4060, PrefectHQ#4076 and PrefectHQ#4077, leaving this one outstanding.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ddb8c74f3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if ctx is not None: | ||
| await ctx.__aexit__(None, None, None) |
There was a problem hiding this comment.
Keep restored context alive for same-request callbacks
_make_restoring_handler now always calls ctx.__aexit__() after each handler invocation, but _restore_request_context only re-enters a Context when request_id changes (or request_ctx is missing). If an upstream server emits multiple callbacks for the same request (for example, several progress or log notifications), the second callback runs without a freshly entered context, so get_context() in the default handlers can resolve a stale context or raise RuntimeError("No active context found."). This regresses multi-notification flows within a single request.
Useful? React with 👍 / 👎.
|
tl;dr: Root Cause: The new test file has an unused import ( Fix: Run the following locally and push the result: Log excerptsDiff applied by the hooks to @@ -240,9 +240,7 @@ class TestRestoreRequestContext:
- assert ctx._tokens, (
- "Context.__aenter__ was not called: _tokens is empty"
- )
+ assert ctx._tokens, "Context.__aenter__ was not called: _tokens is empty"
# _current_context must point at the freshly entered Context
@@ -264,8 +262,6 @@ class TestRestoreRequestContext:
import weakref
from unittest.mock import MagicMock
- from mcp.server.lowlevel.server import request_ctx
-
from fastmcp import FastMCP |
jlowin
left a comment
There was a problem hiding this comment.
Three things before merge:
-
Use a context manager for Context lifecycle. The current shape (
ctx = await _restore_request_context(...)+try/finally: await ctx.__aexit__(None, None, None)) is hand-rolling lifecycle management that the language already has syntax for. It also loses exception info: when the handler raises,__aexit__gets(None, None, None)instead of the actual exception tuple. Two ways to fix. Make_restore_request_contextan@asynccontextmanagerand have the wrapper doasync with _restore_request_context(rc_ref): return await handler(...), or take anAsyncExitStackas a parameter and push the Context onto it (matches how_lifespan_managerhandles conditional entries). Either is fine. -
Retitle. The title says "forward
__aenter__to wrapped client inStatefulProxyClient" but the change is in_restore_request_context/_make_restoring_handler. Nothing to do withStatefulProxyClient.__aenter__. Something likefix(proxy): enter Context properly in _restore_request_context. -
Hoist test imports to module level. Every method in
TestRestoreRequestContexthas its imports inside the function body. Move them to the top oftests/server/providers/proxy/test_stateful_proxy_client.py.
|
This PR targets Bug 4 from #4054, and that gap is real: The reason we're closing this rather than iterating on it: the bug is that the path doesn't call The
All three exist because a scope was opened that then has to be unwound — they aren't independent bugs to patch. Decomposing what So the minimal, principled fix keeps the original set-only model and adds the one missing line: request_ctx.set(rc)
fastmcp = fastmcp_ref()
if fastmcp is not None:
_current_context.set(Context(fastmcp))
_current_server.set(weakref.ref(fastmcp)) # the actual Bug 4 gapNo This is also a good illustration of why CONTRIBUTING.md asks for the planned approach to be discussed on the issue before a PR — the lifecycle direction here would have been worth aligning on up front, before the implementation and review cycles. Closing this in favor of the set-only fix tracked on #4054. |
Why
Issue #4054 catalogues four unrelated auth-handling bugs. The
StatefulProxyClientContextlifecycle bug (Bug 4) is the last one outstanding:refresh_expires_in=0handling): tracked in fix: treat Keycloak refresh_expires_in=0 as never-expires sentinel #4060._restore_request_contextskippedContext.__aenter__.This PR closes out Bug 4 only.
For visibility, two adjacent issues from the same audit batch are being addressed in #4076 (parse_qs blank values, refs #4056) and #4077 (
ToolResult.isError+ dedupe meta deep-copy, refs #4055).What
src/fastmcp/server/providers/proxy.py—_restore_request_contextpreviously did:That bypasses
Context.__aenter__, so:Context._tokensstays empty, which means the matchingContext.__aexit__is a no-op._current_serveris never set, so dependency-injection helpers that rely on it see stale values.SharedContextis never created when running without a Docket lifespan, soShared()dependencies inside the handler get a stale shared store.The fresh
Contexttherefore looks half-initialised inside the proxy handlers triggered fromStatefulProxyClient's receive loop, and its tokens leak as the wrapper returns.The fix:
_restore_request_contextasyncandawait Context(fastmcp).__aenter__()instead of touching_current_contextdirectly. The function returns the enteredContext | Noneso the caller can pair the enter with an exit._make_restoring_handlerto wrap the handler intry/finallyand callawait ctx.__aexit__(None, None, None)after the handler runs, so the_current_contexttoken (and any_current_server/_shared_context/ docket tokens) are released cleanly.The
request_ctxrestoration logic and the staleness-detection branch (only override when same session, different request_id) are unchanged — only the_current_contextside now goes through the proper enter/exit.Tests
tests/server/providers/proxy/test_stateful_proxy_client.py— newTestRestoreRequestContextclass covering:test_restore_enters_context_and_sets_current_server— the returnedContexthas populated_tokens,_current_context.get()is the freshContext,_current_serverresolves back to the sameFastMCP, andrequest_ctxwas restored to the stashed value. After__aexit__,_current_contextno longer points at the enteredContext.test_make_restoring_handler_awaits_aenter_and_aexit— the handler invoked through the wrapper sees an activeContextwith non-empty_tokens, and after the wrapper returns_current_contextis back toNone(no token leak).test_restore_returns_none_when_rc_ref_empty— pins down therc_ref[0] is Noneshortcut.The existing
TestStatefulProxyClientsuite (concurrent log routing, stateful state, multi-proxy isolation, elicitation-over-HTTP) should continue to pass since the request_ctx and routing behaviour is unchanged.Notes
proxy.py, one new test class._restore_request_contextbecomingasyncis internal — it's only called from_make_restoring_handler(also internal).