mcp-ledger-server-ts
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-ledger-server-tsfind invoices that fail the three-way match and explain why"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 HTTPEvery tool input and output is a zod schema (typed results,
outputSchemaadvertised 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_poto 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 |
| read | Summaries (id, vendor, PO, total, dates, status), filter by |
| read | One invoice with line items (sku, quantity, unit price in minor units) and any recorded decision. |
| read | Three-way match. Returns |
| write | Records |
| 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 |
| blocking | PO missing; vendor differs; currency differs (line comparison is then skipped, amounts are not comparable) |
| 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. |
| blocking | No goods receipt recorded for the PO |
| blocking | SKU is not on the PO |
| blocking | Invoiced quantity exceeds what was received (or ordered), net of quantity already covered by approved invoices, beyond the quantity tolerance |
| warning | Over-billed vs received but within tolerance (default 2%) |
| blocking | Unit price differs from the PO by more than the percentage tolerance (default 2%) and more than the absolute tolerance (1 minor unit) |
| warning | Differs, but inside tolerance |
| blocking | Invoice dated after the evaluation date, or before the PO |
| 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=trueand 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).
approvedandrejectedare terminal. Repeating the identical decision returnschanged: false; a different decision returnsCONFLICT.
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 oneRegister it in Claude Desktop or any MCP-capable agent runtime using mcp_config.example.json (replace the absolute path).
Configuration (environment)
Variable | Default | Meaning |
|
|
|
| none | REST base URL and optional bearer token (required URL for |
|
| Used when the backend omits a currency |
| today | Pin the evaluation date ( |
|
| Recorded as |
|
| Tolerances as fractions |
|
| 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? |
| yes |
| no (only |
| no (the service has no receipts yet) |
| 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 harnessOutput from my run (Node v25.2.1, Vitest 4.1.12):
Test Files 7 passed (7)
Tests 109 passed (109)File | Tests | Scope |
| 43 | every rule, tolerance boundaries (exactly 2% passes, 2.1% fails), duplicates, dates, aggregation |
| 16 | policy, overrides, idempotency, terminal states |
| 19 | in-memory store, HTTP client (stubbed fetch: mapping, retries, errors, idempotency key) |
| 10 | summary, money, tolerance, config |
| 14 | real MCP server + SDK |
| 4 | real HTTP server with the SDK HTTP client; real stdio child process |
| 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.
matchInvoiceandplanRoutetake all inputs as arguments (includingasOfand 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_exceptionrecomputes 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 anIdempotency-Keyon writes and never auto-retries POSTs.Error model. Expected failures are tool results with
isError: trueand 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 becomeINTERNALwith 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.
LedgerStoreis 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.
decidedByis 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
approvedstatus, 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.tsLicense
MIT
This server cannot be deployed
Maintenance
Related MCP Connectors
Accounts-payable exception environment with a pass/fail verifier and proof packet: AI agents work AP
- StackOneOAuthcom.stackone
Give AI agents 30,000+ safe, token-optimized actions across Workday, SAP, Oracle + hundreds more.
Receipt extraction, invoice-to-PO quantity allocation, and free three-document discrepancy checks.
Receipt ingestion, Gmail capture, expense analysis, and reviewed reports for AI-agent workflows.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceThis 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
- FlicenseAqualityBmaintenanceEnables 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-
- AlicenseNot gradedqualityCmaintenanceEnables 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
- AlicenseAqualityBmaintenanceEnables 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.4MIT