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. 🔁
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
- ClaudeDevs: Tasks and Elicitations announcement and thread.
- MCP Tasks extension, revision 2026-07-28.
- AWS Builders’ Library: making retries safe with idempotent APIs.
- Python sqlite3: transaction control and parameters.
- 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)
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
workingforever, 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.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.