diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index b3041ecce..228cf857d 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -108,6 +108,13 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can // TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read. var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + // Only the legacy initialize-handshake probe failures may fall back to SSE. Authentication, + // authorization, and server errors must retain their HTTP semantics without a deprecated GET. + if (!ShouldTrySseFallback(response.StatusCode)) + { + throw streamableHttpError; + } + await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); } @@ -178,6 +185,14 @@ public async ValueTask DisposeAsync() } } + /// + /// Determines whether an HTTP failure can indicate an older server that requires the initialize handshake. + /// + private static bool ShouldTrySseFallback(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.BadRequest + or HttpStatusCode.NotFound + or HttpStatusCode.MethodNotAllowed; + [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} attempting to connect using Streamable HTTP transport.")] private partial void LogAttemptingStreamableHttp(string endpointName); diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d1f2a9d7a..6b2120b40 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -381,14 +381,17 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) fallbackToInitialize = true; } catch (HttpRequestException ex) when ( - ex.GetStatusCode() is HttpStatusCode.BadRequest or HttpStatusCode.NotFound) + ex.GetStatusCode() is HttpStatusCode.BadRequest + or HttpStatusCode.NotFound + or HttpStatusCode.MethodNotAllowed) { // A server predating SEP-2575 can reject the session-less server/discover POST at the // HTTP layer instead of with a JSON-RPC error: 400 when it cannot parse the request, - // 404 when it requires Mcp-Session-Id on every non-initialize POST. A 400 carrying a - // structured JSON-RPC error is surfaced as McpProtocolException and handled above, so - // anything reaching here is plain or empty. Either way this is an initialize-handshake - // server, so fall back. Other statuses stay uncaught and surface to the caller. + // 404 when it requires Mcp-Session-Id on every non-initialize POST, and 405 when it + // does not accept POST at this endpoint at all. A 400 carrying a structured JSON-RPC + // error is surfaced as McpProtocolException and handled above, so anything reaching + // here is plain or empty. Either way this is an initialize-handshake server, so fall + // back. Other statuses stay uncaught and surface to the caller. fallbackToInitialize = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index 557dc5655..1b504fb86 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -214,13 +214,16 @@ public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() [InlineData(HttpStatusCode.NotFound, HttpTransportMode.AutoDetect)] [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.MethodNotAllowed, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.MethodNotAllowed, HttpTransportMode.AutoDetect)] public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( HttpStatusCode status, HttpTransportMode transportMode) { // A server predating SEP-2575 can reject the session-less server/discover probe at the HTTP layer // rather than with a JSON-RPC error: 404 when it requires Mcp-Session-Id on every non-initialize - // POST, or a plain/empty 400 when it cannot parse the request. Both are initialize-handshake - // servers, so the connect must fall back instead of failing. + // POST, a plain/empty 400 when it cannot parse the request, or 405 when the endpoint rejects + // the probe method. All three are initialize-handshake servers, so the connect must fall back + // instead of failing. var ct = TestContext.Current.CancellationToken; var initializeReceived = false; @@ -240,17 +243,22 @@ public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( } [Theory] - [InlineData(HttpTransportMode.StreamableHttp)] - [InlineData(HttpTransportMode.AutoDetect)] - public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Initialize( - HttpTransportMode transportMode) + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.BadRequest, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.NotFound, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.MethodNotAllowed, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.MethodNotAllowed, HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredFallbackHttpStatusFromProbe_FallsBackTo_Initialize( + HttpStatusCode status, HttpTransportMode transportMode) { var ct = TestContext.Current.CancellationToken; var initializeReceived = false; using var mockHttpHandler = new MockHttpHandler(); using var httpClient = new HttpClient(mockHttpHandler); - mockHttpHandler.RequestHandler = CreateStructuredInvalidRequestProbeServer( + mockHttpHandler.RequestHandler = CreateStructuredProbeRejectingServer( + status, () => initializeReceived = true); await using var transport = CreateTransport(httpClient, transportMode); @@ -265,19 +273,21 @@ public async Task Client_OnStructuredInvalidRequestFromHttpProbe_FallsBackTo_Ini [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Unauthorized, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.AutoDetect)] public async Task Client_OnOtherHttpErrorFromProbe_Surfaces_NoFallback( HttpStatusCode status, HttpTransportMode transportMode) { - // Only 400 and 404 are read as "this server needs the initialize handshake". Any other HTTP failure - // is a genuine transport error and must surface, so callers are not handed a misleading downstream - // error. Guards the deliberate narrowing of the status filter. + // Only 400, 404, and 405 indicate that the server needs the initialize handshake. Authentication + // and server failures must surface directly, without probing deprecated SSE or attempting initialize. var ct = TestContext.Current.CancellationToken; var initializeReceived = false; + var sseRequested = false; using var mockHttpHandler = new MockHttpHandler(); using var httpClient = new HttpClient(mockHttpHandler); mockHttpHandler.RequestHandler = CreateProbeRejectingServer( - status, "nope", () => initializeReceived = true); + status, "nope", () => initializeReceived = true, () => sseRequested = true); await using var transport = CreateTransport(httpClient, transportMode); @@ -288,6 +298,7 @@ await Assert.ThrowsAnyAsync(async () => }); Assert.False(initializeReceived); + Assert.False(sseRequested); } private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode) @@ -303,13 +314,17 @@ private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransport /// and, if the client falls back, completes an initialize handshake at 2025-11-25. /// private static Func> CreateProbeRejectingServer( - HttpStatusCode probeStatus, string probeBody, Action onInitialize) + HttpStatusCode probeStatus, string probeBody, Action onInitialize, Action? onSseRequest = null) => async request => { // The server offers no standalone SSE stream, which the spec permits. // net472 does not populate a default Content, so every response sets one explicitly. if (request.Method == HttpMethod.Get) + { + // Track accidental AutoDetect fallback for non-allowlisted HTTP failures. + onSseRequest?.Invoke(); return EmptyResponse(HttpStatusCode.MethodNotAllowed); + } var body = await request.Content!.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); @@ -339,8 +354,8 @@ private static Func> CreateProbeRe } }; - private static Func> CreateStructuredInvalidRequestProbeServer( - Action onInitialize) + private static Func> CreateStructuredProbeRejectingServer( + HttpStatusCode probeStatus, Action onInitialize) => async request => { if (request.Method == HttpMethod.Get) @@ -356,7 +371,7 @@ private static Func> CreateStructu 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) + return new HttpResponseMessage(probeStatus) { Content = new StringContent(error, Encoding.UTF8, "application/json"), };