Skip to content

Dispose the POST response in SseClientSessionTransport to stop leaking a connection per message - #1841

Open
yalcinfu22 wants to merge 3 commits into
modelcontextprotocol:mainfrom
yalcinfu22:fix/sse-post-response-leak
Open

Dispose the POST response in SseClientSessionTransport to stop leaking a connection per message#1841
yalcinfu22 wants to merge 3 commits into
modelcontextprotocol:mainfrom
yalcinfu22:fix/sse-post-response-leak

Conversation

@yalcinfu22

@yalcinfu22 yalcinfu22 commented Aug 27, 2026

Copy link
Copy Markdown

Fixes #1840

What

One-word change in SseClientSessionTransport.SendMessageAsync: the HttpResponseMessage
returned for each POSTed JSON-RPC message is now wrapped in using. A deterministic
regression test is included.

Why

McpHttpClient.SendAsync sends every request with HttpCompletionOption.ResponseHeadersRead,
so the underlying connection stays checked out until the response is consumed or disposed.
The POST path never did either on the success path (a 202 Accepted whose body is unused),
so every sent message stranded one ESTABLISHED connection — never returned to the pool,
never reused, reclaimed only by GC or an idle timeout. Measurements and the full analysis
are in #1840 (per client: 1 live SSE connection + one stranded connection per POST —
initialize, notifications/initialized, tools/list).

The surrounding code already does this correctly: the SSE GET response is wrapped in
using var response in the same file, and the failure path reads the body before throwing.
Only the success path was missing the dispose.

Regression test

HttpClientTransportTests.SendMessageAsync_Disposes_Response_On_Success drives the SSE
transport through the public HttpClientTransport.ConnectAsync + SendMessageAsync path
against the existing MockHttpHandler. The mocked POST response carries content that
records its own disposal, and the test asserts the response has been disposed by the time
SendMessageAsync returns. No sockets, no GC, no timing dependence: neither
HttpResponseMessage nor HttpContent has a finalizer, so nothing but the transport's
explicit dispose can set the flag.

dotnet test tests/ModelContextProtocol.Tests/ --filter "FullyQualifiedName~SendMessageAsync_Disposes_Response_On_Success"
  • Without the fix (parent commit's SseClientSessionTransport.cs): fails on every
    target framework — net10.0, net9.0, net8.0, and net472.
  • With the fix: passes on all four TFMs, in both Debug and Release. The full
    HttpClientTransportTests class is 17/17 on each TFM.
  • Full ModelContextProtocol.Tests suite: green locally on net10.0 (2357 passed,
    0 failed, 6 skipped — the tests requiring external credentials or Docker).
    ModelContextProtocol.AspNetCore.Tests (which exercises the SSE client transport
    against a real in-memory Kestrel server) is also green locally on net10.0, net9.0,
    and net8.0 (615 passed, 0 failed, 30 skipped each).

Notes

  • The failure path is unaffected: the body is read and the HttpRequestException is fully
    materialized from StatusCode/ReasonPhrase/body before the using scope unwinds; the
    exception holds no reference to the response.
  • The full solution builds cleanly with TreatWarningsAsErrors enabled.

🤖 Generated with Claude Code

yalcinfu22 and others added 2 commits August 27, 2026 11:12
SendMessageAsync sends every message with HttpCompletionOption.ResponseHeadersRead
(via McpHttpClient) but never disposed the returned HttpResponseMessage on the
success path, so the underlying connection was never returned to the pool. Each
JSON-RPC POST therefore opened and stranded a new connection, which was only
reclaimed by GC or an idle timeout.

Disposing the response returns the connection to the pool deterministically;
subsequent POSTs reuse a single connection.

Fixes modelcontextprotocol#1840

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SendMessageAsync_Disposes_Response_On_Success drives the SSE transport
through the public HttpClientTransport ConnectAsync + SendMessageAsync
path against the existing MockHttpHandler. The mocked POST response
carries content that records its own disposal, and the test asserts the
response has been disposed by the time SendMessageAsync returns - no
sockets, no GC, no timing dependence.

Fails on the parent commit (response never disposed on the success
path), passes with the fix, on net10.0, net9.0, net8.0, and net472.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yalcinfu22

Copy link
Copy Markdown
Author

Per CONTRIBUTING.md's "tests are included for new features or bug fixes": added a deterministic regression test, HttpClientTransportTests.SendMessageAsync_Disposes_Response_On_Success. It fails on the parent commit on all four target frameworks (net10.0, net9.0, net8.0, net472 — the POST response is never disposed on the success path) and passes with the fix on all four. Description updated with the command and results.

HttpResponseMessage and HttpContent have no finalizer, so cleanup
never happens implicitly via GC finalization; only an explicit
Dispose() releases the connection. Reworded the comment and assert
message to say cleanup becomes nondeterministic instead of claiming
GC finalizes the response.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant