Skip to content

Streamable HTTP: server/discover leaks a ServerSession on stateful servers #1136

Description

@scottlz0310-user

Summary

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().

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func count(s *mcp.Server) int {
	n := 0
	for range s.Sessions() {
		n++
	}
	return n
}

func main() {
	srv := mcp.NewServer(&mcp.Implementation{Name: "repro", Version: "1.0.0"}, nil)
	mcp.AddTool(srv, &mcp.Tool{Name: "noop", Description: "noop"},
		func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, any, error) {
			return &mcp.CallToolResult{}, nil, nil
		})

	// Stateful (default). SessionTimeout unset => no idle eviction.
	h := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil)
	ts := httptest.NewServer(h)
	defer ts.Close()

	ctx := context.Background()
	for i := 1; i <= 3; i++ {
		c := mcp.NewClient(&mcp.Implementation{Name: "c", Version: "1.0.0"}, nil)
		sess, err := c.Connect(ctx, &mcp.StreamableClientTransport{
			Endpoint: ts.URL, DisableStandaloneSSE: true, MaxRetries: -1,
		}, nil)
		if err != nil {
			panic(err)
		}
		if _, err := sess.ListTools(ctx, nil); err != nil {
			panic(err)
		}
		fmt.Printf("client %d connected: negotiated=%s sessions=%d\n",
			i, sess.InitializeResult().ProtocolVersion, count(srv))
		if err := sess.Close(); err != nil { // clean shutdown, sends DELETE
			panic(err)
		}
		fmt.Printf("client %d closed   : sessions=%d (want 0)\n", i, count(srv))
	}
	fmt.Printf("\nFINAL leaked sessions = %d (want 0)\n", count(srv))
}

Actual output (v1.7.0 and main @ 0c004ee)

client 1 connected: negotiated=2025-11-25 sessions=2
client 1 closed   : sessions=1 (want 0)
client 2 connected: negotiated=2025-11-25 sessions=3
client 2 closed   : sessions=2 (want 0)
client 3 connected: negotiated=2025-11-25 sessions=4
client 3 closed   : sessions=3 (want 0)

FINAL leaked sessions = 3 (want 0)

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:

# Request Response
1 server/discover (Mcp-Protocol-Version: 2026-07-28) 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:

  1. 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.

  2. The end-of-function safety net does not fire:

    defer func() {
        // If initialization failed, clean up the session (#578).
        if session.InitializeParams() == nil {
            session.Close()
        }
    }()

    Since mcp: do not exclude Notifications from carrying InitializeParams in Meta #1049, a discover request's _meta populates InitializeParams, so InitializeParams() != nil and 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/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
  • Go 1.26.5

Possibly related (none appear to cover this)

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions