無人で動かしている自動投稿が、X への投稿リクエストの途中で接続を切られたことがあります。返ってきたのはタイムアウトのエラーだけで、投稿が通ったのかどうかは分かりませんでした。ログには「失敗」と残っていたのに、数分後にタイムラインを見ると、同じ内容がちゃんと投稿されていたのです。サーバー側では成功していて、こちらに結果が届かなかっただけ、という状態でした。
このとき素朴に「失敗したならやり直そう」と再実行していたら、同じ投稿が2つ並んでいたはずです。個人開発で複数サイトの告知を自動化している立場からすると、これは品質以前の事故です。読者から見れば、同じ内容を2回流すだけで「雑な運用をしている」という印象になります。書き込み系のツール呼び出しを無人で回すなら、この「通ったのか分からない」状態を正面から設計しておく必要があります。
「失敗」と「不明」は別物です
エラーには2種類あります。サーバーが「あなたのリクエストは受け付けません」と明確に拒否した失敗と、応答が返る前に通信が切れた不明です。前者は安全にやり直せます。サーバーは何もしていないと分かっているからです。
やっかいなのは後者です。リクエストはサーバーに届いて処理されたかもしれないし、届く前に切れたのかもしれません。こちら側からは区別がつきません。これは分散システムでよく知られた問題で、送信側は「相手が実行したか」を確実には知り得ません。タイムアウト、接続のリセット、ストリームの途中切断は、すべてこの「不明」に分類すべきものです。
2026年6月27日の更新で Claude Code の MCP まわりのレジリエンスが上がり、ストリームの途中で接続が切れても部分応答を保持するようになりました。受信側の粘りは確実に改善しています。それでも、書き込みツールの呼び出しが途切れた瞬間に残る「サーバー側で実行されたか」という不確実性は、受信側の改善だけでは消えません。ここはアプリケーション側で設計する領域です。
実装でつまずきやすいのは、エラーの分類です。HTTP の 5xx は「サーバーが処理に失敗した」とも「処理はしたが応答だけ落ちた」とも取れるため、安易に failed へ寄せると危険です。私は迷ったエラーはすべて uncertain に倒す方針にしています。uncertain は復旧フェーズで照合して決着するので、過剰に uncertain と判定しても二重実行にはつながりません。逆に、本来 uncertain なものを failed と誤判定すると即座に二重実行へ直結します。分類に迷ったら安全側、つまり uncertain へ倒すのが鉄則です。本番運用では、この誤判定こそが最も怖い落とし穴です。安全側へ倒すという一点を守るだけで、二重実行の大半は回避できます。
なぜ素朴な再試行が二重実行を生むのか
リトライ処理を書くとき、多くの人は状態を2つで考えます。成功か、失敗か。失敗ならやり直す。この設計が二重実行の温床です。
「不明」を「失敗」に丸めてしまうと、実はサーバー側で成功していたケースまで再実行してしまいます。投稿なら二重投稿、課金なら二重請求、メール送信なら二通目です。私が最初に踏んだのもこれでした。リトライ用のラッパーを except Exception: で雑にくくり、中で無条件に再送していたのです。テスト環境では一度も再現せず、本番で接続が不安定になった夜に初めて二重投稿が出ました。
正しくは、状態を3つに分けます。成功(committed)、明確な失敗(failed)、不明(uncertain)です。再実行してよいのは failed のときだけで、uncertain のときは「やり直す前に確かめる」という一手を必ず挟みます。
対策の柱は二つ — 冪等キーと照合読み取り
uncertain を安全に解消する方法は2つあります。
冪等キー(idempotency key) : リクエストごとに一意なキーを付け、サーバー側で「同じキーは一度しか実行しない」と保証してもらう方法です。Stripe の Idempotency-Key ヘッダが代表例です。サーバーが対応していれば、同じキーで何度再送しても効果は一度きりになります。
照合読み取り(reconcile read) : 再実行する前に、その効果がすでに存在するかをサーバーに問い合わせる方法です。投稿なら「自分の相関トークンを含む投稿があるか」を検索し、あればスキップ、なければ再送します。サーバー側の冪等性に依存しません。
理想は冪等キーですが、現実の MCP ツールはまだ冪等キーを受け付けないものが大半です。そこで、冪等キーを付けられるツールには付け、付けられないツールは照合読み取りで守る、という二段構えにします。
冪等キーを付けられるツールには付ける
まず、論理的な操作ごとに安定した operation_id を生成し、呼び出しの前にローカル台帳へ記録します。ここが肝心で、台帳への記録は必ず呼び出しの前に行います。呼び出しのあとに記録しようとすると、呼び出し中にプロセスが落ちたときに「投げたのに記録がない」操作が生まれ、復旧時に追跡できなくなります。
import json, os, time, uuid, tempfile
LEDGER = os.path.expanduser( "~/.cache/mcp_ledger.jsonl" )
def _append (rec: dict ) -> None :
# 追記は fsync まで含めて確実に永続化する(クラッシュ復旧の前提)
os.makedirs(os.path.dirname( LEDGER ), exist_ok = True )
with open ( LEDGER , "a" , encoding = "utf-8" ) as f:
f.write(json.dumps(rec, ensure_ascii = False ) + " \n " )
f.flush()
os.fsync(f.fileno())
def begin_operation (action: str , args: dict ) -> str :
op_id = str (uuid.uuid4())
# 呼び出しの「前」に pending を記録しておく
_append({ "op_id" : op_id, "action" : action, "args" : args,
"status" : "pending" , "ts" : time.time()})
return op_id
def mark (op_id: str , status: str ) -> None :
_append({ "op_id" : op_id, "status" : status, "ts" : time.time()})
呼び出し側では、operation_id を冪等キーとしてツール引数に添えます。MCP サーバーがキーを解釈してくれるなら、これだけで再送が安全になります。
TRANSIENT = ( TimeoutError , ConnectionError , ConnectionResetError )
def call_with_idempotency (client, action: str , args: dict ):
op_id = begin_operation(action, args)
payload = { ** args, "idempotency_key" : op_id}
try :
result = client.call_tool(action, payload) # MCP ツール呼び出し
mark(op_id, "committed" )
return result
except TRANSIENT :
# 通信が切れた — 実行されたかは不明。ここでは再送しない
mark(op_id, "uncertain" )
raise
except Exception :
# サーバーが明確に拒否した — 安全に失敗扱いにできる
mark(op_id, "failed" )
raise
ポイントは、uncertain を捕まえた箇所では再送しないことです。再送の判断は、後述する復旧フェーズに一本化します。同じ場所で握って即リトライすると、結局「不明なまま投げ直す」ことになり、3状態に分けた意味がなくなります。
冪等キーを受け付けないツールは「照合読み取り」で守る
MCP ツールが冪等キーを無視する場合、こちらで効果の有無を確かめるしかありません。そのために、書き込む内容そのものに相関トークンを埋め込んでおきます。投稿本文に見えない形で識別子を混ぜる、外部 ID フィールドに op_id を入れる、メタデータに付与する、といった方法です。後からその識別子で検索できれば、照合読み取りが成立します。
def reconcile (client, op_id: str , action: str , args: dict ) -> bool :
"""その操作の効果がサーバー側に存在するかを確かめる。
存在すれば True(再送不要)、なければ False(再送してよい)。"""
if action == "post_status" :
# 相関トークンを含む自分の投稿を検索する
found = client.call_tool( "search_own_posts" , { "contains" : op_id})
return len (found.get( "items" , [])) > 0
if action == "create_issue" :
found = client.call_tool( "search_issues" , { "external_id" : op_id})
return len (found.get( "items" , [])) > 0
# 照合できないアクションは「確かめられない」を呼び出し側へ返す
raise LookupError ( f "no reconcile strategy for action= { action } " )
def recover_uncertain (client) -> None :
"""前回までに uncertain で終わった操作を点検し、安全に決着させる。"""
for op_id, action, args in load_uncertain( LEDGER ):
# 相関トークンを書き込み内容へ必ず混ぜておくのが前提
args = { ** args, "correlation_token" : op_id}
try :
if reconcile(client, op_id, action, args):
mark(op_id, "committed" ) # 実は通っていた
else :
client.call_tool(action, args) # 通っていなかった → 再送
mark(op_id, "committed" )
except LookupError :
# 照合できないアクションは自動で再送しない(人手の確認へ回す)
mark(op_id, "needs_review" )
この復旧処理を、無人パイプラインの起動直後に毎回走らせます。前回の夜に uncertain のまま終わった操作があれば、次の起動時に必ず点検され、二重実行せずに決着がつきます。Dolice Labs の自動投稿では、記事を push したあとの SNS 告知をこの方式に乗せ替えてから、二重投稿の再発がなくなりました。
照合できないアクションをどう扱うか
すべてのアクションが照合できるわけではありません。メール送信のように「送ったものを後から検索できない」操作では、冪等キーがなければ効果の有無を確かめる術がありません。このときは、二度送る損害と、一度も送られない損害のどちらが重いかで方針を決めます。
戦略 前提 向くアクション リスク
冪等キー サーバーがキーで重複排除する 課金・在庫更新など副作用が重いもの サーバー実装に依存する
照合読み取り 効果を後から検索できる 投稿・Issue 作成・レコード追加 相関トークンの埋め込みを忘れると無力
再送して重複を許容 重複が安いか、後で消せる 冪等な集計・上書き更新 受け手に重複が見える場合は不可
再送しない 取りこぼしが許容できる 確認できないメール送信など たまに送られないことがある
私自身は、副作用が読者に見えるアクション(投稿・通知)は照合読み取りを最優先にし、照合できないものは原則として自動再送しない方を選んでいます。二重に届く不快感のほうが、まれに届かないことより運用上のダメージが大きいと考えているからです。読者の目に触れる書き込みほど、照合読み取りを軸に据えることを強く推奨します。逆に、サーバー側で上書きになる冪等な更新は、迷わず再送して構いません。
まず試すこと
手元の書き込み系 MCP ツールを1つ選び、次の順で手を入れてみてください。
そのツールの効果を後から検索できるか(=照合読み取りが成立するか)を確かめます。
検索できるなら、書き込み内容に op_id を相関トークンとして埋め込みます。
パイプラインの起動直後に recover_uncertain を1本足し、前夜の uncertain を点検します。
この3手だけで、接続断による二重実行の大半は防げます。検索できないツールだった場合は、それが「再送してよいアクションか」をまず言語化することが出発点になります。