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
42 changes: 27 additions & 15 deletions src/Common/HttpResponseMessageExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,37 @@ public static async Task EnsureSuccessStatusCodeWithResponseBodyAsync(this HttpR
{
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);
throw await CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);
}
}

if (responseBody.Length > MaxResponseBodyLength)
{
responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
}
}
catch
/// <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)
{
// Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
}

throw CreateHttpRequestException(response, responseBody);
}
catch
{
// Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
}

return CreateHttpRequestException(response, responseBody);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
// 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);

var streamableHttpError = await HttpResponseMessageExtensions.CreateHttpRequestExceptionWithBodyAsync(response, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the body here is correct since it runs before the response is disposed. One thing worth a short comment: when the Streamable HTTP response is application/json but simply isn't a JSON-RPC error, TryReadJsonRpcErrorAsync above has already read the body once. HttpContent buffers after the first read so this second read returns the same buffered content and is safe, but a note would stop a future reader from mistaking it for a stream-consumption bug. For the common non-JSON error responses (415, 405, plain text) there is no double read because TryReadJsonRpcErrorAsync returns early on the content type.


await streamableHttpTransport.DisposeAsync().ConfigureAwait(false);
await InitializeSseTransportAsync(message, cancellationToken).ConfigureAwait(false);
await InitializeSseTransportAsync(message, streamableHttpError, cancellationToken).ConfigureAwait(false);
}
}
catch when (ActiveTransport is null)
Expand All @@ -110,7 +114,7 @@ private async Task InitializeAsync(JsonRpcMessage message, CancellationToken can
}
}

private async Task InitializeSseTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken)
private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpRequestException? streamableHttpError, CancellationToken cancellationToken)
{
if (_options.KnownSessionId is not null)
{
Expand All @@ -128,6 +132,21 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, Cancellat
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
throw new HttpRequestException(streamableHttpError.Message, sseError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On net472 this overload can't carry the status code, so callers on that target framework only get the status text in the exception message and not a programmatic StatusCode. That is acceptable given the framework limitation, but it means the preserved-status-code behavior is net5+ only. A one line note here would make it clear this is intentional rather than an oversight.

#endif
}
catch
{
await sseTransport.DisposeAsync().ConfigureAwait(false);
Expand Down Expand Up @@ -166,4 +185,7 @@ public async ValueTask DisposeAsync()

[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);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,12 +44,12 @@ public async Task AutoDetectMode_UsesStreamableHttp_WhenServerSupportsIt()
};

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
Expand Down Expand Up @@ -102,8 +104,236 @@ public async Task AutoDetectMode_FallsBackToSse_WhenStreamableHttpFails()
};

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"));
}
}