mcp: return json-rpc error on stateful server serving protocolVersion20260728 requests (#1143)

Fixes #1130
This commit is contained in:
Guglielmo Colombo 2026-08-05 12:16:39 +02:00 • committed by GitHub
commit 6b1518cb0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 159 additions and 4 deletions

View file

@ -18,6 +18,22 @@
## `MCPGODEBUG` history ## `MCPGODEBUG` history
### 1.8.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
- `plaintextstatefulrejection` added. If set to `1`, a stateful
`StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body
when it receives a request carrying per-request metadata (i.e. an
`io.modelcontextprotocol/protocolVersion` `_meta` field, or an
`MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous
behavior. The default behavior was changed so that the server responds with a
JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying
an `UnsupportedProtocolVersionData` payload that advertises the legacy
versions the server supports. This lets the client's
existing renegotiation logic recover and prevents the failure from tearing
down the underlying connection.
### 1.7.0 ### 1.7.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK. Options listed below were added and will be removed in the 1.9.0 version of the SDK.

View file

@ -17,6 +17,22 @@
## `MCPGODEBUG` history ## `MCPGODEBUG` history
### 1.8.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK.
- `plaintextstatefulrejection` added. If set to `1`, a stateful
`StreamableHTTPHandler` will respond with a plain-text `http.Error` 400 body
when it receives a request carrying per-request metadata (i.e. an
`io.modelcontextprotocol/protocolVersion` `_meta` field, or an
`MCP-Protocol-Version` header >= `2026-07-28`), restoring the previous
behavior. The default behavior was changed so that the server responds with a
JSON-RPC error of code `CodeUnsupportedProtocolVersion` (`-32022`) carrying
an `UnsupportedProtocolVersionData` payload that advertises the legacy
versions the server supports. This lets the client's
existing renegotiation logic recover and prevents the failure from tearing
down the underlying connection.
### 1.7.0 ### 1.7.0
Options listed below were added and will be removed in the 1.9.0 version of the SDK. Options listed below were added and will be removed in the 1.9.0 version of the SDK.

View file

@ -314,6 +314,18 @@ var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody")
// The option will be removed in the 1.8.0 version of the SDK. // The option will be removed in the 1.8.0 version of the SDK.
var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck") var disablecontenttypecheck = mcpgodebug.Value("disablecontenttypecheck")
// plaintextstatefulrejection is a compatibility parameter that restores the
// previous behavior of a stateful [StreamableHTTPHandler] when it receives a
// request carrying SEP-2575 per-request metadata (i.e. an
// `io.modelcontextprotocol/protocolVersion` `_meta` field, or an
// `MCP-Protocol-Version` header >= 2026-07-28). When unset (the default), the
// server responds with a JSON-RPC error of code
// [CodeUnsupportedProtocolVersion] and an [UnsupportedProtocolVersionData]
// payload listing its supported legacy versions, as required by SEP-2575. When
// set to "1", the server instead responds with a plain-text `http.Error` body
// mentioning the `StreamableHTTPOptions.Stateless` field.
var plaintextstatefulrejection = mcpgodebug.Value("plaintextstatefulrejection")
// writeJSONRPCError writes a JSON-RPC error response with the given HTTP // writeJSONRPCError writes a JSON-RPC error response with the given HTTP
// status code, request ID (may be a zero ID for errors that occur before the // status code, request ID (may be a zero ID for errors that occur before the
// request body has been parsed), and JSON-RPC error. // request body has been parsed), and JSON-RPC error.
@ -1519,10 +1531,31 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
// rejection as it should learn about the supported protocols from the // rejection as it should learn about the supported protocols from the
// DiscoverResult response. // DiscoverResult response.
if !c.stateless && jreq.Method != methodDiscover { if !c.stateless && jreq.Method != methodDiscover {
http.Error(w, fmt.Sprintf( if plaintextstatefulrejection == "1" {
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)", http.Error(w, fmt.Sprintf(
protocolVersion), "Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
http.StatusBadRequest) protocolVersion),
http.StatusBadRequest)
return
}
// Advertise only the legacy versions this stateful server accepts
// (i.e. exclude 2026-07-28, since that's what the request asked for
// and what we're rejecting).
legacyVersions := slices.DeleteFunc(slices.Clone(supportedProtocolVersions), func(v string) bool {
return v >= protocolVersion20260728
})
data, _ := json.Marshal(UnsupportedProtocolVersionData{
Supported: legacyVersions,
Requested: protocolVersion,
})
c.logger.Warn(fmt.Sprintf(
"rejecting request with protocol version %q: this server is stateful; set StreamableHTTPOptions.Stateless = true to accept it",
protocolVersion))
writeJSONRPCError(w, http.StatusBadRequest, jreq.ID, &jsonrpc.Error{
Code: CodeUnsupportedProtocolVersion,
Message: fmt.Sprintf("protocol version %q is not supported by this server", protocolVersion),
Data: data,
})
return return
} }
if headerVersion == "" { if headerVersion == "" {

View file

@ -3504,6 +3504,96 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) {
if resp.StatusCode != http.StatusBadRequest { if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody) t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody)
} }
msg, err := jsonrpc.DecodeMessage(respBody)
if err != nil {
t.Fatalf("decoding response body as JSON-RPC: %v; body = %s", err, respBody)
}
jresp, ok := msg.(*jsonrpc.Response)
if !ok || jresp.Error == nil {
t.Fatalf("response is not a JSON-RPC error: %s", respBody)
}
var jerr *jsonrpc.Error
if !errors.As(jresp.Error, &jerr) {
t.Fatalf("response error is not *jsonrpc.Error: %v", jresp.Error)
}
if jerr.Code != CodeUnsupportedProtocolVersion {
t.Errorf("error code = %d, want %d (CodeUnsupportedProtocolVersion)", jerr.Code, CodeUnsupportedProtocolVersion)
}
var data UnsupportedProtocolVersionData
if err := json.Unmarshal(jerr.Data, &data); err != nil {
t.Fatalf("decoding error data: %v; data = %s", err, jerr.Data)
}
if data.Requested != protocolVersion20260728 {
t.Errorf("data.Requested = %q, want %q", data.Requested, protocolVersion20260728)
}
if slices.Contains(data.Supported, protocolVersion20260728) {
t.Errorf("data.Supported = %v, must not contain the rejected version %q",
data.Supported, protocolVersion20260728)
}
if len(data.Supported) == 0 {
t.Errorf("data.Supported is empty; expected at least one legacy version")
}
}
// TestStreamableStateful_RejectsNewProtocol_LegacyPlainText verifies that
// MCPGODEBUG=plaintextstatefulrejection=1 restores the pre-v1.8.0 plain-text
// 400 body for the stateful/new-protocol rejection.
func TestStreamableStateful_RejectsNewProtocol_LegacyPlainText(t *testing.T) {
prev := plaintextstatefulrejection
plaintextstatefulrejection = "1"
t.Cleanup(func() { plaintextstatefulrejection = prev })
server := NewServer(testImpl, nil)
AddTool(server, &Tool{Name: "noop"},
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
})
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
httpServer := httptest.NewServer(handler)
defer httpServer.Close()
initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody)
if err != nil {
t.Fatal(err)
}
initReq.Header.Set("Content-Type", "application/json")
initReq.Header.Set("Accept", "application/json, text/event-stream")
initResp, err := http.DefaultClient.Do(initReq)
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, initResp.Body)
initResp.Body.Close()
sessionID := initResp.Header.Get(sessionIDHeader)
if sessionID == "" {
t.Fatalf("initialize response missing %s header", sessionIDHeader)
}
body := newProtocolBody(t, "noop", struct{}{})
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set(sessionIDHeader, sessionID)
req.Header.Set(protocolVersionHeader, protocolVersion20260728)
req.Header.Set(methodHeader, "tools/call")
req.Header.Set(nameHeader, "noop")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Errorf("Content-Type = %q, want text/plain; body = %s", ct, respBody)
}
if !strings.Contains(string(respBody), "stateless") { if !strings.Contains(string(respBody), "stateless") {
t.Errorf("body = %q, want a message mentioning 'stateless'", respBody) t.Errorf("body = %q, want a message mentioning 'stateless'", respBody)
} }