Route SendRequestAsync logic through outgoing message filters (#1465)

This commit is contained in:
Stephen Halter 2026-03-27 09:09:45 -07:00 • committed by GitHub
commit afc40d1e2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -9,6 +9,7 @@ using System.ComponentModel;
using System.Diagnostics;
using System.Net;
using System.Security.Claims;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.AspNetCore.Tests;
@ -290,6 +291,200 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
}
[Fact]
public async Task IncomingFilter_SeesClientRequests()
{
var observedMethods = new List<string>();
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<EchoHttpContextUserTools>();
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<string, object?> { ["message"] = "hi" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains(RequestMethods.Initialize, 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<string>();
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<ClaimsPrincipalTools>()
.WithTools<SamplingRegressionTools>();
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(clientOptions: clientOptions);
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
await client.CallToolAsync("echo_claims_principal",
new Dictionary<string, object?> { ["message"] = "hi" },
cancellationToken: TestContext.Current.CancellationToken);
await client.CallToolAsync("sampling-tool",
new Dictionary<string, object?> { ["prompt"] = "Hello" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains("initialize-response", observedMessageTypes);
Assert.Contains("tools-list-response", observedMessageTypes);
Assert.Contains("tool-call-response", observedMessageTypes);
Assert.Contains($"request:{RequestMethods.SamplingCreateMessage}", observedMessageTypes);
}
[Fact]
public async Task OutgoingFilter_MultipleFilters_ExecuteInOrder()
{
var executionOrder = new List<string>();
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");
}
});
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<ClaimsPrincipalTools>();
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);
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<ClaimsPrincipalTools>();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var client = await ConnectAsync();
var extraReceived = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var registration = client.RegisterNotificationHandler("test/extra", (notification, _) =>
{
extraReceived.TrySetResult(notification.Params?["message"]?.GetValue<string>());
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)],