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
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):
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"
)
funcfixture(t*testing.T, sawDelete*atomic.Bool) string {
t.Helper()
ts:=httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r*http.Request) {
ifr.Method==http.MethodDelete {
sawDelete.Store(true)
w.WriteHeader(http.StatusNoContent)
return
}
body, err:=io.ReadAll(r.Body)
iferr!=nil {
http.Error(w, "read", http.StatusInternalServerError)
return
}
varreqstruct {
ID json.RawMessage`json:"id"`Methodstring`json:"method"`
}
ifjson.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)
switchreq.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)
returnts.URL
}
funcTestConnectLeaksTheSessionOnAnUnsupportedProtocolVersion(t*testing.T) {
varsawDelete atomic.Boolurl:=fixture(t, &sawDelete)
client:=mcp.NewClient(&mcp.Implementation{Name: "repro"}, nil)
session, err:=client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: url}, nil)
iferr==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:
Two details matter. The wrapper must return the SDK's own Connectionunchanged — 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.
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.
Describe the bug
In
go-sdk v1.7.0,Client.ConnectreturnsunsupportedProtocolVersionErrorwithout closing theClientSessionit has already built, and hands the callernilfor 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, inConnect):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/discoverto push the client onto the legacyinitializepath, then answersinitializewith a protocol version no revision defines. It records whether aDELETE— the client saying "I am done with this session" — ever arrives.Output on v1.7.0:
Expected behavior
The branch closes the session before returning, like its two neighbours —
_ = cs.Close()ahead of thereturn.Workaround, for anyone hitting this before a release
It is fixable from outside, but only awkwardly, and the shape is not obvious.
Transportis a one-method interface, so a wrapper can keep theConnectionits inner transport produced and close that whenConnectfails:Two details matter. The wrapper must return the SDK's own
Connectionunchanged — the SDK type-asserts it toclientConnectionto reachsessionUpdated, so substituting a wrapper there breaks version negotiation. AndConnection.Closeis idempotent, so closing on the error path is safe against the branches that do close.Additional context
go-sdkv1.7.0 (current latest release), Go 1.26, darwin/arm64.ServerSessionleak on the server side; this one is client-side.