All posts

Automate invoicing with MCP: my Invoice Ninja server

“Log 45 minutes of weekly meeting on the Audioempire project, cost 0.” One sentence, done. Behind it sits an MCP server I run myself — and one day where it deleted tracked hours.

The short answer

A self-hosted MCP server wires Invoice Ninja into Claude Code: time tracking, invoices and expenses from a sentence in chat, without your billing data passing through a third-party SaaS. Two design decisions carry it: write tools are only registered when a flag is set (otherwise they don't exist at all), and the domain pitfalls live in a skill rather than in the developer's head. The code is public at github.com/halviclabs/invoiceninja-mcp (MIT).

"Log 45 minutes of weekly meeting on the Audioempire project, cost 0 — it's covered by the retainer."

That was my entire time-tracking effort this afternoon. One sentence in chat. The agent resolved the client, found the project, created a task at rate 0 and wrote 45 minutes into the right window. Two minutes later came the correction — "make it 14:30 to 15:15" — and that was one sentence too.

There's no product behind this that I bought. There's an MCP server I run myself. This article covers why I built it, how it's put together, and which mistake cost me real tracked hours. If you've already built your first MCP server, this is the next step: the road from a hello-world tool to something wired into your actual accounting.

The real problem isn't the software

I run Invoice Ninja. Good software. My time tracking was still patchy for years — not because the UI is bad, but because every entry is a context switch. You're in the terminal, your head is in the bug. Tracking time means: browser, login, find the client, find the project, create a task, type the window. Six steps for something that isn't the work, it's the bookkeeping around the work.

So you postpone it. And at month end you reconstruct from your calendar and the git log what probably happened. Whatever's missing doesn't get billed.

The agent is already in the terminal. It has the context. All it lacks is access — which is exactly what MCP is for.

Why build your own

The obvious shortcut would be an off-the-shelf bridge. I looked at the options before writing a line of code:

Option The catch
Zapier / viaSocket / Pipedream MCP Hosted on US infra, billed per call — your invoice data transits a third party
a-wiseguy/invoiceninja-mcp (Python) Read-only, writes never implemented
invoice-ninja-mcp-server (npm) A single unverified 1.0.0 release
Official IN MCP Requested (issue #11843), doesn't exist

For client data, contact addresses and revenue figures, "goes through a US service that bills per call" isn't an option for me — and it isn't for most of my clients either. The build is roughly 1,500 lines of TypeScript with two runtime dependencies (@modelcontextprotocol/sdk and zod), and it runs wherever the instance runs. The code is public: github.com/halviclabs/invoiceninja-mcp, MIT.

What's inside

The server is layered, one job per module: config.ts reads the environment, client.ts is a typed wrapper over the REST API, timelog.ts holds the pure time logic, tools.ts defines the MCP tools, server.ts wires it together. Two entry points share the same server: index.ts for stdio (Claude Desktop, Claude Code) and http.ts for streamable HTTP if you want it running remotely on your own box — stateless per request, optional bearer token, /healthz for the probe.

The tools cover the daily loop: clients, projects, tasks, time tracking (in_log_time, in_start_task, in_stop_task), invoices including lifecycle, payments, quotes, expenses, document upload, and an aggregated in_outstanding_summary for receivables. List tools project each row down to a small field allowlist — the agent gets ten fields per row instead of eighty, and full records come from the in_get_* tools on demand. Sounds like a detail; it's the difference between "fits in context" and "one client list eats half the window."

The design decision that matters: write access that doesn't exist

An agent with write access to your accounting is an uncomfortable thought. The usual answer is a permission check inside the handler. I solved it differently:

export function registerTools(server: McpServer, cfg: Config): void {
  // ... all read tools are registered unconditionally

  if (!cfg.allowWrites) return;

  // ... from here: create, update, lifecycle, delete
}

Unless INVOICE_NINJA_ALLOW_WRITES is true, the write tools are never registered — they simply don't exist in the agent's tool list. There's no handler that could say "no", so there's nothing for a model to talk its way around. The default is false.

A second layer sits on top: every tool carries annotations. Read tools get readOnlyHint, writes get destructiveHint: false, lifecycle and delete actions get destructiveHint: true. That lets the MCP host prompt for exactly the actions that hurt — emailing an invoice, marking it paid, cancelling it.

The day the server deleted tracked hours

Now the part you rarely read in tool announcements.

The first version of the lifecycle actions — archive, restore, delete — sent PUT /<entity>/<id>?action=<verb>. It looked plausible. That route does not exist in Invoice Ninja v5. The API ignored the action parameter and treated the request as an ordinary update with a sparse body.

For a task, a sparse body means: anything you don't send gets overwritten. A delete attempt on a task destroyed its time_log. Tracked hours, gone, with no error — the API cheerfully returned 200.

The fix was to use the real routes, and they are not uniform:

  • Invoices have a per-entity route: GET /invoices/<id>/<action>
  • email isn't on it — only on POST /invoices/bulk
  • Tasks have no per-entity route at all: archive, restore and delete go through POST /tasks/bulk
  • The task action "invoice this" isn't an action; it's creating an invoice whose line item carries a task_id, after which Invoice Ninja sets task.invoice_id itself

Two lessons that outlive this project. First: with someone else's API, never assume a plausible route exists — a non-existent route is rarely a 404, it's often a different route that quietly does something else. Second: the round trip belongs in the test, not the single call. What caught this was the chain create → log time → archive → restore → delete, run live against the real instance, checking time_log after every step.

Two quirks that aren't documented anywhere

The header without which nothing works. Every request to the Invoice Ninja API needs X-Requested-With: XMLHttpRequest. Leave it out and the API answers with HTML redirects instead of JSON, and your parser dies on an error that has nothing to do with the actual problem.

The time_log format. A task's time entries aren't rows in a table; they're a JSON-encoded string in the time_log field: an array of [start, end] pairs in epoch seconds, with end === 0 meaning "still running". Newer builds append further elements per entry (description, billable). Write code that only knows about the first two and you throw the rest away. That's why timelog.ts only ever touches indices 0 and 1 and passes everything else through untouched.

There's one more trap that quietly produces wrong numbers: the index endpoints return soft-deleted records mixed in. So the server always sends an explicit record-state filter, defaulting to active.

MCP gives capability, the skill gives judgement

The server alone isn't enough. It tells the agent what is possible, not what is customary here. That the search parameter is called filter, not search. That "uninvoiced" goes through client_status, not status. That a task without an explicit rate will later be billed at 0.00.

That knowledge lives in a skill — a Markdown file of house rules the agent loads whenever time or invoicing comes up. The division of labour that has held up: MCP is the socket, the skill is the house rules.

The same skill holds the piece that makes the biggest practical difference: a script that turns one repo-month of git history into one Invoice Ninja task, with one time entry per work session and real wall-clock timestamps. Commits become sessions, sessions become time entries, time entries become an invoice with a work log attached as a PDF. Dry run is the default; I see the session list before anything is written.

Setup

git clone https://github.com/halviclabs/invoiceninja-mcp && cd invoiceninja-mcp
npm install && npm run build

claude mcp add invoiceninja --scope user \
  -e INVOICE_NINJA_URL=https://your-instance.tld \
  -e INVOICE_NINJA_TOKEN=... \
  -- node "$PWD/dist/index.js"

Get the token in Invoice Ninja under Settings → Account Management → Integrations → API Tokens. Start without INVOICE_NINJA_ALLOW_WRITES — spend a week reading only: ask the agent about outstanding receivables, uninvoiced tasks, revenue per client. Once you trust the answers, turn writes on.

What to take away

The reflex when bookkeeping annoys you is to look for a tool. The better move is usually to bring the tool you already have to the place where you already work. That's what MCP is: not a new product, a socket into what you've got.

If you build one thing this week, make it the smallest tool that removes your most annoying context switch — and don't enable writes until you've watched it read for a while. The server here is public; take it as a template or as a warning, depending on which section you just read: github.com/halviclabs/invoiceninja-mcp.

More build guides like this — MCP, skills, agent patterns from real projects — land regularly on the blog.

Frequently asked questions

What does an MCP server actually buy you for bookkeeping?

It removes the context switch. Instead of opening the web UI, finding the client, finding the project, creating a task and typing a time window, you say one sentence in chat and the agent makes the four API calls. The win isn't the saved minutes — it's that the tracking happens at all, even when something else is more urgent.

Why build your own instead of using Zapier or an existing bridge?

Because otherwise your billing data transits someone else's infrastructure. The hosted MCP bridges run on US infra and bill per call; the two open-source servers are read-only and a single unverified release respectively. A purpose-built server is around 1,500 lines of TypeScript and runs wherever your instance runs.

How do you stop an agent from doing damage in your accounting?

Don't register the write tools in the first place. In this server registration returns early before every write tool unless INVOICE_NINJA_ALLOW_WRITES is true — the agent never sees those tools and can't hallucinate them. On top, annotations (readOnlyHint, destructiveHint) let the host prompt before lifecycle actions.

Which Invoice Ninja quirks do you need to know?

Three. Every request needs the X-Requested-With header or the API answers with HTML redirects instead of JSON. A task's time entries live as a JSON-encoded string of epoch-second pairs in the time_log field. And v5 has no PUT route with an action parameter — a PUT with a sparse body is a plain update and overwrites fields.

AI code without tech debt — the checklist

Sign up: the checklist plus new posts on AI engineering. No spam, unsubscribe anytime.