Skip to content

Commit 755707a

Browse files
author
Loi Nguyen
committed
feat: add JSON response mode for Streamable HTTP
1 parent 0d34048 commit 755707a

7 files changed

Lines changed: 316 additions & 17 deletions

File tree

docs/concepts/stateless/stateless.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ builder.Services.AddMcpServer()
418418
| Property | Type | Default | Description |
419419
|----------|------|---------|-------------|
420420
| <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. |
421+
| <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. |
421422
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.IdleTimeout> | `TimeSpan` | 2 hours | _Stateful only (`MCP9006`)._ Duration of inactivity before a session is closed. Checked every 5 seconds. |
422423
| <xref:ModelContextProtocol.AspNetCore.HttpServerTransportOptions.MaxIdleSessionCount> | `int` | 10,000 | _Stateful only (`MCP9006`)._ Maximum idle sessions before the oldest are forcibly terminated. |
423424
| <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. |

docs/concepts/transports/transports.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,21 @@ app.Run();
185185

186186
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.
187187

188+
#### JSON response mode
189+
190+
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:
191+
192+
```csharp
193+
builder.Services.AddMcpServer()
194+
.WithHttpTransport(options =>
195+
{
196+
options.EnableJsonResponse = true;
197+
})
198+
.WithTools<MyTools>();
199+
```
200+
201+
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.
202+
188203
#### Host name validation
189204

190205
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).
@@ -245,7 +260,7 @@ app.MapMcp("/mcp").RequireCors("McpBrowserClient");
245260

246261
#### How messages flow
247262

248-
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.
263+
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.
249264

250265
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.
251266

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ public class HttpServerTransportOptions
7272
/// </remarks>
7373
public bool Stateless { get; set; } = true;
7474

75+
/// <summary>
76+
/// Gets or sets a value that indicates whether Streamable HTTP POST requests return a single JSON response
77+
/// instead of an SSE stream.
78+
/// </summary>
79+
/// <value>
80+
/// <see langword="true"/> to return the final JSON-RPC response as <c>application/json</c>;
81+
/// <see langword="false"/> to use an SSE response stream. The default is <see langword="false"/>.
82+
/// </value>
83+
/// <remarks>
84+
/// JSON response mode is intended for simple request/response scenarios where intermediaries do not support SSE.
85+
/// Request-related notifications and other intermediate messages are not included in the response. Standalone GET
86+
/// requests continue to use SSE when stateful mode is enabled.
87+
/// </remarks>
88+
public bool EnableJsonResponse { get; set; }
89+
7590
/// <summary>
7691
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
7792
/// for backward compatibility with clients that do not support the Streamable HTTP transport.

src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,15 @@ await WriteJsonRpcErrorAsync(context,
124124

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

127-
InitializeSseResponse(context);
127+
if (session.Transport.EnableJsonResponse)
128+
{
129+
context.Response.ContentType = "application/json";
130+
}
131+
else
132+
{
133+
InitializeSseResponse(context);
134+
}
135+
128136
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, context.RequestAborted);
129137
if (!wroteResponse)
130138
{
@@ -449,6 +457,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
449457
transport = new(loggerFactory)
450458
{
451459
SessionId = sessionId,
460+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
452461
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
453462
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
454463
OnSessionInitialized = HttpServerTransportOptions.SessionMigrationHandler is { } handler
@@ -468,6 +477,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
468477
transport = new(loggerFactory)
469478
{
470479
Stateless = true,
480+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
471481
};
472482
}
473483

@@ -524,6 +534,7 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
524534
var transport = new StreamableHttpServerTransport(loggerFactory)
525535
{
526536
SessionId = sessionId,
537+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
527538
#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally.
528539
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
529540
EventStreamStore = HttpServerTransportOptions.EventStreamStore,

src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ internal sealed partial class StreamableHttpPostTransport(
1919
{
2020
private readonly SemaphoreSlim _messageLock = new(1, 1);
2121
private readonly TaskCompletionSource<bool> _httpResponseTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
22-
private readonly SseEventWriter _httpSseWriter = new(responseStream);
22+
private readonly SseEventWriter? _httpSseWriter = parentTransport.EnableJsonResponse ? null : new(responseStream);
2323

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

7272
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
7373
{
74-
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
75-
if (primingItem.HasValue)
74+
if (!parentTransport.EnableJsonResponse)
7675
{
77-
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
78-
}
79-
else
80-
{
81-
// If there's no priming write, flush the stream to ensure HTTP response headers are
82-
// sent to the client now that the server is ready to process the request.
83-
// This prevents HttpClient timeout for long-running requests.
84-
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
76+
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
77+
if (primingItem.HasValue)
78+
{
79+
await _httpSseWriter!.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
80+
}
81+
else
82+
{
83+
// If there's no priming write, flush the stream to ensure HTTP response headers are
84+
// sent to the client now that the server is ready to process the request.
85+
// This prevents HttpClient timeout for long-running requests.
86+
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
87+
}
8588
}
8689

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

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

109112
try
110113
{
114+
if (parentTransport.EnableJsonResponse)
115+
{
116+
if (_finalResponseMessageSent)
117+
{
118+
return;
119+
}
120+
121+
if ((message is JsonRpcResponse or JsonRpcError) && ((JsonRpcMessageWithId)message).Id == _pendingRequest)
122+
{
123+
try
124+
{
125+
if (!_httpResponseCompleted)
126+
{
127+
await JsonSerializer.SerializeAsync(
128+
responseStream,
129+
message,
130+
McpJsonUtilities.JsonContext.Default.JsonRpcMessage,
131+
cancellationToken).ConfigureAwait(false);
132+
}
133+
}
134+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
135+
{
136+
_httpResponseTcs.TrySetException(ex);
137+
}
138+
finally
139+
{
140+
_finalResponseMessageSent = true;
141+
_httpResponseTcs.TrySetResult(true);
142+
}
143+
}
144+
145+
// JSON mode only returns the final correlated response. Intermediate messages are omitted.
146+
return;
147+
}
111148

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

131168
try
132169
{
133-
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
170+
await _httpSseWriter!.WriteAsync(item, cancellationToken).ConfigureAwait(false);
134171
}
135172
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
136173
{
@@ -152,6 +189,11 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
152189

153190
public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationToken cancellationToken)
154191
{
192+
if (parentTransport.EnableJsonResponse)
193+
{
194+
throw new InvalidOperationException("Polling is not supported when JSON responses are enabled.");
195+
}
196+
155197
if (parentTransport.Stateless)
156198
{
157199
throw new InvalidOperationException("Polling is not supported in stateless mode.");
@@ -173,7 +215,7 @@ public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationTo
173215
// Write to the response stream if it still exists.
174216
if (!_httpResponseCompleted)
175217
{
176-
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
218+
await _httpSseWriter!.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
177219
}
178220

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

241283
_httpResponseTcs.TrySetResult(true);
242284

243-
_httpSseWriter.Dispose();
285+
_httpSseWriter?.Dispose();
244286

245287
// Don't dispose the event stream writer here, as we may continue to write to the event store
246288
// after disposal if there are pending messages.

src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null)
6969
/// </summary>
7070
public bool Stateless { get; init; }
7171

72+
/// <summary>
73+
/// Gets or initializes a value that indicates whether POST requests return the final JSON-RPC response as JSON
74+
/// instead of streaming messages as SSE events.
75+
/// </summary>
76+
/// <value>
77+
/// <see langword="true"/> to write a single JSON response; <see langword="false"/> to use SSE. The default is
78+
/// <see langword="false"/>.
79+
/// </value>
80+
/// <remarks>
81+
/// When enabled, request-related notifications and other intermediate messages are omitted from POST responses.
82+
/// Standalone GET requests are unaffected and continue to use SSE.
83+
/// </remarks>
84+
public bool EnableJsonResponse { get; init; }
85+
7286
/// <summary>
7387
/// Gets or initializes a value indicating whether the execution context should flow from the calls to <see cref="HandlePostRequestAsync(JsonRpcMessage, Stream, CancellationToken)"/>
7488
/// to the corresponding <see cref="JsonRpcMessageContext.ExecutionContext"/> property contained in the <see cref="JsonRpcMessage"/> instances returned by the <see cref="MessageReader"/>.

0 commit comments

Comments
 (0)