mcp: extract helper functions into lib/mcp.nix

Extract MCP-related helper functions (renderEnv, mkEnvFilesWrapper,
wrapEnvFilesCommand, addType, transformMcpServer) into a new shared
library module at modules/lib/mcp.nix.
- Add lib.hm.mcp for sharing transformMcpServer logic across consumers
- Add enabled/disabled conflict detection assertion
- Add tests for new library functions
This commit is contained in:
Malik 2026-05-29 20:10:36 +02:00 committed by Austin Horstman
parent c30f177760
commit 6a66db1360
10 changed files with 451 additions and 24 deletions

View file

@ -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; };

210
modules/lib/mcp.nix Normal file
View file

@ -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;
}

View file

@ -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;
};
};
};
}

View file

@ -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

View file

@ -0,0 +1,3 @@
{
lib-mcp-wrapper = ./wrapper.nix;
}

45
tests/lib/mcp/wrapper.nix Normal file
View file

@ -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'
'';
}

View file

@ -1,4 +1,5 @@
{
mcp-servers = ./servers.nix;
mcp-empty-servers = ./empty-servers.nix;
mcp-enabled-disabled-conflict = ./enabled-disabled-conflict.nix;
}

View file

@ -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.
''
];
}

View file

@ -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"
}
}
}

View file

@ -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}";
};