DEV Community

Shivam Dwivedi
Shivam Dwivedi

Posted on Fully Autonomous

MCP Tasks: the retry bug hiding behind a successful job

The export was accepted. Its acknowledgement vanished. The client tried again.

That is the failure I simulated while looking at Claude’s new MCP Tasks support. A naive retry produced two job records. A retry with a saved request key returned the original task ID and left one record. The job didn’t fail; the client lost certainty about it. 🔁

Local acceptance sequence: commit an export task, lose the acknowledgement, retry with the same request key and recover the saved task ID. A fresh key per attempt produces two records.

Results from a local SQLite acceptance demo with a simulated lost acknowledgement. No Claude session or MCP transport was used.

Anthropic’s Tasks and Elicitations release makes this timely. Tasks let a server keep working after the initial tool call returns; elicitation provides a way to ask for input during the work. [1] The useful engineering question is what the client can recover when the conversation or connection stops.

Start with the receipt

The current Tasks extension allows a task-eligible tools/call to return a task handle. The server must first make that handle durably retrievable. Clients follow it through tasks/get; input is returned through tasks/update, and cancellation has its own method. The request must declare the extension capability. [2]

That creates two moments worth distinguishing:

server commits the task → client receives and saves the task ID
Enter fullscreen mode Exit fullscreen mode

Between them, a lost response can leave a real job with no receipt on the client. Retrying submission may create another job unless the application has a way to recognize the original intent.

This is where I’d use an application-level idempotency key: a caller-provided identifier that means “this is the same intended operation.” AWS’s Builders’ Library describes the pattern and the need to bind the identifier to the caller and the request’s meaning. It also explains why matching arguments alone can be insufficient: sometimes someone really does want two identical resources. [3]

A key for the operation and a server-generated task ID serve different purposes. The first helps recover acceptance; the second follows the accepted job. Save the operation key before the first submission, so a retry can reuse it.

A tiny acceptance store 🧪

Here is a complete local example. Save it as accept_demo.py and run python3 accept_demo.py. It uses the standard library and a disposable database. This is the job-acceptance part of an application, not an MCP server implementation.

import hashlib
import json
import sqlite3
import tempfile
import uuid
from pathlib import Path

def accept(path, tenant, key, arguments):
    canonical = json.dumps(arguments, sort_keys=True, separators=(",", ":"),
                           allow_nan=False)
    digest = hashlib.sha256(canonical.encode()).hexdigest()
    db = sqlite3.connect(path, isolation_level=None)
    try:
        db.execute("BEGIN IMMEDIATE")
        db.execute("""CREATE TABLE IF NOT EXISTS jobs(
            tenant TEXT, request_key TEXT, digest TEXT NOT NULL,
            task_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL,
            PRIMARY KEY(tenant, request_key))""")
        old = db.execute(
            "SELECT digest, task_id FROM jobs WHERE tenant=? AND request_key=?",
            (tenant, key)).fetchone()
        if old:
            if old[0] != digest:
                raise ValueError("request key reused with different arguments")
            task_id = old[1]
        else:
            task_id = str(uuid.uuid4())
            db.execute("INSERT INTO jobs VALUES(?,?,?,?,?)",
                       (tenant, key, digest, task_id, "working"))
        db.execute("COMMIT")
        return task_id
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise
    finally:
        db.close()

with tempfile.TemporaryDirectory() as directory:
    path = Path(directory) / "jobs.sqlite"
    arguments = {"quarter": "Q3", "format": "csv"}
    original = accept(path, "team-a", "export-42", arguments)
    # Pretend the acknowledgement was lost after the commit.
    retry = accept(path, "team-a", "export-42", arguments)
    assert original == retry
    with sqlite3.connect(path) as db:
        jobs = db.execute("SELECT count(*) FROM jobs").fetchone()[0]
    print("same task:", original == retry)
    print("job records:", jobs)
    try:
        accept(path, "team-a", "export-42", {"quarter": "Q4"})
    except ValueError as error:
        print("conflict:", error)
Enter fullscreen mode Exit fullscreen mode

Output:

same task: True
job records: 1
conflict: request key reused with different arguments
Enter fullscreen mode Exit fullscreen mode

The transaction makes the lookup and insertion one acceptance decision. Each call opens a fresh connection. The stored key is scoped by caller, so another team can independently use the same key. In a service, derive that caller identity from authentication. SQLite’s Python documentation covers explicit transaction control and bound SQL parameters. [4]

✅ In the fuller check, I also reversed the input dictionary’s key order, retried after the simulated timeout, and used the same key from another tenant. The first caller still had one job; the second had its own job. As a failing control, two attempts using fresh keys created two records.

The fingerprint in this example suits these small JSON objects. A real API should define normalization deliberately: missing defaults, numerical representations and schema changes can otherwise turn “same intent” into an accidental formatting rule.

Polling and cancellation need their own decisions

Once the client has a task ID, it can recover by asking about that task instead of submitting the operation again. Preserve the endpoint identity with the ID; an identifier sent to the wrong server is a particularly unhelpful receipt. 🧾

The extension defines working, input_required, completed, cancelled and failed states. An input_required task carries outstanding requests, which the client should handle with the same trust rules as their standalone equivalents. Cancellation is cooperative: acknowledging a cancellation request doesn’t guarantee that work stopped. [2]

⚠️ The common mistake is treating a timeout or an unknown task as permission to rerun a mutating operation. First reconcile what the server already did. Keep “the client stopped watching,” “cancellation was requested,” and “the effect was reversed” as separate events in the application.

For example, a report could already have been uploaded when cancellation arrives. A UI can accurately say it requested cancellation while still checking whether an output exists. Hiding that distinction behind a single spinner makes recovery harder for everyone.

Where this fits in Claude Code

Claude Code’s documentation puts MCP Tasks in public beta from v2.1.304. It distinguishes remote jobs that can outlive the client from stdio servers whose process stops when the client exits. Following a remote task again requires resuming the originating session. Ordinary client-side backgrounding has a different lifetime. [5]

Those are product integration details to verify alongside the server’s support. Updating a client alone doesn’t give your job storage, worker supervision or recovery policy a promotion.

My demo checks acceptance and duplicate suppression only. It doesn’t execute an external side effect, test power-loss durability, or implement the MCP extension. A database record and a downstream API call aren’t automatically atomic: the worker still needs a recoverable handoff and, where possible, idempotency at the downstream operation.

That’s the implementation review I’d bring to this release: can I recover the original receipt, inspect the same job, and explain what happened after an interrupted call? Those answers make long-running tools substantially more useful. 💡

References

  1. ClaudeDevs: Tasks and Elicitations announcement and thread.
  2. MCP Tasks extension, revision 2026-07-28.
  3. AWS Builders’ Library: making retries safe with idempotent APIs.
  4. Python sqlite3: transaction control and parameters.
  5. Claude Code: MCP tasks that run on the server.

Asset credit: original diagram based on the saved local acceptance-demo results.

AI disclosure

AI was used to draft the article.

Top comments (2)

Collapse
 
gautamtalksdev profile image
Gautam K •

The failing control with fresh keys is what makes this convincing. One case I'd add to the acceptance test: the worker dies after the row is committed. The retry correctly gets the original task ID back, but that task sits in working forever, so the dedupe that saved you from two exports now guarantees zero.

A lease on the row closes that: a heartbeat the worker renews, and an expiry that moves the task to failed. Then there's a policy choice worth writing down. After a task has failed, does the same request key return the failed task, or start a fresh attempt? Either is defensible, but the client has to know which.

Collapse
 
kevinhall profile image
Kevin Hall •

The case that makes the two-record failure expensive is a job that bills per row: the client loses the receipt, submits again and pays for the same rows twice. The request key fixes acceptance, as you show, and there is a second place the key can live that also survives a key the client forgot to save: the output. On a data tool we let the caller pass the ids of the result sets it already holds, the server skips every record present in them and reports the skips in a free row, so a retried export delivers and bills only what is new, whatever key it came with. Your commenter's lease covers the third state, the worker that died after the commit; I would add that a result the client cannot find is unknown, not missing, and never on its own a reason to resubmit.