0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MCPのキャンセルはクライアントの待機を止めても、ツールの実行までは止めないことがある

0
Posted at

この記事は、Worksi の AI エージェントが英語の原文をもとに日本語で書き直し、別の AI エージェントが内容を事実確認したものです。原文(英語)へのリンクは末尾にあります。
Adapted into Japanese by Worksi's AI agents from the English original (linked at the end), and fact-checked by a separate AI agent.

An unplugged cable lies beside its socket while a row of gears keeps turning and an orange box slides off a small conveyor belt into a tray.
イラストは AI で作成しました。

キャンセルされたMCP呼び出しでも、記録が残ることがあります。クライアントはリクエストをキャンセル済みと報告しているのに、ツールが書き込むはずだったレコードはそのまま現れる。自分のテストでそうなった場面を想像してみてください。何もクラッシュしていません。MCPツール呼び出しのキャンセルはツールの実行中に送られており、プロトコルはそれを禁じていません。2025-11-25版の仕様では、通常のリクエストをキャンセルするとは、相手側に停止を求めるメッセージを送ることです。TypeScript SDKの2026-07-28版Streamable HTTP接続では、そのリクエストのストリームを閉じることです。2025-11-25版のメッセージについて、仕様は受信側がそのまま処理を終えること、メッセージを無視すること、あるいは受け取るのが遅すぎることを許しています。

クライアント側でキャンセルされたリクエストが示すのは一つだけです。クライアントが待つのをやめた、ということです。ハンドラーが止まったことは示しませんし、ハンドラーが開始したデータベース書き込みやキュージョブについてはなおさら何も示しません。クライアントのログでもサーバーのログでも、これは決着しません。必要なのは、停止が観測されるまで作業を既知のフェーズで保持しておくテストと、独立した証人です。つまり、下流のワーカーがMCPの経路の外に書き込むマーカーを、テスト対象のどの層も信用せずにハーネスが読み取る仕組みです。

2025-11-25版の仕様は受信側に停止を求めるが、義務付けてはいない

2025-11-25版のキャンセルのページは、requestId と任意の reason を持つ notifications/cancelled を定義しています。送信側のルールは厳格です。この通知は「同じ方向で以前に発行された」リクエストで、かつ「まだ処理中と考えられる」ものだけを参照しなければなりません(MUST)。クライアントは initialize をキャンセルしてはなりません(MUST NOT)。

受信側のルールはもっと緩やかです。受信側は処理を停止し、関連するリソースを解放し、レスポンスを送らないべきです(SHOULD)。リクエストが未知の場合、処理がすでに完了している場合、または「リクエストをキャンセルできない」場合には、通知を無視してもかまいません(MAY)。送信側は、その後に届いたレスポンスを無視すべきです(SHOULD)。また、通知は処理の完了後、場合によってはレスポンスが送られた後に届くこともあり、両者はその競合に対処しなければなりません(MUST)。

つまり、クライアント側の「キャンセル済み、レスポンスなし」は、サーバー側の少なくとも3つの状態と矛盾しません。作業が止まった、作業がまだ続いている、あるいは通知が届く前に作業が終わっていてクライアントが結果を捨てた、の3つです。プロトコルのメッセージではこれらを区別できません。プロトコルの外にある証人なら区別できます。

ワイヤ上のシグナルはリビジョンとトランスポートによって変わる

キャプチャの中から notifications/cancelled を探す前に、その接続でどのシグナルが流れるはずかを確認してください。MCP TypeScript SDKのプロトコルリビジョン2026-07-28へのガイドによれば、2026-07-28版のStreamable HTTP接続では、処理中のクライアントリクエストを中断すると「そのリクエストのSSEレスポンスストリームを閉じる」とされ、ガイドはこれを仕様上のキャンセルシグナルと呼んでいます。これは「notifications/cancelledをPOSTする代わりに」行われます。同じSDKでも、2025年版の接続と、どの版のstdioでも、引き続き notifications/cancelled が送られます。

Ruby SDKのキャンセルのページもサーバー側を同じように説明しています。新しいライフサイクルのリクエストはセッションを持たない単一のPOSTのやり取りなので、「別途POSTされたキャンセル通知はそれらに届かない」。新しいクライアントは代わりに接続を閉じます。

キャプチャ地点で期待した形のシグナルが見えなかった場合、言える結論は狭いものです。期待したシグナルがそこで観測されなかった、ということだけです。失われたと判断する前に、クライアントがこのリクエストのキャンセルに対応していること、中断がリクエストまで伝わったこと、リクエストがまだ完了していなかったこと、そしてシグナルが通るはずの経路をキャプチャがカバーしていることを確認してください。

どちらのSDKの挙動も、あらゆるクライアント、トランスポート、リビジョンに当てはめないでください。ネゴシエートされた内容を記録します。TypeScript SDKのガイドには、クライアント側の client.getProtocolEra() と、serveStdio で提供するサーバー向けの getNegotiatedProtocolVersion() が記載されており、v2はデフォルトで2025年版のプロトコルを話し、2026-07-28は明示的なオプトインだとされています。インストールされているSDKのバージョンも、リビジョンと並べて記録してください。

キャンセルされたタスクはステータスであり、ワーカーが止まったことではない

タスクで拡張された tools/call は別のルールに従います。2025-11-25版のタスクのページは、タスクは「現時点では実験的とみなされる」としており、キャンセルのページは、タスクで拡張されたリクエストには notifications/cancelled ではなく tasks/cancel を使わなければならない(MUST)としています。

タスクのページはこの点で具体的です。有効な tasks/cancel を受け取ると、受信側は「タスクの実行を停止しようと試みるべきであり(SHOULD)、レスポンスを送る前にタスクをcancelledステータスに遷移させなければならない(MUST)」。さらに、「タスクがいったんキャンセルされたら、実行が完了まで続いても失敗しても、cancelledステータスのままでなければならない(MUST)」。cancelled ステータスが記録するのは、キャンセルが受け付けられたことです。仕様自身の言葉によれば、その後も実行は続き得ます。すでに終端ステータスにあるタスクは -32602 で拒否され、受信側はキャンセルされたタスクをいつ削除してもかまわない(MAY)ので、必要な情報はキャンセルする前に読み取っておいてください。

停止が消え得る5つの境界

それぞれの境界で、手前側ではすべてが正しく見えたまま、停止が落ちることがあります。

  1. 呼び出し元からクライアントへ。 エージェントフレームワークやUIは中断しているのに、その中断がMCPリクエストに届かない。2026年7月27日に起票されたopenai-agents-js issue #1530がこれを報告しています。MCPツールのラッパーが実行の中断シグナルを読んでいなかったため、実行を中断してもMCP呼び出しは最後まで走り、notifications/cancelled は送られませんでした。同日、#1532によって完了としてクローズされています。これはある時点の、あるコードベースについての一件の報告です。ご自身のバージョンを確認してください。
  2. クライアントからワイヤへ。 リクエストはローカルで中断されたが、プロセスから何も出ていかない、あるいは誤ったリクエストIDが出ていく。
  3. ワイヤからサーバーランタイムへ。 シグナルは届いたが、ランタイムがそれを処理中のリクエストと対応付けられない。未知のIDは、仕様が受信側に無視を許しているケースの一つです。
  4. サーバーランタイムからハンドラーへ。 ランタイムは把握しているが、ハンドラーが確認しない。Ruby SDKではキャンセルは協調的で、「SDKはツールのコードを強制終了しない」ため、長時間実行されるツールは server_context.cancelled? をポーリングします。同じページには、StdioTransport ではツール内のネストしたサンプリング呼び出しが「親のtools/callがキャンセルされても完了まで実行される」とも書かれています。
  5. ハンドラーから下流へ。 ハンドラーは止まるが、ハンドラーが発行したHTTPリクエスト、開いたトランザクション、キューに入れたジョブは止まらない。コミット済みの書き込みや、すでにキューに渡されたジョブはハンドラーから呼び戻せません。ハンドラーにできるのは待つのをやめることだけです。

停止が観測されるまで作業を保持するハーネス

これは、合成ツール、偽の下流ワーカー、本物の認証情報なしの、隔離されたテスト環境でのみ実行してください。副作用は無害なマーカーで、テストIDをキーにしたファイルか、スクラッチストアの行です。

キャンセルのタイミングをsleepで合わせると、結果が不安定になります。ゲートのほうが適していますが、2つの瞬間を分けておく必要があります。ローカルでの中断、つまりクライアントのコントローラーが発火した時点と、リモートでの観測、つまりサーバーのハンドラーが停止を受け取った時点です。ハーネスは中断後もすべての作業ゲートを閉じたままにし、ハンドラーがシグナルを報告するのを、上限付きのタイムアウトで待ちます。時間内に届かなければ、ハーネスは cancel_not_observed を記録し、ゲートを開放します。これはラベル付きの競合ステップとして扱い、その結果は診断目的に限ります。

各ケースには前提条件もあります。そのケースがテストすると称するフェーズです。タイムアウトした wait_for はfalseを返すので、それを無視すると、実際には到達していないフェーズのラベルが試行に付いてしまいます。そこで、すべてのフェーズイベントは require_event を通し、after-settleのケースでは中断の前に呼び出しが解決済みであることを要求します。前提条件を満たさない試行は invalid_phase として記録してクリーンアップし、後述の結果表には決して入れません。そうした試行の数は数えておいてください。頻発するなら、結果を読む前にセットアップを直すか、タイムアウトを延ばしてください。

ハンドラーはキャンセルリスナーを通じてシグナルを報告します。Ruby SDKの server_context.cancelled? のように、SDKがポーリング用のフラグしか公開していない場合は、テストサーバーで小さなポーラーを動かし、フラグが最初に切り替わった時点をログに出してください。

試行ごとに、次の項目を一つの追記専用ログに記録します。

  • test_id。ツールの引数として渡します。リクエストIDはセッションと方向の中でしか一意でないので、クライアント、サーバー、ワーカーの記録はテストIDで結合します。
  • ワイヤ上のリクエストID、ネゴシエートされたリビジョン、トランスポート、クライアントとサーバーのSDKバージョン。
  • クライアントの中断時刻と、呼び出しの決着の仕方。解決、中断による拒否、タイムアウト時点で保留中、のいずれか。
  • サーバーのトランスポートで見えた、そのリクエストIDのワイヤイベント(HTTPならプロキシ、stdioならパイプへのtee)。
  • ハンドラーが受け取ったシグナル、ハンドラーがそれに基づいて動いたチェックポイント、そしてハンドラーの終了の仕方。
  • ワーカー自身が書き込む、ワーカーの開始・コミット・停止と、観測ウィンドウの終わりにハーネスが読み取るマーカーの状態。

可能なら、すべてのプロセスを一つのホスト、一つの時計の上に置いてください。ホストをまたぐと、クロックのずれにより近接したタイムスタンプは信頼できなくなります。順序の証拠としては、ゲートの順序のほうが確かです。

# Pseudocode. Isolated test environment, synthetic tool, fake worker,
# scratch marker store, no real credentials.

tool slow_marker(test_id, ctx):
    log(test_id, "handler_start", request_id=ctx.request_id)
    ctx.on_cancel(lambda: log(test_id, "handler_signal_received"))
    try:
        wait(gate[test_id].before_work)
        if ctx.cancelled():
            log(test_id, "handler_acted_on_cancel", phase="before_work"); return
        job = fake_worker.submit(test_id, signal=ctx.cancel_signal)
        result = job.wait(signal=ctx.cancel_signal)
        log(test_id, "handler_got_result")
        wait(gate[test_id].release_response)
        return result
    except Cancelled:
        log(test_id, "handler_acted_on_cancel", phase="waiting_on_job"); raise
    finally:
        log(test_id, "handler_exit", cancelled=ctx.cancelled())

fake_worker.run(test_id, signal):
    log(test_id, "worker_started")
    wait(gate[test_id].allow_commit)
    if signal.aborted:
        log(test_id, "worker_stopped"); worker_state[test_id] = "stopped"; return
    write_marker(test_id)
    log(test_id, "worker_committed"); worker_state[test_id] = "committed"

require_event(test_id, event):
    if not wait_for(test_id, event, timeout=T):
        raise InvalidPhase(event)

harness(case):   # baseline | before_work | during_work | after_commit | after_settle
    test_id = new_id()
    call = client.call_tool("slow_marker", {test_id}, abort=controller.signal)
    log(test_id, "client_sent", revision=negotiated_revision(), transport=transport_name())
    try:
        require_event(test_id, "handler_start")
        if case == "during_work":
            open(gate.before_work)
            require_event(test_id, "worker_started")
        if case == "after_commit":
            open(gate.before_work, gate.allow_commit)
            require_event(test_id, "handler_got_result")
        if case == "after_settle":
            open(gate.before_work, gate.allow_commit, gate.release_response)
            outcome = settle(call, timeout=T)
            log(test_id, "client_settled", outcome=outcome)
            if outcome != "resolved":
                raise InvalidPhase("resolved")
        log(test_id, "phase_established", case=case)
    except InvalidPhase as missing:
        log(test_id, "invalid_phase", missing=missing)
        cleanup(test_id, call)        # open gates, abort, settle; no verdict
        return "invalid_phase"

    if case != "baseline":
        controller.abort(); log(test_id, "client_abort")
        if not wait_for(test_id, "handler_signal_received", timeout=T):
            log(test_id, "cancel_not_observed", within=T)
            log(test_id, "race_step_begins")      # diagnostic only from here

    open(gate.before_work, gate.allow_commit, gate.release_response)
    if case != "after_settle":
        log(test_id, "client_settled", outcome=settle(call, timeout=T))
    sleep(observation_window)
    return verdict(read_log(test_id), wire_capture(test_id),
                   marker_exists(test_id), worker_state.get(test_id, "unknown"))

cancel_not_observed の競合ステップは、invalid_phase とは別物です。これは phase_established の後にしか起こらないので、競合の結果は必ず既知の開始フェーズに属します。

次の5つのケースを実行します。

  • ベースライン、キャンセルなし。 呼び出しは解決し、マーカーが現れなければなりません。そうでなければハーネスが壊れており、どのキャンセル結果にも意味はありません。
  • 作業前。 ハンドラーが最初のゲートで待っている間に中断します。
  • 作業中。 ワーカーが開始した後、コミットが許可される前に中断します。下流の作業への伝搬をテストします。
  • コミット後、レスポンス前。 マーカーは存在し、レスポンスゲートがリクエストを保留状態に保っています。遅れて届く結果をテストします。
  • 決着後。 呼び出しが解決した後でクライアントが中断します。シグナルは期待されず、cancel_not_observed が正しい結果です。ここでシグナルが出るなら、そのクライアントはすでに完了したリクエストをキャンセルしています。

各ケースは、実行間で結果が一致するまで繰り返してください。同じケースの2回の実行で結果が食い違ったら、両方の結果とともに競合として記録します。1回だけの実行は競合のどちら側にも転び得るので、自信に満ちた誤答を返すことがあります。

各結果を、示すことと未解決のまま残すことに分けて読む

判定は観測を記述するもので、原因を記述するものではありません。ここに入るのは phase_established を記録した試行だけです。シグナルの欠落に関する行は、保留中の通常リクエストに当てはまるもので、決着後のケースやタスクで拡張された呼び出しには当てはまりません。

観測 示すこと 未解決のまま残すこと 次に見る箇所
ベースライン:呼び出しが解決しない、またはマーカーがない ハーネスか偽ワーカーが動いていない キャンセルに関するすべて まずハーネスを直す
決着後:ワイヤ上にシグナルなし 想定どおり:リクエストは完了していた 保留中の作業については何も 他のケースを実行する
保留中のリクエスト、cancel_not_observed、期待された形(2025年版またはstdioでは通知、TypeScript SDKの2026-07-28版Streamable HTTPではストリームのクローズ)がサーバーのトランスポートで見えない 期待されたシグナルが、タイムアウト内にキャプチャ地点で観測されなかった このリクエストのキャンセルへのクライアントの対応、中断の伝搬、キャプチャが見落とす経路 クライアントの対応、中断の伝搬、キャプチャの範囲
サーバーのトランスポートでシグナルをキャプチャ、ハンドラーでは cancel_not_observed シグナルはサーバーに届いた ID対応付けの失敗、または発火しなかったリスナー ランタイムの対応付け、ハンドラーのリスナー
コミットゲートが開く前にハンドラーがシグナルを受信、その後ワーカーがコミット ハンドラーが停止を受け取った後に、下流の作業が完了した シグナルが伝搬されなかった、または止められないステップ 伝搬、そのステップがキャンセル可能かどうか
コミット後:キャンセルの後に、そのリクエストIDへのレスポンスがワイヤを通った サーバーがキャンセル済みのリクエストに応答した。仕様はこれをすべきでない(SHOULD NOT)としている 効果については何も。効果はすでに起きている クライアントの決着:破棄されたか、表に出たか
cancel_not_observed、その後、競合ステップ中にコミット 停止は時間内にハンドラーに届かず、作業は完了した 停止が後で届いたかどうか 効果は完了したものとして扱い、突き合わせる
ハンドラーとワーカーが停止を記録、ワーカーの状態は stopped、ウィンドウの終わりにマーカーなし ウィンドウ内での停止が独立に確認された ウィンドウ後のこと、たとえばキューのリトライ キューを使う作業ではウィンドウを延ばす
ワーカーの状態を読めない 下流の効果については何も 効果が起きたかどうか unknown を記録する

最後の2行を飛ばさないでください。30秒後にマーカーがないことは、その30秒についての証拠です。下流のシステムが失敗したジョブをリトライしたり、遅延実行の作業を走らせたりするなら、ウィンドウはそれをカバーしなければならず、そうでなければ判定は限定的なままです。ワーカーが独立に読める状態を公開していないなら、正直な判定は unknown であり、MCP層の cancelled ステータスはそれを変えません。

それぞれの証人には限界があります。クライアントのログは、クライアントが何を意図したかを示します。ワイヤキャプチャは、一つのホップを何が通ったかを示します。ハンドラーのログは、テスト対象のコードが書いたものです。ワーカーの状態とマーカーは効果に最も近いものですが、信頼できるのは自分で書いた偽ワーカーの範囲までです。

ゲートウェイのトレースはどこに位置付けられるか

MCPのトラフィックがゲートウェイを通るなら、ゲートウェイの記録は、もう一つの境界におけるもう一人の証人です。上の表では、ゲートウェイのスパンはマーカーの隣ではなく、ワイヤキャプチャの隣に置くべきものです。

動く前に、わからないことを書き出す

テストが unknown や競合で終わったとき、次の一手は効果についての判断です。リトライするか、突き合わせるか、そのままにするか。その判断は、効果自体の冪等性と突き合わせの設計に委ねられます。これについては、エージェントのツールタイムアウト、リトライ、冪等性を扱った記事で説明しています。判断する前に、停止が最後に観測されたのはどの境界か、接続がどのリビジョンとトランスポートを使っていたか、どれだけの時間観測したか、どの下流の状態を読めなかったかを書き出してください。そのリストなしに決めたリトライは、まだ実行中かもしれないツールについての当て推量です。


原文(英語): An MCP cancellation can stop the wait without stopping the tool

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?