Skip to content

Implement SEP-2663 Tasks Extension#1579

Open
PranavSenthilnathan wants to merge 12 commits into
modelcontextprotocol:mainfrom
PranavSenthilnathan:new-tasks
Open

Implement SEP-2663 Tasks Extension#1579
PranavSenthilnathan wants to merge 12 commits into
modelcontextprotocol:mainfrom
PranavSenthilnathan:new-tasks

Conversation

@PranavSenthilnathan

@PranavSenthilnathan PranavSenthilnathan commented May 15, 2026

Copy link
Copy Markdown

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

  • Opt-in per request. A client adds the io.modelcontextprotocol/tasks key to a request's _meta to signal task support; the server must not return CreateTaskResult otherwise (enforced in McpServerImpl).
  • Drop-in server config. Set McpServerOptions.TaskStore = new InMemoryMcpTaskStore() and every [McpServerTool] invocation is automatically wrapped, with tasks/get/tasks/update/tasks/cancel handlers wired from the store. Explicit handlers in McpServerOptions.Handlers override any slot.
  • Transparent client polling. McpClient.CallToolAsync injects the extension marker, polls until terminal, dispatches input requests through the registered sampling/elicitation handlers, and dedupes resolved keys across polls. CallToolRawAsync returns the raw ResultOrCreatedTask<T> for manual lifecycle management.
  • Cooperative cancellation. tasks/cancel transitions the task in the store and signals a per-task CancellationTokenSource so the tool's CancellationToken observes the cancel. Disposal races are resolved with TryRemove.
  • Server-initiated requests inside a task. server.ElicitAsync/server.SampleAsync/server.RequestRootsAsync called from inside a tool scope are redirected through the store as input requests instead of direct JSON-RPC, surfaced to the client via InputRequiredTaskResult.InputRequests, and resumed via the store's InputResponseReceived event when tasks/update arrives. Custom handlers can opt in via McpServer.CreateMcpTaskScope(taskId, store).

Public API surface

Protocol (ModelContextProtocol.Core/Protocol)

  • CreateTaskResult, GetTaskResult (+ Working/InputRequired/Completed/Cancelled/Failed subtypes)
  • UpdateTaskRequestParams/UpdateTaskResult, CancelTaskRequestParams/CancelTaskResult, GetTaskRequestParams
  • TaskStatusNotificationParams (+ 5 subtypes — scaffolding for SEP-2575 push notifications)
  • ResultOrCreatedTask<T> discriminator with implicit converters
  • McpTaskStatus enum (snake_case wire values)
  • McpExtensions.Tasks constant
  • Result.ResultType discriminator ("task" / "complete")

Server (ModelContextProtocol.Core/Server)

  • IMcpTaskStore + InMemoryMcpTaskStore (immutable record snapshots, lock-free CAS)
  • McpTaskInfo, InputResponseReceivedEventArgs
  • McpServerOptions.TaskStore
  • McpServerHandlers.{CallToolWithTaskHandler, GetTaskHandler, UpdateTaskHandler, CancelTaskHandler} (CallToolHandlerCallToolWithTaskHandler mutual 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 / CancelTaskAsync

Safety nets baked in

  • Handler-set validation. Returning a CreateTaskResult without tasks/get wired throws InvalidOperationException at request time so misconfigured deployments fail loudly instead of producing unpollable tasks.
  • Stuck-task detector. CallToolAsync gives up after 60 consecutive InputRequired polls with no new input request keys, issues a best-effort tasks/cancel, and throws McpException so a misbehaving server can't trap the client in an unbounded poll loop.
  • Status semantics. Tools returning IsError = true produce Completed (per SEP) — Failed is reserved for JSON-RPC protocol errors.
  • Capability bypass inside a task scope. Elicit/Sample/RequestRoots skip 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

  • 1869 ModelContextProtocol.Tests passing on .NET 10 (114 task-specific), 364 ModelContextProtocol.AspNetCore.Tests passing.
  • New test files: McpServerTaskTests, McpTaskStoreTests, InMemoryMcpTaskStoreTests, McpClientTaskMethodsTests, TaskSerializationTests, TaskCancellationIntegrationTests, TaskHandlerConfigurationValidationTests, TaskPollStuckDetectorTests.
  • Coverage includes wire-format round-trips for every GetTaskResult subtype, store CAS/idempotency, terminal-state transitions, isErrorCompleted mapping, capability bypass, mutual exclusion of CallToolHandler/CallToolWithTaskHandler, handler-set validation, stuck-detector cancellation flow, and parity tests preserved/restored from the removed SEP-1686 implementation.

Documentation

  • Rewrote docs/concepts/tasks/tasks.md end-to-end (correct API examples, status/cancellation semantics, stuck-detector, custom store guidance, capability bypass, known limitations).
  • Expanded XML docs on the three task lifecycle handlers, McpTaskExecutionContext, and the CallToolAsync(string, …) overload.

Known limitations (carried forward)

  • Server-push task status notifications (SEP-2575) — not implemented; clients rely on polling.
  • Lazy task creation — CreateTaskAsync is invoked eagerly even for tools that return inline.
  • Mid-execution promotion to task — [McpServerTool] methods can't start sync and then escalate; use CallToolWithTaskHandler for that pattern.
  • roots/list as input request — server emits, client doesn't dispatch yet.
  • ServerCapabilities.Extensions source-gen round-trip remains lossy for arbitrary object payloads.

# Conflicts:
#	docs/concepts/tasks/tasks.md
#	docs/list-of-diagnostics.md
#	src/ModelContextProtocol.Core/Client/McpClient.cs
#	src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@PranavSenthilnathan PranavSenthilnathan marked this pull request as ready for review May 29, 2026 05:46
@PranavSenthilnathan PranavSenthilnathan changed the title [WIP] Implement SEP-2663 Tasks Extension Implement SEP-2663 Tasks Extension May 29, 2026
halter73 added a commit that referenced this pull request Jun 3, 2026
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 halter73 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Wire-format _meta envelope (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/tasks key 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.
  2. Failed payload structure + JsonDocument lifetime. Two real bugs in the same five-line catch block in McpServerImpl.cs: the failed payload only emits {"message": ...} (SEP-2663 §186 says it MUST be a JSON-RPC error object with at least code), and the JsonDocument.Parse(...).RootElement is never using-disposed so its pooled buffer can be recycled while the element is still in the store. Inline suggestion fixes both.
  3. Roots/list tests. Production-side dispatch looks good — just needs a RootsTool fixture next to sample-tool and one RootsTool_ViaTask_RedirectsThroughStore test mirroring the existing sample/elicit pair. Inline suggestions provided.
  4. Two related task-composition failure modes — both should fail loudly instead of silently. A CallToolWithTaskHandler that returns IsTask = true inside a TaskStore wrapper orphans the store's pre-created task (client polls a task that never completes); and an InputRequiredException thrown from a [McpServerTool] inside a task wrapper becomes a Failed task with a misleading message. Both fixes are a few lines — turn the orphan into a SetFailedAsync with a clear payload (Comment 5), and add a dedicated catch (InputRequiredException) that produces a "MRTR + tasks composition isn't supported under [McpServerTool] yet — use CallToolWithTaskHandler" error (Comment 9). I filed #1635 for the broader composability story as a follow-up.
  5. IMcpTaskStore.CreateTaskAsync docs. SEP-2663 §306 imposes a strong-consistency requirement (tasks/get MUST resolve immediately after CreateTaskAsync returns). 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):

  • IMcpTaskStore lifetime under stateless HTTP. Once #1610 lands and flips HTTP to Stateless = true by default, each POST spins up a fresh server instance. CallToolAsync → CallToolRawAsync → PollTaskToCompletionAsync issues tasks/get as fresh POSTs, so IMcpTaskStore MUST be a singleton DI service (or backed by external storage) for the lookup to resolve across polls. Worth a sentence in docs/concepts/tasks/tasks.md and a class-level remarks paragraph on IMcpTaskStore itself.

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 _meta opt-in but the server has no TaskStore configured (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 (currently MaxConsecutiveStuckPolls = 60) configurable on McpClientOptions — 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!

Comment on lines +1347 to +1354
private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta)
{
JsonObject meta = existingMeta is not null
? (JsonObject)existingMeta.DeepClone()
: [];
meta.TryAdd(McpExtensions.Tasks, new JsonObject());
return meta;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Comment on lines +933 to +938
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
[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);
}

Comment on lines +921 to +924
if (augmented.IsTask)
{
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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";
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
[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));
}

Comment on lines 37 to +42
/// <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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SEP-2663 §306 imposes a strong-consistency requirement on CreateTaskAsync that's currently implicit:

The server MUST NOT return CreateTaskResult until the task is durably created — that is, until a tasks/get for the returned taskId would 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):

Suggested change
/// <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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@halter73

halter73 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

The CI is red, but my agent claims to know what's up:

root cause is test-only: tests use anonymous types with JsonSerializer.SerializeToElement(...) (no JsonTypeInfo), which falls back to reflection. The test project has JsonSerializerIsReflectionEnabledByDefault=false on net9.0 specifically, so all those tests fail there but pass on net8.0/net10.0/net472. Examples: McpServerTaskTests.cs:184 (_taskStore.FailTask(taskId, new { code = -32000, message = ... }) → flows into JsonSerializer.SerializeToElement(entry.Error) at line 521) and TaskSerializationTests.cs:141 (same shape). Easiest fix is to swap the anonymous types for JsonNode.Parse("""{...}""") or a small named record registered with [JsonSerializable].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SEP-2663: Tasks Extension

3 participants