MP-301c · Module 2

Parallel Tool Calls

4 min read

MCP clients can send multiple tool calls simultaneously. Claude, for example, may decide that two lookups are independent and fire both without waiting for the first to complete. Your server receives these as concurrent requests on the same transport. If your handlers are stateless and pure (read data, return result), concurrent execution works out of the box — Node.js handles the async interleaving naturally. The problems start when handlers share mutable state: in-memory caches, counters, session data, or any structure that multiple handlers read and write.

The parallel tool call concurrency model in MCP is cooperative, not preemptive. Node.js runs one JavaScript operation at a time; concurrency comes from interleaving at await points. This means two handlers that increment a counter with counter++ will never produce a torn read — but two handlers that do read → compute → write with awaits between steps can still produce race conditions. The classic bug: handler A reads count=5, handler B reads count=5, both compute count+1=6, both write 6 — the count should be 7 but is 6. This is the read-modify-write problem, and it applies to any shared state accessed across await boundaries.

// WRONG: read-modify-write race condition
let requestCount = 0;

async function badHandler(args: unknown) {
  const current = requestCount;     // read
  await doWork(args);               // yields event loop
  requestCount = current + 1;       // write (stale!)
  return { content: [{ type: "text", text: `Request #${requestCount}` }] };
}

// RIGHT: atomic update, no race
async function goodHandler(args: unknown) {
  requestCount++;                   // atomic (no await between read and write)
  const myCount = requestCount;     // capture after atomic update
  await doWork(args);
  return { content: [{ type: "text", text: `Request #${myCount}` }] };
}

// RIGHT: mutex for complex read-modify-write
import { Mutex } from "async-mutex";
const mutex = new Mutex();

async function safeHandler(args: { id: string; delta: number }) {
  const release = await mutex.acquire();
  try {
    const current = await db.getBalance(args.id);  // read
    const updated = current + args.delta;           // modify
    await db.setBalance(args.id, updated);          // write
    return { content: [{ type: "text", text: `Balance: ${updated}` }] };
  } finally {
    release();
  }
}
  1. Identify shared mutable state Audit every variable, map, and cache that persists across tool calls. If two concurrent handlers can read and write the same state with an await in between, you have a race condition.
  2. Prefer atomic operations For simple counters and flags, keep the read-modify-write in a single synchronous expression. No await between read and write means no race.
  3. Use mutexes for complex operations For multi-step read-modify-write sequences (check balance → debit → credit), use async-mutex to serialize access. Scope the mutex to the specific resource, not the entire handler.