- 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>
13 KiB
jortt-mcp
A Model Context Protocol (MCP) server for the Jortt 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, 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.jsonand bundled intodist/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 withnpm run fetch-spec; the server never loads it from the network unless you setJORTT_OPENAPI_URLdeliberately. - 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
bodyargument. - 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
- A Jortt account.
- A registered API application. Create one under Profile → API → OAuth applications, selecting the scopes you want to grant. Registration gives you a Client ID and Client secret.
Installation
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 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:
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):
{
"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:
"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:
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 comes in:
{
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:
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
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:
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_ONLYif writes aren't needed, and in OIDC mode always setMCP_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_ORIGINSadd DNS-rebinding protection. - Secrets can be read from files (
*_FILE) instead of the environment; the NixOS module keeps them in a root-ownedEnvironmentFile(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 (
pageargument). - This is an unofficial, community project and is not affiliated with or endorsed by Jortt.
License
MIT — see LICENSE.