mcp: Client.Connect falls back to initialize after a 401 on server/discover, running the authorization flow twice
Maintainer thường phản hồi trong vòng 1 ngày
@kerthans đang làm issue này rồi.
Từ ngày 21/10/2026.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 35/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Đình trệ
- Công nghệ
- go
- Lĩnh vực
- authentication, backend-api-design
Hướng nghiên cứu
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.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
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.
- Ngôn ngữ chính
- Go
- Star
- 5.3k
- Fork
- 607
- Merge trung bình
- 1 ngày 17 giờ
- Pull request đã merge (30 ngày)
- 80
Chuẩn bị môi trường
Khởi chạy dev container của dự án ngay trên trình duyệt, bằng tài khoản GitHub của bạn.
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của modelcontextprotocol/go-sdk
-
Expose generic `SendNotification` on `ServerSession` for custom protocol extensionsCó thể làm lại được Pull request cho issue này đã bị đóng mà không được merge. Đang mởneeds investigation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
modelcontextprotocol/go-sdk#745 · 13 bình luận · 1 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 50/100
modelcontextprotocol/go-sdk#1492 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
mcp: ClientSession.CallTool omits Mcp-Param-* headers unless the tool is in the ListTools cache, so a call without a prior tools/list is rejectedCó thể đã có người làm @huynle-dev đã nhận 2 ngày trước. Đang mở
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 35/100
modelcontextprotocol/go-sdk#1481 · 2 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
mcp: list_changed can only be broadcast; a server cannot notify one session (2026-07-28 subscriptions/listen)Có thể đã có người làm @huynle-dev đã nhận 2 ngày trước. Đang mở
Độ khó 4/5 1-2 ngày Mức phù hợp với người mới 32/100
modelcontextprotocol/go-sdk#1480 · 1 bình luận · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
jsonrpc: decimal and exponent integer IDs still lose precision after #1401Có thể đã có người làm @skosovsky đã nhận 19 ngày trước. Đang mở
Độ khó 3/5 1-2 ngày Mức phù hợp với người mới 25/100
modelcontextprotocol/go-sdk#1404 · 3 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của modelcontextprotocol/go-sdk
Issue tương tự
-
component:obi contribfest go test
Độ khó 2/5 Dưới một giờ Mức phù hợp với người mới 83/100
open-telemetry/opentelemetry-ebpf-instrumentation#4143 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
area/ambient
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
Maintainer thường phản hồi trong vòng 1 ngày
-
kind/cleanup
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
kubernetes-sigs/kueue#18324 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
enhancement
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100