Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2393f594b3 | ||
|
|
c57cf38abe | ||
|
|
78b0a621fd | ||
|
|
106bf1014e | ||
|
|
ecf37568a3 | ||
|
|
b55c7af097 | ||
|
|
e9dc6f91b7 | ||
|
|
e797c592cd | ||
|
|
e9946694e6 | ||
|
|
4709b47c1e |
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -27,11 +27,13 @@
|
||||
mcp-email-server = pkgs.callPackage ./mcp-email-server/package.nix { };
|
||||
namecheap-mcp = pkgs.callPackage ./namecheap-mcp/package.nix { };
|
||||
navidrome-mcp = pkgs.callPackage ./navidrome-mcp/package.nix { };
|
||||
romarr-mcp = pkgs.callPackage ./romarr-mcp/package.nix { };
|
||||
ovhcloud-mcp = pkgs.callPackage ./ovhcloud-mcp/package.nix { };
|
||||
slskd-mcp = pkgs.callPackage ./slskd-mcp/package.nix { };
|
||||
yarr = pkgs.callPackage ./yarr/package.nix { };
|
||||
pdf-mcp = pkgs.callPackage ./pdf-mcp/package.nix { };
|
||||
mcp-searxng = pkgs.callPackage ./mcp-searxng/package.nix { };
|
||||
python-ssh-mcp = pkgs.callPackage ./python-ssh-mcp/package.nix { };
|
||||
romm-mcp = pkgs.callPackage ./romm-mcp/package.nix { };
|
||||
# example-mcp-server = pkgs.callPackage ./example-mcp-server/package.nix { };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
python3,
|
||||
makeWrapper,
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation {
|
||||
pname = "romarr-mcp";
|
||||
version = "1.0.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
dontBuild = true;
|
||||
dontConfigure = true;
|
||||
|
||||
# Single stdlib-only file: no wheels to vendor, no interpreter to pin beyond
|
||||
# the python3 in nixpkgs. ROMarr's own API is reached over plain HTTP(S).
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/lib/romarr-mcp $out/bin
|
||||
install -m644 romarr_mcp.py $out/lib/romarr-mcp/romarr_mcp.py
|
||||
makeWrapper ${python3}/bin/python3 $out/bin/romarr-mcp \
|
||||
--add-flags "$out/lib/romarr-mcp/romarr_mcp.py"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "MCP server for ROMarr: library, wanted, queue, indexer search, DAT audit";
|
||||
homepage = "https://github.com/BlizzHacker/romarr";
|
||||
license = licenses.mit;
|
||||
mainProgram = "romarr-mcp";
|
||||
platforms = platforms.all;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env python3
|
||||
"""romarr-mcp — an MCP server for ROMarr, the *arr for games.
|
||||
|
||||
Talks to ROMarr's REST API (documented at /api/v1/openapi.json). ROMarr
|
||||
generates its spec from the routes it actually serves, but it carries no
|
||||
OpenAPI tags, so a generic spec-to-MCP bridge exposes all 133 operations with
|
||||
names like ``postApiV1AuditQuarantineMove``. This server names the twenty that
|
||||
are actually useful and gives them arguments a model can reason about.
|
||||
|
||||
Standard library only: no httpx, no mcp SDK. That keeps the Nix package a
|
||||
single file with no vendored wheels, and the protocol surface this server needs
|
||||
(initialize, tools/list, tools/call, ping) is small.
|
||||
|
||||
Environment:
|
||||
ROMARR_URL base URL, default http://127.0.0.1:6868
|
||||
ROMARR_API_KEY required; Settings -> General, or ROMARR_API_KEY upstream
|
||||
ROMARR_READ_ONLY when "1"/"true", refuses every write tool
|
||||
ROMARR_TIMEOUT seconds, default 60
|
||||
ROMARR_VERIFY_TLS set "0" to skip certificate verification
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
SERVER_NAME = "romarr"
|
||||
SERVER_VERSION = "1.0.0"
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
BASE_URL = os.environ.get("ROMARR_URL", "http://127.0.0.1:6868").rstrip("/")
|
||||
API_KEY = os.environ.get("ROMARR_API_KEY", "").strip()
|
||||
READ_ONLY = os.environ.get("ROMARR_READ_ONLY", "").strip().lower() in ("1", "true", "yes", "on")
|
||||
TIMEOUT = float(os.environ.get("ROMARR_TIMEOUT", "60") or 60)
|
||||
VERIFY_TLS = os.environ.get("ROMARR_VERIFY_TLS", "1").strip().lower() not in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""A tool failed in a way the caller should see as an error, not a crash."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- http
|
||||
|
||||
|
||||
def _ssl_context():
|
||||
if VERIFY_TLS:
|
||||
return None
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def call_api(method, path, params=None, body=None):
|
||||
"""Call ROMarr and return the decoded JSON body."""
|
||||
if not API_KEY:
|
||||
raise ToolError(
|
||||
"ROMARR_API_KEY is not set. Find the key under ROMarr -> Settings -> General "
|
||||
"(or GET /api/v1/system/apikey with an existing credential)."
|
||||
)
|
||||
url = BASE_URL + path
|
||||
clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
|
||||
if clean:
|
||||
url += "?" + urllib.parse.urlencode(clean, doseq=True)
|
||||
data = None
|
||||
headers = {"X-Api-Key": API_KEY, "Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT, context=_ssl_context()) as resp:
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
status = exc.code
|
||||
detail = raw
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
detail = parsed.get("error") or parsed.get("detail") or raw
|
||||
except Exception:
|
||||
pass
|
||||
raise ToolError(f"ROMarr {method} {path} -> HTTP {status}: {str(detail)[:400]}")
|
||||
except urllib.error.URLError as exc:
|
||||
raise ToolError(f"cannot reach ROMarr at {BASE_URL}: {exc.reason}")
|
||||
if not raw.strip():
|
||||
return {"status": status, "ok": True}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {"status": status, "body": raw[:4000]}
|
||||
|
||||
|
||||
def get(endpoint, **params):
|
||||
return call_api("GET", endpoint, params=params)
|
||||
|
||||
|
||||
def post(endpoint, body=None):
|
||||
if READ_ONLY:
|
||||
raise ToolError("ROMARR_READ_ONLY is set; this server only reads right now.")
|
||||
return call_api("POST", endpoint, body=body if body is not None else {})
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- tools
|
||||
|
||||
|
||||
def t_system_status(_):
|
||||
return get("/api/v1/system/status")
|
||||
|
||||
|
||||
def t_system_counts(_):
|
||||
return get("/api/v1/system/counts")
|
||||
|
||||
|
||||
def t_tasks(a):
|
||||
return get("/api/v1/system/tasks", limit=a.get("limit"), platform=a.get("platform"))
|
||||
|
||||
|
||||
def t_library(a):
|
||||
return get(
|
||||
"/api/v1/game",
|
||||
platform=a.get("platform"),
|
||||
shelf=a.get("shelf"),
|
||||
limit=a.get("limit", 50),
|
||||
offset=a.get("offset"),
|
||||
decade=a.get("decade"),
|
||||
genre=a.get("genre"),
|
||||
region=a.get("region"),
|
||||
origin=a.get("origin"),
|
||||
)
|
||||
|
||||
|
||||
def t_wanted(a):
|
||||
return get("/api/v1/wanted/missing", platform=a.get("platform"), limit=a.get("limit"), view=a.get("view"))
|
||||
|
||||
|
||||
def t_queue(_):
|
||||
return get("/api/v1/queue")
|
||||
|
||||
|
||||
def t_history(a):
|
||||
return get("/api/v1/history", limit=a.get("limit"), platform=a.get("platform"))
|
||||
|
||||
|
||||
def t_search_releases(a):
|
||||
if not a.get("game"):
|
||||
raise ToolError("game is required")
|
||||
return get("/api/v1/release", game=a["game"], platform=a.get("platform"))
|
||||
|
||||
|
||||
def t_platforms(_):
|
||||
return get("/api/platforms")
|
||||
|
||||
|
||||
def t_audit(a):
|
||||
return get("/api/v1/audit", platform=a.get("platform"), status=a.get("status"), game=a.get("game"))
|
||||
|
||||
|
||||
def t_duplicates(_):
|
||||
return get("/api/v1/audit/quarantine")
|
||||
|
||||
|
||||
def t_indexers(_):
|
||||
return get("/api/v1/indexer")
|
||||
|
||||
|
||||
def t_download_clients(_):
|
||||
return get("/api/v1/downloadclient")
|
||||
|
||||
|
||||
def t_request_game(a):
|
||||
game, platform = (a.get("game") or "").strip(), (a.get("platform") or "").strip()
|
||||
if not game or not platform:
|
||||
raise ToolError("both game and platform are required")
|
||||
return post("/api/request", {"game": game, "platform": platform})
|
||||
|
||||
|
||||
def t_grab_release(a):
|
||||
release_id = (a.get("id") or "").strip()
|
||||
if not release_id:
|
||||
raise ToolError("id is required (from search_releases)")
|
||||
return post("/api/v1/release/grab", {"id": release_id})
|
||||
|
||||
|
||||
def t_queue_action(a):
|
||||
action = (a.get("action") or "").strip()
|
||||
if action not in ("retry", "remove"):
|
||||
raise ToolError("action must be 'retry' or 'remove'")
|
||||
if a.get("index") is None:
|
||||
raise ToolError("index is required (row number from queue)")
|
||||
return post("/api/v1/queue/action", {"index": int(a["index"]), "action": action})
|
||||
|
||||
|
||||
def t_audit_run(a):
|
||||
return post("/api/v1/audit", {"platform": a.get("platform") or ""})
|
||||
|
||||
|
||||
def t_quarantine(a):
|
||||
if a.get("confirm") is not True:
|
||||
raise ToolError("pass confirm=true; this moves files into ROMarr's quarantine")
|
||||
files = a.get("files") or []
|
||||
if not files:
|
||||
raise ToolError("files is required (the paths from the duplicates list)")
|
||||
return post("/api/v1/audit/quarantine/move", {"platform": a.get("platform") or "", "files": files})
|
||||
|
||||
|
||||
def t_rename_preview(a):
|
||||
return post(
|
||||
"/api/v1/audit/renames/preview",
|
||||
{"platform": a.get("platform") or "", "scope": a.get("scope") or "", "offset": int(a.get("offset") or 0)},
|
||||
)
|
||||
|
||||
|
||||
def t_import_finished(_):
|
||||
return post("/api/import", {})
|
||||
|
||||
|
||||
def t_manual_scan(a):
|
||||
if not a.get("path"):
|
||||
raise ToolError("path is required (a directory on ROMarr's host)")
|
||||
return get("/api/v1/manualimport", path=a["path"], platform=a.get("platform"))
|
||||
|
||||
|
||||
def _obj(props, required=None):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": required or [],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
P_PLATFORM = {"type": "string", "description": "Platform folder slug, e.g. 'psx', 'snes', 'gbc'."}
|
||||
P_LIMIT = {"type": "integer", "description": "Maximum rows to return.", "minimum": 1, "maximum": 500}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "system_status",
|
||||
"description": "Health of every ROMarr dependency (library server, indexers, download clients) plus play-route counts.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_system_status,
|
||||
},
|
||||
{
|
||||
"name": "system_counts",
|
||||
"description": "Library and queue sizes: how many games, how many on disk, how many catalogued elsewhere.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_system_counts,
|
||||
},
|
||||
{
|
||||
"name": "tasks",
|
||||
"description": "The scheduled jobs with interval, last run and last result — use it to see whether a scan or audit is in flight.",
|
||||
"schema": _obj({"limit": P_LIMIT, "platform": P_PLATFORM}),
|
||||
"write": False,
|
||||
"fn": t_tasks,
|
||||
},
|
||||
{
|
||||
"name": "library",
|
||||
"description": "The games ROMarr holds, filterable by platform, shelf, decade, genre, region. Paged by limit/offset.",
|
||||
"schema": _obj(
|
||||
{
|
||||
"platform": P_PLATFORM,
|
||||
"shelf": {"type": "string", "description": "Shelf filter such as 'top-rated' or 'recent'."},
|
||||
"limit": P_LIMIT,
|
||||
"offset": {"type": "integer", "minimum": 0},
|
||||
"decade": {"type": "string", "description": "e.g. '1990'."},
|
||||
"genre": {"type": "string"},
|
||||
"region": {"type": "string", "description": "e.g. 'USA', 'Europe', 'Japan'."},
|
||||
"origin": {"type": "string", "description": "Where the entry came from: on-disk, elsewhere, or wanted."},
|
||||
}
|
||||
),
|
||||
"write": False,
|
||||
"fn": t_library,
|
||||
},
|
||||
{
|
||||
"name": "wanted_missing",
|
||||
"description": "Games that are wanted but not yet found — the acquisition backlog.",
|
||||
"schema": _obj({"platform": P_PLATFORM, "limit": P_LIMIT, "view": {"type": "string"}}),
|
||||
"write": False,
|
||||
"fn": t_wanted,
|
||||
},
|
||||
{
|
||||
"name": "queue",
|
||||
"description": "Active downloads with their state, progress and error text.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_queue,
|
||||
},
|
||||
{
|
||||
"name": "history",
|
||||
"description": "What ROMarr has done: grabs, imports, failures, with timestamps.",
|
||||
"schema": _obj({"limit": P_LIMIT, "platform": P_PLATFORM}),
|
||||
"write": False,
|
||||
"fn": t_history,
|
||||
},
|
||||
{
|
||||
"name": "search_releases",
|
||||
"description": "Interactive search across every configured indexer for one game+platform; returns scored candidates with a rejection reason per candidate and the id to pass to grab_release.",
|
||||
"schema": _obj({"game": {"type": "string"}, "platform": P_PLATFORM}, ["game"]),
|
||||
"write": False,
|
||||
"fn": t_search_releases,
|
||||
},
|
||||
{
|
||||
"name": "platforms",
|
||||
"description": "Every platform ROMarr knows, with its media, file extensions, size ceiling and how many games it holds.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_platforms,
|
||||
},
|
||||
{
|
||||
"name": "audit",
|
||||
"description": "DAT verification state of the library: verified, bad dump, unknown, per platform. Read-only view of the last audit.",
|
||||
"schema": _obj({"platform": P_PLATFORM, "status": {"type": "string"}, "game": {"type": "string"}}),
|
||||
"write": False,
|
||||
"fn": t_audit,
|
||||
},
|
||||
{
|
||||
"name": "duplicates",
|
||||
"description": "Preview exact byte-identical duplicates and list the reversible quarantine manifests already recorded.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_duplicates,
|
||||
},
|
||||
{
|
||||
"name": "indexers",
|
||||
"description": "Configured indexers with their keys masked, plus whether each is enabled.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_indexers,
|
||||
},
|
||||
{
|
||||
"name": "download_clients",
|
||||
"description": "Configured download clients (torrent and usenet), keys masked.",
|
||||
"schema": _obj({}),
|
||||
"write": False,
|
||||
"fn": t_download_clients,
|
||||
},
|
||||
{
|
||||
"name": "request_game",
|
||||
"description": "Request a game: ROMarr searches the indexers, scores the releases, grabs the best and imports it. This starts a download.",
|
||||
"schema": _obj({"game": {"type": "string"}, "platform": P_PLATFORM}, ["game", "platform"]),
|
||||
"write": True,
|
||||
"fn": t_request_game,
|
||||
},
|
||||
{
|
||||
"name": "grab_release",
|
||||
"description": "Grab one specific candidate returned by search_releases, by its id.",
|
||||
"schema": _obj({"id": {"type": "string", "description": "Release id from search_releases."}}, ["id"]),
|
||||
"write": True,
|
||||
"fn": t_grab_release,
|
||||
},
|
||||
{
|
||||
"name": "queue_action",
|
||||
"description": "Retry or remove one queue row by its index.",
|
||||
"schema": _obj(
|
||||
{
|
||||
"index": {"type": "integer", "minimum": 0, "description": "Row index from queue."},
|
||||
"action": {"type": "string", "enum": ["retry", "remove"]},
|
||||
},
|
||||
["index", "action"],
|
||||
),
|
||||
"write": True,
|
||||
"fn": t_queue_action,
|
||||
},
|
||||
{
|
||||
"name": "audit_run",
|
||||
"description": "Re-verify one platform (or all) against the loaded No-Intro/Redump DATs. Long-running.",
|
||||
"schema": _obj({"platform": P_PLATFORM}),
|
||||
"write": True,
|
||||
"fn": t_audit_run,
|
||||
},
|
||||
{
|
||||
"name": "quarantine_duplicates",
|
||||
"description": "Move selected byte-identical duplicates into ROMarr's quarantine. Reversible via the manifest, but it moves files: requires confirm=true.",
|
||||
"schema": _obj(
|
||||
{
|
||||
"files": {"type": "array", "items": {"type": "string"}, "description": "File paths from duplicates."},
|
||||
"platform": P_PLATFORM,
|
||||
"confirm": {"type": "boolean", "description": "Must be true; acknowledgement that files are moved."},
|
||||
},
|
||||
["files", "confirm"],
|
||||
),
|
||||
"write": True,
|
||||
"fn": t_quarantine,
|
||||
},
|
||||
{
|
||||
"name": "rename_preview",
|
||||
"description": "Build a read-only, DAT-verified rename preview (e.g. scope='missing-dat-names'). Nothing is renamed.",
|
||||
"schema": _obj({"platform": P_PLATFORM, "scope": {"type": "string"}, "offset": {"type": "integer", "minimum": 0}}),
|
||||
"write": True,
|
||||
"fn": t_rename_preview,
|
||||
},
|
||||
{
|
||||
"name": "import_finished",
|
||||
"description": "Run an import pass over finished downloads, so completed items land in the library.",
|
||||
"schema": _obj({}),
|
||||
"write": True,
|
||||
"fn": t_import_finished,
|
||||
},
|
||||
{
|
||||
"name": "manual_scan",
|
||||
"description": "Scan a directory on ROMarr's host for files it could adopt, without importing them.",
|
||||
"schema": _obj({"path": {"type": "string"}, "platform": P_PLATFORM}, ["path"]),
|
||||
"write": False,
|
||||
"fn": t_manual_scan,
|
||||
},
|
||||
]
|
||||
|
||||
BY_NAME = {t["name"]: t for t in TOOLS}
|
||||
|
||||
|
||||
def tool_descriptors():
|
||||
out = []
|
||||
for t in TOOLS:
|
||||
d = {"name": t["name"], "description": t["description"], "inputSchema": t["schema"]}
|
||||
d["annotations"] = {
|
||||
"title": t["name"].replace("_", " ").title(),
|
||||
"readOnlyHint": not t["write"],
|
||||
"destructiveHint": t["name"] in ("quarantine_duplicates", "queue_action"),
|
||||
"openWorldHint": True,
|
||||
}
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- protocol
|
||||
|
||||
|
||||
def handle(req):
|
||||
method = req.get("method")
|
||||
rid = req.get("id")
|
||||
params = req.get("params") or {}
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"protocolVersion": params.get("protocolVersion") or PROTOCOL_VERSION,
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
||||
}
|
||||
if method == "ping":
|
||||
return {}
|
||||
if method == "tools/list":
|
||||
return {"tools": tool_descriptors()}
|
||||
if method == "tools/call":
|
||||
name = params.get("name")
|
||||
tool = BY_NAME.get(name)
|
||||
if tool is None:
|
||||
raise ToolError(f"unknown tool: {name}")
|
||||
if tool["write"] and READ_ONLY:
|
||||
raise ToolError("ROMARR_READ_ONLY is set; this server only reads right now.")
|
||||
result = tool["fn"](params.get("arguments") or {})
|
||||
return {
|
||||
"content": [{"type": "text", "text": json.dumps(result, indent=2, ensure_ascii=False)[:100000]}],
|
||||
"structuredContent": result if isinstance(result, dict) else {"result": result},
|
||||
"isError": False,
|
||||
}
|
||||
if method in ("notifications/initialized", "notifications/cancelled"):
|
||||
return None
|
||||
if method == "resources/list":
|
||||
return {"resources": []}
|
||||
if method == "prompts/list":
|
||||
return {"prompts": []}
|
||||
raise _UnknownMethod(method)
|
||||
|
||||
|
||||
class _UnknownMethod(Exception):
|
||||
def __init__(self, method):
|
||||
self.method = method
|
||||
|
||||
|
||||
def _read_message(stream):
|
||||
"""Newline-delimited JSON, tolerating the LSP-style Content-Length framing."""
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.lower().startswith("content-length:"):
|
||||
try:
|
||||
length = int(line.split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
while True: # skip remaining headers up to the blank line
|
||||
hdr = stream.readline()
|
||||
if not hdr or not hdr.strip():
|
||||
break
|
||||
payload = stream.read(length)
|
||||
if not payload:
|
||||
return None
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
try:
|
||||
return json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def _write_message(obj):
|
||||
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
if not API_KEY:
|
||||
sys.stderr.write("romarr-mcp: ROMARR_API_KEY is not set; every tool will fail.\n")
|
||||
sys.stderr.write(f"romarr-mcp {SERVER_VERSION}: stdio, target {BASE_URL}, {len(TOOLS)} tools\n")
|
||||
sys.stderr.flush()
|
||||
while True:
|
||||
msg = _read_message(sys.stdin)
|
||||
if msg is None:
|
||||
return 0
|
||||
if not isinstance(msg, dict) or "method" not in msg:
|
||||
continue
|
||||
is_notification = "id" not in msg
|
||||
try:
|
||||
result = handle(msg)
|
||||
except _UnknownMethod as exc:
|
||||
if not is_notification:
|
||||
_write_message({"jsonrpc": "2.0", "id": msg.get("id"), "error": {"code": -32601, "message": f"method not found: {exc.method}"}})
|
||||
continue
|
||||
except ToolError as exc:
|
||||
if not is_notification:
|
||||
_write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg.get("id"),
|
||||
"result": {"content": [{"type": "text", "text": f"error: {exc}"}], "isError": True},
|
||||
}
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001 - never kill the server on one bad call
|
||||
if not is_notification:
|
||||
_write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg.get("id"),
|
||||
"result": {"content": [{"type": "text", "text": f"error: {type(exc).__name__}: {exc}"}], "isError": True},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if is_notification or result is None:
|
||||
continue
|
||||
_write_message({"jsonrpc": "2.0", "id": msg.get("id"), "result": result})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
Generated
+1267
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
lib,
|
||||
buildNpmPackage,
|
||||
fetchzip,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "romm-mcp";
|
||||
version = "1.0.5";
|
||||
|
||||
src = fetchzip {
|
||||
url = "https://registry.npmjs.org/${pname}/-/${pname}-${version}.tgz";
|
||||
hash = "sha256-SOYzG0CtNSvIrxOYJq3UDXRlXylqbDjo2f5qfpbiYmI=";
|
||||
};
|
||||
|
||||
# Upstream publishes no package-lock.json (only package.json), and
|
||||
# buildNpmPackage requires one to compute npmDepsHash. This lock is generated
|
||||
# from the published tarball itself -- `npm install --package-lock-only
|
||||
# --ignore-scripts` inside the extracted 1.0.5 package -- so it locks exactly
|
||||
# the dependency tree that shipped with this version.
|
||||
npmDepsHash = "sha256-oSlECdIf2ZP8ZZhOUChkcJAmUTj6TFTdeTJLY5RwLGo=";
|
||||
|
||||
postPatch = ''
|
||||
cp ${./package-lock.json} package-lock.json
|
||||
|
||||
# romm_system action=scan was stubbed out client-side ("Scan endpoint removed in
|
||||
# RomM 5.0"), but RomM 5.4 restored POST /api/tasks/scan, so the stub only blocks a
|
||||
# working endpoint. Upstream publishes no sources in the npm tarball, so this patches
|
||||
# the compiled dist/ directly; drop it once upstream restores runScan().
|
||||
patch -p1 < ${./romm-mcp-scan.patch}
|
||||
'';
|
||||
|
||||
# dist/*.js (tsc output) ships inside the npm tarball, so there is nothing to
|
||||
# compile here -- same arrangement as navidrome-mcp and n8n-mcp.
|
||||
dontNpmBuild = true;
|
||||
|
||||
meta = with lib; {
|
||||
description = "MCP server for RomM: browse, search and administrate a self-hosted ROM library";
|
||||
homepage = "https://github.com/rodrigosiviero/romm-mcp";
|
||||
license = licenses.mit;
|
||||
mainProgram = pname;
|
||||
platforms = platforms.all;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
--- a/dist/client.js
|
||||
+++ b/dist/client.js
|
||||
@@ -244,8 +244,14 @@
|
||||
// NOTE: RomM 5.0 removed the POST /api/tasks/run endpoint.
|
||||
// Scans are now managed through RQ workers and require background job management.
|
||||
// Use the RomM web UI for scans and metadata updates.
|
||||
- async runScan() {
|
||||
- throw new Error("Scan endpoint removed in RomM 5.0. Use the RomM web UI to trigger library scans.");
|
||||
+ async runScan(scanType = "quick") {
|
||||
+ // RomM 5.4 restored library scans behind POST /api/tasks/scan (ScanPayload:
|
||||
+ // type, platforms, platform_fs_slugs, roms_ids, apis). The stub above predates
|
||||
+ // that. "quick" indexes new/changed files; "full" re-hashes the whole library.
|
||||
+ return this.request("/api/tasks/scan", {
|
||||
+ method: "POST",
|
||||
+ body: JSON.stringify({ type: scanType }),
|
||||
+ });
|
||||
}
|
||||
async runMetadataScan() {
|
||||
throw new Error("Metadata scan endpoint removed in RomM 5.0. Use the RomM web UI to trigger metadata updates.");
|
||||
Reference in new issue
Block a user