Installing a document-recognition tool in an editor takes a minute. Knowing what to do with it afterwards takes longer. This post skips the setup tour and goes straight to three jobs developers actually bring to Claude Code and Cursor when passports are involved:
- Turning a folder of passport scans into a CSV, in a way that survives a crash halfway through.
- Checking one passport against the onboarding form you are building.
- Recording scan results once and using them as test fixtures, so your test suite never calls a live service.
All three use the doc.cheap MCP server, the package @doc-cheap/mcp (MIT, Node 20 or later). This is doc.cheap's own blog, the Passport and ID OCR API behind that server, so weigh the product choices accordingly. The patterns themselves (a fenced image directory, one key per file, a fixture recorded once) apply to any tool of this kind.
What the server gives the editor
Six tools, each with the hints a client reads before it decides whether to ask you first:
| Tool | What it does | Charges a credit? |
|---|---|---|
scan_document |
Reads a passport, ID card or driver's licence into structured JSON | Only when a document is recognised |
check_balance |
Remaining credits and this month's counters | No |
search_docs |
Searches the API docs bundled with the server, offline | No |
list_scans |
Lists the account's stored scans, newest first | No |
get_scan |
Reads one stored scan back by its id | No |
delete_scan |
Deletes one stored scan for good | No |
scan_document takes the image as image_base64, image_url or, on the local server, image_path, plus the optional expect_country, return_portrait, retain_hours, reference and idempotency_key. Those five options carry most of the workflows below.
With no key set, the server uses the public sandbox key: 10 free recognised documents per address in all, and at most 10 requests per address an hour. That is enough for workflows 2 and 3. Workflow 1 needs a key of your own; registering gives 100 free recognitions every month.
If you have not connected an MCP server to an agent before, Letting an agent read identity documents over MCP covers the basics and the privacy questions in more depth.
Workflow 1: a folder of scans into a CSV, in Claude Code
The job. Someone hands you a folder of passport scans (a hotel's guest backlog, an event's attendee list, a migration from a paper archive) and wants a spreadsheet: one row per file, the fields that matter, and a clear note for every file that could not be read.
The setup. Put a .mcp.json at the root of the project. Claude Code reads it as the project's own server list, so the configuration travels with the repository:
{
"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"
}
}
}
}
The first time Claude Code starts in that folder, it asks you to approve the server from .mcp.json.
Two lines do the work:
DOC_CHEAP_API_KEYis taken from your shell, so no key is committed. When the variable is unset, the value is empty and the server falls back to the sandbox key.DOC_CHEAP_IMAGE_ROOTis the one directory the server may read files from. Without it,image_pathis refused outright. With it, a path is resolved through symlinks first, and anything that escapes the directory (a.., a link pointing out) is refused. A relativeimage_pathis taken from this directory, so the agent can pass bare file names.
That fence matters here more than anywhere. The file names come from the agent, and the agent is reading a folder you did not create. Images also never pass through the conversation: the server reads the file and sends it straight to the API.
The prompt.
Call check_balance first. Then, for every JPEG and PNG in the scan directory, call scan_document with image_path set to the file name, reference set to the file name, idempotency_key set to "backlog-" plus the file's SHA-256, return_portrait false and retain_hours 24. Write backlog.csv with the columns file, status, billed, kind, country, number, surname, given_names, birth_date, expiry_date, is_expired, mrz_status. For any status other than recognized, leave the data columns empty. At the end, list the files that were not recognized and why.
Why each option is there:
referenceis echoed back in the result'smeta.referenceand shows on everylist_scansrow, so each stored scan can be traced to its file without opening it.idempotency_keyis what makes the batch safe to resume. On a live key, a repeat with the same key returns the first result instead of recognising and charging again. If the session dies at file 140 of 300, run the same prompt again: the first 139 come back as replays at no cost, and the work continues from there. A key built from the file's hash, rather than a random UUID, is what lets a new session reproduce it. Claude Code can compute it withshasum -a 256.retain_hours: 24is the trade-off the idempotency guide spells out. A replay hands back the stored result, so a scan made withretain_hours: 0cannot be replayed: a retry under its key within 24 hours is refused withidempotency_replay_unavailable. A one-day window keeps retries free and still leaves nothing stored the day after.return_portrait: falseleaves out the crop of the holder's photo, which a CSV has no use for.
What comes back. Each row's status is one of recognized, no_document_found, unreadable, unsupported_document or rejected, and billed says whether that file drew a credit. Only recognized is charged, so a folder with twenty blurry photos in it costs twenty fewer credits than it has files. The billed column is also your own count of what the batch spent, with no invoice to reconcile.
One more habit. The CSV now holds personal data. Add it to .gitignore before the agent writes it, not after.
Workflow 2: checking one passport while you build a form, in Cursor
The job. You are building an onboarding or check-in form, and you want to see what a real scan returns before you decide on field names, formats and validation.
The setup. For a quick look, Cursor can use the hosted server, which needs no install. In the project's .cursor/mcp.json:
{
"mcpServers": {
"doc-cheap": {
"url": "https://mcp.doc.cheap/mcp"
}
}
}
With no key header, the hosted server uses the sandbox key. It cannot see files on your machine, so there scan_document takes the image as image_base64 or a public https: image_url. If your specimens are local files, run the server locally instead. Cursor writes variables in its own syntax, ${workspaceFolder} for the project folder and ${env:NAME} for a shell variable, so the entry from workflow 1 is not copied as it is:
{
"mcpServers": {
"doc-cheap": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@doc-cheap/mcp"],
"env": {
"DOC_CHEAP_IMAGE_ROOT": "${workspaceFolder}/specimens"
}
}
}
}
With no DOC_CHEAP_API_KEY line, this entry uses the sandbox key too, and the server reads images only from the project's specimens folder. Use synthetic or specimen documents here, never your own passport.
The prompt.
Scan td3-specimen.jpg with scan_document. Compare the result with the fields in components/OnboardingForm.tsx and propose changes so every form field is filled from the scan. Use search_docs for any field you are unsure about.
What the comparison usually turns up, and what the result shape answers:
- Names come split.
holder.surnameandholder.given_namesare separate, andholder.full_namejoins them. A single "Full name" input loses the split your back office will want later. - Dates are already ISO
YYYY-MM-DD.holder.birth_dateanddocument.expiry_datedrop straight into an<input type="date">, whose value uses the same format. No parsing ofDD.MM.YYYYor the MRZ's own six-digit dates. - Countries come as codes with details.
holder.nationalityis the document's three-letter code, andholder.nationality_detailadds the ISO alpha-3 and alpha-2 forms. If your country picker is keyed by two-letter codes, map from the detail object rather than writing your own table. - Expiry is computed for you.
document.is_expiredanddocument.days_remaining(negative once expired) let the form warn on an expired passport without date arithmetic. - Anything can be null. Every key is always present, and an unknown value is
null, never a missing key. A form that assumes every field arrives will break on the first worn passport. - The MRZ verdict is separate from the fields.
mrz.statusispassed,failedorabsent. It tells you whether the machine-readable zone was read and its check digits agree; it does not say the document is genuine. The result'sauthenticitygroup readsnot_checked.
search_docs is worth using in this loop. It reads the documentation bundled with the server, works offline and costs nothing, so "what does unsupported_document mean" or "which fields does an ID card have" gets the documented answer instead of a guess.
Workflow 3: recording test fixtures once
The job. Your own code calls the API, and its tests should not. A test suite that sends a passport to a live service on every run is slow, needs a key in CI, and on the sandbox it soon meets its limits: 10 requests an hour, and a document_repeated refusal for the same document submitted too many times.
The answer is to record each outcome your code must handle once, save it as JSON, and let the tests run against the files.
The prompt, in either Claude Code or Cursor:
Scan td3-specimen.jpg and blank-page.png with scan_document and save each structured result as test/fixtures/scan-
.json. Replace every personal value (names, numbers, dates of birth, MRZ lines) with a placeholder of the same type, and remove the images object's data URLs.
The tool's structured result is the same Scan object the HTTP API returns, so the fixture matches what your code will get in production. A masked fixture looks like this:
{
"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<…" }
}
(Trimmed here; keep every key the real result has, so a test fails when your code reads one that is missing.)
Three details make the fixtures honest:
- Record the failures too. A blank page gives you
no_document_foundwithbilled: falseand the data groupsnull. That file is the one that catches code which assumes a scan always has a holder. - On the sandbox,
billedis a forecast. Nothing is charged there, and the flag says whether the same scan would have been charged on a live key. That is exactly what a billing test needs to assert on. - On a live key, read back instead of scanning again.
list_scansfinds an earlier scan by its reference or time andget_scanreturns it as first returned, without recognising the image again and without charging. Two differences: everyimagesslot isnullandqualityreadsnot_checked, because image crops are never stored.
If you would rather record from a script than from the editor, the same call over plain HTTP is:
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
Mask the file before you commit it.
Which workflow needs what
| Key | Server | Images reach the API from | |
|---|---|---|---|
| Folder to CSV | Your own (beyond 10 documents) | Local, npx |
The fenced directory |
| Form check | Sandbox is enough | Hosted or local | A URL or the fenced directory |
| Test fixtures | Sandbox is enough | Either | The fenced directory, once |
The per-client config for every editor, including Windsurf, VS Code, Gemini CLI and Kiro, is on the OCR MCP server for Claude and Cursor page and in the MCP server guide. If you are still choosing a service, the passport OCR API comparison puts doc.cheap next to the others on what each publishes.
If one of these workflows breaks in your editor, or you have a fourth one worth writing up, write to admin@doc.cheap.
A question for you: what do you hand your editor's agent most often – one document at a time, or a whole folder? Tell us in the comments below.