diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs index 95411f7e2..5b105c3fe 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs @@ -76,9 +76,6 @@ public async ValueTask HandlePostAsync(JsonRpcMessage message, Cancellatio return false; } - CancellationTokenSource? deferredFlushCts = null; - Task? deferredFlushTask = null; - bool deferHeaderFlush = false; using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false)) { var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false); @@ -87,88 +84,33 @@ public async ValueTask HandlePostAsync(JsonRpcMessage message, Cancellatio await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false); } - else if (onResponseStarting is null) + else if (onResponseStarting is not null && + McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion)) { - // If there's no priming write, flush the stream to ensure HTTP response headers are - // sent to the client now that the server is ready to process the request. - // This prevents HttpClient timeout for long-running requests. - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + // Per-request-metadata protocol revisions map the first JSON-RPC error onto the HTTP + // status line. Keep the headers uncommitted until that message arrives so the mapping + // cannot depend on how long dispatch takes. } else { - deferHeaderFlush = true; + // Earlier protocol revisions keep the eager header flush for long-running handlers. + // Mark the response as started before flushing because any later JSON-RPC error must + // not attempt to change an already committed status line. + await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); + await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } // Ensure that we've sent the priming event before processing the incoming request. await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false); } - if (deferHeaderFlush) - { - // Defer the flush (and the header commit it implies) so the callback can still choose - // the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period - // only after the request has been queued for dispatch. - deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token); - } - - try - { - // Wait for the response to be written before returning from the handler. - // This keeps the HTTP response open until the final response message is sent. - await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - if (deferredFlushCts is not null) - { - deferredFlushCts.Cancel(); - await deferredFlushTask!.ConfigureAwait(false); - deferredFlushCts.Dispose(); - } - } + // Wait for the response to be written before returning from the handler. + // This keeps the HTTP response open until the final response message is sent. + await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); return true; } - /// - /// Bounds the deferred header flush: after a short grace window, flushes the response headers - /// if no response message has been written yet. Immediate rejections land well inside the - /// 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). - /// - private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken) - { - try - { - await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false); - using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false); - if (!_httpResponseStarted && !_httpResponseCompleted) - { - await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false); - await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // The response was written or the request ended before the grace window elapsed. - } - catch (Exception ex) - { - // Surface the failure to the awaiting HandlePostAsync when possible. If the response - // future has already been resolved (the response started or completed on another path), - // TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable. - if (!_httpResponseTcs.TrySetException(ex)) - { - LogDeferredHeaderFlushFailed(ex); - } - } - } - - /// How long the response-header flush may be deferred waiting for the first response message. - internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250); - /// /// Invokes the response-starting callback exactly once, immediately before the first write to /// the HTTP response stream, so the HTTP application can still set the response status line. @@ -343,6 +285,4 @@ public async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")] private partial void LogStoreStreamDisposalFailed(Exception exception); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")] - private partial void LogDeferredHeaderFlushFailed(Exception exception); } diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs index f143eaaa7..7f9a1958a 100644 --- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs +++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs @@ -216,14 +216,14 @@ public Task HandlePostRequestAsync(JsonRpcMessage message, Stream response /// This overload additionally reports the first JSON-RPC message written to the response via /// , before any response bytes are written, so the HTTP /// application can still choose the response status line (SEP-2575 maps some JSON-RPC error - /// codes to HTTP statuses). When is provided, the eager - /// response-header flush that normally precedes request processing is deferred until that first - /// message; the callback receives when the first write is not a JSON-RPC - /// message (e.g. a resumability priming event). + /// codes to HTTP statuses). When is provided for a + /// per-request-metadata protocol revision, the eager response-header flush that normally precedes + /// request processing is deferred until that first message; the callback receives + /// when the first write is not a JSON-RPC message (e.g. a resumability + /// priming event). /// The status line can only be influenced by the FIRST write: when a handler streams a - /// notification (e.g. progress) before failing, or runs past the transport's bounded - /// header-flush grace window, the status is already committed and a later JSON-RPC error - /// rides the committed status. + /// notification (e.g. progress) before failing, the status is already committed and a later + /// JSON-RPC error rides the committed status. /// /// The JSON-RPC message to process. /// The response stream to write any JSON-RPC responses to. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index ef6832101..43a3c12b5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -239,10 +239,9 @@ public async Task Server_ShutsDownQuickly_WhenClientIsConnected() [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { - // Regression test for: Tool calls that last over HttpClient timeout without producing - // intermediate notifications will timeout because HttpClient doesn't see the 200 response - // until the first message is written. When primingItem is null (no ISseEventStreamStore), - // we should flush the response stream so HttpClient sees the 200 immediately. + // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing + // intermediate notifications need an eager response flush. Per-request-metadata protocol + // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); @@ -274,7 +273,11 @@ public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); - await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + await using var mcpClient = await McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = "2025-11-25" }, + LoggerFactory, + TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 0fce6dc08..b57852fd3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -207,6 +207,27 @@ public async Task July2026Post_MissingRequiredCapability_Returns400() Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); } + [Fact] + public async Task July2026Post_SlowHandler_MissingRequiredCapability_Returns400() + { + await StartAsync(); + + var body = + @"{""jsonrpc"":""2.0"",""id"":20,""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(20, json["id"]!.GetValue()); + Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue()); + } + [Fact] public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() { @@ -500,10 +521,21 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali [McpServerToolType] private sealed class CapabilityTools { + private static readonly TimeSpan SlowHandlerDelay = TimeSpan.FromSeconds(1); + [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(SlowHandlerDelay, cancellationToken); + throw new MissingRequiredClientCapabilityException( + new ClientCapabilities { Sampling = new() }, + "sampling capability required but not declared by client"); + } } }