|
1 | 1 | using Microsoft.AspNetCore.Authentication; |
2 | 2 | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| 3 | +using Microsoft.AspNetCore.Authorization; |
3 | 4 | using Microsoft.AspNetCore.Builder; |
4 | 5 | using Microsoft.AspNetCore.Http; |
5 | 6 | using Microsoft.AspNetCore.WebUtilities; |
|
8 | 9 | using ModelContextProtocol.AspNetCore.Authentication; |
9 | 10 | using ModelContextProtocol.Authentication; |
10 | 11 | using ModelContextProtocol.Client; |
| 12 | +using ModelContextProtocol.Protocol; |
11 | 13 | using ModelContextProtocol.Server; |
12 | 14 | using System.Net; |
13 | 15 | using System.Net.Http.Json; |
14 | 16 | using System.Security.Claims; |
| 17 | +using System.Text.Json; |
15 | 18 | using Xunit.Sdk; |
16 | 19 |
|
17 | 20 | namespace ModelContextProtocol.AspNetCore.Tests.OAuth; |
@@ -211,32 +214,46 @@ public async Task CanAuthenticate_WithTokenRefresh() |
211 | 214 | { |
212 | 215 | var hasForcedRefresh = false; |
213 | 216 |
|
214 | | - Builder.Services.AddHttpContextAccessor(); |
215 | 217 | 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) => |
216 | 226 | { |
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) |
222 | 228 | { |
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") |
224 | 243 | { |
225 | 244 | hasForcedRefresh = true; |
226 | 245 |
|
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() |
235 | 251 | } |
236 | | - }; |
237 | | - }); |
| 252 | + } |
238 | 253 |
|
239 | | - await using var app = await StartMcpServerAsync(); |
| 254 | + await next(context); |
| 255 | + }); |
| 256 | + }); |
240 | 257 |
|
241 | 258 | await using var transport = new HttpClientTransport(new() |
242 | 259 | { |
@@ -451,29 +468,66 @@ public async Task AuthorizationFlow_UsesScopeFromForbiddenHeader() |
451 | 468 | { |
452 | 469 | var adminScopes = "admin:read admin:write"; |
453 | 470 |
|
454 | | - Builder.Services.AddHttpContextAccessor(); |
455 | 471 | Builder.Services.AddMcpServer() |
456 | 472 | .WithTools([ |
457 | 473 | McpServerTool.Create([McpServerTool(Name = "admin-tool")] |
458 | | - async (IServiceProvider serviceProvider, ClaimsPrincipal user) => |
| 474 | + (ClaimsPrincipal user) => |
459 | 475 | { |
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"); |
470 | 479 | return "Admin tool executed."; |
471 | 480 | }), |
472 | 481 | ]); |
473 | 482 |
|
474 | 483 | string? requestedScope = null; |
475 | 484 |
|
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 | + }); |
477 | 531 |
|
478 | 532 | await using var transport = new HttpClientTransport(new() |
479 | 533 | { |
|
0 commit comments