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 @@ -418,6 +418,7 @@ builder.Services.AddMcpServer()
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.Stateless> | `bool` | `true` | Enables stateless mode. No sessions, no `Mcp-Session-Id` header, no server-to-client requests on the legacy protocol. Required by the `2026-07-28` protocol revision. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableJsonResponse> | `bool` | `false` | Returns the final JSON-RPC response as `application/json` instead of an SSE stream. Intermediate request-related messages, POST resumability, and polling are unavailable in this mode. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. |
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.ConfigureSessionOptions> | `Func<HttpContext, McpServerOptions, CancellationToken, Task>?` | `null` | Per-session callback to customize `McpServerOptions` with access to `HttpContext`. In stateless mode (including all `2026-07-28` requests), this runs on every HTTP request. |
Expand Down
17 changes: 16 additions & 1 deletion docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@ app.Run();

By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown.

#### JSON response mode

By default, each Streamable HTTP POST returns an SSE stream so the server can send progress notifications and other intermediate messages before the final JSON-RPC response. Set <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.EnableJsonResponse> to `true` when an intermediary, such as a web application firewall or reverse proxy, cannot pass SSE responses:

```csharp
builder.Services.AddMcpServer()
.WithHttpTransport(options =>
{
options.EnableJsonResponse = true;
})
.WithTools<MyTools>();
```

In this mode, POST requests return the final JSON-RPC response directly with an `application/json` content type. Request-related progress notifications and other intermediate messages are omitted, event-store resumability and polling are unavailable for POST responses, and notification-only POST requests still return an empty `202 Accepted` response. Stateful servers can still use the standalone GET SSE stream for unsolicited messages.

#### Host name validation

For local HTTP servers, keep the set of accepted host names limited to loopback values. This helps protect against DNS rebinding, where a browser reaches a local server through an attacker-controlled DNS name while sending that DNS name in the HTTP `Host` header. ASP.NET Core's Kestrel server doesn't validate `Host` headers by default, so configure `AllowedHosts` with known host names rather than `"*"`. This also avoids reflecting untrusted host names through ASP.NET Core features such as absolute URL generation. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering) and [URL generation concepts | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/routing#url-generation-concepts).
Expand Down Expand Up @@ -245,7 +260,7 @@ app.MapMcp("/mcp").RequireCors("McpBrowserClient");

#### How messages flow

In Streamable HTTP, client requests arrive as HTTP POST requests. The server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. This provides natural HTTP-level backpressure: each POST holds its connection until the handler completes.
In Streamable HTTP, client requests arrive as HTTP POST requests. By default, the server holds each POST response body open as an SSE stream and writes the JSON-RPC response — plus any intermediate messages like progress notifications or server-to-client requests — back through it. JSON response mode instead returns only the final response as `application/json`. Both modes provide natural HTTP-level backpressure because each POST remains open until the handler completes.

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.

Expand Down
15 changes: 15 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ public class HttpServerTransportOptions
/// </remarks>
public bool Stateless { get; set; } = true;

/// <summary>
/// Gets or sets a value that indicates whether Streamable HTTP POST requests return a single JSON response
/// instead of an SSE stream.
/// </summary>
/// <value>
/// <see langword="true"/> to return the final JSON-RPC response as <c>application/json</c>;
/// <see langword="false"/> to use an SSE response stream. The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// JSON response mode is intended for simple request/response scenarios where intermediaries do not support SSE.
/// Request-related notifications and other intermediate messages are not included in the response. Standalone GET
/// requests continue to use SSE when stateful mode is enabled.
/// </remarks>
public bool EnableJsonResponse { get; set; }

@halter73 halter73 Jul 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EnableJsonResponse name and ordinary POST behavior match other SDKs, but those SDKs still permit SSE for cases such as subscriptions/listen or standalone GET. Is that the contract we want, or should this be an explicit mode API like ResponseMode = HttpResponseMode.JsonOnly?

For deployments where intermediaries buffer streaming responses, JsonOnly should mean no SSE anywhere. Ordinary POSTs return JSON, GET returns 405, subscriptions/listen returns a correlated MethodNotFound, and EnableLegacySse is incompatible.

We could add a JsonPreferred mode if the hybrid behavior is valuable, but I suspect disabling SSE entirely is the primary use case. Am I off base here?


/// <summary>
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
/// for backward compatibility with clients that do not support the Streamable HTTP transport.
Expand Down
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,15 @@ await WriteJsonRpcErrorAsync(context,

await using var _ = await session.AcquireReferenceAsync(context.RequestAborted);

InitializeSseResponse(context);
if (session.Transport.EnableJsonResponse)
{
context.Response.ContentType = "application/json";
}
else
{
InitializeSseResponse(context);
}

var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted);
if (!wroteResponse)
{
Expand Down Expand Up @@ -449,6 +457,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
transport = new(loggerFactory)
{
SessionId = sessionId,
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
OnSessionInitialized = HttpServerTransportOptions.SessionMigrationHandler is { } handler
Expand All @@ -468,6 +477,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
transport = new(loggerFactory)
{
Stateless = true,
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
};
}

Expand Down Expand Up @@ -524,6 +534,7 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
var transport = new StreamableHttpServerTransport(loggerFactory)
{
SessionId = sessionId,
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally.
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
Expand Down
72 changes: 57 additions & 15 deletions src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed partial class StreamableHttpPostTransport(
{
private readonly SemaphoreSlim _messageLock = new(1, 1);
private readonly TaskCompletionSource<bool> _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SseEventWriter _httpSseWriter = new(responseStream);
private readonly SseEventWriter? _httpSseWriter = parentTransport.EnableJsonResponse ? null : new(responseStream);

private TaskCompletionSource<bool>? _storeStreamTcs;
#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally.
Expand Down Expand Up @@ -71,20 +71,23 @@ public async ValueTask<bool> HandlePostAsync(JsonRpcMessage message, Cancellatio

using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
{
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
if (primingItem.HasValue)
if (!parentTransport.EnableJsonResponse)
{
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
}
else
{
// If there's no priming write, flush the stream to ensure HTTP response headers are
// sent to the client now that the server is ready to process the request.
// This prevents HttpClient timeout for long-running requests.
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
if (primingItem.HasValue)
{
await _httpSseWriter!.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
}
else
{
// If there's no priming write, flush the stream to ensure HTTP response headers are
// sent to the client now that the server is ready to process the request.
// This prevents HttpClient timeout for long-running requests.
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
}

// Ensure that we've sent the priming event before processing the incoming request.
// In SSE mode, ensure that we've sent the priming event before processing the incoming request.
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
}

Expand All @@ -108,6 +111,40 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can

try
{
if (parentTransport.EnableJsonResponse)
{
if (_finalResponseMessageSent)
{
return;
}

if ((message is JsonRpcResponse or JsonRpcError) && ((JsonRpcMessageWithId)message).Id == _pendingRequest)
{
try
{
if (!_httpResponseCompleted)
{
await JsonSerializer.SerializeAsync(
responseStream,
message,
McpJsonUtilities.JsonContext.Default.JsonRpcMessage,
cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
_httpResponseTcs.TrySetException(ex);
}
finally
{
_finalResponseMessageSent = true;
_httpResponseTcs.TrySetResult(true);
}
}

// JSON mode only returns the final correlated response. Intermediate messages are omitted.
return;
}

if (_finalResponseMessageSent)
{
Expand All @@ -130,7 +167,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can

try
{
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
await _httpSseWriter!.WriteAsync(item, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
{
Expand All @@ -152,6 +189,11 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can

public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationToken cancellationToken)
{
if (parentTransport.EnableJsonResponse)
{
throw new InvalidOperationException("Polling is not supported when JSON responses are enabled.");
}

if (parentTransport.Stateless)
{
throw new InvalidOperationException("Polling is not supported in stateless mode.");
Expand All @@ -173,7 +215,7 @@ public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationTo
// Write to the response stream if it still exists.
if (!_httpResponseCompleted)
{
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
await _httpSseWriter!.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
}

// Set the mode to 'Polling' so that the replay stream ends as soon as all available messages have been sent.
Expand Down Expand Up @@ -240,7 +282,7 @@ public async ValueTask DisposeAsync()

_httpResponseTcs.TrySetResult(true);

_httpSseWriter.Dispose();
_httpSseWriter?.Dispose();

// Don't dispose the event stream writer here, as we may continue to write to the event store
// after disposal if there are pending messages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null)
/// </summary>
public bool Stateless { get; init; }

/// <summary>
/// Gets or initializes a value that indicates whether POST requests return the final JSON-RPC response as JSON
/// instead of streaming messages as SSE events.
/// </summary>
/// <value>
/// <see langword="true"/> to write a single JSON response; <see langword="false"/> to use SSE. The default is
/// <see langword="false"/>.
/// </value>
/// <remarks>
/// When enabled, request-related notifications and other intermediate messages are omitted from POST responses.
/// Standalone GET requests are unaffected and continue to use SSE.
/// </remarks>
public bool EnableJsonResponse { get; init; }

/// <summary>
/// Gets or initializes a value indicating whether the execution context should flow from the calls to <see cref="HandlePostRequestAsync(JsonRpcMessage, Stream, CancellationToken)"/>
/// to the corresponding <see cref="JsonRpcMessageContext.ExecutionContext"/> property contained in the <see cref="JsonRpcMessage"/> instances returned by the <see cref="MessageReader"/>.
Expand Down
Loading