Skip to content

Commit 4beb079

Browse files
committed
feat: enforce SEP-2575 protocol version header validation and restrict stateless protocol to stateless HTTP servers
1 parent 222d145 commit 4beb079

2 files changed

Lines changed: 211 additions & 5 deletions

File tree

mcp/streamable.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,7 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter
395395
// the >= 2026-06-30 sessionless protocol (SEP-2575).
396396
//
397397
// For old-protocol requests, default session state is synthesized so that
398-
// the session's init gate doesn't reject the request. For new-protocol
399-
// requests, no state is synthesized: the request carries its identity in
400-
// `_meta`.
398+
// the session's init gate doesn't reject the request.
401399
//
402400
// It is used for both stateless servers and stateful servers with no session ID.
403401
//
@@ -1297,6 +1295,7 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
12971295
tokenInfo := auth.TokenInfoFromContext(req.Context())
12981296
isInitialize := false
12991297
var initializeProtocolVersion string
1298+
headerVersion := req.Header.Get(protocolVersionHeader)
13001299
for _, msg := range incoming {
13011300
if jreq, ok := msg.(*jsonrpc.Request); ok {
13021301
// Preemptively check that this is a valid request, so that we can fail
@@ -1314,6 +1313,39 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques
13141313
initializeProtocolVersion = params.ProtocolVersion
13151314
}
13161315
}
1316+
// SEP-2575: requests carrying `_meta.protocolVersion` require the
1317+
// Mcp-Protocol-Version HTTP header to be present and to match the
1318+
// per-request `_meta.protocolVersion` value.
1319+
//
1320+
// Per the SDK design doc (design/stateless.md), the new (>=
1321+
// 2026-06-30) protocol is supported on the HTTP transport only
1322+
// when [StreamableHTTPOptions.Stateless] is true.
1323+
if meta := extractRequestMeta(jreq.Params); meta != nil {
1324+
if metaVersion, ok := meta[MetaKeyProtocolVersion].(string); ok {
1325+
if !c.stateless {
1326+
http.Error(w, fmt.Sprintf(
1327+
"Bad Request: protocol version %q is only supported on stateless HTTP servers (set StreamableHTTPOptions.Stateless = true)",
1328+
metaVersion),
1329+
http.StatusBadRequest)
1330+
return
1331+
}
1332+
if headerVersion == "" {
1333+
http.Error(w, fmt.Sprintf(
1334+
"Bad Request: %s header is required for requests carrying %q",
1335+
protocolVersionHeader, MetaKeyProtocolVersion),
1336+
http.StatusBadRequest)
1337+
return
1338+
}
1339+
if headerVersion != metaVersion {
1340+
http.Error(w, fmt.Sprintf(
1341+
"Bad Request: %s header %q does not match request %s %q",
1342+
protocolVersionHeader, headerVersion,
1343+
MetaKeyProtocolVersion, metaVersion),
1344+
http.StatusBadRequest)
1345+
return
1346+
}
1347+
}
1348+
}
13171349
// Include metadata for all requests (including notifications).
13181350
jreq.Extra = &RequestExtra{
13191351
TokenInfo: tokenInfo,

mcp/streamable_test.go

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3236,7 +3236,7 @@ func newProtocolBody(t *testing.T, toolName string, args any) []byte {
32363236
return body
32373237
}
32383238

3239-
func TestEphemeralConnectOpts_NewProtocol(t *testing.T) {
3239+
func TestEphemeralConnectOpts(t *testing.T) {
32403240
mkReq := func(body []byte) *http.Request {
32413241
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
32423242
r.Header.Set("Content-Type", "application/json")
@@ -3262,7 +3262,7 @@ func TestEphemeralConnectOpts_NewProtocol(t *testing.T) {
32623262
}
32633263
})
32643264

3265-
t.Run("old-protocol request: synthetic state preserved", func(t *testing.T) {
3265+
t.Run("old-protocol request: synthetic state populated", func(t *testing.T) {
32663266
body, err := json.Marshal(map[string]any{
32673267
"jsonrpc": "2.0",
32683268
"id": 1,
@@ -3310,6 +3310,62 @@ func TestEphemeralConnectOpts_NewProtocol(t *testing.T) {
33103310
})
33113311
}
33123312

3313+
// TestServePOST_NewProtocolHeaderCrossCheck verifies that the SEP-2575
3314+
// header/body cross-check runs inside streamableServerConn.servePOST, which
3315+
// is the single chokepoint reached by every POST regardless of stateful or
3316+
// stateless mode.
3317+
func TestServePOST_NewProtocolHeaderCrossCheck(t *testing.T) {
3318+
mcpServer := NewServer(testImpl, nil)
3319+
AddTool(mcpServer, &Tool{Name: "noop"},
3320+
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3321+
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3322+
})
3323+
handler := NewStreamableHTTPHandler(
3324+
func(*http.Request) *Server { return mcpServer },
3325+
&StreamableHTTPOptions{Stateless: true},
3326+
)
3327+
httpServer := httptest.NewServer(handler)
3328+
defer httpServer.Close()
3329+
3330+
mkReq := func(headerVersion string) *http.Request {
3331+
body := newProtocolBody(t, "noop", struct{}{})
3332+
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3333+
if err != nil {
3334+
t.Fatal(err)
3335+
}
3336+
req.Header.Set("Content-Type", "application/json")
3337+
req.Header.Set("Accept", "application/json, text/event-stream")
3338+
if headerVersion != "" {
3339+
req.Header.Set(protocolVersionHeader, headerVersion)
3340+
}
3341+
return req
3342+
}
3343+
3344+
t.Run("mismatched header: 400", func(t *testing.T) {
3345+
resp, err := http.DefaultClient.Do(mkReq("2025-06-18"))
3346+
if err != nil {
3347+
t.Fatal(err)
3348+
}
3349+
defer resp.Body.Close()
3350+
if resp.StatusCode != http.StatusBadRequest {
3351+
body, _ := io.ReadAll(resp.Body)
3352+
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body)
3353+
}
3354+
})
3355+
3356+
t.Run("missing header: 400", func(t *testing.T) {
3357+
resp, err := http.DefaultClient.Do(mkReq(""))
3358+
if err != nil {
3359+
t.Fatal(err)
3360+
}
3361+
defer resp.Body.Close()
3362+
if resp.StatusCode != http.StatusBadRequest {
3363+
body, _ := io.ReadAll(resp.Body)
3364+
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, body)
3365+
}
3366+
})
3367+
}
3368+
33133369
// statelessHandlerCapture builds a stateless server with a single tool whose
33143370
// handler captures everything we want to assert about the per-request view of
33153371
// the session and the new-protocol accessors.
@@ -3322,6 +3378,13 @@ type statelessHandlerCapture struct {
33223378
}
33233379

33243380
func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) {
3381+
// SEP-2575: the MCP-Protocol-Version header is mandatory for new-protocol
3382+
// requests and must be a supported version. The 2026-06-30 version is
3383+
// not yet in the global list, so register it for the duration of the test.
3384+
orig := supportedProtocolVersions
3385+
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
3386+
t.Cleanup(func() { supportedProtocolVersions = orig })
3387+
33253388
capture := &statelessHandlerCapture{}
33263389
mcpServer := NewServer(testImpl, nil)
33273390
AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"},
@@ -3349,6 +3412,7 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) {
33493412
}
33503413
httpReq.Header.Set("Content-Type", "application/json")
33513414
httpReq.Header.Set("Accept", "application/json, text/event-stream")
3415+
httpReq.Header.Set(protocolVersionHeader, protocolVersion20260630)
33523416

33533417
resp, err := http.DefaultClient.Do(httpReq)
33543418
if err != nil {
@@ -3375,3 +3439,113 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) {
33753439
t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities)
33763440
}
33773441
}
3442+
3443+
// TestStreamableStateful_RejectsNewProtocol verifies that a stateful HTTP
3444+
// server rejects requests carrying _meta.protocolVersion (i.e. >= 2026-06-30
3445+
// requests) with HTTP 400. The new protocol is
3446+
// supported on HTTP only when StreamableHTTPOptions.Stateless=true.
3447+
func TestStreamableStateful_RejectsNewProtocol(t *testing.T) {
3448+
// Make 2026-06-30 a "known" version so that the request reaches servePOST
3449+
// (otherwise the early header validation at ServeHTTP rejects it).
3450+
orig := supportedProtocolVersions
3451+
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
3452+
t.Cleanup(func() { supportedProtocolVersions = orig })
3453+
3454+
server := NewServer(testImpl, nil)
3455+
AddTool(server, &Tool{Name: "noop"},
3456+
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3457+
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3458+
})
3459+
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, nil)
3460+
httpServer := httptest.NewServer(handler)
3461+
defer httpServer.Close()
3462+
3463+
// Initialize a legacy session first.
3464+
initBody := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}`)
3465+
initReq, err := http.NewRequest(http.MethodPost, httpServer.URL, initBody)
3466+
if err != nil {
3467+
t.Fatal(err)
3468+
}
3469+
initReq.Header.Set("Content-Type", "application/json")
3470+
initReq.Header.Set("Accept", "application/json, text/event-stream")
3471+
initResp, err := http.DefaultClient.Do(initReq)
3472+
if err != nil {
3473+
t.Fatal(err)
3474+
}
3475+
io.Copy(io.Discard, initResp.Body)
3476+
initResp.Body.Close()
3477+
sessionID := initResp.Header.Get(sessionIDHeader)
3478+
if sessionID == "" {
3479+
t.Fatalf("initialize response missing %s header", sessionIDHeader)
3480+
}
3481+
3482+
// Drive the existing session with a new-protocol request whose header and
3483+
// body agree. The cross-check passes; the stateful-rejection check fires.
3484+
body := newProtocolBody(t, "noop", struct{}{})
3485+
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3486+
if err != nil {
3487+
t.Fatal(err)
3488+
}
3489+
req.Header.Set("Content-Type", "application/json")
3490+
req.Header.Set("Accept", "application/json, text/event-stream")
3491+
req.Header.Set(sessionIDHeader, sessionID)
3492+
req.Header.Set(protocolVersionHeader, protocolVersion20260630)
3493+
req.Header.Set(methodHeader, "tools/call")
3494+
req.Header.Set(nameHeader, "noop")
3495+
3496+
resp, err := http.DefaultClient.Do(req)
3497+
if err != nil {
3498+
t.Fatal(err)
3499+
}
3500+
defer resp.Body.Close()
3501+
respBody, _ := io.ReadAll(resp.Body)
3502+
if resp.StatusCode != http.StatusBadRequest {
3503+
t.Fatalf("status = %d, want 400; body = %s", resp.StatusCode, respBody)
3504+
}
3505+
if !strings.Contains(string(respBody), "stateless") {
3506+
t.Errorf("body = %q, want a message mentioning 'stateless'", respBody)
3507+
}
3508+
}
3509+
3510+
// TestStreamableStateless_AcceptsNewProtocol is the positive control:
3511+
// confirms that a stateless server still accepts new-protocol requests
3512+
// (the rejection in TestStreamableStateful_RejectsNewProtocol must not
3513+
// fire on Stateless: true).
3514+
func TestStreamableStateless_AcceptsNewProtocol(t *testing.T) {
3515+
orig := supportedProtocolVersions
3516+
supportedProtocolVersions = append(slices.Clone(orig), protocolVersion20260630)
3517+
t.Cleanup(func() { supportedProtocolVersions = orig })
3518+
3519+
server := NewServer(testImpl, nil)
3520+
AddTool(server, &Tool{Name: "noop"},
3521+
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3522+
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3523+
})
3524+
handler := NewStreamableHTTPHandler(
3525+
func(*http.Request) *Server { return server },
3526+
&StreamableHTTPOptions{Stateless: true},
3527+
)
3528+
httpServer := httptest.NewServer(handler)
3529+
defer httpServer.Close()
3530+
3531+
body := newProtocolBody(t, "noop", struct{}{})
3532+
req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3533+
if err != nil {
3534+
t.Fatal(err)
3535+
}
3536+
req.Header.Set("Content-Type", "application/json")
3537+
req.Header.Set("Accept", "application/json, text/event-stream")
3538+
req.Header.Set(protocolVersionHeader, protocolVersion20260630)
3539+
req.Header.Set(methodHeader, "tools/call")
3540+
req.Header.Set(nameHeader, "noop")
3541+
3542+
resp, err := http.DefaultClient.Do(req)
3543+
if err != nil {
3544+
t.Fatal(err)
3545+
}
3546+
defer resp.Body.Close()
3547+
if resp.StatusCode != http.StatusOK {
3548+
respBody, _ := io.ReadAll(resp.Body)
3549+
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
3550+
}
3551+
}

0 commit comments

Comments
 (0)