With your assistant
Badlen ships an MCP server with eight tools built on the /api/v1 API. It is how a whole statement
gets into the product at once.
Your statements ──▶ your MCP client ──▶ MCP tools ──▶ Badlen /api/v1 reads and extracts import_transactions dedup, writeYour client reads the files, on your machine, under whatever model you already pay for or run. The server is a stdio bridge and holds no model, so Badlen never calls a model provider, no model key is ever stored on the server, and nothing from a statement reaches Badlen except operations that have already been extracted.
You have nothing to start and nothing to expose. The server is a Node script your client launches
when it needs it and talks to over standard input and output, so it has no port of its own, no
address it listens on, and no switch in Badlen: closing the client closes it. Its whole
configuration is two environment variables the client hands it, BADLEN_URL and BADLEN_API_KEY,
set where that client declares the server.
Set it up
Section titled “Set it up”-
Have a running Badlen instance, and know its exact address, scheme included. That address is your own instance’s, and it is what
BADLEN_URLexpects. A stack put up from Configuration and left on its defaults answers athttp://localhost:8080, which is also what the server falls back to whenBADLEN_URLis unset. MoveWEB_PORT, put the stack behind a reverse proxy, or serve it under a domain of yours, and that default is no longer your address: use the one you type in the browser. The examples below write it ashttps://badlen.example.com. -
Have Node and pnpm on the machine the client runs on. The server is a Node script your client starts directly, outside Docker, so a machine that only followed Install has neither:
node22.12.0 or newer andpnpm12.5.1, the versions the repository pins in its rootpackage.json. -
Run
pnpm installat the repository root, which pulls the MCP SDK. -
Issue an API key carrying exactly five permissions:
accounts:read,dashboard:read,positions:read,accounts:writeandtransactions:write. The form lists each box under its own name, so these five are the ones to look for. -
Register the server with your client. Any MCP client takes the same three things under its own syntax: the command
node, the absolute path toserver.mjs, and the two variables. The path has to be absolute, since the client does not start it from inside the repository. Two clients as examples:Terminal window claude mcp add badlen \-e BADLEN_URL=https://badlen.example.com \-e BADLEN_API_KEY=sk_your_key \-- node /absolute/path/to/packages/mcp/src/server.mjsclaude_desktop_config.json {"mcpServers": {"badlen": {"command": "node","args": ["/absolute/path/to/packages/mcp/src/server.mjs"],"env": {"BADLEN_URL": "https://badlen.example.com","BADLEN_API_KEY": "sk_your_key"}}}}A client driving a model you run yourself, through Ollama for instance, reaches these tools the same way and takes the same two variables. Getting such a client to read a statement well is advanced work that this page does not cover.
The eight tools
Section titled “The eight tools”| Tool | What it does |
|---|---|
list_accounts |
The accounts, with id, name, type and value, so the client knows where to import |
get_net_worth |
Net worth and allocation by asset class |
get_positions |
Holdings by security, with latent and realised gains |
create_account |
Create an account to import into |
set_balance |
Set a manual account’s balance on a given day |
add_holding |
Record a purchase of a fund or ETF by ISIN, price resolved automatically |
reconcile_transactions |
Dry run: compare a batch against the account without writing |
import_transactions |
Write the batch, deduplicated |
None of them deletes anything. Before you let a client retry, know what a second, identical call does:
set_balancekeeps one balance per account per day, so a second call for the same day replaces the first and the earlier figure is gone. The account is also valued at its newest dated balance, which is why a date years ahead is refused rather than stored.add_holdingwrites one line per purchase, so buying more of a security the account already holds adds another line, as it would in any living portfolio. A purchase is identified by its entry day, quantity, cost and fees, so the same call arriving twice writes the line once and answers with the line already there. That identification is what makes the call safe to retry, so sendentryDate: without it, on a security the account already holds, the call refuses and writes nothing, naming the lines in place, since nothing then tells a second purchase from a call made again after a dropped connection. A real second identical purchase on one day is declared withoccurrence: 2, which is itself as replayable as the first call.
Importing a statement
Section titled “Importing a statement”Point your client at the files and ask it, in plain words, to import them into Badlen. It
normalises the operations, calls reconcile_transactions to see the duplicates and raise
its questions with you, then calls import_transactions once you have answered.
Re-running the same statement is safe: duplicates are skipped. The same holds for a
purchase sent twice, as long as it carries its entry day, as above.
One statement is one call. Duplicate detection does not cross calls: two identical operations on the same day (two card payments to the same merchant, for the same amount) are told apart by their rank inside the batch. A statement split over several calls therefore loses one of each identical pair, and the answer reports it as a skipped duplicate, which looks exactly like the protection working.
The import route
Section titled “The import route”Both import tools call one route, POST /api/v1/import, and its dry run,
POST /api/v1/import/reconcile, with the key’s transactions:write. A script of your own would use
the same route and the same key; this section explains what they do with a batch, whoever sends it.
Line format
Section titled “Line format”The route takes an account id and an array of normalised lines, and turning a broker file into them is the caller’s work.
| Field | Required | Shape |
|---|---|---|
date |
yes | YYYY-MM-DD, a day the calendar has, 1900 to tomorrow |
amount |
yes | Signed decimal string; negative is money out |
label |
yes | Non-empty text |
currency |
no | Three letters; EUR when absent |
fee |
no | Non-negative decimal string |
tax |
no | Non-negative decimal string |
Money travels as a decimal string, never as a JSON number: a number is a double by the time the API reads it, and a quantity loses its last digits on the way in. A body that sends one is refused, and so is a figure past what a money column holds.
A batch holds at most 20 000 lines, more than any statement, and is taken whole or not at all. A single line the schema rejects refuses the whole batch, and the error gives the line’s position in what you sent and says what is wrong with its value. Nothing was written, so the lines can be corrected and the same batch sent again.
The schema also accepts type and counterparty, but the import does not write them onto the line.
It takes no category either, and needs none: filing operations is the application’s own job,
whatever their origin, so the import files each line as it writes it, by your own rules first and
the built-in keywords after. The answer says how many lines that filed, in categorized; a line no
rule recognises is left filed nowhere, and Badlen does not guess a category for it.
Operations imported before this existed are caught up by Recategorize on Settings → Categories, which reads what is filed nowhere and never rewrites an operation that already carries a category.
Duplicates
Section titled “Duplicates”Each line gets a fingerprint built from its date, its amount and its label folded to lower case. A line whose fingerprint the account already holds is skipped, which is what makes re-sending the same statement safe. Two identical lines of one batch are ranked apart, so both land; that rank depends on the batch and never on what is stored, which is why a statement goes in one call.
An operation you deleted after an earlier import is remembered. Badlen keeps a deletion marker for the fingerprint, and the same line in a later import of the same statement is not written back.
The dry run
Section titled “The dry run”POST /api/v1/import/reconcile takes exactly the same body and writes nothing. It answers
with the split of the batch (how many lines are new, how many are already there, how many
are buried), plus its date range, the inflow and outflow totals, how many operations the
account already holds and up to what date, and the lines that match an existing date and
amount under a different label. It also returns the questions worth putting to you before
committing.
Both calls count through the same function and the import answers with the same figures, so what the dry run announces is what the write does.
When a call fails
Section titled “When a call fails”| What you see | What it means |
|---|---|
401 on every tool |
The key is absent, unknown, revoked or expired |
403 on one tool |
The key was issued without the permission that tool needs |
400 on an amount |
Amounts travel as decimal strings; a JSON number is refused outright |
400 on a date |
A date is ten characters, YYYY-MM-DD, a day the calendar has, and no later than tomorrow |
400 naming a line |
One line of the batch was refused. The message gives its rank in the batch and what is wrong with the value, and nothing was written: correct those lines and send the same batch again |
Cannot reach Badlen at … |
Nothing answered at that address. The message quotes the one it tried and says whether it came from BADLEN_URL or from the default, so check the instance is running and that the port is the one it serves on. This is the first failure most readers meet |
Every route the tools use is described in the REST API reference, generated from the controller rather than typed by hand.