M README.md => README.md +1 -0
@@ 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.<name>.{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. |
M checks/default.nix => checks/default.nix +36 -6
@@ 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
M modules/claude.nix => modules/claude.nix +16 -1
@@ 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}"
]