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
31 changes: 31 additions & 0 deletions docs/concepts/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ The following request filter methods are available on `IMcpRequestFilterBuilder`
- `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers
- `AddSetLoggingLevelFilter` - Filter for logging level handlers

### Alternate-Result Filters

Extension packages can augment any typed server method with a filter that returns either the method's
ordinary result or an alternate `Result` subtype. Register the filter by case-sensitive method name on
`McpServerOptions`:

```csharp
services.Configure<McpServerOptions>(options =>
options.AddAlternateResultFilter<GetPromptRequestParams, GetPromptResult>(
RequestMethods.PromptsGet,
next => async (context, cancellationToken) =>
{
if (ShouldDefer(context.Params))
{
return ResultOrAlternate<GetPromptResult>.FromAlternate(
CreateDeferredResult(),
MyJsonContext.Default.DeferredResult);
}

return await next(context, cancellationToken);
}));
```

The parameter and ordinary result types must exactly match the configured typed handler for the method.
The server reports unknown methods and type mismatches when it starts. Multiple filters for one method
run in registration order, with the first registered filter outermost. The ordinary request-specific
filters and handler execute inside the alternate-result filters.

An explicit `CallToolWithAlternateHandler` remains a low-level replacement for `tools/call`; it cannot
be combined with ordinary call-tool filters or a method-keyed alternate-result filter for that method.

## Message Filters

In addition to the request-specific filters above, there are low-level message filters that intercept all JSON-RPC messages before they are routed to specific handlers.
Expand Down
21 changes: 16 additions & 5 deletions docs/concepts/tasks/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ When tasks are enabled with `WithTasks` the SDK automatically:
- Plumbs a `CancellationToken` through to the tool that fires when the client invokes
`tasks/cancel`, so cancellation propagates cooperatively.

The task wrapper runs outside the ordinary `tools/call` pipeline. For an opted-in call, the store creates
the task record first and the ordinary request filters then run exactly once during background execution,
with the matched tool available in `RequestContext.MatchedPrimitive`. Authorization and validation filters
therefore still run before the tool body. If authorization fails, the task transitions to `failed` and the
tool is not invoked; the task record already exists when that denial occurs.

Background execution receives a new asynchronous dependency-injection scope before the initiating request
completes. Scoped filter services and tool dependencies therefore remain available after an HTTP request scope
has been disposed. The execution scope is disposed after the task reaches its recorded outcome.

For production scenarios that need durability, session isolation, multi-process routing, or
TTL-based cleanup, implement <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore> yourself
(see [Implementing a custom task store](#implementing-a-custom-task-store) below).
Expand Down Expand Up @@ -111,15 +121,16 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) =>
PollIntervalMs = 1000,
};

return new ResultOrAlternate<CallToolResult>(created, McpTasksJsonContext.Default.CreateTaskResult);
return ResultOrAlternate<CallToolResult>.FromAlternate(
created,
McpTasksJsonContext.Default.CreateTaskResult);
};
```

> This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the
> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also
> returns an alternate. Use one mechanism or the other. When you return a task this way you are also
> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides
> automatically.
> SDK does the wrapping for you and rejects an explicit alternate handler when the server starts.
> Use one mechanism or the other. When you return a task this way you are also responsible for serving
> `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides automatically.

> <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolHandler?displayProperty=nameWithType>
> and <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithAlternateHandler?displayProperty=nameWithType>
Expand Down
20 changes: 12 additions & 8 deletions src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,15 @@ public IList<McpRequestFilter<ListToolsRequestParams, ListToolsResult>> ListTool
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
/// </para>
/// <para>
/// Cannot be used together with <see cref="CallToolWithAlternateFilters"/>. If both are non-empty at configuration time,
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
/// register different tool-call filter styles, such as authorization filters (which use this collection) and the
/// tasks extension (which uses <see cref="CallToolWithAlternateFilters"/>).
/// These filters run inside <see cref="CallToolWithAlternateFilters"/> and any method-keyed alternate-result filters
/// registered through <see cref="McpServerOptions.AddAlternateResultFilter{TParams, TResult}(string, McpRequestFilter{TParams, ResultOrAlternate{TResult}})"/>.
/// Each ordinary filter runs exactly once when an alternate-result filter invokes the ordinary tool pipeline.
/// For task-backed calls, that invocation occurs in the background after the task record is created and before the
/// matched tool is executed.
/// </para>
/// <para>
/// These filters cannot be used with an explicit <see cref="McpServerHandlers.CallToolWithAlternateHandler"/>, which
/// replaces the ordinary tool-call pipeline rather than augmenting it.
/// </para>
/// </remarks>
public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
Expand All @@ -71,10 +76,9 @@ public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFi
/// subtype for asynchronous execution.
/// </para>
/// <para>
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time,
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
/// register different tool-call filter styles, such as the tasks extension (which uses this collection) and
/// authorization filters (which use <see cref="CallToolFilters"/>).
/// These filters compose outside <see cref="CallToolFilters"/>. The ordinary pipeline, including primitive matching
/// and ordinary filters, is adapted to <see cref="ResultOrAlternate{TResult}"/> before these filters are applied.
/// The first alternate-result filter added is the outermost filter.
/// </para>
/// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
Expand Down
5 changes: 4 additions & 1 deletion src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ public McpRequestHandler<CallToolRequestParams, CallToolResult>? CallToolHandler
/// a <see cref="CallToolResult"/> for immediate results or an alternate <see cref="Result"/> subtype.
/// </para>
/// <para>
/// Cannot be set if <see cref="CallToolHandler"/> is already set.
/// This is a low-level full replacement for the ordinary tool-call pipeline. It cannot be set if
/// <see cref="CallToolHandler"/> is already set, and it cannot be composed with ordinary
/// <see cref="McpRequestFilters.CallToolFilters"/> or a method-keyed alternate-result filter for
/// <see cref="RequestMethods.ToolsCall"/>.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception>
Expand Down
122 changes: 82 additions & 40 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal sealed partial class McpServerImpl : McpServer
private readonly List<Action> _disposables = [];
private readonly NotificationHandlers _notificationHandlers;
private readonly RequestHandlers _requestHandlers;
private readonly HashSet<string> _configuredAlternateResultFilterMethods = new(StringComparer.Ordinal);
private readonly McpSessionHandler _sessionHandler;
private readonly string[] _supportedProtocolVersions;
private readonly string[] _initializeHandshakeProtocolVersions;
Expand Down Expand Up @@ -108,6 +109,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
ConfigureCompletion(options);
ConfigureSubscriptions(options);
ConfigureExperimentalAndExtensions(options);
ValidateAlternateResultFilters(options);
ConfigureMrtr();
ConfigureCustomRequestHandlers(options);

Expand Down Expand Up @@ -996,6 +998,19 @@ private void ConfigureExperimentalAndExtensions(McpServerOptions options)
ServerCapabilities.Extensions = options.Capabilities?.Extensions;
}

private void ValidateAlternateResultFilters(McpServerOptions options)
{
foreach (var method in options.AlternateResultFilterMethods)
{
if (!_configuredAlternateResultFilterMethods.Contains(method))
{
throw new InvalidOperationException(
$"An alternate-result filter was registered for method '{method}', but the server has no matching typed handler. " +
$"Configure the built-in handler and capability for that method, and ensure the registered parameter and result types match it.");
}
}
}

private void ConfigureCustomRequestHandlers(McpServerOptions options)
{
#pragma warning disable MCPEXP002
Expand Down Expand Up @@ -1321,20 +1336,21 @@ private void ConfigureTools(McpServerOptions options)
var callToolFilters = options.Filters.Request.CallToolFilters;
var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters;

// Validate: cannot mix non-alternate filters/handler with alternate filters/handler.
bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0;
bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0;
if (callToolWithAlternateHandler is not null && callToolFilters.Count > 0)
{
throw new InvalidOperationException(
$"Cannot apply {nameof(McpRequestFilters.CallToolFilters)} when an explicit " +
$"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler replaces " +
$"the ordinary tool-call pipeline. Move the behavior to {nameof(McpRequestFilters.CallToolWithAlternateFilters)} " +
$"or remove the explicit alternate handler.");
}

if (hasNonAlternatePath && hasAlternatePath)
if (callToolWithAlternateHandler is not null && options.HasAlternateResultFilters(RequestMethods.ToolsCall))
{
throw new InvalidOperationException(
$"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
$"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " +
$"These two styles cannot currently be composed on the same server. " +
$"This most commonly happens when combining features that register different tool-call filter styles, " +
$"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " +
$"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " +
$"Configure only one style, or avoid combining features that require different styles.");
$"Cannot apply an alternate-result filter registered for '{RequestMethods.ToolsCall}' when an explicit " +
$"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler replaces " +
$"the ordinary tool-call pipeline. Remove the explicit alternate handler or the method-keyed filter.");
}

// Handle tools provided via DI by augmenting the list handler.
Expand Down Expand Up @@ -1376,12 +1392,9 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)

listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);

// Build the unified alternate-result handler from one of the two paths.
if (hasAlternatePath)
// An explicit alternate handler replaces the ordinary tool-call pipeline.
if (callToolWithAlternateHandler is not null)
{
// Case 2: alternate filter + alternate handler
callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));

// Augment with DI tools.
if (tools is not null)
{
Expand All @@ -1398,10 +1411,15 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
}

callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools));

SetWithAlternateHandler(
RequestMethods.ToolsCall,
callToolWithAlternateHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CallToolResult);
}
else
{
// Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));

// Augment with DI tools.
Expand All @@ -1421,10 +1439,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)

callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));

// Convert to alternate-based.
var finalCallToolHandler = callToolHandler;
callToolWithAlternateHandler = async (request, cancellationToken) =>
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
SetHandler(
RequestMethods.ToolsCall,
callToolHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CallToolResult,
callToolWithAlternateFilters);
}
ServerCapabilities.Tools.ListChanged = listChanged;

Expand All @@ -1434,11 +1454,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
McpJsonUtilities.JsonContext.Default.ListToolsResult);

SetWithAlternateHandler(
RequestMethods.ToolsCall,
callToolWithAlternateHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CallToolResult);
}
private static async ValueTask<ResultOrAlternate<CallToolResult>> InvokeToolWithAlternate(
McpServerTool tool,
Expand Down Expand Up @@ -1635,11 +1650,14 @@ private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest js
return server;
}

#pragma warning disable MCPEXP002 // SetHandler and SetWithAlternateHandler wrap the experimental ResultOrAlternate seam
private void SetHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, TResult> handler,
JsonTypeInfo<TParams> requestTypeInfo,
JsonTypeInfo<TResult> responseTypeInfo)
JsonTypeInfo<TResult> responseTypeInfo,
IList<McpRequestFilter<TParams, ResultOrAlternate<TResult>>>? alternateResultFilters = null)
where TResult : Result
{
// SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list,
// resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields.
Expand All @@ -1662,19 +1680,38 @@ private void SetHandler<TParams, TResult>(
};
}

if (typeof(Result).IsAssignableFrom(typeof(TResult)))
var resultHandler = handler;
handler = async (request, cancellationToken) =>
{
var innerHandler = handler;
handler = async (request, cancellationToken) =>
var result = await resultHandler(request, cancellationToken).ConfigureAwait(false);
if (result.ResultType is null)
{
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
if (result is Result protocolResult && protocolResult.ResultType is null)
{
protocolResult.ResultType = "complete";
}
result.ResultType = "complete";
}

return result;
};
return result;
};

var registeredAlternateResultFilters = ServerOptions.GetAlternateResultFilters<TParams, TResult>(method);
if (registeredAlternateResultFilters.Count > 0 || alternateResultFilters is { Count: > 0 })
{
if (registeredAlternateResultFilters.Count > 0)
{
_configuredAlternateResultFilterMethods.Add(method);
}

var ordinaryHandler = handler;
McpRequestHandler<TParams, ResultOrAlternate<TResult>> alternateHandler = async (request, cancellationToken) =>
await ordinaryHandler(request, cancellationToken).ConfigureAwait(false);

if (alternateResultFilters is not null)
{
alternateHandler = BuildFilterPipeline(alternateHandler, alternateResultFilters);
}

alternateHandler = BuildFilterPipeline(alternateHandler, registeredAlternateResultFilters);
SetWithAlternateHandler(method, alternateHandler, requestTypeInfo, responseTypeInfo);
return;
}

_requestHandlers.Set(method,
Expand All @@ -1683,7 +1720,6 @@ private void SetHandler<TParams, TResult>(
requestTypeInfo, responseTypeInfo);
}

#pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam
private void SetWithAlternateHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, ResultOrAlternate<TResult>> handler,
Expand All @@ -1695,9 +1731,15 @@ private void SetWithAlternateHandler<TParams, TResult>(
handler = async (request, cancellationToken) =>
{
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult)
if (!result.IsAlternate && result.Result is { } immediateResult)
{
immediateResult.ResultType = "complete";
if (immediateResult is ICacheableResult cacheable)
{
cacheable.TimeToLive ??= TimeSpan.Zero;
cacheable.CacheScope ??= CacheScope.Private;
}

immediateResult.ResultType ??= "complete";
}

return result;
Expand Down
Loading