~ruther/agent-environments

e66f59a632c73d11829a24b508eec15a3e6e02c2 — František Boháček a month ago cb5207d
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.
3 files changed, 53 insertions(+), 7 deletions(-)

M README.md
M checks/default.nix
M modules/claude.nix
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}"
      ]