String() で勝手に直していたからバグを見逃していたz.union() や z.coerce.string() には別の危険があるので、筆者は preprocess で限定的に吸収する方法を選んでいるこの記事で扱っているのは、かなり地味だけれど、実はものすごく厄介なバグです。
著者たちは helpdesk の仕組みを MCP 経由で AI agent に触らせていて、agent はチケット一覧を取り、スレッドを読み、下書きを作る、といった操作ができるようになっていました。
で、ある日、実際の agent が「ある操作の結果を次の操作にそのまま渡す」という、いかにも自然な流れをやったところ、そこで止まった。
原因は単純で、ある tool は id を number で返すのに、別の tool は同じ id を string で受け取るようになっていたからです。
たとえばこんな感じです。
create_ticket の返り値: {"ticketId": 47, ...}get_ticket_context の入力: {"ticketId": {"type": "string", "minLength": 1}}人間なら、たぶん 47 を見て「文字列として渡せばいいんだな」と勝手に直します。
でも AI agent はそういう気の利いた補正をしません。ドキュメントに書いてある通り、数値の 47 をそのまま次の tool に渡す。するとバリデーション(入力チェック)で弾かれる。
これ、かなり嫌なバグです。
なぜかというと、システム全体は一見ちゃんと動いているように見えるからです。普通の人間ユーザーは手で操作するので気づかない。でも agent は律儀に壊れた道をまっすぐ進む。壊れているのはコードというより、人間の前提のほうだったわけです。
この記事の面白いところは、著者たちが「もちろんテストは通っていた」と認めている点です。ここがかなり刺さる。
テストでは、id を渡すときに毎回こんなことをしていたそうです。
String(ticket.id)
つまりテストは、ちゃんとした人間が気をつけて入力した世界を再現していた。
でも実際の agent は、そんな親切な入力のしかたはしません。出力を見て、そこから次へつなぐだけ。だから、テストは「存在しない理想の利用者」を検証していたことになります。
この話、かなり本質的だと思います。
テストって、書いている人の頭の中の「こうしてくれるはず」という期待を、無意識に混ぜ込みやすいんですよね。人間相手のUIならそれでも多少は吸収される。でも AI agent は、こちらの期待を読まず、命令の形をそのまま実行する。だから、テストの甘さがそのまま露出する。
個人的には、ここがいちばん怖いポイントだと思いました。
バグそのものより、バグを見逃す構造が完成していたことのほうがずっと痛い。
この種の問題で、つい考えたくなる修正は2つあります。
1つは、返り値の id も string に揃えてしまうこと。
でも著者たちはこれはやめています。理由は、既存クライアントにとってレスポンスの形が変わるからです。API の返り値は契約みたいなものなので、黙って変えるのは危ない。
もう1つは、入力側で z.union([z.string(), z.number()]) のように両方受けること。
ただしこれには anyOf という別の契約変更が発生します。anyOf は「どちらかを受け入れる」という意味で、JSON Schema の見え方が変わる。ツール一覧や OpenAPI のように、API を他のクライアントへ公開している場合、これもまた無視できない変更です。
さらに、z.coerce.string() のような方法もありますが、これも危ない。
null が "null" になったり、undefined が "undefined" になったりして、本来ははっきり失敗すべき入力を、変な文字列として通してしまう。そうなると、エラーの場所がズレて、あとで not found みたいな分かりにくい失敗になりがちです。
著者が採ったのは、その中間のやり方です。
数値の safe integer だけを string に変換し、それ以外はそのまま落とす。かなり地味だけど、すごく筋がいい。
つまり、こういう発想です。
47 のような正しい id だけ救うnull、{}、小数、負数はそのまま失敗させるここ、実務的でいいです。私はかなり好きです。
派手な「全部受ける」より、壊れているものは壊れているまま返すほうが、長期的にずっと安全なんですよね。

本文を読んでいて強く感じたのは、AI agent って「未来の便利機能」というより、API の欠陥を容赦なくあぶり出す新しい利用者なんだな、ということです。
人間は文脈を補います。
たとえば「id を渡して」と書いてあれば、実際には型が違ってもなんとなく合わせます。
でも agent は、そこにない空気を読まない。読めない。だからこそ、ドキュメントがそのまま実装上の挙動を決める。
この性質は便利でもあり、怖くもあります。
便利なのは、こちらが書いた仕様を本当にそのまま使ってくれること。
怖いのは、仕様の穴までそのまま踏むこと。
著者が「Agents are the most literal API consumers you will ever have」と書いているのは、かなり名言だと思います。
AI agent は、API の“もっとも忠実な読者”なんですね。人間なら勝手に補ってしまうところを、勝手に補わない。だからこそ、ドキュメントの甘さが一番きれいに露呈する。

記事の最後に出てくるチェックリストも、かなり実践的です。
まず大事なのは、自分のAPI出力を、そのまま入力に戻すテストを書くこと。
String() や Number() で整えない。生のまま返す。これをやるだけで、今回みたいなズレはかなり見つけやすくなります。

次に、テスト内の「防御的な型変換」を探すこと。
String(id) があるなら、そこは一度疑ったほうがいい。
その変換は親切のようでいて、実は「本番の失敗をテストが代わりに隠している」だけかもしれません。
そして、validator を変えたら JSON Schema の差分を見ること。
ここは地味ですが重要です。
「同じ値を受け入れる」ことと、「同じ契約として見える」ことは別問題だからです。API はただ動けばいいのではなく、他のクライアントが見て分かる形で安定していないと困る。
最後に、実際に返ってくるレスポンスを人間の目で読むこと。
これも地味だけど効きます。著者たちは、今回の paid call で id の問題だけでなく、レスポンス文面の文法ミスにも気づいたそうです。
つまり、コードだけ見ていると見えないズレが、実際の出力には出てくる。これはかなり人間くさい話だなと思いました。

この記事を読んで、「これはたまたま id の型がズレただけ」と片づけるのはもったいないです。
本質はもっと広くて、**“人間が読むドキュメント” と “機械が実行するドキュメント” の違い**にあります。
人間向けの仕様書は、多少あいまいでも通ります。
でも AI agent は、あいまいさを補完してくれない。
その結果、今まで見逃されていた「入力の型」「出力の型」「エラーの見え方」のズレが、いきなり表面化する。
これは面倒です。かなり面倒。
でも面倒だからこそ、きちんと直す意味があるとも思います。
AI に API を触らせるなら、API はもう「人間がだいたい分かる」で済ませてはいけない。ドキュメントもテストも、機械がそのまま従えるレベルまで冷たく正確にしておく必要がある。

この記事は、そのことをかなり具体的に、しかも失敗談として教えてくれます。
失敗の話なのに、読後感はわりと良いです。というのも、著者がちゃんと全体を見直し、契約を壊さない形で直しているからでしょう。こういう泥臭い改善は、派手なAIデモよりずっと信頼できます。
参考: Our API docs told AI agents to do the exact thing that fails