607 lines
18 KiB
Nix
607 lines
18 KiB
Nix
{
|
|
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" ];
|
|
};
|
|
};
|
|
}
|