Skip to content
Open
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.DisableStandaloneStreaming> | `false` | Skips the standalone GET stream for unsolicited messages. 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.DisableStandaloneStreaming> 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
Expand Down
10 changes: 10 additions & 0 deletions src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ public required Uri Endpoint
/// </remarks>
public string? KnownSessionId { get; set; }

/// <summary>
/// Gets or sets a value indicating whether the Streamable HTTP transport skips the standalone GET SSE stream.
/// </summary>
/// <remarks>
/// Set this to <see langword="true"/> 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.
/// </remarks>
public bool DisableStandaloneStreaming { get; set; }

/// <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 @@ -54,7 +54,7 @@ public StreamableHttpClientSessionTransport(
if (_options.KnownSessionId is { } knownSessionId)
{
SessionId = knownSessionId;
_getReceiveTask = ReceiveUnsolicitedMessagesAsync();
StartUnsolicitedMessageStreamIfEnabled();
}
}

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

private void StartUnsolicitedMessageStreamIfEnabled()
{
if (!_options.DisableStandaloneStreaming)
{
_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
Original file line number Diff line number Diff line change
Expand Up @@ -377,4 +377,129 @@ await session.SendMessageAsync(
// Assert - Total GET requests = 1 initial connection + MaxReconnectionAttempts reconnections.
Assert.Equal(1 + MaxReconnectionAttempts, getRequestCount);
}
}

[Fact]
public async Task StreamableHttp_DisableStandaloneStreaming_DoesNotOpenGetSseAfterInitialize()
{
var getRequestReceived = new TaskCompletionSource<bool>(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<bool>(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);
}
}