# 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 " ``` 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).