Hacktoberfest 2026:メンテナが10月に向けて印を付けた、オープンで初心者向けの issue。 Hacktoberfest の issue を見る

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

オープン
#1,423 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

@kerthans がすでに取り組んでいます。

2026年10月21日 から。

  • #1425 @kerthans による — オープン
  • #1467 @guglielmo-san による — マージされずにクローズ

評価

難易度
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 (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.

主要言語
Go
スター
5.3k
フォーク
606
平均マージ
1日 14時間
マージ済み PR(30日)
77

環境構築

Codespaces で開く

このプロジェクトの開発コンテナを、あなたの GitHub アカウントでブラウザ上に起動します。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

modelcontextprotocol/go-sdk のほかの issue

modelcontextprotocol/go-sdk の issue をすべて見る

似ている issue

Go の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。