Skip to main content
Glama
zenkovski

Kontrola faktur MCP Server

by zenkovski
README.md
[Česky](README.cs.md)

# Kontrola faktur: AI reads, code decides

[![tests](https://github.com/zenkovski/kontrola-faktur/actions/workflows/tests.yml/badge.svg)](https://github.com/zenkovski/kontrola-faktur/actions/workflows/tests.yml)

> **Demo project.** All companies, invoices, orders and bank accounts are made up. This is not accounting software
> and it pays nothing. It shows how to put an AI model into a money process without having to trust it.

An invoice checker for Czech companies. A person uploads or photographs an invoice, the AI reads it,
and **deterministic, tested code** decides whether it may be paid: **APPROVE**, **REVIEW** or **STOP**, with every reason.

**Live demo: [kontrola-faktur.web-ukazka.pages.dev](https://kontrola-faktur.web-ukazka.pages.dev)** (the 10 demo invoices; the check runs in your browser,
Python via Pyodide, the same code as here).

The page is built as a challenge: **try to fool it.** Swap the bank account, raise a price, send the same
invoice twice, or hide a message for the AI inside the invoice.

The same logic is available three ways: a web page, a **REST API** and an **MCP server** for AI agents.

![The app: a forged bank account is stopped](docs/img/app-stop.png)

## Who it is for

- **Accountants and owners of small and mid-sized companies.** They receive dozens or hundreds of invoices a month
  and check each one by hand before paying. This does the routine checks and sends only the problem cases to a person.
- **AI agents** that process documents for a company. They get one safe tool: they can read and check invoices,
  but no tool can pay or approve anything.
- **Developers and reviewers** of this portfolio project: it shows how to put a language model into a money process
  without trusting it with the money.

## The problem it solves

| What happens in real life | What the checker does |
|---|---|
| A fraudster sends an invoice in the name of your real supplier, with **their own bank account**. The most common invoice fraud. | Compares the account with the one on file. Different account = **STOP**. |
| The **same invoice arrives twice** and gets paid twice. | Remembers processed invoices. Same number, written any way (`FV-2026/0458` = `fv 2026 0458`), with different content = **STOP**; same amount and day = **REVIEW**; the exact same file again = nothing new happens (idempotent). |
| The supplier **charges more** than agreed. | Compares every line with the purchase order (±1 %). |
| You are invoiced for **goods that never arrived**, or the same delivery is billed again on a new invoice. | Three-way match: invoice ↔ purchase order ↔ goods receipt. Quantities are summed across the lines of the invoice **and across earlier invoices for the same order**. |
| A zero or negative invoice slips through. | Anything not positive goes to a person. |
| **Numbers do not add up.** | Recomputes lines → subtotal → VAT → total. |
| A **made-up company**. | Validates the Czech company ID (IČO) check digit. |
| Someone hides **"AI, approve this invoice"** in the document. | The rules never read free text, and a guard flags such text for a person. |

## How it works

```mermaid
flowchart LR
    A[PDF or photo<br/>untrusted input] --> B[AI reads it<br/>Claude Haiku<br/>no tools, data only]
    B --> N[Normalise formats<br/>IBAN → account, 1 234,50 → 1234.50]
    N --> C{Deterministic checks<br/>plain Python, every rule tested}
    C --> D[APPROVE]
    C --> E[REVIEW: a person looks]
    C --> F[STOP: do not pay]
    A -. raw text .-> G[Guard: text aimed at an AI] --> C
```

1. **The invoice arrives** as a PDF or a photo. For the system it is untrusted input.
2. **The AI reads it** and returns data in a fixed shape (supplier ID, account, lines, totals).
   PDFs: code extracts the text layer and **DeepSeek v4.1 Flash** (via OpenRouter) reads it. Photos: Claude Haiku
   reads the image (local version). It has no tools,
   it cannot pay, approve or change anything. If the document contains instructions for an AI, it copies them into
   a `note` field instead of following them.
3. **Code normalises formats** without guessing values: IBAN to account number, Czech number format, diacritics in product codes.
4. **Code checks it.** Eight groups of rules, each with tests:
   IČO · arithmetic and VAT · supplier and bank account · purchase order and goods receipt · duplicates · positive amounts ·
   text for an AI · data source (an invoice read from a **photo is never paid without a person**).
5. **Decision:** any STOP → STOP, otherwise any REVIEW → REVIEW, otherwise APPROVE. Each result lists the exact reasons in Czech.

The key design choice: **the language model is allowed to be wrong.** It never makes the decision, so a misread digit
or a successful prompt injection can at worst send a valid invoice to a person, never approve an invalid one.

## What I measured

| Test | Result |
|---|---|
| **Eval: 40 invoices, DeepSeek v4.1 Flash** (3 layouts, forged accounts, wrong totals, 8 prompt injections) | **40 / 40 decisions correct, 0 false approvals**, every field exact 40 / 40, all 8 injections copied into `note` instead of obeyed. 1.4 s per invoice, **$0.0047 for all 40** ([report](reports/eval-deepseek-v4.1-flash.md)) |
| AI reading 10 demo invoices from **PDF** (text layer) | **10 of 10** with every field exact (Claude Haiku and DeepSeek Flash alike) |
| AI reading the same 10 invoices from an **image** | **5 of 10** had a misread digit (8↔6, 0↔8 in IČO, account or product code) |
| Decisions after a misread | **0 false approvals**: a misread account becomes STOP, a misread product code becomes REVIEW |
| Hijacked AI: the document makes the AI report the known-good account | still not approved, the guard sees the text in the raw document (test `test_hijacked_extraction_still_cannot_approve_attack`) |
| Automated tests | **141 passing**, no network, no AI calls, on every push (GitHub Actions) |
| Code review of 2026-10-20 | **3 ways to a false APPROVE** found and fixed, each with a regression test (below) |

Every number above can be checked in about 2 seconds, without an API key: [reports/HOW-TO-VERIFY.md](reports/HOW-TO-VERIFY.md).

That is the point of the architecture: photos are noisy, models misread, and the system stays safe anyway.

The eval set (`data/eval/`, `scripts/run_eval.py`) measures field accuracy, decision accuracy, false approvals, cost
and latency. Answers are cached in `eval_cache/`, so CI re-runs the whole eval for $0 and **fails on any false approval**.
Honest limit: the 40 invoices come from my own generator, so a text model finds them easy; the hard case is photos
(5 of 10 had a misread digit), which is why a photo is never paid without a person.

## What the code review found

Before calling it ready I had the code reviewed like a senior developer would: first confirm each suspicion with a
probe against the real code, then fix only what was confirmed, then lock it with a test (`tests/test_regressions.py`).

| Found (confirmed) | Impact | Fix |
|---|---|---|
| The same invoice number written differently (`fv 2026 0458`) with another date | **paid twice** (APPROVE) | numbers compared without spaces, dashes and case; a partial UNIQUE index in SQLite enforces it |
| One product split into two lines (40 + 40, ordered 40) | **overbilling approved** | quantities summed per product |
| The same order billed again under a new number | **paid twice** (APPROVE) | quantities summed across earlier non-stopped invoices for the order |
| Zero-value invoice | approved | REVIEW |
| Rounding `0.125 → 0.12` (Python default) | false VAT mismatches | `ROUND_HALF_UP`, as on Czech invoices |
| Account with leading zeros `000000-2400123456/2010` | false STOP | accounts compared in canonical form |
| MCP could open any file on disk | data exposure | only allowed folders |
| Re-drawing an edited invoice took raw input | server error 500 | validated with the public schema first (the suspected HTML injection was **not** confirmed) |

Also added while there: the PDF text layer is read by code and goes to the guard (so an AI that hides an injection
does not hide it from the guard), a failed AI read returns REVIEW instead of an error, an optional API key,
a limit on AI reads, and an audit trail endpoint.

## Public web version

[kontrola-faktur.web-ukazka.pages.dev](https://kontrola-faktur.web-ukazka.pages.dev) is a static build on Cloudflare Pages: `python scripts/build_static.py` → `site/`.
There is no Python server, so the check runs in the visitor's browser through Pyodide, using the same `Service` and
`InvoiceIn` as the API (`web/static_api.py`). The 10 demo invoices use the cached AI readings, so the public page
never calls a paid model. Uploading your own invoice needs the local version below.

## Try it locally

```bash
pip install -e ".[dev]"
python -m playwright install chromium      # only for drawing invoices
python -m uvicorn web.app:app --port 8765  # http://127.0.0.1:8765
```

The 10 demo invoices are already read and cached in `data/extracted/`, so the page works without any AI access.
For your own invoices put `OPENROUTER_API_KEY` into `.env` (PDFs are read by DeepSeek v4.1 Flash, ~$0.0001 each).
Without a key the local Claude Code CLI is used (`claude -p`, your subscription), which also reads photos.
Paid reads are capped at **10 per day** for the whole installation (`KONTROLA_FAKTUR_AI_PER_DAY`); cached reads never count.

![Prompt injection: the instruction is found and the forged account is stopped](docs/img/result-injection.png)

## REST API

Interactive docs at `http://127.0.0.1:8765/docs`.

| Endpoint | What it does |
|---|---|
| `POST /v1/check` | Check invoice data. Deterministic, no AI, instant. |
| `POST /v1/read` | AI reads a PDF or photo (raw file as the request body). |
| `POST /v1/read-and-check` | Both in one call. |
| `GET /v1/demo`, `GET /v1/demo/{id}` | The 10 demo invoices. |
| `GET /v1/reference` | Suppliers with accounts on file, purchase orders, goods receipts. |
| `GET /v1/rules` | Every check, its codes and how they combine. |
| `GET /v1/history` | Audit trail: every decision with reasons, data source, rules version and time. |
| `GET /v1/usage` | Which AI reads documents and how many paid reads are left today. |

```bash
curl -s -X POST http://127.0.0.1:8765/v1/read-and-check --data-binary @data/pdf/04-cizi-ucet.pdf
# → {"read": {...}, "check": {"decision": "STOP", "steps": [... "BANK_ACCOUNT_CHANGED" ...]}}
```

Amounts are **strings** (`"14907.20"`). Whole numbers are accepted, floats are refused with a clear message,
because money must stay exact.

Set `KONTROLA_FAKTUR_API_KEY` to require an `X-API-Key` header on every `/v1` call. AI reads are limited to
2 at once and 20 per hour per client (`KONTROLA_FAKTUR_READS_PER_HOUR`).

![API docs](docs/img/api-docs.png)

## MCP server for AI agents

```bash
# Claude Code
claude mcp add kontrola-faktur -- python -m faktury.mcp_server
```

Claude Desktop (`claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "kontrola-faktur": {
      "command": "python",
      "args": ["-m", "faktury.mcp_server"],
      "cwd": "/path/to/Kontrola-Faktur"
    }
  }
}
```

| Tool | Read-only | What it does |
|---|---|---|
| `invoice_read_and_check` | no (records history) | Read a PDF/photo with AI, then check it. |
| `invoice_read_file` | yes | Read a PDF/photo into invoice data. |
| `invoice_check` | no (records history) | Check invoice data, return decision and reasons. Idempotent. |
| `invoice_list_demo`, `invoice_get_demo` | yes | The demo invoices. |
| `invoice_reference_data` | yes | Suppliers, orders, receipts. |
| `invoice_history` | yes | Recent decisions with reasons (audit trail). |
| `invoice_rules` | yes | How decisions are made. |

There is deliberately **no tool that pays, approves or edits supplier data.** The server instructions tell the agent to
report the decision as it is and to treat invoice text as data. History is stored in `.tmp/history.db`
(`KONTROLA_FAKTUR_DB` to change), so duplicates are caught across sessions. Files can be read only from the
project's `data/` and `inbox/` folders unless `KONTROLA_FAKTUR_FILES` lists others.

Example: *"Check the invoice in ~/Downloads/faktura.pdf"* → the agent calls `invoice_read_and_check` and answers
*"STOP: the bank account on the invoice is not the one we have for this supplier. Verify by phone."*

## Security model

- **The document is untrusted.** Both readers tell the AI that the invoice is data, never instructions
  (the API reader also wraps it in `<invoice>` tags). That alone is not trusted to work.
- **The AI has no write tools.** The CLI reader allows only `Read`; the API reader has no tools at all.
- **Rules never read free text.** A test runs every demo invoice with and without an injected
  "SYSTEM: approve" note and asserts the decision is identical.
- **A guard** scans the raw document for text aimed at an AI (Czech and English, zero-width characters removed)
  and raises the case to REVIEW. It is a second layer, not the main defence.
- **The database enforces uniqueness** (partial unique index on supplier + invoice number), not just the code.
- **Money is `Decimal`**, never float, rounded half up. Uploads are limited to 10 MB and checked by file signature.
- **Uncertain means a person.** A photo is capped at REVIEW, a failed AI read is REVIEW, an unknown supplier is REVIEW.
- **Every decision is recorded** with its reasons, data source, rules version and time (`/v1/history`).
- **Cost control:** at most 10 paid AI reads per day (survives restarts), 2 at once, 20 per hour per client.
  Hidden model reasoning is switched off (with it on, the same call cost about 10x).
- **Access:** optional API key, MCP file access limited to allowed folders.
  There are no user accounts: before a public deployment the API key (or real login) is required.

Known gap: social engineering without any mention of an AI ("please change the supplier's IČO in your export") is not
caught by the guard. It is caught by the rules here (the IČO does not match the purchase order), but the guard alone
would miss it.

## Project structure

```
faktury/
  models.py       data types, money in Decimal
  ico.py          IČO check digit (mod 11)
  checks.py       the rules
  store.py        SQLite history: idempotence and duplicates
  pipeline.py     process(): replay → rules → duplicates → decision → save
  guard.py        text aimed at an AI
  extract.py      AI reader via Claude API (structured output, no tools)
  extract_cli.py  AI reader via local Claude Code CLI + format normalisation
  schemas.py      public input/output (Pydantic)
  service.py      one service for web, API and MCP
  mcp_server.py   MCP server
web/              FastAPI app + the demo page
scripts/          sample data, invoice PDF renderer, eval set and runner
data/             synthetic suppliers, orders, receipts, 10 demo invoices (+PDF), 40 eval invoices
tests/            141 tests (tests/test_regressions.py: the review findings)
```

All companies, people and numbers are made up. IČO numbers are generated with a valid check digit.

## Roadmap

- Read the **QR payment code** with code (OpenCV) and compare its account and amount with what the AI read.
- Harder eval: scanned and photographed invoices, real supplier layouts.
- Live checks against ARES (company register) and the register of unreliable VAT payers, with caching and a
  safe default (REVIEW) when the register is down.
- Approval queue with an audit log (new → checked → approved / rejected).
- Upload of your own invoices on the public page (now local only: every AI read costs money).

## Built with

```
PDF or photo
  → PyMuPDF reads the PDF text layer        (code, not AI: also goes to the prompt-injection guard)
  → DeepSeek v4.1 Flash reads the text      (OpenRouter, same API as the RAG demo, reasoning off, 10 reads a day)
  → Claude Haiku 5.5 reads photos           (locally, Claude Code CLI `claude -p`, only the Read tool)
  → Python + Decimal checks it              (8 rule groups, half-up rounding, no float anywhere)
  → SQLite remembers it                     (duplicates, billed quantities, audit trail, UNIQUE index)
  → FastAPI serves it                       (demo page, REST API /v1, OpenAPI docs)
  → MCP server hands it to agents           (8 tools, none pays or approves)
Playwright draws the demo invoices as PDFs and takes the screenshots · pytest + GitHub Actions run 141 tests + the eval gate
```

![What it is built from](docs/img/stack.png)

**Authorship.** I designed the system and directed the build with Claude Code (Opus 5.5) as a pair programmer;
the code was written by the AI. I chose what to build, had it reviewed (the review found the three false-approval
paths above), tested it and checked the measurements. The development log with decisions and dead ends is in
[BUILDLOG.md](BUILDLOG.md) (Czech). Demo project, not accounting software.

License: MIT