mcp: do not re-prompt OAuth after cancelled Authorize (#885)

When `OAuthHandler.Authorize` returns a context-cancelled error, the
cancellation notification that the call layer sends in response to the
same ctx cancellation is routed back through the streamable client
transport, re-triggering the `401` → `Authorize` path and prompting the
user a second time for a request they have already abandoned.

Fix in two layers:

1. **`mcp/transport.go`** — bound the cancellation `Notify` with a fresh
`context.Background()`-derived timeout instead of reusing the cancelled
caller context. This guarantees the caller never blocks on a doomed
notify against a degraded connection.
2. **`mcp/streamable.go`** — when `Authorize` returns a
context-cancelled error, mark the streamable client connection as failed
via `c.fail()`. The existing failure check in `Write()` then
short-circuits the cancellation notification without re-invoking the
OAuth handler, matching the bug reporter's expectation that cancelling
`Authorize` abandons the connection.

Includes a regression test
(`TestStreamableClientOAuth_CancelledAuthorize_NoReprompt`) with a
blocking `OAuthHandler` that asserts `Authorize` is invoked exactly
once. The test was verified to fail on unfixed code (`got 2` calls) and
pass with the fix.

Fixes #882

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ravish Gupta 2026-04-24 07:58:57 -07:00 • committed by GitHub
commit db50910c70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1814,6 +1814,22 @@ func (c *streamableClientConn) Write(ctx context.Context, msg jsonrpc.Message) e
if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil {
if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil {
// If the caller's context was cancelled while we were running the
// authorization flow, treat the connection as failed so subsequent
// operations on it (e.g. the cancellation notify the call layer
// sends in response to ctx cancellation) short-circuit instead of
// re-invoking the OAuth handler. Otherwise the user gets prompted
// to authorize a request they have already abandoned. See #882.
//
// We check ctx.Err() rather than the error returned by Authorize,
// because the handler is user-implemented and may return an error
// that does not wrap context.Canceled (e.g. a custom sentinel or
// a fmt.Errorf with %v). The context itself is the authoritative
// source for whether the caller abandoned the request.
ctxErr := ctx.Err()
if errors.Is(ctxErr, context.Canceled) || errors.Is(ctxErr, context.DeadlineExceeded) {
c.fail(fmt.Errorf("%s: authorization cancelled: %w", requestSummary, err))
}
// Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr
// and permanently break the connection.
// Wrap the authorization error as well for client inspection.