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:
|
# Copy to .env and fill in credentials from your registered Jortt API application:
|
||||||
# https://app.jortt.nl/new_profile/api/jortt/oauth_applications/list
|
# 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_ID=
|
||||||
JORTT_CLIENT_SECRET=
|
JORTT_CLIENT_SECRET=
|
||||||
|
|
||||||
# Optional: space-separated scopes to request (defaults to all registered scopes)
|
# Optional: space-separated scopes to request (defaults to all registered scopes)
|
||||||
# JORTT_SCOPES=invoices:read customers:read reports:read
|
# 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
|
*.log
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
result
|
||||||
# Fetched at build time by scripts/fetch-spec.mjs
|
|
||||||
src/openapi.json
|
|
||||||
|
|
|
||||||
173
README.md
173
README.md
|
|
@ -14,17 +14,21 @@ faithful to the API: **82 tools** across every documented resource.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- The build step downloads the Jortt OpenAPI spec to `src/openapi.json` and
|
- The Jortt OpenAPI spec is committed at `src/openapi.json` and bundled into
|
||||||
bundles it into `dist/`. On startup the server reads that spec, resolves all
|
`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
|
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
|
JSON Schema for its arguments. Refresh the spec explicitly with
|
||||||
live from Jortt as a fallback.)
|
`npm run fetch-spec`; the server never loads it from the network unless you
|
||||||
- Authentication uses the OAuth 2.0 **client credentials** grant (the flow Jortt
|
set `JORTT_OPENAPI_URL` deliberately.
|
||||||
recommends for applications bound to your own administration). The server
|
- Authentication towards Jortt uses the OAuth 2.0 **client credentials** grant
|
||||||
fetches a bearer token, caches it until shortly before it expires, and
|
(the flow Jortt recommends for applications bound to your own
|
||||||
transparently refreshes on expiry or a `401`.
|
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
|
- Path parameters, query parameters, and JSON request bodies are all handled
|
||||||
automatically. Request bodies are passed under a single `body` argument.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
|
|
@ -40,23 +44,59 @@ faithful to the API: **82 tools** across every documented resource.
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/deprekated/jortt-mcp.git
|
git clone https://github.com/deprekated/jortt-mcp.git
|
||||||
cd jortt-mcp
|
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
|
On Nix, `nix build github:deprekated/jortt-mcp` — or see [NixOS](#nixos) below
|
||||||
> network access. Set `JORTT_OPENAPI_URL` to point at a local copy or mirror if
|
for the module.
|
||||||
> you build offline.
|
|
||||||
|
|
||||||
## Configuration
|
## 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 |
|
| Variable | Required | Description |
|
||||||
| -------------------- | -------- | --------------------------------------------------------------------------- |
|
| -------------------- | -------- | --------------------------------------------------------------------------- |
|
||||||
| `JORTT_CLIENT_ID` | yes | OAuth client ID of your registered application. |
|
| `JORTT_CLIENT_ID` | yes | OAuth client ID of your registered application. |
|
||||||
| `JORTT_CLIENT_SECRET`| yes | OAuth client secret. |
|
| `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_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
|
### 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 overview
|
||||||
|
|
||||||
Tool names follow a predictable `verb_resource` convention derived from the API
|
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
|
### Updating to the latest API
|
||||||
|
|
||||||
The tool set is generated from the OpenAPI spec, which the build downloads
|
The tool set is generated from the committed OpenAPI spec. To pick up new
|
||||||
fresh each time. To pick up new Jortt endpoints, just rebuild:
|
Jortt endpoints, refresh it and rebuild:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run fetch-spec # downloads https://api.jortt.nl/swagger_doc to src/openapi.json
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
No code changes are needed — new operations become new tools automatically.
|
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
|
## 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",
|
"name": "jortt-mcp",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jortt-mcp",
|
"name": "jortt-mcp",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0"
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"jose": "^6.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"jortt-mcp": "dist/index.js"
|
"jortt-mcp": "dist/index.js"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "jortt-mcp",
|
"name": "jortt-mcp",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Model Context Protocol server for the Jortt accounting API",
|
"description": "Model Context Protocol server for the Jortt accounting API",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -13,8 +13,7 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-spec": "node scripts/fetch-spec.mjs",
|
"fetch-spec": "node scripts/fetch-spec.mjs",
|
||||||
"build": "npm run fetch-spec && tsc && node -e \"require('fs').copyFileSync('src/openapi.json','dist/openapi.json')\"",
|
"build": "tsc && node -e \"require('fs').copyFileSync('src/openapi.json','dist/openapi.json')\"",
|
||||||
"prepare": "npm run build",
|
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|
@ -31,7 +30,8 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.0"
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"jose": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|
|
||||||
|
|
@ -78,12 +78,13 @@ export class TokenManager {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(
|
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",
|
Accept: "application/json",
|
||||||
};
|
};
|
||||||
if (init.body) headers["Content-Type"] = "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();
|
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,
|
type Tool,
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { TokenManager } from "./auth.js";
|
import { TokenManager } from "./auth.js";
|
||||||
|
import {
|
||||||
|
IntrospectionVerifier,
|
||||||
|
JwtVerifier,
|
||||||
|
StaticTokenVerifier,
|
||||||
|
type BearerVerifier,
|
||||||
|
} from "./bearer.js";
|
||||||
import { JorttClient } from "./client.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";
|
import { generateTools, type JorttTool } from "./openapi.js";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
const VERSION = "0.2.0";
|
||||||
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]),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
function buildMcpServer(
|
||||||
|
jorttTools: JorttTool[],
|
||||||
|
toolsByName: Map<string, JorttTool>,
|
||||||
|
client: JorttClient,
|
||||||
|
): Server {
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: "jortt-mcp", version: "0.1.0" },
|
{ name: "jortt-mcp", version: VERSION },
|
||||||
{ capabilities: { tools: {} } },
|
{ capabilities: { tools: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -85,11 +74,132 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const transport = new StdioServerTransport();
|
return server;
|
||||||
await server.connect(transport);
|
}
|
||||||
console.error(
|
|
||||||
`[jortt-mcp] ready — ${jorttTools.length} tools exposed over stdio`,
|
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) => {
|
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));
|
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. */
|
/** A single generated MCP tool derived from an OpenAPI operation. */
|
||||||
export interface JorttTool {
|
export interface JorttTool {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -77,34 +74,45 @@ const ACTION_SEGMENTS = new Set([
|
||||||
let cachedSpec: SwaggerSpec | null = null;
|
let cachedSpec: SwaggerSpec | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the OpenAPI spec. Prefers the copy bundled next to the compiled module
|
* Load the OpenAPI spec bundled next to the compiled module. The spec is
|
||||||
* (produced at build time); if that is missing, fetches it live from Jortt so
|
* committed to the repository and refreshed explicitly with
|
||||||
* the server still works from a bare checkout. Override the source with
|
* `npm run fetch-spec`; the server never fetches it from the network on its
|
||||||
* `JORTT_OPENAPI_URL`.
|
* 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> {
|
async function loadSpec(): Promise<SwaggerSpec> {
|
||||||
if (cachedSpec) return cachedSpec;
|
if (cachedSpec) return cachedSpec;
|
||||||
|
|
||||||
try {
|
const overrideUrl = process.env.JORTT_OPENAPI_URL;
|
||||||
const raw = await readFile(join(__dirname, "openapi.json"), "utf8");
|
if (overrideUrl) {
|
||||||
cachedSpec = JSON.parse(raw) as SwaggerSpec;
|
const res = await fetch(overrideUrl, {
|
||||||
return cachedSpec;
|
headers: { Accept: "application/json" },
|
||||||
} catch {
|
signal: AbortSignal.timeout(30_000),
|
||||||
// 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) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not load the Jortt OpenAPI spec from ${url} (HTTP ${res.status}). ` +
|
`Could not load the Jortt OpenAPI spec from ${overrideUrl} (HTTP ${res.status}).`,
|
||||||
`Run \`npm run build\` to bundle it, or set JORTT_OPENAPI_URL.`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
cachedSpec = (await res.json()) as SwaggerSpec;
|
cachedSpec = (await res.json()) as SwaggerSpec;
|
||||||
return cachedSpec;
|
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. */
|
/** Recursively inline `$ref` references into a plain JSON Schema, guarding cycles. */
|
||||||
function resolveSchema(
|
function resolveSchema(
|
||||||
schema: JsonSchema,
|
schema: JsonSchema,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue