Buchhaltung automatisieren: mein Invoice-Ninja-MCP-Server
„Logge 45 Minuten Weekly Meeting aufs Audioempire-Projekt, Kosten 0.“ Ein Satz, erledigt. Dahinter steckt ein MCP-Server, den ich selbst betreibe — und ein Tag, an dem er mir erfasste Stunden gelöscht hat.
Ein eigener MCP-Server hängt Invoice Ninja an Claude Code: Zeiterfassung, Rechnungen und Ausgaben per Satz im Chat, ohne dass die Daten über einen Dritt-SaaS laufen. Zwei Design-Entscheide tragen das Ding: Schreib-Tools werden nur registriert, wenn ein Flag gesetzt ist (sie existieren sonst gar nicht), und die Domänen-Fallstricke stehen im Skill, nicht im Kopf des Entwicklers. Der Code liegt öffentlich unter github.com/halviclabs/invoiceninja-mcp (MIT).
„Logge 45 Minuten Weekly Meeting aufs Audioempire-Projekt, Kosten 0 — läuft über die Pauschale."
Das war heute Nachmittag mein kompletter Aufwand für die Zeiterfassung. Ein Satz im Chat. Der Agent hat den Kunden aufgelöst, das Projekt gefunden, einen Task mit Rate 0 angelegt und 45 Minuten ins richtige Zeitfenster geschrieben. Zwei Minuten später kam die Korrektur — „14:30 bis 15:15" — und auch die war ein Satz.
Dahinter steckt kein Produkt, das ich gekauft habe, sondern ein MCP-Server, den ich selbst betreibe. Dieser Artikel zeigt, warum ich ihn gebaut habe, wie er aufgebaut ist, und welcher Fehler mich echte erfasste Stunden gekostet hat. Wenn du deinen ersten MCP-Server schon gebaut hast, ist das hier der nächste Schritt: der Weg vom Hello-World-Tool zu etwas, das an deiner echten Buchhaltung hängt.
Das eigentliche Problem ist nicht die Software
Ich nutze Invoice Ninja. Gute Software. Trotzdem war meine Zeiterfassung jahrelang lückenhaft — nicht weil das UI schlecht ist, sondern weil jede Erfassung ein Kontextwechsel ist. Du bist im Terminal, im Kopf beim Bug. Zeit erfassen heisst: Browser, Login, Kunde suchen, Projekt suchen, Task anlegen, Zeitfenster tippen. Sechs Schritte für etwas, das nicht die Arbeit ist, sondern deren Buchhaltung.
Also verschiebt man es. Und rekonstruiert am Monatsende aus Kalender und Git-Log, was vermutlich passiert ist. Was fehlt, wird nicht verrechnet.
Der Agent sitzt bereits im Terminal. Er hat den Kontext. Ihm fehlt nur der Zugriff — und genau dafür ist MCP da.
Warum ein eigener Server
Die naheliegende Abkürzung wäre eine fertige Bridge. Ich habe die Optionen durchgeschaut, bevor ich Code geschrieben habe:
| Option | Haken |
|---|---|
| Zapier / viaSocket / Pipedream MCP | Gehostet auf US-Infrastruktur, Abrechnung pro Aufruf — deine Rechnungsdaten laufen über einen Dritten |
a-wiseguy/invoiceninja-mcp (Python) |
Read-only, Writes nie implementiert |
invoice-ninja-mcp-server (npm) |
Ein einzelner, unverifizierter 1.0.0-Release |
| Offizieller IN-MCP | Beantragt (Issue #11843), existiert nicht |
Für Kundendaten, Kontaktadressen und Umsätze ist „läuft über einen US-Dienst, der pro Call abrechnet" für mich keine Option — und für einen Grossteil meiner Kunden auch nicht. Der Eigenbau ist rund 1'500 Zeilen TypeScript, hat zwei Laufzeit-Abhängigkeiten (@modelcontextprotocol/sdk und zod) und läuft dort, wo die Instanz läuft. Der Code liegt öffentlich: github.com/halviclabs/invoiceninja-mcp, MIT.
Was drin ist
Der Server ist geschichtet, jedes Modul macht eine Sache: config.ts liest die Umgebung, client.ts ist ein typisierter Wrapper über die REST-API, timelog.ts enthält die reine Zeitlogik, tools.ts definiert die MCP-Tools, server.ts verdrahtet alles. Zwei Einstiegspunkte teilen sich denselben Server: index.ts für stdio (Claude Desktop, Claude Code) und http.ts für Streamable HTTP, falls er remote auf einer eigenen Box laufen soll — zustandslos pro Request, optionaler Bearer-Token, /healthz für den Probe.
Die Tools decken den Alltag ab: Kunden, Projekte, Tasks, Zeiterfassung (in_log_time, in_start_task, in_stop_task), Rechnungen inklusive Lifecycle, Zahlungen, Offerten, Ausgaben, Dokument-Upload und eine aggregierte in_outstanding_summary für offene Posten. Listen-Tools projizieren die Antwort auf eine kleine Feld-Allowlist — der Agent bekommt zehn Felder pro Zeile statt achtzig, Volldatensätze holt man gezielt über in_get_*. Das klingt nach Detail, ist aber der Unterschied zwischen „passt in den Kontext" und „eine Kundenliste frisst das halbe Fenster".
Der Design-Entscheid, der zählt: Schreibrechte, die es nicht gibt
Ein Agent mit Schreibzugriff auf die Buchhaltung ist erst mal ein unangenehmer Gedanke. Die übliche Antwort wäre eine Berechtigungsprüfung im Handler. Ich habe es anders gelöst:
export function registerTools(server: McpServer, cfg: Config): void {
// ... alle Read-Tools werden bedingungslos registriert
if (!cfg.allowWrites) return;
// ... ab hier: create, update, lifecycle, delete
}
Steht INVOICE_NINJA_ALLOW_WRITES nicht auf true, werden die Schreib-Tools nicht registriert — sie existieren im Tool-Verzeichnis des Agenten schlicht nicht. Es gibt keinen Handler, der „nein" sagen könnte, also auch nichts, worum sich ein Modell herumreden kann. Der Default ist false.
Darüber liegt eine zweite Schicht: Jedes Tool trägt Annotations. Read-Tools readOnlyHint, Schreib-Tools destructiveHint: false, Lifecycle- und Löschaktionen destructiveHint: true. Der MCP-Host kann so vor genau den Aktionen nachfragen, die wehtun — Rechnung mailen, als bezahlt markieren, stornieren.
Der Tag, an dem der Server mir erfasste Stunden gelöscht hat
Jetzt der Teil, den man in Tool-Ankündigungen selten liest.
Die erste Version der Lifecycle-Aktionen — archivieren, wiederherstellen, löschen — schickte PUT /<entity>/<id>?action=<verb>. Sah plausibel aus. Diese Route existiert in Invoice Ninja v5 nicht. Die API hat den action-Parameter ignoriert und den Request als ganz normales Update mit sparsamem Body behandelt.
Ein sparsamer Body heisst bei einem Task: alles, was nicht mitgeschickt wird, wird überschrieben. Ein Löschversuch auf einen Task hat dessen time_log zerstört. Erfasste Stunden, weg, ohne Fehlermeldung — die API hat brav 200 geantwortet.
Der Fix war, die echten Routen zu benutzen, und die sind uneinheitlich:
- Rechnungen haben eine Per-Entity-Route:
GET /invoices/<id>/<action> emailgibt es dort nicht — nur überPOST /invoices/bulk- Tasks haben überhaupt keine Per-Entity-Route: archivieren, wiederherstellen, löschen laufen über
POST /tasks/bulk - Die Task-Aktion „in Rechnung stellen" ist gar keine Aktion, sondern das Anlegen einer Rechnung, deren Position eine
task_idträgt — daraufhin setzt Invoice Ninjatask.invoice_idselbst
Zwei Lehren, die über dieses Projekt hinausgehen. Erstens: Verlass dich bei einer fremden API nie darauf, dass eine plausible Route existiert — eine nicht existierende Route ist selten ein 404, sondern oft eine andere, die stillschweigend etwas anderes tut. Zweitens: Der Round-Trip gehört in den Test, nicht der einzelne Call. Was den Fehler gefunden hat, war die Kette anlegen → Zeit erfassen → archivieren → wiederherstellen → löschen, live gegen die echte Instanz, mit Prüfung des time_log nach jedem Schritt.
Zwei Eigenheiten, die nirgends dokumentiert sind
Der Header, ohne den nichts geht. Jeder Request an die Invoice-Ninja-API braucht X-Requested-With: XMLHttpRequest. Fehlt er, antwortet die API mit HTML-Redirects statt JSON, und dein Parser stirbt an einer Fehlermeldung, die nichts mit dem eigentlichen Problem zu tun hat.
Das time_log-Format. Die Zeiteinträge eines Tasks stehen nicht in einer Tabelle, sondern als JSON-kodierter String im Feld time_log: ein Array von [start, ende]-Paaren in Epoch-Sekunden, ende === 0 heisst „läuft gerade". Neuere Builds hängen pro Eintrag weitere Elemente an (Beschreibung, verrechenbar). Wer beim Schreiben nur die ersten beiden Elemente kennt, wirft die anderen weg. Deshalb fasst timelog.ts ausschliesslich die Indizes 0 und 1 an und reicht den Rest unverändert durch.
Dazu kommt eine Falle, die einen still falsche Zahlen rechnen lässt: Die Index-Endpunkte liefern soft-gelöschte Datensätze mit. Der Server sendet deshalb immer einen expliziten Statusfilter und setzt ihn per Default auf active.
MCP gibt Fähigkeit, der Skill gibt Urteilsvermögen
Der Server allein reicht nicht. Er sagt dem Agenten was möglich ist, nicht was hier üblich ist. Dass der Suchparameter filter heisst und nicht search. Dass „unverrechnet" über client_status läuft und nicht über status. Dass ein Task ohne gesetzte rate später mit 0.00 fakturiert wird.
Dieses Wissen liegt bei mir in einem Skill — einer Markdown-Datei mit den Spielregeln, die der Agent lädt, wenn es um Zeit oder Rechnungen geht. Die Arbeitsteilung, die sich bewährt hat: MCP ist der Anschluss, der Skill ist das Hausrecht.
Im selben Skill liegt das Stück, das den grössten Unterschied macht: ein Script, das einen Repo-Monat Git-History in einen Invoice-Ninja-Task übersetzt, mit einem Zeiteintrag pro Arbeitssession und echten Uhrzeiten. Aus Commits werden Sessions, aus Sessions Zeiteinträge, aus Zeiteinträgen eine Rechnung mit Arbeitsnachweis als PDF-Anhang. Dry-Run ist der Default; ich sehe die Session-Liste, bevor irgendetwas geschrieben wird.
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://deine-instanz.tld \
-e INVOICE_NINJA_TOKEN=... \
-- node "$PWD/dist/index.js"
Den Token holst du in Invoice Ninja unter Einstellungen → Kontoverwaltung → Integrationen → API-Tokens. Fang ohne INVOICE_NINJA_ALLOW_WRITES an — lies eine Woche lang nur, frag den Agenten nach offenen Posten, nach unverrechneten Tasks, nach dem Umsatz pro Kunde. Wenn du den Antworten traust, schaltest du Schreibrechte dazu.
Was du mitnimmst
Der Reflex bei „Buchhaltung nervt" ist, ein Tool zu suchen. Der bessere Weg ist oft, das vorhandene Tool an den Ort zu bringen, an dem du ohnehin arbeitest. Genau das ist MCP: kein neues Produkt, sondern eine Steckdose an das, was du schon hast.
Wenn du diese Woche eine Sache baust, dann das kleinste Tool, das den lästigsten Kontextwechsel deines Alltags wegnimmt — und schalte Schreibrechte erst frei, wenn du dem Ding beim Lesen zugesehen hast. Der Server hier ist öffentlich; nimm ihn als Vorlage oder als Warnung, je nachdem, welchen Abschnitt du gerade gelesen hast: github.com/halviclabs/invoiceninja-mcp.
Mehr Bauanleitungen dieser Art — MCP, Skills, Agenten-Patterns aus echten Projekten — erscheinen regelmässig hier im Blog.
Häufige Fragen
Was bringt ein MCP-Server für die Buchhaltung?
Er nimmt den Kontextwechsel weg. Statt Weboberfläche öffnen, Kunde suchen, Projekt suchen, Task anlegen, Zeit eintragen, sagst du im Chat einen Satz, und der Agent macht die vier API-Calls. Der Gewinn sind nicht die gesparten Minuten, sondern dass die Erfassung überhaupt passiert — auch dann, wenn gerade etwas anderes wichtiger ist.
Warum ein eigener Server statt Zapier oder einer fertigen Bridge?
Weil Rechnungsdaten sonst über fremde Infrastruktur laufen. Die gehosteten MCP-Bridges liegen auf US-Infrastruktur und rechnen pro Aufruf ab; die zwei existierenden Open-Source-Server sind read-only beziehungsweise ein einzelner unverifizierter Release. Ein eigener Server ist rund 1'500 Zeilen TypeScript und läuft dort, wo deine Instanz läuft.
Wie verhindert man, dass ein Agent in der Buchhaltung Schaden anrichtet?
Schreib-Tools gar nicht erst registrieren. In diesem Server steigt die Registrierung vor jedem Write-Tool aus, wenn INVOICE_NINJA_ALLOW_WRITES nicht auf true steht — der Agent sieht diese Tools dann nicht und kann sie nicht halluzinieren. Zusätzlich markieren Annotations (readOnlyHint, destructiveHint) jedes Tool, damit der Host vor Lifecycle-Aktionen nachfragt.
Welche Invoice-Ninja-Eigenheiten muss man kennen?
Drei. Jeder Request braucht den Header X-Requested-With, sonst antwortet die API mit HTML-Redirects statt JSON. Die Zeiterfassung eines Tasks steckt als JSON-kodierter String von Epoch-Sekunden-Paaren im Feld time_log. Und es gibt in v5 keine PUT-Route mit action-Parameter: ein PUT mit sparsamem Body ist ein normales Update und überschreibt Felder.