Skip to main content
Glama
hmshuv

closeloop-mcp

by hmshuv
README.md
# CloseLoop

**MCP-native month-end reconciliation for Indian finance teams: gateway settlements ↔ bank statement ↔ ledger, with deterministic matching you can audit and an AI layer that is only allowed to read.**

## Results

<!-- results:start (generated by pnpm eval; do not edit) -->
- **Held-out** (8 seeds × 25,000 payments, unseen noise variants included): 90.5% of bank credits linked to exactly the right settlements (n=1467), 1 wrong link; corrupted UTRs recovered 100.0% (n=352), counting correct suggestions a human still confirms.
- **Batch credits:** 76.2% decomposed correctly (contiguous 100.0%, non-contiguous 50.0%), 23.8% sent to a human as ambiguous; missing bank credits 3 detected / 16 deferred / 0 missed.
- **Exceptions:** macro recall 91.8%, precision 94.1% across 13 types; 1,18,947 records/s; full eval 9.5 s. Details in [EVAL.md](EVAL.md).
<!-- results:end -->

## Why this exists

> **[PLACEHOLDER — the author will write this section.]**

## Architecture

```mermaid
flowchart LR
  subgraph SRC["Source data (insert-only, or a read-only mount)"]
    P["Payments + refunds"]
    S["Gateway settlements + items"]
    B["Bank statement"]
    L["Ledger export (Tally / Zoho style)"]
  end
  subgraph ENG["Deterministic engine (source of truth)"]
    direction TB
    E1["1 · Items: fee + 18% GST on fee"] --> E2a["2a · Exact UTR"] --> E2c["2c · Fuzzy UTR (edit distance)"] --> E2b["2b · Batch: contiguous run → proof-only subset-sum"] --> E3["3 · Bank ↔ ledger + period tie-out"] --> E4["4 · T+n business-day windows"]
  end
  SRC --> ENG --> ST[("State: runs · matches · exceptions · audit (append-only)")]
  AI["Constrained AI layer: narration hints · explanations · draft journals"]
  ST -- "read-only evidence" --> AI
  AI -- "grounded candidate tokens only" --> E2c
  MCP["MCP server (stdio): 9 tools · resource · prompt"] --> ENG
  MCP --> ST
  C["Claude Desktop / any MCP client"] <--> MCP
  H["Human reviewer"] -- "two-step approve / escalate" --> MCP
  T[("truth.db (eval only)")] -.-> EV["pnpm eval"]
```

## Try it in 60 seconds

```bash
npx -y closeloop-mcp check   # seeds a demo dataset, reconciles it, prints the summary
```

Until the package is on npm, run it from a clone: `pnpm install && pnpm build && npx closeloop-mcp check`.

Add it to Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`), then restart Claude Desktop:

```json
{
  "mcpServers": {
    "closeloop": { "command": "npx", "args": ["-y", "closeloop-mcp"] }
  }
}
```

From a clone, use absolute paths instead. Claude Desktop doesn't inherit your shell's `PATH`:

```json
{ "mcpServers": { "closeloop": { "command": "/opt/homebrew/bin/node", "args": ["/absolute/path/to/closeloop/packages/mcp/dist/closeloop-mcp.js"] } } }
```

Then ask:
- "What's unreconciled in October and why?"
- "Break down the largest unmatched bank credit."
- "Break down bank credit bnk_000080."
- Or pick the **month_end_review** prompt. [DEMO.md](DEMO.md) lists three demo cases from the October data (a 15-settlement contiguous payout, a corrupted-UTR fuzzy match and a match conflict), picked by code from an actual run.

## Dashboard

**Live: https://closeloop-ivory.vercel.app**: a read-only snapshot of the October demo run.

[`apps/dashboard`](apps/dashboard) is a read-only static site built from one recorded snapshot of the October demo run (`pnpm snapshot`). It shows:
- the month-end summary;
- the exception queue;
- exception detail with evidence, rule trace, source-labelled explanations, draft journals and audit history;
- the batch decomposition visual;
- how it works.

It has no database, no API routes, no LLM calls at runtime and no secrets. Deployment steps for Vercel are in [apps/dashboard/README.md](apps/dashboard/README.md).

## Design principles

**Keep financial truth deterministic; use AI only where data is messy, and only from read-only evidence.**

| Deterministic engine decides | AI layer may |
|---|---|
| every match, exception, exposure class and tie-out | suggest UTR tokens that literally appear in a narration (the scorer still decides) |
| fee/GST arithmetic, in integer paise only | explain an exception from a read-only evidence bundle, citing only ids in that bundle |
| when a batch decomposition is provably unique | propose a journal entry that must balance to the engine's amount (draft, `pending_approval`) |

- **Money is integer paise.** It is validated by zod and stored in SQLite `STRICT` integer columns. Rupee strings are formatted for display only.
- **Financial tables are insert-only.** Database triggers enforce this, and in customer deployments the source data is attached read-only. No tool, model or human action can edit a payment, settlement, bank line or ledger entry.
- **Proof before auto-match.** A batch credit auto-matches only when the subset is proven unique (meet-in-the-middle, or budgeted branch-and-bound), or when it is a unique contiguous run under a stated payout-model assumption. Everything else goes to a human with ranked candidate sets.
- **Humans confirm explicitly.** Approve and escalate are two-step: a preview with a 5-minute single-use token, then token + reviewer name. Every run, investigation, draft, approval and escalation lands in an append-only audit trail with the actor (system / llm / human).
- **Untrusted text stays data.** Narrations and ledger text reach a model only inside `<untrusted_data>` delimiters. The real guards are schema validation, evidence-id checks, a review-policy floor the model can't lower, and the rule that AI output can never create a match.
- **Honest evaluation.** Ground truth lives in a separate database the matcher never receives. Held-out data includes noise variants the rules weren't written around. Noise rates are realistic and never inflated. Every published number is generated.

## Evaluation

`pnpm eval` generates 8 independent datasets (seeds fixed up front in `config/generator.json`). It reconciles the dev and held-out splits of each on isolated in-memory databases, scores them against ground truth, and writes [EVAL.md](EVAL.md) and the Results block above. EVAL.md reports:
- match rate by kind
- precision/recall per exception type, with n and the per-seed spread
- batch results by contiguity and by daily volume
- fuzzy recovery by mechanism
- missing credits as detected / deferred-to-human / missed
- exposure by class
- throughput
- categorised held-out misses

CI runs a 5k-payment smoke version on every push.

## MCP server

| Tool | What it does | Annotations |
|---|---|---|
| `run_reconciliation({from?, to?})` | Matched counts by kind, exceptions by type, exposure per class (never summed), bank-vs-books tie-out | writes a run |
| `list_exceptions({type?, exposure_class?, min_amount_paise?, status?, limit?, cursor?})` | Largest first, 20 per page with a cursor, totals for the whole filtered set | read-only |
| `get_evidence({exception_id})` | Records, rule trace, candidate sets with totals, audit history | read-only |
| `explain_exception({exception_id})` | Root cause and recommended action from read-only evidence (LLM or deterministic) | writes audit |
| `decompose_bank_credit({bank_line_id})` | The settlement set behind one bank credit, or the ranked candidate sets | read-only |
| `draft_journal_entry({exception_id})` | Balanced correcting entry, `pending_approval`, never posted | writes draft |
| `approve_suggestion({id, candidate_index?, set_index?, confirm_token?, reviewer?})` | Two-step human approval: preview + token (5 min), then token + reviewer | destructive |
| `escalate({id, note?, confirm_token?, reviewer?})` | Two-step human escalation with a note | destructive |
| `get_audit_trail({entity_id?, limit?, cursor?})` | Append-only audit events, paginated | read-only |

Also: resource `closeloop://summary/latest` and prompt `month_end_review({month?})`. Every amount comes as raw `amount_paise` plus a pre-formatted `amount_inr` (₹12,33,701.15), and every response starts with a one-line summary, so the model never does arithmetic. On first run with an empty database the server seeds the reproducible demo dataset into `~/.closeloop/closeloop.db` (override with `CLOSELOOP_DB`). Subcommands: `closeloop-mcp check` (one reconciliation, summary to stderr) and `closeloop-mcp seed PATH` (write the demo data as a standalone source DB).

**MCP Inspector:**

```bash
npx @modelcontextprotocol/inspector node packages/mcp/dist/closeloop-mcp.js      # web UI
npx @modelcontextprotocol/inspector --cli node packages/mcp/dist/closeloop-mcp.js \
  --method tools/call --tool-name decompose_bank_credit --tool-arg bank_line_id=bnk_000080
```

## LLM setup (optional)

CloseLoop works fully with no API key; every AI step has a deterministic fallback. Any OpenAI-compatible chat-completions endpoint works. Put the settings in `.env` (git-ignored), or in `~/.closeloop/.env` for Claude Desktop:

| Provider | `LLM_BASE_URL` | `LLM_MODEL` (example; check the provider's current model list) |
|---|---|---|
| Groq | `https://api.groq.com/openai/v1` | `openai/gpt-oss-120b` |
| Google AI Studio | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` |
| OpenRouter | `https://openrouter.ai/api/v1` | any model id, e.g. `meta-llama/llama-3.3-70b-instruct:free` |

```bash
LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_API_KEY=your-key
LLM_MODEL=openai/gpt-oss-120b
LLM_REASONING_EFFORT=low   # reasoning models only; keeps answers within the token budget
```

Budgets are in `config/ai.json`; narration parsing is sequential and capped per run.

Failure handling:
- An HTTP 429 gets one retry, honouring `Retry-After` and capped at 5 s.
- A second 429, a timeout, a malformed response or a schema failure falls back to the deterministic path. The reason is recorded on the output and in the audit trail.
- JSON mode is requested. If a provider rejects it, CloseLoop retries without it and extracts the JSON from the text.

## Deploying inside a customer environment

The server is built to run next to the data, not the other way round.

```bash
docker compose up                       # demo: seeds ./data/source.db, then starts the server
docker compose run --rm -T closeloop    # attach an MCP client over stdio
```

- **Read-only data mounts.** Point `CLOSELOOP_SOURCE_DB` at the customer's export, mounted `:ro`. CloseLoop attaches it read-only and keeps its own state (runs, exceptions, audit, drafts) in a separate database on a writable volume (`CLOSELOOP_DB`). Writes to source data fail at the storage layer, not just in code. The export must be a SQLite file in rollback-journal mode (not WAL) to be readable from a read-only mount.
- **Hardened container.** Non-root user (uid 10001), read-only root filesystem with a tmpfs `/tmp`, nothing listening on a port. The MCP transport is stdin/stdout.
- **No outbound calls when the LLM is disabled.** With no `LLM_*` variables the code makes no network requests, and the default compose service runs with `network_mode: none` to guarantee it. The LLM is opt-in through environment variables only (`docker compose --profile llm up closeloop-llm` reads `.env`).
- **What leaves when the LLM is on.** Explanation and drafting prompts contain the evidence records, including narrations. To keep data inside the customer's network, point `LLM_BASE_URL` at a self-hosted OpenAI-compatible endpoint (vLLM, Ollama, an internal gateway).
- **Connecting a client** to the containerised server:
  ```json
  { "mcpServers": { "closeloop": { "command": "docker", "args": ["run", "-i", "--rm", "--network", "none", "-v", "/path/to/export:/data:ro", "-v", "closeloop-state:/state", "-e", "CLOSELOOP_SOURCE_DB=/data/source.db", "closeloop-mcp:local"] } } }
  ```

## Lessons from deployment

Running the AI layer against a real free-tier provider (Groq, `openai/gpt-oss-120b`) surfaced problems that mocked tests didn't:

1. **Reasoning models spend the token budget on thinking.** With a 200-token cap the model used every token reasoning and returned an empty answer. Groq then rejected it under JSON mode as `json_validate_failed` (HTTP 400). Fix: send `reasoning_effort: low` when configured, raise the budgets, and classify `json_validate_failed` as a malformed answer rather than a bad request. The deterministic fallback kept every output correct in the meantime, which is the point of having one.
2. **Free-tier rate limits are per minute, and narration parsing ate them.** The tier allowed about 8k tokens a minute (from the response headers). Four parallel narration-parsing calls exhausted it, so the explanations that followed all fell back with `rate_limited`. Fix: narration parsing runs sequentially with a per-run call budget (`config/ai.json`). The one-retry-then-fallback policy means a busy provider degrades output quality, never availability.
3. **LLMs will do arithmetic on paise if you let them.** An early explanation added up raw paise amounts in its prose. Fix: prompts carry pre-formatted `*_inr` strings with an instruction never to compute, and every MCP response includes pre-computed totals, so the client model never has to add anything either.
4. **Model catalogues churn.** The model in the first draft of these docs had been retired. Check the provider's live `GET /models` list before configuring one (that's how `openai/gpt-oss-120b` was chosen); the examples above are only examples.

## Known limitations

- **Synthetic data only.** The generator and the matcher were written by the same person. Held-out noise variants the rules weren't written around reduce, but don't remove, that bias.
- **Narrow scope.** Single currency (INR), one Razorpay-style gateway model, one bank account, no multi-entity books.
- **No real connectors yet**: no bank-statement parsers, gateway settlement/payout API, or Tally/Zoho Books sync. Gateway payout reports would resolve most batch ambiguity, which amount-only decomposition can't (see EVAL.md).
- **The contiguous-batch rule is an assumption.** It holds for hold-and-release and scheduled payouts; when it's wrong, the match is wrong. EVAL.md counts how often.
- **Approximate bank calendar.** The holiday list is illustrative and covers 2026 only.
- **Single node, no authentication.** SQLite on one machine. Reviewer names are self-asserted, and confirmation tokens live in memory, so a restart forgets them.
- **Explanations are checked for structure, not truth.** Schema and cited evidence ids are validated; factual accuracy is not.
- **The dashboard is a static snapshot.** Approvals and escalations happen in the MCP agent, not on the site.

## Development

```bash
pnpm install
pnpm typecheck && pnpm lint && pnpm test
pnpm gen --seed 42 --payments 5000      # demo data → ~/.closeloop/closeloop.db (+ truth.db)
pnpm recon --from 2026-10-01 --to 2026-10-31
pnpm eval                               # full eval → EVAL.md (+ README Results)
pnpm eval --payments 5000               # CI smoke
pnpm demo:cases                         # regenerate DEMO.md
pnpm build && pnpm pack:smoke           # bundle, then clean-install test of the npm tarball
```

Repo layout: `packages/core` (generator, matcher, AI layer, audit, eval), `packages/mcp` (server and npm package), `scripts/`, `config/`. The design and every decision are in [PLAN.md](PLAN.md); project conventions are in [CLAUDE.md](CLAUDE.md).

## Credits

Design principle of deterministic truth with constrained AI inspired by RazorRecon (https://github.com/Ragh234/RazorRecon). CloseLoop is an independent implementation focused on batch decomposition, fuzzy matching and multi-source ledgers.