using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.ComponentModel; using System.Diagnostics; using System.Net; using System.Security.Claims; using System.Text.Json.Nodes; namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) { protected abstract bool UseStreamableHttp { get; } protected abstract bool Stateless { get; } protected virtual void ConfigureStateless(HttpServerTransportOptions options) { options.Stateless = Stateless; } protected async Task ConnectAsync( string? path = null, HttpClientTransportOptions? transportOptions = null, Action? configureClient = null) { path ??= UseStreamableHttp ? "/" : "/sse"; await using var transport = new HttpClientTransport(transportOptions ?? new HttpClientTransportOptions { Endpoint = new Uri($"http://localhost:5000{path}"), TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse, }, HttpClient, LoggerFactory); var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } [Fact] public async Task MapMcp_ThrowsInvalidOperationException_IfWithHttpTransportIsNotCalled() { Builder.Services.AddMcpServer(); await using var app = Builder.Build(); var exception = Assert.Throws(() => app.MapMcp()); Assert.StartsWith("You must call WithHttpTransport()", exception.Message); } [Fact] public async Task Can_UseIHttpContextAccessor_InTool() { Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); Builder.Services.AddHttpContextAccessor(); await using var app = Builder.Build(); app.Use(next => { return async context => { context.User = CreateUser("TestUser"); await next(context); }; }); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var mcpClient = await ConnectAsync(); var response = await mcpClient.CallToolAsync( "echo_with_user_name", new Dictionary() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken); var content = Assert.Single(response.Content.OfType()); Assert.Equal("TestUser: Hello world!", content.Text); } [Fact] public async Task Messages_FromNewUser_AreRejected() { Assert.SkipWhen(Stateless, "User validation across requests is not applicable in stateless mode."); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); // Add an authentication scheme that will send a 403 Forbidden response. Builder.Services.AddAuthentication().AddBearerToken(); Builder.Services.AddHttpContextAccessor(); await using var app = Builder.Build(); app.Use(next => { var i = 0; return async context => { context.User = CreateUser($"TestUser{Interlocked.Increment(ref i)}"); await next(context); }; }); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); // Session-scoped user validation across requests is a legacy stateful-session behavior. Starting with the // 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions. Pin to the latest stable version to keep covering it. var httpRequestException = await Assert.ThrowsAsync( () => ConnectAsync(configureClient: options => options.ProtocolVersion = "2025-11-25")); Assert.Equal(HttpStatusCode.Forbidden, httpRequestException.StatusCode); } [Fact] public async Task ClaimsPrincipal_CanBeInjected_IntoToolMethod() { Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); await using var app = Builder.Build(); app.Use(next => async context => { context.User = CreateUser("TestUser"); await next(context); }); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(); var response = await client.CallToolAsync( "echo_claims_principal", new Dictionary() { ["message"] = "Hello world!" }, cancellationToken: TestContext.Current.CancellationToken); var content = Assert.Single(response.Content.OfType()); Assert.Equal("TestUser: Hello world!", content.Text); } [Fact] public async Task Sampling_DoesNotCloseStreamPrematurely() { Assert.SkipWhen(Stateless, "Sampling is not supported in stateless mode."); Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); var sampleCount = 0; await using var mcpClient = await ConnectAsync(configureClient: options => { // Server->client sampling over the open response stream is a stateful-session behavior. // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions, so the implicit-MRTR suspend path // doesn't apply over HTTP (this sampling path is covered by the stdio MRTR tests). Pin legacy. options.ProtocolVersion = "2025-11-25"; options.Handlers.SamplingHandler = async (parameters, _, _) => { Assert.NotNull(parameters?.Messages); var message = Assert.Single(parameters.Messages); Assert.Equal(Role.User, message.Role); Assert.Equal("Test prompt for sampling", Assert.IsType(Assert.Single(message.Content)).Text); sampleCount++; return new CreateMessageResult { Model = "test-model", Role = Role.Assistant, Content = [new TextContentBlock { Text = "Sampling response from client" }], }; }; }); var result = await mcpClient.CallToolAsync("sampling-tool", new Dictionary { ["prompt"] = "Test prompt for sampling" }, cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Null(result.IsError); var textContent = Assert.Single(result.Content); Assert.Equal("text", textContent.Type); Assert.Equal("Sampling completed successfully. Client responded: Sampling response from client", Assert.IsType(textContent).Text); Assert.Equal(2, sampleCount); // Verify that the tool call and the sampling request both used the same ID to ensure we cover against regressions. // https://github.com/modelcontextprotocol/csharp-sdk/issues/464 Assert.Single(MockLoggerProvider.LogMessages, m => m.Category == "ModelContextProtocol.Client.McpClient" && m.Message.Contains("request '2' for method 'tools/call'")); Assert.Single(MockLoggerProvider.LogMessages, m => m.Category == "ModelContextProtocol.Server.McpServer" && m.Message.Contains("request '2' for method 'sampling/createMessage'")); } [Fact] public async Task Server_ShutsDownQuickly_WhenClientIsConnected() { Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); // Connect a client which will open a long-running GET request (SSE or Streamable HTTP) await using var mcpClient = await ConnectAsync(); // Verify the client is connected var tools = await mcpClient.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.NotEmpty(tools); // Now measure how long it takes to stop the server var stopwatch = Stopwatch.StartNew(); await app.StopAsync(TestContext.Current.CancellationToken); stopwatch.Stop(); // The server should shut down quickly (within a few seconds). We use 5 seconds as a generous threshold. // This is much less than the default HostOptions.ShutdownTimeout of 30 seconds. Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Server took {stopwatch.Elapsed.TotalSeconds:F2} seconds to shut down with a connected client. " + "This suggests the GET request is not respecting ApplicationStopping token."); } [Fact] public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() { // Legacy Streamable HTTP tool calls that run past the HttpClient timeout without producing // intermediate notifications need an eager response flush. Per-request-metadata protocol // revisions instead wait for the first JSON-RPC message so SEP-2575 can map its status. Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); // Retry a couple of times to reduce occasional flakiness on low-resource machines. // If the server regresses to flushing only after tool completion, each attempt should still fail // because HttpClient timeout (1 second) is below the tool duration (2 seconds). for (var attempt = 0; attempt < 3; attempt++) { try { // Create a custom HttpClient with a very short timeout (1 second) // The tool will take 2 seconds to complete using var shortTimeoutClient = new HttpClient(SocketsHttpHandler, disposeHandler: false) { BaseAddress = new Uri("http://localhost:5000/"), Timeout = TimeSpan.FromSeconds(1) }; var path = UseStreamableHttp ? "/" : "/sse"; var transportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse; await using var transport = new HttpClientTransport(new() { Endpoint = new($"http://localhost:5000{path}"), TransportMode = transportMode, }, shortTimeoutClient, LoggerFactory); await using var mcpClient = await McpClient.CreateAsync( transport, new McpClientOptions { ProtocolVersion = "2025-11-25" }, LoggerFactory, TestContext.Current.CancellationToken); // Call a tool that takes 2 seconds - this should succeed despite the 1 second HttpClient timeout // because the response stream is flushed immediately after receiving the request var response = await mcpClient.CallToolAsync( "long_running_operation", new Dictionary() { ["durationMs"] = 2000 }, cancellationToken: TestContext.Current.CancellationToken); var content = Assert.Single(response.Content.OfType()); Assert.Equal("Operation completed after 2000ms", content.Text); return; } catch (OperationCanceledException) when (attempt < 2) { // Retry intermittent timeout-related failures on slow CI machines. } } } [Fact] public async Task IncomingFilter_SeesClientRequests() { var observedMethods = new List(); Builder.Services.AddMcpServer() .WithHttpTransport(ConfigureStateless) .WithMessageFilters(filters => filters.AddIncomingFilter((next) => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest request) { observedMethods.Add(request.Method); } await next(context, cancellationToken); })) .WithTools(); Builder.Services.AddHttpContextAccessor(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); await client.CallToolAsync("echo_with_user_name", new Dictionary { ["message"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); // The client now defaults to the 2026-07-28 protocol revision, whose handshake is server/discover // rather than the legacy initialize request. On the stateful Streamable HTTP fixture the // request is refused, so the client downgrades to the legacy initialize. var expectedHandshakeMethod = UseStreamableHttp && !Stateless ? RequestMethods.Initialize : RequestMethods.ServerDiscover; Assert.Contains(expectedHandshakeMethod, observedMethods); Assert.Contains(RequestMethods.ToolsList, observedMethods); Assert.Contains(RequestMethods.ToolsCall, observedMethods); } [Fact] public async Task OutgoingFilter_SeesResponsesAndRequests() { Assert.SkipWhen(Stateless, "Server-originated requests are not supported in stateless mode."); var observedMessageTypes = new List(); Builder.Services.AddMcpServer() .WithHttpTransport(ConfigureStateless) .WithMessageFilters(filters => filters.AddOutgoingFilter((next) => async (context, cancellationToken) => { var typeName = context.JsonRpcMessage switch { JsonRpcRequest request => $"request:{request.Method}", JsonRpcResponse r when r.Result is JsonObject obj && obj.ContainsKey("protocolVersion") => "initialize-response", JsonRpcResponse r when r.Result is JsonObject obj && obj.ContainsKey("tools") => "tools-list-response", JsonRpcResponse r when r.Result is JsonObject obj && obj.ContainsKey("content") => "tool-call-response", _ => null, }; if (typeName is not null) { observedMessageTypes.Add(typeName); } await next(context, cancellationToken); })) .WithTools() .WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); var clientOptions = new McpClientOptions { Capabilities = new() { Sampling = new() }, Handlers = new() { SamplingHandler = (_, _, _) => new(new CreateMessageResult { Content = [new TextContentBlock { Text = "sampled response" }], Model = "test-model", }), }, }; await using var client = await ConnectAsync(configureClient: opts => { // Server-originated sampling requests and the initialize response are legacy stateful // behaviors; the 2026-07-28 protocol revision routes sampling through MRTR and drops initialize. opts.ProtocolVersion = "2025-11-25"; opts.Capabilities = clientOptions.Capabilities; opts.Handlers = clientOptions.Handlers; }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); await client.CallToolAsync("echo_claims_principal", new Dictionary { ["message"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); await client.CallToolAsync("sampling-tool", new Dictionary { ["prompt"] = "Hello" }, cancellationToken: TestContext.Current.CancellationToken); // Exact counts catch regressions where the outgoing filter pipeline gets applied more than once // per outbound message (e.g., SendRequestAsync double-wrapping SendToRelatedTransportAsync). Assert.Equal(1, observedMessageTypes.Count(m => m == "initialize-response")); Assert.Equal(1, observedMessageTypes.Count(m => m == "tools-list-response")); Assert.Equal(2, observedMessageTypes.Count(m => m == "tool-call-response")); // one per CallToolAsync Assert.Equal(2, observedMessageTypes.Count(m => m == $"request:{RequestMethods.SamplingCreateMessage}")); // sampling-tool makes two SampleAsync calls } [Fact] public async Task OutgoingFilter_MultipleFilters_ExecuteInOrder() { var executionOrder = new List(); var allFiltersComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Builder.Services.AddMcpServer() .WithHttpTransport(ConfigureStateless) .WithMessageFilters(filters => { filters.AddOutgoingFilter((next) => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcResponse r && r.Result is JsonObject obj && obj.ContainsKey("tools")) { executionOrder.Add("filter1-before"); } await next(context, cancellationToken); if (context.JsonRpcMessage is JsonRpcResponse r2 && r2.Result is JsonObject obj2 && obj2.ContainsKey("tools")) { executionOrder.Add("filter1-after"); allFiltersComplete.TrySetResult(); } }); filters.AddOutgoingFilter((next) => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcResponse r && r.Result is JsonObject obj && obj.ContainsKey("tools")) { executionOrder.Add("filter2-before"); } await next(context, cancellationToken); if (context.JsonRpcMessage is JsonRpcResponse r2 && r2.Result is JsonObject obj2 && obj2.ContainsKey("tools")) { executionOrder.Add("filter2-after"); } }); }) .WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); // The outermost filter's "after" callback runs after the response has been // sent to the client, so ListToolsAsync may return before it executes. // Wait for it to complete before asserting, but use a timeout to avoid hanging // the test indefinitely if the filter pipeline regresses. using var allFiltersCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); allFiltersCts.CancelAfter(TestConstants.DefaultTimeout); await allFiltersComplete.Task.WaitAsync(allFiltersCts.Token); Assert.Equal(["filter1-before", "filter2-before", "filter2-after", "filter1-after"], executionOrder); } [Fact] public async Task OutgoingFilter_CanSendAdditionalMessages() { Builder.Services.AddMcpServer() .WithHttpTransport(ConfigureStateless) .WithMessageFilters(filters => filters.AddOutgoingFilter((next) => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject result && result.ContainsKey("tools")) { var extraNotification = new JsonRpcNotification { Method = "test/extra", Params = new JsonObject { ["message"] = "injected" }, Context = new JsonRpcMessageContext { RelatedTransport = context.JsonRpcMessage.Context?.RelatedTransport }, }; await next(new MessageContext(context.Server, extraNotification), cancellationToken); } await next(context, cancellationToken); })) .WithTools(); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectAsync(); var extraReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var registration = client.RegisterNotificationHandler("test/extra", (notification, _) => { extraReceived.TrySetResult(notification.Params?["message"]?.GetValue()); return default; }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var extraMessage = await extraReceived.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); Assert.Equal("injected", extraMessage); } private ClaimsPrincipal CreateUser(string name) => new(new ClaimsIdentity( [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], "TestAuthType", "name", "role")); [McpServerToolType] protected class EchoHttpContextUserTools(IHttpContextAccessor contextAccessor) { [McpServerTool, Description("Echoes the input back to the client with their user name.")] public string EchoWithUserName(string message) { var httpContext = contextAccessor.HttpContext ?? throw new Exception("HttpContext unavailable!"); var userName = httpContext.User.Identity?.Name ?? "anonymous"; return $"{userName}: {message}"; } } [McpServerToolType] protected class ClaimsPrincipalTools { [McpServerTool, Description("Echoes the input back to the client with the user name from ClaimsPrincipal.")] public string EchoClaimsPrincipal(ClaimsPrincipal? user, string message) { var userName = user?.Identity?.Name ?? "anonymous"; return $"{userName}: {message}"; } } [McpServerToolType] private class SamplingRegressionTools { [McpServerTool(Name = "sampling-tool")] public static async Task SamplingToolAsync(McpServer server, string prompt, CancellationToken cancellationToken) { // This tool reproduces the scenario described in https://github.com/modelcontextprotocol/csharp-sdk/issues/464 // 1. The client calls tool with request ID 2, because it's the first request after the initialize request. // 2. This tool makes two sampling requests which use IDs 1 and 2. // 3. In the old buggy Streamable HTTP transport code, this would close the SSE response stream, // because the second sampling request used an ID matching the tool call. var samplingRequest = new CreateMessageRequestParams { Messages = [ new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }], } ], MaxTokens = 1000 }; await server.SampleAsync(samplingRequest, cancellationToken); var samplingResult = await server.SampleAsync(samplingRequest, cancellationToken); return $"Sampling completed successfully. Client responded: {Assert.IsType(Assert.Single(samplingResult.Content)).Text}"; } } [McpServerToolType] protected class LongRunningTools { [McpServerTool, Description("Simulates a long-running operation")] public static async Task LongRunningOperation( [Description("Duration of the operation in milliseconds")] int durationMs, CancellationToken cancellationToken) { await Task.Delay(durationMs, cancellationToken); return $"Operation completed after {durationMs}ms"; } } }