From 6b15e93c5d99dd4479834f627a28d140f2f404b5 Mon Sep 17 00:00:00 2001 From: Yosi Zelensky Date: Mon, 15 Jun 2026 16:56:03 +0300 Subject: [PATCH] fix: stateless transport should ignore unmatched cancellations and reject DELETE with 405 In stateless mode two normal client messages are rejected with HTTP 400: 1. notifications/cancelled -> 400 "lack session": handleNotifyWithCancelled fails the GetSession("") lookup and returns ErrLackSession. Per the MCP cancellation spec, a cancellation for an unknown/unmatchable request is fire-and-forget and SHOULD be ignored. The function already returns nil for an unknown requestID; do the same when there is no session to look it up in. 2. DELETE -> 400 "Missing session ID": a stateless server issues no session id, so clients have none to send. The transport spec's response for a server that does not support session termination is 405, not 400 (400 is specified only for servers that require a session id). Mirrors handleGet's stateless 405. Both paths are covered by new tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/cancelled_stateless_test.go | 29 +++++++++++++++++++++ server/handle.go | 5 +++- transport/streamable_http_server.go | 5 ++++ transport/streamable_http_stateless_test.go | 27 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 server/cancelled_stateless_test.go create mode 100644 transport/streamable_http_stateless_test.go diff --git a/server/cancelled_stateless_test.go b/server/cancelled_stateless_test.go new file mode 100644 index 0000000..b8011a9 --- /dev/null +++ b/server/cancelled_stateless_test.go @@ -0,0 +1,29 @@ +package server + +import ( + "encoding/json" + "io" + "testing" + + "github.com/ThinkInAIXYZ/go-mcp/transport" +) + +// A cancellation that cannot be matched to an in-progress request (e.g. in +// stateless mode, where there is no session to look it up in) is fire-and-forget +// per the MCP cancellation spec and must be ignored, not answered with an error. +func TestStatelessCancelledNotificationIgnored(t *testing.T) { + reader, _ := io.Pipe() + _, writer := io.Pipe() + + s, err := NewServer(transport.NewMockServerTransport(reader, writer)) + if err != nil { + t.Fatalf("NewServer: %v", err) + } + + params := json.RawMessage(`{"requestId":"123","reason":"Request timed out"}`) + + // sessionID "" => no session (stateless); must be ignored, returning nil. + if err := s.handleNotifyWithCancelled("", params); err != nil { + t.Fatalf("stateless cancelled notification: got err %v, want nil (ignored)", err) + } +} diff --git a/server/handle.go b/server/handle.go index f973375..92f9fad 100644 --- a/server/handle.go +++ b/server/handle.go @@ -300,7 +300,10 @@ func (server *Server) handleNotifyWithCancelled(sessionID string, rawParams json s, ok := server.sessionManager.GetSession(sessionID) if !ok { - return pkg.ErrLackSession + // No session to match the request against (e.g. stateless mode). Per the + // cancellation spec a notification referencing an unknown request is + // fire-and-forget and must be ignored, not answered with an error. + return nil } cancel, ok := s.GetClientReqID2cancelFunc().Get(fmt.Sprint(params.RequestID)) diff --git a/transport/streamable_http_server.go b/transport/streamable_http_server.go index 502e2ae..cd38f91 100644 --- a/transport/streamable_http_server.go +++ b/transport/streamable_http_server.go @@ -360,6 +360,11 @@ func (t *streamableHTTPServerTransport) handleGet(w http.ResponseWriter, r *http } func (t *streamableHTTPServerTransport) handleDelete(w http.ResponseWriter, r *http.Request) { + if t.stateMode == Stateless { + t.writeError(w, http.StatusMethodNotAllowed, "server is stateless, does not support session termination") + return + } + sessionID := r.Header.Get("Mcp-Session-Id") if sessionID == "" { t.writeError(w, http.StatusBadRequest, "Missing session ID") diff --git a/transport/streamable_http_stateless_test.go b/transport/streamable_http_stateless_test.go new file mode 100644 index 0000000..82d2798 --- /dev/null +++ b/transport/streamable_http_stateless_test.go @@ -0,0 +1,27 @@ +package transport + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// In stateless mode there are no sessions to terminate, so a DELETE to the MCP +// endpoint must respond 405 Method Not Allowed (per the Streamable HTTP session +// management spec), rather than 400 "Missing session ID". +func TestStatelessDeleteReturnsMethodNotAllowed(t *testing.T) { + _, handler, err := NewStreamableHTTPServerTransportAndHandler( + WithStreamableHTTPServerTransportAndHandlerOptionStateMode(Stateless), + ) + if err != nil { + t.Fatalf("NewStreamableHTTPServerTransportAndHandler: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, "/mcp", nil) + w := httptest.NewRecorder() + handler.HandleMCP().ServeHTTP(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("stateless DELETE: got status %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +}