mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice
メンテナーはふだん 1 日以内に返信
@kerthans がすでに取り組んでいます。
2026年10月21日 から。
評価
- 難易度
- 3/5
- 見積もり時間
- 1〜2日
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 停滞
- 技術スタック
- go
調査の方向性
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.
索引モデルが issue の本文から書いたものです。
説明
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.
- 主要言語
- Go
- スター
- 5.3k
- フォーク
- 606
- 平均マージ
- 1日 14時間
- マージ済み PR(30日)
- 77
環境構築
このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
modelcontextprotocol/go-sdk のほかの issue
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensions再び着手できるかも このイシューのプルリクエストはマージされずにクローズされました。 オープンneeds investigation
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
modelcontextprotocol/go-sdk#745 · コメント 13 件 · リアクション 1 件 ·
メンテナーはふだん 1 日以内に返信
-
mcp: LoggingHandler drops records logged at custom slog levels対応中かも @kaankucukx が 7 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 45/100
modelcontextprotocol/go-sdk#1445 ·
メンテナーはふだん 1 日以内に返信
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401対応中かも @skosovsky が 15 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 25/100
modelcontextprotocol/go-sdk#1404 · コメント 2 件 ·
メンテナーはふだん 1 日以内に返信
-
mcp: rejected request IDs receive no JSON-RPC error envelope対応中かも @skosovsky が 19 日前に担当しました。 オープン
難易度 4/5 1〜2日 初心者へのやさしさ 68/100
modelcontextprotocol/go-sdk#1391 ·
メンテナーはふだん 1 日以内に返信
-
auth: Authorize fails against a 2025-03-26 server whose URL has a query string対応中かも @nahapetyan-serob が 17 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 40/100
modelcontextprotocol/go-sdk#1347 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
modelcontextprotocol/go-sdk の issue をすべて見る
似ている issue
-
Headerless responses block while skipping duplicate status lines対応中かも @mynhe が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 73/100
projectdiscovery/rawhttp#554 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 80/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 82/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
lognorman20/monaco#7428 ·
-
docs(prompts): IF BLOCKED workflow-scope branch says to comment on the issue on jira/local trackersオープンagent-butler-finding bug
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
jordansmall/spindrift#6238 ·
メンテナーはふだん 1 日以内に返信