diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index c9e821adf..f99d9d68a 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -74,7 +74,8 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; } - else if (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError) + else if (response.StatusCode != HttpStatusCode.MethodNotAllowed && + 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, @@ -84,6 +85,11 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can // 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. + // + // 405 Method Not Allowed is the exception: it means the peer explicitly does not + // accept POST at this endpoint, so a JSON-RPC body here is incidental + // (framework-generated) rather than evidence of Streamable HTTP support. Treat it + // like any other non-JSON-RPC failure and fall back to SSE (see #1848). LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index a62ad0eea..dc19c650c 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -389,14 +389,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/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index 7100e728a..d9faa27ce 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs @@ -168,6 +168,82 @@ public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails() Assert.NotNull(session); } + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1848 + // A 405 Method Not Allowed with a JSON-RPC error body (framework-generated, e.g. gitmcp.io) + // must NOT be treated as evidence of Streamable HTTP support. The SDK should fall back to SSE. + [Fact] + public async Task AutoDetectMode_FallsBackToSse_When405CarriesJsonRpcErrorBody() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.AutoDetect, + Name = "AutoDetect test client" + }; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + var requestCount = 0; + + mockHttpHandler.RequestHandler = (request) => + { + requestCount++; + + if (request.Method == HttpMethod.Post && requestCount == 1) + { + // Streamable HTTP POST rejected with 405 and a structured JSON-RPC error body, + // exactly like an SSE-only server built on a JSON-RPC framework. + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.MethodNotAllowed, + Content = new StringContent( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"Method not allowed\"},\"id\":null}", + System.Text.Encoding.UTF8, + "application/json") + }); + } + + if (request.Method == HttpMethod.Get) + { + // SSE connection request succeeds, proving the fallback path was taken. + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + "event: endpoint\r\ndata: /sse-endpoint\r\n\r\n", + System.Text.Encoding.UTF8, + "text/event-stream") + }); + } + + if (request.Method == HttpMethod.Post && requestCount > 1) + { + // Subsequent POST to the SSE endpoint succeeds. + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("accepted") + }); + } + + throw new InvalidOperationException($"Unexpected request: {request.Method}, count: {requestCount}"); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + + // The auto-detecting transport should be returned (SSE fallback succeeded rather than + // adopting Streamable HTTP and throwing McpProtocolException). Trigger the lazy + // auto-detection by sending the initialize message; the SSE fallback GET must have + // been issued and the POST to the SSE endpoint accepted. + await session.SendMessageAsync( + new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) }, + TestContext.Current.CancellationToken); + + Assert.True(requestCount >= 2, "expected the SSE fallback GET to have been issued"); + } + [Fact] public async Task AutoDetectMode_WhenProvisionalSseFails_LeavesSharedMessageChannelOpen() {