From 43263a6d16a780f1870a11c789a8266e7531fa9e Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 5 Aug 2026 05:34:06 -0700 Subject: [PATCH] Make the deferred header flush grace configurable The SEP-2575 HTTP status mapping only works while the response headers are still uncommitted. StreamableHttpPostTransport bounded that wait at a fixed 250 ms, so a dispatch slower than the window committed a default 200 and a later JSON-RPC error rode the committed status. That made the mapped status a function of machine scheduling rather than of server behavior. Expose the window as DeferredHeaderFlushGrace on StreamableHttpServerTransport and HttpServerTransportOptions, defaulting to the historical 250 ms so the out-of-the-box behavior is unchanged. Timeout.InfiniteTimeSpan never forces the flush, which makes the mapping deterministic. The raw HTTP conformance tests now disable the bound, and two tests pin both halves of the tradeoff against a handler that runs past the old window. --- .../HttpServerTransportOptions.cs | 17 ++++ .../StreamableHttpHandler.cs | 3 + .../Server/StreamableHttpPostTransport.cs | 15 +++- .../Server/StreamableHttpServerTransport.cs | 20 ++++- .../RawHttpConformanceTests.cs | 78 ++++++++++++++++++- 5 files changed, 127 insertions(+), 6 deletions(-) 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"); + } } }