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 

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) 

Output:

same task: True job records: 1 conflict: request key reused with different arguments 

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.