Topic: concurrent access security

MCP server concurrent access security — TOCTOU race conditions, advisory locking, optimistic concurrency

MCP servers that handle multiple simultaneous tool calls — whether from parallel LLM invocations, multiple Claude Code sessions, or a single session issuing tool calls without waiting for each to complete — face a class of vulnerabilities that do not exist in single-threaded request handlers. The key insight is that every await is a potential concurrency window: any operation that reads state before an await and modifies it after can be raced by a concurrent handler executing in that gap. This page covers the five highest-impact patterns: TOCTOU file races, database read-modify-write races, advisory in-process locking, optimistic concurrency with version stamps, and Node.js event-loop interleaving.

1. TOCTOU race in file operations

The check-then-write pattern is the most common file TOCTOU in MCP servers. A tool checks whether a file exists, then writes it. Between the check and the write, a concurrent request performs the same check — both see the file absent — and both proceed to write. The last write wins; the first write's data is silently discarded. In quota-enforcement scenarios, this also allows two requests to each "claim" the same slot, exceeding the quota by 1× per concurrent pair.

The atomic rename pattern eliminates this window. Write to a randomly named temporary file in the same directory (same filesystem, so rename is atomic), then rename it into place. If two requests race, only one rename succeeds atomically; the other either creates a distinct file or overwrites the winner — both are detectable. For exclusive creation (the "create-if-absent" case), O_EXCL makes the entire check-and-create a single atomic syscall.

import fs from 'fs/promises'
import path from 'path'
import { randomBytes } from 'crypto'
import { constants } from 'fs'

const WORKSPACE = '/app/workspace'

// DANGEROUS: check-then-write — two concurrent requests both see the file
// absent, both create it, first write is silently overwritten.
async function saveReportDangerous(name: string, content: string): Promise<void> {
  const dest = path.join(WORKSPACE, `${name}.json`)

  // Check: does the file exist?
  try {
    await fs.access(dest)
    throw new Error('Report already exists')  // Only one request gets here
  } catch (err: any) {
    if (err.code !== 'ENOENT') throw err
    // TOCTOU WINDOW: another request also saw ENOENT and is now here too.
    // Both proceed to write.
  }

  // Both requests write — second write discards first write's data.
  await fs.writeFile(dest, content, 'utf8')
}

// SAFE PATTERN 1: atomic exclusive create with O_EXCL
// O_EXCL | O_CREAT causes open() to fail with EEXIST if the file exists.
// The check and the create are one atomic syscall — no TOCTOU window.
async function saveReportExclusive(name: string, content: string): Promise<void> {
  const dest = path.join(WORKSPACE, `${name}.json`)
  let fh: fs.FileHandle | undefined
  try {
    fh = await fs.open(dest, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644)
    await fh.writeFile(content, 'utf8')
  } catch (err: any) {
    if (err.code === 'EEXIST') throw new Error('Report already exists')
    throw err
  } finally {
    await fh?.close()
  }
}

// SAFE PATTERN 2: atomic rename for update-or-create
// Write to a tmp file first; rename is atomic on POSIX filesystems.
// If two requests race, both renames succeed (POSIX rename is atomic replace),
// and the last rename wins — but no data is silently lost: each request
// wrote its own tmp file, and the winner is deterministic.
async function saveReportAtomicRename(name: string, content: string): Promise<void> {
  const dest = path.join(WORKSPACE, `${name}.json`)
  // Tmp file in the same directory to guarantee same filesystem (atomic rename)
  const tmp = path.join(WORKSPACE, `.tmp-${randomBytes(8).toString('hex')}.json`)

  try {
    await fs.writeFile(tmp, content, 'utf8')
    // atomic: either the old file is replaced or it isn't — no partial state
    await fs.rename(tmp, dest)
  } catch (err) {
    // Clean up the tmp file if rename failed
    await fs.unlink(tmp).catch(() => {})
    throw err
  }
}

2. Database TOCTOU — SELECT then UPDATE

The most common database TOCTOU is the balance debit pattern: read the balance, check it is sufficient, write the new balance. Under concurrent load, two requests can both read the same balance (say, 100), both conclude that a 50-unit debit is valid (100 >= 50), and both write 50 as the new balance — executing 100 units of debits while deducting only 50. This is a classic double-spend vulnerability.

The fix is to move the check into the UPDATE itself using a conditional WHERE clause. The database engine evaluates the condition atomically with the write inside the transaction. If the condition is not met (balance is already too low), the UPDATE affects 0 rows; checking rowCount === 0 detects the race. For cases where you need to read-then-modify a complex record, SELECT ... FOR UPDATE acquires a row-level exclusive lock that blocks concurrent SELECT FOR UPDATE on the same row until the first transaction commits.

import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// DANGEROUS: SELECT then UPDATE in two separate statements.
// Two concurrent requests both read balance=100, both check 100>=50 (pass),
// both write 100-50=50. Net result: 100 units debited, only 50 deducted.
async function debitBalanceDangerous(accountId: string, amount: number): Promise<void> {
  const { rows } = await pool.query(
    'SELECT balance FROM accounts WHERE id = $1',
    [accountId]
  )
  if (!rows[0]) throw new Error('Account not found')

  const balance = rows[0].balance
  // Both concurrent requests pass this check with balance=100, amount=50
  if (balance < amount) throw new Error('Insufficient funds')

  // Both requests write 50 — the debit runs twice but balance only drops once
  await pool.query(
    'UPDATE accounts SET balance = $1 WHERE id = $2',
    [balance - amount, accountId]
  )
}

// SAFE PATTERN 1: conditional UPDATE — move the check into WHERE.
// The database evaluates balance >= amount atomically with the write.
// If the check fails (balance already debited by a concurrent request),
// rowCount === 0 and we raise a conflict error.
async function debitBalanceSafe(accountId: string, amount: number): Promise<void> {
  const result = await pool.query(
    `UPDATE accounts
        SET balance = balance - $2,
            updated_at = NOW()
      WHERE id = $1
        AND balance >= $2`,
    [accountId, amount]
  )
  if (result.rowCount === 0) {
    // Either account not found or insufficient funds — re-read to distinguish
    const { rows } = await pool.query('SELECT id FROM accounts WHERE id = $1', [accountId])
    if (rows.length === 0) throw new Error('Account not found')
    throw new Error('Insufficient funds')
  }
}

// SAFE PATTERN 2: SELECT FOR UPDATE for complex read-modify-write.
// Acquires a row-level exclusive lock. Concurrent transactions block on
// their SELECT FOR UPDATE until this transaction commits or rolls back.
async function debitWithSelectForUpdate(accountId: string, amount: number): Promise<void> {
  const client = await pool.connect()
  try {
    await client.query('BEGIN')

    const { rows } = await client.query(
      'SELECT id, balance FROM accounts WHERE id = $1 FOR UPDATE',
      [accountId]
    )
    if (!rows[0]) {
      await client.query('ROLLBACK')
      throw new Error('Account not found')
    }

    // Safe to read balance here: row is locked. Concurrent requests block
    // at their own SELECT FOR UPDATE until this COMMIT releases the lock.
    if (rows[0].balance < amount) {
      await client.query('ROLLBACK')
      throw new Error('Insufficient funds')
    }

    await client.query(
      'UPDATE accounts SET balance = balance - $2 WHERE id = $1',
      [accountId, amount]
    )
    await client.query('COMMIT')
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {})
    throw err
  } finally {
    client.release()
  }
}

3. Advisory locking in Node.js

Some operations must be serial per resource, but starting a full database transaction just to serialize two in-process operations is expensive — it requires a round-trip to the database for BEGIN and COMMIT even if no database rows are involved. The alternative is an in-process advisory lock: a Map of Promises where each key represents a locked resource, and each new lock acquisition chains onto the previous promise for that key. When the lock holder resolves its promise, the next waiter automatically gets the lock.

This pattern works reliably in a single Node.js process. For multi-process deployments, switch to a distributed advisory lock (Redis SET NX PX with a heartbeat, or PostgreSQL pg_advisory_lock). The critical discipline is always releasing the lock in a finally block — an uncaught exception that skips the release will deadlock every subsequent request for that resource.

// AsyncMutex: per-resource advisory lock using a Map of chained Promises.
// acquire(key) returns a release() function. Always call release() in finally.

class AsyncMutex {
  private locks = new Map<string, Promise<void>>()

  async acquire(key: string): Promise<() => void> {
    // Chain this acquisition onto the tail of the current lock queue for this key.
    // If no lock exists, resolves immediately.
    const prev = this.locks.get(key) ?? Promise.resolve()

    let release!: () => void
    const next = new Promise<void>((resolve) => {
      release = resolve
    })

    // The next waiter blocks until the current holder calls release()
    this.locks.set(key, prev.then(() => next))

    // Wait for the previous holder to release
    await prev

    return () => {
      release()
      // Clean up the Map entry when the lock queue for this key drains
      // (if no other waiters chained on, the Promise is already resolved)
      if (this.locks.get(key) === next) {
        this.locks.delete(key)
      }
    }
  }
}

const mutex = new AsyncMutex()

// DANGEROUS: two concurrent calls to processUserFile both proceed in parallel.
// Both open the file, both read its contents, both write modifications —
// the second write overwrites the first write's changes.
async function processUserFileDangerous(userId: string): Promise<void> {
  const filePath = `/app/data/${userId}.json`
  const content = JSON.parse(await fs.readFile(filePath, 'utf8'))
  content.processedAt = new Date().toISOString()
  content.callCount = (content.callCount ?? 0) + 1
  await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf8')
}

// SAFE: AsyncMutex serializes per-userId. Second caller waits for first to
// finish before acquiring the lock. No interleaving, no lost updates.
async function processUserFileSafe(userId: string): Promise<void> {
  const filePath = `/app/data/${userId}.json`
  const release = await mutex.acquire(`user-file:${userId}`)
  try {
    const content = JSON.parse(await fs.readFile(filePath, 'utf8'))
    content.processedAt = new Date().toISOString()
    content.callCount = (content.callCount ?? 0) + 1
    await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf8')
  } finally {
    // ALWAYS release in finally — an exception must not leave the lock held
    release()
  }
}

// Lock guard helper: reduces boilerplate and ensures release is never forgotten
async function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
  const release = await mutex.acquire(key)
  try {
    return await fn()
  } finally {
    release()
  }
}

// Usage:
// const result = await withLock(`invoice:${invoiceId}`, () => finalizeInvoice(invoiceId))

4. Optimistic concurrency with version stamps

Advisory locking serializes access, but at the cost of throughput: every concurrent request for the same resource queues behind the current holder. For high-read, low-conflict workloads, optimistic concurrency control is more efficient. Every mutable record carries a version integer (or a UUID). Each UPDATE includes a WHERE version = $n clause and bumps the version. If a concurrent update already incremented the version, the WHERE clause matches nothing and rowCount === 0 signals a conflict. The caller retries with a fresh read.

The retry loop must include bounded retries and jitter to prevent a thundering herd when many concurrent writers all lose the race at the same time and immediately retry, all racing again simultaneously.

import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// Schema: every mutable table has a version column.
// CREATE TABLE documents (
//   id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
//   title       TEXT NOT NULL,
//   body        TEXT NOT NULL,
//   version     INTEGER NOT NULL DEFAULT 1,
//   updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
// );

interface Document {
  id: string
  title: string
  body: string
  version: number
}

// DANGEROUS: update without version check.
// Concurrent updates silently overwrite each other's changes.
async function updateDocumentDangerous(id: string, newBody: string): Promise<void> {
  await pool.query(
    'UPDATE documents SET body = $2, updated_at = NOW() WHERE id = $1',
    [id, newBody]
  )
}

// SAFE: optimistic concurrency — include AND version = $3 in the WHERE clause.
// Bump version on every successful write. If 0 rows affected, a concurrent
// update already changed the version — retry with a fresh read.
async function updateDocumentOptimistic(id: string, newBody: string): Promise<void> {
  // Read current version
  const { rows } = await pool.query<Document>(
    'SELECT id, title, body, version FROM documents WHERE id = $1',
    [id]
  )
  if (!rows[0]) throw new Error('Document not found')

  const currentVersion = rows[0].version

  // Update only if version hasn't changed since we read it
  const result = await pool.query(
    `UPDATE documents
        SET body = $2,
            version = version + 1,
            updated_at = NOW()
      WHERE id = $1
        AND version = $3`,
    [id, newBody, currentVersion]
  )

  if (result.rowCount === 0) {
    // A concurrent update changed the version — caller must retry
    throw new ConcurrencyConflictError('Document was modified by a concurrent request')
  }
}

class ConcurrencyConflictError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'ConcurrencyConflictError'
  }
}

// Retry loop with exponential backoff and jitter.
// Without jitter, all concurrent losers retry at the same time and race again.
async function updateDocumentWithRetry(
  id: string,
  newBody: string,
  maxRetries = 5
): Promise<void> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      await updateDocumentOptimistic(id, newBody)
      return  // Success
    } catch (err) {
      if (!(err instanceof ConcurrencyConflictError)) throw err
      if (attempt === maxRetries) throw new Error(`Update failed after ${maxRetries} retries`)

      // Exponential backoff: 10ms, 20ms, 40ms, 80ms, 160ms + up to 50ms jitter
      const backoffMs = Math.min(10 * 2 ** attempt, 500) + Math.random() * 50
      await new Promise(resolve => setTimeout(resolve, backoffMs))
    }
  }
}

5. Node.js event loop and async concurrency windows

Node.js is single-threaded — it does not execute JavaScript in parallel — but it is not free of concurrency. The event loop processes callbacks one at a time, but await suspends the current function and allows other event-loop callbacks (including other active request handlers) to run. The window between two awaits in one handler is precisely the time during which a concurrent handler can observe and modify shared state.

This is subtle because the code looks sequential. A developer writing a balance-check-before-debit handler in Node.js may reason that "since Node is single-threaded, the check and the debit can't be interleaved" — but that reasoning is only correct if neither operation involves an await. The moment the balance check hits a database await, the handler suspends; a concurrent request's handler resumes; by the time the first handler gets its balance result, the database has already been modified by the second request.

// DANGEROUS: the "Node is single-threaded so this is safe" fallacy.
// A balance check before an await and a debit after the await can be raced.

let cachedBalance: Map<string, number> = new Map()  // In-process balance cache

async function debitFromCacheDangerous(accountId: string, amount: number): Promise<void> {
  // Step 1: read from cache (synchronous — no await, no race here)
  let balance = cachedBalance.get(accountId)

  if (balance === undefined) {
    // Step 2: cache miss — fetch from DB
    // CONCURRENCY WINDOW OPENS HERE: while awaiting the DB, another call
    // to debitFromCacheDangerous for the same accountId also hits a cache
    // miss, also reaches this await, and also proceeds to fetch from DB.
    const { rows } = await pool.query(
      'SELECT balance FROM accounts WHERE id = $1',
      [accountId]
    )
    balance = rows[0]?.balance ?? 0
    cachedBalance.set(accountId, balance)  // Both requests set the same value
  }
  // CONCURRENCY WINDOW: both requests now have balance=100, amount=50.
  // Both check 100>=50 (pass). Both proceed to debit.

  if (balance < amount) throw new Error('Insufficient funds')

  // Step 3: debit — both requests execute this, resulting in a double-spend
  // Step 3a: update cache (both write 50)
  cachedBalance.set(accountId, balance - amount)
  // Step 3b: persist to DB
  // ANOTHER WINDOW: the await here also suspends — the first request updates
  // the DB, resumes, while the second request's DB update was already sent.
  await pool.query(
    'UPDATE accounts SET balance = balance - $2 WHERE id = $1',
    [accountId, amount]
  )
}

// SAFE: hold a per-account advisory lock across the entire read-check-write.
// The lock spans all awaits in the critical section — no concurrent handler
// can interleave with the read, check, or write for this accountId.
const accountMutex = new AsyncMutex()

async function debitFromCacheSafe(accountId: string, amount: number): Promise<void> {
  await withLock(`account:${accountId}`, async () => {
    // Everything inside this callback is serialized per accountId.
    // No concurrent handler can enter this block for the same accountId
    // until this async function resolves — even across awaits.

    let balance = cachedBalance.get(accountId)
    if (balance === undefined) {
      const { rows } = await pool.query(
        'SELECT balance FROM accounts WHERE id = $1',
        [accountId]
      )
      balance = rows[0]?.balance ?? 0
      cachedBalance.set(accountId, balance)
    }

    // Safe: no other handler is running for this accountId, so balance
    // cannot have changed since we read it.
    if (balance < amount) throw new Error('Insufficient funds')

    cachedBalance.set(accountId, balance - amount)
    await pool.query(
      'UPDATE accounts SET balance = balance - $2 WHERE id = $1',
      [accountId, amount]
    )
  })
}

// Rule of thumb: if you read state, make a decision based on it, and then
// write state — and any of those three steps involves an await — you have
// a concurrency window unless you hold a lock across all three steps,
// or use a conditional atomic operation (UPDATE ... WHERE old_value = expected).

What SkillAudit checks

SkillAudit's concurrent access scanner flags the following patterns in MCP server tool handler code:

Run a SkillAudit scan

Paste your GitHub URL and get concurrent access, TOCTOU, and optimistic-concurrency results in 60 seconds.

Scan your MCP server free →