MCP ツール呼び出しにおけるリトライ安全性
クライアント側で失敗したツール呼び出しが、サーバー側では完了している場合がある。呼び出し元がリトライすれば、ツールは二度実行される。本稿ではその4つの再現例、関連する仕様の記述、そしてリファレンス実装における冪等性サポートの現状を記録する。
まとめ
| # | シナリオ | トランスポート | リトライの発行元 | 呼び出し回数 | 副作用 |
|---|---|---|---|---|---|
| 1 | クライアントのタイムアウト後にキャンセル | stdio | テストハーネス | 1 | 2 |
| 2 | サーバーを SIGKILL して再起動 | stdio | テストハーネス | 1 | 2 |
| 3 | クライアントのタイムアウト後にキャンセル | Streamable HTTP | テストハーネス | 1 | 2 |
| 4 | ツールが返った後の ConnectionError |
stdio | LangGraph、デフォルトポリシー | 1 | 3 |
いずれの場合もツールは idempotentHint: false を宣言しており、クライアントはそのアノテーションを受け取っている。
環境
すべてが週単位で変わるため、バージョンを固定している。
| コンポーネント | バージョン |
|---|---|
@modelcontextprotocol/sdk(TypeScript) |
1.30.0 |
mcp(Python) |
1.29.1 |
langgraph |
1.2.11 |
langchain-mcp-adapters |
0.3.2 |
langchain-core |
1.6.1 |
@cloudflare/think |
0.17.0 |
| ネゴシエートされたプロトコルバージョン | 2025-11-25 |
| Node | 26.7.0 |
TypeScript SDK の LATEST_PROTOCOL_VERSION は 2025-11-25 で、SUPPORTED_PROTOCOL_VERSIONS もそこで終わっているため、リファレンスクライアントは 2026-07-28 をネゴシエートできない。再現は 2025-11-25 上で実行した。
テストサーバーは charge というツールを1つ公開しており、台帳ファイルに1行追記してからスリープする。書き込みはスリープの前に行われるため、副作用は呼び出しの早い段階で確定し、その後の失敗では取り消されない。
2026-07-28 改訂での変更点
現行の改訂版における2系統の変更が本件に関係する。
リトライは2つの機能で通常の制御フローとして現れる。Multi Round-Trip Requests は、クライアントが「元のリクエストのリトライにおいて」inputResponses を提供したときに解決する。エリシテーション完了通知は、「クライアントは元のリクエストをリトライすることで、帯域外のやり取りの結果を知る」ため削除された。
接続をまたいで状態を保持していた4つの仕組みが削除された。
| 削除された項目 | 変更 |
|---|---|
プロトコルセッション、Mcp-Session-Id |
主要変更 1 |
SSE の再開可能性、Last-Event-ID、イベント ID |
主要変更 9 |
initialize / notifications/initialized |
主要変更 2 |
ping、logging/setLevel |
主要変更 5 |
主要変更 9 は代替となる挙動を規定している。「レスポンスストリームが壊れると処理中のリクエストは失われる。クライアントは新しいリクエスト ID を持つ新規リクエストとして再発行しなければならない。」
この改訂では _meta キーが6つ予約された(clientCapabilities、clientInfo、logLevel、oauth、protocolVersion、serverInfo)。いずれも重複排除や冪等性のためのキーではない。exactly-once、at-least-once、deduplicate という文字列は仕様に登場しない。
シナリオ 1: タイムアウトとキャンセル
3秒かかるツールに対してクライアントのタイムアウトを1秒に設定し、その後リトライする。
[client] attempt 1 {"timeoutMs":1000}
[server] SIDE EFFECT COMMITTED {"reqId":"2","amount":100}
[client] attempt 1 FAILED {"code":-32001,"message":"Request timed out"}
[client] retrying
[server] <<< INBOUND notifications/cancelled {"requestId":2}
[server] SIDE EFFECT COMMITTED {"reqId":"3","amount":100}
[server] handler finishing {"reqId":"2","aborted":true}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
順序について注目すべき点が3つある。
notifications/cancelled がサーバーに届くのは、クライアントが呼び出し元へエラーを返した後である。SDK より上位のリトライ処理は、サーバーに何も伝わらないうちに走る。
リクエスト 2 のサーバーハンドラは aborted: true を検知したうえで最後まで実行された。書き込みはすでに済んでいた。
クライアントはリクエスト 2 の結果を受け取らなかった。仕様はキャンセルされたリクエストに応答しないようサーバーに指示しているため、呼び出し元が最初の試行について得られる情報は「返ってこなかった」ということだけであり、これは「実行されなかった」と「実行されたがレスポンスが破棄された」を区別できない。
シナリオ 2: プロセスの再起動
stdio トランスポートの節は復旧手順を規定している。
サーバープロセスが予期せず終了した場合、クライアントはそれを再起動すべきである(SHOULD)。プロトコルはステートレスなので、処理中のリクエストは単に失われ、クライアントは新しいプロセスに対してリトライできる。
書き込みが確定した後に SIGKILL を送り、記述どおりに実行すると次のようになる。
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] SIGKILL the server process
[client] attempt 1 FAILED {"code":-32000,"message":"Connection closed"}
[client] restarting per spec guidance and retrying
[server] SIDE EFFECT COMMITTED {"reqId":"1","amount":100}
[client] RESULT {"logicalInvocations":1,"actualCharges":2}
示されている根拠はプロトコルがステートレスであることだ。プロトコルのステートレス性は接続について述べたものであり、サーバー側の副作用については何も言っておらず、リクエストを繰り返しても安全であることを保証しない。
台帳の2つのエントリはどちらも reqId: 1 を持つ。再起動したプロセスはリクエストカウンタを最初から数え直すため、リクエスト ID は再起動をまたいで一意ではない。
シナリオ 3: Streamable HTTP
シナリオ 1 と同じタイムアウトとリトライを、StreamableHTTPServerTransport と StreamableHTTPClientTransport 上で行う。1回の呼び出しに対して副作用が2つ発生し、リクエスト ID はそれぞれ 1 と 2 で異なる。トランスポートの選択によって結果は変わらなかった。
シナリオ 4: LangGraph のデフォルトリトライポリシー
シナリオ 1 から 3 ではハーネスが書いたリトライを使った。ここではフレームワーク側のものを使う。
LangGraph のノードが langchain-mcp-adapters 経由で charge を呼び出し、その後 ConnectionError を発生させる。これは MCP のトランスポートが切断されたときに表面化する例外である。LLM は関与しないため実行は決定的だ。ノードは retry_on を上書きしないデフォルトの RetryPolicy を使う。
[client] tool 'charge' metadata: {'title': None, 'readOnlyHint': None,
'destructiveHint': True, 'idempotentHint': False, 'openWorldHint': None}
[client] node attempt 1: calling charge -> 'charged 100.0'
[client] node attempt 2: calling charge -> 'charged 100.0'
[client] node attempt 3: calling charge -> 'charged 100.0'
[client] RESULT {"logicalInvocations":1,"nodeAttempts":3,"actualCharges":3}
デフォルトポリシーの2つの性質がこれを引き起こす。
langgraph/_internal/_retry.py はまず isinstance(exc, ConnectionError) を判定し True を返す。続いて ValueError、TypeError、RuntimeError、ArithmeticError、LookupError、NameError などについては、一時的なものではなくプログラムの誤りを示すという理由で False を返す。この分類は意図的なものであり、トランスポートエラーはまさに完了状態が不明なカテゴリである。
リトライの粒度はノードである。ツール呼び出しはノードの内部にあるため、ノードを再実行すると呼び出しも再実行される。これは一般に成り立つ。こうしたフレームワークにおけるチェックポイント境界は、単一のツール呼び出しより粗い。
idempotentHint の利用者
MCP は改訂 2025-03-26 以降、ツールアノテーションに idempotentHint を備えている。両方のリファレンススタックで、それを読むコードを検索した結果は次のとおり。
TypeScript、@modelcontextprotocol/sdk 1.30.0。 実行時の出現は1件で、dist/esm/types.js の Zod スキーマのフィールドである。それ以外はすべて .d.ts の宣言で、実行前に消去される。
Python、langgraph 1.2.11、langchain-core 1.6.1、langchain-mcp-adapters 0.3.2、mcp 1.29.1 を通して。 出現は1件で、mcp/types.py:1276 のフィールド宣言である。
どちらのスタックにも利用者はいない。このアノテーションは送信され、受信され、シナリオ 4 が示すようにエージェント層の tool.metadata に公開されるだけだ。
クライアント側の冪等性: @cloudflare/think
冪等性の仕組みを持つ実装が1つある。@cloudflare/think 0.17.0 は15ファイルにまたがって idempotencyKey を持ち、4ファイルに ActionPendingError 型があり、ドキュメントには「配信は at-least-once である。独自の永続的な冪等性のためには idempotencyKey または occurrenceKey を使うこと」と記されている。残存する at-least-once のケースと、そのうちどれを許容するかも列挙している。
.d.ts を除き、実行時ファイルのみを検索した結果は次のとおり。
callToolを呼ぶ実行時ファイルは5件idempotencyKeyを参照する実行時ファイルは8件- 両方を含む実行時ファイルは0件
依存ツリー内で唯一の共起は agents/dist/agent-routing-*.d.ts の型宣言である。冪等性キーはこのライブラリ自身のスケジューリング層で使われており、tools/call には届かない。tools/call にはそれを運ぶフィールドが予約されていない。
範囲
本稿で扱っていないもの。
- プロトコル改訂 2026-07-28。リファレンスクライアントがネゴシエートできない。
- ネットワーク分断とクロックのずれ。どちらも未検証。
- LangGraph 以外のフレームワーク。
- プロトコルの外で独自に重複排除を行うサーバー実装。
- 実運用環境でこれが起きた事例があるかどうか。本稿は作り込んだ再現である。
先行研究
Sajjad Khan の「Resume Means Resume」(2026年8月)は、永続化層の側面を本稿より深く扱っている。TLA+ と TLAPS で検証した6つの性質から成る適合性コントラクト、39セルの障害マトリクス、実際に SIGKILL を与えた5つの実稼働フレームワークでの測定である。LangGraph 1.2.9 を「割り込みをまたいでは exactly-once、クラッシュをまたいでは at-least-once」と測定し、適合プロファイルが一致するフレームワークは2つとして存在しないと報告している。MCP、分断、クロックのずれは扱っていない。
関連する提案
SEP-3182 は tools/call にオプションの idempotencyKey フィールドを追加し、サーバーがリトライを認識して再実行せずに元の結果を返せるようにすることを提案していた。
このスレッドは3週間続いた。あるレビュアーが 08-01 にマルチラウンドトリップの等価性のギャップと、リファレンス実装におけるワイヤーレベルの不整合を指摘し、著者は 08-02 に両方の修正をプッシュ、08-05 にはキーを専用フィールドに置くべきか予約済みの _meta キーに置くべきかを尋ねた。この質問はワーキンググループに向けられたが、18日間回答がなかった。08-23 にメンテナが「開示も文脈もない AI 生成の PR」というコメントを添えて PR をクローズした。著者はどのような文脈が追加で必要かを尋ねたが、2026-09-01 時点で返答はない。
このスレッドのレビューは他の非メンバーから寄せられたものだった。クローズ時を含め、どの時点でもメンテナが提案の技術的内容に踏み込むことはなかった。
再現方法
API キーもモデル呼び出しも不要。
npm i @modelcontextprotocol/sdk@1.30.0 zod@3 tsx@4
LEDGER=$PWD/ledger.jsonl DELAY_MS=3000 TIMEOUT_MS=1000 node --import tsx client.ts
LEDGER=$PWD/ledger2.jsonl node --import tsx client-restart.ts
uv pip install langgraph langchain-mcp-adapters mcp
LEDGER=$PWD/ledger.jsonl python graph.py
これを拡張する人への注意点が1つある。シナリオ 2 の初期版ではサーバーを npx tsx server.ts として起動していたため、トランスポートの子プロセスが npx になり、サーバーは孫プロセスになっていた。SIGKILL は npx に当たり、サーバーは生き残り、試行 1 は成功し、ハーネスは成功した呼び出しをリトライしていた。副作用は2件と報告されたが、何も証明していない。子プロセスがサーバー自身になるよう node --import tsx server.ts で起動し、数え始める前に試行 1 が -32000 で失敗することをアサートすること。
解決に必要なもの
tools/call における重複排除キー。SEP-3182 が提案したものだ。あるいは、レスポンスを失ったリクエストがどうなったかをクライアントがサーバーに問い合わせる手段である。
現時点ではどちらも存在しない。いずれかが実現するまで、ツール呼び出しを1度だけ行う必要のあるアプリケーションは、自前のキーをツール引数に載せてサーバー側で重複排除するしかない。プロトコルはそれを運んでくれないからだ。
参考資料:
- 再現用ハーネス - 4つのシナリオ、バージョン固定
- MCP 2026-07-28 変更履歴
- SEP-3182: Request Idempotency
- Resume Means Resume - Khan、ワークフロー永続化の適合性コントラクト