Tasks (v1)

Async tool lifecycle: sync calls, optional async, polling, progress notifications, cancellation.

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. See docs/SEP_2577_DEPRECATIONS.md for 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 (queuedrunningcompleted / failed / cancelled) and the polling pattern via tasks/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-tool GetTask / GetResult overrides 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 StoreTerminalResult with 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: workingcompleted.
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 parentTaskId pointing to deploy
  • Both children run in parallel
  • Parent waits for both to complete (join)
  • Parent continues with “push”
  • tasks/list shows 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

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 task hint 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 task hint returns an error.
  • optional: client chooses. tools/call blocks for the result; tools/call with task hint returns a CreateTaskResult immediately.
  • 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.TaskContextcore/task.go
  • Client helpers: client/tasks.goToolCallAsTask, 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