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
15 changed files with 1164 additions and 286 deletions

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>
/// 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);
@ -30,8 +31,13 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
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);
@ -81,7 +87,8 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
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);
if (!authResult.Succeeded)
@ -93,20 +100,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
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)
@ -374,7 +368,7 @@ internal sealed class AuthorizationFilterSetup(IAuthorizationPolicyProvider? pol
: 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.

View file

@ -34,6 +34,7 @@ public static class HttpMcpServerBuilderExtensions
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)
@ -55,14 +56,17 @@ public static class HttpMcpServerBuilderExtensions
/// <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>());
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>
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>
@ -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.
/// </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>
@ -71,14 +77,15 @@ public sealed class McpRequestFilters
/// 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

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;
/// <summary>
/// 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();
internal virtual Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? OutgoingRequestInterceptor => null;
/// <summary>
/// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any.
/// </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.
/// Creates a non-mutating server facade that redirects server-initiated requests through an interceptor.
/// </summary>
/// <param name="interceptor">
/// 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
/// returns the serialized result (or <see langword="null"/> to indicate no result).
/// </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>
/// <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.
/// </remarks>
[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);
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;
return new OutgoingRequestInterceptingMcpServer(this, interceptor);
}
/// <summary>
@ -119,7 +96,7 @@ public abstract partial class McpServer : McpSession
// redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are
// intentionally skipped because the interceptor's alternate channel is responsible for
// delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
if (CurrentOutgoingRequestInterceptor is { } interceptor)
if (OutgoingRequestInterceptor is { } interceptor)
{
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
@ -326,7 +303,7 @@ public abstract partial class McpServer : McpSession
// redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped
// because the interceptor's alternate channel is responsible for delivering the request to
// the client. See SendRequestViaInterceptorAsync remarks.
if (CurrentOutgoingRequestInterceptor is { } interceptor)
if (OutgoingRequestInterceptor is { } interceptor)
{
return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams,
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
@ -372,7 +349,7 @@ public abstract partial class McpServer : McpSession
// redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are
// intentionally skipped because the interceptor's alternate channel is responsible for
// 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 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.
/// </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>
/// </remarks>
/// <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 ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
private static readonly string[] s_perRequestMetadataKeys =
[
MetaKeys.ProtocolVersion,
@ -1321,20 +1320,13 @@ internal sealed partial class McpServerImpl : McpServer
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 (hasNonAlternatePath && hasAlternatePath)
if (callToolWithAlternateHandler is not null && callToolFilters.Count > 0)
{
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 {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.");
}
// 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);
// 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)
{
var originalHandler = callToolWithAlternateHandler;
callToolWithAlternateHandler = (request, cancellationToken) =>
{
MatchTool(request, tools);
if (request.MatchedPrimitive is McpServerTool tool)
{
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
{
// 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.
@ -1419,12 +1411,12 @@ internal sealed partial class McpServerImpl : McpServer
};
}
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters);
// Convert to alternate-based.
var finalCallToolHandler = callToolHandler;
callToolWithAlternateHandler = async (request, cancellationToken) =>
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
callToolWithAlternateHandler = BuildComposedCallToolHandler(
callToolHandler,
callToolWithAlternateFilters,
tools);
}
ServerCapabilities.Tools.ListChanged = listChanged;
@ -1448,58 +1440,79 @@ internal sealed partial class McpServerImpl : McpServer
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
}
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
async (request, cancellationToken) =>
private McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildComposedCallToolHandler(
McpRequestHandler<CallToolRequestParams, CallToolResult> callToolHandler,
IList<McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> callToolWithAlternateFilters,
McpServerPrimitiveCollection<McpServerTool>? tools)
{
return async (request, cancellationToken) =>
{
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
MatchTool(request, tools);
var invocation = new ComposedCallToolInvocationState();
var composedHandler = BuildInvocationFilterPipeline(
InvokeOrdinaryPipelineAsync,
callToolWithAlternateFilters);
try
{
var result = await handler(request, cancellationToken).ConfigureAwait(false);
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
var result = await composedHandler(request, cancellationToken).ConfigureAwait(false);
LogToolCallLifecycles(request, invocation.CompleteOuter(result));
return result;
}
catch (Exception e)
{
// Skip logging for InputRequiredException - it's normal MRTR control flow,
// not an error (tools throw it to signal an InputRequiredResult).
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
}
LogToolCallLifecycles(
request,
invocation.CompleteOuterException(
e,
cancellationToken.IsCancellationRequested));
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{
throw;
}
return new()
return CreateToolCallErrorResult(request, e);
}
async ValueTask<ResultOrAlternate<CallToolResult>> InvokeOrdinaryPipelineAsync(
RequestContext<CallToolRequestParams> ordinaryRequest,
CancellationToken ordinaryCancellationToken)
{
try
{
IsError = true,
Content = [new TextContentBlock
MatchTool(ordinaryRequest, tools);
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 ?
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
$"An error occurred invoking '{request.Params?.Name}'.",
}],
};
throw;
}
return CreateToolCallErrorResult(ordinaryRequest, exception);
}
}
};
}
private McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
async (request, cancellationToken) =>
private McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
McpServerPrimitiveCollection<McpServerTool>? tools) =>
async (request, handler, cancellationToken) =>
{
if (request.Params?.Name is { } toolName && tools is not null &&
tools.TryGetPrimitive(toolName, out var tool))
{
request.MatchedPrimitive = tool;
}
MatchTool(request, tools);
try
{
@ -1525,18 +1538,54 @@ internal sealed partial class McpServerImpl : McpServer
throw;
}
return new CallToolResult
{
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}'.",
}],
};
return CreateToolCallErrorResult(request, e);
}
};
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
private void ConfigureLogging(McpServerOptions options)
@ -1730,6 +1779,31 @@ internal sealed partial class McpServerImpl : McpServer
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)
{
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>
/// Enables MCP Tasks support backed by the specified task store.
/// </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="store">The task store.</param>
/// <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
// background task body has somewhere to report failures. It is optional: if no logging is
// registered, the options fall back to NullLoggerFactory.
builder.Services.AddSingleton<IPostConfigureOptions<McpServerOptions>>(
sp => new McpTasksPostConfigureOptions(store, sp.GetService<ILoggerFactory>()));
builder.Services.AddSingleton<IConfigureOptions<McpServerOptions>>(
sp => new McpTasksConfigureOptions(
store,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetService<ILoggerFactory>()));
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 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);
public void PostConfigure(string? name, McpServerOptions options)
public void Configure(McpServerOptions options)
{
#if NET
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.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.
// This ensures it intercepts tool calls BEFORE the tool is invoked, allowing
// it to spawn background execution and return the task alternate immediately.
options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) =>
{
if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta))
options.Filters.Request.CallToolWithAlternateFilters.Insert(
options.Filters.Request.CallToolWithAlternateFilters.Count,
async (request, next, cancellationToken) =>
{
var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
var taskId = taskInfo.TaskId;
var cts = new CancellationTokenSource();
_cancellationSources[taskId] = cts;
var taskCancellationToken = cts.Token;
_ = Task.Run(async () =>
if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.Params?.Meta))
{
try
{
using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store))
{
try
{
var augmented = await next(request, taskCancellationToken).ConfigureAwait(false);
return await next(request, cancellationToken).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 RunAsTaskAsync(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 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);
private async ValueTask<ResultOrAlternate<CallToolResult>> RunAsTaskAsync(
McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>> next,
RequestContext<CallToolRequestParams> request,
CancellationToken cancellationToken)
{
var executionScope = _serviceScopeFactory.CreateAsyncScope();
var executionRequest = new RequestContext<CallToolRequestParams>(
request.Server,
request.JsonRpcRequest,
request.Params)
{
MatchedPrimitive = request.MatchedPrimitive,
Services = executionScope.ServiceProvider,
};
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);
}
McpTaskInfo taskInfo;
try
{
taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
}
catch
{
await executionScope.DisposeAsync().ConfigureAwait(false);
throw;
}
if (_cancellationSources.TryRemove(taskId, out var leftoverCts))
{
leftoverCts.Dispose();
}
}
}, CancellationToken.None);
var taskId = taskInfo.TaskId;
executionRequest.Server = request.Server.WithMcpTaskOutgoingRequestInterceptor(taskId, _store);
var cts = new CancellationTokenSource();
_cancellationSources[taskId] = cts;
return ResultOrAlternate<CallToolResult>.FromAlternate(
ToCreateTaskResult(taskInfo),
McpTasksJsonContext.Default.CreateTaskResult);
// Capture the token before dispatching. Cancellation can remove and dispose the source
// before the background delegate starts.
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)

View file

@ -37,14 +37,10 @@ public static class McpTasksServerExtensions
cancellationToken);
}
/// <summary>
/// Creates a scope that routes server-initiated requests through the specified task store.
/// </summary>
/// <param name="server">The server whose outgoing requests should be redirected.</param>
/// <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)
internal static McpServer WithMcpTaskOutgoingRequestInterceptor(
this McpServer server,
string taskId,
IMcpTaskStore store)
{
#if NET
ArgumentNullException.ThrowIfNull(server);
@ -56,7 +52,7 @@ public static class McpTasksServerExtensions
if (store is null) throw new ArgumentNullException(nameof(store));
#endif
return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) =>
return server.WithOutgoingRequestInterceptor(async (method, paramsNode, cancellationToken) =>
{
var requestId = Guid.NewGuid().ToString("N");

View file

@ -42,6 +42,10 @@ public static class McpRequestFilterBuilderExtensions
/// <summary>
/// Adds a filter to the call tool handler pipeline.
/// </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="filter">The filter function that wraps the handler.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>