Skip to content

Add client-side call-tool filters for tool-call policy enforcement - #1882

Open
1aifanatic wants to merge 1 commit into
modelcontextprotocol:mainfrom
1aifanatic:feat/client-calltool-filters
Open

1aifanatic wants to merge 1 commit into
modelcontextprotocol:mainfrom
1aifanatic:feat/client-calltool-filters

Conversation

@1aifanatic

Copy link
Copy Markdown

Closes #1453

Motivation

Hosts that let a model choose tools need a single place to apply policy before a tools/call leaves the client, for example requiring confirmation for tools annotated destructiveHint: true. Today there is no client-side equivalent of the server's CallToolFilters, so hosts have to wrap every McpClientTool by hand, and any path they miss (such as a direct client.CallToolAsync(...)) bypasses the policy.

Change

This follows the design @elzouhery laid out in the issue thread. It adds client request filters that mirror the server-side filter shape (McpServerOptions.Filters.Request.CallToolFilters):

var options = new McpClientOptions();
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
{
    // context.Tool is the definition cached by ListToolsAsync/AddKnownTools, or null if unknown -> fail closed.
    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);
});
  • New types in ModelContextProtocol.Client: McpClientFilters, McpClientRequestFilters, McpClientRequestFilter<TParams, TResult>, McpClientRequestHandler<TParams, TResult>, and McpClientRequestContext<TParams> (Client, settable Params, Tool?). They are exposed through McpClientOptions.Filters.
  • One interception point. CallToolAsync(CallToolRequestParams, ...) now routes through a private protected virtual CallToolCoreAsync seam, in the same way ValidateCacheableResult does. Every other overload, McpClientTool.CallAsync, and McpClientTool invoked through an IChatClient already converge there, so all of them are filtered. McpClient's public or abstract surface doesn't change.
  • Annotations come from the existing _toolCache, which ListToolsAsync and AddKnownTools populate and which is already used for the SEP-2243 Mcp-Param-* headers, so no new caching is added. On a cache miss, Tool is null, and the docs say to fail closed.
  • The pipeline is composed once, when the client is created, and the first registered filter is the outermost, matching the server. With no filters registered, the call path is the same as before.
  • All new APIs are marked [Experimental(MCPEXP002)] so the shape can still change. list-of-diagnostics.md and docs/concepts/filters.md are updated.

Design notes

  • Blocking returns a result, not an exception. When FunctionInvokingChatClient catches an exception, the model only sees "Error: Function failed." (unless IncludeDetailedErrors is on). When the filter returns IsError = true, the model sees the reason. The docs recommend that pattern, so there is no ToolBlockedException.
  • Not covered: raw SendRequestAsync(JsonRpcRequest), which also bypasses server filters (this is documented), and task-augmented calls from ModelContextProtocol.Extensions.Tasks. Those return CreateTaskResult through a separate path. I'm happy to follow up if you want them to compose.

Tests

McpClientCallToolFilterTests has 8 tests against a real in-memory McpServer/McpClient pair:

  • a destructive tool is blocked before it reaches the server (the server-side invocation counter stays at 0), and a read-only tool is allowed
  • Tool is null when the tool wasn't listed, and the policy fails closed
  • Tool is populated for tools registered with AddKnownTools
  • the filter runs for all 5 entry points: CallToolAsync(string), CallToolAsync(string) with progress, CallToolAsync(CallToolRequestParams), McpClientTool.CallAsync, and AIFunction.InvokeAsync
  • the first registered filter is the outermost
  • rewritten Params (argument redaction) reach the server, and the filter can post-process the result
  • a filter exception reaches the caller and the request is never sent
  • the property setters reject null

To check that the tests can fail, I built once with the pipeline never installed: 7 of the 8 tests failed (the setter test doesn't depend on the pipeline).

Full local run on Windows (.NET SDK 10.0.302): dotnet build of the solution is clean (warnings are errors), and the new tests pass on all four target frameworks (net10.0, net9.0, net8.0, net472). The results of the full suites:

  • ModelContextProtocol.AspNetCore.Tests: net10.0 635 passed / 0 failed, net9.0 635 / 0. On net8.0 one ClientConformanceTests auth scenario failed once, then passed 40/40 on two reruns.
  • ModelContextProtocol.Tests: in the full parallel run across all four TFMs, the only failures besides the two noted below were ClientIntegrationTests against the npx-launched "everything" server, which timed out under load. Run on their own, they pass 59/59 (2 skipped) on every TFM.
  • The first full run also caught an AOT problem in two of the new tests on net9.0 (reflection-based SerializeToElement). I fixed it with McpJsonUtilities.DefaultOptions and re-verified on all four TFMs.

🤖 Generated with Claude Code

https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr

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 modelcontextprotocol#1453

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p29dMDsLnn6KDFmsz2PGr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add client-side tool call interceptor (annotation-aware policy enforcement)

1 participant