diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 896cea6ce..676005a49 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -30,6 +30,37 @@ The following request filter methods are available on `IMcpRequestFilterBuilder` - `AddUnsubscribeFromResourcesFilter` - Filter for resource unsubscription handlers - `AddSetLoggingLevelFilter` - Filter for logging level handlers +### Alternate-Result Filters + +Extension packages can augment any typed server method with a filter that returns either the method's +ordinary result or an alternate `Result` subtype. Register the filter by case-sensitive method name on +`McpServerOptions`: + +```csharp +services.Configure(options => + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + next => async (context, cancellationToken) => + { + if (ShouldDefer(context.Params)) + { + return ResultOrAlternate.FromAlternate( + CreateDeferredResult(), + MyJsonContext.Default.DeferredResult); + } + + return await next(context, cancellationToken); + })); +``` + +The parameter and ordinary result types must exactly match the configured typed handler for the method. +The server reports unknown methods and type mismatches when it starts. Multiple filters for one method +run in registration order, with the first registered filter outermost. The ordinary request-specific +filters and handler execute inside the alternate-result filters. + +An explicit `CallToolWithAlternateHandler` remains a low-level replacement for `tools/call`; it cannot +be combined with ordinary call-tool filters or a method-keyed alternate-result filter for that method. + ## Message Filters In addition to the request-specific filters above, there are low-level message filters that intercept all JSON-RPC messages before they are routed to specific handlers. diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 5342946bc..6220a83f4 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -79,6 +79,16 @@ When tasks are enabled with `WithTasks` the SDK automatically: - Plumbs a `CancellationToken` through to the tool that fires when the client invokes `tasks/cancel`, so cancellation propagates cooperatively. +The task wrapper runs outside the ordinary `tools/call` pipeline. For an opted-in call, the store creates +the task record first and the ordinary request filters then run exactly once during background execution, +with the matched tool available in `RequestContext.MatchedPrimitive`. Authorization and validation filters +therefore still run before the tool body. If authorization fails, the task transitions to `failed` and the +tool is not invoked; the task record already exists when that denial occurs. + +Background execution receives a new asynchronous dependency-injection scope before the initiating request +completes. Scoped filter services and tool dependencies therefore remain available after an HTTP request scope +has been disposed. The execution scope is disposed after the task reaches its recorded outcome. + For production scenarios that need durability, session isolation, multi-process routing, or TTL-based cleanup, implement yourself (see [Implementing a custom task store](#implementing-a-custom-task-store) below). @@ -111,15 +121,16 @@ options.Handlers.CallToolWithAlternateHandler = async (context, ct) => PollIntervalMs = 1000, }; - return new ResultOrAlternate(created, McpTasksJsonContext.Default.CreateTaskResult); + return ResultOrAlternate.FromAlternate( + created, + McpTasksJsonContext.Default.CreateTaskResult); }; ``` > This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the -> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also -> returns an alternate. Use one mechanism or the other. When you return a task this way you are also -> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides -> automatically. + > SDK does the wrapping for you and rejects an explicit alternate handler when the server starts. + > Use one mechanism or the other. When you return a task this way you are also responsible for serving + > `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides automatically. > > and diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index cb8c9db23..1631ec71d 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -43,10 +43,15 @@ public IList> ListTool /// requests. The handler should implement logic to execute the requested tool and return appropriate results. /// /// - /// Cannot be used together with . If both are non-empty at configuration time, - /// an 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 ). + /// These filters run inside and any method-keyed alternate-result filters + /// registered through . + /// 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. + /// + /// + /// These filters cannot be used with an explicit , which + /// replaces the ordinary tool-call pipeline rather than augmenting it. /// /// public IList> CallToolFilters @@ -71,10 +76,9 @@ public IList> CallToolFi /// subtype for asynchronous execution. /// /// - /// Cannot be used together with . If both are non-empty at configuration time, - /// an 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 ). + /// These filters compose outside . The ordinary pipeline, including primitive matching + /// and ordinary filters, is adapted to before these filters are applied. + /// The first alternate-result filter added is the outermost filter. /// /// [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 7e655603a..e33c836e8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -72,7 +72,10 @@ public McpRequestHandler? CallToolHandler /// a for immediate results or an alternate subtype. /// /// - /// Cannot be set if is already set. + /// This is a low-level full replacement for the ordinary tool-call pipeline. It cannot be set if + /// is already set, and it cannot be composed with ordinary + /// or a method-keyed alternate-result filter for + /// . /// /// /// is already set. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8b38421c4..3a97a45f0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -26,6 +26,7 @@ internal sealed partial class McpServerImpl : McpServer private readonly List _disposables = []; private readonly NotificationHandlers _notificationHandlers; private readonly RequestHandlers _requestHandlers; + private readonly HashSet _configuredAlternateResultFilterMethods = new(StringComparer.Ordinal); private readonly McpSessionHandler _sessionHandler; private readonly string[] _supportedProtocolVersions; private readonly string[] _initializeHandshakeProtocolVersions; @@ -108,6 +109,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); + ValidateAlternateResultFilters(options); ConfigureMrtr(); ConfigureCustomRequestHandlers(options); @@ -996,6 +998,19 @@ private void ConfigureExperimentalAndExtensions(McpServerOptions options) ServerCapabilities.Extensions = options.Capabilities?.Extensions; } + private void ValidateAlternateResultFilters(McpServerOptions options) + { + foreach (var method in options.AlternateResultFilterMethods) + { + if (!_configuredAlternateResultFilterMethods.Contains(method)) + { + throw new InvalidOperationException( + $"An alternate-result filter was registered for method '{method}', but the server has no matching typed handler. " + + $"Configure the built-in handler and capability for that method, and ensure the registered parameter and result types match it."); + } + } + } + private void ConfigureCustomRequestHandlers(McpServerOptions options) { #pragma warning disable MCPEXP002 @@ -1321,20 +1336,21 @@ private void ConfigureTools(McpServerOptions options) var callToolFilters = options.Filters.Request.CallToolFilters; var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters; - // Validate: cannot mix non-alternate filters/handler with alternate filters/handler. - bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0; - bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0; + if (callToolWithAlternateHandler is not null && callToolFilters.Count > 0) + { + throw new InvalidOperationException( + $"Cannot apply {nameof(McpRequestFilters.CallToolFilters)} when an explicit " + + $"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler replaces " + + $"the ordinary tool-call pipeline. Move the behavior to {nameof(McpRequestFilters.CallToolWithAlternateFilters)} " + + $"or remove the explicit alternate handler."); + } - if (hasNonAlternatePath && hasAlternatePath) + if (callToolWithAlternateHandler is not null && options.HasAlternateResultFilters(RequestMethods.ToolsCall)) { throw new InvalidOperationException( - $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + - $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " + - $"These two styles cannot currently be composed on the same server. " + - $"This most commonly happens when combining features that register different tool-call filter styles, " + - $"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " + - $"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " + - $"Configure only one style, or avoid combining features that require different styles."); + $"Cannot apply an alternate-result filter registered for '{RequestMethods.ToolsCall}' when an explicit " + + $"{nameof(McpServerHandlers.CallToolWithAlternateHandler)} is configured. The alternate handler replaces " + + $"the ordinary tool-call pipeline. Remove the explicit alternate handler or the method-keyed filter."); } // Handle tools provided via DI by augmenting the list handler. @@ -1376,12 +1392,9 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // Build the unified alternate-result handler from one of the two paths. - if (hasAlternatePath) + // An explicit alternate handler replaces the ordinary tool-call pipeline. + if (callToolWithAlternateHandler is not null) { - // Case 2: alternate filter + alternate handler - callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); - // Augment with DI tools. if (tools is not null) { @@ -1398,10 +1411,15 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools)); + + SetWithAlternateHandler( + RequestMethods.ToolsCall, + callToolWithAlternateHandler, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult); } else { - // Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. @@ -1421,10 +1439,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); - // Convert to alternate-based. - var finalCallToolHandler = callToolHandler; - callToolWithAlternateHandler = async (request, cancellationToken) => - await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); + SetHandler( + RequestMethods.ToolsCall, + callToolHandler, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + callToolWithAlternateFilters); } ServerCapabilities.Tools.ListChanged = listChanged; @@ -1434,11 +1454,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetWithAlternateHandler( - RequestMethods.ToolsCall, - callToolWithAlternateHandler, - McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult); } private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, @@ -1635,11 +1650,14 @@ private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest js return server; } +#pragma warning disable MCPEXP002 // SetHandler and SetWithAlternateHandler wrap the experimental ResultOrAlternate seam private void SetHandler( string method, McpRequestHandler handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo) + JsonTypeInfo responseTypeInfo, + IList>>? alternateResultFilters = null) + where TResult : Result { // SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, // resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. @@ -1662,19 +1680,38 @@ private void SetHandler( }; } - if (typeof(Result).IsAssignableFrom(typeof(TResult))) + var resultHandler = handler; + handler = async (request, cancellationToken) => { - var innerHandler = handler; - handler = async (request, cancellationToken) => + var result = await resultHandler(request, cancellationToken).ConfigureAwait(false); + if (result.ResultType is null) { - var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (result is Result protocolResult && protocolResult.ResultType is null) - { - protocolResult.ResultType = "complete"; - } + result.ResultType = "complete"; + } - return result; - }; + return result; + }; + + var registeredAlternateResultFilters = ServerOptions.GetAlternateResultFilters(method); + if (registeredAlternateResultFilters.Count > 0 || alternateResultFilters is { Count: > 0 }) + { + if (registeredAlternateResultFilters.Count > 0) + { + _configuredAlternateResultFilterMethods.Add(method); + } + + var ordinaryHandler = handler; + McpRequestHandler> alternateHandler = async (request, cancellationToken) => + await ordinaryHandler(request, cancellationToken).ConfigureAwait(false); + + if (alternateResultFilters is not null) + { + alternateHandler = BuildFilterPipeline(alternateHandler, alternateResultFilters); + } + + alternateHandler = BuildFilterPipeline(alternateHandler, registeredAlternateResultFilters); + SetWithAlternateHandler(method, alternateHandler, requestTypeInfo, responseTypeInfo); + return; } _requestHandlers.Set(method, @@ -1683,7 +1720,6 @@ private void SetHandler( requestTypeInfo, responseTypeInfo); } -#pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam private void SetWithAlternateHandler( string method, McpRequestHandler> handler, @@ -1695,9 +1731,15 @@ private void SetWithAlternateHandler( handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult) + if (!result.IsAlternate && result.Result is { } immediateResult) { - immediateResult.ResultType = "complete"; + if (immediateResult is ICacheableResult cacheable) + { + cacheable.TimeToLive ??= TimeSpan.Zero; + cacheable.CacheScope ??= CacheScope.Private; + } + + immediateResult.ResultType ??= "complete"; } return result; diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index a84bd600e..23f238fad 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -10,6 +10,8 @@ namespace ModelContextProtocol.Server; /// public sealed class McpServerOptions { + private readonly List _alternateResultFilters = []; + /// /// Gets or sets information about this server implementation, including its name and version. /// @@ -219,4 +221,87 @@ public McpServerFilters Filters /// [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public IList? RequestHandlers { get; set; } + + /// + /// Adds a typed filter that may return an alternate result for an existing server request handler. + /// + /// The request parameter type handled by the method. + /// The method's standard result type. + /// The case-sensitive JSON-RPC method name to augment. + /// The alternate-result filter to add. + /// + /// + /// The method must identify a typed handler configured by the server, and the supplied parameter and result + /// types must exactly match that handler. Invalid method names and type mismatches are reported when the server + /// is created. + /// + /// + /// Filters are applied in registration order. The first filter registered for a method is the outermost filter + /// and executes first. The ordinary typed handler pipeline executes inside these filters. + /// + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public void AddAlternateResultFilter( + string method, + McpRequestFilter> filter) + where TResult : Result + { + Throw.IfNullOrWhiteSpace(method); + Throw.IfNull(filter); + + _alternateResultFilters.Add(new AlternateResultFilterRegistration(method, filter)); + } + + internal bool HasAlternateResultFilters(string method) => + _alternateResultFilters.Exists(registration => string.Equals(registration.Method, method, StringComparison.Ordinal)); + + internal IList>> GetAlternateResultFilters(string method) + where TResult : Result + { + List>>? filters = null; + + foreach (var registration in _alternateResultFilters) + { + if (!string.Equals(registration.Method, method, StringComparison.Ordinal)) + { + continue; + } + + if (registration is not AlternateResultFilterRegistration typedRegistration) + { + throw new InvalidOperationException( + $"An alternate-result filter for method '{method}' was registered for " + + $"'{registration.ParamsType.FullName}' and '{registration.ResultType.FullName}', but the server handler uses " + + $"'{typeof(TParams).FullName}' and '{typeof(TResult).FullName}'."); + } + + (filters ??= []).Add(typedRegistration.Filter); + } + + return filters ?? []; + } + + internal IEnumerable AlternateResultFilterMethods => + _alternateResultFilters.Select(registration => registration.Method).Distinct(StringComparer.Ordinal); + + private abstract class AlternateResultFilterRegistration(string method) + { + public string Method { get; } = method; + + public abstract Type ParamsType { get; } + + public abstract Type ResultType { get; } + } + + private sealed class AlternateResultFilterRegistration( + string method, + McpRequestFilter> filter) : AlternateResultFilterRegistration(method) + where TResult : Result + { + public McpRequestFilter> Filter { get; } = filter; + + public override Type ParamsType => typeof(TParams); + + public override Type ResultType => typeof(TResult); + } } diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index 06073ce17..84a5f5c93 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -36,13 +36,20 @@ public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTa // 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>( - sp => new McpTasksPostConfigureOptions(store, sp.GetService())); + sp => new McpTasksPostConfigureOptions( + store, + sp.GetRequiredService(), + sp.GetService())); return builder; } - private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions + private sealed class McpTasksPostConfigureOptions( + IMcpTaskStore store, + IServiceScopeFactory serviceScopeFactory, + ILoggerFactory? loggerFactory) : IPostConfigureOptions { private readonly IMcpTaskStore _store = store; + private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); @@ -66,124 +73,161 @@ public void PostConfigure(string? name, McpServerOptions options) options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask }); - // 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) => + // Augment Core's typed tool handler at the outer boundary. This lets the extension + // return the task alternate immediately while the ordinary filters and tool run in the background. + options.AddAlternateResultFilter( + RequestMethods.ToolsCall, + WrapCallToolWithTasks); + } + + private McpRequestHandler> WrapCallToolWithTasks( + McpRequestHandler> next) => + async (request, cancellationToken) => + { + if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) || !HasTaskExtensionOptIn(request.Params?.Meta)) + { + return await next(request, cancellationToken).ConfigureAwait(false); + } + + var executionScope = _serviceScopeFactory.CreateAsyncScope(); + request.Services = executionScope.ServiceProvider; + + McpTaskInfo taskInfo; + try + { + taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await executionScope.DisposeAsync().ConfigureAwait(false); + throw; + } + + var taskCancellationSource = new CancellationTokenSource(); + _cancellationSources[taskInfo.TaskId] = taskCancellationSource; + _ = Task.Run( + () => RunToolAsTaskAsync(next, request, taskInfo.TaskId, taskCancellationSource, executionScope), + CancellationToken.None); + + return ResultOrAlternate.FromAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); + }; + + private async Task RunToolAsTaskAsync( + McpRequestHandler> next, + RequestContext request, + string taskId, + CancellationTokenSource taskCancellationSource, + AsyncServiceScope executionScope) + { + try + { + try + { + using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) + { + await ExecuteToolPipelineAsync(next, request, taskId, taskCancellationSource.Token).ConfigureAwait(false); + } + } + finally + { + await executionScope.DisposeAsync().ConfigureAwait(false); + } + } + catch (Exception outer) + { + // Expected outcomes are recorded by ExecuteToolPipelineAsync. Reaching here usually + // means a custom store operation or scope disposal 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()); + 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 (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + if (_cancellationSources.TryRemove(taskId, out var registeredCancellationSource)) { - var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); - var taskId = taskInfo.TaskId; - var cts = new CancellationTokenSource(); - _cancellationSources[taskId] = cts; - var taskCancellationToken = cts.Token; + registeredCancellationSource.Dispose(); + } + else + { + taskCancellationSource.Dispose(); + } + } + } + + private async Task ExecuteToolPipelineAsync( + McpRequestHandler> next, + RequestContext request, + string taskId, + CancellationToken taskCancellationToken) + { + try + { + var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); - _ = Task.Run(async () => + if (augmented.IsAlternate) + { + var error = new JsonRpcErrorDetail { - try - { - using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) - { - 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()); - await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - return; - } - - var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - 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()); - 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()); - 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()); - 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 - { - var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - } - catch (Exception storeEx) - { - _logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId); - } - - if (_cancellationSources.TryRemove(taskId, out var leftoverCts)) - { - leftoverCts.Dispose(); - } - } - }, CancellationToken.None); - - return ResultOrAlternate.FromAlternate( - ToCreateTaskResult(taskInfo), - McpTasksJsonContext.Default.CreateTaskResult); + Code = (int)McpErrorCode.InternalError, + Message = $"{nameof(IMcpTaskStore)} is configured and an inner alternate-result filter returned IsAlternate = true. Use only one alternate-result mechanism per request.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + return; } - return await next(request, cancellationToken).ConfigureAwait(false); - }); + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + 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()); + 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()); + 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()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } } private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs index 76a7201d8..afbaa62b2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/AuthorizeAttributeTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.AspNetCore.Tests.Utils; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; @@ -93,6 +94,47 @@ public async Task Authorize_Tool_AllowsAuthenticatedUser() Assert.Equal("Authorized: test", content.Text); } + [Fact] + public async Task Authorize_ToolAsTask_DeniesBeforeToolExecution() + { + var taskStore = new TrackingTaskStore { DefaultPollIntervalMs = 10 }; + TaskAuthorizationTestTools.InvocationCount = 0; + await using var app = await StartServerWithAuth(builder => builder + .WithTools() + .WithTasks(taskStore)); + + await using var client = await ConnectAsync(); + + var exception = await Assert.ThrowsAsync(async () => + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "authorized_task_tool" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("Access forbidden: This tool requires authorization.", exception.Message); + Assert.Equal(1, taskStore.CreateCount); + Assert.Equal(0, TaskAuthorizationTestTools.InvocationCount); + } + + [Fact] + public async Task Authorize_ToolAsTask_AllowsAuthenticatedTaskToComplete() + { + var taskStore = new TrackingTaskStore { DefaultPollIntervalMs = 10 }; + TaskAuthorizationTestTools.InvocationCount = 0; + await using var app = await StartServerWithAuth(builder => builder + .WithTools() + .WithTasks(taskStore), "TestUser"); + + await using var client = await ConnectAsync(); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "authorized_task_tool" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("authorized task result", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, taskStore.CreateCount); + Assert.Equal(1, TaskAuthorizationTestTools.InvocationCount); + } + [Fact] public async Task AuthorizeWithRoles_Tool_RequiresAdminRole() { @@ -541,6 +583,31 @@ private ClaimsPrincipal CreateUser(string name, params string[] roles) [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name), .. roles.Select(role => new Claim("role", role))], "TestAuthType", "name", "role")); + private sealed class TrackingTaskStore : InMemoryMcpTaskStore, IMcpTaskStore + { + public int CreateCount { get; private set; } + + Task IMcpTaskStore.CreateTaskAsync(CancellationToken cancellationToken) + { + CreateCount++; + return base.CreateTaskAsync(cancellationToken); + } + } + + [McpServerToolType] + private sealed class TaskAuthorizationTestTools + { + public static int InvocationCount; + + [McpServerTool(Name = "authorized_task_tool")] + [Authorize] + public static string AuthorizedTaskTool() + { + Interlocked.Increment(ref InvocationCount); + return "authorized task result"; + } + } + [McpServerToolType] private class AuthorizationTestTools { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index d75877bab..781aa7178 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -60,6 +60,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs new file mode 100644 index 000000000..25cdc676c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs @@ -0,0 +1,109 @@ +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Server; + +#pragma warning disable MCPEXP002 // exercises the experimental alternate-result filter seam +public class AlternateResultFilterTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +{ + private readonly List _invocations = []; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) => + mcpServerBuilder.Services.Configure(options => + { + options.Handlers.GetPromptHandler = (request, cancellationToken) => + { + _invocations.Add("handler"); + return new ValueTask(new GetPromptResult + { + Description = "normal result", + }); + }; + + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + next => async (request, cancellationToken) => + { + _invocations.Add("outer-before"); + var result = await next(request, cancellationToken); + _invocations.Add("outer-after"); + return result; + }); + + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + next => async (request, cancellationToken) => + { + _invocations.Add("filter"); + if (request.Params?.Name == "as-task") + { + return ResultOrAlternate.FromAlternate( + new CreateTaskResult + { + TaskId = "prompt-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UnixEpoch, + LastUpdatedAt = DateTimeOffset.UnixEpoch, + }, + McpTasksJsonContext.Default.CreateTaskResult); + } + + return await next(request, cancellationToken); + }); + + options.AddAlternateResultFilter( + RequestMethods.PromptsList, + next => (request, cancellationToken) => + new ValueTask>(new ListPromptsResult())); + }); + + [Fact] + public async Task AlternateResultFilter_OnPromptHandler_CanReturnAlternateResult() + { + await using var client = await CreateMcpClientForServer(); + var serializerOptions = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions); + serializerOptions.TypeInfoResolverChain.Add(McpTasksJsonContext.Default); + + var result = await client.SendRequestAsync( + RequestMethods.PromptsGet, + new GetPromptRequestParams { Name = "as-task" }, + serializerOptions: serializerOptions, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("prompt-task", result.TaskId); + Assert.Equal(["outer-before", "filter", "outer-after"], _invocations); + } + + [Fact] + public async Task AlternateResultFilter_OnPromptHandler_CanInvokeNormalPipeline() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.GetPromptAsync( + "normal", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("normal result", result.Description); + Assert.Equal(["outer-before", "filter", "handler", "outer-after"], _invocations); + } + + [Fact] + public async Task AlternateResultFilter_NormalizesCacheableImmediateResult() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.SendRequestAsync( + RequestMethods.PromptsList, + new ListPromptsRequestParams(), + serializerOptions: McpJsonUtilities.DefaultOptions, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(TimeSpan.Zero, result.TimeToLive); + Assert.Equal(CacheScope.Private, result.CacheScope); + Assert.Equal("complete", result.ResultType); + } +} +#pragma warning restore MCPEXP002 \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterValidationTests.cs new file mode 100644 index 000000000..aecd3ac61 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterValidationTests.cs @@ -0,0 +1,79 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +#pragma warning disable MCPEXP002 // exercises the experimental alternate-result filter seam +public class AlternateResultFilterValidationTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + private static McpRequestFilter> PassThroughAlternateFilter => + next => next; + + [Fact] + public async Task AlternateResultFilter_ForUnknownMethod_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions(); + options.AddAlternateResultFilter("unknown/method", PassThroughAlternateFilter); + + var exception = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("unknown/method", exception.Message); + Assert.Contains("no matching typed handler", exception.Message); + } + + [Fact] + public async Task AlternateResultFilter_WithMismatchedTypes_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + Capabilities = new() { Prompts = new() }, + }; + options.AddAlternateResultFilter(RequestMethods.PromptsGet, PassThroughAlternateFilter); + + var exception = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(RequestMethods.PromptsGet, exception.Message); + Assert.Contains(nameof(CallToolRequestParams), exception.Message); + Assert.Contains(nameof(GetPromptRequestParams), exception.Message); + } + + [Fact] + public async Task ExplicitAlternateHandler_WithMethodKeyedFilter_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Handlers.CallToolWithAlternateHandler = static (request, cancellationToken) => + new ValueTask>(new CallToolResult()); + options.AddAlternateResultFilter(RequestMethods.ToolsCall, PassThroughAlternateFilter); + + var exception = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), exception.Message); + Assert.Contains(RequestMethods.ToolsCall, exception.Message); + Assert.Contains("replaces", exception.Message); + } + + [Fact] + public async Task ExplicitAlternateHandler_WithOrdinaryFilter_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Handlers.CallToolWithAlternateHandler = static (request, cancellationToken) => + new ValueTask>(new CallToolResult()); + options.Filters.Request.CallToolFilters.Add(next => next); + + var exception = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), exception.Message); + Assert.Contains(nameof(McpRequestFilters.CallToolFilters), exception.Message); + Assert.Contains("replaces", exception.Message); + } +} +#pragma warning restore MCPEXP002 \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs index 3c9b8bb9d..68fcf1b73 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -1,38 +1,67 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that combining the non-alternate with the -/// alternate fails at configuration time -/// with an actionable message. +/// Verifies composition of the non-alternate with the +/// alternate . /// -public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) { #pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam + private readonly List _invocations = []; + private static McpRequestFilter PassThroughCallToolFilter => next => next; private static McpRequestFilter> PassThroughAlternateFilter => next => next; + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) => + mcpServerBuilder.Services.Configure(options => + { + options.Handlers.CallToolHandler = (request, cancellationToken) => + { + _invocations.Add("handler"); + return new ValueTask(new CallToolResult + { + Content = [new TextContentBlock { Text = "mixed filters result" }], + }); + }; + + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + _invocations.Add("ordinary-before"); + var result = await next(request, cancellationToken); + _invocations.Add("ordinary-after"); + return result; + }); + + options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) => + { + _invocations.Add("alternate-before"); + var result = await next(request, cancellationToken); + _invocations.Add("alternate-after"); + return result; + }); + }); + [Fact] - public async Task MixingCallToolFilters_WithAlternateFilters_ThrowsActionableError() + public async Task MixingCallToolFilters_WithAlternateFilters_ComposesInOrder() { - await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); - var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; - options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); - options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + await using var client = await CreateMcpClientForServer(); - var ex = Assert.Throws( - () => McpServer.Create(transport, options, LoggerFactory)); + var result = await client.CallToolAsync( + "mixed-tool", + cancellationToken: TestContext.Current.CancellationToken); - Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message); - Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message); - Assert.Contains("AddAuthorizationFilters", ex.Message); - Assert.Contains("WithTasks", ex.Message); + Assert.Equal( + ["alternate-before", "ordinary-before", "handler", "ordinary-after", "alternate-after"], + _invocations); + Assert.Equal("mixed filters result", Assert.IsType(Assert.Single(result.Content)).Text); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs new file mode 100644 index 000000000..208da7de1 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/TaskCallToolFilterCompositionTests.cs @@ -0,0 +1,104 @@ +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using Microsoft.Extensions.DependencyInjection; + +namespace ModelContextProtocol.Tests.Server; + +#pragma warning disable MCPEXP002 // exercises the experimental alternate-result filter seam +public class TaskCallToolFilterCompositionTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +{ + private readonly TaskCompletionSource _continueBackgroundExecution = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _backgroundExecutionCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _executionScopeDisposed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _filterInvocationCount; + private string? _matchedPrimitiveId; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 10 }); + + services.AddScoped(_ => new ScopedDependency(_executionScopeDisposed)); + + mcpServerBuilder.Services.Configure(options => + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + Interlocked.Increment(ref _filterInvocationCount); + _matchedPrimitiveId = request.MatchedPrimitive?.Id; + + if (request.Params?.Name != "scoped-task-filter-tool") + { + return await next(request, cancellationToken); + } + + try + { + await _continueBackgroundExecution.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + _ = request.Services!.GetRequiredService(); + var result = await next(request, cancellationToken); + _backgroundExecutionCompleted.TrySetResult(result); + return result; + } + catch (Exception exception) + { + _backgroundExecutionCompleted.TrySetException(exception); + throw; + } + })); + } + + [Fact] + public async Task TaskBackedTool_RunsOrdinaryFilterOnce_WithMatchedPrimitive() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "task-filter-tool" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("task filter result", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.Equal(1, _filterInvocationCount); + Assert.Equal("task-filter-tool", _matchedPrimitiveId); + } + + [Fact] + public async Task TaskBackedTool_UsesIndependentScopeForBackgroundExecution() + { + await using var client = await CreateMcpClientForServer(); + var cancellationToken = TestContext.Current.CancellationToken; + + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "scoped-task-filter-tool" }, + cancellationToken); + + Assert.True(augmented.IsTask); + _continueBackgroundExecution.TrySetResult(true); + + var result = await _backgroundExecutionCompleted.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + Assert.Equal("scoped task result", Assert.IsType(Assert.Single(result.Content)).Text); + Assert.True(await _executionScopeDisposed.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken)); + } + + private sealed class ScopedDependency(TaskCompletionSource disposed) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + disposed.TrySetResult(true); + return default; + } + } + + [McpServerToolType] + private sealed class TaskFilterTools + { + [McpServerTool(Name = "task-filter-tool")] + public static string Invoke() => "task filter result"; + + [McpServerTool(Name = "scoped-task-filter-tool")] + public static string InvokeWithScopedDependency(ScopedDependency dependency) => "scoped task result"; + } +} +#pragma warning restore MCPEXP002 \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 144b08476..1fc98c4fa 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -1,26 +1,20 @@ using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// /// Verifies that when both and -/// are configured and the handler returns -/// (IsTask = true), the store's pre-created task is failed with a -/// clear error rather than being orphaned in forever. +/// are configured, server creation fails before +/// either alternate-result mechanism can create a task. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { #pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam - private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; - - public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { #if !NET Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587"); @@ -35,56 +29,20 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { options.Capabilities ??= new ServerCapabilities(); - // Returning IsTask = true here while the tasks extension is also configured is the - // misconfiguration the server must guard against. - options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => - new ValueTask>( - ResultOrAlternate.FromAlternate( - new CreateTaskResult - { - TaskId = "user-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }, - s_createTaskResultTypeInfo)); + options.Handlers.CallToolWithAlternateHandler = static (context, cancellationToken) => + new ValueTask>(new CallToolResult()); }); } [Fact] - public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearError() + public void TaskStoreAndExplicitAlternateHandler_ThrowsActionableStartupError() { - await using var client = await CreateMcpClientForServer(); - var ct = TestContext.Current.CancellationToken; - - // The store's task is created synchronously and its taskId returned to the client. - var augmented = await client.CallToolAsTaskAsync( - new CallToolRequestParams { Name = "anything" }, ct); - - Assert.True(augmented.IsTask); - var taskId = augmented.TaskCreated!.TaskId; - - // Poll until the background runner observes the handler's IsTask=true and fails the - // store's task. Without the fix this would loop forever in Working. - GetTaskResult? taskResult = null; - for (int i = 0; i < 40; i++) - { - await Task.Delay(50, ct); - taskResult = await client.GetTaskAsync(taskId, ct); - if (taskResult is FailedTaskResult) - { - break; - } - } - - var failed = Assert.IsType(taskResult); - Assert.Equal(JsonValueKind.Object, failed.Error.ValueKind); - Assert.Equal((int)McpErrorCode.InternalError, failed.Error.GetProperty("code").GetInt32()); + var exception = Assert.Throws(() => StartServer()); - var message = failed.Error.GetProperty("message").GetString(); - Assert.NotNull(message); - Assert.Contains(nameof(IMcpTaskStore), message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), exception.Message); + Assert.Contains(RequestMethods.ToolsCall, exception.Message); + Assert.Contains("alternate-result filter", exception.Message); + Assert.Contains("replaces", exception.Message); } #pragma warning restore MCPEXP002 }