Fix HTTP task filter composition (#1722)
This commit is contained in:
parent
ef37c2156e
commit
a5179df39b
21 changed files with 1164 additions and 286 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
src/Directory.Build.targets
Normal file
13
src/Directory.Build.targets
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Server;
|
||||||
|
|
||||||
|
internal sealed record ToolCallLifecycle(
|
||||||
|
CallToolResult? Result,
|
||||||
|
Exception? Exception,
|
||||||
|
bool CancellationRequested);
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue