Skip to main content

MCP Tasks Gets Retry Semantics and Expiry Policies: The 'Call Now, Fetch Later' Pattern for Deploys That Outlive an HTTP Timeout

9 min readDora NodaDora Noda
Share

One of Amazon's own internal teams had already hit this wall before MCP had an answer for it: their code-migration MCP server split every long-running job into a create tool and a get tool, forcing the model to track job state by hand — and it worked, until the model hallucinated a job name because it hadn't listed its jobs first. That bug report, cited by name in MCP's own spec proposal, is the entire argument for why a synchronous tools/call was never going to work for anything that takes longer than an HTTP timeout: a build, a database migration, a multi-service rollout.

That's what SEP-1686 — the Tasks primitive — exists to fix, and as of mid-2026 it's no longer a proposal. It shipped experimentally in the 2025-11-25 spec, went through a year of production use, and the SEP itself now carries a Final, Standards Track status. Along the way it picked up the two things early adopters actually asked for: a retry model that survives a dropped connection, and an expiry policy for how long a result sticks around. This post is about those two mechanisms specifically — what they are, exactly, and what a deploy-from-chat MCP server has to implement to expose deploy, rollback, and migrate as Tasks instead of tool calls that silently time out.

What a Task actually is

A Task augments any MCP request — not just tools/call — with a durable state machine the client can poll independently of the original request/response cycle. You attach one by putting a taskId (and optionally a keepAlive duration) in the request's _meta:

json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "deploy_application",
    "arguments": { "service": "api", "ref": "a1b2c3d" },
    "_meta": {
      "modelcontextprotocol.io/task": {
        "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
        "keepAlive": 60000
      }
    }
  }
}

The server acknowledges with a notifications/tasks/created event once the task exists, and from there the client polls tasks/get:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "keepAlive": 30000,
    "pollFrequency": 5000,
    "status": "working"
  }
}

A task can only move through a fixed set of transitions — submittedworkinginput_required → one of completed / failed / cancelled / unknown — and once it lands in a terminal state it cannot transition again. Once status reads completed, tasks/result returns the same result shape the original request would have returned synchronously. Call it before then and the server has to reject you with a protocol error, not a guess.

The retry deliverable: task IDs are client-generated, on purpose

This is the mechanism the title is actually about, and it's easy to walk past because it looks like a small API design choice: the client generates the task ID, not the server.

The SEP's own rationale is blunt about why. With server-assigned IDs, a timeout or dropped connection leaves the client with no safe way to retry — it doesn't know if the original request landed, so retrying risks a duplicate deploy. With client-generated IDs, the client can resend the exact same tools/call with the exact same taskId after any timeout. The server checks whether that ID already maps to a task; if it does, it returns a protocol error (-32602, "Task ID already exists") instead of starting a second deploy. That response is the confirmation the client was missing — the original request landed, work is in flight, resume polling.

That's the whole retry story, and it's worth being precise about what it doesn't cover, because it's a narrower claim than "MCP retries failed tasks for you":

  • It only protects the submission, not the outcome. If a task actually reaches failed — the deploy ran and the build broke — that's a terminal state. Retrying tasks/get on the same ID just replays the same failure forever; a genuine second attempt needs a new taskId tied to a new tools/call. Idempotent resubmission answers "did my request get through," not "please try again with different luck."
  • Polling cadence isn't left to client guesswork either. The server can return a pollFrequency (milliseconds) in every tasks/get response, and the spec says clients should respect it rather than invent their own interval. The one place the spec mentions exponential backoff at all is a narrower fallback case — clients on a transport without server-to-client streaming, where the notifications/tasks/created signal can't arrive at all, so they're stuck guessing when to start polling. That's a connectivity workaround, not a general "how MCP handles transient failures" policy.

So the honest version of "retry semantics" is: idempotent resubmission on the way in, server-dictated polling cadence on the way out, and an explicit line between "resend because the network dropped" and "retry because it actually failed."

The expiry deliverable: keepAlive, and who actually controls it

The second gap is what happens to a result after the task finishes. A client requests a retention window with keepAlive (milliseconds); the server is free to override it, and must echo the value it actually decided on in every tasks/get response — null means unlimited. Once a task hits a terminal state and its keepAlive window elapses, the server is allowed to delete both the task and its result. Ask again after that and you get this, not a silent empty response:

json
{
  "jsonrpc": "2.0",
  "id": 71,
  "error": { "code": -32602, "message": "Failed to retrieve task: Task has expired" }
}

Two details matter more than the happy path. First, keepAlive is a ceiling the server sets, not a promise the client extracts — a server under load can shorten what you asked for, so a platform's own MCP server should document its actual maximum rather than let clients assume their requested value held. Second, the spec's own security guidance cuts the other way from what you'd expect: it tells requestors to ask for shorter keepAlive durations and fetch results promptly for sensitive operations, because a task result can outlive the request that created it, sitting retrievable on the server for the full window. A deploy or rollback result carrying environment details or a partial log tail is exactly that case.

What just changed underneath this: Tasks became an extension

The 2026 roadmap named these two gaps — "retry semantics when a task fails transiently, and expiry policies for how long results are retained" — as the concrete production feedback closing out of the experimental 2025-11-25 Tasks feature. What actually shipped in response, in the 2026-07-28 spec release candidate, is bigger than a patch: Tasks moved out of the core specification entirely and became a formal extension, alongside MCP Apps.

The mechanics shifted with it. The driving trio is now tasks/get, tasks/update, and tasks/canceltasks/list is gone, because in a protocol that dropped session state (any request can now land on any server instance behind a plain round-robin load balancer, no sticky routing required), there's no safe way to scope "list my tasks" without a session to scope it to. Task creation also became server-directed rather than purely client-augmented: the server decides whether a given call runs as a task at all, instead of a client attaching task metadata to any request and hoping the server honors it. Anyone who built against the experimental 2025-11-25 Tasks API has a real migration on their hands, not a drop-in version bump.

The retry story gets sharper here too, for a different class of interaction: multi-round-trip requests, like a deploy tool that needs to elicit a confirmation mid-call. Instead of holding a stateful SSE stream open, the server returns an InputRequiredResult carrying an opaque, signed requestState blob; the client collects the answer and re-issues the call with that state echoed back verbatim. Because the resume payload carries everything needed to continue, any server instance can pick it up — which is the same idempotent-resubmission principle as task IDs, applied to the conversation instead of the task.

Mapping this onto deploy, rollback, and migrate

The SEP's own "Future Work" section sketches nested tasks using — appropriately — a deploy_application tool call that spawns build and test subtasks under a parent deploy-123 task, tracked via a childTasks array. Worth being direct about this: nested/hierarchical tasks are proposed, not shipped — that part of the diagram is a design sketch, not something a server can rely on today. But the flat, single-task version underneath it is exactly what a self-hosted PaaS's MCP server should ship right now for anything that can plausibly outrun an HTTP timeout:

  • deploy — call it with a client-generated taskId; the handler keys off that ID so a dropped agent-chat connection reattaches to the same in-flight deploy on reconnect instead of triggering a second one against the same service.
  • rollback and migrate — same shape, plus the keepAlive decision matters more here: a migration's result (row counts, failed statements) is worth keeping around only as long as the chat session that triggered it is realistically still open — minutes, not hours — and should be deletable on demand once retrieved, per the spec's own "don't just rely on expiry" guidance.
  • Idempotent handlers, not idempotent operations. The task-ID mechanism guarantees you won't accidentally kick off two identical deploy calls from a retried request. It does not make deploy itself idempotent — that's still the platform's job, the same job it already had before Tasks existed.

The honest gap that's left

Tasks is still poll-only. The SEP's "Future Work" section explicitly flags server-pushed notifications on task completion as the next step, useful once operations start running for hours rather than minutes — think the healthcare/life-sciences batch jobs the SEP cites, not a typical git push-to-deploy. Until that lands, a deploy-from-chat platform's MCP server is on the hook for a sane pollFrequency default and a keepAlive that matches how long an agent conversation actually stays open — not indefinite, and not so short that a client's poll cadence races the expiry window.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with AI agents as first-class operators through an MCP server. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Give your agents a chain backend

Autonomous agents hit RPC endpoints very differently than people do. See what bex router handles on their behalf.

Read the agents guide