Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/mcpgodebug.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@

## `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

Options listed below were added and will be removed in the 1.9.0 version of the SDK.
Expand Down
16 changes: 16 additions & 0 deletions internal/docs/mcpgodebug.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@

## `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

Options listed below were added and will be removed in the 1.9.0 version of the SDK.
Expand Down
41 changes: 37 additions & 4 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,18 @@ var noprotocolerrorbody = mcpgodebug.Value("noprotocolerrorbody")
// The option will be removed in the 1.8.0 version of the SDK.
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
// status code, request ID (may be a zero ID for errors that occur before the
// request body has been parsed), and JSON-RPC error.
Expand Down Expand Up @@ -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
// DiscoverResult response.
if !c.stateless && jreq.Method != methodDiscover {
http.Error(w, fmt.Sprintf(
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
protocolVersion),
http.StatusBadRequest)
if plaintextstatefulrejection == "1" {
http.Error(w, fmt.Sprintf(
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
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
}
if headerVersion == "" {
Expand Down
90 changes: 90 additions & 0 deletions mcp/streamable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3504,6 +3504,96 @@ func TestStreamableStateful_RejectsNewProtocol(t *testing.T) {
if resp.StatusCode != http.StatusBadRequest {
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") {
t.Errorf("body = %q, want a message mentioning 'stateless'", respBody)
}
Expand Down
Loading