Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// 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).
/// </summary>
/// <value>
/// The default is 250 milliseconds. Use <see cref="Timeout.InfiniteTimeSpan"/> to never force the flush,
/// which makes the SEP-2575 status mapping independent of how long dispatch takes.
/// </value>
/// <remarks>
/// 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 <c>200 OK</c>. 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 <see cref="Timeout.InfiniteTimeSpan"/>) trades how quickly clients see response
/// headers for a deterministic status mapping.
/// </remarks>
public TimeSpan DeferredHeaderFlushGrace { get; set; } = TimeSpan.FromMilliseconds(250);

/// <summary>
/// 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.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ private async ValueTask<StreamableHttpSession> 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,
Expand All @@ -522,6 +523,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
transport = new(loggerFactory)
{
Stateless = true,
DeferredHeaderFlushGrace = HttpServerTransportOptions.DeferredHeaderFlushGrace,
};
}

Expand Down Expand Up @@ -582,6 +584,7 @@ private async ValueTask<StreamableHttpSession> MigrateSessionAsync(
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
#pragma warning restore MCP9006
DeferredHeaderFlushGrace = HttpServerTransportOptions.DeferredHeaderFlushGrace,
};

// Initialize the transport with the migrated session's init params.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal sealed partial class StreamableHttpPostTransport(
Stream responseStream,
CancellationToken sessionCancellationToken,
ILogger logger,
TimeSpan deferredHeaderFlushGrace,
Func<JsonRpcMessage?, ValueTask>? onResponseStarting = null) : ITransport
{
private readonly SemaphoreSlim _messageLock = new(1, 1);
Expand Down Expand Up @@ -137,12 +138,17 @@ public async ValueTask<bool> 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).
/// <para>
/// When the grace window is <see cref="Timeout.InfiniteTimeSpan"/> 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.
/// </para>
/// </summary>
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)
{
Expand All @@ -166,8 +172,11 @@ private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken)
}
}

/// <summary>How long the response-header flush may be deferred waiting for the first response message.</summary>
internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
/// <summary>
/// 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.
/// </summary>
internal static readonly TimeSpan DefaultDeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);

/// <summary>
/// Invokes the response-starting callback exactly once, immediately before the first write to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,24 @@ public StreamableHttpServerTransport(ILoggerFactory? loggerFactory = null)
/// </value>
public bool FlowExecutionContextFromRequests { get; init; }

/// <summary>
/// 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).
/// </summary>
/// <value>
/// The default is 250 milliseconds. Use <see cref="Timeout.InfiniteTimeSpan"/> to never force the
/// flush, which makes the SEP-2575 status mapping independent of how long dispatch takes.
/// </value>
/// <remarks>
/// 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 <c>200 OK</c>. 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 <see cref="Timeout.InfiniteTimeSpan"/>) trades
/// how quickly clients see response headers for a deterministic status mapping.
/// </remarks>
public TimeSpan DeferredHeaderFlushGrace { get; init; } = StreamableHttpPostTransport.DefaultDeferredHeaderFlushGrace;

/// <summary>
/// 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.
Expand Down Expand Up @@ -239,7 +257,7 @@ public async Task<bool> 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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,21 @@ 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 =>
{
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<CapabilityTools>();

Expand Down Expand Up @@ -207,6 +213,59 @@ public async Task July2026Post_MissingRequiredCapability_Returns400()
Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue<int>());
}

/// <summary>
/// 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.
/// </summary>
[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<long>());
Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue<int>());
}

/// <summary>
/// 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.
/// </summary>
[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<long>());
Assert.Equal((int)McpErrorCode.MissingRequiredClientCapability, json["error"]!["code"]!.GetValue<int>());
}

[Fact]
public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion()
{
Expand Down Expand Up @@ -500,10 +559,25 @@ public async Task July2026Post_MalformedClientCapabilities_Returns400_WithInvali
[McpServerToolType]
private sealed class CapabilityTools
{
/// <summary>
/// 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.
/// </summary>
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<string> SlowRequiresSampling(CancellationToken cancellationToken)
{
await Task.Delay(PastDefaultGrace, cancellationToken);
throw new MissingRequiredClientCapabilityException(
new ClientCapabilities { Sampling = new() },
"sampling capability required but not declared by client");
}
}
}