Skip to content

get_access_token() returns stale token in stateful streamable-HTTP sessions #2208

Description

@maxisbey

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_contextServerRequestContext.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    authIssues and PRs related to Authentication / OAuthbugSomething isn't workingv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions