Tasks (v1)
Async tool lifecycle: sync calls, optional async, polling, progress notifications, cancellation.
Source: examples/tasks/README.md
Tasks Example Server ¶
Stable (frozen) — MCP Tasks v1 (spec 2025-11-25). Superseded by tasks-v2 (SEP-2663) for new work.
SEP-2577 deprecation note: this example demonstrates
TaskContext.TaskSample(...), which is deprecated per SEP-2577 (scheduled for removal in mcpkit v0.4). The code still works on v0.3.x. Seedocs/SEP_2577_DEPRECATIONS.mdfor the migration story.
Demonstrates MCP Tasks (spec 2025-11-25) — async tool execution with lifecycle tracking.
🚀 Skip to the guided walkthrough → — 8-step demokit walkthrough with sequence diagram covering sync calls, optional async tasks, polling, progress notifications via SSE, required-task tools, and cancellation. Run it with
just serve+just demo.
What it demonstrates ¶
- The three task-support modes on
core.ToolDef.Execution: sync-only (no Execution), optional (client picks sync vs async), required (must be async). - Task lifecycle states (
queued→running→completed/failed/cancelled) and the polling pattern viatasks/get. - Progress notifications streamed over the GET SSE channel while a task runs.
- Elicitation from a background task (
TaskContext.TaskElicit) — pausing a task to ask the user a question. - Sampling from a background task (
TaskContext.TaskSample) — pausing a task to query an LLM. - Cancellation propagation via
tasks/cancel, including verifying the work actually stops. - TTL enforcement (tasks expire) and session isolation (cross-session task access denied).
- The external-proxy pattern via
server.TaskCallbacks— per-toolGetTask/GetResultoverrides for tasks living in an external system (Step Functions, etc.).
MCPKit Features Used ¶
| Category | Feature |
|---|---|
| Core | core.ToolDef.Execution, core.TaskSupportOptional, core.TaskSupportRequired, core.DetachForBackground |
| Server | server.RegisterTasks, server.TasksConfig, server.TaskContext, server.GetTaskContext |
| Side-channel | TaskContext.TaskElicit (elicitation from background task), TaskContext.TaskSample (sampling from background task) |
| Callbacks | server.TaskCallbacks (per-tool GetTask/GetResult overrides for external proxy pattern) |
| MCP methods | tasks/get, tasks/result, tasks/cancel, tasks/list |
Setup ¶
Go server (mcpkit) ¶
cd examples/tasks
export PORT=8080
go run . -addr :$PORT
TS SDK reference server (for comparison) ¶
A TypeScript reference server with the same 6 tools is included for side-by-side wire format comparison. It imports the official MCP TypeScript SDK as a dependency:
cd examples/tasks
npm install # first time only
node ts-reference-server.mjs # default port 8080
PORT=8090 node ts-reference-server.mjs # custom port
All curl exercises below produce identical responses from both servers (except task IDs and timestamps). Use test-side-by-side.sh for automated comparison.
Connect a Host ¶
MCPJam, VS Code, or any MCP client: http://localhost:$PORT/mcp
Tools ¶
| Tool | Task Support | Behavior |
|---|---|---|
greet |
forbidden (absent) | Sync-only. Returns greeting immediately. |
slow_compute |
optional | Sleeps N seconds. Sync without hint, async with hint. |
failing_job |
required | Always fails after 1s. Must be called as a task. |
confirm_delete |
required + elicitation | Asks user for confirmation before deleting. |
write_haiku |
required + sampling | Asks the LLM to write a haiku on a topic. |
external_job |
required + TaskCallbacks | Simulates proxying an external job system. |
Important: Host Support Required ¶
MCP Tasks is an experimental protocol extension (spec 2025-11-25). Most MCP hosts don’t support it yet — they will call tools synchronously and ignore the task hints.
Until your host supports tasks, use the curl commands in each exercise below, or run the automated walkthrough:
# Start a server (Go or TS)
go run . -addr :8080 &
# OR: node ts-reference-server.mjs &
# Run all exercises automatically
bash run-exercises.sh 8080
The script runs exercises 1–9, prints each command and its result with sorted JSON keys for easy comparison between Go and TS servers.
Curl Prerequisites ¶
All curl commands below use $SESSION_ID and $TASK_ID env vars so you can copy-paste without manual replacement. Requires jq.
The server may return SSE (data: {...}) or plain JSON depending on the method. The helper function below handles both formats:
# Helper: extract JSON from SSE or plain response, pretty-print, and save to /tmp/mcp-body.json
mcp() {
local raw
raw=$(curl -s "$@")
local json
json=$(echo "$raw" | grep '^data: ' | tail -1 | sed 's/^data: //')
if [ -z "$json" ]; then json="$raw"; fi
echo "$json" | tee /tmp/mcp-body.json | jq .
}
Note: confirm_delete and write_haiku cannot be fully tested with curl because they use the side-channel pattern — the server sends elicitation/sampling requests back to the client during tasks/result, which requires a client that can handle server-initiated requests. Use the Go test suite (go test ./...) for those.
Initialize a session before running any curl exercises:
# Step 1: Initialize — prints response and captures SESSION_ID
# Note: Go server returns plain JSON, TS server returns SSE. This handles both.
RAW=$(curl -s -D /tmp/mcp-headers.txt http://localhost:$PORT/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}')
JSON=$(echo "$RAW" | grep '^data: ' | tail -1 | sed 's/^data: //')
if [ -z "$JSON" ]; then JSON="$RAW"; fi
echo "$JSON" | jq .
export SESSION_ID=$(grep -i mcp-session-id /tmp/mcp-headers.txt | awk '{print $2}' | tr -d '\r')
echo "SESSION_ID=$SESSION_ID"
# Step 2: Send initialized notification
curl -s http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
Phase 1: Core Task Lifecycle ✅ ¶
These exercises work today. Each shows the prompt (for MCP hosts) and the curl equivalent.
1. Sync tool call ¶
| Prompt | Curl |
|---|---|
Greet World |
See below |
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"greet","arguments":{"name":"World"}}}'
Returns immediately: Hello, World!
2. Async computation ¶
| Prompt | Curl |
|---|---|
Run a slow computation for 5 seconds labeled "pi" |
See below |
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":5,"label":"pi"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
Returns a task ID immediately. The computation runs in the background.
3. Check on a running task ¶
| Prompt | Curl |
|---|---|
What's the status of my computation? |
See below |
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
The host polls tasks/get — status transitions from working to completed.
4. Failing job ¶
| Prompt | Curl |
|---|---|
Run the failing job |
See below |
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"failing_job","arguments":{},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Wait 2s, then check status
sleep 2
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
This tool requires task invocation. The job starts, then fails after 1 second — status transitions to failed.
5. Elicitation from a task (confirm_delete) ¶
| Prompt | Curl |
|---|---|
Delete the file important.txt |
See below (partial — curl can’t respond to elicitation) |
# Create the task
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"confirm_delete","arguments":{"filename":"important.txt"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Poll status — should show "input_required" (task is waiting for your confirmation)
sleep 1
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
The task transitions to input_required while waiting for the elicitation response. With curl, the task stays stuck here — the server sent an elicitation request via SSE but curl can’t respond to it.
To complete the flow, use the Go test suite which has a mock elicitation handler:
cd server && go test -run TestTaskElicitE2E -v
6. Sampling from a task (write_haiku) ¶
| Prompt | Curl |
|---|---|
Write a haiku about the ocean |
See below (partial — curl can’t respond to sampling) |
# Create the task
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"write_haiku","arguments":{"topic":"ocean"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Poll status — should show "input_required" (task is waiting for LLM response)
sleep 1
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
Same as confirm_delete — the task transitions to input_required while the server waits for a sampling response. Curl can’t provide one.
To complete the flow:
cd server && go test -run TestTaskSampleE2E -v
7. Cancel a running task ¶
| Prompt | Curl |
|---|---|
Run a slow computation for 30 seconds, then cancel it |
See below |
# Start a long computation
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":30,"label":"long"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Cancel it
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tasks/cancel\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
Start a long computation, then cancel before it finishes. Status transitions to cancelled.
8. List all tasks ¶
| Prompt | Curl |
|---|---|
List all tasks |
See below |
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":9,"method":"tasks/list","params":{}}'
Shows all tasks with their current status.
Phase 2: TTL Enforcement ✅ ¶
Status: Implemented. Tasks are automatically cleaned up after TTL expires.
9. Task expires after TTL ¶
| Prompt | Curl |
|---|---|
| (curl-only — needs custom TTL param) | See below |
# Create a task with short TTL (5 seconds)
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":2,"label":"short"},"task":{"ttl":5000}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Wait for TTL to expire
sleep 7
# Poll — should be gone
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
Expected: Task not found — it was cleaned up after TTL expired.
Behavior: Timer resets on result storage and cancellation, so the TTL window starts fresh from the last state change.
Phase 3: Session Isolation ✅ ¶
Status: Implemented. Tasks are scoped to the session that created them.
10. Cross-session task access denied ¶
| Prompt | Curl |
|---|---|
| (curl-only — needs two sessions) | See below |
# Initialize session A
curl -s -D /tmp/mcp-headers-a.txt http://localhost:$PORT/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"session-a","version":"1.0"}}}' \
| grep '^data: ' | sed 's/^data: //' | jq .
export SESSION_A=$(grep -i mcp-session-id /tmp/mcp-headers-a.txt | awk '{print $2}' | tr -d '\r')
echo "SESSION_A=$SESSION_A"
curl -s http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_A" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
# Create a task in session A
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_A" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":30,"label":"secret"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Initialize session B
curl -s -D /tmp/mcp-headers-b.txt http://localhost:$PORT/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"session-b","version":"1.0"}}}' \
| grep '^data: ' | sed 's/^data: //' | jq .
export SESSION_B=$(grep -i mcp-session-id /tmp/mcp-headers-b.txt | awk '{print $2}' | tr -d '\r')
echo "SESSION_B=$SESSION_B"
curl -s http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_B" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}'
# Try to access session A's task from session B
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_B" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tasks/get\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
Expected: Task not found — session B can’t see session A’s tasks.
Behavior: Empty sessionID (backward compat) allows access to all tasks.
Phase 4: Store API Alignment ✅ ¶
Status: Implemented. Atomic
StoreTerminalResultwith terminal guard.
11. Double-complete is rejected ¶
Complete a task, then try to store another result (internal API — no curl equivalent).
Expected: Error — can’t store result for terminal task. Second StoreTerminalResult is rejected.
Behavior: Terminal guard prevents cancel→completed race and double-completion.
Phase 5: Cancellation Propagation ✅ ¶
Status: Implemented. Cancel stops the background goroutine via context cancellation.
12. Cancel actually stops the work ¶
| Prompt | Curl |
|---|---|
Run a slow computation for 60 seconds, then cancel it |
See below |
# Start a 60-second computation
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"slow_compute","arguments":{"seconds":60,"label":"long"},"task":{}}}'
export TASK_ID=$(jq -r '.result.task.taskId' /tmp/mcp-body.json)
echo "TASK_ID=$TASK_ID"
# Cancel it
mcp http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"tasks/cancel\",\"params\":{\"taskId\":\"$TASK_ID\"}}"
Expected: Server log shows [slow_compute] cancelled "long" at N/60. Goroutine exits immediately.
Behavior: context.WithCancel propagates to the tool handler’s ctx.Done() channel.
Phase 6: Status Notifications ✅ ¶
Status: Implemented. Status notifications sent via cancel handler and tasks/result handler.
13. Receive status change notifications ¶
| Prompt | Curl |
|---|---|
| (host auto-receives notifications) | See below |
# Terminal 1: open GET SSE stream
curl -N http://localhost:$PORT/mcp \
-H "Mcp-Session-Id: $SESSION_ID" \
-H "Accept: text/event-stream"
# Terminal 2: create a task (same as exercise 2)
Expected (after Phase 6): The GET SSE stream receives notifications/tasks/status events as the task transitions: working → completed.
Today: No notifications. The SSE stream is silent. Clients must poll tasks/get.
Phase 7: Progress Notifications ✅ ¶
Status: Implemented. Progress from background tasks uses client’s
_meta.progressToken.
14. Progress notifications flow through tasks ¶
Create a task and send progress notifications from the tool handler.
Expected (after Phase 7): Progress notifications from the tool handler are associated with the task and delivered to the client.
Today: Progress token from the original tools/call is lost when the task is created.
Phase 8: Sub-Task Threading 🔲 ¶
Status: Not yet implemented. Tracked in #281.
15. Fan-out / join with sub-tasks ¶
| Prompt | Curl |
|---|---|
Deploy the app (build + test in parallel, then push) |
(not yet implemented) |
Expected (after Phase 8):
- Parent task “deploy” created
- Child tasks “build” and “test” created with
parentTaskIdpointing to deploy - Both children run in parallel
- Parent waits for both to complete (join)
- Parent continues with “push”
tasks/listshows the full tree
Today: No sub-task support. The deploy tool would have to run everything sequentially in one goroutine.
16. Cascade cancel ¶
| Prompt | Curl |
|---|---|
Deploy the app, then cancel the deployment |
(not yet implemented) |
Expected (after Phase 8): Cancelling the parent task cascades to all children — build and test are also cancelled.
Today: No cascade. Only the parent task is cancelled.
Screenshots ¶
Async tool returns a task ID immediately ¶
Polling tasks/get — status transitions to completed ¶
failing_job — task transitions to failed after 1 second ¶
Key Files ¶
| File | What |
|---|---|
main.go |
Go server: 6 tools, server.RegisterTasks() |
ts-reference-server.mjs |
TS SDK reference server: same 6 tools, for comparison |
run-exercises.sh |
Runs all README exercises against a running server (Go or TS) |
test-side-by-side.sh |
Starts both servers, compares wire format side-by-side |
package.json |
TS SDK dependencies for the reference server |
../../server/tasks_v1.go |
v1 tasks middleware, handlers, RegisterTasksV1 |
../../server/task_callbacks.go |
TaskCallbacks struct (per-tool GetTask/GetResult overrides) |
../../server/task_store.go |
TaskStore interface + InMemoryTaskStore |
../../server/task_session.go |
TaskContext, TaskElicit, TaskSample |
../../client/tasks_v1.go |
v1 client helpers: GetTaskV1, ToolCallAsTaskV1, etc. |
../../docs/TASKS_V2_MIGRATION.md |
v1 → v2 migration guide (current canonical doc) |
Next steps ¶
Walkthrough
Generated from the scripted demo — run it with make serve + make demo. Source: examples/tasks/WALKTHROUGH.md
MCP Tasks — Async Tool Execution Lifecycle ¶
Walks through the MCP Tasks (SEP-1036) lifecycle: optional/required task support, polling, progress notifications, and cancellation.
What you’ll learn ¶
- Connect to the MCP server — The server advertises tasks capability in initialize. The mcpkit client opens a GET SSE stream so server-pushed notifications (progress, status changes) reach us during polling.
- Sync call: greet (taskSupport=forbidden) — greet is sync-only. The result returns directly in the tools/call response — no task created. This is the path most existing tools use today; tasks are opt-in per tool.
- Optional task: slow_compute as task → CreateTaskResult — slow_compute has taskSupport=optional. Sending the
taskhint tells the server to run it asynchronously. We get a taskId back immediately while the work runs in a background goroutine. - Poll tasks/get until terminal — receive notifications/progress — The server streams progress notifications over the GET SSE channel while the task runs. Our notification callback (set up in Step 1) prints them inline. Once status reaches
completed, the polling stops. - Fetch the result payload via tasks/result — tasks/get returns task status only. To get the actual tool result (content blocks, isError flag, structured content), the host calls tasks/result with the same taskId.
- Required task: failing_job — sync call returns an error — failing_job declares Execution.TaskSupport=required. Sync invocation returns an error telling the host to retry with a task hint. This guards expensive/long tools from blocking the request thread.
- Invoke failing_job as task → terminal status: failed — Errors from required-task tools surface as a terminal status of
failed. The host gets the taskId immediately, polls, and learns the task failed via the status field — no exception thrown on the polling call. - Cancel a long-running task mid-flight — Tasks support cooperative cancellation. The server cancels the goroutine’s context; tools that select on ctx.Done() exit cleanly. Status transitions to
cancelled. mcpkit guards against terminal-to-terminal transitions so a tool finishing normally after cancel doesn’t overwrite the cancelled status.
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 MCP server
Host->>Server: POST /mcp — initialize
Server-->>Host: serverInfo + Mcp-Session-Id + tasks capability
Note over Host,Server: Step 2: Sync call: greet (taskSupport=forbidden)
Host->>Server: tools/call: greet {name: "world"} (no task hint)
Server-->>Host: ToolResult immediately
Note over Host,Server: Step 3: Optional task: slow_compute as task → CreateTaskResult
Host->>Server: tools/call: slow_compute {seconds:3} + task: {ttl: 60s}
Server-->>Host: {taskId, status: working, ttl, pollInterval}
Note over Host,Server: Step 4: Poll tasks/get until terminal — receive notifications/progress
Host->>Server: tasks/get {taskId} (polled every pollInterval)
Server-->>Host: notifications/progress (1/3, 2/3, 3/3) via SSE
Server-->>Host: {status: completed} on terminal poll
Note over Host,Server: Step 5: Fetch the result payload via tasks/result
Host->>Server: tasks/result {taskId}
Server-->>Host: ToolResult
Note over Host,Server: Step 6: Required task: failing_job — sync call returns an error
Host->>Server: tools/call: failing_job (no task hint)
Server-->>Host: JSON-RPC error (taskSupport=required)
Note over Host,Server: Step 7: Invoke failing_job as task → terminal status: failed
Host->>Server: tools/call: failing_job + task hint
Server-->>Host: {taskId, status: working}
Host->>Server: tasks/get (polled)
Server-->>Host: {status: failed, error: "simulated failure"}
Note over Host,Server: Step 8: Cancel a long-running task mid-flight
Host->>Server: tools/call: slow_compute {seconds: 10} + task hint
Server-->>Host: {taskId, status: working}
Host->>Server: tasks/cancel {taskId}
Server-->>Host: ack
Host->>Server: tasks/get (final)
Server-->>Host: {status: cancelled}
Steps ¶
Setup ¶
Start the MCP server in a separate terminal first:
Terminal 1: just serve # tasks server on :8080
Terminal 2: just run # this demo
How tasks work ¶
Each tool’s Execution.TaskSupport declares whether it can run as a task:
- forbidden (or absent): sync-only. Calling with a
taskhint returns an error. - optional: client chooses.
tools/callblocks for the result;tools/callwithtaskhint returns aCreateTaskResultimmediately. - required: must be invoked as a task. Direct sync calls return an error.
For task invocations, the server responds with {taskId, ttl, pollInterval}. The host polls tasks/get until the status is terminal (completed, failed, or cancelled), then fetches the result via tasks/result. Progress notifications stream over the session’s GET SSE channel.
Step 1: Connect to the MCP server ¶
The server advertises tasks capability in initialize. The mcpkit client opens a GET SSE stream so server-pushed notifications (progress, status changes) reach us during polling.
Reproduce on the wire ¶
# initialize: capture the Mcp-Session-Id response header into $SID
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":{}}}' \
-D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
# notifications/initialized: complete 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 (taskSupport=forbidden) ¶
greet is sync-only. The result returns directly in the tools/call response — no task created. This is the path most existing tools use today; tasks are opt-in per tool.
Reproduce on the wire ¶
# tools/call with no task hint: result returns inline under .result
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: Optional task: slow_compute as task → CreateTaskResult ¶
slow_compute has taskSupport=optional. Sending the task hint tells the server to run it asynchronously. We get a taskId back immediately while the work runs in a background goroutine.
Reproduce on the wire ¶
# SEP-1036: the task hint is a sibling of name+arguments under params.
# Response carries {taskId, status, pollInterval, ttl} under .result.
# Capture the taskId for the poll/result steps below.
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"},"task":{"ttl":60000}}}')
echo "$R" | jq '.result'
TID=$(echo "$R" | jq -r '.result.taskId')
Step 4: Poll tasks/get until terminal — receive notifications/progress ¶
The server streams progress notifications over the GET SSE channel while the task runs. Our notification callback (set up in Step 1) prints them inline. Once status reaches completed, the polling stops.
Reproduce on the wire ¶
# tasks/get returns task status only; poll it every pollInterval until terminal.
# progress notifications (1/3, 2/3, 3/3) arrive separately over the GET SSE channel.
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":3,"method":"tasks/get","params":{"taskId":"'"$TID"'"}}' \
| jq '.result'
Step 5: Fetch the result payload via tasks/result ¶
tasks/get returns task status only. To get the actual tool result (content blocks, isError flag, structured content), the host calls tasks/result with the same taskId.
Reproduce on the wire ¶
# tasks/result returns the actual ToolResult (content blocks, isError, structured)
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":"tasks/result","params":{"taskId":"'"$TID"'"}}' \
| jq '.result'
Step 6: Required task: failing_job — sync call returns an error ¶
failing_job declares Execution.TaskSupport=required. Sync invocation returns an error telling the host to retry with a task hint. This guards expensive/long tools from blocking the request thread.
Reproduce on the wire ¶
# tools/call with no task hint on a required-task tool: response carries a
# JSON-RPC .error telling the host to retry with a task hint.
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":5,"method":"tools/call","params":{"name":"failing_job","arguments":{}}}' \
| jq '.error'
Step 7: Invoke failing_job as task → terminal status: failed ¶
Errors from required-task tools surface as a terminal status of failed. The host gets the taskId immediately, polls, and learns the task failed via the status field — no exception thrown on the polling call.
Reproduce on the wire ¶
# create the task (task hint sibling under params), capture taskId
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":"failing_job","arguments":{},"task":{"ttl":60000}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
# poll tasks/get: the error surfaces as a terminal status: failed (not a JSON-RPC error)
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":7,"method":"tasks/get","params":{"taskId":"'"$TID"'"}}' \
| jq '.result'
Step 8: Cancel a long-running task mid-flight ¶
Tasks support cooperative cancellation. The server cancels the goroutine’s context; tools that select on ctx.Done() exit cleanly. Status transitions to cancelled. mcpkit guards against terminal-to-terminal transitions so a tool finishing normally after cancel doesn’t overwrite the cancelled status.
Reproduce on the wire ¶
# start a long task, capture taskId
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":"slow_compute","arguments":{"seconds":10,"label":"to-cancel"},"task":{"ttl":60000}}}')
TID=$(echo "$R" | jq -r '.result.taskId')
# tasks/cancel: server cancels the goroutine's context (cooperative)
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":9,"method":"tasks/cancel","params":{"taskId":"'"$TID"'"}}' \
| jq '.result'
# final tasks/get shows status: cancelled
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":10,"method":"tasks/get","params":{"taskId":"'"$TID"'"}}' \
| jq '.result'
Where each piece lives in mcpkit ¶
- Tasks server library:
server/task_*.go,server/tasks_experimental.go - TaskContext (used by required-task tools):
core.TaskContext—core/task.go - Client helpers:
client/tasks.go—ToolCallAsTask,WaitForTask,GetTask,GetTaskPayload,CancelTask - Tool declares task support via
core.ToolDef.Execution.TaskSupport(forbidden|optional|required)
For elicitation/sampling from inside a task (the confirm_delete and write_haiku tools also registered on this server), see examples/tasks/run-exercises.sh.
Run it ¶
go run ./examples/tasks/
Pass --non-interactive to skip pauses:
go run ./examples/tasks/ --non-interactive