From e9946694e6ca9f10aa7f3f160b1991a7a37257e6 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Bueso de Barrio Date: Thu, 22 Oct 2026 17:12:12 +0200 Subject: [PATCH] msg --- AGENTS.md | 8 + README.md | 18 ++ docs/mcp-gateway.md | 62 ++++ docs/ssh-mcp.md | 77 +++++ flake.nix | 14 + modules/mcp-gateway.nix | 606 ++++++++++++++++++++++++++++++++++++++++ modules/ssh-mcp.nix | 521 ++++++++++++++++++++++++++++++++++ 7 files changed, 1306 insertions(+) create mode 100644 docs/mcp-gateway.md create mode 100644 docs/ssh-mcp.md create mode 100644 modules/mcp-gateway.nix create mode 100644 modules/ssh-mcp.nix diff --git a/AGENTS.md b/AGENTS.md index cdc6582..1b0388d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,14 @@ packages/ overlays/ └── default.nix # Nix overlay that exports all MCP packages +modules/ +├── mcp-gateway.nix # Home Manager module: services.mcp-gateway +└── ssh-mcp.nix # Home Manager module: services.ssh-mcp + +docs/ +├── mcp-gateway.md # Gateway module usage +└── ssh-mcp.md # ssh-mcp module usage + flake.nix # Main flake definition flake.lock # Locked dependency versions ``` diff --git a/README.md b/README.md index bc4b889..3c61240 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,24 @@ This repository provides Nix derivations for MCP (Model Context Protocol) server | slskd-mcp | slskd (Soulseek) search, download, peer browsing, private messages | Python | | mcp-score | AI-driven music score generation and MuseScore live control | Python | +## Home Manager Modules + +The flake also exports reusable Home Manager modules: + +```nix +home-manager.sharedModules = [ + mcp-servers.homeManagerModules.mcp-gateway + mcp-servers.homeManagerModules.ssh-mcp +]; +``` + +- `services.mcp-gateway` renders `gateway.yaml` from Nix options. +- `services.ssh-mcp.instances.` renders per-instance `hosts.toml` and + `known_hosts` files and exposes a `gatewayBackend` helper. + +See [docs/mcp-gateway.md](docs/mcp-gateway.md) and +[docs/ssh-mcp.md](docs/ssh-mcp.md). + ## Usage ### Using the Overlay (Recommended) diff --git a/docs/mcp-gateway.md b/docs/mcp-gateway.md new file mode 100644 index 0000000..d788975 --- /dev/null +++ b/docs/mcp-gateway.md @@ -0,0 +1,62 @@ +# MCP Gateway Home Manager Module + +`homeManagerModules.mcp-gateway` exposes `services.mcp-gateway` and renders +`~/.config/mcp-gateway/gateway.yaml` from Nix options. + +## Import + +```nix +home-manager.sharedModules = [ + mcp-servers.homeManagerModules.mcp-gateway +]; +``` + +## Minimal example + +```nix +services.mcp-gateway = { + enable = true; + server = { + host = "100.64.0.19"; + port = 39401; + }; + + environmentFile = "/run/secrets/rendered/mcp_gateway_env"; + + auth.apiKeys.dev = { + key = "env:DEV_GATEWAY_KEY"; + backends = [ "nixos" "fetch" ]; + }; + + backends = { + nixos.command = "${pkgs.mcp-nixos}/bin/mcp-nixos"; + fetch.command = "${pkgs.mcp-server-fetch}/bin/mcp-server-fetch"; + }; +}; +``` + +`services.mcp-gateway.url` is read-only and resolves to +`http://:/mcp`, for use by MCP clients. + +## Backends + +Backends are keyed by name and support: + +- `command` for stdio backends, or `httpUrl` for HTTP backends +- `cwd`, `timeout`, `env`, `headers` +- `passthrough`, `allowCleartextCredentials` +- `resolveEnv = true` to wrap commands that read literal `${env.VAR}` + placeholders +- `extraConfig` for fields not modelled by the module + +## API keys + +API keys are keyed by client name. The key may use the gateway's `env:VAR`, +`keychain:NAME`, or `oauth:PROVIDER` indirection. Avoid literal secrets in the +Nix store. + +## Routing profiles + +`routingProfiles.` supports `allowBackends`, `denyBackends`, +`allowTools`, and `denyTools`. Set `defaultRoutingProfile` to the profile used +when a session has not selected one. diff --git a/docs/ssh-mcp.md b/docs/ssh-mcp.md new file mode 100644 index 0000000..76e47d2 --- /dev/null +++ b/docs/ssh-mcp.md @@ -0,0 +1,77 @@ +# ssh-mcp Home Manager Module + +`homeManagerModules.ssh-mcp` exposes `services.ssh-mcp.instances.`. +Each instance renders its own SSH host policy and known_hosts file under +`~/.config/ssh-mcp//`. + +## Import + +```nix +home-manager.sharedModules = [ + mcp-servers.homeManagerModules.ssh-mcp +]; +``` + +## Example + +```nix +services.ssh-mcp.instances.admin = { + enable = true; + description = "Read-only systemd/journalctl over SSH"; + + defaults = { + user = "ai-agent"; + port = 22; + platform = "linux"; + persistentSession = false; + auth = { + method = "key"; + key = "/run/secrets/ssh_ai_agent_key"; + }; + }; + + hosts = { + perlis.hostname = "100.64.0.4"; + ravel.hostname = "100.64.0.19"; + }; + + knownHosts = { + "100.64.0.4" = "ssh-ed25519 AAAA..."; + "100.64.0.19" = "ssh-ed25519 AAAA..."; + }; + + settings = { + enabledGroups = [ "host" "systemctl" ]; + allowLowAccessTools = false; + allowDangerousTools = false; + allowSudo = false; + }; +}; +``` + +## Gateway integration + +Each instance exposes `gatewayBackend`, a plain attrset shaped like a +`services.mcp-gateway.backends.` entry: + +```nix +services.mcp-gateway.backends = { + ssh-mcp = config.services.ssh-mcp.instances.admin.gatewayBackend; + ssh-mcp-media = config.services.ssh-mcp.instances.media.gatewayBackend; +}; +``` + +Read-only paths are also available: + +- `config.services.ssh-mcp.instances..hostsFile` +- `config.services.ssh-mcp.instances..knownHostsFile` + +## Notes + +- The default `package` is `pkgs.python-ssh-mcp`, so apply + `mcp-servers.overlays.default` on the host or set `package` explicitly. +- `SSH_ENABLED_GROUPS` is server-wide, not per-host. Run separate instances + when different catalogs are needed. +- Tier flags (`allowLowAccessTools`, `allowDangerousTools`, `allowSudo`) are + hard security gates. Group filters only change what is advertised. +- Empty path or command allowlists mean denied, not unrestricted. diff --git a/flake.nix b/flake.nix index 2ad137f..8cb4f1b 100644 --- a/flake.nix +++ b/flake.nix @@ -24,9 +24,23 @@ "aarch64-darwin" ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + + mcpGatewayModule = import ./modules/mcp-gateway.nix; + sshMcpModule = import ./modules/ssh-mcp.nix; in { overlays.default = import ./overlays/default.nix; + + homeManagerModules = { + mcp-gateway = mcpGatewayModule; + ssh-mcp = sshMcpModule; + default = { + imports = [ + mcpGatewayModule + sshMcpModule + ]; + }; + }; } // { packages = forAllSystems ( diff --git a/modules/mcp-gateway.nix b/modules/mcp-gateway.nix new file mode 100644 index 0000000..160c205 --- /dev/null +++ b/modules/mcp-gateway.nix @@ -0,0 +1,606 @@ +{ + 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" ]; + }; + }; +} diff --git a/modules/ssh-mcp.nix b/modules/ssh-mcp.nix new file mode 100644 index 0000000..bf96603 --- /dev/null +++ b/modules/ssh-mcp.nix @@ -0,0 +1,521 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkEnableOption + mkOption + mkIf + types + ; + + cfg = config.services.ssh-mcp; + hostConfig = config; + + toml = pkgs.formats.toml { }; + + 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; + + authType = types.submodule { + options = { + method = mkOption { + type = types.enum [ "agent" "key" "password" ]; + default = "agent"; + description = "SSH authentication method."; + }; + + key = mkOption { + type = types.nullOr types.str; + default = null; + description = "Private key path for `method = \"key\"`."; + }; + + identityFingerprint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Pin one agent identity by SHA-256 fingerprint."; + }; + + identitiesOnly = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Offer only the pinned identity to the server."; + }; + + identityAgent = mkOption { + type = types.nullOr types.str; + default = null; + description = "SSH agent socket to use instead of SSH_AUTH_SOCK."; + }; + + passphraseCmd = mkOption { + type = types.nullOr types.str; + default = null; + description = "Local command that prints the key passphrase."; + }; + + extraConfig = mkOption { + type = types.attrsOf types.anything; + default = { }; + description = "Escape hatch merged into this auth table."; + }; + }; + }; + + defaultsType = types.submodule { + options = { + user = mkOption { + type = types.str; + default = "root"; + description = "Default SSH user."; + }; + + port = mkOption { + type = types.port; + default = 22; + description = "Default SSH port."; + }; + + platform = mkOption { + type = types.str; + default = "linux"; + description = "Default target platform."; + }; + + persistentSession = mkOption { + type = types.bool; + default = false; + description = "Allow persistent shell sessions by default."; + }; + + pathAllowlist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Default path roots. Empty means no paths are allowed."; + }; + + restrictedPaths = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Default paths carved out of the allowlist."; + }; + + commandAllowlist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Default exec command allowlist."; + }; + + auth = mkOption { + type = authType; + default = { }; + description = "Default SSH authentication policy."; + }; + + extraConfig = mkOption { + type = types.attrsOf types.anything; + default = { }; + description = "Escape hatch merged into the `[defaults]` table."; + }; + }; + }; + + hostType = types.submodule { + options = { + hostname = mkOption { + type = types.str; + description = "Canonical hostname or address used on the wire."; + }; + + user = mkOption { + type = types.nullOr types.str; + default = null; + description = "Per-host SSH user override."; + }; + + port = mkOption { + type = types.nullOr types.port; + default = null; + description = "Per-host SSH port override."; + }; + + platform = mkOption { + type = types.nullOr types.str; + default = null; + description = "Per-host platform override."; + }; + + persistentSession = mkOption { + type = types.nullOr types.bool; + default = null; + description = "Per-host persistent-session override."; + }; + + pathAllowlist = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "Per-host path roots."; + }; + + restrictedPaths = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "Per-host paths carved out of the allowlist."; + }; + + commandAllowlist = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = "Per-host exec command allowlist."; + }; + + auth = mkOption { + type = types.nullOr authType; + default = null; + description = "Per-host authentication policy override."; + }; + + extraConfig = mkOption { + type = types.attrsOf types.anything; + default = { }; + description = "Escape hatch merged into this host's table."; + }; + }; + }; + + settingsType = types.submodule { + options = { + enabledGroups = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Tool groups to expose. Empty exposes all groups."; + }; + + hostsAllowlist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Additional hostnames allowed by the server."; + }; + + hostsBlocklist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Hostnames denied by the server. Deny wins."; + }; + + pathAllowlist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Server-wide path allowlist."; + }; + + restrictedPaths = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Server-wide restricted paths."; + }; + + commandAllowlist = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Server-wide exec command allowlist."; + }; + + allowLowAccessTools = mkOption { + type = types.bool; + default = false; + description = "Enable SFTP-mediated file mutation tools."; + }; + + allowDangerousTools = mkOption { + type = types.bool; + default = false; + description = "Enable arbitrary command execution tools."; + }; + + allowSudo = mkOption { + type = types.bool; + default = false; + description = "Enable sudo-tier tools. Also requires dangerous tools."; + }; + + allowAnyCommand = mkOption { + type = types.bool; + default = false; + description = "Permit arbitrary commands when no command allowlist is set."; + }; + + allowPasswordAuth = mkOption { + type = types.bool; + default = false; + description = "Permit password authentication."; + }; + + pingIncludesNotes = mkOption { + type = types.bool; + default = false; + description = "Include operator notes in host ping results."; + }; + + pingIncludesAgentNotes = mkOption { + type = types.bool; + default = false; + description = "Include agent notes in host ping results."; + }; + + otelEnabled = mkOption { + type = types.bool; + default = false; + description = "Enable OpenTelemetry export."; + }; + + localTransferRoots = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Local paths the server may read for uploads and transfers."; + }; + + dockerCmd = mkOption { + type = types.nullOr types.str; + default = null; + description = "Docker-compatible CLI to use, e.g. `podman`."; + }; + + configFile = mkOption { + type = types.nullOr types.str; + default = null; + description = "Optional OpenSSH config file to fill in unset host fields."; + }; + + extraEnv = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Extra environment variables for the ssh-mcp process."; + }; + }; + }; + + renderAuth = + auth: + { inherit (auth) method; } + // lib.optionalAttrs (auth.key != null) { inherit (auth) key; } + // lib.optionalAttrs (auth.identityFingerprint != null) { + identity_fingerprint = auth.identityFingerprint; + } + // lib.optionalAttrs (auth.identitiesOnly != null) { + identities_only = auth.identitiesOnly; + } + // lib.optionalAttrs (auth.identityAgent != null) { + identity_agent = auth.identityAgent; + } + // lib.optionalAttrs (auth.passphraseCmd != null) { + passphrase_cmd = auth.passphraseCmd; + } + // auth.extraConfig; + + renderDefaults = + defaults: + { + inherit (defaults) user port platform; + persistent_session = defaults.persistentSession; + path_allowlist = defaults.pathAllowlist; + restricted_paths = defaults.restrictedPaths; + command_allowlist = defaults.commandAllowlist; + auth = renderAuth defaults.auth; + } + // defaults.extraConfig; + + renderHost = + host: + { inherit (host) hostname; } + // lib.optionalAttrs (host.user != null) { inherit (host) user; } + // lib.optionalAttrs (host.port != null) { inherit (host) port; } + // lib.optionalAttrs (host.platform != null) { inherit (host) platform; } + // lib.optionalAttrs (host.persistentSession != null) { + persistent_session = host.persistentSession; + } + // lib.optionalAttrs (host.pathAllowlist != null) { + path_allowlist = host.pathAllowlist; + } + // lib.optionalAttrs (host.restrictedPaths != null) { + restricted_paths = host.restrictedPaths; + } + // lib.optionalAttrs (host.commandAllowlist != null) { + command_allowlist = host.commandAllowlist; + } + // lib.optionalAttrs (host.auth != null) { + auth = renderAuth host.auth; + } + // host.extraConfig; + + renderInstanceSettings = + instance: + { + ALLOW_LOW_ACCESS_TOOLS = renderEnvValue instance.settings.allowLowAccessTools; + ALLOW_DANGEROUS_TOOLS = renderEnvValue instance.settings.allowDangerousTools; + ALLOW_SUDO = renderEnvValue instance.settings.allowSudo; + ALLOW_ANY_COMMAND = renderEnvValue instance.settings.allowAnyCommand; + ALLOW_PASSWORD_AUTH = renderEnvValue instance.settings.allowPasswordAuth; + SSH_PING_INCLUDES_NOTES = renderEnvValue instance.settings.pingIncludesNotes; + SSH_PING_INCLUDES_AGENT_NOTES = renderEnvValue instance.settings.pingIncludesAgentNotes; + OTEL_ENABLED = renderEnvValue instance.settings.otelEnabled; + } + // lib.optionalAttrs (instance.settings.enabledGroups != [ ]) { + SSH_ENABLED_GROUPS = renderEnvValue instance.settings.enabledGroups; + } + // lib.optionalAttrs (instance.settings.hostsAllowlist != [ ]) { + SSH_HOSTS_ALLOWLIST = renderEnvValue instance.settings.hostsAllowlist; + } + // lib.optionalAttrs (instance.settings.hostsBlocklist != [ ]) { + SSH_HOSTS_BLOCKLIST = renderEnvValue instance.settings.hostsBlocklist; + } + // lib.optionalAttrs (instance.settings.pathAllowlist != [ ]) { + SSH_PATH_ALLOWLIST = renderEnvValue instance.settings.pathAllowlist; + } + // lib.optionalAttrs (instance.settings.restrictedPaths != [ ]) { + SSH_RESTRICTED_PATHS = renderEnvValue instance.settings.restrictedPaths; + } + // lib.optionalAttrs (instance.settings.commandAllowlist != [ ]) { + SSH_COMMAND_ALLOWLIST = renderEnvValue instance.settings.commandAllowlist; + } + // lib.optionalAttrs (instance.settings.localTransferRoots != [ ]) { + SSH_LOCAL_TRANSFER_ROOTS = renderEnvValue instance.settings.localTransferRoots; + } + // lib.optionalAttrs (instance.settings.dockerCmd != null) { + SSH_DOCKER_CMD = instance.settings.dockerCmd; + } + // lib.optionalAttrs (instance.settings.configFile != null) { + SSH_CONFIG_FILE = instance.settings.configFile; + } + // instance.settings.extraEnv; + + instanceType = types.submodule ( + { name, config, ... }: + { + options = { + enable = mkEnableOption "the ssh-mcp instance ${name}"; + + package = mkOption { + type = types.package; + default = pkgs.python-ssh-mcp; + description = "ssh-mcp package for this instance."; + }; + + description = mkOption { + type = types.str; + default = "ssh-mcp instance ${name}"; + description = "Backend description used by `gatewayBackend`."; + }; + + hostsFileName = mkOption { + type = types.str; + default = "hosts.toml"; + description = "Generated hosts policy filename under `~/.config/ssh-mcp/${name}`."; + }; + + knownHostsFileName = mkOption { + type = types.str; + default = "known_hosts"; + description = "Generated known_hosts filename under `~/.config/ssh-mcp/${name}`."; + }; + + defaults = mkOption { + type = defaultsType; + default = { }; + description = "Default host policy for this instance."; + }; + + hosts = mkOption { + type = types.attrsOf hostType; + default = { }; + description = "Hosts reachable through this instance, keyed by alias."; + }; + + knownHosts = mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Pinned host keys keyed by canonical hostname. Each value is the + key type and public key, e.g. `ssh-ed25519 AAAA...`. + ''; + }; + + settings = mkOption { + type = settingsType; + default = { }; + description = "ssh-mcp server-wide environment settings."; + }; + + hostsFile = mkOption { + type = types.str; + readOnly = true; + default = "${hostConfig.xdg.configHome}/ssh-mcp/${name}/${config.hostsFileName}"; + description = "Runtime path of the generated hosts policy."; + }; + + knownHostsFile = mkOption { + type = types.str; + readOnly = true; + default = "${hostConfig.xdg.configHome}/ssh-mcp/${name}/${config.knownHostsFileName}"; + description = "Runtime path of the generated known_hosts file."; + }; + + gatewayBackend = mkOption { + type = types.attrsOf types.anything; + readOnly = true; + default = { + command = "${config.package}/bin/ssh-mcp"; + inherit (config) description; + env = { + SSH_HOSTS_FILE = config.hostsFile; + SSH_KNOWN_HOSTS = config.knownHostsFile; + } + // renderInstanceSettings config; + }; + description = "Backend attrset ready for `services.mcp-gateway.backends`."; + }; + }; + } + ); +in +{ + options.services.ssh-mcp = { + instances = mkOption { + type = types.attrsOf instanceType; + default = { }; + description = "Independent ssh-mcp server instances."; + }; + }; + + config = { + xdg.configFile = lib.mkMerge ( + lib.mapAttrsToList ( + name: instance: + mkIf instance.enable { + "ssh-mcp/${name}/${instance.hostsFileName}".source = toml.generate "ssh-mcp-${name}-hosts.toml" { + defaults = renderDefaults instance.defaults; + hosts = lib.mapAttrs (_host: host: renderHost host) instance.hosts; + }; + + "ssh-mcp/${name}/${instance.knownHostsFileName}".text = + lib.optionalString (instance.knownHosts != { }) ( + lib.concatStringsSep "\n" ( + lib.mapAttrsToList (host: key: "${host} ${key}") instance.knownHosts + ) + + "\n" + ); + } + ) cfg.instances + ); + }; +}