Route SendRequestAsync logic through outgoing message filters (#1465)
This commit is contained in:
parent
e700951bd8
commit
afc40d1e2e
2 changed files with 393 additions and 40 deletions
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOutgoingMessageFilter_Sees_Initialize_Progress_And_Response()
|
||||
public async Task AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requests()
|
||||
{
|
||||
var observedMessages = new List<string>();
|
||||
|
||||
|
|
@ -326,6 +326,9 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
{
|
||||
switch (context.JsonRpcMessage)
|
||||
{
|
||||
case JsonRpcRequest request:
|
||||
observedMessages.Add($"request:{request.Method}");
|
||||
break;
|
||||
case JsonRpcResponse response when response.Result is JsonObject result:
|
||||
if (result.ContainsKey("protocolVersion"))
|
||||
{
|
||||
|
|
@ -343,22 +346,41 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
|
||||
await next(context, cancellationToken);
|
||||
}))
|
||||
.WithTools<ProgressTool>();
|
||||
.WithTools<ProgressTool>()
|
||||
.WithTools<SamplingTool>();
|
||||
|
||||
StartServer();
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
var clientOptions = new McpClientOptions
|
||||
{
|
||||
Capabilities = new() { Sampling = new() },
|
||||
Handlers = new()
|
||||
{
|
||||
SamplingHandler = (_, _, _) => new(new CreateMessageResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = "sampled" }],
|
||||
Model = "test-model",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
IProgress<ProgressNotificationValue> progress = new Progress<ProgressNotificationValue>(_ => { });
|
||||
await client.CallToolAsync("progress-tool", progress: progress, cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
await client.CallToolAsync("sampling-tool", new Dictionary<string, object?> { ["prompt"] = "Hello" },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
int initializeIndex = observedMessages.IndexOf("initialize");
|
||||
int progressIndex = observedMessages.IndexOf("progress");
|
||||
int responseIndex = observedMessages.LastIndexOf("response");
|
||||
int requestIndex = observedMessages.IndexOf($"request:{RequestMethods.SamplingCreateMessage}");
|
||||
|
||||
Assert.True(initializeIndex >= 0);
|
||||
Assert.True(progressIndex > initializeIndex);
|
||||
Assert.True(responseIndex > progressIndex);
|
||||
Assert.True(requestIndex >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -428,6 +450,127 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
Assert.Equal("extra", extraMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOutgoingMessageFilter_SkipNext_DoesNotLogSending()
|
||||
{
|
||||
ServiceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
McpServerBuilder
|
||||
.WithMessageFilters(filters => filters.AddOutgoingFilter((next) => (context, cancellationToken) =>
|
||||
{
|
||||
// Skip sending tool list responses
|
||||
if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject result && result.ContainsKey("tools"))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return next(context, cancellationToken);
|
||||
}))
|
||||
.WithTools<TestTool>();
|
||||
|
||||
StartServer();
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Clear any logs from initialization
|
||||
while (MockLoggerProvider.LogMessages.TryDequeue(out _)) { }
|
||||
|
||||
using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await client.ListToolsAsync(cancellationToken: requestCts.Token);
|
||||
});
|
||||
|
||||
// Since the filter skipped next, no "sending message" log should appear for the skipped response
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
|
||||
m.Category.Contains("McpServer") && m.Message.Contains("sending message", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddOutgoingMessageFilter_CallsNext_LogsSending()
|
||||
{
|
||||
ServiceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
McpServerBuilder
|
||||
.WithTools<TestTool>();
|
||||
|
||||
StartServer();
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Clear any logs from initialization
|
||||
while (MockLoggerProvider.LogMessages.TryDequeue(out _)) { }
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// The response should have been sent, producing a "sending message" log from the server
|
||||
Assert.Contains(MockLoggerProvider.LogMessages, m =>
|
||||
m.Category.Contains("McpServer") && m.Message.Contains("sending message", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddIncomingMessageFilter_SkipNext_DoesNotLogSendingResponse()
|
||||
{
|
||||
ServiceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
McpServerBuilder
|
||||
.WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) =>
|
||||
{
|
||||
// Skip processing tools/list requests — handler never runs, no response sent
|
||||
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return next(context, cancellationToken);
|
||||
}))
|
||||
.WithTools<TestTool>();
|
||||
|
||||
StartServer();
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Clear any logs from initialization
|
||||
while (MockLoggerProvider.LogMessages.TryDequeue(out _)) { }
|
||||
|
||||
using var requestCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await client.ListToolsAsync(cancellationToken: requestCts.Token);
|
||||
});
|
||||
|
||||
// Since the incoming filter skipped next, no handler ran, so no response was sent
|
||||
Assert.DoesNotContain(MockLoggerProvider.LogMessages, m =>
|
||||
m.Category.Contains("McpServer") && m.Message.Contains("sending message", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddIncomingMessageFilter_CallsNext_LogsSendingResponse()
|
||||
{
|
||||
ServiceCollection.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug));
|
||||
|
||||
McpServerBuilder
|
||||
.WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) =>
|
||||
{
|
||||
// Pass through — handler runs, response is sent
|
||||
return next(context, cancellationToken);
|
||||
}))
|
||||
.WithTools<TestTool>();
|
||||
|
||||
StartServer();
|
||||
|
||||
await using McpClient client = await CreateMcpClientForServer();
|
||||
|
||||
// Clear any logs from initialization
|
||||
while (MockLoggerProvider.LogMessages.TryDequeue(out _)) { }
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// The handler ran and sent a response, producing a "sending message" log from the server
|
||||
Assert.Contains(MockLoggerProvider.LogMessages, m =>
|
||||
m.Category.Contains("McpServer") && m.Message.Contains("sending message", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddIncomingMessageFilter_Items_Flow_To_Request_Filters()
|
||||
{
|
||||
|
|
@ -644,7 +787,6 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
Assert.Equal("modifiedByFilter1", observedValues[1]);
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class TestTool
|
||||
{
|
||||
[McpServerTool]
|
||||
|
|
@ -654,7 +796,6 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
}
|
||||
}
|
||||
|
||||
[McpServerPromptType]
|
||||
public sealed class TestPrompt
|
||||
{
|
||||
[McpServerPrompt]
|
||||
|
|
@ -668,7 +809,6 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
}
|
||||
}
|
||||
|
||||
[McpServerResourceType]
|
||||
public sealed class TestResource
|
||||
{
|
||||
[McpServerResource(UriTemplate = "test://resource/{id}")]
|
||||
|
|
@ -678,7 +818,6 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
}
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ProgressTool
|
||||
{
|
||||
[McpServerTool(Name = "progress-tool")]
|
||||
|
|
@ -708,7 +847,6 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
}
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class SimpleTool
|
||||
{
|
||||
[McpServerTool(Name = "simple-tool")]
|
||||
|
|
@ -717,4 +855,19 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
|||
return "success";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SamplingTool
|
||||
{
|
||||
[McpServerTool(Name = "sampling-tool")]
|
||||
public static async Task<string> SampleAsync(McpServer server, string prompt, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await server.SampleAsync(new CreateMessageRequestParams
|
||||
{
|
||||
Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }],
|
||||
MaxTokens = 100,
|
||||
}, cancellationToken);
|
||||
|
||||
return $"Sampled: {Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue