diff --git a/docs/mcpgodebug.md b/docs/mcpgodebug.md index 4562821b..39a76a8b 100644 --- a/docs/mcpgodebug.md +++ b/docs/mcpgodebug.md @@ -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. diff --git a/internal/docs/mcpgodebug.src.md b/internal/docs/mcpgodebug.src.md index c009a7c5..efb02da2 100644 --- a/internal/docs/mcpgodebug.src.md +++ b/internal/docs/mcpgodebug.src.md @@ -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. diff --git a/mcp/streamable.go b/mcp/streamable.go index 2799d146..db81a37b 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -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. @@ -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 == "" { diff --git a/mcp/streamable_test.go b/mcp/streamable_test.go index 726733d5..18d6b5fd 100644 --- a/mcp/streamable_test.go +++ b/mcp/streamable_test.go @@ -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) }