259 lines
9.4 KiB
Nix
259 lines
9.4 KiB
Nix
|
|
{ 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 = "";
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}
|