feat: MCP server module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-28 06:48:37 +02:00
commit 02504f9fd3

401
modules/mcp.nix Normal file
View 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
'';
};
};
}