diff --git a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
index 95411f7..5b105c3 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs
@@ -76,9 +76,6 @@ internal sealed partial class StreamableHttpPostTransport(
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 @@ internal sealed partial class StreamableHttpPostTransport(
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 @@ internal sealed partial class StreamableHttpPostTransport(
[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 f143eaa..7f9a195 100644
--- a/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
+++ b/src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs
@@ -216,14 +216,14 @@ public sealed partial class StreamableHttpServerTransport : ITransport
/// 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 ef68321..43a3c12 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
@@ -239,10 +239,9 @@ public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
[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 abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) :
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 0fce6dc..b57852f 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs
@@ -207,6 +207,27 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
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 class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn
[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");
+ }
}
}