Skip to content

Commit 7c85ac6

Browse files
Copilotstephentoub
andauthored
Include response body in HttpRequestException for transport client errors (#1193)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 482e311 commit 7c85ac6

6 files changed

Lines changed: 149 additions & 6 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.Net;
2+
using System.Net.Http;
3+
4+
namespace ModelContextProtocol;
5+
6+
/// <summary>
7+
/// Extension methods for <see cref="HttpResponseMessage"/>.
8+
/// </summary>
9+
internal static class HttpResponseMessageExtensions
10+
{
11+
private const int MaxResponseBodyLength = 1024;
12+
13+
/// <summary>
14+
/// Throws an <see cref="HttpRequestException"/> if the <see cref="HttpResponseMessage.IsSuccessStatusCode"/> property is <see langword="false"/>.
15+
/// Unlike <see cref="HttpResponseMessage.EnsureSuccessStatusCode"/>, this method includes the response body in the exception message
16+
/// to help diagnose issues when the server returns error details in the response body.
17+
/// </summary>
18+
/// <param name="response">The HTTP response message to check.</param>
19+
/// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
20+
/// <returns>A task that represents the asynchronous operation.</returns>
21+
/// <exception cref="HttpRequestException">The response status code does not indicate success.</exception>
22+
public static async Task EnsureSuccessStatusCodeWithResponseBodyAsync(this HttpResponseMessage response, CancellationToken cancellationToken = default)
23+
{
24+
if (!response.IsSuccessStatusCode)
25+
{
26+
string? responseBody = null;
27+
try
28+
{
29+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
30+
cts.CancelAfter(TimeSpan.FromSeconds(5));
31+
responseBody = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
32+
33+
if (responseBody.Length > MaxResponseBodyLength)
34+
{
35+
responseBody = responseBody.Substring(0, MaxResponseBodyLength) + "...";
36+
}
37+
}
38+
catch
39+
{
40+
// Ignore all errors reading the response body (e.g., stream closed, timeout, cancellation) - we'll throw without it.
41+
}
42+
43+
throw CreateHttpRequestException(response, responseBody);
44+
}
45+
}
46+
47+
/// <summary>
48+
/// Creates an <see cref="HttpRequestException"/> for a non-success response, including the response body in the message.
49+
/// </summary>
50+
/// <param name="response">The HTTP response message.</param>
51+
/// <param name="responseBody">The response body content, if available.</param>
52+
/// <returns>An <see cref="HttpRequestException"/> with the response details.</returns>
53+
public static HttpRequestException CreateHttpRequestException(HttpResponseMessage response, string? responseBody)
54+
{
55+
int statusCodeInt = (int)response.StatusCode;
56+
string message = string.IsNullOrEmpty(responseBody)
57+
? $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase})."
58+
: $"Response status code does not indicate success: {statusCodeInt} ({response.ReasonPhrase}). Response body: {responseBody}";
59+
60+
#if NET
61+
return new HttpRequestException(message, inner: null, response.StatusCode);
62+
#else
63+
return new HttpRequestException(message);
64+
#endif
65+
}
66+
}

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ private async Task<string> ExchangeCodeForTokenAsync(
499499
};
500500

501501
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
502-
httpResponse.EnsureSuccessStatusCode();
502+
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
503503

504504
var tokens = await HandleSuccessfulTokenResponseAsync(httpResponse, cancellationToken).ConfigureAwait(false);
505505
LogOAuthAuthorizationCompleted();
@@ -544,7 +544,7 @@ private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpRespon
544544
using var httpResponse = await _httpClient.GetAsync(metadataUrl, cancellationToken).ConfigureAwait(false);
545545
if (requireSuccess)
546546
{
547-
httpResponse.EnsureSuccessStatusCode();
547+
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
548548
}
549549
else if (!httpResponse.IsSuccessStatusCode)
550550
{

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,19 @@ public override async Task SendMessageAsync(
8888

8989
if (!response.IsSuccessStatusCode)
9090
{
91+
// Read the response body once to include in both logging and exception
92+
string responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
93+
9194
if (_logger.IsEnabled(LogLevel.Trace))
9295
{
93-
LogRejectedPostSensitive(Name, messageId, await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
96+
LogRejectedPostSensitive(Name, messageId, responseBody);
9497
}
9598
else
9699
{
97100
LogRejectedPost(Name, messageId);
98101
}
99102

100-
response.EnsureSuccessStatusCode();
103+
throw HttpResponseMessageExtensions.CreateHttpRequestException(response, responseBody);
101104
}
102105
}
103106

@@ -148,7 +151,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
148151

149152
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
150153

151-
response.EnsureSuccessStatusCode();
154+
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
152155

153156
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
154157

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation
6161
{
6262
// Immediately dispose the response. SendHttpRequestAsync only returns the response so the auto transport can look at it.
6363
using var response = await SendHttpRequestAsync(message, cancellationToken).ConfigureAwait(false);
64-
response.EnsureSuccessStatusCode();
64+
await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
6565
}
6666

6767
// This is used by the auto transport so it can fall back and try SSE given a non-200 response without catching an exception.

src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<Compile Include="..\Common\Throw.cs" Link="Throw.cs" />
2626
<Compile Include="..\Common\Obsoletions.cs" Link="Obsoletions.cs" />
2727
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
28+
<Compile Include="..\Common\HttpResponseMessageExtensions.cs" Link="HttpResponseMessageExtensions.cs" />
2829
<Compile Include="..\Common\ServerSentEvents\**\*.cs" Link="ServerSentEvents\%(RecursiveDir)%(FileName)%(Extension)" />
2930
</ItemGroup>
3031

tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,32 @@ public async Task ConnectAsync_Throws_Exception_On_Failure()
8282
Assert.Equal(1, retries);
8383
}
8484

85+
[Fact]
86+
public async Task ConnectAsync_Throws_HttpRequestException_With_ResponseBody_On_ErrorStatusCode()
87+
{
88+
using var mockHttpHandler = new MockHttpHandler();
89+
using var httpClient = new HttpClient(mockHttpHandler);
90+
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
91+
92+
const string errorDetails = "Bad request: Invalid MCP protocol version";
93+
mockHttpHandler.RequestHandler = (request) =>
94+
{
95+
return Task.FromResult(new HttpResponseMessage
96+
{
97+
StatusCode = HttpStatusCode.BadRequest,
98+
ReasonPhrase = "Bad Request",
99+
Content = new StringContent(errorDetails)
100+
});
101+
};
102+
103+
var httpException = await Assert.ThrowsAsync<HttpRequestException>(() => transport.ConnectAsync(TestContext.Current.CancellationToken));
104+
Assert.Contains(errorDetails, httpException.Message);
105+
Assert.Contains("400", httpException.Message);
106+
#if NET
107+
Assert.Equal(HttpStatusCode.BadRequest, httpException.StatusCode);
108+
#endif
109+
}
110+
85111
[Fact]
86112
public async Task SendMessageAsync_Handles_Accepted_Response()
87113
{
@@ -120,6 +146,53 @@ public async Task SendMessageAsync_Handles_Accepted_Response()
120146
Assert.True(true);
121147
}
122148

149+
[Fact]
150+
public async Task SendMessageAsync_Throws_HttpRequestException_With_ResponseBody_On_ErrorStatusCode()
151+
{
152+
using var mockHttpHandler = new MockHttpHandler();
153+
using var httpClient = new HttpClient(mockHttpHandler);
154+
await using var transport = new HttpClientTransport(_transportOptions, httpClient, LoggerFactory);
155+
156+
var firstCall = true;
157+
const string errorDetails = "Invalid JSON-RPC message format: missing 'id' field";
158+
159+
mockHttpHandler.RequestHandler = (request) =>
160+
{
161+
if (request.Method == HttpMethod.Post && request.RequestUri?.AbsoluteUri == "http://localhost:8080/sseendpoint")
162+
{
163+
return Task.FromResult(new HttpResponseMessage
164+
{
165+
StatusCode = HttpStatusCode.BadRequest,
166+
ReasonPhrase = "Bad Request",
167+
Content = new StringContent(errorDetails)
168+
});
169+
}
170+
else
171+
{
172+
if (!firstCall)
173+
throw new IOException("Abort");
174+
else
175+
firstCall = false;
176+
177+
return Task.FromResult(new HttpResponseMessage
178+
{
179+
StatusCode = HttpStatusCode.OK,
180+
Content = new StringContent("event: endpoint\r\ndata: /sseendpoint\r\n\r\n")
181+
});
182+
}
183+
};
184+
185+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
186+
var httpException = await Assert.ThrowsAsync<HttpRequestException>(() =>
187+
session.SendMessageAsync(new JsonRpcRequest { Method = RequestMethods.Initialize, Id = new RequestId(44) }, CancellationToken.None));
188+
189+
Assert.Contains(errorDetails, httpException.Message);
190+
Assert.Contains("400", httpException.Message);
191+
#if NET
192+
Assert.Equal(HttpStatusCode.BadRequest, httpException.StatusCode);
193+
#endif
194+
}
195+
123196
[Fact]
124197
public async Task ReceiveMessagesAsync_Handles_Messages()
125198
{

0 commit comments

Comments
 (0)