Discord メッセージ検索 MCP サーバー 仕様書
バージョン: 0.11.0
作成日: 2026-07-01
最終更新日: 2026-07-27
対象: Cloudflare Workers(MCP 本体) + Node.js 20+ VPS 常駐(Discord Gateway Bot) / TypeScript / Hono + @hono/mcp / discord.js v14.26.3
関連 Issue: #235, #371
本書は 要件定義書 で定義した要件を実現するための実装仕様である。 要件の優先度・受け入れ条件・未確定事項は requirements.md を正とし、本書はアーキテクチャ、モジュール境界、API 契約、運用、テストを定義する。
改訂履歴
- 0.11.0 (2026-07-27): 第8次レビュー反映。(1) VPS 内部 API を PM2 fork mode +
instances: 1の単一プロセス運用へ固定(Gateway 接続・readiness・権限キャッシュがプロセスローカルであり、cluster mode では IDENTIFY 多重化・キャッシュ不整合・health/ready不整合が生じるため)。(2) サーキットブレーカーのhalf-openprobe に**期限付き lease(probeId/ 10 秒)**を導入し、Worker 中断時の永久占有と stale completion の反映を排除。(3) Durable Object 到達不可時を fail-closed から isolate ローカルのフォールバック制御への縮退へ変更(サーキットブレーカーは可用性統制であり認可境界ではないため)。(4) Workers 側の遮断にCIRCUIT_OPENを新設しGATEWAY_UNAVAILABLEとの混用を禁止、監査ログ・アラートにorigin次元を必須化(R-13 の補助検知が上流劣化と混同されるため)。(5) 平文 HTTP の redirect /4xx終端は認証情報を保護しないことを明記し、全 AI Agent Host のhttps://実物検証をリリース条件化、平文到達時の緊急ローテーション手順を §7.6 に追加。(6)max_restartsがmin_uptime超過でリセットされる PM2 仕様を補うため、**アプリ側の永続 restart budget(24 時間 / 10 回)**を必須化。(7)gateway-workerの定義を「TLS guard 通過後は pass-through」へ修正。(8)MCP_CIRCUIT_BREAKERの SQLite 状態と restart budget ファイルを §6.1 保持データへ追加(保存項目・pruning・削除条件を明記)。(9) userId クォータが/token/verify後にしか効かない問題に対し、keyId 単位のPREAUTH_RATE_LIMITERを認証前段に追加。(10) Workers 側の署名前 body サイズ検査、受信制御の応答形式(JSON-RPC error +Retry-After)、平文判定の一次根拠(URL.protocol)、エラー表の status 昇順を整理。 - 0.10.0 (2026-07-27): 第7次レビュー反映。(1) 平文 HTTP も Cloudflare edge / Worker に到達しうる実挙動に合わせ、zone の Always Use HTTPS と
gateway-workerの転送前 TLS 判定を必須化。(2) 内部 API の body 上限を 65,536 bytes、超過応答を413 PAYLOAD_TOO_LARGEに固定。(3) userId クォータを Rate Limiting binding、guild 単位の厳密なサーキットブレーカーを Durable Object の永続状態として定義。(4) PM2 の不一致 close code 分岐にmin_uptime: "60s"を追加し、max_restartsの保証範囲を起動直後の連続異常終了に限定。(5) B-03 を本番リリース Blocker として統一し、無通信時も認証済み heartbeat で恒久停止を検知する方式を一次経路、Workers のリクエスト起点アラートを補助経路へ修正。(6) v1 と Phase 2 のリプレイ試験を分離。 - 0.9.0 (2026-07-27): 第5次レビュー反映。(1)
shardDisconnectは discord.js v14.26.3 の private 定数UNRECOVERABLE_CLOSE_CODES(4004 / 4010 / 4011 / 4012 / 4013 / 4014)に該当する場合のみ発火する終端イベントであり、無条件の自動再起動は token / intent / shard 設定の誤りに対して無限クラッシュループとなるため、同じ 6 コードを自前 allowlist(当該定数は export されないため参照禁止)として保持し、一致時は終了コード78で自動再起動を抑止して手動是正へ、不一致時はバージョンアップ互換の防御的分岐として上限付き・指数バックオフ付き再起動へ振り分ける方針に修正(§3.8 / §7.4 / §8.2)。(2) アラート経路の秘匿情報流出対策として、log-throw-man送出データにフィールド allowlist・設定済み秘密値の完全一致マスク・秘密パターンのマスク・errorStackの 1,500 文字上限・1 invocation 内の request-local 重複抑止を必須化(§6.3。requirements の R-12)。(3) §5.4 見出しへのリンク anchor が実際の見出し ID と一致していなかった破損を修正。第6次レビュー反映として、(4) exit 78 の自動再起動抑止と B-03 未確定(VPS 側外部アラート無効)の組み合わせで恒久停止が無検知になる問題に対し、Workers 側の内部 API 到達不可 /GATEWAY_UNAVAILABLE率アラートを検知経路として必須化(§3.8 / §7.2、requirements の R-13)、(5) プロセスマネージャを PM2 5.1+ に固定して systemd 分岐を削除(§2.3 / §7.1)、(6)requestPath/workerNameは Discord メッセージ本文へ直接展開されるため固定候補値への限定とマスク対象化を追加(§6.3)。 - 0.8.0 (2026-07-26): 運用項目(権限変更がキャッシュへ反映されるまでの時間・監査ログ保存先・AI Agent Host 接続契約)を確定し、§9 の未確定事項から解消。第2次レビュー反映として、アクセスキーと guild の関連付け(keyId → guildId)、Gateway 切断中の fail-closed(ready=false で検索拒否)、
/api/tokenの実挙動(既存 Cookie 返却・customer 優先)に合わせた JWT 取得手順、VPS → logger の認証済み経路と既存 logger 4フィールドへのアダプタ契約を追加。第3次レビュー反映として、guild 横断防止の主要な対策を「1 セッション = 1 guild」に訂正(キーと guild の関連付けだけでは横断を防げないため)、TTL 上限の数値化、log-throw-man側のworkers_dev: false必須化、監査ログ保持期間 30 日と Discord 通知を監査ログ保持の補完手段から除外、スタッフトークン誤設定の診断(staff_token_required)を反映。第4次レビュー反映として、discord.js v14.26.3 の実ランタイム契約に合わせてinvalidated依存を除去し、shardError/shardDisconnectの復旧方針と再開可能な Invalid Session の残存リスクを明文化。health/readyを guild 部分障害時の degraded 応答へ変更し、logger のerrorStack || errorMessage選択でも相関情報を失わない送信データ仕様へ修正。VPS の外部監視サービスは未選定事項として B-03 に戻した。 - 0.7.0 (2026-07-18): Blocker B-01(Workers ↔ VPS 接続・認証)と B-02(
aud/guildId伝搬)の方式を確定。B-01 は Cloudflare Tunnel + Cloudflare Access(Service Auth)+ アプリ層 HMAC 署名の3層構成、確定パラメータ(deadline 30s / clock skew ±30s)、health の別ポート分離、VPS から状態を送信する監視方式、鍵ローテーション・運用手順。B-02 はunified-auth-worker不変の制約下で、MCP Worker のuserType: 'staff'強制(ゲスト JWT 拒否)+サーバー側 guild バインド(path→guildId allowlist、マルチギルド routing)+毎リクエスト/token/verify検証を補完的な対策として S-003 に定義(Issue #371)。レビュー反映として、MCP 専用アクセスキー(宛先スコープの強制)、global_fetch_strictly_public必須化、代替構成での Access JWT 検証モード、body サイズの hash 前検証を追加。実装コストと残存リスクの比較から、nonce 照合・boot grace・PM2 fork mode 単一プロセス制約を Phase 2 へ移し、v1 でリプレイ可能な時間を deadline / clock skew により約 60 秒へ制限する方針に確定(nonceフィールドとREPLAY_DETECTEDは前方互換のため予約)。design review 反映として、staging の否定系検証、VPS 側 raw body 保持、health listener の非共有、アクセスキー照合の等長比較、検索バッチの逐次実行と deadline 打ち切り、/token/verifyメモ化の禁止を追加。 - 0.6.0 (2026-07-16): アーキテクチャを Cloudflare Workers(MCP 本体: transport / auth) + VPS(Discord Gateway Bot: authz / cache / provider)の2プロセス構成に変更し、Gateway Pattern、versioned 内部 API 契約、セキュリティ Blocker、互換性・監査要件を追加(Issue #371)。
- 0.5.0 (2026-07-08): 要件を
requirements.mdに分離し、本書を仕様書として再構成。 - 0.4.0 (2026-07-02): 統合仕様としての最終版。
- 0.1.0 (2026-07-01): 初版。
1. 仕様の位置づけ
1.1 参照要件
この仕様書は、次の要件定義を前提にする。
- 目的、スコープ、ユースケース、受け入れ条件は requirements.md を参照する
- 実装上の優先順位は requirements.md の Must / Should / Could に従う
- 未確定事項は requirements.md の Blocker を解消してから確定する
1.2 実装原則
- 権限判定は MCP サーバー側(Workers / VPS 双方)で強制する
- LLM に権限判断を委ねない
- 検索結果は allowlist で再フィルタする
- 認証失敗、権限不明、上流障害時は fail-closed で拒否する
- MCP トランスポートは stateless とし、セッション状態に依存しない
- Discord Gateway は専用 Bot の VPS 常駐プロセスで処理する
- 外部公開入口は既存の
gateway-workerに集約し、MCP Worker へは Cloudflare Service Binding でルーティングする - MCP 本体(Cloudflare Workers)と Discord Gateway Bot(VPS)は物理的に分離し、両者は versioned な内部 API を介してのみ通信する
- 内部 API は Workers からの呼び出しのみを受け付け、他の経路からのアクセスは拒否する
1.3 セキュリティ MUST
/mcpは HTTPS でのみ公開する。zone の Always Use HTTPS を有効化し、さらにgateway-workerが元リクエストの scheme を/mcpの認証 header・body の読取前かつMCP_WORKERへの転送前に検証する。平文 HTTP は redirect または4xxで終端し、MCP Worker / 認証処理へ到達させない- ただし、redirect /
4xx終端を「平文 HTTP に対する防御」と見なしてはならない。 Cloudflare の Always Use HTTPS は「HTTP リクエストを受信してから redirect を返す」動作であり、redirect が返る時点でAuthorization: Bearer <スタッフ JWT>とX-MCP-Access-Keyは既に平文でネットワーク上を流れている。サーバー側の終端処理が守れるのは以降の処理コストと VPS への波及だけであり、認証情報そのものは守れない。したがって: - Discord への連携には Bot token のみを用い、受信した Bearer token やユーザートークンを転送しない(Bot token を保持するのは VPS 側のみとし、Workers 側には配置しない)
- 受信した JWT を
/token/verifyで検証し、userType === 'staff'を強制して対象外種別(ゲスト JWT を含む)を fail-closed で拒否する。対象 guildId はサーバー側 route/config バインドで決定し、クライアント入力からは取得しない /mcpは MCP 専用アクセスキー(X-MCP-Access-Key)を必須とし、staff JWT 単体では呼び出せない(staff JWT は全サービス共通 credential でありトークン自体から宛先を判別できないため、宛先スコープを第二の認証情報で強制する)- 認証失敗、BAN、権限不明時は fail-closed で拒否する
- 権限外の channel_id / 本文 / 監査対象情報を保存しない
- Workers ↔ VPS 間の内部 API は外部に公開せず、ネットワーク到達性の制限とアプリケーション層の送信元認証を併用する
- 内部 API の認証失敗、認証済み主体と userId / guildId の不一致、期限切れを fail-closed で拒否する。Phase 2 で nonce store を有効化した後はリプレイ検知時も拒否する
1.4 仕様上の非対象
- AI Agent の会話制御
- Discord メッセージの自前インデックス化
- 書き込み系操作
- DM 検索
- 人が直接操作する UI
2. システム構成
2.1 全体方針
本システムは、MCP 本体を Cloudflare Workers、Discord Gateway Bot を VPS 常駐プロセスとして運用する 2 プロセス構成とする。外部からの /mcp リクエストは既存の gateway-worker が受け、MCP Worker へ Service Binding で転送する。MCP Worker と VPS は versioned な内部 API(Workers → VPS の一方向呼び出し)でのみ接続する。
gateway-workerを唯一の公開入口とし、/mcpprefix(既定 guild の/mcpと追加 guild の/mcp/g/{slug})を TLS guard の通過後は path / query、method、request headers / body、status、response headers、streaming response を維持したまま、buffering せず MCP Worker へ pass-through で転送する。gateway-workerに置いてよいロジックは、route 追加と 転送前の TLS 判定(1.3 セキュリティ MUST / 3.9)だけとし、認証・guild 解決・body の解釈は一切行わない- MCP 本体(
/mcpエンドポイント、認証)は Cloudflare Workers 上で Hono +@hono/mcpにより提供し、MCP Worker はworkers_dev: false、preview_urls: false、routes / custom domains なしとして直接公開しない - Discord 連携(Gateway 接続、権限解決、検索実行)は VPS 常駐の discord.js v14 プロセスが担う
- 既存 soshosai 認証基盤(unified-auth-worker)とは、他の Worker 群と同様に Cloudflare Service Binding で連携する(モノレポの既存 Gateway Pattern に整合。詳細は 認証システム仕様書 を参照)
gateway-worker→ MCP Worker と MCP Worker → unified-auth-worker には通常の Cloudflare Service Binding を使用する。MCP Worker → VPS は Worker 間 Service Binding の対象外であり、Cloudflare Tunnel(非公開到達経路)+ Cloudflare Access(Service Auth service token による edge 認証)+ アプリケーション層の HMAC 署名(送信元認証・完全性・有効期限を検証する最終手段)の3層で接続する(Blocker B-01 で確定。詳細は 3.3 / 3.4)。Workers VPC private service binding は beta のため初期採用せず、GA 後の移行候補とする。内部 API の hostname は本番と同一ゾーンに置くため、MCP Worker のcompatibility_flagsにglobal_fetch_strictly_publicを必須で含め(このフラグがないと同一ゾーン hostname へのfetchがゾーンのオリジンへ直接ルーティングされ、Access / WAF を迂回してCf-Access-Jwt-Assertionも付与されない)、接続前に staging で loopback 経路(Access 評価とCf-Access-Jwt-Assertion到達)を検証する- 認証(Workers)、検索ロジック・権限ロジック・キャッシュ(VPS)はプロセス境界でモジュール分離する
- Discord Gateway 常駐が必要なコンポーネント(authz / cache / provider)のみを VPS に残し、それ以外は Cloudflare のエッジで完結させることで、既存インフラとの一貫性とレイテンシを両立する(Issue #371 の背景)
2.2 全体構成図
2.3 技術スタック
| 区分 | 技術 | 配置 | 採用理由 |
|---|---|---|---|
| ランタイム(MCP 本体) | Cloudflare Workers | Workers | エッジでの低レイテンシ応答、既存 Gateway Pattern との整合 |
| ランタイム(Bot) | Node.js 20+ | VPS | Discord Gateway 常駐が必要なため |
| 言語 | TypeScript 5+ | 共通 | 型安全性と既存モノレポ整合性 |
| Discord | discord.js v14.26.3 | VPS | Gateway / 権限判定 / Search 連携に必要。Gateway イベント契約の検証基準版として固定する |
| MCP | Hono + @hono/mcp | Workers | Streamable HTTP の実装が容易 |
| スキーマ | zod | 共通 | 入力・設定の検証に使用 |
| 受信制御(Rate Limiting) | Cloudflare Workers Rate Limiting binding | Workers | 認証前段の keyId 単位(PREAUTH_RATE_LIMITER。/token/verify 到達前の遮断)と認証後段の userId 単位(USER_RATE_LIMITER。粗い早期遮断)の2段で使用する。いずれも location-local・許容的な制御であり、厳密な課金・セキュリティ境界には使用しない |
| サーキットブレーカー | Cloudflare Durable Objects(SQLite storage) | Workers | guild 単位で全 isolate から共有する強整合な状態遷移を直列化する |
| デプロイ | Wrangler | Workers | Cloudflare Workers へのデプロイ |
| プロセス管理 | PM2 5.1+(fork mode / instances: 1) | VPS | VPS 常駐プロセス運用に適する。stop_exit_codes(恒久停止時の自動再起動抑止。3.8)が 5.1 で追加されたため下限を固定する。Gateway 接続・readiness・権限キャッシュがプロセスローカルなため cluster mode は使用しない |
| パッケージ管理 | pnpm | 共通 | モノレポ標準 |
2.4 ディレクトリ構成
Workers 側(MCP 本体)
| 軸 | 役割 | 主な責務 |
|---|---|---|
transport/ | MCP トランスポート | /mcp、認証済みリクエストの受付 |
auth/ | 認証アダプタ | JWT 検証、BAN 確認、claim 正規化、Service Binding 経由の /token/verify 呼び出し |
internal-client/ | 内部 API クライアント | 認証済みコンテキストを VPS 側の内部 API へ橋渡しする |
config/ | 設定 | env / Wrangler 変数の読み込み、zod 検証 |
既存 gateway-worker
| 軸 | 役割 | 主な責務 |
|---|---|---|
MCP_WORKER Service Binding | MCP 公開入口 | /mcp prefix(/mcp・/mcp/g/{slug})を MCP Worker へ転送し、path / query、method、認証・MCP protocol headers、body、status、response headers、streaming response を保持する |
本書で
gateway-workerと表記するのは、モノレポ上のパッケージpackages/api/system-workers/gateway-workerを指す。 Cloudflare 上の実サービス名はsystem-gatewayであり、環境ごとにsystem-gateway-stg/system-gateway-preprod/system-gateway-dev1〜dev5が存在する。 Service Binding のserviceに指定するのはパッケージ名ではなくこの実サービス名であり、環境別に値を切り替える(既存例:packages/web/sho-room/wrangler.jsoncのGATEWAYbinding)。
VPS 側(Discord Gateway Bot)
| 軸 | 役割 | 主な責務 |
|---|---|---|
gateway/ | Discord Gateway | discord.js 常駐接続、権限に関わる Gateway イベント受信 |
internal-api/ | 内部 API サーバー | Workers からの認証済みリクエストの受付。他経路からのアクセスは拒否する |
authz/ | 権限解決 | 可視チャンネル算出、交差、ポストフィルタ |
cache/ | 権限キャッシュ | TTL、イベント無効化、backend 抽象 |
provider/ | 検索プロバイダ | Discord Search API 呼び出しの隔離 |
config/ | 設定 | env 読み込み、zod 検証 |
共通
| 軸 | 役割 | 主な責務 |
|---|---|---|
tests/ | テスト | 単体・結合・E2E(Workers 側 / VPS 側それぞれに配置) |
ディレクトリ・パッケージの厳密な配置(モノレポ内のパス)は実装 Issue(#365, #367, #369, #370, #371)側で確定する。本書は責務の切り分けのみを規定する。
3. モジュール仕様
モジュールは配置先(Cloudflare Workers / VPS)ごとに分離する。Workers と VPS の間は 3.4 internal-api で定義する内部 API のみで通信し、 それ以外の経路は許可しない。
外部クライアントは gateway-worker の /mcp prefix のみへ接続する。gateway-worker は MCP_WORKER Service Binding で MCP Worker の同一パスへ純粋 pass-through で転送し、MCP Worker に直接公開 route は設定しない。マルチギルドは path で表現し(既定 guild は /mcp、追加 guild は /mcp/g/{slug})、MCP Worker が path → guildId の allowlist(サーバー側 config)で解決する。allowlist 外・未定義 path は一律 404 とし、guild の存在有無でレスポンス差を持たせない。/mcp prefix の OPTIONS も MCP transport の契約に従って転送し、既存の汎用 app.options('*') / CORS middleware で先に終端しない。
3.1 transport(配置: Cloudflare Workers)
transport/ は gateway-worker から Service Binding 経由で呼び出される MCP サーバーの HTTP エントリポイントを提供する。
- 公開
/mcpprefix はgateway-workerの HTTPS route のみとする。平文 HTTP も Cloudflare edge / Worker へ到達しうるため、zone の Always Use HTTPS に加え、gateway-workerで元リクエストの scheme を最初に検証し、認証 header・body を読まず Service Binding にも転送せず終端する。この終端は到達後の処理を止めるだけで、既に平文送信された認証情報は守れない(1.3)。平文終端イベントは監査ログとアラートの対象とし、当該アクセスキー・スタッフ JWT を漏洩扱いとしてローテーションする app.all('/mcp', ...)とapp.all('/mcp/g/:slug', ...)handler を登録し、定義済み path 以外は404を返す(gateway の prefix 転送下でも未定義 path を外部到達させない)MCP-Protocol-Versionを受け付けるOriginを検証し、想定外の呼び出し元を拒否する- 認証失敗時は
401を返す WWW-Authenticate: Bearerを返す- 認証済みコンテキスト(userId / guildId)を内部 API 経由で VPS 側のツール層へ伝搬する
- セッション状態は保持しない
3.2 auth(配置: Cloudflare Workers)
auth/ は外部認証を内側の仕様に変換する。
- MCP 専用アクセスキーで宛先スコープと guild スコープを強制する:
X-MCP-Access-Keyheader を必須とし、config の許可キーリスト(keyId 付き世代管理、keyId と guildId をサーバー側で関連付ける)と constant-time で照合する。照合は受信値と期待値の双方を SHA-256 でハッシュしてから固定長比較する(crypto.subtle.timingSafeEqual相当)。長さの事前チェックで早期 return すると期待キー長が漏れるため、長さ差による分岐を作らない。さらに、request path から解決した guildId と、提示キーに関連付けられた guildId が一致しない場合も拒否する(guild A 用のキーで guild B の route を呼べない)。欠如・不一致・guild 不一致は401で拒否し、/token/verifyを呼ばない。キーは AI Agent Host の MCP サーバー設定(カスタム header)で供給し、VPS へは転送しない - Bearer トークンを
unified-auth-workerの/token/verifyで検証する(現行 I/F のまま Service Binding 経由で呼び出す。unified-auth-workerは変更しない) payload.userType === 'staff'を強制し、対象外の principal 種別(ゲスト JWT を含む)を fail-closed で拒否する。/token/verifyはゲスト JWT(LINE 認証)も valid として返すため、この判定と MCP 専用アクセスキーの組で宛先分離を構成する(詳細は 5.1.1)- 非スタッフ種別の拒否は、汎用 401 と区別できる識別可能なエラーとして返す(例:
error: "staff_token_required"と、スタッフトークンを設定し直す旨の指示をWWW-Authenticate/ body に含める)。/api/tokenがゲストトークンを返す既知の挙動(4.6)により、利用者が意図せずゲストトークンを設定する事故が起こりうるため、原因を自己診断できる粒度で返す。監査ログにはreason: non_staff_principalを残す(トークン値そのものは残さない) - なお、複数トークンの同時受理・ゲストトークンのフォールバック・
userTypeチェックの緩和は行わない(いずれも S-003 の宛先分離を壊すため)。MCP 側でできるのは「誤ったトークンを安全に弾き、原因を判別可能にする」ところまでであり、スタッフトークンを確実に取得させること自体は 4.6 の取得手順と login-api 側の挙動に依存する - payload を zod で構造検証し、
subが Discord snowflake 形式(数値文字列)であることを確認する subを userId として扱うguildIdは request path(/mcp//mcp/g/{slug})を allowlist 解決したサーバー側バインドから取得する。トークン claim・tool 引数・query・header からは取得しない- BAN 判定は毎リクエストの検証結果に含める
unified-auth-workerの/token/verify相当の検証は Cloudflare Service Binding 経由で呼び出す(既存の Gateway Pattern・auth-workers 連携方式に整合させ、公開 HTTPS 越しの呼び出しは行わない)- Discord Bot token は一切保持・使用しない(Bot token を扱うのは VPS 側の
gateway//provider/のみ) - 外部認証へ到達できない場合は fail-closed で拒否する
3.3 internal-client(配置: Cloudflare Workers)
internal-client/ は認証済みコンテキストを VPS 側の内部 API へ橋渡しする。接続方式は 2.1 で確定した「Cloudflare Tunnel + Cloudflare Access(Service Auth)+ アプリ層 HMAC 署名」の3層とする。
- Workers で生成した requestId、リプレイ防止用 nonce、issuedAt / deadlineAt、認証済み principal(userId / guildId)、検証済み input を versioned request envelope として VPS へ送る
nonceは v1 では VPS 側の照合対象外(3.4 の Phase 2)だが、Workers は v1 から必ず生成して envelope に載せる。HMAC の保護対象に含まれるため、Phase 2 の有効化が VPS 側だけの追加実装で済み、契約変更・version 上げを伴わない- 受信した Bearer token やユーザートークンは転送しない(S-002 を Workers/VPS 境界でも維持する)
- edge 認証: Cloudflare Access の service token を
CF-Access-Client-Id/CF-Access-Client-Secretheader で付与する。Access application は必ず Service Auth decision で作成し、identity 要求(Allow)にはしない(Allow だと Worker のfetchが401ではなくログイン HTML への302を受け取り、原因不明の失敗に見える) - アプリ層署名(送信元と完全性の最終的な検証根拠): raw body を一度だけ生成し、
"v1\n" + HTTP method + "\n" + canonical path + "\n" + hex(SHA-256(raw body))を canonical string として HMAC-SHA256 で署名し、X-Internal-Signature: v1, keyId=<id>, sig=<base64url>で付与する。issuedAt / deadlineAt / nonce / principal は raw body 内にあり body hash で完全性が担保されるため、署名対象を header 側に二重化しない。tool endpoint への query string は付与しない(canonical path 正規化問題を消す) - 署名前に body サイズを検査する。Workers 側も VPS 側と同じ共通定数
MAX_INTERNAL_BODY_BYTES = 65_536(3.4)を参照し、raw body が上限を超える場合は署名・送信を行わず、その場でツール呼び出しを失敗させる。VPS の413 PAYLOAD_TOO_LARGEは「Workers 側の事前検査が漏れた場合の最終防壁」であり、通常運用で到達してはならない。MCP クライアントにはINVALID_REQUEST(retryable: false)として「入力が大きすぎる」旨を返し、上限超過の発生をメトリクス化する(channel_idバッチ(4.2)の分割粒度が上限を超えていないかの監視に用いる) - body 送信と hash 計算は同一の raw bytes を使う(再 serialize による key 順序不一致を避ける)。HMAC 鍵は環境(stg / preprod / prod)ごとに別鍵とし、環境間リプレイを構造的に不能にする
- VPS 到達不可・タイムアウト時は fail-closed で拒否する
- 内部 hop の自動再試行は、Discord 上流の予算・レート制限を重複消費するため行わない。Access ローテ中などの一時失敗も Workers 側で同一 envelope を自動再送しない(deadline を過ぎた envelope は再送しても成功しない)。
retryable: trueの一時エラーは MCP クライアントへそのまま返し、新しいツール呼び出し(新しい nonce / issuedAt の envelope)としての再試行に委ねる。Discord の202/429に対する provider 内再試行は、同一 request の deadline と上流予算の範囲内に限定する
3.4 internal-api(配置: VPS)
internal-api/ は Workers からの認証済みリクエストを受け付け、外部には公開しない。接続経路には Cloudflare Tunnel を使用する(cloudflared のアウトバウンド接続のみとし、VPS に公開インバウンドポートを開けない)。
- Workers 以外からのアクセスは拒否する。到達性(Tunnel による非公開経路)、edge 認証(Cloudflare Access)、アプリ層署名(HMAC)の3層で担保する
- 検証順序は次の順に評価し、どの層で失敗しても 3.4.3 の fail-closed 契約に従って拒否する: (1) Access JWT 検証モードが
required(既定)の場合、Cloudflare が付与するCf-Access-Jwt-Assertionを検証する(欠如・無効は拒否。edge が Access をスキップする経路が仮に存在しても header 欠如で拒否し、機密性を優先して可用性の低下を許容する。disabledは 7.6 の代替構成でのみ許可し、本手順を省略して HMAC 層を最終的な検証の根拠とする)→ (2)Content-Type: application/jsonの完全一致と body サイズを検証する。v1 の上限MAX_INTERNAL_BODY_BYTESは Workers / VPS 共通定数の **65,536 bytes(64 KiB)**に固定し、個別環境で変更しない。Content-Lengthがあれば読み込み前に超過を413 PAYLOAD_TOO_LARGEで拒否し、streaming / chunked の場合も読み込み中に 65,537 byte 目を受信した時点で中断して同じ応答を返す。巨大 body に対する HMAC 計算の強制を防ぐため、サイズ検証は必ず hash 計算より前・計算中に行う → (3)X-Internal-Signatureの HMAC を constant-time で検証(この HMAC 層を送信元認証・完全性の最終的な検証の根拠とし、否定系テスト・fail-closed 判定はこの層を基準に定義する)→ (4) envelope(method / canonical path / version / requestId / nonce / issuedAt / deadlineAt / principal / input)を共通 Zod schema で再検証 → (5) 有効期限(deadline / clock skew)を検証 → (6) principal を、認証済み MCP Worker が生成したコンテキストとして確定する - HMAC 検証は受信した raw bytes に対して行う。Node.js の標準的な body parser(
express.json()、Hono のc.req.json()等)は parse 後に raw bytes を破棄するため、raw buffer を保持する設定(raw-body相当)を明示的に有効化し、parse 済みオブジェクトをJSON.stringifyで再直列化した結果を署名検証に使わない(key 順序・空白の差で署名が壊れる) - Access JWT の検証はチームドメインの JWKS(
/cdn-cgi/access/certs)に依存する。ディスクキャッシュ+バックグラウンド更新+取得失敗メトリクスを備え、cold start で JWKS を取得できない場合も fail-closed とする - v1 ではリプレイ可能な時間を期限で制限する。envelope の
deadlineAt/issuedAtを許容 clock skew の範囲で検証し、期限外はREQUEST_EXPIREDで拒否する。リプレイ可能な時間は最長でも deadline horizon + clock skew(約 60 秒)となる - nonce の照合による、許容期間内でのリプレイ攻撃防止は Phase 2 とする。v1 では VPS 側に nonce store を持たない。理由は、in-memory nonce store では再起動直後に過去の nonce を判定できず、その対策(boot grace)として再起動ごとに最大 60 秒の受付停止と単一プロセス制約が必要になるためである。この実装・運用コストは下記の残存リスクに見合わないと判断した。Phase 2 で有効化する際は VPS 側に check-and-set を追加するのみで、契約変更を伴わない
- 残存リスク: Access service token を保持し、かつ署名済み request のバイト列を入手した攻撃者が、約 60 秒以内に同一 request を再実行できる。ただし envelope は HMAC で固定されるため、攻撃者は userId・guildId・input のいずれも改変できず、「正規利用者が直前に実行した検索の再実行」以上のことはできない(他 guild・他ユーザー権限への横断は不可)。この受容は requirements のリスク表に記録する
- Phase 2 で nonce store を実装する場合は、
has→addを非同期処理を挟まず同期的・原子的に行う。水平スケール時は Redis の Lua script 等で check-and-set の原子性を担保する
- authz / cache / provider へのエントリポイントとして機能する。v1 は PM2 の fork mode +
instances: 1の単一プロセス運用に固定する(MUST)。Gateway 接続(3.8)・shard / guild 単位の readiness・権限キャッシュ(3.6)はいずれもプロセスローカルな状態であり、cluster mode で複数 instance を起動すると (a) instance ごとに独立した Gateway 接続が張られて IDENTIFY が多重化し、(b) 権限剥奪イベントを受けた instance のキャッシュだけが flush されて他 instance が旧権限情報を返し、(c)health/readyが到達した instance の状態しか反映しない、という不整合が生じる。PM2 の cluster mode も対象アプリケーションが stateless であることを前提としており、本プロセスは該当しない。水平スケールが必要になった場合は、Gateway 接続と readiness / キャッシュを共有ストア(Redis 等)へ外出しし、shard 配分を明示設計してから解禁する- Phase 2 で nonce を有効化する場合も、この単一プロセス制約がそのまま前提条件となる(共有 nonce store を導入する場合に限り複数プロセスを検討できる)
3.4.1 HTTP 契約 v1
内部 API v1 は次の endpoint を提供する。
| Method | Path | 用途 |
|---|---|---|
| POST | /internal/v1/tools/search_messages | 可視範囲を解決してメッセージを検索する |
| POST | /internal/v1/tools/list_accessible_channels | 可視チャンネル一覧を取得する |
| POST | /internal/v1/tools/get_message_context | 指定メッセージの前後文脈を取得する |
| GET | /internal/v1/health/live | VPS 内部 API プロセスの生存確認 |
| GET | /internal/v1/health/ready | Gateway 接続と必須依存を含むプロセス受付可否の確認(全対象 guild 正常時は ready、一部正常時は degraded、受付可能な guild がない場合は not_ready) |
- tool endpoint は
Content-Type: application/jsonのみを受け付ける - tool endpoint は path を canonical な操作名とし、body に tool 名を重複して持たせない
requestIdは Workers が生成し、VPS の応答・監査ログまで同じ値を維持する- userId / guildId を query parameter や tool input から取得しない
- request / response schema は
@soshosai/sharedを SSOT とし、Workers / VPS の双方で同じ Zod schema と型を使用する - health endpoint は tool endpoint とは 別ポート・別 listener で serve し、tunnel ingress の hostname→port マッピングで構造分離する(
127.0.0.1:PORT_TOOLSを tool hostname、127.0.0.1:PORT_HEALTHを health のみ)。これにより health listener に tool のルートが物理的に存在せず、「health 用 identity で tool を呼べない」ことをポリシーではなく構造で保証する(tool endpoint の送信元認証とは別系統)。構造分離を成立させるため、tool 用 hostname の tunnel ingress からはPORT_HEALTHへの経路を定義せず(逆も同様)、health listener は tool 用の Hono / HTTP サーバーインスタンスを共有せず独立に生成する(同一インスタンスを別ポートで listen させると経路分離が名目化する) - B-03 は Gateway Bot の本番リリース Blockerとする。本番では VPS から外部監視サービスへ認証済み heartbeat を送信する方式を一次検知経路とし、リクエストがない時間帯の停止も検知する。B-03 確定前に許可するのは開発・pre-production での PM2 ローカル監視と構造化ログまでであり、本番稼働と外部 heartbeat の無認証送信は許可しない。外部監視サービスから health endpoint を取得する必要がある場合のみ、別 hostname → 別 Access application → health 専用 service token → tunnel ingress で
PORT_HEALTHへ、というルートを追加する(tool 用とは別 identity) - health endpoint は tool 用の principal envelope・HMAC 署名を要求せず、秘密情報・設定値・詳細な上流レスポンスを返さない
/internal/v1/health/readyは guild ごとの tool 受付判定を集約し、次の HTTP 契約を返す。raw guildId や guild 名は health 応答へ含めず、件数だけを返す- 全対象 guild が受付可能:
200/{ "status": "ready", "readyGuildCount": n, "unreadyGuildCount": 0 } - 一部の対象 guild が受付可能:
200/{ "status": "degraded", "readyGuildCount": n, "unreadyGuildCount": m } - 受付可能な対象 guild がない:
503/{ "status": "not_ready", "readyGuildCount": 0, "unreadyGuildCount": m }
- 全対象 guild が受付可能:
degradedはプロセスをロードバランサーや監視対象から除外するための失敗ではない。監視では可用状態として扱いつつアラートを発火し、ready な guild の tool リクエストを継続する
3.4.2 Request envelope
type InternalToolRequest<TInput> = {
version: '1';
requestId: string;
nonce: string;
issuedAt: string;
deadlineAt: string;
principal: {
userId: string;
guildId: string;
};
input: TInput;
};
requestIdは UUID、nonceは 128 bit 以上の entropy を持つ base64url、issuedAt/deadlineAtは UTC の RFC 3339 として共通 Zod schema で検証する。nonceは v1 でも必須フィールドとして Workers が生成・送出し schema 検証の対象とするが、VPS 側の再利用照合は Phase 2 とする(3.4)- version は path segment(
/internal/v1/)と envelope のversionの双方に持つ。path は routing 用、envelope のversionは完全性保護の対象となる raw body に含まれる値であり、両者が不一致の request は解釈を試みずINVALID_REQUESTとして拒否する(path のみを書き換えた request を検知するため) - 最大 deadline horizon は 30 秒、許容 clock skew は ±30 秒(issuedAt の未来方向許容も同値)とする(Blocker B-01 で確定)。任意に長い有効期間は許可しない。この2値がリプレイ可能な最長時間(約 60 秒)を直接決めるため、延長する場合は残存リスクの再評価を伴う。Phase 2 で nonce を有効化する場合の保持期限は 120 秒(horizon + skew + margin)とする。VPS は chrony / systemd-timesyncd で NTP 同期を必須とし、drift をメトリクス監視して skew/2 を超えたらアラートする(drift が skew を超えると全 request が
REQUEST_EXPIREDになるため)
3.4.3 Response envelope とエラー
type InternalErrorCode =
| 'INVALID_REQUEST'
| 'PAYLOAD_TOO_LARGE'
| 'UNAUTHENTICATED'
| 'CONTEXT_BINDING_FAILED'
| 'FORBIDDEN'
| 'REQUEST_EXPIRED'
| 'REPLAY_DETECTED'
| 'RATE_LIMITED'
| 'DISCORD_UPSTREAM_ERROR'
| 'GATEWAY_UNAVAILABLE'
| 'CIRCUIT_OPEN'
| 'UPSTREAM_TIMEOUT'
| 'INTERNAL_ERROR';
type InternalToolResponse<TResult> =
| {
version: '1';
requestId: string;
ok: true;
result: TResult;
}
| {
version: '1';
requestId: string | null;
ok: false;
error: {
code: InternalErrorCode;
message: string;
retryable: boolean;
};
};
| HTTP status | code | 条件 |
|---|---|---|
| 400 | INVALID_REQUEST | version、path、schema が不正 |
| 400 | REQUEST_EXPIRED | deadline 超過または許容 clock skew 外 |
| 401 | UNAUTHENTICATED | 内部 API の送信元認証に失敗 |
| 403 | CONTEXT_BINDING_FAILED | request と認証済み MCP Worker / principal context の対応関係を検証できない |
| 403 | FORBIDDEN | 認証済み context の guild が許可外、または操作権限がない |
| 409 | REPLAY_DETECTED | request の再利用を検知(Phase 2 で使用。v1 では返さないが、契約変更なしに有効化できるよう code を予約する) |
| 413 | PAYLOAD_TOO_LARGE | raw body が 65,536 bytes を超過(HMAC・schema 検証前に拒否) |
| 429 | RATE_LIMITED | 内部または Discord 上流の予算超過 |
| 500 | INTERNAL_ERROR | 公開可能な詳細を持たない予期しない内部エラー |
| 502 | DISCORD_UPSTREAM_ERROR | Discord 上流が処理に失敗 |
| 503 | GATEWAY_UNAVAILABLE | Discord Gateway または権限解決に必要な状態が利用不可(VPS 側でのみ生成する) |
| 503 | CIRCUIT_OPEN | Workers 側サーキットブレーカーが open(Workers 側でのみ生成し、内部 API 呼び出しの前段で返す。7.3) |
| 504 | UPSTREAM_TIMEOUT | 設定された上流 deadline を超過 |
GATEWAY_UNAVAILABLEとCIRCUIT_OPENを混用しない。 両者はいずれも503/retryable: trueだが発生元が異なり、GATEWAY_UNAVAILABLEは「VPS の Gateway 接続または guild 状態が利用不可」、CIRCUIT_OPENは「Workers 側が Discord 上流の劣化を検知して自ら遮断した」を意味する。R-13 はGATEWAY_UNAVAILABLE率を Gateway Bot 恒久停止の補助検知に使うため、上流劣化による遮断を同じ code に混ぜると恒久停止と区別できなくなる。監査ログ・メトリクス・アラートにはcodeに加えて発生元次元(origin: 'workers' | 'vps')を必須で付与するREQUEST_EXPIREDに408は用いない。408は「クライアントが時間内に request を送りきらなかった」という接続レベルの意味を持ち、同一 request の自動再送を促す実装が存在する。deadline 超過・clock skew 外の request は同一バイト列を再送しても成功しないため、400としてretryable: falseを返す。REQUEST_EXPIREDは「同一 request の再送が決して成功しない恒久拒否」に限定し、新規 request の生成で回復しうる一時状態はGATEWAY_UNAVAILABLE(retryable: true)として区別する- HTTP status は同一 status に複数 code が対応しうるため、Workers 側の分岐は status ではなく
error.codeで行う deadlineAtは Workers の MCP tool 全体 deadline より短い値として設定し、VPS は残り時間を超えて上流再試行を行わない- error response の
requestIdは認証・schema 検証を通過した UUID のみ echo し、invalid JSON、未指定、不正値、認証失敗時はnullとする - 内部エラーの詳細、秘密情報、Discord レスポンス本文は
messageに含めない
3.4.4 互換性とデプロイ順序
- v1 内の変更は optional field の追加など後方互換な変更に限定する
- 必須 field の追加、意味変更、削除は
/internal/v2/として新しい schema を定義する - 破壊的変更時は、先に VPS を deploy して旧版・新版の双方を受け付け、次に Workers を deploy し、rollback 期間経過後に旧版を削除する
- 初回リリースは VPS 内部 API、非公開 MCP Worker、最後に
gateway-workerの Binding / route の順で deploy し、参照先を先に利用可能にする - consumer / provider contract test で、現行 Workers と新 VPS、新 Workers と現行 VPS の両組み合わせ、および実 HTTP serialization、認証失敗、改ざん、期限切れ、リプレイ、未対応 version を検証する
- discord.js は v14.26.3 を Gateway イベント契約の基準版とする。更新時は
shardReconnecting/shardResume/shardReady/shardDisconnect/shardErrorと Invalid Session(OP 9)の結合テストを再実行し、状態遷移が同一であることを確認してから反映する
3.5 authz(配置: VPS)
authz/ は検索可視範囲の決定責務を持つ。discord.js Client が Gateway で保持するキャッシュ済みの Guild / Channel / Member 情報に依存するため、Gateway 接続を持つ VPS プロセス内に置く。
guildIdのメンバーであることを確認するVIEW_CHANNELとREAD_MESSAGE_HISTORYを条件に可視チャンネルを算出する- LLM が指定した channel_id は拡大に使わず、必ず可視集合との交差に限定する
- 検索結果は可視集合で再度 allowlist フィルタする
3.6 cache(配置: VPS)
cache/ は権限キャッシュを扱う。
- 既定実装は in-memory とする
- TTL は既定 5 分・最大 15 分とし、Gateway イベントを受信できなかった場合のフォールバック処理として残す(config で調整可能。最大値超過は起動時の zod 検証で拒否)
- 権限に影響する Gateway イベントで即時無効化する
- Gateway の再接続開始を検知したら、該当 shard が担当する guild のキャッシュを全 flush する(fail-closed)
- backend はインターフェース化し、将来差し替え可能にする
- Cloudflare Workers バインディング前提の KV / D1 には依存しない(VPS プロセス内で完結させる)
3.7 provider(配置: VPS)
provider/ は Discord Search API の細部を吸収する。
- Search API 呼び出しを 1 箇所に閉じ込める
202とretry_afterを解釈して再試行する- 1 回の要求を必要に応じてバッチ分割する
- バッチは並列発行せず、逐次または明示的な concurrency 上限のもとで実行する(
channel_idを 500 件単位に分割した結果を一斉に投げると Discord の rate limit を自ら誘発するため) - 各バッチの発行前に残り時間を確認し、
deadlineAtに対して余裕がない場合は以降のバッチを打ち切って部分結果ではなくエラーを返す(3.4.3 のUPSTREAM_TIMEOUT。上限超過時に部分成功へ倒さない方針は 7.3 受信制御と一致させる) - 将来、別検索基盤へ差し替え可能にする
3.8 gateway(配置: VPS)
gateway/ は Discord Gateway への常駐接続を担う。
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
MessageContentは privileged intent として有効化が必要- スレッドとチャンネルの可視性は Gateway イベントと REST を組み合わせて判断する
- 権限に影響する Gateway イベントを
cache/の無効化トリガーへ橋渡しする - Gateway の接続状態は shard 単位と guild 単位で保持する。起動時は全 shard・全対象 guild を
ready=falseとする。discord.js v14 のshardReconnecting(shardId)を受けた時点で該当 shard をready=falseにし、shardResume/shardReadyで再同期が完了した時点で該当 shard の担当 guild を flush してから当該 shard をready=trueに戻す - guild の部分障害も fail-closed にする。
shardReady(shardId, unavailableGuilds)のunavailableGuildsに含まれる対象 guild はready=falseのまま維持し、接続中にguildUnavailableを受けた場合も当該 guild を即座にready=falseにする。guildAvailableを受けたら当該 guild のキャッシュを全 flush し、状態再同期を確認してからready=trueに戻す - 受付可否は「対象 guild を担当する shard と対象 guild 自体がともに ready であること」で guild ごとに判定する。任意の shard / guild の復帰イベントで他の guild の状態を書き換えてはならない(別 shard の切断中または別 guild の unavailable 中に旧権限情報を返すおそれがあるため)。一方、unavailable な guild が 1 つあるだけで正常な guild まで停止させない。プロセス全体の
health/readyは全正常をready、一部正常をdegraded、全停止をnot_readyとして返す(3.4.1 HTTP 契約 v1、5.4 キャッシュによる権限反映のタイムラグ)- 単一 shard 構成(小規模 guild 数の想定)でも guild 単位の受付判定を維持し、1 guild の unavailable で同じ shard 上の他 guild を停止しない
shardDisconnectは discord.js v14.26.3 では「切断後に再接続しない」終端イベントである。v14.26.3 のWebSocketManagerは private 定数UNRECOVERABLE_CLOSE_CODES(4004 認証失敗 / 4010 無効な shard / 4011 sharding 必須 / 4012 API バージョン不正 / 4013 intent 不正 / 4014 privileged intent 不許可)に該当する場合のみ本イベントを発火し、それ以外の close code では内部で再接続する。この定数は@discordjs/wsからも discord.js からも export されていないため参照・import してはならない。本サーバーは同じ 6 コードを自前の allowlist として保持し、shardDisconnectの close code をこれと照合する。該当 shard をready=falseにした後、発生後のshardResume/shardReadyは待たずにプロセスを終了する。これらはいずれも token・intent・shard 設定の誤りであり再起動では解消しないため、無条件の自動再起動を行ってはならない(無限クラッシュループとなり、IDENTIFY を反復して Discord 側のレート制限・トークン失効を招く):- 自前 allowlist に一致する場合(v14.26.3 では常にこちら): 専用の終了コード(
78/EX_CONFIG)で終了し、プロセスマネージャの自動再起動を明示的に抑止する(PM2stop_exit_codes: [78])。終了前に close code と shardId を VPS 側の構造化ログへ記録し、手動での是正(token 再発行・privileged intent の有効化・shard 設定修正)を必須とする - allowlist に一致しない close code で
shardDisconnectを受けた場合(バージョンアップ互換の防御的分岐): v14.26.3 では到達しないが、discord.js 側のUNRECOVERABLE_CLOSE_CODESが将来変更されても未定義動作にしないための経路として定義する。終了コード1で終了して自動再起動を許可する。PM2 はmin_uptime: "60s"、max_restarts: 5、exp_backoff_restart_delay: 1000を必須設定とし、起動から 60 秒未満で同じ終端条件を繰り返す連続異常終了を最大 5 回で停止する- PM2 の
max_restartsだけでは不十分である。 PM2 の不安定再起動カウンタはmin_uptimeを超えて稼働した時点でリセットされるため、shardDisconnectが 60 秒より長い間隔で繰り返す障害(例: 10 分ごとに切断)では再起動が無期限に続き、IDENTIFY の反復による Discord 側レート制限・トークン失効という「無条件再起動を禁じた本来の理由」がそのまま再現する - したがって、アプリケーション側に永続的な時間窓 restart budget を持たせる(MUST)。VPS のローカル状態ファイル(例
var/restart-budget.json。min_uptimeを超えた稼働ではリセットしない)にshardDisconnect起因の異常終了時刻を追記し、24 時間の rolling window で 10 回を超えた時点で、close code が allowlist 外であっても終了コード78へ切り替えて自動再起動を打ち切る。状態ファイルは起動時に window 外の記録を pruning し、手動是正後は運用手順で明示的にリセットする。状態ファイルへの書き込みが IO エラーで失敗した場合は budget 枯渇と同じ扱いとし、終了コード78で終了する(fail-closed。書き込み失敗を無視すると budget 保護が無効化されるため) - 上限到達(PM2 の連続異常終了上限、または上記 restart budget の枯渇)と B-03 heartbeat 欠損はいずれもアラートのうえ手動対応へ引き継ぐ。この分岐に到達した場合は「discord.js の終端条件が自前 allowlist と乖離した」シグナルとして扱い、依存更新時に allowlist を見直す
- PM2 の
- 自前 allowlist に一致する場合(v14.26.3 では常にこちら): 専用の終了コード(
shardErrorは接続エラーの通知であり、それ単体では切断や後続の復帰イベントを保証しない。ログ・メトリクスには記録するが、このイベントだけでready=falseにしない。後続のshardReconnecting/shardDisconnectが発火した場合のみ対応する状態遷移へ進む- discord.js v14.26.3 の public
ClientAPI ではinvalidatedを readiness 統制に使用しない。型には残っているが実ランタイムでは発火せず、再開可能な Gateway Invalid Session(OP 9,d=true)はshardReconnectingを経ず同一接続で resume し、replay 後にshardResumeのみ発火する。この経路は v1 では Discord の replay を信頼し、shardResume受信時に担当 guild を flush する。事前にready=falseへ落とせない残存リスクは requirements の R-11 に記録し、厳密な fail-closed が必要になった場合は@discordjs/ws/ discord.js に OP 9 の状態通知を公開する変更を導入する shardResumeは replay 完了後の通知として扱い、担当 guild のキャッシュを flush してから受付を継続する。shardReadyは再 IDENTIFY 後の通知として同様に flush してから受付を再開する
3.9 config(配置: Cloudflare Workers / VPS)
config/ は環境変数・Wrangler 変数を検証する。Workers 側と VPS 側でそれぞれ個別のスキーマを持つ。
gateway-worker 側
- MCP Worker への
MCP_WORKERService Binding /mcpprefix(/mcpおよび/mcp/g/{slug})route の path 保持と汎用OPTIONS/ CORS middleware より前の転送設定- zone の Always Use HTTPS を有効化する。加えて、
/mcpprefix の最初の処理で平文 HTTP を判定し、認証 header・body の読取前および Service Binding 転送前に redirect または4xxで終端する。判定の一次根拠はnew URL(request.url).protocol === 'http:'とする(request.cf.tlsVersionは Cloudflare 側の実装依存であり、平文リクエストで必ず未定義になる保証がないため、補助シグナルとしてのみ用いる) - 平文終端イベントの監査ログ出力先とアラート条件(1.3。キー値・トークン値は出力しない)
MCP Worker 側
- 認証方式(
/token/verifyの返却 payload に対するuserType === 'staff'強制。トークン層のaud検証は行わない) - 許可済みの MCP 専用アクセスキー一覧(
X-MCP-Access-Key検証用。keyId と guildId を関連付け、環境別の secret として管理) path → guildIdの allowlist マップ(既定 guild のGUILD_ID、追加 guild のGUILD_ROUTES)ALLOWED_ORIGINS- unified-auth-worker への Service Binding 名
- 内部 API(VPS)の hostname、Cloudflare Access service token(
CF-Access-Client-Id/CF-Access-Client-Secret)、HMAC 署名鍵(keyId 付き・環境別)。通常の Worker 間 Service Binding とは区別し、いずれも secret として管理する compatibility_flagsにglobal_fetch_strictly_publicを必須で含める(同一ゾーン loopback で Access / WAF を迂回させないため。2.1 参照)MAX_INTERNAL_BODY_BYTES = 65_536(VPS 側と同一の共通定数。署名前サイズ検査用。3.3)observability.enabled: true(他 Worker と同様の native observability)と、log-throw-manへの Service Binding(公開 URL ではなく Worker → Worker 経路)- keyId 単位の
PREAUTH_RATE_LIMITERRate Limiting binding(/token/verify呼び出し前の遮断用。v1 はlimit: 300/period: 60)と、userId 単位のUSER_RATE_LIMITERRate Limiting binding(v1 はlimit: 30/period: 60)。いずれも環境ごとに一意なnamespace_idを割り当てる(7.3) - guild 単位の
MCP_CIRCUIT_BREAKERDurable Object binding と SQLite migration。instance 名は"{environment}:{guildId}"とし、全 guild を単一 instance へ集約しない workers_dev: false、preview_urls: false、公開 routes / custom domains なし
VPS 側
- Discord Bot token
- 対象 guildId 群
- キャッシュ TTL(既定 5 分)
- ログレベル(構造化 JSON ログの出力設定)、ログ保持日数(30 日)・容量上限・ローテーション世代数・バックアップ方針
- 監視・アラート先情報(B-03 で選定する外部監視サービスの endpoint・認証 token・heartbeat 間隔 / 有効期限・timeout・retry 設定。VPS は認証を持たない
log-throw-man公開 URL を直接使わない)。本番環境では未設定を起動時エラーとする - 内部 API の受け入れ設定: Access チームドメインと期待 audience(
Cf-Access-Jwt-Assertion検証用)、Access JWT 検証モード(required既定 /disabledは 7.6 の代替構成のみ)、受け付ける HMAC 署名鍵(現行世代 N と N-1)、MAX_INTERNAL_BODY_BYTES = 65_536(v1 固定)、tool / health の listen ポート、deadline horizon / clock skew / nonce 保持期限、cloudflared の tunnel token
4. インターフェース仕様
以下の各ツールにおける「可視チャンネル集合の解決」「Discord Search API 呼び出し」「文脈取得」の各処理は、 Workers(
transport/+auth/)での認証完了後、内部 API を介して VPS 側(authz//cache//provider/)が実行する。 ツールの入出力契約自体は Workers/VPS 分離前と変わらない。
4.1 MCP ツール一覧
| ツール | 目的 | 入力 | 出力 |
|---|---|---|---|
search_messages | ギルド内メッセージ検索 | content, author_id, has, mentions, sort_by, sort_order, before, after, limit, channel_id | メッセージ一覧、カーソル、件数 |
list_accessible_channels | 可視チャンネル一覧 | なし | channel_id、名前、種別 |
get_message_context | メッセージ前後文脈取得 | channel_id, message_id | 前後メッセージ、対象位置 |
4.2 search_messages
入力
content: 検索文字列author_id: 投稿者指定has: 添付条件mentions: メンション条件sort_by:timestampまたはrelevancesort_order: 昇順 / 降順before/after: ISO 日時limit: 返却件数channel_id: 任意の絞り込み対象
処理
- 認証済み userId / guildId を取得する
- 可視チャンネル集合を解決する
- 指定 channel_id がある場合は可視集合と交差する
- Discord Search API を呼び出す
- 取得結果を allowlist で再フィルタする
- サニタイズした結果だけを返す
実装上の注意
before/afterは Discord API に直接ないため、snowflake へ変換する- ページあたりの取得件数は Discord API の制限に合わせる
offset上限を超えないように集約する- 大量件数はページングで返す
エラー
- 認証失敗:
401 - 権限解決失敗: fail-closed
- 202 未インデックス: バックオフ後に再試行
- 429 / 予算超過: バックオフ後に再試行、枯渇時は失敗
4.3 list_accessible_channels
入力
- なし
処理
- userId / guildId を検証する
- guild のメンバーであることを確認する
- 可視チャンネル一覧を返す
- 権限解決に失敗した場合は空またはエラーを返す
注意
- これは UX 補助であり、セキュリティゲートではない
- 権限不明時に全チャンネルを列挙しない
4.4 get_message_context
入力
channel_idmessage_id
処理
- channel_id が可視集合に含まれるか再検証する
- 含まれない場合は fail-closed
- 該当メッセージ前後の文脈だけを返す
注意
- グローバルなメッセージ参照は行わない
- 非可視 channel_id からの文脈取得は不可
4.5 Discord Search API 仕様
- エンドポイントは
GET /guilds/{guild.id}/messages/search content、channel_id、author_id、mentions、has、sort_by、sort_order、limit、offsetを利用するbefore/afterは存在しないため snowflake ベースで表現する- 未インデックス時は
HTTP 202とretry_afterを返す channel_idは最大 500 件単位でバッチ化する。バッチは逐次実行し、deadlineAt接近時は早期に打ち切る(3.7 provider)
4.6 AI Agent Host 接続契約
呼び出し側(AI Agent Host)は特定実装に縛らず、標準的な MCP クライアント設定で接続できる契約に固定する。具体的なホスト実装(どのエージェントを使うか)は別 Issue とする。
接続契約
- エンドポイント URL は guild ごとに 1 つ(既定 guild は
/mcp、追加 guild は/mcp/g/{slug})とし、scheme は必ずhttps://とする。http://URL を「送信前に」拒否することがホスト側の責務であり、サーバー側の redirect /4xxを credential 保護として当てにしない(redirect が返る時点でAuthorizationとX-MCP-Access-Keyは既に平文送信済み。1.3)- リリース条件: 実際に利用する全 AI Agent Host 設定について、登録済み endpoint URL が
https://であることを設定の実物で検証する。ホスト実装に URL scheme の事前検証機能がある場合はそれも有効化し、http://を設定した際にリクエストが送出されないことを実機で確認する。手順の定義・周知だけでは合格としない
- リリース条件: 実際に利用する全 AI Agent Host 設定について、登録済み endpoint URL が
- 必須ヘッダー:
Authorization: Bearer <スタッフ JWT>とX-MCP-Access-Key: <その guild に関連付けられた MCP 専用アクセスキー> - guildId はツール引数・query・header で渡さない(URL で選択し、サーバー側 allowlist で解決する)
- 1 セッション(1 ホスト設定)に登録する guild は 1 つに限定する(MUST)。これを prompt injection による guild 横断を防ぐ主要な対策とする。guild とアクセスキーの関連付け(5.1.1)はキーの流用を防ぐが、横断自体は防げない。複数 guild を扱う場合はセッション/ホスト設定を分けるか、ホスト側 capability gate で利用可能な guild を 1 つに限定する
認証情報のライフサイクル(手動設定+定期リフレッシュ)
- スタッフ JWT について、現行の
/api/token(login-api)は新しいトークンを発行せず、ブラウザの既存 Cookie をそのまま返す。しかもsoshosai_customer_jwt(guest)をsoshosai_staff_jwtより優先するため、両 Cookie を持つ利用者が/api/tokenを使うと guest トークンが返り MCP では 401(userType !== 'staff')になる。したがって:- 取得手順: 次のいずれかで
soshosai_staff_jwtを確実に取得する。(a) customer Cookie が存在しない状態(別プロファイル / プライベートウィンドウで Discord スタッフ認証のみを行う)で/api/tokenを呼ぶ、または (b) ブラウザの Cookie ストアからsoshosai_staff_jwtの値を直接取得する - 取得したトークンの残存有効期間は発行時からの 3 日の残りであり、取得時点で 3 日にリセットされるわけではない
- 更新は「再認証して新しいスタッフトークンを得る」ことであり、
/api/tokenの再呼び出しでは延長されない。expの十分手前で再認証する運用とする - 誤設定時の挙動(MCP 側の回避策): 誤ってゲストトークンを設定した場合、MCP Worker は汎用 401 ではなく識別可能なエラー(
staff_token_required)で拒否し、スタッフトークンを設定し直すべき旨を返す(3.2)。これにより「なぜか 401 になる」状態を自己診断可能にする - 残存制約: MCP 側の対策は誤りの検出・診断までであり、
/api/tokenがゲストトークンを優先して返す挙動そのものは login-api 側の実装に属するため解消できない。スタッフトークンの取得は上記の手動手順に依存する
- 取得手順: 次のいずれかで
- MCP 専用アクセスキーは、リポジトリにコミットせず、管理者が統制された経路で認可済み運用者へ配布する。guild ごとに別キーとし、設定ファイルの権限を絞り、ローテーションは 6.4 秘密情報 の keyId 世代方式に従う
5. 認証・権限制御
5.1 認証フロー
- MCP リクエストを Cloudflare Workers(
gateway-workerからの pass-through)で受信する - request path(
/mcp//mcp/g/{slug})を allowlist で解決し、対象 guildId をサーバー側バインドとして決定する(未定義 path は404) X-MCP-Access-Keyを許可キーリストと constant-time で照合し、キーに関連付けられた guildId が 2. で解決した guildId と一致することも確認する(欠如・不一致・guild 不一致は401で拒否し、/token/verifyを呼ばない)PREAUTH_RATE_LIMITERを keyId 単位で評価する(7.3)。超過時は429で終端し、/token/verifyを呼ばない- Bearer トークンを
unified-auth-workerの/token/verifyで検証する(現行 I/F・Service Binding 経由。毎リクエスト実行し BAN・exp・署名を authority の現在値で判定する) payload.userType === 'staff'を強制し、ゲストを含む対象外種別を拒否する。payload を zod 検証しsubが Discord snowflake 形式であることを確認するsubを userId とし、principal{userId, guildId}を構成する(guildId は 2. のサーバー側バインドのみ。トークン claim・tool 引数・query・header からは取得しない)USER_RATE_LIMITERを userId 単位で評価し、続けて当該 guild のMCP_CIRCUIT_BREAKERの状態を確認する(openなら503 CIRCUIT_OPEN)- principal を内部 API 経由(B-01 の HMAC envelope で完全性を保護)で VPS 側のツール層(
authz//provider/)へ渡す。Bearer トークン・MCP 専用アクセスキーは VPS へ送らない - 失敗時は拒否する(Workers 側で完結し、VPS へは到達させない)
5.1.1 principal 種別強制と guild バインド
現行 /token/verify はスタッフ JWT とゲスト JWT(LINE 認証・sub=LINE User ID)を同一 JWT_SECRET で検証し、いずれも valid を返すため、トークン層の aud scoping は issuer 側対応なしには実現できない。そこで B-02 は auth-worker を変更せず、次の補完的な対策で確定する。
- MCP 専用アクセスキー(宛先スコープ+guild スコープの強制): staff JWT は全サービス共通で、トークン自体からは「MCP 宛て」であることを判別できない。そこで
/mcpは MCP Worker の config で管理する専用アクセスキー(X-MCP-Access-Key、keyId 世代管理・ローテ可能)を第二の認証情報として必須化し、staff JWT 単体(他サービス文脈で取得されたものを含む)では呼び出せないようにする。キーは利用者共有の宛先証明であり、本人性・BAN・鮮度は従来どおり staff JWT +/token/verifyが担う - guild 別キーによる資格情報の分離: アクセスキーは keyId と guildId をサーバー側で関連付け、request path から解決した guildId と一致するキーでなければ拒否する。これが防ぐのは「guild A 用のキーで guild B の route を呼ぶ」という資格情報の流用であり、キー漏洩時の影響を当該 guild に限定する
- guild 横断の防止は「1 セッション = 1 guild」を主要な対策とする(MUST): 上記のキーと guild の関連付けだけでは prompt injection による guild 横断を防げない。AI Agent Host に guild B のエントリが正規に登録されていれば、ホストは B の URL と B のキーを用いるため、サーバー側から見て完全に正当なリクエストとなり、「利用者の意図によるものか、注入された指示によるものか」を区別できないためである。したがって横断防止は次のいずれかで担保する:
- 1 セッション(1 ホスト設定)に登録する guild を 1 つに限定する(既定。運用手順で強制し、複数 guild を扱う場合はセッション/ホスト設定自体を分ける)
- 複数 guild を 1 ホストに登録せざるをえない場合は、ホスト側の capability gate(セッション中に利用可能な MCP サーバーを 1 guild へ限定する、ツール選択を人間が承認する等)を設ける
- 残存リスク: この対策はサーバー側で強制できず、ホスト設定と運用に依存する。サーバーは「登録済み guild への正当なリクエスト」を拒否できないため、複数 guild を 1 セッションに登録した時点で横断は成立しうる。requirements の R-10 に記録する
- principal 種別強制: MCP Worker は
/token/verifyの返却 payload に対しuserType === 'staff'を強制し、ゲストを含む対象外を fail-closed で拒否する。auth adapter の返却型はuserType: 'staff'に絞り込まれた型として扱い、valid だけで認証成功と見なさない - server-side guild バインド: 対象 guildId は request path を allowlist 解決したサーバー側の値のみとする。トークン claim・tool 引数・query・header 由来の guildId は受理しない。理由は、VPS の membership 強制は「非所属 guild への越権」は防ぐが、利用者が正当に所属する“開示意図のない guild”への prompt injection 由来の横断は防げないため、guild 選択を LLM の攻撃面(tool 引数)に置かず、人間が設定する route/config レベルに限定する
- マルチギルド: guild ごとに path を分ける(既定
/mcp、追加/mcp/g/{slug})。gateway-workerは TLS guard 通過後の/mcpprefix を pass-through で転送し(route 追加と転送前 TLS 判定のみで、guild 解決は行わない)、MCP Worker がpath → guildIdの allowlist マップ(env)で解決する。AI Agent Host 側は guild ごとに MCP サーバー設定エントリ 1 つ(URL が異なる)で表現する。allowlist 外は404 - BAN の最新状態を確認する: 各ツール呼び出しで
/token/verifyを実行するため、BAN・exp・署名は常に認証基盤の最新状態を反映する(トークンを独自発行して自己検証する方式は BAN の反映を遅らせるため採用しない)。検証結果を Worker 側でメモ化キャッシュしてはならない(短 TTL であっても BAN 反映が遅延し、requirements §13 の「BAN 適用後の次のツール呼び出しが拒否される」を満たさなくなる。連続ツール呼び出しの重複検証を削減したい場合は、キャッシュ導入ではなく受け入れ条件の変更を伴う明示的な意思決定として扱う) - VPS 側の権限制御: VPS は principal の guild membership と可視チャンネルを確認し、非メンバーは
FORBIDDENで拒否する - 残存リスク: base スタッフ JWT は全サービス共通の広い権限を持つ認証情報であり、AI Agent Host の設定に保存される(MCP 専用アクセスキーにより JWT 単体で
/mcpは呼べないが、JWT 漏洩時に他サービスへ及ぶ影響は残る)。また MCP 専用アクセスキーは利用者間で共有される固定 secret であり、利用者ごとの認証情報ではない。issuer 側でaud/ スコープ付きトークンが導入可能になった時点で B-02 を再検討する(「9. 未確定事項」の将来拡張)
5.2 権限解決(配置: VPS)
guild.channels.fetch()で通常チャンネルを取得するguild.channels.fetchActiveThreads()でアクティブスレッドを含めるchannel.permissionsFor(member)で最終権限を判定するVIEW_CHANNELとREAD_MESSAGE_HISTORYを両方満たす場合のみ可視とする
5.3 スレッド処理(配置: VPS)
スレッドは親チャンネルだけで判定しない。
- Public Thread は親と権限を合わせて判定する
- Private Thread はメンバーシップを確認する
- Forum 投稿はスレッドとして扱う
- アーカイブ済みスレッドは事前列挙しない
- Search 結果で未知の
channel_idが来た場合のみオンデマンド検証する - 検証できない入力は fail-closed で拒否する
5.4 キャッシュ無効化(配置: VPS)
無効化対象イベント
GUILD_ROLE_UPDATEGUILD_ROLE_DELETECHANNEL_UPDATECHANNEL_DELETETHREAD_MEMBERS_UPDATEGUILD_MEMBER_UPDATEGUILD_MEMBER_REMOVE- BAN 系イベント
無効化方針
- 権限に影響しない変更では無効化しない
- ロール系は
VIEW_CHANNEL/READ_MESSAGE_HISTORY/ position に影響する変更時のみ全体 fan-out 無効化する - チャンネル系は
permissionOverwritesに影響する変更時のみ全体 fan-out 無効化する THREAD_MEMBERS_UPDATEは追加・除名された対象ユーザーだけを無効化するTHREAD_MEMBER_UPDATEは Bot 自身にしか届かないため、無効化トリガには使わないTHREAD_UPDATEとTHREAD_DELETEは単独では無効化トリガにしない- ユーザー個別イベントは該当ユーザーだけを無効化する
- TTL は Gateway イベントを受信できなかった場合のフォールバック処理として残す
- Gateway の再接続時は該当 shard が担当する guild を全 flush し、切断中に受信できなかった権限変更によって旧権限情報が残らないようにする
キャッシュによる権限反映のタイムラグ
Gateway イベントの伝播には時間差がある。一方、Discord へ毎回問い合わせると p95 2 秒の応答目標を満たせないため、権限変更後も旧権限情報が短時間残ることを許容する。この時間は次の方法で上限を定める。
- 受信イベントによる即時無効化を基本とする
- TTL はイベントを受信できなかった場合のフォールバック処理とし、既定 5 分・最大 15 分とする。config で調整可能だが、最大値を超える設定は起動時の zod 検証で拒否する(旧権限情報が参照され続ける最長期間を検証可能にするため)
- Gateway の再接続開始(
shardReconnecting)を検知した時点で該当 shard のreadyを落とし、対象 guild を担当する shard が ready でない間は tool リクエストをGATEWAY_UNAVAILABLE(retryable: true)で拒否する。再同期(shardResume/shardReady)が完了するまで検索を実行せず、担当 guild を全 flush してから受付を再開する。shardDisconnectは復帰不能としてプロセスを終了し、close code が自前 allowlist(4004 / 4010 / 4011 / 4012 / 4013 / 4014)に一致する場合は自動再起動を抑止する(v14.26.3 では常にこちら。恒久停止の検知は B-03 の認証済み heartbeat 欠損を一次経路、Workers 側アラートを補助経路とする。7.2 監視)。allowlist 外の場合(到達しないバージョンアップ互換の分岐)のみ上限付き・指数バックオフ付きで再起動する。shardError単体では readiness を変更しない(詳細は 3.8 gateway) - 再開可能な Invalid Session(OP 9,
d=true)は例外として Discord の replay を信頼する。discord.js v14.26.3 の publicClientAPI には resume 開始前に readiness を落とせる実ランタイムイベントがないため、shardResume受信時に担当 guild を全 flush する。resume 開始から完了まで fail-closed にできない残存リスクは requirements の R-11 として明示的に受容し、実装でinvalidatedを購読して統制済みとみなさない shardReadyのunavailableGuildsと、接続中のguildUnavailable/guildAvailableを guild 単位の readiness に反映する。unavailable な対象 guild は shard が ready でもGATEWAY_UNAVAILABLEで拒否し、guildAvailable後に当該 guild を全 flush してから受付を再開する。他の ready な guild の受付は継続し、health/readyは一部正常時に200 degraded、全 guild 停止時のみ503 not_readyとする
これにより、shardReconnecting で検知できる Gateway 切断中は該当 shard の検索を拒否する。切断中に権限が剥奪されても、古いキャッシュから結果を返し続けることはない。正常接続中に旧権限情報が参照される時間はイベントの伝播時間に限定され、イベントを受信できない場合でも TTL 以内に失効する。再開可能な Invalid Session だけは R-11 の残存リスクとして明示的に受容する。この設計は requirements の R-02 / R-11 に記録し、再接続・再開・復帰不能な切断・guild unavailable の結合テストを必須ケースとする。
5.5 ポストフィルタ(配置: VPS)
取得結果は必ず allowlist 方式で再検査する。
- 可視集合に含まれる channel_id のみ通す
- 未知の channel_id は結果から除外する
- イベントを受信できない場合は検索結果の欠落を許容し、権限外の情報は返さない
6. データ・ログ
6.1 保持データ
ローカルに永続化しない。
- Discord メッセージ本文
- 検索インデックス
保持するのは次の情報だけとする。
- 権限キャッシュ(VPS プロセス内 in-memory)
- 監査ログ(Workers native observability / VPS 構造化ログ)
- 設定値
- サーキットブレーカー状態(
MCP_CIRCUIT_BREAKERDurable Object の SQLite storage) - Gateway Bot の restart budget(VPS ローカルの状態ファイル)
いずれも Discord メッセージ本文・検索クエリ文字列・検索結果は含めない。永続化する 2 件は次の仕様に従う。
MCP_CIRCUIT_BREAKER(Durable Object / SQLite)
- instance は
"{environment}:{guildId}"単位。保存するのは次の項目だけとするstate(closed/open/half-open)、stateChangedAt、openUntil- 直近 60 秒 window の呼び出し記録(
observedAt、outcomeはsuccess/upstream_error/timeoutの 3 値のみ) half-openの probe lease(probeId、leaseExpiresAt)
- userId・検索クエリ・channelId・メッセージ本文は保存しない(guildId は instance 名で表現し、行データとしては持たない)
- pruning: 呼び出し記録は書き込みのたびに
observedAt < now - 60sの行を削除する。加えて Durable Object の alarm で、probe lease の期限到来時に lease をクリアし、window 外の行を掃除する - 削除条件:
closedかつ window 内の記録が 0 件の状態が 24 時間続いた instance は、alarm で全行を削除してdeleteAll()により instance 自体を破棄する(guild 追加・削除に伴う孤児 instance を残さない)
restart budget ファイル(VPS ローカル)
- 保存するのは
shardDisconnect起因の異常終了時刻の配列と最終更新時刻だけとする(close code と shardId は構造化ログ側に残し、本ファイルには持たない) - 起動時に 24 時間 window 外の記録を pruning する。手動是正後は運用手順で明示的にファイルを削除する(3.8)
- 状態ファイルへの書き込みが IO エラーで失敗した場合は budget 枯渇と同じ扱いとし、終了コード
78で終了する(fail-closed。書き込み失敗を無視すると budget の保護が無効化されるため。3.8)
6.2 主要エンティティ
| エンティティ | 概要 | 主な属性 |
|---|---|---|
| PermissionCacheEntry | ユーザー単位の可視チャンネル集合 | guildId, userId, visibleChannelIds, computedAt, expiresAt |
| AuditLogEntry | 検索・権限判定の監査記録 | requestId, userId, guildId, origin, grantedScope, excludedChannelIds, resultCount |
| CircuitBreakerState | guild 単位のサーキットブレーカー状態(Durable Object) | state, stateChangedAt, openUntil, probeId, leaseExpiresAt, observations[] |
| SearchResultEnvelope | MCP の返却形式 | channelName, author, excerpt, timestamp, link, cursor |
6.3 監査ログ
- userId、guildId、付与スコープ、件数、除外 channel_id を残す
- Workers で生成した requestId を内部 API の request / response と VPS 側ログへ伝搬し、両プロセスの監査記録を相関可能にする
- Bearer トークンは残さない
- メッセージ本文は残さない
- 権限逸脱試行の兆候を検知できる粒度で残す
- 保存先は既存資源で二系統とし、requestId で相関する(v1 では統一監査ストアを新設しない。コスト要件に合致):
- Workers 側: Cloudflare Workers native observability(
observability.enabled。他 Worker と同様)へ構造化ログとして出力する - VPS 側: 構造化 JSON ログ(pino 等)を PM2 でローテートしつつ保持する。保持日数・容量上限・VPS 障害時のバックアップ方針を config / 運用手順で定める
- Workers 側: Cloudflare Workers native observability(
- アラート集約は認証済み経路のみで行う(既存
log-throw-manは Discord webhook へ転送するが、アプリ層認証・レート制限を持たず、公開workers.devが有効なため、URL を直接呼び出す設計では第三者に Discord アラートを乱発されるおそれがある):- 前提条件(
log-throw-man側の必須ハードニング): Service Binding を追加しても、log-throw-manの未認証な公開エンドポイントが残っていれば第三者による Discord スパム経路は塞がらない。log-throw-manにworkers_dev: falseとpreview_urls: falseを設定し、公開 routes を持たせないことを本連携の前提とする(現状は未設定のため、既定でworkers.devが有効。この設定変更はlog-throw-manパッケージ側の対応であり本仕様のスコープ外だが、未対応のまま本連携を有効化しない) - Workers 側は
log-throw-manを Service Binding 経由(Worker → Worker の認証済み経路)で呼ぶ。公開 URL は使わない - VPS 側は認証を持たない
log-throw-manの公開 URL を直接呼び出さない。VPS から状態を送信する外部監視サービスは B-03 で選定する。endpoint・認証 token・送信データ形式・heartbeat 間隔 / 有効期限・timeout・retry / backoff・rate limit・環境分離・否定系テストを確定するまで、外部への heartbeat / アラート送信を有効化しない。選定前に許可するのは開発・pre-production の PM2 ローカル監視と VPS 構造化ログまでとし、Gateway Bot の本番リリースは行わない - 送信データ仕様:
log-throw-manの既存 handler はerrorStack/errorMessage/requestPath/workerNameの4フィールドのみを読み、通知本文にはerrorStack || errorMessageの一方だけを採用する。相関情報(requestId・event 種別・guildId)を失わないよう、Workers 側アダプタはerrorStackを通知本文の基準フィールドとし、先頭を[requestId=<id>][event=<type>]、後続を allowlist 済みのエラー分類コード・例外クラス名・解析済みスタックフレームから組み立てる。raw のerror.messageと stack 先頭行はコピーせず、errorMessageは送信しない。workerNameには発生プロセス、requestPathには guildId を設定する。logger 側に versioned な入力 schema を導入して構造化フィールドを増やすのは将来拡張とする - 秘匿情報のマスクと上限(必須):
log-throw-manの転送先は Discord チャンネルであり、監査ログ本体より閲覧範囲が広い。上記4フィールドのerrorStack/errorMessageに例外の raw stack や message をそのまま載せると、6.3 の「Bearer トークンは残さない」「メッセージ本文は残さない」がアラート経路で破られる。Workers 側アダプタは送信前に次を行う:- フィールド allowlist: 送出してよいのは requestId・event 種別・guildId・エラー分類コード・発生プロセス名・発生時刻・例外クラス名・解析済みスタックフレーム(ファイル / 関数 / 行)に限る。raw の
error.message/ stack 先頭行、request body、header、Cookie、クエリ文字列、検索クエリ文字列、Discord メッセージ本文は載せない - マスク順序: allowlist 済みフィールドから
errorStackを組み立てた後、Workers 側アダプタが参照できる空でない設定済み秘密値(MCP 専用アクセスキー、内部 API の HMAC 鍵、Access service token secret)を文字列の長さやラベルの有無にかかわらず完全一致で[REDACTED]へ置換する。次に既知の秘密パターン(eyJ始まりの JWT、Bearer以降の値、X-MCP-Access-Key、X-Internal-Signature、Access service token、tunnel token、32 文字以上の base64url / hex 連続列)を同様に置換する。ただし UUID 形式(8-4-4-4-12のハイフン付き hex)はパターンマスクの対象から除外する(requestId 等の相関識別子が[REDACTED]に置換されると、二系統のログ間で相関が取れなくなるため)。完全一致マスクとパターンマスクはアダプタの単体テスト対象とする requestPath/workerNameも同じ規律の対象とする:log-throw-manはこの2フィールドを添付ファイルではなく Discord メッセージ本文へバッククォート内で直接展開するため、errorStackより露出が大きい。値は固定の候補に限定し(requestPathは対象 guildId のみ、workerNameは MCP Worker のプロセス名のみ)、request path・URL・例外由来の文字列を代入してはならない。両フィールドも送出前に完全一致マスクとパターンマスクを通し、バッククォート・改行を含む値は拒否または除去する(アラートチャンネルへの markdown 崩し・混入を防ぐ)- サイズ上限: 完全一致マスクとパターンマスクの後、
errorStackを 1,500 文字で切り詰め、超過分は破棄する(全文は requestId で二系統のログから引く) - 重複抑止(request-local): MCP Worker の 1 invocation(1 tool request)内では request-local の送出済みフラグで
log-throw-manへの送出試行を 1 回に集約し、同一 requestId から複数の例外ハンドラが通知を生成しても Service Binding の呼び出しを 1 回にする。追加の永続ストレージは設けず、異なる invocation 間で同じ requestId が再利用された場合の重複抑止は v1 の保証対象外とする
- フィールド allowlist: 送出してよいのは requestId・event 種別・guildId・エラー分類コード・発生プロセス名・発生時刻・例外クラス名・解析済みスタックフレーム(ファイル / 関数 / 行)に限る。raw の
- 前提条件(
- 保持期間は 30 日を目標値として統一する(事後調査に必要な期間として確定)。ただし系統ごとに達成度が異なるため、次を前提とする:
- VPS 側(検索実行・権限判定=監査の本体): 構造化ログを 30 日保持する。ローテーション世代数・容量上限をこの期間に合わせて設定し、ディスク逼迫時も 30 日を割らないよう監視する
- Workers 側(認証失敗・拒否イベント): Cloudflare Workers Logs の保持上限(Free 3 日 / 有料でも 7 日程度)は 30 日に満たない。この差分は追加基盤を増やさない方針(コスト要件)のもとで受容する
- Discord 通知は保持期間の補償に数えない。
log-throw-man経由の通知は配送保証も保持保証もない best-effort のアラート手段であり、監査記録として依拠しない(欠落・削除・チャンネル移行で失われうる) - 残存制約: 発見が遅れたインシデント(保持上限超)では、Workers 側の詳細ログは失われ、VPS 側ログ(30 日)のみで調査することになる。Workers 側についても 30 日の証跡が必要と判断された時点で、Logpush → R2 等の永続化を導入する(将来拡張)
- フル監査証跡の統一クエリが必要になった場合は、
analytics-worker/ D1 パターンで audit イベントを 1 箇所へ集約する audit-worker を後付けする(将来拡張)
6.4 秘密情報
- Bot token は VPS 側の env / secret manager でのみ管理し、Cloudflare Workers 側には配置しない
- unified-auth-worker との連携情報は Service Binding に閉じ、Workers の env に Bot token 等の Discord 側秘密情報を持たない
- 内部 API(Workers ↔ VPS)の認証情報 — Cloudflare Access service token、HMAC 署名鍵(keyId 付き・環境別)、cloudflared の tunnel token — も secret manager で管理する。HMAC 鍵は keyId で複数世代(VPS は N / N-1 を受け付け、Workers は最新で署名)を許容し、「VPS の許可済み鍵一覧に新鍵を追加 → Workers の署名鍵を切り替え → 観測期間後に旧鍵を削除」の手順でローテートする。Access service token は既定 1 年で失効するため失効日をカレンダー・アラート化し、rotate → Workers secret 更新 → 確認 → 旧 secret 失効の手順を runbook 化する
- MCP 専用アクセスキー(
X-MCP-Access-Key)も secret manager で管理し、keyId 世代でローテートする(許可キーリストに新キーを追加 → AI Agent Host 側の設定を更新 → 観測期間後に旧キーを削除。漏洩が疑われる場合は許可キーリストから即時削除する) - リポジトリにコミットしない
- ログに平文で出さない
7. 運用設計
7.1 デプロイ
- 公開入口:
gateway-workerにMCP_WORKERService Binding と/mcpprefix route(/mcpおよび/mcp/g/{slug})を追加し、外部公開 route はgateway-workerのみに設定する。zone の Always Use HTTPS を有効化し、gateway の転送前 TLS 判定も有効化する。/mcpprefix のOPTIONSは汎用 handler で終端させない - MCP 本体: Cloudflare Workers へ
workers_dev: false、preview_urls: false、公開 routes / custom domains なしでデプロイし、gateway-workerからの Service Binding でのみ呼び出す - Discord Gateway Bot: VPS 常駐 Node プロセスとして運用し、プロセスマネージャは PM2 5.1+ に固定する(systemd は使用しない)。3.8 gateway の復旧方針に従い、
exec_mode: "fork"、instances: 1、stop_exit_codes: [78]、min_uptime: "60s"、max_restarts: 5、exp_backoff_restart_delay: 1000を ecosystem 設定へ明記する。cluster mode は使用しない(Gateway 接続・readiness・権限キャッシュがプロセスローカルなため。3.4)。max_restartsはmin_uptime未満の連続異常終了に対する上限であり全稼働期間の通算上限ではないため、長間隔で繰り返す障害はアプリ側の永続 restart budget(24 時間 / 10 回)で打ち切る - 本番リリース gate: B-03 の外部監視サービス・認証済み heartbeat・欠損アラートを確定して有効化し、停止時の通知試験に合格するまで Gateway Bot を本番へデプロイしない
- 両者は別デプロイ単位とし、3.4.4 互換性とデプロイ順序を守る範囲で独立してリリース可能にする
wrangler.jsoncの Worker 設定(MCP 本体)と VPS の PM2 設定(Gateway Bot)は独立して管理する
7.2 監視
Workers 側
gateway-workerから MCP Worker への Service Binding 転送エラー率・レイテンシ- 認証失敗率・BAN 検知率
/mcpエンドポイントのエラー率・レイテンシ- 内部 API(VPS)呼び出しのエラー率・レイテンシ
VPS 側
- Gateway 接続状態
- Search API のエラー率
- 429 発生率
- 上流リクエスト予算の消費
- キャッシュヒット率
- 権限解決失敗率
- 内部 API の受信状況
アラート集約
- Workers 側は
log-throw-manを Service Binding 経由で呼ぶ。log-throw-man側にworkers_dev: false/preview_urls: falseが設定されていることを連携の前提とし、既存 logger の4フィールド schema に合わせてアダプタで変換する - Gateway Bot の恒久停止(
shardDisconnectによる exit 78・自動再起動抑止。3.8 gateway)は、B-03 で確定する VPS → 外部監視サービスの認証済み heartbeat 欠損を一次経路として検知する。実リクエストの有無に依存せず検知できることを本番リリース条件とする。Workers 側の内部 API 到達不可率・GATEWAY_UNAVAILABLE率アラートは、トラフィックがある場合の補助経路として併用する - heartbeat / Workers アラートには event 種別を含める。requestId が存在する Workers アラートは、Workers native observability と VPS 構造化ログの close code / shardId を requestId で突合する。heartbeat 欠損には requestId がないため、環境・プロセス名・最終 heartbeat 時刻で突合する。通知は best-effort であり監査記録の代替にはしない(詳細は 6.3 監査ログ)
7.3 受信制御
外部利用者からの過剰な呼び出しを抑止するため、受信側にも明示的な制御を入れる。Cloudflare Workers 側に主要な受信制御を置くが、遮断段は「認証前」と「認証後」に分け、それぞれ何を守るのかを分けて定義する。
認証前(/token/verify 到達より前)
- userId は
/token/verifyの結果からしか得られないため、userId 単位のクォータでは/token/verify自体への呼び出し洪水を防げない。未認証リクエストが unified-auth-worker への Service Binding 呼び出しへそのまま増幅されないよう、MCP 専用アクセスキーの keyId(照合成功後・/token/verify呼び出し前)を key とするPREAUTH_RATE_LIMITERを先に評価する(v1 は 300 calls / 60 seconds)。アクセスキーが欠如・不一致の request は401で即時終了するため、この段より前に到達コストは発生しない - 認証前段の超過は
429で終端し、/token/verifyを呼ばない
認証後(userId 確定後)
- userId 単位の遮断は
USER_RATE_LIMITERRate Limiting binding のlimit({ key: userId })で実施し、v1 は 30 calls / 60 seconds とする。このカウンタは Cloudflare location ごとで許容的・結果整合的なため、厳密な全体上限・課金・認可の根拠には使用しない - 1 ツール呼び出しあたりの上流リクエスト総数に上限を設ける(VPS 側の
provider/で計測)
応答形式
/mcpは MCP Streamable HTTP エンドポイントであるため、受信制御による拒否は HTTP status と JSON-RPC error の双方で表現する。HTTP は429(レート超過)/503(CIRCUIT_OPEN)とし、body には対応する JSON-RPC error object(error.data.codeにRATE_LIMITED/CIRCUIT_OPEN、error.data.retryable)を載せる。429/503にはRetry-After(秒)を必ず付与する- 上限超過時は部分成功へ倒さず、拒否か短い再試行案内を返す
サーキットブレーカー
- 厳密なサーキットブレーカー状態は
MCP_CIRCUIT_BREAKERDurable Object の SQLite storage を正本とする。全体を単一 instance に集約せず、"{environment}:{guildId}"を決定的な名前として guild 単位に分割し、当該 guild の全 user / isolate からの状態遷移を直列化する - 60 秒 rolling window で最低 10 calls を観測後、Discord 上流エラーまたは timeout が 50% 以上なら 30 秒間
openとする。open中は新規検索を503 CIRCUIT_OPEN(retryable: true)で遮断する half-openの probe は期限付き lease で発行する。openの 30 秒経過後、最初に到達した 1 request にのみ{ probeId, leaseExpiresAt }を発行してhalf-openへ遷移させ、他の request はCIRCUIT_OPENのまま遮断する。lease の有効期間は上流 deadline に合わせて 10 秒とし、Durable Object の alarm で期限を監視する。- probe の成功 / 失敗報告は
probeIdを伴う場合にのみ受理する。lease 期限後に届いた報告(stale completion)は状態遷移に反映せず破棄する(Worker の中断・isolate 破棄で報告が失われた後に遅れて到達しても、次の probe の結果を上書きしない) - lease が結果報告なしに期限切れした場合は失敗とみなし、再び 30 秒
openとする。これにより Worker が probe 実行中に中断されてもhalf-openが永久占有されない - 成功報告を受けたら
closedへ遷移し、window カウンタをリセットする
- probe の成功 / 失敗報告は
- Durable Object へ到達できない場合は遮断せず、isolate ローカルのフォールバック制御(当該 isolate 内の直近 60 秒・エラー率 50% の簡易カウンタ)へ縮退する。サーキットブレーカーは Discord 上流を保護するための可用性統制であり、認証・認可の境界ではない。ここを fail-closed にすると Durable Object 側の一時障害・instance migration がそのまま全 guild の検索全停止に直結するため、1.2 実装原則の fail-closed(認証失敗・権限不明・上流障害)とは切り離して扱う。縮退の発生はメトリクス化してアラートする(権限判定・可視性判定は本縮退の対象外であり、従来どおり fail-closed を維持する)
7.4 障害時の扱い
- 認証失敗: 拒否(Workers 側で完結)
- 内部 API(Workers ↔ VPS)到達不可・タイムアウト: fail-closed で拒否する
- 権限解決失敗: fail-closed
- 上流未インデックス: バックオフ後に再試行
- 429 / 予算超過: 早期遮断
- Gateway 再接続:
shardReconnectingで該当 shard をready=falseにして tool リクエストをGATEWAY_UNAVAILABLEで拒否(fail-closed)、再同期後に担当 guild を flush して受付再開。復帰不能なshardDisconnectはプロセスを終了するが、close code が自前 allowlist(4004 / 4010 / 4011 / 4012 / 4013 / 4014)に一致する場合は終了コード78で自動再起動を抑止し手動是正へ、不一致の場合(v14.26.3 では到達しないバージョンアップ互換の分岐)のみmin_uptime未満の連続異常終了に対して上限付き・指数バックオフ付きの自動再起動とする。ただし PM2 のカウンタは 60 秒以上の稼働でリセットされるため、アプリ側の永続 restart budget(24 時間 / 10 回)を併用して長間隔で繰り返す障害も打ち切る。恒久停止は B-03 の heartbeat 欠損を一次経路、Workers 側の到達不可率アラートを補助経路として検知する(7.2 監視、5.4 キャッシュによる権限反映のタイムラグ)
7.5 ヘルスチェック
gateway-workerの公開/mcpから MCP Worker までの疎通を監視する- VPS 側の
/internal/v1/health/liveと/internal/v1/health/readyは tool endpoint とは別ポート・別 listener で serve する(3.4.1 HTTP 契約 v1 参照)。開発・pre-production では B-03 確定まで PM2 のローカル監視を使用できるが、本番では B-03 の認証済み heartbeat と欠損アラートを必須とする。外部監視サービスから health endpoint を取得する必要がある場合のみ、health 専用の別 Access application + service token 経由で到達させる - 生存確認と依存確認(Gateway 接続、Discord API 到達性等)は分ける
7.6 鍵・トークン・時刻の運用
- HMAC 鍵ローテーション: keyId overlap 方式(6.4 秘密情報)。鍵漏洩疑い時は kill switch(Access application 無効化または tunnel ingress 削除で即遮断 → HMAC 鍵の全世代ローテート)を用意する
- Access service token: 失効カレンダーとローテ runbook を維持する。Access application は Service Auth decision で作成し、token なしのアクセスが
401/403(302のログイン HTML ではない)で拒否されることを監視・テストする - 平文 HTTP 到達時の緊急ローテーション:
gateway-workerが平文 HTTP の/mcpを終端した場合、その時点で当該リクエストのX-MCP-Access-Keyとスタッフ JWT は平文で送信済みである(1.3)。アラートを受けたら (1) 当該 guild の MCP 専用アクセスキーを即時ローテートし(許可キーリストから旧キーを削除)、(2) 該当利用者にスタッフ認証をやり直させて JWT を再発行させ、(3) 誤設定された AI Agent Host の endpoint URL をhttps://へ修正する、という順で対応する。key 値・token 値はアラートにも監査ログにも出さないため、対象 guild は route と keyId から特定する - NTP 同期: VPS は chrony / systemd-timesyncd で時刻同期し、drift を監視して skew/2 でアラートする
- cloudflared: systemd / PM2 で自動再起動し、Tunnel の健全性を監視する。JWKS キャッシュ障害時の手順を runbook 化する
- 同一ゾーン loopback 検証: 内部 hostname を本番と同一ゾーンに置くため、MCP Worker に
global_fetch_strictly_publiccompatibility flag を必須設定した上で(フラグなしでは同一ゾーンへのfetchがオリジン直行となり Access / WAF が実行されない)、接続前に staging で次の肯定系と否定系の両方を検証する(Blocker B-01 の接続前必須チェック)。肯定系: Worker のfetchsubrequest に対して Access が評価されCf-Access-Jwt-Assertionが VPS に届くこと。否定系: service token を付けない同一経路のリクエストが Cloudflare Access の時点で遮断され VPS に到達しないこと(肯定系だけでは「Access が実際にアクセスを制御しているか」を確認できず、フラグ設定漏れでオリジン直行していても JWT 以外は素通りしうるため、否定系の到達不能確認を必須とする)。成立しない場合は Access を外し、「Tunnel + HMAC + 内部 hostname 宛の WAF レート制限」の2層構成へ切り替える(HMAC が送信元と完全性を最終的に検証するため、セキュリティ要件は維持される)。切り替え時は VPS の Access JWT 検証モードもdisabledへ明示的に変更する(3.4 の検証手順 (1) を省略する。切り替えは runbook に従う計画的デプロイのみとし、ヘッダー欠如を検知しての実行時自動フォールバックは行わない)
8. テスト設計
8.1 テスト層
| 層 | 対象 | 方針 |
|---|---|---|
| 単体 | 可視判定、交差、エラー分類 | Discord API をモックして決定的に検証する |
| 結合 | Auth Adapter、Provider、キャッシュ無効化、内部 API(Workers ↔ VPS) | 上流・対向プロセスをスタブして連携を検証する |
| E2E | MCP ツール呼び出しから応答まで(Workers → 内部 API → VPS の全経路) | Inspector 相当で契約を確認する |
8.2 必須ケース
- ロール階層と overwrite の各組み合わせ
- Admin / owner のバイパス確認
- member 固有 overwrite
- プライベートスレッド非メンバー
- フォーラム投稿の可視/非可視
- 権限解決失敗時の fail-closed
- 空交差時に全体へフォールバックしないこと
- 古いキャッシュの無効化
- guildId 非メンバー時の拒否
- 認証否定系
- DoS 制御の発火
PREAUTH_RATE_LIMITERが keyId 単位で 300 calls / 60 seconds の超過を429で遮断し、その際/token/verifyが呼ばれないこと(未認証洪水が unified-auth-worker へ増幅されないことの回帰)USER_RATE_LIMITERが同一 Cloudflare location 内の同一 userId について 30 calls / 60 seconds の超過を429で遮断すること。location-local・許容的な binding であるため、厳密な全体カウンタとしてのテストを要求しないこと- 受信制御による拒否の応答が、HTTP status(
429/503)と JSON-RPC error(error.data.codeにRATE_LIMITED/CIRCUIT_OPEN、retryable)の双方を持ち、Retry-Afterが付与されること - 複数 isolate を模擬して同一 guild の
MCP_CIRCUIT_BREAKERDurable Object を呼び、60 秒 window・最低 10 calls・エラー率 50% の閾値でopen、30 秒後にhalf-openの単一 probe、成功でclosed、失敗で再openとなること。異なる guild は別 instance となり相互に遮断しないこと half-openprobe の lease が期限切れした場合(probe 実行中に Worker が中断された状況を模擬)、half-openが占有され続けず 30 秒openへ戻ること。lease 期限後に到達した stale な probe 結果報告が状態遷移へ反映されないことMCP_CIRCUIT_BREAKERへ到達できない場合、遮断せず isolate ローカルのフォールバック制御へ縮退し、縮退がメトリクスに記録されること(権限判定・可視性判定は縮退の対象外で fail-closed が維持されること)- 60 秒 window 外の呼び出し記録が pruning され、
closedかつ無トラフィックが 24 時間続いた instance が alarm で破棄されること。Durable Object に userId・検索クエリ・channelId・メッセージ本文が保存されていないこと - Workers 側の
CIRCUIT_OPENと VPS 側のGATEWAY_UNAVAILABLEが別 code として区別され、監査ログ・メトリクスにorigin(workers/vps)が付与されること(R-13 の補助検知が上流劣化と混同されないことの回帰) gateway-workerの/mcpが Service Binding 経由で MCP Worker へ転送され、MCP Worker に直接公開 route がないこと。gateway-workerに TLS 判定と route 転送以外のロジック(認証・guild 解決・body 解釈)が存在しないことhttp://の公開/mcpが zone の Always Use HTTPS で redirect されるか gateway の4xxで終端し、いずれの場合もMCP_WORKERService Binding、/token/verify、MCP 専用アクセスキー検証へ到達しないこと。https://の同一 request は通常の認証処理へ進むこと。判定がnew URL(request.url).protocolを一次根拠としており、request.cf.tlsVersionの有無だけに依存していないこと- 平文終端が監査ログとアラートに記録され、キー値・トークン値が出力されないこと。あわせて、実利用する全 AI Agent Host 設定の endpoint URL が
https://であることを設定の実物で検証し、http://を設定した場合にホスト側がリクエストを送出しないことを実機で確認すること(サーバー側 redirect では認証情報を保護できないため。リリース条件) - 内部 API(Workers ↔ VPS)到達不可・認証失敗時に fail-closed で拒否すること
- 内部 API の context binding 失敗・schema 不正・期限切れが fail-closed で拒否されること
- v1 では有効期限内の同一 raw body / nonce の再送を受理し、期限超過後は
REQUEST_EXPIREDで拒否すること。Phase 2 の nonce store を有効化した試験構成でのみ、期限内の再送を409 REPLAY_DETECTEDで拒否すること - MCP 専用アクセスキーの欠如・不一致時に
401で拒否され、/token/verifyが呼ばれないこと - raw body が 65,536 bytes の場合は後続の HMAC / schema 検証へ進み、65,537 bytes の場合は
Content-Length宣言あり / streaming の両方で413 PAYLOAD_TOO_LARGEとなること。超過時は HMAC / schema 検証を実行しないこと - Workers 側が 65,536 bytes を超える body を署名・送信せず、送信前に
INVALID_REQUEST(retryable: false)で失敗させること。VPS の413は Workers 側検査が漏れた場合の最終防壁であり、通常経路では到達しないこと deadlineAt超過・clock skew 外の request がREQUEST_EXPIRED(retryable: false)で拒否されること- VPS 再起動を挟んでも、期限内の正規 request が拒否されないこと(v1 は nonce store を持たないため、再起動に伴う受付停止時間が追加されていないことの回帰テスト)
- parse 済みオブジェクトを再直列化した body では HMAC 検証が失敗し、raw bytes でのみ成功すること
- MCP 専用アクセスキーの照合が長さの異なる値でも早期 return せず、
401を返すこと - guild A に関連付けられたアクセスキーで guild B の route(
/mcp/g/{slug})を呼ぶと401で拒否されること(キー流用の遮断。なお guild 横断そのものは「1 セッション = 1 guild」の運用統制で担保し、サーバー側テストの対象外) shardReconnectingで該当 shard がready=falseとなり、tool リクエストがGATEWAY_UNAVAILABLEで拒否されること。切断中に権限剥奪された利用者へ旧権限情報に基づく結果を返さず、shardResume/shardReady後に担当 guild を flush してから受付再開することshardDisconnectでは復帰イベントを待たずにプロセスが非 0 で終了すること。close code が自前 allowlist(4004 / 4010 / 4011 / 4012 / 4013 / 4014)に一致する場合は終了コード78となり、PM2 のstop_exit_codes実設定により再起動されないこと。allowlist 外の close code を起動後 60 秒未満で注入した場合(v14.26.3 では実発生しない防御的分岐)は終了コード1となり、PM2 のmin_uptime: "60s"/max_restarts: 5/exp_backoff_restart_delay: 1000の実設定により指数バックオフし、5 回の連続異常終了後に再起動が停止すること。60 秒以上の安定稼働で PM2 の不安定再起動カウンタがリセットされることも確認し、全稼働期間の通算上限と誤解しないこと。allowlist は discord.js の private 定数を import せず自前で保持していること- allowlist 外の close code を 60 秒より長い間隔(PM2 のカウンタがリセットされる条件)で繰り返し注入し、アプリ側の永続 restart budget により 24 時間 / 10 回で終了コード
78へ切り替わって再起動が打ち切られること。budget ファイルが起動時に window 外の記録を pruning し、close code・shardId を保持していないこと。budget ファイルへの書き込みが IO エラーで失敗した場合も終了コード78で終了すること(fail-closed) - VPS 内部 API が PM2 fork mode +
instances: 1で起動しており、cluster mode /instances > 1の設定では起動時に拒否されること(Gateway 接続・readiness・権限キャッシュがプロセスローカルであるため) - 通常の MCP リクエストが 0 件の状態で Gateway Bot を exit 78 により恒久停止させ、B-03 の heartbeat 有効期限超過で外部監視アラートが発火すること。トラフィックがある場合は、補助経路として Workers 側の内部 API 到達不可 /
GATEWAY_UNAVAILABLE率アラートもlog-throw-manへ発火すること shardError単体では readiness を変更せず、後続のshardReconnecting/shardDisconnectが発生した場合のみ対応する状態遷移へ進むこと- discord.js v14.26.3 の
invalidatedを readiness 統制に使用していないこと。再開可能な Invalid Session(OP 9,d=true)を模擬し、replay 後のshardResumeで担当 guild を flush すること。resume 開始前にready=falseへ落とせない点は R-11 の受容範囲と一致すること - 複数 shard 構成で、ある shard が切断中に別 shard の
shardReady/shardResumeを受けても、切断中 shard が担当する guild の受付が再開されないこと。一方で ready な shard / guild の受付は継続し、health/readyが200 degradedを返すこと。全 guild が受付不可になった場合のみ503 not_readyを返すこと shardReadyのunavailableGuildsに含まれる対象 guild と、guildUnavailableを受けた対象 guild は、担当 shard が ready でもGATEWAY_UNAVAILABLEで拒否されること。guildAvailable後に当該 guild を flush してから受付を再開すること。他に ready な guild があればhealth/readyは200 degraded、なければ503 not_readyを返すこと- キャッシュ TTL に最大値(15 分)を超える設定を与えると起動時に拒否されること
- 非スタッフ(ゲスト)JWT を設定した場合に
/mcpが401かつ識別可能なstaff_token_requiredを返し、汎用の認証失敗と区別できること(監査ログにreason: non_staff_principalが残り、トークン値は残らないこと) log-throw-manへ stack を含む例外を渡しても、採用されるerrorStackの先頭に requestId / event 種別が残り、後続が allowlist 済みのエラー分類・例外クラス・解析済みスタックフレームだけで構成されること。raw のerror.message/ stack 先頭行とerrorMessageフィールドが送出されないこと- MCP 専用アクセスキーを 32 文字未満の任意文字列として設定し、ラベルを伴わず例外データへ埋め込んでも、設定済み秘密値の完全一致マスクにより
[REDACTED]へ置換されること。JWT・Bearerトークン・HMAC 署名・Access service token・長い base64url / hex 列もパターンマスクされ、マスク後のerrorStackが 1,500 文字を超えないこと。UUID 形式(8-4-4-4-12のハイフン付き hex)がパターンマスクの対象外であり、requestId 等の相関識別子が[REDACTED]に置換されないこと。request body・header・検索クエリ文字列・Discord メッセージ本文が送出されないこと - 1 invocation 内で複数の例外ハンドラが同じ requestId のアラート送出を試みても、
log-throw-manへの Service Binding 呼び出しが 1 回だけであること。異なる invocation 間の永続的な重複抑止をテスト要件に含めないこと - requestId が Workers / VPS の request・response・監査ログで一致すること
- 現行/新旧バージョン間の consumer / provider contract test が通ること
8.3 カバレッジ
authz/は高いブランチカバレッジを目標とする- それ以外は通常水準でよい
- 具体値は requirements.md の確定値に合わせる
9. 未確定事項
requirements.md の Blocker は、実装着手前に確定する。
| 項目 | 内容 |
|---|---|
トークン層 aud の導入(将来拡張) | issuer(unified-auth-worker)側でスコープ付きトークン発行が可能になった時点で B-02 を再訪し、userType 強制に加えてトークン層の aud 検証を追加する |
| nonce 照合によるリプレイ排除(Phase 2) | v1 では deadline / clock skew によりリプレイ可能な時間を約 60 秒に制限し、その時間内のリプレイ防止は Phase 2 とする。envelope の nonce と REPLAY_DETECTED は v1 から予約済みのため、VPS 側に check-and-set を追加するのみで契約変更なしに有効化できる。有効化の判断材料は、実運用での監査ログ・受信制御の観測結果と、単一プロセス運用(または共有 nonce store)の許容可否 |
| VPS の外部監視サービスとの通信仕様(B-03) | 本番リリース Blocker。既存資源を優先してサービスを選定し、環境別 endpoint、service token 等の送信元認証、送信データ形式、heartbeat の有効期限、timeout、retry / backoff、rate limit、欠損アラート条件、否定系テストを確定する。確定前は開発・pre-production の PM2 ローカル監視と VPS 構造化ログのみを使用できるが、Gateway Bot を本番稼働させない |
「権限変更がキャッシュへ反映されるまでの時間」「監査ログの保存先」「AI Agent Host I/F」は 0.8.0 で確定した。 旧権限情報が参照される時間は、TTL(既定 5 分・最大 15 分)と、
shardReconnectingで検知した Gateway 切断中の fail-closed(再同期後に flush)で制限する(5.4、R-02)。監査ログは既存資源で二系統(Workers native observability + VPS 構造化ログ)とし、Workers のアラートはlog-throw-manへ集約して requestId で相関する(6.3 / 7.2)。VPS の外部監視サービスとの通信仕様は B-03 の未確定事項かつ本番リリース Blocker として残し、無通信時も heartbeat 欠損で停止を検知できるまで本番稼働させない。統一監査ストアは将来拡張。AI Agent Host は契約 I/F のみ確定し、汎用 MCP クライアント前提とする(4.6)。Blocker B-01(Workers ↔ VPS 内部 API のセキュリティ設計)は 0.7.0 で確定した。 Cloudflare Tunnel(非公開到達経路)+ Cloudflare Access(Service Auth service token による edge 認証)+ アプリ層 HMAC 署名(送信元・完全性・有効期限の最終検証)の3層とし、確定パラメータ(deadline horizon 30s / clock skew ±30s)、v1 のリプレイ対策(リプレイ可能な時間を期限で制限し、nonce 照合は Phase 2)、health の別ポート分離、VPS から状態を送信する監視方式、鍵ローテーションを 3.3 / 3.4 / 7.6 に定義した。Workers VPC private service binding(beta)は初期採用せず GA 後の移行候補とする。内部 hostname を本番と同一ゾーンに置くため、接続前の staging loopback 検証(Access 評価と
Cf-Access-Jwt-Assertion到達の肯定系、および service token なしが Access で遮断され VPS に到達しない否定系)を必須チェックとして残し、不成立時は Tunnel + HMAC + WAF レート制限の2層構成へ切り替える。Blocker B-02(
aud/guildIdの署名済み伝搬)は 0.7.0 で確定し、0.8.0 で guild 横断防止の位置づけを訂正した。unified-auth-workerは変更せず、MCP 専用アクセスキー(X-MCP-Access-Key。keyId と guildId の関連付けによりキーの流用を防ぐが、guild 横断そのものは「1 セッション = 1 guild」の運用上の対策で担保する。R-10)による宛先スコープの強制+ MCP Worker でのuserType === 'staff'強制(ゲスト JWT 拒否)+ request path を allowlist で解決して guildId をサーバー側で決定する方式(マルチギルド routing、未定義 path は404)+ 毎リクエスト/token/verify検証を補完的な対策として S-003 に定義した。トークン層のaud検証は issuer 側対応を要する将来拡張として残す。
10. 付録
10.1 関連文書
10.2 主要出典
- Discord Search Guild Messages
- Discord Permissions
- MCP Specification 2025-06-18
discord.jsGuildChannel#permissionsFor- Cloudflare Always Use HTTPS
- Cloudflare Workers Rate Limiting binding
- Cloudflare Durable Objects: Rules of Durable Objects
- PM2 Restart Strategies