Skip to content

Commit cb711d3

Browse files
Loi Nguyenlntutor
authored andcommitted
feat: add JSON response mode for Streamable HTTP
1 parent 155f619 commit cb711d3

7 files changed

Lines changed: 349 additions & 22 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. 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, see [Stateless and Stateful](xref:stateless).
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).
@@ -244,7 +259,7 @@ app.MapMcp("/mcp").RequireCors("McpBrowserClient");
244259

245260
#### How messages flow
246261

247-
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.
262+
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.
248263

249264
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 (for example, 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. For a detailed breakdown, see [How Streamable HTTP delivers messages](xref:stateless#how-streamable-http-delivers-messages).
250265

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
@@ -178,7 +178,15 @@ await WriteJsonRpcErrorAsync(context,
178178
};
179179
}
180180

181-
InitializeSseResponse(context);
181+
if (session.Transport.EnableJsonResponse)
182+
{
183+
context.Response.ContentType = "application/json";
184+
}
185+
else
186+
{
187+
InitializeSseResponse(context);
188+
}
189+
182190
var wroteResponse = await session.Transport.HandlePostRequestAsync(message, context.Response.Body, onResponseStarting, context.RequestAborted);
183191
if (!wroteResponse)
184192
{
@@ -503,6 +511,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
503511
transport = new(loggerFactory)
504512
{
505513
SessionId = sessionId,
514+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
506515
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
507516
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
508517
OnSessionInitialized = HttpServerTransportOptions.SessionMigrationHandler is { } handler
@@ -522,6 +531,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
522531
transport = new(loggerFactory)
523532
{
524533
Stateless = true,
534+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
525535
};
526536
}
527537

@@ -578,6 +588,7 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
578588
var transport = new StreamableHttpServerTransport(loggerFactory)
579589
{
580590
SessionId = sessionId,
591+
EnableJsonResponse = HttpServerTransportOptions.EnableJsonResponse,
581592
#pragma warning disable MCP9006 // Stateful Streamable HTTP options are obsolete but still wired up internally.
582593
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
583594
EventStreamStore = HttpServerTransportOptions.EventStreamStore,

src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs

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

2525
private TaskCompletionSource<bool>? _storeStreamTcs;
2626
#pragma warning disable MCP9006 // Stateful Streamable HTTP resumability types are obsolete but still wired up internally.
@@ -81,25 +81,28 @@ public async ValueTask<bool> HandlePostAsync(JsonRpcMessage message, Cancellatio
8181
bool deferHeaderFlush = false;
8282
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
8383
{
84-
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
85-
if (primingItem.HasValue)
84+
if (!parentTransport.EnableJsonResponse)
8685
{
87-
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
88-
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
89-
}
90-
else if (onResponseStarting is null)
91-
{
92-
// If there's no priming write, flush the stream to ensure HTTP response headers are
93-
// sent to the client now that the server is ready to process the request.
94-
// This prevents HttpClient timeout for long-running requests.
95-
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
96-
}
97-
else
98-
{
99-
deferHeaderFlush = true;
86+
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
87+
if (primingItem.HasValue)
88+
{
89+
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
90+
await _httpSseWriter!.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
91+
}
92+
else if (onResponseStarting is null)
93+
{
94+
// If there's no priming write, flush the stream to ensure HTTP response headers are
95+
// sent to the client now that the server is ready to process the request.
96+
// This prevents HttpClient timeout for long-running requests.
97+
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
98+
}
99+
else
100+
{
101+
deferHeaderFlush = true;
102+
}
100103
}
101104

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

@@ -200,6 +203,41 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
200203

201204
try
202205
{
206+
if (parentTransport.EnableJsonResponse)
207+
{
208+
if (_finalResponseMessageSent)
209+
{
210+
return;
211+
}
212+
213+
if ((message is JsonRpcResponse or JsonRpcError) && ((JsonRpcMessageWithId)message).Id == _pendingRequest)
214+
{
215+
try
216+
{
217+
if (!_httpResponseCompleted)
218+
{
219+
await NotifyResponseStartingAsync(message).ConfigureAwait(false);
220+
await JsonSerializer.SerializeAsync(
221+
responseStream,
222+
message,
223+
McpJsonUtilities.JsonContext.Default.JsonRpcMessage,
224+
cancellationToken).ConfigureAwait(false);
225+
}
226+
}
227+
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
228+
{
229+
_httpResponseTcs.TrySetException(ex);
230+
}
231+
finally
232+
{
233+
_finalResponseMessageSent = true;
234+
_httpResponseTcs.TrySetResult(true);
235+
}
236+
}
237+
238+
// JSON mode only returns the final correlated response. Intermediate messages are omitted.
239+
return;
240+
}
203241

204242
if (_finalResponseMessageSent)
205243
{
@@ -223,7 +261,7 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
223261
try
224262
{
225263
await NotifyResponseStartingAsync(message).ConfigureAwait(false);
226-
await _httpSseWriter.WriteAsync(item, cancellationToken).ConfigureAwait(false);
264+
await _httpSseWriter!.WriteAsync(item, cancellationToken).ConfigureAwait(false);
227265
}
228266
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
229267
{
@@ -245,6 +283,11 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can
245283

246284
public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationToken cancellationToken)
247285
{
286+
if (parentTransport.EnableJsonResponse)
287+
{
288+
throw new InvalidOperationException("Polling is not supported when JSON responses are enabled.");
289+
}
290+
248291
if (parentTransport.Stateless)
249292
{
250293
throw new InvalidOperationException("Polling is not supported in stateless mode.");
@@ -267,7 +310,7 @@ public async ValueTask EnablePollingAsync(TimeSpan retryInterval, CancellationTo
267310
if (!_httpResponseCompleted)
268311
{
269312
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
270-
await _httpSseWriter.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
313+
await _httpSseWriter!.WriteAsync(primingItem, cancellationToken).ConfigureAwait(false);
271314
}
272315

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

335378
_httpResponseTcs.TrySetResult(true);
336379

337-
_httpSseWriter.Dispose();
380+
_httpSseWriter?.Dispose();
338381

339382
// Don't dispose the event stream writer here, as we may continue to write to the event store
340383
// 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)