Topic: mcp server idempotency security
MCP server idempotency security — duplicate tool calls, at-most-once execution, and state-machine enforcement for agentic loops
In a human-driven web application, a double-submit is unusual and usually caught by a disabled submit button. In an MCP server, duplicate tool calls are a normal operating condition: the LLM may retry a tool call after a timeout, an agentic loop may issue the same call multiple times due to ambiguous success signals, and a network error between the orchestrator and server may cause the call to be delivered twice. Any tool handler that performs a state-changing action — charging a card, sending an email, writing a database record — must be idempotent: calling it twice with the same arguments must produce the same result as calling it once. Without that guarantee, duplicate calls create duplicate charges, duplicate notifications, and data consistency violations.
Why agentic loops generate duplicate tool calls
Three distinct mechanisms cause the same tool call to be issued more than once in an MCP context:
LLM retry on ambiguous result. If a tool call returns a success response that the LLM interprets as ambiguous ("Transaction processing..." rather than "Transaction completed: txn_abc123"), the LLM may call the tool again to confirm or re-attempt. The first call may have fully executed.
Agentic loop with duplicate-triggering exit condition. A loop whose exit condition is "call the tool until it returns X" will call the tool multiple times if the response format changes between calls — even if the action was completed on the first call.
Network-level delivery uncertainty. The MCP protocol does not guarantee exactly-once delivery. A call that times out at the transport layer may have been fully processed by the server before the timeout — the next retry creates a second execution.
// Vulnerable — no idempotency protection
async function send_invoice(args) {
const invoice = await db.invoices.create({
customer_id: args.customer_id,
amount: args.amount,
email: args.email,
});
await emailService.send({
to: args.email,
subject: `Invoice #${invoice.id}`,
amount: args.amount,
});
return { status: 'sent', invoice_id: invoice.id };
}
// If this tool is called twice (LLM retry after timeout):
// - Two invoice records are created in the database
// - Two emails are sent to the customer
// - Two charges may be attempted if payment is downstream
Fix 1 — idempotency key tied to the tool call arguments
The canonical fix is to accept an idempotency key as a tool argument and store a record of completed operations keyed on it. On the second call with the same key, return the stored result rather than re-executing:
// The idempotency key should be generated by the caller (LLM orchestrator)
// as a stable hash of the logical operation's identity — same arguments = same key.
// Using a UUID v5 (deterministic) over the canonical argument set is reliable.
import { v5 as uuidv5 } from 'uuid';
const IDEMPOTENCY_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; // DNS namespace
async function send_invoice(args) {
// Generate deterministic idempotency key from logical arguments
const idempKey = uuidv5(
JSON.stringify({ customer_id: args.customer_id, amount: args.amount, email: args.email }),
IDEMPOTENCY_NAMESPACE
);
// Check if this operation was already completed
const existing = await db.idempotency_records.findOne({ key: idempKey });
if (existing) {
// Return the stored result from the first execution — do not re-execute
return existing.result;
}
// Execute the operation
const invoice = await db.invoices.create({ customer_id: args.customer_id, amount: args.amount });
await emailService.send({ to: args.email, subject: `Invoice #${invoice.id}`, amount: args.amount });
const result = { status: 'sent', invoice_id: invoice.id };
// Persist the idempotency record with TTL (24h is typical)
await db.idempotency_records.create({
key: idempKey,
result,
expires_at: new Date(Date.now() + 86400_000),
});
return result;
}
Fix 2 — state-machine enforcement for multi-step operations
For multi-step workflows where each tool call is a step in a state machine (create → confirm → fulfill → invoice), idempotency at the tool level is not sufficient — the state machine must also reject out-of-order or duplicate state transitions:
const VALID_TRANSITIONS = {
'created': ['confirmed'],
'confirmed': ['fulfilled'],
'fulfilled': ['invoiced'],
'invoiced': [], // terminal state — no further transitions allowed
};
async function confirm_order(args) {
const order = await db.orders.findById(args.order_id);
if (!order) throw new Error('Order not found');
const allowed = VALID_TRANSITIONS[order.status] ?? [];
if (!allowed.includes('confirmed')) {
// Idempotent for same state, error for invalid transition
if (order.status === 'confirmed') {
return { status: 'already_confirmed', order_id: args.order_id };
}
throw new Error(`Cannot confirm order in status '${order.status}'`);
}
await db.orders.update(args.order_id, { status: 'confirmed' });
return { status: 'confirmed', order_id: args.order_id };
}
// If the LLM calls confirm_order twice:
// First call: order.status = 'created' → transition to 'confirmed' → success
// Second call: order.status = 'confirmed' → returns { status: 'already_confirmed' }
// No double-execution, idempotent result returned
Fix 3 — at-most-once semantics via optimistic locking
For high-throughput scenarios where database round-trips to check idempotency records are too slow, optimistic locking via a unique constraint on the logical operation identity prevents concurrent duplicates at the database level:
// Schema: invoices table has a unique constraint on (customer_id, billing_period)
// Inserting a duplicate row throws a unique violation rather than creating a second record.
async function create_monthly_invoice(args) {
try {
const invoice = await db.invoices.create({
customer_id: args.customer_id,
billing_period: args.billing_period, // e.g., '2026-06'
amount: args.amount,
});
await emailService.send({ to: args.email, ...invoiceDetails(invoice) });
return { status: 'created', invoice_id: invoice.id };
} catch (err) {
if (err.code === '23505') { // PostgreSQL unique violation
// Already created — return the existing record
const existing = await db.invoices.findOne({
customer_id: args.customer_id,
billing_period: args.billing_period,
});
return { status: 'already_exists', invoice_id: existing.id };
}
throw err;
}
}
SkillAudit detection
SkillAudit flags the following idempotency-related patterns in the Security and business logic axes:
- Tool handlers with names suggesting state-changing operations (
send_,create_,charge_,delete_,update_) that lack either an idempotency key parameter or a uniqueness constraint check. - Email-sending tool calls (
sendgrid.send,ses.sendEmail,nodemailer.sendMail) without a preceding deduplication check. - Payment API calls (
stripe.charges.create,stripe.paymentIntents.create) that do not pass a Stripe idempotency key header. - Multi-step workflows (detected via multiple tools with inter-dependent state fields) that do not implement a state-machine transition guard.
Idempotency failures are closely related to the business logic vulnerabilities covered in the business logic security guide and the agentic loop amplification patterns covered in the rate limit security guide. For the multi-agent case where multiple agents may call the same tool concurrently, the multi-agent MCP security post covers distributed locking and consensus patterns that extend idempotency to concurrent execution.