fix(client): preserve underlying status code in AutoDetect probe (#1530)
This commit is contained in:
parent
20c464c55b
commit
ba372faece
3 changed files with 295 additions and 23 deletions
|
|
@ -23,27 +23,39 @@ internal static class HttpResponseMessageExtensions
|
|||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string? responseBody = null;
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (responseBody.Length > MaxResponseBodyLength)
|
||||
{
|
||||
responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
|
||||
}
|
||||
|
||||
throw CreateHttpRequestException(response, responseBody);
|
||||
throw await CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpRequestException"/> for a non-success response, making a best-effort attempt to
|
||||
/// include the server's response body in the exception message so the diagnostic isn't lost.
|
||||
/// </summary>
|
||||
/// <param name="response">The non-success HTTP response message.</param>
|
||||
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
|
||||
/// <returns>An <see cref="HttpRequestException"/> with the response status and, when available, its body.</returns>
|
||||
public static async Task<HttpRequestException> CreateHttpRequestExceptionWithBodyAsync(HttpResponseMessage response, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? responseBody = null;
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (responseBody.Length > MaxResponseBodyLength)
|
||||
{
|
||||
responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
|
||||
}
|
||||
|
||||
return CreateHttpRequestException(response, responseBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpRequestException"/> for a non-success response, including the response body in the message.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -93,11 +93,20 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
// 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.
|
||||
// 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).
|
||||
LogStreamableHttpFailed(_name, response.StatusCode);
|
||||
|
||||
// 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.
|
||||
var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
|
||||
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch when (ActiveTransport is null)
|
||||
|
|
@ -110,7 +119,7 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
}
|
||||
}
|
||||
|
||||
private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
|
||||
private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options.KnownSessionId is not null)
|
||||
{
|
||||
|
|
@ -128,6 +137,24 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
LogUsingSSE(_name);
|
||||
ActiveTransport = sseTransport;
|
||||
}
|
||||
catch (Exception sseError) when (streamableHttpError is not null && sseError is not OperationCanceledException)
|
||||
{
|
||||
// SSE fallback also failed. Surface the original Streamable HTTP error as the primary failure so the
|
||||
// user sees the real server diagnostic (e.g. 415 Unsupported Media Type) instead of the unrelated
|
||||
// SSE-fallback error (e.g. a 405 from a Streamable-HTTP-only server that doesn't accept GET). Preserve
|
||||
// the original status code and attach the SSE failure as the inner exception so neither is lost, and
|
||||
// keep HttpRequestException as the surfaced type so existing callers can still catch it and read StatusCode.
|
||||
await sseTransport.DisposeAsync().ConfigureAwait(false);
|
||||
LogSseFallbackFailedAfterStreamableHttp(_name, sseError);
|
||||
#if NET
|
||||
throw new HttpRequestException(streamableHttpError.Message, sseError, streamableHttpError.StatusCode);
|
||||
#else
|
||||
// net472 has no HttpRequestException overload that carries a status code, so this target
|
||||
// preserves the status text in the message but not a programmatic StatusCode. Preserving the
|
||||
// status code is intentionally net5+ only rather than an oversight.
|
||||
throw new HttpRequestException(streamableHttpError.Message, sseError);
|
||||
#endif
|
||||
}
|
||||
catch
|
||||
{
|
||||
await sseTransport.DisposeAsync().ConfigureAwait(false);
|
||||
|
|
@ -166,4 +193,7 @@ internal sealed partial class AutoDetectingClientSessionTransport : ITransport
|
|||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
|
||||
private partial void LogUsingSSE(string endpointName);
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} SSE fallback failed after Streamable HTTP also failed; surfacing both errors.")]
|
||||
private partial void LogSseFallbackFailedAfterStreamableHttp(string endpointName, Exception sseError);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Transport;
|
||||
|
|
@ -42,12 +44,12 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp
|
|||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
|
||||
// The auto-detecting transport should be returned
|
||||
Assert.NotNull(session);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
|
||||
{
|
||||
var options = new HttpClientTransportOptions
|
||||
|
|
@ -102,8 +104,236 @@ public class HttpClientTransportAutoDetectTests(ITestOutputHelper testOutputHelp
|
|||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
|
||||
// The auto-detecting transport should be returned
|
||||
Assert.NotNull(session);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_PreservesOriginalError_WhenStreamableHttpReturns415AndSseFallbackFails()
|
||||
{
|
||||
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);
|
||||
|
||||
const string streamableHttpBody = "Content-Type must be 'application/json'";
|
||||
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
// Streamable HTTP fails with 415 - this is the real server diagnostic the user needs to see.
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.UnsupportedMediaType,
|
||||
Content = new StringContent(streamableHttpBody),
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.MethodNotAllowed,
|
||||
Content = new StringContent("Method not allowed"),
|
||||
});
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request: {request.Method}");
|
||||
};
|
||||
|
||||
// ConnectAsync only constructs the AutoDetect transport; the probe runs on the first SendMessageAsync.
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
session.SendMessageAsync(
|
||||
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.
|
||||
// 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(streamableHttpBody, combined);
|
||||
|
||||
static string Flatten(Exception e)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
void Walk(Exception? cur)
|
||||
{
|
||||
while (cur is not null)
|
||||
{
|
||||
sb.Append(cur.GetType().FullName).Append(": ").AppendLine(cur.Message);
|
||||
if (cur is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
{
|
||||
Walk(inner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
cur = cur.InnerException;
|
||||
}
|
||||
}
|
||||
Walk(e);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// When Streamable HTTP fails (non-JSON-RPC) and the SSE fallback also fails, the surfaced exception must remain
|
||||
// an HttpRequestException carrying the original Streamable HTTP status/body, with the SSE failure as its inner.
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_SurfacesStreamableHttpError_WithSseAsInner_WhenSseFallbackFails()
|
||||
{
|
||||
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);
|
||||
|
||||
const string streamableHttpBody = "Content-Type must be 'application/json'";
|
||||
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.UnsupportedMediaType,
|
||||
Content = new StringContent(streamableHttpBody),
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Method == HttpMethod.Get)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.MethodNotAllowed,
|
||||
Content = new StringContent("Method not allowed"),
|
||||
});
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request: {request.Method}");
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
session.SendMessageAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
// The surfaced exception is the original Streamable HTTP error (the real server diagnostic), not the SSE 405.
|
||||
var httpEx = Assert.IsType<HttpRequestException>(ex);
|
||||
Assert.Contains("415", httpEx.Message);
|
||||
Assert.Contains(streamableHttpBody, httpEx.Message);
|
||||
#if NET
|
||||
Assert.Equal(HttpStatusCode.UnsupportedMediaType, httpEx.StatusCode);
|
||||
#endif
|
||||
|
||||
// The SSE fallback failure (the 405 from the GET) is preserved as the inner exception, not dropped.
|
||||
Assert.NotNull(httpEx.InnerException);
|
||||
Assert.Contains("405", httpEx.InnerException.ToString());
|
||||
}
|
||||
|
||||
// Cancellation during the AutoDetect probe must surface as an OperationCanceledException, not be masked or
|
||||
// wrapped in the dual-failure AggregateException.
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_SurfacesCancellation_WithoutWrapping()
|
||||
{
|
||||
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);
|
||||
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.UnsupportedMediaType,
|
||||
Content = new StringContent("Content-Type must be 'application/json'"),
|
||||
});
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var ex = await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
session.SendMessageAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
|
||||
cts.Token));
|
||||
|
||||
Assert.IsNotType<AggregateException>(ex);
|
||||
Assert.IsAssignableFrom<OperationCanceledException>(ex);
|
||||
}
|
||||
|
||||
// The dual-failure case must be visible in logs at Warning level, even when callers swallow the exception.
|
||||
[Fact]
|
||||
public async Task AutoDetectMode_LogsWarning_WhenSseFallbackFailsAfterStreamableHttp()
|
||||
{
|
||||
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);
|
||||
|
||||
mockHttpHandler.RequestHandler = (request) =>
|
||||
{
|
||||
if (request.Method == HttpMethod.Post)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.UnsupportedMediaType,
|
||||
Content = new StringContent("Content-Type must be 'application/json'"),
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.MethodNotAllowed,
|
||||
Content = new StringContent("Method not allowed"),
|
||||
});
|
||||
};
|
||||
|
||||
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() =>
|
||||
session.SendMessageAsync(
|
||||
new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(1) },
|
||||
TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains(
|
||||
MockLoggerProvider.LogMessages,
|
||||
m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SSE fallback failed"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue