diff --git a/modules/mcp.nix b/modules/mcp.nix new file mode 100644 index 0000000..ab4258b --- /dev/null +++ b/modules/mcp.nix @@ -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.` +# in this module, decrypted at activation into `/run/secrets/` +# (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/ + # 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 + ''; + }; + }; +}