Skip to content

Progress & cancellation

Long-running actions stream progress forward, and may be cancelled at any moment. Both are first-class in the protocol.

Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line. AGENT MCP GATEWAY SDK HANDLER 1 tools/call { ... } 2 actions/invoke 3 actions/progress { percent: 10 } 4 notifications/progress 5 actions/progress { percent: 60 } 6 notifications/progress 7 result { ... } 8 tools/call result
Progress notifications ride along while the handler runs. The agent's UI typically renders them as an animated status line.
tesseron.action('importCsv')
.input(z.object({ url: z.string().url() }))
.handler(async ({ url }, ctx) => {
ctx.progress({ message: 'downloading', percent: 5 });
const rows = await fetchCsv(url);
for (let i = 0; i < rows.length; i += 100) {
if (ctx.signal.aborted) throw new Error('Cancelled');
ctx.progress({
message: `${i}/${rows.length}`,
percent: 5 + Math.floor((i / rows.length) * 90),
});
await importBatch(rows.slice(i, i + 100));
}
return { imported: rows.length };
});

Wire format - sent by the app as a notification (no response):

{
"jsonrpc": "2.0",
"method": "actions/progress",
"params": {
"invocationId": "inv_abc",
"message": "500/2000",
"percent": 27,
"data": { "etaMs": 14000 }
}
}

All three payload fields (message, percent, data) are optional. Send any combination. The MCP gateway forwards the notification to the agent as MCP notifications/progress; MCP clients render them at their leisure.

Guideline: cap progress updates at ~2 / second. Faster rates spam the agent UI without adding information.

The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted. AGENT MCP GATEWAY SDK HANDLER 1 tools/call { ... } 2 actions/invoke { invocationId: 'inv_1' } handler running (reads ctx.signal) 3 cancel invocation 4 actions/cancel { invocationId: 'inv_1' } ctx.signal.aborted = true 5 error -32001 Cancelled 6 tools/call error
The agent cancels. The gateway translates to actions/cancel. The handler sees ctx.signal.aborted.
tesseron.action('generateReport')
.input(...)
.handler(async (input, ctx) => {
const rows = await slowQuery(ctx.signal); // pass signal down
if (ctx.signal.aborted) throw new Cancelled();
return formatReport(rows);
});
  • ctx.signal is a standard AbortSignal. Pass it to fetch, setTimeout, database drivers, or anything else that accepts one.
  • Cancellation fires for two reasons: the agent explicitly cancelled, or the action's timeout expired. Your handler treats them the same way - yield as fast as you can.
  • After abort, the SDK returns an error response with code -32001 Cancelled (explicit) or -32002 Timeout (timer).

Wire format - notification from gateway to app:

{
"jsonrpc": "2.0",
"method": "actions/cancel",
"params": { "invocationId": "inv_abc" }
}

The app doesn't acknowledge the cancellation. It just aborts the signal and lets the normal response path return an error.

When the agent is Claude Code, progress notifications surface in the running tool-call block. For other MCP clients, behaviour varies - some render a progress bar, some print each message, some ignore them entirely. Don't depend on rich rendering; treat progress as "best-effort hint".

  • Final results. Use the response.
  • Data the next handler needs. Use a return value, a sampling round-trip, or a resource.
  • Error surfacing. Return an error response.

Next: sampling - handlers that call back into the LLM.