mcp-ledger-server-ts
by Gurmeher23
README.md
# mcp-ledger-server-ts
A TypeScript [Model Context Protocol](https://modelcontextprotocol.io) server that exposes an accounts-payable **invoice ledger** as tools an LLM agent can call: list and inspect invoices, run a **three-way match** (invoice vs purchase order vs goods receipt), and **route exceptions** (approve / hold / reject) under server-enforced policy.
It is built to answer one question: _how do you let an agent act on an ERP-like system without trusting the agent?_ The model proposes, the server verifies and decides what is allowed.
- Strict TypeScript (ESM, `strict`, `noUncheckedIndexedAccess`), Node 20+
- Official `@modelcontextprotocol/sdk`: stdio transport plus stateless Streamable HTTP
- Every tool input **and** output is a zod schema (typed results, `outputSchema` advertised to clients)
- Pure, exhaustively tested domain modules; pluggable in-memory or REST backend
- Vitest unit and integration tests, a tool-calling eval harness, ESLint (typescript-eslint strict) + Prettier, GitHub Actions CI
## Real-world use
Finance teams receive invoices that must be reconciled against what was ordered and what actually arrived before anyone pays. Most invoices are fine; a minority carry price drift, over-billing, duplicates or missing deliveries. An agent is good at the triage loop (read, compare, explain, escalate) but must never be able to wave a duplicate through to payment because it was talked into it.
This server gives an agent exactly the verbs it needs, and keeps the dangerous judgement in deterministic code:
- The agent calls `match_invoice_to_po` to get findings with severities and a recommendation.
- When it calls `route_exception`, the server **re-runs the match itself** and checks the request against policy. The agent's own claim about the invoice is not evidence.
- Risky actions (forcing an approval, rejecting) need evidence or an explicit, logged human override.
The same pattern applies to any system of record where an agent is allowed to act: purchase approvals, billing disputes, customer-account changes.
## Architecture
```
MCP client (Claude Desktop / agent runtime / test)
| JSON-RPC over stdio or Streamable HTTP (POST /mcp)
v
+-----------------------------------------------------------+
| src/server createLedgerServer(service) |
| zod input schemas -> tool handler -> zod output schema |
| guard(): LedgerError -> {isError, code, message} |
+-----------------------------+-----------------------------+
|
+-----------------------------v-----------------------------+
| src/service.ts LedgerService (clock + tolerance + actor)|
| matchInvoice(id) routeException(id, request) |
+--------+-----------------------------------+--------------+
| |
+--------v------------------+ +----------v--------------+
| src/domain (pure, no I/O)| | src/store LedgerStore |
| matching.ts routing.ts | | InMemoryLedgerStore |
| summary.ts tolerance | | HttpLedgerClient ---> REST backend
| money.ts types | | (picked by LEDGER_BACKEND)
+---------------------------+ +-------------------------+
```
Dependencies point inwards: the domain knows nothing about MCP, HTTP or storage, so it can be unit-tested without mocks and reused from a CLI or REST API.
## Tool catalogue
| Tool | Reads / writes | What it does for the agent |
| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `list_invoices` | read | Summaries (id, vendor, PO, total, dates, status), filter by `status` / `vendorId`, `limit` 1-100 (default 25). Start here to discover ids. |
| `get_invoice` | read | One invoice with line items (sku, quantity, unit price in minor units) and any recorded decision. |
| `match_invoice_to_po` | read | Three-way match. Returns `outcome` (`matched`, `matched_within_tolerance`, `exception`), findings with severity (`warning` / `blocking`), and a recommended action and reason code. Never mutates the ledger. |
| `route_exception` | **write** | Records `approve` / `hold` / `reject` with a reason code. Re-matches server-side and enforces policy (below). Idempotent. |
| `ledger_summary` | read | Counts and totals per status and currency, overdue open invoices, oldest open invoice, tally of decision reasons. |
Tool descriptions are written for the model: they state when to call the tool, what it returns, what it will refuse, and whether it changes state. MCP annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`) are set per tool.
### Matching rules (`src/domain/matching.ts`)
| Finding | Severity | Trigger |
| ----------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NO_PO`, `VENDOR_MISMATCH`, `CURRENCY_MISMATCH` | blocking | PO missing; vendor differs; currency differs (line comparison is then skipped, amounts are not comparable) |
| `DUPLICATE_INVOICE` / `DUPLICATE_PROBABLE` | blocking | An earlier, non-rejected invoice has the same vendor and invoice number (case/space-insensitive), or the same vendor, PO, currency and total within 30 days. Only the _later_ invoice is flagged. |
| `NO_RECEIPT` | blocking | No goods receipt recorded for the PO |
| `LINE_NOT_ON_PO` | blocking | SKU is not on the PO |
| `QTY_EXCEEDS_RECEIPT` / `QTY_EXCEEDS_PO` | blocking | Invoiced quantity exceeds what was received (or ordered), net of quantity already covered by _approved_ invoices, beyond the quantity tolerance |
| `QTY_WITHIN_TOLERANCE` | warning | Over-billed vs received but within tolerance (default 2%) |
| `PRICE_VARIANCE` | blocking | Unit price differs from the PO by more than the percentage tolerance (default 2%) **and** more than the absolute tolerance (1 minor unit) |
| `PRICE_WITHIN_TOLERANCE` | warning | Differs, but inside tolerance |
| `FUTURE_DATED`, `INVOICE_BEFORE_PO` | blocking | Invoice dated after the evaluation date, or before the PO |
| `STALE_INVOICE` | warning | Invoice dated more than 90 days after the last receipt |
### Routing policy (`src/domain/routing.ts`)
- **approve**: refused when blocking findings exist, unless `reasonCode=MANUAL_OVERRIDE`, `override=true` and a note of 10+ characters. **Duplicates can never be approved, even with an override.**
- **reject** (final): needs a blocking finding with the same reason code, or an explicit override.
- **hold**: always allowed (reversible, the safe default).
- `approved` and `rejected` are terminal. Repeating the identical decision returns `changed: false`; a different decision returns `CONFLICT`.
## Run it
```bash
npm install
npm run build
npm start # stdio MCP server with seeded in-memory data
MCP_TRANSPORT=http npm start # Streamable HTTP on http://127.0.0.1:3333/mcp
npm run smoke # spawn the server, list tools, call one
```
Register it in Claude Desktop or any MCP-capable agent runtime using [`mcp_config.example.json`](mcp_config.example.json) (replace the absolute path).
### Configuration (environment)
| Variable | Default | Meaning |
| -------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------- |
| `LEDGER_BACKEND` | `memory` | `memory` (seeded, standalone) or `http` (REST backend) |
| `LEDGER_API_URL`, `LEDGER_API_TOKEN` | none | REST base URL and optional bearer token (required URL for `http`) |
| `LEDGER_DEFAULT_CURRENCY` | `EUR` | Used when the backend omits a currency |
| `LEDGER_AS_OF` | today | Pin the evaluation date (`YYYY-MM-DD`) for reproducible demos |
| `LEDGER_ACTOR` | `mcp-agent` | Recorded as `decidedBy` on decisions |
| `LEDGER_QTY_TOLERANCE_PCT`, `LEDGER_PRICE_TOLERANCE_PCT` | `0.02` | Tolerances as fractions |
| `MCP_TRANSPORT`, `MCP_HOST`, `MCP_PORT` | `stdio`, `127.0.0.1`, `3333` | Transport selection |
Configuration is parsed with zod once at startup and fails fast with a readable message.
### Backends
`InMemoryLedgerStore` ships with 11 deterministic invoices, each built to exercise one scenario (clean match, price within tolerance, 15% price variance, billed more than received, duplicate, no receipt, currency mismatch, missing PO, vendor mismatch, already approved, already rejected).
`HttpLedgerClient` is a typed client (zod-validated responses, timeout, bounded retries with backoff for GETs only, idempotency key on POSTs). Its wire format follows my Python [invoice-ledger-service](https://github.com/Gurmeher23/invoice-ledger-service) (`/invoices`, `/invoices/{invoice_number}`, snake_case, decimal amounts, bearer auth, statuses `approved` / `exception`). The client calls these endpoints:
| Call | Exists in invoice-ledger-service today? |
| --------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `GET /invoices?status=`, `GET /invoices/{invoice_number}` | yes |
| `GET /purchase-orders/{po_number}` | **no** (only `POST /purchase-orders`); also needs `supplier_name` in the response |
| `GET /receipts?po_number=` | **no** (the service has no receipts yet) |
| `POST /invoices/{id}/decision` | **no** |
So the HTTP backend needs those additions on the Python side before a full run against it. Where the backend is header-only (no `lines`, `currency`, dates), the client degrades gracefully: the invoice total becomes one synthetic `TOTAL` line, currency falls back to `LEDGER_DEFAULT_CURRENCY`, and missing dates fall back to the startup date. The client is verified against stubbed `fetch` responses in the tests; it has not been run against a live deployment.
## Tests
```bash
npm run lint && npm run typecheck && npm test
npm run test:coverage # enforces coverage thresholds
npm run eval # tool-calling eval harness
```
Output from my run (Node v25.2.1, Vitest 4.1.12):
```
Test Files 7 passed (7)
Tests 109 passed (109)
```
| File | Tests | Scope |
| ----------------------------------------- | ----- | ------------------------------------------------------------------------------------------------ |
| `tests/unit/matching.test.ts` | 43 | every rule, tolerance boundaries (exactly 2% passes, 2.1% fails), duplicates, dates, aggregation |
| `tests/unit/routing.test.ts` | 16 | policy, overrides, idempotency, terminal states |
| `tests/unit/stores.test.ts` | 19 | in-memory store, HTTP client (stubbed fetch: mapping, retries, errors, idempotency key) |
| `tests/unit/summary_money_config.test.ts` | 10 | summary, money, tolerance, config |
| `tests/integration/server.test.ts` | 14 | real MCP server + SDK `Client` over a linked in-memory transport |
| `tests/integration/transports.test.ts` | 4 | real HTTP server with the SDK HTTP client; real stdio child process |
| `tests/integration/evals.test.ts` | 3 | runs all eval cases; proves the harness fails on wrong expectations |
Coverage (`npm run test:coverage`): **96.73% statements, 94.05% branches, 96.06% functions, 98.05% lines**. Thresholds in `vitest.config.ts` fail the run below 90/80/90/90.
### Eval harness
`evals/cases/ledger.json` holds scripted agent trajectories: ordered tool calls with expectations (`isError`, `errorCode`, deep-subset `match` on `structuredContent`, exact `findingCodes`). Each case runs against a fresh server and store through the real SDK client. `npm run eval` prints per-case results and the pass rate, and exits non-zero on any failure.
```
PASS clean-invoice-approve-and-replay
PASS price-within-tolerance
PASS price-variance-blocks-approval
PASS billed-more-than-received
PASS duplicate-is-rejected-never-approved
PASS original-not-flagged-as-duplicate
PASS no-goods-receipt
PASS currency-mismatch
PASS missing-purchase-order
PASS vendor-mismatch
PASS unknown-invoice-id
PASS reject-needs-evidence
PASS override-requires-human-note
PASS hold-then-resolve
PASS ledger-summary-baseline
PASS triage-workflow
PASS invalid-arguments-rejected
17/17 cases passed (100.0%)
```
These evals are **scripted trajectories**: they test that the server behaves correctly for the tool-call sequences an agent should make, including the ones it must be stopped from making. They do not call an LLM, so they do not measure how well a particular model chooses tools. That would be the next layer (replaying the same cases with a model in the loop).
## Design decisions
- **zod at every boundary.** Tool inputs, tool outputs, environment config and REST responses are all zod schemas. One definition gives runtime validation, static types (`z.infer`) and the JSON Schema the SDK advertises to the model. Descriptions on fields double as prompt text.
- **Pure domain modules.** `matchInvoice` and `planRoute` take all inputs as arguments (including `asOf` and tolerances) and do no I/O, so they are deterministic and unit-testable without mocks. The service layer is the only place that touches the clock and the store.
- **Money is integer minor units.** No floating-point drift in tolerance maths; formatted strings are added only at the edge.
- **Server-side verification.** `route_exception` recomputes the match instead of trusting an agent-supplied verdict. Reject needs evidence, approve against findings needs an explicit override, duplicates are unapprovable.
- **Idempotency.** Replaying an identical decision is a no-op that returns the original decision and timestamp (`changed: false`), so agent retries after a timeout are safe. The HTTP client sends an `Idempotency-Key` on writes and never auto-retries POSTs.
- **Error model.** Expected failures are tool results with `isError: true` and a stable JSON body `{ error: { code, message, retryable } }` (codes: `NOT_FOUND`, `INVALID_INPUT`, `POLICY_VIOLATION`, `CONFLICT`, `BACKEND_UNAVAILABLE`, `INTERNAL`). The model can read the message and self-correct. Unexpected exceptions become `INTERNAL` with no internal detail leaked. Only protocol problems are JSON-RPC errors.
- **Stateless HTTP transport.** A fresh server and transport per request, no session state, so it scales horizontally. It binds to loopback by default.
- **Ports and adapters.** `LedgerStore` is an interface; the in-memory store and REST client are interchangeable, which also keeps the server runnable with zero infrastructure.
## Limitations
- **No authentication or authorisation on the MCP server.** The HTTP transport is plain HTTP on loopback. Put it behind an authenticating proxy before exposing it. `decidedBy` is a configured label, not a verified identity.
- **The in-memory store is not persistent** and is not safe for concurrent writers across processes.
- **The REST backend contract is partly aspirational** (see Backends): three of the endpoints it calls do not exist in my Python service yet, and the client has not been run against a live backend.
- **Stateless HTTP means no server-initiated messages** (no resumable streams, notifications, or sampling).
- **Matching rules are a simplified model.** No tax or freight lines, no multi-currency conversion, no partial-PO close logic, no approval limits or segregation-of-duties checks. The "already invoiced" netting counts only invoices in `approved` status, so two concurrent unapproved invoices can each pass against the same receipt.
- **Evals are scripted, not model-in-the-loop** (see above).
- The sample data is synthetic.
## Project layout
```
src/domain/ pure rules: matching, routing, summary, tolerance, money, types, errors
src/store/ LedgerStore port, InMemoryLedgerStore, HttpLedgerClient, seed data
src/server/ MCP tool registration, zod schemas, result/error helpers
src/service.ts application service (clock, tolerance, orchestration)
src/http.ts Streamable HTTP transport src/index.ts entrypoint (stdio | http)
evals/ eval cases (JSON), runner, in-process harness
tests/ unit/ and integration/
scripts/ smoke.ts
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues