Skip to main content
Glama
hmshuv

closeloop-mcp

by hmshuv

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

  • 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.

Related MCP server: fincon

Why this exists

[PLACEHOLDER — the author will write this section.]

Architecture

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

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:

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

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

{ "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 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 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.

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 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:

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

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.

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:

    { "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

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; project conventions are in 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    An accounting-ops agent that reconciles payments against open orders, auto-books provably safe payments through a deterministic policy gate, and escalates exceptions to a human queue with audit trails.
    MIT
  • F
    license
    B
    quality
    B
    maintenance
    Enables AI assistants to perform financial reconciliation with a deterministic proof engine: intake files, match transactions, verify proofs, resolve exceptions, and sign off on balanced journals under the user's authority.
    21
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI IDEs to perform financial reconciliation through five native tools for matching invoices, detecting bundled payments, and handling bank fee variances.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI agents to propose structured payment intents, apply deterministic policy, require human approval, and record tamper-evident audit trails without connecting to live payment rails.
    MIT