Skip to content
NewA 9-month company budget from six data sources in one hour
Nov 2, 2026·5 MIN READ
A KSeF MCP server in an afternoon, for three companies at once

A KSeF MCP server in an afternoon, for three companies at once

I built a small MCP server for KSeF, the Polish e-invoicing system, so Claude can list and download invoices for my three companies. The login flow is short, and the one trap is that every token belongs to exactly one company.

Today I was matching a bank statement to invoices for my accountant. Six of the invoices I could not find in any mailbox were sitting in KSeF, the Polish national e-invoicing system. I clicked through the KSeF web app to get them, and then decided I would not do that again next month. A few hours later I had a small MCP server that lists and downloads KSeF invoices, and it worked on the first live login.

Three companies, three contexts

I run three companies: two limited companies and a sole proprietorship. In KSeF each one is a separate context, identified by its tax number (NIP). So the server has one profile per company, and each profile has its own token:

.env
KSEF_PROFILES=vimo=1234567890,mtl=2345678901,musictechlab=3456789012
KSEF_TOKEN_VIMO=...
KSEF_TOKEN_MTL=...
KSEF_TOKEN_MUSICTECHLAB=...

Every tool takes a profile argument. I use the same aliases in my Gmail MCP server and in the accounting skill, so vimo means the same company everywhere.

Having three contexts turned out to be useful. One company sells to another, so the same invoice shows up with the same KSeF number as a sale in one profile and as a purchase in the other. That gave me a free cross-check on the first day.

The login flow

KSeF 2.0 has a public REST API, and its OpenAPI spec explains more than the guides do. Logging in with a KSeF token takes five calls:

  1. POST /auth/challenge returns a challenge and a timestamp in milliseconds.
  2. Encrypt token|timestampMs with RSA-OAEP (SHA-256) using the Ministry of Finance certificate marked KsefTokenEncryption, from GET /security/public-key-certificates.
  3. POST /auth/ksef-token with the challenge, the encrypted token and {"type": "Nip", "value": "<NIP>"} as the context.
  4. Poll GET /auth/{referenceNumber} until the status code goes from 100 (in progress) to 200.
  5. POST /auth/token/redeem returns a JWT access token and a refresh token.

After that it is two endpoints. POST /invoices/query/metadata lists invoices by date and role (buyer or seller), up to 250 per page. GET /invoices/ksef/{ksefNumber} returns the invoice XML. The server has three tools: ksef_profiles, ksef_list_invoices and ksef_download_invoice. It is read-only and cannot issue anything.

The one trap: a token belongs to one company

You generate a token in the KSeF web app, and it belongs to whichever company is selected in the top right corner at that moment. I generated my third token while the app was still switched to the second company. The login failed with:

KSeF response
450 Uwierzytelnianie zakończone niepowodzeniem z powodu błędnego tokenu.
Token nie może być użyty w kontekście nip-1234567890.

The token string itself contains the NIP it was made for, so I could check it without printing the secret. I switched the company in the app, generated a new token, and it worked.

Never paste a token into a chat. I copied mine to the clipboard and ran a one-liner that writes it straight into .env and then clears the clipboard. One token did end up in the chat by mistake, so I revoked it and generated a new one.

Tests without a token

I did not want the tests to need a real token. They run against a fake KSeF built with httpx.MockTransport. The fake has its own RSA key, so it can decrypt the token and check that the client sent exactly token|timestamp. Another test covers time zones: I ask KSeF for whole days in Polish time, and October 2026 ends after the switch to winter time, so the month starts at +02:00 and ends at +01:00.

Two smaller things came up. A fresh install pulled in mcp 2.x, where FastMCP has a new name, so I pinned mcp<2. And httpx logs every request URL at INFO level, including the login reference numbers, so I turned that logger down to WARNING.

The API only returns XML (the FA(3) schema). The PDF you see in the KSeF web app is made by the app, so if your accountant wants PDFs, you need your own XML to PDF step.

What it took

One afternoon, most of it spent reading the OpenAPI spec and generating three tokens in the right contexts. The code itself is small: a config module, a client and a server with three tools. If I did it again, I would write the token check into the server from the start, so ksef_profiles shows which NIP each token was made for. That would have caught the wrong-context token before the first login.

Share
Mariusz Smenżyk
Written by

Mariusz Smenżyk

Builder for sport. Make Things Live.

Related notes