Topic: mcp server concurrent modification security
MCP server concurrent modification security — TOCTOU vulnerabilities, compare-and-swap patterns, and pessimistic locking for sensitive mutations
TOCTOU (time-of-check to time-of-use) race conditions have existed in software since the first concurrent systems. What is new in the MCP context is that the attacker is an LLM orchestrator capable of making hundreds of parallel tool calls per second, in precisely coordinated timing, without human latency. A check-then-act pattern that would be extremely difficult for a human to race is trivially exploitable at machine speed. Compare-and-swap and pessimistic locking make sensitive mutations atomic — closing the race window entirely rather than making it small.
The TOCTOU race in MCP tool handlers
A TOCTOU vulnerability occurs whenever a tool handler reads state, makes a decision based on that state, and then acts — with a gap between the read and the write. If another tool call can modify the state during that gap, the initial check is invalidated by the time the write occurs:
// Classic TOCTOU pattern: check-then-act with an exploitable gap
export async function transfer_credits(args: {
fromUserId: string;
toUserId: string;
amount: number;
}) {
// CHECK: read current balance
const fromUser = await db.users.findOne({ id: args.fromUserId });
if (fromUser.credits < args.amount) {
throw new Error('Insufficient credits');
}
// GAP: async operations here create a race window
// (the await pause is where the race condition lives in Node.js)
// USE: write the deduction
await db.users.updateOne(
{ id: args.fromUserId },
{ $inc: { credits: -args.amount } }
);
await db.users.updateOne(
{ id: args.toUserId },
{ $inc: { credits: +args.amount } }
);
}
// Attack: LLM (or an attacker via prompt injection) initiates two concurrent
// transfer_credits calls simultaneously:
// Call A: transfer 100 credits (balance = 100) → reads balance (100 ≥ 100 ✓)
// Call B: transfer 100 credits (balance = 100) → reads balance (100 ≥ 100 ✓)
// Call A: deducts 100 → balance = 0
// Call B: deducts 100 → balance = -100 ← check was valid, write is not
//
// In Node.js, the await in the gap allows the event loop to process Call B's
// read before Call A's write completes — the race is deterministic, not probabilistic.
Fix 1 — compare-and-swap (optimistic locking)
The compare-and-swap pattern performs the check and the update atomically at the database level. The write operation includes the value observed at check time as a condition — if another write changed the value between check and update, the conditional update fails and the operation must retry:
// Fixed: compare-and-swap using MongoDB findOneAndUpdate with condition
export async function transfer_credits(args: {
fromUserId: string;
toUserId: string;
amount: number;
}) {
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Read current state — include version/etag for CAS
const fromUser = await db.users.findOne({ id: args.fromUserId });
if (!fromUser) throw new Error('User not found');
if (fromUser.credits < args.amount) throw new Error('Insufficient credits');
// Atomic conditional update: only proceed if balance is STILL the value we checked
const result = await db.users.findOneAndUpdate(
{
id: args.fromUserId,
credits: fromUser.credits // CAS condition: value must not have changed
},
{ $inc: { credits: -args.amount } },
{ returnDocument: 'after' }
);
if (result) {
// CAS succeeded — the check value matched, deduction was applied atomically
await db.users.updateOne(
{ id: args.toUserId },
{ $inc: { credits: +args.amount } }
);
return { success: true, newBalance: result.credits };
}
// CAS failed — another write changed the balance between our read and write
// Retry: re-read and re-check
if (attempt === MAX_RETRIES - 1) {
throw new Error('Transfer failed: balance changed concurrently. Please retry.');
}
// Brief backoff before retry
await new Promise(r => setTimeout(r, 50 * (attempt + 1)));
}
}
Fix 2 — optimistic locking with version vectors
For more complex state, a version field (monotonically increasing integer or UUID) makes the CAS condition unambiguous — the write includes the version seen at read time, and the DB rejects any write where the version has advanced:
// Schema: add version field to track concurrent modifications
// { id, credits, version: number, updatedAt: Date }
export async function spend_credits(args: { userId: string; amount: number; reason: string }) {
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const user = await db.users.findOne({ id: args.userId });
if (!user) throw new Error('User not found');
if (user.credits < args.amount) throw new Error('Insufficient credits');
// Update conditionally on both id AND current version
const updated = await db.users.findOneAndUpdate(
{
id: args.userId,
version: user.version // optimistic lock on version
},
{
$inc: {
credits: -args.amount,
version: 1 // increment version atomically with the credit deduction
},
$set: { updatedAt: new Date() }
},
{ returnDocument: 'after' }
);
if (updated) {
await auditLog.append({
tool: 'spend_credits',
userId: args.userId,
amount: args.amount,
reason: args.reason,
fromVersion: user.version,
toVersion: updated.version
});
return { success: true, remaining: updated.credits };
}
if (attempt === MAX_RETRIES - 1) {
throw new Error('Concurrent modification detected. Please retry.');
}
await new Promise(r => setTimeout(r, 100 * Math.pow(2, attempt)));
}
}
Fix 3 — pessimistic locking for complex multi-step mutations
Optimistic locking is efficient when conflicts are rare. For tool handlers that perform multi-step mutations where a conflict at step 3 requires rolling back steps 1 and 2, pessimistic locking is more appropriate — acquire a lock before reading, hold it through all writes, release on completion:
// Pessimistic locking using a distributed lock (Redlock pattern)
import Redlock from 'redlock';
const redlock = new Redlock([redisClient], { retryCount: 3, retryDelay: 200 });
export async function process_order(args: {
orderId: string;
userId: string;
paymentMethodId: string;
}) {
// Acquire exclusive lock on this order — no other tool call can process it simultaneously
const lockKey = `lock:order:${args.orderId}`;
const lockTTL = 10_000; // 10-second TTL — must complete within TTL
let lock;
try {
lock = await redlock.acquire([lockKey], lockTTL);
} catch {
throw new Error('Order is currently being processed. Please wait and retry.');
}
try {
// All reads and writes within the lock — no concurrent process can interleave
const order = await db.orders.findOne({ id: args.orderId });
if (!order) throw new Error('Order not found');
if (order.status !== 'pending') throw new Error(`Order already ${order.status}`);
// Multi-step mutation — all steps protected by the lock
await db.orders.updateOne({ id: args.orderId }, { $set: { status: 'processing' } });
const charge = await stripe.charges.create({
amount: order.totalCents,
currency: 'usd',
payment_method: args.paymentMethodId,
confirm: true,
idempotency_key: `order-${args.orderId}`
});
await db.orders.updateOne(
{ id: args.orderId },
{ $set: { status: 'completed', stripeChargeId: charge.id } }
);
return { success: true, chargeId: charge.id };
} catch (err) {
// Roll back order status on failure
await db.orders.updateOne(
{ id: args.orderId, status: 'processing' },
{ $set: { status: 'pending' } }
);
throw err;
} finally {
// Always release the lock — even on error
await lock.release();
}
}
TOCTOU in filesystem operations
MCP tools that operate on files have the same TOCTOU risk at the OS level. A check for file existence followed by a create operation has a race window where another process can create the file in between:
// Vulnerable: check-then-create with race window
export async function create_report(args: { reportId: string; content: string }) {
const path = `/reports/${sanitizePath(args.reportId)}.md`;
// CHECK
const exists = await fs.access(path).then(() => true).catch(() => false);
if (exists) throw new Error('Report already exists');
// RACE WINDOW: another tool call can create this file here
// USE
await fs.writeFile(path, args.content, { encoding: 'utf8' });
}
// Fixed: atomic exclusive-create flag (O_EXCL equivalent)
export async function create_report(args: { reportId: string; content: string }) {
const path = `/reports/${sanitizePath(args.reportId)}.md`;
try {
// 'wx' flag: create exclusively — fails atomically if file already exists
// No gap between check and create — the OS guarantees this is atomic
const fh = await fs.open(path, 'wx');
await fh.writeFile(args.content, { encoding: 'utf8' });
await fh.close();
} catch (err: any) {
if (err.code === 'EEXIST') {
throw new Error('Report already exists');
}
throw err;
}
}
SkillAudit detection
SkillAudit's Security axis flags these TOCTOU and concurrent modification patterns in MCP server tool handlers:
- check then update async — patterns where a database read for condition checking is followed by an
awaitbefore the conditional write, creating an exploitable race window - no conditional update —
updateOne/findOneAndUpdatecalls that lack a CAS condition in the filter, allowing non-atomic check-then-write - no version field mutation — document schemas that lack a version or ETag field for optimistic locking on write-heavy collections
- file access then write —
fs.access()orfs.stat()followed byfs.writeFile()without thewxexclusive-create flag - no distributed lock — multi-step mutations in tool handlers that modify multiple records without a distributed lock or transaction
Scan your MCP server for TOCTOU vulnerabilities
SkillAudit detects check-then-act patterns, missing CAS conditions, and unprotected multi-step mutations in MCP tool handler implementations.
Request a free audit →Related security topics
- MCP server idempotency security — preventing duplicate mutations from agentic retry loops, a closely related concern
- MCP server cache timing security — cache invalidation races and cache poisoning in MCP tool response caches
- MCP server BOLA/IDOR security — object-level authorization, often bypassed via race conditions that allow cross-user access
- MCP server STRIDE threat model — threat modeling that surfaces TOCTOU risks under the Tampering threat category
- MCP server incident response playbook — responding to concurrent modification exploits that succeed in production