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>
This commit is contained in:
Copilot 2026-02-14 10:19:53 -05:00 • committed by GitHub
commit fb2daf2f0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 172 additions and 41 deletions

View file

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
@ -8,10 +9,12 @@ using ModelContextProtocol;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using Xunit.Sdk;
namespace ModelContextProtocol.AspNetCore.Tests.OAuth;
@ -211,32 +214,46 @@ public class AuthTests : OAuthTestBase
{
var hasForcedRefresh = false;
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer(options =>
{
options.ToolCollection = new();
});
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
{
// Add middleware to intercept list tools requests and force a token refresh on the first call
app.Use(async (context, next) =>
{
options.ToolCollection = new();
})
.AddListToolsFilter(next =>
{
return async (mcpContext, cancellationToken) =>
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh)
{
if (!hasForcedRefresh)
// Enable buffering so we can read the request body multiple times
context.Request.EnableBuffering();
// Read the request body to check if it's calling tools/list
var message = await JsonSerializer.DeserializeAsync(
context.Request.Body,
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
context.RequestAborted) as JsonRpcMessage;
// Reset the request body position so MapMcp can read it
context.Request.Body.Position = 0;
// Check if this is a tools/list request
if (message is JsonRpcRequest request && request.Method == "tools/list")
{
hasForcedRefresh = true;
var httpContext = mcpContext.Services!.GetRequiredService<IHttpContextAccessor>().HttpContext!;
await httpContext.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
// Return 401 to force token refresh
await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme);
await context.Response.StartAsync(context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
return; // Short-circuit, don't call next()
}
else
{
return await next(mcpContext, cancellationToken);
}
};
});
}
await using var app = await StartMcpServerAsync();
await next(context);
});
});
await using var transport = new HttpClientTransport(new()
{
@ -451,29 +468,66 @@ public class AuthTests : OAuthTestBase
{
var adminScopes = "admin:read admin:write";
Builder.Services.AddHttpContextAccessor();
Builder.Services.AddMcpServer()
.WithTools([
McpServerTool.Create([McpServerTool(Name = "admin-tool")]
async (IServiceProvider serviceProvider, ClaimsPrincipal user) =>
(ClaimsPrincipal user) =>
{
if (!user.HasClaim("scope", adminScopes))
{
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext!;
httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
httpContext.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
await httpContext.Response.CompleteAsync();
throw new Exception("This exception will not impact the client because the response has already been completed.");
}
// Tool now just checks if user has the required scopes
// If they don't, it shouldn't get here due to middleware
Assert.True(user.HasClaim("scope", adminScopes), "User should have admin scopes when tool executes");
return "Admin tool executed.";
}),
]);
string? requestedScope = null;
await using var app = await StartMcpServerAsync();
await using var app = await StartMcpServerAsync(configureMiddleware: app =>
{
// Add middleware to intercept requests and check for admin-tool calls
app.Use(async (context, next) =>
{
if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/")
{
// Enable buffering so we can read the request body multiple times
context.Request.EnableBuffering();
// Read the request body to check if it's calling admin-tool
var message = await JsonSerializer.DeserializeAsync(
context.Request.Body,
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)),
context.RequestAborted) as JsonRpcMessage;
// Reset the request body position so MapMcp can read it
context.Request.Body.Position = 0;
// Check if this is a tools/call request for admin-tool
if (message is JsonRpcRequest request && request.Method == "tools/call")
{
var toolCallParams = JsonSerializer.Deserialize(
request.Params,
McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(CallToolRequestParams))) as CallToolRequestParams;
if (toolCallParams?.Name == "admin-tool")
{
// Check if user has required scopes
var user = context.User;
if (!user.HasClaim("scope", adminScopes))
{
// User lacks required scopes, return 403 before MapMcp processes the request
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.Headers.WWWAuthenticate = $"Bearer error=\"insufficient_scope\", resource_metadata=\"{McpServerUrl}/.well-known/oauth-protected-resource\", scope=\"{adminScopes}\"";
await context.Response.StartAsync(context.RequestAborted);
await context.Response.Body.FlushAsync(context.RequestAborted);
return; // Short-circuit, don't call next()
}
}
}
}
await next(context);
});
});
await using var transport = new HttpClientTransport(new()
{

View file

@ -81,7 +81,7 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
}
}
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null)
protected async Task<WebApplication> StartMcpServerAsync(string path = "", string? authScheme = null, Action<WebApplication>? configureMiddleware = null)
{
// Wait for the OAuth server to be ready before starting the MCP server.
// This prevents race conditions in CI where the OAuth server may not be
@ -94,6 +94,10 @@ public abstract class OAuthTestBase : KestrelInMemoryTest, IAsyncDisposable
});
var app = Builder.Build();
// Allow tests to add custom middleware before MapMcp
configureMiddleware?.Invoke(app);
app.MapMcp(path).RequireAuthorization(new AuthorizeAttribute
{
AuthenticationSchemes = authScheme