Skip to content

Client.Connect leaks the ClientSession when the server answers with an unsupported protocol version #1154

Description

@helebest

Describe the bug

In go-sdk v1.7.0, Client.Connect returns unsupportedProtocolVersionError without closing the ClientSession it has already built, and hands the caller nil for the session — so nothing outside the SDK can close it either.

The two adjacent error paths in the same function both call cs.Close(). Only this one does not (mcp/client.go, in Connect):

res, err := handleSend[*InitializeResult](ctx, methodInitialize, req)
if err != nil {
    _ = cs.Close()                                       // closes
    return nil, err
}
if !slices.Contains(supportedProtocolVersions, res.ProtocolVersion) {
    return nil, unsupportedProtocolVersionError{res.ProtocolVersion}   // does not close
}
cs.state.InitializeResult = res
...
if err := handleNotify(ctx, notificationInitialized, req2); err != nil {
    _ = cs.Close()                                       // closes
    return nil, err
}

Impact is per attempt, not per process: a client that retries against a server answering this way leaks a reader goroutine and its HTTP connection every time, and on a stateful server the session is never deleted.

To Reproduce

The fixture refuses server/discover to push the client onto the legacy initialize path, then answers initialize with a protocol version no revision defines. It records whether a DELETE — the client saying "I am done with this session" — ever arrives.

package repro

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"

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

func fixture(t *testing.T, sawDelete *atomic.Bool) string {
	t.Helper()
	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodDelete {
			sawDelete.Store(true)
			w.WriteHeader(http.StatusNoContent)
			return
		}
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "read", http.StatusInternalServerError)
			return
		}
		var req struct {
			ID     json.RawMessage `json:"id"`
			Method string          `json:"method"`
		}
		if json.Unmarshal(body, &req) != nil {
			http.Error(w, "bad request", http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Mcp-Session-Id", "leaky-session")
		enc := json.NewEncoder(w)
		switch req.Method {
		case "server/discover":
			_ = enc.Encode(map[string]any{
				"jsonrpc": "2.0", "id": req.ID,
				"error": map[string]any{"code": -32601, "message": "method not found"},
			})
		case "initialize":
			_ = enc.Encode(map[string]any{
				"jsonrpc": "2.0", "id": req.ID,
				"result": map[string]any{
					"protocolVersion": "1999-01-01",
					"capabilities":    map[string]any{},
					"serverInfo":      map[string]any{"name": "fixture", "version": "1"},
				},
			})
		default:
			_ = enc.Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{}})
		}
	}))
	t.Cleanup(ts.Close)
	return ts.URL
}

func TestConnectLeaksTheSessionOnAnUnsupportedProtocolVersion(t *testing.T) {
	var sawDelete atomic.Bool
	url := fixture(t, &sawDelete)

	client := mcp.NewClient(&mcp.Implementation{Name: "repro"}, nil)
	session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: url}, nil)
	if err == nil {
		t.Fatal("Connect succeeded against an unsupported protocol version")
	}
	t.Logf("Connect error: %v", err)
	t.Logf("session handed back to the caller: %v", session)

	if !sawDelete.Load() {
		t.Error("no DELETE reached the server — the session the SDK built is still open, " +
			"and Connect returned no handle to close it")
	}
}

Output on v1.7.0:

    repro_test.go:78: Connect error: unsupported protocol version: "1999-01-01"
    repro_test.go:79: session handed back to the caller: <nil>
    repro_test.go:86: no DELETE reached the server — the session the SDK built is still open, and Connect returned no handle to close it
--- FAIL: TestConnectLeaksTheSessionOnAnUnsupportedProtocolVersion (0.01s)

Expected behavior

The branch closes the session before returning, like its two neighbours — _ = cs.Close() ahead of the return.

Workaround, for anyone hitting this before a release

It is fixable from outside, but only awkwardly, and the shape is not obvious. Transport is a one-method interface, so a wrapper can keep the Connection its inner transport produced and close that when Connect fails:

type capturingTransport struct {
	inner mcp.Transport
	conn  mcp.Connection
}

func (t *capturingTransport) Connect(ctx context.Context) (mcp.Connection, error) {
	conn, err := t.inner.Connect(ctx)
	t.conn = conn
	return conn, err
}

Two details matter. The wrapper must return the SDK's own Connection unchanged — the SDK type-asserts it to clientConnection to reach sessionUpdated, so substituting a wrapper there breaks version negotiation. And Connection.Close is idempotent, so closing on the error path is safe against the branches that do close.

Additional context

  • go-sdk v1.7.0 (current latest release), Go 1.26, darwin/arm64.
  • Distinct from Streamable HTTP: server/discover leaks a ServerSession on stateful servers #1136, which is a ServerSession leak on the server side; this one is client-side.
  • Found while building an MCP client whose connections are per-work-item, where a server answering this way to every attempt makes the leak accumulate rather than stay a one-off.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge cases

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions