Tasks & Long-Running Work

11 min read

C12
Deep Dive · MCP

Tasks reads like an async convenience bolted onto MCP. It is not — it is where durability went when the session was deleted, and the server, not you, decides when you get one.

Every mechanism that used to carry work across a broken connection — the session id, SSE resumability, Last-Event-ID, server-initiated requests — was removed in 2026-07-28, and a task handle is the only thing left that survives a reconnect. So a client that treats the io.modelcontextprotocol/tasks extension as optional has not declined a feature; it has declared that every call it makes must finish inside one HTTP response. And because task creation is server-directed, the server sets the three numbers that decide whether your long job completes at all: how long the handle lives, how often you may ask, and what happens to the work when nobody asks again.

STEP 1

Tasks did not get demoted to an extension. It got promoted out of a core that could no longer hold it.

Tasks shipped as an experimental core feature in 2025-11-25 and left core in 2026-07-28, moving to the standalone extension io.modelcontextprotocol/tasks under SEP-2663. Read in isolation that looks like a downgrade: a feature that was in the spec is now in an add-on. Read against the rest of the revision it is the opposite, because of what else left at the same time.

The 2026-07-28 revision deleted the initialize handshake, the Mcp-Session-Id header, GET on the MCP endpoint and with it the standalone SSE stream, Last-Event-ID and therefore stream resumability, and the three server-initiated requests. Count what that removes from a long-running tool call. Under 2025-11-25 a forty-minute build could hold an SSE stream open and emit progress notifications; if the stream broke, the client reconnected with Last-Event-ID and the server replayed. All three of those mechanisms are gone, and the spec's replacement rule for a broken stream is explicit: re-issue the work as a new request with a new request ID.

That rule is only survivable if the work has an identity independent of the request that started it. A task id is that identity. Which means Tasks is not the asynchronous option alongside a synchronous one — for anything that outlives a single response, it is the only option the protocol still offers, and the extension boundary is a packaging decision rather than a statement about how central it is.

The useful mental test: ask what happens to your longest tool call when the load balancer moves the next request to a different replica. Under 2025-11-25 the answer was "sticky routing prevents that". Under 2026-07-28 there is no stickiness to buy, so the answer has to be "the work is addressable by id from any replica" — which is a statement about your storage, not about your transport.

STEP 2

The wire shape: one new resultType, five statuses, three methods, and one ordering rule that catches everyone.

Support is negotiated per request, like everything else in this revision: the client lists io.modelcontextprotocol/tasks in the capabilities it restates inside params._meta on every call, and the server advertises it in its server/discover capabilities. There is no handshake in which this is agreed once.

When the server decides a call will be long-running, it answers the original request immediately with resultType: "task" and a task object — a unique taskId, an initial status, a ttlMs and a pollIntervalMs. The client then drives everything through three methods: tasks/get to poll, tasks/update, and tasks/cancel. The status is one of five values — working, input_required, completed, cancelled, failed — and the transitions are the obvious ones, with working and input_required able to alternate any number of times before one of the three terminal states.

// 1. An ordinary tools/call. The client has declared it can take a task.
{"jsonrpc": "2.0", "id": 11, "method": "tools/call",
 "params": {"name": "reindex_corpus",
            "arguments": {"collection": "support-tickets"},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {
                        "io.modelcontextprotocol/tasks": {}}}}}

// 2. The server declines to answer now. This IS the success case.
{"jsonrpc": "2.0", "id": 11, "result": {
  "resultType": "task",
  "task": {"taskId": "tsk_01JA7Q2X",
           "status": "working",
           // Milliseconds. Not seconds. See STEP 4.
           "ttlMs": 3600000,
           "pollIntervalMs": 5000}}}

// 3. Poll. Any replica can answer this; that is the whole point.
{"jsonrpc": "2.0", "id": 12, "method": "tasks/get",
 "params": {"taskId": "tsk_01JA7Q2X", "_meta": {"...": "..."}}}

The ordering rule is the one that bites implementers, and it is worth stating precisely: a server MUST NOT return the creation result until a tasks/get for that id would resolve. Obvious when written down, routinely violated in practice, because the natural implementation is to generate an id, return it, and then enqueue the job — at which point a fast client polls and gets "unknown task" for a job that exists. Persist first, answer second. The symptom when you get this backwards is a flaky client that works locally and fails under load, which is the most expensive class of bug to find.

Note also what did go away. The 2025-11-25 shape had a blocking tasks/result that let a client wait for completion in one call; it is removed. Polling is the model now, which is a deliberate trade — a blocking call reintroduces exactly the long-held connection the revision was trying to delete.

STEP 3

Server-directed creation is the real design change, and it reverses who owns the decision.

A client cannot ask for a task. It can only declare that it is able to receive one; the server decides, per call, whether this particular invocation becomes a task or returns inline. That single asymmetry is responsible for most of the work the extension creates on both sides.

For a client author it means there is no such thing as a tool call you know is synchronous. Any tools/call, resources/read or prompts/get against a task-capable server may come back as a handle, including one that returned inline a thousand times before — the server is entitled to change its mind when its queue is deep. So the result-handling branch is not an optional code path for the few tools you know are slow; it is the shape of every call site. The anti-pattern is a client that advertises the capability because a library turned it on and then treats resultType: "task" as an unexpected response, which surfaces as a tool that silently returns nothing useful to the model.

For a server author it means the promotion decision is yours and it is a capacity decision, not a semantic one. The honest default is a deadline: if the work will not finish inside the response budget you are willing to hold a connection open for — a few seconds, not a few minutes — promote it. Promoting on tool identity instead ("reindex_corpus is always a task") is tempting and wrong, because the same tool is sub-second on a small collection and an hour on a large one, and you have just forced a round trip onto the common case.

Promotion changes what the model sees. An inline result is content the model reasons over on the next turn; a task handle is a promise with no content in it at all. If your harness does not explicitly give the model something to do while a task is working — wait, do other work, tell the user — the usual failure is that the model treats the empty handle as a completed call and proceeds to summarise work that has not happened. That is the failure-concealment pattern arriving through the protocol rather than through the model.

STEP 4

ttlMs and pollIntervalMs are a retention contract and a load contract, and the units changed under you.

These two fields look like hints. They are the operational bar the extension actually sets, and they are set by the server, which means the client's retry strategy is no longer the client's to choose.

ttlMs is how long the server retains the task, measured from creation. It is a retention policy with a sharp edge: a job whose work takes longer than its own TTL, or a client that goes away for longer than the TTL and comes back, finds the handle gone. Worse, "gone" is indistinguishable from "never existed" unless you design for it. A client that resumes after a crash and gets "unknown task" cannot tell whether the reindex it started finished, failed, or is still running under a handle it can no longer address — and the work may well still be running, because expiring the record does not cancel the job unless you make it. Set the TTL from the worst-case duration of the work plus the worst-case client outage, not from how long you want to keep rows.

pollIntervalMs is the server telling clients how often it is willing to be asked. Honour it. A client that polls a 5-second interval every 200ms is running a small denial-of-service against a server that is, by construction, already busy — and because every request now restates the full handshake in params._meta, a poll is not a cheap request. On the server side, set the interval from the actual granularity of your progress, not from impatience: an interval shorter than the time between real status changes buys the client nothing and costs you the whole poll volume.

The 1000× bug. The 2025-11-25 core shape used ttl and pollInterval in seconds. The extension uses ttlMs and pollIntervalMs in milliseconds. A migration that maps the old field onto the new name without converting produces a TTL a thousand times too short — a one-hour retention becomes 3.6 seconds — and the failure presents as "tasks randomly disappear", not as a type error. Implementations have also been reported advertising the extension while still speaking the 2025-11-25 shape, so a server that advertises it is not a server that implements it; test the wire format rather than the capability list, which is the argument the MCP testing essay makes about every part of this protocol.

STEP 5

input_required is where Tasks meets MRTR, and it is the only reason a long job can ask a question.

Because server-initiated requests were deleted, a task that needs a human decision halfway through — approve this migration, pick between two merge candidates, supply a credential — has no way to interrupt the client. The input_required status is the replacement: the task parks itself, the next tasks/get reports the status along with the pending input requests, and the client answers through the Multi Round-Trip Request pattern the revision introduced for sampling and elicitation. The job does not resume until someone asks and answers.

Two consequences follow and neither is obvious from the state diagram. First, a parked task is burning TTL while it waits for a human, and humans are slower than every other actor in this system — a TTL tuned to the compute time of the job will expire a task that is merely waiting for someone to come back from lunch. Second, a client that polls on a timer but only surfaces terminal states to the user will sit in a loop forever, politely asking a task that is politely waiting for it. input_required must be routed to a human surface the moment it is observed, which makes this a UX obligation rather than a protocol one; the async agent UX playbook is where that queue lives.

The symmetrical mistake on the server is to park on a question nobody can answer. If the task was started by a scheduled run with no human attached, input_required is a deadlock dressed as a status. Servers exposing tools that can need input should take the deadline with them: park, and fail the task when the input does not arrive inside a bound you set, so the caller gets failed with a reason instead of a handle that is working forever.

STEP 6

Operating it: idempotent creation, cooperative cancellation, and the fact that nobody is watching.

Three operational properties finish the picture, and each is a direct consequence of something the revision removed.

  • Creation must be idempotent, because the transport no longer protects it. With stream resumability gone, the client's prescribed response to a broken connection is to re-issue the work as a new request with a new id. A server without an idempotency key therefore starts the forty-minute reindex twice for one network blip, and the second one is invisible to the client, which only ever learned about the second handle. Thread a caller-supplied idempotency key through tool arguments and return the existing task for a repeat — this is the idempotency discipline the rest of your stack already has, newly mandatory here.
  • Cancellation is cooperative and should be treated as a request, not a stop. tasks/cancel moves the record to cancelled; whether the work actually stops depends entirely on whether your job checks. A server that marks cancelled and leaves the job running has built the worst version of this: the client believes it stopped, the invoice says otherwise, and any side effects land after the user was told they would not. Check a cancellation flag at every point where the job is about to do something externally visible.
  • Assume nobody comes back. Polling means the completion of a task is only observed if a client chooses to look, and clients crash, get closed, and change conversations. Every task must therefore have a terminal disposition that does not depend on being read: results durable to the TTL, side effects either completed or compensated, and alerting that fires on tasks that reached failed and were never fetched. The reliability of the job cannot be contingent on the attentiveness of the poller.

Implement the extension server-side before you need it, and set your promotion deadline at the point where you would otherwise be holding a connection open — for most servers that is three to ten seconds. Then spend your design effort on the two numbers rather than the state machine: a ttlMs covering worst-case work plus worst-case client absence, and a pollIntervalMs matching the real granularity of progress. The state machine is five values and will take an afternoon. A TTL that expires under a human approval, or a creation path that is not idempotent, will cost you a production incident each, and both of them present as "tasks are flaky" rather than as the specific bugs they are. For the surrounding operational posture — health, versioning, and what to page on — see MCP ops in production.