要点

  • クライアント側で失敗したMCPツール呼び出しでも、サーバー側では完了していることがあり、リトライすると二重実行になる。4つの再現例:タイムアウト、サーバー再起動、Streamable HTTP、LangGraphのデフォルトのリトライポリシー。
  • 各ツールは idempotentHint: false を宣言していたが、どちらのリファレンス実装スタックにもこのフラグを読むコードはない。
  • 仕様はクライアントに失われたリクエストの再発行を求めており、tools/call には重複排除キーを運ぶ手段がない。SEP-3182がそれを提案したが、メンテナが内容に踏み込まないまま閉じられた。
  • プロトコルがキーを運ぶようになるまで、一度だけ実行されるべきツールは引数に独自のキーを持たせ、サーバー側で重複排除する必要がある。

クライアント側で失敗したツール呼び出しも、サーバー側では完了している場合がある。呼び出し側がリトライすれば、ツールは二度実行される。本稿ではその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 を提供することで解決する。エリシテーション(elicitation)の完了通知は、「クライアントは元のリクエストをリトライすることで帯域外のやり取りの結果を知る」という理由で削除された。

接続をまたいで状態を保持していた4つの仕組みが削除された:

削除されたもの 変更
プロトコルセッション、Mcp-Session-Id 主要変更1
SSEの再開機能、Last-Event-ID、イベントID 主要変更9
initialize / notifications/initialized 主要変更2
ping、logging/setLevel 主要変更5

主要変更9は代替の挙動を規定している。「応答ストリームが切断されると処理中のリクエストは失われる。クライアントは新しいリクエストIDで新規リクエストとして再発行しなければならない」。

この改訂では6つの _meta キー(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 を持つ。再起動したプロセスはリクエストカウンタを再び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が提案したものである。あるいは、応答を失ったリクエストがどうなったかをクライアントがサーバーに問い合わせる手段でもよい。

今日どちらも存在しない。どちらかができるまで、ツール呼び出しを一度だけ起こす必要のあるアプリケーションは、ツール引数に独自のキーを持たせてサーバー側で重複排除するしかない。プロトコルはそれを運んでくれないからだ。


リソース: