Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/concepts/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/list-of-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,17 @@ public ValueTask<CallToolResult> CallToolAsync(
{
Throw.IfNull(requestParams);

return CallToolCoreAsync(requestParams, cancellationToken);
}

/// <summary>
/// Sends a <see cref="RequestMethods.ToolsCall"/> request. Every <c>CallToolAsync</c> overload routes through this
/// method, so derived clients can override it to apply <see cref="McpClientOptions.Filters"/>.
/// </summary>
private protected virtual ValueTask<CallToolResult> CallToolCoreAsync(
CallToolRequestParams requestParams,
CancellationToken cancellationToken)
{
return SendRequestAsync(
RequestMethods.ToolsCall,
requestParams,
Expand Down
27 changes: 27 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientFilters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Client;

/// <summary>
/// Provides filter collections for outgoing MCP client requests.
/// </summary>
/// <remarks>
/// Filters allow middleware-style composition where a filter can perform actions before and after the inner handler,
/// mirroring <see cref="Server.McpServerFilters"/> on the server.
/// </remarks>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public sealed class McpClientFilters
{
/// <summary>
/// Gets or sets the filters for request-specific client pipelines.
/// </summary>
public McpClientRequestFilters Request
{
get => field ??= new();
set
{
Throw.IfNull(value);
field = value;
}
}
}
33 changes: 33 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal sealed partial class McpClientImpl : McpClient
private readonly ConcurrentDictionary<string, Tool> _toolCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte> _registeredToolNames = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, byte> _cacheableConformanceWarnedMethods = new(StringComparer.Ordinal);
private readonly McpClientRequestHandler<CallToolRequestParams, CallToolResult>? _callToolHandler;

private ServerCapabilities? _serverCapabilities;
private Implementation? _serverInfo;
Expand Down Expand Up @@ -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<CallToolRequestParams, CallToolResult> 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 = () =>
Expand Down Expand Up @@ -663,6 +679,23 @@ public override void ClearKnownTools()
_registeredToolNames.Clear();
}

#pragma warning disable MCPEXP002 // Client request filters are experimental
private protected override ValueTask<CallToolResult> CallToolCoreAsync(CallToolRequestParams requestParams, CancellationToken cancellationToken)
{
if (_callToolHandler is null)
{
return base.CallToolCoreAsync(requestParams, cancellationToken);
}

return _callToolHandler(
new McpClientRequestContext<CallToolRequestParams>(this, requestParams)
{
Tool = requestParams.Name is { } name && _toolCache.TryGetValue(name, out var tool) ? tool : null,
},
cancellationToken);
}
#pragma warning restore MCPEXP002

/// <inheritdoc/>
public override async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
{
Expand Down
17 changes: 17 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,21 @@ public McpClientHandlers Handlers
}
}

/// <summary>
/// Gets or sets the filters applied to outgoing requests sent by the client.
/// </summary>
/// <remarks>
/// Use <see cref="McpClientRequestFilters.CallToolFilters"/> to inspect, modify, or block tool calls before they
/// reach the server, for example to require confirmation for tools annotated as destructive.
/// </remarks>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public McpClientFilters Filters
{
get => field ??= new();
set
{
Throw.IfNull(value);
field = value;
}
}
}
53 changes: 53 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientRequestContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Client;

/// <summary>
/// Provides the context for an outgoing client request as it flows through a
/// <see cref="McpClientRequestFilter{TParams, TResult}"/> pipeline.
/// </summary>
/// <typeparam name="TParams">Type of the request parameters specific to each MCP operation.</typeparam>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public sealed class McpClientRequestContext<TParams>
{
/// <summary>
/// Initializes a new instance of the <see cref="McpClientRequestContext{TParams}"/> class.
/// </summary>
/// <param name="client">The client sending the request.</param>
/// <param name="parameters">The parameters of the request.</param>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
public McpClientRequestContext(McpClient client, TParams parameters)
{
Throw.IfNull(client);

Client = client;
Params = parameters;
}

/// <summary>Gets the client sending the request.</summary>
public McpClient Client { get; }

/// <summary>Gets or sets the parameters of the request.</summary>
/// <remarks>
/// 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.
/// </remarks>
public TParams Params { get; set; }

/// <summary>
/// Gets or sets the tool definition the client knows for the tool being called by a <see cref="RequestMethods.ToolsCall"/> request.
/// </summary>
/// <remarks>
/// <para>
/// The definition, including its <see cref="Protocol.Tool.Annotations"/>, comes from the client's tool cache, which is populated
/// by <see cref="McpClient.ListToolsAsync(RequestOptions?, CancellationToken)"/> and <see cref="McpClient.AddKnownTools"/>.
/// It is <see langword="null"/> when the tool is not in that cache and for requests other than <see cref="RequestMethods.ToolsCall"/>.
/// </para>
/// <para>
/// A filter that enforces policy based on this definition should treat <see langword="null"/> as unknown and fail closed.
/// Annotations are hints supplied by the server; only rely on them for servers you trust.
/// </para>
/// </remarks>
public Tool? Tool { get; set; }
}
14 changes: 14 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientRequestFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Client;

/// <summary>
/// Delegate type for applying filters to outgoing MCP requests with specific parameter and result types from a client.
/// </summary>
/// <typeparam name="TParams">The type of the parameters sent with the request.</typeparam>
/// <typeparam name="TResult">The type of the result returned for the request.</typeparam>
/// <param name="next">The next request handler in the pipeline.</param>
/// <returns>The next request handler wrapped with the filter.</returns>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public delegate McpClientRequestHandler<TParams, TResult> McpClientRequestFilter<TParams, TResult>(
McpClientRequestHandler<TParams, TResult> next);
42 changes: 42 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientRequestFilters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Client;

/// <summary>
/// Provides grouped request-specific filter collections for outgoing client requests.
/// </summary>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public sealed class McpClientRequestFilters
{
/// <summary>
/// Gets or sets the filters for the <see cref="RequestMethods.ToolsCall"/> pipeline.
/// </summary>
/// <remarks>
/// <para>
/// These filters wrap every tool call made through <see cref="McpClient.CallToolAsync(CallToolRequestParams, CancellationToken)"/>,
/// which all other <c>CallToolAsync</c> overloads, <see cref="McpClientTool.CallAsync"/>, and <see cref="McpClientTool"/>
/// invocations through an <c>IChatClient</c> route through. A filter can inspect or modify the request, return a
/// <see cref="CallToolResult"/> without calling the next handler to block the call, or post-process the result.
/// </para>
/// <para>
/// To block a call, prefer returning a <see cref="CallToolResult"/> with <see cref="CallToolResult.IsError"/> set to
/// <see langword="true"/> over throwing: the result's content reaches the model, while a thrown exception's message is
/// typically hidden from it.
/// </para>
/// <para>
/// 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
/// <see cref="McpSession.SendRequestAsync(JsonRpcRequest, CancellationToken)"/> bypass these filters.
/// </para>
/// </remarks>
public IList<McpClientRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
{
get => field ??= [];
set
{
Throw.IfNull(value);
field = value;
}
}
}
16 changes: 16 additions & 0 deletions src/ModelContextProtocol.Core/Client/McpClientRequestHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Client;

/// <summary>
/// Delegate type for sending outgoing MCP requests with specific parameter and result types from a client.
/// </summary>
/// <typeparam name="TParams">The type of the parameters sent with the request.</typeparam>
/// <typeparam name="TResult">The type of the result returned for the request.</typeparam>
/// <param name="request">The request context containing the parameters and other metadata.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A task representing the asynchronous operation, with the result of the request.</returns>
[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)]
public delegate ValueTask<TResult> McpClientRequestHandler<TParams, TResult>(
McpClientRequestContext<TParams> request,
CancellationToken cancellationToken);
Loading