From 5c8f47387f8bb9f8ba94e5f1ae031c4d18ba5090 Mon Sep 17 00:00:00 2001 From: King Star Date: Mon, 29 Jun 2026 12:04:59 +0800 Subject: [PATCH] fix: allow disabling Streamable HTTP standalone GET --- docs/concepts/stateless/stateless.md | 1 + docs/concepts/transports/transports.md | 2 + .../Client/HttpClientTransportOptions.cs | 10 ++ .../StreamableHttpClientSessionTransport.cs | 12 +- .../Transport/HttpClientTransportTests.cs | 127 +++++++++++++++++- 5 files changed, 149 insertions(+), 3 deletions(-) diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..8686f8e3c 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -373,6 +373,7 @@ The following prop | Property | Default | Description | |----------|---------|-------------| | | `null` | Pre-existing session ID for use with . When set, the client includes this session ID immediately and starts listening for unsolicited messages. | +| | `false` | Skips the standalone GET stream for unsolicited messages. POST request/response streaming still works. | | | `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. | | | `null` | Custom headers included in all requests (e.g., for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. | diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 354299ce1..a6b8acaff 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -249,6 +249,8 @@ In Streamable HTTP, client requests arrive as HTTP POST requests. The server hol 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. +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 to `true`. Direct responses and streaming responses to client POST requests still work; only the standalone GET stream is skipped. + A custom route can be specified. For example, the [AspNetCoreMcpPerSessionTools] sample uses a route parameter: [AspNetCoreMcpPerSessionTools]: https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools diff --git a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs index f97c346ea..6ea27bd7d 100644 --- a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs @@ -94,6 +94,16 @@ public required Uri Endpoint /// public string? KnownSessionId { get; set; } + /// + /// Gets or sets a value indicating whether the Streamable HTTP transport skips the standalone GET SSE stream. + /// + /// + /// Set this to for servers where the client does not need unsolicited server-to-client + /// messages, or when a long-lived standalone GET would block other requests under a constrained + /// connection pool. + /// + public bool DisableStandaloneStreaming { get; set; } + /// /// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose. /// diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 26ffdce65..1f04b8b6a 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -54,7 +54,7 @@ public StreamableHttpClientSessionTransport( if (_options.KnownSessionId is { } knownSessionId) { SessionId = knownSessionId; - _getReceiveTask = ReceiveUnsolicitedMessagesAsync(); + StartUnsolicitedMessageStreamIfEnabled(); } } @@ -225,7 +225,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult); _negotiatedProtocolVersion = initializeResult?.ProtocolVersion; - _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); + StartUnsolicitedMessageStreamIfEnabled(); } else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse) { @@ -238,6 +238,14 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes return response; } + private void StartUnsolicitedMessageStreamIfEnabled() + { + if (!_options.DisableStandaloneStreaming) + { + _getReceiveTask ??= ReceiveUnsolicitedMessagesAsync(); + } + } + /// /// Reads the protocol version from a request's _meta/io.modelcontextprotocol/protocolVersion field, /// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns for messages that diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs index 2627bc504..24f88c0eb 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs @@ -377,4 +377,129 @@ await session.SendMessageAsync( // Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections. Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount); } -} \ No newline at end of file + + [Fact] + public async Task StreamableHttp_DisableStandaloneStreaming_DoesNotOpenGetSseAfterInitialize() + { + var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + DisableStandaloneStreaming = true, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""", + Encoding.UTF8, + "application/json"), + }; + response.Headers.Add("Mcp-Session-Id", "test-session"); + return Task.FromResult(response); + } + + if (request.Method == HttpMethod.Get) + { + getRequestReceived.TrySetResult(true); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + var completedTask = await Task.WhenAny(getRequestReceived.Task, Task.Delay(100, TestContext.Current.CancellationToken)); + Assert.NotSame(getRequestReceived.Task, completedTask); + } + + [Fact] + public async Task StreamableHttp_DisableStandaloneStreaming_DoesNotOpenGetSseForKnownSessionId() + { + var getRequestReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "test-session", + DisableStandaloneStreaming = true, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Get) + { + getRequestReceived.TrySetResult(true); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + var completedTask = await Task.WhenAny(getRequestReceived.Task, Task.Delay(100, TestContext.Current.CancellationToken)); + Assert.NotSame(getRequestReceived.Task, completedTask); + } + + [Fact] + public async Task StreamableHttp_DisableStandaloneStreaming_StillProcessesPostSseResponses() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost:8080"), + TransportMode = HttpTransportMode.StreamableHttp, + DisableStandaloneStreaming = true, + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = (request) => + { + if (request.Method == HttpMethod.Post) + { + var response = new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + "event: message\r\n" + + """data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"TestServer","version":"1.0.0"}}}""" + + "\r\n\r\n", + Encoding.UTF8, + "text/event-stream"), + }; + response.Headers.Add("Mcp-Session-Id", "test-session"); + return Task.FromResult(response); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}"); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + Assert.Equal("test-session", session.SessionId); + } +}