Lexware Office MCP Server
An open-source, self-hostable MCP server for the Lexware Office accounting API. Run your own instance, connect it to Claude, and let an agent read your invoices, vouchers, contacts and articles — and (optionally) draft new ones.
Bring your own Lexware API key — the server is single-tenant per deployment and never stores anyone else's credentials. It runs as a remote HTTP server on any container host, built with the Skybridge framework.
Two authentication methods — choose one:
- OAuth 2.1 — the way to use this as a web MCP server / custom connector in the Claude app, claude.ai web, or ChatGPT, with each user signing in.
- Static bearer token — simpler. Works with Claude Code / Claude Desktop (which let you send a request header). On claude.ai it works only through the organization-admin "static headers" connector option, which is in beta for a limited set of organizations.
Setup for both is in the Client support & authentication section below.
Related projects — local (stdio) Lexware MCP servers: lazyants/lexware-mcp-server, JannikWempe/mcp-lexware-office.
⚠️ This brokers real accounting data. Read SECURITY.md, protect your tokens, and note that finalized invoices are legally binding — you are responsible for their tax/legal correctness. No warranty (MIT).
Capabilities
62 tools across three tiers you enable via environment variables, plus one opt-in tool
outside them (upload-file-from-url, see below):
Set LEXWARE_READ_ONLY=true to force read-only (overrides the flags above).
upload-file-from-url — outside the tiers, off by default
One tool sits outside this table: upload-file-from-url fetches a file from a share link
server-side and stores it in Lexware, which is how a receipt already sitting in
OneDrive/SharePoint gets into the books without a round trip through the model. It is
enabled with LEXWARE_ENABLE_URL_UPLOAD=true and needs the drafts tier (it will not turn
that tier on for you).
It has its own switch because it has its own risk: it is the only tool that makes this server originate an outbound request to a destination the model chose — server-side request forgery, in the general case. Three things bound it, applied at every redirect hop:
- A host allow-list, matched on a dot boundary so
evilsharepoint.comcannot pass assharepoint.com. Configure withLEXWARE_UPLOAD_ALLOWED_HOSTS; unset means the Microsoft file-sharing defaults, a set value replaces them, an empty value blocks everything. - A resolved-address check rejecting loopback, private, link-local (including the
169.254.169.254metadata endpoint), CGNAT, multicast and reserved space, in every IPv4, IPv6 and IPv4-in-IPv6 spelling. - Connection pinning. The socket connects to an address from the very lookup that step 2 approved, rather than letting the HTTP client resolve the name again. Without this, steps 1 and 2 describe one lookup and the connection uses another, and a DNS answer that changes in between (rebinding) slips past a check that looked correct. TLS is unaffected: the certificate is still validated against the hostname.
Only https is accepted, the download is capped at 20 MB and 30 s, and redirects are
followed manually — at most three — so no hop skips the checks.
What that looks like in practice
"Summarize my open invoices for this quarter — who still owes what?"
"Draft an invoice to Müller GmbH for 12 consulting hours at 140 €, due in 14 days."
"Here's the Hetzner receipt for June — file it in the bookkeeping." → see Uploading receipts
"Render invoice RE-2026-0042 as a PDF and give me the deeplink to it."
Uploading receipts (no base64 through the model)
Attaching a file through a chat has an awkward default: upload-file needs the bytes as
base64 inside the model's tool call, so the whole receipt travels through the
conversation — every byte billed as tokens (base64 adds ~33% on top), the file's contents
sitting in the transcript, and a ~8 MB practical ceiling. The model never needed the bytes;
it only needs the file id that comes back.
The ticket flow routes the bytes around the model:
- You ask Claude to file a receipt. It calls
create-upload-ticketand hands you a link likehttps://your-server…/upload/<ticket>. - You open the link and drag the file onto the page — or run the ready-made
curlone-liner next to a local file (replace only theFILE=path; nothing else needs to be installed or edited). The bytes go client → server → Lexware directly. - Claude reads the resulting file id with
get-upload-result(thecurlvariant prints it directly) and carries on with the actual bookkeeping — e.g.create-voucherwithfiles: [fileId], or linking the receipt to an existing voucher.
The ticket is the credential. It is minted only through the authenticated /mcp
endpoint (drafts tier), is 24 random bytes (192-bit) rendered base64url, valid for
15 minutes, single-use, and write-only — it authorizes exactly one file drop and grants no
read access. The /upload/:ticket endpoint sits outside the OAuth gate by design (a
plain browser or curl holds no MCP token); this is the same trust model as a pre-signed
upload URL. Filenames travel as X-Filename-B64 (base64url of the UTF-8 bytes), so
umlauts, dashes, typographic quotes and emoji arrive in the books intact.
The ticket store is in-memory: run a single instance (see Deploy), and note
that a restart voids open tickets — they answer 410, and you simply issue a new one.
Client support & authentication
The server supports two ways to protect /mcp, chosen by environment:
- OAuth 2.1 (
OAUTH_ISSUER, …) — the recommended path. Use any OAuth provider (e.g. WorkOS AuthKit, Stytch, Auth0, Clerk; or self-hosted Keycloak/Zitadel) as the authorization server. This makes the server work as a custom connector in the Claude app, on claude.ai web, and in ChatGPT, with a real sign-in. Optionally restrict access withOAUTH_ALLOWED_EMAIL_DOMAINS(enforced server-side via the token's email / the provider's userinfo endpoint). - Static bearer token (
MCP_AUTH_TOKEN) — the simpler fallback. Works with Claude Code and Claude Desktop (which let you set a request header). claude.ai can send one too, but only when an organization admin adds the connector with a static header — a beta available to a limited set of organizations. Claude sends the value exactly as entered, so enterBearer <token>. Every user of that connector then shares one credential, so prefer OAuth wherever users sign in individually.
OAuth takes precedence when OAUTH_ISSUER is set; otherwise the static token is used. With
neither set, the server refuses to start unless MCP_ALLOW_UNAUTHENTICATED=true.
Connecting as a custom connector (OAuth)
Use the MCP endpoint URL — https://…/mcp, exactly as users will enter it in Claude — in all
three places below. Claude sends that URL (path included) as the RFC 8707 resource when it
asks for a token, and expects this server's protected-resource metadata to name it exactly.
- In your provider, register that URL as a Resource Indicator (the token audience).
Tokens then carry it as
aud, which this server checks (OAUTH_VERIFY_AUDIENCE, on by default). With WorkOS AuthKit: add it under the MCP Auth resource indicators in the dashboard — without one, AuthKit ignores the requestedresourceand stamps every token with an environment-wide default audience. - Let Claude register itself as a client. Prefer Client ID Metadata Documents (CIMD),
Claude's recommended option; in WorkOS it is off by default, under Connect →
Configuration. Claude uses CIMD only when the issuer's metadata advertises it. Dynamic
Client Registration still works, but the MCP spec deprecated it in 2026-07-28 and it lets
anyone register a client on your tenant — enable it only for clients without CIMD. Or
pre-register Claude's redirect
https://claude.ai/api/mcp/auth_callbackand give users the client ID. - Deploy with
OAUTH_ISSUER,OAUTH_RESOURCE=https://…/mcp, and optionallyOAUTH_ALLOWED_EMAIL_DOMAINS. Upload links are built from the same URL without/mcp. - In the Claude app → Connectors → Add custom connector, enter
https://…/mcp. Claude discovers the authorization server via the protected-resource metadata and walks you through sign-in.
Quick start (Docker)
git clone https://github.com/marselsel/Lexware-MCP-Server && cd Lexware-MCP-Server
cp .env.example .env # set LEXWARE_API_KEY and MCP_AUTH_TOKEN
docker compose up --build # serves on http://localhost:8080/mcp
Generate a strong auth token:
openssl rand -hex 32
Without Docker:
npm install
npm run build
LEXWARE_API_KEY=... MCP_AUTH_TOKEN=... npm start
Configuration
¹ The server needs either OAUTH_ISSUER (OAuth) or MCP_AUTH_TOKEN (static). It
refuses to start with neither, unless MCP_ALLOW_UNAUTHENTICATED=true.
Connect to Claude (Code / Desktop)
Add to your MCP config (e.g. ~/.claude.json or the Desktop config):
{
"mcpServers": {
"lexware": {
"type": "http",
"url": "https://<your-host>/mcp",
"headers": { "Authorization": "Bearer <MCP_AUTH_TOKEN>" }
}
}
}
Deploy
The server is a standard Docker container (Dockerfile) — run it on any host that can serve HTTPS (a VPS, Fly.io, Render, Railway, Cloud Run, Kubernetes, …):
docker build -t lexware-mcp .
docker run -p 8080:8080 --env-file .env lexware-mcp
Production notes:
- Serve over HTTPS (terminate TLS at your platform or a reverse proxy).
- Set auth via env (
OAUTH_ISSUER…orMCP_AUTH_TOKEN) — the server fails closed otherwise. - Run a single instance (or cap autoscaling to 1), for two reasons: the ~2 req/s rate limiter is per-process, so multiple instances would aggregate beyond Lexware's limit — and upload tickets live in process memory, so behind a load balancer without sticky sessions an upload can land on an instance that never issued its ticket.
- Health check:
GET /status(returns200).
Google Cloud Run: a step-by-step recipe (Secret Manager + gcloud run deploy + custom
domain) is in docs/cloud-run.md.
How it works
src/config.ts— env parsing/validation, fail-closed auth, capability tiers.src/auth.ts— constant-time static-bearer middleware on/mcp.src/lexware/— rate-limited (~2 req/s, token bucket), retry-aware client with safe error mapping; never retries non-idempotent POSTs on ambiguous failures (no duplicate documents).src/tools/— tools registered conditionally by tier.src/uploads/— the single-use ticket store and the raw-body/upload/:ticketroutes (mounted only with the drafts tier; the ticket is the credential).src/server.ts— wires it together on the Skybridge Express server.
Development
See CONTRIBUTING.md. npm run dev starts the Skybridge dev server +
DevTools at http://localhost:3000.
License
MIT © marselsel.