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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down