Implement SEP-2663 Tasks Extension#1579
Conversation
# Conflicts: # docs/concepts/tasks/tasks.md # docs/list-of-diagnostics.md # src/ModelContextProtocol.Core/Client/McpClient.cs # src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Reverts most of commit 18c0df7's removal of MrtrContext/MrtrContinuation/MrtrExchange, gating it to stateful sessions only. Tools calling ElicitAsync/SampleAsync/RequestRootsAsync under DRAFT-2026-v1 on stdio and stateful Streamable HTTP again transparently suspend the handler via TCS and emit InputRequiredResult to the client, with retries resumed via continuation lookup on requestState. Stateless Streamable HTTP still requires explicit InputRequiredException for MRTR: the WrapHandlerWithMrtr gate skips the implicit machinery when !IsStatefulSession() and routes through InvokeWithInputRequiredResultHandlingAsync, which already throws when the client doesn't support MRTR on stateless. Deferred-task related machinery (DeferredTask / DeferredTaskCreationResult / DeferTaskCreation / HandleDeferredTaskCreationAsync) is NOT restored. That work was superseded by SEP-2663 (PR #1579), which uses an entirely different API surface (McpServerOptions.TaskStore + per-request task metadata) and would just have to delete the restored SEP-1686 code during its rebase. Test coverage restored: MrtrIntegrationTests (Client), MrtrHandlerLifecycleTests / MrtrMessageFilterTests / MrtrSessionLimitTests (Server), plus the deleted SessionDelete_* + RetryWithInvalidRequestState_* tests in MrtrProtocolTests and the Mrtr_ParallelAwaits theory rows in MapMcpTests.Mrtr.cs. All previously wrapped methods (tools/call, prompts/get, resources/read) are wired through the MRTR interceptor again; updated InputRequiredResult XML doc accordingly. Co-authored-by: Copilot <[email protected]>
Merges upstream/main which includes the MRTR landing (SEP-2322, modelcontextprotocol#1458). Replaces the tasks PR's ad-hoc IDictionary<string, JsonElement> input-request/response envelopes with MRTR's typed InputRequest/InputResponse DTOs. Wire format is unchanged; this simply reuses the shared MRTR types across the tasks/get, tasks/update, and notifications/tasks paths so the two extensions share a single set of typed types. Conflict resolutions: - McpServerImpl.cs: combined task cancellation infrastructure with MRTR continuations; rethrow InputRequiredException from BuildInitialCallToolFilter/BuildInitialTaskToolFilter so the MRTR backcompat resolver (InvokeWithInputRequiredResultHandlingAsync) can catch it. - McpClientImpl.cs: collapsed the duplicate JsonElement-typed ResolveInputRequestsAsync into MRTR's typed override. - McpServer.Methods.cs: SendRequestViaTaskAsync now stores an InputRequest and unwraps InputResponse via Deserialize<T>(typeInfo). - UpdateTaskRequestParams: drops the redeclared InputResponses property and uses the inherited RequestParams.InputResponses from MRTR. - Result.cs: includes the 'task' resultType value alongside MRTR's 'complete' and 'input_required'. Co-authored-by: Copilot <[email protected]>
halter73
left a comment
There was a problem hiding this comment.
I had an agent take a careful pass against SEP-2663, SEP-2575, and the MRTR landing in #1458. Overall this is in great shape. The MRTR integration is clean, the typed input-request/response flow reads well, and the roots/list dispatch you added in this revision closes the gap from our earlier conversation.
Highlights from the inline comments, in priority order:
- Wire-format
_metaenvelope (most important). SEP-2663 §51 / §58–62 says the per-request opt-in is the SEP-2575 envelope:_meta.io.modelcontextprotocol/clientCapabilities.extensions.io.modelcontextprotocol/tasks. We're currently writing and reading the bare_meta.io.modelcontextprotocol/taskskey on both sides, so two C# peers agree but a strictly-spec-shaped non-C# peer won't. Suggested change is symmetric on client (McpClient.Methods.cs) and server (McpServerImpl.cs); I'd also pair it with a small JSON-shape contract test (one that asserts the literal JSON path) so a future refactor can't silently regress to a bare key. Failedpayload structure +JsonDocumentlifetime. Two real bugs in the same five-linecatchblock inMcpServerImpl.cs: the failed payload only emits{"message": ...}(SEP-2663 §186 says it MUST be a JSON-RPC error object with at leastcode), and theJsonDocument.Parse(...).RootElementis neverusing-disposed so its pooled buffer can be recycled while the element is still in the store. Inline suggestion fixes both.- Roots/list tests. Production-side dispatch looks good — just needs a
RootsToolfixture next tosample-tooland oneRootsTool_ViaTask_RedirectsThroughStoretest mirroring the existing sample/elicit pair. Inline suggestions provided. - Two related task-composition failure modes — both should fail loudly instead of silently. A
CallToolWithTaskHandlerthat returnsIsTask = trueinside aTaskStorewrapper orphans the store's pre-created task (client polls a task that never completes); and anInputRequiredExceptionthrown from a[McpServerTool]inside a task wrapper becomes aFailedtask with a misleading message. Both fixes are a few lines — turn the orphan into aSetFailedAsyncwith a clear payload (Comment 5), and add a dedicatedcatch (InputRequiredException)that produces a "MRTR + tasks composition isn't supported under[McpServerTool]yet — useCallToolWithTaskHandler" error (Comment 9). I filed #1635 for the broader composability story as a follow-up. IMcpTaskStore.CreateTaskAsyncdocs. SEP-2663 §306 imposes a strong-consistency requirement (tasks/getMUST resolve immediately afterCreateTaskAsyncreturns). Worth adding a<remarks>paragraph so custom store implementers on eventually-consistent storage don't accidentally violate it.
Forward-compat notes from a parallel cross-check against #1610 (sessionless + handshake-less, SEP-2575/2567):
IMcpTaskStorelifetime under stateless HTTP. Once #1610 lands and flips HTTP toStateless = trueby default, each POST spins up a fresh server instance.CallToolAsync → CallToolRawAsync → PollTaskToCompletionAsyncissuestasks/getas fresh POSTs, soIMcpTaskStoreMUST be a singleton DI service (or backed by external storage) for the lookup to resolve across polls. Worth a sentence indocs/concepts/tasks/tasks.mdand a class-level remarks paragraph onIMcpTaskStoreitself.
Nit on the PR body: the "Known limitations" list still includes roots/list as input request — server emits, client doesn't dispatch yet, which is now stale given the dispatch you added.
Things I'd be happy to move to follow-up issues instead of holding this PR for:
- Replacement sample for the removed
samples/LongRunningTasks/(the new doc page covers the concepts, but a runnable sample is more useful for ecosystem adoption). - A test that pins down what happens when a client sends the tasks
_metaopt-in but the server has noTaskStoreconfigured (currently silent fallback to sync — fine if intentional). - Behavioral coverage for
SendTaskStatusNotificationAsync(today there's only round-trip serialization coverage — nothing verifies an actual server→client emission end-to-end, and nothing in the task wrapper auto-emits). - Make
PollTaskToCompletionAsync's stuck-detector threshold (currentlyMaxConsecutiveStuckPolls = 60) configurable onMcpClientOptions— useful for slow networks and debugging. - Once SEP-2575 server-push (
subscriptions/listen/notifications/tasks) lands, wire auto-emit from the task wrapper so the client can opt out of polling.
Thanks!
| private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) | ||
| { | ||
| JsonObject meta = existingMeta is not null | ||
| ? (JsonObject)existingMeta.DeepClone() | ||
| : []; | ||
| meta.TryAdd(McpExtensions.Tasks, new JsonObject()); | ||
| return meta; | ||
| } |
There was a problem hiding this comment.
SEP-2663 §51 says the per-request opt-in is the SEP-2575 capabilities envelope — not a bare _meta key. Spec says:
A client signals support for tasks on a per-request basis using the SEP-2575 client capabilities envelope:
_meta["io.modelcontextprotocol/clientCapabilities"]["extensions"]["io.modelcontextprotocol/tasks"] = {}
The current bare-key form will be interoperable only with our own server; non-C# peers built strictly to spec won't recognize it.
The matching constant (NotificationMethods.ClientCapabilitiesMetaKey) ships in my draft branch halter73/remove-session-id-draft together with the SEP-2575 plumbing, so the literal here will be replaceable once that lands. Until then, suggest writing the nested form directly:
| private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) | |
| { | |
| JsonObject meta = existingMeta is not null | |
| ? (JsonObject)existingMeta.DeepClone() | |
| : []; | |
| meta.TryAdd(McpExtensions.Tasks, new JsonObject()); | |
| return meta; | |
| } | |
| private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; | |
| private const string ExtensionsKey = "extensions"; | |
| private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) | |
| { | |
| // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: | |
| // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} | |
| // TODO: replace the literal with NotificationMethods.ClientCapabilitiesMetaKey once the | |
| // SEP-2575 plumbing lands (#halter73-draft) and drop the local consts. | |
| JsonObject meta = existingMeta is not null | |
| ? (JsonObject)existingMeta.DeepClone() | |
| : []; | |
| if (meta[ClientCapabilitiesMetaKey] is not JsonObject capsRoot) | |
| { | |
| capsRoot = []; | |
| meta[ClientCapabilitiesMetaKey] = capsRoot; | |
| } | |
| if (capsRoot[ExtensionsKey] is not JsonObject extensionsRoot) | |
| { | |
| extensionsRoot = []; | |
| capsRoot[ExtensionsKey] = extensionsRoot; | |
| } | |
| extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); | |
| return meta; | |
| } |
Note: Also requires a matching change in the server-side detector (McpServerImpl.cs:900 left as a separate comment there). I'd also move any private const string to the beginning although it will be replaced with NotificationMethods.ClientCapabilitiesMetaKey shortly.
| catch (Exception ex) | ||
| { | ||
| var escapedMessage = JsonSerializer.Serialize(ex.Message, McpJsonUtilities.JsonContext.Default.String); | ||
| var errorJson = JsonDocument.Parse($$$"""{{"message": {{{escapedMessage}}}}}""").RootElement; | ||
| await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); | ||
| } |
There was a problem hiding this comment.
Two issues here, fixable together:
1. Missing JSON-RPC code field (SEP-2663 §186). The spec says failed.error MUST be a JSON-RPC error object — { code, message, data? }. Right now we emit only { "message": ex.Message }, so a strict client (or our own McpException.Converter on the round-trip) sees an invalid error object with no code.
2. JsonDocument lifetime bug. JsonDocument.Parse(...).RootElement returns a JsonElement backed by the parent JsonDocument's pooled buffer. Because the document is neither assigned nor using-disposed, it's eligible for GC immediately — its ArrayPool buffer may be returned and recycled while the JsonElement is still being held by InMemoryMcpTaskStore (or any other store implementation). This is a latent buffer-corruption / ObjectDisposedException bug, not just style.
Suggested fix — typed error object + SerializeToElement (no JsonDocument lifetime to worry about) + prefer McpException.ErrorCode when available + don't leak arbitrary exception messages from non-McpException to the client (matches how BuildInitialCallToolFilter redacts non-McpException messages at line 1144):
| catch (Exception ex) | |
| { | |
| var escapedMessage = JsonSerializer.Serialize(ex.Message, McpJsonUtilities.JsonContext.Default.String); | |
| var errorJson = JsonDocument.Parse($$$"""{{"message": {{{escapedMessage}}}}}""").RootElement; | |
| 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?}. | |
| // Prefer McpException.ErrorCode; otherwise InternalError. Don't leak arbitrary | |
| // exception messages from non-McpException to the client (mirrors the redaction | |
| // in BuildInitialCallToolFilter at line 1144). | |
| var error = ex is McpException 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); | |
| } |
Requires adding JsonRpcErrorDetail to the source-gen context (McpJsonUtilities.cs). If the existing JsonRpcError/McpError type can be reused, prefer that — I didn't grep exhaustively. Either way, the shape on the wire should be { "code": -32603, "message": "..." }.
If there's a good way to consolidate this with existing our existing transport-level error writing logic, I'm open to that.
| var innerTaskHandler = callToolWithTaskHandler; | ||
| callToolWithTaskHandler = async (request, cancellationToken) => | ||
| { | ||
| if (request.Params?.Meta?.ContainsKey(McpExtensions.Tasks) is true) |
There was a problem hiding this comment.
Paired with the client-side change at McpClient.Methods.cs#GetMetaWithTaskCapability — the SEP-2663 §51 opt-in is the SEP-2575 nested envelope (_meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks), not a bare top-level _meta key.
Suggested helper + replacement check:
| if (request.Params?.Meta?.ContainsKey(McpExtensions.Tasks) is true) | |
| if (HasTaskExtensionOptIn(request.Params?.Meta)) |
…and add this helper near the bottom of the file (or in McpExtensions):
// Per SEP-2663 §51, the client opts in to the tasks extension on a per-request basis
// via the SEP-2575 capabilities envelope. Accept the bare key as a transitional
// fallback so older C# clients keep working during the experimental window.
// TODO: drop the bare-key fallback once SEP-2575 ships and clients are migrated;
// also swap the literal for NotificationMethods.ClientCapabilitiesMetaKey at that point.
private static bool HasTaskExtensionOptIn(JsonObject? meta)
{
if (meta is null)
{
return false;
}
if (meta["io.modelcontextprotocol/clientCapabilities"] is JsonObject caps &&
caps["extensions"] is JsonObject exts &&
exts.ContainsKey(McpExtensions.Tasks))
{
return true;
}
// Transitional: accept the bare-key form written by older C# clients.
return meta.ContainsKey(McpExtensions.Tasks);
}Reading both shapes during the experimental window keeps us compatible with both today's C# clients (bare key) and spec-shaped peers (nested envelope).
| Assert.NotNull(result); | ||
| Assert.Equal("sampled response", Assert.IsType<TextContentBlock>(result.Content[0]).Text); | ||
| } | ||
|
|
There was a problem hiding this comment.
Adding the test method mirroring SampleTool_ViaTask_RedirectsThroughStore above — covers the server→client roots/list redirect added in McpClientImpl.cs#ResolveInputRequestAsync. Inline suggestion to insert between SampleTool_ViaTask_RedirectsThroughStore and ElicitTool_ViaTask_ClientDedups_InputRequests:
| [Fact] | |
| public async Task RootsTool_ViaTask_RedirectsThroughStore() | |
| { | |
| await using var client = await CreateMcpClientForServer(new McpClientOptions | |
| { | |
| Handlers = new McpClientHandlers | |
| { | |
| RootsHandler = (request, ct) => | |
| { | |
| return new ValueTask<ListRootsResult>(new ListRootsResult | |
| { | |
| Roots = [new Root { Uri = "file:///workspace", Name = "workspace" }], | |
| }); | |
| } | |
| } | |
| }); | |
| var ct = TestContext.Current.CancellationToken; | |
| var result = await client.CallToolAsync( | |
| new CallToolRequestParams { Name = "roots-tool" }, ct); | |
| Assert.NotNull(result); | |
| Assert.Equal("file:///workspace", Assert.IsType<TextContentBlock>(result.Content[0]).Text); | |
| } | |
| if (augmented.IsTask) | ||
| { | ||
| return; | ||
| } |
There was a problem hiding this comment.
When a CallToolWithTaskHandler returns IsTask = true while TaskStore is also configured, the store's pre-created task is left orphaned in Working forever — the client already received the store's taskId synchronously at L951, so we can't redirect mid-flight to the user's task, and the background runner's early return; here never resolves the store's task.
Most CallToolWithTaskHandler users won't hit this (they return IsTask = false to get the typed input-request/response shape), and registration-time exclusivity would forbid that working combination. So the minimal fix is to turn the orphan into a clear runtime failure on the store's task:
| if (augmented.IsTask) | |
| { | |
| return; | |
| } | |
| if (augmented.IsTask) | |
| { | |
| // The handler created its own task externally, but the client already holds | |
| // the store's taskId from the synchronous return at L951 — 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.", | |
| }; | |
| var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail); | |
| await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); | |
| return; | |
| } |
|
|
||
| return result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "no response"; | ||
| } | ||
|
|
There was a problem hiding this comment.
Adding the matching RootsTool fixture, in the same shape as SampleTool two methods up so the test below can drive it. Inline suggestion to insert between SampleTool and IsErrorTool:
| [McpServerTool(Name = "roots-tool"), System.ComponentModel.Description("A tool that lists roots")] | |
| public static async Task<string> RootsTool(McpServer server, CancellationToken cancellationToken) | |
| { | |
| var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); | |
| return string.Join(",", result.Roots.Select(r => r.Uri)); | |
| } | |
| /// <remarks> | ||
| /// Implementations must generate a unique task ID and set the <see cref="McpTask.CreatedAt"/> | ||
| /// and <see cref="McpTask.LastUpdatedAt"/> timestamps. The implementation may override the | ||
| /// requested TTL to enforce storage limits. | ||
| /// Implementations must generate a unique task ID and set appropriate timestamps. | ||
| /// The server infrastructure maps the returned <see cref="McpTaskInfo"/> to the appropriate | ||
| /// protocol response type when communicating with clients. | ||
| /// </remarks> | ||
| Task<McpTask> CreateTaskAsync( | ||
| McpTaskMetadata taskParams, | ||
| RequestId requestId, | ||
| JsonRpcRequest request, | ||
| string? sessionId = null, | ||
| CancellationToken cancellationToken = default); | ||
| Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default); |
There was a problem hiding this comment.
SEP-2663 §306 imposes a strong-consistency requirement on CreateTaskAsync that's currently implicit:
The server MUST NOT return
CreateTaskResultuntil the task is durably created — that is, until atasks/getfor the returnedtaskIdwould resolve.
The InMemoryMcpTaskStore satisfies this trivially (it assigns to the dictionary before returning), and the existing GetTaskAsync_ImmediatelyAfterCreate_Resolves test enforces it for that one implementation — but the contract on the interface is silent. A custom durable store backed by, say, eventually-consistent storage (or a write-behind cache, or a queued background persistence path) could violate it without realizing it broke the spec, because the SDK's own server code on line 902 just awaits CreateTaskAsync and immediately returns the task ID to the client.
Suggested docs addition (inline):
| /// <remarks> | |
| /// Implementations must generate a unique task ID and set the <see cref="McpTask.CreatedAt"/> | |
| /// and <see cref="McpTask.LastUpdatedAt"/> timestamps. The implementation may override the | |
| /// requested TTL to enforce storage limits. | |
| /// Implementations must generate a unique task ID and set appropriate timestamps. | |
| /// The server infrastructure maps the returned <see cref="McpTaskInfo"/> to the appropriate | |
| /// protocol response type when communicating with clients. | |
| /// </remarks> | |
| Task<McpTask> CreateTaskAsync( | |
| McpTaskMetadata taskParams, | |
| RequestId requestId, | |
| JsonRpcRequest request, | |
| string? sessionId = null, | |
| CancellationToken cancellationToken = default); | |
| Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default); | |
| /// <remarks> | |
| /// Implementations must generate a unique task ID and set appropriate timestamps. | |
| /// The server infrastructure maps the returned <see cref="McpTaskInfo"/> to the appropriate | |
| /// protocol response type when communicating with clients. | |
| /// <para> | |
| /// Per the MCP specification (SEP-2663 §306), the returned task MUST be durably created | |
| /// before this method completes: a subsequent <see cref="GetTaskAsync"/> with the returned | |
| /// <see cref="McpTaskInfo.TaskId"/> MUST resolve, even if it runs on a different process or | |
| /// node. Implementations backed by eventually-consistent storage must therefore wait for the | |
| /// write to be visible (e.g., quorum acknowledgement, write-through, or an equivalent | |
| /// barrier) before returning. | |
| /// </para> | |
| /// </remarks> | |
| Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default); |
| { | ||
| await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); | ||
| } | ||
| catch (Exception ex) |
There was a problem hiding this comment.
When a [McpServerTool] method throws InputRequiredException (the MRTR signal) and the call came in with the tasks _meta opt-in, the rethrow at BuildInitialTaskToolFilter line 1133 propagates out of innerTaskHandler at line 920 — but it lands in the generic catch (Exception ex) below. The result: the client gets a Failed task with the literal InputRequiredException message in the payload, with no hint that "MRTR can't compose with tasks under this wrapper."
The PR body already lists "Mid-execution promotion to task — [McpServerTool] methods can't start sync and then escalate" as a known limitation, so the behavior (no MRTR-within-tasks) is acknowledged. The issue is that the failure mode is silent and the surfaced error is misleading.
Minimum fix for this PR — add a dedicated catch (InputRequiredException) before the general catch:
catch (InputRequiredException)
{
var error = new JsonRpcErrorDetail
{
Code = (int)McpErrorCode.InvalidRequest,
Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " +
"use CallToolWithTaskHandler 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);
}Combine this with the code-field + JsonDocument-lifetime fix above so the catch block ends up as one coherent edit.
The broader composition story — restoring DeferTaskCreation for sync-to-task escalation + task-aware MRTR so input requests issued inside a task body actually work — is tracked in #1635. Happy to have that picked up as a follow-up after this lands.
|
The CI is red, but my agent claims to know what's up:
|
Closes #1573
Implement SEP-2663 Tasks Extension
Re-implements the experimental MCP tasks extension to track SEP-2663, replacing the SEP-1686 implementation removed in cec5d99. Long-running tool invocations can now be offloaded to a background task that the client polls via
tasks/get, with cooperative cancellation and multi-round-trip input requests.Experimental — gated behind
MCPEXP001.Highlights
io.modelcontextprotocol/taskskey to a request's_metato signal task support; the server must not returnCreateTaskResultotherwise (enforced inMcpServerImpl).McpServerOptions.TaskStore = new InMemoryMcpTaskStore()and every[McpServerTool]invocation is automatically wrapped, withtasks/get/tasks/update/tasks/cancelhandlers wired from the store. Explicit handlers inMcpServerOptions.Handlersoverride any slot.McpClient.CallToolAsyncinjects the extension marker, polls until terminal, dispatches input requests through the registered sampling/elicitation handlers, and dedupes resolved keys across polls.CallToolRawAsyncreturns the rawResultOrCreatedTask<T>for manual lifecycle management.tasks/canceltransitions the task in the store and signals a per-taskCancellationTokenSourceso the tool'sCancellationTokenobserves the cancel. Disposal races are resolved withTryRemove.server.ElicitAsync/server.SampleAsync/server.RequestRootsAsynccalled from inside a tool scope are redirected through the store as input requests instead of direct JSON-RPC, surfaced to the client viaInputRequiredTaskResult.InputRequests, and resumed via the store'sInputResponseReceivedevent whentasks/updatearrives. Custom handlers can opt in viaMcpServer.CreateMcpTaskScope(taskId, store).Public API surface
Protocol (
ModelContextProtocol.Core/Protocol)CreateTaskResult,GetTaskResult(+Working/InputRequired/Completed/Cancelled/Failedsubtypes)UpdateTaskRequestParams/UpdateTaskResult,CancelTaskRequestParams/CancelTaskResult,GetTaskRequestParamsTaskStatusNotificationParams(+ 5 subtypes — scaffolding for SEP-2575 push notifications)ResultOrCreatedTask<T>discriminator with implicit convertersMcpTaskStatusenum (snake_case wire values)McpExtensions.TasksconstantResult.ResultTypediscriminator ("task"/"complete")Server (
ModelContextProtocol.Core/Server)IMcpTaskStore+InMemoryMcpTaskStore(immutable record snapshots, lock-free CAS)McpTaskInfo,InputResponseReceivedEventArgsMcpServerOptions.TaskStoreMcpServerHandlers.{CallToolWithTaskHandler, GetTaskHandler, UpdateTaskHandler, CancelTaskHandler}(CallToolHandler⊥CallToolWithTaskHandlermutual exclusion enforced at set time)McpServer.CreateMcpTaskScope(taskId, store)Client (
ModelContextProtocol.Core/Client)McpClient.CallToolAsync(task-aware auto-polling)McpClient.CallToolRawAsync(no auto-polling)McpClient.GetTaskAsync/UpdateTaskAsync/CancelTaskAsyncSafety nets baked in
CreateTaskResultwithouttasks/getwired throwsInvalidOperationExceptionat request time so misconfigured deployments fail loudly instead of producing unpollable tasks.CallToolAsyncgives up after 60 consecutiveInputRequiredpolls with no new input request keys, issues a best-efforttasks/cancel, and throwsMcpExceptionso a misbehaving server can't trap the client in an unbounded poll loop.IsError = trueproduceCompleted(per SEP) —Failedis reserved for JSON-RPC protocol errors.Elicit/Sample/RequestRootsskip the negotiated-capability checks when called inside a task scope, since the tasks extension itself is the negotiated capability (the client opted in via_meta).Tests
ModelContextProtocol.Testspassing on .NET 10 (114 task-specific), 364ModelContextProtocol.AspNetCore.Testspassing.McpServerTaskTests,McpTaskStoreTests,InMemoryMcpTaskStoreTests,McpClientTaskMethodsTests,TaskSerializationTests,TaskCancellationIntegrationTests,TaskHandlerConfigurationValidationTests,TaskPollStuckDetectorTests.GetTaskResultsubtype, store CAS/idempotency, terminal-state transitions,isError→Completedmapping, capability bypass, mutual exclusion ofCallToolHandler/CallToolWithTaskHandler, handler-set validation, stuck-detector cancellation flow, and parity tests preserved/restored from the removed SEP-1686 implementation.Documentation
docs/concepts/tasks/tasks.mdend-to-end (correct API examples, status/cancellation semantics, stuck-detector, custom store guidance, capability bypass, known limitations).McpTaskExecutionContext, and theCallToolAsync(string, …)overload.Known limitations (carried forward)
CreateTaskAsyncis invoked eagerly even for tools that return inline.[McpServerTool]methods can't start sync and then escalate; useCallToolWithTaskHandlerfor that pattern.roots/listas input request — server emits, client doesn't dispatch yet.ServerCapabilities.Extensionssource-gen round-trip remains lossy for arbitraryobjectpayloads.