MCP Fusion/Protocol and runtime/ストリーミングとキャンセル
ストリーミングとキャンセル
長時間実行ツールを正しく扱います:進捗を発行するジェネレーターハンドラ、全段階を生き延びる AbortSignal の伝播、そして不足入力に対する return ベースの MRTR elicitation です。
エージェントから見るとツールの呼び出しはアトミックに見えますが、その裏の作業はアトミックではありません。このページでは、アトミックでない呼び出しのための 3 つの仕組みを扱います:ストリーミングで外へ出す進捗、内へ届くキャンセル、そして実行の途中で要求される入力です。
ストリーミング:ジェネレーターハンドラ
どのハンドラも async ジェネレーターにできます:
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
});コントラクトは厳密です:yield は副チャネル、return がレスポンスです。progress(percent, message) イベントのみがクライアントに見え、それ以外の yield はすべて無視されます。最終的な戻り値は通常の Presenter パイプラインを通るため、ストリーミングと形状づけられた知覚は組み合わせて使えます。
進捗は、呼び出しに _meta.progressToken が載っている場合にのみ、MCP の notifications/progress としてクライアントに届きます。トークンがなければチャネルもなく、割り当てもありません。進捗は生きたセッションを必要とするため、ステートレスな JSON モードに進捗はありません:これがトランスポートのトレードオフであり、正直に文書化されています。
キャンセル:隅々まで届く signal
リクエストの AbortSignal はエンジンの全段階を貫通します:
- キュー待ち:キャンセルされたリクエストは並行度キューを即座に出ます
- chain gate:コンパイル済みのミドルウェアチェーンは実行前に
signal.abortedを確認します - destructive mutex:mutation の FIFO シリアライザは中断された waiter を捨てます
- ジェネレーター:あらゆる
yieldで signal を再チェックし、それぞれのnext()は abort と競争します。そのため、遅い I/O で詰まったジェネレーターがサーバーを人質に取ることはできません。abort 時、フレームワークはgen.return()を fire-and-forget で呼び、レスポンスをブロックせずにfinallyのクリーンアップを実行します
signal は ctx.signal を通じてあなたのコードに届きます(extra に生のリクエストが入っている contextFactory で捕捉してください)。そして fetch、ORM、ドライバーに渡します。協調的な abort により、キャンセルは願望ではなく保証になります:長いジョブは I/O ステップ 1 つで停止します。
対話的入力:ブロッキングの代わりに MRTR
ツールが、エージェントから提供されなかった入力を必要とする場合、古いパターンはサーバー起点のリクエストで、永続的な接続でのみ機能していました。MCP 2.0 の答えは Multi Round-Trip Requests です:ハンドラが入力が必要であることを return し、クライアントが入力集め、サーバーは答えとともにハンドラへ再入ります。MCP Fusion ではこれは 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()、ask.enum(values) はフィールドディスクリプタです。requireInput.elicit(message, fields) はフォームを要求し、requireInput.url(message, url) は帯域外の URL 訪問(OAuth の同意画面が定番の用途)を要求します。readInput は再入した呼び出しで答えを返し、readRequestState() は継続ペイロードを与えてくれます:往復を生き延びなければならない状態です。
2 つの保証があります。2026 世代の接続では、フレームワークはレスポンスをそのまま発行し、プロトコルが再試行を駆動します。2025 世代の永続接続では、生きているチャネル自体上でリクエストを満たします。チャネルが無い場合、呼び出しは ELICITATION_UNSUPPORTED のクリーンなエラーに縮退し、ループは上限(8 ラウンド)付きなので、混乱したクライアントが無限にピンポンをすることはありません。
命令形(ハンドラ内で await する ask() コール)は、人間の回答を待つリクエストのブロッキングはサーバーレスで生き残れないため、MCP Fusion 5.0 で削除されました。古い例で await ask(...) が見つかったら、上の MRTR の形が現在の API です。
次のステップ
- Runtime architecture: ジェネレーターと signal の位置
- State sync: staleness と購読
- Errors: 中断された呼び出しの報告方法
