mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice
I maintainer di solito rispondono entro 1 giorno
@kerthans ci sta già lavorando.
Dal 21/10/2026.
Valutazione
- Difficoltà
- 3/5
- Tempo stimato
- 1-2 giorni
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Ferma
- Stack tecnologico
- go
- Ambito
- authentication, backend-api-design
Direzione di ricerca
Start with Client.Connect in mcp/client.go and the authorization retry in mcp/streamable.go; the issue also links open pull request #1425, which may show the work already under way. Check how authorization failures are distinguished from errors that trigger the legacy initialize fallback, then review or add tests for rejected authorization and an authenticated legacy server. Done means authorization runs once on failure while the legacy fallback still works.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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 #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 #1014. The 401 case doesn't seem to have been considered there.
- Related: #1416 / #1419 (duplicate DCR registration, fixed); #1332 / #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.
- Lingua principale
- Go
- Stelle
- 5.3k
- Fork
- 606
- Merge medio
- 1g 14h
- PR unite (30g)
- 77
Preparare l'ambiente
Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di modelcontextprotocol/go-sdk
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensionsForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Apertaneeds investigation
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
modelcontextprotocol/go-sdk#745 · 13 commenti · 1 reazione ·
I maintainer di solito rispondono entro 1 giorno
-
mcp: LoggingHandler drops records logged at custom slog levelsForse già presa @kaankucukx l’ha presa 7 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 45/100
modelcontextprotocol/go-sdk#1445 ·
I maintainer di solito rispondono entro 1 giorno
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401Forse già presa @skosovsky l’ha presa 15 giorni fa. Aperta
Difficoltà 3/5 1-2 giorni Idoneità per principianti 25/100
modelcontextprotocol/go-sdk#1404 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
mcp: rejected request IDs receive no JSON-RPC error envelopeForse già presa @skosovsky l’ha presa 19 giorni fa. Aperta
Difficoltà 4/5 1-2 giorni Idoneità per principianti 68/100
modelcontextprotocol/go-sdk#1391 ·
I maintainer di solito rispondono entro 1 giorno
-
auth: Authorize fails against a 2025-03-26 server whose URL has a query stringForse già presa @nahapetyan-serob l’ha presa 17 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 40/100
modelcontextprotocol/go-sdk#1347 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di modelcontextprotocol/go-sdk
Issue simili
-
Headerless responses block while skipping duplicate status linesForse già presa @mynhe l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 73/100
projectdiscovery/rawhttp#554 ·
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 80/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
lognorman20/monaco#7428 ·
-
docs(prompts): IF BLOCKED workflow-scope branch says to comment on the issue on jira/local trackersApertaagent-butler-finding bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
jordansmall/spindrift#6238 ·
I maintainer di solito rispondono entro 1 giorno