Author SHA1 Message Date
luis e797c592cd Merge branch 'main' of gitea.bueso.eu:luis/mcp-servers
NixOS Configuration CI / Nix Flake Check (push) Successful in 15s
2026-10-22 17:12:44 +02:00
luis e9946694e6 msg 2026-10-22 17:12:12 +02:00
hermes-agent 4709b47c1e Merge pull request 'pinchflat-mcp: stop leaking the API token, gate admin tools, allow retention' (#17) from fix/pinchflat-mcp-secrets into main
NixOS Configuration CI / Nix Flake Check (push) Successful in 12s
2026-10-18 15:21:51 +02:00
hermes-agent 80d0352d1e pinchflat-mcp: redact credentials, gate admin tools, expose retention
NixOS Configuration CI / Nix Flake Check (pull_request) Successful in 15s
2026-10-18 15:18:18 +02:00
hermes-agent e4e6d083e5 Merge pull request 'Bump jellyfin-mcp to 2026.1015.1 (Jellyfin 12 auth header)' (#16) from bump/jellyfin-mcp-2026.1015.1 into main
NixOS Configuration CI / Nix Flake Check (push) Successful in 13s
2026-10-18 13:50:31 +02:00
8 changed files with 1458 additions and 37 deletions

No files matched your search

+8
View File
@@ -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
```
+18
View File
@@ -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.<name>` 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)
+62
View File
@@ -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://<server.host>:<server.port>/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.<name>` supports `allowBackends`, `denyBackends`,
`allowTools`, and `denyTools`. Set `defaultRoutingProfile` to the profile used
when a session has not selected one.
+77
View File
@@ -0,0 +1,77 @@
# ssh-mcp Home Manager Module
`homeManagerModules.ssh-mcp` exposes `services.ssh-mcp.instances.<name>`.
Each instance renders its own SSH host policy and known_hosts file under
`~/.config/ssh-mcp/<instance>/`.
## 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.<name>` 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.<name>.hostsFile`
- `config.services.ssh-mcp.instances.<name>.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.
+14
View File
@@ -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 (
+606
View File
@@ -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" ];
};
};
}
+521
View File
@@ -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
);
};
}
+152 -37
View File
@@ -1,5 +1,28 @@
#!/usr/bin/env python3
"""Pinchflat MCP server — exposes the Pinchflat REST API as MCP tools."""
"""Pinchflat MCP server — exposes the Pinchflat REST API as MCP tools.
Tool tiers
----------
Two tiers of tools exist and only one is exposed by default:
* ``subscriptions`` (default) — sources, media items, media profiles, tasks.
This is what an assistant needs to manage subscriptions and their files.
* ``admin`` — global application state: ``get_settings``, ``get_app_info``.
These describe the installation rather than a subscription and are NOT
exposed unless explicitly enabled.
Select the tier with ``PINCHFLAT_MCP_TOOLSET``:
PINCHFLAT_MCP_TOOLSET=subscriptions # default
PINCHFLAT_MCP_TOOLSET=admin
PINCHFLAT_MCP_TOOLSET=all
Responses are scrubbed of secrets on the way out (see ``_scrub``), because the
Pinchflat settings record embeds the application's own ``api_token``. Exposing
it to a tool caller hands out a credential that outlives the tool session, so
secret-bearing keys are redacted at the transport boundary rather than left to
individual tools to remember.
"""
import os
import sys
@@ -20,13 +43,76 @@ HEADERS = {"Authorization": f"Bearer {PINCHFLAT_TOKEN}", "Content-Type": "applic
mcp = FastMCP("pinchflat")
# --- Tool tiers -------------------------------------------------------------
_TIERS = {
"subscriptions": {"subscriptions"},
"admin": {"subscriptions", "admin"},
"all": {"subscriptions", "admin"},
}
_TOOLSET = os.environ.get("PINCHFLAT_MCP_TOOLSET", "subscriptions").strip().lower()
ENABLED_TIERS = _TIERS.get(_TOOLSET, _TIERS["subscriptions"])
def tool(tier: str = "subscriptions"):
"""Register a tool only when its tier is enabled."""
def decorator(fn):
if tier in ENABLED_TIERS:
return mcp.tool()(fn)
return fn
return decorator
# --- Response scrubbing -----------------------------------------------------
# Keys whose values are credentials and must never cross the tool boundary.
SECRET_KEYS = frozenset(
{
"api_token",
"api_key",
"token",
"secret",
"password",
"password_hash",
"youtube_api_key",
"apprise_server",
}
)
REDACTED = "[redacted]"
def _scrub(obj: Any) -> Any:
"""Recursively redact credential-bearing values from an API response."""
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(k, str) and k.strip().lower() in SECRET_KEYS and v:
out[k] = REDACTED
else:
out[k] = _scrub(v)
return out
if isinstance(obj, list):
return [_scrub(v) for v in obj]
return obj
# --- HTTP helpers -----------------------------------------------------------
def _unwrap(r: httpx.Response) -> Any:
"""Decode a response and scrub secrets before it reaches the caller."""
body = r.json()
return _scrub(body)
def _get(path: str, params: Optional[dict] = None) -> Any:
"""Make a GET request to the Pinchflat API."""
with httpx.Client(timeout=30) as client:
r = client.get(f"{BASE}{path}", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
return _unwrap(r)
def _post(path: str, json: Optional[dict] = None) -> Any:
@@ -34,7 +120,7 @@ def _post(path: str, json: Optional[dict] = None) -> Any:
with httpx.Client(timeout=30) as client:
r = client.post(f"{BASE}{path}", headers=HEADERS, json=json)
r.raise_for_status()
return r.json()
return _unwrap(r)
def _put(path: str, json: dict) -> Any:
@@ -42,38 +128,40 @@ def _put(path: str, json: dict) -> Any:
with httpx.Client(timeout=30) as client:
r = client.put(f"{BASE}{path}", headers=HEADERS, json=json)
r.raise_for_status()
return r.json()
return _unwrap(r)
def _delete(path: str) -> Any:
def _delete(path: str, params: Optional[dict] = None, timeout: int = 30) -> Any:
"""Make a DELETE request to the Pinchflat API."""
with httpx.Client(timeout=30) as client:
r = client.delete(f"{BASE}{path}", headers=HEADERS)
with httpx.Client(timeout=timeout) as client:
r = client.delete(f"{BASE}{path}", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
return _unwrap(r)
# --- Sources ---
@mcp.tool()
@tool()
def list_sources() -> dict:
"""List all configured media sources (YouTube channels, playlists, etc.)."""
return _get("/sources")
@mcp.tool()
@tool()
def get_source(source_id: int) -> dict:
"""Get details of a specific source by ID, including its pending tasks."""
return _get(f"/sources/{source_id}")
@mcp.tool()
@tool()
def create_source(
original_url: str,
media_profile_id: int,
collection_type: str = "channel",
custom_name: Optional[str] = None,
download_cutoff_date: Optional[str] = None,
retention_period_days: Optional[int] = None,
) -> dict:
"""Create a new media source (e.g. add a YouTube channel to monitor).
@@ -83,6 +171,8 @@ def create_source(
collection_type: 'channel', 'playlist', or 'video'
custom_name: Optional custom name for the source
download_cutoff_date: Only download media after this date (YYYY-MM-DD)
retention_period_days: Delete downloaded media this many days after
publication; omit to keep everything
"""
source = {
"media_profile_id": media_profile_id,
@@ -93,18 +183,30 @@ def create_source(
source["custom_name"] = custom_name
if download_cutoff_date:
source["download_cutoff_date"] = download_cutoff_date
if retention_period_days is not None:
source["retention_period_days"] = retention_period_days
return _post("/sources", json={"source": source})
@mcp.tool()
@tool()
def update_source(
source_id: int,
custom_name: Optional[str] = None,
download_cutoff_date: Optional[str] = None,
enabled: Optional[bool] = None,
index_frequency_minutes: Optional[int] = None,
retention_period_days: Optional[int] = None,
prevent_culling: Optional[bool] = None,
download_media: Optional[bool] = None,
) -> dict:
"""Update an existing source. Only provided fields will be changed."""
"""Update an existing source. Only provided fields will be changed.
Args:
retention_period_days: Delete downloaded media this many days after
publication (0 keeps everything)
prevent_culling: If True, never delete this source's downloads
download_media: If False, index the source but download nothing
"""
updates = {}
if custom_name is not None:
updates["custom_name"] = custom_name
@@ -114,34 +216,46 @@ def update_source(
updates["enabled"] = enabled
if index_frequency_minutes is not None:
updates["index_frequency_minutes"] = index_frequency_minutes
if retention_period_days is not None:
updates["retention_period_days"] = retention_period_days
if prevent_culling is not None:
updates["prevent_culling"] = prevent_culling
if download_media is not None:
updates["download_media"] = download_media
if not updates:
return {"data": {"message": "No fields to update."}}
return _put(f"/sources/{source_id}", json={"source": updates})
@mcp.tool()
@tool()
def delete_source(source_id: int) -> dict:
"""Delete a source (marks it for deletion)."""
"""Delete a source (marks it for deletion).
Note: this removes the source and its media records, but does NOT delete
the files from disk. Delete the media items first if space is the goal.
"""
return _delete(f"/sources/{source_id}")
@mcp.tool()
@tool()
def force_download_pending(source_id: int) -> dict:
"""Force download of all pending media items for a source."""
return _post(f"/sources/{source_id}/force_download_pending")
@mcp.tool()
@tool()
def force_redownload(source_id: int) -> dict:
"""Force re-download of all existing media for a source."""
return _post(f"/sources/{source_id}/force_redownload")
@mcp.tool()
@tool()
def force_index(source_id: int) -> dict:
"""Force re-index of a source."""
return _post(f"/sources/{source_id}/force_index")
@mcp.tool()
@tool()
def force_metadata_refresh(source_id: int) -> dict:
"""Force metadata refresh for a source."""
return _post(f"/sources/{source_id}/force_metadata_refresh")
@@ -149,7 +263,8 @@ def force_metadata_refresh(source_id: int) -> dict:
# --- Media Items ---
@mcp.tool()
@tool()
def list_media(
source_id: Optional[int] = None,
limit: int = 50,
@@ -172,25 +287,25 @@ def list_media(
return _get("/media", params=params)
@mcp.tool()
@tool()
def search_media(query: str) -> dict:
"""Search media items by title or description."""
return _get("/media/search", params={"q": query})
@mcp.tool()
@tool()
def get_media(media_id: int) -> dict:
"""Get details of a specific media item."""
return _get(f"/media/{media_id}")
@mcp.tool()
@tool()
def force_download_media(media_id: int) -> dict:
"""Force download of a specific media item."""
return _post(f"/media/{media_id}/force_download")
@mcp.tool()
@tool()
def delete_media(media_id: int, prevent_download: bool = False) -> dict:
"""Delete a media item's downloaded files from disk.
@@ -201,21 +316,19 @@ def delete_media(media_id: int, prevent_download: bool = False) -> dict:
params = {}
if prevent_download:
params["prevent_download"] = "true"
with httpx.Client(timeout=120) as client:
r = client.delete(f"{BASE}/media/{media_id}", headers=HEADERS, params=params)
r.raise_for_status()
return r.json()
return _delete(f"/media/{media_id}", params=params, timeout=120)
# --- Media Profiles ---
@mcp.tool()
@tool()
def list_media_profiles() -> dict:
"""List all media profiles (download quality/container settings)."""
return _get("/media_profiles")
@mcp.tool()
@tool()
def get_media_profile(profile_id: int) -> dict:
"""Get details of a specific media profile."""
return _get(f"/media_profiles/{profile_id}")
@@ -223,7 +336,8 @@ def get_media_profile(profile_id: int) -> dict:
# --- Tasks ---
@mcp.tool()
@tool()
def list_tasks(source_id: Optional[int] = None) -> dict:
"""List all tasks. Optionally filter by source_id."""
params = {}
@@ -232,21 +346,22 @@ def list_tasks(source_id: Optional[int] = None) -> dict:
return _get("/tasks", params=params)
@mcp.tool()
@tool()
def get_task(task_id: int) -> dict:
"""Get details of a specific task."""
return _get(f"/tasks/{task_id}")
# --- Settings & Info ---
# --- Settings & Info (admin tier — not exposed by default) ---
@mcp.tool()
@tool("admin")
def get_settings() -> dict:
"""Get current Pinchflat settings."""
"""Get current Pinchflat settings (credential values are redacted)."""
return _get("/settings")
@mcp.tool()
@tool("admin")
def get_app_info() -> dict:
"""Get Pinchflat app info (version, environment, etc.)."""
return _get("/app_info")
@@ -254,4 +369,4 @@ def get_app_info() -> dict:
if __name__ == "__main__":
transport = os.environ.get("PINCHFLAT_MCP_TRANSPORT", "stdio")
mcp.run(transport=transport if transport in ("stdio", "http") else "stdio")
mcp.run(transport=transport if transport in ("stdio", "http") else "stdio")