feat: MCP server module
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ab550cd0a7
commit
02504f9fd3
1 changed files with 401 additions and 0 deletions
401
modules/mcp.nix
Normal file
401
modules/mcp.nix
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
|
||||
# Omnixient MCP (Model Context Protocol) server configuration for Claude Code.
|
||||
#
|
||||
# Shape: NixOS module defines `omni.mcp.*` options and renders a
|
||||
# `~/.config/omni/mcp-servers.json` via home-manager. A home-manager
|
||||
# activation step merges just the `.mcpServers` key into `~/.claude.json`
|
||||
# using jq, so Claude Code's other state (project history, OAuth tokens,
|
||||
# etc.) is never clobbered on rebuild.
|
||||
#
|
||||
# Secrets are sourced from sops-nix: declared via `sops.secrets.<name>`
|
||||
# in this module, decrypted at activation into `/run/secrets/<name>`
|
||||
# (mode 0400, owned by `omni.user`), and read by thin wrapper scripts
|
||||
# at MCP launch time — never baked into the nix store or shell
|
||||
# environment. The encrypted YAML lives at `../secrets/omni.yaml`;
|
||||
# recipients are declared in `/etc/nixos/.sops.yaml`.
|
||||
|
||||
let
|
||||
inherit (lib)
|
||||
mkEnableOption
|
||||
mkOption
|
||||
mkIf
|
||||
types
|
||||
optionalAttrs
|
||||
;
|
||||
cfg = config.omni.mcp;
|
||||
user = config.omni.user;
|
||||
|
||||
forgejoMcpPkg = pkgs.callPackage ../packages/forgejo-mcp.nix { };
|
||||
forgejoTokenFile = config.sops.secrets.forgejo-token.path;
|
||||
|
||||
# Wrapper: source the token at exec time, keep it off the shell
|
||||
# environment and out of ~/.claude.json.
|
||||
forgejoMcpWrapper = pkgs.writeShellScript "omni-forgejo-mcp" ''
|
||||
set -eu
|
||||
if [ ! -r "${forgejoTokenFile}" ]; then
|
||||
echo "forgejo-mcp: token file missing or unreadable: ${forgejoTokenFile}" >&2
|
||||
echo "(expected to be decrypted by sops-nix at activation; check journalctl -u sops-install-secrets)" >&2
|
||||
exit 1
|
||||
fi
|
||||
export FORGEJO_ACCESS_TOKEN="$(${pkgs.coreutils}/bin/cat "${forgejoTokenFile}")"
|
||||
export FORGEJO_URL="${cfg.servers.forgejo.url}"
|
||||
exec "${cfg.servers.forgejo.binary}" --transport stdio
|
||||
'';
|
||||
|
||||
lnbitsApiKeyFile = config.sops.secrets.lnbits-admin-key.path;
|
||||
|
||||
# LNbits MCP server (lnbits/LNbits-MCP-Server) — Python, stdio. Not on
|
||||
# PyPI; resolved from git via uvx. The first invocation pulls + caches
|
||||
# the install under ~/.cache/uv; subsequent invocations are fast.
|
||||
#
|
||||
# Default points at a local FakeWallet instance — pointing the agent
|
||||
# at a wallet with real liquidity should be an explicit per-host opt-in.
|
||||
lnbitsMcpWrapper = pkgs.writeShellScript "omni-lnbits-mcp" ''
|
||||
set -eu
|
||||
if [ ! -r "${lnbitsApiKeyFile}" ]; then
|
||||
echo "lnbits-mcp: api key file missing or unreadable: ${lnbitsApiKeyFile}" >&2
|
||||
echo "(expected to be decrypted by sops-nix at activation; check journalctl -u sops-install-secrets)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# pragma: allowlist secret
|
||||
export LNBITS_API_KEY="$(${pkgs.coreutils}/bin/cat "${lnbitsApiKeyFile}")"
|
||||
export LNBITS_URL="${cfg.servers.lnbits.url}"
|
||||
export LNBITS_AUTH_METHOD="${cfg.servers.lnbits.authMethod}"
|
||||
exec ${pkgs.nix}/bin/nix shell nixpkgs#uv -c uvx \
|
||||
--from "git+https://github.com/lnbits/LNbits-MCP-Server" \
|
||||
lnbits-mcp-server
|
||||
'';
|
||||
|
||||
nextcloudPasswordFile = config.sops.secrets.nextcloud-password.path;
|
||||
|
||||
# Nextcloud MCP server (cbcoutinho/nextcloud-mcp-server on PyPI) via
|
||||
# uvx. App password (not login password) read from a secrets file at
|
||||
# launch time and exported as NEXTCLOUD_PASSWORD.
|
||||
nextcloudMcpWrapper = pkgs.writeShellScript "omni-nextcloud-mcp" ''
|
||||
set -eu
|
||||
if [ ! -r "${nextcloudPasswordFile}" ]; then
|
||||
echo "nextcloud-mcp: password file missing or unreadable: ${nextcloudPasswordFile}" >&2
|
||||
echo "(expected to be decrypted by sops-nix at activation; check journalctl -u sops-install-secrets)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# pragma: allowlist secret
|
||||
export NEXTCLOUD_PASSWORD="$(${pkgs.coreutils}/bin/cat "${nextcloudPasswordFile}")"
|
||||
export NEXTCLOUD_HOST="${cfg.servers.nextcloud.url}"
|
||||
export NEXTCLOUD_USERNAME="${cfg.servers.nextcloud.username}"
|
||||
exec ${pkgs.nix}/bin/nix shell nixpkgs#uv -c uvx \
|
||||
nextcloud-mcp-server run --transport stdio
|
||||
'';
|
||||
|
||||
# Postgres access mode: restricted (readonly-ish, safe) by default.
|
||||
# Unrestricted lets Claude run DROP TABLE / DELETE / TRUNCATE — only
|
||||
# enable when actively running a migration you've verified.
|
||||
pgAccessMode = if cfg.servers.postgres.writable then "unrestricted" else "restricted";
|
||||
|
||||
mcpServers =
|
||||
optionalAttrs cfg.servers.mcp-nixos.enable {
|
||||
mcp-nixos = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nix}/bin/nix";
|
||||
args = [
|
||||
"run"
|
||||
"github:utensils/mcp-nixos"
|
||||
"--"
|
||||
];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.github.enable {
|
||||
# HTTP GitHub Copilot MCP — OAuth via `/mcp` in Claude Code.
|
||||
github = {
|
||||
type = "http";
|
||||
url = "https://api.githubcopilot.com/mcp/";
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.forgejo.enable {
|
||||
forgejo-mcp = {
|
||||
type = "stdio";
|
||||
command = toString forgejoMcpWrapper;
|
||||
args = [ ];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.lnbits.enable {
|
||||
lnbits = {
|
||||
type = "stdio";
|
||||
command = toString lnbitsMcpWrapper;
|
||||
args = [ ];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.nextcloud.enable {
|
||||
nextcloud = {
|
||||
type = "stdio";
|
||||
command = toString nextcloudMcpWrapper;
|
||||
args = [ ];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.postgres.enable {
|
||||
postgres-mcp = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nix}/bin/nix";
|
||||
args = [
|
||||
"shell"
|
||||
"nixpkgs#uv"
|
||||
"-c"
|
||||
"uvx"
|
||||
"postgres-mcp"
|
||||
"--access-mode=${pgAccessMode}"
|
||||
];
|
||||
env = {
|
||||
DATABASE_URI = cfg.servers.postgres.databaseUri;
|
||||
};
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.docker.enable {
|
||||
docker-mcp = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nix}/bin/nix";
|
||||
args = [
|
||||
"shell"
|
||||
"nixpkgs#uv"
|
||||
"-c"
|
||||
"uvx"
|
||||
"docker-mcp"
|
||||
];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.fetch.enable {
|
||||
fetch = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nodejs}/bin/npx";
|
||||
args = [
|
||||
"-y"
|
||||
"mcp-fetch-server"
|
||||
];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.nostr.enable {
|
||||
nostr-mcp = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nodejs}/bin/npx";
|
||||
args = [
|
||||
"-y"
|
||||
"nostr-mcp-server"
|
||||
];
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.shadcn-vue.enable {
|
||||
shadcn-vue = {
|
||||
type = "stdio";
|
||||
command = "${pkgs.nodejs}/bin/npx";
|
||||
args = [
|
||||
"shadcn-vue@latest"
|
||||
"mcp"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
mcpServersJson = builtins.toJSON mcpServers;
|
||||
|
||||
in
|
||||
{
|
||||
options.omni.mcp = {
|
||||
enable = mkEnableOption "Claude Code MCP servers managed declaratively";
|
||||
|
||||
servers = {
|
||||
mcp-nixos.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "nixpkgs / NixOS option search MCP.";
|
||||
};
|
||||
|
||||
github.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "GitHub MCP via HTTP + OAuth (api.githubcopilot.com).";
|
||||
};
|
||||
|
||||
forgejo.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Forgejo MCP. Requires forgejo-token secret file.";
|
||||
};
|
||||
|
||||
forgejo.url = mkOption {
|
||||
type = types.str;
|
||||
default = "https://git.atitlan.io";
|
||||
};
|
||||
|
||||
forgejo.binary = mkOption {
|
||||
type = types.str;
|
||||
default = lib.getExe forgejoMcpPkg;
|
||||
description = ''
|
||||
Path to forgejo-mcp binary. Defaults to the nix-packaged
|
||||
derivation at packages/forgejo-mcp.nix (upstream
|
||||
codeberg.org/goern/forgejo-mcp). Override to use a
|
||||
locally-built binary.
|
||||
'';
|
||||
};
|
||||
|
||||
lnbits.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
LNbits MCP server (lnbits/LNbits-MCP-Server) via uvx. Off by
|
||||
default — enable per-host. Requires the api key file at
|
||||
`lnbits.apiKeyFile`. Point at a FakeWallet dev instance unless
|
||||
you specifically want the agent reaching a wallet with real
|
||||
liquidity (the server exposes payment-sending tools).
|
||||
'';
|
||||
};
|
||||
|
||||
lnbits.url = mkOption {
|
||||
type = types.str;
|
||||
default = "http://localhost:5000";
|
||||
description = "LNBITS_URL passed to the MCP server.";
|
||||
};
|
||||
|
||||
lnbits.authMethod = mkOption {
|
||||
type = types.str;
|
||||
default = "api_key_header";
|
||||
description = ''
|
||||
LNBITS_AUTH_METHOD passed to the MCP server. Typically
|
||||
`api_key_header`; other modes supported upstream.
|
||||
'';
|
||||
};
|
||||
|
||||
nextcloud.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Nextcloud MCP server (nextcloud-mcp-server on PyPI) via uvx.
|
||||
Off by default — enable per-host. Requires the app password
|
||||
file at `nextcloud.passwordFile`. Generate the app password
|
||||
under Nextcloud → Settings → Security → Devices & sessions;
|
||||
don't use your login password.
|
||||
'';
|
||||
};
|
||||
|
||||
nextcloud.url = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
NEXTCLOUD_HOST passed to the MCP server — full URL of the
|
||||
Nextcloud instance (e.g. https://cloud.example.com).
|
||||
'';
|
||||
};
|
||||
|
||||
nextcloud.username = mkOption {
|
||||
type = types.str;
|
||||
default = "";
|
||||
description = "NEXTCLOUD_USERNAME passed to the MCP server.";
|
||||
};
|
||||
|
||||
postgres.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "PostgreSQL MCP via uvx. Off by default; enable per-host.";
|
||||
};
|
||||
|
||||
postgres.writable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Promote postgres-mcp from --access-mode=restricted (safe)
|
||||
to --access-mode=unrestricted (can DROP / DELETE / TRUNCATE).
|
||||
Only enable during explicit migration work.
|
||||
'';
|
||||
};
|
||||
|
||||
postgres.databaseUri = mkOption {
|
||||
type = types.str;
|
||||
default = "postgresql://lamassu:lamassu_dev_password@localhost:5432/lamassu_dev";
|
||||
description = "DATABASE_URI passed to postgres-mcp.";
|
||||
};
|
||||
|
||||
docker.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Docker MCP (container ls/start/stop) via uvx.";
|
||||
};
|
||||
|
||||
fetch.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Generic HTTP fetch MCP (mcp-fetch-server).";
|
||||
};
|
||||
|
||||
nostr.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
};
|
||||
|
||||
shadcn-vue.enable = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
# Decrypt only the secrets whose MCP is enabled, into /run/secrets/<name>
|
||||
# owned by `omni.user` so the wrapper (running as that user) can read
|
||||
# them. `mode = "0400"` keeps even group out.
|
||||
sops.secrets = optionalAttrs cfg.servers.forgejo.enable {
|
||||
forgejo-token = {
|
||||
mode = "0400";
|
||||
owner = user;
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.lnbits.enable {
|
||||
lnbits-admin-key = {
|
||||
mode = "0400";
|
||||
owner = user;
|
||||
};
|
||||
}
|
||||
// optionalAttrs cfg.servers.nextcloud.enable {
|
||||
nextcloud-password = {
|
||||
mode = "0400";
|
||||
owner = user;
|
||||
};
|
||||
};
|
||||
|
||||
# The inner attrset is a function so we receive home-manager's extended
|
||||
# `lib` (with `lib.hm.dag.*`). The NixOS module's outer `lib` doesn't
|
||||
# have those helpers.
|
||||
home-manager.users.${user} =
|
||||
{ lib, ... }:
|
||||
{
|
||||
# Source of truth — the rendered JSON. Versioned by every rebuild,
|
||||
# easy to inspect with `cat ~/.config/omni/mcp-servers.json`.
|
||||
home.file.".config/omni/mcp-servers.json".text = mcpServersJson;
|
||||
|
||||
# Merge managed .mcpServers into ~/.claude.json without touching
|
||||
# Claude Code's other state (project history, auth tokens, etc.).
|
||||
# Falls back gracefully if the jq merge fails rather than leaving
|
||||
# ~/.claude.json in a half-written state.
|
||||
home.activation.omniMergeMcpServers = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
claude_config="$HOME/.claude.json"
|
||||
mcp_source="$HOME/.config/omni/mcp-servers.json"
|
||||
|
||||
if [ ! -f "$claude_config" ]; then
|
||||
echo '{}' > "$claude_config"
|
||||
fi
|
||||
|
||||
tmp=$(${pkgs.coreutils}/bin/mktemp)
|
||||
if ${pkgs.jq}/bin/jq \
|
||||
--slurpfile mcp "$mcp_source" \
|
||||
'.mcpServers = $mcp[0]' \
|
||||
"$claude_config" > "$tmp"; then
|
||||
${pkgs.coreutils}/bin/mv "$tmp" "$claude_config"
|
||||
else
|
||||
echo "omni-mcp: jq merge failed; ~/.claude.json left untouched" >&2
|
||||
${pkgs.coreutils}/bin/rm -f "$tmp"
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue