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:
parent
c30f177760
commit
6a66db1360
10 changed files with 451 additions and 24 deletions
|
|
@ -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
210
modules/lib/mcp.nix
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
3
tests/lib/mcp/default.nix
Normal file
3
tests/lib/mcp/default.nix
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
lib-mcp-wrapper = ./wrapper.nix;
|
||||
}
|
||||
45
tests/lib/mcp/wrapper.nix
Normal file
45
tests/lib/mcp/wrapper.nix
Normal 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'
|
||||
'';
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
mcp-servers = ./servers.nix;
|
||||
mcp-empty-servers = ./empty-servers.nix;
|
||||
mcp-enabled-disabled-conflict = ./enabled-disabled-conflict.nix;
|
||||
}
|
||||
|
|
|
|||
19
tests/modules/programs/mcp/enabled-disabled-conflict.nix
Normal file
19
tests/modules/programs/mcp/enabled-disabled-conflict.nix
Normal 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.
|
||||
''
|
||||
];
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue