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

The Tasks extension and ASP.NET Core tool authorization use alternate-result `tools/call` filters.
Alternate-result filters run in registration order outside the ordinary `AddCallToolFilter`
pipeline. The Tasks filter creates a task at its position in that order and runs its remaining
pipeline in the background. Consequently, alternate-result filters registered before Tasks run
before task creation, while those registered after Tasks run in the background before the
ordinary filters and tool. ASP.NET Core authorization is registered before Tasks, so an
unauthorized call is rejected without creating a task.

Ordinary call-tool filters run exactly once after all alternate-result filters and before the tool.
For task-backed calls, ordinary filters execute in the background after task creation. An explicit
`CallToolWithAlternateHandler` remains a full replacement and cannot be combined with ordinary
call-tool filters.

Configure `WithTasks` before adding ordinary call-tool filters. Tasks validates this ordering when
its filter is installed because it must execute outside the ordinary call-tool pipeline.

## 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
33 changes: 11 additions & 22 deletions docs/concepts/tasks/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ 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.

Alternate-result `tools/call` filters run in registration order, with the Tasks filter creating a
task at its position in that order. Filters before Tasks run before task creation. Filters after
Tasks run in the background before the ordinary filter pipeline. ASP.NET Core tool authorization
uses an alternate-result filter registered before Tasks, so an unauthorized call does not create
a task.

Ordinary `tools/call` filters still run exactly once for task-backed calls. They execute in the
background after the task record is created and before the tool body, so validation and telemetry
continue to apply. Each background invocation gets an independent DI scope that remains alive
until the tool pipeline completes.

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 @@ -126,28 +137,6 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) =>
> are mutually exclusive. Setting one while the other is already non-null throws
> `InvalidOperationException` at the property setter.

#### Task scope for server-initiated requests

When you start background work from a custom <xref:ModelContextProtocol.Server.McpServerHandlers.CallToolWithAlternateHandler?displayProperty=nameWithType>
(rather than the SDK's auto-wrapping), use <xref:ModelContextProtocol.Extensions.Tasks.McpTasksServerExtensions.CreateMcpTaskScope*>
to route elicitation, sampling, and `roots/list` calls through the task store as input requests
instead of direct JSON-RPC messages:

```csharp
using ModelContextProtocol.Extensions.Tasks;

using (server.CreateMcpTaskScope(taskId, taskStore))
{
// ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as
// entries in the task's inputRequests, then await client responses via tasks/update.
var elicit = await server.ElicitAsync(elicitParams, ct);
}
```

`CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on
`Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via
`WithTasks`, so this API is only needed for custom handlers.

### Client usage

#### Automatic polling
Expand Down
13 changes: 13 additions & 0 deletions src/Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project>
<Target Name="PinMcpProjectReferencePackageVersions"
AfterTargets="_GetProjectReferenceVersions"
Condition="'$(MSBuildProjectName)' == 'ModelContextProtocol'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.AspNetCore'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Tasks'">
<ItemGroup>
<_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)">
<ProjectVersion>[%(_ProjectReferencesWithVersions.ProjectVersion)]</ProjectVersion>
</_ProjectReferencesWithVersions>
</ItemGroup>
</Target>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

namespace ModelContextProtocol.AspNetCore;

internal sealed class AuthorizationFiltersMarker;

internal sealed class AuthorizationCallToolFilterGuardSetup(AuthorizationFiltersMarker? marker = null) : IPostConfigureOptions<McpServerOptions>
{
public void PostConfigure(string? name, McpServerOptions options)
{
if (marker is not null)
{
return;
}

#pragma warning disable MCPEXP002 // The guard must run before Tasks can dispatch the request.
options.Filters.Request.CallToolWithAlternateFilters.Insert(0, static async (context, next, cancellationToken) =>
{
if (AuthorizationFilterSetup.HasAuthorizationMetadata(context.MatchedPrimitive))
{
throw new InvalidOperationException(
"Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. " +
"Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return await next(context, cancellationToken);
});
#pragma warning restore MCPEXP002
}
}
32 changes: 13 additions & 19 deletions src/ModelContextProtocol.AspNetCore/AuthorizationFilterSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Evaluates authorization policies from endpoint metadata.
/// </summary>
internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? policyProvider = null) : IConfigureOptions<McpServerOptions>, IPostConfigureOptions<McpServerOptions>
internal sealed class AuthorizationFilterSetup(
IAuthorizationPolicyProvider? policyProvider = null,
AuthorizationFiltersMarker? marker = null) : IConfigureOptions<McpServerOptions>, IPostConfigureOptions<McpServerOptions>
{
private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked";

public void Configure(McpServerOptions options)
{
ConfigureListToolsFilter(options);
ConfigureCallToolFilter(options);

ConfigureListResourcesFilter(options);
ConfigureListResourceTemplatesFilter(options);
Expand All @@ -30,8 +31,13 @@ public void Configure(McpServerOptions options)

public void PostConfigure(string? name, McpServerOptions options)
{
// Add tool authorization after all regular configuration so it always wraps Tasks.
if (marker is not null)
{
ConfigureCallToolFilter(options);
}

CheckListToolsFilter(options);
CheckCallToolFilter(options);

CheckListResourcesFilter(options);
CheckListResourceTemplatesFilter(options);
Expand Down Expand Up @@ -81,7 +87,8 @@ private static void CheckListToolsFilter(McpServerOptions options)

private void ConfigureCallToolFilter(McpServerOptions options)
{
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
#pragma warning disable MCPEXP002 // Authorization must run in the alternate-result pipeline before task dispatch.
Comment thread
PranavSenthilnathan marked this conversation as resolved.
options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) =>
{
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
if (!authResult.Succeeded)
Expand All @@ -93,20 +100,7 @@ private void ConfigureCallToolFilter(McpServerOptions options)

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

private static void CheckCallToolFilter(McpServerOptions options)
{
options.Filters.Request.CallToolFilters.Add(next => async (context, cancellationToken) =>
{
if (HasAuthorizationMetadata(context.MatchedPrimitive)
&& !context.Items.ContainsKey(AuthorizationFilterInvokedKey))
{
throw new InvalidOperationException("Authorization filter was not invoked for tools/call operation, but authorization metadata was found on the tool. Ensure that AddAuthorizationFilters() is called on the IMcpServerBuilder to configure authorization filters.");
}

return await next(context, cancellationToken);
});
#pragma warning restore MCPEXP002
}

private void ConfigureListResourcesFilter(McpServerOptions options)
Expand Down Expand Up @@ -374,7 +368,7 @@ private async ValueTask<AuthorizationResult> GetAuthorizationResultAsync(
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build());
}

private static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive)
internal static bool HasAuthorizationMetadata([NotNullWhen(true)] IMcpServerPrimitive? primitive)
{
// If no primitive was found for this request or there is IAllowAnonymous metadata anywhere on the class or method,
// the request should go through as normal.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
builder.Services.AddHostedService<IdleTrackingBackgroundService>();

builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationCallToolFilterGuardSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<HttpServerTransportOptions>, HttpServerTransportOptionsSetup>());

if (configureOptions is not null)
Expand All @@ -55,14 +56,17 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
/// <remarks>
/// This method automatically configures authorization filters for all MCP server handlers. These filters respect
/// authorization attributes such as <see cref="AuthorizeAttribute"/>
/// and <see cref="AllowAnonymousAttribute"/>.
/// and <see cref="AllowAnonymousAttribute"/>. Tool authorization runs in the alternate-result pipeline before
/// the Tasks extension dispatches background execution, so an unauthorized tool call does not create a task.
/// </remarks>
public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

// Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive.
builder.Services.TryAddSingleton<AuthorizationFiltersMarker>();
builder.Services.AddTransient<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
Comment thread
PranavSenthilnathan marked this conversation as resolved.

return builder;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using ModelContextProtocol.Protocol;

namespace ModelContextProtocol.Server;

#pragma warning disable MCPEXP002
internal sealed class ComposedCallToolInvocationState
{
private readonly object _sync = new();
private readonly List<ToolCallLifecycle> _pendingOrdinaryLifecycles = [];
private bool _outerCompleted;
private bool _outerReturnedAlternate;

public IReadOnlyList<ToolCallLifecycle> RecordOrdinaryResult(CallToolResult result) =>
RecordOrdinaryLifecycle(new(result, null, false));

public IReadOnlyList<ToolCallLifecycle> RecordOrdinaryException(
Exception exception,
bool cancellationRequested) =>
RecordOrdinaryLifecycle(new(null, exception, cancellationRequested));

public IReadOnlyList<ToolCallLifecycle> CompleteOuter(ResultOrAlternate<CallToolResult> result)
{
lock (_sync)
{
if (result.IsAlternate)
{
_outerReturnedAlternate = true;
return DrainPendingLifecycles();
}

_outerCompleted = true;
var exceptions = _pendingOrdinaryLifecycles
.Where(lifecycle => lifecycle.Exception is not null)
.ToArray();
_pendingOrdinaryLifecycles.Clear();
return exceptions.Length > 0 ?
exceptions :
[new(result.Result!, null, false)];
}
}

public IReadOnlyList<ToolCallLifecycle> CompleteOuterException(
Exception exception,
bool cancellationRequested)
{
lock (_sync)
{
_outerCompleted = true;
_pendingOrdinaryLifecycles.Clear();
return [new(null, exception, cancellationRequested)];
}
}

private IReadOnlyList<ToolCallLifecycle> RecordOrdinaryLifecycle(ToolCallLifecycle lifecycle)
{
lock (_sync)
{
if (_outerReturnedAlternate)
{
return [lifecycle];
}

if (!_outerCompleted)
{
_pendingOrdinaryLifecycles.Add(lifecycle);
}

return [];
}
}

private IReadOnlyList<ToolCallLifecycle> DrainPendingLifecycles()
{
if (_pendingOrdinaryLifecycles.Count == 0)
{
return [];
}

var lifecycles = _pendingOrdinaryLifecycles.ToArray();
_pendingOrdinaryLifecycles.Clear();
return lifecycles;
}
}
#pragma warning restore MCPEXP002
31 changes: 19 additions & 12 deletions src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ namespace ModelContextProtocol.Server;
/// </summary>
public sealed class McpRequestFilters
{
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
private IList<McpRequestFilter<CallToolRequestParams, CallToolResult>>? _callToolFilters;

/// <summary>
/// Gets or sets the filters for the list-tools handler pipeline.
/// </summary>
Expand Down Expand Up @@ -43,23 +46,26 @@ 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"/>. 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. Filters
/// that must run before task creation should use the alternate-result pipeline instead.
/// </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
{
get => field ??= [];
get => _callToolFilters ??= [];
set
{
Throw.IfNull(value);
field = value;
_callToolFilters = value;
}
}

#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
/// <summary>
/// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
/// </summary>
Expand All @@ -71,14 +77,15 @@ 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"/>).
/// When no explicit <see cref="McpServerHandlers.CallToolWithAlternateHandler"/> is configured, these filters
/// compose outside <see cref="CallToolFilters"/>. Primitive matching occurs before either filter family runs, then
/// the ordinary pipeline is adapted to <see cref="ResultOrAlternate{TResult}"/> before these filters are applied.
/// Alternate-result filters run in registration order. If one filter dispatches the remainder of the pipeline
/// asynchronously, filters registered after it execute as part of that asynchronous operation.
/// </para>
/// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
public IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
{
get => field ??= [];
set
Expand Down
18 changes: 18 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpRequestInvocationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;

namespace ModelContextProtocol.Server;

/// <summary>
/// Delegate type for filtering a single incoming MCP request invocation.
/// </summary>
/// <typeparam name="TParams">The type of the parameters sent with the request.</typeparam>
/// <typeparam name="TResult">The type of the response returned by the handler.</typeparam>
/// <param name="context">The context for the current request.</param>
/// <param name="next">The next request handler in the pipeline for this invocation.</param>
/// <param name="cancellationToken">The cancellation token for the current request.</param>
/// <returns>The result of the filtered request invocation.</returns>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
public delegate ValueTask<TResult> McpRequestInvocationFilter<TParams, TResult>(
RequestContext<TParams> context,
McpRequestHandler<TParams, TResult> next,
CancellationToken cancellationToken);
Loading