An agent asks a tool to create a resource. The server creates it, commits the database transaction, and sends a response. The connection drops before the agent receives that response.
From the agent's perspective, the call timed out. From the application's perspective, it succeeded. Retrying with a fresh request can create a second resource. Refusing to retry can leave the workflow stuck even though the first result is available.
A timeout means the caller did not observe a result. It does not establish whether the operation happened. Reliable tools need a way to resolve that uncertainty.
This article develops an application-level contract for MCP tools, then implements and tests it using Python and SQLite. The implementation uses only the standard library and runs locally. It is the business logic behind a tool, not a complete MCP server or an SDK-specific integration.
Give one intended action a durable identity
A JSON-RPC request ID associates a response with a request. The JSON-RPC specification defines that correlation; it does not turn an application mutation into a deduplicated operation.
Use a separate idempotency key for the intended business action. Persist it before dispatching the first attempt, and retain it when the response is lost. A retry may have a new protocol request ID while carrying the same business key and arguments.
For a tool named create_note, its application arguments might be:
{
"idempotency_key": "note-request-7b025dc8",
"body": "Investigate the failing parser fixture."
}
This is an example of the tool's arguments only. Let your MCP SDK construct the protocol envelope and metadata for the version it supports. The key is your tool's documented input, not a built-in MCP retry guarantee.
Define these semantics before writing the handler:
| Incoming call | Required application behavior |
|---|---|
| New key with valid arguments | Perform the operation and retain its result |
| Same key and same arguments | Return the retained result |
| Same key but different arguments | Reject the conflict without mutating anything |
| Same key while the original is running | Wait within a bound or return a status handle |
| Result cannot be established | Report an unknown outcome with a recovery path |
The key identifies intent, not merely content. Two deliberately separate notes can have identical text, so hashing the body alone would incorrectly collapse them. Conversely, generating a new random key inside every retry defeats deduplication.
Scope the key by authenticated account or principal, operation, and relevant workspace. Resolve that scope on the server. A model-supplied tenant_id is not authorization. Check access before replaying a stored result, just as you would before returning a resource from a read tool.
Commit the effect and its receipt together
Consider two naive implementations:
create the note -> save the retry receipt
save the retry receipt -> create the note
The first can crash after creating the note but before recording the result. The second can claim success for a note that was never created. For a mutation entirely inside one database, put both writes in the same transaction.
The following example creates a note row and a receipt row atomically. A receipt stores the normalized request and the original result, including the generated note ID. Retrying returns that same ID.
Save this first block as idempotent_notes.py:
import json
import sqlite3
import uuid
from contextlib import closing
OPERATION = "create_note:v1"
def initialize(db_path):
with closing(sqlite3.connect(db_path)) as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
scope TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS receipts (
scope TEXT NOT NULL,
operation TEXT NOT NULL,
request_key TEXT NOT NULL,
request_json TEXT NOT NULL,
result_json TEXT NOT NULL,
PRIMARY KEY (scope, operation, request_key)
);
""")
def create_note(db_path, scope, key, body):
# scope must come from trusted, authorized server context.
if not isinstance(scope, str) or not scope:
raise ValueError("invalid scope")
if not isinstance(key, str) or not 1 <= len(key) <= 128:
raise ValueError("invalid idempotency key")
if not isinstance(body, str) or not 1 <= len(body) <= 10000:
raise ValueError("invalid note body")
request_json = json.dumps(
{"body": body}, sort_keys=True, separators=(",", ":")
)
identity = (scope, OPERATION, key)
with closing(sqlite3.connect(
db_path, timeout=5, isolation_level=None
)) as db:
try:
db.execute("BEGIN IMMEDIATE")
previous = db.execute(
"SELECT request_json, result_json FROM receipts "
"WHERE scope=? AND operation=? AND request_key=?",
identity,
).fetchone()
if previous is not None:
if previous[0] != request_json:
raise ValueError("idempotency key payload conflict")
result = json.loads(previous[1])
else:
note_id = str(uuid.uuid4())
db.execute(
"INSERT INTO notes (id, scope, body) VALUES (?, ?, ?)",
(note_id, scope, body),
)
result = {"status": "succeeded", "note_id": note_id}
db.execute(
"INSERT INTO receipts "
"(scope, operation, request_key, request_json, "
"result_json) VALUES (?, ?, ?, ?, ?)",
(*identity, request_json, json.dumps(result)),
)
db.commit()
return result
except BaseException:
if db.in_transaction:
db.rollback()
raise
BEGIN IMMEDIATE acquires SQLite's write transaction before the lookup. Concurrent writers serialize: a second call reads the receipt after the first commits instead of both observing an absent key. SQLite allows only one writer at a time, so keep this transaction short. The connection's five-second timeout bounds lock waiting; it is not an end-to-end request deadline. See SQLite transactions and Python's SQLite connection parameters.
This example deliberately has no network call inside the transaction. Its useful guarantee is narrow: within the retained scope, operation, and key, retries create at most one committed note row and replay its result. SQLite's atomic-commit documentation explains the storage assumptions behind transaction durability. A database transaction cannot guarantee delivery of the response to the caller.
The operation version is part of the identity so a future change in meaning is explicit. Retrying an old operation must still use its old contract; silently routing an old retry to a new operation version can bypass its receipt.
Test the failures your happy-path demo misses
Append this block to the same file:
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from tempfile import TemporaryDirectory
def demonstrate():
with TemporaryDirectory() as directory:
path = Path(directory) / "notes.sqlite3"
initialize(path)
scope = "account-17:user-42:project-9"
key = "one-intended-note"
body = "Investigate the failing parser fixture."
# Separate connections race for the same intended operation.
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(
lambda _: create_note(path, scope, key, body),
range(8),
))
assert all(result == results[0] for result in results)
# Model a lost response by discarding it and asking again.
create_note(path, scope, "lost-response", "A second note")
recovered = create_note(
path, scope, "lost-response", "A second note"
)
assert recovered == create_note(
path, scope, "lost-response", "A second note"
)
try:
create_note(path, scope, key, "Different arguments")
except ValueError as error:
assert "payload conflict" in str(error)
else:
raise AssertionError("conflicting retry was accepted")
with closing(sqlite3.connect(path)) as db:
assert db.execute(
"SELECT COUNT(*) FROM notes"
).fetchone()[0] == 2
assert db.execute(
"SELECT COUNT(*) FROM receipts"
).fetchone()[0] == 2
print("Passed: concurrent retries, lost response, payload conflict")
if __name__ == "__main__":
demonstrate()
Run it with python3 idempotent_notes.py. The demonstration creates and removes a temporary database; it does not connect to an MCP server or create resources in another service.
The lost-response case simulates the application boundary by discarding a committed result. It is not a network fault-injection test. For a deployed handler, add tests that disconnect after commit, restart the worker, and verify that the recovered ID is unchanged. Also test authorization changes, a second principal using the same key, and receipt expiry.
For rollback testing, inject a failure between the note insert and receipt insert. Neither row should remain. For lock contention, hold a competing write transaction longer than the lock timeout and verify that the tool reports a retryable busy condition rather than inventing success.
External side effects need a second recovery mechanism
Now replace the note insert with an external API call that starts a deployment. The local database and the deployment provider do not share a transaction. A local receipt alone cannot close the failure window.
| Failure window | What the worker knows | Recovery |
|---|---|---|
| Before dispatch to the provider | No external attempt started | Retry within the operation's deadline |
| Provider accepted, response lost | The deployment may exist | Reuse a provider-supported key or reconcile by operation ID |
| Provider replied, local receipt failed | The deployment exists but the receipt may not | Recover the provider result and repair the receipt |
| Local receipt committed, client response lost | The recorded result is available | Replay it after authorization checks |
If the provider supports idempotency keys, derive a stable provider key from your persisted operation identity and reuse it. Check the provider's scope, retention window, and payload-conflict semantics. A field named request_id is not sufficient evidence that it deduplicates writes.
If it supports lookup by a client reference, persist that reference before dispatch and reconcile through the lookup API. If it supports neither, an ambiguous result may require inspection rather than automatic retry. Label it outcome_unknown and explain what must be checked.
A transactional outbox helps when you need to commit an application change and an intent to dispatch together. A worker later sends the durable intent. It still needs deduplication or reconciliation at the receiver: a crash after sending but before marking the outbox row complete can cause another send. The outbox closes one local consistency gap; it does not make an arbitrary remote action happen exactly once.
Make timeouts and cancellation describe reality
Use a total operation deadline in addition to individual attempt timeouts. For example, an application might allow a 30-second total budget, cap each network attempt at eight seconds, and permit at most three attempts. Those are illustrative policy values, not MCP defaults.
Measure elapsed time with a monotonic clock inside the process. Before each attempt, compute the remaining budget and cap the attempt timeout accordingly. Backoff and lock waiting consume that budget too. Retry at one defined layer so nested SDK and worker retries do not silently multiply the attempt count.
Add randomized backoff for transient contention, and honor a provider's retry guidance when it fits the remaining budget. Do not retry permission denials or payload conflicts unchanged. Most importantly, retry an ambiguous write only when you can preserve its identity or establish that the original was not applied. The same distinction underlies the retry guidance in HTTP semantics, section 9.2.2.
Cancellation needs equally precise states. A caller stopping its wait is different from a worker stopping execution. A worker stopping execution is different from undoing a completed side effect. If a deployment was already submitted, cancelling a tool request does not establish that the deployment was cancelled.
For long-running work, design a start operation that returns a durable handle, a read operation that reports status, and a cancellation operation with explicit semantics. Use the async facilities supported by your negotiated MCP version and SDK, or expose these as ordinary application tools. Do not invent a transport-level cancellation message from this article.
Useful application states include accepted, running, succeeded, failed, cancel_requested, cancelled, and outcome_unknown. Only report cancelled when you have established what stopped. If cancellation races with success, the status tool should return the actual final outcome.
Return enough information to recover
A tool result should tell the next caller what happened and what remains possible. A successful result might contain:
{
"status": "succeeded",
"operation_id": "op_42",
"resource_id": "note_91",
"replayed": true
}
An unresolved external write could instead return:
{
"status": "outcome_unknown",
"operation_id": "op_42",
"retryable": false,
"next_action": "Call get_operation with operation_id op_42"
}
These are proposed application fields, not reserved MCP fields. Put the stable shape in your tool's output schema, return it as structured content, and include a concise explanation. MCP distinguishes tool execution failures from protocol errors; use the SDK's appropriate result or error representation. The MCP tools specification documents structured results, output schemas, and error handling.
The SQLite demo returns the original result unchanged. A production wrapper can add replayed as response metadata without changing the stored business result. Keep operation IDs stable across attempts and log a separate attempt ID for each execution. Avoid logging whole note bodies, credentials, or private tool inputs merely to diagnose retries.
Decide how long the promise lasts
Idempotency has a retention policy. If you delete a receipt and later accept the same key as new, the original operation can happen again. Retain receipts for at least the documented retry window and decide how late arrivals behave. A compact tombstone can reject an expired key without retaining the full result indefinitely.
Document four things in the tool description: when callers should generate a key, when they must reuse it, how long it remains valid, and how to inspect an uncertain outcome. Persist the key and operation ID in the workflow's durable state so a resumed agent does not guess. An agent handoff should carry these identifiers alongside the task's progress.
If you are exposing tools around terminal sessions, apply the same reasoning to session creation and command execution. Two attempts to read output are usually harmless; two attempts to run a migration may not be. Keep interactive observation separate from mutation, retain completion evidence, and make the recovery command visible to the agent. The PTY versus pipes guide covers why captured command output can behave differently from an interactive session.
For the surrounding protocol concepts, see the MCP practical guide. For tools that never connect in the first place, start with MCP connection debugging. Once a tool can execute, the next useful test is whether you can lose its response and still recover the same result.