Summary
When connecting to an OAuth-protected server with the default dual-path connect (ProtocolVersion unset), an interactive authorization flow started by the server/discover probe's 401 challenge is silently destroyed by McpClientOptions.DiscoverProbeTimeout (default 5s), and the connect then reliably fails with:
ModelContextProtocol.McpException: Failed to handle unauthorized response with 'Bearer' scheme. The authorization response state did not match the state sent in the authorization request.
Sequence (observed against a production MCP server, SDK 2.2.0)
- The client sends the
server/discover probe. The server answers 401, which enters ClientOAuthProvider: metadata discovery, DCR, then AuthorizationCallbackHandler is invoked. The host presents the authorization URL (state S1) to the user, who starts logging in.
- After 5 seconds,
DiscoverProbeTimeout elapses. This timeout exists for servers that silently drop unknown methods — but here the cancellation propagates through HandleUnauthorizedResponseAsync into the pending interactive flow and aborts it, even though the user is mid-login in a browser the SDK cannot close.
- The client falls back to
initialize, which draws another 401 and starts a second interactive flow with a fresh state S2 and PKCE verifier. The host has no new popup to show — the user is still completing flow 1.
- The user finishes logging in; the redirect arrives carrying
S1. The second flow expects S2 → ValidateStateResponse throws the state-mismatch error above. The user-approved authorization code is unusable by flow 2 anyway (different PKCE verifier), so there is no way to recover the flow the user actually completed.
A second, related problem: the whole interactive login is bounded by InitializationTimeout (default 60s), which a real login (with MFA etc.) easily exceeds. That part is at least configurable, but nothing in the docs points hosts at it.
Expected behavior
Canceling the request whose challenge started an interactive authorization should not abort the flow itself — the user-facing browser flow is already in progress and only one flow can ever match the redirect the user completes. A subsequent challenge (from the initialize fallback, or any concurrent request) should join the in-flight flow and reuse its result instead of starting a competing flow the user never sees.
Suggested fix
In ClientOAuthProvider, memoize the in-flight authorization-code flow and detach it from the triggering request's cancellation token (bounding it by provider disposal instead); challenge handlers await it with their own token via Task.WaitAsync. The existing _tokenAcquisitionLock + cached-token re-check already handles the "another caller acquired the token" case once the flow survives.
Docs for DiscoverProbeTimeout / InitializationTimeout should also mention the interactive-authorization interplay.
I have a PR ready with this change plus regression tests (deterministic cancellation-survival test, an end-to-end probe-timeout scenario test, and a transport-dispose cancellation test).
Workaround (current releases)
For interactive connects, set DiscoverProbeTimeout = Timeout.InfiniteTimeSpan and raise InitializationTimeout above the host's authorization window.
Summary
When connecting to an OAuth-protected server with the default dual-path connect (
ProtocolVersionunset), an interactive authorization flow started by theserver/discoverprobe's 401 challenge is silently destroyed byMcpClientOptions.DiscoverProbeTimeout(default 5s), and the connect then reliably fails with:Sequence (observed against a production MCP server, SDK 2.2.0)
server/discoverprobe. The server answers 401, which entersClientOAuthProvider: metadata discovery, DCR, thenAuthorizationCallbackHandleris invoked. The host presents the authorization URL (stateS1) to the user, who starts logging in.DiscoverProbeTimeoutelapses. This timeout exists for servers that silently drop unknown methods — but here the cancellation propagates throughHandleUnauthorizedResponseAsyncinto the pending interactive flow and aborts it, even though the user is mid-login in a browser the SDK cannot close.initialize, which draws another 401 and starts a second interactive flow with a fresh stateS2and PKCE verifier. The host has no new popup to show — the user is still completing flow 1.S1. The second flow expectsS2→ValidateStateResponsethrows the state-mismatch error above. The user-approved authorization code is unusable by flow 2 anyway (different PKCE verifier), so there is no way to recover the flow the user actually completed.A second, related problem: the whole interactive login is bounded by
InitializationTimeout(default 60s), which a real login (with MFA etc.) easily exceeds. That part is at least configurable, but nothing in the docs points hosts at it.Expected behavior
Canceling the request whose challenge started an interactive authorization should not abort the flow itself — the user-facing browser flow is already in progress and only one flow can ever match the redirect the user completes. A subsequent challenge (from the
initializefallback, or any concurrent request) should join the in-flight flow and reuse its result instead of starting a competing flow the user never sees.Suggested fix
In
ClientOAuthProvider, memoize the in-flight authorization-code flow and detach it from the triggering request's cancellation token (bounding it by provider disposal instead); challenge handlers await it with their own token viaTask.WaitAsync. The existing_tokenAcquisitionLock+ cached-token re-check already handles the "another caller acquired the token" case once the flow survives.Docs for
DiscoverProbeTimeout/InitializationTimeoutshould also mention the interactive-authorization interplay.I have a PR ready with this change plus regression tests (deterministic cancellation-survival test, an end-to-end probe-timeout scenario test, and a transport-dispose cancellation test).
Workaround (current releases)
For interactive connects, set
DiscoverProbeTimeout = Timeout.InfiniteTimeSpanand raiseInitializationTimeoutabove the host's authorization window.