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