---
title: "Idempotency and Safety"
description: "Designing tools to be safely retryable and clearly annotated as read or write"
url: "https://agentsurface.dev/docs/tool-design/idempotency-and-safety"
lastVerified: 2026-09-24
lastModified: 2026-09-25T10:35:24.000Z
---



## Summary [#summary]

Design tools to be safely retryable given network timeouts, rate limits, 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 idempotency keys, dry-run modes before mutations, clear read vs. write classification. Annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint in MCP) 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 [#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 [#patterns]

**Upsert instead of create + update:**

```typescript
// 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:

```typescript
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](/docs/error-handling/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 [#when-to-mark-as-idempotent]

Use the MCP `idempotentHint` annotation:

```typescript
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 [#read-vs-write-classification]

Every tool must be clearly classified as read-only or write (mutating).

### Read-Only Tools [#read-only-tools]

Tools that query without side effects:

```typescript
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 tool calling - the model issues several tool calls within a single turn - distinct from the concurrent fan-out covered in [Orchestration](/docs/agents/orchestration), where the orchestrator, 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 APIs 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 [#write-tools]

Tools that create, update, or mutate state:

```typescript
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 [#destructive-tools]

Tools that delete, void, or irreversibly modify data:

```typescript
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 [#dry-run-modes]

For risky operations, expose a preview tool that describes what would happen without actually doing it:

```typescript
// 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 [#tool-composition-for-safety]

### Read Before Write [#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 [#list-before-get]

Destructive tools often require a valid ID. Ensure the corresponding list operation exists:

```typescript
list_invoices(); // Returns invoice_ids
get_invoice(id); // Fetches full details
delete_invoice(id); // Requires valid id from list or get
```

Agents cannot delete safely if they cannot list and inspect targets first.

### Conditional Mutations [#conditional-mutations]

When possible, use conditional mutations instead of separate delete tools:

```typescript
// 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 [#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:

```typescript
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](/docs/tool-design/tool-curation).

## Draft-Then-Confirm for Destructive Actions [#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:

```typescript
// 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](/docs/tool-design/human-approval).

## Documenting Safety Constraints [#documenting-safety-constraints]

In descriptions, explicitly state:

1. **Whether the tool is idempotent** - "Safe to retry with same inputs"
2. **Whether it's reversible** - "This cannot be undone" vs "Can be reverted with `archive_invoice`"
3. **What approvals are required** - "Requires human approval before executing"
4. **Rate limits** - "Max 10 calls per minute"

```typescript
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 [#error-handling-for-destructive-operations]

See `/docs/error-handling/idempotency` for structured error patterns. For destructive operations, always return structured errors rather than throwing:

```typescript
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 [#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](/docs/mcp-servers/annotations) for the full semantics of each hint and how clients act on them.

## See also [#see-also]

* [Tool Annotations](/docs/mcp-servers/annotations) - canonical semantics of the four MCP behavioral hints
* [Human Approval](/docs/tool-design/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](https://modelcontextprotocol.io/specification/2026-07-28/)) - current annotation semantics
* ([Anthropic multi-agent research system](https://www.anthropic.com/engineering/multi-agent-research-system))
