You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On a stateful StreamableHTTPHandler (the default, Stateless: false), every server/discover request creates a ServerSession that is registered in StreamableHTTPHandler.sessions and is never closed.
Because a v1.7.0 client always sends server/discover before falling back to initialize, every client connection leaks exactly one ServerSession, permanently — even when the client shuts down cleanly with a DELETE.
The leaked session is unreachable: the discover response carries no Mcp-Session-Id, so no client can ever DELETE it. If StreamableHTTPOptions.SessionTimeout is left at its zero value (no idle eviction), nothing reclaims it and server memory grows without bound.
Reproduced on both v1.7.0 and current main (0c004ee).
Reproduction
Self-contained, depends only on the SDK. Each client is closed cleanly via session.Close().
200, supportedVersions: ["2025-11-25", …], no Mcp-Session-Id
2
initialize (falls back to 2025-11-25)
200, Mcp-Session-Id: YFTC…
3
notifications/initialized
202
After step 1 the server already holds one session that the client has no way to address.
Root cause
server/discover is deliberately exempt from the stateful rejection of new-protocol requests (mcp/streamable.go:1516):
// server/discover is exempt from the stateful// 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 ...
However, the exempted request still flows through the ordinary session-creating path in serveStatefulPOST. Two things then conspire:
sessionID = server.opts.GetSessionID() returns a non-empty ID, so the request does not take the ephemeral branch (if sessionID == "" → defer session.Close()). A full session is created and stored in h.sessions. Yet the discover response never surfaces Mcp-Session-Id to the client, so the session is permanently unaddressable — DELETE is impossible.
The end-of-function safety net does not fire:
deferfunc() {
// If initialization failed, clean up the session (#578).ifsession.InitializeParams() ==nil {
session.Close()
}
}()
The net effect is a session that is neither addressable nor reclaimable.
Behavior matrix
Sending server/discover five times directly:
Configuration
Sessions afterwards
SessionTimeout: 0 (zero value)
5 after 5 requests; never decreases
SessionTimeout: 2s
0 after 3s (reclaimed by the idle timer)
Stateless: true
0 (no session is ever created)
So the leak is confined to stateful servers, and SessionTimeout only masks it — sessions still accumulate between sweeps, and the zero value provides no protection at all.
Suggested fix
A discover-only request establishes no session by definition, so it should take the existing ephemeral path (connect, serve, defer session.Close()) rather than the addressable-session path.
diff --git a/mcp/streamable.go b/mcp/streamable.go--- a/mcp/streamable.go+++ b/mcp/streamable.go@@ -593,12 +593,34 @@ func (h *StreamableHTTPHandler) serveStatefulGET(w http.ResponseWriter, req *htt
return
}
sessInfo.transport.ServeHTTP(w, req)
}
+// isDiscoverOnlyRequest reports whether every JSON-RPC request in the body is+// server/discover. The body is restored so downstream handlers can re-read it.+func isDiscoverOnlyRequest(req *http.Request) bool {+ body, err := io.ReadAll(req.Body)+ req.Body.Close()+ req.Body = io.NopCloser(bytes.NewBuffer(body))+ if err != nil {+ return false+ }+ msgs, _, err := readBatch(body)+ if err != nil || len(msgs) == 0 {+ return false+ }+ for _, msg := range msgs {+ r, ok := msg.(*jsonrpc.Request)+ if !ok || r.Method != methodDiscover {+ return false+ }+ }+ return true+}+
// serveStatefulDELETE handles DELETE requests for session termination.
// DELETE requires a valid Mcp-Session-Id header.
func (h *StreamableHTTPHandler) serveStatefulDELETE(w http.ResponseWriter, req *http.Request) {
sessionID := req.Header.Get(sessionIDHeader)
if sessionID == "" {
http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest)
@@ -649,12 +671,22 @@ func (h *StreamableHTTPHandler) serveStatefulPOST(w http.ResponseWriter, req *ht
if server == nil {
http.Error(w, "no server available", http.StatusBadRequest)
return
}
sessionID = server.opts.GetSessionID()
+ // server/discover is exempt from the new-protocol stateful rejection in+ // streamableServerConn.ServeHTTP, so it can reach a stateful server without+ // ever establishing a session. It must not create an addressable session:+ // the response carries no Mcp-Session-Id, so the client could never DELETE+ // it, and its InitializeParams are populated from _meta, which defeats the+ // "initialization failed" cleanup below. Serve it ephemerally instead.+ if isDiscoverOnlyRequest(req) {+ sessionID = ""+ }+
transport := &StreamableServerTransport{
SessionID: sessionID,
Stateless: false,
EventStore: h.opts.EventStore,
jsonResponse: h.opts.JSONResponse,
logger: h.opts.Logger,
Verification of the patch (applied to main @ 0c004ee)
The reproduction above prints FINAL leaked sessions = 0, and sessions stays at 1 while a client is connected.
go test -count=1 ./mcp/... ./internal/... gives identical results with and without the patch. My environment (Windows) has one pre-existing failure, TestServerConformance/resources.txtar, caused by CRLF checkout of the txtar fixture ("blob": "Q29udGVudHMNCg==" vs "Q29udGVudHMK"); it fails on unmodified main too and is unrelated to this change. No other failures.
A downstream stateful server (which asserts that one session is reused across requests) goes from failing to passing with no changes on its side.
I am happy to open a PR with this change plus a regression test if the approach looks right. An alternative, if discover should never reach session creation at all, is to dispatch it earlier in serveStatefulPOST, before getServer/GetSessionID; I went with the minimal change that reuses the existing ephemeral machinery.
Environment
github.com/modelcontextprotocol/go-sdk v1.7.0, and main at 0c004ee
A Renovate PR bumping a downstream project from v1.6.1 to v1.7.0 turned a passing test red; the test asserts that a stateful handler reuses a single session across requests. Downstream write-up: scottlz0310/review-raven#109
Summary
On a stateful
StreamableHTTPHandler(the default,Stateless: false), everyserver/discoverrequest creates aServerSessionthat is registered inStreamableHTTPHandler.sessionsand is never closed.Because a v1.7.0 client always sends
server/discoverbefore falling back toinitialize, every client connection leaks exactly oneServerSession, permanently — even when the client shuts down cleanly with a DELETE.The leaked session is unreachable: the discover response carries no
Mcp-Session-Id, so no client can ever DELETE it. IfStreamableHTTPOptions.SessionTimeoutis left at its zero value (no idle eviction), nothing reclaims it and server memory grows without bound.Reproduced on both
v1.7.0and currentmain(0c004ee).Reproduction
Self-contained, depends only on the SDK. Each client is closed cleanly via
session.Close().Actual output (v1.7.0 and main @ 0c004ee)
Expected
FINAL leaked sessions = 0. A cleanly closed client should leave no server session behind.Wire trace
Captured by wrapping the handler with a logging
http.Handler:server/discover(Mcp-Protocol-Version: 2026-07-28)supportedVersions: ["2025-11-25", …], noMcp-Session-Idinitialize(falls back to2025-11-25)Mcp-Session-Id: YFTC…notifications/initializedAfter step 1 the server already holds one session that the client has no way to address.
Root cause
server/discoveris deliberately exempt from the stateful rejection of new-protocol requests (mcp/streamable.go:1516):However, the exempted request still flows through the ordinary session-creating path in
serveStatefulPOST. Two things then conspire:sessionID = server.opts.GetSessionID()returns a non-empty ID, so the request does not take the ephemeral branch (if sessionID == ""→defer session.Close()). A full session is created and stored inh.sessions. Yet the discover response never surfacesMcp-Session-Idto the client, so the session is permanently unaddressable — DELETE is impossible.The end-of-function safety net does not fire:
Since mcp: do not exclude Notifications from carrying InitializeParams in Meta #1049, a discover request's
_metapopulatesInitializeParams, soInitializeParams() != niland the cleanup introduced for Streamable HTTP: don't allocate resources for failed connections #578 is skipped.The net effect is a session that is neither addressable nor reclaimable.
Behavior matrix
Sending
server/discoverfive times directly:SessionTimeout: 0(zero value)SessionTimeout: 2sStateless: trueSo the leak is confined to stateful servers, and
SessionTimeoutonly masks it — sessions still accumulate between sweeps, and the zero value provides no protection at all.Suggested fix
A discover-only request establishes no session by definition, so it should take the existing ephemeral path (connect, serve,
defer session.Close()) rather than the addressable-session path.Verification of the patch (applied to main @ 0c004ee)
FINAL leaked sessions = 0, andsessionsstays at 1 while a client is connected.go test -count=1 ./mcp/... ./internal/...gives identical results with and without the patch. My environment (Windows) has one pre-existing failure,TestServerConformance/resources.txtar, caused by CRLF checkout of the txtar fixture ("blob": "Q29udGVudHMNCg=="vs"Q29udGVudHMK"); it fails on unmodifiedmaintoo and is unrelated to this change. No other failures.I am happy to open a PR with this change plus a regression test if the approach looks right. An alternative, if discover should never reach session creation at all, is to dispatch it earlier in
serveStatefulPOST, beforegetServer/GetSessionID; I went with the minimal change that reuses the existing ephemeral machinery.Environment
github.com/modelcontextprotocol/go-sdkv1.7.0, andmainat0c004eePossibly related (none appear to cover this)
server/discoveron stateful servers responding without2026-07-28and the client falling back toinitialize. Same code path, but about advertised versions and the fallback, not session lifetime.ServerSessionvalues retained inStreamableHTTPHandler.sessions.How this surfaced
A Renovate PR bumping a downstream project from v1.6.1 to v1.7.0 turned a passing test red; the test asserts that a stateful handler reuses a single session across requests. Downstream write-up: scottlz0310/review-raven#109