The 2026-07-28 MCP spec made the protocol stateless, and the Tasks extension (SEP-2663) is how slow tools survive that: a server can answer a tools/call with a task handle instead of a result, and you fetch the outcome later by polling tasks/get. The explainer for that extension ends on a warning most first implementations ignore — lose the id and the work is orphaned. This is how you make sure you never do.

Why the burden moved to you#

In the old sessioned protocol, the server remembered your in-flight work; a tasks/list call could hand it back. The stateless spec deleted both. The reasoning is blunt and correct: without a session there is no server-side notion of "this client's tasks," so tasks/list can't be scoped to a caller safely and was removed outright.

That has one consequence you have to design around. When the server hands you a task handle, that id is the only pointer to the work. The job runs — or finishes — on the server regardless. But if your client forgets the id, you can never poll it again, and there is no enumeration endpoint to rediscover it. The task isn't lost; it's unreachable. Durable bookkeeping is now the client's job.

The one unrecoverable window#

There is exactly one moment you cannot crash through: between the server accepting your call and your store recording the handle. If the process dies there, the job is running and you never learned its id. Everything else is recoverable if — and only if — the handle is already on disk.

So the rule is: persist the handle synchronously, the instant the response says resultType: "task", before you return control anywhere. Same transaction boundary as "I issued this call."

import sqlite3, time

class TaskStore:
    def __init__(self, path="tasks.db"):
        self.db = sqlite3.connect(path, isolation_level=None)  # autocommit
        self.db.execute("""CREATE TABLE IF NOT EXISTS tasks(
          id TEXT PRIMARY KEY, server TEXT, tool TEXT, args TEXT,
          idem_key TEXT, status TEXT, created REAL, updated REAL)""")
        self.db.execute("CREATE INDEX IF NOT EXISTS ix_open ON tasks(status)")

    def record(self, task_id, server, tool, args_json, idem_key):
        self.db.execute(
          "INSERT OR IGNORE INTO tasks VALUES(?,?,?,?,?,?,?,?)",
          (task_id, server, tool, args_json, idem_key, "working", time.time(), time.time()))

    def mark(self, task_id, status):
        self.db.execute("UPDATE tasks SET status=?, updated=? WHERE id=?",
                        (status, time.time(), task_id))

    def open_tasks(self):
        return self.db.execute(
          "SELECT id, server, tool FROM tasks WHERE status IN ('working','input_required')"
        ).fetchall()

    def seen(self, idem_key):
        row = self.db.execute("SELECT id FROM tasks WHERE idem_key=?", (idem_key,)).fetchone()
        return row[0] if row else None

The call site branches on the discriminator, because the same tool can return either shape — the server chooses per call:

async def call(session, store, tool, args, idem_key):
    if existing := store.seen(idem_key):        # idempotency: you hold the ticket
        return await poll(session, store, existing)
    resp = await session.call_tool(tool, args, capabilities={"tasks": True})
    if resp.result_type == "task":              # <-- got a handle, not an answer
        store.record(resp.task_id, session.server, tool, json.dumps(args), idem_key)
        return await poll(session, store, resp.task_id)
    return resp.content                         # <-- direct result, done

Poll with backoff, and reconcile on boot#

poll walks tasks/get until the task reaches a terminal state, backing off so a ten-minute job doesn't hammer the server. Crucially, treat an unknown or expired id as terminal — a stateless server may have aged the task out, and that's a stop condition, not a reason to retry forever:

async def poll(session, store, task_id, delay=1.0):
    while True:
        r = await session.tasks_get(task_id)
        if r.status in ("completed", "failed", "cancelled", "unknown"):
            store.mark(task_id, r.status)
            return r.result if r.status == "completed" else None
        store.mark(task_id, r.status)           # e.g. input_required, working
        await asyncio.sleep(delay)
        delay = min(delay * 1.6, 30)

Then the piece that makes the store worth having — reconcile every open task on startup, so a deploy that restarts mid-task resumes instead of orphaning:

async def reconcile(session_for, store):
    for task_id, server, tool in store.open_tasks():
        session = session_for(server)
        asyncio.create_task(poll(session, store, task_id))   # re-attach and finish

Three things first cuts forget#

Idempotency, because you hold the only ticket. On restart you may be tempted to re-issue a call whose handle you're not sure landed. Don't — dedupe on an idempotency key first (store.seen). Re-issuing a slow, side-effecting tool call launches the job twice, and there's no session to collapse the duplicate.

input_required is a real state, not a failure. tasks/update exists precisely so a task can pause for input mid-run. Your poll loop should surface that state to the caller, not treat a non-completed status as done.

Garbage-collect terminal tasks, and cancel on abandon. When a user walks away from an in-flight task, call tasks/cancel and mark it — otherwise you leak server work you're paying for. And prune completed rows on a schedule so open_tasks() stays a fast lookup.

The store is about sixty lines. It is not incidental plumbing — it is the exact bookkeeping the stateless spec deliberately handed to the client when it deleted tasks/list. Build it once, and "the deploy restarted while a task was running" stops being an incident. Skip it, and it's the bug you find in production, after the work has already run somewhere you can no longer reach.

Companion piece: the handle store is one of two pieces of client-side machinery the stateless era makes your job. The other is lazy tool loading — see build progressive tool disclosure yourself for the discover/load/unload loop that keeps a fat MCP tool budget from eating your context window.