diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index c9e821adf..b3041ecce 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -77,16 +77,19 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) { // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // It just rejected our specific request (e.g., -32022 UnsupportedProtocolVersion, - // -32021 MissingRequiredClientCapability, -32020 HeaderMismatch, or any other - // application-level error). Don't fall back to SSE — that would mask the real signal - // and surface a misleading "session id required" error from the SSE GET path. - // Adopt the Streamable HTTP transport and throw the structured exception so the - // connect-time fallback logic can react per spec PR #2844. Setting ActiveTransport - // first makes the catch filter below leave the now-owned transport alone. + // Adopt it before surfacing the failure so the catch filter leaves the now-owned + // transport alone, and never mask the response by attempting deprecated SSE. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + + if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) + { + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); + } + + // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response + // body and status without consuming the network stream a second time. + throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); } else { diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index a62ad0eea..d1f2a9d7a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -367,14 +367,6 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // fallback): falling back to initialize wouldn't fix a malformed envelope. throw; } - catch (McpProtocolException ex) when ( - ex.ErrorCode == McpErrorCode.InvalidRequest && - ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) - { - // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. - // This is not evidence of an initialize-handshake server, so do not fall back. - throw; - } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index db67b2e6b..5b294496d 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -80,8 +80,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && - (response.StatusCode == HttpStatusCode.BadRequest || - IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -89,10 +88,11 @@ await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => - code is McpErrorCode.UnsupportedProtocolVersion - or McpErrorCode.MissingRequiredClientCapability - or McpErrorCode.HeaderMismatch; + internal static bool ShouldSurfaceJsonRpcErrorAsProtocolException(HttpStatusCode statusCode, JsonRpcError error) => + statusCode == HttpStatusCode.BadRequest || + (McpErrorCode)error.Error.Code is McpErrorCode.UnsupportedProtocolVersion + or McpErrorCode.MissingRequiredClientCapability + or McpErrorCode.HeaderMismatch; /// /// Reads a JSON-RPC error envelope from an application/json response body, returning diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 599bdd9a8..9126331de 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -57,7 +57,7 @@ public async ValueTask DisposeAsync() base.Dispose(); } - private async Task StartServerAsync(RequestDelegate handler) + private async Task StartServerAsync(RequestDelegate handler, bool acceptGet = false) { Builder.Services.Configure(options => { @@ -65,7 +65,14 @@ private async Task StartServerAsync(RequestDelegate handler) }); _app = Builder.Build(); - _app.MapPost("/mcp", handler); + if (acceptGet) + { + _app.MapMethods("/mcp", [HttpMethods.Get, HttpMethods.Post], handler); + } + else + { + _app.MapPost("/mcp", handler); + } await _app.StartAsync(TestContext.Current.CancellationToken); } @@ -302,6 +309,63 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, Assert.False(initializeReceived); } + [Theory] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task AutoDetect_OnNonModernJsonRpcErrorOutside400_PreservesHttpFailure_NoFallback(HttpStatusCode statusCode) + { + var ct = TestContext.Current.CancellationToken; + var initializeRequests = 0; + var sseGetRequests = 0; + + await StartServerAsync(async context => + { + if (HttpMethods.IsGet(context.Request.Method)) + { + sseGetRequests++; + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + return; + } + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.Initialize }) + { + initializeRequests++; + } + + await WriteJsonRpcErrorAsync( + context, + statusCode, + code: (int)McpErrorCode.InvalidRequest, + message: "non-modern structured error"); + }, acceptGet: true); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.AutoDetect, + }, HttpClient, LoggerFactory); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var client = await McpClient.CreateAsync( + transport, + new McpClientOptions(), + loggerFactory: LoggerFactory, + cancellationToken: ct); + }); + + Assert.Equal(statusCode, exception.StatusCode); + Assert.Contains("non-modern structured error", exception.Message); + Assert.Equal(0, initializeRequests); + Assert.Equal(0, sseGetRequests); + } + [Fact] public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() { diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index fed5df665..557dc5655 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -239,6 +239,28 @@ public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } + [Theory] + [InlineData(HttpTransportMode.StreamableHttp)] + [InlineData(HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Initialize( + HttpTransportMode transportMode) + { + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredInvalidRequestProbeServer( + () => initializeReceived = true); + + await using var transport = CreateTransport(httpClient, transportMode); + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.True(initializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Theory] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] @@ -317,6 +339,47 @@ private static Func> CreateProbeRe } }; + private static Func> CreateStructuredInvalidRequestProbeServer( + Action onInitialize) + => async request => + { + if (request.Method == HttpMethod.Get) + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + + var body = await request.Content!.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("method", out var methodElement)) + return EmptyResponse(HttpStatusCode.Accepted); + + if (methodElement.GetString() == RequestMethods.ServerDiscover) + { + var id = doc.RootElement.GetProperty("id").GetRawText(); + var error = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"error\":{\"code\":-32600,\"message\":\"Mcp-Session-Id header is required\"}}"; + return new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(error, Encoding.UTF8, "application/json"), + }; + } + + if (methodElement.GetString() == RequestMethods.Initialize) + { + onInitialize(); + var id = doc.RootElement.GetProperty("id").GetRawText(); + var result = "{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"result\":{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + + "\",\"capabilities\":{},\"serverInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}"; + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(result, Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "test-session"); + return response; + } + + return EmptyResponse(HttpStatusCode.Accepted); + }; + private static HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status) { Content = new StringContent(string.Empty) };