- 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>
289 lines
13 KiB
Markdown
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).
|