Add opt-out for Streamable HTTP standalone GET stream (#1699)

Co-authored-by: Jeff Handley <jeffhandley@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
King Star 2026-07-15 08:06:47 +08:00 • committed by GitHub
commit 6af05d0bf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 210 additions and 3 deletions

View file

@ -94,6 +94,24 @@ public sealed class HttpClientTransportOptions
/// </remarks>
public string? KnownSessionId { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the Streamable HTTP transport opens the standalone GET SSE stream
/// used to receive unsolicited server-to-client messages.
/// </summary>
/// <remarks>
/// <para>
/// This defaults to <see langword="true"/>, matching the Streamable HTTP behavior of opening a standalone GET
/// SSE stream after initialization so the server can push unsolicited server-to-client messages.
/// </para>
/// <para>
/// Set this to <see langword="false"/> 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
/// <see cref="HttpClient"/> connection pool. Direct responses and streaming responses to client POST requests
/// still work; only the standalone GET stream is skipped.
/// </para>
/// </remarks>
public bool EnableStandaloneGetStream { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose.
/// </summary>

View file

@ -26,6 +26,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private string? _negotiatedProtocolVersion;
private Task? _getReceiveTask;
private bool _streamableHttpAdopted;
private volatile ClientTransportClosedException? _disconnectError;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
@ -54,7 +55,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
if (_options.KnownSessionId is { } knownSessionId)
{
SessionId = knownSessionId;
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
_streamableHttpAdopted = true;
StartUnsolicitedMessageStreamIfEnabled();
}
}
@ -228,7 +230,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
_streamableHttpAdopted = true;
StartUnsolicitedMessageStreamIfEnabled();
}
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
{
@ -241,6 +244,14 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
return response;
}
private void StartUnsolicitedMessageStreamIfEnabled()
{
if (_options.EnableStandaloneGetStream)
{
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
}
}
/// <summary>
/// Reads the protocol version from a request's <c>_meta/io.modelcontextprotocol/protocolVersion</c> field,
/// Introduced by the 2026-07-28 protocol revision (SEP-2575). Returns <see langword="null"/> for messages that
@ -298,7 +309,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
{
// If we're auto-detecting the transport and failed to connect, leave the message Channel open for the SSE transport.
// This class isn't directly exposed to public callers, so we don't have to worry about changing the _state in this case.
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _getReceiveTask is not null)
if (_options.TransportMode is not HttpTransportMode.AutoDetect || _streamableHttpAdopted)
{
// _disconnectError is set when the server returns 404 indicating session expiry.
// When null, this is a graceful client-initiated closure (no error).