Tool Design
Idempotency and Safety
Designing tools to be safely retryable and clearly annotated as read or write
Last verified 2026-09-24
Summary
Design tools to be safely retryable given network timeouts, , and model stochasticity. Idempotent tools produce identical results when called multiple times with the same inputs. Patterns: upsert instead of create+update, tag systems with , dry-run modes before mutations, clear read vs. write classification. Annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint in ) enable downstream safety reasoning. Avoid hidden state changes; minimize side effects.
- Idempotency: Same inputs → same results on retry
- Upsert pattern: Single operation instead of create + update
- Idempotency keys: Client-provided UUID for deduplication
- Dry-run modes: Test mutations before execution
- Read vs. write: Clear classification, read-only tools safe to retry
- Annotations: MCP hints for downstream reasoning
Agents are not deterministic. Network timeouts, rate limits, and model stochasticity mean tools are called multiple times with the same inputs. Tools must be idempotent or explicitly marked as destructive so downstream systems can reason about safety.
Idempotency
An idempotent tool produces the same result when called multiple times with identical inputs. Retries then return the same outcome instead of duplicating the side effect.
Patterns
Upsert instead of create + update:
// Anti-pattern: two separate operations
create_user(name: string, email: string)
update_user(id: string, name: string, email: string)
// Better: idempotent single operation
upsert_user(
email: string,
name: string,
id?: string // If provided, update; if omitted, create
)The agent calls upsert_user once. If the call succeeds, the user exists with the provided data. If the call fails and the agent retries, the result is identical.
Idempotency keys:
The tool-design concern is exposing the key as a first-class parameter the agent controls, so a retry with the same value is safe:
const createInvoiceSchema = z.object({
customer_id: z.string().uuid(),
amount_cents: z.number().int().positive(),
idempotency_key: z
.string()
.describe(
"Unique key (e.g., UUID) for this operation. Prevents duplicate invoices if retried.",
),
});The handler looks the key up before executing and returns the existing result on a match, so if the agent retries with the same idempotency_key, it gets the same invoice back - no duplicate charge. The server-side storage, 24-hour TTL, and replay semantics behind that lookup are covered on the Idempotency page.
For read-only tools:
All read tools are idempotent by definition. Calling search_issues ten times with the same query returns the same result set (ignoring time-series changes). No special handling needed.
When to Mark as Idempotent
Use the MCP idempotentHint annotation:
server.tool(
"upsert_user",
"Creates a user or updates if exists. Idempotent.",
schema,
{
annotations: {
idempotentHint: true, // Safe to retry
destructiveHint: false, // Does not delete
},
},
handler,
);Read vs Write Classification
Every tool must be clearly classified as read-only or write (mutating).
Read-Only Tools
Tools that query without side effects:
server.tool(
"search_issues",
"Search for existing issues...",
searchSchema,
{
annotations: {
readOnlyHint: true, // No mutations
destructiveHint: false, // Nothing deleted
idempotentHint: true, // Always safe to retry
openWorldHint: false, // All effects local to this API
},
},
handler,
);Read-only tools can be called in parallel without coordination. Agents may call five read tools simultaneously without risk.
This is model-initiated parallel - the model issues several tool calls within a single turn - distinct from the concurrent covered in Orchestration, where the , not the model, decides to run branches concurrently. A tool must be safe to run concurrently with itself and with the other tools in its set before it can be offered for parallel calling; if it is not, say so in its description and serialize it in the host. Provider can switch parallel calls off only for a whole request, not for a single tool, so relying on the model to serialize its own calls is not a control.
Write Tools
Tools that create, update, or mutate state:
server.tool(
"create_invoice",
"Creates a new invoice...",
createSchema,
{
annotations: {
readOnlyHint: false, // Mutates database
destructiveHint: false, // Does not delete (can be reversed)
idempotentHint: true, // Supports idempotency keys
openWorldHint: false, // All effects local
},
},
handler,
);Write tools should typically be called sequentially or in known dependency order. If multiple write tools are called in parallel, the agent must understand and handle conflicts.
Destructive Tools
Tools that delete, void, or irreversibly modify data:
server.tool(
"delete_invoice",
"Permanently deletes an invoice. This cannot be undone...",
deleteSchema,
{
annotations: {
readOnlyHint: false,
destructiveHint: true, // Irreversible mutation
idempotentHint: false, // Not safe to retry (second call will fail)
openWorldHint: false,
},
},
handler,
);Agents may require explicit human approval before calling destructive tools. See /docs/error-handling for error design around destructive operations.
Dry-Run Modes
For risky operations, expose a preview tool that describes what would happen without actually doing it:
// Preview tool
server.tool(
"preview_migration",
"Shows what tables and columns would change without executing...",
migrationSchema,
{ annotations: { readOnlyHint: true } },
async (params) => {
const plan = await generateMigrationPlan(params.migration_id);
return {
content: [
{
type: "text",
text: `Would create tables: ${plan.tablesToCreate.join(", ")}. Would add columns: ${plan.columnsToAdd.join(", ")}.`,
},
],
};
},
);
// Execution tool (called only after preview approved)
server.tool(
"execute_migration",
"Executes the migration. Call preview_migration first to review changes...",
migrationSchema,
{ annotations: { destructiveHint: true, idempotentHint: false } },
async (params) => {
const result = await runMigration(params.migration_id);
return { content: [{ type: "text", text: `Migration ${params.migration_id} applied.` }] };
},
);The agent calls preview_migration first to show the human what will happen, then calls execute_migration only after approval.
Tool Composition for Safety
Read Before Write
When a write operation needs information the agent does not already hold, expose a paired read tool so the agent can gather the required IDs or current state before mutating. The write tool's description should name the read tool it depends on.
list_projects → create_task (requires project_id)
get_user → update_user (requires current values to avoid overwriting)
list_team_members → assign_task (requires assignee_id)Making the dependency explicit in the description ("Requires a valid project_id from list_projects") is more reliable than expecting the agent to infer it from a bare project_id: string field.
List Before Get
Destructive tools often require a valid ID. Ensure the corresponding list operation exists:
list_invoices(); // Returns invoice_ids
get_invoice(id); // Fetches full details
delete_invoice(id); // Requires valid id from list or getAgents cannot delete safely if they cannot list and inspect targets first.
Conditional Mutations
When possible, use conditional mutations instead of separate delete tools:
// Instead of: delete_invoice(id)
// Offer: update_invoice(id, status: 'void' | 'delete')
update_invoice(
id: string,
status: z.enum(['draft', 'open', 'void', 'archive']).optional(),
// ... other fields
)This gives agents finer control and reversibility. Voiding an invoice is safer than deleting it.
Collapsing Hints into Presets
Most tool registries don't reason about all four MCP hints independently - they collapse them into three presets (READ_ONLY, WRITE, DESTRUCTIVE) and drive system-prompt safety rules, client confirmation UI, and tool-selection filtering from whichever preset a tool carries:
export const READ_ONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
export const WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
export const DESTRUCTIVE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
};A system prompt built from these groups separates tools into three lists (read-only: call freely; write: explain before calling; destructive: never call without explicit user confirmation) and a client UI shows a confirmation dialog only for tools carrying DESTRUCTIVE. At tool-selection time, filter destructive tools out of a candidate set by default and require them to be explicitly re-included - see Tool Curation.
Draft-Then-Confirm for Destructive Actions
Never expose a direct-send or direct-delete tool to the model unprompted. Split the action into a _draft (or _prepare) tool that creates state without side effects and returns a preview, and a _confirm (or _execute) tool that performs the irreversible step:
// Step 1: safe and idempotent: returns a preview, does not send
server.tool(
"invoice_create_draft",
"Create a draft invoice. Does not send.",
draftSchema,
{ annotations: WRITE },
handler,
);
// Step 2: irreversible: only called after the human has seen the preview and confirmed
server.tool(
"invoice_confirm_and_send",
"Send a draft invoice. Irreversible.",
confirmSchema,
{ annotations: DESTRUCTIVE },
handler,
);The system prompt instructs the model to call the draft tool, show the result to the user, wait for explicit confirmation ("yes", "send it"), and only then call the confirm tool. For bulk operations, the same shape applies: a _prepare tool that returns a match count and a sample of affected records, and an _execute tool gated behind it. This is the tool-design half of a human-approval gate; the surface contract for suspending and resuming on the human's decision - durable pending state, idempotent resume, expiry - is covered in Human Approval.
Documenting Safety Constraints
In descriptions, explicitly state:
- Whether the tool is idempotent - "Safe to retry with same inputs"
- Whether it's reversible - "This cannot be undone" vs "Can be reverted with
archive_invoice" - What approvals are required - "Requires human approval before executing"
- Rate limits - "Max 10 calls per minute"
server.tool(
"send_invoice_email",
"Sends the invoice to the customer via email. Idempotent: calling twice " +
"on the same invoice does not send duplicate emails. The customer receives " +
"the hosted payment link in the email body. This can be reverted by calling " +
"`revoke_sent_invoice` if sent in error.",
schema,
{ annotations: { idempotentHint: true } },
handler,
);Error Handling for Destructive Operations
See /docs/error-handling/idempotency for structured error patterns. For destructive operations, always return structured errors rather than throwing:
async (params) => {
const invoice = await db.invoices.findById(params.invoice_id);
if (!invoice) {
return {
isError: true,
content: [
{
type: "text",
text: `Invoice ${params.invoice_id} not found. Cannot delete.`,
},
],
};
}
if (invoice.status === "paid") {
return {
isError: true,
content: [
{
type: "text",
text: `Cannot delete paid invoice. Call \`void_invoice\` instead to prevent future charges.`,
},
],
};
}
await db.invoices.delete(invoice.id);
return {
content: [
{
type: "text",
text: `Invoice ${invoice.id} deleted.`,
},
],
};
};MCP Annotations Reference
The MCP readOnlyHint, destructiveHint, idempotentHint, and openWorldHint hints let downstream clients reason about tool safety - the same read/write/destructive classifications above, expressed as machine-readable metadata. MCP defaults are conservative: tools are assumed writable, potentially destructive, non-idempotent, and open-world unless you say otherwise, so set hints only when the property is accurate for the implementation. See Tool Annotations for the full semantics of each hint and how clients act on them.
See also
- Tool Annotations - canonical semantics of the four MCP behavioral hints
- Human Approval - the surface contract for suspending a call on human sign-off and resuming it
/docs/error-handling/idempotency- retry logic and recovery patterns- (MCP 2026-07-28) - current annotation semantics
- (Anthropic multi-agent research system)