Skip to content

Commit 5c8f473

Browse files
committed
fix: allow disabling Streamable HTTP standalone GET
1 parent b2a4012 commit 5c8f473

5 files changed

Lines changed: 149 additions & 3 deletions

File tree

docs/concepts/stateless/stateless.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ The following <xref:ModelContextProtocol.Client.HttpClientTransportOptions> prop
373373
| Property | Default | Description |
374374
|----------|---------|-------------|
375375
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.KnownSessionId> | `null` | Pre-existing session ID for use with <xref:ModelContextProtocol.Client.McpClient.ResumeSessionAsync*>. When set, the client includes this session ID immediately and starts listening for unsolicited messages. |
376+
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.DisableStandaloneStreaming> | `false` | Skips the standalone GET stream for unsolicited messages. POST request/response streaming still works. |
376377
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.OwnsSession> | `true` | Whether to send a DELETE request when the client is disposed. Set to `false` when you don't want disposal to terminate the server session. |
377378
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.AdditionalHeaders> | `null` | Custom headers included in all requests (e.g., for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. |
378379

docs/concepts/transports/transports.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,8 @@ In Streamable HTTP, client requests arrive as HTTP POST requests. The server hol
249249

250250
In stateful mode, the client can also open a long-lived GET request to receive **unsolicited** messages — notifications or server-to-client requests that the server initiates outside any active request handler (e.g., resource-changed notifications from a background watcher). In stateless mode, the GET endpoint is not mapped, so every message must be part of a POST response. See [How Streamable HTTP delivers messages](xref:stateless#how-streamable-http-delivers-messages) for a detailed breakdown.
251251

252+
If your client does not need unsolicited server-to-client messages, or if a long-lived GET stream would block other requests under a constrained `HttpClient` connection pool, set <xref:ModelContextProtocol.Client.HttpClientTransportOptions.DisableStandaloneStreaming> to `true`. Direct responses and streaming responses to client POST requests still work; only the standalone GET stream is skipped.
253+
252254
A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter:
253255

254256
[AspNetCoreMcpPerSessionTools]: https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools

src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ public required Uri Endpoint
9494
/// </remarks>
9595
public string? KnownSessionId { get; set; }
9696

97+
/// <summary>
98+
/// Gets or sets a value indicating whether the Streamable HTTP transport skips the standalone GET SSE stream.
99+
/// </summary>
100+
/// <remarks>
101+
/// Set this to <see langword="true"/> for servers where the client does not need unsolicited server-to-client
102+
/// messages, or when a long-lived standalone GET would block other requests under a constrained
103+
/// <see cref="HttpClient"/> connection pool.
104+
/// </remarks>
105+
public bool DisableStandaloneStreaming { get; set; }
106+
97107
/// <summary>
98108
/// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose.
99109
/// </summary>

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public StreamableHttpClientSessionTransport(
5454
if (_options.KnownSessionId is { } knownSessionId)
5555
{
5656
SessionId = knownSessionId;
57-
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
57+
StartUnsolicitedMessageStreamIfEnabled();
5858
}
5959
}
6060

@@ -225,7 +225,7 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
225225
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
226226
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
227227

228-
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
228+
StartUnsolicitedMessageStreamIfEnabled();
229229
}
230230
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
231231
{
@@ -238,6 +238,14 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
238238
return response;
239239
}
240240

241+
private void StartUnsolicitedMessageStreamIfEnabled()
242+
{
243+
if (!_options.DisableStandaloneStreaming)
244+
{
245+
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
246+
}
247+
}
248+
241249
/// <summary>
242250
/// Reads the protocol version from a request's <c>_meta/io.modelcontextprotocol/protocolVersion</c> field,
243251
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns <see langword="null"/> for messages that

tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,4 +377,129 @@ await session.SendMessageAsync(
377377
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
378378
Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount);
379379
}
380-
}
380+
381+
[Fact]
382+
public async Task StreamableHttp_DisableStandaloneStreaming_DoesNotOpenGetSseAfterInitialize()
383+
{
384+
var getRequestReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
385+
386+
var options = new HttpClientTransportOptions
387+
{
388+
Endpoint = new Uri("http://localhost:8080"),
389+
TransportMode = HttpTransportMode.StreamableHttp,
390+
DisableStandaloneStreaming = true,
391+
};
392+
393+
using var mockHttpHandler = new MockHttpHandler();
394+
using var httpClient = new HttpClient(mockHttpHandler);
395+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
396+
397+
mockHttpHandler.RequestHandler = (request) =>
398+
{
399+
if (request.Method == HttpMethod.Post)
400+
{
401+
var response = new HttpResponseMessage
402+
{
403+
StatusCode = HttpStatusCode.OK,
404+
Content = new StringContent(
405+
"""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""",
406+
Encoding.UTF8,
407+
"application/json"),
408+
};
409+
response.Headers.Add("Mcp-Session-Id", "test-session");
410+
return Task.FromResult(response);
411+
}
412+
413+
if (request.Method == HttpMethod.Get)
414+
{
415+
getRequestReceived.TrySetResult(true);
416+
}
417+
418+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
419+
};
420+
421+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
422+
await session.SendMessageAsync(
423+
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
424+
TestContext.Current.CancellationToken);
425+
426+
var completedTask = await Task.WhenAny(getRequestReceived.Task, Task.Delay(100, TestContext.Current.CancellationToken));
427+
Assert.NotSame(getRequestReceived.Task, completedTask);
428+
}
429+
430+
[Fact]
431+
public async Task StreamableHttp_DisableStandaloneStreaming_DoesNotOpenGetSseForKnownSessionId()
432+
{
433+
var getRequestReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
434+
435+
var options = new HttpClientTransportOptions
436+
{
437+
Endpoint = new Uri("http://localhost:8080"),
438+
TransportMode = HttpTransportMode.StreamableHttp,
439+
KnownSessionId = "test-session",
440+
DisableStandaloneStreaming = true,
441+
};
442+
443+
using var mockHttpHandler = new MockHttpHandler();
444+
using var httpClient = new HttpClient(mockHttpHandler);
445+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
446+
447+
mockHttpHandler.RequestHandler = (request) =>
448+
{
449+
if (request.Method == HttpMethod.Get)
450+
{
451+
getRequestReceived.TrySetResult(true);
452+
}
453+
454+
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
455+
};
456+
457+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
458+
459+
var completedTask = await Task.WhenAny(getRequestReceived.Task, Task.Delay(100, TestContext.Current.CancellationToken));
460+
Assert.NotSame(getRequestReceived.Task, completedTask);
461+
}
462+
463+
[Fact]
464+
public async Task StreamableHttp_DisableStandaloneStreaming_StillProcessesPostSseResponses()
465+
{
466+
var options = new HttpClientTransportOptions
467+
{
468+
Endpoint = new Uri("http://localhost:8080"),
469+
TransportMode = HttpTransportMode.StreamableHttp,
470+
DisableStandaloneStreaming = true,
471+
};
472+
473+
using var mockHttpHandler = new MockHttpHandler();
474+
using var httpClient = new HttpClient(mockHttpHandler);
475+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
476+
477+
mockHttpHandler.RequestHandler = (request) =>
478+
{
479+
if (request.Method == HttpMethod.Post)
480+
{
481+
var response = new HttpResponseMessage
482+
{
483+
StatusCode = HttpStatusCode.OK,
484+
Content = new StringContent(
485+
"event: message\r\n" +
486+
"""data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""" +
487+
"\r\n\r\n",
488+
Encoding.UTF8,
489+
"text/event-stream"),
490+
};
491+
response.Headers.Add("Mcp-Session-Id", "test-session");
492+
return Task.FromResult(response);
493+
}
494+
495+
throw new InvalidOperationException($"Unexpected request: {request.Method}");
496+
};
497+
498+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
499+
await session.SendMessageAsync(
500+
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
501+
TestContext.Current.CancellationToken);
502+
503+
Assert.Equal("test-session", session.SessionId);
504+
}
505+
}

0 commit comments

Comments
 (0)