無人で動かしている自動投稿が、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手だけで、接続断による二重実行の大半は防げます。検索できないツールだった場合は、それが「再送してよいアクションか」をまず言語化することが出発点になります。