Skip to main content
Glama
Gurmeher23

mcp-ledger-server-ts

by Gurmeher23

mcp-ledger-server-ts

A TypeScript Model Context Protocol 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.

Related MCP server: Payables MCP

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

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 (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 (/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

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    This MCP server enables autonomous finance operations, providing tools to run 3-way matching audits, fetch unmatched invoices, trigger vendor holds, and retrieve vendor aging summaries. It integrates with an AI agent for policy-based discrepancy resolution and executive dashboards.
    MIT
  • F
    license
    A
    quality
    B
    maintenance
    Enables an AI agent to handle accounts payable tasks against a mock ERP, including reading and writing bills and vendors, checking duplicates, matching invoices, recommending approvals, and queuing payment releases, with configurable profiles that limit available tools.
    11
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables natural-language-driven purchase-to-pay workflows against mock procurement and payment APIs, including invoice matching, exception handling, human approvals, and idempotent payment scheduling with a tamper-evident audit log.
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables LLM agents to query and reconcile an accounts-payable ledger through safe read-only SQL, deterministic invoice-to-payment matching, and exception explanations for underpayments, duplicates, currency mismatches, and overdue invoices. Ships with a synthetic ledger, golden-set evals, and a month-end review workflow so quality can be measured in CI.
    4
    MIT