jortt-mcp/nix/module.nix

259 lines
9.4 KiB
Nix
Raw Permalink Normal View History

{ 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 = "";
};
};
};
}