From 7ff51fc32ee03b94d9ed0c355dd1b5e47b0b9f23 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 17:31:03 -0700 Subject: [PATCH 1/3] Add generic public seams to Core for Tasks extension extraction - Add ResultOrAlternate replacing task-specific types in server pipeline - Add McpServerRequestHandler for custom request handler registration (seam #1) - Add McpServerOptions.RequestHandlers property with wiring in McpServerImpl - Rename CallToolWithTaskHandler/Filters to CallToolWithAlternateHandler/Filters - Rename SetTaskAugmented to SetWithAlternate (remove tasks/get guard) - Rename InvokeToolAsTask to InvokeToolWithAlternate - Rename BuildInitialTaskToolFilter to BuildInitialAlternateToolFilter - Make McpClient.ResolveInputRequestsAsync public (seam #4) - Update test references to use new names - Adapt TaskHandlerConfigurationValidationTests for removed guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClient.cs | 3 +- .../Client/McpClientImpl.cs | 2 +- .../Protocol/ResultOrAlternate.cs | 86 ++++++++ .../RequestHandlers.cs | 29 +-- .../Server/McpRequestFilters.cs | 14 +- .../Server/McpServer.Methods.cs | 188 +++++++++++------- .../Server/McpServerHandlers.cs | 22 +- .../Server/McpServerImpl.cs | 105 ++++++---- .../Server/McpServerOptions.cs | 18 +- .../Server/McpServerRequestHandler.cs | 35 ++++ .../Protocol/TaskSerializationTests.cs | 8 +- .../Server/McpServerTaskTests.cs | 18 +- .../Server/McpServerTasksNoStoreTests.cs | 4 +- .../Server/McpTaskStoreTests.cs | 2 +- ...TaskHandlerConfigurationValidationTests.cs | 33 ++- .../Server/TaskPollStuckDetectorTests.cs | 4 +- .../Server/TaskStoreOrphanedTaskTests.cs | 8 +- 17 files changed, 379 insertions(+), 200 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs create mode 100644 src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index b238c59c3..0969d8626 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -82,7 +82,8 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - private protected abstract ValueTask> ResolveInputRequestsAsync( + [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] + public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index eb2fedc85..25bbd6911 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -181,7 +181,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// - private protected override async ValueTask> ResolveInputRequestsAsync( + public override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs new file mode 100644 index 000000000..72ba2e67d --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a request that may return either the standard result or an alternate +/// subtype for scenarios like asynchronous task execution. +/// +/// The standard result type for the request (e.g., ). +/// +/// +/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate +/// that the server returned an alternate result instead of the normal one. The alternate carries its +/// own so the transport layer can serialize it without compile-time knowledge +/// of the concrete type. +/// +/// +/// Use to determine which variant was returned, then access either +/// for the immediate result or for the alternate. +/// +/// +public class ResultOrAlternate where TResult : Result +{ + private readonly TResult? _result; + private readonly Result? _alternate; + private readonly JsonTypeInfo? _alternateTypeInfo; + + /// + /// Initializes a new instance of with an immediate result. + /// + /// The standard result returned by the server. + public ResultOrAlternate(TResult result) + { + Throw.IfNull(result); + _result = result; + } + + /// + /// Initializes a new instance of with an alternate result. + /// + /// The alternate result. + /// The used to serialize the alternate result. + public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) + { + Throw.IfNull(alternate); + Throw.IfNull(alternateTypeInfo); + _alternate = alternate; + _alternateTypeInfo = alternateTypeInfo; + } + + /// + /// Gets a value indicating whether the server returned an alternate result instead of the standard result. + /// + public bool IsAlternate => _alternate is not null; + + /// + /// Gets the immediate result, or if the server returned an alternate. + /// + public TResult? Result => _result; + + /// + /// Gets the alternate result, or if the server returned the standard result. + /// + public Result? Alternate => _alternate; + + /// + /// Gets the for serializing the alternate result, or + /// if the server returned the standard result. + /// + public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo; + + /// + /// Implicitly converts a to a + /// wrapping the immediate result. + /// + /// The result to wrap. + public static implicit operator ResultOrAlternate(TResult result) => new(result); + + /// + /// Implicitly converts a to a + /// wrapping the task handle as an alternate result using the default serialization context. + /// + /// The task creation result to wrap. + public static implicit operator ResultOrAlternate(CreateTaskResult taskCreated) => + new(taskCreated, McpJsonUtilities.JsonContext.Default.CreateTaskResult); +} diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index f15ce316c..ff167cc7d 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -47,44 +47,29 @@ public void Set( } /// - /// Registers a handler that may return either a standard result or a - /// for task-augmented execution. + /// Registers a handler that may return either a standard result or an alternate + /// subtype for scenarios like task-augmented execution. /// - public void SetTaskAugmented( + public void SetWithAlternate( string method, - Func>> handler, + Func>> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { Throw.IfNull(method); Throw.IfNull(handler); Throw.IfNull(requestTypeInfo); Throw.IfNull(responseTypeInfo); - Throw.IfNull(taskResultTypeInfo); this[method] = async (request, cancellationToken) => { TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); - if (augmented.IsTask) + if (augmented.IsAlternate) { - // Guard against a misconfiguration where a handler opts into task-augmented - // execution but the server has no task lifecycle handlers wired up. Without - // tasks/get, a client that received a CreateTaskResult would have no way to - // poll the task to completion. Configure McpServerOptions.TaskStore or set - // the task handlers explicitly via McpServerOptions.Handlers. - if (!ContainsKey(RequestMethods.TasksGet)) - { - throw new InvalidOperationException( - $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " + - $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " + - "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server."); - } - - return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo); + return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!); } return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index 9d9774e8b..c31125af7 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -42,7 +42,7 @@ 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, + /// Cannot be used together with . If both are non-empty at configuration time, /// an will be thrown. /// /// @@ -57,21 +57,21 @@ public IList> CallToolFi } /// - /// Gets or sets the filters for the call-tool handler pipeline with task support. + /// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// /// /// - /// These filters wrap the task-augmented call-tool handler whose return type is - /// . Use these filters when the server's tool pipeline - /// supports returning either an immediate or a - /// for asynchronous execution. + /// These filters wrap the alternate-result call-tool handler whose return type is + /// . Use these filters when the server's tool pipeline + /// supports returning either an immediate or an alternate + /// subtype for asynchronous execution. /// /// /// Cannot be used together with . If both are non-empty at configuration time, /// an will be thrown. /// /// - public IList>> CallToolWithTaskFilters + public IList>> CallToolWithAlternateFilters { get => field ??= []; set diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 4f739c28a..657f58ba0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession private static Dictionary>? s_elicitAllowedProperties = null; + /// + /// Ambient interceptor that, when installed, redirects server-initiated requests + /// (, + /// , and + /// ) 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. + /// + private static readonly AsyncLocal>?> s_outgoingRequestInterceptor = new(); + + /// + /// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any. + /// + internal static Func>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value; + + /// + /// Installs an interceptor that redirects server-initiated requests for the duration of the + /// returned scope on the current asynchronous execution context. + /// + /// + /// The interceptor invoked for each outgoing request. It receives the request method, the + /// pre-serialized request parameters (or ), and a cancellation token, and + /// returns the serialized result (or to indicate no result). + /// + /// An that restores the previous interceptor when disposed. + /// is . + /// + /// While an interceptor is installed, the redirected methods skip their client-capability checks, + /// because the alternate channel is responsible for delivering the request to the client. + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IDisposable InterceptOutgoingRequests(Func> interceptor) + { + Throw.IfNull(interceptor); + + var previous = s_outgoingRequestInterceptor.Value; + s_outgoingRequestInterceptor.Value = interceptor; + return new OutgoingRequestInterceptorScope(previous); + } + + private sealed class OutgoingRequestInterceptorScope(Func>? previous) : IDisposable + { + public void Dispose() => s_outgoingRequestInterceptor.Value = previous; + } + /// /// Creates a new instance of an . /// @@ -74,14 +120,13 @@ public ValueTask SampleAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect sampling through the task store. - // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult, cancellationToken); @@ -282,14 +327,13 @@ public ValueTask RequestRootsAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect through the task store. - // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped + // because the interceptor's alternate channel is responsible for delivering the request to + // the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult, cancellationToken); @@ -334,18 +378,15 @@ public async ValueTask ElicitAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect elicitation through the task store. - // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because - // the client opted into the tasks extension when submitting the originating request, and - // input requests are delivered through the tasks/get response channel rather than as - // direct server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) - { - var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult, - cancellationToken).ConfigureAwait(false); - return taskResult ?? new ElicitResult { Action = "cancel" }; + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) + { + var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams); + var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false); + return resultNode?.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult) ?? new ElicitResult { Action = "cancel" }; } ThrowIfElicitationUnsupported(requestParams); @@ -607,69 +648,68 @@ public IDisposable CreateMcpTaskScope( Throw.IfNull(taskId); Throw.IfNull(store); - var previous = McpTaskExecutionContext.Current.Value; - McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext + return InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => { - TaskId = taskId, - Store = store, - }; - return new McpTaskExecutionContext.Scope(previous); + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + return JsonNode.Parse(response.RawValue.GetRawText()); + } + finally + { + store.InputResponseReceived -= handler; + } + }); } /// - /// Sends a server-initiated request through the task store as an input request, then awaits the response. + /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response. /// /// - /// When executing inside a task scope, capability negotiation checks (such as + /// When an interceptor is installed, capability negotiation checks (such as /// , , and /// ) are intentionally skipped by the callers - /// of this helper. The task channel itself is the negotiated capability: the client opted - /// in to the tasks extension when it submitted the originating request, and is responsible - /// for handling or rejecting the input requests surfaced through tasks/get. + /// of this helper. The interceptor's alternate channel is the negotiated capability and is + /// responsible for delivering the request to the client or rejecting it. /// - private async ValueTask SendRequestViaTaskAsync( - McpTaskExecutionContext taskContext, + private async ValueTask SendRequestViaInterceptorAsync( + Func> interceptor, string method, TRequest request, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo, CancellationToken cancellationToken) { - var requestId = Guid.NewGuid().ToString("N"); - var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo); - - var inputRequest = new InputRequest - { - Method = method, - Params = paramsJson, - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskContext.TaskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } - } - - taskContext.Store.InputResponseReceived += handler; - try - { - await taskContext.Store.SetInputRequestsAsync( - taskContext.TaskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return response.Deserialize(responseTypeInfo)!; - } - finally - { - taskContext.Store.InputResponseReceived -= handler; - } + var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo); + var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false); + return resultNode is null ? default! : resultNode.Deserialize(responseTypeInfo)!; } private void ThrowIfElicitationUnsupported(ElicitRequestParams request) diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 4f9509b9d..3aa1fdea6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -42,19 +42,19 @@ public sealed class McpServerHandlers /// /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. - /// Use instead if the tool may return a - /// for asynchronous execution. + /// Use instead if the tool may return an alternate result + /// (such as ) for asynchronous execution. /// - /// is already set. + /// is already set. public McpRequestHandler? CallToolHandler { get; set { - if (value is not null && CallToolWithTaskHandler is not null) + if (value is not null && CallToolWithAlternateHandler is not null) { throw new InvalidOperationException( - $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured."); + $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithAlternateHandler)} is already set. Only one call tool handler may be configured."); } field = value; @@ -62,20 +62,20 @@ public McpRequestHandler? CallToolHandler } /// - /// Gets or sets the handler for requests with task support. + /// Gets or sets the handler for requests with alternate result support. /// /// /// /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or a for - /// long-running asynchronous operations. + /// a for immediate results or an alternate subtype + /// (such as ) for long-running asynchronous operations. /// /// /// Cannot be set if is already set. /// /// /// is already set. - public McpRequestHandler>? CallToolWithTaskHandler + public McpRequestHandler>? CallToolWithAlternateHandler { get; set @@ -83,7 +83,7 @@ public McpRequestHandler is the recommended way to wire all three /// task lifecycle handlers (, , /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a + /// handlers to override any slot. If can return a /// but no is configured (either /// directly or via a task store), the server throws /// when processing the request so misconfigured deployments fail loudly instead of producing diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..11ba56734 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -97,6 +97,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureExperimentalAndExtensions(options); ConfigureTasks(options); ConfigureMrtr(); + ConfigureCustomRequestHandlers(options); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) @@ -865,6 +866,26 @@ private void ConfigureExperimentalAndExtensions(McpServerOptions options) ServerCapabilities.Extensions = options.Capabilities?.Extensions; } + private void ConfigureCustomRequestHandlers(McpServerOptions options) + { +#pragma warning disable MCPEXP002 + if (options.RequestHandlers is not { Count: > 0 } customHandlers) +#pragma warning restore MCPEXP002 + { + return; + } + + foreach (var entry in customHandlers) + { + SetRawHandler(entry.Method, entry.Handler); + } + } + + private void SetRawHandler(string method, Func> handler) + { + _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask(); + } + private void ConfigureResources(McpServerOptions options) { var listResourcesHandler = options.Handlers.ListResourcesHandler; @@ -1134,11 +1155,11 @@ private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; var callToolHandler = options.Handlers.CallToolHandler; - var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler; + var callToolWithAlternateHandler = options.Handlers.CallToolWithAlternateHandler; var tools = options.ToolCollection; var toolsCapability = options.Capabilities?.Tools; - if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null && + if (listToolsHandler is null && callToolHandler is null && callToolWithAlternateHandler is null && tools is null && toolsCapability is null) { return; @@ -1150,17 +1171,17 @@ private void ConfigureTools(McpServerOptions options) var listChanged = toolsCapability?.ListChanged; var callToolFilters = options.Filters.Request.CallToolFilters; - var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters; + var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters; - // Validate: cannot mix non-task filters/handler with task filters/handler. - bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0; - bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0; + // 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 (hasNonTaskPath && hasTaskPath) + if (hasNonAlternatePath && hasAlternatePath) { throw new InvalidOperationException( - $"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + - $"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other."); + $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + + $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}). Use one style or the other."); } // Handle tools provided via DI by augmenting the list handler. @@ -1202,32 +1223,32 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // Build the unified task-augmented handler from one of the two paths. - if (hasTaskPath) + // Build the unified alternate-result handler from one of the two paths. + if (hasAlternatePath) { - // Case 2: task filter + task handler - callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + // Case 2: alternate filter + alternate handler + callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. if (tools is not null) { - var originalHandler = callToolWithTaskHandler; - callToolWithTaskHandler = (request, cancellationToken) => + var originalHandler = callToolWithAlternateHandler; + callToolWithAlternateHandler = (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerTool tool) { - return InvokeToolAsTask(tool, request, cancellationToken); + return InvokeToolWithAlternate(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); }; } - callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools)); + callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools)); } else { - // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based + // 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. @@ -1247,9 +1268,9 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); - // Convert to task-based. + // Convert to alternate-based. var finalCallToolHandler = callToolHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + callToolWithAlternateHandler = async (request, cancellationToken) => await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); } @@ -1257,8 +1278,8 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // the tool execution is offloaded to the background via the store. if (options.TaskStore is { } taskStore) { - var innerTaskHandler = callToolWithTaskHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + var innerAlternateHandler = callToolWithAlternateHandler; + callToolWithAlternateHandler = async (request, cancellationToken) => { // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the @@ -1285,16 +1306,16 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { try { - var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsTask) + var augmented = await innerAlternateHandler(request, taskCancellationToken).ConfigureAwait(false); + if (augmented.IsAlternate) { // The handler created its own task externally, but the client already holds - // the store's taskId from the synchronous return below — we can't redirect. + // the store's taskId from the synchronous return below -- we can't redirect. // Fail the store's task so the client sees a clear error instead of polling forever. var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, - Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.", + Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism to create the task.", }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); @@ -1319,7 +1340,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { Code = (int)McpErrorCode.InvalidRequest, Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " + - $"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.", + $"use {nameof(McpServerHandlers.CallToolWithAlternateHandler)} to manage the input-request loop manually within the task body.", }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); @@ -1348,10 +1369,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } }, CancellationToken.None); - return ToCreateTaskResult(taskInfo); + return new ResultOrAlternate( + ToCreateTaskResult(taskInfo), + McpJsonUtilities.JsonContext.Default.CreateTaskResult); } - return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false); + return await innerAlternateHandler(request, cancellationToken).ConfigureAwait(false); }; } @@ -1363,12 +1386,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetTaskAugmentedHandler( + SetWithAlternateHandler( RequestMethods.ToolsCall, - callToolWithTaskHandler, + callToolWithAlternateHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult, - McpJsonUtilities.JsonContext.Default.CreateTaskResult); + McpJsonUtilities.JsonContext.Default.CallToolResult); } private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() @@ -1450,7 +1472,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), }; - private static async ValueTask> InvokeToolAsTask( + private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, RequestContext request, CancellationToken cancellationToken) @@ -1501,7 +1523,7 @@ private McpRequestFilter BuildInitialCall } }; - private McpRequestFilter> BuildInitialTaskToolFilter( + private McpRequestFilter> BuildInitialAlternateToolFilter( McpServerPrimitiveCollection? tools) => handler => async (request, cancellationToken) => { @@ -1514,7 +1536,7 @@ private McpRequestFilter( requestTypeInfo, responseTypeInfo); } - private void SetTaskAugmentedHandler( + private void SetWithAlternateHandler( string method, - McpRequestHandler> handler, + McpRequestHandler> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { - _requestHandlers.SetTaskAugmented(method, + _requestHandlers.SetWithAlternate(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), - requestTypeInfo, responseTypeInfo, taskResultTypeInfo); + requestTypeInfo, responseTypeInfo); } private static McpRequestHandler BuildFilterPipeline( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..d31d7882b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Server; @@ -204,4 +204,20 @@ public McpServerFilters Filters /// /// public IMcpTaskStore? TaskStore { get; set; } + + /// + /// Gets or sets custom request handlers to register with the server. + /// + /// + /// + /// Each registers a raw JSON-RPC method handler that + /// bypasses the typed handler infrastructure. This enables extensions to register handlers + /// for methods not known to Core at compile time. + /// + /// + /// Handlers registered here take precedence over built-in handlers for the same method. + /// + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IList? RequestHandlers { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs new file mode 100644 index 000000000..be845f599 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a custom request handler that can be registered with the MCP server to handle +/// arbitrary JSON-RPC methods. +/// +/// +/// +/// Custom request handlers are registered via and +/// are invoked when a JSON-RPC request with the matching is received. +/// The handler receives the raw and returns a serialized +/// response, giving extensions full control over request/response serialization. +/// +/// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public sealed class McpServerRequestHandler +{ + /// + /// Gets the JSON-RPC method name this handler responds to. + /// + public required string Method { get; init; } + + /// + /// Gets the handler function that processes incoming requests for the specified method. + /// + /// + /// The handler receives the full and a , + /// and returns a serialized response (or for void methods). + /// + public required Func> Handler { get; init; } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 86acc57f6..0a0f510a4 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -439,10 +439,10 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() #endregion - #region ResultOrCreatedTask + #region ResultOrAlternate [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromResult() + public static void ResultOrAlternate_ImplicitConversion_FromResult() { CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] }; @@ -454,7 +454,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromResult() } [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() + public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult() { CreateTaskResult taskCreated = new() { @@ -472,7 +472,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() } [Fact] - public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask() + public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask() { var result = new ResultOrCreatedTask(new CallToolResult()); var task = new ResultOrCreatedTask(new CreateTaskResult diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722f3..9b73d0b0e 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -30,7 +30,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { options.Capabilities ??= new ServerCapabilities(); - options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = async (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); @@ -376,7 +376,7 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting - // the handler. The CallToolWithTaskHandler already receives the request, + // the handler. The CallToolWithAlternateHandler already receives the request, // so we can observe the meta there. We test the client-side injection indirectly // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); @@ -506,9 +506,9 @@ public async Task CallToolAsync_RespectsServerPollInterval() } [Fact] - public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult() + public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult() { - // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works + // Verify that the implicit conversion from CallToolResult to ResultOrAlternate works // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); @@ -520,11 +520,11 @@ public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResul } [Fact] - public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive() + public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive() { var handlers = new McpServerHandlers(); - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => handlers.CallToolHandler = async (ctx, ct) => new CallToolResult()); @@ -532,7 +532,7 @@ public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusi handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult()); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult()); } [Fact] @@ -544,8 +544,8 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet() handlers.CallToolHandler = null; // Now setting the other should work - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); - Assert.NotNull(handlers.CallToolWithTaskHandler); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); + Assert.NotNull(handlers.CallToolWithAlternateHandler); } /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 9e264af78..3f3c411f6 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither an nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -25,7 +25,7 @@ public McpServerTasksNoStoreTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler. + // Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler. mcpServerBuilder.WithTools(); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index 09a501ab8..f28761812 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -233,7 +233,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 8cd2f7839..3c2d1d5ba 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -6,9 +6,10 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Verifies the runtime validation that fires when a handler opts into task-augmented execution -/// (returns a ) without the server having any tasks/get -/// handler registered. +/// Verifies behavior when a handler returns a alternate without +/// the server having any tasks/get handler registered. After the ResultOrAlternate +/// generalization, the Core server no longer guards against this -- the extension is responsible +/// for ensuring lifecycle handlers are registered. /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { @@ -25,10 +26,10 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { options.Capabilities ??= new ServerCapabilities(); - // Intentionally configure a task-augmented handler without TaskStore or any of the + // Configure a task-augmented handler without TaskStore or any of the // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult { TaskId = "orphan-task", Status = McpTaskStatus.Working, @@ -39,21 +40,15 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime() + public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError() { + // The Core guard that previously threw InvalidOperationException at request time when + // a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being + // registered has been removed. The extension is now responsible for that guarantee. + // This test verifies the server starts and connects successfully with such configuration. await using var client = await CreateMcpClientForServer(); - // Client surfaces a generic protocol error (the server intentionally redacts the message - // on the wire), so use the base McpException type and confirm via server-side logs that - // the originating exception was the misconfiguration guard. - await Assert.ThrowsAnyAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "anything" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains(MockLoggerProvider.LogMessages, log => - log.Exception is InvalidOperationException ioe && - ioe.Message.Contains("tasks/get", StringComparison.Ordinal) && - ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal)); + // If we get here, the server accepted the handler config without error. + Assert.NotNull(client); } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 1d17be223..d73fe42a7 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -32,10 +32,10 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult + return new ValueTask>(new CreateTaskResult { TaskId = taskId, Status = McpTaskStatus.InputRequired, diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 303d16e17..719bd5d19 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that when both and -/// are configured and the handler returns +/// 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. /// @@ -33,8 +33,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Returning IsTask = true here while TaskStore is also configured is the // misconfiguration the server must guard against. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult { TaskId = "user-task", Status = McpTaskStatus.Working, @@ -77,6 +77,6 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains(nameof(McpServerOptions.TaskStore), message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } } From b37e901df21a947cc3a8c9494db8415d5bad0839 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 19:45:31 -0700 Subject: [PATCH 2/3] Extract Tasks into ModelContextProtocol.Extensions.Tasks package - Create src/ModelContextProtocol.Extensions.Tasks/ with csproj, JSON context, server builder extensions, client extension methods - Move task protocol DTOs (CreateTaskResult, GetTaskResult, UpdateTask*, CancelTask*, McpTaskStatus, TaskStatusNotificationParams) to extension - Move server types (IMcpTaskStore, InMemoryMcpTaskStore, McpTaskInfo, InputResponseReceivedEventArgs) to extension - Move task constants (RequestMethods.Tasks*, NotificationMethods.TaskStatus*, MetaKeys.RelatedTask, McpExtensions.Tasks) into TasksProtocol static class - Delete McpTaskExecutionContext, ResultOrCreatedTask from Core - Remove ~18 task [JsonSerializable] entries from McpJsonUtilities - Remove McpServerOptions.TaskStore and McpClientOptions.MaxConsecutiveStuckPolls - Remove task client methods from McpClient (CallToolRawAsync, PollTaskToCompletion, GetTaskAsync, UpdateTaskAsync, CancelTaskAsync, GetMetaWithTaskCapability, ThrowIfTasksNotSupported) - Remove task server methods/handlers (GetTaskHandler, UpdateTaskHandler, CancelTaskHandler, ConfigureTasks, InvokeToolAsTask, task cancellation sources) - Add Tasks_DiagnosticId (MCPEXP001) to Experimentals.cs - Extension WithTasks(store) registers request handlers via seam #1, alternate filter via seam #2, interceptor via seam #3 - Extension client methods: CallToolAsTaskAsync, CallToolWithPollingAsync, GetTaskAsync, UpdateTaskAsync, CancelTaskAsync - Manually serialize/deserialize InputResponses in UpdateTaskAsync to handle internal Core property visibility across assembly boundary - Update test project and TasksExtension sample to reference new package - Update all task test files with new using and extension API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ModelContextProtocol.slnx | 1 + samples/TasksExtension/Program.cs | 54 +-- samples/TasksExtension/TasksExtension.csproj | 2 + .../Client/McpClient.Methods.cs | 374 +----------------- .../Client/McpClient.cs | 10 +- .../Client/McpClientImpl.cs | 1 - .../Client/McpClientOptions.cs | 37 -- .../McpJsonUtilities.cs | 27 +- .../Protocol/McpExtensions.cs | 18 - .../Protocol/MetaKeys.cs | 20 - .../Protocol/NotificationMethods.cs | 10 - .../Protocol/NotificationParams.cs | 4 +- .../Protocol/RequestMethods.cs | 27 -- .../Protocol/RequestParams.cs | 4 +- .../Protocol/Result.cs | 10 +- .../Protocol/ResultOrAlternate.cs | 8 - .../Server/McpServer.Methods.cs | 87 ---- .../Server/McpServerHandlers.cs | 73 +--- .../Server/McpServerImpl.cs | 311 --------------- .../Server/McpServerOptions.cs | 15 - .../Server/McpTaskExecutionContext.cs | 21 - .../Client/McpTasksClientExtensions.cs | 370 +++++++++++++++++ .../McpTasksJsonContext.cs | 32 ++ ...delContextProtocol.Extensions.Tasks.csproj | 51 +++ .../Protocol/CancelTaskRequestParams.cs | 3 +- .../Protocol/CancelTaskResult.cs | 3 +- .../Protocol/CreateTaskResult.cs | 3 +- .../Protocol/GetTaskRequestParams.cs | 3 +- .../Protocol/GetTaskResult.cs | 8 +- .../Protocol/McpTaskStatus.cs | 3 +- .../Protocol/ResultOrCreatedTask.cs | 13 +- .../Protocol/TaskStatusNotificationParams.cs | 9 +- .../Protocol/UpdateTaskRequestParams.cs | 3 +- .../Protocol/UpdateTaskResult.cs | 3 +- .../Server/IMcpTaskStore.cs | 2 +- .../Server/InMemoryMcpTaskStore.cs | 2 +- .../Server/InputResponseReceivedEventArgs.cs | 2 +- .../Server/McpTaskInfo.cs | 2 +- .../Server/McpTasksBuilderExtensions.cs | 301 ++++++++++++++ .../Server/McpTasksServerExtensions.cs | 109 +++++ .../TasksProtocol.cs | 37 ++ .../Client/McpClientTaskMethodsTests.cs | 32 +- .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/TaskSerializationTests.cs | 75 ++-- .../Server/InMemoryMcpTaskStoreTests.cs | 1 + .../Server/McpServerTaskTests.cs | 195 +++++---- .../Server/McpServerTasksNoStoreTests.cs | 11 +- .../Server/McpTaskStoreTests.cs | 90 ++--- .../TaskCancellationIntegrationTests.cs | 46 +-- ...TaskHandlerConfigurationValidationTests.cs | 24 +- .../Server/TaskPollStuckDetectorTests.cs | 108 ++--- .../Server/TaskProtocolGatingTests.cs | 27 +- .../Server/TaskStoreOrphanedTaskTests.cs | 32 +- 53 files changed, 1321 insertions(+), 1394 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpExtensions.cs delete mode 100644 src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CancelTaskRequestParams.cs (92%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CancelTaskResult.cs (90%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CreateTaskResult.cs (96%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/GetTaskRequestParams.cs (90%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/GetTaskResult.cs (98%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/McpTaskStatus.cs (94%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/ResultOrCreatedTask.cs (91%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/TaskStatusNotificationParams.cs (98%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/UpdateTaskRequestParams.cs (93%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/UpdateTaskResult.cs (88%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/IMcpTaskStore.cs (99%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/InMemoryMcpTaskStore.cs (99%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/InputResponseReceivedEventArgs.cs (92%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/McpTaskInfo.cs (94%) create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index abfb430cb..9020d2fbe 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index fff353307..905c30a64 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -1,18 +1,17 @@ // Demonstrates the MCP tasks extension (SEP-2663): // -// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation +// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation // becomes a background task when the client opts in via the per-request _meta marker. -// - The client invokes the same tool two ways: -// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final -// CallToolResult, just like a synchronous call. -// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls, -// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream -// status updates rather than block on a single await. +// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync. // // Both server and client are wired together in-process over an in-memory pipe so the sample // is self-contained — no separate server process or HTTP transport required. +#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004 + +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; @@ -21,32 +20,27 @@ Pipe clientToServerPipe = new(), serverToClientPipe = new(); -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be - // automatically wrapped as background tasks when the client opts in. - TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, - ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], - }); +var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }; + +var services = new ServiceCollection(); +services.AddMcpServer() + .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })]) + .WithTasks(store); +services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())); + +await using var serviceProvider = services.BuildServiceProvider(); +var server = serviceProvider.GetRequiredService(); _ = server.RunAsync(); await using McpClient client = await McpClient.CreateAsync( - new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); - -Console.WriteLine("=== CallToolAsync (auto-poll) ==="); -var auto = await client.CallToolAsync( - new CallToolRequestParams { Name = "run-report" }); -Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); -Console.WriteLine(); + new StreamClientTransport( + serverInput: clientToServerPipe.Writer.AsStream(), + serverOutput: serverToClientPipe.Reader.AsStream())); -Console.WriteLine("=== CallToolRawAsync (manual poll) ==="); -var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" }); +Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ==="); +var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { - // Either the server doesn't advertise the tasks extension or it chose to run the call - // synchronously despite the client opt-in. Surface the inline result and stop. Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}"); return; } @@ -70,7 +64,6 @@ switch (state) { case CompletedTaskResult completed: - // The Result property carries the wrapped CallToolResult as a raw JsonElement. var callToolResult = completed.Result.Deserialize()!; Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}"); return; @@ -88,9 +81,6 @@ continue; case InputRequiredTaskResult inputRequired: - // The auto-poll path (CallToolAsync above) routes these through the registered - // ElicitationHandler/SamplingHandler automatically. The manual path needs to call - // UpdateTaskAsync with responses for each outstanding key. Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))"); continue; } @@ -101,8 +91,6 @@ internal static class SlowTools [Description("Runs a short simulated report and returns when it's done.")] public static async Task RunReport(CancellationToken cancellationToken) { - // Real-world workloads would do meaningful work here; we just sleep so the polling - // path is observable in the console output. await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); return "report ready"; } diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj index 2f66badd7..fe01a8440 100644 --- a/samples/TasksExtension/TasksExtension.csproj +++ b/samples/TasksExtension/TasksExtension.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index bfe81d19b..e78830802 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -899,25 +899,17 @@ public Task UnsubscribeFromResourceAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - /// /// Invokes a tool on the server. /// - /// The name of the tool to call on the server. + /// The name of the tool to invoke. /// An optional dictionary of arguments to pass to the tool. - /// An optional progress reporter for server notifications. + /// An optional progress handler for tracking operation progress. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// The from the tool execution. + /// The result of the tool invocation. /// is . /// The request failed or the server returned an error response. - /// - /// This overload supports the tasks extension transparently. If the server responds with a - /// task handle rather than an immediate result, this method polls tasks/get until the - /// task completes, dispatching any entries through - /// the client's registered sampling and elicitation handlers along the way. Use - /// to disable automatic polling. - /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -988,221 +980,19 @@ async ValueTask SendRequestWithProgressAsync( /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// This method automatically includes the io.modelcontextprotocol/tasks extension capability - /// in the request metadata. If the server returns a task handle instead of an immediate result, - /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. - /// Use - /// to receive the raw without automatic polling. - /// - public async ValueTask CallToolAsync( - CallToolRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - - var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); - - if (!augmented.IsTask) - { - return augmented.Result!; - } - - return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); - } - - /// - /// Polls a task until it reaches a terminal state and returns the final . - /// - private async ValueTask PollTaskToCompletionAsync( - CreateTaskResult taskCreated, - CancellationToken cancellationToken) - { - // If the server claims InputRequired but never publishes new input requests after we have - // already responded to everything it asked for, treat that as a stuck task. The client - // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll - // loop when the server is misbehaving. The threshold is configurable via - // McpClientOptions.MaxConsecutiveStuckPolls. - int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; - - string taskId = taskCreated.TaskId; - long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; - HashSet? resolvedRequestKeys = null; - bool isFirstPoll = true; - int consecutiveStuckPolls = 0; - - while (true) - { - // Skip the delay before the first poll: many tasks complete almost immediately and we - // don't want to pay the poll interval as gratuitous latency. - if (!isFirstPoll) - { - await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); - } - isFirstPoll = false; - - var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Update poll interval if the server changed it. - if (taskResult.PollIntervalMs is { } newInterval) - { - pollIntervalMs = newInterval; - } - - switch (taskResult) - { - case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - - case FailedTaskResult failed: - throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - - case CancelledTaskResult: - throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); - - case InputRequiredTaskResult inputRequired: - // Dedup: only resolve input requests we haven't already responded to. - var newRequests = new Dictionary(); - if (inputRequired.InputRequests is { } incomingRequests) - { - foreach (var kvp in incomingRequests) - { - if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) - { - newRequests[kvp.Key] = kvp.Value; - } - } - } - - if (newRequests.Count > 0) - { - consecutiveStuckPolls = 0; - - IDictionary inputResponses; - try - { - inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). - // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired - // until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating the original exception. - } - - throw; - } - - await UpdateTaskAsync(new UpdateTaskRequestParams - { - TaskId = taskId, - InputResponses = inputResponses, - }, cancellationToken).ConfigureAwait(false); - - resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); - foreach (var key in inputResponses.Keys) - { - resolvedRequestKeys.Add(key); - } - } - else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) - { - // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating an exception. - } - - throw new McpException( - $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + - "without publishing new input requests after all previously requested inputs were resolved."); - } - - break; - - case WorkingTaskResult: - // Continue polling. - consecutiveStuckPolls = 0; - break; - - default: - throw new McpException( - $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); - } - } - } - - /// - /// Invokes a tool on the server with task extension support, returning the raw response - /// without automatic polling. The caller is responsible for handling task lifecycle. - /// - /// The request parameters to send. The tasks extension capability will be injected into the request metadata. - /// The to monitor for cancellation requests. The default is . - /// A that is either an immediate result or a task handle. - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Unlike , this method does not - /// automatically poll for task completion. If the server returns a , - /// the caller must manage polling via . - /// - /// - public async ValueTask> CallToolRawAsync( + public ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - var paramsWithMeta = new CallToolRequestParams - { - Name = requestParams.Name, - Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call - // (no task capability envelope) so the server returns a direct CallToolResult and never - // creates a task. - Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, - }; - - JsonRpcRequest jsonRpcRequest = new() - { - Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), - }; - - JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Discriminate based on resultType field. - if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() == "task") - { - var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) - ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); - return new ResultOrCreatedTask(taskCreated); - } - - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from response."); - return new ResultOrCreatedTask(callToolResult); + return SendRequestAsync( + RequestMethods.ToolsCall, + requestParams, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); } - /// /// Sets the logging level for the server to control which log messages are sent to the client. /// @@ -1257,111 +1047,6 @@ public Task SetLoggingLevelAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The stable identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - GetTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(GetTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksGet, - requestParams, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Provides input responses to a task that is in the state. - /// - /// The request parameters containing the task ID and input responses. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the update. - /// is . - /// The request failed or the server returned an error response. - public ValueTask UpdateTaskAsync( - UpdateTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksUpdate, - requestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The stable identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - CancelTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksCancel, - requestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult, - cancellationToken: cancellationToken); - } - - /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( IReadOnlyDictionary? arguments, JsonSerializerOptions options) { @@ -1380,43 +1065,4 @@ public ValueTask CancelTaskAsync( return result; } - // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: - // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) - { - JsonObject meta = existingMeta is not null - ? (JsonObject)existingMeta.DeepClone() - : []; - - if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) - { - capsRoot = []; - meta[MetaKeys.ClientCapabilities] = capsRoot; - } - - if (capsRoot["extensions"] is not JsonObject extensionsRoot) - { - extensionsRoot = []; - capsRoot["extensions"] = extensionsRoot; - } - - extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); - return meta; - } - - /// - /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks - /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session - /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel - /// on an older session is a programming error rather than a recoverable protocol condition. - /// - private void ThrowIfTasksNotSupported(string operationName) - { - if (!IsJuly2026OrLaterProtocol()) - { - throw new InvalidOperationException( - $"'{operationName}' requires a newer protocol revision that supports tasks. " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); - } - } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index 0969d8626..be0f8ab87 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -73,7 +73,7 @@ protected McpClient() public abstract Task Completion { get; } /// - /// Resolves input requests embedded in an by dispatching + /// Resolves input requests by dispatching /// each request to the appropriate registered handler. /// /// @@ -82,17 +82,9 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); - /// - /// Gets the maximum number of consecutive stuck-in- polls - /// allowed by before the client cancels and throws. - /// Sourced from . - /// - private protected abstract int MaxConsecutiveStuckPolls { get; } - /// /// Registers one or more tool definitions in the client's tool cache, enabling the transport /// to send Mcp-Param-* headers for those tools without requiring a prior call. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 25bbd6911..6d07da1c3 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -178,7 +178,6 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not public override Task Completion => _sessionHandler.CompletionTask; /// - private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// public override async ValueTask> ResolveInputRequestsAsync( diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..510934cd8 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -147,41 +147,4 @@ public McpClientHandlers Handlers } } - /// - /// Gets or sets the maximum number of consecutive task polls during which a task may report - /// without publishing any new input requests, before - /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws - /// an . - /// - /// - /// The maximum number of consecutive stuck polls allowed. The default value is 60. - /// - /// - /// - /// This guard prevents an unbounded poll loop when the server keeps a task in - /// but never publishes new input requests after the - /// client has already responded to every previously surfaced request. It only affects the - /// long-poll path used by ; - /// it does not affect direct calls. - /// - /// - /// Callers should size this value with the configured server-side poll interval in mind: the - /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. - /// Setting this to a very small value can cause false positives for servers that are slow to - /// surface follow-up input requests; setting it too large can mask misbehaving servers. - /// - /// - /// The value is less than 1. - public int MaxConsecutiveStuckPolls - { - get; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); - } - field = value; - } - } = 60; } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index da1b898dd..b193bb8de 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -54,7 +54,13 @@ private static JsonSerializerOptions CreateDefaultOptions() return options; } - internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => + /// + /// Gets the resolved for from the specified options. + /// + /// The type whose serialization metadata should be resolved. + /// The serializer options providing the type-info resolver chain. + /// The resolved . + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8); @@ -120,12 +126,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(TaskStatusNotificationParams))] - [JsonSerializable(typeof(WorkingTaskNotificationParams))] - [JsonSerializable(typeof(CompletedTaskNotificationParams))] - [JsonSerializable(typeof(FailedTaskNotificationParams))] - [JsonSerializable(typeof(CancelledTaskNotificationParams))] - [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] @@ -174,19 +174,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(GetTaskRequestParams))] - [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(WorkingTaskResult))] - [JsonSerializable(typeof(CompletedTaskResult))] - [JsonSerializable(typeof(FailedTaskResult))] - [JsonSerializable(typeof(CancelledTaskResult))] - [JsonSerializable(typeof(InputRequiredTaskResult))] - [JsonSerializable(typeof(UpdateTaskRequestParams))] - [JsonSerializable(typeof(UpdateTaskResult))] - [JsonSerializable(typeof(CancelTaskRequestParams))] - [JsonSerializable(typeof(CancelTaskResult))] - [JsonSerializable(typeof(CreateTaskResult))] - // MCP Content [JsonSerializable(typeof(ContentBlock))] [JsonSerializable(typeof(TextContentBlock))] diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs deleted file mode 100644 index a41e4a576..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ModelContextProtocol.Protocol; - -/// -/// Provides constants for well-known MCP extension identifiers. -/// -public static class McpExtensions -{ - /// - /// The extension identifier for the MCP Tasks extension. - /// - /// - /// When included in client per-request capabilities, indicates the client can handle - /// in lieu of a standard result. - /// See the SEP-2663 - /// specification for details. - /// - public const string Tasks = "io.modelcontextprotocol/tasks"; -} diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 4d9139953..29db6f5ae 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -53,24 +53,4 @@ public static class MetaKeys /// belonging to different subscriptions on a shared channel (especially STDIO). /// public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; - - /// - /// The metadata key used to associate requests, responses, and notifications with a task. - /// - /// - /// - /// This constant defines the key "io.modelcontextprotocol/related-task" used in the - /// _meta field to associate messages with their originating task across the entire - /// request lifecycle. - /// - /// - /// For example, an elicitation that a task-augmented tool call depends on must share the - /// same related task ID with that tool call's task. - /// - /// - /// For tasks/get, tasks/list, and tasks/cancel operations, this - /// metadata should not be included as the taskId is already present in the message structure. - /// - /// - public const string RelatedTask = "io.modelcontextprotocol/related-task"; } diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 0bd3348f8..7911d9e33 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -144,16 +144,6 @@ public static class NotificationMethods /// public const string CancelledNotification = "notifications/cancelled"; - /// - /// The name of the notification sent by the server to push task status updates to subscribed clients. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Each notification carries a complete task state for the current status, identical to what - /// tasks/get would have returned at that moment. - /// - public const string TaskStatusNotification = "notifications/tasks/status"; - /// /// The name of the notification sent first on a /// response stream to indicate which notification types the server agreed to deliver. diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs index 54432a4c2..b13a746cd 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class NotificationParams { - /// Prevent external derivations. - private protected NotificationParams() + /// Initializes the base notification parameter type. + protected NotificationParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..29d7d3764 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -125,33 +125,6 @@ public static class RequestMethods /// public const string Initialize = "initialize"; - /// - /// The name of the request method sent from the client to poll for task completion. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Clients poll for task status by sending this request with the task ID. - /// - public const string TasksGet = "tasks/get"; - - /// - /// The name of the request method sent from the client to provide input responses to a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Used when a task has input_required status and the client needs to fulfill outstanding requests. - /// - public const string TasksUpdate = "tasks/update"; - - /// - /// The name of the request method sent from the client to signal intent to cancel a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Cancellation is cooperative — the server decides whether and when to honor it. - /// - public const string TasksCancel = "tasks/cancel"; - /// /// The name of the request method sent from the client to discover the server's protocol versions, /// capabilities, and metadata. diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 67a8f00ba..1f14aa754 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class RequestParams { - /// Prevent external derivations. - private protected RequestParams() + /// Initializes the base request parameter type. + protected RequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..e64be9728 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class Result { - /// Prevent external derivations. - private protected Result() + /// Initializes the base result type. + protected Result() { } @@ -28,10 +28,8 @@ private protected Result() /// /// /// When absent or set to "complete", the result is a normal completed response. - /// When set to "input_required", the result is an indicating - /// that additional input is needed before the request can be completed. - /// When set to "task", the result is a indicating that the server - /// has created a long-running task in lieu of returning the result directly. + /// Other values discriminate alternate result subtypes so callers can choose the appropriate + /// concrete payload to deserialize. /// /// /// Defaults to , which is equivalent to "complete". diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs index 72ba2e67d..e2e1f112e 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -75,12 +75,4 @@ public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) /// /// The result to wrap. public static implicit operator ResultOrAlternate(TResult result) => new(result); - - /// - /// Implicitly converts a to a - /// wrapping the task handle as an alternate result using the default serialization context. - /// - /// The task creation result to wrap. - public static implicit operator ResultOrAlternate(CreateTaskResult taskCreated) => - new(taskCreated, McpJsonUtilities.JsonContext.Default.CreateTaskResult); } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 657f58ba0..d6cb83c70 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -107,11 +107,6 @@ public static McpServer Create( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for the client response, - /// then returns to when the response is received. - /// /// [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ValueTask SampleAsync( @@ -366,11 +361,6 @@ public ValueTask RequestRootsAsync( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for user input, - /// then returns to when the response is received. - /// /// public async ValueTask ElicitAsync( ElicitRequestParams requestParams, @@ -463,26 +453,6 @@ public async ValueTask> ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } - /// - /// Sends a task status notification to the connected client. - /// - /// The task status notification parameters to send. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous send operation. - /// is . - public Task SendTaskStatusNotificationAsync( - TaskStatusNotificationParams notificationParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(notificationParams); - - return SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams, - cancellationToken); - } - /// /// Builds a request schema for elicitation based on the public serializable properties of . /// @@ -632,63 +602,6 @@ private void ThrowIfRootsUnsupported() } } - /// - /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through - /// the task store as input requests for the duration of the scope. Use this when executing tool logic - /// in the background as a task, so that any server-to-client requests are surfaced to the client via - /// the task's state instead of direct JSON-RPC messages. - /// - /// The task ID in the store. - /// The task store to write input requests to. - /// An that restores the previous context when disposed. - public IDisposable CreateMcpTaskScope( - string taskId, - IMcpTaskStore store) - { - Throw.IfNull(taskId); - Throw.IfNull(store); - - return InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => - { - var requestId = Guid.NewGuid().ToString("N"); - - var inputRequest = new InputRequest - { - Method = method, - Params = paramsNode is null - ? default - : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } - } - - store.InputResponseReceived += handler; - try - { - await store.SetInputRequestsAsync( - taskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return JsonNode.Parse(response.RawValue.GetRawText()); - } - finally - { - store.InputResponseReceived -= handler; - } - }); - } - /// /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 3aa1fdea6..37d2a3d73 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -43,7 +43,7 @@ public sealed class McpServerHandlers /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. /// Use instead if the tool may return an alternate result - /// (such as ) for asynchronous execution. + /// for the caller to handle. /// /// is already set. public McpRequestHandler? CallToolHandler @@ -67,8 +67,7 @@ public McpRequestHandler? CallToolHandler /// /// /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or an alternate subtype - /// (such as ) for long-running asynchronous operations. + /// a for immediate results or an alternate subtype. /// /// /// Cannot be set if is already set. @@ -202,74 +201,6 @@ public McpRequestHandler? SetLoggingLevelHandler { get; set; } - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client polls for the current state of a task. - /// The handler should return the appropriate subtype - /// based on the task's current status (for example, , - /// , , - /// , or ). - /// - /// - /// Setting is the recommended way to wire all three - /// task lifecycle handlers (, , - /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a - /// but no is configured (either - /// directly or via a task store), the server throws - /// when processing the request so misconfigured deployments fail loudly instead of producing - /// unpollable tasks. - /// - /// - public McpRequestHandler? GetTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client provides input responses for a task - /// that is in the state. Responses keyed - /// by an identifier that does not currently correspond to an outstanding input request - /// (including responses for tasks in a terminal state) should be silently ignored per - /// SEP-2663. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store dispatches to - /// and raises - /// to wake any pending - /// - /// or - /// calls executing inside a task scope. - /// - /// - public McpRequestHandler? UpdateTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client requests cancellation of an in-progress task. - /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should - /// always acknowledge the request with , even if the task is - /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions - /// to is up to the implementation. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store calls - /// and signals the per-task - /// so the tool's - /// observes cancellation. - /// - /// - public McpRequestHandler? CancelTaskHandler { get; set; } - /// Gets or sets notification handlers to register with the server. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 11ba56734..f4e1c52cc 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -28,7 +28,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); @@ -95,7 +94,6 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); - ConfigureTasks(options); ConfigureMrtr(); ConfigureCustomRequestHandlers(options); @@ -313,13 +311,6 @@ public override async ValueTask DisposeAsync() _disposed = true; - foreach (var kvp in _taskCancellationSources) - { - kvp.Value.Cancel(); - kvp.Value.Dispose(); - } - _taskCancellationSources.Clear(); - // Dispose the session handler - cancels message processing and waits for all // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. // After this returns, no new requests can be processed and no new MRTR continuations @@ -754,112 +745,6 @@ private void ConfigureCompletion(McpServerOptions options) return result; } - - private void ConfigureTasks(McpServerOptions options) - { - var getTaskHandler = options.Handlers.GetTaskHandler; - var updateTaskHandler = options.Handlers.UpdateTaskHandler; - var cancelTaskHandler = options.Handlers.CancelTaskHandler; - var taskStore = options.TaskStore; - - // If a task store is provided, wire up handlers from it for any that aren't explicitly set. - if (taskStore is not null) - { - getTaskHandler ??= async (request, cancellationToken) => - { - var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); - return info is null - ? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams) - : ToGetTaskResult(info); - }; - - updateTaskHandler ??= async (request, cancellationToken) => - { - var inputResponses = request.Params!.InputResponses ?? new Dictionary(); - await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false); - - return new UpdateTaskResult(); - }; - - cancelTaskHandler ??= async (request, cancellationToken) => - { - // Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether - // the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown - // or already-terminal tasks; we still surface a success response to the client. - await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false); - - // Signal the task's CancellationTokenSource if one exists. Whichever side - // (this handler or the background runner's finally block) wins TryRemove owns disposal, - // which prevents the runner from observing ObjectDisposedException through cts.Token. - if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts)) - { - cts.Cancel(); - cts.Dispose(); - } - - return new CancelTaskResult(); - }; - } - - if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null) - { - return; - } - - getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); - - // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. - getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); - updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); - cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); - - // Advertise tasks extension in server capabilities. - ServerCapabilities.Extensions ??= new Dictionary(); - ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); - - SetHandler( - RequestMethods.TasksGet, - getTaskHandler, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult); - - SetHandler( - RequestMethods.TasksUpdate, - updateTaskHandler, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult); - - SetHandler( - RequestMethods.TasksCancel, - cancelTaskHandler, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult); - } - - /// - /// Wraps a tasks/* request handler so it throws unless the - /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only - /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers. - /// - private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol( - McpRequestHandler inner, string method) - => (request, cancellationToken) => - { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - throw new McpProtocolException( - $"The method '{method}' requires a newer protocol revision that supports tasks; " + - $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", - McpErrorCode.MethodNotFound); - } - - return inner(request, cancellationToken); - }; - private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; @@ -1273,111 +1158,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolWithAlternateHandler = async (request, cancellationToken) => await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); } - - // If a task store is configured, wrap so that when the client signals task support - // the tool execution is offloaded to the background via the store. - if (options.TaskStore is { } taskStore) - { - var innerAlternateHandler = callToolWithAlternateHandler; - callToolWithAlternateHandler = async (request, cancellationToken) => - { - // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not - // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the - // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise - // run the inner handler and return the direct result (best-effort downgrade, which also - // defends against a non-conformant legacy client that forges the opt-in envelope). - if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) - { - var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); - var taskId = taskInfo.TaskId; - - var cts = new CancellationTokenSource(); - _taskCancellationSources[taskId] = cts; - - // Capture the token synchronously before Task.Run dispatches the work. - // The cancel handler may race with the background runner: whichever side wins - // the TryRemove call owns disposal. If we accessed cts.Token from inside the - // lambda after the handler had already disposed cts, we'd hit ObjectDisposedException. - var taskCancellationToken = cts.Token; - - _ = Task.Run(async () => - { - using (CreateMcpTaskScope(taskId, taskStore)) - { - try - { - var augmented = await innerAlternateHandler(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsAlternate) - { - // The handler created its own task externally, but the client already holds - // the store's taskId from the synchronous return below -- we can't redirect. - // Fail the store's task so the client sees a clear error instead of polling forever. - var error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.InternalError, - Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism to create the task.", - }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - return; - } - - var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult); - await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); - } - catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) - { - await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch (InputRequiredException) - { - // MRTR (input requests) cannot be composed with the task-store wrapper for - // [McpServerTool] methods today: the task ID was already returned synchronously, - // so we have no way to surface InputRequiredResult to the client retroactively. - // Fail the task with a clear, actionable error instead of leaking the raw - // InputRequiredException through the generic catch below. - var error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.InvalidRequest, - Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " + - $"use {nameof(McpServerHandlers.CallToolWithAlternateHandler)} to manage the input-request loop manually within the task body.", - }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - } - catch (Exception ex) - { - // SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}. - // McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to - // propagate (Message + ErrorCode). For any other exception type, redact the message - // and use InternalError (mirrors the redaction in BuildInitialCallToolFilter). - var error = ex is McpProtocolException mcpEx - ? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message } - : new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); - await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - } - finally - { - // Only the side that wins TryRemove disposes cts. This prevents a - // double-dispose race with the default tasks/cancel handler. - if (_taskCancellationSources.TryRemove(taskId, out var registeredCts)) - { - registeredCts.Dispose(); - } - } - } - }, CancellationToken.None); - - return new ResultOrAlternate( - ToCreateTaskResult(taskInfo), - McpJsonUtilities.JsonContext.Default.CreateTaskResult); - } - - return await innerAlternateHandler(request, cancellationToken).ConfigureAwait(false); - }; - } - ServerCapabilities.Tools.ListChanged = listChanged; SetHandler( @@ -1392,86 +1172,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult); } - - private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() - { - TaskId = info.TaskId, - Status = info.Status, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "task", - }; - - private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch - { - McpTaskStatus.Working => new WorkingTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.Completed => new CompletedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), - ResultType = "complete", - }, - McpTaskStatus.Failed => new FailedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), - ResultType = "complete", - }, - McpTaskStatus.Cancelled => new CancelledTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.InputRequired => new InputRequiredTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store - // implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire - // DTO uses IDictionary like every other Protocol type. Most concrete stores back - // their dictionaries with a type that implements both interfaces (Dictionary, - // ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we - // only allocate a copy as a fallback. - InputRequests = info.InputRequests is IDictionary dict - ? dict - : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(), - ResultType = "complete", - }, - _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), - }; - private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, RequestContext request, @@ -1725,15 +1425,6 @@ private static McpRequestHandler BuildFilterPipeline - meta is not null && - meta[MetaKeys.ClientCapabilities] is JsonObject caps && - caps["extensions"] is JsonObject exts && - exts.ContainsKey(McpExtensions.Tasks); - private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) @@ -1808,12 +1499,10 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => /// internal bool HasStatefulTransport() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; - /// /// Returns when the given request was negotiated under the 2026-07-28 or later protocol /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works /// for requests over stateless HTTP) and falling back to the session-negotiated version. - /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index d31d7882b..ad3758151 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -190,21 +190,6 @@ public McpServerFilters Filters [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public int MaxSamplingOutputTokens { get; set; } = 1000; - /// - /// Gets or sets the task store for managing asynchronous task executions. - /// - /// - /// - /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension - /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store. - /// Tool executions from clients that signal task support will be wrapped in tasks via the store. - /// - /// - /// If explicit task handlers are also set on , the explicit handlers take precedence. - /// - /// - public IMcpTaskStore? TaskStore { get; set; } - /// /// Gets or sets custom request handlers to register with the server. /// diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs deleted file mode 100644 index 749f36517..000000000 --- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ModelContextProtocol.Server; - -/// -/// Provides ambient context when a tool is executing as a background task. -/// When established, calls to , -/// , -/// and -/// are redirected through the task store as input requests rather than sent directly to the client. -/// -internal sealed class McpTaskExecutionContext -{ - internal static readonly AsyncLocal Current = new(); - - public required string TaskId { get; init; } - public required IMcpTaskStore Store { get; init; } - - internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable - { - public void Dispose() => Current.Value = previous; - } -} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs new file mode 100644 index 000000000..43dd0c2ee --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -0,0 +1,370 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware client operations. +/// +public static class McpTasksClientExtensions +{ + /// + /// Calls a tool and returns either an immediate result or a created task. + /// + public static async ValueTask> CallToolAsTaskAsync( + this McpClient client, + CallToolRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + }; + + JsonRpcRequest jsonRpcRequest = new() + { + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + string.Equals(resultTypeNode?.GetValue(), "task", StringComparison.Ordinal)) + { + var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); + } + + /// + /// Calls a tool and, if needed, polls the created task to completion. + /// + public static async ValueTask CallToolWithPollingAsync( + this McpClient client, + CallToolRequestParams requestParams, + int maxConsecutiveStuckPolls = 60, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false); + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a task by ID. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Retrieves a task using explicit request parameters. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + GetTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksGet, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + /// + /// Updates a task with input responses. + /// + public static async ValueTask UpdateTaskAsync( + this McpClient client, + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync)); + + // Manually construct the JSON params because InputResponses is backed by an internal + // property in Core that the extension's source-gen context cannot access for serialization. + JsonObject paramsObj = new() + { + ["taskId"] = requestParams.TaskId, + }; + + if (requestParams.InputResponses is { Count: > 0 } inputResponses) + { + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, + McpJsonUtilities.DefaultOptions.GetTypeInfo>()); + } + + JsonRpcRequest jsonRpcRequest = new() + { + Method = TasksProtocol.MethodTasksUpdate, + Params = paramsObj, + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) + ?? new UpdateTaskResult(); + } + + /// + /// Requests task cancellation by ID. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Requests task cancellation using explicit request parameters. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + CancelTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksCancel, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + private static async ValueTask PollTaskToCompletionAsync( + McpClient client, + CreateTaskResult taskCreated, + int maxConsecutiveStuckPolls, + CancellationToken cancellationToken) + { + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) + { + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + + isFirstPoll = false; + + var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false); + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } + + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); + + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); + + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } + } + + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw; + } + + await UpdateTaskAsync(client, new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } + + break; + + case WorkingTaskResult: + consecutiveStuckPolls = 0; + break; + + default: + throw new McpException($"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } + } + } + + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) + { + capsRoot = []; + meta[MetaKeys.ClientCapabilities] = capsRoot; + } + + if (capsRoot["extensions"] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot["extensions"] = extensionsRoot; + } + + if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId)) + { + extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject(); + } + + return meta; + } + + private static bool IsJuly2026OrLaterProtocol(McpClient client) => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion); + + private static void ThrowIfTasksNotSupported(McpClient client, string operationName) + { + if (!IsJuly2026OrLaterProtocol(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires a newer protocol revision that supports tasks " + + $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later). " + + $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs new file mode 100644 index 000000000..13c92ee14 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides source-generated JSON serialization metadata for MCP Tasks extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CreateTaskResult))] +[JsonSerializable(typeof(GetTaskRequestParams))] +[JsonSerializable(typeof(GetTaskResult))] +[JsonSerializable(typeof(WorkingTaskResult))] +[JsonSerializable(typeof(CompletedTaskResult))] +[JsonSerializable(typeof(FailedTaskResult))] +[JsonSerializable(typeof(CancelledTaskResult))] +[JsonSerializable(typeof(InputRequiredTaskResult))] +[JsonSerializable(typeof(UpdateTaskRequestParams))] +[JsonSerializable(typeof(UpdateTaskResult))] +[JsonSerializable(typeof(CancelTaskRequestParams))] +[JsonSerializable(typeof(CancelTaskResult))] +[JsonSerializable(typeof(TaskStatusNotificationParams))] +[JsonSerializable(typeof(WorkingTaskNotificationParams))] +[JsonSerializable(typeof(CompletedTaskNotificationParams))] +[JsonSerializable(typeof(FailedTaskNotificationParams))] +[JsonSerializable(typeof(CancelledTaskNotificationParams))] +[JsonSerializable(typeof(InputRequiredTaskNotificationParams))] +public sealed partial class McpTasksJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj new file mode 100644 index 000000000..e2b52f106 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -0,0 +1,51 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Tasks + MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + false + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs similarity index 92% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs index ee458d064..f4d6ff69f 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task. diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index 4d066862b..c9d92a06e 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/cancel request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs similarity index 96% rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs index 6d9bac23c..9e262e5c0 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs @@ -1,8 +1,9 @@ +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result returned by a server when it creates a task in lieu of a standard result. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs index 52b82d902..a865bf690 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/get request to poll for task completion. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 3fea8cc6b..4d083d8a1 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/get request, containing the full task state. @@ -169,7 +169,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -315,7 +315,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs similarity index 94% rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs index 3b705a947..90ee12d27 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the status of an MCP task. diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs similarity index 91% rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs index 87b857470..1e9100c67 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs @@ -1,4 +1,5 @@ -namespace ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a request that supports task-augmented execution, which may be either @@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result /// The standard result returned by the server. public ResultOrCreatedTask(TResult result) { - Throw.IfNull(result); + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } _result = result; } @@ -41,7 +45,10 @@ public ResultOrCreatedTask(TResult result) /// The task creation result returned by the server. public ResultOrCreatedTask(CreateTaskResult taskCreated) { - Throw.IfNull(taskCreated); + if (taskCreated is null) + { + throw new ArgumentNullException(nameof(taskCreated)); + } _taskCreated = taskCreated; } diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 4e859a22c..020896a1f 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a notifications/tasks notification sent by the server @@ -171,7 +171,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -311,7 +311,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); @@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification [JsonPropertyName("inputRequests")] public IDictionary? InputRequests { get; set; } } - diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs similarity index 93% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs index 07b45de15..aeeb2c79a 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/update request to provide input responses diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs similarity index 88% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index 531039c23..b9f59f395 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/update request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs index 337cb1946..6851f21ac 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an interface for storing and managing the lifecycle of MCP tasks. diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs index 762598226..1b8b61246 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an in-memory implementation of for development and testing scenarios. diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs similarity index 92% rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs index 14447bd6f..c1cedf6d1 100644 --- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs @@ -1,6 +1,6 @@ using ModelContextProtocol.Protocol; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides data for the event. diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs similarity index 94% rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs index d275fb3a6..a9a86ed54 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the state of a task in an . diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs new file mode 100644 index 000000000..ec01e86bd --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -0,0 +1,301 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for to enable MCP Tasks support. +/// +public static class McpTasksBuilderExtensions +{ + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// The server builder. + /// The task store. + /// The builder provided in . + public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(store); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + builder.Services.AddSingleton>(new McpTasksPostConfigureOptions(store)); + return builder; + } + + private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store) : IPostConfigureOptions + { + private readonly IMcpTaskStore _store = store; + private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); + + public void PostConfigure(string? name, McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId)) + { + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + } + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask }); + 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) => + { + if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + { + var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; + var cts = new CancellationTokenSource(); + _cancellationSources[taskId] = cts; + var taskCancellationToken = cts.Token; + + _ = Task.Run(async () => + { + 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(); + } + } + } + }, CancellationToken.None); + + return new ResultOrAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); + } + + return await next(request, cancellationToken).ConfigureAwait(false); + }); + } + + private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + if (info is null) + { + throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams); + } + + return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult); + } + + private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); + + var taskId = request.Params?["taskId"]?.GetValue() + ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); + + // Deserialize inputResponses using Core's options which can access the internal + // InputResponsesCore backing property on RequestParams. The extension's source-gen + // context cannot see that internal member. + var inputResponses = request.Params?["inputResponses"]?.Deserialize( + McpJsonUtilities.DefaultOptions.GetTypeInfo>()) + ?? new Dictionary(); + + await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult); + } + + private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + + if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult); + } + + private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method) + { + if (!IsJuly2026OrLaterProtocolRequest(request)) + { + throw new McpProtocolException( + $"The method '{method}' requires a newer protocol revision that supports tasks " + + $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later); " + + $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + } + + private static bool HasTaskExtensionOptIn(JsonObject? meta) => + meta is not null && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && + exts.ContainsKey(TasksProtocol.ExtensionId); + + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); + + private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() + { + TaskId = info.TaskId, + Status = info.Status, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "task", + }; + + private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), + ResultType = "complete", + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), + ResultType = "complete", + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + InputRequests = info.InputRequests is IDictionary dict + ? dict + : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(), + ResultType = "complete", + }, + _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs new file mode 100644 index 000000000..31445fd1f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -0,0 +1,109 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware server operations. +/// +public static class McpTasksServerExtensions +{ + /// + /// Sends a task status notification to the connected client. + /// + /// The server sending the notification. + /// The notification payload. + /// The cancellation token. + /// A task representing the send operation. + public static Task SendTaskStatusNotificationAsync( + this McpServer server, + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(notificationParams); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams)); +#endif + + return server.SendNotificationAsync( + TasksProtocol.NotificationTaskStatus, + notificationParams, + McpTasksJsonContext.Default.Options, + cancellationToken); + } + + /// + /// Creates a scope that routes server-initiated requests through the specified task store. + /// + /// The server whose outgoing requests should be redirected. + /// The related task identifier. + /// The task store used to surface input requests. + /// An that restores the previous outgoing-request behavior. + public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(taskId); + ArgumentNullException.ThrowIfNull(store); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => + { + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + +#if NET + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); +#else + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + var response = await tcs.Task.ConfigureAwait(false); + return JsonNode.Parse(response.RawValue.GetRawText()); + } +#endif + +#if NET + return JsonNode.Parse(response.RawValue.GetRawText()); +#endif + } + finally + { + store.InputResponseReceived -= handler; + } + }); + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs new file mode 100644 index 000000000..44172666a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides constants for the MCP Tasks extension (SEP-2663). +/// +public static class TasksProtocol +{ + /// + /// The extension identifier for the MCP Tasks extension. + /// + public const string ExtensionId = "io.modelcontextprotocol/tasks"; + + /// + /// The name of the request method sent from the client to poll for task completion. + /// + public const string MethodTasksGet = "tasks/get"; + + /// + /// The name of the request method sent from the client to provide input responses to a task. + /// + public const string MethodTasksUpdate = "tasks/update"; + + /// + /// The name of the request method sent from the client to signal intent to cancel a task. + /// + public const string MethodTasksCancel = "tasks/cancel"; + + /// + /// The name of the notification sent by the server when a task's status changes. + /// + public const string NotificationTaskStatus = "notifications/tasks/status"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + public const string MetaRelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index 879173819..af1334fab 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client; /// /// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync, -/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync. +/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync. /// public class McpClientTaskMethodsTests : ClientServerTestBase { @@ -25,15 +26,12 @@ public McpClientTaskMethodsTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -60,7 +58,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -97,12 +95,12 @@ await Assert.ThrowsAsync(async () => } [Fact] - public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask() + public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask() { await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -122,12 +120,12 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "hello"), - }, ct); + }, cancellationToken: ct); Assert.NotNull(result); Assert.NotEmpty(result.Content); @@ -141,7 +139,7 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -172,7 +170,7 @@ public async Task CancelTaskAsync_NullTaskId_Throws() await using var client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken)); + await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -194,7 +192,7 @@ public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -235,7 +233,7 @@ public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds() for (int i = 0; i < 5; i++) { - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd64..c9fdff85f 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -83,6 +83,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 0a0f510a4..da4afb62f 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,8 +28,8 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie Meta = new JsonObject { ["key"] = "value" } }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(deserialized); Assert.Equal("task-123", deserialized.TaskId); @@ -57,7 +58,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); // Must use camelCase wire names Assert.Contains("\"ttlMs\":", json); @@ -82,7 +83,7 @@ public static void CreateTaskResult_ResultType_SerializesAsTask() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("task", (string)node["resultType"]!); @@ -104,8 +105,8 @@ public static void GetTaskResult_Working_RoundTrip() PollIntervalMs = 2000, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("w1", working.TaskId); @@ -126,8 +127,8 @@ public static void GetTaskResult_Completed_RoundTrip_IncludesResult() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("c1", completed.TaskId); @@ -147,8 +148,8 @@ public static void GetTaskResult_Failed_RoundTrip_IncludesError() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("f1", failed.TaskId); @@ -167,8 +168,8 @@ public static void GetTaskResult_Cancelled_RoundTrip() StatusMessage = "User cancelled", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var cancelled = Assert.IsType(deserialized); Assert.Equal("x1", cancelled.TaskId); @@ -195,8 +196,8 @@ public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.Equal("i1", inputRequired.TaskId); @@ -228,7 +229,7 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() _ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""", }; - var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(result); Assert.IsType(expectedType, result); } @@ -238,42 +239,42 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() public static void GetTaskResult_MissingTaskId_ThrowsJsonException() { var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_MissingStatus_ThrowsJsonException() { var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_UnknownStatus_ThrowsJsonException() { var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException() { var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_FailedMissingError_ThrowsJsonException() { var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException() { var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Theory] @@ -303,14 +304,14 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) ["k"] = new InputRequest { Method = "test/method", - Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions), + Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options), }, }, }, _ => throw new InvalidOperationException() }; - string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -328,10 +329,10 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) [InlineData(McpTaskStatus.Failed, "failed")] public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue) { - string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options); Assert.Equal($"\"{expectedWireValue}\"", json); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.Equal(status, deserialized); } @@ -350,8 +351,8 @@ public static void TaskStatusNotificationParams_Working_RoundTrip() StatusMessage = "Working on it", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("n1", working.TaskId); @@ -370,8 +371,8 @@ public static void TaskStatusNotificationParams_Completed_RoundTrip() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("n2", completed.TaskId); @@ -390,8 +391,8 @@ public static void TaskStatusNotificationParams_Failed_RoundTrip() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("n3", failed.TaskId); @@ -408,8 +409,8 @@ public static void TaskStatusNotificationParams_Cancelled_RoundTrip() LastUpdatedAt = DateTimeOffset.UtcNow, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.IsType(deserialized); } @@ -429,8 +430,8 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.NotNull(inputRequired.InputRequests); @@ -496,7 +497,7 @@ public static void UpdateTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/update responses use resultType="complete". var result = new UpdateTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -507,7 +508,7 @@ public static void CancelTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/cancel responses use resultType="complete". var result = new CancelTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 83afbfe23..5015a5f30 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9b73d0b0e..94bb4dcd0 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Microsoft.Extensions.DependencyInjection; @@ -5,6 +6,8 @@ using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable MCPEXP001, MCPEXP002 + namespace ModelContextProtocol.Tests.Server; /// @@ -29,74 +32,97 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); - options.Handlers.CallToolWithAlternateHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); var toolName = context.Params!.Name; - if (toolName == "immediate-tool") + ResultOrAlternate result = toolName switch { - return new CallToolResult() + "immediate-tool" => new(new CallToolResult { Content = [new TextContentBlock { Text = "immediate result" }], - }; - } + }), + "async-tool" => new( + new CreateTaskResult + { + TaskId = store.CreateTask(), + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + "input-required-tool" => new( + new CreateTaskResult + { + TaskId = store.CreateTask(McpTaskStatus.InputRequired), + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + _ => throw new McpException($"Unknown tool: {toolName}"), + }; - if (toolName == "async-tool") - { - var taskId = store.CreateTask(); - return new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + return new ValueTask>(result); + }; - if (toolName == "input-required-tool") + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - var taskId = store.CreateTask(McpTaskStatus.InputRequired); - return new CreateTaskResult + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + GetTaskResult result; + try { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + result = _taskStore.GetTask(requestParams.TaskId); + } + catch (McpException ex) + { + throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams); + } - throw new McpException($"Unknown tool: {toolName}"); - }; + return new ValueTask(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.GetTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - return store.GetTask(taskId); - }; + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams); - options.Handlers.UpdateTaskHandler = async (context, cancellationToken) => - { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary()); - return new UpdateTaskResult(); - }; + _taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary()); + return new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.CancelTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.CancelTask(taskId); - return new CancelTaskResult(); - }; + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + _taskStore.CancelTask(requestParams.TaskId); + return new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); }); } @@ -105,9 +131,9 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() { await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -115,11 +141,11 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() } [Fact] - public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() + public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -130,11 +156,11 @@ public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() } [Fact] - public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated() + public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -154,7 +180,7 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() // Complete the task after a brief delay so polling finds it. _ = Task.Run(async () => { - await Task.Delay(100, ct); + await Task.Delay(100, cancellationToken: ct); // The store should have exactly one task by now var taskId = _taskStore.GetAllTaskIds().Single(); _taskStore.CompleteTask(taskId, new CallToolResult @@ -163,9 +189,8 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Single(result.Content); @@ -192,9 +217,8 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException() }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await failedTask.Task); } @@ -219,9 +243,8 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await cancelledTask.Task); } @@ -231,10 +254,10 @@ public async Task GetTaskAsync_ReturnsCurrentState() { await using var client = await CreateMcpClientForServer(); - // Create a task via CallToolRawAsync - var augmented = await client.CallToolRawAsync( + // Create a task via CallToolAsTaskAsync + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); var taskId = augmented.TaskCreated!.TaskId; @@ -250,7 +273,7 @@ public async Task CancelTaskAsync_CancelsTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -276,7 +299,7 @@ public async Task ConfigureTasks_AdvertisesExtensionInCapabilities() var extensions = client.ServerCapabilities.Extensions; #pragma warning restore MCP_EXTENSIONS Assert.NotNull(extensions); - Assert.True(extensions.ContainsKey(McpExtensions.Tasks)); + Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId)); } [Fact] @@ -284,7 +307,7 @@ public async Task CreateTaskResult_HasResultTypeTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -298,7 +321,7 @@ public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() // Strong consistency: tasks/get immediately after CreateTaskResult must resolve. await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -328,7 +351,7 @@ public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -347,7 +370,7 @@ public async Task UpdateTaskAsync_TransitionsFromInputRequired() var ct = TestContext.Current.CancellationToken; // Create an input-required task - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "input-required-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -373,7 +396,7 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams } [Fact] - public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() + public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting // the handler. The CallToolWithAlternateHandler already receives the request, @@ -381,7 +404,7 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -390,14 +413,14 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() } [Fact] - public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope() + public async Task CallToolAsTaskAsync_OptIn_UsesSep2575CapabilitiesEnvelope() { // SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} // This test pins the literal wire path so future refactors can't regress. await using var client = await CreateMcpClientForServer(); - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -413,7 +436,7 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() + public async Task CallToolAsTaskAsync_OptIn_PreservesExistingMetaSiblings() { // User-supplied _meta entries at the root must not be clobbered, and the SEP-2575 // envelope must be added alongside them, not in place of them. @@ -431,7 +454,7 @@ public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() }, }; - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -452,13 +475,13 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_PreservesExistingUserMeta() + public async Task CallToolAsTaskAsync_PreservesExistingUserMeta() { // Verify that user-supplied meta fields are not clobbered await using var client = await CreateMcpClientForServer(); var userMeta = new JsonObject { ["customKey"] = "customValue" }; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -494,8 +517,8 @@ public async Task CallToolAsync_RespectsServerPollInterval() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); var elapsed = DateTime.UtcNow - startTime; @@ -512,9 +535,9 @@ public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallTool // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 3f3c411f6..db051d19f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -10,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -35,8 +36,8 @@ public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta. - var augmented = await client.CallToolRawAsync( + // CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta. + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "sync-tool" }, ct); // With no task store configured, the server must complete synchronously and return @@ -54,8 +55,8 @@ public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sync-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text); diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index f28761812..a904c87ed 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -13,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the -based auto-wiring of tools/call into tasks. -/// Verifies that setting enables task support +/// Verifies that enables task support /// for -based tools. /// public class McpTaskStoreTests : ClientServerTestBase @@ -27,23 +28,20 @@ public McpTaskStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHe protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithTools(); - - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); + }); } [Fact] - public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult() + public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, TestContext.Current.CancellationToken); @@ -60,9 +58,9 @@ public async Task CallToolAsync_WithTaskStore_PollsToCompletion() await using var client = await CreateMcpClientForServer(); // CallToolAsync should poll until the background execution completes. - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "slow-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -75,7 +73,7 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() await using var client = await CreateMcpClientForServer(); // Even a fast tool should go through the task store when the client signals capability. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, TestContext.Current.CancellationToken); @@ -88,8 +86,8 @@ public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( - new CallToolRequestParams { Name = "fast-tool" }, ct); + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct); var taskId = augmented.TaskCreated!.TaskId; @@ -115,7 +113,7 @@ public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled() var ct = TestContext.Current.CancellationToken; // Create a slow task that won't complete on its own - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -145,7 +143,7 @@ public async Task ToolExecution_Failure_StoresAsCompletedWithError() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -176,7 +174,7 @@ public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorSh await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -210,7 +208,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "mrtr-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -233,7 +231,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); + Assert.Contains("tasks", message); } [Fact] @@ -253,8 +251,8 @@ public async Task ElicitTool_ViaTask_RedirectsThroughStore() var ct = TestContext.Current.CancellationToken; // CallToolAsync will poll and resolve input requests automatically. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); @@ -279,8 +277,8 @@ public async Task SampleTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sample-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text); @@ -309,8 +307,8 @@ public async Task RootsTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "roots-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text); @@ -328,12 +326,10 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is not null) { notifications.Writer.TryWrite(typed); @@ -342,8 +338,8 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica return default; }); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "notifying-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct); Assert.Equal("notified", Assert.IsType(result.Content[0]).Text); @@ -377,12 +373,10 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is FailedTaskNotificationParams) { notifications.Writer.TryWrite(typed); @@ -393,8 +387,8 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati // The tool emits a Failed notification then returns a normal result, so we isolate the // notification round-trip from the task-store's own failure handling. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "failing-notify-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct); Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text); @@ -426,8 +420,8 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); // The handler should be called exactly once despite potential multiple polls Assert.Equal(1, elicitCallCount); @@ -435,7 +429,7 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() } [Fact] - public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() + public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() { await using var client = await CreateMcpClientForServer(new McpClientOptions { @@ -447,7 +441,7 @@ public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequ }); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "elicit-tool" }, ct); Assert.True(augmented.IsTask); @@ -476,7 +470,7 @@ public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndD var ct = TestContext.Current.CancellationToken; // Run a fast tool to completion via the task store. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -516,7 +510,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli var sw = System.Diagnostics.Stopwatch.StartNew(); var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct)); sw.Stop(); Assert.Equal("handler-failed", ex.Message); @@ -531,7 +525,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately() { // SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the - // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw + // extension capability." We bypass CallToolAsTaskAsync (which injects the marker) and send a raw // tools/call request without the io.modelcontextprotocol/tasks key in _meta. await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; @@ -561,7 +555,7 @@ public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_ await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "iserror-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -602,8 +596,8 @@ public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "multi-elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct); // Exactly two handler invocations — one per unique input request key. Assert.Equal(2, elicitCount); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 0e8693353..e751bdcdc 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -29,16 +30,13 @@ public TaskCancellationIntegrationTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, DefaultTimeToLive = TimeSpan.FromSeconds(5), - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (CancellationToken ct) => { _toolStarted.TrySetResult(true); @@ -66,7 +64,7 @@ public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -93,7 +91,7 @@ public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -130,15 +128,12 @@ public TaskCancellationConcurrencyTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string marker, CancellationToken ct) => { TaskCompletionSource startTcs; @@ -219,14 +214,14 @@ public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks() RegisterMarker("task2"); // Start two tasks - var result1 = await client.CallToolRawAsync( + var result1 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task1"), }, ct); - var result2 = await client.CallToolRawAsync( + var result2 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", @@ -273,15 +268,12 @@ public TerminalTaskStatusTransitionTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([ + }) + .WithTools([ McpServerTool.Create( async (CancellationToken ct) => { @@ -316,7 +308,7 @@ public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchange await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "quick-tool" }, ct); Assert.True(augmented.IsTask); @@ -346,7 +338,7 @@ public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); Assert.True(augmented.IsTask); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 3c2d1d5ba..84f32267a 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -1,7 +1,10 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -13,6 +16,8 @@ namespace ModelContextProtocol.Tests.Server; /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -27,15 +32,18 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.Capabilities ??= new ServerCapabilities(); // Configure a task-augmented handler without TaskStore or any of the - // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). + // task lifecycle request handlers. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "orphan-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = "orphan-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index d73fe42a7..58a0e68ec 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -1,10 +1,13 @@ +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.Nodes; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Tests.Server; @@ -29,49 +32,66 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - ResultType = "task", - }); + return new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult)); }; // GetTask always reports InputRequired with NO outstanding input requests — the // misbehaving-server condition the stuck-detector exists to break out of. - options.Handlers.GetTaskHandler = (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - Interlocked.Increment(ref _pollCount); - - return new ValueTask(new InputRequiredTaskResult + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - TaskId = context.Params!.TaskId, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - InputRequests = new Dictionary(), - ResultType = "complete", - }); - }; - - // CancelTask must succeed since the client issues a best-effort cancel when it - // gives up; otherwise the cancel failure would mask the real exception. - options.Handlers.CancelTaskHandler = (context, cancellationToken) => - new ValueTask(new CancelTaskResult { ResultType = "complete" }); + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + Interlocked.Increment(ref _pollCount); + + return new ValueTask(JsonSerializer.SerializeToNode(new InputRequiredTaskResult + { + TaskId = requestParams.TaskId, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + InputRequests = new Dictionary(), + ResultType = "complete", + }, McpTasksJsonContext.Default.Options)); + }, + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); - // UpdateTask is never invoked in this scenario (there are no input requests to resolve) - // but must be present so the handler-configuration validation passes. - options.Handlers.UpdateTaskHandler = (context, cancellationToken) => - new ValueTask(new UpdateTaskResult { ResultType = "complete" }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); }); } @@ -82,7 +102,7 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct)); Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); Assert.Contains("consecutive polls", ex.Message); @@ -93,18 +113,15 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro [Fact] public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() { - // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync: + // Verifies CallToolWithPollingAsync plumbs the explicit threshold into PollTaskToCompletionAsync: // a smaller configured threshold is surfaced verbatim in the McpException message. const int CustomThreshold = 3; - await using var client = await CreateMcpClientForServer(new McpClientOptions - { - MaxConsecutiveStuckPolls = CustomThreshold, - }); + await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: ct)); // The message embeds the configured threshold, which is the strongest signal that the // option value (not the 60-default constant) is what governed the loop. @@ -112,19 +129,4 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() Assert.Equal(CustomThreshold, _pollCount); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(int.MinValue)] - public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value) - { - var options = new McpClientOptions(); - Assert.Throws(() => options.MaxConsecutiveStuckPolls = value); - } - - [Fact] - public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60() - { - Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls); - } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f9790526..5379dac8c 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -6,8 +7,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 - namespace ModelContextProtocol.Tests.Server; /// @@ -32,15 +31,12 @@ public TaskProtocolGatingTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -68,7 +64,7 @@ private static JsonObject CreateForgedTaskOptInMeta() => { [ExtensionsKey] = new JsonObject { - [McpExtensions.Tasks] = new JsonObject(), + [TasksProtocol.ExtensionId] = new JsonObject(), }, }, }; @@ -115,7 +111,7 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -135,7 +131,7 @@ public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDire // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy // request. The server must still refuse to create a task because the per-request protocol // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -157,10 +153,9 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() // gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound. var request = new JsonRpcRequest { - Method = RequestMethods.TasksGet, + Method = TasksProtocol.MethodTasksGet, Params = JsonSerializer.SerializeToNode( - new GetTaskRequestParams { TaskId = "some-task-id" }, - McpJsonUtilities.DefaultOptions), + new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options), }; var ex = await Assert.ThrowsAsync(async () => @@ -176,7 +171,7 @@ public async Task July2026ProtocolClient_CallToolRaw_CreatesTask() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 719bd5d19..97221b2f9 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -1,22 +1,26 @@ +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; #pragma warning disable MCPEXP001 namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that when both and +/// 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. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -26,21 +30,25 @@ public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { + mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore()); + mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - options.TaskStore = new InMemoryMcpTaskStore(); - // Returning IsTask = true here while TaskStore is also configured is the + // 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>(new CreateTaskResult - { - TaskId = "user-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = "user-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } @@ -51,7 +59,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var ct = TestContext.Current.CancellationToken; // The store's task is created synchronously and its taskId returned to the client. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "anything" }, ct); Assert.True(augmented.IsTask); @@ -76,7 +84,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); - Assert.Contains(nameof(McpServerOptions.TaskStore), message); + Assert.Contains(nameof(IMcpTaskStore), message); Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } } From 44ca105009ba3846ee182597afeeaca6473db25c Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 22:58:32 -0700 Subject: [PATCH 3/3] Restore accurate illustrative comments in TasksExtension sample - Restore the inline-result branch comment on the !raw.IsTask path - Restore the CompletedTaskResult JsonElement deserialization comment - Restore the RunReport sleep-vs-real-work comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/TasksExtension/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index 905c30a64..50ddf0d03 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -41,6 +41,8 @@ var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { + // Either the server doesn't advertise the tasks extension or it chose to run the call + // synchronously despite the client opt-in. Surface the inline result and stop. Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}"); return; } @@ -64,6 +66,7 @@ switch (state) { case CompletedTaskResult completed: + // The Result property carries the wrapped CallToolResult as a raw JsonElement. var callToolResult = completed.Result.Deserialize()!; Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}"); return; @@ -91,6 +94,8 @@ internal static class SlowTools [Description("Runs a short simulated report and returns when it's done.")] public static async Task RunReport(CancellationToken cancellationToken) { + // Real-world workloads would do meaningful work here; we just sleep so the polling + // path is observable in the console output. await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); return "report ready"; }