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>
This commit is contained in:
Claude 2026-07-11 19:10:55 +02:00
parent 240a53fade
commit a73af353d4
17 changed files with 1255 additions and 77 deletions

View file

@ -1,6 +1,36 @@
# Copy to .env and fill in credentials from your registered Jortt API application:
# https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list
# Every secret below can alternatively be read from a file via <NAME>_FILE,
# e.g. JORTT_CLIENT_SECRET_FILE=/run/agenix/jortt-client-secret
JORTT_CLIENT_ID=
JORTT_CLIENT_SECRET=
# Optional: space-separated scopes to request (defaults to all registered scopes)
# JORTT_SCOPES=invoices:read customers:read reports:read
# Optional: expose only GET (list/get) tools
# JORTT_READ_ONLY=1
# --- HTTP transport (default is stdio) ---
# MCP_TRANSPORT=http
# MCP_HOST=127.0.0.1
# MCP_PORT=3910
# Authentication for incoming MCP requests: "token" or "oidc" (required for http)
# MCP_AUTH_MODE=token
# MCP_AUTH_TOKEN= # generate with: openssl rand -hex 32
# OIDC resource-server mode (auth by an external OAuth provider / proxy):
# MCP_AUTH_MODE=oidc
# MCP_OIDC_ISSUER=https://auth.example.com
# MCP_RESOURCE_URL=https://jortt-mcp.example.com/mcp
# MCP_OIDC_AUDIENCE= # defaults to MCP_RESOURCE_URL
# MCP_OIDC_ALLOWED_SUBJECTS=kate,kate@example.com
# For issuers with opaque tokens (e.g. Authelia), use RFC 7662 introspection:
# MCP_OIDC_INTROSPECTION_URL= # discovered from the issuer when omitted
# MCP_OIDC_CLIENT_ID=
# MCP_OIDC_CLIENT_SECRET=
# DNS-rebinding protection: exact Host / Origin header allowlists
# MCP_ALLOWED_HOSTS=jortt-mcp.example.com
# MCP_ALLOWED_ORIGINS=

4
.gitignore vendored
View file

@ -3,6 +3,4 @@ dist/
*.log
.env
.DS_Store
# Fetched at build time by scripts/fetch-spec.mjs
src/openapi.json
result

173
README.md
View file

@ -14,17 +14,21 @@ faithful to the API: **82 tools** across every documented resource.
## How it works
- The build step downloads the Jortt OpenAPI spec to `src/openapi.json` and
bundles it into `dist/`. On startup the server reads that spec, resolves all
- 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. (If no bundled spec is found, it fetches one
live from Jortt as a fallback.)
- Authentication 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`.
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
@ -40,23 +44,59 @@ faithful to the API: **82 tools** across every documented resource.
```bash
git clone https://github.com/deprekated/jortt-mcp.git
cd jortt-mcp
npm install # runs the build, which downloads the current Jortt OpenAPI spec
npm install
npm run build
```
> The build fetches `https://api.jortt.nl/swagger_doc`, so it needs outbound
> network access. Set `JORTT_OPENAPI_URL` to point at a local copy or mirror if
> you build offline.
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:
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_OPENAPI_URL` | no | Override the OpenAPI spec source (used at build time and as a runtime fallback). Defaults to `https://api.jortt.nl/swagger_doc`. |
| `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
@ -88,6 +128,84 @@ Restrict what the assistant can do by narrowing the scopes:
}
```
### 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
@ -127,14 +245,37 @@ npm run typecheck # type-check without emitting
### Updating to the latest API
The tool set is generated from the OpenAPI spec, which the build downloads
fresh each time. To pick up new Jortt endpoints, just rebuild:
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

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1783522502,
"narHash": "sha256-iffAls3iaNTyJC2faYcUXSI+Gp02cDjYl+MygxKl2GI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0bb7ec54c8483066ec9d7720e780a5caa71f8612",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View file

@ -0,0 +1,35 @@
{
description = "MCP server for the Jortt accounting & invoicing API";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
mkPackage = pkgs: pkgs.callPackage ./nix/package.nix { };
in
{
packages = forAllSystems (pkgs: rec {
jortt-mcp = mkPackage pkgs;
default = jortt-mcp;
});
overlays.default = final: prev: {
jortt-mcp = mkPackage final;
};
nixosModules.jortt-mcp = { pkgs, lib, ... }: {
imports = [ ./nix/module.nix ];
services.jortt-mcp.package =
lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.jortt-mcp;
};
nixosModules.default = self.nixosModules.jortt-mcp;
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.nodejs_22 pkgs.typescript ];
};
});
};
}

258
nix/module.nix Normal file
View file

@ -0,0 +1,258 @@
{ config, lib, pkgs, ... }:
let
cfg = config.services.jortt-mcp;
in
{
options.services.jortt-mcp = {
enable = lib.mkEnableOption "jortt-mcp, an MCP server for the Jortt accounting API";
package = lib.mkOption {
type = lib.types.package;
description = "The jortt-mcp package to run.";
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = ''
Address to bind the Streamable HTTP listener to. Keep this on
loopback and terminate TLS in a reverse proxy (nginx, caddy, ...);
the server itself speaks plain HTTP.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 3910;
description = "Port for the Streamable HTTP listener.";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/agenix/jortt-mcp.env";
description = ''
systemd EnvironmentFile with the secrets, in KEY=value format.
Intended for an agenix-managed file, e.g.
{command}`age.secrets.jortt-mcp-env.file`.
Required keys: {env}`JORTT_CLIENT_ID`, {env}`JORTT_CLIENT_SECRET`.
For `auth.mode = "token"` also set {env}`MCP_AUTH_TOKEN` (one or
more tokens, comma- or newline-separated). For OIDC token
introspection also set {env}`MCP_OIDC_CLIENT_SECRET`.
'';
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Expose only GET operations (list/get tools). A useful hard stop on
top of OAuth scopes when the assistant should not create or modify
anything in the administration.
'';
};
scopes = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
example = [ "invoices:read" "customers:read" "reports:read" ];
description = ''
Jortt OAuth scopes to request. Defaults to all scopes the
registered Jortt application was granted.
'';
};
auth = {
mode = lib.mkOption {
type = lib.types.enum [ "token" "oidc" ];
description = ''
How incoming MCP requests are authenticated.
`token`: static bearer token(s), supplied via
{env}`MCP_AUTH_TOKEN` in {option}`environmentFile`. Works with
clients that can send an Authorization header (Claude Code,
the Claude API).
`oidc`: validate OAuth 2.0 access tokens issued by an external
authorization server or OAuth proxy (Keycloak, Authelia,
Pomerium, ...). The server acts as an OAuth resource server per
the MCP authorization spec and publishes RFC 9728
protected-resource metadata for client discovery. Required for
claude.ai remote connectors.
'';
};
oidc = {
issuer = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://auth.example.com";
description = "Issuer URL of the authorization server / OAuth proxy.";
};
audience = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Audience the access token must carry. Defaults to
{option}`resourceUrl`.
'';
};
allowedSubjects = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "kate" "kate@example.com" ];
description = ''
When non-empty, only tokens whose subject, preferred_username
or email matches one of these values are accepted. Strongly
recommended: any account on the IdP can otherwise obtain a
valid token for this (single-administration) server.
'';
};
introspectionUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
RFC 7662 token introspection endpoint, for authorization
servers that issue opaque (non-JWT) access tokens, e.g.
Authelia. When set (or when {option}`introspectionClientId`
is set, using endpoint discovery), tokens are introspected
instead of validated as JWTs; the introspection client
secret comes from {env}`MCP_OIDC_CLIENT_SECRET` in
{option}`environmentFile`.
'';
};
introspectionClientId = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Client ID used to authenticate to the introspection endpoint.";
};
};
};
resourceUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://jortt-mcp.example.com/mcp";
description = ''
Public URL of the MCP endpoint as reached through the reverse
proxy. Used as the OAuth resource identifier / token audience and
in the published protected-resource metadata.
'';
};
allowedHosts = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "jortt-mcp.example.com" ];
description = ''
Exact values the HTTP Host header must match (DNS-rebinding
protection). Include the public hostname (add `:port` if
non-standard). Leave empty to disable the check, e.g. when a
reverse proxy already enforces the virtual host.
'';
};
allowedOrigins = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Exact Origin header values to accept (browser-based clients).";
};
extraEnvironment = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
description = "Extra environment variables for the service (non-secret).";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open the firewall for the listener port (only sensible with a non-loopback host).";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.environmentFile != null;
message = "services.jortt-mcp.environmentFile is required: it must provide JORTT_CLIENT_ID and JORTT_CLIENT_SECRET (and MCP_AUTH_TOKEN in token mode).";
}
{
assertion = cfg.auth.mode != "oidc" || cfg.auth.oidc.issuer != null;
message = "services.jortt-mcp.auth.oidc.issuer must be set when auth.mode = \"oidc\".";
}
{
assertion = cfg.auth.mode != "oidc" || cfg.resourceUrl != null || cfg.auth.oidc.audience != null;
message = "services.jortt-mcp: with auth.mode = \"oidc\", set resourceUrl (recommended) or auth.oidc.audience.";
}
];
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
systemd.services.jortt-mcp = {
description = "Jortt MCP server";
wantedBy = [ "multi-user.target" ];
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
environment = {
MCP_TRANSPORT = "http";
MCP_HOST = cfg.host;
MCP_PORT = toString cfg.port;
MCP_AUTH_MODE = cfg.auth.mode;
}
// lib.optionalAttrs cfg.readOnly { JORTT_READ_ONLY = "1"; }
// lib.optionalAttrs (cfg.scopes != null) { JORTT_SCOPES = lib.concatStringsSep " " cfg.scopes; }
// lib.optionalAttrs (cfg.resourceUrl != null) { MCP_RESOURCE_URL = cfg.resourceUrl; }
// lib.optionalAttrs (cfg.allowedHosts != [ ]) { MCP_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts; }
// lib.optionalAttrs (cfg.allowedOrigins != [ ]) { MCP_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.allowedOrigins; }
// lib.optionalAttrs (cfg.auth.oidc.issuer != null) { MCP_OIDC_ISSUER = cfg.auth.oidc.issuer; }
// lib.optionalAttrs (cfg.auth.oidc.audience != null) { MCP_OIDC_AUDIENCE = cfg.auth.oidc.audience; }
// lib.optionalAttrs (cfg.auth.oidc.allowedSubjects != [ ]) {
MCP_OIDC_ALLOWED_SUBJECTS = lib.concatStringsSep "," cfg.auth.oidc.allowedSubjects;
}
// lib.optionalAttrs (cfg.auth.oidc.introspectionUrl != null) { MCP_OIDC_INTROSPECTION_URL = cfg.auth.oidc.introspectionUrl; }
// lib.optionalAttrs (cfg.auth.oidc.introspectionClientId != null) { MCP_OIDC_CLIENT_ID = cfg.auth.oidc.introspectionClientId; }
// cfg.extraEnvironment;
serviceConfig = {
ExecStart = lib.getExe cfg.package;
EnvironmentFile = [ cfg.environmentFile ];
Restart = "on-failure";
RestartSec = 5;
# Hardening
DynamicUser = true;
NoNewPrivileges = true;
UMask = "0077";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectHostname = true;
ProtectProc = "invisible";
ProcSubset = "pid";
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
SystemCallArchitectures = "native";
SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
CapabilityBoundingSet = "";
AmbientCapabilities = "";
};
};
};
}

31
nix/package.nix Normal file
View file

@ -0,0 +1,31 @@
{ lib, buildNpmPackage, nodejs_22 }:
buildNpmPackage {
pname = "jortt-mcp";
version = "0.2.0";
# Explicit fileset: never pull .env, dist/ or other stray files into the store.
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
../package.json
../package-lock.json
../tsconfig.json
../src
../scripts
../LICENSE
../README.md
];
};
nodejs = nodejs_22;
npmDepsHash = "sha256-eKdcd++SsI9gi3exUoNogbCpsUxSrVGRM1iQMUcF9Y4=";
meta = {
description = "Model Context Protocol server for the Jortt accounting API";
homepage = "https://github.com/deprekated/jortt-mcp";
license = lib.licenses.mit;
mainProgram = "jortt-mcp";
};
}

7
package-lock.json generated
View file

@ -1,15 +1,16 @@
{
"name": "jortt-mcp",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jortt-mcp",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
"@modelcontextprotocol/sdk": "^1.29.0",
"jose": "^6.0.0"
},
"bin": {
"jortt-mcp": "dist/index.js"

View file

@ -1,6 +1,6 @@
{
"name": "jortt-mcp",
"version": "0.1.0",
"version": "0.2.0",
"description": "Model Context Protocol server for the Jortt accounting API",
"license": "MIT",
"type": "module",
@ -13,8 +13,7 @@
],
"scripts": {
"fetch-spec": "node scripts/fetch-spec.mjs",
"build": "npm run fetch-spec && tsc && node -e \"require('fs').copyFileSync('src/openapi.json','dist/openapi.json')\"",
"prepare": "npm run build",
"build": "tsc && node -e \"require('fs').copyFileSync('src/openapi.json','dist/openapi.json')\"",
"start": "node dist/index.js",
"dev": "tsc --watch",
"typecheck": "tsc --noEmit"
@ -31,7 +30,8 @@
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.0"
"@modelcontextprotocol/sdk": "^1.29.0",
"jose": "^6.0.0"
},
"devDependencies": {
"@types/node": "^22.10.0",

View file

@ -78,12 +78,13 @@ export class TokenManager {
Accept: "application/json",
},
body: body.toString(),
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to obtain Jortt access token (HTTP ${res.status}): ${text || res.statusText}`,
`Failed to obtain Jortt access token (HTTP ${res.status}): ${(text || res.statusText).slice(0, 500)}`,
);
}

247
src/bearer.ts Normal file
View file

@ -0,0 +1,247 @@
import { createHash, timingSafeEqual } from "node:crypto";
import {
createRemoteJWKSet,
jwtVerify,
type JWTPayload,
type JWTVerifyGetKey,
} from "jose";
export type VerifyResult =
| { ok: true; subject?: string; scopes?: string[] }
| { ok: false; error: string };
/** Verifies the bearer token presented on incoming MCP HTTP requests. */
export interface BearerVerifier {
verify(token: string): Promise<VerifyResult>;
}
function sha256(input: string): Buffer {
return createHash("sha256").update(input, "utf8").digest();
}
/**
* Static shared-secret tokens. Comparison is constant-time over SHA-256
* digests so token length is not observable either.
*/
export class StaticTokenVerifier implements BearerVerifier {
private readonly digests: Buffer[];
constructor(tokens: string[]) {
if (tokens.length === 0) {
throw new Error("StaticTokenVerifier requires at least one token");
}
for (const t of tokens) {
if (t.length < 16) {
throw new Error(
"Refusing a static bearer token shorter than 16 characters; " +
"generate one with e.g. `openssl rand -hex 32`.",
);
}
}
this.digests = tokens.map(sha256);
}
async verify(token: string): Promise<VerifyResult> {
const digest = sha256(token);
for (const expected of this.digests) {
if (timingSafeEqual(digest, expected)) return { ok: true };
}
return { ok: false, error: "invalid token" };
}
}
interface SubjectPolicy {
/** When non-empty, only these `sub` (or preferred_username/email) values are accepted. */
allowedSubjects: string[];
}
function subjectAllowed(
policy: SubjectPolicy,
candidates: Array<string | undefined>,
): boolean {
if (policy.allowedSubjects.length === 0) return true;
return candidates.some((c) => c !== undefined && policy.allowedSubjects.includes(c));
}
async function discoverAuthServer(
issuer: string,
): Promise<{ jwks_uri?: string; introspection_endpoint?: string }> {
const base = issuer.replace(/\/$/, "");
const candidates = [
`${base}/.well-known/openid-configuration`,
`${base}/.well-known/oauth-authorization-server`,
];
let lastError = "";
for (const url of candidates) {
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
return (await res.json()) as {
jwks_uri?: string;
introspection_endpoint?: string;
};
}
lastError = `HTTP ${res.status} from ${url}`;
} catch (err) {
lastError = `${url}: ${err instanceof Error ? err.message : String(err)}`;
}
}
throw new Error(`OAuth discovery failed for issuer ${issuer}: ${lastError}`);
}
/**
* Validates JWT access tokens issued by an external OAuth/OIDC authorization
* server (the "OAuth proxy"): signature via the issuer's JWKS, plus issuer,
* expiry, and audience checks.
*/
export class JwtVerifier implements BearerVerifier {
private jwks: JWTVerifyGetKey | null = null;
constructor(
private readonly issuer: string,
private readonly audience: string,
private readonly policy: SubjectPolicy,
) {}
private async getJwks(): Promise<JWTVerifyGetKey> {
if (this.jwks) return this.jwks;
const meta = await discoverAuthServer(this.issuer);
if (!meta.jwks_uri) {
throw new Error(
`Issuer ${this.issuer} does not advertise a jwks_uri; ` +
`if it issues opaque tokens, configure token introspection instead.`,
);
}
this.jwks = createRemoteJWKSet(new URL(meta.jwks_uri));
return this.jwks;
}
async verify(token: string): Promise<VerifyResult> {
let payload: JWTPayload;
try {
const jwks = await this.getJwks();
({ payload } = await jwtVerify(token, jwks, {
issuer: this.issuer,
audience: this.audience,
}));
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
const username =
typeof payload.preferred_username === "string"
? payload.preferred_username
: undefined;
const email = typeof payload.email === "string" ? payload.email : undefined;
if (!subjectAllowed(this.policy, [payload.sub, username, email])) {
return { ok: false, error: `subject ${payload.sub} not allowed` };
}
const scopes =
typeof payload.scope === "string" ? payload.scope.split(" ") : undefined;
return { ok: true, subject: payload.sub, scopes };
}
}
interface IntrospectionResponse {
active: boolean;
sub?: string;
username?: string;
scope?: string;
aud?: string | string[];
exp?: number;
}
/**
* RFC 7662 token introspection, for authorization servers that issue opaque
* access tokens (e.g. Authelia). Successful results are cached briefly (keyed
* by token digest) to avoid a round-trip to the AS on every MCP request.
*/
export class IntrospectionVerifier implements BearerVerifier {
private endpoint: string | null;
private readonly cache = new Map<string, { until: number; result: VerifyResult }>();
constructor(
private readonly issuer: string,
private readonly clientId: string,
private readonly clientSecret: string,
private readonly audience: string | undefined,
private readonly policy: SubjectPolicy,
endpoint?: string,
) {
this.endpoint = endpoint ?? null;
}
private async getEndpoint(): Promise<string> {
if (this.endpoint) return this.endpoint;
const meta = await discoverAuthServer(this.issuer);
if (!meta.introspection_endpoint) {
throw new Error(
`Issuer ${this.issuer} does not advertise an introspection_endpoint; ` +
`set MCP_OIDC_INTROSPECTION_URL explicitly.`,
);
}
this.endpoint = meta.introspection_endpoint;
return this.endpoint;
}
async verify(token: string): Promise<VerifyResult> {
const key = sha256(token).toString("hex");
const now = Date.now();
const cached = this.cache.get(key);
if (cached && cached.until > now) return cached.result;
const result = await this.introspect(token);
// Cache positive results for up to 60s (bounded by token expiry);
// negative results are not cached so revocations aren't masked longer
// than necessary and probing stays expensive for the AS, not for us.
if (result.ok) {
this.cache.set(key, { until: now + 60_000, result });
if (this.cache.size > 1000) {
for (const [k, v] of this.cache) {
if (v.until <= now) this.cache.delete(k);
}
}
}
return result;
}
private async introspect(token: string): Promise<VerifyResult> {
const endpoint = await this.getEndpoint();
const basic = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString(
"base64",
);
const res = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({ token }).toString(),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
throw new Error(`Token introspection failed: HTTP ${res.status}`);
}
const data = (await res.json()) as IntrospectionResponse;
if (!data.active) return { ok: false, error: "token not active" };
if (this.audience) {
const aud = Array.isArray(data.aud) ? data.aud : data.aud ? [data.aud] : [];
if (!aud.includes(this.audience)) {
return { ok: false, error: "token audience mismatch" };
}
}
if (!subjectAllowed(this.policy, [data.sub, data.username])) {
return { ok: false, error: `subject ${data.sub} not allowed` };
}
return { ok: true, subject: data.sub, scopes: data.scope?.split(" ") };
}
}

View file

@ -66,7 +66,11 @@ export class JorttClient {
Accept: "application/json",
};
if (init.body) headers["Content-Type"] = "application/json";
return fetch(url, { ...init, headers });
return fetch(url, {
...init,
headers,
signal: AbortSignal.timeout(60_000),
});
};
let res = await doFetch();

50
src/config.ts Normal file
View file

@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
/**
* Read a configuration value from the environment, with `<NAME>_FILE`
* indirection: if `<NAME>_FILE` is set, the value is read from that file
* (trailing whitespace stripped). This keeps secrets out of process
* environments and lets systemd credentials / agenix-managed files be used
* directly.
*/
export function envOrFile(name: string): string | undefined {
const direct = process.env[name];
const file = process.env[`${name}_FILE`];
if (direct && file) {
throw new Error(`Both ${name} and ${name}_FILE are set; use only one.`);
}
if (file) {
try {
return readFileSync(file, "utf8").replace(/\s+$/, "");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Could not read ${name}_FILE (${file}): ${message}`);
}
}
return direct || undefined;
}
/** Like envOrFile, but exits with a helpful message when unset. */
export function requireEnvOrFile(name: string, hint: string): string {
const value = envOrFile(name);
if (!value) {
console.error(`[jortt-mcp] Missing required configuration: ${name}\n${hint}`);
process.exit(1);
}
return value;
}
export function envFlag(name: string): boolean {
const value = process.env[name]?.toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
/** Parse a comma- or whitespace-separated env list. */
export function envList(name: string): string[] {
const value = process.env[name];
if (!value) return [];
return value
.split(/[,\s]+/)
.map((s) => s.trim())
.filter(Boolean);
}

236
src/http.ts Normal file
View file

@ -0,0 +1,236 @@
import { randomUUID } from "node:crypto";
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { BearerVerifier } from "./bearer.js";
const MCP_PATH = "/mcp";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const SESSION_SWEEP_MS = 10 * 60 * 1000;
const MAX_SESSIONS = 64;
export interface HttpConfig {
host: string;
port: number;
verifier: BearerVerifier;
/**
* Public URL of the MCP endpoint (e.g. https://mcp.example.com/mcp). When
* set together with `issuer`, OAuth protected-resource metadata (RFC 9728)
* is served so MCP clients can discover the authorization server.
*/
resourceUrl?: string;
/** Authorization server (OAuth proxy / IdP) issuer URL, for metadata. */
issuer?: string;
/** Exact `host[:port]` values accepted in the Host header. */
allowedHosts: string[];
/** Exact origins accepted in the Origin header (browser clients). */
allowedOrigins: string[];
/** Creates a fresh MCP Server instance for each session. */
createMcpServer: () => Server;
}
interface Session {
transport: StreamableHTTPServerTransport;
lastSeen: number;
}
function protectedResourceMetadata(config: HttpConfig): string {
return JSON.stringify({
resource: config.resourceUrl,
authorization_servers: [config.issuer],
bearer_methods_supported: ["header"],
});
}
function send(res: ServerResponse, status: number, body: string, headers: Record<string, string> = {}): void {
res.writeHead(status, { "Content-Type": "application/json", ...headers });
res.end(body);
}
function jsonRpcError(res: ServerResponse, status: number, code: number, message: string, headers: Record<string, string> = {}): void {
send(
res,
status,
JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }),
headers,
);
}
export function startHttpServer(config: HttpConfig): Promise<void> {
const sessions = new Map<string, Session>();
const metadataUrl = config.resourceUrl
? new URL("/.well-known/oauth-protected-resource", config.resourceUrl).toString()
: undefined;
const unauthorized = (res: ServerResponse, detail: string): void => {
const challenge = [
`Bearer realm="jortt-mcp"`,
`error="invalid_token"`,
metadataUrl && config.issuer ? `resource_metadata="${metadataUrl}"` : null,
]
.filter(Boolean)
.join(", ");
jsonRpcError(res, 401, -32001, `Unauthorized: ${detail}`, {
"WWW-Authenticate": challenge,
});
};
const authenticate = async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
unauthorized(res, "missing bearer token");
return false;
}
try {
const result = await config.verifier.verify(header.slice("Bearer ".length).trim());
if (!result.ok) {
console.error(`[jortt-mcp] auth rejected: ${result.error}`);
unauthorized(res, "invalid or expired token");
return false;
}
return true;
} catch (err) {
console.error(`[jortt-mcp] auth verification error:`, err);
jsonRpcError(res, 503, -32000, "Authorization backend unavailable");
return false;
}
};
const evictIfNeeded = (): void => {
if (sessions.size < MAX_SESSIONS) return;
let oldestId: string | null = null;
let oldestSeen = Infinity;
for (const [id, s] of sessions) {
if (s.lastSeen < oldestSeen) {
oldestSeen = s.lastSeen;
oldestId = id;
}
}
if (oldestId) {
const session = sessions.get(oldestId)!;
sessions.delete(oldestId);
void session.transport.close().catch(() => {});
}
};
const newTransport = async (): Promise<StreamableHTTPServerTransport> => {
evictIfNeeded();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
enableDnsRebindingProtection:
config.allowedHosts.length > 0 || config.allowedOrigins.length > 0,
allowedHosts: config.allowedHosts.length ? config.allowedHosts : undefined,
allowedOrigins: config.allowedOrigins.length ? config.allowedOrigins : undefined,
onsessioninitialized: (sessionId) => {
sessions.set(sessionId, { transport, lastSeen: Date.now() });
},
onsessionclosed: (sessionId) => {
sessions.delete(sessionId);
},
});
transport.onclose = () => {
if (transport.sessionId) sessions.delete(transport.sessionId);
};
const mcpServer = config.createMcpServer();
await mcpServer.connect(transport);
return transport;
};
const handleMcp = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (!(await authenticate(req, res))) return;
const sessionId = req.headers["mcp-session-id"];
if (typeof sessionId === "string") {
const session = sessions.get(sessionId);
if (!session) {
jsonRpcError(res, 404, -32001, "Session not found");
return;
}
session.lastSeen = Date.now();
await session.transport.handleRequest(req, res);
return;
}
if (req.method !== "POST") {
jsonRpcError(res, 400, -32000, "Mcp-Session-Id header required");
return;
}
// No session yet: only an initialize request is valid here. The transport
// enforces that and responds 400 itself for anything else.
const transport = await newTransport();
await transport.handleRequest(req, res);
};
const httpServer = createHttpServer((req, res) => {
const path = (req.url ?? "/").split("?")[0].replace(/\/+$/, "") || "/";
if (req.method === "GET" && path === "/healthz") {
send(res, 200, JSON.stringify({ status: "ok" }));
return;
}
if (
req.method === "GET" &&
(path === "/.well-known/oauth-protected-resource" ||
path === `/.well-known/oauth-protected-resource${MCP_PATH}`)
) {
if (config.resourceUrl && config.issuer) {
send(res, 200, protectedResourceMetadata(config), {
"Cache-Control": "public, max-age=3600",
});
} else {
send(res, 404, JSON.stringify({ error: "no OAuth metadata configured" }));
}
return;
}
if (path === MCP_PATH) {
handleMcp(req, res).catch((err) => {
console.error("[jortt-mcp] request error:", err);
if (!res.headersSent) {
jsonRpcError(res, 500, -32603, "Internal server error");
} else {
res.end();
}
});
return;
}
send(res, 404, JSON.stringify({ error: "not found" }));
});
// Reap sessions that have been idle past the TTL.
const sweeper = setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [id, session] of sessions) {
if (session.lastSeen < cutoff) {
sessions.delete(id);
void session.transport.close().catch(() => {});
}
}
}, SESSION_SWEEP_MS);
sweeper.unref();
const shutdown = (): void => {
clearInterval(sweeper);
for (const [id, session] of sessions) {
sessions.delete(id);
void session.transport.close().catch(() => {});
}
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(0), 3000).unref();
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
return new Promise((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(config.port, config.host, () => {
console.error(
`[jortt-mcp] listening on http://${config.host}:${config.port}${MCP_PATH}`,
);
resolve();
});
});
}

View file

@ -7,37 +7,26 @@ import {
type Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { TokenManager } from "./auth.js";
import {
IntrospectionVerifier,
JwtVerifier,
StaticTokenVerifier,
type BearerVerifier,
} from "./bearer.js";
import { JorttClient } from "./client.js";
import { envFlag, envList, envOrFile, requireEnvOrFile } from "./config.js";
import { startHttpServer } from "./http.js";
import { generateTools, type JorttTool } from "./openapi.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
console.error(
`[jortt-mcp] Missing required environment variable: ${name}\n` +
`Set JORTT_CLIENT_ID and JORTT_CLIENT_SECRET with an application registered at\n` +
`https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list`,
);
process.exit(1);
}
return value;
}
async function main(): Promise<void> {
const clientId = requireEnv("JORTT_CLIENT_ID");
const clientSecret = requireEnv("JORTT_CLIENT_SECRET");
const scopes = process.env.JORTT_SCOPES; // optional space-separated override
const tokens = new TokenManager(clientId, clientSecret, scopes || undefined);
const client = new JorttClient(tokens);
const jorttTools = await generateTools();
const toolsByName = new Map<string, JorttTool>(
jorttTools.map((t) => [t.name, t]),
);
const VERSION = "0.2.0";
function buildMcpServer(
jorttTools: JorttTool[],
toolsByName: Map<string, JorttTool>,
client: JorttClient,
): Server {
const server = new Server(
{ name: "jortt-mcp", version: "0.1.0" },
{ name: "jortt-mcp", version: VERSION },
{ capabilities: { tools: {} } },
);
@ -85,11 +74,132 @@ async function main(): Promise<void> {
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(
`[jortt-mcp] ready — ${jorttTools.length} tools exposed over stdio`,
return server;
}
function buildVerifier(resourceUrl: string | undefined): {
verifier: BearerVerifier;
issuer?: string;
} {
const mode = process.env.MCP_AUTH_MODE;
switch (mode) {
case "token": {
const raw = requireEnvOrFile(
"MCP_AUTH_TOKEN",
"MCP_AUTH_MODE=token requires MCP_AUTH_TOKEN or MCP_AUTH_TOKEN_FILE " +
"(one or more tokens, newline- or comma-separated).",
);
const tokens = raw
.split(/[\n,]+/)
.map((t) => t.trim())
.filter(Boolean);
return { verifier: new StaticTokenVerifier(tokens) };
}
case "oidc": {
const issuer = requireEnvOrFile(
"MCP_OIDC_ISSUER",
"MCP_AUTH_MODE=oidc requires MCP_OIDC_ISSUER (the authorization server / OAuth proxy issuer URL).",
).replace(/\/$/, "");
const audience = process.env.MCP_OIDC_AUDIENCE || resourceUrl;
const policy = { allowedSubjects: envList("MCP_OIDC_ALLOWED_SUBJECTS") };
const introspectionUrl = process.env.MCP_OIDC_INTROSPECTION_URL;
const clientId = envOrFile("MCP_OIDC_CLIENT_ID");
if (introspectionUrl || clientId) {
const clientSecret = requireEnvOrFile(
"MCP_OIDC_CLIENT_SECRET",
"Token introspection requires MCP_OIDC_CLIENT_ID and MCP_OIDC_CLIENT_SECRET " +
"(credentials of a client permitted to introspect tokens).",
);
if (!clientId) {
console.error("[jortt-mcp] Token introspection requires MCP_OIDC_CLIENT_ID.");
process.exit(1);
}
return {
verifier: new IntrospectionVerifier(
issuer,
clientId,
clientSecret,
audience,
policy,
introspectionUrl,
),
issuer,
};
}
if (!audience) {
console.error(
"[jortt-mcp] MCP_AUTH_MODE=oidc requires an audience to validate: " +
"set MCP_RESOURCE_URL (recommended) or MCP_OIDC_AUDIENCE.",
);
process.exit(1);
}
return { verifier: new JwtVerifier(issuer, audience, policy), issuer };
}
default:
console.error(
`[jortt-mcp] MCP_TRANSPORT=http requires MCP_AUTH_MODE to be "token" or "oidc" ` +
`(got: ${mode ?? "unset"}). Refusing to serve the Jortt API unauthenticated.`,
);
process.exit(1);
}
}
async function main(): Promise<void> {
const clientId = requireEnvOrFile(
"JORTT_CLIENT_ID",
"Set JORTT_CLIENT_ID and JORTT_CLIENT_SECRET (or their *_FILE variants) with an\n" +
"application registered at https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list",
);
const clientSecret = requireEnvOrFile(
"JORTT_CLIENT_SECRET",
"Set JORTT_CLIENT_SECRET or JORTT_CLIENT_SECRET_FILE.",
);
const scopes = process.env.JORTT_SCOPES; // optional space-separated override
const readOnly = envFlag("JORTT_READ_ONLY");
const tokens = new TokenManager(clientId, clientSecret, scopes || undefined);
const client = new JorttClient(tokens);
let jorttTools = await generateTools();
if (readOnly) {
jorttTools = jorttTools.filter((t) => t.method === "get");
}
const toolsByName = new Map<string, JorttTool>(
jorttTools.map((t) => [t.name, t]),
);
const transport = process.env.MCP_TRANSPORT || "stdio";
const toolSummary = `${jorttTools.length} tools${readOnly ? " (read-only)" : ""}`;
if (transport === "stdio") {
const server = buildMcpServer(jorttTools, toolsByName, client);
await server.connect(new StdioServerTransport());
console.error(`[jortt-mcp] ready — ${toolSummary} over stdio`);
return;
}
if (transport !== "http") {
console.error(
`[jortt-mcp] Unknown MCP_TRANSPORT: ${transport} (expected "stdio" or "http")`,
);
process.exit(1);
}
const resourceUrl = process.env.MCP_RESOURCE_URL;
const { verifier, issuer } = buildVerifier(resourceUrl);
await startHttpServer({
host: process.env.MCP_HOST || "127.0.0.1",
port: Number(process.env.MCP_PORT || 3910),
verifier,
resourceUrl,
issuer,
allowedHosts: envList("MCP_ALLOWED_HOSTS"),
allowedOrigins: envList("MCP_ALLOWED_ORIGINS"),
createMcpServer: () => buildMcpServer(jorttTools, toolsByName, client),
});
console.error(`[jortt-mcp] ready — ${toolSummary} over streamable HTTP`);
}
main().catch((err) => {

1
src/openapi.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -4,9 +4,6 @@ import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Where to fetch the spec from when no bundled copy is present. */
const DEFAULT_SPEC_URL = "https://api.jortt.nl/swagger_doc";
/** A single generated MCP tool derived from an OpenAPI operation. */
export interface JorttTool {
name: string;
@ -77,34 +74,45 @@ const ACTION_SEGMENTS = new Set([
let cachedSpec: SwaggerSpec | null = null;
/**
* Load the OpenAPI spec. Prefers the copy bundled next to the compiled module
* (produced at build time); if that is missing, fetches it live from Jortt so
* the server still works from a bare checkout. Override the source with
* `JORTT_OPENAPI_URL`.
* Load the OpenAPI spec bundled next to the compiled module. The spec is
* committed to the repository and refreshed explicitly with
* `npm run fetch-spec`; the server never fetches it from the network on its
* own unless `JORTT_OPENAPI_URL` is explicitly set (since the spec determines
* which requests the tools can make, loading it from a remote source should
* be a deliberate choice).
*/
async function loadSpec(): Promise<SwaggerSpec> {
if (cachedSpec) return cachedSpec;
try {
const raw = await readFile(join(__dirname, "openapi.json"), "utf8");
cachedSpec = JSON.parse(raw) as SwaggerSpec;
return cachedSpec;
} catch {
// No bundled spec — fall back to fetching it.
}
const url = process.env.JORTT_OPENAPI_URL || DEFAULT_SPEC_URL;
const res = await fetch(url, { headers: { Accept: "application/json" } });
const overrideUrl = process.env.JORTT_OPENAPI_URL;
if (overrideUrl) {
const res = await fetch(overrideUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
throw new Error(
`Could not load the Jortt OpenAPI spec from ${url} (HTTP ${res.status}). ` +
`Run \`npm run build\` to bundle it, or set JORTT_OPENAPI_URL.`,
`Could not load the Jortt OpenAPI spec from ${overrideUrl} (HTTP ${res.status}).`,
);
}
cachedSpec = (await res.json()) as SwaggerSpec;
return cachedSpec;
}
const specPath = join(__dirname, "openapi.json");
try {
const raw = await readFile(specPath, "utf8");
cachedSpec = JSON.parse(raw) as SwaggerSpec;
return cachedSpec;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not read the bundled OpenAPI spec at ${specPath}: ${message}. ` +
`Run \`npm run build\` to bundle it (or set JORTT_OPENAPI_URL to load one remotely).`,
);
}
}
/** Recursively inline `$ref` references into a plain JSON Schema, guarding cycles. */
function resolveSchema(
schema: JsonSchema,