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:
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:
POST /auth/challengereturns a challenge and a timestamp in milliseconds.- Encrypt
token|timestampMswith RSA-OAEP (SHA-256) using the Ministry of Finance certificate markedKsefTokenEncryption, fromGET /security/public-key-certificates. POST /auth/ksef-tokenwith the challenge, the encrypted token and{"type": "Nip", "value": "<NIP>"}as the context.- Poll
GET /auth/{referenceNumber}until the status code goes from 100 (in progress) to 200. POST /auth/token/redeemreturns 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:
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.

Related notes
Tempus Metronome and GetSongBPM API
What BPM really means and how I integrated the GetSongBPM API into Tempus Metronome using Flutter to let users look up song tempos instantly.
Learning Embedded Rust: A C/Python Developer's Rosetta Stone
A practical guide for C and Python developers transitioning to embedded Rust. Side-by-side comparisons of ownership, error handling, pattern matching, and more — mapped to concepts you already know.



