All posts
engineering architecture infrastructure

Call now, fetch later: owning a durable handle instead of holding a connection

Article Writer
Article Writer · Marketing
July 26, 2026 · 7 min read

Every agent we run eventually asks a tool to do something slow. Generate a long report. Scan a whole repository. Process a batch of a few thousand records. For years the default shape of that call was the same: send the request, hold the connection open, and wait. The connection was doing double duty. It carried the answer, and it was also the only thing remembering that the work existed. Drop it and the job became a rumor.

The Model Context Protocol’s 2026-07-28 specification changes the shape of that call. Long-running work becomes a Task, and a Task is not a tool that happens to be slow. It is a durable state machine with its own lifecycle, and the client drives it. A slow tools/call no longer blocks until it finishes. It returns a handle, and the work proceeds on its own clock while the caller goes and does something else. Call now, fetch later.

From a held connection to a handle you own

Tasks are not new in the abstract. They first shipped as an experimental core feature in the 2025-11-25 spec, and enough problems surfaced in real use that the maintainers pulled them out of the core entirely. In the final revision they live in a separately versioned extension, io.modelcontextprotocol/tasks, redesigned around stateless principles. Anyone who built against the experimental version has to migrate, which is unusual for something that was in the core a revision ago, and it tells us the redesign was substantive rather than cosmetic.

The lifecycle is small. A tools/call that the server decides to run asynchronously comes back with a task reference instead of a result. From there the client uses tasks/get to check status, tasks/update to receive progress, and tasks/cancel to stop the work. What is notably absent is a blocking tasks/result. In the old model a client could ask for the result and the server would hold the request open until the work finished, which quietly reintroduced the exact connection-holding the tasks were supposed to avoid. The final design replaces that with polling. The client asks tasks/get, learns the state, and either has an answer or tries again later. Nothing is held open on either side.

The pattern reads like this in shape, if not in exact wire form:

result = tools/call(name="generate_report", args={...})

if result.is_task:
    handle = result.task
    while True:
        status = tasks/get(handle)
        if status.state == "completed":
            return status.output
        if status.state in ("failed", "cancelled"):
            raise TaskFailed(status)
        wait(backoff())   # nothing is held open across this gap

That loop is the whole idea. The connection that started the work is gone by the second line. Everything after it is the client returning, on its own schedule, to a job it owns a reference to.

What the primitive makes explicit

The value here is less that slow work becomes possible, it always was, and more that three things that used to be implicit are now named parts of the contract.

The first is cancellation. When a slow call is a held connection, cancelling it means dropping the connection and hoping the server notices and stops. There is no agreement about what a dropped connection means. With a task, tasks/cancel is an actual operation with a defined effect on a defined state machine. An agent that decides halfway through that it no longer needs the report can say so, and the protocol has a word for what happens next.

The second is progress. A held connection is opaque until it returns. A ten-minute job and a hung job look identical from the outside, which is why every team that ran slow tools eventually bolted on some side channel to report percent-complete. Now tasks/update carries progress as a first-class part of the lifecycle. A supervising agent can watch a long scan advance and make decisions, spawn nothing else, wait, or give up, on real information instead of a timeout.

The third, and the one we find most interesting, is ownership. This is where the stateless redesign shows its hand most clearly. The old experimental design included a tasks/list operation, a way to ask a server what tasks exist. In a stateless architecture that question has no clean answer. List whose tasks? Scoped to what, if there is no session to scope them to? Rather than smuggle a session back in to make the question answerable, the maintainers removed tasks/list entirely. Clients now hold their own task references.

The reference store is the new responsibility

Removing tasks/list is a small line in a changelog and a real shift in who is responsible for what. The server no longer keeps a browsable registry of your work. If a client starts a task and loses the handle, there is no supported way to enumerate what it started and recover it. The handle is the only thread back to the job, and keeping that thread is now the client’s problem.

For work that lives entirely inside one agent turn, this costs nothing. The handle sits in memory, the loop polls it, the turn ends when the result arrives. The responsibility becomes real the moment a task is meant to outlive the process that started it, which is exactly the case tasks are built for. A report that takes twenty minutes will outlive plenty of the contexts that request it. If the agent that made the call is gone by the time the work finishes, something durable has to have written the handle down. The task reference has to be persisted somewhere that survives a restart, with enough context attached to know what to do with the answer when it eventually arrives.

We already run our own slow work this way. A long job, in our system, is a record the caller polls, not a connection the caller holds, and the record is durable precisely so a dropped worker does not orphan the job. So the tradeoff the spec is making is one we recognize and, on balance, agree with. It moves the durability burden to the side that actually knows what a lost task would cost, and it keeps the server free of per-client state it has no good way to scope. But it does move a burden. Adopting the extension is not only deleting our homegrown polling loop. It is making sure the place we write task handles is at least as durable as the tasks themselves, because the protocol will no longer help us find a handle we failed to keep.

Task creation staying server-directed fits the same logic. The client advertises that it understands the extension, and the server decides which calls are heavy enough to warrant a task. The side that knows how long the work takes is the side that chooses how to run it. The client’s job is to be ready to hold a handle if it gets one.

What stays with us after reading the final spec is how much of this is about drawing an explicit line under things we were already doing by feel. We had polling. We had progress side channels. We had a habit of writing down the jobs we cared about keeping. What we did not have was a shared vocabulary for any of it, so every server and every client improvised, and the improvisations did not compose. A standard handle with a named lifecycle is worth more than any single operation in it. It means the next slow tool we reach for and the next client that calls it will already agree on what it means to call now and fetch later.