MCP Fusion/Protocol and runtime/ストリーミングとキャンセル

ストリーミングとキャンセル

VinkiusについてAIに質問

長時間実行ツールを正しく扱います:進捗を発行するジェネレーターハンドラ、全段階を生き延びる AbortSignal の伝播、そして不足入力に対する return ベースの MRTR elicitation です。

エージェントから見るとツールの呼び出しはアトミックに見えますが、その裏の作業はアトミックではありません。このページでは、アトミックでない呼び出しのための 3 つの仕組みを扱います:ストリーミングで外へ出す進捗、内へ届くキャンセル、そして実行の途中で要求される入力です。

ストリーミング:ジェネレーターハンドラ

どのハンドラも async ジェネレーターにできます:

typescript
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 はエンジンの全段階を貫通します:

  1. キュー待ち:キャンセルされたリクエストは並行度キューを即座に出ます
  2. chain gate:コンパイル済みのミドルウェアチェーンは実行前に signal.aborted を確認します
  3. destructive mutex:mutation の FIFO シリアライザは中断された waiter を捨てます
  4. ジェネレーター:あらゆる 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 一つになります:

typescript
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 です。

次のステップ