Skip to content

Commit e024335

Browse files
authored
Merge branch 'main' into feature/oauth-token-endpoint-auth-selector
2 parents 7d110cc + cb3e6e8 commit e024335

9 files changed

Lines changed: 206 additions & 109 deletions

File tree

‎src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,19 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
7777
else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError)
7878
{
7979
// A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server.
80-
// It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion,
81-
// -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other
82-
// application-level error). Don't fall back to SSE — that would mask the real signal
83-
// and surface a misleading "session id required" error from the SSE GET path.
84-
// Adopt the Streamable HTTP transport and throw the structured exception so the
85-
// connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport
86-
// first makes the catch filter below leave the now-owned transport alone.
80+
// Adopt it before surfacing the failure so the catch filter leaves the now-owned
81+
// transport alone, and never mask the response by attempting deprecated SSE.
8782
LogUsingStreamableHttp(_name);
8883
ActiveTransport = streamableHttpTransport;
89-
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
84+
85+
if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
86+
{
87+
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
88+
}
89+
90+
// TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response
91+
// body and status without consuming the network stream a second time.
92+
throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
9093
}
9194
else
9295
{

‎src/ModelContextProtocol.Core/Client/McpClientImpl.cs‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -367,14 +367,6 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
367367
// fallback): falling back to initialize wouldn't fix a malformed envelope.
368368
throw;
369369
}
370-
catch (McpProtocolException ex) when (
371-
ex.ErrorCode == McpErrorCode.InvalidRequest &&
372-
ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal))
373-
{
374-
// Local transport validation: a 2026-07-28+ response must not carry HTTP session state.
375-
// This is not evidence of an initialize-handshake server, so do not fall back.
376-
throw;
377-
}
378370
catch (McpProtocolException)
379371
{
380372
// Per spec PR #2844, the fallback MUST NOT be keyed to a single error code.

‎src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
8080
// for robustness. Servers occasionally emit them with 4xx codes other than 400.
8181
if (!response.IsSuccessStatusCode &&
8282
await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError &&
83-
(response.StatusCode == HttpStatusCode.BadRequest ||
84-
IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code)))
83+
ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError))
8584
{
8685
throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError);
8786
}
8887

8988
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
9089
}
9190

92-
private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) =>
93-
code is McpErrorCode.UnsupportedProtocolVersion
94-
or McpErrorCode.MissingRequiredClientCapability
95-
or McpErrorCode.HeaderMismatch;
91+
internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) =>
92+
statusCode == HttpStatusCode.BadRequest ||
93+
(McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion
94+
or McpErrorCode.MissingRequiredClientCapability
95+
or McpErrorCode.HeaderMismatch;
9696

9797
/// <summary>
9898
/// Reads a JSON-RPC error envelope from an <c>application/json</c> response body, returning

‎src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs‎

Lines changed: 13 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,6 @@ public async ValueTask<bool> HandlePostAsync(JsonRpcMessage message, Cancellatio
7676
return false;
7777
}
7878

79-
CancellationTokenSource? deferredFlushCts = null;
80-
Task? deferredFlushTask = null;
81-
bool deferHeaderFlush = false;
8279
using (await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false))
8380
{
8481
var primingItem = await TryStartSseEventStreamAsync(_pendingRequest).ConfigureAwait(false);
@@ -87,88 +84,33 @@ public async ValueTask<bool> HandlePostAsync(JsonRpcMessage message, Cancellatio
8784
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
8885
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
8986
}
90-
else if (onResponseStarting is null)
87+
else if (onResponseStarting is not null &&
88+
McpProtocolVersions.RequiresPerRequestMetadata(message.Context.ProtocolVersion))
9189
{
92-
// If there's no priming write, flush the stream to ensure HTTP response headers are
93-
// sent to the client now that the server is ready to process the request.
94-
// This prevents HttpClient timeout for long-running requests.
95-
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
90+
// Per-request-metadata protocol revisions map the first JSON-RPC error onto the HTTP
91+
// status line. Keep the headers uncommitted until that message arrives so the mapping
92+
// cannot depend on how long dispatch takes.
9693
}
9794
else
9895
{
99-
deferHeaderFlush = true;
96+
// Earlier protocol revisions keep the eager header flush for long-running handlers.
97+
// Mark the response as started before flushing because any later JSON-RPC error must
98+
// not attempt to change an already committed status line.
99+
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
100+
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
100101
}
101102

102103
// Ensure that we've sent the priming event before processing the incoming request.
103104
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);
104105
}
105106

106-
if (deferHeaderFlush)
107-
{
108-
// Defer the flush (and the header commit it implies) so the callback can still choose
109-
// the HTTP status line for an immediate JSON-RPC error. Start the bounded grace period
110-
// only after the request has been queued for dispatch.
111-
deferredFlushCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
112-
deferredFlushTask = DeferredHeaderFlushAsync(deferredFlushCts.Token);
113-
}
114-
115-
try
116-
{
117-
// Wait for the response to be written before returning from the handler.
118-
// This keeps the HTTP response open until the final response message is sent.
119-
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
120-
}
121-
finally
122-
{
123-
if (deferredFlushCts is not null)
124-
{
125-
deferredFlushCts.Cancel();
126-
await deferredFlushTask!.ConfigureAwait(false);
127-
deferredFlushCts.Dispose();
128-
}
129-
}
107+
// Wait for the response to be written before returning from the handler.
108+
// This keeps the HTTP response open until the final response message is sent.
109+
await _httpResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
130110

131111
return true;
132112
}
133113

134-
/// <summary>
135-
/// Bounds the deferred header flush: after a short grace window, flushes the response headers
136-
/// if no response message has been written yet. Immediate rejections land well inside the
137-
/// window, so the response-starting callback can still map their JSON-RPC error codes onto the
138-
/// HTTP status line; a handler that runs longer commits the headers here so clients see them
139-
/// promptly (long-running tool calls must not trip HttpClient's response timeout).
140-
/// </summary>
141-
private async Task DeferredHeaderFlushAsync(CancellationToken cancellationToken)
142-
{
143-
try
144-
{
145-
await Task.Delay(DeferredHeaderFlushGrace, cancellationToken).ConfigureAwait(false);
146-
using var _ = await _messageLock.LockAsync(cancellationToken).ConfigureAwait(false);
147-
if (!_httpResponseStarted && !_httpResponseCompleted)
148-
{
149-
await NotifyResponseStartingAsync(firstMessage: null).ConfigureAwait(false);
150-
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
151-
}
152-
}
153-
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
154-
{
155-
// The response was written or the request ended before the grace window elapsed.
156-
}
157-
catch (Exception ex)
158-
{
159-
// Surface the failure to the awaiting HandlePostAsync when possible. If the response
160-
// future has already been resolved (the response started or completed on another path),
161-
// TrySetException is a no-op, so log here to keep the deferred-flush failure diagnosable.
162-
if (!_httpResponseTcs.TrySetException(ex))
163-
{
164-
LogDeferredHeaderFlushFailed(ex);
165-
}
166-
}
167-
}
168-
169-
/// <summary>How long the response-header flush may be deferred waiting for the first response message.</summary>
170-
internal static readonly TimeSpan DeferredHeaderFlushGrace = TimeSpan.FromMilliseconds(250);
171-
172114
/// <summary>
173115
/// Invokes the response-starting callback exactly once, immediately before the first write to
174116
/// the HTTP response stream, so the HTTP application can still set the response status line.
@@ -343,6 +285,4 @@ public async ValueTask DisposeAsync()
343285
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to dispose SSE event stream writer.")]
344286
private partial void LogStoreStreamDisposalFailed(Exception exception);
345287

346-
[LoggerMessage(Level = LogLevel.Warning, Message = "Failed to flush deferred Streamable HTTP response headers.")]
347-
private partial void LogDeferredHeaderFlushFailed(Exception exception);
348288
}

‎src/ModelContextProtocol.Core/Server/StreamableHttpServerTransport.cs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,14 @@ public Task<bool> HandlePostRequestAsync(JsonRpcMessage message, Stream response
216216
/// This overload additionally reports the first JSON-RPC message written to the response via
217217
/// <paramref name="onResponseStarting"/>, before any response bytes are written, so the HTTP
218218
/// application can still choose the response status line (SEP-2575 maps some JSON-RPC error
219-
/// codes to HTTP statuses). When <paramref name="onResponseStarting"/> is provided, the eager
220-
/// response-header flush that normally precedes request processing is deferred until that first
221-
/// message; the callback receives <see langword="null"/> when the first write is not a JSON-RPC
222-
/// message (e.g. a resumability priming event).
219+
/// codes to HTTP statuses). When <paramref name="onResponseStarting"/> is provided for a
220+
/// per-request-metadata protocol revision, the eager response-header flush that normally precedes
221+
/// request processing is deferred until that first message; the callback receives
222+
/// <see langword="null"/> when the first write is not a JSON-RPC message (e.g. a resumability
223+
/// priming event).
223224
/// The status line can only be influenced by the FIRST write: when a handler streams a
224-
/// notification (e.g. progress) before failing, or runs past the transport's bounded
225-
/// header-flush grace window, the status is already committed and a later JSON-RPC error
226-
/// rides the committed status.
225+
/// notification (e.g. progress) before failing, the status is already committed and a later
226+
/// JSON-RPC error rides the committed status.
227227
/// </summary>
228228
/// <param name="message">The JSON-RPC message to process.</param>
229229
/// <param name="responseStream">The response stream to write any JSON-RPC responses to.</param>

‎tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,22 @@ public async ValueTask DisposeAsync()
5757
base.Dispose();
5858
}
5959

60-
private async Task StartServerAsync(RequestDelegate handler)
60+
private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false)
6161
{
6262
Builder.Services.Configure<JsonOptions>(options =>
6363
{
6464
options.SerializerOptions.TypeInfoResolverChain.Add(McpJsonUtilities.DefaultOptions.TypeInfoResolver!);
6565
});
6666

6767
_app = Builder.Build();
68-
_app.MapPost("/mcp", handler);
68+
if (acceptGet)
69+
{
70+
_app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler);
71+
}
72+
else
73+
{
74+
_app.MapPost("/mcp", handler);
75+
}
6976
await _app.StartAsync(TestContext.Current.CancellationToken);
7077
}
7178

@@ -302,6 +309,63 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest,
302309
Assert.False(initializeReceived);
303310
}
304311

312+
[Theory]
313+
[InlineData(HttpStatusCode.Unauthorized)]
314+
[InlineData(HttpStatusCode.Forbidden)]
315+
[InlineData(HttpStatusCode.InternalServerError)]
316+
public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode)
317+
{
318+
var ct = TestContext.Current.CancellationToken;
319+
var initializeRequests = 0;
320+
var sseGetRequests = 0;
321+
322+
await StartServerAsync(async context =>
323+
{
324+
if (HttpMethods.IsGet(context.Request.Method))
325+
{
326+
sseGetRequests++;
327+
context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
328+
return;
329+
}
330+
331+
var message = await JsonSerializer.DeserializeAsync(
332+
context.Request.Body,
333+
GetJsonTypeInfo<JsonRpcMessage>(),
334+
ct);
335+
336+
if (message is JsonRpcRequest { Method: RequestMethods.Initialize })
337+
{
338+
initializeRequests++;
339+
}
340+
341+
await WriteJsonRpcErrorAsync(
342+
context,
343+
statusCode,
344+
code: (int)McpErrorCode.InvalidRequest,
345+
message: "non-modern structured error");
346+
}, acceptGet: true);
347+
348+
await using var transport = new HttpClientTransport(new()
349+
{
350+
Endpoint = new("http://localhost:5000/mcp"),
351+
TransportMode = HttpTransportMode.AutoDetect,
352+
}, HttpClient, LoggerFactory);
353+
354+
var exception = await Assert.ThrowsAsync<HttpRequestException>(async () =>
355+
{
356+
await using var client = await McpClient.CreateAsync(
357+
transport,
358+
new McpClientOptions(),
359+
loggerFactory: LoggerFactory,
360+
cancellationToken: ct);
361+
});
362+
363+
Assert.Equal(statusCode, exception.StatusCode);
364+
Assert.Contains("non-modern structured error", exception.Message);
365+
Assert.Equal(0, initializeRequests);
366+
Assert.Equal(0, sseGetRequests);
367+
}
368+
305369
[Fact]
306370
public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState()
307371
{

‎tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,9 @@ public async Task Server_ShutsDownQuickly_WhenClientIsConnected()
239239
[Fact]
240240
public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore()
241241
{
242-
// Regression test for: Tool calls that last over HttpClient timeout without producing
243-
// intermediate notifications will timeout because HttpClient doesn't see the 200 response
244-
// until the first message is written. When primingItem is null (no ISseEventStreamStore),
245-
// we should flush the response stream so HttpClient sees the 200 immediately.
242+
// Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing
243+
// intermediate notifications need an eager response flush. Per-request-metadata protocol
244+
// revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status.
246245

247246
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<LongRunningTools>();
248247

@@ -274,7 +273,11 @@ public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore()
274273
TransportMode = transportMode,
275274
}, shortTimeoutClient, LoggerFactory);
276275

277-
await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
276+
await using var mcpClient = await McpClient.CreateAsync(
277+
transport,
278+
new McpClientOptions { ProtocolVersion = "2025-11-25" },
279+
LoggerFactory,
280+
TestContext.Current.CancellationToken);
278281

279282
// Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout
280283
// because the response stream is flushed immediately after receiving the request

0 commit comments

Comments
 (0)