mcp: return json-rpc error on stateful server serving protocolVersion20260728 requests (#1143)
Fixes #1130
This commit is contained in:
parent
725694100a
commit
6b1518cb0b
4 changed files with 159 additions and 4 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue