diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 7563acd10..c493acbe0 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -74,58 +74,45 @@ 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 (await StreamableHttpClientSessionTransport.TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && + StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) { - // A JSON-RPC error envelope in the body means the peer IS a Streamable HTTP server. - // Adopt it before surfacing the failure so the catch filter leaves the now-owned - // transport alone, and never mask the response by attempting deprecated SSE. + // Recognized modern JSON-RPC error: the peer is Streamable HTTP. Adopt it and surface + // the protocol failure without masking it behind a deprecated SSE GET. LogUsingStreamableHttp(_name); ActiveTransport = streamableHttpTransport; - - if (StreamableHttpClientSessionTransport.ShouldSurfaceJsonRpcErrorAsProtocolException(response.StatusCode, parsedError)) - { - throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); - } - - // TryReadJsonRpcErrorAsync buffered the content, so this preserves the same response - // body and status without consuming the network stream a second time. - throw await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); + throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } else { - // Non-JSON-RPC error response: either the server doesn't speak MCP at all, or this - // is an older deployment that expects the SSE transport (which establishes its - // protocol via GET /sse rather than POST). Fall back to SSE per the original - // behavior. Capture the underlying error (status + body) before falling back so that, - // if SSE also fails, we can surface the real Streamable HTTP diagnostic to the caller - // instead of dropping it on the floor (see https://github.com/modelcontextprotocol/csharp-sdk/issues/1526). - // This reads the response body a second time for the application/json case, where - // TryReadJsonRpcErrorAsync above already read it. HttpContent buffers after the first - // read, so this returns the same buffered content and is safe (not a second stream - // consumption). For the common non-JSON error responses (415, 405, plain text) - // TryReadJsonRpcErrorAsync returns early on the content type, so there is no double read. + // Unstructured response, or a parsed JSON-RPC error that is not a recognized modern + // signal. Classify the full HTTP response: preserve #1855's server/discover 400/404 + // skip, keep 401/403/5xx (and other non-allowlisted statuses) off SSE, and try SSE + // only for the remaining unrecognized 400/404/405 responses (including a JSON-RPC- + // bodied 405 that is not a recognized modern error). + // + // TryReadJsonRpcErrorAsync may already have buffered application/json content; + // HttpContent returns that buffer, so this is safe (not a second stream consumption). + // For common non-JSON errors (415, 405, plain text) TryReadJsonRpcErrorAsync returns + // early on the content type, so there is no double read. var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false); - if (IsDiscoverProbeRejection(message, response.StatusCode)) + // Preserve #1855: unrecognized server/discover 400/404 must reach the initialize + // retry without an intervening GET. Non-allowlisted statuses (401/403/5xx/415/…) + // retain their HTTP semantics without a deprecated GET. + if (IsDiscoverProbeRejection(message, response.StatusCode) || + !ShouldTrySseFallback(response.StatusCode)) { - // The server/discover probe is protocol negotiation, not transport detection. A server - // predating SEP-2575 rejects the session-less POST with 400 (can't parse the request) or - // 404 (requires Mcp-Session-Id on every non-initialize POST) whether it speaks Streamable - // HTTP or SSE, so neither status is evidence about which transport to use. McpClientImpl - // .ConnectAsync treats exactly these two statuses as "initialize-handshake server" and - // immediately retries with initialize on this same transport — and that attempt still - // falls back to SSE, so an SSE-only server is reached one POST later rather than not at - // all. Attempting SSE here instead spends a GET whose result is discarded on every - // connect to a Streamable-HTTP-only server that predates SEP-2575, and logs a "falling - // back to SSE transport" line that misreports settled protocol negotiation as a failure. - LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode); - - await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); + if (IsDiscoverProbeRejection(message, response.StatusCode)) + { + LogSkippingSseFallbackForDiscoverProbe(_name, response.StatusCode); + } + throw streamableHttpError; } + // Try SSE for the remaining unrecognized 400/404/405 responses. LogStreamableHttpFailed(_name, response.StatusCode); - await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false); } @@ -133,7 +120,7 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can catch when (ActiveTransport is null) { // Only dispose the Streamable HTTP transport when we didn't adopt it. If we set - // ActiveTransport above (success path OR structured-error path), the transport's + // ActiveTransport above (success path OR recognized-error path), the transport's // lifetime is owned by the outer transport from this point on. await streamableHttpTransport.DisposeAsync().ConfigureAwait(false); throw; @@ -205,6 +192,15 @@ public async ValueTask DisposeAsync() } } + /// + /// Spec allowlist for HTTP→SSE transport fallback: only 400, 404, or 405 may indicate an older + /// SSE-only deployment. Authentication, authorization, and server errors must not trigger a GET. + /// + 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..768c24583 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -381,14 +381,19 @@ 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) { // 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, + // 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. Other statuses stay uncaught and surface to the caller. + // 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 557dc5655..d22ac254a 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -220,7 +220,10 @@ public async Task Client_OnFallbackHttpStatusFromProbe_FallsBackTo_Initialize( // 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. + // 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; @@ -240,17 +243,20 @@ 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)] + 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); @@ -261,23 +267,131 @@ public async Task Client_OnStructuredInvalidRequestFromHttpProbe_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 (no GET), and in AutoDetect mode the client + // attempts the deprecated SSE GET. Assert GET/no-GET explicitly; do not treat "no initialize" + // as a general invariant after a successful SSE selection (revised #1719 will handshake then). + 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.Equal(expectSseAttempt, sseRequested); + if (transportMode == HttpTransportMode.StreamableHttp) + { + // Explicit Streamable HTTP has no SSE path, so initialize must not be used to recover a 405. + Assert.False(initializeReceived); + } + } + + [Theory] + [InlineData(HttpTransportMode.StreamableHttp, false)] + [InlineData(HttpTransportMode.AutoDetect, true)] + public async Task Client_OnStructured405FromProbe_TriesSseOnlyInAutoDetect( + HttpTransportMode transportMode, bool expectSseAttempt) + { + // An unrecognized JSON-RPC error on 405 is not a recognized modern signal, so AutoDetect must + // classify the full response and try SSE. Explicit Streamable HTTP surfaces the 405 with no GET. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + var sseRequested = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredProbeRejectingServer( + HttpStatusCode.MethodNotAllowed, + () => 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.Equal(expectSseAttempt, sseRequested); + if (transportMode == HttpTransportMode.StreamableHttp) + { + Assert.False(initializeReceived); + } + } + [Theory] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.UnsupportedMediaType, HttpTransportMode.StreamableHttp)] [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Unauthorized, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.UnsupportedMediaType, 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. + // Non-allowlisted statuses (401/403/5xx/415/…) must surface directly for both structured and + // unstructured bodies — no initialize handshake and no deprecated SSE GET. 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); + + await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.False(initializeReceived); + Assert.False(sseRequested); + } + + [Theory] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.UnsupportedMediaType, HttpTransportMode.StreamableHttp)] + [InlineData(HttpStatusCode.InternalServerError, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Unauthorized, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.Forbidden, HttpTransportMode.AutoDetect)] + [InlineData(HttpStatusCode.UnsupportedMediaType, HttpTransportMode.AutoDetect)] + public async Task Client_OnStructuredOtherHttpErrorFromProbe_Surfaces_NoGet( + HttpStatusCode status, HttpTransportMode transportMode) + { + // Same non-allowlist coverage for unrecognized JSON-RPC-bodied responses: no SSE GET. + var ct = TestContext.Current.CancellationToken; + var initializeReceived = false; + var sseRequested = false; + + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler); + mockHttpHandler.RequestHandler = CreateStructuredProbeRejectingServer( + status, () => initializeReceived = true, () => sseRequested = true); await using var transport = CreateTransport(httpClient, transportMode); @@ -288,6 +402,7 @@ await Assert.ThrowsAnyAsync(async () => }); Assert.False(initializeReceived); + Assert.False(sseRequested); } private HttpClientTransport CreateTransport(HttpClient httpClient, HttpTransportMode transportMode) @@ -303,13 +418,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,12 +458,15 @@ private static Func> CreateProbeRe } }; - private static Func> CreateStructuredInvalidRequestProbeServer( - Action onInitialize) + private static Func> CreateStructuredProbeRejectingServer( + HttpStatusCode probeStatus, Action onInitialize, Action? onSseRequest = null) => async request => { if (request.Method == HttpMethod.Get) + { + onSseRequest?.Invoke(); return EmptyResponse(HttpStatusCode.MethodNotAllowed); + } var body = await request.Content!.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); @@ -356,7 +478,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"), }; diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs index c46383306..8823d4c35 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"), }); } @@ -616,10 +617,10 @@ await session.SendMessageAsync( await ssePipe.Writer.CompleteAsync(); } - // The skip is scoped to the two statuses ConnectAsync acts on. Any other failure on the discover probe - // keeps the original fallback, because it is not evidence that an initialize retry is coming. + // 415 is outside the spec's 400/404/405 SSE-fallback allowlist, so AutoDetect must surface it + // without a deprecated GET — including when the failing request happens to be server/discover. [Fact] - public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrelatedStatus() + public async Task AutoDetectMode_SkipsSseFallback_WhenDiscoverProbeFailsWithNonAllowlistedStatus() { var options = new HttpClientTransportOptions { @@ -649,11 +650,15 @@ public async Task AutoDetectMode_FallsBackToSse_WhenDiscoverProbeFailsWithUnrela await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); - await Assert.ThrowsAsync(() => + var ex = await Assert.ThrowsAsync(() => session.SendMessageAsync( new JsonRpcRequest { Method = RequestMethods.ServerDiscover, Id = new RequestId(1) }, TestContext.Current.CancellationToken)); - Assert.Equal(1, getCount); + Assert.Equal(0, getCount); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, ex.Data["ModelContextProtocol.HttpStatusCode"]); + Assert.DoesNotContain( + MockLoggerProvider.LogMessages, + m => m.Message.Contains("falling back to SSE transport")); } }