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:

  1. Turning a folder of passport scans into a CSV, in a way that survives a crash halfway through.
  2. Checking one passport against the onboarding form you are building.
  3. 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_KEY is 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_ROOT is the one directory the server may read files from. Without it, image_path is 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 relative image_path is 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:

  • reference is echoed back in the result's meta.reference and shows on every list_scans row, so each stored scan can be traced to its file without opening it.
  • idempotency_key is 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 with shasum -a 256.
  • retain_hours: 24 is the trade-off the idempotency guide spells out. A replay hands back the stored result, so a scan made with retain_hours: 0 cannot be replayed: a retry under its key within 24 hours is refused with idempotency_replay_unavailable. A one-day window keeps retries free and still leaves nothing stored the day after.
  • return_portrait: false leaves 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.surname and holder.given_names are separate, and holder.full_name joins them. A single "Full name" input loses the split your back office will want later.
  • Dates are already ISO YYYY-MM-DD. holder.birth_date and document.expiry_date drop straight into an <input type="date">, whose value uses the same format. No parsing of DD.MM.YYYY or the MRZ's own six-digit dates.
  • Countries come as codes with details. holder.nationality is the document's three-letter code, and holder.nationality_detail adds 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_expired and document.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.status is passed, failed or absent. 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's authenticity group reads not_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_found with billed: false and the data groups null. That file is the one that catches code which assumes a scan always has a holder.
  • On the sandbox, billed is 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_scans finds an earlier scan by its reference or time and get_scan returns it as first returned, without recognising the image again and without charging. Two differences: every images slot is null and quality reads not_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.