← Blog

MCP server request cancellation security

The MCP 2025-03-26 specification allows clients to send a notifications/cancelled notification to signal that a previously-issued request is no longer needed. The server is expected to attempt to cancel the in-progress operation and stop consuming resources on its behalf. This mechanism introduces four attack surfaces: spoofed cancellation can abort critical operations mid-execution; a TOCTOU race can discard completed results; predictable request IDs enable cross-session cancellation; and rapid cancel/retry cycles exhaust server resources.

By SkillAudit  |  31 Oct 2026

Cancellation is an optimization, not a security primitive. The MCP spec notes that servers “SHOULD attempt to cancel the operation” but may not always be able to. This creates an asymmetry: cancellation is powerful enough to interrupt real operations, but lightweight enough that it receives less security scrutiny than primary request handlers. The four SA-MCP-CAN patterns target this gap between the power of cancellation and the security assumptions around notification handling.

SA-MCP-CAN-001: spoofed cancellation aborts critical operations leaving partial state

MCP servers that honor notifications/cancelled for operations in the middle of a multi-step execution sequence (database transactions, file writes, external API call chains) without atomicity guarantees leave the server in a corrupted intermediate state. An attacker times a cancellation notification to arrive during the most destructive moment of a tool operation — after a database INSERT but before the corresponding UPDATE, or after a file is created but before content is written — producing a partial state that is difficult to detect and roll back.

// VULNERABLE: cancellation honored mid-transaction without rollback
const inFlight = new Map(); // requestId → AbortController

server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, controller);

  try {
    // Step 1: create database record
    await db.query('INSERT INTO orders (id, user_id, status) VALUES (?, ?, ?)',
      [newId, context.userId, 'pending']);  // ← order created

    if (controller.signal.aborted) return {};  // ← cancellation honored HERE
    // Attacker sends notifications/cancelled after INSERT, before UPDATE
    // Database now has orphaned 'pending' order with no subsequent state

    // Step 2: update payment record
    await paymentService.charge(context.userId, amount);  // ← never reached
    await db.query('UPDATE orders SET status = ? WHERE id = ?', ['paid', newId]);

  } finally {
    inFlight.delete(req.id);
  }
  // Result: database has order in 'pending' state indefinitely
  // Depending on business logic: user charged but order not fulfilled, or vice versa
});

// Cancellation handler
server.setNotificationHandler('notifications/cancelled', async (notif, context) => {
  const controller = inFlight.get(notif.params.requestId);
  if (controller) controller.abort();  // No validation — any session can cancel any request ID
});

// SECURE: wrap multi-step operations in transactions; only cancel at safe checkpoints
server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, { controller, sessionId: context.sessionId }); // store session for validation

  // Use database transactions for atomicity
  await db.transaction(async (tx) => {
    await tx.query('INSERT INTO orders ...', [...]);

    // Check cancellation BEFORE entering non-reversible operations
    if (controller.signal.aborted) {
      throw new Error('CANCELLED');  // transaction auto-rolls back on throw
    }

    await paymentService.charge(context.userId, amount);
    await tx.query('UPDATE orders SET status = ? WHERE id = ?', ['paid', newId]);
    // If cancelled after this point: transaction completes — safer than partial state
  });
  // Transaction guarantees: either all steps complete or none do
});

// DETECTION (SkillAudit):
// Find cancellation check points (controller.signal.aborted, isCancelled()) in handler code
const cancelCheckpoints = findCancellationCheckpoints();
for (const cp of cancelCheckpoints) {
  if (isInsideTransaction(cp)) continue; // safe
  if (isAfterIrreversibleOperation(cp)) → HIGH // SA-MCP-CAN-001 (partial state)
  if (isBeforeCompensatingAction(cp)) → HIGH // SA-MCP-CAN-001 (no rollback path)
}
// Does the server use database transactions for multi-step tools/call handlers?
if (!usesTransactionsForMultiStepOps()) → HIGH // SA-MCP-CAN-001

Cancellation timing attacks target the gap between non-atomic operations: An attacker does not need to guess the exact microsecond. Operations with measurable latency between steps (network calls, database round trips, file I/O) create windows of 10-500ms where cancellation produces persistent corrupt state. The attacker fires many cancellations at varying delays and observes error behavior to identify which timing window produced the most useful partial state.

HIGH
SA-MCP-CAN-001 — cancellation honored between non-atomic operation steps: partial database state, orphaned records, or incomplete file writes persist after cancel. Detection: controller.signal.aborted checks between irreversible operations outside a transaction → HIGH.

SA-MCP-CAN-002: cancellation TOCTOU — cancel after operation completes discards result without rollback

A subtler variant of the cancellation race: an operation completes successfully (all database writes committed, all external API calls made, side effects applied) but the cancellation notification arrives before the server sends its JSON-RPC response. Servers that treat a post-completion cancel as “operation cancelled” discard the result and return nothing — while all side effects of the operation remain. The LLM sees no result and may retry the tool call, causing double-writes, duplicate charges, or repeated external API calls.

// VULNERABLE: cancellation check after all side effects are complete discards result
server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, controller);

  // Operation completes fully — all side effects applied
  const result = await sendEmail({ to: req.params.arguments.to, body: req.params.arguments.body });
  await db.query('INSERT INTO sent_emails (id, to, subject) VALUES ...', [...]);
  // ↑ Email sent, database record created

  // TOCTOU: cancellation arrives here, after operation complete but before response sent
  if (controller.signal.aborted) {
    inFlight.delete(req.id);
    return {}; // Returns empty result — operation appeared to fail
    // But: email was already sent, database record already written
  }

  inFlight.delete(req.id);
  return { content: [{ type: 'text', text: `Email sent to ${result.recipientCount} recipients` }] };
});

// ATTACK: LLM calls send_email tool → attacker cancels just after send → LLM retries
// Result: email sent twice, database has duplicate records

// SECURE: record completion state before checking cancellation; return result if already done
server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, controller);

  let operationResult = null;
  let operationComplete = false;

  try {
    const result = await sendEmail({ to: req.params.arguments.to, body: req.params.arguments.body });
    await db.query('INSERT INTO sent_emails ...', [...]);

    operationResult = result;
    operationComplete = true;  // ← Mark complete BEFORE checking cancellation
  } finally {
    inFlight.delete(req.id);
  }

  // Even if cancelled, return result if operation completed — prevents retry with duplicate effect
  if (operationComplete) {
    return { content: [{ type: 'text', text: `Email sent to ${operationResult.recipientCount} recipients` }] };
  }
  return {}; // Only return empty if operation truly did not complete
});

// ALSO SECURE: use idempotency keys for all external API calls
// so LLM retries after spurious empty result do not duplicate effects
async function sendEmailIdempotent(params, idempotencyKey) {
  const existing = await db.query('SELECT id FROM sent_emails WHERE idempotency_key = ?', [idempotencyKey]);
  if (existing.length > 0) return { recipientCount: 1, duplicate: true }; // already sent
  const result = await emailService.send(params);
  await db.query('INSERT INTO sent_emails (idempotency_key, ...) VALUES ...', [idempotencyKey, ...]);
  return result;
}

// DETECTION (SkillAudit):
// Find cancellation checks that appear AFTER irreversible operations (email send, payment, DB write)
const cancelChecks = findCancellationChecks();
for (const check of cancelChecks) {
  if (isAfterIrreversibleOperation(check) && returnsEmptyOnCancel(check)) → HIGH // SA-MCP-CAN-002
}
// Do email-sending, payment, or external API tools use idempotency keys?
const externalCallTools = findToolsWithExternalCalls();
if (externalCallTools.some(t => !usesIdempotencyKey(t))) → MEDIUM // SA-MCP-CAN-002

Post-completion cancellation silently invites LLM retries with full side effects: When an LLM calls a tool and receives no result (empty response from cancelled operation), it typically retries — that’s the expected behavior. If the original operation already completed, the retry doubles the effect. For tools that send messages, charge cards, or write records, this is a high-severity data integrity issue that manifests as user complaints rather than obvious security alerts.

HIGH
SA-MCP-CAN-002 — cancellation check after operation completion returns empty result: side effects already applied; LLM retries cause duplicate emails, charges, or database records. Detection: controller.signal.aborted check after irreversible operations without idempotency guard → HIGH.

SA-MCP-CAN-003: predictable request IDs allow cross-session cancellation of other users’ operations

The MCP spec requires the client to include the requestId of the request it wants to cancel in the notifications/cancelled notification. Servers that track in-flight requests in a global map (keyed only by request ID, not by session) allow any connected client to cancel any other client’s in-flight operation if they can predict or guess the request ID. JSON-RPC request IDs are often sequential integers or short UUIDs, making them guessable with a small brute-force window.

// VULNERABLE: in-flight map keyed only by requestId — shared across all sessions
const inFlight = new Map(); // requestId → AbortController (NO session binding)

server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, controller);  // req.id = integer like 1, 2, 3...
  // ...
});

server.setNotificationHandler('notifications/cancelled', async (notif, context) => {
  const controller = inFlight.get(notif.params.requestId);
  if (controller) {
    controller.abort();  // ← any session can cancel any requestId
    inFlight.delete(notif.params.requestId);
  }
});

// ATTACK: if request IDs are sequential integers
// Session A (victim): sends tools/call → gets requestId = 42
// Session B (attacker): guesses or observes sequential IDs → sends notifications/cancelled with requestId = 42
// Session A's operation is aborted by Session B

// ALSO VULNERABLE: JSON-RPC IDs from clients that increment from 1
// MCP SDK default: client.request() increments from 1 each session
// Multi-session server: client A has ID 5, client B has ID 5 → collision; cancel by B aborts A's ID 5

// SECURE: bind in-flight operations to session; validate cancellation source
const inFlight = new Map(); // `${sessionId}:${requestId}` → AbortController

server.setRequestHandler('tools/call', async (req, context) => {
  const key = `${context.sessionId}:${req.id}`;
  const controller = new AbortController();
  inFlight.set(key, controller);

  try {
    // ... operation ...
  } finally {
    inFlight.delete(key);
  }
});

server.setNotificationHandler('notifications/cancelled', async (notif, context) => {
  // Only allow cancellation of operations within the same session
  const key = `${context.sessionId}:${notif.params.requestId}`;
  const controller = inFlight.get(key);
  if (controller) {
    controller.abort();
    inFlight.delete(key);
  }
  // Silently ignore cancel for request IDs from other sessions
  // Do not return an error — that would confirm whether the ID was valid (oracle)
});

// ALSO SECURE: use server-generated request keys instead of client-supplied IDs
// Server generates its own internal operation ID; maps client requestId → internal ID per-session
const operationRegistry = new Map(); // sessionId → Map(requestId → internalOpId)

// DETECTION (SkillAudit):
// Is the in-flight request map keyed by requestId alone (without sessionId)?
const inFlightMap = findInFlightRequestMap();
if (keyedByRequestIdOnly(inFlightMap)) → CRITICAL // SA-MCP-CAN-003
// Does notifications/cancelled handler validate that requestId belongs to the same session?
const cancelHandler = findCancelNotificationHandler();
if (!validatesSessionBinding(cancelHandler)) → CRITICAL // SA-MCP-CAN-003

Cross-session cancellation is a denial-of-service primitive that requires no special privileges: Any authenticated client can disrupt any other client’s operations by guessing or observing request IDs. In a hosted MCP server where users run long operations (large file processing, multi-step workflows), cross-session cancellation is an effective targeted DoS that terminates specific users’ operations without affecting the attacker.

CRITICAL
SA-MCP-CAN-003 — in-flight request map keyed by request ID without session binding: any client can cancel another client’s operations by guessing or observing sequential request IDs. Detection: notifications/cancelled handler that looks up in a global request map without validating context.sessionId → CRITICAL.

SA-MCP-CAN-004: rapid cancel/retry cycles exhaust server resources via repeated operation startup and teardown

An attacker who can issue rapid tools/call requests followed immediately by notifications/cancelled forces the server to repeatedly allocate resources for an operation (thread pool workers, database connections, memory for operation state) and then tear them down before completion. The startup cost of each operation may be higher than its cancellation cost — especially for operations that acquire locks, open file handles, or create network connections. A sustained cancel-flood doubles the server’s effective work per real operation.

// VULNERABLE: no rate limit on tools/call or cancel cycles; startup cost is high
server.setRequestHandler('tools/call', async (req, context) => {
  // Each call: acquire DB connection from pool (expensive), open file handle, allocate buffer
  const conn = await dbPool.acquire();  // ← pool has 20 connections; attacker exhausts it
  const fileHandle = await fs.open(req.params.arguments.path, 'r');
  const buffer = Buffer.alloc(10 * 1024 * 1024);  // 10MB per operation

  const controller = new AbortController();
  inFlight.set(req.id, controller);

  try {
    if (controller.signal.aborted) return {};  // ← attacker cancels immediately after call
  } finally {
    // Cleanup on cancel: releases connection back to pool, closes file handle
    // But connection acquire latency (5-50ms) + file open (1-5ms) = ~50ms wasted per cycle
    dbPool.release(conn);
    await fileHandle.close();
    Buffer.alloc(0); // buffer GC
  }
});

// ATTACK: cancel flood
for (let i = 0; i < 1000; i++) {
  const { id } = client.request({ method: 'tools/call', params: { name: 'process_file', arguments: { path: '/test' } } });
  // Immediately cancel before server can proceed past resource acquisition
  client.notification({ method: 'notifications/cancelled', params: { requestId: id } });
}
// 1000 rapid acquire/release cycles exhaust DB connection pool for legitimate users
// Server spends most of its time in connection acquire/release, not real work

// SECURE: rate limit tools/call per session; track and limit cancel rate
const callRateLimits = new Map();  // sessionId → token bucket
const cancelRateLimits = new Map();

function checkRateLimit(sessionId, limiterMap, maxPerMinute) {
  const now = Date.now();
  const limit = limiterMap.get(sessionId) ?? { count: 0, windowStart: now };
  if (now - limit.windowStart > 60_000) { limit.count = 0; limit.windowStart = now; }
  limit.count++;
  limiterMap.set(sessionId, limit);
  if (limit.count > maxPerMinute) {
    throw new McpError(ErrorCode.InvalidRequest, 'Rate limit exceeded');
  }
}

server.setRequestHandler('tools/call', async (req, context) => {
  checkRateLimit(context.sessionId, callRateLimits, 60); // max 60 tool calls/min/session
  // ... operation ...
});

server.setNotificationHandler('notifications/cancelled', async (notif, context) => {
  checkRateLimit(context.sessionId, cancelRateLimits, 120); // max 120 cancels/min/session
  // Abnormal cancel rate > 2× call rate is a signal for abuse
  const callRate = callRateLimits.get(context.sessionId)?.count ?? 0;
  const cancelRate = cancelRateLimits.get(context.sessionId)?.count ?? 0;
  if (cancelRate > callRate * 3) {
    logger.warn('Suspicious cancel flood pattern', { sessionId: context.sessionId.slice(0, 8) });
    // Consider: temporary session suspension
  }
  // ... cancel logic ...
});

// ALSO SECURE: defer expensive resource acquisition until after first cancellation check
server.setRequestHandler('tools/call', async (req, context) => {
  const controller = new AbortController();
  inFlight.set(req.id, controller);

  // Check for immediate cancel BEFORE acquiring expensive resources
  if (controller.signal.aborted) return {};

  // Only acquire resources if not immediately cancelled
  const conn = await dbPool.acquire();
  // ...
});

// DETECTION (SkillAudit):
// Is there a per-session rate limit on tools/call?
if (!hasToolCallRateLimit()) → HIGH // SA-MCP-CAN-004
// Is there a rate limit on notifications/cancelled?
if (!hasCancelRateLimit()) → MEDIUM // SA-MCP-CAN-004
// Are expensive resources acquired before the first cancellation check?
if (acquiresResourcesBeforeCancelCheck()) → HIGH // SA-MCP-CAN-004

Cancel floods are an asymmetric resource amplification attack: Sending a notifications/cancelled notification is nearly zero-cost for the client (a small JSON-RPC message). On the server, each cancel/retry cycle may trigger database connection pool operations, thread allocation, or lock acquisition and release. In a serverless MCP deployment with per-invocation billing, a cancel flood could also generate unexpected cost by triggering many short-lived function invocations.

HIGH
SA-MCP-CAN-004 — no rate limit on tool call cancellation cycles: rapid cancel/retry exhausts database connection pool, thread pool workers, or file handle limits for all users. Detection: missing per-session rate limit on tools/call and notifications/cancelled; expensive resources acquired before cancellation check → HIGH.

How SkillAudit detects SA-MCP-CAN patterns

PatternDetection methodSeverity
SA-MCP-CAN-001Static: cancellation signal checks between irreversible operations (INSERT/UPDATE, file write, external API) outside database transaction scopeHIGH
SA-MCP-CAN-002Static: controller.signal.aborted check after irreversible operations returns empty result; no idempotency key for external API callsHIGH
SA-MCP-CAN-003Static: notifications/cancelled handler looks up in global request map without context.sessionId validationCRITICAL
SA-MCP-CAN-004Static: missing per-session rate limit on tools/call and notifications/cancelled; expensive resource acquisition before cancellation checkHIGH

Run a SkillAudit scan on your MCP server’s cancellation implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-CAN patterns and 200+ additional MCP security checks.