jortt-mcp/README.md
Claude a73af353d4 Harden server; add authenticated Streamable HTTP transport and Nix module
- Streamable HTTP transport (MCP_TRANSPORT=http) with mandatory auth:
  static bearer tokens or OIDC resource-server mode (JWT via JWKS, or
  RFC 7662 introspection), RFC 9728 protected-resource metadata, and
  DNS-rebinding protection
- secrets loadable from files via *_FILE variants
- timeouts on all outbound requests; JORTT_READ_ONLY tool filtering
- OpenAPI spec committed and bundled; no implicit network fetches
- Nix flake: sandboxed package build + hardened NixOS module taking an
  EnvironmentFile (agenix-friendly)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:10:55 +02:00

289 lines
13 KiB
Markdown

# jortt-mcp
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for the
[Jortt](https://www.jortt.nl) accounting & invoicing API.
It exposes the full Jortt REST API — customers, invoices, estimates, expenses,
projects, bank accounts, reports, and more — as MCP tools, so an MCP-capable
assistant (Claude Desktop, Claude Code, etc.) can read and write your Jortt
administration on your behalf.
The tools are generated directly from Jortt's published
[OpenAPI specification](https://api.jortt.nl/swagger_doc), so coverage stays
faithful to the API: **82 tools** across every documented resource.
## How it works
- The Jortt OpenAPI spec is committed at `src/openapi.json` and bundled into
`dist/` at build time. On startup the server reads that spec, resolves all
schema references, and turns every operation into an MCP tool with a proper
JSON Schema for its arguments. Refresh the spec explicitly with
`npm run fetch-spec`; the server never loads it from the network unless you
set `JORTT_OPENAPI_URL` deliberately.
- Authentication towards Jortt uses the OAuth 2.0 **client credentials** grant
(the flow Jortt recommends for applications bound to your own
administration). The server fetches a bearer token, caches it until shortly
before it expires, and transparently refreshes on expiry or a `401`.
- Path parameters, query parameters, and JSON request bodies are all handled
automatically. Request bodies are passed under a single `body` argument.
- Two transports: **stdio** (default, for local clients like Claude Desktop)
and **Streamable HTTP** (`MCP_TRANSPORT=http`) for remote access. The HTTP
transport refuses to start without authentication configured.
## Prerequisites
1. A [Jortt account](https://app.jortt.nl/van-start).
2. A registered API application. Create one under
**Profile → API**
[OAuth applications](https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list),
selecting the scopes you want to grant. Registration gives you a
**Client ID** and **Client secret**.
## Installation
```bash
git clone https://github.com/deprekated/jortt-mcp.git
cd jortt-mcp
npm install
npm run build
```
On Nix, `nix build github:deprekated/jortt-mcp` — or see [NixOS](#nixos) below
for the module.
## Configuration
The server is configured entirely through environment variables. Every
secret-bearing variable can instead be read from a file by appending `_FILE`
(e.g. `JORTT_CLIENT_SECRET_FILE=/run/agenix/jortt-client-secret`), which keeps
secrets out of process environments and works directly with agenix / systemd
credentials.
### Core
| Variable | Required | Description |
| -------------------- | -------- | --------------------------------------------------------------------------- |
| `JORTT_CLIENT_ID` | yes | OAuth client ID of your registered application. |
| `JORTT_CLIENT_SECRET`| yes | OAuth client secret. |
| `JORTT_SCOPES` | no | Space-separated scopes to request. Defaults to all scopes; the token server only grants scopes your application was registered with. |
| `JORTT_READ_ONLY` | no | `1`/`true`: expose only GET (list/get) tools — a hard stop on writes regardless of scopes. |
| `JORTT_OPENAPI_URL` | no | Load the OpenAPI spec from this URL instead of the bundled copy. Off by default. |
### HTTP transport & authentication
| Variable | Description |
| --------------------- | ------------------------------------------------------------------------------ |
| `MCP_TRANSPORT` | `stdio` (default) or `http` (Streamable HTTP). |
| `MCP_HOST` / `MCP_PORT` | Bind address and port. Defaults: `127.0.0.1:3910`. The server speaks plain HTTP — terminate TLS in a reverse proxy. |
| `MCP_AUTH_MODE` | `token` or `oidc`. **Required** in http mode; the server refuses to run unauthenticated. |
| `MCP_AUTH_TOKEN` | Static bearer token(s), comma/newline separated (`token` mode). Generate with `openssl rand -hex 32`. Compared in constant time; minimum 16 chars. |
| `MCP_OIDC_ISSUER` | Issuer URL of your OAuth authorization server / proxy (`oidc` mode). |
| `MCP_RESOURCE_URL` | Public URL of the MCP endpoint (e.g. `https://jortt-mcp.example.com/mcp`). Used as token audience and published in RFC 9728 protected-resource metadata for MCP client discovery. |
| `MCP_OIDC_AUDIENCE` | Override the audience to require in tokens (default: `MCP_RESOURCE_URL`). |
| `MCP_OIDC_ALLOWED_SUBJECTS` | Comma-separated allowlist matched against `sub`, `preferred_username`, and `email`. Strongly recommended — without it, *any* account on your IdP is accepted. |
| `MCP_OIDC_INTROSPECTION_URL`, `MCP_OIDC_CLIENT_ID`, `MCP_OIDC_CLIENT_SECRET` | Use RFC 7662 token introspection instead of JWT validation, for issuers with opaque access tokens (e.g. Authelia). The endpoint is discovered from the issuer when the URL is omitted. |
| `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | Exact-match allowlists for the `Host` / `Origin` headers (DNS-rebinding protection). Include the public hostname (with `:port` if non-standard). |
In `oidc` mode the server is a standard OAuth 2.0 **resource server** per the
[MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization):
it validates bearer tokens issued by your IdP/OAuth proxy (Keycloak, Authelia,
Pomerium, Kanidm, ...) and serves `/.well-known/oauth-protected-resource` so
clients like the claude.ai remote-connector UI can discover the authorization
server and run the authorization-code + PKCE flow against it. For claude.ai,
your IdP needs either dynamic client registration or a manually registered
client whose ID/secret you paste into the connector settings; the redirect URL
to register is `https://claude.ai/api/mcp/auth_callback`.
Endpoints in http mode: `POST/GET/DELETE /mcp` (MCP, authenticated),
`GET /healthz` (unauthenticated liveness probe),
`GET /.well-known/oauth-protected-resource` (only when OIDC is configured).
### Claude Desktop / Claude Code
Add the server to your MCP client configuration (for Claude Desktop, this is
`claude_desktop_config.json`):
```json
{
"mcpServers": {
"jortt": {
"command": "node",
"args": ["/absolute/path/to/jortt-mcp/dist/index.js"],
"env": {
"JORTT_CLIENT_ID": "your-client-id",
"JORTT_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
Restrict what the assistant can do by narrowing the scopes:
```json
"env": {
"JORTT_CLIENT_ID": "your-client-id",
"JORTT_CLIENT_SECRET": "your-client-secret",
"JORTT_SCOPES": "invoices:read customers:read reports:read"
}
```
### Remote (Streamable HTTP)
With a static token, from Claude Code:
```bash
claude mcp add --transport http jortt https://jortt-mcp.example.com/mcp \
--header "Authorization: Bearer <token>"
```
With OIDC, add `https://jortt-mcp.example.com/mcp` as a custom connector on
claude.ai — the OAuth flow against your IdP is discovered automatically.
## NixOS
The repository is a flake providing a package and a NixOS module. The module
runs the server as a hardened systemd service (DynamicUser, syscall filtering,
read-only filesystem view) listening on loopback; put nginx/caddy in front for
TLS. Secrets are supplied via a systemd `EnvironmentFile`, which is where
[agenix](https://github.com/ryantm/agenix) comes in:
```nix
{
inputs.jortt-mcp.url = "github:deprekated/jortt-mcp";
# in your host configuration:
imports = [ inputs.jortt-mcp.nixosModules.default ];
age.secrets.jortt-mcp-env.file = ./secrets/jortt-mcp.env.age;
# The encrypted file contains:
# JORTT_CLIENT_ID=...
# JORTT_CLIENT_SECRET=...
# MCP_AUTH_TOKEN=... # token mode only
# MCP_OIDC_CLIENT_SECRET=... # only for introspection mode
services.jortt-mcp = {
enable = true;
environmentFile = config.age.secrets.jortt-mcp-env.path;
port = 3910;
# Simplest: static bearer token (set MCP_AUTH_TOKEN in the env file)
auth.mode = "token";
# Or: OAuth via your IdP / OAuth proxy, as needed for claude.ai
# auth.mode = "oidc";
# auth.oidc.issuer = "https://auth.example.com";
# auth.oidc.allowedSubjects = [ "kate" ];
# resourceUrl = "https://jortt-mcp.example.com/mcp";
allowedHosts = [ "jortt-mcp.example.com" ];
# readOnly = true;
# scopes = [ "invoices:read" "customers:read" "reports:read" ];
};
services.nginx.virtualHosts."jortt-mcp.example.com" = {
enableACME = true;
forceSSL = true;
locations."/".proxyPass = "http://127.0.0.1:3910";
# SSE responses must not be buffered:
locations."/".extraConfig = ''
proxy_buffering off;
proxy_read_timeout 1h;
'';
};
}
```
If your IdP issues opaque access tokens (Authelia does), switch the OIDC block
to introspection:
```nix
auth.oidc = {
issuer = "https://auth.example.com";
introspectionClientId = "jortt-mcp";
allowedSubjects = [ "kate" ];
};
# and put MCP_OIDC_CLIENT_SECRET=... in the agenix env file
```
## Tool overview
Tool names follow a predictable `verb_resource` convention derived from the API
path and version. A selection:
| Tool | Method & path | What it does |
| --------------------------- | ----------------------------------- | ----------------------------------------- |
| `list_customers` | `GET /customers` | List / search customers |
| `create_customers` | `POST /customers` | Create a customer |
| `get_customers` | `GET /customers/{customer_id}` | Fetch a single customer |
| `list_invoices` | `GET /invoices` | List / search invoices |
| `create_v3_invoices` | `POST /v3/invoices` | Create an invoice (invoice-level VAT) |
| `send_invoices` | `POST /invoices/{id}/send` | Send an invoice |
| `get_invoices_download` | `GET /invoices/{id}/download` | Get a download URL for the invoice PDF |
| `list_v2_estimates` | `GET /v2/estimates` | List estimates |
| `create_v2_estimates` | `POST /v2/estimates` | Create an estimate |
| `list_v3_expenses` | `GET /v3/expenses` | List expenses |
| `list_v3_bank_accounts` | `GET /v3/bank-accounts` | List bank accounts |
| `get_reports_summaries_profit_and_loss` | `GET /reports/summaries/profit_and_loss` | Dashboard profit & loss summary |
| `get_organizations_me` | `GET /organizations/me` | The organization tied to your credentials |
Newer resources are versioned (`v2`, `v3`) and the version is part of the tool
name. Where Jortt offers multiple versions of the same operation (for example
invoice creation), prefer the highest version unless you have a reason not to —
`create_v3_invoices` supports invoice-level VAT.
Run the server and call `tools/list` to see the full, always-current set with
per-tool argument schemas.
## Development
```bash
npm run build # compile TypeScript to dist/ (and copy the spec)
npm run dev # tsc --watch
npm run typecheck # type-check without emitting
```
### Updating to the latest API
The tool set is generated from the committed OpenAPI spec. To pick up new
Jortt endpoints, refresh it and rebuild:
```bash
npm run fetch-spec # downloads https://api.jortt.nl/swagger_doc to src/openapi.json
npm run build
```
No code changes are needed — new operations become new tools automatically.
Review and commit the updated `src/openapi.json`.
## Security model
- **The MCP endpoint fronts your entire administration.** Whoever can call it
can do whatever the Jortt application's scopes allow. Layer the controls:
narrow the scopes at registration, set `JORTT_READ_ONLY` if writes aren't
needed, and in OIDC mode always set `MCP_OIDC_ALLOWED_SUBJECTS`.
- The HTTP transport refuses to start without `MCP_AUTH_MODE`; there is no
unauthenticated mode.
- Static tokens are compared in constant time (over SHA-256 digests); JWTs are
validated for signature, issuer, expiry, and audience; introspection results
are cached for at most 60 s so revocation takes effect quickly.
- The server binds to loopback by default and expects a TLS-terminating
reverse proxy in front. `MCP_ALLOWED_HOSTS`/`MCP_ALLOWED_ORIGINS` add
DNS-rebinding protection.
- Secrets can be read from files (`*_FILE`) instead of the environment; the
NixOS module keeps them in a root-owned `EnvironmentFile` (e.g. agenix) and
runs the service fully sandboxed.
- All outbound HTTP calls (Jortt API, token endpoint, JWKS/introspection)
carry timeouts. The OpenAPI spec that defines the tool surface is bundled at
build time, never fetched implicitly at runtime.
## Notes & limits
- Jortt rate-limits the API to ~10 requests/second. List endpoints paginate at a
maximum of 100 items per page (`page` argument).
- This is an unofficial, community project and is not affiliated with or endorsed
by Jortt.
## License
MIT — see [LICENSE](LICENSE).