The last primitive is the newest, and the only one the spec still marks experimental. Tasks, added in 2025-11-25, turn a request into call-now-fetch-later: instead of blocking until the work is done, the receiver returns a handle immediately, the requestor polls for status, and the real result is fetched later. The Go SDK does not implement tasks yet, so this post reads the spec’s wire shapes and hand-rolls the flow on the jsonrpc package from the grammar post.
This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source
txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision2025-11-25, with the official Go SDK atv1.6.1.
§Call Now, Fetch Later
A normal tool call is one round trip: the request goes out, the server runs the tool, the result comes back. That works until the tool takes minutes, or the connection cannot be held open that long, or the requestor wants to fire off several jobs at once and collect them later. Tasks split the round trip in two. The first phase accepts the work and returns a handle; the second phase retrieves the result once it is ready.
§Augmenting a Request
A request becomes a task by adding a task field to its params, optionally with a ttl in milliseconds for how long the result should be retained. The hand-rolled client sends a tools/call that way:
{ "id": 1, "method": "tools/call",
"params": { "name": "generate_report", "arguments": { "range": "90d" }, "task": { "ttl": 60000 } } }
Tasks are not specific to tool calls. Either side can be the requestor: a client can task-augment a tools/call, and a server can task-augment a sampling/createMessage or an elicitation/create back to the client. The mechanism is the same task field in any of those requests.
§The Handle and the Poll Loop
The receiver does not run the tool and wait. It generates a task id, returns it immediately as a CreateTaskResult, and starts the work in the background:
{ "id": 1, "result": { "task": {
"taskId": "task-7f3a", "status": "working", "statusMessage": "the operation is in progress",
"createdAt": "2026-06-17T17:15:10Z", "lastUpdatedAt": "2026-06-17T17:15:10Z",
"ttl": 60000, "pollInterval": 500 } } }
The id is receiver-generated and unique. The status starts at working, and the pollInterval is the receiver’s hint for how often to check. The requestor then polls with tasks/get, respecting that interval, until the status reaches a terminal state. The captured loop went from working to completed:
-> tasks/get { "taskId": "task-7f3a" }
<- { "status": "working", "lastUpdatedAt": "2026-06-17T17:15:10Z", ... }
-> tasks/get { "taskId": "task-7f3a" }
<- { "status": "completed", "lastUpdatedAt": "2026-06-17T17:15:11Z", ... }
The status lifecycle is the state machine at the top of this post. A task starts at working, may move to input_required if the receiver needs something mid-flight, and ends in one of three terminal states: completed, failed, or cancelled. Terminal is terminal; a finished task never moves again.
§Fetching the Result
A terminal status means the answer is ready. The requestor calls tasks/result, which returns exactly what the original request would have returned, a CallToolResult here, tagged with the related task id:
{ "id": 99, "result": {
"content": [{ "type": "text", "text": "report ready: 3.2M rows summarized" }],
"_meta": { "io.modelcontextprotocol/related-task": { "taskId": "task-7f3a" } } } }
tasks/result blocks until the task is terminal, so a requestor that wants to wait can call it directly, and one that wants to stay responsive can poll tasks/get instead and call tasks/result only once the status flips. A failed task returns the underlying error, including a tool result with isError set. Two more operations round it out: tasks/list enumerates a requestor’s tasks with cursor pagination, and tasks/cancel moves a non-terminal task to cancelled, rejecting an already-terminal one with -32602. Every message tied to a task carries that io.modelcontextprotocol/related-task key so the whole lifecycle can be correlated.
§Negotiating Task Support
A requestor cannot just decide to use a task; the receiver controls it, in two layers. The first is the tasks capability, declared at the handshake, which spells out exactly which requests may be augmented:
"capabilities": { "tasks": {
"list": {}, "cancel": {},
"requests": { "tools": { "call": {} } } } }
A server that lists tasks.requests.tools.call lets clients task-augment tool calls; a client that lists tasks.requests.sampling.createMessage lets servers task-augment sampling. The second layer is per tool. In tools/list, a tool declares execution.taskSupport with one of three values, and it composes with the capability:
"forbidden", the default: the tool must be called normally, and a task-augmented call gets a-32601."optional": the client may call it either way."required": the client must use a task, and a normal call gets a-32601.
So a long-running tool can require task execution, refusing to be called in a way that would block, while a quick tool forbids the overhead.
§Hand-Rolled, Because the SDK Has Not Caught Up
None of the captures above came from the SDK. At v1.6.1, the official Go SDK has no Task type, no execution.taskSupport field, and no tasks capability beyond the generic experimental map. Tasks are in the spec but not yet in the library, which is exactly the situation the wire-first approach of this series is built for. The server here is the jsonrpc package from the grammar post and a dispatch loop:
case "tools/call":
// ... if params has no "task", this tool refuses with -32601 ...
t := &task{ID: "task-7f3a", Status: "working", CreatedAt: now(), TTL: ttl, PollInterval: 500}
st.set(t)
go func() { // the work finishes later
time.Sleep(500 * time.Millisecond)
done := *t
done.Status, done.Result = "completed", marshalCallToolResult()
st.set(&done)
}()
conn.Write(msg.Reply(map[string]any{"task": taskJSON(t)})) // CreateTaskResult, immediately
case "tasks/result":
for { // block until terminal, then return the real result
if t := st.get(taskID); t != nil && t.Status == "completed" {
conn.Write(msg.Reply(t.Result))
break
}
time.Sleep(50 * time.Millisecond)
}
That is the whole shape: accept and hand back a handle, do the work out of band, serve the result on demand. When the SDK adds tasks, it will do this with types and validation, but the messages on the wire will be the ones above.
§Where This Is Heading
Tasks exist because agentic work is getting longer. A model that dispatches a twenty-minute analysis, a batch over millions of rows, or a call into an external job API cannot sit inside a single blocking request, and the progress notifications from earlier only help while the connection holds. A task survives the connection: the result is durable, addressed by id, and fetched whenever the requestor comes back for it. The design is requestor-driven on purpose, so a host can fire several tasks at once and orchestrate them, which is the shape long-running agents are taking. It is experimental and will change, but a tool written to be idempotent and resumable today is a tool ready for it. That is worth doing now, because the direction is clear even if the field names are not final.
txn2/mcp-data-platform does not use tasks yet, the same as the SDK, but it has the use case waiting. A long trino_query reports progress today through notifications/progress; tasks are where that pattern is headed, a query dispatched now and its result fetched when it finishes, surviving a dropped connection.
§What’s Next
Every piece is on the table: the substrate, both transports, all four server primitives, the three the server calls back, the cross-cutting utilities, auth, security, and now tasks. The final post puts them together. A Complete MCP Server and Client in Go builds one server that exposes tools, resources, and prompts, uses sampling and elicitation, runs over both transports, sits behind auth, and is tested end to end, with one annotated wire trace through the whole thing.
The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.