Skip to main content
Glama
Gurmeher23

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