Skip to main content
Glama
szlaskidaniel

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
```