Blog · MCP Server Security
MCP server AbortController security — cancellation oracle timing attacks, AbortSignal composition for timeout and user cancel, resource leak prevention, and signal.throwIfAborted() patterns
AbortController is the standard mechanism for canceling in-flight tool call requests in MCP clients. The client creates an AbortController, passes signal to the fetch (or WebSocket message handler), and calls abort() when the user cancels or a timeout fires. The security implications appear in three places: abort timing as an oracle for backend processing state, AbortSignal composition race conditions, and server-side resource leaks when the abort signal is propagated without proper cleanup.
Cancellation oracle: what abort timing reveals
When a client aborts an MCP tool call in flight, the server either:
- Receives the abort before completing the tool execution → request is canceled mid-flight
- Completes the tool execution before the abort arrives → tool ran, but client doesn't get the result
The critical difference is in case 2: the tool's side effects have occurred even though the client received an abort error. An email_send tool that completed before the abort still sent the email. A database_write tool that completed before the abort still wrote the record. The client UI shows "canceled" but the operation happened.
For an attacker, the timing of the abort error response is an oracle: a very fast error (AbortError returned in < 50ms) means the server caught the abort signal early — the tool probably did not execute. A slow error (200–500ms) means the server returned abort response after tool execution completed — the side effect probably occurred. This timing distinction can be exploited to probe backend execution state without receiving the tool result:
// Timing oracle attack: probe whether a rate-limited tool executed
async function probeExecution(toolName, args) {
const controller = new AbortController();
const start = performance.now();
// Start the tool call
const callPromise = mcpClient.call(toolName, args, { signal: controller.signal });
// Abort after a deliberate delay designed to fall at the execution boundary
setTimeout(() => controller.abort(), 150); // tune to the tool's typical execution time
try {
await callPromise;
} catch (e) {
if (e.name !== 'AbortError') throw e;
const elapsed = performance.now() - start;
// elapsed ≈ 150ms → abort caught pre-execution (tool did NOT run)
// elapsed >> 150ms → abort arrived post-execution (tool DID run, side effects happened)
return elapsed > 200 ? 'executed' : 'canceled_before_execution';
}
}
MCP-specific risk: MCP tools often have observable side effects (file writes, email sends, API calls). The cancellation oracle allows an attacker to determine whether a side effect occurred without receiving the tool's output. Combined with knowledge of what the tool does, this leaks operational state.
The server-side defense is to make abort response timing uniform — return the abort error at a fixed delay regardless of whether execution completed, and use idempotency keys to detect and suppress duplicate side effects from race-condition aborts.
AbortSignal composition: race conditions in timeout + user cancel
AbortSignal.any() (Node 20+, browsers) composes multiple signals — the composed signal aborts when the first of its sources aborts. The common pattern for MCP tool calls is composing a timeout signal with a user-cancel signal:
// Common pattern — has a subtle race condition
async function callTool(name, args, userSignal) {
const timeoutSignal = AbortSignal.timeout(5000);
const combined = AbortSignal.any([timeoutSignal, userSignal]);
return mcpClient.call(name, args, { signal: combined });
}
// The race condition:
// 1. Tool call starts at t=0
// 2. Timeout fires at t=5000ms — combined signal aborts
// 3. Server was processing — abort propagated to server
// 4. Server returns AbortError at t=5020ms
// 5. But the tool's database write committed at t=4990ms
// 6. Client assumes canceled, retries the call
// 7. Duplicate write — no idempotency key to detect it
The race condition is not in the signal composition itself but in what happens after the abort: if the client retries on abort without an idempotency key, and the server completed the first call before aborting, the side effect is duplicated. The defense is mandatory idempotency keys on all side-effectful tool calls, and detecting and rejecting duplicate keys server-side:
// Client: always include idempotency key
async function callTool(name, args, userSignal) {
const idempotencyKey = crypto.randomUUID(); // unique per logical operation
const timeoutSignal = AbortSignal.timeout(5000);
const combined = AbortSignal.any([timeoutSignal, userSignal]);
try {
return await mcpClient.call(name, args, {
signal: combined,
headers: { 'Idempotency-Key': idempotencyKey }
});
} catch (e) {
if (e.name !== 'AbortError') throw e;
// DO NOT automatically retry with the same idempotency key
// Inform the user and let them decide whether to retry
throw new McpCancelledError({ idempotencyKey, mayHaveExecuted: true });
}
}
// Server: check idempotency key before executing
async function handleToolCall(req) {
const key = req.headers['idempotency-key'];
if (key) {
const existing = await db.query('SELECT result FROM idempotency_cache WHERE key = ?', [key]);
if (existing) return existing.result; // return cached result, do not re-execute
}
const result = await executeTool(req.tool, req.args, req.signal);
if (key) await db.query('INSERT INTO idempotency_cache VALUES (?, ?)', [key, result]);
return result;
}
Resource leaks on abort: server-side cleanup
When the client aborts a tool call, the server receives an abort signal (via req.signal in Node.js, or via the connection close event). If the server's tool handler started external operations — opened a database connection, acquired a mutex lock, started a child process, opened a file handle — and the abort signal fires mid-operation, those resources must be cleaned up. Without explicit cleanup, aborted tool calls leak resources until garbage collection:
// Dangerous: resource leak on abort
async function unsafeToolHandler(args, signal) {
const conn = await db.getConnection(); // acquired
const result = await conn.query(args.sql, { signal }); // may throw AbortError
conn.release(); // NEVER REACHED if abort fires during query
return result;
}
// Safe: always release in finally
async function safeToolHandler(args, signal) {
const conn = await db.getConnection();
try {
signal.throwIfAborted(); // fail fast if already aborted before we started
const result = await conn.query(args.sql, { signal });
return result;
} finally {
conn.release(); // always runs — abort or success
}
}
The signal.throwIfAborted() check at the start of the handler is a fast-fail: if the signal is already aborted when the handler begins (because the client aborted before the server processed the request), skip the entire operation and return immediately, releasing any resources before they're acquired. The finally block ensures cleanup regardless of outcome.
Abort signal propagation to downstream services
MCP tools that call downstream services (other APIs, databases, message queues) should propagate the abort signal so that downstream calls are also canceled when the client aborts. Without propagation, the server continues executing downstream calls after the client has disconnected, wasting resources and potentially causing side effects:
// Propagate abort to all downstream calls
async function compoundTool(args, signal) {
const [data, metadata] = await Promise.all([
fetchFromApi(args.id, { signal }), // abort propagated
fetchMetadata(args.id, { signal }) // abort propagated
]);
// If client aborts, both fetches are canceled simultaneously
return transform(data, metadata);
}
SkillAudit findings
try/finally cleanup. Aborted tool calls leak acquired resources until process restart or GC — under high abort rate, exhausts connection pool. −16 pts
SkillAudit check: SkillAudit's static analysis detects MCP tool handlers that acquire resources without try/finally blocks and side-effectful tool implementations lacking idempotency key handling. Audit your MCP server →
See also: MCP server Streams API security (backpressure and cancel safety) · MCP server fetch() security (abort propagation in fetch chains)