Long-Running MCP Tools with the Tasks Extension: Polling, Cancellation, and Recovery
The tool works. Locally it returns in ninety seconds, and the agent happily consumes the result. Then you deploy it behind a load balancer, and every call dies at thirty seconds with a 504. The model sees a truncated error, apologizes to the user, and tries again — quietly starting a second ninety-second job that will also die at the same wall.
That loop is the defining failure mode of long-running MCP tools. The protocol's default model is synchronous: the client sends tools/call, the connection stays open, the server replies with a result. That's fine for a weather lookup. It falls apart the moment real work takes longer than whatever proxy, gateway, or serverless runtime sits between the client and your server.
The answer the MCP ecosystem has converged on is the tasks pattern: decouple submitting work from collecting its result. The tool returns a handle immediately; the client polls that handle; the client can cancel; and the server can survive restarts without losing or double-running work. Some servers expose this as a native protocol capability, others implement it as a small trio of tools. The wire-level details are still settling across SDKs, but the engineering underneath is stable, and that's what this article is about.
TL;DR: Treat a long-running tool as a durable state machine, not a blocking function. Return a task handle fast, store task state somewhere that survives restarts, poll with exponential backoff plus jitter, make cancellation cooperative via an abort signal, and gate every submission behind an idempotency key. Recovery is a startup scan that re-leases orphaned running tasks. Short tools should stay synchronous — don't add this machinery for a two-second call.
📋 Table of Contents
- 1. The Thirty-Second Wall
- 2. A Task Is a State Machine, Not a Promise
- 3. Where Task State Actually Lives
- 4. Kicking Off Work Without Blocking
- 5. Polling Without Punishing Your Server
- 6. Cancellation Is Cooperative, and That's the Hard Part
- 7. Idempotency Keys Make Retries Safe
- 8. Progress Updates: Report What's True
- 9. Recovery: Surviving a Restart Mid-Task
- 10. Not Every Tool Needs This
- Choosing the Right Shape
1. The Thirty-Second Wall
Scenario: Your MCP server exposes analyze_dataset. It reads a few million rows, runs aggregations, and returns a summary. Locally it takes 70 seconds. Behind an API gateway with a 30-second idle timeout, the connection is severed mid-run. The agent retries. Now two analyses are running, neither will deliver, and you're paying for both.
Why it matters: The instinct is to "just make it faster" or bump the timeout. Both are dead ends. You can't always make the work faster, and timeout knobs are owned by infrastructure you often don't control — corporate proxies, managed gateways, serverless platforms with hard execution caps. Even if you raise your own timeout, there's usually another hop in the path with a lower limit. And a longer-held connection is fragile in its own right: any network blip kills it and you lose the result of work that already completed.
Solution: Stop holding the connection hostage. The tool call should do one cheap thing — register the intent to do work — and return a reference. The actual work runs independently of the connection that requested it.
Client Server
│ tools/call analyze_dataset │
├──────────────────────────────►│ create task, return handle (<100ms)
│◄──────────────────────────────┤ { taskId: "t_8f3a", status: "pending" }
│ │ (work runs in background)
│ poll t_8f3a │
├──────────────────────────────►│
│◄──────────────────────────────┤ { status: "running", progress: 42 }
│ ... │
├──────────────────────────────►│
│◄──────────────────────────────┤ { status: "completed", result: {...} }
Why this works: The request/response pair is now short and resilient. A dropped connection during polling costs you nothing — you just poll again. The work's lifetime is no longer coupled to any single network connection, which is the property that makes everything downstream (cancellation, recovery, retries) possible.
🔍 Why this matters: The moment submission and completion are decoupled, your tool stops being an RPC and starts being a queue producer. That's a real architectural shift, and it's worth being deliberate about rather than stumbling into it.
2. A Task Is a State Machine, Not a Promise
The biggest modeling mistake is treating a task as "a Promise you saved." A Promise lives in one process, resolves once, and dies. A task is a record with a lifecycle that must be consistent across processes, restarts, and retries.
Model it explicitly:
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
interface Task {
id: string;
type: string; // which operation, e.g. "analyze_dataset"
status: TaskStatus;
input: unknown; // arguments, needed for re-execution on recovery
result?: unknown;
error?: string;
progress?: number; // 0-100, optional
idempotencyKey?: string;
leaseUntil?: number; // epoch ms; see recovery section
attempt: number;
createdAt: number;
updatedAt: number;
}
The legal transitions are narrow, and enforcing them is where correctness comes from:
pending ──► running ──► completed
│ │
│ ├──► failed
│ │
└───────────┴──► cancelled
Two rules worth hardcoding:
- Terminal states are terminal. Once a task is
completed,failed, orcancelled, no further transition is legal. A poll after completion must keep returning the result, and a cancel after completion is a no-op, not an error. - Only
runningtasks produce results. Clients should treat any non-terminal status as "keep polling," and only readresultwhencompleted,errorwhenfailed.
Why this works: Agents are sloppy clients. They'll poll too often, cancel things that already finished, and retry things that already succeeded. A strict state machine means every one of those misbehaviors degrades into a harmless, idempotent read instead of corrupting state.
⚠️ Gotcha: Don't let
cancelledbe reachable fromcompleted. If a cancel request arrives a millisecond after the result is written, return the completed result. Racing cancellation against completion is normal; picking a deterministic winner (completion) keeps the system sane.
3. Where Task State Actually Lives
An in-memory Map<string, Task> is fine for a demo and a trap in production. The instant you have more than one server replica — or you deploy at all — in-memory state is gone on restart and invisible across instances.
The requirement is simple: task state must outlive the process that created it.
| Backend | Durability | Multi-instance | Best for |
|---|---|---|---|
In-memory Map | ❌ Lost on restart | ❌ | Local dev only |
| Redis | ⚠️ With persistence config | ✅ | High-throughput, TTL-friendly workloads |
| Postgres / SQL | ✅ | ✅ | Correctness-critical, auditable work |
| Managed queue (SQS, etc.) | ✅ | ✅ | When you already run one |
For most MCP servers I'd default to Postgres, because the thing you need most is transactional state changes ("claim this task and mark it running, atomically") and a durable audit trail. Redis is a good fit when tasks are numerous, short, and disposable, and you want built-in TTL expiry.
Define a narrow interface so the storage is swappable:
interface TaskStore {
create(task: Omit<Task, "createdAt" | "updatedAt">): Promise<Task>;
findByIdempotencyKey(key: string): Promise<Task | undefined>;
get(id: string): Promise<Task | undefined>;
// Atomic state transitions — the concurrency safety lives here
transition(id: string, from: TaskStatus, to: TaskStatus, patch?: Partial<Task>): Promise<Task | undefined>;
claimOrphaned(now: number, leaseMs: number): Promise<Task[]>; // for recovery
}
The transition method is the important one. It should be a conditional update — UPDATE ... WHERE id = ? AND status = ? — returning whether the transition happened. That single atomic operation is what prevents two workers from both deciding they own the same task.
Practical note: whatever you choose, don't store large results inline. Put big payloads in object storage and keep a reference in the task record. Task rows get scanned constantly during polling; fat rows make every poll slower.
4. Kicking Off Work Without Blocking
Here's the submission tool. The key property: it returns as soon as the task is recorded, not when it's done.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
server.registerTool("analyze_dataset", {
title: "Analyze Dataset",
description:
"Starts a long-running dataset analysis. Returns a task handle immediately. " +
"Poll with get_task_status and cancel with cancel_task.",
inputSchema: {
datasetId: z.string(),
idempotencyKey: z.string()
.describe("Client-generated unique key. Re-sending the same key returns the same task.")
.optional(),
},
annotations: {
readOnlyHint: true, // the analysis itself doesn't mutate external state
idempotentHint: true, // safe to retry with the same idempotency key
},
}, async ({ datasetId, idempotencyKey }) => {
const task = await tasks.submit({
type: "analyze_dataset",
input: { datasetId },
idempotencyKey,
});
return {
content: [{
type: "text",
text: `Started analysis as task ${task.id} (${task.status}). Poll get_task_status with this id.`,
}],
structuredContent: { taskId: task.id, status: task.status },
};
});
The submit call does the idempotency check and enqueues; we'll cover idempotency in section 7. For now, note what's not happening: no await on the actual analysis. The handler persists intent and returns.
A companion polling tool and cancellation tool complete the trio:
server.registerTool("get_task_status", {
title: "Get Task Status",
description: "Returns the current status, progress, and (when done) the result of a task.",
inputSchema: { taskId: z.string() },
annotations: { readOnlyHint: true },
}, async ({ taskId }) => {
const task = await tasks.get(taskId);
if (!task) {
return { isError: true, content: [{ type: "text", text: `No task with id ${taskId}` }] };
}
return {
content: [{ type: "text", text: describeTask(task) }],
structuredContent: {
taskId: task.id,
status: task.status,
progress: task.progress,
result: task.status === "completed" ? task.result : undefined,
error: task.status === "failed" ? task.error : undefined,
},
};
});
Why this works: Three thin, individually-callable tools are portable across every MCP client, including ones that don't understand any newer async capability. If your client and server both support a native tasks capability, you can prefer that — but this tool-trio shape is the safe baseline that everywhere understands.
5. Polling Without Punishing Your Server
Polling is where good designs quietly DDoS themselves. An eager agent, told "poll until done," will happily call get_task_status every 200 milliseconds for ten minutes. That's three thousand calls to learn that nothing changed.
Two defenses, and you need both.
Client-side: exponential backoff with jitter. Start fast (the task might finish quickly), back off, and add randomness so many clients don't synchronize into a thundering herd:
async function pollUntilDone(taskId: string): Promise<unknown> {
let delay = 500; // start optimistic
const maxDelay = 15_000;
for (;;) {
const task = await getTaskStatus(taskId);
switch (task.status) {
case "completed": return task.result;
case "failed": throw new Error(task.error ?? "task failed");
case "cancelled": throw new TaskCancelledError(taskId);
}
// Prefer a server-suggested interval if one is provided
const wait = task.retryAfterMs ?? delay;
await sleep(wait);
// Exponential backoff with ±20% jitter
delay = Math.min(delay * 2, maxDelay) * (0.8 + Math.random() * 0.4);
}
}
Server-side: tell the client how fast to poll. The most underused lever is returning a retryAfterMs hint in the status response. The server knows the expected duration of the work; the client doesn't. If a job is projected to take ten minutes, there's no reason to let anyone poll it more than every few seconds:
function suggestPollInterval(task: Task): number {
if (task.status === "pending") return 1_000; // about to start
if (task.progress != null && task.progress > 90) return 1_000; // nearly done
return 5_000; // otherwise, take it easy
}
Why this works: Backoff bounds the worst case; the server hint tunes the typical case. Together they keep polling load roughly constant regardless of how long tasks run, instead of growing linearly with duration.
💡 Practical note: If your tasks commonly finish in under a second, add a short "long-poll" variant — the status call holds for up to N milliseconds and returns early if the task completes. It cuts latency for fast tasks without needing websockets. Just cap the hold time well below your infrastructure's idle timeout.
6. Cancellation Is Cooperative, and That's the Hard Part
Cancellation in this model is not a kill switch. The server can't safely yank a running computation out from under itself — you'd leak file handles, half-written rows, and rented cloud resources. Instead, cancellation sets a flag, and the worker checks that flag and stops itself at a safe point.
Marking a task cancelled is cheap and atomic:
server.registerTool("cancel_task", {
title: "Cancel Task",
description: "Requests cancellation of a running task. Cancellation is cooperative.",
inputSchema: { taskId: z.string() },
annotations: { destructiveHint: true, idempotentHint: true },
}, async ({ taskId }) => {
const result = await tasks.cancel(taskId); // sets status → cancelled if pending/running
return {
content: [{ type: "text", text: result.message }],
structuredContent: { taskId, status: result.status },
};
});
The real work happens in the runner, which plumbs an AbortSignal through the operation:
async function executeTask(task: Task, signal: AbortSignal) {
try {
const result = await runOperation(task, signal);
await store.transition(task.id, "running", "completed", { result });
} catch (err) {
if (signal.aborted) {
await store.transition(task.id, "running", "cancelled");
} else {
await store.transition(task.id, "running", "failed", { error: String(err) });
}
}
}
async function runOperation(task: Task, signal: AbortSignal) {
for (const chunk of splitIntoChunks(task.input)) {
if (signal.aborted) throw new TaskCancelledError(task.id); // checkpoint
await processChunk(chunk, signal);
await store.transition(task.id, "running", "running", {
progress: nextProgress(),
});
}
return summarize();
}
The critical detail is the checkpoint: a loop that checks signal.aborted between units of work. Cancellation latency is bounded by your checkpoint granularity. If a single processChunk takes 60 seconds and you only check between chunks, "cancel" effectively takes up to a minute to take effect.
Why this works: Cooperative cancellation means you're never interrupted mid-invariant. You stop at a point where cleanup is possible, so you don't leave external systems in a broken intermediate state.
🚨 Production warning: Push the
AbortSignalall the way down into I/O — pass it to your HTTP clients, database queries, and SDK calls. A cancellation that stops your loop but leaves a 90-second upstream HTTP request still in flight hasn't actually cancelled anything; it's just stopped waiting.
There's a subtlety with where the abort signal comes from in a stateless server: the cancellation request arrives on one connection, but the work is running somewhere else. The durable task record is the meeting point. The cancel tool flips the status in the store; the worker notices either by polling its own task's status, by a pub/sub notification, or by checking the store at each checkpoint. Don't assume an in-process signal is enough once you have more than one replica.
7. Idempotency Keys Make Retries Safe
Agents retry. Networks drop. The model decides the first response looked weird and calls the tool again. If a bare retry starts a second task, you've reintroduced the exact double-execution bug the task pattern was supposed to fix.
The fix is an idempotency key: a client-supplied identifier that says "this is the same logical request as before." On submission, the server checks for an existing task with that key and returns it instead of creating a new one.
async submit(req: { type: string; input: unknown; idempotencyKey?: string }): Promise<Task> {
if (req.idempotencyKey) {
const existing = await this.store.findByIdempotencyKey(req.idempotencyKey);
if (existing) return existing; // retry → same task, no duplicate work
}
const task: Task = {
id: randomId("task"),
type: req.type,
status: "pending",
input: req.input,
idempotencyKey: req.idempotencyKey,
attempt: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
};
try {
return await this.store.create(task);
} catch (err) {
if (isUniqueConstraintViolation(err) && req.idempotencyKey) {
// Lost a race to a concurrent submit with the same key — return theirs.
const winner = await this.store.findByIdempotencyKey(req.idempotencyKey);
if (winner) return winner;
}
throw err;
}
}
Note the race handling: two retries can arrive simultaneously, both see "no existing task," and both try to create. A unique index on idempotencyKey guarantees only one wins; the loser catches the constraint violation and returns the winner's task. Checking-then-inserting without the unique index is a race condition.
Why this works: With idempotency keys, retrying a submission is always safe — it converges on a single task. That's what lets clients (and your own infrastructure) retry aggressively on timeout without fear of duplicating expensive or side-effectful work.
Two practical rules:
- Make the key required for anything with side effects. A tool that sends emails or charges money must not accept a retry it can't deduplicate. For pure reads, keys are optional.
- The key should describe the request, not the attempt. If the client generates a fresh UUID per HTTP call, retries get fresh keys and deduplication silently fails. The key must be stable across the retries of one logical operation.
8. Progress Updates: Report What's True
A progress field sounds like a nice touch. In practice it's where servers start lying, and lying about progress is worse than reporting nothing.
Two failure modes to avoid:
- Fake linear progress. Reporting
elapsed / estimated * 100when you have no real estimate produces progress bars that hit 99% and sit there for ten minutes, or jump backwards. Agents and users both notice. - Progress that costs more than the work. Writing a durable progress update after every single row processed turns your task store into a hotspot.
The guidelines I'd follow:
- Only report a number when you genuinely know the fraction done — e.g., you're iterating over a known set of chunks. Otherwise report
nulland rely on status alone. - Throttle writes. Update persisted progress at most every couple of seconds, or at coarse percentage steps, whichever comes later. Keep the fine-grained counter in memory.
- Progress must be monotonic within a run. It's acceptable for it to reset after a recovery-driven restart, but say so in the status text if that can happen.
let lastProgressWrite = 0;
async function maybePersistProgress(task: Task, fraction: number) {
const now = Date.now();
if (now - lastProgressWrite < 2_000) return; // throttle
lastProgressWrite = now;
await store.transition(task.id, "running", "running", {
progress: Math.round(fraction * 100),
});
}
The important part: progress is a convenience for pacing polls and reassuring users. It must never be load-bearing for correctness. Anything that depends on progress values will break the first time a task is recovered and resumes from an earlier point.
9. Recovery: Surviving a Restart Mid-Task
Here's the scenario the happy path ignores: a task is running, and the server process dies — a deploy, a crash, an out-of-memory kill. On the next boot, that task is stuck: marked running but with no worker actually running it. Without recovery it stays running forever, and any client polling it waits forever too.
The standard fix is leases. When a worker starts a task, it claims it for a bounded window:
// Worker claims an orphaned task and leases it
const leased = await store.transition(
task.id,
"pending", // or reclaim from a lapsed "running"
"running",
{ leaseUntil: Date.now() + LEASE_MS, attempt: task.attempt + 1 },
);
if (!leased) return; // someone else won the claim
The worker periodically renews the lease while it runs. A task whose leaseUntil is in the past with no renewal is, by definition, orphaned.
On startup, run a recovery scan:
async function recoverOnBoot() {
const now = Date.now();
const orphans = await store.claimOrphaned(now, LEASE_MS);
for (const task of orphans) {
if (!isSafeToRetry(task)) {
// Non-idempotent work we can't prove didn't finish → fail loudly, don't guess.
await store.transition(task.id, "running", "failed", {
error: "interrupted by restart; not safe to auto-retry",
});
continue;
}
await store.transition(task.id, "running", "pending", {
progress: undefined, // restarting; progress resets
});
}
}
The crux is isSafeToRetry. This is where idempotency pays off a second time: a task submitted with an idempotency key and made of idempotent steps can be safely re-run from the start. A task with irreversible side effects that you can't verify cannot be blindly retried — the honest move is to mark it failed with a clear message rather than risk doing the side effect twice.
Why this works: Leases turn "is this task still being worked on?" from an unanswerable question into a time-based one. Recovery becomes a deterministic scan instead of a guess, and the combination of leases plus idempotency means restarts are an inconvenience, not a correctness incident.
🧠 The important part: recovery correctness is inherited from your operation's idempotency, not from the task machinery. The task system can detect orphans and re-dispatch them; only you know whether re-running the work is safe. Design the operation so the answer is yes wherever you can.
10. Not Every Tool Needs This
The task pattern is real machinery — a store, a worker loop, recovery logic, three tools instead of one. Wrapping a 500-millisecond lookup in it is pure overhead, and it makes the agent's life harder by forcing it to manage a polling loop for no benefit.
My working thresholds:
- Under ~5 seconds, stay synchronous. A blocking call is simpler and the result arrives in one step. This covers the vast majority of tools.
- 5 seconds to your infra's timeout, and reliable: you might still get away with synchronous, but you're living close to the wall. Watch for gateway timeouts.
- Beyond the timeout, or expensive, or side-effectful: use the task pattern. This is where it earns its keep.
There's also a middle option worth knowing: streaming progress over the MCP transport. If your client connection supports server-sent updates and the work produces incremental output, streaming can be simpler than a task store. But it couples progress to a live connection, so it doesn't help with the timeout problem or with recovery — a dropped stream still loses the work unless you've also persisted it. Streaming and tasks aren't either/or; tasks are the durable backbone, streaming is a nice-to-have on top when the connection allows.
When to avoid tasks entirely: when the caller is a human-in-the-loop agent that can't sensibly manage a polling loop, and the work is short. Forcing a chatty agent to babysit a task handle for a one-second operation adds latency and failure modes. Match the mechanism to the duration and to who's driving.
Choosing the Right Shape
Here's the decision framework I'd actually use:
| Situation | Shape | Why |
|---|---|---|
| Fast, cheap, no side effects | Synchronous tool | Simplest; result in one round trip |
| Fast but produces a stream | Sync or streaming | No durability needed |
| Longer than infra timeout | Task pattern | Connection can't stay open that long |
| Expensive / billable work | Task pattern + idempotency key | Retries must not duplicate cost |
| Irreversible side effects | Task pattern + idempotency key + explicit safety check | Must dedupe and reason about recovery |
| Needs to survive deploys/crashes | Task pattern + durable store + leases | In-memory state won't |
If you take one thing away, make it this: the task pattern doesn't make long-running work safe by itself — it gives you the structure in which idempotency, cancellation, and recovery can make it safe. The handle, the state machine, and the durable store are scaffolding. The real safety comes from operations that are safe to retry, checkpoints that honor cancellation, and leases that make orphan detection deterministic.
Get those four things right and a ninety-second tool behaves identically whether it runs on your laptop, behind a 30-second gateway, or through a rolling deploy — and that's the whole point.