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:
parent
240a53fade
commit
a73af353d4
17 changed files with 1255 additions and 77 deletions
30
.env.example
30
.env.example
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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
173
README.md
|
|
@ -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
27
flake.lock
generated
Normal 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
35
flake.nix
Normal 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
258
nix/module.nix
Normal 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
31
nix/package.nix
Normal 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
7
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
247
src/bearer.ts
Normal 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(" ") };
|
||||
}
|
||||
}
|
||||
|
|
@ -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
50
src/config.ts
Normal 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
236
src/http.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
170
src/index.ts
170
src/index.ts
|
|
@ -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
1
src/openapi.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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,32 +74,43 @@ 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;
|
||||
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 ${overrideUrl} (HTTP ${res.status}).`,
|
||||
);
|
||||
}
|
||||
cachedSpec = (await res.json()) 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" } });
|
||||
if (!res.ok) {
|
||||
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 load the Jortt OpenAPI spec from ${url} (HTTP ${res.status}). ` +
|
||||
`Run \`npm run build\` to bundle it, or set JORTT_OPENAPI_URL.`,
|
||||
`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).`,
|
||||
);
|
||||
}
|
||||
cachedSpec = (await res.json()) as SwaggerSpec;
|
||||
return cachedSpec;
|
||||
}
|
||||
|
||||
/** Recursively inline `$ref` references into a plain JSON Schema, guarding cycles. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue