
MCP Tasks: How Long-Running MCP Tools Stop Timing Out
The MCP Tasks extension lets a server answer tools/call with a task handle instead of blocking until the work finishes. You get back a result whose resultType is "task", carrying a taskId, a status, a TTL and a suggested poll interval. You then poll tasks/get until the status is terminal. Two-minute crawls, batch renders and report builds stop dying at whatever read timeout sits between your client and your server — and they survive a client restart.
The Problem Nobody Designs For Until It Bites
Every MCP server starts out with fast tools. Read a row, format a string, hit an API, return in 300ms. Then somebody asks for the tool that actually earns its keep, and it takes ninety seconds.
I run a self-hosted SEO audit server behind an MCP endpoint. Its main tool crawls a site and then runs Lighthouse over the pages it found. On a 30-page crawl that is roughly a minute of crawling and another forty seconds of Lighthouse after the page count is already full. Perfectly reasonable work. Completely impossible to express as a blocking JSON-RPC call, because between my client and that server sits Cloudflare, a reverse proxy and a Node HTTP server, and every one of them has an opinion about how long a response is allowed to take.
So I did what everyone does. I split it into three tools: one that kicks the audit off, one that reports status, one that returns the issues. Then I wrote the poll loop in the client. It worked. It also cost me a full afternoon of debugging that had nothing to do with SEO, which I will come back to, because every bug I hit is something the Tasks extension now specifies away.
The one-line version:
A synchronous request cannot model long-running work. Tasks stop pretending it can — the server hands back a claim ticket and the client comes back for the result.
What the Tasks Extension Actually Is
Tasks shipped in the 2026-07-28 specification as an official extension identified as io.modelcontextprotocol/tasks, defined by SEP-2663. It is not new in spirit — an experimental tasks feature landed in the core protocol back in 2025-11-25 — but production use surfaced enough design problems that the working group pulled it out of core, redesigned it, and shipped it as an extension so it can evolve without waiting on the core release cadence.
Three things changed, and all three matter if you wrote against the old one.
The handshake collapsed to one point. Previously a client had to prime itself with a tools/list call to learn which tools accepted task augmentation, then attach a task parameter only to those requests. Now the client declares the extension once, in its per-request capabilities under _meta, and the server decides per request whether to materialise a task. No warmup call, no per-tool bookkeeping.
Task creation became server-directed.The client does not ask for a task and cannot refuse one. Declaring the capability means “I can handle either shape”, and a compliant client must branch on the result it actually gets. A server that never elects to create a task is still fully compliant.
The blocking method is gone. tasks/result used to block until the whole operation finished, which forced long-lived connections nobody wanted. It has been replaced by polling on tasks/get plus a new tasks/update for feeding input back in. tasks/list was deleted outright.
That last deletion is a security decision, not a simplification. With sessions removed from the protocol entirely there is no natural scope a server can use to decide which tasks belong to which caller, so a list endpoint would risk handing one caller another caller's task IDs. No list, no leak.
The Task Handle, Field by Field
When a server converts a call into a task, it answers the original tools/call with a CreateTaskResult. Same JSON-RPC id, different shape:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "task",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"statusMessage": "Crawling page 12 of 30.",
"createdAt": "2026-08-18T10:30:00Z",
"lastUpdatedAt": "2026-08-18T10:30:00Z",
"ttlMs": 600000,
"pollIntervalMs": 5000
}
}resultType is the discriminator. Every result in the 2026-07-28 spec carries one — "complete" for an ordinary result, "input_required" for a multi round-trip interim, and "task" for a handle. Branch on it and nothing else.
ttlMs is the time-to-live from creation in milliseconds, or null for unlimited, and it can change over the task's life. Treat it as a backstop: past createdAt plus ttlMs the server is allowed to mark the task failed and delete it, and a later tasks/get will come back -32602.
pollIntervalMs is the server telling you how often it wants to be asked. Honour it. Servers are explicitly permitted to rate-limit clients that poll faster, and if you have ever watched a client hammer get_status every 500ms against a job that takes two minutes, you know exactly why that clause exists.
One rule saves you an entire class of race condition: a server must not return the handle until the task is durably created, meaning a tasks/get for that ID would already resolve. If you hold the handle, the task exists. No speculative first poll, no retry-on-404 dance.
Five States and Three Methods
A task is a state machine with exactly five states. Three are terminal.
| Status | Terminal | What the tasks/get response carries |
|---|---|---|
working | No | Status only. Use statusMessage for progress you can show a user. |
input_required | No | An inputRequests map you must answer via tasks/update. |
completed | Yes | A result field shaped like the original request's result. |
failed | Yes | An error field with the JSON-RPC error that killed it. |
cancelled | Yes | Status only. |
The distinction that catches everyone: a tool that returns isError: true is a completed task, not a failed one. failed is reserved for JSON-RPC protocol errors during execution. Completed means the tool ran and had an opinion; failed means the call itself broke. Handle both branches or you will quietly swallow every tool-level error your server produces.
The method surface is three calls, all keyed by taskId:
- tasks/get — read the current state. Returns the task with result, error or inputRequests inlined depending on status. An unknown or expired taskId returns -32602.
- tasks/update — write. Supply inputResponses keyed to outstanding inputRequests. Returns an empty ack, and that ack is eventually consistent: the task's visible status may not reflect your answer yet.
- tasks/cancel — ack-only, cooperative. The server must acknowledge, but it is not obligated to stop the work, and the task may still land on completed instead of cancelled.
Polling is the baseline, not the only option. A server may also push notifications/tasks updates, which you opt into through subscriptions/listen. That is a nice optimisation and a bad dependency — build the poll loop first, treat notifications as a latency win.
What Rolling My Own Cost Me
Here is the honest case for adopting the extension instead of shipping what I shipped. Every one of these was a real bug in my hand-rolled version, and the spec has an answer for each.
My status payload was nested one level deeper than I assumed. The status tool returned a structure where the state string sat two keys down, so my poll loop compared a dictionary against the string "completed", never matched, and burned every retry before giving up on a job that had actually succeeded. Tasks give you a flat, specified status field with five known values. Nothing to guess.
My results endpoint returned an empty body when the payload got too large. Asking for 200 issues produced zero bytes on stdout, zero on stderr, exit code 0 — indistinguishable from a dead endpoint. Fifty issues worked fine. I diagnosed it as a 502 and went looking for an outage that did not exist. Under Tasks the result travels inline on a completed task with a defined shape, and a genuine failure is a failed status carrying an actual JSON-RPC error rather than silence.
I invented my own polling cadence. I guessed. Sometimes I hammered, sometimes I waited too long. pollIntervalMs is the server answering that question with information I do not have, and it may revise the number as the job progresses.
My job IDs did not survive a restart. The whole thing lived in process memory, so a crash orphaned the work. The spec tells clients to persist task IDs to durable storage precisely so polling can resume — and because the handle is all the state the client needs, that is a one-column table.
None of that is exotic. It is the same set of mistakes every team makes the first time they bolt async onto a synchronous protocol, which is exactly the argument for having it specified once rather than reinvented per server. If you are weighing what belongs in an MCP server versus a shell script in the first place, I went through that trade in MCP vs CLI for AI agents.
Migrating From the 2025-11-25 Tasks
The two are not wire-compatible. If you built against the experimental feature, four things break:
- tasks/result is removed. Clients that call it get -32601 Method Not Found. Replace it with a tasks/get poll loop.
- The task parameter on CallToolRequest is removed. Servers must ignore it as an unknown field rather than treating it as an opt-in.
- tasks/list is gone. There is no supported way to enumerate tasks; hold your own IDs.
- The legacy tasks.requests.*, tasks.cancel and tasks.list capability declarations are replaced by the single io.modelcontextprotocol/tasks extension, and servers must stop advertising the old ones under any protocol version that includes the extension.
If you need to serve both generations, shim at the SDK level: implement the legacy and extension flows in parallel and dispatch on the negotiated protocol version plus whichever capability the peer actually declared. Servers supporting both should keep letting legacy clients call tasks/get and tasks/cancel against tasks created under the old flow.
Tasks is not the only thing that moved in this release. Sessions and the initialize handshake are gone, server-initiated requests were replaced by the multi round-trip pattern, and Roots, Sampling and Logging all entered a twelve-month deprecation window. I covered that last shift in why MCP sampling is deprecated and what elicitation replaces it with. If you are exposing any of this over the network, pair it with proper remote MCP server authentication — task IDs can act as bearer tokens for stored state, so the spec requires enough entropy that nobody can enumerate them and an authorization check on every single task request.
When You Should Not Reach for Tasks
A task is a state machine you now own. It has storage, a TTL, an authorization boundary and a cleanup story. That is real cost, and it is wasted on a tool that returns in under a second.
There is a token cost too, and it is easy to miss. Every poll is a round trip through the agent loop, and if your client surfaces each one to the model, a two-minute job at a five-second interval is roughly two dozen extra turns of context for zero new information. Drive the poll loop inside your client and surface only the final result — the spec explicitly blesses this, noting that existing code returning a fixed shape can drive polling internally and expose just the completed result. I broke down where that kind of overhead actually lands in the real token cost of an MCP server.
My rule after living with both: if the tool can finish inside your tightest proxy timeout with headroom to spare, keep it synchronous. If it cannot, or if it ever needs to survive a client restart, make it a task. There is no middle ground worth building.
And if you are wiring MCP into an orchestrator rather than writing a server from scratch, the same question shows up one layer up — I compared those two surfaces in n8n MCP Server vs MCP Server Trigger.
Read the primary sources rather than the summaries: the 2026-07-28 changelog for everything that moved in this release, and SEP-2663 for the full task schema, error codes and message flow. Both are shorter than the blog posts about them.
Got an MCP Tool That Keeps Timing Out?
I build and run MCP servers in production — auditing, attribution, content pipelines — and most of the pain is never the model. It is timeouts, auth, and a poll loop somebody wrote at 2am. If you have a tool that works locally and dies behind your proxy, that is a fixable afternoon, not a rewrite.
Related Posts
AI Agents
MCP Sampling Is Deprecated. Use Elicitation Instead.
Spec revision 2026-07-28 deprecated Sampling, Roots and Logging together under SEP-2577, with removal eligible from 2027-07-28. The migration path is one line: call LLM provider APIs directly. Elicitation survived — because asking the human is the one thing no provider API can do for you — and it grew a URL mode that's now mandatory for anything secret. Plus the delivery change that turns blocking tool handlers into re-entrant ones, and the phishing attack hiding in URL mode.
AI Agents
MCP Apps: Interactive UI Inside Your AI Client
MCP Apps are the first official Model Context Protocol extension (shipped Jan 26, 2026): an MCP tool can now return a real interface — a dashboard, form, chart, or multi-step wizard — that the client renders in a sandboxed iframe right inside the chat, instead of plain text. Three parts make it work: a ui:// resource (bundled HTML/JS), a tool linked to it via _meta.ui.resourceUri, and an App class that speaks two-way JSON-RPC over postMessage so the UI can receive the tool result, call server tools, and push the user's selection back into the model's context. It's a cross-client standard — Claude, ChatGPT, VS Code, and Goose already render the same UI resource. Reach for an App only when the user needs to see or manipulate something; plain text tools still win for short answers. Bonus: rendering data in a UI instead of narrating 500 rows back through the model can cut token cost, not add it.
AI Agents
MCP Server Security: How to Stop Tool Poisoning
Tool poisoning is when an MCP server hides instructions inside its own tool descriptions — text your agent reads as commands and you almost never see. The model obeys it because, inside the context window, a description and a system prompt are the same kind of thing, which is why no system prompt fixes this. The four controls that hold are structural: approve individual tools instead of whole servers, pin exact versions and diff the tool descriptions in CI so a rug pull is a failed build, keep secrets out of the model's context entirely, and run local servers in a container with no network access.