The StreamableHttpClientSessionTransport unconditionally opens a standalone GET SSE long-poll immediately after the initialize response, in parallel with the outbound notifications/initialized POST. When the underlying HttpClient's connection pool is small and the server holds the GET open (perfectly legitimate per spec — the GET is for server-initiated notifications, the server is allowed to keep it open indefinitely), the parallel POST queues behind the GET on the connection pool and never sends. The handshake times out via InitializationTimeout (default 60s, configurable) with a generic TimeoutException("Initialization timed out") that doesn't hint at the real cause.
Note: PR #1609 (merged June 8) addresses the server-side hang from the same GET-SSE design — server can't push to client when no GET is open. The client-side equivalent — letting callers disable the standalone GET that the client opens automatically — is still missing. Per the "Out of scope" section of #1609, the standalone GET is removed entirely in protocol revision 2026-07-28 (SEP-2575 / SEP-2567), which makes a client opt-out doubly justified: forward-compat with the new revision and a workaround for misbehaving servers on the current one.
Other-language MCP SDKs all shipped opt-outs for this exact scenario:
| SDK |
Opt-out option |
| Go |
DisableStandaloneStreaming (added in v1.3.0 via go-sdk#633) |
| Swift |
streaming: false on HTTPClientTransport |
| Java |
openConnectionOnStartup: false |
| Python |
only opens standalone GET when server returned a session ID |
| Rust |
only opens standalone GET when server returned a session ID |
| C# |
none — always opens the GET, no toggle |
The cross-SDK research table from go-sdk#633 is the source.
Reproducer
Minimal file-based app (dotnet repro.cs, requires .NET 10). Spins up an in-process HttpListener that mimics a streamable-HTTP MCP server: returns 200 to initialize POST, 202 to notifications/initialized POST, and holds GET requests open forever. Caps MaxConnectionsPerServer = 1 to make the bug deterministic regardless of runtime defaults.
#:package ModelContextProtocol@1.2.0
// Minimal repro for the C# MCP SDK 1.2.0 streamable-HTTP transport bug where
// the SDK's standalone SSE long-poll GET starves the parallel
// `notifications/initialized` POST under HttpClient connection-pool pressure,
// causing the client connect to time out instead of complete.
//
// Cross-SDK context (Go, Swift, Java, Python all shipped opt-outs called
// `DisableStandaloneStreaming` or equivalent):
// https://github.com/modelcontextprotocol/go-sdk/issues/633
//
// Run with: dotnet run repro.cs
// Flip `ApplyWorkaround = true` below to verify the synthetic-405 fix
// (which Helix's Microsoft.Ide.Agent uses) unblocks the same scenario.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using ModelContextProtocol.Client;
const bool ApplyWorkaround = false;
const string Endpoint = "http://localhost:8765/";
// ---------------------------------------------------------------------------
// Server: simulates a streamable-HTTP MCP server that exhibits the bug.
// POST / initialize -> 200 with InitializeResult
// POST / notifications/initialized -> 202
// GET / Accept: text/event-stream -> NEVER RESPONDS (holds connection)
// The GET behavior is what every SDK quietly relies on the server NOT doing.
// ---------------------------------------------------------------------------
var listener = new HttpListener();
listener.Prefixes.Add(Endpoint);
listener.Start();
Console.WriteLine($"server: listening on {Endpoint}");
var serverCts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
while (!serverCts.IsCancellationRequested)
{
HttpListenerContext context;
try { context = await listener.GetContextAsync(); }
catch { return; }
_ = Task.Run(() => HandleAsync(context));
}
});
static async Task HandleAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
try
{
if (request.HttpMethod == "GET")
{
// THE BUG TRIGGER: standalone SSE long-poll GET. Real MCP servers
// either return 405 (declining SSE) or stream events. This server
// does neither -- the connection just sits open forever, exactly
// the shape that starves a small connection pool.
Console.WriteLine("server: GET (Accept: text/event-stream) -> HOLDING OPEN");
await Task.Delay(Timeout.InfiniteTimeSpan);
return;
}
string body;
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
{
body = await reader.ReadToEndAsync();
}
if (body.Contains("\"method\":\"initialize\""))
{
string id = ExtractJsonRpcId(body);
string payload = """
{"jsonrpc":"2.0","id":__ID__,"result":{"protocolVersion":"2025-06-18","serverInfo":{"name":"repro-server","version":"0.0.1"},"capabilities":{"tools":{"listChanged":false}}}}
""".Replace("__ID__", id);
byte[] bytes = Encoding.UTF8.GetBytes(payload);
response.StatusCode = 200;
response.ContentType = "application/json";
response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes);
Console.WriteLine("server: POST initialize -> 200");
}
else if (body.Contains("\"method\":\"notifications/initialized\""))
{
response.StatusCode = 202;
response.ContentLength64 = 0;
Console.WriteLine("server: POST notifications/initialized -> 202");
}
else
{
response.StatusCode = 200;
response.ContentLength64 = 0;
Console.WriteLine($"server: POST (other) -> 200 body[0..80]={Truncate(body, 80)}");
}
}
catch (Exception ex)
{
Console.WriteLine($"server: handler threw {ex.GetType().Name}: {ex.Message}");
}
finally
{
try { response.OutputStream.Close(); } catch { }
}
}
static string ExtractJsonRpcId(string body)
{
int idx = body.IndexOf("\"id\":", StringComparison.Ordinal);
if (idx < 0) return "1";
int start = idx + 5;
while (start < body.Length && char.IsWhiteSpace(body[start])) start++;
int end = start;
while (end < body.Length && body[end] != ',' && body[end] != '}') end++;
return body[start..end].Trim();
}
static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "...";
// ---------------------------------------------------------------------------
// Client: minimal MCP client. Forces StreamableHttp transport (skipping the
// SDK's AutoDetect masking) and caps the connection pool at 1 so the bug
// reproduces deterministically across .NET Framework and .NET 8+. Without
// the cap, .NET 8+'s default SocketsHttpHandler limit (int.MaxValue) hides
// the failure; with it, GET takes the only slot and POST has nowhere to go.
// ---------------------------------------------------------------------------
var inner = new SocketsHttpHandler { MaxConnectionsPerServer = 1 };
HttpMessageHandler outer = ApplyWorkaround ? new DisableSseHandler(inner) : inner;
var httpClient = new HttpClient(outer);
var transportOptions = new HttpClientTransportOptions
{
Endpoint = new Uri(Endpoint),
TransportMode = HttpTransportMode.StreamableHttp,
};
var clientOptions = new McpClientOptions
{
InitializationTimeout = TimeSpan.FromSeconds(5),
};
Console.WriteLine();
Console.WriteLine($"client: ApplyWorkaround = {ApplyWorkaround}");
Console.WriteLine("client: calling McpClient.CreateAsync (5s init timeout) ...");
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var transport = new HttpClientTransport(transportOptions, httpClient, ownsHttpClient: true);
await using var client = await McpClient.CreateAsync(transport, clientOptions);
Console.WriteLine($"client: CONNECTED in {sw.Elapsed.TotalSeconds:F1}s ({client.ServerInfo.Name} v{client.ServerInfo.Version})");
Console.WriteLine("client: PASS -- workaround unblocks the handshake.");
}
catch (Exception ex)
{
Console.WriteLine($"client: FAILED in {sw.Elapsed.TotalSeconds:F1}s");
Console.WriteLine($" {ex.GetType().FullName}: {ex.Message}");
if (ex.InnerException is { } inner2)
{
Console.WriteLine($" inner: {inner2.GetType().FullName}: {inner2.Message}");
}
Console.WriteLine("client: REPRO -- bug confirmed. Set ApplyWorkaround=true to see the fix.");
}
serverCts.Cancel();
listener.Close();
// ---------------------------------------------------------------------------
// Workaround: short-circuit the SDK's standalone SSE GET to a synthetic 405.
// The SDK treats 405 as "server doesn't offer SSE here" and stops trying,
// freeing the connection for the notifications/initialized POST. Matches the
// DisableStandaloneStreaming opt-in shipped by other-language SDKs and the
// fix Helix's Microsoft.Ide.Agent applies.
// ---------------------------------------------------------------------------
internal sealed class DisableSseHandler : DelegatingHandler
{
public DisableSseHandler(HttpMessageHandler inner) : base(inner) { }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Method == HttpMethod.Get
&& request.Headers.Accept.Any(h => string.Equals(h.MediaType, "text/event-stream", StringComparison.OrdinalIgnoreCase)))
{
Console.WriteLine("workaround: short-circuiting standalone SSE GET -> 405");
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)
{
RequestMessage = request,
ReasonPhrase = "Standalone SSE stream disabled by client",
Content = new StringContent("Standalone SSE stream disabled by client"),
});
}
return base.SendAsync(request, cancellationToken);
}
}
ApplyWorkaround = false (default):
server: listening on http://localhost:8765/
client: ApplyWorkaround = False
client: calling McpClient.CreateAsync (5s init timeout) ...
server: POST initialize -> 200
server: GET (Accept: text/event-stream) -> HOLDING OPEN
client: FAILED in 5.0s
System.TimeoutException: Initialization timed out
inner: System.Threading.Tasks.TaskCanceledException: A task was canceled.
The server never sees the notifications/initialized POST. It's queued behind the GET on the size-1 pool and the SDK times out before the pool ever frees.
ApplyWorkaround = true:
server: listening on http://localhost:8765/
client: ApplyWorkaround = True
client: calling McpClient.CreateAsync (5s init timeout) ...
server: POST initialize -> 200
workaround: short-circuiting standalone SSE GET -> 405
server: POST notifications/initialized -> 202
client: CONNECTED in 0.1s (repro-server v0.0.1)
A DelegatingHandler that returns a synthetic 405 for GET with Accept: text/event-stream unblocks the handshake — the SDK already treats 405 on the standalone GET as "server doesn't offer SSE here" and stops trying, freeing the connection for the POST.
Real-world impact
We hit this against Tavily's hosted MCP at https://mcp.tavily.com/mcp from a .NET Framework 4.7.2 host (default MaxConnectionsPerServer = 2, only one connection slot effectively because the OAuth token-exchange POST consumes another). Symptom in the wild was a ~4-minute hang followed by TaskCanceledException. The repro above is the distilled form — same code path, deterministic.
Ask
A DisableStandaloneStreaming (or similar) bool on HttpClientTransportOptions that causes StreamableHttpClientSessionTransport to skip ReceiveUnsolicitedMessagesAsync. Matches the pattern in go-sdk v1.3.0 / Swift / Java / Python / Rust. We'd flip it on for servers we don't subscribe to notifications from, and leave it off for servers where we do.
The
StreamableHttpClientSessionTransportunconditionally opens a standaloneGETSSE long-poll immediately after theinitializeresponse, in parallel with the outboundnotifications/initializedPOST. When the underlyingHttpClient's connection pool is small and the server holds the GET open (perfectly legitimate per spec — the GET is for server-initiated notifications, the server is allowed to keep it open indefinitely), the parallel POST queues behind the GET on the connection pool and never sends. The handshake times out viaInitializationTimeout(default 60s, configurable) with a genericTimeoutException("Initialization timed out")that doesn't hint at the real cause.Note: PR #1609 (merged June 8) addresses the server-side hang from the same GET-SSE design — server can't push to client when no GET is open. The client-side equivalent — letting callers disable the standalone GET that the client opens automatically — is still missing. Per the "Out of scope" section of #1609, the standalone GET is removed entirely in protocol revision
2026-07-28(SEP-2575 / SEP-2567), which makes a client opt-out doubly justified: forward-compat with the new revision and a workaround for misbehaving servers on the current one.Other-language MCP SDKs all shipped opt-outs for this exact scenario:
DisableStandaloneStreaming(added in v1.3.0 via go-sdk#633)streaming: falseonHTTPClientTransportopenConnectionOnStartup: falseThe cross-SDK research table from go-sdk#633 is the source.
Reproducer
Minimal file-based app (
dotnet repro.cs, requires .NET 10). Spins up an in-processHttpListenerthat mimics a streamable-HTTP MCP server: returns 200 toinitializePOST, 202 tonotifications/initializedPOST, and holds GET requests open forever. CapsMaxConnectionsPerServer = 1to make the bug deterministic regardless of runtime defaults.ApplyWorkaround = false(default):The server never sees the
notifications/initializedPOST. It's queued behind the GET on the size-1 pool and the SDK times out before the pool ever frees.ApplyWorkaround = true:A
DelegatingHandlerthat returns a synthetic 405 forGETwithAccept: text/event-streamunblocks the handshake — the SDK already treats 405 on the standalone GET as "server doesn't offer SSE here" and stops trying, freeing the connection for the POST.Real-world impact
We hit this against Tavily's hosted MCP at
https://mcp.tavily.com/mcpfrom a .NET Framework 4.7.2 host (defaultMaxConnectionsPerServer = 2, only one connection slot effectively because the OAuth token-exchange POST consumes another). Symptom in the wild was a~4-minutehang followed byTaskCanceledException. The repro above is the distilled form — same code path, deterministic.Ask
A
DisableStandaloneStreaming(or similar) bool onHttpClientTransportOptionsthat causesStreamableHttpClientSessionTransportto skipReceiveUnsolicitedMessagesAsync. Matches the pattern in go-sdk v1.3.0 / Swift / Java / Python / Rust. We'd flip it on for servers we don't subscribe to notifications from, and leave it off for servers where we do.