Skip to content

Commit 222d145

Browse files
committed
refactor: remove legacy stateless session handling logic and associated regression tests
1 parent 98b2a44 commit 222d145

2 files changed

Lines changed: 2 additions & 185 deletions

File tree

mcp/streamable.go

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,6 @@ func (h *StreamableHTTPHandler) serveStateless(w http.ResponseWriter, req *http.
343343
return
344344
}
345345

346-
// Peek at the body to determine whether this is a new-protocol request.
347-
// New-protocol requests are fully sessionless: even under the legacy
348-
// `allowsessionsinstateless` compat flag, we must not read or generate
349-
// a session ID for them.
350346
connectOpts, usesNewProtocol, err := h.ephemeralConnectOpts(req)
351347
if err != nil {
352348
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -401,15 +397,12 @@ func (h *StreamableHTTPHandler) serveStatelessLegacyDELETE(w http.ResponseWriter
401397
// For old-protocol requests, default session state is synthesized so that
402398
// the session's init gate doesn't reject the request. For new-protocol
403399
// requests, no state is synthesized: the request carries its identity in
404-
// `_meta`, and [ServerSession.InitializeParams] returning nil is the
405-
// migration signal that handlers should read identity via the per-request
406-
// accessors on [ServerRequest].
400+
// `_meta`.
407401
//
408402
// It is used for both stateless servers and stateful servers with no session ID.
409403
//
410404
// The returned usesNewProtocol bool reports whether any request in the body
411-
// carried `_meta.protocolVersion`. Callers may use it to suppress legacy
412-
// session-handling behavior (e.g., reading Mcp-Session-Id) for such requests.
405+
// carried `_meta.protocolVersion`.
413406
func (h *StreamableHTTPHandler) ephemeralConnectOpts(req *http.Request) (opts *ServerSessionOptions, usesNewProtocol bool, err error) {
414407
protocolVersion := protocolVersionFromContext(req.Context())
415408
if protocolVersion == "" {

mcp/streamable_test.go

Lines changed: 0 additions & 176 deletions
Original file line numberDiff line numberDiff line change
@@ -3375,179 +3375,3 @@ func TestStreamableStateless_NewProtocolSession_NoFakeInit(t *testing.T) {
33753375
t.Errorf("req.ClientCapabilities() = %+v, want non-nil Sampling", capture.reqClientCapabilities)
33763376
}
33773377
}
3378-
3379-
func TestStreamableStateless_OldProtocolUnchanged(t *testing.T) {
3380-
// Regression: an old-protocol request to a stateless server must still
3381-
// observe a non-nil (synthetic) InitializeParams on the session, so
3382-
// existing handlers and the init gate continue to work.
3383-
capture := &statelessHandlerCapture{}
3384-
mcpServer := NewServer(testImpl, nil)
3385-
AddTool(mcpServer, &Tool{Name: "capture", Description: "captures request info"},
3386-
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3387-
capture.mu.Lock()
3388-
defer capture.mu.Unlock()
3389-
capture.sessionInitParams = req.Session.InitializeParams()
3390-
capture.reqProtocolVersion = req.ProtocolVersion()
3391-
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3392-
})
3393-
3394-
handler := NewStreamableHTTPHandler(
3395-
func(*http.Request) *Server { return mcpServer },
3396-
&StreamableHTTPOptions{Stateless: true},
3397-
)
3398-
httpServer := httptest.NewServer(handler)
3399-
defer httpServer.Close()
3400-
3401-
body, err := json.Marshal(map[string]any{
3402-
"jsonrpc": "2.0",
3403-
"id": 1,
3404-
"method": "tools/call",
3405-
"params": map[string]any{"name": "capture", "arguments": map[string]any{}},
3406-
})
3407-
if err != nil {
3408-
t.Fatal(err)
3409-
}
3410-
httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3411-
if err != nil {
3412-
t.Fatal(err)
3413-
}
3414-
httpReq.Header.Set("Content-Type", "application/json")
3415-
httpReq.Header.Set("Accept", "application/json, text/event-stream")
3416-
httpReq.Header.Set("MCP-Protocol-Version", protocolVersion20250618)
3417-
3418-
resp, err := http.DefaultClient.Do(httpReq)
3419-
if err != nil {
3420-
t.Fatal(err)
3421-
}
3422-
defer resp.Body.Close()
3423-
if resp.StatusCode != http.StatusOK {
3424-
respBody, _ := io.ReadAll(resp.Body)
3425-
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
3426-
}
3427-
3428-
capture.mu.Lock()
3429-
defer capture.mu.Unlock()
3430-
if capture.sessionInitParams == nil {
3431-
t.Errorf("Session.InitializeParams() = nil, want synthetic non-nil for old-protocol session")
3432-
}
3433-
if got, want := capture.reqProtocolVersion, protocolVersion20250618; got != want {
3434-
t.Errorf("req.ProtocolVersion() = %q (via synthetic session), want %q from MCP-Protocol-Version header", got, want)
3435-
}
3436-
}
3437-
3438-
func TestStreamableStateless_LegacySessionIgnoredForNewProtocol(t *testing.T) {
3439-
// Under the legacy `allowsessionsinstateless=1` compat flag, stateless
3440-
// servers normally read Mcp-Session-Id from the request and call
3441-
// GetSessionID. For new-protocol requests, those legacy behaviors must
3442-
// be skipped: the session is fully sessionless.
3443-
prev := allowsessionsinstateless
3444-
allowsessionsinstateless = "1"
3445-
t.Cleanup(func() { allowsessionsinstateless = prev })
3446-
3447-
var capturedSessionID string
3448-
mcpServer := NewServer(testImpl, nil)
3449-
AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"},
3450-
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3451-
capturedSessionID = req.Session.ID()
3452-
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3453-
})
3454-
3455-
getSessionIDCalled := false
3456-
handler := NewStreamableHTTPHandler(
3457-
func(*http.Request) *Server { return mcpServer },
3458-
&StreamableHTTPOptions{Stateless: true},
3459-
)
3460-
// Patch the server's GetSessionID to detect whether it was consulted.
3461-
mcpServer.opts.GetSessionID = func() string {
3462-
getSessionIDCalled = true
3463-
return "should-not-be-used"
3464-
}
3465-
httpServer := httptest.NewServer(handler)
3466-
defer httpServer.Close()
3467-
3468-
body := newProtocolBody(t, "capture", struct{}{})
3469-
httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3470-
if err != nil {
3471-
t.Fatal(err)
3472-
}
3473-
httpReq.Header.Set("Content-Type", "application/json")
3474-
httpReq.Header.Set("Accept", "application/json, text/event-stream")
3475-
// Explicitly send a session-ID header that the legacy compat path would
3476-
// normally honor. For new-protocol requests it must be ignored.
3477-
httpReq.Header.Set(sessionIDHeader, "legacy-client-supplied-id")
3478-
3479-
resp, err := http.DefaultClient.Do(httpReq)
3480-
if err != nil {
3481-
t.Fatal(err)
3482-
}
3483-
defer resp.Body.Close()
3484-
if resp.StatusCode != http.StatusOK {
3485-
respBody, _ := io.ReadAll(resp.Body)
3486-
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
3487-
}
3488-
3489-
if capturedSessionID != "" {
3490-
t.Errorf("Session.ID() = %q, want empty (new-protocol request must ignore Mcp-Session-Id)", capturedSessionID)
3491-
}
3492-
if getSessionIDCalled {
3493-
t.Errorf("server.opts.GetSessionID was consulted for a new-protocol request; want it to be skipped")
3494-
}
3495-
if echoed := resp.Header.Get(sessionIDHeader); echoed != "" {
3496-
t.Errorf("response %s header = %q, want empty for new-protocol request", sessionIDHeader, echoed)
3497-
}
3498-
}
3499-
3500-
func TestStreamableStateless_LegacySessionHonoredForOldProtocol(t *testing.T) {
3501-
// Regression: under `allowsessionsinstateless=1`, an OLD-protocol request
3502-
// must still see the legacy session-handling behavior (Mcp-Session-Id
3503-
// honored, GetSessionID consulted) so existing deployments don't break.
3504-
prev := allowsessionsinstateless
3505-
allowsessionsinstateless = "1"
3506-
t.Cleanup(func() { allowsessionsinstateless = prev })
3507-
3508-
var capturedSessionID string
3509-
mcpServer := NewServer(testImpl, nil)
3510-
AddTool(mcpServer, &Tool{Name: "capture", Description: "captures session id"},
3511-
func(ctx context.Context, req *CallToolRequest, args struct{}) (*CallToolResult, any, error) {
3512-
capturedSessionID = req.Session.ID()
3513-
return &CallToolResult{Content: []Content{&TextContent{Text: "ok"}}}, nil, nil
3514-
})
3515-
3516-
handler := NewStreamableHTTPHandler(
3517-
func(*http.Request) *Server { return mcpServer },
3518-
&StreamableHTTPOptions{Stateless: true},
3519-
)
3520-
httpServer := httptest.NewServer(handler)
3521-
defer httpServer.Close()
3522-
3523-
body, err := json.Marshal(map[string]any{
3524-
"jsonrpc": "2.0",
3525-
"id": 1,
3526-
"method": "tools/call",
3527-
"params": map[string]any{"name": "capture", "arguments": map[string]any{}},
3528-
})
3529-
if err != nil {
3530-
t.Fatal(err)
3531-
}
3532-
httpReq, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(body))
3533-
if err != nil {
3534-
t.Fatal(err)
3535-
}
3536-
httpReq.Header.Set("Content-Type", "application/json")
3537-
httpReq.Header.Set("Accept", "application/json, text/event-stream")
3538-
httpReq.Header.Set(sessionIDHeader, "old-protocol-session-id")
3539-
3540-
resp, err := http.DefaultClient.Do(httpReq)
3541-
if err != nil {
3542-
t.Fatal(err)
3543-
}
3544-
defer resp.Body.Close()
3545-
if resp.StatusCode != http.StatusOK {
3546-
respBody, _ := io.ReadAll(resp.Body)
3547-
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, respBody)
3548-
}
3549-
3550-
if capturedSessionID != "old-protocol-session-id" {
3551-
t.Errorf("Session.ID() = %q, want %q (legacy header should be honored for old-protocol requests)", capturedSessionID, "old-protocol-session-id")
3552-
}
3553-
}

0 commit comments

Comments
 (0)