modelcontextprotocol / modelcontextprotocol/csharp-sdk

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

Open Beginner friendly
#1,840 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C#
Stars
4.5k
Forks
814
Avg merge
9d 19h
Merged PRs (30d)
4

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs at SendMessageAsync and compare its POST response handling with the disposed SSE GET response in the same file. Make the success path dispose the response deterministically, then verify that POST connections are returned to the pool and no longer accumulate as described in the issue.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
api, networking
Issue type
Bug
Difficulty
1/5
Estimated time
Under an hour
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
88/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.