mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
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:
+16
-7
@@ -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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
+69
-27
@@ -74,18 +74,33 @@ func TestStreamableClientRequestTimeout(t *testing.T) {
|
||||
_ = c3.Close()
|
||||
}
|
||||
|
||||
func TestStreamableClientProtocolVersions(t *testing.T) {
|
||||
for _, supported := range []string{"2025-11-25", "2025-06-18"} {
|
||||
t.Run(supported, func(t *testing.T) {
|
||||
var attempts []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
version := r.Header.Get("MCP-Protocol-Version")
|
||||
if req.Method == "initialize" {
|
||||
attempts = append(attempts, version)
|
||||
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)
|
||||
}
|
||||
if version != supported {
|
||||
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", "2025-03-26"} {
|
||||
t.Run(supported, func(t *testing.T) {
|
||||
var seen []initRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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,17 +176,19 @@ 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" {
|
||||
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 {
|
||||
case "initialize":
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user