Skip to content

auth: bind refreshing token source to a background context, not the request ctx#988

Open
toabctl wants to merge 2 commits into
modelcontextprotocol:mainfrom
toabctl:fix/oauth-token-source-context
Open

auth: bind refreshing token source to a background context, not the request ctx#988
toabctl wants to merge 2 commits into
modelcontextprotocol:mainfrom
toabctl:fix/oauth-token-source-context

Conversation

@toabctl

@toabctl toabctl commented Jun 3, 2026

Copy link
Copy Markdown

Problem

AuthorizationCodeHandler.exchangeAuthorizationCode builds the long-lived token source from the request context passed to Authorize():

clientCtx := context.WithValue(ctx, oauth2.HTTPClient, h.config.Client)
...
h.tokenSource = cfg.TokenSource(clientCtx, token)

golang.org/x/oauth2 captures that context and reuses it for every future refresh:

  • oauth2.go: Config.TokenSource(ctx, …) stores ctx in tokenRefresher{ctx}
  • tokenRefresher.Token() (no ctx arg) calls retrieveToken(tf.ctx, …)
  • internal/token.go: ContextClient(ctx).Do(req.WithContext(ctx))

Authorize() is normally invoked from a request- or connect-scoped context — e.g. the streamable client transport calls it during setMCPHeaders/initialize, and that context is cancelled once the operation completes. The cached access token works until expiry; the first refresh after expiry then fails instantly with context canceled, before any HTTP request is sent. The connection can no longer refresh.

setMCPHeaders only re-invokes Authorize() for an invalid_grant oauth2.RetrieveError (#917). A context canceled is not a RetrieveError, so it is never recovered — the connection is wedged until the client reconnects.

Reproduce

  1. Connect a StreamableClientTransport with an AuthorizationCodeHandler to a server whose initialize returns 401 (so Authorize() runs during connect, under the connect context).
  2. Let the connect context be cancelled after connect returns (the common pattern: a context.WithTimeout(parent, …) + defer cancel() bounding the interactive flow).
  3. Wait for the access token to expire and issue a request.
  4. Every request fails with Post "<token endpoint>": context canceled and dur≈0 — the refresh never hits the network.

Fix

A token source outlives the request that created it, so bind its refreshes to context.Background() (still carrying the configured HTTP client via oauth2.HTTPClient). The one-shot token exchange keeps using the request context.

refreshCtx := context.WithValue(context.Background(), oauth2.HTTPClient, h.config.Client)
h.tokenSource = cfg.TokenSource(refreshCtx, token)

auth package tests pass. Behaviour is otherwise unchanged: the same HTTP client value is carried; only the cancellation parent differs.

🤖 Generated with Claude Code

…equest ctx

AuthorizationCodeHandler.exchangeAuthorizationCode built the long-lived
token source with cfg.TokenSource(clientCtx, token), where clientCtx
derives from the per-request context passed to Authorize(). golang.org/x/
oauth2 captures that context inside tokenRefresher and reuses it for every
subsequent refresh round-trip (oauth2.go: Config.TokenSource ->
tokenRefresher{ctx}; token.go: ContextClient(ctx).Do(req.WithContext(ctx))).

Authorize() is typically called from a request- or connect-scoped context
(e.g. the transport's setMCPHeaders/initialize path), which is cancelled
once that operation completes. The cached access token works until it
expires; the first refresh after expiry then fails immediately with
"context canceled" before any HTTP request is sent, leaving the connection
permanently unable to refresh. setMCPHeaders only re-runs Authorize() for an
invalid_grant oauth2.RetrieveError, so a context-cancellation error is not
recovered and the connection is wedged until the client reconnects.

A token source outlives the request that created it, so bind its refreshes
to context.Background() (still carrying the configured HTTP client via
oauth2.HTTPClient) instead of the request context. The one-shot token
exchange continues to use the request context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds opt-in refresh support to the fake authorization server (a configurable
access-token TTL and a refresh_token grant; both default off so existing
tests are unchanged) and a test that authorizes under a cancellable context,
cancels it, then forces a refresh. Without the fix it reproduces the
production failure exactly:

    Post ".../token": context canceled

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants