Tasks v2 (SEP-2663)
Server-directed async tool calls: no client hint, resultType discriminator, inlined results.
Source: examples/tasks-v2/README.md
MCP Tasks v2 (SEP-2663) — Server-Directed Async + MRTR ¶
Stable — implements SEP-2663 (Tasks v2), merged into the MCP spec.
Server-side implementation of the v2 Tasks extension. v2 inverts v1’s client-driven model: the server decides when to create a task, and clients call tools/call normally with no task hint.
🚀 Skip to the guided walkthrough → — 8-step demokit walkthrough covering the full v2 surface: extension negotiation, polymorphic
tools/call, inlined results, the newtasks/updateMRTR loop, ack-only cancel, and tool-vs-protocol error semantics. Run it withjust serve+just demo.🔁 Migrating from v1? See the v1 → v2 migration guide for the wire-shape diff, server entry points (
tasks.Registerinext/tasks/server.RegisterTasksV1), and the rolling-upgrade recipe.
Key Differences from v1 ¶
| Aspect | v1 (SEP-1036) | v2 (SEP-2663) |
|---|---|---|
| Capability slot | capabilities.tasks |
capabilities.extensions["io.modelcontextprotocol/tasks"] |
| Client opt-in | (none) | client.WithTasksExtension() required |
| Client task hint | task: {ttl, pollInterval} in params |
none — server decides |
Discriminator on tools/call |
absent (use taskId presence) |
resultType: "task" |
| Read endpoints | tasks/get + tasks/result (two RTTs) |
tasks/get only (result inlined) |
Result on terminal tasks/get |
only status — fetch separately | inlined result / error / inputRequests |
| MRTR resume path | side-channel via tasks/result long-poll |
tasks/update (new method) |
tasks/cancel response |
rich task envelope | empty {} ack |
| TTL field | ttl (ms by convention) |
ttlMs (integer milliseconds) |
| Poll-interval field | pollInterval |
pollIntervalMs (integer milliseconds) |
| Tool errors | status: failed |
status: completed, result.isError: true |
| Protocol errors | status: failed, error: ... |
status: failed, error: {code, message, data} |
tasks/list |
exists | removed |
| Mcp-Name HTTP header | not set | set on task-creating responses (SEP-2243) |
Quick Start ¶
just serve # terminal 1: v2 tasks server on :8080
just demo # terminal 2: demokit walkthrough (7 steps)
See WALKTHROUGH.md for the full step-by-step description and sequence diagram.
Agent mode ¶
Moved. An agent driving these task tools is now demonstrated in
chakra, the agent SDK, which was extracted from mcpkit into its
own repository. The point it made still holds and is covered there by host/tasks_test.go and the
agent-async example: a model calls a sync-only tool and a server-directed async tool the same way,
and the SEP-2663 task machinery never surfaces to it.
This example stays focused on the server side.
What it demonstrates ¶
- Tasks-as-extension negotiation —
client.WithTasksExtension()opts in during initialize; servers gate task-creatingtools/calland everytasks/*method on it. - Polymorphic
tools/callwith theresultType: "task"discriminator — the server decides whether to create a task, no client hint required. - Inlined results on terminal
tasks/get—result/error/inputRequestsarrive in one RTT,tasks/resultremoved. - Tool-vs-protocol error semantics — tool errors land in
status: completed, isError: true; protocol errors instatus: failedwith structurederror. - Empty-ack
tasks/cancelplus follow-uptasks/getto observe the resultingcancelledstatus. - The new
tasks/updateMRTR resume path closing the elicitation/sampling loop. - The
Mcp-NameHTTP response header (SEP-2243) carrying taskIds for observability without parsing the body. - TaskCallbacks proxy pattern (external task store) via
external_job.
Tools ¶
| Tool | TaskSupport | What it demonstrates |
|---|---|---|
greet |
forbidden | Sync-only — server returns ToolResult directly, no resultType |
slow_compute |
optional | Server creates a task; client gets resultType: "task" discriminator |
failing_job |
required | Tool error path → terminal completed + isError: true |
protocol_error_job |
required | Protocol error path → terminal failed + error: {...} |
external_job |
required | TaskCallbacks proxy pattern (external task store) |
Conformance ¶
- Scenarios live in the
panyam/mcpconformancefork (branchfeat/tasks-mrtr-extension, upstream Draft PR modelcontextprotocol/conformance#262). Run viajust testconf-tasks-v2from the repo root — points the fork’s vitest run at this binary. - Migration guide:
docs/TASKS_V2_MIGRATION.md - Spec: SEP-2663
Where to look in the code ¶
- v1 example:
examples/tasks/ - Server library:
ext/tasks/tasks.go - Wire types:
core/task_v2.go - Client helpers:
client/tasks.go(ToolCall,GetTask,WaitForTask,UpdateTask,CancelTask) - Conformance scenarios: panyam/mcpconformance —
src/scenarios/server/tasks/ - Local sentinel for mcpkit-stricter scenarios:
conformance/tasks-v2/
Next steps ¶
Walkthrough
Generated from the scripted demo — run it with make serve + make demo. Source: examples/tasks-v2/WALKTHROUGH.md
MCP Tasks v2 (SEP-2663) — Server-Directed Async + MRTR ¶
Walks through the v2 Tasks extension where the server decides whether to create a task — clients no longer send a task hint. Polymorphic tools/call, inlined results, ack-only cancel, and the new tasks/update flow that closes the elicit/sample (MRTR) loop.
What you’ll learn ¶
- Connect to the v2 tasks server (declare extension) —
client.WithTasksExtension()addsio.modelcontextprotocol/tasksto ClientCapabilities.Extensions during initialize. Without that declaration, the v2 server falls through to synchronous tools/call and rejects tasks/* with -32601. - Sync call: greet — ToolCall returns Sync variant —
client.ToolCall(c, name, args)returns a polymorphic*ToolCallResult. For sync tools (no Execution / TaskSupport=forbidden) the server returns a plainToolResultand the helper setsSync(notTask). Callers branch onresult.IsTask(). - slow_compute (no task hint!) — server creates a task → ToolCall returns Task variant — Critical v2 semantics: no
taskparam in the request — the server elects to create a task because slow_compute has TaskSupport=optional. The discriminatorresultType: "task"lights upresult.IsTask()on the helper. The Mcp-Name HTTP header carries the same taskId so HTTP routing/observability can key off it without parsing the body. - failing_job → status: completed, result.isError: true (TOOL error semantics) — In v2, a tool that returns an error result lands in status
completedwithresult.isError: true. The task itself ran to completion — the operation failed but the infrastructure didn’t. Distinct from protocol failures (next step). - protocol_error_job → status: failed, error: {…} (PROTOCOL error semantics) — Protocol errors (panics, framework bugs, things that aren’t the tool’s fault) land in status
failedwith the error inlined aserror: {code, message, data}mirroring the JSON-RPC error shape. The host should treat this as ‘something is broken’, not ’the tool said no’. - confirm_delete → input_required → tasks/update → completed (SEP-2663 MRTR) — This is the new SEP-2663 MRTR loop: the tool blocks on
TaskElicit, the task parks ininput_required,tasks/getsurfaces the pending request underinputRequests(server-minted opaque keys), andclient.UpdateTaskdelivers the matching response so the goroutine resumes. Cancellation during input_required propagates via ctx.Done() — seeTestV2_ElicitCancelUnblocksin server tests. - Cancel a long-running task → empty ack, status settles to cancelled — Same cooperative cancellation as v1 (server cancels the goroutine context; tools that select on ctx.Done() exit cleanly), but the response shape changed: SEP-2663 cancel returns an empty
{}ack. Observe thecancelledstatus via the nexttasks/get(orWaitForTaskwhich does it for you).
Flow ¶
sequenceDiagram
participant Host as MCP Host (this client)
participant Server as MCP Server (just serve)
Note over Host,Server: Step 1: Connect to the v2 tasks server (declare extension)
Host->>Server: POST /mcp — initialize (declares io.modelcontextprotocol/tasks)
Server-->>Host: serverInfo + tasks extension advertised under capabilities.extensions
Note over Host,Server: Step 2: Sync call: greet — ToolCall returns Sync variant
Host->>Server: tools/call: greet {name: "world"}
Server-->>Host: ToolResult (no resultType discriminator → ToolCallResult.Sync)
Note over Host,Server: Step 3: slow_compute (no task hint!) — server creates a task → ToolCall returns Task variant
Host->>Server: tools/call: slow_compute {seconds: 3}
Server-->>Host: {resultType: "task", taskId, status: working, ttlMs, ...}
+ Mcp-Name: <taskId> response header (SEP-2243)
Note over Host,Server: Step 4: failing_job → status: completed, result.isError: true (TOOL error semantics)
Host->>Server: tools/call: failing_job → CreateTaskResult
Host->>Server: WaitForTask polls tasks/get until terminal
Server-->>Host: {status: completed, result: {isError: true, content: [...]}}
Note over Host,Server: Step 5: protocol_error_job → status: failed, error: {...} (PROTOCOL error semantics)
Host->>Server: tools/call: protocol_error_job → CreateTaskResult
Host->>Server: WaitForTask polls tasks/get until terminal
Server-->>Host: {status: failed, error: {code, message}}
Note over Host,Server: Step 6: confirm_delete → input_required → tasks/update → completed (SEP-2663 MRTR)
Host->>Server: tools/call: confirm_delete {filename: "important.txt"}
Server-->>Host: {resultType: task, taskId, status: working, ...}
Host->>Server: GetTask (polled until status = input_required)
Server-->>Host: DetailedTask {status: input_required, inputRequests: { "elicit-N": {method, params} }}
Host->>Server: tasks/update {taskId, inputResponses: { "elicit-N": {action: accept, content: {confirm: true}} }}
Server-->>Host: {} (empty ack)
Host->>Server: WaitForTask until terminal
Server-->>Host: {status: completed, result: {content: ["deleted 'important.txt'"]}}
Note over Host,Server: Step 7: Cancel a long-running task → empty ack, status settles to cancelled
Host->>Server: tools/call: slow_compute {seconds: 10}
Server-->>Host: {resultType: task, taskId, ...}
Host->>Server: client.CancelTask
Server-->>Host: {} (empty ack — SEP-2663 cancel returns no task state)
Host->>Server: WaitForTask polls tasks/get
Server-->>Host: {status: cancelled}
Steps ¶
Setup ¶
Start the MCP server in a separate terminal first:
Terminal 1: just serve # tasks-v2 server on :8080
Terminal 2: just demo # this demo
v1 vs v2 — what changed ¶
v1 (SEP-1036, MCP spec 2025-11-25) had the client hint at task vs sync via a task param. v2 (SEP-2663, in-flight) flips the contract:
- Tasks is an extension (
io.modelcontextprotocol/tasks). Clients declare support duringinitialize; servers gate every task-creatingtools/calland everytasks/*method on the negotiation. - No client task hint. Just call
tools/callnormally — the server decides whether to run sync or create a task. The clientclient.ToolCallhelper returns a polymorphicToolCallResultwith eitherSyncorTaskpopulated. resultTypediscriminator ontools/callresponse:"task"means a task was created; absent means sync.tasks/getreturnsDetailedTaskwith inlinedresult/error/inputRequests/requestStateper status. No separatetasks/resultround-trip.tasks/cancelreturns an empty ack. Observe the resultingcancelledstatus with the nexttasks/get.tasks/updateis the SEP-2663 resume path for MRTR input rounds — the client deliversinputResponseskeyed to whateverinputRequeststhe server emitted.- Wire fields renamed:
ttlMs,pollIntervalMs(both integer milliseconds, per the 2026-05-07 SEP-2663 commit aligning duration suffixes).parentTaskIdremoved. - Mcp-Name HTTP header (SEP-2243) carries the new taskId on task-creating responses.
- Error semantics: tool errors →
status: completed, isError: true. Protocol errors →status: failed+errorobject. tasks/resultandtasks/listremoved —tasks/getis the single read endpoint.
Step 1: Connect to the v2 tasks server (declare extension) ¶
client.WithTasksExtension() adds io.modelcontextprotocol/tasks to ClientCapabilities.Extensions during initialize. Without that declaration, the v2 server falls through to synchronous tools/call and rejects tasks/* with -32601.
Reproduce on the wire ¶
# initialize MUST declare the tasks extension under capabilities.extensions,
# else the server treats you as a v1/sync-only client and rejects tasks/* with -32601.
SID=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{"extensions":{"io.modelcontextprotocol/tasks":{}}}}}' \
-D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
# notifications/initialized completes the handshake (no response body)
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
echo "SID=$SID"
Step 2: Sync call: greet — ToolCall returns Sync variant ¶
client.ToolCall(c, name, args) returns a polymorphic *ToolCallResult. For sync tools (no Execution / TaskSupport=forbidden) the server returns a plain ToolResult and the helper sets Sync (not Task). Callers branch on result.IsTask().
Reproduce on the wire ¶
# v2 tools/call has NO task hint — just name+arguments; the server decides.
# A sync tool returns a plain ToolResult: no .result.resultType discriminator.
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"greet","arguments":{"name":"world"}}}' \
| jq '.result'
Step 3: slow_compute (no task hint!) — server creates a task → ToolCall returns Task variant ¶
Critical v2 semantics: no task param in the request — the server elects to create a task because slow_compute has TaskSupport=optional. The discriminator resultType: "task" lights up result.IsTask() on the helper. The Mcp-Name HTTP header carries the same taskId so HTTP routing/observability can key off it without parsing the body.
Reproduce on the wire ¶
# Same plain tools/call (no hint). The server elects a task: the response carries
# .result.resultType=="task", .result.taskId, .result.status, ttlMs/pollIntervalMs (int ms),
# plus an Mcp-Name: <taskId> response header. Capture the taskId for tasks/get.
R=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":3,"label":"demo"}}}')
echo "$R" | jq '.result'
TID=$(echo "$R" | jq -r '.result.taskId')
# poll tasks/get until terminal; the DetailedTask inlines the result (no tasks/result in v2)
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq '.result'
Step 4: failing_job → status: completed, result.isError: true (TOOL error semantics) ¶
In v2, a tool that returns an error result lands in status completed with result.isError: true. The task itself ran to completion — the operation failed but the infrastructure didn’t. Distinct from protocol failures (next step).
Reproduce on the wire ¶
# Create the task, then poll tasks/get. A TOOL error settles as
# status: completed with result.isError: true (the result is inlined).
R=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"failing_job","arguments":{}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq '.result' # => {status:"completed", result:{isError:true, content:[...]}}
Step 5: protocol_error_job → status: failed, error: {…} (PROTOCOL error semantics) ¶
Protocol errors (panics, framework bugs, things that aren’t the tool’s fault) land in status failed with the error inlined as error: {code, message, data} mirroring the JSON-RPC error shape. The host should treat this as ‘something is broken’, not ’the tool said no’.
Reproduce on the wire ¶
# Same create+poll, but a PROTOCOL error settles as status: failed with the
# error inlined under .result.error (code+message), not under result.isError.
R=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"protocol_error_job","arguments":{}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq '.result' # => {status:"failed", error:{code,message}}
Step 6: confirm_delete → input_required → tasks/update → completed (SEP-2663 MRTR) ¶
This is the new SEP-2663 MRTR loop: the tool blocks on TaskElicit, the task parks in input_required, tasks/get surfaces the pending request under inputRequests (server-minted opaque keys), and client.UpdateTask delivers the matching response so the goroutine resumes. Cancellation during input_required propagates via ctx.Done() — see TestV2_ElicitCancelUnblocks in server tests.
Reproduce on the wire ¶
# Create the task, poll until status:input_required, then read the opaque key
# the server minted under .result.inputRequests, and resume with tasks/update.
R=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"confirm_delete","arguments":{"filename":"important.txt"}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
# poll tasks/get until status:input_required, then grab the server-minted opaque key
KEY=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq -r '.result.inputRequests | keys[0]')
# tasks/update delivers the matching response keyed by that opaque key → empty {} ack
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tasks/update\",\"params\":{\"taskId\":\"$TID\",\"inputResponses\":{\"$KEY\":{\"action\":\"accept\",\"content\":{\"confirm\":true}}}}}" \
| jq '.result' # => {} ack; next tasks/get shows status:completed
Step 7: Cancel a long-running task → empty ack, status settles to cancelled ¶
Same cooperative cancellation as v1 (server cancels the goroutine context; tools that select on ctx.Done() exit cleanly), but the response shape changed: SEP-2663 cancel returns an empty {} ack. Observe the cancelled status via the next tasks/get (or WaitForTask which does it for you).
Reproduce on the wire ¶
# Start a long task, cancel it (empty {} ack), then observe cancelled via tasks/get.
R=$(curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream, application/json' \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":10,"label":"to-cancel"}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
# tasks/cancel returns an empty {} ack — no task state in the response (SEP-2663)
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tasks/cancel\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq '.result' # => {}
# follow-up tasks/get shows the settled status
curl -s -X POST http://localhost:8080/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TID\"}}" \
| jq '.result.status' # => "cancelled"
Where each piece lives in mcpkit ¶
- v2 server library:
server/tasks_v2.go(RegisterTasks, gating, MRTR runtime) - v2 wire types (
CreateTaskResult,DetailedTask,TaskInfoV2,UpdateTaskRequest,ResultTypeTask):core/task_v2.go(SEP-2663) - v2 client helpers (
ToolCall,GetTask,UpdateTask,WaitForTask,CancelTask):client/tasks.go - Conformance scenarios: panyam/mcpconformance fork (branch feat/tasks-mrtr-extension, upstream Draft PR modelcontextprotocol/conformance#262)
- Local sentinel for mcpkit-stricter tests:
conformance/tasks-v2/
Run it ¶
go run ./examples/tasks-v2/
Pass --non-interactive to skip pauses:
go run ./examples/tasks-v2/ --non-interactive