AIエージェントに「投稿する」ツールを渡すと、放っておけば公開し過ぎます。この暴走を止める制限を、プロンプトやスキルの文章ではなく、自作したMCPサーバーの中に置きました。しかも単純な回数カウントではなく、予約してから確定する二段階にしています。この記事は、その公開制御をどう実装したかの記録です。
対象読者は、MCPサーバーを自作してAIエージェントに実際の操作を任せようとしている実務者です。先に結論を置きます。エージェントに渡す危険な操作の制限は、エージェント側の指示ではなく、ツールを提供するサーバー側に置く。指示は破れますが、サーバーが弾く境界は破れません。
MCPは「エージェントに操作を渡す」規格
MCP(Model Context Protocol)は、AIエージェントに外部ツールやデータへのアクセスを渡すためのオープンな規格です(modelcontextprotocol.io)。サーバー側でツールを定義しておくと、エージェントはそれを呼び出して実際の操作を実行できます。
私はこの仕組みで、SNSやブログへ自動で公開するツール群を1つのMCPサーバーにまとめています。実装はCloudflare Workers上で、Threads・Instagram・LinkedIn・記事公開のツールを持ちます。複数のRoutine(定期実行されるエージェント)が、このサーバーのツールを呼んで公開まで進めます。
便利なのですが、ここには素直な危うさがあります。エージェントが公開ツールを持っているということは、理屈のうえでは何回でも投稿できるということです。
そして、MCPサーバーはちょうどよい場所にあります。モデルが「投稿したい」と考えてから、実際に外部へ投稿が飛ぶまでの間に、必ず通る一点だからです。モデルの側でいくら判断させても、その判断はぶれます。外部APIの側に制限を置こうとしても、こちらの都合の上限(1日何件にするか)は表現できません。両者の境目にあるMCPサーバーが、自分たちのルールで操作を止められる唯一の場所でした。ここに制限を実装する、というのが今回の出発点です。
エージェントに「1日3件まで」と書いても守られない
最初に考えたのは、制限をプロンプトやスキルの手順書に書くことでした。「Threadsは1日3件まで」「60分に3件を超えない」。人間なら守れるルールですし、書くだけなら簡単です。
ですが、自然言語の制限はあくまでお願いです。エージェントは指示を取り違えることがあります。別々のRoutineが同じ時間帯に走れば、それぞれが「自分は初回だ」と判断して、合計では上限を超えることもあります。文脈が長くなれば、冒頭のルールを見落とすこともあります。破ろうとして破るのではなく、事故で超える。ここが問題でした。
しかも、指示に制限を書く方式には隠れたコストもあります。上限のルールが各スキルや各プロンプトに散らばると、変えたいときに全部を直して回ることになり、直し忘れがそのまま抜け穴になります。制限が「文章」である限り、それは増えるほど管理が難しくなる資産です。
公開のような後戻りできない操作では、「たぶん守られる」では足りません。超えられない壁が要る。そして壁は、指示を読む側ではなく、操作を実行する側に置くしかありません。つまりMCPサーバーの中です。サーバーに1か所置けば、どのエージェントがどんな指示で呼んでも、同じ壁を通ります。
制限をサーバー側のKVに移す
そこで、公開回数の状態をWorkerのKV(Cloudflareのキーバリューストア)に持たせ、公開ツールが呼ばれるたびにそこで判定するようにしました。判定は2種類です。
- 総量制限: 全チャネル合算で、直近60分に3件まで
- 日次上限: チャネル別に、日本時間の同一日で Threads 3件・Instagram 2件・LinkedIn 1件・記事 2件まで
総量制限は「ローリング60分」にしました。カレンダーの時間区切りではなく、いまから遡って60分以内の公開イベント数を毎回数えます。区切り方式だと境界の前後で一気に投稿できてしまいますが、ローリングならその抜け道が消えます。日次はチャネルごとに別枠で、日本時間で日付を判定します。
総量制限を全チャネル合算にしたのには理由があります。チャネルごとの上限だけだと、Threads・Instagram・LinkedIn・記事が同じ数分に一斉に走ったとき、1つのアカウントとして見れば不自然な連投になります。合算の60分枠を1本かぶせておくと、どのチャネルが動いても短時間の総量が抑えられます。日次はチャネルの体力に合わせて別枠、総量は全体のペース、という二層です。
もう一つ効いているのは、公開系のツールがすべて同じ制御を通ることです。個々の公開ツールが自前で回数を数えるのではなく、共通の制御処理でくるんでから外部APIを呼びます。ツールが増えても、制御を通す入口が1つなら、数え忘れる経路が生まれません。
大事なのは、この判定がプロンプトから見えないところにあることです。エージェントがどんな指示を受けていても、上限に達していれば公開ツールはエラーを返して止まります。指示の書き方に依存しない壁ができました。
なぜ「予約→確定」の二段階にしたか
ここが今回の肝です。単純に「公開したらカウントを1増やす」だけでは足りませんでした。
理由は、公開が失敗することがあるからです。上限判定を通っても、その先の外部API呼び出しがネットワークエラーで落ちるかもしれません。もし「先にカウントを増やす」方式だと、失敗した公開が枠を1つ食ったまま残ります。逆に「成功したら増やす」方式だと、判定と実際の公開の間に別のRoutineが割り込む隙ができます。
そこで、予約してから確定する二段階にしました。流れはこうです。
- 予約(reserve): 上限判定を通ったら、まず「予約済み」の枠をKVに1つ確保する
- 公開: 実際に外部へ投稿する
- 確定(commit)/解放(release): 公開が成功したら予約を「完了」に確定する。失敗したら予約を解放して枠を戻す
こうすると、判定と公開の間に割り込まれても予約済みの枠が先に立っているので二重取りが防げます。そして公開が失敗したときは枠がちゃんと戻るので、失敗が上限を無駄に消費しません。予約は残高の仮押さえ、確定はその引き落とし、と考えると近いです。
予約1件ずつには、重複しない識別子を振っています。あとで「確定」や「解放」をするとき、この識別子でちょうど1件だけを狙い撃ちにするためです。複数の予約が同時に立っていても、確定すべき予約を取り違えたり、他人の枠を巻き込んで戻したりしないための工夫です。地味ですが、並列で動く前提だと「どの枠の話をしているか」を取り違えないことが効いてきます。
KVには、この予約・完了のイベントを配列で1か所に持たせています。1件ずつが、識別子・チャネル・作成時刻・状態(予約済みか完了か)を持ちます。判定のたびに、もう数えなくてよい古いイベント(直近60分より前で、かつ当日でもないもの)を掃除してから書き戻します。放っておくと配列が延々と伸びるので、読むたびに畳む方針です。状態が1つのキーに集約されているので、どのRoutineから呼ばれても同じ残高を見ます。
細かいですが、状態が壊れていたときの振る舞いも決めました。KVから読んだ値が期待した形になっていなければ、勝手に空へリセットせず、そこで処理を止めてエラーにします。自動運用で怖いのは、壊れた状態を「たぶん大丈夫」と空に戻して、上限をすり抜けて連投してしまうことです。分からないときは進めない。ここも安全な側へ倒しています。
並列で走っても数がずれない
この二段階が一番効くのは、複数のRoutineが近い時間に動くときです。たとえば朝の時間帯に、別々のRoutineがそれぞれ記事公開とThreads告知を進めるとします。単純な「成功したら数える」方式だと、片方が判定を通ってから実際に公開するまでの短い隙に、もう片方も判定を通ってしまう。両方とも「まだ枠は空いている」と思い込むわけです。
予約を先に立てておくと、この隙が埋まります。最初のRoutineが予約した時点で枠が1つ埋まるので、次のRoutineの判定はその予約済みも数に入れます。実際の外部公開が終わる前でも、残高は正しく減っている。人が見ていない時間に何本ものエージェントが走る前提だと、この「先に仮押さえする」だけで、静かなずれがかなり防げます。
失敗と通知の優先順位を決めておく
自動運用では、境界に引っかかったことや公開の成否を人が後から追えるようにしておきたい。そこで、予約が上限で弾かれたとき・公開が失敗したとき・公開が成功したときに、それぞれSlackへ通知を送るようにしました。
ここで一つ、はっきり決めた原則があります。通知の失敗で、成功した公開を失敗扱いにしない。たとえばSlackへの通知がこけても、公開そのものが成功しているなら、それは成功です。同じく、公開に成功した後で予約の「確定」処理がこけても、公開が取り消されるわけではないので、その場合はログに残すだけにして処理は成功として返します。
順位をつけると、外部への公開が最上位、次に枠の整合、通知は最後です。周辺処理の不調が本体の結果をひっくり返さないように、失敗しても安全な側に倒れる作りにしています。自動化で人が見ていない時間帯こそ、この「どちらに倒れるか」を先に決めておくのが効きます。
限界と、どこまでで十分とするか
正直に書いておくと、この作りは「完全な排他制御」ではありません。KVは、書き込みが世界中へ行き渡るまでに時間差がある、結果整合のストアです。つまり、ほぼ同時刻に2つの書き込みが起きると、片方が古い状態を読んだまま予約を立て、理論上は枠を1つ多く取ってしまう可能性が残ります。予約方式で隙はかなり狭くなりますが、ゼロにはなりません。
ではなぜKVで十分としたか。うちの公開量が、1日に数件という規模だからです。日次上限がThreadsで3件、記事で2件という世界では、ミリ秒単位でぶつかる書き込みはまず起きません。仮に一度ずれても、被害は「1件多い」程度で、後戻りできない致命傷にはならない。厳密さのコストと、実際に起きうる事故の大きさを見比べて、ここはKVの結果整合で受けると判断しました。
もし公開量が桁違いに増えて、同時刻の衝突が現実的になったら、ここはより強い一貫性を持つ仕組み——たとえば単一の調整役に書き込みを直列化させるDurable Objectsのような選択肢——へ移すことになります。いまはその段階ではない、というだけです。要件に対して過剰な堅牢さを先に作り込まない。これも一つの判断です。
制限は境界に置く、という一貫した考え方
振り返ると、これは以前hooksで書いた話と同じ発想でした。エージェントに破らせたくない操作は、指示で禁じるのではなく、実行の経路上で機械的に止める。hooksはローカルの実行を止める壁で、今回のKV制限はMCPサーバーという公開の経路に置いた壁です。置き場所は違っても、「信用するのは指示ではなく境界」という点は共通しています。
Claude Codeのhooksで自動運用に禁止ラインを引く|Bashガード実装記録Claude Codeのhooksで、エージェントに破らせたくない操作をコードとして強制する実装記録。PreToolUseフックの設定、Bashコマンドを検査するガードスクリプト、exit code 2でブロックする仕組み、正規表現ルールの設計と限界まで、実運用中の構成をそのまま解説します。www.tentspace.netこの予約→確定のパターンは、公開に限らず「後戻りできない・回数を絞りたい操作」なら同じ形で使えます。有料APIの呼び出し、メール送信、予算の消費。どれも「上限を確認してから実行し、実行が失敗したら確保を戻す」という骨格は共通です。エージェントに任せる操作のうち、失敗やコストが伴うものには、この骨格を1枚かぶせておくと安心して任せられます。
まとめると、AIエージェントに後戻りできない操作を渡すときは、制限をエージェントの外側、ツールを提供する側に置く。そして単純なカウントではなく、予約と確定を分けて失敗時に枠を戻す。地味ですが、複数のエージェントが並列で動く前提だと、この二段階がないと静かに数がずれていきます。エージェントを賢くするより、境界を堅くするほうが、公開のような操作では効きました。指示は破れても、境界は破れません。

