Skip to content

Commit fb2daf2

Browse files
Copilothalter73
andauthored
Fix HttpClient timeout for long-running tools without event stream store (#1268)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
1 parent ec2983b commit fb2daf2

File tree

5 files changed

+172
-41
lines changed

5 files changed

+172
-41
lines changed

src/ModelContextProtocol.Core/Server/StreamableHttpPostTransport.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ public async ValueTask<bool> HandlePostAsync(JsonRpcMessage message, Cancellatio
7474
{
7575
await _httpSseWriter.WriteAsync(primingItem.Value, cancellationToken).ConfigureAwait(false);
7676
}
77+
else
78+
{
79+
// If there's no priming write, flush the stream to ensure HTTP response headers are
80+
// sent to the client now that the server is ready to process the request.
81+
// This prevents HttpClient timeout for long-running requests.
82+
await responseStream.FlushAsync(cancellationToken).ConfigureAwait(false);
83+
}
7784

7885
// Ensure that we've sent the priming event before processing the incoming request.
7986
await parentTransport.MessageWriter.WriteAsync(message, cancellationToken).ConfigureAwait(false);

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,50 @@ public async Task Server_ShutsDownQuickly_WhenClientIsConnected()
231231
"This suggests the GET request is not respecting ApplicationStopping token.");
232232
}
233233

234+
[Fact]
235+
public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore()
236+
{
237+
// Regression test for: Tool calls that last over HttpClient timeout without producing
238+
// intermediate notifications will timeout because HttpClient doesn't see the 200 response
239+
// until the first message is written. When primingItem is null (no ISseEventStreamStore),
240+
// we should flush the response stream so HttpClient sees the 200 immediately.
241+
242+
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<LongRunningTools>();
243+
244+
await using var app = Builder.Build();
245+
app.MapMcp();
246+
await app.StartAsync(TestContext.Current.CancellationToken);
247+
248+
// Create a custom HttpClient with a very short timeout (1 second)
249+
// The tool will take 2 seconds to complete
250+
using var shortTimeoutClient = new HttpClient(SocketsHttpHandler)
251+
{
252+
BaseAddress = new Uri("http://localhost:5000/"),
253+
Timeout = TimeSpan.FromSeconds(1)
254+
};
255+
256+
var path = UseStreamableHttp ? "/" : "/sse";
257+
var transportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse;
258+
259+
await using var transport = new HttpClientTransport(new()
260+
{
261+
Endpoint = new($"http://localhost:5000{path}"),
262+
TransportMode = transportMode,
263+
}, shortTimeoutClient, LoggerFactory);
264+
265+
await using var mcpClient = await McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
266+
267+
// Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout
268+
// because the response stream is flushed immediately after receiving the request
269+
var response = await mcpClient.CallToolAsync(
270+
"long_running_operation",
271+
new Dictionary<string, object?>() { ["durationMs"] = 2000 },
272+
cancellationToken: TestContext.Current.CancellationToken);
273+
274+
var content = Assert.Single(response.Content.OfType<TextContentBlock>());
275+
Assert.Equal("Operation completed after 2000ms", content.Text);
276+
}
277+
234278
private ClaimsPrincipal CreateUser(string name)
235279
=> new(new ClaimsIdentity(
236280
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
@@ -288,4 +332,17 @@ public static async Task<string> SamplingToolAsync(McpServer server, string prom
288332
return $"Sampling completed successfully. Client responded: {Assert.IsType<TextContentBlock>(Assert.Single(samplingResult.Content)).Text}";
289333
}
290334
}
335+
336+
[McpServerToolType]
337+
protected class LongRunningTools
338+
{
339+
[McpServerTool, Description("Simulates a long-running operation")]
340+
public static async Task<string> LongRunningOperation(
341+
[Description("Duration of the operation in milliseconds")] int durationMs,
342+
CancellationToken cancellationToken)
343+
{
344+
await Task.Delay(durationMs, cancellationToken);
345+
return $"Operation completed after {durationMs}ms";
346+
}
347+
}
291348
}

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.AspNetCore.Authentication;
22
using Microsoft.AspNetCore.Authentication.JwtBearer;
3+
using Microsoft.AspNetCore.Authorization;
34
using Microsoft.AspNetCore.Builder;
45
using Microsoft.AspNetCore.Http;
56
using Microsoft.AspNetCore.WebUtilities;
@@ -8,10 +9,12 @@
89
using ModelContextProtocol.AspNetCore.Authentication;
910
using ModelContextProtocol.Authentication;
1011
using ModelContextProtocol.Client;
12+
using ModelContextProtocol.Protocol;
1113
using ModelContextProtocol.Server;
1214
using System.Net;
1315
using System.Net.Http.Json;
1416
using System.Security.Claims;
17+
using System.Text.Json;
1518
using Xunit.Sdk;
1619

1720
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@@ -211,32 +214,46 @@ public async Task CanAuthenticate_WithTokenRefresh()
211214
{
212215
var hasForcedRefresh = false;
213216

214-
Builder.Services.AddHttpContextAccessor();
215217
Builder.Services.AddMcpServer(options =>
218+
{
219+
options.ToolCollection = new();
220+
});
221+
222+
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
223+
{
224+
// Add middleware to intercept list tools requests and force a token refresh on the first call
225+
app.Use(async (context, next) =>
216226
{
217-
options.ToolCollection = new();
218-
})
219-
.AddListToolsFilter(next =>
220-
{
221-
return async (mcpContext, cancellationToken) =>
227+
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh)
222228
{
223-
if (!hasForcedRefresh)
229+
// Enable buffering so we can read the request body multiple times
230+
context.Request.EnableBuffering();
231+
232+
// Read the request body to check if it's calling tools/list
233+
var message = await JsonSerializer.DeserializeAsync(
234+
context.Request.Body,
235+
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
236+
context.RequestAborted) as JsonRpcMessage;
237+
238+
// Reset the request body position so MapMcp can read it
239+
context.Request.Body.Position = 0;
240+
241+
// Check if this is a tools/list request
242+
if (message is JsonRpcRequest request && request.Method == "tools/list")
224243
{
225244
hasForcedRefresh = true;
226245

227-
var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!;
228-
await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
229-
await httpContext.Response.CompleteAsync();
230-
throw new Exception("This exception will not impact the client because the response has already been completed.");
231-
}
232-
else
233-
{
234-
return await next(mcpContext, cancellationToken);
246+
// Return 401 to force token refresh
247+
await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
248+
await context.Response.StartAsync(context.RequestAborted);
249+
await context.Response.Body.FlushAsync(context.RequestAborted);
250+
return; // Short-circuit, don't call next()
235251
}
236-
};
237-
});
252+
}
238253

239-
await using var app = await StartMcpServerAsync();
254+
await next(context);
255+
});
256+
});
240257

241258
await using var transport = new HttpClientTransport(new()
242259
{
@@ -451,29 +468,66 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader()
451468
{
452469
var adminScopes = "admin:read admin:write";
453470

454-
Builder.Services.AddHttpContextAccessor();
455471
Builder.Services.AddMcpServer()
456472
.WithTools([
457473
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
458-
async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
474+
(ClaimsPrincipal user) =>
459475
{
460-
if (!user.HasClaim("scope", adminScopes))
461-
{
462-
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
463-
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
464-
httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
465-
await httpContext.Response.CompleteAsync();
466-
467-
throw new Exception("This exception will not impact the client because the response has already been completed.");
468-
}
469-
476+
// Tool now just checks if user has the required scopes
477+
// If they don't, it shouldn't get here due to middleware
478+
Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes");
470479
return "Admin tool executed.";
471480
}),
472481
]);
473482

474483
string? requestedScope = null;
475484

476-
await using var app = await StartMcpServerAsync();
485+
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
486+
{
487+
// Add middleware to intercept requests and check for admin-tool calls
488+
app.Use(async (context, next) =>
489+
{
490+
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
491+
{
492+
// Enable buffering so we can read the request body multiple times
493+
context.Request.EnableBuffering();
494+
495+
// Read the request body to check if it's calling admin-tool
496+
var message = await JsonSerializer.DeserializeAsync(
497+
context.Request.Body,
498+
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
499+
context.RequestAborted) as JsonRpcMessage;
500+
501+
// Reset the request body position so MapMcp can read it
502+
context.Request.Body.Position = 0;
503+
504+
// Check if this is a tools/call request for admin-tool
505+
if (message is JsonRpcRequest request && request.Method == "tools/call")
506+
{
507+
var toolCallParams = JsonSerializer.Deserialize(
508+
request.Params,
509+
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
510+
511+
if (toolCallParams?.Name == "admin-tool")
512+
{
513+
// Check if user has required scopes
514+
var user = context.User;
515+
if (!user.HasClaim("scope", adminScopes))
516+
{
517+
// User lacks required scopes, return 403 before MapMcp processes the request
518+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
519+
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
520+
await context.Response.StartAsync(context.RequestAborted);
521+
await context.Response.Body.FlushAsync(context.RequestAborted);
522+
return; // Short-circuit, don't call next()
523+
}
524+
}
525+
}
526+
}
527+
528+
await next(context);
529+
});
530+
});
477531

478532
await using var transport = new HttpClientTransport(new()
479533
{

tests/ModelContextProtocol.AspNetCore.Tests/OAuth/OAuthTestBase.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ public async ValueTask DisposeAsync()
8181
}
8282
}
8383

84-
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
84+
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null, Action<WebApplication>? configureMiddleware = null)
8585
{
8686
// Wait for the OAuth server to be ready before starting the MCP server.
8787
// This prevents race conditions in CI where the OAuth server may not be
@@ -94,6 +94,10 @@ protected async Task<WebApplication> StartMcpServerAsync(string path = "", strin
9494
});
9595

9696
var app = Builder.Build();
97+
98+
// Allow tests to add custom middleware before MapMcp
99+
configureMiddleware?.Invoke(app);
100+
97101
app.MapMcp(path).RequireAuthorization(new AuthorizeAttribute
98102
{
99103
AuthenticationSchemes = authScheme

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,13 @@ public async Task DeleteRequest_CompletesSession_WhichCancelsLongRunningToolCall
350350
await CallInitializeAndValidateAsync();
351351

352352
Task<HttpResponseMessage> CallLongRunningToolAsync() =>
353-
HttpClient.PostAsync("", JsonContent(CallTool("long-running")), TestContext.Current.CancellationToken);
353+
HttpClient.SendAsync(
354+
new HttpRequestMessage(HttpMethod.Post, "")
355+
{
356+
Content = JsonContent(CallTool("long-running"))
357+
},
358+
HttpCompletionOption.ResponseHeadersRead,
359+
TestContext.Current.CancellationToken);
354360

355361
var longRunningToolTasks = new Task<HttpResponseMessage>[10];
356362
for (int i = 0; i < longRunningToolTasks.Length; i++)
@@ -360,25 +366,28 @@ Task<HttpResponseMessage> CallLongRunningToolAsync() =>
360366

361367
var getResponse = await HttpClient.GetAsync("", HttpCompletionOption.ResponseHeadersRead, TestContext.Current.CancellationToken);
362368

363-
for (int i = 0; i < longRunningToolTasks.Length; i++)
369+
// Wait for all long-running tool calls to receive 200 response headers before sending DELETE
370+
var responseHeaders = await Task.WhenAll(longRunningToolTasks);
371+
foreach (var response in responseHeaders)
364372
{
365-
Assert.False(longRunningToolTasks[i].IsCompleted);
373+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
366374
}
367375

376+
// Now send DELETE to cancel the session
368377
await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken);
369378

370379
// Get request should complete gracefully.
371380
var sseResponseBody = await getResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
372381
Assert.Empty(sseResponseBody);
373382

374-
// Currently, the OCE thrown by the canceled session is unhandled and turned into a 500 error by Kestrel.
383+
// Currently, responses are flushed immediately to prevent HttpClient timeouts for long-running requests.
384+
// This means the response starts with a 200 status code. When the session is canceled, Kestrel closes
385+
// the connection without writing the chunk terminator, causing an HttpRequestException when reading the response body.
375386
// The spec suggests sending CancelledNotifications. That would be good, but we can do that later.
376-
// For now, the important thing is that request completes without indicating success.
377-
await Task.WhenAll(longRunningToolTasks);
378-
foreach (var task in longRunningToolTasks)
387+
// For now, the important thing is that reading the response body fails.
388+
foreach (var response in responseHeaders)
379389
{
380-
var response = await task;
381-
Assert.False(response.IsSuccessStatusCode);
390+
await Assert.ThrowsAsync<HttpRequestException>(async () => await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken));
382391
}
383392
}
384393

0 commit comments

Comments
 (0)