From e66f59a632c73d11829a24b508eec15a3e6e02c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Boh=C3=A1=C4=8Dek?= Date: Fri, 16 Oct 2026 08:44:13 +0200 Subject: [PATCH] Add claude.mcpServers: MCP servers loaded with --mcp-config The flag is variadic, so it goes first and --settings always follows it; otherwise a prompt after it would be read as another config file. Logins stay in the account. --- README.md | 1 + checks/default.nix | 42 ++++++++++++++++++++++++++++++++++++------ modules/claude.nix | 17 ++++++++++++++++- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 90a86dc304952030e6202150445859f0a9a4ad88..c50285371d85bf931a835e6faba0b57f9928e915 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Runnable examples: [`examples/basic`](examples/basic) and | `claude.package`, `codex.package` | Agent package; defaults come from this flake's inputs. | | `claude.settings` | JSON passed with `--settings` (layered over the account's `settings.json`). | | `claude.plugins` | Plugin directories loaded with `--plugin-dir`; nothing is installed into the account. | +| `claude.mcpServers` | MCP servers loaded with `--mcp-config` (the `mcpServers` object of an MCP JSON file). Logins stay in the account: run `/mcp` once. | | `codex.settings` | TOML linked as `$CODEX_HOME/agent-environments.config.toml` and selected with `--profile agent-environments` (layered over the account's `config.toml`). | | `instructions..{text,order}` | Fragments of `CLAUDE.md` / `AGENTS.md`, sorted by `order` (default 1000), then name. | | `skills.packages` | `mkSkill` packages to enable. Their skill dependencies are added automatically. | diff --git a/checks/default.nix b/checks/default.nix index ebbf9fa4652888d1935e55e6a445ae131c56eae4..87cd52c082230470f77435693803f9180aa7e6be 100644 --- a/checks/default.nix +++ b/checks/default.nix @@ -70,6 +70,10 @@ let }; claude.settings.model = "opus"; claude.plugins = [ fixturePlugin ]; + claude.mcpServers.probe = { + type = "http"; + url = "https://probe.invalid/mcp"; + }; codex.settings.model = "gpt-5"; launchers = { claude-work.agent = "claude"; @@ -135,12 +139,14 @@ in [ -f $s/skills/leaf/SKILL.md ] || fail "dependency skill leaf missing" grep -qxF "HELLO=yes" $STUB_LOG.env || fail "skill runtime input not on PATH" - # Settings layered via --settings, plugins loaded with --plugin-dir, user args - # passed through after them. - [ "$(sed -n 1p $STUB_LOG.args)" = "--settings" ] || fail "missing --settings" - grep -q '"model": *"opus"' "$(sed -n 2p $STUB_LOG.args)" || fail "settings file content" - [ "$(sed -n 3,4p $STUB_LOG.args)" = "$(printf -- "--plugin-dir\n${fixturePlugin}")" ] || fail "plugin not loaded" - [ "$(sed -n '5,$p' $STUB_LOG.args)" = "$(printf -- '--resume\nx')" ] || fail "user args not passed through" + # MCP servers loaded via --mcp-config, settings layered via --settings, + # plugins loaded with --plugin-dir, user args passed through after them. + [ "$(sed -n 1p $STUB_LOG.args)" = "--mcp-config" ] || fail "missing --mcp-config" + grep -q '"url": *"https://probe.invalid/mcp"' "$(sed -n 2p $STUB_LOG.args)" || fail "mcp config content" + [ "$(sed -n 3p $STUB_LOG.args)" = "--settings" ] || fail "missing --settings" + grep -q '"model": *"opus"' "$(sed -n 4p $STUB_LOG.args)" || fail "settings file content" + [ "$(sed -n 5,6p $STUB_LOG.args)" = "$(printf -- "--plugin-dir\n${fixturePlugin}")" ] || fail "plugin not loaded" + [ "$(sed -n '7,$p' $STUB_LOG.args)" = "$(printf -- '--resume\nx')" ] || fail "user args not passed through" [ ! -e $s/settings.json ] || fail "launcher must not own settings.json" ${full.launchers.codex-work}/bin/codex-work exec hi @@ -186,6 +192,30 @@ in grep -qxF "HELLO=yes" $STUB_LOG.env || fail "packages not on PATH" ''; + # --mcp-config takes every following argument until the next flag, so a + # launcher with only MCP servers must still put a flag between it and the + # user's arguments, or a prompt would be read as another config file. + mcp-config-not-last = + let + env = mkEnvironment { + inherit pkgs; + modules = [ + stubs + { + claude.enable = true; + claude.mcpServers.probe.url = "https://probe.invalid/mcp"; + launchers.claude-mcp.agent = "claude"; + } + ]; + }; + in + runTest "mcp-config-not-last" '' + ${env.launchers.claude-mcp}/bin/claude-mcp "a prompt" + [ "$(sed -n 1p $STUB_LOG.args)" = "--mcp-config" ] || fail "missing --mcp-config" + [ "$(sed -n 3p $STUB_LOG.args)" = "--settings" ] || fail "--mcp-config not followed by a flag: $(cat $STUB_LOG.args)" + [ "$(sed -n '5,$p' $STUB_LOG.args)" = "a prompt" ] || fail "user args not passed through" + ''; + # Without settings, no flags are injected and no files are managed. no-settings = runTest "no-settings" '' ${bare.launchers.claude-work}/bin/claude-work diff --git a/modules/claude.nix b/modules/claude.nix index f6c348165262939f2484a00b1a9263af390fbb66..c420f168aa919f2e37320df41d2ca87ed3af5e29 100644 --- a/modules/claude.nix +++ b/modules/claude.nix @@ -7,6 +7,10 @@ let cfg = config.claude; settingsFile = (pkgs.formats.json { }).generate "claude-settings.json" cfg.settings; + mcpConfigFile = (pkgs.formats.json { }).generate "claude-mcp.json" { + mcpServers = cfg.mcpServers; + }; + hasMcp = cfg.mcpServers != { }; in { options.claude = { @@ -25,6 +29,11 @@ in default = [ ]; description = "Plugin directories, loaded with --plugin-dir. Nothing is installed into the account."; }; + mcpServers = lib.mkOption { + type = (pkgs.formats.json { }).type; + default = { }; + description = "MCP servers (the mcpServers object of an MCP JSON file), loaded with --mcp-config. Credentials stay in the account."; + }; }; config.build.agents.claude = lib.mkIf cfg.enable { @@ -33,8 +42,14 @@ in instructionsFile = "CLAUDE.md"; # Managed links: path relative to the state dir -> store path. files = { }; + # --mcp-config is variadic and would swallow the user's arguments if it came + # last, so it goes first and --settings always follows it. flags = - lib.optionals (cfg.settings != { }) [ + lib.optionals hasMcp [ + "--mcp-config" + "${mcpConfigFile}" + ] + ++ lib.optionals (cfg.settings != { } || hasMcp) [ "--settings" "${settingsFile}" ]