From 1f5a164ec0b58d0be9d72393f170c9111045618a Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Thu, 17 Sep 2026 22:35:20 -0500 Subject: [PATCH] Add client-side call-tool filters for tool-call policy enforcement Adds McpClientOptions.Filters.Request.CallToolFilters, mirroring the server-side filter pipeline, so hosts can inspect, rewrite, or block tools/call requests (for example, based on tool annotations) before they reach the server. Every CallToolAsync overload, McpClientTool.CallAsync, and McpClientTool invocations through an IChatClient route through a single private protected CallToolCoreAsync seam, so no path bypasses the filters. Filters receive the tool definition from the existing tool cache (populated by ListToolsAsync/AddKnownTools), or null when unknown. The new APIs are marked experimental (MCPEXP002). Fixes #1453 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr --- docs/concepts/filters.md | 35 +++ docs/list-of-diagnostics.md | 2 +- .../Client/McpClient.Methods.cs | 11 + .../Client/McpClientFilters.cs | 27 +++ .../Client/McpClientImpl.cs | 33 +++ .../Client/McpClientOptions.cs | 17 ++ .../Client/McpClientRequestContext.cs | 53 +++++ .../Client/McpClientRequestFilter.cs | 14 ++ .../Client/McpClientRequestFilters.cs | 42 ++++ .../Client/McpClientRequestHandler.cs | 16 ++ .../Client/McpClientCallToolFilterTests.cs | 219 ++++++++++++++++++ 11 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 src/ModelContextProtocol.Core/Client/McpClientFilters.cs create mode 100644 src/ModelContextProtocol.Core/Client/McpClientRequestContext.cs create mode 100644 src/ModelContextProtocol.Core/Client/McpClientRequestFilter.cs create mode 100644 src/ModelContextProtocol.Core/Client/McpClientRequestFilters.cs create mode 100644 src/ModelContextProtocol.Core/Client/McpClientRequestHandler.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientCallToolFilterTests.cs diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 80d6d4653..594cb1457 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -608,3 +608,38 @@ Within filters, you have access to: - `context.User` - The current user's `ClaimsPrincipal`. - `context.Services` - The request's service provider for resolving authorization services. - `context.MatchedPrimitive` - The matched tool/prompt/resource with its metadata including authorization attributes via `context.MatchedPrimitive.Metadata`. + +## Client tool-call filters + +Hosts can also filter the tool calls their own client sends, for example to enforce a policy based on +tool annotations before a model-selected tool reaches the server. Add filters to +`McpClientOptions.Filters.Request.CallToolFilters`. They wrap every `CallToolAsync` overload as well as +`McpClientTool.CallAsync` and `McpClientTool` invocations made through an `IChatClient`, so no call path +bypasses the policy. These APIs are experimental (`MCPEXP002`). + +```csharp +var options = new McpClientOptions(); +options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) => +{ + // context.Tool is the definition cached by ListToolsAsync or AddKnownTools, or null if unknown. + // Fail closed: treat an unknown tool the same as a destructive one. + if (context.Tool?.Annotations?.DestructiveHint is not false) + { + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"'{context.Params.Name}' requires user confirmation." }], + IsError = true + }; + } + + return await next(context, cancellationToken); +}); + +await using var client = await McpClient.CreateAsync(transport, options); +``` + +Prefer returning a `CallToolResult` with `IsError = true` over throwing when blocking a call: the result's +content is returned to the model, while a thrown exception's message is typically hidden from it. +Filters run in registration order (the first filter is the outermost), can rewrite `context.Params` before +calling `next` (for example, to redact arguments), and can post-process the result. Tool annotations are +hints supplied by the server, so only rely on them for servers you trust. diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 577334246..b42a91254 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -28,7 +28,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | Diagnostic ID | Description | | :------------ | :---------- | | `MCPEXP001` | Experimental APIs tied to MCP specification features. Reuse this ID for newly introduced experimental spec features, and add feature-specific messages/URLs in `Experimentals`. | -| `MCPEXP002` | Experimental SDK extensibility APIs used to implement features in standalone packages without requiring Core to understand those features. For example, the Tasks package uses these APIs to extend the SDK while keeping the Tasks concept out of Core. This includes `McpClient`/`McpServer` subclassing, custom request handlers, alternate handlers and filters, outgoing request interception, and `RunSessionHandler`. These APIs remain experimental until additional extensibility scenarios validate the design. | +| `MCPEXP002` | Experimental SDK extensibility APIs used to implement features in standalone packages without requiring Core to understand those features. For example, the Tasks package uses these APIs to extend the SDK while keeping the Tasks concept out of Core. This includes `McpClient`/`McpServer` subclassing, custom request handlers, alternate handlers and filters, client request filters, outgoing request interception, and `RunSessionHandler`. These APIs remain experimental until additional extensibility scenarios validate the design. | | `MCPEXP003` | Experimental MCP Apps extension APIs. MCP Apps is the first official MCP extension (`io.modelcontextprotocol/ui`), enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). | ## Obsolete APIs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bccdd7a2e..1e9f3b7ae 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -1043,6 +1043,17 @@ public ValueTask CallToolAsync( { Throw.IfNull(requestParams); + return CallToolCoreAsync(requestParams, cancellationToken); + } + + /// + /// Sends a request. Every CallToolAsync overload routes through this + /// method, so derived clients can override it to apply . + /// + private protected virtual ValueTask CallToolCoreAsync( + CallToolRequestParams requestParams, + CancellationToken cancellationToken) + { return SendRequestAsync( RequestMethods.ToolsCall, requestParams, diff --git a/src/ModelContextProtocol.Core/Client/McpClientFilters.cs b/src/ModelContextProtocol.Core/Client/McpClientFilters.cs new file mode 100644 index 000000000..578ce19f3 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpClientFilters.cs @@ -0,0 +1,27 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Client; + +/// +/// Provides filter collections for outgoing MCP client requests. +/// +/// +/// Filters allow middleware-style composition where a filter can perform actions before and after the inner handler, +/// mirroring on the server. +/// +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public sealed class McpClientFilters +{ + /// + /// Gets or sets the filters for request-specific client pipelines. + /// + public McpClientRequestFilters Request + { + get => field ??= new(); + set + { + Throw.IfNull(value); + field = value; + } + } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d1f2a9d7a..cf0cbdbb6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -27,6 +27,7 @@ internal sealed partial class McpClientImpl : McpClient private readonly ConcurrentDictionary _toolCache = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _registeredToolNames = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal); + private readonly McpClientRequestHandler? _callToolHandler; private ServerCapabilities? _serverCapabilities; private Implementation? _serverInfo; @@ -67,6 +68,21 @@ internal McpClientImpl(ITransport transport, string endpointName, McpClientOptio outgoingMessageFilter: null, _logger); +#pragma warning disable MCPEXP002 // Client request filters are experimental + var callToolFilters = options.Filters.Request.CallToolFilters; + if (callToolFilters.Count > 0) + { + McpClientRequestHandler handler = + (request, cancellationToken) => base.CallToolCoreAsync(request.Params, cancellationToken); + for (int i = callToolFilters.Count - 1; i >= 0; i--) + { + handler = callToolFilters[i](handler); + } + + _callToolHandler = handler; + } +#pragma warning restore MCPEXP002 + ToolDiscovered = tool => _toolCache[tool.Name] = tool; ToolRejected = (tool, reason) => LogToolRejected(tool.Name, reason); ToolCacheClearing = () => @@ -663,6 +679,23 @@ public override void ClearKnownTools() _registeredToolNames.Clear(); } +#pragma warning disable MCPEXP002 // Client request filters are experimental + private protected override ValueTask CallToolCoreAsync(CallToolRequestParams requestParams, CancellationToken cancellationToken) + { + if (_callToolHandler is null) + { + return base.CallToolCoreAsync(requestParams, cancellationToken); + } + + return _callToolHandler( + new McpClientRequestContext(this, requestParams) + { + Tool = requestParams.Name is { } name && _toolCache.TryGetValue(name, out var tool) ? tool : null, + }, + cancellationToken); + } +#pragma warning restore MCPEXP002 + /// public override async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 61a0613df..4c0e3883c 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -151,4 +151,21 @@ public McpClientHandlers Handlers } } + /// + /// Gets or sets the filters applied to outgoing requests sent by the client. + /// + /// + /// Use to inspect, modify, or block tool calls before they + /// reach the server, for example to require confirmation for tools annotated as destructive. + /// + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] + public McpClientFilters Filters + { + get => field ??= new(); + set + { + Throw.IfNull(value); + field = value; + } + } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientRequestContext.cs b/src/ModelContextProtocol.Core/Client/McpClientRequestContext.cs new file mode 100644 index 000000000..a3787b1c3 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpClientRequestContext.cs @@ -0,0 +1,53 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Client; + +/// +/// Provides the context for an outgoing client request as it flows through a +/// pipeline. +/// +/// Type of the request parameters specific to each MCP operation. +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public sealed class McpClientRequestContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The client sending the request. + /// The parameters of the request. + /// is . + public McpClientRequestContext(McpClient client, TParams parameters) + { + Throw.IfNull(client); + + Client = client; + Params = parameters; + } + + /// Gets the client sending the request. + public McpClient Client { get; } + + /// Gets or sets the parameters of the request. + /// + /// Filters can replace or mutate the parameters, for example to redact arguments, before invoking the next handler. + /// The parameters observed by the innermost handler are the ones sent to the server. + /// + public TParams Params { get; set; } + + /// + /// Gets or sets the tool definition the client knows for the tool being called by a request. + /// + /// + /// + /// The definition, including its , comes from the client's tool cache, which is populated + /// by and . + /// It is when the tool is not in that cache and for requests other than . + /// + /// + /// A filter that enforces policy based on this definition should treat as unknown and fail closed. + /// Annotations are hints supplied by the server; only rely on them for servers you trust. + /// + /// + public Tool? Tool { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClientRequestFilter.cs b/src/ModelContextProtocol.Core/Client/McpClientRequestFilter.cs new file mode 100644 index 000000000..8f137625c --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpClientRequestFilter.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Client; + +/// +/// Delegate type for applying filters to outgoing MCP requests with specific parameter and result types from a client. +/// +/// The type of the parameters sent with the request. +/// The type of the result returned for the request. +/// The next request handler in the pipeline. +/// The next request handler wrapped with the filter. +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public delegate McpClientRequestHandler McpClientRequestFilter( + McpClientRequestHandler next); diff --git a/src/ModelContextProtocol.Core/Client/McpClientRequestFilters.cs b/src/ModelContextProtocol.Core/Client/McpClientRequestFilters.cs new file mode 100644 index 000000000..8a5ff98c3 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpClientRequestFilters.cs @@ -0,0 +1,42 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Client; + +/// +/// Provides grouped request-specific filter collections for outgoing client requests. +/// +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public sealed class McpClientRequestFilters +{ + /// + /// Gets or sets the filters for the pipeline. + /// + /// + /// + /// These filters wrap every tool call made through , + /// which all other CallToolAsync overloads, , and + /// invocations through an IChatClient route through. A filter can inspect or modify the request, return a + /// without calling the next handler to block the call, or post-process the result. + /// + /// + /// To block a call, prefer returning a with set to + /// over throwing: the result's content reaches the model, while a thrown exception's message is + /// typically hidden from it. + /// + /// + /// Filters run in the order they were added: the first filter is the outermost. The pipeline is built when the client + /// is created, so changes to this list after that point are not observed. Requests sent directly with + /// bypass these filters. + /// + /// + public IList> CallToolFilters + { + get => field ??= []; + set + { + Throw.IfNull(value); + field = value; + } + } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClientRequestHandler.cs b/src/ModelContextProtocol.Core/Client/McpClientRequestHandler.cs new file mode 100644 index 000000000..d8eba81f3 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/McpClientRequestHandler.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Client; + +/// +/// Delegate type for sending outgoing MCP requests with specific parameter and result types from a client. +/// +/// The type of the parameters sent with the request. +/// The type of the result returned for the request. +/// The request context containing the parameters and other metadata. +/// The to monitor for cancellation requests. +/// A task representing the asynchronous operation, with the result of the request. +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public delegate ValueTask McpClientRequestHandler( + McpClientRequestContext request, + CancellationToken cancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCallToolFilterTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCallToolFilterTests.cs new file mode 100644 index 000000000..e6c867cea --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCallToolFilterTests.cs @@ -0,0 +1,219 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; + +#pragma warning disable MCPEXP002 // Client request filters are experimental + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientCallToolFilterTests : ClientServerTestBase +{ + private int _deleteInvocations; + + public McpClientCallToolFilterTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithTools([ + McpServerTool.Create( + (string input) => $"echo {input}", + new() { Name = "echo", ReadOnly = true, Destructive = false }), + McpServerTool.Create( + (string id) => + { + Interlocked.Increment(ref _deleteInvocations); + return $"deleted {id}"; + }, + new() { Name = "delete_record", Destructive = true }), + ]); + } + + private static McpClientRequestFilter BlockDestructiveTools(List? observedTools = null) => + next => async (request, cancellationToken) => + { + observedTools?.Add(request.Tool); + + // Fail closed: an unknown tool is treated like a destructive one. + if (request.Tool?.Annotations?.DestructiveHint is not false) + { + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Blocked by policy: {request.Params.Name}" }], + }; + } + + return await next(request, cancellationToken); + }; + + private static string GetText(CallToolResult result) => Assert.IsType(Assert.Single(result.Content)).Text; + + [Fact] + public async Task Filter_SeesAnnotations_AndBlocksDestructiveToolBeforeItReachesServer() + { + List observedTools = []; + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(BlockDestructiveTools(observedTools)); + + await using McpClient client = await CreateMcpClientForServer(options); + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var blocked = await client.CallToolAsync("delete_record", new Dictionary { ["id"] = "42" }, cancellationToken: TestContext.Current.CancellationToken); + var allowed = await client.CallToolAsync("echo", new Dictionary { ["input"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(blocked.IsError); + Assert.Equal("Blocked by policy: delete_record", GetText(blocked)); + Assert.Equal(0, _deleteInvocations); + + Assert.NotEqual(true, allowed.IsError); + Assert.Equal("echo hi", GetText(allowed)); + + Assert.Collection(observedTools, + tool => Assert.True(tool!.Annotations!.DestructiveHint), + tool => Assert.True(tool!.Annotations!.ReadOnlyHint)); + } + + [Fact] + public async Task Filter_ToolIsNull_WhenToolWasNotListed() + { + List observedTools = []; + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(BlockDestructiveTools(observedTools)); + + await using McpClient client = await CreateMcpClientForServer(options); + + // No ListToolsAsync, so the client has no definition for the tool and the policy fails closed. + var result = await client.CallToolAsync("echo", new Dictionary { ["input"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Null(Assert.Single(observedTools)); + } + + [Fact] + public async Task Filter_ToolIsAvailable_ForToolsRegisteredWithAddKnownTools() + { + List observedTools = []; + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(BlockDestructiveTools(observedTools)); + + await using McpClient client = await CreateMcpClientForServer(options); + client.AddKnownTools([new Tool + { + Name = "echo", + InputSchema = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone(), + Annotations = new() { DestructiveHint = false }, + }]); + + var result = await client.CallToolAsync("echo", new Dictionary { ["input"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("echo hi", GetText(result)); + Assert.False(Assert.Single(observedTools)!.Annotations!.DestructiveHint); + } + + [Fact] + public async Task Filter_RunsForEveryCallToolEntryPoint() + { + int filterInvocations = 0; + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(next => (request, cancellationToken) => + { + Interlocked.Increment(ref filterInvocations); + return next(request, cancellationToken); + }); + + await using McpClient client = await CreateMcpClientForServer(options); + var ct = TestContext.Current.CancellationToken; + var echo = Assert.Single(await client.ListToolsAsync(cancellationToken: ct), t => t.Name == "echo"); + var args = new Dictionary { ["input"] = "hi" }; + + await client.CallToolAsync("echo", args, cancellationToken: ct); + await client.CallToolAsync("echo", args, progress: new Progress(), cancellationToken: ct); + await client.CallToolAsync(new CallToolRequestParams { Name = "echo", Arguments = new Dictionary { ["input"] = JsonSerializer.SerializeToElement("hi", McpJsonUtilities.DefaultOptions) } }, ct); + await echo.CallAsync(args, cancellationToken: ct); + await echo.InvokeAsync(new AIFunctionArguments(args), ct); + + Assert.Equal(5, filterInvocations); + } + + [Fact] + public async Task Filters_RunInRegistrationOrder_FirstIsOutermost() + { + List log = []; + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + log.Add("first:before"); + var result = await next(request, cancellationToken); + log.Add("first:after"); + return result; + }); + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + log.Add("second:before"); + var result = await next(request, cancellationToken); + log.Add("second:after"); + return result; + }); + + await using McpClient client = await CreateMcpClientForServer(options); + await client.CallToolAsync("echo", new Dictionary { ["input"] = "hi" }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["first:before", "second:before", "second:after", "first:after"], log); + } + + [Fact] + public async Task Filter_CanRewriteArgumentsAndResult() + { + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + request.Params = new CallToolRequestParams + { + Name = request.Params.Name, + Arguments = new Dictionary { ["input"] = JsonSerializer.SerializeToElement("[redacted]", McpJsonUtilities.DefaultOptions) }, + }; + + var result = await next(request, cancellationToken); + result.Meta = new() { ["audited"] = true }; + return result; + }); + + await using McpClient client = await CreateMcpClientForServer(options); + var result = await client.CallToolAsync("echo", new Dictionary { ["input"] = "4111-1111-1111-1111" }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("echo [redacted]", GetText(result)); + Assert.True(result.Meta!["audited"]!.GetValue()); + } + + [Fact] + public async Task Filter_ExceptionPropagatesToCaller_AndRequestIsNotSent() + { + McpClientOptions options = new(); + options.Filters.Request.CallToolFilters.Add(next => (request, cancellationToken) => + throw new InvalidOperationException("denied")); + + await using McpClient client = await CreateMcpClientForServer(options); + + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync("delete_record", new Dictionary { ["id"] = "42" }, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("denied", ex.Message); + Assert.Equal(0, _deleteInvocations); + } + + [Fact] + public void Filters_SetNull_Throws() + { + McpClientOptions options = new(); + + Assert.Throws(() => options.Filters = null!); + Assert.Throws(() => options.Filters.Request = null!); + Assert.Throws(() => options.Filters.Request.CallToolFilters = null!); + } +}