AIエージェントにAPIを操作させる2つの方法。MCPではなく、OpenAPIという選択肢
AIエージェントにAPIを操作させたい。そう考えたとき、最初に浮かぶのはMCP(Model Context Protocol)サーバーを書くことだと思います。私たちもそう考えました。
ただ、対象のAPIはエンドポイントが213個ありました。これを全部ツールにしたら何が起きるのかが分からなかったので、先に測ることにしました。
結果を先に書きます。
213操作をMCPツールにすると、常時16,854トークンを占有しました。同じことをOpenAPI経由でやると4,220トークンで済みました。4.0倍の差です。
そして、実際に作ってみて分かったことのほうが重要でした。
- MCPは「載せるツールを絞る」判断が必須です。しかし絞った時点で、絞らなかった操作は存在しないのと同じになります。213個から使う15個を選ぶのは、使ってみないと決められません
- OpenAPIは既存コードを1行も変えずに自動生成できました。本体の変更はゼロ、devDependencyとnpm scriptの追加だけです
- ただしOpenAPIには「気づかせる仕組み」がありません。ここが一番の手間でした。MCPの本質的な価値は、実はここにあります
- 仕様は古くなった瞬間、無いより有害になります。pre-commitフックで自動再生成する必要がありました
つまり「MCPか、OpenAPIか」という問いの立て方が間違っていました。本質は「機械可読な仕様が実装から自動生成され、それに気づく導線があり、古くならない仕組みがあるか」でした。
なお、相手が外部サービスのAPIでも、自分たちで書いたAPIでも、コンテキストをどれだけ食うかという比較はそのまま当てはまります。違うのは「機械可読な仕様が既にあるかどうか」だけです。
OpenAPIが公開されているAPIを使うなら、後半の「生成する」章は読み飛ばしてかまいません。仕様が無いAPIをどう機械可読にするかは、記事の後半でまとめて扱います。今回の検証対象がたまたま自分たちのシステムだったので、そちらの手順まで含めています。
以下、実測データと実際に動いているコードを掲載します。
AIエージェントがAPIを叩くたび、同じ失敗をしていました
きっかけは、AIエージェントが社内管理システム(案件・見積・請求情報などを扱うもの)のAPIを呼ぶたびに失敗していたことです。1セッションで無駄な往復が4回発生していました。
| # | 失敗 | 原因 |
|---|---|---|
| 1 | 必須項目とは別のIDを送り、エラーになった例: 書類のID(documentId)が必要なところに、案件のID(projectId)を送った | どの項目が必須かを知らなかった |
| 2 | 決められた選択肢に無い値を送り、エラーになった例: 分類の欄に、6種類の選択肢に無い「保持データ」を入れた | 入力できる値が6種類に固定されていると知らなかった |
| 3 | 結果が返っているのに、空だと誤解した例: 一覧が {projects: [...]} の中に入っていて、その中を見ていなかった | データが一段包まれた形で返ることを知らなかった |
| 4 | 呼び出し先のURLを、ソースコードを検索して探した例: 「案件一覧を取るURL」を、サーバーのコードを文字列検索して探した | 使えるAPIの一覧が無かった |
4つに共通しているのは、呼ぶ前にスキーマが分かっていれば起きなかったという点です。エージェントの賢さの問題ではなく、情報が無い状態で叩かせていたことが問題でした。
素朴な解「MCPサーバーにする」の限界
最初に検討したのはMCPサーバー化です。MCPのツール定義はJSON Schemaを含むため、失敗1・2・4は確かに消えます。
問題は規模でした。
MCPのツール定義は、常にコンテキストに載り続けます
ほとんどのMCPクライアントは、ツール定義を事前にLLMのシステムプロンプトへ読み込む設計になっています。モデルはその一覧を見て「どのツールを使うか」を判断するため、結果として使わなかったツールの定義も載り続けます。
これは実装の都合ではなく、仕様も前提にしている挙動です。
なお、以下で「最新の仕様」と書いているのは、2026年7月に公開されたMCPの仕様(版名: 2026-07-28)のことです。MCPの仕様はバージョン番号ではなく、公開日で版を区別しています。
その最新の仕様は tools/list の並び順について、決定的な順序が「tools are included in model context(ツールがモデルのコンテキストに含まれる)」ときのプロンプトキャッシュのヒット率を上げる、と書いています。ツールがコンテキストに載ることは織り込み済みです。
この負荷は実測されています。azukiazusa氏の記事では、serena・playwright・next-devtools・chrome-devtoolsの4つのMCPサーバーを接続した状態で、55.7k(コンテキストの27.9%)がツール定義に消費されていたと報告されています。
サーバー4つでこれです。私たちが扱おうとしていたのは213エンドポイントでした。
213操作をツール化すると16,854トークンでした
実際に測りました。生成済みのOpenAPI仕様(後述)から、1操作=1ツールの形でMCPツール定義を機械生成し、シリアライズしたJSONのトークン数を数えています。
| 項目 | 値 |
|---|---|
| ツール数 | 213 |
| 合計 | 16,854トークン(200kコンテキストの8.4%) |
| 1ツールあたり平均 | 79トークン |
| 中央値 | 50トークン |
| 最大 | 456トークン(PUT /ui-screens/{id}/elements) |
会話が始まる前に8.4%が埋まります。
共有スキーマ72件が、各ツールに展開されます
数字が膨らむ理由は、スキーマの重複にあります。
OpenAPIには components.schemas という共有領域があり、同じスキーマを何度 $ref で参照しても実体は1つです。一方MCPの tools/list が返すのは、ツールごとに自分の inputSchema を持った配列です。仕様上、ツールをまたいでスキーマを共有する領域がありません。
「外部の共有スキーマを $ref で参照すればよいのでは」と思うかもしれませんが、これも仕様で塞がれています。最新の仕様の $ref Resolution にはこうあります。
Implementations MUST NOT automatically dereference
$refvalues that resolve to a network URI.
ネットワークURIの $ref は既定で解決してはならず、opt-in で有効化する場合も既定は無効・ホスト許可リスト付きが求められます。共有スキーマを外に置いて参照する運用は、既定では成立しません。
結果、共有スキーマは参照しているツールの数だけコピーされます。
| トークン数 | |
|---|---|
| 共有スキーマ72件を1回だけ持つ場合 | 8,747 |
| MCPで各ツールに展開した場合 | 16,894 |
同じ内容が、およそ2倍に膨らんでいます。エンドポイントが増えるほどこの差は開きます。
最新仕様に入った「ツール一覧のキャッシュ」では解決しないのか
ここで当然の疑問が出ます。最新の仕様では、サーバーが返すツール一覧をクライアント側でキャッシュできるようになりました。レスポンスに有効期限(ttlMs)と共有範囲(cacheScope)が付き、毎回取り直さずに済みます。決定的な並び順を推奨しているのも、プロンプトキャッシュのヒット率を上げるためです。
これは効きます。ただし効く先が違います。
| 何が改善するか | この記事の問題に効くか |
|---|---|
| ツール一覧の再取得が減る | 効く(通信とレイテンシ) |
| プロンプトキャッシュのヒット率が上がる | 効く(課金額と初回応答速度) |
| コンテキスト窓の占有 | 効かない |
プロンプトキャッシュは「同じ内容を安く・速く送る」仕組みであって、送る量そのものは減りません。16,854トークンがキャッシュに乗っても、200kのうち16,854トークンが埋まっている事実は変わらず、モデルが読む対象も減りません。
コンテキストが長くなるほど情報を正確に取り出しにくくなる(Context Rot)ことを踏まえると、安く積めるようになったことは、積んでよい理由にはなりません。
OpenAPIを先に作るとどうなるか
比較対象として、OpenAPI経由の構成も同じ仕様から機械生成して測りました。
常時載せるのは「汎用ツール1個 + 操作カタログ」でよい
考え方はこうです。213個のツールを常駐させる代わりに、常駐させるのは次の2つだけにします。
- 汎用ツール:
methodとpathを受け取ってAPIを呼ぶツール(1個) - 操作カタログ:
GET /projects — 案件一覧を返すのような1行×213行の一覧
そして、詳細なスキーマ(必須項目・enum・リクエストボディ)は、実際にその操作を使う段になってから引くという設計です。
実測比較
| 方式 | 常時コンテキストに載る量 | 200k中の占有 |
|---|---|---|
| MCP(213操作を全部ツール化) | 16,854トークン | 8.4% |
| OpenAPI(汎用ツール2個 + カタログ213行) | 3,388トークン | 1.7% |
| OpenAPI + 実際に使った10操作をオンデマンド取得 | 4,220トークン | 2.1% |
4.0倍の差、削減率75.0%でした。内訳では、汎用ツールの定義が197トークン、カタログ213行が3,191トークンです。
参考として、OpenAPI仕様(289KB)を丸ごと文脈に入れると35,697トークン(17.8%)になります。これは後述するとおり、やってはいけない読み方です。
計測の条件を書いておきます。トークン数は gpt-tokenizer(o200k_base)で数えた値です。Claudeの実トークナイザとは差が出ますが、同一の仕様から両方式を生成して比較しているため、比率の傾向は変わりません。また、この仕様は説明文が213操作中70操作にしか付いていないため、MCP側にとって有利な下限値です。全操作に説明を付ければMCP側はさらに増えます。
既存コードを1行も変えずにOpenAPIを生成する
ここからは、そのOpenAPIをどう作ったかです。
前提:スキーマはモジュールローカルの定数でした
対象は素のHono + zodで書かれたAPIです。@hono/zod-openapi は使っていません。
// routes/requirement-specs.ts
const RequirementSpecCreateSchema = z.object({
documentId: z.string().uuid(),
title: z.string().min(1),
// …
});
app.post("/requirement-specs", async (c) => {
const body = RequirementSpecCreateSchema.parse(await c.req.json());
// …
});つまり、次の状態でした。
- ルート定義とスキーマが構造的に結びついていない(規約で対応しているだけ)
- スキーマは
exportされていないモジュールローカルの定数 - 規模はルートファイル29個、エンドポイント213個
スキーマが export されていないので、「実行時に import して読む」ができません。かといって @hono/zod-openapi へ移行するには213エンドポイントを書き換える必要があり、現実的ではありませんでした。
方針:ソースから宣言だけを切り出して評価する
本体を変更しない方法として、次の手順を採りました。
app.<method>("<path>"を正規表現で拾い、エンドポイント一覧を作る- そのハンドラ本文から
XxxSchema.parse(await c.req.json())を拾い、パスとスキーマ名を対応づける const XxxSchema = z.…の宣言部分だけをソースから切り出す- 切り出した宣言を
new Function("z", …)で評価して、zodスキーマの実体を得る zod-to-json-schemaでJSON Schemaに変換する
要点は、ハンドラ本文は評価しないことです。宣言だけを取り出して動かすので、DBにもサービス層にも触れません。副作用がなく高速です。
パスとスキーマの対応づけは、エンドポイントの出現位置で本文を区切って判定しています。
const idxs = [...src.matchAll(/app\.(get|post|patch|put|delete)\(\s*"([^"]+)"/g)];
idxs.forEach((cur, k) => {
const end = k + 1 < idxs.length ? idxs[k + 1].index : src.length;
const body = src.slice(cur.index, end);
const b = body.match(/([A-Za-z0-9_]+Schema)\.parse\(\s*await\s+c\.req\.json\(\)/);
// b?.[1] が requestBody のスキーマ名
});つまずいた6パターン
素朴に実装すると、44件が未解決になりました。失敗ログを見ながら順に潰した経過が次の表です。
| # | 症状 | 原因 | 対処 |
|---|---|---|---|
| 1 | Unexpected identifier 'as' | ] as const; がJSとして評価できない | 評価前に as const / satisfies … を除去 |
| 2 | XxxUpdateSchema を解決できず | const A = BSchema.partial(); の合成を拾えていない | 右辺に 識別子Schema を許可し、1行宣言として扱う |
| 3 | DATE_REGEX is not defined | スキーマが素の定数(正規表現)を参照 | 右辺が記号で始まる宣言も取り込む |
| 4 | MAX_EVENTS_PER_BATCH is not defined | 同上(数値リテラル) | 右辺が数値・真偽値の宣言も取り込む |
| 5 | InvoiceCreateSchema を解決できず | const X = z の直後で改行を挟む | 正規表現を空白許容に緩める |
| 6 | CredentialSecretSchema is not defined | 別モジュールからimportされた共有スキーマ | import 文を辿って取り込む |
最終的に未解決は0件になりました。
一般化できる3つの教訓
このアプローチを他のコードベースで試す方に向けて、再現しそうな部分を挙げます。
1つ目。TypeScriptの型専用構文を落とす前処理が要ります。 今回は as const と satisfies が最初の1ファイルで出ました。zodスキーマを素のJavaScriptとして評価する以上、型専用の構文はどこかで必ず邪魔をします。どの構文が出るかはコードベース次第ですが、前処理そのものは省けません。
2つ目。スキーマは単独では完結しない前提で作るべきです。 今回は正規表現・数値定数・他モジュールの共有スキーマへの依存が出ました。44件の未解決のうち大半がこれです。「zodスキーマだけ切り出せばいい」という当初の前提は成り立ちませんでした。依存の種類は違っても、何にも依存しないスキーマばかりのコードベースは考えにくいと思います。
3つ目。「宣言の終わり」の判定が本質です。 括弧の対応を数える方式にしましたが、合成(BSchema.partial())や定数(const N = 100;)は括弧が0個のことがあるため、別経路が必要でした。
進め方としては、まず8割動かして、失敗ログを見ながら潰すのが有効でした。最初から完璧を狙うと、実在しないパターンにも備えることになります。実際に出た6パターンは、事前に想像していたものとは違いました。
仕様の価値は「なぜそう決めたか」にあります
生成できた仕様を眺めていて気づいたことがあります。enumの値が並んでいるだけでは、失敗2は防げなかったということです。
冒頭の失敗2は、選択肢に無い値(category: "保持データ")を送ってエラーになったものでした。選択肢が6種と分かっていれば、確かにこのエラーは避けられます。しかし「では自分が入れたかったこの概念はどこへ書くのか」は分かりません。
コメントの位置を、取りに行く前に数えました
このコードベースには日本語コメントが充実していたので、description として取り込むことにしました。
このとき有効だったのは、実装する前に「どの位置にどれだけあるか」を数えたことです。
| コメントの位置 | 件数 | 判断 |
|---|---|---|
| ファイル冒頭 | 29 / 29ファイル | タグの説明に使える |
| エンドポイント直前 | 70 / 213(32%) | summaryに使える |
| スキーマ宣言の直前 | 3 / 118 | ほぼ無い |
| フィールド行の末尾 | 3 | ほぼ無い |
| 定数配列の直前 | 3 / 19 | 少ないが中身が濃い |
このコードベースには「スキーマにコメントを付ける」文化がありませんでした。最初はスキーマ直前のコメントを拾う実装をしたのですが、ほとんど何も付きませんでした。数えていなければ「実装のバグ」と誤認して時間を溶かしていたと思います。
設計意図は定数側に書かれていました
では意図はどこにあったかというと、enumの元になる定数の側でした。
// 要件明細のカテゴリは固定集合(これまでの実データの分類と一致させている)。
// ここを enum で強制することで、「認証機能」「操作」等のアドホックなカテゴリ乱立
// (=画面単位ではなく機能グルーピングした要件登録)を書き込み経路を問わず防ぐ。
// 画面固有の分類が必要な場合は category ではなく subcategory を使う(例: 表示項目/ヘッダー)。
export const REQUIREMENT_ITEM_CATEGORIES = ["契機", "表示項目", …] as const;問題は、zodToJsonSchema がenumを値の配列に潰してしまうため、この対応関係が失われることでした。
そこで、値の組み合わせをキーにして、後からdescriptionを貼り直す方式を採りました。
// 定数配列のコメントを「値をソートしたJSON文字列」で引ける表にする
docs.set(JSON.stringify([...values].sort()), comment);
// 生成済み JSON Schema を歩き、enum が一致するノードに description を付ける
if (Array.isArray(node.enum) && !node.description) {
const hit = docs.get(JSON.stringify([...node.enum].sort()));
if (hit) node.description = hit;
}結果、category は選択肢だけでなく「なぜその制約があるか」まで読めるようになりました。使ってよい値だけでなく、使ってはいけない書き方が分かります。「独自カテゴリを作りたくなったら subcategory を使う」という判断ができる状態です。
仕様の価値は「何が許されるか」より「なぜそう決めたか」にある。そしてコメントは散らばっているので、位置ごとに数えてから取りに行く。これが得られた教訓です。
生成しただけでは、使われません
ここが最大の落とし穴でした。
仕様が生成できて満足していたのですが、リポジトリ内を検索したところ、この仕様への言及がどこにも存在しませんでした。
$ grep -rl "openapi" CLAUDE.md .claude/ docs/
(出力なし)AIエージェントは、ファイルが存在することを知りません。知らなければ結局ソースをgrepして推測するので、生成前と何も変わりません。
「読め」と書くのも間違いです
では「openapi.json を読んでください」と書けばいいかというと、そうでもありませんでした。
| 値 | |
|---|---|
| ファイルサイズ | 289 KB |
| 行数 | 11,151 |
| 1 エンドポイントの定義 | 平均 453 文字(中央値 456) |
1エンドポイントを知るためにファイル全体を開くと、コンテキストの大半を消費します。仕様を用意した結果、かえって動きが悪くなるという本末転倒が起きます。
採った解は、所在・jq レシピ・再生成方法の3点をセットにしたエージェント向けの手順書を用意することでした。
# 必須項目だけ
jq -r '.components.schemas.RequirementSpecCreateSchema.required' openapi.json
# → ["documentId","title"]
# レスポンス形式
jq -r '.paths."/projects".get.responses."200".description' openapi.json
# → OK(レスポンスは { "projects": … } の形で返る)流入経路を4つ用意しました
そのうえで、エージェントがどこから来ても気づけるように導線を張りました。1箇所に書いただけでは届きません。
| 経路 | 場所 | 誰が通るか |
|---|---|---|
| 1 | エージェントが最初に必ず読む設定ファイル(CLAUDE.md)の手順一覧 | 全エージェント(必ず読む) |
| 2 | 認証情報の取り方を書いた手順書 | 認証情報が必要になったエージェント |
| 3 | システムごとの説明ファイル | その案件を触るエージェント |
| 4 | grep | システム名で検索した場合 |
効いたのは経路2でした。このシステムは認証情報の保管にも使っているため、別の作業の途中でも頻繁に呼ばれます。その手順書に書いておいたことで、仕様にも気づいてもらえました。「API仕様」の棚だけに置くと、そこを見に来ない経路の利用者には届きません。
仕様書は「作る」より「気づかせる」ほうが難しい、というのがここでの結論です。誰がどこから来るかを列挙して、その全部に導線を置く必要がありました。
古い仕様は「無い」仕様より有害です
導線を張った以上、エージェントは仕様を信じます。信じられるものにする責任が生まれます。
| 状態 | エージェントの動き | 結果 |
|---|---|---|
| 仕様が無い | ソースを読みに行く | 遅いが正しい |
| 仕様が古い | 仕様を信じて呼ぶ | 失敗する。しかも原因が分かりにくい |
古い仕様が厄介なのは、仕様どおりに書いたのに落ちるため、エージェントが自分の書き方を疑って堂々巡りになる点です。「無い」ほうがまだましな状態が存在します。
手順書に書くだけでは守られません
システムの説明ファイルに「APIを変更したら npm run openapi を実行する」と書きましたが、これは強制力を持ちません。私たちのリポジトリには前例がありました。
以前の監査で、「整形コマンドはあるのに実行されない」という状態が実在していたことが分かっています。3つの構成要素にまたがって、未整形のファイルが合計93件残っていました。手順として書いただけでは実行されません。
pre-commitフックで自動再生成する
そこで、既存フックと同じ「止めるのではなく直す」方針で自動化しました。
ROUTES=$(echo "$STAGED" | grep '^server/src/routes/.*\.ts$')
if [ -n "$ROUTES" ]; then
npm run --silent openapi # 再生成
git add openapi.json # 差分があれば staged に追加
fi設計上の判断を4点書いておきます。
| 論点 | 判断 |
|---|---|
| CIかフックか | フック。mainへ直接コミットする運用のため、PRでブロックするCIは効きにくい |
| いつ動かすか | stagedにルート定義がある時だけ。関係ないコミットでは走らせない |
| 生成に失敗したら | コミットを止める。仕様が古いまま入るほうが危険 |
node_modules が無い環境 | 警告を出して続行。黙って飛ばさない |
ルート定義のコメントを変更してコミットすると openapi.json の summary が追随することを、実機で確認しています。
結果と、残っている限界
最終的な数字です。
| 項目 | 値 |
|---|---|
| エンドポイント | 213 |
| パス | 128 |
| スキーマ(components) | 72 |
| 未解決 | 0 |
| レスポンス封筒を特定 | 144 / 213(68%) |
| 説明の付与 | エンドポイント70 / タグ29 / enum 3 |
| 出力サイズ | 289 KB |
| 生成器の実装 | 単一ファイル237行 |
| サーバー本体の変更 | 0行(devDependencyとnpm scriptの追加のみ) |
冒頭に挙げた4つの失敗は、すべて解消しました。
限界も書いておきます。レスポンスの中身の型は未対応です。封筒のキー({projects: …})は、ハンドラ本文から c.json の第1引数だけを正規表現で拾う方式で144件(68%)を特定しましたが、残り69件は c.json(result) のように変数を直接返しているため、封筒の有無自体がソースから読めません。
ここは無記載にする判断をしました。利用側から見ると「封筒があると書いてあれば信じてよい、書いていなければ実装を見る」という運用になります。誤った情報を出すよりは安全だと考えています。
このほか、パスパラメータの型は全て string 固定、クエリパラメータは一部の書き方しか拾えていません。あくまで草案の自動生成であり、正となるのは実装です。
この方式が向く場面、向かない場面
向くのは、既存APIが大きくて @hono/zod-openapi への移行が現実的でない場合、本体を変更したくない場合、そして「まず機械可読な仕様が欲しい」段階です。
向かないのは、スキーマの書き方が統一されていないコードベースです。今回うまくいったのは29ファイルすべてが同じ設計パターンで書かれていたからで、ここが揃っていなければ成立しません。
なお、新規に作るなら最初から @hono/zod-openapi を使うべきです。この方式は、既にあるものを機械可読にするための手段です。
それでもMCPを選ぶべき場面
ここまでOpenAPI側の話を書いてきましたが、MCPを否定する意図はありません。作り終えてから振り返ると、両者の差は「便利さ」ではなく次の2点に集約されました。
| 論点 | OpenAPI + jq | MCP |
|---|---|---|
| コンテキストの消費 | 必要な部分だけ引ける(1 エンドポイント平均 453 文字) | ツール一覧が常に載る。213個は載せられない |
| 気づかせる仕組み | 自前で導線を張る必要がある | ツールとして提示されるため自動的に気づく |
MCPの本質的な価値は「存在を知らせなくても使われる」ことにあります。 ホストがツール一覧を提示してくれるので、導線の設計が要りません。私たちが一番手間をかけたのがこの導線だったので、これは大きな利点です。
複数のクライアントから同じツール群を共有する場合、ツールが頻繁に増減してランタイムでの発見が必要な場合、prompts や resources といったMCP固有の機能を使う場合も、MCPが適します。
一方でMCPには「載せるツールを絞る」判断が必須です。そして絞った時点で、絞らなかった操作は存在しないのと同じになります。213個から使う15個を選ぶのは、使ってみないと決められません。
だから順序としては、OpenAPIが先で正しかったと考えています。使用頻度の高い操作が固まってからMCPを載せれば、そのツール定義自体を openapi.json から自動生成できます。両者は対立しません。
まとめ:判断基準
問いを「MCPか、OpenAPIか」と立てると答えが出ません。次の3点で考えるほうが実態に合っていました。
- 機械可読な仕様が、実装から自動生成されているか。手書きの仕様書は必ず実装と食い違います
- その仕様に気づく導線があるか。生成しただけでは使われません。MCPはここを肩代わりしてくれます
- 古くならない仕組みがあるか。古い仕様は無い仕様より有害です
そのうえで、判断はこうなります。
- エンドポイントが多く、使う操作がまだ絞れていない → OpenAPIを先に作る
- 使う操作が10〜20個に固まっていて、複数クライアントで共有したい → MCPにする(定義はOpenAPIから生成できる)
- どちらにせよ → 自動生成と陳腐化対策をセットで用意する
今回の作業は、サーバー本体を1行も変えずに237行の生成器を書くだけで完了しました。同じ構成のAPIをお持ちであれば、試す価値はあると思います。
私たちは、AIエージェントを実際の業務に組み込んで運用しています。既存システムをAIから扱える状態にする設計や、開発体制そのもののご相談も承っています。お気軽にお問い合わせください。
