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
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue