Integrating an MCP server for the Fortnox Swedish accounting system so LLMs can manage invoices, customers, suppliers, orders, vouchers, and accounting data. Evaluating and standing up an existing open-source Fortnox MCP server as the base.
Milestones
No milestones defined.
Items (11)
| Code | Status | Severity | Kind | Title | Date |
|---|---|---|---|---|---|
| E-006 | open | blocking | issue | Company 2 cannot be integrated — no API/integration license | 2026-06-06 |
| E-003 | open | warning | issue | Leaked hardcoded credentials in get-token.ts (v1.0.1) | 2026-06-06 |
| E-001 | open | info | decision | Use erp-mafia/fortnox-mcp as the base MCP server | 2026-06-06 |
| E-002 | open | info | decision | Run server from built source on main, not npx/v1.0.1 | 2026-06-06 |
| E-005 | open | info | decision | Local patch: FORTNOX_TOKEN_FILE for per-instance token caches (arbitrary companies) | 2026-06-06 |
| E-007 | open | info | notice | get-token.ts local fixes + E# wired up (7 scopes, connected) | 2026-06-06 |
| E-008 | open | info | decision | Hardened the MCP server: fixed 4 data-integrity/reliability bugs, contributed upstream via PR #6 | 2026-06-06 |
| E-009 | open | info | decision | Deployment architecture: shared-account Docker server on LAN behind a bearer-token reverse proxy | 2026-06-06 |
| E-010 | open | info | notice | Deployment LIVE on factory.local:9000 — Docker stack verified end-to-end | 2026-06-06 |
| E-011 | open | info | notice | How to get access: connecting to the shared Fortnox MCP server | 2026-06-06 |
| E-004 | closed | warning | notice | Server built & smoke-tested; deps patched (0 prod vulns) | 2026-06-06 |
Requirements
No requirements recorded.
Test Cases
No test cases recorded.
Verification Records
No verification records.
Issues
Problem
Company 2's OAuth authorization fails with error_missing_license for every scope set tried, down to companyinformation alone. Tested (2026-06-06), all failed:
- 7 scopes (full)
- 5 core (customer, invoice, supplier, bookkeeping, companyinformation)
bookkeeping companyinformationcompanyinformationonly
Making the user a system administrator on company 2 did not change the result — so it is not a user-permission issue.
Diagnosis
error_missing_license even for the most basic scope means company 2's Fortnox subscription does not include external API / integration access (most likely a free/limited tier such as free Bokföring). This is a plan/licensing matter on the Fortnox side — not fixable in code or config.
Resolution path (needs user / Fortnox)
- Upgrade company 2 to a Fortnox plan that includes integrations / API access (or add the integration add-on), then re-run the OAuth flow.
- Until then, company 2 stays unwired. Company E# is unaffected and fully authorized.
Status
Blocking for company 2 only. Recommend confirming company 2's plan in Fortnox (admin → subscription) before any upgrade.
Finding (security vetting of base repo)
At tag v1.0.1, scripts/get-token.ts lines 20–21 hardcode the maintainer's real Fortnox client_id and client_secret as fallback defaults:
const CLIENT_ID = process.env.FORTNOX_CLIENT_ID || "A3fjvEGS3mUw";
const CLIENT_SECRET = process.env.FORTNOX_CLIENT_SECRET || "<real secret committed in repo>";
Impact: running the helper without first exporting your own credentials would mint a refresh token against the maintainer's Fortnox app, intermediating your accounting-data access through their app identity. Also a hygiene red flag (a real secret is committed to a public repo).
Scope limit: the leaked secret lives only in scripts/, which is NOT in the published npm package — the running server (src/auth/credentials.ts) has no embedded secret and requires env vars. So this only affected the one-time token-acquisition step.
Resolution
Fixed upstream after v1.0.1 by the 2026-04-28 commit "Require FORTNOX_CLIENT_ID and FORTNOX_CLIENT_SECRET env vars in get-token.ts". We adopt main, which includes the fix, so no local patch needed [[run-server-from-built-source-on-main]]. Closed.
Rest of auth vetting (clean)
Standard OAuth2 against official apps.fortnox.se/oauth-v1 endpoints; client secret only sent as Basic auth to Fortnox; refresh-token rotation honored; no telemetry/exfiltration. Minor platform note: token file ~/.fortnox-mcp/tokens.json uses 0600/0700 modes which are effectively no-ops on Windows NTFS — protect the user profile instead.
Decisions
Decision
Adopt erp-mafia/fortnox-mcp as the base Fortnox MCP server, rather than building from scratch or using the alternative.
Rationale
Evaluated two existing open-source options (data as of 2026-06-06):
| erp-mafia/fortnox-mcp (chosen) | Proviscale/fortnox-mcp-server (rejected) | |
|---|---|---|
| Stars / forks | 30 / 14 | 1 / 0 |
| Commit history | Real history + tagged releases (v1.0.1) | 1 commit only ("Initial release") |
| Last code push | 2026-04-28 | 2026-04-01 (none since) |
| Tools/endpoints | 40+ across 10 categories incl. BI/analytics | 20 across 6 categories |
| Language | TypeScript (94%) | JavaScript (69%) |
| License | MIT | None specified (all rights reserved) |
| Auth | OAuth2 local (env) + remote (browser OAuth, JWT, Redis) | OAuth2 access token only |
| Transports | stdio + HTTP | basic |
| Distribution | npm + MCP Registry | source only |
Decisive factors: active maintenance with real releases, ~2x endpoint coverage (incl. BI/analytics), TypeScript, both stdio + HTTP transports, and published to npm/MCP Registry. The alternative is a single-commit prototype.
Rejected alternatives
- Proviscale/fortnox-mcp-server — rejected: single-commit prototype, no maintenance since initial commit, half the endpoint coverage, and crucially no license (legally all-rights-reserved, so no permission to use/modify/redistribute) — close to disqualifying for business use of accounting data.
- Build from scratch — rejected for now: erp-mafia already covers the needed surface; revisit only if vetting reveals blocking issues.
Open follow-ups (not blocking this decision)
- Single-maintainer (~30 stars) project — vet source, especially OAuth/token-storage handling, before pointing at production Fortnox data.
- Pin a version rather than tracking latest.
- Trust check on author (Jakob Wennberg) since the server holds the Fortnox client secret + refresh token.
- Test against a non-production Fortnox company before exposing write-tools (create voucher/invoice).
Decision
Run the Fortnox MCP server from locally built source on the main branch, rather than via npx -y fortnox-mcp-server (which pulls the npm-published v1.0.1).
Context / rationale
npx -y fortnox-mcp-serveralways runs the npm-published version (v1.0.1) regardless of the git checkout — the clone only feeds theget-token.tshelper. So "running main" requires building from source and pointing the MCP client config at the builtdist/index.js.mainis only 2 commits ahead of v1.0.1 (both 2026-04-28): (1) get-token.ts now requires env-var credentials [[leaked-credentials-in-get-token-v1-0-1]], (2) remote-mode OAuth callback returns plain-text errors (irrelevant to local mode).- Running from vetted source means we run exactly the code we audited, not an opaque npm tarball trusted to match the tag — stronger for a single-maintainer dependency handling accounting credentials.
- The get-token footgun is already fixed on main, so no local patch is needed.
Tradeoffs accepted
- Requires a build step (
npm install+npm run build) and re-pull/rebuild to update. - MCP client config points at an absolute path to
dist/index.jsinstead of the simpler npx command. mainis a moving target (not a pinned release); revisit if we want reproducibility.
Rejected alternative
- npx + v1.0.1 — simpler, zero build, but runs an opaque published tarball and we'd miss the get-token fix; superseded by the security-hygiene argument above.
Repo cloned at C:\tmp\claude\fortnox\fortnox-mcp, currently on main (501ee13).
Decision
Apply a small local patch to src/auth/fileTokenStore.ts so the token cache path is configurable via a new FORTNOX_TOKEN_FILE env var, defaulting to the original ~/.fortnox-mcp/tokens.json when unset.
Why
Stock behavior hardcodes the cache to a single file and prefers it over the env-var refresh token (envVarProvider.ts:32-39). Running multiple server instances (one per Fortnox company) would clobber each other's tokens. This patch gives each instance its own cache → supports an arbitrary number of companies, not just two. One shared Client ID/Secret (the app), one refresh token + one token file + one MCP entry per company.
Change
// path import: add dirname
const TOKEN_FILE = process.env.FORTNOX_TOKEN_FILE
? process.env.FORTNOX_TOKEN_FILE
: join(homedir(), ".fortnox-mcp", "tokens.json");
const TOKEN_DIR = dirname(TOKEN_FILE);
Verification
Rebuilt; confirmed via the built module: with FORTNOX_TOKEN_FILE set it reads that file; unset it returns to the default path. Default behavior unchanged.
Maintenance note
This is a local diff on main, not upstreamed — it will be lost on git pull/reset. Re-apply (or upstream as a PR) after updating the base. Consider contributing it back to erp-mafia. [[run-server-from-built-source-on-main]]
Status
The base server (erp-mafia/fortnox-mcp) is stood up and running locally against the live E-Sharp Fortnox account. While exercising it for real financial analyses (cash-flow forecast, payables report, full-year ledger P&L) we found and fixed four bugs, all of which were silently producing wrong or missing numbers.
Fixes (all on branch fix/mcp-reliability)
- Supplier-invoice amounts concatenated instead of summed. Fortnox returns supplier-invoice
Balance/Totalas strings;sum + valueconcatenated them (cash-flow forecast reported a net position of-8.9e+47). Coerced to numbers infortnox_cash_flow_forecastandfortnox_payables_report. fortnox_account_activitypublished an empty MCP input schema because its Zod schema ended in.refine()(ZodEffects has no.shape). Clients sent all args as strings; every call was rejected. Removed the top-level refine; validation moved to the handler.- Loud completeness reporting for
account_activity: raised scan cap 500→2000, addedfailed_detail_fetches+summary_complete+ an explicit truncation/drop warning. Previously a rate-limited scan silently dropped ~95% of rows while reporting success. - Resilient HTTP layer (
api.ts): added 429/5xx retry with backoff honoringRetry-After, and serialized the rate limiter with ~210ms spacing so concurrent calls form a steady ~5 req/s stream instead of an instant 25-request burst.
Verification
A one-month account_activity scan went from 253/275 failed detail fetches (7 matches) to 0 failures (43 matches). End-to-end, a complete 12-month FY5 ledger P&L ran (~3,000 sequential fetches, 765s) with zero drops and every month summary_complete: true; revenue reconciled against billed invoices.
Contribution path
daniel-rhodin has no direct push access to erp-mafia/fortnox-mcp, so the change was pushed to the fork daniel-rhodin/fortnox-mcp and a PR opened upstream: https://github.com/erp-mafia/fortnox-mcp/pull/6 (awaiting review/merge).
Next
- Merge PR #6.
- Candidate follow-up: a dedicated
fortnox_profit_and_losstool baking in month-chunking + BAS-class bucketing (70–76 personnel / 78 depreciation / 79 other) for one-call annual P&L.
Decision
Run the Fortnox MCP server as a Docker deployment on an internal host so colleagues can share it, configured as: single shared Fortnox account · LAN-only · shared bearer token via reverse proxy · all tools exposed.
Rationale (decisions + why)
- Single shared account (local/HTTP mode), not per-user OAuth. Everyone works on the same company books (E-Sharp AB), so one service authorization is the natural fit. Per-user OAuth (remote mode) would add a Redis token store, a public HTTPS URL, and Fortnox callback config for no benefit when there's only one company. Server runs
TRANSPORT=httpwith one credential set. - LAN-only reachability. Keeps the financial endpoint off the public internet entirely — simplest and lowest attack surface. Colleagues are in the office network.
- Shared bearer token via a Caddy reverse proxy. Local/HTTP mode has no built-in auth, and the toolset can read and modify the books, so an unauthenticated port on the LAN = anyone can rewrite the accounts. Caddy gates
/mcpon anAuthorization: Bearertoken; only Caddy publishes a port (8080), the MCP server (3000) stays internal. Chosen over per-user OAuth because it's one secret to distribute for a single-account setup. - All tools exposed (incl. write/delete/bookkeep). Colleagues need full capability, not just read. Accepted risk is mitigated by the bearer token + LAN isolation.
Key operational caveats (captured so they aren't rediscovered painfully)
- Dedicated refresh token required. Fortnox rotates the refresh token on every refresh; two instances sharing one token log each other out. The server must get its OWN authorization (run
scripts/get-token.tsonce) — do not reuse the token a local Claude instance uses. - Token persistence is mandatory. The rotating refresh token is persisted to
/data/tokens.json; this is a Docker named volume (fortnox-tokens). Deleting the volume forces re-authorization. - Plain HTTP on the LAN means the bearer token is sniffable by someone already on the wire — acceptable on a trusted LAN; upgrade Caddy to TLS (
tls internalor a real cert) if needed. - No per-user audit in shared mode — all actions hit Fortnox as the one service identity. Switch to remote/OAuth mode if per-user accountability is ever required.
- The global rate limiter is process-wide, so all colleagues' combined traffic is paced under Fortnox's 5 req/s automatically.
Artifacts added to the repo
Dockerfile (multi-stage), docker-compose.yml (mcp + caddy), Caddyfile (bearer gate), .env.example, .dockerignore, DEPLOYMENT.md (full guide incl. client config for Claude Code/Desktop). docker compose config validates; full build/run pending on the target Linux host (Docker daemon not running on the dev box used to author this).
Builds on the reliability fixes in [[E-008]] / PR #6 — the deployed image should include that branch.
Notices
Build state (2026-06-06)
- Node.js 24.16.0 LTS installed via winget (
OpenJS.NodeJS.LTS); npm 11.13.0. Was not previously installed. - Repo cloned at
C:\tmp\claude\fortnox\fortnox-mcp, onmain(501ee13). npm ci→npm audit fix(non-breaking) →npm run build✅.dist/index.jsbuilds.- Smoke tests pass: errors cleanly with no credentials; boots and holds on stdio with dummy credentials (all 40+ tools register).
Dependency security
- After
npm audit fix: production-only audit = 0 vulnerabilities (axios, @modelcontextprotocol/sdk, express/qs/path-to-regexp all patched within semver). - 9 remaining vulns (3 moderate, 6 high) are confined to
@vercel/*build/deploy deps (Vercel remote-mode path we don't use). Fixing requiresnpm audit fix --force→ breaking@vercel/node@4downgrade. Deferred as not-applicable to local stdio runtime.
⚠️ Risk to address
- The server runs from source at
C:\tmp\claude\...— a temp path that may be cleared by cleanup/reboot, which would break the MCP server. Action: relocate the repo to a stable location (e.g. under the user profile or a projects dir) before wiring up the MCP client config, and point the config at that stable path.
Remaining (needs user — requires Fortnox login)
- Create Fortnox developer app; register redirect URI
http://localhost:8888/callback. - Run
npx tsx scripts/get-token.tswith own CLIENT_ID/SECRET to get refresh token. - Register MCP server in client pointing at
node <stable-path>\dist\index.jswith the 3 env vars. - Test read-only against a non-production Fortnox company first.
Note: get-token scopes omit
order— order tools may fail until added [[leaked-credentials-in-get-token-v1-0-1]].
Company E# — DONE
Authorized with all 7 scopes (customer, invoice, supplier, supplierinvoice, bookkeeping, companyinformation, order). MCP server fortnox-e added at user scope (~/.claude.json), status ✓ Connected, with its own token cache C:\Users\DanielRhodin\.fortnox-mcp\tokens-e.json (via FORTNOX_TOKEN_FILE [[local-patch-fortnox-token-file]]).
- NOTE: "Connected" only means the server booted with a token present; the token is validated lazily on first API call. Still needs a read-only verification after a Claude Code restart.
Local fixes to scripts/get-token.ts (not upstreamed — re-apply after git pull; candidates to PR back)
- OAuth param order — Fortnox's auth endpoint is order-sensitive; reordered to
client_id, redirect_uri, scope, state, access_type, response_type. Fixed the initialinvalid_request / "Invalid or missing response type"error. (The stock repo order is broken.) - Default scopes expanded from 5 to 7 (added
supplierinvoice,order) so all server tools are covered. FORTNOX_SCOPESenv override — space-separated, lets per-company scope trimming for companies lacking modules. Defaults to the 7.
Process notes
- get-token must be run as a true background process (Bash/PowerShell
run_in_background);Start-Processfrom a tool shell gets killed when the shell exits → "localhost refused to connect". - Redirect URI registered in the Fortnox app:
http://localhost:8888/callback. - Fortnox refresh tokens rotate on use; first MCP run consumes the env token and persists the rotated one to the token file.
The shared Fortnox MCP server is deployed and operational on factory.local, implementing the architecture decided in [[E-009]].
What's running
- Two containers via
docker compose(projectfortnox-mcp, separate from the workflowengine stack) at~/fortnox-mcpon factory.local:fortnox-mcp— Node MCP server, local/HTTP mode, internal port 3000 (not published), healthy.caddy— reverse proxy publishing host port 9000, enforcing the shared bearer token.
- Endpoint for colleagues:
http://factory.local:9000/mcp(LAN/mDNS).
Verified
/healthOK;POST /mcpwithout token → 401; with token → MCP handshake OK.- Real Fortnox call
fortnox_get_company_inforeturned E-Sharp AB data through the full chain (token activated). - Image built from the branch carrying the reliability fixes ([[E-008]] / PR #6).
Port selection
Picked 9000 after scanning factory.local — 8080 was already taken by workflowengine-orchestra. Configurable via MCP_HOST_PORT in .env.
Secrets / operations (NOT in git)
- Server
.env(perms 600) holds client id/secret, the dedicated server refresh token, and the bearer token. The rotating refresh token persists in thefortnox-tokensDocker volume (/data/tokens.json). - The shared bearer token is held in the server
.env; distribute to colleagues out-of-band (it's theirAuthorization: Bearervalue). - Server authenticated via a dedicated passphraseless deploy key (
~/.ssh/factory_deploy) — independent of the user'sid_ed25519.
Remaining / optional
- Commit + push the deploy files (Dockerfile, docker-compose.yml, Caddyfile, .env.example, .dockerignore, DEPLOYMENT.md) to the fork — pending decision on same vs separate PR from the reliability fixes.
- Plain HTTP on LAN (bearer sniffable on-wire); TLS is the upgrade path if needed.
Step-by-step for a colleague to connect their AI client to the shared Fortnox MCP server. See [[E-010]] for the deployment and [[E-009]] for the security model.
What you need
- On the LAN (office network or VPN) — the server is
factory.local, not exposed to the internet. - The shared bearer token — ask Daniel (it lives in the server's
.env,MCP_BEARER_TOKEN). Treat it like a password. - Endpoint:
http://factory.local:9000/mcp(iffactory.localdoesn't resolve on your machine, use the server's LAN IP instead).
Claude Code
claude mcp add --transport http fortnox http://factory.local:9000/mcp \
--header "Authorization: Bearer <SHARED_TOKEN>"
Claude Desktop
Edit claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/; Windows: %APPDATA%\Claude\):
{
"mcpServers": {
"fortnox": {
"type": "http",
"url": "http://factory.local:9000/mcp",
"headers": { "Authorization": "Bearer <SHARED_TOKEN>" }
}
}
}
Restart Claude Desktop.
Verify it works
Ask the assistant: "Use Fortnox to show the company info." You should get E-Sharp AB back. Or from a terminal on the LAN:
curl -s http://factory.local:9000/health # -> {"status":"ok",...}
Notes / troubleshooting
- 401 Unauthorized → missing/wrong bearer token in the
Authorizationheader. - Can't connect / DNS → not on the LAN/VPN, or
factory.localmDNS not resolving (use the IP). - Everyone shares one Fortnox account (E-Sharp AB) and the full toolset (read + write/bookkeep) — there is no per-user identity, so all actions appear in Fortnox as the one service login. Be careful with write operations.
- Plain HTTP on the LAN: keep the token on a trusted network.
Design Rule Status
■ No violations ■ Warning ■ Error ■ Waived