Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/concepts/stateless/stateless.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ The following <xref:ModelContextProtocol.Client.HttpClientTransportOptions> prop
| 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.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.AdditionalHeaders> | `null` | Custom headers included in all requests (e.g., for authentication). These are sent alongside the automatic `Mcp-Session-Id` header. |

Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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:

[AspNetCoreMcpPerSessionTools]: https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/AspNetCoreMcpPerSessionTools
Expand Down
18 changes: 18 additions & 0 deletions src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ public required Uri Endpoint
/// </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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -54,7 +55,8 @@ public StreamableHttpClientSessionTransport(
if (_options.KnownSessionId is { } knownSessionId)
{
SessionId = knownSessionId;
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
_streamableHttpAdopted = true;
StartUnsolicitedMessageStreamIfEnabled();
}
}

Expand Down Expand Up @@ -228,7 +230,8 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
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)
{
Expand All @@ -241,6 +244,14 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
return response;
}

private void StartUnsolicitedMessageStreamIfEnabled()
Comment thread
jstar0 marked this conversation as resolved.
{
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
Expand Down Expand Up @@ -298,7 +309,7 @@ public override async ValueTask DisposeAsync()
{
// 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).
Expand Down
175 changes: 175 additions & 0 deletions tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -411,4 +411,179 @@ await session.SendMessageAsync(
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
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