Guide · MCP Protocol

MCP server cancellation

When a user closes a chat window, changes their mind mid-operation, or hits an escape key, the MCP client sends a notifications/cancelled message to the server. Without cancellation handling, your tool handler continues running — consuming database connections, making external API calls, and writing to disk — long after the client has moved on. Proper cancellation requires three things: reading the AbortSignal from extra.signal in your handler, propagating it to downstream async operations, and cleaning up any partial state in a finally block. The tricky part is that cancellation can arrive at any point during execution, and some operations — particularly database writes and file mutations — need to be rolled back or left in a consistent state even when interrupted mid-way.

TL;DR

The MCP SDK exposes an AbortSignal at extra.signal in every tool handler. Pass it to fetch() calls, database queries, and any other interruptible operation. In finally, release locks and close resources regardless of how the handler exits. For operations with side effects, wrap them in a database transaction and roll back on signal.aborted. When the signal fires, return a clean { isError: false, content: [{ type: 'text', text: 'Cancelled.' }] } — not an exception — so the MCP session stays open for subsequent calls.

The cancellation protocol

MCP cancellation uses a standard JSON-RPC notification. When the client decides to cancel an in-flight tool call, it sends:

{
  "jsonrpc": "2.0",
  "method": "notifications/cancelled",
  "params": {
    "requestId": "42",
    "reason": "User cancelled"
  }
}

The MCP SDK receives this notification, locates the in-flight request by its requestId, and aborts the AbortController it created for that request. Your tool handler sees the abort via extra.signal.aborted === true or via an 'abort' event on the signal. You do not need to parse the notifications/cancelled message yourself — the SDK handles the mapping.

Cancellation is a best-effort mechanism. The notification may arrive after your handler has already returned a result, in which case both the result and the cancellation notification are discarded. This is normal; cancellation is not a guarantee of non-execution, only a request to stop if still possible.

Reading extra.signal

Every tool handler receives an AbortSignal in the second argument (extra) under the signal property. For tools where cancellation is not meaningful — fast operations that return in under a second — you can ignore it. For long-running tools, passing it to downstream operations is the key step.

server.tool(
  'bulk_process',
  { dataset_id: z.string(), operation: z.enum(['transform', 'validate', 'export']) },
  async (args, extra) => {
    const { signal } = extra;

    // Pass signal to every async operation that supports it
    const dataset = await fetchDataset(args.dataset_id, { signal });

    const results: string[] = [];
    for (const item of dataset.items) {
      if (signal.aborted) {
        // Early exit between items — clean state, no partial writes yet
        return {
          content: [{ type: 'text', text: `Cancelled after processing ${results.length} items.` }],
        };
      }

      const result = await processItem(item, args.operation, { signal });
      results.push(result);
    }

    return {
      content: [{ type: 'text', text: `Processed ${results.length} items.` }],
    };
  }
);

The signal.aborted check inside the loop is a polling pattern — it catches cancellation at the boundary between items. For operations where you want the cancellation to interrupt a long single step (a large fetch, a slow query), pass the signal as the AbortSignal option and let the underlying API throw an AbortError.

Propagating cancellation to fetch() and databases

Most modern async APIs accept an AbortSignal option directly. Pass extra.signal to every operation that might take more than a few hundred milliseconds.

// Native fetch — signal supported natively
const response = await fetch('https://api.example.com/data', {
  signal: extra.signal,
  headers: { Authorization: `Bearer ${apiKey}` },
});

// Node.js https — use undici Dispatcher or pass via AbortController
// undici fetch also accepts signal natively:
import { fetch } from 'undici';
const data = await fetch(url, { signal: extra.signal });

// node-postgres (pg)
// pg Client.query() does not accept AbortSignal — use a listener instead:
const ac = new AbortController();
extra.signal.addEventListener('abort', () => ac.abort(), { once: true });
const client = await pool.connect();
try {
  const result = await Promise.race([
    client.query('SELECT * FROM large_table WHERE id = $1', [args.id]),
    new Promise<never>((_, reject) =>
      ac.signal.addEventListener('abort', () => reject(new Error('Cancelled')))
    ),
  ]);
  return { content: [{ type: 'text', text: JSON.stringify(result.rows) }] };
} finally {
  client.release();
}

// Prisma — wrap in signal listener
const queryPromise = prisma.record.findMany({ where: { datasetId: args.id } });
const cancelPromise = new Promise<never>((_, reject) => {
  extra.signal.addEventListener('abort', () => reject(new Error('Cancelled')), { once: true });
});
const records = await Promise.race([queryPromise, cancelPromise]);

When fetch() receives an aborted signal, it throws a DOMException with name === 'AbortError'. Catch this specifically and return a clean cancelled result rather than letting it propagate as an unhandled exception, which would produce a JSON-RPC -32603 error and may confuse the client about whether the session is still usable.

try {
  const response = await fetch(url, { signal: extra.signal });
  const data = await response.json();
  return { content: [{ type: 'text', text: JSON.stringify(data) }] };
} catch (err) {
  if (err instanceof DOMException && err.name === 'AbortError') {
    return { content: [{ type: 'text', text: 'Request cancelled.' }] };
  }
  throw err; // unexpected error — let the SDK handle it
}

Cleaning up in finally blocks

Resources acquired before a cancellation point must be released regardless of how the handler exits. Use try/finally to guarantee cleanup even when an AbortError propagates through the call stack.

ResourceWhat to clean upWhere to do it
Database connection from poolCall client.release()finally block after pool.connect()
Filesystem file handleCall fileHandle.close()finally block after fs.open()
Distributed lock (Redis, etc.)Release the lock keyfinally block after lock acquisition
Temp files or directoriesfs.rm(tmpDir, { recursive: true })finally block after mkdtemp()
In-progress BullMQ jobMark as failed or move to DLQcatch block on AbortError
server.tool('write_report', { ... }, async (args, extra) => {
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'report-'));
  const lock = await acquireLock(`report:${args.id}`);

  try {
    const data = await gatherData(args, { signal: extra.signal });
    const reportPath = path.join(tmpDir, 'report.pdf');
    await generatePdf(data, reportPath, { signal: extra.signal });
    const buffer = await fs.readFile(reportPath);

    return {
      content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'application/pdf' }],
    };
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      return { content: [{ type: 'text', text: 'Report generation cancelled.' }] };
    }
    throw err;
  } finally {
    // Always runs — even on AbortError or unexpected exceptions
    await lock.release();
    await fs.rm(tmpDir, { recursive: true, force: true });
  }
});

Rolling back partial writes

Cancellation is most dangerous when a tool has already begun writing to durable state — a database, a file, a third-party API — and is interrupted mid-way. The correct approach depends on what you are writing:

Database writes: use transactions. Start a transaction before any writes. On cancellation or error, roll back. The database returns to a consistent state regardless of how many rows were written before the signal fired.

const client = await pool.connect();
await client.query('BEGIN');
try {
  for (const record of args.records) {
    if (extra.signal.aborted) throw new Error('Cancelled');
    await client.query(
      'INSERT INTO records (id, data) VALUES ($1, $2)',
      [record.id, record.data]
    );
  }
  await client.query('COMMIT');
  return { content: [{ type: 'text', text: `Inserted ${args.records.length} records.` }] };
} catch (err) {
  await client.query('ROLLBACK');
  if (err.message === 'Cancelled') {
    return { content: [{ type: 'text', text: 'Insert cancelled — no records written.' }] };
  }
  throw err;
} finally {
  client.release();
}

File writes: write to a temp file, then atomic rename. Write to a temporary path in the same directory; on success, fs.rename() the temp file to the final path. Cancellation before the rename leaves the original file untouched and the temp file is cleaned up in finally.

Third-party API calls: idempotency keys. For APIs that support idempotency keys, generate a key from the session ID and tool call arguments before starting. If the call is cancelled and later retried, the same key produces the same result without a duplicate action on the remote system.

Cancellation vs timeout

Both cancellation and timeout use the same AbortSignal mechanism. A tool that imposes its own timeout does so with an AbortController that it aborts after a deadline:

server.tool('timed_tool', { ... }, async (args, extra) => {
  // Merge timeout signal with client cancellation signal
  const timeoutController = new AbortController();
  const timeoutId = setTimeout(() => timeoutController.abort(), 30_000);

  // Combine: aborted if either the client cancels OR the timeout fires
  const combined = AbortSignal.any([extra.signal, timeoutController.signal]);

  try {
    const result = await runOperation(args, { signal: combined });
    return { content: [{ type: 'text', text: result }] };
  } catch (err) {
    if (err instanceof DOMException && err.name === 'AbortError') {
      const reason = extra.signal.aborted ? 'cancelled by client' : 'timed out after 30 seconds';
      return { content: [{ type: 'text', text: `Operation ${reason}.` }], isError: true };
    }
    throw err;
  } finally {
    clearTimeout(timeoutId);
  }
});

AbortSignal.any() (Node.js 20+) returns a signal that fires when any of the input signals aborts. This is the cleanest way to combine client-cancellation and server-timeout into a single signal that you can pass throughout your code.

Frequently asked questions

What should I return when a tool is cancelled — isError true or false?

Return isError: false (or omit the field, which defaults to false) with a plain-text content item describing the cancellation. Cancellation is not an error — it is expected behaviour when the user changes their mind. Returning isError: true causes the LLM to treat the tool call as a failure and may prompt it to retry, which is the wrong response to a deliberate cancellation. Reserve isError: true for unexpected failures, not user-initiated stops.

Does the AbortSignal fire when the SSE connection drops?

Yes. When the SSE connection closes (network failure, user closes the browser tab, server-side session cleanup), the MCP SDK aborts all in-flight requests associated with that session. Your handlers receive the abort signal as if the client had explicitly sent notifications/cancelled. This means the same cancellation-handling code path protects you from both deliberate user cancellation and unexpected connection drops — a useful property that eliminates the need for separate disconnect-detection logic in most tool handlers.

How do I test cancellation handling?

In integration tests using InMemoryTransport.createLinkedPair(), create an AbortController and pass its signal as the signal field of the callTool options. Call controller.abort() after the tool starts executing but before it completes — a setTimeout with a short delay works for tools that have a measurable startup phase. Assert that the returned result has the correct cancellation message and that any cleanup side effects (lock release, temp file deletion) occurred. Also test that subsequent tool calls on the same session succeed, confirming the session stayed open.

Can I ignore cancellation for short-lived tools?

Yes, for tools that complete in under a second or two, ignoring the signal is reasonable. A cancellation that arrives after the tool has already returned is handled silently by the SDK. The rule of thumb: if your tool calls any external API, performs a database query that might take more than a few hundred milliseconds, or runs in a loop over more than a handful of items, implement cancellation. For simple in-memory lookups or fast computations, skip it.

Does AliveMCP send cancellation signals during probes?

No. AliveMCP's standard probe sends initialize and tools/list — it does not send tools/call and therefore does not generate any cancellation signals. If you configure a synthetic tool-call probe via AliveMCP's custom probe feature, it waits for the full tool result and does not cancel mid-call. Cancellation handling does not affect how your server appears to uptime monitoring probes.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free