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.SendAsyncsends every request withHttpCompletionOption.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
HttpResponseMessageis disposed.SseClientSessionTransport.SendMessageAsyncreceives that response withoutusing, 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:
using var response(same file, receive loop),only the success path (the common case, a
202 Accepted) leaks. The same pattern is still present onmaintoday (var responseatSseClientSessionTransport.cs#L93vs.using var responseat#L161).Observed impact
Measured with OS-level connection counting (
netstat/IPGlobalProperties.GetActiveTcpConnections), .NET 8/10, packageModelContextProtocol 0.3.0-preview.3, against a localModelContextProtocol.AspNetCoreserver:ListToolsAsyncperforms 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).HttpClient, viaownsHttpClient: true) does not release them: from the handler's perspective those requests are still in flight, andDisposedeliberately does not tear down in-flight connections.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.