diff --git a/modules/lib/default.nix b/modules/lib/default.nix index 63639fdb9..5161dcfac 100644 --- a/modules/lib/default.nix +++ b/modules/lib/default.nix @@ -12,6 +12,7 @@ rec { git = import ./git.nix { inherit lib; }; gvariant = import ./gvariant.nix { inherit lib; }; maintainers = import ./maintainers.nix; + mcp = import ./mcp.nix { inherit lib; }; strings = import ./strings.nix { inherit lib; }; types = import ./types.nix { inherit gvariant lib; }; diff --git a/modules/lib/mcp.nix b/modules/lib/mcp.nix new file mode 100644 index 000000000..0c43f7af8 --- /dev/null +++ b/modules/lib/mcp.nix @@ -0,0 +1,210 @@ +{ lib }: + +let + /* + Checks whether a value is a file reference submodule. + + A file reference has the shape `{ file = "/path/to/file"; }` and is used + to indicate that an environment variable's value should be read from a file + at runtime (e.g. via sops-nix or systemd credentials). + + Type: isFileRef :: Value -> Bool + */ + isFileRef = value: lib.isAttrs value && value ? file; + + /* + Filters an `env` attrset to contain only literal (non-file-ref) values. + + This is useful when a transform needs to produce two separate outputs: + one with the literal values and one with the file paths (for wrapping). + + Type: literalEnv :: AttrSet -> AttrSet + + Example: + literalEnv { API_KEY = "secret"; TOKEN.file = "/run/secrets/token"; } + => { API_KEY = "secret"; } + */ + literalEnv = env: lib.filterAttrs (_: value: !isFileRef value) env; + + /* + Extracts only the file paths from file-ref entries in an `env` attrset. + + Returns a flat `attrsOf str` mapping variable names to their file paths. + Non-file-ref entries are excluded. + + Type: fileRefEnv :: AttrSet -> AttrSet + + Example: + fileRefEnv { API_KEY = "secret"; TOKEN.file = "/run/secrets/token"; } + => { TOKEN = "/run/secrets/token"; } + */ + fileRefEnv = env: lib.mapAttrs (_: value: value.file) (lib.filterAttrs (_: isFileRef) env); + + /* + Render an `env` attrset (literals + file refs) as a flat `attrsOf str` + by mapping file refs through `mkFileRef path` and leaving literals untouched. + + Type: renderEnv :: (String -> String) -> AttrSet -> AttrSet + + Example: + renderEnv (path: "{file:${path}}") { + API_KEY = "literal"; + SESSION_TOKEN.file = "/run/secrets/token"; + } + => { API_KEY = "literal"; SESSION_TOKEN = "{file:/run/secrets/token}"; } + */ + renderEnv = + mkFileRef: env: + lib.mapAttrs (_: value: if isFileRef value then mkFileRef value.file else value) env; + + /* + Wrap a local MCP server command in a shell script that reads file-backed + env values into the environment before executing the original process. + Compatible with file-based secret managers such as sops-nix or systemd + credentials. Reads file refs from `server.env`. + + Type: mkEnvFilesWrapper :: { pkgs, name, server } -> Derivation + */ + mkEnvFilesWrapper = + { + pkgs, + name, + server, + }: + let + files = fileRefEnv (server.env or { }); + in + pkgs.writeShellScript "mcp-${name}-wrapper" '' + ${lib.concatStrings ( + lib.mapAttrsToList (var: path: '' + if ${var}=$(cat ${lib.escapeShellArg path}); then + export ${var} + else + printf '[${name} wrapper ] Failed to read env var %s from %s\n' \ + ${lib.escapeShellArg var} \ + ${lib.escapeShellArg path} >&2 + fi + '') files + )} + exec ${lib.escapeShellArgs ([ server.command ] ++ (server.args or [ ]))} + ''; + + /* + extraTransform factory: when the server has file refs in `env` and a + local `command`, replace `command` with a wrapper script that reads + those files at startup, reset `args` to `[ ]`, and drop the file refs + from `env` (leaving only literal entries). + + When there is nothing to wrap, returns the server unchanged. + + Type: wrapEnvFilesCommand :: { pkgs, name } -> AttrSet -> AttrSet + */ + wrapEnvFilesCommand = + { pkgs, name }: + server: + let + files = fileRefEnv (server.env or { }); + needsWrapping = files != { }; + in + server + // lib.optionalAttrs needsWrapping { + command = mkEnvFilesWrapper { inherit pkgs name server; }; + args = [ ]; + env = literalEnv (server.env or { }); + }; + + /* + Resolve the effective `enabled` state from a server config that may + have either an `enabled` or a `disabled` field (but not both). + + Returns `null` if neither field is present, allowing callers to omit + the attribute from the output via the empty-value filter. + + Type: resolveEnabled :: AttrSet -> Null | Bool + */ + resolveEnabled = + server: + let + hasEnabled = server ? enabled && server.enabled != null; + hasDisabled = server ? disabled && server.disabled != null; + in + if hasEnabled then + server.enabled + else if hasDisabled then + !server.disabled + else + null; + + /* + extraTransform that adds `type = "stdio" | "http"` based on whether the + server has a `url`. + + Type: addType :: AttrSet -> AttrSet + */ + addType = + server: + if server ? type then + server + else + server + // { + type = if server ? url && server.url != null then "http" else "stdio"; + }; +in +{ + inherit + renderEnv + mkEnvFilesWrapper + wrapEnvFilesCommand + addType + ; + + /* + Normalise an MCP server attribute set for consumption by a client + program. Performs only universal steps: + + 1. Resolve `enabled` from optional `enabled`/`disabled` fields. + 2. Apply each function in `extraTransforms` to the server attrs, in + order. Transforms run before `mkFileRef`, `exclude` and the + empty-value filter, so a transform can still read attrs that will + be excluded and operate on the raw env shape (with file refs). + 3. Render any remaining file refs in `env` through `mkFileRef path`. + Callers that consume file refs themselves (e.g. via + `wrapEnvFilesCommand`) will have stripped them in an + extraTransform, so `mkFileRef` is a no-op for them. + 4. Remove `disabled` and any keys listed in `exclude`. + 5. Filter `null`, `[]`, and `{}` values. + + Type: transformMcpServer :: + { server, exclude?, extraTransforms?, mkFileRef? } + -> AttrSet + */ + transformMcpServer = + { + server, + exclude ? [ ], + extraTransforms ? [ ], + mkFileRef ? (path: "{file:${path}}"), + }: + let + enabled = resolveEnabled server; + normalised = server // { + inherit enabled; + url = if (server.url or null) != null then server.url else server.serverUrl or null; + }; + transformed = lib.foldl' (acc: transform: transform acc) normalised extraTransforms; + withRenderedEnv = + transformed + // lib.optionalAttrs (transformed ? env) { + env = renderEnv mkFileRef transformed.env; + }; + cleaned = removeAttrs withRenderedEnv ( + [ + "disabled" + "serverUrl" + ] + ++ exclude + ); + in + lib.filterAttrs (_: value: value != null && value != [ ] && value != { }) cleaned; +} diff --git a/modules/programs/mcp.nix b/modules/programs/mcp.nix index 7ebd8c60f..86fcacd47 100644 --- a/modules/programs/mcp.nix +++ b/modules/programs/mcp.nix @@ -13,52 +13,193 @@ let ; cfg = config.programs.mcp; - jsonFormat = pkgs.formats.json { }; + + serverModule = lib.types.submodule { + freeformType = jsonFormat.type; + + options = { + command = mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Executable for a local (stdio) MCP server. + Mutually exclusive with {option}`url`. + ''; + example = "npx"; + }; + + args = mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Arguments passed to {option}`command`. + Only valid for local servers. + ''; + example = [ + "-y" + "@modelcontextprotocol/server-everything" + ]; + }; + + env = mkOption { + type = lib.types.attrsOf ( + lib.types.oneOf [ + lib.types.str + (lib.types.submodule { + options = { + file = mkOption { + type = lib.types.str; + description = '' + Path to a file whose content is read at startup. + Compatible with file-based secret managers such as sops-nix + or systemd credentials. + ''; + }; + }; + }) + ] + ); + default = { }; + description = '' + Environment variables set when spawning the MCP server. + Each value is either a literal string or a file reference + (`{ file = "/path"; }`). + + File references behave differently depending on the consumer: + - Clients that understand the `{file:…}` syntax (e.g. opencode) + receive the path as a substitution token. + - Other clients receive a generated wrapper script that reads + the secret at startup instead. + + Only valid for local servers. + ''; + example = literalExpression '' + { + API_BASE_URL = "https://api.example.com"; + FORGEJO_ACCESS_TOKEN.file = "/run/secrets/forgejo_token"; + } + ''; + }; + + url = mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + HTTP(S) endpoint for a remote (SSE/HTTP) MCP server. + Mutually exclusive with {option}`command`. + ''; + example = "https://mcp.context7.com/mcp"; + }; + + headers = mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = '' + HTTP headers for requests to a remote MCP server. + Only valid for remote servers. + ''; + example = literalExpression '' + { MY_API_KEY = "{env:MY_API_KEY}"; } + ''; + }; + + enabled = mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = '' + Whether this MCP server is enabled. + + If this option and `disabled` are both set, they must satisfy + `enabled == !disabled`. + ''; + }; + }; + }; in { - meta.maintainers = with lib.maintainers; [ delafthi ]; + meta.maintainers = with lib.maintainers; [ + delafthi + malik + ]; options.programs.mcp = { enable = mkEnableOption "mcp"; servers = mkOption { - inherit (jsonFormat) type; + type = lib.types.attrsOf serverModule; default = { }; example = literalExpression '' { everything = { command = "npx"; - args = [ - "-y" - "@modelcontextprotocol/server-everything" - ]; + args = [ "-y" "@modelcontextprotocol/server-everything" ]; }; context7 = { url = "https://mcp.context7.com/mcp"; - headers = { - CONTEXT7_API_KEY = "{env:CONTEXT7_API_KEY}"; - }; + headers.CONTEXT7_API_KEY = "{env:CONTEXT7_API_KEY}"; + }; + codeberg = { + command = "/path/to/forgejo-mcp"; + args = [ "transport" "stdio" "--url" "https://codeberg.org" ]; + env.FORGEJO_ACCESS_TOKEN.file = "/run/secrets/codeberg_token"; }; } ''; description = '' - MCP server configurations written to - {file}`XDG_CONFIG_HOME/mcp/mcp.json` + MCP server configurations written to {file}`$XDG_CONFIG_HOME/mcp/mcp.json`. + + Each server is either a local (stdio) server via {option}`command` + or a remote (HTTP/SSE) server via {option}`url`. + + Besides declared options, arbitrary MCP fields are allowed. ''; }; }; config = mkIf cfg.enable { - xdg.configFile = mkIf (cfg.servers != { }) ( - let - mcp-config = { - mcpServers = cfg.servers; - }; - in - { - "mcp/mcp.json".source = jsonFormat.generate "mcp.json" mcp-config; - } + assertions = lib.concatLists ( + lib.mapAttrsToList (name: server: [ + { + assertion = (server.command != null) != (server.url != null); + message = '' + programs.mcp.servers.${name}: exactly one of `command` or `url` must be set. + ''; + } + { + assertion = server.url == null || (server.args == [ ] && server.env == { }); + message = '' + programs.mcp.servers.${name}: `args` and `env` are only valid for local servers (`command`). + ''; + } + { + assertion = server.headers == { } || server.url != null; + message = '' + programs.mcp.servers.${name}: `headers` is only valid for remote servers (`url`). + ''; + } + { + assertion = + !(server.enabled != null && server ? disabled && server.disabled != null) + || server.enabled != server.disabled; + message = '' + programs.mcp.servers.${name}: `enabled` and `disabled` are set to incompatible values. + ''; + } + ]) cfg.servers ); + + xdg.configFile = mkIf (cfg.servers != { }) { + "mcp/mcp.json".source = jsonFormat.generate "mcp.json" { + mcpServers = lib.mapAttrs ( + _: server: + lib.hm.mcp.transformMcpServer { + inherit server; + extraTransforms = [ lib.hm.mcp.addType ]; + exclude = [ "serverUrl" ]; + } + ) cfg.servers; + }; + }; }; } diff --git a/tests/default.nix b/tests/default.nix index 4856e9e54..0a8569e0f 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -179,6 +179,7 @@ import nmtSrc { # keep-sorted start case=no numeric=yes ./lib/deprecations ./lib/generators + ./lib/mcp ./lib/types ./modules/files ./modules/home-environment diff --git a/tests/lib/mcp/default.nix b/tests/lib/mcp/default.nix new file mode 100644 index 000000000..7b830079b --- /dev/null +++ b/tests/lib/mcp/default.nix @@ -0,0 +1,3 @@ +{ + lib-mcp-wrapper = ./wrapper.nix; +} diff --git a/tests/lib/mcp/wrapper.nix b/tests/lib/mcp/wrapper.nix new file mode 100644 index 000000000..a5ed3bd84 --- /dev/null +++ b/tests/lib/mcp/wrapper.nix @@ -0,0 +1,45 @@ +{ pkgs, lib, ... }: +let + fakeSecret = pkgs.writeText "fake-npm-token" "supersecret"; + + wrapper = lib.hm.mcp.mkEnvFilesWrapper { + inherit pkgs; + name = "test-server"; + server = { + command = "npx"; + args = [ + "-y" + "@modelcontextprotocol/server-everything" + ]; + env.NPM_TOKEN.file = "${fakeSecret}"; + }; + }; + + wrapperNoArgs = lib.hm.mcp.mkEnvFilesWrapper { + inherit pkgs; + name = "test-server-noargs"; + server = { + command = "npx"; + env.NPM_TOKEN.file = "${fakeSecret}"; + }; + }; +in +{ + home.file."mcp-wrapper-under-test" = { + source = wrapper; + }; + home.file."mcp-wrapper-noargs-under-test" = { + source = wrapperNoArgs; + }; + + nmt.script = '' + assertFileContains home-files/mcp-wrapper-under-test \ + 'if NPM_TOKEN=$(cat ${fakeSecret}); then' + assertFileContains home-files/mcp-wrapper-under-test \ + 'export NPM_TOKEN' + assertFileContains home-files/mcp-wrapper-under-test \ + 'exec npx -y @modelcontextprotocol/server-everything' + assertFileContains home-files/mcp-wrapper-noargs-under-test \ + 'exec npx' + ''; +} diff --git a/tests/modules/programs/mcp/default.nix b/tests/modules/programs/mcp/default.nix index 2a10a18b8..a55b7e4a4 100644 --- a/tests/modules/programs/mcp/default.nix +++ b/tests/modules/programs/mcp/default.nix @@ -1,4 +1,5 @@ { mcp-servers = ./servers.nix; mcp-empty-servers = ./empty-servers.nix; + mcp-enabled-disabled-conflict = ./enabled-disabled-conflict.nix; } diff --git a/tests/modules/programs/mcp/enabled-disabled-conflict.nix b/tests/modules/programs/mcp/enabled-disabled-conflict.nix new file mode 100644 index 000000000..05884f619 --- /dev/null +++ b/tests/modules/programs/mcp/enabled-disabled-conflict.nix @@ -0,0 +1,19 @@ +{ + programs.mcp = { + enable = true; + servers = { + invalid = { + command = "echo"; + args = [ "test" ]; + enabled = true; + disabled = true; + }; + }; + }; + + test.asserts.assertions.expected = [ + '' + programs.mcp.servers.invalid: `enabled` and `disabled` are set to incompatible values. + '' + ]; +} diff --git a/tests/modules/programs/mcp/mcp.json b/tests/modules/programs/mcp/mcp.json index 9e2571d5e..ca214c1bd 100644 --- a/tests/modules/programs/mcp/mcp.json +++ b/tests/modules/programs/mcp/mcp.json @@ -4,14 +4,19 @@ "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }, - "serverUrl": "https://mcp.context7.com/mcp" + "type": "http", + "url": "https://mcp.context7.com/mcp" }, "everything": { "args": [ "-y", "@modelcontextprotocol/server-everything" ], - "command": "npx" + "command": "npx", + "env": { + "NPM_TOKEN": "{file:/run/secrets/npm-token}" + }, + "type": "stdio" } } } diff --git a/tests/modules/programs/mcp/servers.nix b/tests/modules/programs/mcp/servers.nix index a63e0f4ae..17d305cb1 100644 --- a/tests/modules/programs/mcp/servers.nix +++ b/tests/modules/programs/mcp/servers.nix @@ -8,9 +8,10 @@ "-y" "@modelcontextprotocol/server-everything" ]; + env.NPM_TOKEN.file = "/run/secrets/npm-token"; }; context7 = { - serverUrl = "https://mcp.context7.com/mcp"; + url = "https://mcp.context7.com/mcp"; headers = { CONTEXT7_API_KEY = "{env:CONTEXT7_API_KEY}"; };