Skip to content

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, write

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

  1. Have a running Badlen instance, and know its exact address, scheme included. That address is your own instance’s, and it is what BADLEN_URL expects. A stack put up from Configuration and left on its defaults answers at http://localhost:8080, which is also what the server falls back to when BADLEN_URL is unset. Move WEB_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 as https://badlen.example.com.

  2. 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: node 22.12.0 or newer and pnpm 12.5.1, the versions the repository pins in its root package.json.

  3. Run pnpm install at the repository root, which pulls the MCP SDK.

  4. Issue an API key carrying exactly five permissions: accounts:read, dashboard:read, positions:read, accounts:write and transactions:write. The form lists each box under its own name, so these five are the ones to look for.

  5. Register the server with your client. Any MCP client takes the same three things under its own syntax: the command node, the absolute path to server.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.mjs

    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.

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_balance keeps 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_holding writes 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 send entryDate: 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 with occurrence: 2, which is itself as replayable as the first call.

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.

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.

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.

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.

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.

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.