401 lines
13 KiB
Nix
401 lines
13 KiB
Nix
{
|
|
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
|
|
'';
|
|
};
|
|
};
|
|
}
|