1. なぜ ChatGPT App の境界を防御するのか
古典的なウェブアプリでは、ユーザー=ブラウザであり、あなたのエンドポイントへ比較的予測可能にアクセスします。ChatGPT Apps の世界では新しいタイプのクライアントが現れます。すなわち、「いつどのツールを呼ぶか」を自律的に決める LLM です。
モデルは次のような振る舞いをします:
- 1 つの対話の中で、同じ tool を連続して複数回呼ぶ;
- 試行錯誤する: 「少しだけパラメータを変えて、もう一度 suggest_gifts を呼んだらどうなる?」;
- 何百人ものユーザーに対して並列に処理を走らせる。
ここにボットやテストスクリプト、自前コードの不具合(例: tool‑call を無限にトリガーするループ)まで加わると、善意の DoS がほぼ完成してしまいます。
極めつけはコストです。各 tool‑call は:
- 有料の外部 API(配送、決済、カタログなど)を叩く可能性があり、
- 別の LLM(例: RAG 検索)を呼び出し、
- 重いバックグラウンドタスクを起動することもあります。
境界防御と制限がなければ、1 人の「不運な」クライアントが:
- gateway の背後のすべてのバックエンド(Gift API、Commerce API など)を落とし、
- 外部 API のレート上限を枯らし、
- モデルの予算を大きく燃やしてしまうかもしれません。
この講義の目的は、gateway/proxy + rate limiting + キュー + backpressure によって、この潜在的な災厄を制御可能なシステムへと変える方法を示すことです。
インサイト
ChatGPT プラットフォームは、外部トラフィックからあなたの MCP サーバーを守るための保護機構を一切提供していません。MCP Jam のようなユーティリティを含む、あらゆるインターネットクライアントがリクエストを送信できます。
ChatGPT が提供できるのは、リバースプロキシ(例: NGINX)で allowlist を設定し、IP アドレスで受信トラフィックを制限することくらいです。IP フィルタリングを設定していない場合、あなたの MCP サーバーは完全に開放された状態となり、安全ではありません。あなたにとっても、ユーザーにとっても。
2. Proxy/Gateway をバックエンドとエージェントの前に置く「盾」
まずは図をおさらいしますが、今度は防御の視点で見てみましょう。
典型的な構成を想像してください:
flowchart LR
ChatGPT["ChatGPT / ウィジェット"]
--> GW["MCP Gateway (Auth, Rate Limit, Logs)"]
GW --> GiftAPI["Gift REST API (ギフト選定)"]
GW --> CommerceAPI["Commerce REST API (checkout, ACP)"]
GW --> Analytics["Analytics Service / REST API"]
GW --> Queue["タスクキュー"]
Queue --> Worker["Background workers"]
Gateway は外部(ChatGPT、webhook、テストクライアント)とその他すべての間に立ちます。Gateway は次を行います:
- すべての受信リクエストを観測し、
- 最初にトークンとリクエスト形式を検証し、
- 不正な host、怪しい path、過度に大きな body など、明らかに不可能なものをはね、
- どの内部 REST/HTTP サービスに転送する意味があるかを判断します。
このレイヤーで次の仕組みを導入します:
- rate limiting — 一定時間あたりのリクエスト数を制限する;
- 単純な backpressure — 背後のサービスがすでに悲鳴を上げているときは拒否する;
- 非同期化 — 重い処理はただちにキューへ、クライアントには「受け付けた」と返す。
つまり、gateway は単なる「router」ではなく「防弾チョッキ」でもあります。大事なのは、これを「ビジネスのすべてを抱え込むモノリス」にしないことです。前回の講義でも触れました。
3. 制御すべきトラフィックの種類
ChatGPT App のエコシステムでは、制限と防御の観点から重要なトラフィックが通常 3 種類あります。
第一に、ChatGPT からの MCP tool‑call です。これは MCP プロトコルで届くすべての呼び出し、例えば suggest_gifts、get_product_details、create_checkout_session などです。モデルは、特に背後で Agents が動いていると、かなりの勢いで生成します。
第二に、私たちのバックエンドから外部 API への送信リクエスト です。サービス内には、カタログ、物流、決済といった他社システムに対する自前のレート上限があるかもしれません。破るとブロックやペナルティ、品質低下につながります。
第三に、受信 webhook です。ACP、決済(Stripe など)、配送からの通知はユーザーのアクティビティと無関係にやって来ます。もし私たちの endpoint が遅かったりエラーを返したりすると、外部システムはリトライを行い、繰り返し通知の「ストーム」を引き起こしかねません。
GiftGenius では次のようになります:
- ユーザーとモデルが suggest_gifts と find_similar_gifts を活発に叩く;
- checkout ツールが ACP/コマース系バックエンドを叩く;
- 支払い後に決済が webhook payment.succeeded / payment.failed を送ってくる。
これらのフローはすべて1 つの地点 — Gateway に集まります。したがって、まさにそこで「カウンタ、フィルタ、遮断」を設置するのが合理的です。
4. Rate limiting: 基本防御とコスト節約
本コンテキストにおける rate limiting とは
Rate limiting は、特定クライアントからの一定時間あたりのリクエスト数を制限する仕組みです。考え方自体は古典的ですが、ChatGPT Apps では次の 3 つを一挙に解決します:
- 1 クライアント(やバグ)がサービスを落とすのを防ぐ;
- 外部 API の上限を守るのに役立つ;
- モデル呼び出しの暴走から財布を守る。
代表的なアルゴリズム:
- 固定ウィンドウ(Fixed Window)
- スライディングウィンドウ(Sliding Window)
- トークンバケット(Token Bucket)
重要なのは概念です。「1 分間に N リクエストまで」「各リクエストでトークンを 1 つ消費、トークンは毎秒 X のペースで補充」など。実装はたいていライブラリや API Gateway に任せられます。
どこにリミットを置くか
リミットは複数レイヤーで設定できます。
リバースプロキシ(Nginx、Cloudflare、AWS API Gateway)レベルでは次が便利です:
- IP ベースで荒いトラフィックを遮断する;
- リクエストボディのサイズを制限する;
- 単純な DDoS パターンから守る。
MCP Gateway(アプリケーション) レベルでは、より「意味のある」レート制限が有効です:
- ユーザー別(トークンの userId)
- 組織別(tenantId)
- 操作種別(例: create_checkout_session は厳しめ、search はやや緩め)
- ソース別(webhook vs tool‑call)
さらに、特に高コストな操作については各マイクロサービス側にリミットを設けることもできますが、これはもう一段詳細な話です。
レート制限のキーをどう選ぶか
もっともありがちなミスは IP アドレスで制限することです。ChatGPT の場合これは非効率です:
- すべてのリクエストが OpenAI の同一アドレス帯から来る可能性があり、
- 異なるユーザーが同じ IP を共有します。
より有用なのは次です:
- userId — あなたのアプリ内の特定ユーザー;
- tenantId — 組織(B2B で 1 つのチャットを複数社員が使う場合);
- API トークンや clientId(複数の統合がある場合)。
GiftGenius では、ChatGPT が MCP 呼び出しで渡してくるトークンから取り出せる userId + tenantId で十分なことが多いです。
TypeScript によるシンプルな rate limiting 実装
小さな Express 製 MCP Gateway があると仮定しましょう。ユーザー 1 人につき 1 分あたり 30 回まで tool‑call を許容する簡易レート制限を追加します。
// 単純なレート制限: userId ごとに 1 分あたり N リクエスト
const WINDOW_MS = 60_000;
const MAX = 30;
const hits = new Map<string, { ts: number; count: number }>();
function rateLimit(req: Request, res: Response, next: NextFunction) {
const userId = (req.headers["x-user-id"] as string) ?? "anonymous";
const now = Date.now();
const rec = hits.get(userId) ?? { ts: now, count: 0 };
if (now - rec.ts > WINDOW_MS) { // ウィンドウが期限切れ — カウントをリセット
rec.ts = now;
rec.count = 0;
}
rec.count += 1;
hits.set(userId, rec);
if (rec.count > MAX) {
return res.status(429).json({
error: "rate_limit_exceeded",
retryAfterSec: 60,
message: "Too many tool calls, please retry later."
});
}
next();
}
では、これを MCP のルートで使います:
// すべての MCP tool-call に middleware を適用
app.post("/mcp/tools/call", rateLimit, async (req, res) => {
const result = await callBackendForTool(req.body); // Gift/Commerce/Analytics API への REST 呼び出し
res.json(result);
});
ポイント:
- 意味のあるエラー(error: "rate_limit_exceeded")を返し、単なる 500 にはしない;
- モデルはエラーを読み取り、状況を理解してユーザーへ適切に説明でき、無用な幻覚を避けられます。
実運用では、カウンタは単一プロセスのメモリではなく Redis などの共有ストアに置き、クラスタでも動作するようにします。原理の理解にはこの程度で十分です。
Gateway レベルのレート制限はリクエストの雪崩から守ってくれますが、別の問題は解決しません。すなわち、個々の処理がとても重く長時間かかる点です。ここでは同期 HTTP だけでは足りず、キューと非同期ジョブの出番になります。
5. キューと非同期ジョブ: もはや同期では厳しいとき
ChatGPT のタイムアウト問題
レート制限を丁寧に設定しても、ChatGPT(や一般の HTTP クライアント)は返答が極端に遅いのを嫌います。プラットフォームは tool‑call の実行時間に上限を設けています。もし「超リコメンド」的なアルゴリズムの完了を待っていたら:
- ユーザーは永遠のスピナーを見る;
- プラットフォームがタイムアウトでリクエストを打ち切る;
- モデルは「何かおかしい」と判断し、説明をでっち上げ始める。
解決策は、重い処理を非同期モードへ移すことです。典型的なパターン:
- Gateway がリクエストを受け取る。
- キューにジョブを投入する。
- 202 Accepted と jobId をすぐ返す。
- 別プロセスの worker がキューから取り出して処理する。
- クライアント(ウィジェット、あるいは ChatGPT の別ツール)が jobId で定期的にステータスを確認するか、MCP のイベント通知で進捗を受け取る。
ChatGPT App の用語では、通常は2 つのツールになります。1 つ目の tool がリクエストを受けてジョブをキューに入れ jobId を返す、2 つ目がその jobId でステータスや結果を取得する。進捗イベントは MCP 通知でも重ねて送れます。
GiftGenius のミニキュー(コード例)
数十秒かかることがある重いツール generate_large_gift_report があるとします。実アプリでは jobId のみ返し、別ツール get_report_status がその jobId で状態や結果を取得します。Gateway ではこのツール用にキュー付きの専用 endpoint を設けます。
type Job = { id: string; payload: any };
const queue: Job[] = [];
const MAX_QUEUE = 100;
app.post("/mcp/tools/generate_report", (req, res) => {
if (queue.length >= MAX_QUEUE) {
return res.status(503).json({
error: "system_busy",
message: "System is busy, please retry later."
});
}
const job: Job = { id: crypto.randomUUID(), payload: req.body };
queue.push(job);
res.status(202).json({ jobId: job.id, status: "accepted" });
});
そして 200 ms ごとに 1 件ずつ処理する原始的なワーカー:
async function processJob(job: Job) {
// ここで実際のバックエンドサービスやエージェントのワークフローを REST で呼ぶ
await handleHeavyGiftReport(job.payload);
}
setInterval(async () => {
const job = queue.shift();
if (!job) return;
await processJob(job);
}, 200);
これは大幅に単純化した例です:
- 実際のキューは Redis、SQS、Kafka などに置く;
- ジョブのステータスはどこかに保存し、問い合わせ可能にする;
- worker は通常複数走らせる。
しかしコンセプトは明快です。Gateway は処理完了まで接続を握り続けません。受け付けてキューに入れ、すぐに応答します。
6. Backpressure: 自前のキューで溺れないために
Backpressure は rate limiting とどう違うか
Rate limiting は主として「単一クライアントが時間当たりいくつリクエストを出せるか」に答えます。特定クライアントの暴走やバグからの防御です。
Backpressure は「私たちのシステム全体が同時にいくつのタスク/リクエストを無理なく処理できるか」を定義します。これは送信元に依存しない総量の制御です。
例:
- rate limiting: 「ユーザーは suggest_gifts を 1 分あたり 30 回より頻繁に呼べない」;
- backpressure: 「未処理タスクが 100 件を超えたら新規リクエストを全員に対して拒否する」。
理想的には両者は補完関係です。rate limit がクライアントを抑え、backpressure が群衆が押し寄せたときにシステム全体を守ります。
同時実行数の制限の簡単な実装
もっとも簡単な backpressure は内部の同時呼び出し数を制限することです。例: 特定のバックエンド/REST サービス(Gift API、Commerce API など)へのアクティブな tool‑call を同時に 50 を超えないようにする、など。
let activeCalls = 0;
const MAX_ACTIVE = 50;
app.post("/mcp/tools/call", async (req, res) => {
if (activeCalls >= MAX_ACTIVE) {
return res.status(429).json({
error: "gateway_overloaded",
message: "Gateway is temporarily overloaded, please retry later."
});
}
activeCalls += 1;
try {
const result = await callBackendForTool(req.body); // Gift/Commerce/Analytics API への REST 呼び出し
res.json(result);
} catch (err) {
console.error("Tool call error", err);
res.status(500).json({ error: "internal_error" });
} finally {
activeCalls -= 1;
}
});
ここでの動き:
- 同時実行数が MAX_ACTIVE 未満なら新しい呼び出しを通す;
- 上限に達していれば、すぐに意味のあるエラーで返す;
- finally で必ずカウンタを減らし、エラー時に「スロット」が失われないようにする。
これが最小限の backpressure です。「今は無理なので後で試してください」と正直に伝え、無思慮に受けて倒れるのを避けます。
さらに次の発展が可能です:
- 操作種別ごとに異なる MAX_ACTIVE を設定(例: checkout はほぼ常に通し、レポート生成は厳しめに);
- 負荷メトリクスに応じて動的に制限値を切り替える。
7. Webhook と「ストーム」: 受信イベントの防御
ここまで見てきたのは、私たちや ChatGPT が発火するリクエスト(tool‑call、送信リクエスト、非同期ジョブ)でした。しかし Gateway への負荷源としてもう 1 つ重要なのが、外部システムからの受信 webhook です。
Webhook はコインの裏面です。tool‑call を起動するのは私たち(モデル経由)ですが、webhook を起動するのは外部サービスです。これは第 4 節で挙げた 3 つ目のトラフィックで、時間や頻度を私たちがコントロールできませんが、落ちずに処理できなければなりません。決済、ACP、物流などは、重要な変化のたびに通知(webhook)を送りつけます。「支払い成功」「注文作成」「配達ステータス更新」などです。
問題は次のときに始まります:
- 私たちの endpoint の応答が遅い;
- エラーで応答する;
- ときどきダウンしている。
すると外部サービスはベストプラクティスに従いリトライを開始します。不運が重なると、webhook の「ストーム」— 何十・何百という再送イベント — に見舞われます。
この善意の再送で崩れないために、Gateway レベルでは次を行いましょう:
- 送信元ごとの受信 webhook を制限する(例: 特定プロバイダからの 1 つの event_type につき 1 分あたり 10 件まで)。
- JSON をパースする前に署名を検証する。HMAC などで偽リクエストを弾く。
- イベント処理を冪等にする。event_id 等で再送が注文・決済の重複を生まないようにする。
- 強いストーム時は追加の backpressure を有効化。downstream が追いつかないなら一時的に「503: 後でもう一度」を返す。
超簡単な例(アイデアであってプロダクションコードではありません):
app.post("/webhooks/stripe", rateLimitWebhook, (req, res) => {
const sig = req.headers["stripe-signature"] as string;
if (!isValidSignature(req.rawBody, sig)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
if (isAlreadyProcessed(event.id)) {
return res.json({ received: true }); // 冪等性
}
handleStripeEvent(event);
res.json({ received: true });
});
Gateway レベルで次を行っています:
- webhook 用に別ポリシーのレート制限を適用;
- 本文を信用する前に署名を検証;
- isAlreadyProcessed で重複から保護。
8. GiftGenius への適用: リミットとキュー方針の例
抽象論から離れ、学習用の GiftGenius ではどうなるかを見てみましょう。
3 つの主要シナリオを想定します:
- ギフト検索(suggest_gifts、find_similar_gifts)。
- 注文作成 / checkout(create_checkout_session、confirm_order)。
- 受信 webhook(決済プロバイダと ACP から)。
各シナリオについて決めます:
- どのキーでリミットを数えるか;
- 1 分あたり何件許容するか;
- 超過時の振る舞い。
例:
| シナリオ | リミットキー | 1 分あたりの上限 | 超過時の動作 |
|---|---|---|---|
| ギフト検索 | userId | 30 | 429 + 「検索条件を絞ってください」と案内 |
| 注文作成 | userId + tenantId | 5 | 429 + 「試行回数が多すぎます。注文をご確認ください」 |
| 受信 webhook | provider + eventType | 10 | 429/503、ログ記録、必要に応じて段階的デグレード |
Webhook では通常「プロバイダ + イベント種別」の組み合わせで制限し、event_id による冪等性で重複を排除します。
コード上は rateLimitSearch、rateLimitCheckout、rateLimitWebhook といった別々の middleware になります。
「年間ギフトの大規模 PDF レポートを生成」といった重い処理は、前述のとおりキュー + 非同期パターンで扱います。Gateway は次を行います:
- ChatGPT からのリクエストを受け付ける;
- ジョブをキューに投入する;
- jobId と、モデルがステータスを取得する方法のヒントを返す;
- キューの長さを制限(backpressure)し、システムの過負荷を防ぐ。
忘れてはいけません。rate limiting と backpressure は安全性や信頼性のためだけでなく、UX のためでもあります。延々スピナーを眺めたり「Internal Server Error」を見るより、「いま混んでいるので 1 分後にもう一度試しましょう」とアシスタントに言われる方がずっと良いのです。
9. ミニ実習: MCP Gateway に防御を追加する
机上の空論にしないため、学習プロジェクトで実装できるミニ実習をまとめます。
すべての MCP tool‑call にレート制限を
上の rateLimit middleware を追加し、/mcp/tools/call に適用します。まずは userId ごとに 1 分あたり 30 リクエストという単純な上限から。次を試してみましょう:
- 上限を下げ、あなたの App とモデルの反応を観察する;
- toolName を middleware に渡すなどして、ツール種別ごとに異なるリミットを設ける。
最も簡単なバックプレッシャー(同時呼び出し数)
activeCalls カウンタと MAX_ACTIVE を追加します。リクエストを束で送るスクリプトなどで負荷を模擬し、Gateway がいつ gateway_overloaded を返し始めるか確認しましょう。
重要なのはこの挙動です。すべてが落ちるのを待つのではなく、「いまは熱すぎる」と正直に言って新規受付を断るのです。
重いツール向けのキュー
重い処理を 1 つ選び(あるいは setTimeout や長い fetch を入れて人工的に「重く」して)、それを「キュー + jobId」パターンに移行します。最低限:
- endpoint POST /mcp/tools/generate_report — ジョブをキューに入れて jobId を返す;
- endpoint GET /jobs/:id — ステータス(pending、done、error、必要なら結果)を返す;
- processJob を X ミリ秒ごとに実行するワーカー。
これだけで、BullMQ などの実キューエンジンでどうなるかの感覚が掴めます。
10. 境界防御での典型的なミス
ミス No.1: IP のみで制限する。
ChatGPT Apps の世界ではほとんど意味がありません。多くのリクエストが OpenAI のアドレスから来るため、全ユーザーが同じ IP の背後にいます。結果として、誰か 1 人が全員分の上限を焼き切り、本当の原因は分からないままになります。正しくは userId、tenantId、トークンなどで制限し、IP はリバースプロキシでの荒いフィルタに留めましょう。
ミス No.2: 意味のあるエラーではなく素の 500 を返す。
上限超過や過負荷時に単に 500 Internal Server Error を返すと、モデルは状況を理解できず、でっち上げを始めます。一方、(rate_limit_exceeded、gateway_overloaded などの)コードと人間向け説明を備えた構造化エラーは、LLM が状況を正しく説明し、必要なら後で再試行するのを可能にします。
ミス No.3: backpressure のない無限キューにする。
「とりあえず全部キューに入れよう、あとで処理すればいい」と考えがちです。実際にはキューが何千件にも膨れ、レイテンシは増大し、メモリは枯渇し、ユーザーは結果を見られません。キュー長とアクティブ処理数は常に制限しましょう。503 や 429 で新規受付を正直に断る方が、ブラックホール化するよりも健全です。
ミス No.4: rate limiting だけに頼り、webhook を無視する。
ChatGPT からの受信だけを守り、webhook を「なんとかなるだろう」で放置しがちです。決済プロバイダがリトライを始めると、最も激しいストームを起こすのは webhook です。webhook endpoint には専用の制限、署名検証、冪等処理が必要です。でないと同じ注文が 10 回複製されるのも容易です。
ミス No.5: すべてのカウンタやキューを単一インスタンスのメモリに置く。
学習用なら問題ありませんが、実運用で Gateway を水平展開すると、各ノードのカウンタが独自に動き、上限はグローバルではなくなり、ノード再起動でキューが消えます。実システムでは、レート制限の状態やキューを Redis やクラウドキューといった共有ストアに置きます。スケーリングや本番運用の講義で改めて扱います。
ミス No.6: 「どうせ中継するなら」と Gateway にビジネスロジックをねじ込む。
「どうせ Gateway にリクエストが来るのだから、ギフトの選定もそこでやってしまおう」となりがちです。結果として Gateway が、ルータでありビジネス脳でありロガーでもあるモノリスに化けます。スケーリングや保守が著しく難しくなります。Gateway はネットワーク/インフラ層に留めるべきです。認証、認可、リミット、キャッシュ、ルーティング — はい。ギフト選定 — いいえ。
ミス No.7: 「小規模だから関係ない」と思い込む。
「ユーザーは百万もいないし、gateway/リミットはいらないだろう」と考えがちです。実際には、クライアントコードの 1 件のバグ(あるいは、モデルにツールをグルグル呼ばせるプロンプトのバグ)だけで、小規模でも局地的なアポカリプスが起きます。基本的な rate limiting と最低限の backpressure はぜいたく品ではありません。本番の歯ブラシのようなものです。痛くなる前から使いましょう。
GO TO FULL VERSION