diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
index 024772240..17da7e248 100644
--- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
+++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
@@ -179,6 +179,23 @@ public class HttpServerTransportOptions
[Obsolete(Obsoletions.LegacyStatefulHttp_Message, DiagnosticId = Obsoletions.LegacyStatefulHttp_DiagnosticId, UrlFormat = Obsoletions.LegacyStatefulHttp_Url)]
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromHours(2);
+ ///
+ /// Gets or sets how long the HTTP response headers may stay uncommitted while waiting for the first
+ /// response message, so that an immediate JSON-RPC error can still choose the HTTP status line (SEP-2575).
+ ///
+ ///
+ /// The default is 250 milliseconds. Use to never force the flush,
+ /// which makes the SEP-2575 status mapping independent of how long dispatch takes.
+ ///
+ ///
+ /// Once the headers are committed the status line is fixed, so a JSON-RPC error produced after this window
+ /// elapses is returned over an already-committed 200 OK. Under load a handler can exceed the window,
+ /// which makes the status a function of machine scheduling rather than of server behavior. Raising the window
+ /// (or disabling it with ) trades how quickly clients see response
+ /// headers for a deterministic status mapping.
+ ///
+ public TimeSpan DeferredHeaderFlushGrace { get; set; } = TimeSpan.FromMilliseconds(250);
+
///
/// Gets or sets the maximum number of idle sessions to track in memory. This value is used to limit the number of sessions that can be idle at once.
///
diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
index f0b0b1a12..3a1092fb1 100644
--- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
+++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
@@ -505,6 +505,7 @@ private async ValueTask StartNewSessionAsync(HttpContext
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
+ DeferredHeaderFlushGrace = HttpServerTransportOptions.DeferredHeaderFlushGrace,
OnSessionInitialized = HttpServerTransportOptions.SessionMigrationHandler is { } handler
? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct)
: null,
@@ -522,6 +523,7 @@ private async ValueTask StartNewSessionAsync(HttpContext
transport = new(loggerFactory)
{
Stateless = true,
+ DeferredHeaderFlushGrace = HttpServerTransportOptions.DeferredHeaderFlushGrace,
};
}
@@ -582,6 +584,7 @@ private async ValueTask MigrateSessionAsync(
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
#pragma warning restore MCP9006
+ DeferredHeaderFlushGrace = HttpServerTransportOptions.DeferredHeaderFlushGrace,
};
// Initialize the transport with the migrated session's init params.
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
index 95411f7e2..e70be1552 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
@@ -16,6 +16,7 @@ internal sealed partial class StreamableHttpPostTransport(
Stream responseStream,
CancellationToken sessionCancellationToken,
ILogger logger,
+ TimeSpan deferredHeaderFlushGrace,
Func? onResponseStarting = null) : ITransport
{
private readonly SemaphoreSlim _messageLock = new(1, 1);
@@ -137,12 +138,17 @@ public async ValueTask HandlePostAsync(JsonRpcMessage message, Cancellatio
/// window, so the response-starting callback can still map their JSON-RPC error codes onto the
/// HTTP status line; a handler that runs longer commits the headers here so clients see them
/// promptly (long-running tool calls must not trip HttpClient's response timeout).
+ ///
+ /// When the grace window is the flush is never forced:
+ /// the headers stay uncommitted until the first response message arrives, so the JSON-RPC error
+ /// code always reaches the status line no matter how long dispatch took.
+ ///
///
private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken)
{
try
{
- await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false);
+ await Task.Delay(deferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false);
using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false);
if (!_httpResponseStarted && !_httpResponseCompleted)
{
@@ -166,8 +172,11 @@ private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken)
}
}
- /// How long the response-header flush may be deferred waiting for the first response message.
- internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
+ ///
+ /// The default grace window applied when the owning transport does not specify one. Kept as the
+ /// historical 250 ms so the out-of-the-box behavior is unchanged.
+ ///
+ internal static readonly TimeSpan DefaultDeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
///
/// Invokes the response-starting callback exactly once, immediately before the first write to
diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
index f143eaaa7..8c7a6184c 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
@@ -78,6 +78,24 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null)
///
public bool FlowExecutionContextFromRequests { get; init; }
+ ///
+ /// Gets or initializes how long the HTTP response headers may stay uncommitted while waiting for the
+ /// first response message, so that an immediate JSON-RPC error can still choose the HTTP status line
+ /// (SEP-2575).
+ ///
+ ///
+ /// The default is 250 milliseconds. Use to never force the
+ /// flush, which makes the SEP-2575 status mapping independent of how long dispatch takes.
+ ///
+ ///
+ /// Once the headers are committed the status line is fixed, so a JSON-RPC error produced after this
+ /// window elapses is returned over an already-committed 200 OK. Under load a handler can
+ /// exceed the window, which makes the status a function of machine scheduling rather than of server
+ /// behavior. Raising the window (or disabling it with ) trades
+ /// how quickly clients see response headers for a deterministic status mapping.
+ ///
+ public TimeSpan DeferredHeaderFlushGrace { get; init; } = StreamableHttpPostTransport.DefaultDeferredHeaderFlushGrace;
+
///
/// Gets or sets the event store for resumability support.
/// When set, events are stored and can be replayed when clients reconnect with a Last-Event-ID header.
@@ -239,7 +257,7 @@ public async Task HandlePostRequestAsync(JsonRpcMessage message, Stream re
Throw.IfNull(message);
Throw.IfNull(responseStream);
- var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger, onResponseStarting);
+ var postTransport = new StreamableHttpPostTransport(this, responseStream, _transportDisposedCts.Token, _logger, DeferredHeaderFlushGrace, onResponseStarting);
using var postCts = CancellationTokenSource.CreateLinkedTokenSource(_transportDisposedCts.Token, cancellationToken);
await using (postTransport.ConfigureAwait(false))
{
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
index 8520f929c..78bcd2b7f 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
@@ -23,7 +23,7 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
private WebApplication? _app;
- private async Task StartAsync(string? protocolVersion = null)
+ private async Task StartAsync(string? protocolVersion = null, TimeSpan? deferredHeaderFlushGrace = null)
{
Builder.Services
.AddMcpServer(options =>
@@ -31,7 +31,13 @@ private async Task StartAsync(string? protocolVersion = null)
options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" };
options.ProtocolVersion = protocolVersion;
})
- .WithHttpTransport()
+ // These tests assert the SEP-2575 status mapping, which is only well defined while the
+ // response headers are still uncommitted. The default grace window bounds that wait at
+ // 250ms, so a dispatch slower than the window commits a default 200 and the assertions
+ // become a function of machine load rather than of server behavior. Disabling the bound
+ // keeps the headers uncommitted until the first response message arrives.
+ .WithHttpTransport(options =>
+ options.DeferredHeaderFlushGrace = deferredHeaderFlushGrace ?? Timeout.InfiniteTimeSpan)
.WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })])
.WithTools();
@@ -207,6 +213,59 @@ public async Task July2026Post_MissingRequiredCapability_Returns400()
Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue());
}
+ ///
+ /// Regression test for the load-sensitivity in the SEP-2575 status mapping. The handler runs far past
+ /// the historical 250ms grace window before it produces the JSON-RPC error. With the bound disabled the
+ /// headers stay uncommitted, so the error still selects the 400 status line instead of riding a
+ /// default 200 that the grace window had already committed.
+ ///
+ [Fact]
+ public async Task July2026Post_SlowHandler_MissingRequiredCapability_StillReturns400()
+ {
+ await StartAsync(deferredHeaderFlushGrace: Timeout.InfiniteTimeSpan);
+
+ var body =
+ @"{""jsonrpc"":""2.0"",""id"":42,""method"":""tools/call"",""params"":{""name"":""slow_requires_sampling"",""arguments"":{}," +
+ July2026ProtocolMetaFragment() + "}}";
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
+ request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "slow_requires_sampling");
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
+ Assert.Equal(42, json["id"]!.GetValue());
+ Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue());
+ }
+
+ ///
+ /// The complementary half of the contract. A zero grace window commits the headers before the handler
+ /// can produce its error, so the JSON-RPC error rides an already-committed 200. This pins the tradeoff
+ /// the grace window exists to make, so the knob cannot be quietly turned into a no-op.
+ ///
+ [Fact]
+ public async Task July2026Post_ZeroGrace_CommitsDefaultStatusBeforeSlowHandlerError()
+ {
+ await StartAsync(deferredHeaderFlushGrace: TimeSpan.Zero);
+
+ var body =
+ @"{""jsonrpc"":""2.0"",""id"":43,""method"":""tools/call"",""params"":{""name"":""slow_requires_sampling"",""arguments"":{}," +
+ July2026ProtocolMetaFragment() + "}}";
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) };
+ request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion);
+ request.Headers.Add("Mcp-Method", "tools/call");
+ request.Headers.Add("Mcp-Name", "slow_requires_sampling");
+ using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
+ Assert.Equal(43, json["id"]!.GetValue());
+ Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue());
+ }
+
[Fact]
public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion()
{
@@ -500,10 +559,25 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali
[McpServerToolType]
private sealed class CapabilityTools
{
+ ///
+ /// The historical deferred-header-flush grace window. The slow tool below runs well past it so
+ /// the test cannot pass by accident on a fast machine.
+ ///
+ public static readonly TimeSpan PastDefaultGrace = TimeSpan.FromMilliseconds(1000);
+
[McpServerTool(Name = "requires_sampling")]
public static string RequiresSampling() =>
throw new MissingRequiredClientCapabilityException(
new ClientCapabilities { Sampling = new() },
"sampling capability required but not declared by client");
+
+ [McpServerTool(Name = "slow_requires_sampling")]
+ public static async Task SlowRequiresSampling(CancellationToken cancellationToken)
+ {
+ await Task.Delay(PastDefaultGrace, cancellationToken);
+ throw new MissingRequiredClientCapabilityException(
+ new ClientCapabilities { Sampling = new() },
+ "sampling capability required but not declared by client");
+ }
}
}