fix(mcp): negotiate protocol version with fallback

The Streamable HTTP client always offered 2025-06-18 and kept sending
that MCP-Protocol-Version header after initialize, ignoring the
version the server answered with. A server that only supports
2025-11-25 negotiates its own version and then rejects every later
request with 400, so none of its tools load. Servers that validate
the header on initialize itself reject the handshake outright, and
the client never retried with another version.

- Offer 2025-11-25 first and fall back to 2025-06-18 when the server
  rejects the version: HTTP 400 "Unsupported protocol version",
  JSON-RPC -32602, or an UNSUPPORTED_PROTOCOL_VERSION code
- Send the protocolVersion from the initialize result on later
  requests
- Keep the legacy HTTP+SSE transport on 2024-11-05

Closes #131

Tags: #mcp
Co-Authored-By: htjulia <htjulia1@gmail.com>
This commit is contained in:
htjulia
2026-11-02 15:40:45 +09:00
parent 7f8c7cbcae
commit 934568f336
2 changed files with 153 additions and 11 deletions
+35 -11
View File
@@ -28,7 +28,7 @@ import (
actool "github.com/Autumn-27/norma/tool"
)
const protocolVersion = "2025-06-18"
const protocolVersion = "2025-11-25"
const legacySSEProtocolVersion = "2024-11-05"
// ToolName mirrors mcp.ToolName so remote tools share the mcp__server__tool scheme.
@@ -247,18 +247,42 @@ func (c *Client) readLegacySSE(r *bufio.Reader) {
}
func (c *Client) initialize(ctx context.Context) error {
version := c.protocol
if version == "" {
version = protocolVersion
versions := []string{protocolVersion, "2025-06-18"}
if c.legacySSE {
versions = []string{legacySSEProtocolVersion}
}
if _, err := c.call(ctx, "initialize", map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
}); err != nil {
return err
for i, version := range versions {
c.protocol = version
result, err := c.call(ctx, "initialize", map[string]any{
"protocolVersion": version,
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "artex", "version": "0.2"},
})
if err != nil {
if i+1 < len(versions) && isUnsupportedVersion(err) {
continue
}
return err
}
var negotiated struct {
ProtocolVersion string `json:"protocolVersion"`
}
if json.Unmarshal(result, &negotiated) == nil && negotiated.ProtocolVersion != "" {
c.protocol = negotiated.ProtocolVersion
}
return c.notify(ctx, "notifications/initialized", map[string]any{})
}
return c.notify(ctx, "notifications/initialized", map[string]any{})
return fmt.Errorf("mcp: no supported protocol version")
}
// isUnsupportedVersion reports whether err is a server rejecting the offered
// protocol version: the spec's "Unsupported protocol version" (JSON-RPC -32602,
// or an HTTP 400 on the MCP-Protocol-Version header) or the
// UNSUPPORTED_PROTOCOL_VERSION code some servers return instead.
func isUnsupportedVersion(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unsupported protocol version") ||
strings.Contains(msg, "unsupported_protocol_version")
}
// Tools lists the server's tools and adapts them to CoreTools.
+118
View File
@@ -73,3 +73,121 @@ 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) {
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 {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `{"code":"UNSUPPORTED_PROTOCOL_VERSION"}`)
return
}
w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "initialize":
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":%q,"capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID, supported)
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
case "tools/list":
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"tools":[]}}`, req.ID)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer c.Close()
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 supported == "2025-06-18" && (len(attempts) != 2 || attempts[1] != supported) {
t.Fatalf("fallback attempts = %v", attempts)
}
})
}
}
// 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.
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.Error(w, "Bad Request: Unsupported protocol version", http.StatusBadRequest)
}, true},
{"json-rpc -32602", func(w http.ResponseWriter, id int) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"error":{"code":-32602,"message":"Unsupported protocol version","data":{"supported":["2025-06-18"],"requested":"2025-11-25"}}}`, id)
}, true},
{"unrelated 401", func(w http.ResponseWriter, _ int) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var attempts []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
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
}
w.Header().Set("Content-Type", "application/json")
switch req.Method {
case "initialize":
fmt.Fprintf(w, `{"jsonrpc":"2.0","id":%d,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"0"}}}`, req.ID)
case "notifications/initialized":
w.WriteHeader(http.StatusAccepted)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
c, err := New(context.Background(), "t", srv.URL, nil, false, 0)
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)
}
return
}
if err != nil {
t.Fatalf("connect: %v", err)
}
defer c.Close()
if len(attempts) != 2 || attempts[1] != "2025-06-18" {
t.Fatalf("fallback attempts = %v", attempts)
}
if c.protocol != "2025-06-18" {
t.Fatalf("negotiated protocol = %q", c.protocol)
}
})
}
}