{ config, lib, pkgs, ... }: let inherit (lib) mkEnableOption mkOption mkIf types ; cfg = config.services.mcp-gateway; # Gateway configuration is YAML. `pkgs.formats.yaml` builds it via # structured attrs, which also drops null-valued attributes, so unset # optional fields simply disappear from the rendered file. yaml = pkgs.formats.yaml { }; # The gateway passes `${env.VAR}` placeholders VERBATIM to every backend # subprocess -- it does NOT resolve them. Some MCP servers resolve the # placeholder themselves; others (yarr, slskd-mcp, nextcloud-mcp-server, # gitea-mcp, ovhcloud-mcp, pinchflat-mcp) read the literal `${env.VAR}` # string as the credential and fail auth. This wrapper scans every env var # and resolves any literal `${env.X}` placeholder from the rendered gateway # environment file before exec'ing the real binary. resolveEnv = pkgs.writeShellScript "mcp-gateway-resolve-env" '' env_file=${lib.escapeShellArg (if cfg.environmentFile == null then "" else cfg.environmentFile)} for line in $(env | cut -d= -f1); do val="$(printenv "$line")" if [[ "$val" == \$\{env.* ]]; then name="''${val#\$\{env.}" name="''${name%\}}" export "$line"="$(grep "^$name=" "$env_file" | head -1 | cut -d= -f2-)" fi done exec "$@" ''; apiKeyType = types.submodule ( { name, ... }: { options = { key = mkOption { type = types.str; description = '' API key value. Prefer an indirection such as `env:VAR_NAME`, `keychain:NAME`, or `oauth:PROVIDER` instead of a literal secret. ''; }; name = mkOption { type = types.str; default = name; description = "Human-readable client name. Defaults to the attribute name."; }; rateLimit = mkOption { type = types.ints.unsigned; default = 0; description = "Requests per minute. 0 means unlimited."; }; backends = mkOption { type = types.listOf types.str; default = [ ]; description = "Backends this key may use. Empty means every backend."; }; allowedTools = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "If set, only these tools (glob patterns allowed) are accessible."; }; deniedTools = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "Tools (glob patterns allowed) blocked for this key."; }; admin = mkOption { type = types.bool; default = false; description = "Whether this key may use admin-only management tools."; }; }; } ); routingProfileType = types.submodule { options = { description = mkOption { type = types.str; default = ""; description = "Human-readable profile description."; }; allowBackends = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "If set, only these backends are reachable in this profile."; }; denyBackends = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "Backends denied in this profile."; }; allowTools = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "If set, only these tools (glob patterns allowed) are reachable."; }; denyTools = mkOption { type = types.nullOr (types.listOf types.str); default = null; description = "Tools (glob patterns allowed) denied in this profile."; }; }; }; surfacedToolType = types.submodule { options = { server = mkOption { type = types.str; description = "Backend name."; }; tool = mkOption { type = types.str; description = "Tool name to surface directly in tools/list."; }; }; }; backendType = types.submodule ( { name, ... }: { options = { description = mkOption { type = types.str; default = ""; description = "Human-readable backend description."; }; enabled = mkOption { type = types.bool; default = true; description = "Whether the backend is enabled."; }; command = mkOption { type = types.nullOr types.str; default = null; description = '' Shell command for a stdio backend. Mutually exclusive with `httpUrl`. Set `resolveEnv = true` if the backend reads literal `''${env.VAR}` placeholders. ''; }; cwd = mkOption { type = types.nullOr types.str; default = null; description = "Working directory for a stdio backend."; }; httpUrl = mkOption { type = types.nullOr types.str; default = null; description = "URL for an HTTP backend. Mutually exclusive with `command`."; }; streamableHttp = mkOption { type = types.bool; default = true; description = "Use Streamable HTTP instead of the SSE handshake for `httpUrl`."; }; timeout = mkOption { type = types.str; default = "30s"; description = "Per-backend request timeout, in humantime syntax."; }; env = mkOption { type = types.attrsOf types.str; default = { }; description = "Environment variables passed to the backend process."; }; headers = mkOption { type = types.attrsOf types.str; default = { }; description = "HTTP headers for an HTTP backend."; }; passthrough = mkOption { type = types.bool; default = false; description = '' Skip input sanitization on this backend's tools/call requests. Only use for fully trusted internal backends. ''; }; allowCleartextCredentials = mkOption { type = types.bool; default = false; description = '' Permit credentials over cleartext HTTP to a non-loopback host. This is a security exception, not a convenience toggle. ''; }; resolveEnv = mkOption { type = types.bool; default = false; description = '' Prefix `command` with the module's env-placeholder resolver. This makes backends that read literal `''${env.VAR}` strings resolve them from `environmentFile` before starting. ''; }; extraConfig = mkOption { type = types.attrsOf types.anything; default = { }; description = "Escape hatch merged into this backend's YAML map."; }; }; } ); # Render an environment-like value the way gateway.yaml expects it: # booleans as true/false, lists as comma-separated strings. renderEnvValue = value: if builtins.isBool value then (if value then "true" else "false") else if builtins.isList value then lib.concatStringsSep "," value else toString value; apiSettings = { enabled = cfg.auth.enable; bearer_token = cfg.auth.bearerToken; public_paths = cfg.auth.publicPaths; single_user = cfg.auth.singleUser; api_keys = lib.mapAttrsToList (_name: key: { inherit (key) key name backends admin; rate_limit = key.rateLimit; allowed_tools = key.allowedTools; denied_tools = key.deniedTools; }) cfg.auth.apiKeys; }; metaMcpSettings = { enabled = cfg.metaMcp.enable; cache_tools = cfg.metaMcp.cacheTools; cache_ttl = cfg.metaMcp.cacheTtl; prompts_resources_fetch_timeout = cfg.metaMcp.promptsResourcesFetchTimeout; warm_start = cfg.metaMcp.warmStart; surfaced_tools = cfg.metaMcp.surfacedTools; projection_mode = cfg.metaMcp.projectionMode; exposed_meta_tools = cfg.metaMcp.exposedMetaTools; expose_stats_tool = cfg.metaMcp.exposeStatsTool; }; routingSettings = lib.mapAttrs (_name: profile: { inherit (profile) description; allow_backends = profile.allowBackends; deny_backends = profile.denyBackends; allow_tools = profile.allowTools; deny_tools = profile.denyTools; }) cfg.routingProfiles; backendSettings = lib.mapAttrs ( name: backend: { inherit (backend) description enabled timeout env headers; } // lib.optionalAttrs (backend.command != null) { command = if backend.resolveEnv then "${resolveEnv} ${backend.command}" else backend.command; } // lib.optionalAttrs (backend.cwd != null) { inherit (backend) cwd; } // lib.optionalAttrs (backend.httpUrl != null) { http_url = backend.httpUrl; streamable_http = backend.streamableHttp; } // lib.optionalAttrs backend.passthrough { passthrough = true; } // lib.optionalAttrs backend.allowCleartextCredentials { allow_cleartext_credentials = true; } // backend.extraConfig ) cfg.backends; settings = { server = { inherit (cfg.server) host port; modern_protocol = cfg.server.modernProtocol; request_timeout = cfg.server.requestTimeout; shutdown_timeout = cfg.server.shutdownTimeout; max_body_size = cfg.server.maxBodySize; public_url = cfg.server.publicUrl; ws_port = cfg.server.wsPort; allow_unauthenticated_network_bind = cfg.server.allowUnauthenticatedNetworkBind; }; auth = apiSettings; meta_mcp = metaMcpSettings; default_routing_profile = cfg.defaultRoutingProfile; routing_profiles = routingSettings; backends = backendSettings; } // lib.optionalAttrs (cfg.envFiles != [ ]) { env_files = cfg.envFiles; } // cfg.extraConfig; referencedBackends = lib.concatMap (key: key.backends) (lib.attrValues cfg.auth.apiKeys) ++ lib.concatMap ( profile: lib.optionals (profile.allowBackends != null) profile.allowBackends ++ lib.optionals (profile.denyBackends != null) profile.denyBackends ) (lib.attrValues cfg.routingProfiles) ++ cfg.metaMcp.warmStart ++ map (tool: tool.server) cfg.metaMcp.surfacedTools; missingBackends = lib.filter (name: !(builtins.hasAttr name cfg.backends)) referencedBackends; invalidBackends = lib.filter ( backend: (backend.command == null) == (backend.httpUrl == null) ) (lib.attrValues cfg.backends); in { options.services.mcp-gateway = { enable = mkEnableOption "the local MCP gateway"; package = mkOption { type = types.package; default = pkgs.mcp-gateway; description = "MCP gateway package."; }; environmentFile = mkOption { type = types.nullOr types.str; default = null; description = '' Optional systemd EnvironmentFile containing backend credentials and `env:` API keys. Also the file the `resolveEnv` wrapper consults. ''; }; environment = mkOption { type = types.attrsOf types.str; default = { }; description = "Extra environment variables for the gateway process."; }; envFiles = mkOption { type = types.listOf types.str; default = [ ]; description = "Extra env files loaded by the gateway itself (gateway `env_files`)."; }; server = { host = mkOption { type = types.str; default = "127.0.0.1"; description = '' Address to bind to. Use this host's Tailscale address where remote clients must reach it. Never use 0.0.0.0 on a host with a public interface. ''; }; port = mkOption { type = types.port; default = 39401; description = "TCP port to listen on."; }; modernProtocol = mkOption { type = types.bool; default = true; description = "Serve the newest MCP protocol revision and negotiate down."; }; requestTimeout = mkOption { type = types.str; default = "30s"; description = "Request timeout in humantime syntax."; }; shutdownTimeout = mkOption { type = types.str; default = "30s"; description = "Graceful shutdown timeout in humantime syntax."; }; maxBodySize = mkOption { type = types.ints.positive; default = 10485760; description = "Maximum request body size in bytes."; }; publicUrl = mkOption { type = types.nullOr types.str; default = null; description = "Externally reachable base URL when running behind a proxy."; }; wsPort = mkOption { type = types.nullOr types.port; default = null; description = "Optional WebSocket listener port."; }; allowUnauthenticatedNetworkBind = mkOption { type = types.bool; default = false; description = '' Permit an unauthenticated bind on a non-loopback address. Only set when authentication terminates in front of the gateway. ''; }; }; auth = { enable = mkOption { type = types.bool; default = true; description = "Enable API-key authentication."; }; bearerToken = mkOption { type = types.nullOr types.str; default = null; description = "Optional single bearer token in addition to API keys."; }; publicPaths = mkOption { type = types.listOf types.str; default = [ ]; description = "Paths allowed without authentication."; }; singleUser = mkOption { type = types.bool; default = false; description = "Treat all callers as a single user."; }; apiKeys = mkOption { type = types.attrsOf apiKeyType; default = { }; description = "API keys keyed by client name."; }; }; metaMcp = { enable = mkOption { type = types.bool; default = true; description = "Expose the compact meta-MCP surface."; }; cacheTools = mkOption { type = types.bool; default = true; description = "Cache backend tool lists."; }; cacheTtl = mkOption { type = types.str; default = "5m"; description = "Tool cache TTL in humantime syntax."; }; promptsResourcesFetchTimeout = mkOption { type = types.str; default = "10s"; description = "Per-backend bound for prompts/resources aggregation."; }; warmStart = mkOption { type = types.listOf types.str; default = [ ]; description = "Backends to warm-start on gateway startup."; }; surfacedTools = mkOption { type = types.listOf surfacedToolType; default = [ ]; description = "Backend tools to expose directly alongside the meta-tools."; }; projectionMode = mkOption { type = types.enum [ "off" "on" "experimental" ]; default = "off"; description = "Canonical response-projection rollout mode."; }; exposedMetaTools = mkOption { type = types.listOf types.str; default = [ ]; description = "If non-empty, only these meta-tools are exposed."; }; exposeStatsTool = mkOption { type = types.bool; default = false; description = "List `gateway_get_stats` in tools/list."; }; }; defaultRoutingProfile = mkOption { type = types.str; default = "default"; description = "Routing profile used when a session does not select one."; }; routingProfiles = mkOption { type = types.attrsOf routingProfileType; default = { }; description = "Session-scoped tool/backend access profiles."; }; backends = mkOption { type = types.attrsOf backendType; default = { }; description = "MCP backends keyed by backend name."; }; extraConfig = mkOption { type = types.attrsOf types.anything; default = { }; description = "Escape hatch merged over the generated top-level YAML."; }; url = mkOption { type = types.str; readOnly = true; default = "http://${cfg.server.host}:${toString cfg.server.port}/mcp"; description = "MCP endpoint URL derived from `server.host` and `server.port`."; }; }; config = mkIf cfg.enable { assertions = [ { assertion = missingBackends == [ ]; message = "services.mcp-gateway references unknown backends: ${lib.concatStringsSep ", " missingBackends}"; } { assertion = invalidBackends == [ ]; message = "services.mcp-gateway backends must set exactly one of `command` or `httpUrl`"; } { assertion = cfg.environmentFile != null || !(lib.any ( backend: backend.resolveEnv ) (lib.attrValues cfg.backends)); message = "services.mcp-gateway: `resolveEnv = true` requires `environmentFile`"; } ]; xdg.configFile."mcp-gateway/gateway.yaml".source = yaml.generate "gateway.yaml" settings; systemd.user.services.mcp-gateway = { Unit = { Description = "Local MCP gateway"; After = [ "network.target" ]; }; Service = { ExecStart = "${cfg.package}/bin/mcp-gateway serve --config ${config.xdg.configHome}/mcp-gateway/gateway.yaml"; EnvironmentFile = lib.optional (cfg.environmentFile != null) cfg.environmentFile; Environment = [ "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "HOME=%h" ] ++ lib.mapAttrsToList (name: value: "${name}=${value}") cfg.environment; Restart = "always"; RestartSec = 5; }; Install.WantedBy = [ "default.target" ]; }; }; }