Description
In the legacy HTTP+SSE client transport, every JSON-RPC message sent via POST leaks its HTTP connection until the GC happens to finalize the abandoned response object.
Two lines combine to cause this:
-
McpHttpClient.SendAsync sends every request with HttpCompletionOption.ResponseHeadersRead:
https://github.com/modelcontextprotocol/csharp-sdk/blob/v0.3.0-preview.3/src/ModelContextProtocol.Core/Client/McpHttpClient.cs#L22
In this mode the underlying connection is not returned to the pool until the response content is fully consumed or the HttpResponseMessage is disposed.
-
SseClientSessionTransport.SendMessageAsync receives that response without using, and on the success path neither reads the content nor disposes it:
https://github.com/modelcontextprotocol/csharp-sdk/blob/v0.3.0-preview.3/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs#L85
The method simply returns, leaving the response — and its connection — checked out indefinitely. It is only reclaimed when the GC finalizes the abandoned response (nondeterministic), or an idle/keep-alive timeout eventually fires.
The surrounding code suggests an oversight rather than a design choice:
- the SSE GET response is wrapped in using var response (same file, receive loop),
- the failure path does read the content (for logging) before throwing —
only the success path (the common case, a 202 Accepted) leaks. The same pattern is still present on main today (var response at SseClientSessionTransport.cs#L93 vs. using var response at #L161).
Observed impact
Measured with OS-level connection counting (netstat / IPGlobalProperties.GetActiveTcpConnections), .NET 8/10, package ModelContextProtocol 0.3.0-preview.3, against a local ModelContextProtocol.AspNetCore server:
- A single client connect + ListToolsAsync performs 3 POSTs (initialize, notifications/initialized, tools/list); each leaves one ESTABLISHED connection stuck. Per client: 1 live SSE connection + 3 stuck POST connections (the app-side SSE count and the OS socket count diverge, e.g. 2 vs 11 for one round against 3 servers).
- The stuck connections never return to the pool, so they are also never reused — each subsequent POST opens a fresh socket.
- Disposing the client (and the HttpClient, via ownsHttpClient: true) does not release them: from the handler's perspective those requests are still in flight, and Dispose deliberately does not tear down in-flight connections.
- Applications that create clients per operation accumulate a sawtooth of dead ESTABLISHED sockets, bounded only by GC timing / idle timeouts.
Suggested fix
In SseClientSessionTransport.SendMessageAsync:
using var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
(one-word change: var → using var). With the response disposed, the connection returns to the pool deterministically and subsequent POSTs reuse a single connection instead of opening a new socket per message.
How this was found
While investigating unexpected TCP connection growth in an application that uses the legacy SSE transport: the application's own SSE bookkeeping and the OS-level socket count diverged. Ruling out server-side closes (no TIME_WAIT traces — the sockets sit in ESTABLISHED) and timing races (exactly one stuck connection per POST, on every run) pointed at undisposed responses; reading the transport source then confirmed the missing using.
Description
In the legacy HTTP+SSE client transport, every JSON-RPC message sent via POST leaks its HTTP connection until the GC happens to finalize the abandoned response object.
Two lines combine to cause this:
McpHttpClient.SendAsync sends every request with HttpCompletionOption.ResponseHeadersRead:
https://github.com/modelcontextprotocol/csharp-sdk/blob/v0.3.0-preview.3/src/ModelContextProtocol.Core/Client/McpHttpClient.cs#L22
In this mode the underlying connection is not returned to the pool until the response content is fully consumed or the HttpResponseMessage is disposed.
SseClientSessionTransport.SendMessageAsync receives that response without using, and on the success path neither reads the content nor disposes it:
https://github.com/modelcontextprotocol/csharp-sdk/blob/v0.3.0-preview.3/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs#L85
The method simply returns, leaving the response — and its connection — checked out indefinitely. It is only reclaimed when the GC finalizes the abandoned response (nondeterministic), or an idle/keep-alive timeout eventually fires.
The surrounding code suggests an oversight rather than a design choice:
only the success path (the common case, a 202 Accepted) leaks. The same pattern is still present on main today (var response at SseClientSessionTransport.cs#L93 vs. using var response at #L161).
Observed impact
Measured with OS-level connection counting (netstat / IPGlobalProperties.GetActiveTcpConnections), .NET 8/10, package ModelContextProtocol 0.3.0-preview.3, against a local ModelContextProtocol.AspNetCore server:
Suggested fix
In SseClientSessionTransport.SendMessageAsync:
(one-word change: var → using var). With the response disposed, the connection returns to the pool deterministically and subsequent POSTs reuse a single connection instead of opening a new socket per message.
How this was found
While investigating unexpected TCP connection growth in an application that uses the legacy SSE transport: the application's own SSE bookkeeping and the OS-level socket count diverged. Ruling out server-side closes (no TIME_WAIT traces — the sockets sit in ESTABLISHED) and timing races (exactly one stuck connection per POST, on every run) pointed at undisposed responses; reading the transport source then confirmed the missing using.