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"); + } } }