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"),
};