Skip to content

Commit 7405d8b

Browse files
Xue CaiCopilot
andcommitted
fix: propagate auth errors (401/403) immediately instead of falling back to SSE
When AutoDetectingClientSessionTransport receives a 401 or 403 from the initial Streamable HTTP POST, it should NOT fall back to SSE transport. Auth errors are not transport-related — the server understood the request but rejected the credentials. Falling back to SSE would: 1. Fail with the same credentials (or a different transport error) 2. Mask the real authentication error from the caller This was discovered investigating Azure AI Search MCP endpoints returning 403 on Streamable HTTP POST, which caused the SDK to fall back to SSE GET, resulting in a confusing 405 error that hid the real auth failure. The fix adds an explicit check for 401/403 before the SSE fallback path, and throws HttpRequestException with the auth status code immediately. Code reference: AutoDetectingClientSessionTransport.InitializeAsync https://github.com/modelcontextprotocol/csharp-sdk/blob/v0.9.0-preview.2/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs#L61 Testing: # Build (two warnings suppressed are pre-existing on main, not related to this change): dotnet build '/p:NoWarn=NU1903%3BMCPEXP001' # Run all transport tests on all 3 target frameworks (73 tests × 3 = 219 total, 0 failures): dotnet test tests/ModelContextProtocol.Tests/ '/p:NoWarn=NU1903%3BMCPEXP001' --no-build --filter "FullyQualifiedName~Transport" --framework net10.0 dotnet test tests/ModelContextProtocol.Tests/ '/p:NoWarn=NU1903%3BMCPEXP001' --no-build --filter "FullyQualifiedName~Transport" --framework net9.0 dotnet test tests/ModelContextProtocol.Tests/ '/p:NoWarn=NU1903%3BMCPEXP001' --no-build --filter "FullyQualifiedName~Transport" --framework net8.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aae77b1 commit 7405d8b

File tree

2 files changed

+68
-1
lines changed

2 files changed

+68
-1
lines changed

src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,21 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
7373
LogUsingStreamableHttp(_name);
7474
ActiveTransport = streamableHttpTransport;
7575
}
76+
else if (IsAuthError(response.StatusCode))
77+
{
78+
// Authentication/authorization errors (401, 403) are not transport-related —
79+
// the server understood the request but rejected the credentials. Falling back
80+
// to SSE would fail with the same credentials and mask the real error.
81+
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
82+
83+
LogStreamableHttpAuthError(_name, response.StatusCode);
84+
85+
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
86+
}
7687
else
7788
{
78-
// If the status code is not success, fall back to SSE
89+
// Non-auth, non-success status codes (404, 405, 501, etc.) suggest the server
90+
// may not support Streamable HTTP — fall back to SSE.
7991
LogStreamableHttpFailed(_name, response.StatusCode);
8092

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

106+
private static bool IsAuthError(HttpStatusCode statusCode) =>
107+
statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
108+
94109
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
95110
{
96111
if (_options.KnownSessionId is not null)
@@ -139,6 +154,9 @@ public async ValueTask DisposeAsync()
139154
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} streamable HTTP transport failed with status code {StatusCode}, falling back to SSE transport.")]
140155
private partial void LogStreamableHttpFailed(string endpointName, HttpStatusCode statusCode);
141156

157+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} streamable HTTP transport received authentication error {StatusCode}. Not falling back to SSE.")]
158+
private partial void LogStreamableHttpAuthError(string endpointName, HttpStatusCode statusCode);
159+
142160
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using Streamable HTTP transport.")]
143161
private partial void LogUsingStreamableHttp(string endpointName);
144162

tests/ModelContextProtocol.Tests/Transport/HttpClientTransportAutoDetectTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,55 @@ public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
4747
Assert.NotNull(session);
4848
}
4949

50+
[Theory]
51+
[InlineData(HttpStatusCode.Unauthorized)]
52+
[InlineData(HttpStatusCode.Forbidden)]
53+
public async Task AutoDetectMode_DoesNotFallBackToSse_OnAuthError(HttpStatusCode authStatusCode)
54+
{
55+
// Auth errors (401, 403) are not transport-related — the server understood the
56+
// request but rejected the credentials. The SDK should propagate the error
57+
// immediately instead of falling back to SSE, which would mask the real cause.
58+
var options = new HttpClientTransportOptions
59+
{
60+
Endpoint = new Uri("http://localhost"),
61+
TransportMode = HttpTransportMode.AutoDetect,
62+
Name = "AutoDetect test client"
63+
};
64+
65+
using var mockHttpHandler = new MockHttpHandler();
66+
using var httpClient = new HttpClient(mockHttpHandler);
67+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
68+
69+
var requestMethods = new List<HttpMethod>();
70+
71+
mockHttpHandler.RequestHandler = (request) =>
72+
{
73+
requestMethods.Add(request.Method);
74+
75+
if (request.Method == HttpMethod.Post)
76+
{
77+
// Streamable HTTP POST returns auth error
78+
return Task.FromResult(new HttpResponseMessage
79+
{
80+
StatusCode = authStatusCode,
81+
Content = new StringContent($"{{\"error\": \"{authStatusCode}\"}}")
82+
});
83+
}
84+
85+
// SSE GET should never be reached
86+
throw new InvalidOperationException("Should not fall back to SSE on auth error");
87+
};
88+
89+
var ex = await Assert.ThrowsAsync<HttpRequestException>(
90+
() => transport.ConnectAsync(TestContext.Current.CancellationToken));
91+
92+
Assert.Equal(authStatusCode, ex.StatusCode);
93+
94+
// Verify only POST was sent — no GET fallback
95+
Assert.Single(requestMethods);
96+
Assert.Equal(HttpMethod.Post, requestMethods[0]);
97+
}
98+
5099
[Fact]
51100
public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
52101
{

0 commit comments

Comments
 (0)