MCPサーバーのレート制限とコスト管理|Lambda実行回数をどう制御しているか

はじめに

MCP(Model Context Protocol)サーバーを Lambda で動かすと、エージェントがツールを連続呼び出しするたびに関数が起動します。人間が叩く REST API と違い、「試しに 10 回」「エラーだともう一度」が短時間に積み上がりやすく、個人開発では月次クォータと AWS 請求の両方が気になります。

制御の論点は次の 2 つに分かれます。

  1. 誰がどれだけ呼べるか(API キー単位のプラン上限)
  2. インフラがどれだけ同時実行するか(Lambda の同時実行数)

この記事では、個人開発の MCP サーバー Thousand API で採用している考え方を例に、レート制限とコスト管理の置き場所を整理します。認証キー自体の設計(ハッシュ化・失効)は別記事で扱います。ここではプラン上限と Lambda 実行制御に絞ります。

前提: TypeScript と API Gateway + Lambda の基本は分かっている読者向けです。検証範囲: Thousand API のアーキテクチャドキュメント、料金ページ、および utility.get_usage のレスポンス形状に基づきます。プラン上限の数値はプロダクト側で変更され得るため、実装・運用時は 料金プラン と自環境の正本を確認してください。

用語意味
MCPエージェントが外部ツールを呼ぶためのプロトコル
MCP クライアントCursor など、エージェントから MCP サーバーへツール呼び出しを送る側
月次クォータプランごとに設定する、カレンダー月あたりのリクエスト上限
Reserved concurrencyLambda 関数に割り当てる同時実行の上限(兼予約)
AuthorizerAPI Gateway 手前で認証・可否判定を行う Lambda

背景:MCP で実行回数が増えやすい理由

Thousand API では、MCP 用 Lambda(McpFn)がツール呼び出しを受け取り、内部で REST API へ HTTP プロキシします。エージェントの 1 ツール呼び出し(MCP の tools/call)で次が動きます。

MCP クライアント(Cursor など)
  → API Gateway + McpFn(ツール受付)
    → API Gateway + AuthorizerFn(キー検証・上限チェック)
      → 業務 Lambda(カレンダー、郵便番号、変換など)

ここでのコスト・負荷の特徴は次のとおりです。

エージェントは、バリデーション失敗やタイムアウトでも同じツールを繰り返し呼びがちです。上限超過でも McpFn と Authorizer までは到達し得るため、業務 Lambda を止められるかは設計次第です。またツール一覧を見て、本題と無関係な変換ツールなどを連続で試すといった探索呼び出しも起きやすく、ツール数が多いほど実行回数が増えやすいです。

したがって、「業務 Lambda を呼ばない」制御と、「そもそも同時に何本まで許すか」の制御を分ける必要があります。


レイヤで見る制御手段

Thousand API で意識しているレイヤは次の表です。以降は上から順に、契約上限・対象外パス・同時実行の天井を掘り下げます。

レイヤ何を抑えるか主な手段
プロダクト(契約)キーごとの月間利用量DynamoDB の使用量 + Authorizer の上限チェック
エージェント体験429 の試行錯誤utility.get_usage で残量を先に見せる
運用(無駄呼び)診断や設定確認での消費使用量加算から除外するパス一覧
インフラ突発的な同時実行Lambda Reserved concurrency、必要に応じて Budgets

API Gateway の Usage plans(REST API 向け)もスロットリング手段の 1 つです。Usage plans のドキュメントでは、クォータはベストエフォートだと説明されています。あわせて、コスト制御の唯一の手段にしないよう案内されています。Thousand API は課金連動の月次上限をアプリ側(DynamoDB + Authorizer)で持っています。


1. プラン上限を Authorizer で止める

なぜ業務 Lambda の手前か

上限超過後も業務ハンドラまで進むと、DynamoDB 更新や外部 API、郵便番号のメモリ処理など、相対的に重い処理が走ります。Authorizer で limit_exceeded として Deny すれば、API Gateway はそのリクエストを業務 Lambda に渡しません。

イメージは次のとおりです(認証設計ドキュメントと同系統の骨格です)。

// packages/shared の validateApiKey イメージ
import { PLAN_LIMITS } from '../types/plan';
import { getMonthlyUsage } from '../dynamo/usage';

export type AuthResult =
  | { ok: true; userId: string; apiKeyId: string; plan: string }
  | { ok: false; reason: 'not_found' | 'inactive' | 'limit_exceeded' };

export const validateApiKey = async (rawKey: string): Promise<AuthResult> => {
  const keyRecord = await findApiKey(hashApiKey(rawKey));
  if (!keyRecord?.isActive) {
    return { ok: false, reason: keyRecord ? 'inactive' : 'not_found' };
  }

  const user = await findUser(keyRecord.userId);
  if (!user) return { ok: false, reason: 'not_found' };

  const limit = PLAN_LIMITS[user.plan];
  if (Number.isFinite(limit)) {
    const usage = await getMonthlyUsage(keyRecord.apiKeyId);
    if (usage >= limit) {
      return { ok: false, reason: 'limit_exceeded' };
    }
  }

  return {
    ok: true,
    userId: user.userId,
    apiKeyId: keyRecord.apiKeyId,
    plan: user.plan,
  };
};

許可されたリクエストでは、処理後に使用量を DynamoDB で加算します。UpdateItemADD でカウンタを 1 増やし、並行リクエストでも取りこぼしにくくします。アーキテクチャ上は「業務処理のあとで非同期インクリメント」と整理しています。Thousand API ではこの「処理後加算」を採用しています。Authorizer 通過直後に数える案もありますが、その場合は Deny 前に通過した分の取りこぼしは減る一方、失敗リクエストまで課金対象に含めやすくなります。

公開プランとの対応(参考)

Thousand API の料金では、執筆時点の案内として次のように記載されています(数値は変更され得るため、公開ページを正とします)。

プラン月間リクエスト数(案内)
Free1,000 回
Starter10,000 回
Pro100,000 回

エージェント向けには utility.get_usageGET /v1/user/usage)で残量を返せます。レスポンス形状の例です。

{
  "plan": "free",
  "period": "monthly",
  "monthly_limit": 1000,
  "monthly_used": 143,
  "remaining": 857,
  "reset_at": "2026-08-01T00:00:00.000Z"
}

period はカレンダー月(UTC)、reset_at は次月 UTC 月初です。連続ツール呼び出しの前に残量を見る運用にすると、429 limit_exceeded の試行錯誤を減らせます。


2. 「数えない呼び出し」を設計する

診断や設定確認までクォータを消費すると、トラブルシューティング自体が利用枠を削ります。Thousand API では、使用量加算の手前で除外パスを見ます。一覧に含まれる path はカウンタを増やしません。

// 使用量ミドルウェアのイメージ
const USAGE_EXEMPT_PATHS = new Set([
  '/v1/health',
  '/v1/user/usage',
  '/v1/user/tool-settings',
]);

if (USAGE_EXEMPT_PATHS.has(path)) {
  // 加算せずハンドラへ進む
  return next();
}
await incrementUsage(apiKeyId);

対象外に寄せている用途は次のとおりです。

用途パス / ツール扱い
疎通確認GET /v1/health認証不要・使用量カウントなし
使用量確認GET /v1/user/usageutility.get_usage使用量カウント除外
ツール設定GET /v1/user/tool-settings使用量カウント除外
自己診断utility.diagnose_mcp上記の対象外エンドポイントのみ呼ぶ

失敗しやすいのは、「疎通確認で本番の業務 API をそのまま叩く」ことです。疎通のたびに業務 Lambda と使用量が動き、無料枠が減ります。診断用の薄いエンドポイントを先に用意し、エージェント向け自己診断はその上に載せる形が安全です。

加算を避けるだけでは足りません。呼ばれる前にツールを減らす制御も効きます。ダッシュボードからカテゴリ単位・ツール単位で OFF にできると、探索的な呼び出し自体を減らせます。エージェント側の LLM トークンを抑える効果に加え、呼ばれないツールは Lambda も動かないという意味でコスト制御になります。


3. Lambda 実行回数・同時実行をインフラで抑える

アプリ層の月次上限は「契約どおり使わせる」ための制御です。一方で次のような事態には別の手立てが必要です。

  • バグやプロンプトで極端なリトライが起き、Authorizer / MCP Lambda だけが激増する
  • 1 つの関数がアカウントの同時実行枠を食い、他機能が動かしにくくなる
  • 開発用関数が想定外の負荷をかけ、請求が跳ねる

Reserved concurrency

Reserved concurrency の設定によると、関数に同時実行の上限を割り当てられます。上限を超えた同期呼び出しはスロットルされます。設定自体に追加料金はかからない、とドキュメントに記載されています。

意図的に関数を止める場合は、予約同時実行を 0 にできます。緊急時のキルスイッチや、開発関数の暴走防止に使えます。

# 例: 開発用関数の同時実行を抑える(数値は環境に合わせて決める)
aws lambda put-function-concurrency \
  --function-name my-mcp-dev \
  --reserved-concurrent-executions 5

# 緊急停止(ドキュメント記載の意図的スロットル)
aws lambda put-function-concurrency \
  --function-name my-mcp-dev \
  --reserved-concurrent-executions 0

同時実行の目安は、ドキュメントで紹介されている次の式でざっくり見積もれます。

Concurrency ≈(平均リクエスト / 秒)×(平均処理時間(秒))

たとえば平均 5 リクエスト / 秒、平均処理 0.4 秒なら、同時実行の目安はおよそ 2 です。開発用なら余裕を見て 5 前後から始め、CloudWatch の ConcurrentExecutionsInvocationsDurationThrottles を見ながら調整します。アカウント全体の未予約枠は最低でも 100 残す必要があります(ドキュメントに記載)。全部を 1 関数に予約し切らないよう注意してください。

課金監視

Reserved concurrency は同時実行の天井です。月次の呼び出し総数そのものの会計ではありません。 長時間・低並行で叩き続けるケースには、請求アラートの併用が現実的です。AWS Budgets で予算超過を通知し、Cost Anomaly Detection(利用パターンの異常検知)で想定外の増加を拾います。


失敗例:Authorizer を通らず業務側だけで数える

初期実装でありがちな失敗は、業務 Lambda の先頭だけで使用量を見て 429 を返すパターンです。

// 失敗しやすい例: 上限超過でも業務 Lambda は毎回起動する
export const handler = async (event) => {
  const usage = await getMonthlyUsage(event.requestContext.authorizer.apiKeyId);
  if (usage >= PLAN_LIMITS.free) {
    return { statusCode: 429, body: JSON.stringify({ error: 'limit_exceeded' }) };
  }
  // 重い処理...
};

この場合、上限超過後も次のコストが残り続けます。

  1. API Gateway のリクエスト処理
  2. Authorizer(キー検証)の実行
  3. 業務 Lambda の起動とコールドスタート分
  4. 使用量取得のための DynamoDB 読み取り

Authorizer で Deny すれば 3 と、その先の重い処理は避けられます(1 と Authorizer 自体は残ります)。MCP 経由では McpFn も動くため、Lambda 呼び出しがゼロになるわけではありません。それでも郵便番号検索や外部 API 呼び出しなど、相対的に重い業務処理を止められる点では効果があります。

合わせて、エージェントが上限超過後もリトライし続けると、対象外にしていないエンドポイントでは使用量やログだけが増えます。get_usage で残量を先に見せ、クライアント側で打ち切る設計が有効です。


学び:制御は「契約」と「天井」を分ける

レート制限とコスト管理を混ぜると、次のどちらかに偏りやすいです。

  • アプリ上限だけ → 契約は守れるが、インフラ側の突発スパイクには弱い
  • 同時実行だけ → 請求の爆発は抑えやすいが、キー単位の公平な配分はできない

Thousand API では役割を分けています。契約(月次クォータ)は Authorizer で強制します。残量は get_usage で観察できるようにします。診断系は使用量カウントから外します。必要なら Reserved concurrency で同時実行の天井を付けます。

エージェント向け MCP では「呼べること」より「呼びすぎない導線」が重要です。ツール OFF、対象外パス、事前の残量確認は、請求対策であると同時に UX 対策でもあります。


まとめ

  • MCP ではツール連続呼び出しで Lambda 実行が増えやすい。契約上限とインフラ同時実行を分けて設計する
  • プラン上限は Authorizer で判定すると、上限超過後の業務 Lambda 起動を抑えやすい
  • 診断・使用量確認などはクォータ対象外にし、ツール OFF と get_usage で無駄呼びを減らす
  • Reserved concurrency は同時実行の天井と緊急停止に有効。月次総量の会計とは役割が違う

次に試すこと

  1. utility.get_usage(または自前の使用量 API)で、当月の monthly_used / remaining を確認する
  2. Authorizer(または入口ミドルウェア)に「使用量がプラン上限以上なら Deny / 429」を入れ、上限超過後に業務 Lambda のメトリクスが増えないか見る
  3. 疎通確認用に認証不要・カウント対象外の /health 相当を用意し、診断から業務 API を外す。除外 path の一覧は使用量加算の直前で参照する
  4. 使わないツールを OFF にし、開発用 Lambda に Reserved concurrency を小さめに設定する。スパイク時は CloudWatch の Throttles と、必要なら AWS Budgets のアラートを確認する

Source