Reai
FreeNot checkedMCP server for ReAI, the Norwegian cloud accounting system, enabling AI agents to read books, look up accounts and VAT codes, and perform bookkeeping through a
About
MCP server for ReAI, the Norwegian cloud accounting system, enabling AI agents to read books, look up accounts and VAT codes, and perform bookkeeping through a curated toolset with safety guardrails.
README
An MCP server for ReAI, the Norwegian cloud accounting system — so an AI agent can read the books, look up accounts and VAT codes, and do real bookkeeping through the API.
Not affiliated with or endorsed by ReAI. Community-built, MIT licensed.
You: What did we spend on inventory this year, and which account is it on?
Agent: [reai_general_ledger] Account 1460 "Innkjøpte varer for videresalg" — 12 postings, closing balance 4 812,60 NOK.
Why this exists
ReAI's API is genuinely good — 321 documented operations covering the whole accounting domain. But that is far too many to expose as 321 MCP tools: it would exhaust any client's tool budget and bury the agent in choices.
So this server does two things at once:
- Curated tools for the operations that matter most, with real guardrails — account lookup, VAT codes, vouchers, postings, the general ledger. A voucher's debit/credit balance is checked before the request is sent, so you get a useful explanation instead of a generic
422. - A discovery escape hatch —
reai_search_endpoints,reai_describe_endpointandreai_request— so nothing in the API is out of reach. Leads, agreements, subscriptions, assets, payroll, Peppol: all callable, with schemas on demand.
You get ergonomics where it counts and full coverage everywhere else.
Safety: this writes to real accounting books
Accounting data is not ordinary application data. Under the Norwegian Bookkeeping Act (bokføringsloven), a voucher posted in a closed period cannot simply be deleted — it must be corrected with a reversing entry. A submitted VAT return cannot be unsubmitted.
An agent exploring an API by trial and error is therefore genuinely dangerous here, so every operation is classified and gated by REAI_WRITE_MODE:
| Mode | Allows | Use it when |
|---|---|---|
read-only |
GET only |
Reporting, analysis, letting an agent answer questions about the books |
reversible (default) |
Reads, plus master data that can be cleanly deleted — customers, suppliers, products, departments, offers | Day-to-day agent work |
full |
Everything: ledger postings, invoices, payments, payroll, VAT returns | You are prepared to correct mistakes by hand |
Two properties make this more than a label:
- Tools you cannot use are not advertised. In
reversiblemode the ledger-write tools are not registered at all, so the agent never sees them and cannot try. - The escape hatch fails closed.
reai_requestclassifies each call by method and path. An unrecognised write path is treated as irreversible and blocked — so a future endpoint this server has never heard of cannot slip through as "probably fine". Dot segments cannot straddle two paths either:POST /api/customers/../vouchersis refused, not resolved. - The body is inspected too, not just the path. Some payloads are more dangerous than their endpoint suggests: an order carrying
sendEhf: truearms Peppol transmission to a real counterparty, and a subscription withoutputMode: "create_invoice"issues numbered invoices on a schedule. Both escalate to irreversible.
The default is deliberately the middle setting, not the permissive one.
Changing where money goes is treated as irreversible
A few fields are ordinary master data as records and permanent as consequences. Undoing the edit is trivial; undoing what follows is not, because it happens later and through someone acting perfectly normally.
| Fields | Where | What happens later |
|---|---|---|
iban, bankAccountNumber, swiftCode |
suppliers, customers, creditors | Whoever pays that counterparty next — quite possibly a person clicking through the ReAI web UI weeks afterwards — sends money to whatever account is on file |
bban, swiftCode |
company banks, editing one | Repoints where your own customers pay. Invoices already issued name that account. Adding a new company bank stays ordinary work |
invoiceEmail |
customers, orders, subscriptions | Every future invoice is delivered to that address. Not a payment — a disclosure — so the refusal says so, and tells you to confirm the address through a channel you already trust rather than to check bank details |
So a call carrying one of those fields is classified irreversible and refused in the default mode, on the curated tools and through reai_request alike, even though the endpoint itself is otherwise reversible. Every other field on the same tool is unaffected: renaming a supplier still works in reversible. Adding a new company bank stays ordinary work; repointing an existing one does not.
This was a real gap rather than a hypothetical: reai_update_supplier is declared reversible, its description promised that the bank fields "require REAI_WRITE_MODE=full", and nothing enforced it — while reai_request refused the identical PATCH. A control that is written down but not implemented is worse than none, because it invites running the default mode believing the fields are protected.
Sending things to other people is a separate switch
REAI_WRITE_MODE answers what can be undone in the books. It deliberately does not answer does this reach someone else — those are different questions, and one setting cannot serve both.
So REAI_ALLOW_EXTERNAL_SEND gates everything that leaves the tenant, independently of the write mode:
- EHF/Peppol transmission, and any order carrying
sendEhf: true - Invoice email, payment reminders, agreement signing requests
- Issuing a customer invoice —
POST /api/invoicesstarts delivery asynchronously (eFaktura, then EHF, then PDF by email), so it is not a books-only operation
It is off by default, and REAI_WRITE_MODE=full does not lift it. A posting can be reversed; an invoice that has gone over Peppol cannot be recalled.
Turn it on if this deployment does your invoicing. That is the ordinary case and the reason an accounting integration exists:
REAI_WRITE_MODE=full
REAI_ALLOW_EXTERNAL_SEND=1
Leave it off while evaluating, or when working against books whose real counterparties should not hear from you — which is exactly the situation when there is no sandbox and you are testing against a live company. The combination full + no external send is a genuinely useful place to be: the agent can do real bookkeeping and still cannot email anybody.
Install
Requires Node.js 20 or newer, and a ReAI API token (app.reai.no → settings → API tokens).
Token scope decides how much of this server is useful. ReAI issues both kinds, and the API behaves differently for each — the OpenAPI spec says X-Tenant-Id is "required for tenant-scoped requests when authenticating with a user access token", and GET /api/me returns "the tenants available to the token".
| tenant-scoped token | user-scoped token | |
|---|---|---|
GET /api/me lists |
exactly one company | every company the user can open |
X-Tenant-Id |
ignored when the token reaches one company — any value, even a nonexistent id, returns that company's data | required on every tenant-scoped call, and honoured |
reai_use_tenant |
nothing to switch to | selects which company you are working in |
A user-scoped token is what makes this worth running for an accountant: one connection reaching every client company, with reai_whoami listing them and reai_use_tenant moving between them. reai_whoami reports what it can actually tell — that the token reaches one company or several — without guessing which kind it is, because GET /api/me has no field that distinguishes a tenant-scoped token from a user-scoped one belonging to a user with a single company. It also warns when the companies do not share a currency, and says to read the currency on each record rather than assume the company's: an invoice total is in the invoice's currency, which can differ again.
The safety consequence cuts the other way, which is why a remote connector binds one company at authorization time: a token that reaches thirty client companies should not hand an agent all thirty because it was asked about one. That applies to what is disclosed as well as what can be addressed — on a bound connection reai_whoami lists only the bound company, and says the others exist without naming them.
Claude Code
claude mcp add reai --env REAI_USER_API_TOKEN=your-token -- npx -y reai-mcp
Claude Desktop / Cursor / any stdio client
{
"mcpServers": {
"reai": {
"command": "npx",
"args": ["-y", "reai-mcp"],
"env": {
"REAI_USER_API_TOKEN": "your-token",
"REAI_WRITE_MODE": "reversible"
}
}
}
}
From source
git clone https://github.com/Stener1/reai-mcp.git
cd reai-mcp
npm install
npm run build
REAI_USER_API_TOKEN=your-token npm start
Self-hosting as a remote connector
The same server also speaks MCP over Streamable HTTP, so it can be added as a custom connector rather than spawned locally. There is no hosted instance — you run your own, which means your ReAI token never leaves infrastructure you control.
It implements OAuth 2.1 as its own authorization server: dynamic client registration (RFC 7591), authorization code + PKCE (S256 only), resource metadata (RFC 9728), and refresh tokens. ReAI itself uses static API tokens and has no OAuth endpoints, so the flow bridges the two — the user pastes a ReAI token on the consent page, the server verifies it against GET /api/me, and then mints its own tokens carrying it.
Docker
docker build -t reai-mcp .
docker run -p 8080:8080 \
-e REAI_ENCRYPTION_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")" \
-e PUBLIC_URL=https://reai-mcp.example.com \
-e REAI_WRITE_MODE=reversible \
reai-mcp
Then add https://reai-mcp.example.com/mcp as a custom connector. No REAI_USER_API_TOKEN is needed in remote mode — each user supplies their own during authorization.
Google Cloud Run
./scripts/deploy-cloud-run.sh --project my-gcp-project
That is a single command because the manual version has three steps that are easy to get wrong, and the script handles all of them:
- Creates
REAI_ENCRYPTION_KEYin Secret Manager and grants the runtime service account access. Without a stable key, every authorization breaks on each cold start and separate instances reject each other's tokens. - Sets
PUBLIC_URLin a second pass. The URL is not knowable before the first deploy, and if it does not match, the OAuth metadata advertises the wrong issuer. - Pins
REAI_ALLOWED_HOSTSto the deployed host, so a client-suppliedHostheader cannot decide what the deployment claims to be.
It then verifies /health and checks the advertised issuer matches the deployed URL.
Useful flags: --region, --service, --write-mode (defaults to reversible; full prints a warning and pauses), --allowed-redirect-hosts (defaults to claude.ai).
--allow-unauthenticated is required and is not a mistake — the MCP client must reach the OAuth endpoints before it has a token. The server does its own authentication: every /mcp request needs a valid token, and anonymous ones get a 401 with a WWW-Authenticate challenge.
Two things learned deploying this for real:
- Cloud Run may serve one service on more than one hostname. Pinning
PUBLIC_URLmeans every hostname advertises the same issuer, so a client connecting via an alias follows the metadata to the canonical URL rather than seeing the issuer change per request. - It scales to zero, so it costs essentially nothing idle — but it is your deployment. Anyone who reaches the URL can authorize with their own ReAI token and reach their own books on your compute.
On locking it down, honestly: a public MCP connector is reachable by design, and the usual advice does not straightforwardly apply.
- IAP does not work for this. It requires an external HTTPS load balancer, and once IAP is enforcing,
claude.aicannot authenticate to it — the connector simply stops working. IAP is the right answer only for a private deployment you drive yourself, which is theREAI_ALLOW_TOKEN_PASSTHROUGHstory behind Tailscale. - Cloud Armor alone is bypassable. Putting a load balancer with Cloud Armor in front does nothing while the default
run.appURL is still reachable and unauthenticated — callers just use that instead. If you go this route you must also set--ingress=internal-and-cloud-load-balancingso the service only accepts traffic arriving through the balancer. Note that Anthropic publishes no stable egress IP ranges, so an IP allowlist cannot reliably permitclaude.aianyway.
What actually helps: --max-instances caps the spend, REAI_ALLOWED_REDIRECT_HOSTS means only your own client's callback can start a flow, REAI_WRITE_MODE bounds what any authorized session can do, and not advertising the URL is the practical control. Every authorized user reaches only their own books, because the grant carries their own ReAI token — so the exposure is your compute bill, not your data.
Why there is no database
Access tokens are sealed: the user's ReAI token is encrypted into the token itself with AES-256-GCM, along with the tenant and write mode chosen at authorization time. Any instance can therefore serve any request with no shared session store — which is what makes a scale-to-zero, multi-instance deployment practical.
The trade-offs are worth stating plainly:
REAI_ENCRYPTION_KEYis required in production. Without it a random key is generated at startup, so every existing authorization breaks on restart, and separate instances reject each other's tokens. The server warns loudly.- Individual tokens cannot be revoked before they expire (8 hours). Rotating
REAI_ENCRYPTION_KEYinvalidates all of them at once, which is the intended remedy. - Treat the key like a credential. It decrypts every user's ReAI token. Use Secret Manager, not an env var in source control.
Restrict who can register a client
Client registration is open, because that is what MCP clients expect. On a public deployment that has a consequence worth understanding: anyone can register a client with their own callback URL and send someone a link to your server's genuine consent page, on your domain, with valid TLS — then collect the ReAI token that gets pasted there.
The consent page pushes back on this. It names the redirect host as the party requesting access, treats the client's self-reported name as unverified, and shows the full callback URL next to a warning that whoever controls it gains full access to the books. But the real fix is to say which clients you actually use:
REAI_ALLOWED_REDIRECT_HOSTS=claude.ai
Unknown callback hosts are then refused at registration and never reach the consent page. Loopback stays allowed so local clients and MCP Inspector keep working.
One tenant per authorization
The company selected during authorization is a boundary, not a default. A grant bound to tenant 4711 cannot address any other tenant, even though the underlying ReAI token may unlock dozens — relevant for an accountant whose token reaches every client company. Tools that pass a different tenantId, and reai_use_tenant, are both refused with an explanation. To work in another company, re-authorize and pick it.
An authorization with no bound company is refused outright, at every point it could be used — issuing, redeeming and refreshing. Early builds could mint one when GET /api/me returned no companies, and such a grant had no tenant boundary at all. If you authorized before this and see invalid_token with "not bound to a company", remove and re-add the connector.
Worth being precise about what this rests on, because three different claims are easy to run together. Measured with a user-scoped token:
- Selection is real.
GET /api/chart-of-accountsunder two of the token's companies returns different payloads, soX-Tenant-Idchooses the company. - Isolation is real. The same call with an id the token does not reach (
99999999,1) returns403, so the API refuses a company the token has no access to. - The binding is not the API's. A grant narrowed to one company is enforced here — ReAI sees the underlying user token, which legitimately reaches all of them, so it cannot know the authorization was scoped. Treating that as API-enforced would be a false assurance.
For a tenant-scoped token none of the first two applies: the header is ignored, any id returns that one company's data, and a request that appears to reach elsewhere has not. scripts/check-token.sh reports which case a token is in.
So the binding is exactly as strong as this process, which is the right architecture — the token is the user's own, and they were never prevented from calling ReAI directly — but do not read it as the API sandboxing them.
Request limits
The MCP endpoint enforces two ceilings, both well above any real tool call:
| Limit | Value | Why |
|---|---|---|
| Request body | 8 MB | The transport otherwise parses an unbounded body: a 400 MB POST exhausted the heap of a 512 MiB container, taking every other in-flight request with it. Over the limit is answered 413 and the connection is closed — but see the note below, because a far oversized body gets no response at all |
| JSON-RPC batch | 50 messages | Every entry in a batch is dispatched concurrently, so 1000 of them meant 1000 simultaneous ReAI calls. The write policy is applied per call and never sees the aggregate, which in full mode made one HTTP request a route to thousands of postings |
A 413 is not guaranteed. To answer at all, the server has to finish reading the body it is rejecting: closing while data is still arriving makes the OS send RST, which discards the response the client has not read yet. So an oversized body is drained first — bounded at 32 MB and 5 seconds — and only then answered. Past either bound the request is destroyed with no response, which the client sees as a connection reset. The 400 MB case above is exactly that. Worth knowing before diagnosing a silent reset as a network fault.
GET /mcp answers 405. A standalone SSE stream exists to carry server-initiated messages, which requires a session; this server is stateless by design — a fresh MCP server per request — so nothing could ever be sent on one. The spec permits either SSE or 405 here, and 405 is the honest answer. No client capability is lost: the server runs with enableJsonResponse, so a POST is answered with a single JSON response rather than an event stream, and there is nothing a standalone stream would have carried.
Verify a deployment
REAI_USER_API_TOKEN=your-token node scripts/smoke-http.mjs --url https://reai-mcp.example.com
This walks the entire OAuth flow the way a real client does — discovery, registration, PKCE authorization, token exchange, refresh — then connects over Streamable HTTP and calls read-only tools. It also asserts the negative cases: that PKCE is mandatory, that an authorization code cannot be replayed, that a forged token is refused, and that the ReAI token is never echoed back.
Remote configuration
| Variable | Default | Purpose |
|---|---|---|
PORT |
8080 |
Listen port |
PUBLIC_URL |
inferred from Host |
Set in production. Published in OAuth metadata, so it must match what clients connect to. Must be a bare origin — a path, query or fragment is rejected at startup. REAI_PUBLIC_URL is accepted as an alias |
REAI_ENCRYPTION_KEY |
random per boot | Set in production. 32 bytes, base64 or hex. Seals access tokens |
REAI_ALLOWED_HOSTS |
— | Comma-separated hostnames to accept; enables DNS-rebinding protection and pins the advertised OAuth issuer |
REAI_ALLOWED_REDIRECT_HOSTS |
any https host | Comma-separated hosts allowed as OAuth redirect targets. Recommended on a public deployment — see below. Loopback is always permitted |
REAI_ALLOW_TOKEN_PASSTHROUGH |
off | Accept a raw ReAI token in the Authorization header, skipping OAuth. Convenient behind Tailscale or IAP; anyone who reaches the URL acts as whoever's token they present, so never enable it on a public deployment |
Verify it works
REAI_USER_API_TOKEN=your-token npm run smoke
This launches the server as a real MCP client would, then exercises read-only tools against the live API and asserts that the write policy blocks a ledger write. It touches nothing, so it is safe against production books.
First steps with an agent
Almost every endpoint is tenant-scoped — the tenant id selects which company's books you are in — so start there:
reai_whoami— who the token belongs to, and which companies it reaches.reai_use_tenant— pick one for the session. Validated against the real list, so a typo fails immediately instead of silently writing into the wrong company.
Then work normally. Set REAI_TENANT_ID to skip step 2.
Tools
Orientation
| Tool | Purpose |
|---|---|
reai_whoami |
Authenticated user, accessible tenants, active tenant, current write policy |
reai_use_tenant |
Select the active company for this session |
Discovery — the escape hatch
| Tool | Purpose |
|---|---|
reai_list_api_tags |
All 52 API domains with operation counts — a map of what the system can do |
reai_search_endpoints |
Keyword search across all 321 public operations |
reai_describe_endpoint |
Full schema for one endpoint, nested objects resolved — with known quirks first |
reai_api_notes |
Browse the known API quirks (see below) |
reai_request |
Call any endpoint. Auth and tenant handled; writes are policy-checked |
Bookkeeping
| Tool | Purpose | Risk |
|---|---|---|
reai_list_accounts |
Search the chart of accounts (kontoplan) | read |
reai_list_vat_codes |
VAT (mva) codes with rates — tenant-specific, so look them up | read |
reai_list_vouchers |
Vouchers (bilag) in a date range, with postings | read |
reai_get_voucher |
One voucher with postings and attachments | read |
reai_list_postings |
Ledger postings, filterable; reports canDelete and lockReasons |
read |
reai_general_ledger |
Hovedbok: per-account opening balance, postings, closing balance | read |
reai_create_voucher |
Book a voucher; balance validated locally first | irreversible |
reai_delete_voucher |
Delete a voucher, if the period is still open | irreversible |
Sales
| Tool | Purpose | Risk |
|---|---|---|
reai_list_customers · reai_get_customer |
Find and read customers (kunder) | read |
reai_customer_ledger |
Kundereskontro — who owes what; isOpenPosting answers "who owes us money" |
read |
reai_list_products |
Products and their variants; order lines reference a variantId |
read |
reai_list_orders · reai_get_order |
Orders and their lines | read |
reai_list_offers |
Offers / quotes (tilbud) | read |
reai_list_invoices · reai_get_invoice |
Invoices and credit notes; filter outstanding + overdue |
read |
reai_create_customer · reai_update_customer · reai_set_customer_address · reai_delete_customer |
Customer master data | reversible |
reai_create_product · reai_delete_product |
Create a product (no variants or price — see the tool's note); delete archives it once used | reversible |
reai_create_order · reai_delete_order |
Create an order with lines. Sends nothing to the customer; delete works until it is invoiced | reversible |
reai_create_offer · reai_delete_offer |
Create an offer. Lines require itemName and vatCode; an offer is a draft, so delete removes it outright |
reversible |
reai_create_invoice_from_order |
Issue an invoice from an order | irreversible |
reai_credit_invoice |
Credit note — the correct way to undo an invoice | irreversible |
reai_register_invoice_payment |
Record a customer payment | irreversible |
Purchase
| Tool | Purpose | Risk |
|---|---|---|
reai_list_suppliers · reai_get_supplier |
Find and read suppliers (leverandører) | read |
reai_supplier_ledger |
Leverandørreskontro — isUnpaid answers "what do we owe" |
read |
reai_list_supplier_invoices · reai_get_supplier_invoice |
Registered supplier invoices and credit notes | read |
reai_list_reception_documents |
The document inbox — incoming invoices and receipts not yet booked | read |
reai_parse_ehf_attachment |
Parse an incoming EHF invoice into structured data | read |
reai_list_expenses |
Employee expense claims, incl. per diems and mileage | read |
reai_create_supplier · reai_update_supplier · reai_delete_supplier |
Supplier master data. Changing bank details (iban, bankAccountNumber, swiftCode) escalates the call to irreversible — see below |
reversible |
reai_create_supplier_invoice |
Register a supplier invoice directly | irreversible |
reai_register_supplier_invoice_payment |
Record paying a supplier | irreversible |
Bank & VAT
| Tool | Purpose | Risk |
|---|---|---|
reai_list_company_banks |
The company's own accounts; the id is the companyBankId others need |
read |
reai_get_bank_reconciliation |
Reconciliation state for one account and month — the only way to see bank transactions | read |
reai_get_bank_transaction |
One transaction by id | read |
reai_list_reconciliation_rules |
Automatic booking rules | read |
reai_get_tax_return |
Skattemelding for a year, with submission status | read |
reai_create_company_bank · reai_delete_company_bank |
Register a bank account, or remove one. Neither touches anything at the bank | reversible |
reai_create_reconciliation_rule · reai_delete_reconciliation_rule |
Manage booking rules. A rule is standing authority to post — applying it books vouchers, and deleting it does not reverse them | irreversible |
reai_match_bank_transactions |
Reconcile transactions against existing postings | irreversible |
reai_book_bank_transactions |
Book transactions to a counter-account | irreversible |
reai_apply_reconciliation_rules |
Run the rules over a period (background job) | irreversible |
reai_create_vat_return |
Settle and lock a VAT term — does not file with Skatteetaten | irreversible |
Anything not listed — leads, agreements, subscriptions, projects, assets, warehouses, employees, salary, opening balances, annual accounts — is reachable through reai_search_endpoints + reai_request, and carries its known quirks automatically.
If 63 tools is more than your client wants to see, narrow it with REAI_TOOLSETS — list only the groups you want:
REAI_TOOLSETS=bookkeeping # 15 tools
REAI_TOOLSETS=bookkeeping,sales # 37 tools
REAI_TOOLSETS=purchase # 20 tools
(unset) # all 59
Valid groups are bookkeeping, sales, purchase and bank; listing all four is the same as leaving it unset. Orientation and discovery are never disabled, so a narrowed server still reaches every endpoint through reai_search_endpoints + reai_request.
API quirks worth knowing
An accounting API has more sharp edges than its schema admits, and most of what follows was learned from a rejected request rather than from reading the spec. Rather than leave that knowledge in commit messages, it lives in src/reai/quirks.ts as 50 quirks keyed to the operations they affect — so they surface automatically in reai_describe_endpoint and reai_search_endpoints, including for the ~258 operations no curated tool covers.
Browse them with reai_api_notes, or read the highlights:
Shapes that aren't what the name suggests
- An invoice is created from an ORDER, not from line items.
POST /api/invoicestakes anorderId. There is no endpoint that builds an invoice from lines — the order carries them. - There is no endpoint that lists bank transactions. Only get-by-id. Transactions are visible solely through the reconciliation view for one account and one month, which makes that view the entry point for all bank work.
- Voucher postings and supplier-invoice cost lines use different conventions. A voucher posting is one signed amount (positive debits, negative credits, summing to zero). A cost line names debit and credit accounts explicitly, and its sign encodes document type — positive on an invoice, negative on a credit note.
POST /api/vat-returnstakesyearandperiodas query parameters, not a body.
Constraints the schema omits
- Voucher postings sharing a
rowNumberare merged into one row, so they must agree on the row's fields — notablydescription. An omittedrowNumberputs everything in row 0, so two postings with different descriptions fail with an error that blames the sign convention.reai_create_voucherassigns rows for you. startDate/endDateare required on vouchers, postings and ledgers even where not marked so.- Offer lines are stricter than order lines —
itemNameandvatCodeare both required on an offer. - Order and subscription lines accept only VAT codes from
?usage=customer-invoice; offer lines are not checked against it, so an offer can be accepted carrying a code that fails once the work becomes an order or invoice. - A
+47prefix on a Norwegian phone number is rejected. POST /api/customerssilently discardsinvoiceEmail,phoneanddaysUntilDue— those live on thePATCH.GET /api/timesheetsis unusable without the Project module —projectIdis a required query parameter, and supplying it returns400 "projectId cannot be used when the Project module is disabled". Required and rejected at once, so no request succeeds.
Collections that are not arrays
- The lead endpoints return a page object, not the bare array nearly every other collection does — so iterating the response or reading
.lengthgets you nothing./api/leadsgives{ items, page, hasPrevious, hasNext, … }withpageSize1–200 (default 50);/api/leads/person-profilesgives{ items, hasMore, nextStartOrgNo, limit }and pages bynextStartOrgNoinstead of by number. - A lead row's
idis null only while it is unsaved (a live Brønnøysund entry). Saved leads have ids, and they are the key toGET/PATCH /api/leads/{id}and…/convert— keep them rather than discarding them. /api/warehouses/inventoryreturns{ warehouseId, rows, totalStockValue, totalRetailValue }. The totals are already computed; do not sumrows.
Empty states that look like errors
- A 404 from
/api/opening-balancesor/api/annual-accounts/{year}means "nothing set up yet", which is the normal state for most companies — not a wrong path. Thedetailsays as much; report it as empty instead of hunting for another endpoint. - Payroll lives under
/api/salary-payments;/api/salariesdoes not exist. A new run already contains wage lines derived from expense postings — read it back before adding any, because…/wage-specsadds a manual line and re-entering existing ones inflates salary and expenses, and expense-derived lines cannot be edited or deleted to fix it.…/{id}/completestarts A-melding submission to Skatteetaten, so it counts as an external send.
Things harder to undo than they look
- Settling a VAT period locks it but files nothing. There is no submission endpoint in the public API;
/complete-manuallyexists to record that a return was filed elsewhere. Never report a VAT return as submitted. periodis a term index, not a month: 1 = Jan–Feb … 6 = Nov–Dec. Passing4for April locks Jul–Aug.- An issued invoice cannot be deleted — credit it.
sendEhf: trueon an order arms Peppol transmission to a real counterparty at invoicing time.- A subscription with
outputMode: "create_invoice"andautomaticBillingGeneration: trueissues invoices on a schedule with no further call. DELETEon a supplier invoice reverses it rather than removing it.
Surprises
X-Tenant-Idis ignored when a token reaches only one company. Verified live: every tenant id returns that one company's data — including an id that does not exist, and one belonging to another user. Data stays isolated between users, so this is not a leak; but a 200 is not evidence you reached the tenant you asked for. TreatGET /api/meas the only authority on what a token can reach./api/mecan under-report. A company can exist in the ReAI UI while/api/meomits it — seen with one added but not finished onboarding. Combined with the point above this is a trap: probing it returns 200 with the wrong company's data, which looks like success.- ReAI title-cases stored names, so a round-trip is not byte-equal.
DELETEarchives instead of deleting when a record has transactions; the response says which.- A 403 is often a disabled module, not a permissions problem — read the
detail. apply-rulesis a background job returningstartedoralready_running; the work isn't done when the call returns.- Two reconciliation views exist: synced accounts use
/api/bank-reconciliations/{id}, accounts withproviderType: "manual"use/api/manual-reconciliations/{id}.
Conventions that are simply true everywhere
- Dates are ISO
yyyy-MM-dd; reconciliation months areyyyy-MM. - Account numbers and VAT codes are tenant-specific — look them up, never assume.
daysUntilDueis mandatory on orders and offers, so the API can never apply the customer's own terms by itself. The curated tools read the customer's terms for you and report which source they used.- Deep links need the tenant:
https://app.reai.no/vouchers/123?tenantId=2634. The tools return these already formed.
Where a curated tool exists, it enforces what it can locally so you get an explanation instead of a 400: reai_create_voucher checks the debit/credit balance and reports the exact imbalance, reai_create_supplier_invoice checks cost-line signs against the document type, and reai_apply_reconciliation_rules refuses to run without a bounded period.
A test asserts every quirk still matches a real operation in the spec, so they can't quietly rot as the API changes.
Configuration
| Variable | Default | Purpose |
|---|---|---|
REAI_USER_API_TOKEN |
— | Required. ReAI user API token. REAI_TOKEN is accepted as an alias |
REAI_TENANT_ID |
— | Default tenant, so tenantId can be omitted |
REAI_WRITE_MODE |
reversible |
read-only, reversible or full — see Safety |
REAI_ALLOW_EXTERNAL_SEND |
off | Permit sending to third parties: EHF/Peppol, invoice email, reminders, signing requests, and issuing an invoice. Enable this for a business doing its own invoicing — see below |
REAI_BASE_URL |
https://app.reai.no |
Override for a staging environment |
REAI_TIMEOUT_MS |
30000 |
Per-request timeout |
REAI_MAX_RETRIES |
2 |
Retries on 429/502/503/504, with exponential backoff and jitter |
REAI_VERBOSE |
off | Log one line per API request to stderr. Never logs tokens |
See .env.example for the annotated version.
How the API surface is kept current
spec/reai-openapi.json is a pinned snapshot of https://app.reai.no/openapi. npm run build:spec compresses it into a 195 KB searchable index (from 907 KB), keeping only what is needed to find an operation; full schemas are resolved from the snapshot on demand. Refresh it with:
curl -H 'Accept: application/json' https://app.reai.no/openapi -o spec/reai-openapi.json
npm run build
Development
npm install
npm run build # rebuild the spec index, then compile
npm test # build + unit tests (no credentials needed)
npm run typecheck
npm run smoke # read-only, end-to-end against the live API (needs a token)
Unit tests cover the write-policy classifier and spec search/describe, and need no network access or credentials.
Running CI's checks locally
./scripts/ci-local.sh # the working tree
./scripts/ci-local.sh main # a specific ref
This runs everything .github/workflows/ci.yml runs — typecheck, build, the unit tests, and the published-package check — against every Node version in the matrix, and exits non-zero if any of it would fail.
It was written during a multi-hour GitHub Actions outage, when no workflow could start at all: "wait for a green tick" stops being a quality gate while the service producing the tick is down. It is not a substitute for CI, because it cannot reproduce the clean-room npm ci on Linux, and it says so on every run. If a Node version in the matrix is not installed locally it warns and tells you not to treat the result as equivalent.
Live harnesses, all of which assert the negatives as well as the happy path:
# Read-only. Safe against production books.
REAI_USER_API_TOKEN=... node scripts/smoke.mjs --tenant 1234
# The whole OAuth flow against a deployment.
REAI_USER_API_TOKEN=... node scripts/smoke-http.mjs --url https://…
# WRITES. Reversible master data only.
REAI_WRITE_TEST_TENANTS=1234 REAI_USER_API_TOKEN=... \
node scripts/smoke-write.mjs --tenant 1234
# WRITES TO THE GENERAL LEDGER. Posts and deletes a real voucher.
REAI_WRITE_TEST_TENANTS=1234 REAI_USER_API_TOKEN=... \
node scripts/smoke-full-write.mjs --tenant 1234 --i-understand-this-posts-to-real-books
Both write scripts refuse to run unless the tenant is listed in REAI_WRITE_TEST_TENANTS. A tenant id on the command line is not consent — the tenant has to be declared safe to write to, out of band, in the environment. This exists because passing the wrong --tenant was once all it took to post a voucher into a live business's books. They also clean up in a finally so a mid-run failure still removes what was created, and report loudly enough to act on when they cannot.
smoke-full-write.mjs additionally requires --i-understand-this-posts-to-real-books, and asserts the whole external-send guard before it writes anything: if EHF, invoice email or a tax filing turns out to be reachable, it aborts without touching the ledger.
A note on npm audit
The production tree is clean: npm audit --omit=dev reports nothing, and CI enforces that at --audit-level=moderate as a blocking step. Two advisories arrived through @modelcontextprotocol/sdk and both are resolved by package.json overrides — fast-uri pinned to 3.1.5 (HIGH, host confusion via a backslash authority introducer) and hono to 4.12.34 (MODERATE, ReDoS in CORS middleware).
Both are exact pins, not ranges, and that is deliberate (fast-uri was a caret until this bit — see below). This project installs under a 7-day minimum-release-age policy, which is a supply-chain defence: a version published minutes ago has had no time for a compromised publish to be noticed. Landing a fix that is still inside that window needs npm install --min-release-age=0 — and with the age check off, a caret range takes whatever is newest. ^4.12.34 resolved to 4.13.1, published four hours earlier, which is precisely the exposure the policy guards against. Pinning exactly gets the fix and nothing else.
The bypass is also narrow, and getting that right took two attempts. Deleting the lockfile and reinstalling under the flag re-resolves everything, so @hono/node-server, express-rate-limit, ip-address and jose were all upgraded without the age check — a far wider exception than the one being made. Starting from the existing lockfile and adding only the override changes exactly one line. And it is one-time: npm ci installs from the lockfile without resolving, so CI never runs under a relaxed policy.
Contributing
Issues and PRs welcome. Adding a curated tool is deliberately mechanical:
- Add a
defineTool({...})in the relevantsrc/tools/*.ts, declaring itsrisk. - Export it from that module's array.
- Add it to
allToolsinsrc/server.tsif you created a new module.
Declaring risk correctly is the part that matters — it is what gates the tool behind REAI_WRITE_MODE.
Changelog
See CHANGELOG.md, which also records the known limitations — tokens cannot be individually revoked, path-prefix deployments are unsupported, and the irreversible write paths have not been exercised end to end against live books because ReAI has no sandbox.
License
MIT — see LICENSE.
Installing Reai
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/Stener1/reai-mcpFAQ
Is Reai MCP free?
Yes, Reai MCP is free — one-click install via Unyly at no cost.
Does Reai need an API key?
No, Reai runs without API keys or environment variables.
Is Reai hosted or self-hosted?
Self-hosted: the server runs locally on your machine via the install command above.
How do I install Reai in Claude Desktop, Claude Code or Cursor?
Open Reai on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
Fetch
Web content fetching and conversion for efficient LLM usage.
AWS KB Retrieval
Retrieval from AWS Knowledge Base using Bedrock Agent Runtime.
by modelcontextprotocolSpring AI MCP Server
Provides auto-configuration for setting up an MCP server in Spring Boot applications.
llm-analysis-assistant
A very streamlined mcp client that supports calling and monitoring stdio/sse/streamableHttp, and can also view request responses through the /logs page. It also
by xuzexin-hzCompare Reai with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All ai MCPs
