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:
parent
2ca96f548b
commit
6af05d0bf2
5 changed files with 210 additions and 3 deletions
|
|
@ -373,6 +373,7 @@ The following <xref:ModelContextProtocol.Client.HttpClientTransportOptions> prop
|
||||||
| Property | Default | Description |
|
| Property | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| <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. |
|
| <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. |
|
||||||
|
| <xref:ModelContextProtocol.Client.HttpClientTransportOptions.EnableStandaloneGetStream> | `true` | Opens the standalone GET stream for unsolicited messages. Set to `false` to skip it; POST request/response streaming still works. |
|
||||||
| <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. |
|
| <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. |
|
||||||
| <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. |
|
| <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. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 <xref:ModelContextProtocol.Client.HttpClientTransportOptions.EnableStandaloneGetStream> to `false`. 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:
|
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
|
[AspNetCoreMcpPerSessionTools]: https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,24 @@ public sealed class HttpClientTransportOptions
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string? KnownSessionId { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose.
|
/// Gets or sets a value indicating whether this transport endpoint is responsible for ending the session on dispose.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
|
|
||||||
private string? _negotiatedProtocolVersion;
|
private string? _negotiatedProtocolVersion;
|
||||||
private Task? _getReceiveTask;
|
private Task? _getReceiveTask;
|
||||||
|
private bool _streamableHttpAdopted;
|
||||||
private volatile ClientTransportClosedException? _disconnectError;
|
private volatile ClientTransportClosedException? _disconnectError;
|
||||||
|
|
||||||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||||
|
|
@ -54,7 +55,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
if (_options.KnownSessionId is { } knownSessionId)
|
if (_options.KnownSessionId is { } knownSessionId)
|
||||||
{
|
{
|
||||||
SessionId = 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);
|
var initializeResult = JsonSerializer.Deserialize(initResponse.Result, McpJsonUtilities.JsonContext.Default.InitializeResult);
|
||||||
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
|
_negotiatedProtocolVersion = initializeResult?.ProtocolVersion;
|
||||||
|
|
||||||
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
|
_streamableHttpAdopted = true;
|
||||||
|
StartUnsolicitedMessageStreamIfEnabled();
|
||||||
}
|
}
|
||||||
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
|
else if (rpcRequest.Method == RequestMethods.ServerDiscover && rpcResponseOrError is JsonRpcResponse)
|
||||||
{
|
{
|
||||||
|
|
@ -241,6 +244,14 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StartUnsolicitedMessageStreamIfEnabled()
|
||||||
|
{
|
||||||
|
if (_options.EnableStandaloneGetStream)
|
||||||
|
{
|
||||||
|
_getReceiveTask ??= ReceiveUnsolicitedMessagesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the protocol version from a request's <c>_meta/io.modelcontextprotocol/protocolVersion</c> field,
|
/// 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
|
/// 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.
|
// 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.
|
// 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.
|
// _disconnectError is set when the server returns 404 indicating session expiry.
|
||||||
// When null, this is a graceful client-initiated closure (no error).
|
// When null, this is a graceful client-initiated closure (no error).
|
||||||
|
|
|
||||||
|
|
@ -411,4 +411,179 @@ public class HttpClientTransportTests : LoggedTest
|
||||||
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
|
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
|
||||||
Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount);
|
Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamableHttp_DisablingStandaloneGetStream_DoesNotOpenGetSseAfterInitialize()
|
||||||
|
{
|
||||||
|
var getRequestReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var options = new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:8080"),
|
||||||
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
|
EnableStandaloneGetStream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Assert.False(getRequestReceived.Task.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamableHttp_DisablingStandaloneGetStream_DoesNotOpenGetSseForKnownSessionId()
|
||||||
|
{
|
||||||
|
var getRequestReceived = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
var options = new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:8080"),
|
||||||
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
|
KnownSessionId = "test-session",
|
||||||
|
EnableStandaloneGetStream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
Assert.False(getRequestReceived.Task.IsCompleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AutoDetect_DisablingStandaloneGetStream_DisposeCompletesWithHttpDetails()
|
||||||
|
{
|
||||||
|
var options = new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:8080"),
|
||||||
|
TransportMode = HttpTransportMode.AutoDetect,
|
||||||
|
EnableStandaloneGetStream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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).WaitAsync(
|
||||||
|
TestConstants.DefaultTimeout,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(session.MessageReader.TryRead(out var initializeResponse));
|
||||||
|
Assert.IsType<JsonRpcResponse>(initializeResponse);
|
||||||
|
|
||||||
|
await session.DisposeAsync().AsTask().WaitAsync(
|
||||||
|
TestConstants.DefaultTimeout,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(session.MessageReader.Completion.IsCompleted);
|
||||||
|
var exception = await Assert.ThrowsAsync<ClientTransportClosedException>(
|
||||||
|
async () => await session.MessageReader.Completion);
|
||||||
|
Assert.IsType<HttpClientCompletionDetails>(exception.Details);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamableHttp_DisablingStandaloneGetStream_StillProcessesPostSseResponses()
|
||||||
|
{
|
||||||
|
var options = new HttpClientTransportOptions
|
||||||
|
{
|
||||||
|
Endpoint = new Uri("http://localhost:8080"),
|
||||||
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
|
EnableStandaloneGetStream = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue