書類認識ツールをエディタに入れるのは1分で済みます。そのあと何に使うかを見極めるほうが時間がかかります。この記事ではセットアップの説明を省き、パスポートが絡むときに開発者が実際に Claude Code や Cursor に任せている3つの作業に直接入ります。
- パスポートのスキャン画像が入ったフォルダを CSV に変換する。途中でクラッシュしても続きから再開できる形で。
- 開発中の登録フォームと1冊のパスポートを照合する。
- スキャン結果を一度だけ記録してテストフィクスチャとして使い、テストスイートが本番のサービスを一切呼ばないようにする。
3つとも doc.cheap の MCP サーバー、パッケージ @doc-cheap/mcp(MIT、Node 20 以降)を使います。これは doc.cheap 自身のブログで、そのサーバーの背後にあるパスポート・身分証 OCR API の提供元です。製品選びについてはその点を踏まえてお読みください。パターンそのもの(囲い込んだ画像ディレクトリ、ファイルごとに1つのキー、一度だけ記録するフィクスチャ)は、この種のツールならどれにでも当てはまります。
サーバーがエディタに提供するもの
6つのツールがあり、それぞれにクライアントが「先にユーザーに確認すべきか」を判断する前に読むヒントが付いています。
| ツール | 機能 | クレジットを消費するか |
|---|---|---|
scan_document |
パスポート、身分証、運転免許証を読み取り、構造化 JSON で返す | 書類が認識された場合のみ |
check_balance |
残りクレジットと今月のカウンター | いいえ |
search_docs |
サーバーに同梱された API ドキュメントをオフラインで検索 | いいえ |
list_scans |
アカウントに保存されたスキャンを新しい順に一覧表示 | いいえ |
get_scan |
保存済みのスキャンを id で1件読み戻す | いいえ |
delete_scan |
保存済みのスキャンを1件、完全に削除 | いいえ |
scan_document は画像を image_base64、image_url、またはローカルサーバーでは image_path で受け取り、さらに任意の expect_country、return_portrait、retain_hours、reference、idempotency_key を指定できます。以下のワークフローの大部分は、この5つのオプションで成り立っています。
キーを設定していない場合、サーバーは公開サンドボックスキーを使います。アドレスごとに合計10件まで無料で書類を認識でき、リクエストはアドレスごとに1時間あたり最大10件です。ワークフロー2と3にはこれで足ります。ワークフロー1には自分のキーが必要です。登録すると毎月100回の認識が無料で使えます。
これまで MCP サーバーをエージェントにつないだことがない場合は、MCP 経由でエージェントに身分証を読ませるで基本とプライバシーの論点をより詳しく解説しています。
ワークフロー1:スキャン画像のフォルダを CSV に(Claude Code)
作業内容。 パスポートのスキャン画像が入ったフォルダ(ホテルの未処理の宿泊者分、イベントの参加者リスト、紙のアーカイブからの移行)を渡され、スプレッドシートを求められたとします。1ファイル1行、必要な項目、そして読み取れなかったファイルごとに分かりやすいメモを付けたものです。
セットアップ。 プロジェクトのルートに .mcp.json を置きます。Claude Code はこれをプロジェクト専用のサーバーリストとして読み込むので、設定はリポジトリと一緒に持ち運べます。
{
"mcpServers": {
"doc-cheap": {
"command": "npx",
"args": ["-y", "@doc-cheap/mcp"],
"env": {
"DOC_CHEAP_API_KEY": "${DOC_CHEAP_API_KEY:-}",
"DOC_CHEAP_IMAGE_ROOT": "/Users/you/guest-backlog/scans"
}
}
}
}
そのフォルダで初めて起動するとき、Claude Code は .mcp.json のサーバーを承認するよう求めます。
肝心なのは2行です。
DOC_CHEAP_API_KEYはシェルから取り込まれるので、キーがコミットされることはありません。変数が未設定なら値は空になり、サーバーはサンドボックスキーに切り替わります。DOC_CHEAP_IMAGE_ROOTは、サーバーがファイルを読んでよい唯一のディレクトリです。これがないとimage_pathはその場で拒否されます。これがあると、パスはまずシンボリックリンクをたどって解決され、ディレクトリの外に出るもの(..や外を指すリンク)は拒否されます。相対パスのimage_pathはこのディレクトリを起点に解釈されるため、エージェントはファイル名だけを渡せば済みます。
この囲い込みは、ここで何より重要です。ファイル名を決めるのはエージェントで、そのエージェントはあなたが作ったわけではないフォルダを読んでいるからです。また、画像が会話を経由することもありません。サーバーがファイルを読み、そのまま API に送ります。
プロンプト。
まず check_balance を呼んでください。次に、スキャンディレクトリ内のすべての JPEG と PNG について、image_path にファイル名、reference にファイル名、idempotency_key に "backlog-" とファイルの SHA-256 をつなげたもの、return_portrait に false、retain_hours に 24 を指定して scan_document を呼んでください。列 file, status, billed, kind, country, number, surname, given_names, birth_date, expiry_date, is_expired, mrz_status を持つ backlog.csv を書き出してください。status が recognized 以外の場合、データ列は空のままにしてください。最後に、認識されなかったファイルとその理由を一覧にしてください。
各オプションを付ける理由は次のとおりです。
referenceは結果のmeta.referenceにそのまま返され、list_scansの各行にも表示されます。保存された各スキャンを、開かずに元のファイルまでたどれます。idempotency_keyがあるから、バッチを安全に再開できます。本番キーでは、同じキーでの再送は認識と課金をやり直さず、最初の結果を返します。300件中140件目でセッションが落ちたら、同じプロンプトをもう一度実行してください。最初の139件は無料のリプレイとして戻り、そこから作業が続きます。ランダムな UUID ではなくファイルのハッシュからキーを作るからこそ、新しいセッションでも同じキーを再現できます。Claude Code はshasum -a 256でこれを計算できます。retain_hours: 24は、冪等性ガイドが説明しているトレードオフです。リプレイは保存された結果を返すので、retain_hours: 0で行ったスキャンはリプレイできません。24時間以内にそのキーで再試行するとidempotency_replay_unavailableで拒否されます。1日の保存期間なら再試行は無料のまま、翌日には何も残りません。return_portrait: falseは所持者の顔写真の切り抜きを省きます。CSV には不要だからです。
返ってくるもの。 各行の status は recognized、no_document_found、unreadable、unsupported_document、rejected のいずれかで、billed はそのファイルでクレジットが消費されたかどうかを示します。課金されるのは recognized だけなので、ぼやけた写真が20枚混ざったフォルダなら、ファイル数より20クレジット少なく済みます。billed 列は、そのバッチで使った量を自分で数えた記録にもなり、請求書と突き合わせる必要はありません。
もう1つの習慣。 この CSV には個人データが含まれます。エージェントが書き出す前に .gitignore に追加してください。後からではなく。
ワークフロー2:フォームを作りながらパスポートを1冊照合する(Cursor)
作業内容。 登録フォームやチェックインフォームを作っていて、項目名、形式、バリデーションを決める前に、実際のスキャンが何を返すのかを確認したい場面です。
セットアップ。 さっと試すだけなら、Cursor はインストール不要のホスト型サーバーを使えます。プロジェクトの .cursor/mcp.json に次のように書きます。
{
"mcpServers": {
"doc-cheap": {
"url": "https://mcp.doc.cheap/mcp"
}
}
}
キーのヘッダーがない場合、ホスト型サーバーはサンドボックスキーを使います。ホスト型サーバーからはあなたのマシン上のファイルが見えないため、scan_document は画像を image_base64 か、公開された https: の image_url で受け取ります。見本がローカルファイルなら、代わりにサーバーをローカルで動かしてください。Cursor は変数を独自の書式で書きます。プロジェクトフォルダは ${workspaceFolder}、シェル変数は ${env:NAME} です。そのため、ワークフロー1の設定はそのままコピーせず、次のように書きます。
{
"mcpServers": {
"doc-cheap": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@doc-cheap/mcp"],
"env": {
"DOC_CHEAP_IMAGE_ROOT": "${workspaceFolder}/specimens"
}
}
}
}
DOC_CHEAP_API_KEY の行がなければ、この設定でもサンドボックスキーが使われ、サーバーが画像を読むのはプロジェクトの specimens フォルダからだけです。 ここでは合成書類か見本書類を使い、自分のパスポートは決して使わないでください。
プロンプト。
td3-specimen.jpg を scan_document でスキャンしてください。結果を components/OnboardingForm.tsx の項目と比較し、フォームのすべての項目がスキャン結果から埋まるように変更を提案してください。自信のない項目は search_docs で調べてください。
比較でよく見えてくることと、結果の構造がそれにどう応えるか:
- 氏名は分かれて届きます。
holder.surnameとholder.given_namesは別々で、holder.full_nameがそれをつなげたものです。「氏名」の入力欄が1つだけだと、後でバックオフィスが必要とする姓と名の区別が失われます。 - 日付は最初から ISO の
YYYY-MM-DDです。holder.birth_dateとdocument.expiry_dateは<input type="date">にそのまま入ります。この入力欄の値も同じ形式だからです。DD.MM.YYYYや MRZ 独自の6桁の日付を解析する必要はありません。 - 国はコードと詳細つきで届きます。
holder.nationalityは書類に記載された3文字のコードで、holder.nationality_detailには ISO の alpha-3 と alpha-2 の形式が加わります。国の選択欄が2文字コードを使っているなら、自前の対応表を書かずに詳細オブジェクトから変換してください。 - 有効期限は計算済みです。
document.is_expiredとdocument.days_remaining(期限切れ後は負の値)を使えば、日付計算なしでフォームが期限切れのパスポートを警告できます。 - どの値も null になり得ます。 すべてのキーは必ず存在し、不明な値は
nullになります。キーが欠けることはありません。すべての項目が届く前提のフォームは、最初の擦り切れたパスポートで壊れます。 - MRZ の判定は項目とは別です。
mrz.statusはpassed、failed、absentのいずれかです。機械読み取り領域が読めたか、チェックディジットが一致したかを示すもので、書類が本物かどうかを示すものではありません。結果のauthenticityグループはnot_checkedとなります。
このループでは search_docs を使う価値があります。サーバーに同梱されたドキュメントを読み、オフラインで動き、費用もかかりません。「unsupported_document はどういう意味か」「身分証にはどんな項目があるか」といった疑問に、推測ではなくドキュメントに基づく答えが返ってきます。
ワークフロー3:テストフィクスチャを一度だけ記録する
作業内容。 自分のコードは API を呼びますが、そのテストは呼ぶべきではありません。実行のたびにパスポートを本番のサービスに送るテストスイートは遅く、CI にキーが必要になり、サンドボックスではすぐに上限に達します。1時間に10リクエストまでで、同じ書類を何度も送りすぎると document_repeated で拒否されます。
解決策は、コードが扱うべき結果をそれぞれ一度だけ記録して JSON として保存し、テストはそのファイルに対して実行することです。
プロンプト(Claude Code でも Cursor でも同じです):
td3-specimen.jpg と blank-page.png を scan_document でスキャンし、それぞれの構造化結果を test/fixtures/scan-
.json として保存してください。個人に関する値(氏名、番号、生年月日、MRZ の行)はすべて同じ型のプレースホルダーに置き換え、images オブジェクトのデータ URL は削除してください。
ツールの構造化結果は HTTP API が返すものと同じ Scan オブジェクトなので、フィクスチャは本番でコードが受け取るものと一致します。マスクしたフィクスチャは次のようになります。
{
"meta": { "status": "recognized", "billed": false, "reference": null },
"document": { "kind": "passport", "country": "<ICAO3>", "number": "<NUMBER>", "expiry_date": "<YYYY-MM-DD>", "is_expired": false, "days_remaining": 0 },
"holder": { "surname": "<SURNAME>", "given_names": "<GIVEN NAMES>", "birth_date": "<YYYY-MM-DD>", "nationality": "<ICAO3>" },
"mrz": { "status": "passed", "reason": null, "lines": ["P<…", "…"], "text": "P<…" }
}
(ここでは省略しています。実際の結果にあるキーはすべて残してください。そうすれば、コードが存在しないキーを読んだときにテストが失敗します。)
フィクスチャを正直なものにする3つのポイント:
- 失敗も記録する。 白紙のページからは
no_document_foundが返り、billed: false、データグループはnullです。スキャン結果には必ず所持者がいると決めつけたコードを捕まえるのは、このファイルです。 - サンドボックスでは
billedは予測値です。 サンドボックスでは何も課金されず、このフラグは同じスキャンが本番キーなら課金されたかどうかを示します。課金のテストで検証すべきなのは、まさにこの点です。 - 本番キーでは、再スキャンせずに読み戻す。
list_scansは reference や時刻で以前のスキャンを見つけ、get_scanは最初に返されたとおりの結果を返します。画像を再認識することも、課金されることもありません。違いは2つです。imagesの各スロットはnullになり、qualityはnot_checkedになります。画像の切り抜きは一切保存されないためです。
エディタではなくスクリプトから記録したい場合、同じ呼び出しを素の HTTP で行うと次のとおりです。
curl -X POST https://api.doc.cheap/v1/scans \
-H "Authorization: Bearer sk_sandbox_public" \
-H "Content-Type: application/json" \
-d "{\"image\": \"$(base64 < specimens/td3-specimen.jpg | tr -d '\n')\"}" \
-o test/fixtures/scan-recognized.json
コミットする前にファイルをマスクしてください。
各ワークフローに必要なもの
| キー | サーバー | 画像の送り元 | |
|---|---|---|---|
| フォルダから CSV | 自分のキー(10件を超える場合) | ローカル、npx |
囲い込んだディレクトリ |
| フォームの照合 | サンドボックスで十分 | ホスト型またはローカル | URL または囲い込んだディレクトリ |
| テストフィクスチャ | サンドボックスで十分 | どちらでも | 囲い込んだディレクトリ(一度だけ) |
Windsurf、VS Code、Gemini CLI、Kiro を含む各エディタ向けの設定は、Claude と Cursor 向け OCR MCP サーバーのページと MCP サーバーガイドにあります。まだサービスを選んでいる段階なら、パスポート OCR API の比較で、各社が公開している情報をもとに doc.cheap を他のサービスと並べて確認できます。
これらのワークフローがお使いのエディタでうまく動かない場合や、記事にする価値のある4つ目のワークフローがある場合は、admin@doc.cheap までご連絡ください。
皆さんへの質問: エディタのエージェントによく任せるのはどちらですか – 書類を1件ずつ、それともフォルダごと? 下のコメント欄で教えてください。