From 32ef18a02a6ae2b4e1aac2bc32b704b74f72866c Mon Sep 17 00:00:00 2001
From: yuwk <1729065730@qq.com>
Date: Tue, 1 Sep 2026 10:57:27 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(client):=20[CSharp=20SDK/Client]=20?=
=?UTF-8?q?=E5=AF=B9=E9=BD=90=20HTTP=20=E6=8E=A2=E6=B5=8B=E5=9B=9E?=
=?UTF-8?q?=E9=80=80=E7=AD=96=E7=95=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 仅对 400、404、405 探测失败回退初始化握手。
- 保留 401、403、5xx 的 HTTP 语义且不发起 SSE 请求。
- 覆盖结构化与非结构化探测响应的回归场景。
---
.../AutoDetectingClientSessionTransport.cs | 15 +++++++
.../Client/McpClientImpl.cs | 13 +++---
.../Client/July2026ProtocolFallbackTests.cs | 45 ++++++++++++-------
3 files changed, 53 insertions(+), 20 deletions(-)
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"),
};
From 1596ee5870e60c66ff2b4c0c8277e831d47b6056 Mon Sep 17 00:00:00 2001
From: yuwk <1729065730@qq.com>
Date: Wed, 9 Sep 2026 18:57:58 +0800
Subject: [PATCH 2/2] fix(client): remove 405 from discover-probe initialize
fallback
405 means the POST endpoint rejected the request method, so retrying
initialize over the same transport is not useful. The spec's 405
handling is the AutoDetect transport's SSE fallback. Remove
MethodNotAllowed from McpClientImpl's HTTP-layer fallback catch and
route the regression coverage through the AutoDetect test matrix.
Also realign three AutoDetect transport tests that still encoded the
pre-allowlist 'always fall back to SSE' behavior (403/415), switching
them to an allowlisted 404 so they continue to exercise the
dual-failure surface path.
---
.../Client/McpClientImpl.cs | 18 +++--
.../Client/July2026ProtocolFallbackTests.cs | 77 ++++++++++++++++---
.../HttpClientTransportAutoDetectTests.cs | 49 ++++++------
3 files changed, 103 insertions(+), 41 deletions(-)
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index 6b2120b40..768c24583 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -382,16 +382,18 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
}
catch (HttpRequestException ex) when (
ex.GetStatusCode() is HttpStatusCode.BadRequest
- or HttpStatusCode.NotFound
- or HttpStatusCode.MethodNotAllowed)
+ or HttpStatusCode.NotFound)
{
// 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, 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.
+ // HTTP layer instead of with a JSON-RPC error: 400 when it cannot parse the request, or
+ // 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. A 405 means the POST endpoint rejected the request method
+ // entirely, so retrying initialize over the same transport is not useful; the spec's 405
+ // handling is the AutoDetect transport's fallback to SSE, and in explicit Streamable HTTP
+ // mode a 405 surfaces to the caller. 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 1b504fb86..70efac14a 100644
--- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs
@@ -214,16 +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, 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.
+ // 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. (405 is deliberately excluded: the
+ // POST endpoint rejecting the request method does not mean initialize will succeed over the same
+ // transport, and the spec routes 405 to the AutoDetect transport's SSE fallback — see
+ // Client_On405FromProbe_DoesNotFallBackTo_Initialize.)
var ct = TestContext.Current.CancellationToken;
var initializeReceived = false;
@@ -247,8 +247,6 @@ public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize(
[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)
{
@@ -269,6 +267,66 @@ public async Task Client_OnStructuredFallbackHttpStatusFromProbe_FallsBackTo_Ini
Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
}
+ [Theory]
+ [InlineData(HttpTransportMode.StreamableHttp, false)]
+ [InlineData(HttpTransportMode.AutoDetect, true)]
+ public async Task Client_On405FromProbe_DoesNotFallBackTo_Initialize(
+ HttpTransportMode transportMode, bool expectSseAttempt)
+ {
+ // 405 means the POST endpoint rejected the request method, so retrying initialize over the same
+ // transport is not useful. The spec routes 405 to the AutoDetect transport's SSE fallback: in
+ // Streamable HTTP mode the 405 surfaces directly, and in AutoDetect mode the client attempts the
+ // deprecated SSE GET instead of initialize. Neither path may attempt 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(
+ HttpStatusCode.MethodNotAllowed, "Invalid session ID",
+ () => initializeReceived = true, () => sseRequested = true);
+
+ await using var transport = CreateTransport(httpClient, transportMode);
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
+ loggerFactory: LoggerFactory, cancellationToken: ct);
+ });
+
+ Assert.False(initializeReceived);
+ Assert.Equal(expectSseAttempt, sseRequested);
+ }
+
+ [Theory]
+ [InlineData(HttpTransportMode.StreamableHttp)]
+ [InlineData(HttpTransportMode.AutoDetect)]
+ public async Task Client_OnStructured405FromProbe_DoesNotFallBackTo_Initialize(
+ HttpTransportMode transportMode)
+ {
+ // A 405 carrying a structured JSON-RPC error body means the peer is a Streamable HTTP server
+ // that rejected the method; the AutoDetect transport adopts the transport and surfaces the error
+ // instead of trying SSE, and neither transport should attempt initialize.
+ var ct = TestContext.Current.CancellationToken;
+ var initializeReceived = false;
+
+ using var mockHttpHandler = new MockHttpHandler();
+ using var httpClient = new HttpClient(mockHttpHandler);
+ mockHttpHandler.RequestHandler = CreateStructuredProbeRejectingServer(
+ HttpStatusCode.MethodNotAllowed, () => initializeReceived = true);
+
+ await using var transport = CreateTransport(httpClient, transportMode);
+
+ await Assert.ThrowsAnyAsync(async () =>
+ {
+ await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(),
+ loggerFactory: LoggerFactory, cancellationToken: ct);
+ });
+
+ Assert.False(initializeReceived);
+ }
+
[Theory]
[InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)]
[InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)]
@@ -278,8 +336,9 @@ public async Task Client_OnStructuredFallbackHttpStatusFromProbe_FallsBackTo_Ini
public async Task Client_OnOtherHttpErrorFromProbe_Surfaces_NoFallback(
HttpStatusCode status, HttpTransportMode transportMode)
{
- // 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.
+ // Only 400 and 404 indicate that the server needs the initialize handshake (405 routes to the
+ // AutoDetect SSE fallback instead). 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;
diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
index 7100e728a..779f4a889 100644
--- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs
@@ -53,7 +53,7 @@ public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
[Fact]
public async Task AutoDetectMode_WhenBothTransportsFail_PreservesStreamableHttpException()
{
- // Regression test: when Streamable HTTP POST fails (e.g. 403) and the SSE GET
+ // Regression test: when Streamable HTTP POST fails (e.g. 404) and the SSE GET
// fallback also fails (e.g. 405), the original Streamable HTTP error should
// be preserved. The SSE connection failure is available as its inner exception.
var options = new HttpClientTransportOptions
@@ -71,11 +71,11 @@ public async Task AutoDetectMode_WhenBothTransportsFail_PreservesStreamableHttpE
{
if (request.Method == HttpMethod.Post)
{
- // Streamable HTTP POST fails with 403 (auth error)
+ // Streamable HTTP POST fails with 404 (an SSE-only server with no POST endpoint).
return Task.FromResult(new HttpResponseMessage
{
- StatusCode = HttpStatusCode.Forbidden,
- Content = new StringContent("Forbidden")
+ StatusCode = HttpStatusCode.NotFound,
+ Content = new StringContent("Streamable HTTP not supported")
});
}
@@ -99,12 +99,12 @@ public async Task AutoDetectMode_WhenBothTransportsFail_PreservesStreamableHttpE
var ex = await Assert.ThrowsAsync(
() => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));
- Assert.Contains("403", ex.Message);
+ Assert.Contains("404", ex.Message);
Assert.IsType(ex.InnerException);
Assert.Contains("405", ex.InnerException.Message);
- Assert.Equal(HttpStatusCode.Forbidden, ex.Data["ModelContextProtocol.HttpStatusCode"]);
+ Assert.Equal(HttpStatusCode.NotFound, ex.Data["ModelContextProtocol.HttpStatusCode"]);
#if NET
- Assert.Equal(HttpStatusCode.Forbidden, ex.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, ex.StatusCode);
#endif
}
@@ -278,11 +278,12 @@ await session.SendMessageAsync(
}
// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1526
- // When Streamable HTTP returns 415 (e.g. wrong Content-Type) and the SSE fallback also fails
- // (e.g. a Streamable-HTTP-only server returns 405 to the GET), the surfaced exception must
- // preserve the original Streamable HTTP error rather than dropping it on the floor.
+ // When Streamable HTTP returns 404 (e.g. an SSE-only server with no POST endpoint) and the
+ // SSE fallback also fails (e.g. a Streamable-HTTP-only server returns 405 to the GET), the
+ // surfaced exception must preserve the original Streamable HTTP error rather than dropping
+ // it on the floor.
[Fact]
- public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturns415AndSseFallbackFails()
+ public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturns404AndSseFallbackFails()
{
var options = new HttpClientTransportOptions
{
@@ -295,16 +296,16 @@ public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturn
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
- const string streamableHttpBody = "Content-Type must be 'application/json'";
+ const string streamableHttpBody = "Streamable HTTP not supported";
mockHttpHandler.RequestHandler = (request) =>
{
if (request.Method == HttpMethod.Post)
{
- // Streamable HTTP fails with 415 - this is the real server diagnostic the user needs to see.
+ // Streamable HTTP fails with 404 - this is the real server diagnostic the user needs to see.
return Task.FromResult(new HttpResponseMessage
{
- StatusCode = HttpStatusCode.UnsupportedMediaType,
+ StatusCode = HttpStatusCode.NotFound,
Content = new StringContent(streamableHttpBody),
});
}
@@ -312,7 +313,7 @@ public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturn
if (request.Method == HttpMethod.Get)
{
// Streamable-HTTP-only server: SSE GET is rejected with 405. Without the fix this is the
- // ONLY error the user ever sees, masking the real 415 diagnostic above.
+ // ONLY error the user ever sees, masking the real 404 diagnostic above.
return Task.FromResult(new HttpResponseMessage
{
StatusCode = HttpStatusCode.MethodNotAllowed,
@@ -331,11 +332,11 @@ public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturn
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
TestContext.Current.CancellationToken));
- // Walk the exception chain and assert the original 415 (and its body) is somewhere in it.
+ // Walk the exception chain and assert the original 404 (and its body) is somewhere in it.
// We don't pin the exact exception type so this stays robust to future error-shape tweaks,
// but the underlying status code and server body must reach the caller.
var combined = Flatten(ex);
- Assert.Contains("415", combined);
+ Assert.Contains("404", combined);
Assert.Contains(streamableHttpBody, combined);
static string Flatten(Exception e)
@@ -378,7 +379,7 @@ public async Task AutoDetectMode_SurfacesStreamableHttpError_WithSseAsInner_When
using var httpClient = new HttpClient(mockHttpHandler);
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
- const string streamableHttpBody = "Content-Type must be 'application/json'";
+ const string streamableHttpBody = "Streamable HTTP not supported";
mockHttpHandler.RequestHandler = (request) =>
{
@@ -386,7 +387,7 @@ public async Task AutoDetectMode_SurfacesStreamableHttpError_WithSseAsInner_When
{
return Task.FromResult(new HttpResponseMessage
{
- StatusCode = HttpStatusCode.UnsupportedMediaType,
+ StatusCode = HttpStatusCode.NotFound,
Content = new StringContent(streamableHttpBody),
});
}
@@ -412,11 +413,11 @@ public async Task AutoDetectMode_SurfacesStreamableHttpError_WithSseAsInner_When
// The surfaced exception is the original Streamable HTTP error (the real server diagnostic), not the SSE 405.
var httpEx = Assert.IsType(ex);
- Assert.Contains("415", httpEx.Message);
+ Assert.Contains("404", httpEx.Message);
Assert.Contains(streamableHttpBody, httpEx.Message);
- Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.Data["ModelContextProtocol.HttpStatusCode"]);
+ Assert.Equal(HttpStatusCode.NotFound, httpEx.Data["ModelContextProtocol.HttpStatusCode"]);
#if NET
- Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, httpEx.StatusCode);
#endif
// The SSE fallback failure (the 405 from the GET) is preserved as the inner exception, not dropped.
@@ -482,8 +483,8 @@ public async Task AutoDetectMode_LogsWarning_WhenSseFallbackFailsAfterStreamable
{
return Task.FromResult(new HttpResponseMessage
{
- StatusCode = HttpStatusCode.UnsupportedMediaType,
- Content = new StringContent("Content-Type must be 'application/json'"),
+ StatusCode = HttpStatusCode.NotFound,
+ Content = new StringContent("Streamable HTTP not supported"),
});
}