Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#1,423 1 commento 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@kerthans ci sta già lavorando.

Dal 21/10/2026.

  • #1425 di @kerthans — aperta
  • #1467 di @guglielmo-san — chiusa senza merge

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

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 (Authorize returns 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 main at 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 a RequireBearerToken-protected stateless go-sdk server: two registrations and two consent prompts per Connect on v1.8.0.

References

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 after Authorize (or Authorize returned an error), return an error that wraps an identifiable sentinel or type. That could be unexported, or a new exported ErrUnauthorized via a proposal.
  • In Connect, if errors.Is(err, thatSentinel), cs.Close() and return the error. Don't run the initialize fallback.

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

Apri in Codespaces

Avvia il container di sviluppo del progetto nel browser, con il tuo account GitHub.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di modelcontextprotocol/go-sdk

Tutte le issue di modelcontextprotocol/go-sdk

Issue simili

Altre issue su Go

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.