Repository navigation
mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice #1423
Description
Describe the bug
With an OAuthHandler on StreamableClientTransport, Client.Connect sends server/discover first. If that request is still unauthorized after Authorize ran, or Authorize itself fails, Connect treats the failure as "legacy server" and falls back to initialize (mcp/client.go:376-378: "fall back to the legacy initialize handshake on any non-modern error"). The initialize POST gets the same 401, so the transport calls OAuthHandler.Authorize a second time (mcp/streamable.go:2325).
With auth.AuthorizationCodeHandler, each Authorize is a full flow: PRM and AS discovery, registration, and a browser consent prompt. So one Connect shows the user two consent prompts. Before #1419 (merged today) it also created two DCR clients at the AS. #1419 now reuses the registration, but the second interactive authorization still happens on main. It happens whenever the first authorization doesn't produce a token the server accepts:
- the user denies or cancels consent (
Authorizereturns an error), and is asked again immediately; - the AS issues a token the resource server rejects (we hit this with an AS that ignores RFC 8707
resource, so the token has no audience and a strict RS rejects it); - the account lacks access.
A 401/403 isn't an era signal. The 2026-07-28 Streamable HTTP binding says a client should inspect a 400 body before falling back. A legacy server that requires auth would 401 initialize just the same, so falling back can't help and only repeats the prompt.
To Reproduce
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/oauth2"
)
// countingHandler stands in for auth.AuthorizationCodeHandler; each Authorize
// call represents one full flow (discovery, registration, browser consent).
type countingHandler struct {
authorizeCalls atomic.Int32
fail bool // simulate the user denying consent
}
func (h *countingHandler) TokenSource(context.Context) (oauth2.TokenSource, error) {
return oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "example-token", TokenType: "Bearer"}), nil
}
func (h *countingHandler) Authorize(ctx context.Context, req *http.Request, resp *http.Response) error {
n := h.authorizeCalls.Add(1)
fmt.Printf(" Authorize #%d for JSON-RPC method %q\n", n, req.Header.Get("Mcp-Method"))
if h.fail {
return errors.New("access_denied: user declined consent")
}
return nil
}
func run(name string, fail bool) {
// Resource server that rejects every token.
rs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", resource_metadata="https://rs.example/.well-known/oauth-protected-resource"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer rs.Close()
h := &countingHandler{fail: fail}
c := mcp.NewClient(&mcp.Implementation{Name: "repro", Version: "0.0.1"}, nil)
fmt.Println(name)
_, err := c.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: rs.URL, OAuthHandler: h}, nil)
fmt.Printf(" Connect error: %v\n Authorize calls: %d (want 1)\n", err, h.authorizeCalls.Load())
}
func main() {
run("case 1: authorization succeeds but the token is still rejected", false)
run("case 2: authorization fails (user denies consent)", true)
}Actual behavior (v1.8.0 and main @ 3ebc6d1)
case 1: authorization succeeds but the token is still rejected
Authorize #1 for JSON-RPC method "server/discover"
Authorize #2 for JSON-RPC method ""
Connect error: calling "initialize": sending "initialize": Unauthorized
Authorize calls: 2 (want 1)
case 2: authorization fails (user denies consent)
Authorize #1 for JSON-RPC method "server/discover"
Authorize #2 for JSON-RPC method ""
Connect error: calling "initialize": sending "initialize": rejected by transport: access_denied: user declined consent
Authorize calls: 2 (want 1)
(The second call is the initialize POST, which carries no Mcp-Method header.)
Expected behavior
One Authorize call. Connect returns the server/discover authorization error, such as "Unauthorized" or the handler's access_denied, without trying initialize. The error is also more accurate: today it names initialize, a method the modern server never needed.
Environment
- go-sdk v1.8.0; also reproduced on
mainat 3ebc6d1 (which includes auth: reuse the dynamically registered client across authorizations #1419) go version go1.27.2 linux/amd64- Seen end to end with
auth.AuthorizationCodeHandler+ DCR against Ory Hydra v26.2.0 and aRequireBearerToken-protected stateless go-sdk server: two registrations and two consent prompts perConnecton v1.8.0.
References
- MCP 2026-07-28 Streamable HTTP → Backward Compatibility ("On
400 Bad Request, the client SHOULD inspect the response body before falling back"): https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility - MCP 2026-07-28 Versioning → Backward Compatibility (HTTP: "attempt a modern request and inspect the body of a
400 Bad Requestbefore falling back"; legacy detection is "a4xxwithout a recognized modern error body"): https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions - The "any error" fallback comes from mcp: Enable legacy initialize fallback on any error #1014. The 401 case doesn't seem to have been considered there.
- Related: auth: AuthorizationCodeHandler registers a new DCR client on every Authorize call #1416 / auth: reuse the dynamically registered client across authorizations #1419 (duplicate DCR registration, fixed); mcp: Client.Connect never falls back to initialize when a stdio server ignores server/discover #1332 / mcp: bound server/discover probe on stdio transports #1334 (same fallback loop on stdio, the timeout case).
Suggested fix
In Client.Connect, don't fall back when the server/discover failure is an authorization failure. Return it instead. Concretely:
- In
streamableClientConn.Write, when the response is still 401/403 afterAuthorize(orAuthorizereturned an error), return an error that wraps an identifiable sentinel or type. That could be unexported, or a new exportedErrUnauthorizedvia a proposal. - In
Connect, iferrors.Is(err, thatSentinel),cs.Close()and return the error. Don't run theinitializefallback.
A legacy server behind auth still works. The first 401 runs Authorize, the retried server/discover reaches the legacy server with a valid token and gets its non-modern 400/404, and the fallback proceeds as today, so only one authorization happens. Tests: the two cases above should each assert exactly one Authorize call, plus a "legacy server behind auth" case that still connects through initialize.
Additional context
Our workaround is to run a single pre-flight authorization (401 → PRM → Authorize) before calling Connect. If it doesn't yield an accepted token, we don't call Connect at all.
Activity
guglielmo-san commentedon Oct 21, 2026
@clearly thanks for the detailed report and reproducer!
I agree we should never trigger
OAuthHandler.Authorizetwice during a singleClient.Connect, but I think we need to be careful about skipping theinitializefallback altogether when the retriedserver/discoverrequest returns401or403.There are two distinct cases:
OAuthHandler.Authorizeitself returns an error :Currently in
streamableClientConn.Write,c.failConnection(...)is only called ifctx.Err()iscontext.Canceledorcontext.DeadlineExceeded, and the error is wrapped withjsonrpc2.ErrRejected. That leaves the connection open, soConnectfalls back toinitializeand runsAuthorizea second time. In this case we should not fall back toinitialize.OAuthHandler.Authorizesucceeds (a token is now inTokenSource), but the retriedserver/discoverstill returns401or403:At this point the client has valid credentials in
TokenSource. A server may rejectserver/discoverwith403(or401) becauseserver/discoveris not in its method allowlist or has different per-method authorization rules, while those same credentials do have access toinitialize.Connectimmediately afterserver/discoverfails, we never even tryinitializewith the token we just acquired.initialize, the firstinitializerequest will automatically carry the token fromTokenSource. If the user's credentials have access toinitialize, it will succeed (200 OK) without callingAuthorizeagain.Authorizecall only happens wheninitialize(with that token) also returns401/403, becausestreamableClientConndoes not know thatAuthorizealready ran duringserver/discover.So rather than blocking the
initializefallback when the retriedserver/discovergets401/403, I think the right fix is:Authorizeitself returns an error duringserver/discover, do not fall back toinitialize(return the error immediately).Authorizesucceeds duringserver/discoverand the retriedserver/discoverstill fails (including with401/403), still fall back toinitializeusing the acquired token, but do not runAuthorizea second time ifinitializealso fails with401.What do you think about this approach?
mcp: don't fall back to initialize when discover fails authorization