Skip to content

HTTP+SSE client: POST responses are never disposed, leaking one connection per sent message #1840

Description

@yalcinfu22

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:

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

  2. 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: varusing 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.

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