subiekt123-mcp
README.md
# subiekt123-mcp
Stateless MCP server for invoicing in Subiekt 123 (InsERT API).
- SDK: `@modelcontextprotocol/server` **2.0.0**, spec **2026-07-28**
- Transport: **stateless Streamable HTTP** (`createMcpHandler`) and **stdio**
- Node ≥ 20, zod v4, ESM
The server keeps no state between requests - `createMcpHandler` builds a fresh `McpServer` instance per
HTTP request, so it scales horizontally without session affinity and without `Mcp-Session-Id`.
**New here? Start with [GETTING-STARTED.md](GETTING-STARTED.md)** - credentials, first run, connecting a
client, and issuing your first invoice. This README covers architecture and reference detail.
## Safety gate: HMAC token
`create_invoice` **does not accept an invoice payload** - only a token issued by a `preview_*` tool.
```
preview_<template>_invoice({month:"2026-08"})
→ preview (buyer, line items, totals, rate, warnings)
→ token: <payload>.<HMAC-SHA256> (valid for 10 minutes)
create_invoice({token})
→ signature + TTL verification
→ POST /documents
```
Why: in MCP it is the model that invokes a tool, and `annotations.destructiveHint` is only a hint to the
client - the spec states plainly that annotations do not change how the SDK runs a tool. The token binds
issuance to one specific, previously displayed document: changing even a single amount invalidates the
signature. The token carries the whole payload, so the server stays stateless.
HMAC key: `~/.config/subiekt123/preview-hmac.key` (32 random bytes, generated on first use, `chmod 600`).
It lives **outside** `credentials.json` - a token leaked from a conversation transcript is not enough to
forge approval.
## Installation
```bash
cd subiekt123 && npm install
```
**The repository contains no business data** - no account numbers, no tax IDs, no amounts, no client
names. All of that lives in two files under `~/.config/subiekt123/` (`chmod 600`).
### 1. Secrets - `credentials.json`
```json
{
"clientId": "<Client ID from the InsERT developer portal>",
"clientSecret": "<Client Secret>",
"redirectUri": "http://localhost:53682/callback",
"subscriptionKey": "<Subiekt 123 API subscription key>",
"subscriptionHeader": "Ocp-Apim-Subscription-Key"
}
```
Where to get them: [konto.insert.com.pl](https://konto.insert.com.pl) → _Moje produkty_ → _InsERT API_ →
"Uzyskaj dostęp" → the _Aplikacje_ menu → _InsERT API_
([developers.insert.com.pl](https://developers.insert.com.pl)). There you will find _Moje aplikacje_
(registration: scope `subiekt123`, return address = `redirectUri`) and _Subskrypcje_.
One-time OAuth consent:
```bash
npm run auth
```
The `refreshToken` rotates on every refresh and is persisted automatically. Inactivity for more than 90
days, or more than 365 days since the first consent, requires running `npm run auth` again.
### 2. Invoicing profile - `profile.json`
```bash
cp profile.example.json ~/.config/subiekt123/profile.json
chmod 600 ~/.config/subiekt123/profile.json
```
The profile describes the issuer, bank accounts, clients and invoice templates. Every template becomes an
MCP tool named `preview_<key>_invoice`, so the tool set depends on the profile rather than on the code.
The profile is validated at startup; a missing field or a reference to a non-existent client stops the
server with a specific message.
| Template field | Meaning |
| ---------------------------------------------- | --------------------------------------------------------------------------- |
| `key` | becomes part of the tool name: `preview_<key>_invoice` |
| `kind` | date rule: `monthlyRecurring`, `sameDay`, `prepaid` (see below) |
| `title`, `summary` | tool title and description as seen by the model |
| `client`, `bankAccount` | keys from the `clients` / `bankAccounts` sections |
| `currency` | `PLN` skips the rate; a foreign currency fetches the NBP rate automatically |
| `dueDays` | payment term in days, defaults to 14 |
| `defaultNetPrice` | default amount; omit it to force the model to pass an amount explicitly |
| `note`, `communityTransaction`, `jpkVatGroups` | optional document fields |
| `item` | the single line item: name, `sourceProductId`, `vatRate`, `pkwiu`, `gtu` |
## Running
```bash
npm start # stdio - the MCP client spawns the process itself
npm run start:http # stateless HTTP on 127.0.0.1:3123/mcp
npm run inspect # MCP Inspector
```
### Claude Code / Claude Desktop (stdio)
```json
{
"mcpServers": {
"subiekt123": {
"command": "node",
"args": ["/path/to/subiekt123/src/stdio.mjs"]
}
}
}
```
### HTTP
Bound to `127.0.0.1`, with `Host` and `Origin` validation ahead of the handler
(`localhostHostValidation`, `localhostOriginValidation`) - a defence against DNS rebinding, where a
malicious site points its own domain at `127.0.0.1` so the browser treats the server as same-origin.
A foreign `Origin` gets **403**. `PORT` changes the port, `HOST` the address.
## Tools
| Tool | Annotations | Description |
| ----------------------------- | --------------- | ---------------------------------------------------------------- |
| `list_documents` | read-only | Document list (0-indexed paging, `pageSize` ≤ 50) |
| `get_document` | read-only | Full document with line items |
| `list_clients` / `get_client` | read-only | Clients (`get_client` = 13 more fields) |
| `list_products` | read-only | Products |
| `get_exchange_rate` | read-only | NBP rate from the business day before the issue date |
| `preview_<key>_invoice` | read-only | One per profile template - dates and rate resolved automatically |
| `preview_invoice` | read-only | Arbitrary payload, outside the templates |
| **`create_invoice`** | **destructive** | Issues a document - from a token only |
| **`delete_document`** | **destructive** | Deletes a document (blocked once submitted to KSeF) |
| `print_document` | read-only | PDF as base64, optionally duplicate / ecoMode |
## Date rules (`template.kind`)
The rule lives in code ([`dates.mjs`](src/lib/dates.mjs)); a profile template merely selects it.
### `monthlyRecurring` - recurring monthly invoice
| Field | Rule |
| ---------------------------------- | ----------------------------------------------------------------- |
| `issueDate` | **today** (overridable via the `issueDate` parameter) |
| `deliveryDate`, `taxLiabilityDate` | last day of the billed month (the `month` parameter) |
| `dueDate` | +`dueDays` from the issue date; weekend → Monday (Sat +2, Sun +1) |
| `exchangeRate` | NBP rate from the last business day **before** the issue date |
**Never backdate the issue date** - KSeF reports an error when `issueDate` precedes the day of
submission. Hence the default of today rather than the 1st of the month (user's decision, 2026-08-03).
For the same reason a foreign-currency invoice cannot be issued ahead of time - the rate from the day
before the issue date does not exist yet.
### `sameDay` - one-off invoice
Issue date = sale date = the given date (defaults to today), due +`dueDays` with the weekend adjustment.
### `prepaid` - invoice for a payment already received
Due date = issue date, and `payments` is filled automatically with the full amount.
**Always confirm the sale date with the user** - it is the day the payment arrived, which may be earlier
than the issue date. A sale date later than the issue date is rejected.
Public holidays are not shifted - weekends only (user's decision, 2026-07-31).
## API pitfalls (confirmed empirically)
1. **`calculationMethod`** must match the company's "Licz od" setting - otherwise the fiscalization
status diverges. With `Net` pass `netPrice`, with `Gross` pass `grossPrice`.
2. **`exchangeRate` is mandatory** for foreign currencies. The API does **not** fetch the rate itself
(the UI does) - without it you get `EXCHANGE_RATE_MUST_BE_SET_ON_DOCUMENT_IN_FOREIGN_CURRENCY`.
3. **`items[].symbol` conflicts with `sourceProductId`** - `DOCUMENT_ITEM_SYMBOL_MUST_BE_EMPTY`.
The symbol comes from the product record.
4. **`vatRate` is a string**: `"23%"` or a non-percentage rate such as `"o.o."` (reverse charge).
5. **`eInvoice` is a document type, not a KSeF submission.** After `POST` the invoice has
`invoiceMode: "Ksef"` but `ksefNumber: null`. Submission happens in the UI - the public API has no
endpoint for it.
6. **A KSeF invoice cannot be printed before submission** -
`DOCUMENT_MUST_BE_PROCESSED_BY_KSEF_BEFORE_PRINTING`. The same error is returned for a
non-existent `id`.
7. **An invoice for a natural person requires a PESEL** (`tinKind: "Pesel"` + `tin`), otherwise it is
created as a company. The UI does not require it - the discrepancy was reported to InsERT, with no
response.
8. **`pageNumber` is 0-indexed**, `pageSize` maxes out at 50 (60 → 400).
9. **`POST /documents` returns a nested `{"id":{"value":"..."}}`**, without the document number.
10. **`/printing` requires `Content-Type: application/json`** even with an empty body - otherwise 415.
It returns a binary PDF.
11. **Numbers freed by deleted documents are reused.**
12. **The public API does not list correction invoices (KFS)** - they are visible only in the internal
`gw/api`.
## Layout
```
src/
├── server.mjs McpServer factory - tool registration
├── stdio.mjs stdio entry point
├── http.mjs stateless HTTP entry point
├── auth.mjs one-time OAuth PKCE consent
└── lib/
├── config.mjs credentials.json
├── profile.mjs profile.json - reading and validation
├── api.mjs OAuth + HTTP client (JSON and binary)
├── token.mjs HMAC preview → create
├── dates.mjs date rules, weekend adjustment
├── nbp.mjs NBP exchange rates
├── invoice.mjs validation, totals, preview
└── templates.mjs assembling a payload from a profile template
profile.example.json profile template (placeholders, no real data)
GETTING-STARTED.md setup walkthrough, client wiring, troubleshooting
docs/api-notes.md full API documentation gathered empirically
```
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues