Summary
get_access_token() returns the bearer token from the session-creating request for the entire lifetime of a stateful streamable-HTTP session, regardless of what Authorization header later requests send.
Repro
Open for code
importmultiprocessingimportsocketimporttimeimporthttpximportpytestimportuvicornfromstarlette.applicationsimportStarlettefromstarlette.middlewareimportMiddlewarefromstarlette.middleware.authenticationimportAuthenticationMiddlewarefromstarlette.routingimportMountfrommcp.client.sessionimportClientSessionfrommcp.client.streamable_httpimportstreamable_http_clientfrommcp.serverimportServer, ServerRequestContextfrommcp.server.auth.middleware.auth_contextimportAuthContextMiddleware, get_access_tokenfrommcp.server.auth.middleware.bearer_authimportBearerAuthBackendfrommcp.server.auth.providerimportAccessTokenfrommcp.server.streamable_http_managerimportStreamableHTTPSessionManagerfrommcp.server.transport_securityimportTransportSecuritySettingsfrommcp.typesimport (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
class_EchoTokenVerifier:
"""Accepts any bearer and echoes it back so we can tell tokens apart."""asyncdefverify_token(self, token: str) ->AccessToken|None:
returnAccessToken(token=token, client_id=token, scopes=[], expires_at=int(time.time()) +3600)
asyncdef_handle_whoami(ctx: ServerRequestContext, params: CallToolRequestParams) ->CallToolResult:
# The user-facing contract of get_access_token(): call it from a handler,# get the token for the current request.access=get_access_token()
text=access.tokenifaccesselse"<none>"returnCallToolResult(content=[TextContent(type="text", text=text)])
asyncdef_handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams|None) ->ListToolsResult:
returnListToolsResult(tools=[Tool(name="whoami", input_schema={"type": "object", "properties": {}})])
def_run_auth_server(port: int) ->None:
server=Server(name="auth_test_server", on_call_tool=_handle_whoami, on_list_tools=_handle_list_tools)
security=TransportSecuritySettings(allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"])
session_manager=StreamableHTTPSessionManager(app=server, security_settings=security, stateless=False)
# Same middleware chain lowlevel Server.streamable_http_app builds when auth is onasgi_app=Starlette(
routes=[Mount("/mcp", app=session_manager.handle_request)],
middleware=[
Middleware(AuthenticationMiddleware, backend=BearerAuthBackend(_EchoTokenVerifier())),
Middleware(AuthContextMiddleware),
],
lifespan=lambdaapp: session_manager.run(),
)
uvicorn.run(asgi_app, host="127.0.0.1", port=port, log_level="error")
class_MutableBearerAuth(httpx.Auth):
"""Reads the bearer from a mutable attribute at send-time so we can swap mid-session."""def__init__(self, token: str) ->None:
self.token=tokendefauth_flow(self, request: httpx.Request):
request.headers["Authorization"] =f"Bearer {self.token}"yieldrequest@pytest.mark.anyioasyncdeftest_get_access_token_reflects_current_request_in_stateful_session() ->None:
withsocket.socket() ass:
s.bind(("127.0.0.1", 0))
port=s.getsockname()[1]
proc=multiprocessing.Process(target=_run_auth_server, args=(port,), daemon=True)
proc.start()
try:
# wait for serverfor_inrange(200):
try:
withsocket.socket() ass:
s.connect(("127.0.0.1", port))
breakexceptOSError:
time.sleep(0.01)
url=f"http://127.0.0.1:{port}/mcp"auth=_MutableBearerAuth("token-A")
asyncwithhttpx.AsyncClient(auth=auth, timeout=httpx.Timeout(30, read=30), follow_redirects=True) ashttp_client:
asyncwithstreamable_http_client(url, http_client=http_client) as (read_stream, write_stream):
asyncwithClientSession(read_stream, write_stream) assession:
awaitsession.initialize()
# Request 1: session created, _receive_loop spawns with token-A in its contextr1=awaitsession.call_tool("whoami", {})
assertisinstance(r1.content[0], TextContent)
assertr1.content[0].text=="token-A"# Request 2: same session, different bearer — reuses the existing _receive_loopauth.token="token-B"r2=awaitsession.call_tool("whoami", {})
assertisinstance(r2.content[0], TextContent)
# EXPECTED: "token-B" — handler should see the token sent with THIS request# ACTUAL: "token-A" — handler sees the session-creating request's tokenassertr2.content[0].text=="token-B"finally:
proc.kill()
proc.join(timeout=2)Result on main:
AssertionError: assert 'token-A' == 'token-B'
- token-B
? ^
+ token-A
? ^
Root cause
AuthContextMiddleware sets auth_context_var inside the ASGI request's task. But the tool handler doesn't run in that task — it runs in a task spawned by Server.run()'s tg.start_soon(_handle_message, ...), which is itself inside run_server, which was spawned at session creation:
| awaitself._task_group.start(run_server) |
tg.start() copies the caller's contextvars.Context at call time. So run_server (and every task it spawns) carries a snapshot from request 1. Requests 2..N write to the transport's read stream from the new ASGI task, but the reader is _receive_loop — still running with the request-1 snapshot. The ContextVar set in request N's ASGI task never reaches the handler.
ASGI req 1 (auth_context_var=A)
└─ tg.start(run_server) ← context copied: A
└─ ServerSession.__aenter__
└─ tg.start_soon(_receive_loop) ← inherits A
└─ async for msg in session.incoming_messages:
└─ tg.start_soon(_handle_message) ← inherits A
└─ tool handler: get_access_token() → A ✓
ASGI req 2 (auth_context_var=B)
└─ transport.handle_request(...) ← writes to read_stream
... _receive_loop (still ctx A) reads it ...
... tg.start_soon(_handle_message) ← inherits A, not B
└─ tool handler: get_access_token() → A ✗
The existing unit test passes because MockApp runs inline in the same task as the middleware — no stream crossing:
| self.access_token_during_call=get_access_token() |
Impact
The correct path already exists
The Starlette Request is threaded explicitly through ServerMessageMetadata.request_context → ServerRequestContext.request. Inside a handler:
request: Request=ctx.request_context.requestuser=request.user# set by AuthenticationMiddlewaretoken=user.access_token# per-request, correct
Proposed fix
Given get_access_token() has no callers in src/ or examples/ and isn't documented: remove auth_context_var, get_access_token(), and AuthContextMiddleware. Expose auth on Context as part of #2098 using the explicit request threading above.
Alternative (if we want to keep the API): thread the AuthenticatedUser alongside request on ServerMessageMetadata and set the contextvar at the tg.start_soon site in Server.run(). But that's re-inventing what request.user already provides.
Related
AI Disclaimer
Summary
get_access_token()returns the bearer token from the session-creating request for the entire lifetime of a stateful streamable-HTTP session, regardless of whatAuthorizationheader later requests send.Repro
Open for code
Result on
main:Root cause
AuthContextMiddlewaresetsauth_context_varinside the ASGI request's task. But the tool handler doesn't run in that task — it runs in a task spawned byServer.run()'stg.start_soon(_handle_message, ...), which is itself insiderun_server, which was spawned at session creation:python-sdk/src/mcp/server/streamable_http_manager.py
Line 267 in 528abfa
tg.start()copies the caller'scontextvars.Contextat call time. Sorun_server(and every task it spawns) carries a snapshot from request 1. Requests 2..N write to the transport's read stream from the new ASGI task, but the reader is_receive_loop— still running with the request-1 snapshot. The ContextVar set in request N's ASGI task never reaches the handler.The existing unit test passes because
MockAppruns inline in the same task as the middleware — no stream crossing:python-sdk/tests/server/auth/middleware/test_auth_context.py
Line 33 in 528abfa
Impact
get_access_token().The correct path already exists
The Starlette
Requestis threaded explicitly throughServerMessageMetadata.request_context→ServerRequestContext.request. Inside a handler:Proposed fix
Given
get_access_token()has no callers insrc/orexamples/and isn't documented: removeauth_context_var,get_access_token(), andAuthContextMiddleware. Expose auth onContextas part of #2098 using the explicitrequestthreading above.Alternative (if we want to keep the API): thread the
AuthenticatedUseralongsiderequestonServerMessageMetadataand set the contextvar at thetg.start_soonsite inServer.run(). But that's re-inventing whatrequest.useralready provides.Related
ContextAI Disclaimer