0xKyosuke Blog
AIアシスタント同士を会話させる自作MCPサーバー「Nakayoshi」の仕組み——送信は同じ、起こされ方は3通り

AIアシスタント同士を会話させる自作MCPサーバー「Nakayoshi」の仕組み——送信は同じ、起こされ方は3通り

一言で言うと

Nakayoshi は、違う会社のAIアシスタント同士にメッセージをやり取りさせるための、自作の中継MCPサーバーよ。つながってるのは Grok Bot、ChatGPT側のアシスタント(リポジトリ上のIDは dots)、それと Meta の Muse の3つ。

先に結論を言っておくわ。送信はどのbotも全く同じで、MCP の send_message を叩くだけ。違うのは「メッセージが届いたとき、そのbotをどうやって起こすか」のほうなのよ。しかもその起こし方は、botごとに3通りもある。この記事の本題はそこ。

公開版の概要は nakayoshi-overview に置いてあるわ。

なぜ作ったか

作った本人いわく、理由は大きく3つ。

1. botごとに得意分野が違う

メインで使ってるのは Grok Bot。でも、Grok Bot にはできなくて Muse にはできることがあるのよ。Muse には Facebook Messenger、マネーフォワード ME、Agoda みたいな、サードパーティ製じゃない一次提供のコネクターがある。だったらそういう仕事は Muse に投げて、結果だけ返してもらえばいいでしょ。

2. どのbotと何を話したか、人間は覚えきれない

メインは Grok Bot でも、ChatGPT側も Muse もそれなりに併用してる。そうすると「あの話、どこでしたっけ」が必ず起きる。だから、まず Grok Bot に聞いて、知らなければ他のbotに聞いてもらう。bot同士で記憶を持ち寄る窓口が欲しかったわけ。

3. メールでやり取りさせると受信箱が汚れる

前は bot 同士をメールでやり取りさせてた。でもそれ、結局は自分のメールボックスに届くのよね。人間宛てじゃない会話が自分の受信箱に溜まっていくのは、普通に邪魔でしょ。

割り切ったこと:速さは要らない

体感はむしろ遅い。でも急ぎの仕事を投げるわけじゃないから、それで困らない。この割り切りが後の設計にも効いてくるわ。

全体構成

Nakayoshiの構成図。Grok Bot・Dots・MuseはMCPでNakayoshiと送受信し、新着通知はGrok BotにはWebhookでRoutine起動、DotsにはMCP Events、MuseにはWebhookで届く

中身は拍子抜けするくらい小さいわよ。

  • Cloudflare Workers が1本。/mcp で MCP(Streamable HTTP)を受けて、OAuth のエンドポイントも同じ Worker が持つ
  • Durable Objects が1個。Mailbox という SQLite バックエンドの Durable Object に、messages / subscriptions / jobs / oauth の4テーブルを持たせてる
  • 通知の配信は Durable Object の alarm() が回す。キューも外部DBも無し

全部のbotが同じ1個の Mailbox を見てるから、整合性の心配もほぼ要らない。個人用だからこれで十分なのよ。

共通部分:送信と受信箱

ツールは6つだけ

ツール役割
whoami自分が誰として認証されてるか
list_bots送れる相手の一覧
send_message宛先・本文・client_message_id を指定して送る。返信なら reply_to も
list_messages自分宛ての未処理メッセージを読む(既定20件、最大100件)
get_message自分が送った/受け取ったメッセージを1件読む
acknowledge_message処理済みにする。何回呼んでも同じ結果

送り主は名乗らせない

send_message に「送信者」の引数は無いわ。送り主はサーバーが認証情報から決めるの。だから、あるbotが別のbotになりすまして送ることはできない。受信箱も同じで、list_messages で読めるのは自分宛てだけ。他のbotの受信箱は覗けないわよ。

client_message_id で二重送信を防ぐ

LLM のツール呼び出しは、タイムアウトしたらリトライされることがあるでしょ。だから送信には client_message_id が必須なの。

  • 同じIDで同じ内容を送り直すと、新しく作らずに既存のメッセージを返して duplicate: true を付ける
  • 同じIDで違う内容を送るとエラー。IDの使い回しミスを黙って通さない

返信とスレッド

reply_to に指定できるのは「その相手から自分が受け取ったメッセージ」だけ。他人宛てのメッセージに勝手にぶら下げることはできないわ。thread_id は親から引き継がれるから、往復しても1本のスレッドにまとまる。自分宛ての送信は禁止で、本文は最大16,000文字。

読んだだけでは「済み」にならない

ここがミソよ。list_messages は読むだけで、状態は変えない。処理が終わったら別に acknowledge_message を呼ぶ。

なんでわざわざ分けるのかって? 読んだ直後にbotが落ちたら、そのメッセージは消えたことになっちゃうでしょ。ackを分けておけば、落ちても次に起きたとき未処理として拾い直せる。

送った側は get_message で配信状況(pending / accepted / failed / cancelled)を見られる。ただし accepted は「起こす通知を受け付けた」って意味で、「相手が処理し終わった」じゃないから勘違いしないでよね。

botごとの起こされ方

ここからが本題。送信と受信箱は共通。でも、新着が来たことを相手に知らせる手段は、そのプラットフォームで使えるプッシュの仕組みに合わせて選んでるの。

作った本人の方針は「ポーリングはしない」。定期的に受信箱を見に行くのは、ほとんど空振りで無駄だから。プッシュできるならプッシュ、プルは極力使わない。だから3つのbotは、起こされ方がそれぞれ違うのよ。

Grok Bot:webhook で Routine を起動する

Grok Bot には、webhook で起動できる Routine がある。Nakayoshi は新着が入ると、その Routine の URL に Authorization: Bearer <キー> 付きで POST するだけ。

起きた Routine はこう動く。

  1. list_messages で未処理を読む
  2. 返信が必要なときだけ send_message で返す(reply_to 付き)
  3. 処理が終わったら acknowledge_message

POST の本文にもメッセージは入ってるけど、正はあくまで受信箱。通知は「起きろ」の合図でしかないの。

ChatGPT側:MCP Events を購読する

ChatGPT側のアシスタントは、MCP Events に対応してる。まだドラフトの拡張で、Nakayoshi はプロトコルバージョン 2026-07-28 のリクエストで events/list / events/subscribe / events/unsubscribe を受け付けるわ(普通のツールは2025年版の MCP でも動く)。

流れはこう。

  1. アシスタントが whoami で自分のIDを確認して、message.created を自分の recipient_id で購読する。他人の受信箱は購読できない
  2. 購読時、Nakayoshi はコールバックURLに署名付きのchallengeを送る。相手がそれをそのまま返せたら購読成立
  3. 新着は Standard Webhooks 形式で届く。webhook-id・webhook-timestamp・webhook-signature(v1, + HMAC-SHA256)のヘッダー付き
  4. 購読の期限は最大24時間。「無期限で」って頼まれても24時間で切る。更新はクライアント側の仕事
  5. 購読した瞬間に、未処理のメッセージ(先頭100件まで)をまとめて通知に積む。購読が切れてた間に来た分も取りこぼさない
  6. 相手が 410 Gone を返したら、その購読は消す

細かいところだと、署名キーをローテーションした直後の5分間は、新旧両方の鍵で署名を並べて送るようにしてある。コールバック先は、管理者が許可した完全なホスト名(OpenAI側のコールバックホスト)だけ。ワイルドカードは無しよ。

Muse:ngrok 越しの自前 webhook

一番泥臭いのがここ。Muse には MCP Events も、Routine みたいな「外から起こせる仕組み」も無いのよ。

だから、Muse が動いてる側にローカルの webhook サーバーを立てて、ngrok で外に公開した。Nakayoshi から見れば Grok Bot と同じ「Bearer 付き webhook」で、宛先が違うだけ。サーバー側のコードは1行も Muse 専用になってないわ。

受け側の webhook サーバーは、届いたイベントを受信箱ファイルに書き出す。Muse はそのファイルを監視してて、新しいイベントが来たら Nakayoshi の list_messages で中身を取りに行く。一度見たメッセージIDは読み飛ばす。あとは他の2つと同じで、必要なら返信、終わったら ack。

Muse の webhook は 202 Accepted を返すの。最初は 200 を前提にしてたから、2xx なら何でも受付扱いに直したわ。

OAuth でハマったところ

Muse を MCP コネクターとして登録するときにもひと悶着あったのよ。OAuth の同意画面でキーを入れて送信すると、ブラウザが止まる。

原因は CSP の form-action。Muse のコールバックは agent.meta.ai に戻ったあと、さらに muse.ai へリダイレクトする。ブラウザは form-action をフォーム送信後のリダイレクトの全部の段に適用するから、2段目が弾かれてたの。

直し方はこう。

  • agent.meta.ai を OAuth のリダイレクト先の許可リストに足す
  • 同意画面の form-action を 'self' https: に緩める

1段目の行き先は、登録済みクライアントのURLと照合してるから緩めても大丈夫。同意画面には外から差し込めるマークアップも無いしね。こういうのはドキュメントを読んでも分からなくて、実際にブラウザで踏んで初めて気づくやつよ。

届け方の保証

メッセージと通知ジョブは同じトランザクション

send_message は、messages への保存と jobs への通知ジョブ追加を 1つのトランザクションでやる。「メッセージはあるのに通知が積まれてない」状態は起きない。

再送

配信は Durable Object の alarm() が担当する。

  • 1回のタイムアウトは10秒
  • ネットワークエラー・408・429・5xx なら再送。それ以外の 4xx は即 failed
  • 最大6回まで。間隔は 10秒 × 2^n の指数バックオフで、上限1時間
  • 再送してもイベントIDは同じ
  • 配信待ちの間に相手が ack 済みにしてたら、そのジョブは cancelled にして送らない

通知が失敗してもメッセージは消えない

これが一番大事。通知はあくまで「起きろ」の合図で、正は受信箱。6回失敗して failed になっても本文は受信箱に残るから、botが次に起きたときに拾える。

その代わり、配信は「少なくとも1回」なの。同じ通知が2回届くこともありえる。だから受け手はメッセージIDで重複を弾く前提よ。Muse 側の「見たIDは読み飛ばす」もそのためにあるわけ。

サーバーからの定期的な拾い直しは、わざと無し

サーバー側に「未処理が残ってたら定期的に起こし直す」仕組みは無いわ。足し忘れじゃなくて、さっきの「ポーリングはしない」方針の結果よ。急ぎの仕事じゃないし、次に誰かが送れば起きるし、購読し直せば溜まった分がまとめて来る。それで足りてるの。

セキュリティ

個人用でも、外のサービスにPOSTする以上は手を抜いてないわよ。

  • 認証:botごとの Bearer トークン、または OAuth 2.1。OAuth はメタデータ検出、Dynamic Client Registration、PKCE(S256のみ)、同意画面でbotのキーを入力する方式。アクセストークンは1時間、リフレッシュトークンは30日で、使うたびに交換する。botのキーを変えると、そのキー由来のOAuthトークンも全部無効になる
  • トークン比較:SHA-256 にしてから定数時間で比較
  • 送信先の制限(SSRF対策):HTTPS のみ、ポートは443だけ、リダイレクトは追わない。IPアドレス直指定、localhost、.local、.internal みたいなホストは拒否
  • OAuth のリダイレクト先:許可したホストだけ
  • 同意画面:厳しめの CSP(default-src 'none'、frame-ancestors 'none')と、POST時の Origin チェック
  • リクエストサイズ:本文は128KBまで
  • ログ:本文、署名用secret、トークン、コールバックURLのパスはログに出さない

割り切りと制限

  • ループ防止はプロンプト頼み:bot同士の無限返信を止める回数制限は、サーバーには無い。各botへの指示に「返信は必要なときだけ。受領確認に返信しない」と書いて止めてる。ここは正直、今後の課題ね
  • 遅い:通知 → 起動 → MCPで取得、の3段だから即答はしない。急がない用途前提
  • 自動削除なし:メッセージも処理済みのものも消えない
  • 添付ファイルなし:テキストだけ
  • Durable Object は1個:個人用だからスケールは考えてない
  • プロトコルの履歴リプレイ(cursor)は未対応:代わりに購読時の未処理100件の積み直しで補ってる

まとめ

  • Nakayoshi は、Grok Bot・ChatGPT側アシスタント・Muse をつなぐ自作の中継MCPサーバー。Cloudflare Workers 1本と Durable Object 1個でできてる
  • 送信はみんな同じ send_message。受信箱は共通で、読むことと処理済みにすることは分けてある
  • 違うのは起こされ方。Grok Bot は Routine webhook、ChatGPT側は MCP Events、Muse は ngrok 越しの自前 webhook。どれも「ポーリングせず、使えるプッシュを使う」方針から選んだもの
  • 通知は少なくとも1回届く前提。正は受信箱だから、通知が落ちてもメッセージは消えない

botを1つに決めなくていいのよ。得意なbotに投げて、結果だけ返してもらえばいい。そのための配管がこれってわけ。アンタも複数のアシスタントを使い分けてるなら、まず「受信箱を1個置いて、起こし方だけ合わせる」ところから考えてみなさいよ。

記事一覧へ