Skip to content

mcp: don't fall back to initialize when discover fails authorization - #1425

Open
kerthans wants to merge 2 commits into
modelcontextprotocol:mainfrom
kerthans:fix/discover-auth-no-fallback
Open

kerthans wants to merge 2 commits into
modelcontextprotocol:mainfrom
kerthans:fix/discover-auth-no-fallback

Conversation

@kerthans

Copy link
Copy Markdown
Contributor

With an OAuthHandler on StreamableClientTransport, Client.Connect sends server/discover first. If that request is still unauthorized after OAuthHandler.Authorize ran, or Authorize itself fails, Connect treats the error like any other non-modern discover error and falls back to initialize. The initialize POST gets the same 401/403, so Authorize runs a second time. With auth.AuthorizationCodeHandler that is a second full flow and a second consent prompt for a single Connect, and the returned error names initialize, a method a modern server never needed.

A 401 or 403 is not a protocol era signal: a legacy server that requires authorization rejects initialize the same way, so falling back cannot succeed and only repeats the authorization flow.

This change:

  • In streamableClientConn.Write, wraps authorization failures with an unexported sentinel, both when Authorize returns an error and when the retried request is still 401/403.
  • In Client.Connect, closes the session and returns that error instead of falling back to initialize.

A legacy server behind authorization still connects as before: the first 401 runs Authorize, the retried server/discover reaches the server with valid credentials and fails with a non-auth error, and the initialize fallback proceeds with a single authorization.

Tests:

  • Authorize returns an error: Authorize is called once, initialize is never sent, and the error wraps the handler's error.
  • Authorize succeeds but the retried request is still 401: Authorize is called once and initialize is never sent.
  • A legacy server behind RequireBearerToken still connects through initialize (2025-11-25) with one authorization.

Non-goals: no exported API changes (an exported ErrUnauthorized would need a proposal), and no change to the stdio discover fallback (#1334) or to authorization handling for requests other than the handshake.

AI assistance was used to prepare this change; I reviewed and tested it.

Fixes #1423

With an OAuthHandler on StreamableClientTransport, Client.Connect sends
server/discover first. If that request is still unauthorized after
OAuthHandler.Authorize ran, or Authorize itself failed, Connect treated
the error like any other non-modern discover error and fell back to
initialize. The initialize POST got the same 401/403, so the transport
ran Authorize a second time, which for interactive handlers means a
second consent prompt.

A 401 or 403 is not a protocol era signal: a legacy server that requires
authorization rejects initialize the same way. The streamable client now
wraps authorization failures with an unexported sentinel, and Connect
returns that error instead of falling back. A legacy server behind
authorization still connects through initialize, since the retried
discover reaches it with valid credentials and fails for a non-auth
reason.

Fixes modelcontextprotocol#1423
@kerthans
kerthans force-pushed the fix/discover-auth-no-fallback branch from 1db0d6a to 5e069e9 Compare October 22, 2026 07:26
@kerthans

Copy link
Copy Markdown
Contributor Author

Rebased on main (b874fad). go test ./... and go vet ./... pass locally. The workflows haven't run on this PR yet; could a maintainer approve them?

Comment thread mcp/streamable.go
Comment on lines +2625 to +2627
if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && c.oauthHandler != nil {
err = fmt.Errorf("%w: %w", err, errAuthorizationFailed)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when Authorize succeeds and the retried server/discover is still 401/403, we should still fall back to initialize with the acquired token (a server may authorize per method), just without calling Authorize a second time (see my comment on #1423). Currently a server that returns 403 for server/discover but allows initialize connects on main and fails with this PR. Could you tag only the Authorize error, and skip Authorize for the fallback initialize instead?

This branch has not been deployed

No deployments
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.

mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice

2 participants