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 @@ -73,9 +73,21 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
LogUsingStreamableHttp(_name);
ActiveTransport = streamableHttpTransport;
}
else if (IsAuthError(response.StatusCode))
{
// Authentication/authorization errors (401, 403) are not transport-related —
// the server understood the request but rejected the credentials. Falling back
// to SSE would fail with the same credentials and mask the real error.
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);

LogStreamableHttpAuthError(_name, response.StatusCode);

await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
}
else
{
// If the status code is not success, fall back to SSE
// Non-auth, non-success status codes (404, 405, 501, etc.) suggest the server
// may not support Streamable HTTP — fall back to SSE.
LogStreamableHttpFailed(_name, response.StatusCode);

await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
Expand All @@ -91,6 +103,9 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
}
}

private static bool IsAuthError(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;

private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
{
if (_options.KnownSessionId is not null)
Expand Down Expand Up @@ -139,6 +154,9 @@ public async ValueTask DisposeAsync()
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);

[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} streamable HTTP transport received authentication error {StatusCode}. Not falling back to SSE.")]
private partial void LogStreamableHttpAuthError(string endpointName, HttpStatusCode statusCode);

[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
private partial void LogUsingStreamableHttp(string endpointName);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,59 @@ public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
Assert.NotNull(session);
}

[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task AutoDetectMode_DoesNotFallBackToSse_OnAuthError(HttpStatusCode authStatusCode)
{
// Auth errors (401, 403) are not transport-related — the server understood the
// request but rejected the credentials. The SDK should propagate the error
// immediately instead of falling back to SSE, which would mask the real cause.
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 requestMethods = new List<HttpMethod>();

mockHttpHandler.RequestHandler = (request) =>
{
requestMethods.Add(request.Method);

if (request.Method == HttpMethod.Post)
{
// Streamable HTTP POST returns auth error
return Task.FromResult(new HttpResponseMessage
{
StatusCode = authStatusCode,
Content = new StringContent($"{{\"error\": \"{authStatusCode}\"}}")
});
}

// SSE GET should never be reached
throw new InvalidOperationException("Should not fall back to SSE on auth error");
};

// ConnectAsync for AutoDetect mode just creates the transport without sending
// any HTTP request. The auto-detection is triggered lazily by the first
// SendMessageAsync call, which happens inside McpClient.CreateAsync when it
// sends the JSON-RPC "initialize" message.
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal(authStatusCode, ex.StatusCode);

// Verify only POST was sent — no GET fallback
Assert.Single(requestMethods);
Assert.Equal(HttpMethod.Post, requestMethods[0]);
}

[Fact]
public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
{
Expand Down