whoowes
v0.1.1
Published
MCP server for trip/event expense ledgers: shared expenses, settlements, multi-currency with retroactive weighted-average rates
Downloads
183
Maintainers
Readme
whoowes
MCP server for self-contained trip/event expense ledgers. You talk to a model
("we paid 200k naira for the hotel, 20% timi 80% george", "george sent me 20k",
"I converted another £60 and got 150k"), the model calls typed tools, and the
ledger does the arithmetic with decimal.js. The model never computes a number.
Model
- A tab is a self-contained event (a trip, a house project) with a base currency.
- Everything is an event:
expense,settlement,conversion, orrate. Every event carries an id and an optional free-textnote. - All state (balances, rates) is derived by refolding the full event log on every read. Nothing is stored and incremented, so retroactive revaluation is correct by construction.
- The log is append-mostly:
edit_eventandremove_eventcan correct it, but an edit patches an event in place, keeping its position (see below). - The exchange rate per currency is the weighted average of your real conversions (total base spent / total foreign received). Adding a conversion retroactively revalues every expense in that currency.
- A manually declared rate (
declare_rate, as foreign units per 1 base unit) overrides the conversions average while in force: expenses revalue retroactively and later settlements lock at it. Clearing the declaration falls back to the average. A declared rate needs no conversions, so a currency you never converted into can still be valued. - Settlements lock at the rate in force when they were recorded; they do not float afterwards. Money that actually moved keeps its value.
- Balances always sum to zero across participants.
Storage
A single JSON file at ~/.whoowes/ledger.json (override with the
WHOOWES_DIR env var). It is the event log; back it up by copying it.
Build
npm install
npm run build # tsc -> dist/
npm run smoke # runs the worked scenario with assertionssmoke and test:concurrency are checkout-only: they run TypeScript under
tsx (a devDependency) against scripts/, and the files allow-list ships
neither. They stay in the manifest because they are the development entry
points; running them inside an installed copy of the package will not work.
Publishing
dist/ is gitignored and built by two lifecycle hooks, because npm picks a
different one depending on how the package is being installed:
prepackruns onnpm packandnpm publish, so the tarball you inspect locally is compiled the same way the published one is and cannot ship a stale build.prepareruns on a git-URL install (npm i github:Zaida-3dO/whoowes), which does not fireprepack. Without it such an install lands with nodist/and no way to build one, so npm skips thewhoowesbin shim andnpx github:Zaida-3dO/whoowesfails. npm skipspreparefor registry-tarball installs, so it costs consumers of the published package nothing. (Afile:/local-directory dependency is symlinked into place, andpreparestill runs against the linked checkout — so it buildsdist/there, but only if that checkout's devDependencies are already installed. Otherwise the install fails on a missingtsc.)
The files allow-list ships dist/, README.md and LICENSE only — no
sources, no scripts, no fixtures.
npm pack # inspect the tarball first
npm publish --access publicThe whoowes binary is dist/server.js, which carries a #!/usr/bin/env node
shebang from src/server.ts (tsc preserves it and marks the output executable).
Transports
Stdio is the only transport:
| Entry | Command | Use |
| --- | --- | --- |
| stdio (published) | npx -y -p whoowes whoowes | One client spawns its own copy, no checkout needed. The usual way in. |
| stdio (from a checkout) | node dist/server.js | The same entry point, run from a local build. |
There was once a streamable-HTTP entry point (dist/http.js, serving POST /mcp,
GET /health and GET /view) for running whoowes as one shared process. It was
retired along with its container in September 2026, and src/http.ts, the express
dependency and the Dockerfile were removed with it. The HTML page it served lives
on as the display tool below.
Both stdio forms are the same program: dist/server.js is the package's whoowes
binary. Standard output is the protocol stream, so the server writes its startup
line (and everything else human-readable) to standard error — one stray line on
stdout would corrupt the JSON-RPC framing for every message after it.
display — the rendered page
The display tool writes a read-only HTML page of a tab to a file and returns its
path: position cards and a per-currency waterfall for one participant, then every
entry with its share assignment, the net per person, and the settlements. It folds
the log at render time, so unlike a hand-built snapshot it cannot go stale — but it
is a snapshot once written, so re-render to pick up later events.
displaywith notab— the only open tab, or a list of all of themdisplaywithtab: <name>— a specific tabdisplaywithtab: <name>, who: <participant>— focus the cards and waterfall on someone
Because the output is a standalone file opened over file://, the page carries no
navigation: there is no server to answer a link. Participant names and tab names
are rendered as plain marks, and each row shows the who/tab argument that renders
that view instead. Re-run display with those arguments to move around.
It states a combined cross-currency figure only when every currency involved has a real rate behind it; otherwise it names the missing rate rather than assuming one.
Only ever run one writer against a given WHOOWES_DIR. Within a single process
every tool handler does load → mutate → save synchronously in one tick and save()
is compare-and-swap (it refuses to overwrite a ledger that moved since it was read),
so a concurrent writer loses nothing silently. Two separate instances on the same
file still race at the check-to-rename window — see the note in src/store.ts.
Register
From npm (recommended)
No checkout, no build, no path to keep correct — npx fetches the published
package and runs its binary. In an .mcp.json (or Claude Desktop's
claude_desktop_config.json):
{
"mcpServers": {
"whoowes": {
"type": "stdio",
"command": "npx",
"args": ["-y", "-p", "whoowes", "whoowes"]
}
}
}The equivalent for Claude Code (user scope, works in any project):
claude mcp add --scope user whoowes -- npx -y -p whoowes whoowesTo pin a version, use -p [email protected]. To point the ledger somewhere other
than ~/.whoowes, set WHOOWES_DIR in the server's env block:
{
"mcpServers": {
"whoowes": {
"type": "stdio",
"command": "npx",
"args": ["-y", "-p", "whoowes", "whoowes"],
"env": { "WHOOWES_DIR": "/path/to/your/ledger/dir" }
}
}
}Each client that spawns this gets its own process. That is fine for one person
on one machine, but see the one-writer warning above before pointing two
spawned copies at the same WHOOWES_DIR.
From a local checkout
claude mcp add --scope user whoowes -- node /path/to/whoowes/dist/server.jsClaude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"whoowes": {
"command": "node",
"args": ["/path/to/whoowes/dist/server.js"]
}
}
}Against a shared HTTP instance instead (see below):
claude mcp add --scope user --transport http whoowes http://<host>:18801/mcpShared deployment (Docker)
The included Dockerfile builds the HTTP server. The ledger is not in the
image — mount a volume at /data (the image sets WHOOWES_DIR=/data).
docker build -t whoowes .
docker run -d --name whoowes-mcp -p 18801:8000 -v /srv/whoowes-data:/data whoowes
curl http://localhost:18801/healthClients then point at http://<host>:18801/mcp (transport streamable-http),
and everyone shares the same tabs. Back up by copying ledger.json out of the
mounted volume.
Tools
| Tool | Purpose |
| --- | --- |
| create_tab | New tab with a name and base currency |
| add_participant | Register a person (global, reused across tabs) |
| add_expense | Shared expense; shares by pct (sum 100) or fixed amounts (sum to total) |
| add_settlement | A payment between two people; locks at the current rate |
| add_conversion | A real FX conversion you made; moves the weighted-average rate |
| declare_rate | Manually pin a currency's rate (overrides the average); omit rate to clear |
| get_balances | Net position per participant in the base currency |
| get_person | One person's obligations, payments, and settlements |
| list_tabs | All tabs |
| list_events | The raw event log with ids — where you get the event_id for the two below |
| edit_event | Correct any event in place by id, patching only the fields you pass |
| remove_event | Delete any event by id (not just the last one) |
| set_tab_status | Close or reopen a tab |
| set_base_currency | Rebase a tab; everything revalues retroactively |
| delete_tab | Permanently delete a tab and its log (needs confirm: true) |
| undo_last_event | Remove the most recent event (the cheap common case) |
Editing is in place, on purpose
edit_event keeps an event at its original position in the log, and this is
load-bearing rather than an implementation detail. Settlements lock their base
value at the rate in force at their point in the sequence, so an edit
implemented as remove-then-append would slide the event behind later
conversions and silently relock settlements at a different rate — producing a
ledger that looks fine and is wrong. kind and id are immutable: an edit
corrects an entry, it doesn't turn one kind of event into another.
Both mutations validate by folding, then commit: the change is applied, the
whole tab is refolded, and it is only saved if the fold succeeds. That is what
refuses a remove_event on a conversion some later settlement still needs — the
fold throws no rate available for X, and the log is restored untouched.
Rebasing re-reads each conversion from the other side — a £110 -> ₦250,000
history reads as 0.00044 GBP per NGN on a GBP tab and 2272.73 NGN per GBP on
an NGN one. It is refused if any conversion has no side in the new base, or if a
rate is declared for it (a tab can't hold a rate against its own base).
Not in v1
Donation pots (collecting toward a goal rather than dividing a cost) and any messaging/settle-up flow. Computing is here; texting people is on you.
