h3-mcp logoh3-mcp

Cancellation & Progress

Stop work nobody is waiting for, and report on work that continues.

#Cancellation

Every request handler gets an AbortSignal on event.context.mcp.signal. How it fires depends on the era, but the signal is the same:

  • Modern — the client closes the SSE response stream. That is the cancellation mechanism; notifications/cancelled is not expected over HTTP.
  • Legacy — the client sends notifications/cancelled naming the in-flight request id.

Check it between units of work:

const longRunningTool = defineMcpTool({
  name: "process",
  description: "Long-running task",
  handler: async (event) => {
    const { signal } = event.context.mcp!;

    for (let i = 0; i < 100; i++) {
      if (signal?.aborted) {
        return { isError: true, content: [{ type: "text", text: "Cancelled" }] };
      }
      await doWork(i);
    }

    return { content: [{ type: "text", text: "Done" }] };
  },
});

Better still, hand the signal to whatever you await so cancellation is immediate rather than checked at the top of the next loop:

const res = await fetch(url, { signal: event.context.mcp!.signal });

Cancellation is cooperative. Nothing is killed for you: a handler that ignores the signal runs to completion, and its result is discarded.

#Observing Cancellations

onCancelled fires for legacy notifications/cancelled:

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  onCancelled(notification, event) {
    console.log(`Request ${notification.requestId} cancelled: ${notification.reason}`);
  },
  tools: [longRunningTool],
});

The alias notifications/canceled (one l) is accepted too, for clients that shipped the misspelling.

#Progress

Clients opt into progress by attaching _meta.progressToken to a request. The token is on the context as event.context.mcp.progressToken, and on the modern era you emit updates through progress():

handler: async (event) => {
  const mcp = event.context.mcp!;
  for (let i = 1; i <= 10; i++) {
    mcp.progress?.(i, 10, `row ${i}`);
    await importRow(i);
  }
  return { content: [{ type: "text", text: "Imported" }] };
};

progress() no-ops when the client did not opt in — there is no stream to deliver on, and the spec gives no out-of-band channel.

Read more in Request-scoped streaming.

On the legacy era, notifications go out over the GET/SSE stream, and onProgress receives progress notifications sent by the client:

defineMcpHandler({
  name: "my-server",
  version: "1.0.0",
  onProgress(notification, event) {
    console.log(`Progress: ${notification.progress}/${notification.total ?? "?"}`);
  },
  tools: [/* ... */],
});
h3-mcp logo

h3-mcp  MCP servers, built on H3.