Fix HTTP task filter composition (#1722)

This commit is contained in:
Pranav Senthilnathan 2026-07-24 13:33:19 -07:00 • committed by GitHub
commit a5179df39b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1164 additions and 286 deletions

View file

@ -32,6 +32,22 @@ The following request filter methods are available on `IMcpRequestFilterBuilder`
| `AddUnsubscribeFromResourcesFilter` | Resource unsubscription handlers | | `AddUnsubscribeFromResourcesFilter` | Resource unsubscription handlers |
| `AddSetLoggingLevelFilter` | Logging level handlers | | `AddSetLoggingLevelFilter` | 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 ## 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. 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.

View file

@ -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 - Plumbs a `CancellationToken` through to the tool that fires when the client invokes
`tasks/cancel`, so cancellation propagates cooperatively. `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 For production scenarios that need durability, session isolation, multi-process routing, or
TTL-based cleanup, implement <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore> yourself TTL-based cleanup, implement <xref:ModelContextProtocol.Extensions.Tasks.IMcpTaskStore> yourself
(see the [Implementing a custom task store](#implementing-a-custom-task-store) section). (see the [Implementing a custom task store](#implementing-a-custom-task-store) section).
@ -126,28 +137,6 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) =>
> are mutually exclusive. Setting one while the other is already non-null throws > are mutually exclusive. Setting one while the other is already non-null throws
> `InvalidOperationException` at the property setter. > `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 ### Client usage
#### Automatic polling #### Automatic polling

View file

@ -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>

View file

@ -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
}
}

View file

@ -11,14 +11,15 @@ namespace ModelContextProtocol.AspNetCore;
/// <summary> /// <summary>
/// Evaluates authorization policies from endpoint metadata. /// Evaluates authorization policies from endpoint metadata.
/// </summary> /// </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"; private static readonly string AuthorizationFilterInvokedKey = "ModelContextProtocol.AspNetCore.AuthorizationFilter.Invoked";
public void Configure(McpServerOptions options) public void Configure(McpServerOptions options)
{ {
ConfigureListToolsFilter(options); ConfigureListToolsFilter(options);
ConfigureCallToolFilter(options);
ConfigureListResourcesFilter(options); ConfigureListResourcesFilter(options);
ConfigureListResourceTemplatesFilter(options); ConfigureListResourceTemplatesFilter(options);
@ -30,8 +31,13 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
public void PostConfigure(string? name, 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); CheckListToolsFilter(options);
CheckCallToolFilter(options);
CheckListResourcesFilter(options); CheckListResourcesFilter(options);
CheckListResourceTemplatesFilter(options); CheckListResourceTemplatesFilter(options);
@ -81,7 +87,8 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
private void ConfigureCallToolFilter(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.
options.Filters.Request.CallToolWithAlternateFilters.Insert(0, async (context, next, cancellationToken) =>
{ {
var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context); var authResult = await GetAuthorizationResultAsync(context.User, context.MatchedPrimitive, context.Services, context);
if (!authResult.Succeeded) if (!authResult.Succeeded)
@ -93,20 +100,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
return await next(context, cancellationToken); return await next(context, cancellationToken);
}); });
} #pragma warning restore MCPEXP002
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);
});
} }
private void ConfigureListResourcesFilter(McpServerOptions options) private void ConfigureListResourcesFilter(McpServerOptions options)
@ -374,7 +368,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
: AuthorizationPolicy.Combine(policy, reqPolicyBuilder.Build()); : 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, // 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. // the request should go through as normal.

View file

@ -34,6 +34,7 @@ public static class HttpMcpServerBuilderExtensions
builder.Services.AddHostedService<IdleTrackingBackgroundService>(); builder.Services.AddHostedService<IdleTrackingBackgroundService>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationCallToolFilterGuardSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<HttpServerTransportOptions>, HttpServerTransportOptionsSetup>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<HttpServerTransportOptions>, HttpServerTransportOptionsSetup>());
if (configureOptions is not null) if (configureOptions is not null)
@ -55,14 +56,17 @@ public static class HttpMcpServerBuilderExtensions
/// <remarks> /// <remarks>
/// This method automatically configures authorization filters for all MCP server handlers. These filters respect /// This method automatically configures authorization filters for all MCP server handlers. These filters respect
/// authorization attributes such as <see cref="AuthorizeAttribute"/> /// 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> /// </remarks>
public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder) public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder builder)
{ {
ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(builder);
// Allow the authorization filters to get added multiple times in case other middleware changes the matched primitive. // 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.AddTransient<IConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
return builder; return builder;
} }

View file

@ -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

View file

@ -8,6 +8,9 @@ namespace ModelContextProtocol.Server;
/// </summary> /// </summary>
public sealed class McpRequestFilters public sealed class McpRequestFilters
{ {
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
private IList<McpRequestFilter<CallToolRequestParams, CallToolResult>>? _callToolFilters;
/// <summary> /// <summary>
/// Gets or sets the filters for the list-tools handler pipeline. /// Gets or sets the filters for the list-tools handler pipeline.
/// </summary> /// </summary>
@ -43,23 +46,26 @@ public sealed class McpRequestFilters
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results. /// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
/// </para> /// </para>
/// <para> /// <para>
/// Cannot be used together with <see cref="CallToolWithAlternateFilters"/>. If both are non-empty at configuration time, /// These filters run inside <see cref="CallToolWithAlternateFilters"/>. Each ordinary filter runs exactly once
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that /// when an alternate-result filter invokes the ordinary tool pipeline. For task-backed calls, that invocation
/// register different tool-call filter styles, such as authorization filters (which use this collection) and the /// occurs in the background after the task record is created and before the matched tool is executed. Filters
/// tasks extension (which uses <see cref="CallToolWithAlternateFilters"/>). /// 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> /// </para>
/// </remarks> /// </remarks>
public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
{ {
get => field ??= []; get => _callToolFilters ??= [];
set set
{ {
Throw.IfNull(value); Throw.IfNull(value);
field = value; _callToolFilters = value;
} }
} }
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
/// <summary> /// <summary>
/// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
/// </summary> /// </summary>
@ -71,14 +77,15 @@ public sealed class McpRequestFilters
/// subtype for asynchronous execution. /// subtype for asynchronous execution.
/// </para> /// </para>
/// <para> /// <para>
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time, /// When no explicit <see cref="McpServerHandlers.CallToolWithAlternateHandler"/> is configured, these filters
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that /// compose outside <see cref="CallToolFilters"/>. Primitive matching occurs before either filter family runs, then
/// register different tool-call filter styles, such as the tasks extension (which uses this collection) and /// the ordinary pipeline is adapted to <see cref="ResultOrAlternate{TResult}"/> before these filters are applied.
/// authorization filters (which use <see cref="CallToolFilters"/>). /// 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> /// </para>
/// </remarks> /// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters public IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
{ {
get => field ??= []; get => field ??= [];
set set

View file

@ -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);

View file

@ -23,50 +23,27 @@ public abstract partial class McpServer : McpSession
private static Dictionary<string, HashSet<string>>? s_elicitAllowedProperties = null; private static Dictionary<string, HashSet<string>>? s_elicitAllowedProperties = null;
/// <summary> internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
/// Ambient interceptor that, when installed, redirects server-initiated requests
/// (<see cref="SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
/// <see cref="ElicitAsync(ElicitRequestParams, CancellationToken)"/>, and
/// <see cref="RequestRootsAsync(ListRootsRequestParams, CancellationToken)"/>) away from the
/// transport. The interceptor receives the request method and pre-serialized parameters and
/// returns the serialized result. Used by extensions (such as the Tasks extension) to surface
/// these requests through an alternate channel during background execution.
/// </summary>
private static readonly AsyncLocal<Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>?> s_outgoingRequestInterceptor = new();
/// <summary> /// <summary>
/// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any. /// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
/// </summary>
internal static Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value;
/// <summary>
/// Installs an interceptor that redirects server-initiated requests for the duration of the
/// returned scope on the current asynchronous execution context.
/// </summary> /// </summary>
/// <param name="interceptor"> /// <param name="interceptor">
/// The interceptor invoked for each outgoing request. It receives the request method, the /// The interceptor invoked for each outgoing request. It receives the request method, the
/// pre-serialized request parameters (or <see langword="null"/>), and a cancellation token, and /// pre-serialized request parameters (or <see langword="null"/>), and a cancellation token, and
/// returns the serialized result (or <see langword="null"/> to indicate no result). /// returns the serialized result (or <see langword="null"/> to indicate no result).
/// </param> /// </param>
/// <returns>An <see cref="IDisposable"/> that restores the previous interceptor when disposed.</returns> /// <returns>A server facade that uses <paramref name="interceptor"/> for outgoing requests.</returns>
/// <exception cref="ArgumentNullException"><paramref name="interceptor"/> is <see langword="null"/>.</exception> /// <exception cref="ArgumentNullException"><paramref name="interceptor"/> is <see langword="null"/>.</exception>
/// <remarks> /// <remarks>
/// While an interceptor is installed, the redirected methods skip their client-capability checks, /// On the returned facade, redirected methods skip their client-capability checks,
/// because the alternate channel is responsible for delivering the request to the client. /// because the alternate channel is responsible for delivering the request to the client.
/// </remarks> /// </remarks>
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
public IDisposable InterceptOutgoingRequests(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor) public McpServer WithOutgoingRequestInterceptor(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor)
{ {
Throw.IfNull(interceptor); Throw.IfNull(interceptor);
return new OutgoingRequestInterceptingMcpServer(this, interceptor);
var previous = s_outgoingRequestInterceptor.Value;
s_outgoingRequestInterceptor.Value = interceptor;
return new OutgoingRequestInterceptorScope(previous);
}
private sealed class OutgoingRequestInterceptorScope(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? previous) : IDisposable
{
public void Dispose() => s_outgoingRequestInterceptor.Value = previous;
} }
/// <summary> /// <summary>
@ -119,7 +96,7 @@ public abstract partial class McpServer : McpSession
// redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are
// intentionally skipped because the interceptor's alternate channel is responsible for // intentionally skipped because the interceptor's alternate channel is responsible for
// delivering the request to the client. See SendRequestViaInterceptorAsync remarks. // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
if (CurrentOutgoingRequestInterceptor is { } interceptor) if (OutgoingRequestInterceptor is { } interceptor)
{ {
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams, return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
@ -326,7 +303,7 @@ public abstract partial class McpServer : McpSession
// redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped
// because the interceptor's alternate channel is responsible for delivering the request to // because the interceptor's alternate channel is responsible for delivering the request to
// the client. See SendRequestViaInterceptorAsync remarks. // the client. See SendRequestViaInterceptorAsync remarks.
if (CurrentOutgoingRequestInterceptor is { } interceptor) if (OutgoingRequestInterceptor is { } interceptor)
{ {
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams, return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams,
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
@ -372,7 +349,7 @@ public abstract partial class McpServer : McpSession
// redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are
// intentionally skipped because the interceptor's alternate channel is responsible for // intentionally skipped because the interceptor's alternate channel is responsible for
// delivering the request to the client. See SendRequestViaInterceptorAsync remarks. // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
if (CurrentOutgoingRequestInterceptor is { } interceptor) if (OutgoingRequestInterceptor is { } interceptor)
{ {
var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams); var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams);
var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false); var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false);

View file

@ -72,7 +72,9 @@ public sealed class McpServerHandlers
/// a <see cref="CallToolResult"/> for immediate results or an alternate <see cref="Result"/> subtype. /// a <see cref="CallToolResult"/> for immediate results or an alternate <see cref="Result"/> subtype.
/// </para> /// </para>
/// <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"/>.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception> /// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception>

View file

@ -33,7 +33,6 @@ internal sealed partial class McpServerImpl : McpServer
private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new(); private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new(); private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
private static readonly string[] s_perRequestMetadataKeys = private static readonly string[] s_perRequestMetadataKeys =
[ [
MetaKeys.ProtocolVersion, MetaKeys.ProtocolVersion,
@ -1321,20 +1320,13 @@ internal sealed partial class McpServerImpl : McpServer
var callToolFilters = options.Filters.Request.CallToolFilters; var callToolFilters = options.Filters.Request.CallToolFilters;
var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters; var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters;
// Validate: cannot mix non-alternate filters/handler with alternate filters/handler. if (callToolWithAlternateHandler is not null && callToolFilters.Count > 0)
bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0;
bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0;
if (hasNonAlternatePath && hasAlternatePath)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
$"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + $"Cannot apply {nameof(McpRequestFilters.CallToolFilters)} when an explicit " +
$"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " + $"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler " +
$"These two styles cannot currently be composed on the same server. " + $"replaces the ordinary tool-call pipeline. Move the behavior to " +
$"This most commonly happens when combining features that register different tool-call filter styles, " + $"{nameof(McpRequestFilters.CallToolWithAlternateFilters)} or remove the explicit alternate handler.");
$"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.");
} }
// Handle tools provided via DI by augmenting the list handler. // Handle tools provided via DI by augmenting the list handler.
@ -1376,18 +1368,16 @@ internal sealed partial class McpServerImpl : McpServer
listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);
// Build the unified alternate-result handler from one of the two paths. // An explicit alternate handler replaces the ordinary tool-call pipeline.
if (hasAlternatePath) 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. // Augment with DI tools.
if (tools is not null) if (tools is not null)
{ {
var originalHandler = callToolWithAlternateHandler; var originalHandler = callToolWithAlternateHandler;
callToolWithAlternateHandler = (request, cancellationToken) => callToolWithAlternateHandler = (request, cancellationToken) =>
{ {
MatchTool(request, tools);
if (request.MatchedPrimitive is McpServerTool tool) if (request.MatchedPrimitive is McpServerTool tool)
{ {
return InvokeToolWithAlternate(tool, request, cancellationToken); return InvokeToolWithAlternate(tool, request, cancellationToken);
@ -1397,11 +1387,13 @@ internal sealed partial class McpServerImpl : McpServer
}; };
} }
callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools)); callToolWithAlternateHandler = BuildInvocationFilterPipeline(
callToolWithAlternateHandler,
callToolWithAlternateFilters,
BuildInitialAlternateToolFilter(tools));
} }
else 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)); callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools. // Augment with DI tools.
@ -1419,12 +1411,12 @@ internal sealed partial class McpServerImpl : McpServer
}; };
} }
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters);
// Convert to alternate-based. callToolWithAlternateHandler = BuildComposedCallToolHandler(
var finalCallToolHandler = callToolHandler; callToolHandler,
callToolWithAlternateHandler = async (request, cancellationToken) => callToolWithAlternateFilters,
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); tools);
} }
ServerCapabilities.Tools.ListChanged = listChanged; ServerCapabilities.Tools.ListChanged = listChanged;
@ -1448,58 +1440,79 @@ internal sealed partial class McpServerImpl : McpServer
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
} }
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter( private McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildComposedCallToolHandler(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler => McpRequestHandler<CallToolRequestParams, CallToolResult> callToolHandler,
async (request, cancellationToken) => IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> callToolWithAlternateFilters,
McpServerPrimitiveCollection<McpServerTool>? tools)
{
return async (request, cancellationToken) =>
{ {
if (request.Params?.Name is { } toolName && tools is not null && MatchTool(request, tools);
tools.TryGetPrimitive(toolName, out var tool))
{ var invocation = new ComposedCallToolInvocationState();
request.MatchedPrimitive = tool; var composedHandler = BuildInvocationFilterPipeline(
} InvokeOrdinaryPipelineAsync,
callToolWithAlternateFilters);
try try
{ {
var result = await handler(request, cancellationToken).ConfigureAwait(false); var result = await composedHandler(request, cancellationToken).ConfigureAwait(false);
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true); LogToolCallLifecycles(request, invocation.CompleteOuter(result));
return result; return result;
} }
catch (Exception e) catch (Exception e)
{ {
// Skip logging for InputRequiredException - it's normal MRTR control flow, LogToolCallLifecycles(
// not an error (tools throw it to signal an InputRequiredResult). request,
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) invocation.CompleteOuterException(
{ e,
ToolCallError(request.Params?.Name ?? string.Empty, e); cancellationToken.IsCancellationRequested));
}
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{ {
throw; throw;
} }
return new() return CreateToolCallErrorResult(request, e);
}
async ValueTask<ResultOrAlternate<CallToolResult>> InvokeOrdinaryPipelineAsync(
RequestContext<CallToolRequestParams> ordinaryRequest,
CancellationToken ordinaryCancellationToken)
{
try
{ {
IsError = true, MatchTool(ordinaryRequest, tools);
Content = [new TextContentBlock var result = await callToolHandler(ordinaryRequest, ordinaryCancellationToken).ConfigureAwait(false);
LogToolCallLifecycles(ordinaryRequest, invocation.RecordOrdinaryResult(result));
return result;
}
catch (Exception exception)
{
LogToolCallLifecycles(
ordinaryRequest,
invocation.RecordOrdinaryException(
exception,
ordinaryCancellationToken.IsCancellationRequested));
if ((exception is OperationCanceledException && ordinaryCancellationToken.IsCancellationRequested) ||
exception is McpProtocolException ||
exception is InputRequiredException)
{ {
Text = e is McpException ? throw;
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" : }
$"An error occurred invoking '{request.Params?.Name}'.",
}], return CreateToolCallErrorResult(ordinaryRequest, exception);
}; }
} }
}; };
}
private McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter( private McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler => McpServerPrimitiveCollection<McpServerTool>? tools) =>
async (request, cancellationToken) => async (request, handler, cancellationToken) =>
{ {
if (request.Params?.Name is { } toolName && tools is not null && MatchTool(request, tools);
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
try try
{ {
@ -1525,18 +1538,54 @@ internal sealed partial class McpServerImpl : McpServer
throw; throw;
} }
return new CallToolResult return CreateToolCallErrorResult(request, e);
{
IsError = true,
Content = [new TextContentBlock
{
Text = e is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
} }
}; };
private static void MatchTool(
RequestContext<CallToolRequestParams> request,
McpServerPrimitiveCollection<McpServerTool>? tools)
{
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
}
private static CallToolResult CreateToolCallErrorResult(
RequestContext<CallToolRequestParams> request,
Exception exception) =>
new()
{
IsError = true,
Content = [new TextContentBlock
{
Text = exception is McpException ?
$"An error occurred invoking '{request.Params?.Name}': {exception.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
private void LogToolCallLifecycles(
RequestContext<CallToolRequestParams> request,
IReadOnlyList<ToolCallLifecycle> lifecycles)
{
string toolName = request.Params?.Name ?? string.Empty;
foreach (var lifecycle in lifecycles)
{
if (lifecycle.Result is { } result)
{
ToolCallCompleted(toolName, result.IsError is true);
}
else if (!(lifecycle.Exception is OperationCanceledException && lifecycle.CancellationRequested) &&
lifecycle.Exception is not InputRequiredException)
{
ToolCallError(toolName, lifecycle.Exception!);
}
}
}
#pragma warning restore MCPEXP002 #pragma warning restore MCPEXP002
private void ConfigureLogging(McpServerOptions options) private void ConfigureLogging(McpServerOptions options)
@ -1730,6 +1779,31 @@ internal sealed partial class McpServerImpl : McpServer
return current; return current;
} }
#pragma warning disable MCPEXP002
private static McpRequestHandler<TParams, TResult> BuildInvocationFilterPipeline<TParams, TResult>(
McpRequestHandler<TParams, TResult> baseHandler,
IList<McpRequestInvocationFilter<TParams, TResult>> filters,
McpRequestInvocationFilter<TParams, TResult>? initialHandler = null)
{
var current = baseHandler;
for (int i = filters.Count - 1; i >= 0; i--)
{
var next = current;
var filter = filters[i];
current = (request, cancellationToken) => filter(request, next, cancellationToken);
}
if (initialHandler is not null)
{
var next = current;
current = (request, cancellationToken) => initialHandler(request, next, cancellationToken);
}
return current;
}
#pragma warning restore MCPEXP002
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters) private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters)
{ {
if (filters.Count == 0) if (filters.Count == 0)

View file

@ -0,0 +1,56 @@
using ModelContextProtocol.Protocol;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Server;
#pragma warning disable MCPEXP002
internal sealed class OutgoingRequestInterceptingMcpServer(
McpServer server,
Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor) : McpServer
#pragma warning restore MCPEXP002
{
internal override Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => interceptor;
public override string? SessionId => server.SessionId;
public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion;
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
public override Implementation? ClientInfo => server.ClientInfo;
public override McpServerOptions ServerOptions => server.ServerOptions;
public override IServiceProvider? Services => server.Services;
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
public override bool IsMrtrSupported => server.IsMrtrSupported;
public override ValueTask DisposeAsync() => server.DisposeAsync();
public override IAsyncDisposable RegisterNotificationHandler(
string method,
Func<JsonRpcNotification, CancellationToken, ValueTask> handler) =>
server.RegisterNotificationHandler(method, handler);
public override Task RunAsync(CancellationToken cancellationToken = default) =>
server.RunAsync(cancellationToken);
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) =>
server.SendMessageAsync(message, cancellationToken);
public override async Task<JsonRpcResponse> SendRequestAsync(
JsonRpcRequest request,
CancellationToken cancellationToken = default)
{
Throw.IfNull(request);
return new JsonRpcResponse
{
Id = request.Id,
Result = await interceptor(request.Method, request.Params, cancellationToken).ConfigureAwait(false),
};
}
}

View file

@ -0,0 +1,8 @@
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Server;
internal sealed record ToolCallLifecycle(
CallToolResult? Result,
Exception? Exception,
bool CancellationRequested);

View file

@ -19,6 +19,12 @@ public static class McpTasksBuilderExtensions
/// <summary> /// <summary>
/// Enables MCP Tasks support backed by the specified task store. /// Enables MCP Tasks support backed by the specified task store.
/// </summary> /// </summary>
/// <remarks>
/// Tasks are implemented as an alternate-result call-tool filter. Alternate-result filters registered before
/// the Tasks filter run before task creation. Filters registered after it, along with all ordinary call-tool
/// filters, run in the background before the tool.
/// Register Tasks before configuring ordinary call-tool filters.
/// </remarks>
/// <param name="builder">The server builder.</param> /// <param name="builder">The server builder.</param>
/// <param name="store">The task store.</param> /// <param name="store">The task store.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns> /// <returns>The builder provided in <paramref name="builder"/>.</returns>
@ -35,18 +41,25 @@ public static class McpTasksBuilderExtensions
// Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the
// background task body has somewhere to report failures. It is optional: if no logging is // background task body has somewhere to report failures. It is optional: if no logging is
// registered, the options fall back to NullLoggerFactory. // registered, the options fall back to NullLoggerFactory.
builder.Services.AddSingleton<IPostConfigureOptions<McpServerOptions>>( builder.Services.AddSingleton<IConfigureOptions<McpServerOptions>>(
sp => new McpTasksPostConfigureOptions(store, sp.GetService<ILoggerFactory>())); sp => new McpTasksConfigureOptions(
store,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetService<ILoggerFactory>()));
return builder; return builder;
} }
private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions<McpServerOptions> private sealed class McpTasksConfigureOptions(
IMcpTaskStore store,
IServiceScopeFactory serviceScopeFactory,
ILoggerFactory? loggerFactory) : IConfigureOptions<McpServerOptions>
{ {
private readonly IMcpTaskStore _store = store; private readonly IMcpTaskStore _store = store;
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<McpTasksPostConfigureOptions>(); private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<McpTasksConfigureOptions>();
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationSources = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationSources = new(StringComparer.Ordinal);
public void PostConfigure(string? name, McpServerOptions options) public void Configure(McpServerOptions options)
{ {
#if NET #if NET
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@ -66,124 +79,179 @@ public static class McpTasksBuilderExtensions
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask });
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask });
if (options.Filters.Request.CallToolFilters.Count > 0)
{
throw new InvalidOperationException(
$"{nameof(WithTasks)} must be configured before ordinary call-tool filters because " +
"the Tasks filter must execute outside the ordinary call-tool pipeline.");
}
// Use a filter rather than a handler so it wraps around Core's tool dispatch. // Use a filter rather than a handler so it wraps around Core's tool dispatch.
// This ensures it intercepts tool calls BEFORE the tool is invoked, allowing // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing
// it to spawn background execution and return the task alternate immediately. // it to spawn background execution and return the task alternate immediately.
options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) => options.Filters.Request.CallToolWithAlternateFilters.Insert(
{ options.Filters.Request.CallToolWithAlternateFilters.Count,
if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) async (request, next, cancellationToken) =>
{ {
var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.Params?.Meta))
var taskId = taskInfo.TaskId;
var cts = new CancellationTokenSource();
_cancellationSources[taskId] = cts;
var taskCancellationToken = cts.Token;
_ = Task.Run(async () =>
{ {
try return await next(request, cancellationToken).ConfigureAwait(false);
{ }
using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store))
{
try
{
var augmented = await next(request, taskCancellationToken).ConfigureAwait(false);
if (augmented.IsAlternate) return await RunAsTaskAsync(next, request, cancellationToken).ConfigureAwait(false);
{ });
var error = new JsonRpcErrorDetail }
{
Code = (int)McpErrorCode.InternalError,
Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.",
};
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
return;
}
var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>()); private async ValueTask<ResultOrAlternate<CallToolResult>> RunAsTaskAsync(
await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> next,
} RequestContext<CallToolRequestParams> request,
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) CancellationToken cancellationToken)
{ {
await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); var executionScope = _serviceScopeFactory.CreateAsyncScope();
} var executionRequest = new RequestContext<CallToolRequestParams>(
catch (InputRequiredException) request.Server,
{ request.JsonRpcRequest,
var error = new JsonRpcErrorDetail request.Params)
{ {
Code = (int)McpErrorCode.InvalidRequest, MatchedPrimitive = request.MatchedPrimitive,
Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", Services = executionScope.ServiceProvider,
}; };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (McpProtocolException mcpEx)
{
// SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape.
var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (Exception ex)
{
// Non-protocol exceptions are wrapped as CallToolResult { IsError = true },
// matching Core's BuildInitialAlternateToolFilter behavior.
var errorResult = new CallToolResult
{
IsError = true,
Content = [new TextContentBlock
{
Text = ex is McpException
? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}"
: $"An error occurred invoking '{request.Params?.Name}'.",
}],
};
var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
}
finally
{
if (_cancellationSources.TryRemove(taskId, out var registeredCts))
{
registeredCts.Dispose();
}
}
}
}
catch (Exception outer)
{
// The inner handlers above record every expected outcome. Reaching here means a
// store operation inside one of those handlers (or the task scope) threw, most
// likely from a custom IMcpTaskStore. Record the failure best-effort and never let
// it surface as an unobserved task exception.
_logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId);
try McpTaskInfo taskInfo;
{ try
var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; {
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>()); taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); }
} catch
catch (Exception storeEx) {
{ await executionScope.DisposeAsync().ConfigureAwait(false);
_logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId); throw;
} }
if (_cancellationSources.TryRemove(taskId, out var leftoverCts)) var taskId = taskInfo.TaskId;
{ executionRequest.Server = request.Server.WithMcpTaskOutgoingRequestInterceptor(taskId, _store);
leftoverCts.Dispose(); var cts = new CancellationTokenSource();
} _cancellationSources[taskId] = cts;
}
}, CancellationToken.None);
return ResultOrAlternate<CallToolResult>.FromAlternate( // Capture the token before dispatching. Cancellation can remove and dispose the source
ToCreateTaskResult(taskInfo), // before the background delegate starts.
McpTasksJsonContext.Default.CreateTaskResult); var taskCancellationToken = cts.Token;
_ = Task.Run(
() => ExecuteTaskAsync(next, executionRequest, taskId, taskCancellationToken, executionScope),
CancellationToken.None);
return ResultOrAlternate<CallToolResult>.FromAlternate(
ToCreateTaskResult(taskInfo),
McpTasksJsonContext.Default.CreateTaskResult);
}
private async Task ExecuteTaskAsync(
McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> next,
RequestContext<CallToolRequestParams> request,
string taskId,
CancellationToken taskCancellationToken,
AsyncServiceScope executionScope)
{
try
{
try
{
await ExecuteToolPipelineAsync(next, request, taskId, taskCancellationToken).ConfigureAwait(false);
}
finally
{
await executionScope.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception outer)
{
// Expected outcomes are recorded by ExecuteToolPipelineAsync. Reaching here means a
// store operation, task scope, or service scope failed. Record it best-effort.
_logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId);
try
{
var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (Exception storeEx)
{
_logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId);
}
}
finally
{
if (_cancellationSources.TryRemove(taskId, out var registeredCts))
{
registeredCts.Dispose();
}
}
}
private async Task ExecuteToolPipelineAsync(
McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> next,
RequestContext<CallToolRequestParams> request,
string taskId,
CancellationToken taskCancellationToken)
{
try
{
var augmented = await next(request, taskCancellationToken).ConfigureAwait(false);
if (augmented.IsAlternate)
{
var error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InternalError,
Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.",
};
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
return;
} }
return await next(request, cancellationToken).ConfigureAwait(false); var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
}); await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
}
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
{
await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
}
catch (InputRequiredException)
{
var error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InvalidRequest,
Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.",
};
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (McpProtocolException mcpEx)
{
// SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape.
var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message };
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
}
catch (Exception ex)
{
// Non-protocol exceptions are wrapped as CallToolResult { IsError = true },
// matching Core's BuildInitialCallToolFilter behavior.
var errorResult = new CallToolResult
{
IsError = true,
Content = [new TextContentBlock
{
Text = ex is McpException
? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}"
: $"An error occurred invoking '{request.Params?.Name}'.",
}],
};
var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
}
} }
private async ValueTask<JsonNode?> HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) private async ValueTask<JsonNode?> HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken)

View file

@ -37,14 +37,10 @@ public static class McpTasksServerExtensions
cancellationToken); cancellationToken);
} }
/// <summary> internal static McpServer WithMcpTaskOutgoingRequestInterceptor(
/// Creates a scope that routes server-initiated requests through the specified task store. this McpServer server,
/// </summary> string taskId,
/// <param name="server">The server whose outgoing requests should be redirected.</param> IMcpTaskStore store)
/// <param name="taskId">The related task identifier.</param>
/// <param name="store">The task store used to surface input requests.</param>
/// <returns>An <see cref="IDisposable"/> that restores the previous outgoing-request behavior.</returns>
public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store)
{ {
#if NET #if NET
ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(server);
@ -56,7 +52,7 @@ public static class McpTasksServerExtensions
if (store is null) throw new ArgumentNullException(nameof(store)); if (store is null) throw new ArgumentNullException(nameof(store));
#endif #endif
return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => return server.WithOutgoingRequestInterceptor(async (method, paramsNode, cancellationToken) =>
{ {
var requestId = Guid.NewGuid().ToString("N"); var requestId = Guid.NewGuid().ToString("N");

View file

@ -42,6 +42,10 @@ public static class McpRequestFilterBuilderExtensions
/// <summary> /// <summary>
/// Adds a filter to the call tool handler pipeline. /// Adds a filter to the call tool handler pipeline.
/// </summary> /// </summary>
/// <remarks>
/// This ordinary call-tool filter runs inside all alternate-result call-tool filters. For a task-backed call,
/// it executes in the background after task creation and before the tool.
/// </remarks>
/// <param name="builder">The request filter builder instance.</param> /// <param name="builder">The request filter builder instance.</param>
/// <param name="filter">The filter function that wraps the handler.</param> /// <param name="filter">The filter function that wraps the handler.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns> /// <returns>The builder provided in <paramref name="builder"/>.</returns>

View file

@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore.Tests.Utils;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.Security.Claims;
namespace ModelContextProtocol.AspNetCore.Tests;
public class HttpTaskIntegrationTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper)
{
[Fact]
public async Task WithTasks_CanCallToolOverHttp()
{
Builder.Services
.AddMcpServer()
.WithHttpTransport()
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
.WithTools<TestTools>();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Hello World!", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
}
[Fact]
public async Task WithTasks_AfterOrdinaryFilter_ThrowsActionableError()
{
Builder.Services
.AddMcpServer(options =>
{
options.Filters.Request.CallToolFilters.Add(next => next);
})
.WithHttpTransport()
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
.WithTools<TestTools>();
await using var app = Builder.Build();
var exception = Assert.Throws<InvalidOperationException>(
() => app.Services.GetRequiredService<IOptions<McpServerOptions>>().Value);
Assert.Contains(nameof(McpTasksBuilderExtensions.WithTasks), exception.Message);
Assert.Contains("before ordinary call-tool filters", exception.Message);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WithTasks_AuthorizedTool_Completes(bool registerTasksBeforeAuthorization)
{
var serverBuilder = Builder.Services
.AddMcpServer()
.WithHttpTransport();
if (registerTasksBeforeAuthorization)
{
serverBuilder
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 })
.AddAuthorizationFilters();
}
else
{
serverBuilder
.AddAuthorizationFilters()
.WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 });
}
serverBuilder.WithTools<TestTools>();
Builder.Services.AddAuthorization();
await using var app = Builder.Build();
app.Use(next => async context =>
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "test-user")],
"TestAuthType"));
await next(context);
});
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "authorized-test" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("Authorized", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WithTasks_UnauthorizedTool_DoesNotCreateTask(bool registerTasksBeforeAuthorization)
{
var taskStore = new Mock<IMcpTaskStore>(MockBehavior.Strict);
var serverBuilder = Builder.Services
.AddMcpServer()
.WithHttpTransport();
if (registerTasksBeforeAuthorization)
{
serverBuilder
.WithTasks(taskStore.Object)
.AddAuthorizationFilters();
}
else
{
serverBuilder
.AddAuthorizationFilters()
.WithTasks(taskStore.Object);
}
serverBuilder.WithTools<TestTools>();
Builder.Services.AddAuthorization();
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
await using var transport = new HttpClientTransport(
new HttpClientTransportOptions { Endpoint = new("http://localhost:5000") },
HttpClient,
LoggerFactory);
await using var client = await McpClient.CreateAsync(
transport,
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken);
var exception = await Assert.ThrowsAsync<McpProtocolException>(() =>
client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "authorized-test" },
TestContext.Current.CancellationToken).AsTask());
Assert.Equal(McpErrorCode.InvalidRequest, exception.ErrorCode);
taskStore.Verify(
store => store.CreateTaskAsync(It.IsAny<CancellationToken>()),
Times.Never);
}
[McpServerToolType]
private sealed class TestTools
{
[McpServerTool(Name = "test")]
public static string Test() => "Hello World!";
[McpServerTool(Name = "authorized-test")]
[Authorize]
public static string AuthorizedTest() => "Authorized";
}
}

View file

@ -60,6 +60,7 @@
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" /> <ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" /> <ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" /> <ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\ModelContextProtocol.Extensions.Tasks\ModelContextProtocol.Extensions.Tasks.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" /> <ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
<ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" /> <ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" />
<ProjectReference Include="..\ModelContextProtocol.ConformanceClient\ModelContextProtocol.ConformanceClient.csproj" /> <ProjectReference Include="..\ModelContextProtocol.ConformanceClient\ModelContextProtocol.ConformanceClient.csproj" />

View file

@ -5,9 +5,8 @@ using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests.Server; namespace ModelContextProtocol.Tests.Server;
/// <summary> /// <summary>
/// Verifies that combining the non-alternate <see cref="McpRequestFilters.CallToolFilters"/> with the /// Verifies composition for the non-alternate <see cref="McpRequestFilters.CallToolFilters"/>
/// alternate <see cref="McpRequestFilters.CallToolWithAlternateFilters"/> fails at configuration time /// and alternate <see cref="McpRequestFilters.CallToolWithAlternateFilters"/> pipelines.
/// with an actionable message.
/// </summary> /// </summary>
public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
{ {
@ -15,24 +14,50 @@ public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : Log
private static McpRequestFilter<CallToolRequestParams, CallToolResult> PassThroughCallToolFilter => private static McpRequestFilter<CallToolRequestParams, CallToolResult> PassThroughCallToolFilter =>
next => next; next => next;
private static McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> PassThroughAlternateFilter => private static McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> PassThroughAlternateFilter =>
next => next; static (context, next, cancellationToken) => next(context, cancellationToken);
[Fact] [Fact]
public async Task MixingCallToolFilters_WithAlternateFilters_ThrowsActionableError() public async Task MixingCallToolFilters_WithAlternateFilters_Succeeds()
{
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter);
options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter);
await using var server = McpServer.Create(transport, options, LoggerFactory);
Assert.NotNull(server);
}
[Fact]
public async Task AlternateFilters_AddedAfterOrdinaryFilters_Succeeds()
{ {
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter);
options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter);
await using var server = McpServer.Create(transport, options, LoggerFactory);
Assert.NotNull(server);
}
[Fact]
public async Task CallToolFilters_WithExplicitAlternateHandler_ThrowsActionableError()
{
await using var transport = new StreamServerTransport(Stream.Null, Stream.Null);
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
options.Handlers.CallToolWithAlternateHandler = static (_, _) =>
new(new ResultOrAlternate<CallToolResult>(new CallToolResult()));
options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter);
var ex = Assert.Throws<InvalidOperationException>( var ex = Assert.Throws<InvalidOperationException>(
() => McpServer.Create(transport, options, LoggerFactory)); () => McpServer.Create(transport, options, LoggerFactory));
Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message); Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message);
Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message); Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), ex.Message);
Assert.Contains("AddAuthorizationFilters", ex.Message); Assert.Contains("replaces the ordinary tool-call pipeline", ex.Message);
Assert.Contains("WithTasks", ex.Message);
} }
[Fact] [Fact]

Some files were not shown because too many files have changed in this diff Show more