AIエージェントに社内のAPIを叩かせていたところ、毎回同じ種類の失敗をしていました。必須項目を取り違えて400、選択肢に無い値を送って400、レスポンスの形を知らずに「空だった」と誤解する、呼び出し先のURLをソースコードから探す。どれも「呼ぶ前にスキーマが分かっていれば起きない」失敗です。
そこで、既存のAPIから機械可読な仕様(OpenAPI)を作ることにしました。ただ、対象は @hono/zod-openapi を使っていない素の Hono + zod で、エンドポイントは213個あります。書き換えずに仕様だけ欲しい、という状況です。
この記事は、そのために書いた生成器(単一ファイル237行)の実装の話です。MCPサーバーにする方法とトークン消費を実測で比べた結果や、「なぜMCPではなくOpenAPIを先に作ったか」は本家記事(AIエージェントにAPIを操作させる2つの方法。MCPではなく、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にもサービス層にも触れないので、副作用がなく一瞬で終わります。サーバー本体の変更は0行で、追加したのは zod-to-json-schema(devDependency)と npm script だけです。
以下、実際の生成器から要所を抜き出します。
実装1:エンドポイントとスキーマ名を対応づける
エンドポイントの出現位置で本文を区切り、その範囲の中にある parse を探します。
const re = /app\.(get|post|patch|put|delete)\(\s*"([^"]+)"/g;
const idxs: { method: string; pathTpl: string; at: number }[] = [];
while ((m = re.exec(src))) idxs.push({ method: m[1], pathTpl: m[2], at: m.index });
idxs.forEach((cur, k) => {
const end = k + 1 < idxs.length ? idxs[k + 1].at : src.length;
const body = src.slice(cur.at, end);
const b = body.match(/([A-Za-z0-9_]+Schema)\.parse\(\s*await\s+c\.req\.json\(\)/);
const q = body.match(/([A-Za-z0-9_]+Schema)\.parse\(\s*c\.req\.query\(\)/);
// b?.[1] が requestBody、q?.[1] が query のスキーマ名
});
Hono の :id は OpenAPI の {id} に置き換えます。
const p = ep.pathTpl.replace(/:([A-Za-z0-9_]+)/g, "{$1}");
実装2:宣言を切り出す(ここが本質)
一番手間がかかったのは「宣言がどこで終わるか」の判定です。括弧の対応を数えて、深さが0に戻ったところを終わりとみなします。
const re = /^(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*(z[\s.]|\[|\/|"|'|`|\d|true|false|[A-Z][A-Za-z0-9_]*Schema\b)/gm;
while ((m = re.exec(src))) {
// 合成(= XxxSchema.partial();)や素の定数は1行宣言として行末まで取る
if (/^[A-Z\/"'`\d]|^true|^false/.test(m[2])) {
const eol = src.indexOf("\n", m.index);
out.push(src.slice(m.index, eol < 0 ? src.length : eol).replace(/^export\s+/, ""));
continue;
}
// それ以外は括弧の対応を数える
let i = m.index, depth = 0, started = false;
for (; i < src.length; i++) {
const c = src[i];
if ("([{".includes(c)) { depth++; started = true; }
else if (")]}".includes(c)) { depth--; }
if (started && depth === 0) {
while (i < src.length && src[i] !== ";" && src[i] !== "\n") i++;
break;
}
}
// TypeScript 固有の構文は評価前に落とす
const decl = src.slice(m.index, i + 1)
.replace(/^export\s+/, "")
.replace(/\s+as\s+const/g, "")
.replace(/\s+satisfies\s+[^;=]+/g, "");
out.push(decl);
}
正規表現の右辺の候補が長いのは、後述の「つまずいた6パターン」を1つずつ潰した結果です。
実装3:別モジュールから import した共有スキーマも取り込む
ルートファイルの中だけで完結しないスキーマがあります。import { XxxSchema } from "../…" を辿って、依存先の宣言も同じ方法で切り出します。
for (const im of src.matchAll(/import\s*\{([^}]+)\}\s*from\s*"(\.\.[^"]+)\.js"/g)) {
const names = im[1].split(",").map(x => x.trim()).filter(x => /Schema$/.test(x));
if (!names.length) continue;
const dep = path.join(ROUTES, im[2] + ".ts");
if (!fs.existsSync(dep)) continue;
shared += extractDecls(fs.readFileSync(dep, "utf8")) + "\n";
}
実装4:宣言をまとめて評価し、JSON Schema に変換する
const names = [...decls.matchAll(/^const\s+([A-Za-z0-9_]+)\s*=/gm)].map(x => x[1]);
const fn = new Function("z", `${decls}\nreturn {${names.join(",")}};`);
const scope = fn(z); // スキーマ名 → zod オブジェクト
const js = zodToJsonSchema(scope[name], { target: "openApi3", $refStrategy: "none" });
new Function に渡すのは、自分たちのリポジトリのソースから切り出した宣言だけです。外部から受け取った文字列を評価する用途には使わないでください。
つまずいた6パターン
素朴に作った最初の版では、44件が未解決でした。失敗ログを見ながら順に潰した経過です。
| # | 症状 | 原因 | 対処 |
|---|---|---|---|
| 1 | Unexpected identifier 'as' |
] as const; が JavaScript として評価できない |
評価前に 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 の直後で改行している |
正規表現を z[\s.] に緩める |
| 6 | CredentialSecretSchema is not defined |
別モジュールから import された共有スキーマ | import 文を辿って取り込む(実装3) |
最終的に未解決は0件になりました。他のコードベースで試すなら、次の3点はほぼ確実に踏むと思います。
-
TypeScript の型専用構文を落とす前処理は必須です。
as constとsatisfiesは最初の1ファイルで出ました - スキーマは単独では完結しません。 正規表現・数値定数・他モジュールへの依存を必ず持ちます。44件の大半はこれでした
-
1行宣言には別経路が要ります。 合成(
BSchema.partial())や定数(const N = 100;)は括弧が0個のことがあり、括弧を数える方式では終わりを判定できません
最初から完璧を狙わず、まず8割動かして失敗ログを見ながら潰すのが有効でした。実際に出た6パターンは、事前に想像していたものとは違いました。
実装5:レスポンスの「封筒」だけは拾う
レスポンスは DB の行をそのまま返す実装で、zod スキーマがありません。中身の型は諦めて、c.json({ projects: rows }) の projects のような外側のキーだけを拾いました。これが分かるだけで「空に見えた」失敗は消えます。
const envs = [...body.matchAll(/c\.json\(\s*\{\s*([A-Za-z0-9_]+)\s*[:,]/g)]
.map(x => x[1]).filter(x => x !== "error"); // error は例外経路なので除く
const noContent = /c\.body\(null,\s*204\)/.test(body);
const created = /c\.json\([^;]*,\s*201\s*\)/.test(body);
拾えたのは213件中144件(68%)です。残り69件は c.json(result) のように変数を直接返していて、封筒の有無自体がソースから読めません。ここは推測で埋めず、無記載にしました。「書いてあれば信じてよい、書いていなければ実装を見る」という運用のほうが、誤った情報を出すより安全です。
実装6:enum に「なぜその選択肢なのか」を貼り直す
選択肢に無い値を送って400になる失敗は、enum の値が並んでいるだけでは半分しか防げません。「では自分が入れたかった値はどこに書くのか」が分からないからです。
このコードベースでは、設計意図がスキーマではなく、enum の元になる定数の側にコメントで書かれていました。
// 要件明細のカテゴリは固定集合。
// ここを enum で強制することで、アドホックなカテゴリの乱立を書き込み経路を問わず防ぐ。
// 画面固有の分類が必要な場合は category ではなく subcategory を使う(例: 表示項目/ヘッダー)。
export const REQUIREMENT_ITEM_CATEGORIES = ["契機", "表示項目", …] as const;
ところが zodToJsonSchema は enum を値の配列に潰すので、どの定数から来たかが失われます。そこで、値の組み合わせをキーにして後から貼り直しました。
// 定数配列の直前コメントを、値をソートした JSON 文字列で引ける表にする
for (const d of src.matchAll(/^(?:export\s+)?const\s+([A-Z][A-Z0-9_]*)\s*=\s*\[/gm)) {
const c = commentAbove(src, d.index);
const v = scope[d[1]];
if (c && Array.isArray(v)) docs.set(JSON.stringify([...v].sort()), c);
}
// 生成済みの JSON Schema を歩き、enum が一致するノードに description を付ける
function applyEnumDescriptions(node: any, docs: Map<string, string>): void {
if (!node || typeof node !== "object") return;
if (Array.isArray(node.enum) && !node.description) {
const hit = docs.get(JSON.stringify([...node.enum].sort()));
if (hit) node.description = hit;
}
for (const v of Object.values(node)) applyEnumDescriptions(v, docs);
}
これで、エージェントは選択肢だけでなく「独自の分類を作りたくなったら subcategory を使う」という判断までできるようになりました。
コメントを取り込む前に、どの位置にどれだけあるかを数えたのも効きました。
| コメントの位置 | 件数 |
|---|---|
| ファイル冒頭 | 29 / 29ファイル(タグの説明に使った) |
| エンドポイント直前 | 70 / 213(summary に使った) |
| スキーマ宣言の直前 | 3 / 118 |
| 定数配列の直前 | 3 / 19(少ないが中身が濃い) |
スキーマ直前のコメントを拾う実装を先に書いていたら、ほとんど何も付かず、「実装のバグ」と誤認していたはずです。
結果
| 項目 | 値 |
|---|---|
| エンドポイント / パス | 213 / 128 |
| スキーマ(components) | 72 |
| 未解決 | 0 |
| レスポンス封筒を特定 | 144 / 213(68%) |
| 説明の付与 | エンドポイント70 / タグ29 / enum 3 |
| 生成器 | 単一ファイル237行 |
| サーバー本体の変更 | 0行 |
冒頭の4つの失敗が消えたかは、生成した仕様を jq で引いて確かめました。
# 必須項目(必須項目の取り違え → 消える)
jq -r '.components.schemas.RequirementSpecCreateSchema.required' openapi.json
# → ["documentId","title"]
# レスポンスの形(「空に見えた」→ 消える)
jq -r '.paths."/projects".get.responses."200".description' openapi.json
# → OK(レスポンスは { "projects": … } の形で返る)
仕様のファイルは約290KBあります。エージェントに「全部読んで」と渡すと、それだけでコンテキストを大きく消費します。必要な部分だけ jq で引くレシピを、仕様と一緒に手順書に書いておくのが前提です。
別のエージェントで、本当に使われるかを試した
生成して手順書を書いただけでは、エージェントが実際にそれを読むかは分かりません。そこで、OpenAPI にも手順書にも一切触れない指示を別のエージェントに与えて、何を参照するかを観察しました。
このシステムのAPIを使って、ある案件に登録されている要件定義を1件取得したい。エンドポイント・認証・レスポンス形式を調べ、実際に叩いて確認して。調べる過程で参照したファイルと手段を順番に列挙して。
エージェントが辿った経路(自己報告)は次のとおりです。
- 認証情報の取り方を書いた手順書を読んだ(そこから API の手順書へのリンクを見つけた)
- API の手順書を読んだ
-
jqでopenapi.jsonのpathsを引いた。ソースコードの検索はしていない - 呼ぶ前にレスポンスの封筒を確認した
- 3回の呼び出しで目的の1件を取得した
効いたのは「認証情報の取り方」の手順書にリンクを置いたことでした。このAPIを呼ぶ前には必ず認証情報を取りに行くので、APIの仕様を探しに来ないエージェントにも届きます。
一方で、1回だけ失敗しています。取得した要件の中身のフィールド名を name / description と推測して外しました(実際は category / subcategory / content)。原因は上に書いた「レスポンスの中身は型が無い」という限界そのものです。書き込み系はリクエストのスキーマがあるので問題ありませんが、読み取った結果を加工する場面では、実物を1回叩いて確かめる必要が残ります。
試行は1回・1エージェントだけなので、確からしさの主張はできません。それでも、導線を張ったら1回は実際に辿らせてみるべきだと思います。リンクの文字列が存在することと、エージェントがそれを読むことは別の話でした。
古くならないように、コミットのたびに作り直す
仕様を信じて呼ぶようになると、今度は古い仕様が問題になります。仕様が無ければエージェントはソースを読みに行くので遅いが正しく動きます。古い仕様があると、それを信じて呼んで失敗し、しかも原因が分かりにくくなります。
「APIを変えたら npm run openapi を実行する」と手順書に書いても守られないので、pre-commit フックで自動化しました。
ROUTES=$(echo "$STAGED" | grep '^server/src/routes/.*\.ts$')
if [ -n "$ROUTES" ]; then
npm run --silent openapi # 再生成
git add openapi.json # 差分があれば staged に追加
fi
- ルート定義がステージされているときだけ動かす(関係ないコミットでは走らせない)
- 生成に失敗したらコミットを止める(古い仕様のまま入るほうが危険)
-
node_modulesが無い環境では警告を出して続行する(黙って飛ばさない)
残っている限界
- レスポンスの中身の型は未対応(封筒のキーまで)
- パスパラメータの型は全て
string固定。実際は uuid などの制約がある - クエリパラメータは
Schema.parse(c.req.query())の形しか拾えない。c.req.query("x")を個別に読む実装は対象外 - あくまで草案の自動生成で、正となるのは実装
この方式が向く場面
- 既存のAPIが大きく、
@hono/zod-openapiへの移行が現実的でない - 本体を変更したくない(他の人が触っている最中でも実行できる)
- まず機械可読な仕様が欲しい段階
逆に、スキーマの書き方がファイルごとにばらばらなコードベースでは成立しません。今回うまくいったのは、29ファイルすべてが同じパターンで書かれていたからです。新規に作るなら、最初から @hono/zod-openapi を使うのが素直です。
この仕様をエージェントにどう渡すか、MCPサーバーにした場合とトークン消費がどれだけ違うか(213操作で約4倍)は、本家記事で実測値つきで書いています。

