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 @@ -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);
}
Expand Down Expand Up @@ -178,6 +185,14 @@ public async ValueTask DisposeAsync()
}
}

/// <summary>
/// Determines whether an HTTP failure can indicate an older server that requires the initialize handshake.
/// </summary>
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);

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 @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A 405 here means the POST endpoint rejected the request, so retrying initialize over the same transport is not useful. The spec's 405 handling is the AutoDetect transport fallback to SSE. Can we keep that in AutoDetectingClientSessionTransport and remove 405 from this catch?

{
// 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 @@ -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;

Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -288,6 +298,7 @@ await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
});

Assert.False(initializeReceived);
Assert.False(sseRequested);
}

private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode)
Expand All @@ -303,13 +314,17 @@ private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransport
/// and, if the client falls back, completes an <c>initialize</c> handshake at 2025-11-25.
/// </summary>
private static Func<HttpRequestMessage, Task<HttpResponseMessage>> 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);
Expand Down Expand Up @@ -339,8 +354,8 @@ private static Func<HttpRequestMessage, Task<HttpResponseMessage>> CreateProbeRe
}
};

private static Func<HttpRequestMessage, Task<HttpResponseMessage>> CreateStructuredInvalidRequestProbeServer(
Action onInitialize)
private static Func<HttpRequestMessage, Task<HttpResponseMessage>> CreateStructuredProbeRejectingServer(
HttpStatusCode probeStatus, Action onInitialize)
=> async request =>
{
if (request.Method == HttpMethod.Get)
Expand All @@ -356,7 +371,7 @@ private static Func<HttpRequestMessage, Task<HttpResponseMessage>> 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"),
};
Expand Down