fix(mcp): omit protocol version header on initialize

The client sent MCP-Protocol-Version on the initialize request itself.
The spec requires the header on requests after initialization, and the
offered version already travels in the initialize body. A server that
validates the header rejected the handshake unless the client happened
to offer a version it supports, so connecting depended on matching the
error body and only worked for the two versions the client offers.

- Send the header only after initialize succeeds, with the version
  the server negotiated
- Keep the fallback loop, newest-first order and negotiated-version
  persistence for servers that still reject the initialize body version
- Rewrite both version tests around this, including a 2025-03-26-only
  server, and assert that initialize carries no version header

Tags: #mcp
Co-Authored-By: htjulia <htjulia1@gmail.com>
This commit is contained in:
htjulia
2026-11-03 09:24:05 +09:00
parent 934568f336
commit f6583b4bad
2 changed files with 87 additions and 36 deletions
+16 -7
View File
@@ -66,6 +66,11 @@ type Client struct {
mu sync.Mutex
nextID int
sessionID string
// initialized is set once initialize succeeds. Until then the
// MCP-Protocol-Version header is omitted: the spec requires it on requests
// after initialization, and the offered version already travels in the
// initialize body.
initialized bool
// legacySSE is the pre-2025 MCP SSE transport used by servers such as GSL5.
legacySSE bool
@@ -252,7 +257,9 @@ func (c *Client) initialize(ctx context.Context) error {
versions = []string{legacySSEProtocolVersion}
}
for i, version := range versions {
c.mu.Lock()
c.protocol = version
c.mu.Unlock()
result, err := c.call(ctx, "initialize", map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{},
@@ -267,9 +274,12 @@ func (c *Client) initialize(ctx context.Context) error {
var negotiated struct {
ProtocolVersion string `json:"protocolVersion"`
}
c.mu.Lock()
if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" {
c.protocol = negotiated.ProtocolVersion
}
c.initialized = true
c.mu.Unlock()
return c.notify(ctx, "notifications/initialized", map[string]any{})
}
return fmt.Errorf("mcp: no supported protocol version")
@@ -462,17 +472,16 @@ func (c *Client) roundTrip(ctx context.Context, body rpcRequest, expectResp bool
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
version := c.protocol
if version == "" {
version = protocolVersion
c.mu.Lock()
sid := c.sessionID
initialized, version := c.initialized, c.protocol
c.mu.Unlock()
if initialized && version != "" {
req.Header.Set("MCP-Protocol-Version", version)
}
req.Header.Set("MCP-Protocol-Version", version)
for k, v := range c.headers {
req.Header.Set(k, v)
}
c.mu.Lock()
sid := c.sessionID
c.mu.Unlock()
if sid != "" {
req.Header.Set("Mcp-Session-Id", sid)
}
+71 -29
View File
@@ -74,18 +74,33 @@ func TestStreamableClientRequestTimeout(t *testing.T) {
_ = c3.Close()
}
// initRequest captures what the client sent for one MCP request.
type initRequest struct {
method, header, bodyVersion string
}
func recordRequest(r *http.Request) (rpcRequest, initRequest) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
rec := initRequest{method: req.Method, header: r.Header.Get("MCP-Protocol-Version")}
if p, ok := req.Params.(map[string]any); ok {
rec.bodyVersion, _ = p["protocolVersion"].(string)
}
return req, rec
}
// TestStreamableClientProtocolVersions uses spec-shaped servers that each support
// one version: they answer initialize with their own version, accept a request
// without MCP-Protocol-Version, and reject any other header value. The client
// must send no version header on initialize and the negotiated one afterwards.
func TestStreamableClientProtocolVersions(t *testing.T) {
for _, supported := range []string{"2025-11-25", "2025-06-18"} {
for _, supported := range []string{"2025-11-25", "2025-06-18", "2025-03-26"} {
t.Run(supported, func(t *testing.T) {
var attempts []string
var seen []initRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
version := r.Header.Get("MCP-Protocol-Version")
if req.Method == "initialize" {
attempts = append(attempts, version)
}
if version != supported {
req, rec := recordRequest(r)
seen = append(seen, rec)
if rec.header != "" && rec.header != supported {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`)
return
@@ -112,26 +127,43 @@ func TestStreamableClientProtocolVersions(t *testing.T) {
if _, err := c.Tools(context.Background()); err != nil {
t.Fatalf("tools/list: %v", err)
}
if attempts[0] != protocolVersion {
t.Fatalf("first version = %q", attempts[0])
if c.protocol != supported {
t.Fatalf("negotiated protocol = %q", c.protocol)
}
if supported == "2025-06-18" && (len(attempts) != 2 || attempts[1] != supported) {
t.Fatalf("fallback attempts = %v", attempts)
var inits int
for _, rec := range seen {
if rec.method == "initialize" {
inits++
if rec.header != "" {
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
}
if rec.bodyVersion != protocolVersion {
t.Fatalf("initialize offered %q, want %q", rec.bodyVersion, protocolVersion)
}
continue
}
if rec.header != supported {
t.Fatalf("%s carried MCP-Protocol-Version %q, want %q", rec.method, rec.header, supported)
}
}
if inits != 1 {
t.Fatalf("initialize attempts = %d, want 1", inits)
}
})
}
}
// TestStreamableClientVersionRejectionForms covers the ways a 2025-06-18-only
// server can reject 2025-11-25: the spec's HTTP 400 on the header, the spec's
// JSON-RPC -32602 error, and an unrelated failure that must not trigger a retry.
// TestStreamableClientVersionRejectionForms keeps the fallback loop honest for
// servers that reject the initialize body version instead of negotiating: an
// HTTP 400 "Unsupported protocol version", the JSON-RPC -32602 error from the
// spec's example, and an unrelated failure that must not trigger a retry.
func TestStreamableClientVersionRejectionForms(t *testing.T) {
cases := []struct {
name string
reject func(w http.ResponseWriter, id int)
fallback bool
}{
{"http 400 header", func(w http.ResponseWriter, _ int) {
{"http 400", func(w http.ResponseWriter, _ int) {
http.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest)
}, true},
{"json-rpc -32602", func(w http.ResponseWriter, id int) {
@@ -144,16 +176,18 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var attempts []string
var inits []initRequest
var after []initRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
req, rec := recordRequest(r)
if req.Method == "initialize" {
attempts = append(attempts, r.Header.Get("MCP-Protocol-Version"))
}
if r.Header.Get("MCP-Protocol-Version") != "2025-06-18" {
tc.reject(w, req.ID)
return
inits = append(inits, rec)
if rec.bodyVersion != "2025-06-18" {
tc.reject(w, req.ID)
return
}
} else {
after = append(after, rec)
}
w.Header().Set("Content-Type", "application/json")
switch req.Method {
@@ -168,13 +202,18 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) {
defer srv.Close()
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
for _, rec := range inits {
if rec.header != "" {
t.Fatalf("initialize carried MCP-Protocol-Version %q", rec.header)
}
}
if !tc.fallback {
if err == nil {
_ = c.Close()
t.Fatal("expected connect error")
}
if len(attempts) != 1 {
t.Fatalf("unrelated error retried: attempts = %v", attempts)
if len(inits) != 1 {
t.Fatalf("unrelated error retried: %d initialize attempts", len(inits))
}
return
}
@@ -182,12 +221,15 @@ func TestStreamableClientVersionRejectionForms(t *testing.T) {
t.Fatalf("connect: %v", err)
}
defer c.Close()
if len(attempts) != 2 || attempts[1] != "2025-06-18" {
t.Fatalf("fallback attempts = %v", attempts)
if len(inits) != 2 || inits[0].bodyVersion != protocolVersion || inits[1].bodyVersion != "2025-06-18" {
t.Fatalf("fallback attempts = %+v", inits)
}
if c.protocol != "2025-06-18" {
t.Fatalf("negotiated protocol = %q", c.protocol)
}
if len(after) == 0 || after[0].method != "notifications/initialized" || after[0].header != "2025-06-18" {
t.Fatalf("requests after initialize = %+v", after)
}
})
}
}