MCP Fusion/Protocol and runtime/Streaming and cancellation
Streaming and cancellation
Long-running tools done right: generator handlers that emit progress, AbortSignal propagation that survives every stage, and return-based MRTR elicitation for missing input.
A tool call looks atomic to an agent, but the work behind it is not. This page covers the three mechanisms for non-atomic calls: streaming progress out, cancellation in, and input requested mid-flight.
Streaming: generator handlers
Any handler may be an async generator:
export default f.action('report.generate')
.describe('Build the quarterly report')
.handle(async function* (input, ctx) {
const rows = await loadRows(input);
yield progress(30, 'rows loaded');
const charts = await renderCharts(rows);
yield progress(90, 'charts rendered');
return charts; // THIS is the tool response
});The contract is strict: yield is a side channel, return is the response. Only progress(percent, message) events are client-visible; anything else yielded is ignored. The final return value goes through the normal Presenter pipeline, so streaming and shaped perception compose.
Progress reaches the client as MCP notifications/progress when, and only when, the call carried a _meta.progressToken. No token, no channel, no allocation. Because progress needs a live session, stateless JSON mode has no progress: that is the transport trade-off, documented honestly.
Cancellation: the signal that goes everywhere
The request's AbortSignal is threaded through every stage of the engine:
- queue wait: a cancelled request leaves the concurrency queue immediately
- chain gate: the compiled middleware chain checks
signal.abortedbefore running - destructive mutex: the mutation FIFO serializer drops aborted waiters
- generators: every
yieldre-checks the signal, and eachnext()races the abort, so a generator stuck on slow I/O cannot hold the server hostage; on abort the framework callsgen.return()fire-and-forget to run itsfinallycleanup without blocking the response
The signal arrives in your code through ctx.signal (capture it in contextFactory, where extra holds the raw request) and you pass it into fetch, ORMs and drivers. Cooperative abort turns cancellation from a wish into a guarantee: a long job stops in one I/O step.
Interactive input: MRTR instead of blocking
When a tool needs input the agent did not provide, the old pattern was a server-initiated request that only works on persistent connections. MCP 2.0's answer is Multi Round-Trip Requests: the handler returns that it needs input, the client collects it, and the server re-enters the handler with the answers. MCP Fusion makes this one import:
import { ask, requireInput, readInput } from '@mcpfusion/core';
export default f.mutation('billing.refund')
.withString('id', 'Invoice ID')
.interactive()
.handle(async (input, ctx) => {
const invoice = await getInvoice(input.id);
const confirm = readInput('confirm');
if (!confirm) {
return requireInput.elicit(
`Refund ${invoice.amountCents / 100} to ${invoice.customer}?`,
{ confirm: ask.boolean('Approve the refund') },
);
}
return processRefund(invoice);
});ask.string(desc), ask.number(desc).min().max(), ask.boolean() and ask.enum(values) are field descriptors; requireInput.elicit(message, fields) requests a form and requireInput.url(message, url) an out-of-band URL visit (OAuth consent screens, the classic use). readInput returns the answers on the re-entered call, and readRequestState() gives you the continuation payload: state that must survive the round trip.
Two guarantees: on 2026-era connections the framework emits the response as-is and the protocol drives the retry; on persistent 2025-era connections it fulfills the request over the live channel itself. With no channel available the call degrades to a clean ELICITATION_UNSUPPORTED error, and the loop is capped (eight rounds) so a confused client cannot ping-pong forever.
The imperative form (an awaited ask() call inside the handler) was removed in MCP Fusion 5.0 because blocking a request for a human answer does not survive serverless. If you find old examples with await ask(...), the MRTR shape above is the current API.
Next steps
- Runtime architecture: where generators and signals sit
- State sync: staleness and subscriptions
- Errors: how interrupted calls report back
