diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..763d0fc --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.mcp.json filter=git-crypt diff=git-crypt diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..23029e7 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,19 @@ +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "" + } + }, + "fetch": { + "command": "npx", + "args": ["-y", "@anthropic-ai/mcp-server-fetch"] + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + } + } +} diff --git a/README.org b/README.org index ed5ad90..90a24ce 100644 --- a/README.org +++ b/README.org @@ -23,6 +23,8 @@ For multi-repo setups, skills, and system prompts, see [[*Configuration][Configu - Linux (user/group sandboxing relies on =useradd=, =groupadd=, and setgid) - Python >= 3.9 with pip - git +- git-crypt (for decrypting =.mcp.json= which contains MCP server tokens) +- Node.js and npx (MCP servers are distributed as npm packages) - sudo - make (optional; you can run the setup commands by hand) - bash @@ -508,10 +510,82 @@ per-mirror > global > profile > UNKNOWN. | default_flag | Pass through default flags | {flag} | {flag} | {flag} | Each default flag is rendered through this template. | | skills | Expose skills paths | (none) | (none) | (none) | Use "--skills {path}" or similar if your CLI supports it. | | system_prompt | Inject system prompt | (none) | --system-prompt | --prompt-interactive | If unset, sucoder appends the prompt as trailing text when inline prompts are supported. | +| mcp_config | Pass MCP server config | (none) | --mcp-config {path} | (none) | Only used for sucoder-config-level MCP servers; repo-shipped =.mcp.json= is discovered natively. | If you change any defaults, update =AGENT_PROFILES= in =sucoder/config.py= alongside the README so the documentation stays in sync. +** MCP servers (repo-specific tools) + +MCP (Model Context Protocol) servers give agents access to external services---GitHub, web pages, databases---via a standardised tool interface. Claude Code on the web injects GitHub tools automatically, but the CLI (=claude=) does not. Since =sucoder= launches agents via the CLI, MCP servers bridge the gap. + +*** How it works + +There are two independent sources of MCP configuration; Claude merges them automatically: + +1. *Repo-shipped =.mcp.json=* --- lives in the repository root. Claude discovers it natively when launched in the mirror. This is the simplest approach: add the file, commit it, and every agent session gets those tools. + +2. *Sucoder-config =mcp_servers=* --- defined in =~/.sucoder/config.yaml= at the global or per-mirror level. Sucoder generates a =.sucoder-mcp.json= file in the mirror and passes it via =--mcp-config=. Use this for infrastructure tools that are not part of any single repo. + +*** Shipped MCP servers + +This repository includes a =.mcp.json= with three servers: + +| Server | Purpose | +|----------+------------------------------------------------------| +| =github= | Read/write GitHub issues, PRs, CI status, comments | +| =fetch= | Fetch web pages and URLs (documentation, references) | +| =memory= | Persistent knowledge graph across agent sessions | + +The =github= server requires a =GITHUB_TOKEN= environment variable. Because =.mcp.json= contains this secret, the file is encrypted with =git-crypt=. + +*** Setting up git-crypt (first time) + +After cloning the repository, unlock the encrypted files: + +#+begin_src shell +# Install git-crypt if needed +brew install git-crypt # macOS +sudo apt install git-crypt # Debian/Ubuntu + +# If you are initialising git-crypt for the first time in this repo: +git-crypt init +git-crypt add-gpg-user + +# Edit .mcp.json to add your real GITHUB_TOKEN, then commit. +# The file will be stored encrypted in the repo but readable in your checkout. + +# If git-crypt is already initialised, just unlock: +git-crypt unlock +#+end_src + +Collaborators who have been added via =git-crypt add-gpg-user= can unlock with their GPG key. The agent user (=coder=) works in a mirror cloned from an already-unlocked checkout, so the plaintext =.mcp.json= is available without additional setup. + +*** Configuring MCP servers via =config.yaml= + +For MCP servers that should be available across multiple repos (not shipped in any single repo), add them to the sucoder configuration: + +#+begin_src yaml +# Global MCP servers (available to all mirrors) +mcp_servers: + my-database: + command: npx + args: ["-y", "@modelcontextprotocol/server-postgres"] + env: + DATABASE_URL: "postgresql://localhost/mydb" + +# Or per-mirror (overrides global for that mirror) +mirrors: + project: + canonical_repo: ~/src/project.git + mcp_servers: + custom-tool: + command: my-mcp-server + args: ["--port", "8080"] +#+end_src + +Sucoder writes these to =.sucoder-mcp.json= in the mirror (excluded from git) and passes =--mcp-config= to Claude. If the repo also ships a =.mcp.json=, Claude merges servers from both sources. + ** Agent capability comparison The core =sucoder= workflow (mirror, sync, permissions, branch management) is diff --git a/config.example.yaml b/config.example.yaml index d110d6e..e328dc9 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -46,3 +46,15 @@ mirrors: - "~" flags: skills: "--skills {path}" + # MCP servers available to the agent in this mirror (optional). + # These are written to .sucoder-mcp.json and passed via --mcp-config. + # Repos can also ship their own .mcp.json which Claude discovers natively. + # mcp_servers: + # filesystem: + # command: npx + # args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"] + # github: + # command: npx + # args: ["-y", "@modelcontextprotocol/server-github"] + # env: + # GITHUB_TOKEN: "" diff --git a/sucoder/config.py b/sucoder/config.py index 9ff3b65..a9c161f 100644 --- a/sucoder/config.py +++ b/sucoder/config.py @@ -74,6 +74,15 @@ class NvmConfig: dir: Optional[Path] = None +@dataclass +class McpServerConfig: + """Definition of a single MCP server.""" + + command: str + args: List[str] = field(default_factory=list) + env: Dict[str, str] = field(default_factory=dict) + + @dataclass class AgentLauncher: """Configuration for launching the agent process.""" @@ -104,6 +113,7 @@ class AgentFlagTemplates: default_flag: Optional[str] = "{flag}" skills: Optional[str] = None system_prompt: Optional[str] = None + mcp_config: Optional[str] = None # Agent profiles provide CLI-specific default flag templates. @@ -115,24 +125,28 @@ class AgentFlagTemplates: writable_dir=None, system_prompt=None, skills=None, + mcp_config=None, ), AgentType.CLAUDE: AgentFlagTemplates( yolo="--dangerously-skip-permissions", writable_dir="--add-dir {path}", system_prompt="--system-prompt", # Flag only; content added as separate arg skills=None, # Claude doesn't have a direct skills flag + mcp_config="--mcp-config {path}", ), AgentType.CODEX: AgentFlagTemplates( yolo="--sandbox danger-full-access --ask-for-approval never", writable_dir=None, # codex uses sandbox permissions instead system_prompt=None, # codex uses trailing text skills=None, + mcp_config=None, ), AgentType.GEMINI: AgentFlagTemplates( yolo="--yolo", writable_dir="--include-directories {path}", system_prompt="--prompt-interactive", # stays interactive after prompt skills=None, + mcp_config=None, ), } @@ -159,6 +173,7 @@ class MirrorSettings: task_branch_prefix: str = "task" agent_launcher: AgentLauncher = field(default_factory=AgentLauncher) skills: List[Path] = field(default_factory=list) + mcp_servers: Dict[str, McpServerConfig] = field(default_factory=dict) remote: Optional[RemoteConfig] = None @property @@ -177,6 +192,7 @@ class Config: agent_group: str = "coder" mirror_root: Path = field(default_factory=Path) skills: List[Path] = field(default_factory=list) + mcp_servers: Dict[str, McpServerConfig] = field(default_factory=dict) system_prompt: Optional[Path] = None log_dir: Optional[Path] = None agent_launcher: Optional[AgentLauncher] = None # Global defaults for all mirrors @@ -373,6 +389,7 @@ def _build_config(data: Dict[str, Any], *, path: Path) -> Config: raise ConfigError(f"Configured system_prompt file not found: {system_prompt}") global_skills = _parse_skills(data.get("skills")) + global_mcp_servers = _parse_mcp_servers(data.get("mcp_servers")) # Parse global agent_launcher defaults (applies to all mirrors unless overridden) global_agent_launcher = None @@ -380,7 +397,10 @@ def _build_config(data: Dict[str, Any], *, path: Path) -> Config: global_agent_launcher = _parse_agent_launcher(data.get("agent_launcher")) targets = _parse_targets(data.get("targets")) - mirrors = _parse_mirrors(data.get("mirrors"), global_skills=global_skills, path=path) + mirrors = _parse_mirrors( + data.get("mirrors"), global_skills=global_skills, + global_mcp_servers=global_mcp_servers, path=path, + ) mirror_root = _expand_path(mirror_root_raw) if mirror_root is None: @@ -392,6 +412,7 @@ def _build_config(data: Dict[str, Any], *, path: Path) -> Config: agent_group=data.get("agent_group", data.get("agent_user", "coder")), mirror_root=mirror_root, skills=global_skills, + mcp_servers=global_mcp_servers, system_prompt=system_prompt, log_dir=log_dir, agent_launcher=global_agent_launcher, @@ -400,7 +421,13 @@ def _build_config(data: Dict[str, Any], *, path: Path) -> Config: ) -def _parse_mirrors(raw: Any, *, global_skills: List[Path], path: Path) -> Dict[str, MirrorSettings]: +def _parse_mirrors( + raw: Any, + *, + global_skills: List[Path], + global_mcp_servers: Dict[str, McpServerConfig], + path: Path, +) -> Dict[str, MirrorSettings]: if raw is None: return {} # No mirrors configured; zero-config detection will add them. if isinstance(raw, list): @@ -438,6 +465,13 @@ def _parse_mirrors(raw: Any, *, global_skills: List[Path], path: Path) -> Dict[s skills_raw_present = "skills" in value skills = _parse_skills(value.get("skills")) if skills_raw_present else list(global_skills) + mcp_raw_present = "mcp_servers" in value + mcp_servers = ( + _parse_mcp_servers(value.get("mcp_servers")) + if mcp_raw_present + else dict(global_mcp_servers) + ) + canonical_repo = _expand_path(canonical_raw) if canonical_repo is None: raise ConfigError( @@ -453,6 +487,7 @@ def _parse_mirrors(raw: Any, *, global_skills: List[Path], path: Path) -> Dict[s task_branch_prefix=value.get("task_branch_prefix", "task"), agent_launcher=launcher, skills=skills, + mcp_servers=mcp_servers, remote=remote, ) @@ -577,6 +612,7 @@ def _template(key: str) -> Optional[str]: default_flag=_template("default_flag"), skills=_template("skills"), system_prompt=_template("system_prompt"), + mcp_config=_template("mcp_config"), ) @@ -693,3 +729,34 @@ def _parse_skills(raw: Any) -> List[Path]: continue skills.append(expanded) return skills + + +def _parse_mcp_servers(raw: Any) -> Dict[str, McpServerConfig]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ConfigError("`mcp_servers` must be a mapping of server names to configurations.") + + servers: Dict[str, McpServerConfig] = {} + for name, value in raw.items(): + if not isinstance(name, str): + raise ConfigError("`mcp_servers` keys must be strings.") + if not isinstance(value, dict): + raise ConfigError(f"`mcp_servers.{name}` must be a mapping.") + + command = value.get("command") + if not command or not isinstance(command, str): + raise ConfigError(f"`mcp_servers.{name}.command` must be a non-empty string.") + + args = value.get("args", []) + if not isinstance(args, list) or any(not isinstance(a, str) for a in args): + raise ConfigError(f"`mcp_servers.{name}.args` must be a list of strings.") + + env = value.get("env", {}) + if not isinstance(env, dict) or any( + not isinstance(k, str) or not isinstance(v, str) for k, v in env.items() + ): + raise ConfigError(f"`mcp_servers.{name}.env` must be a mapping of strings to strings.") + + servers[name] = McpServerConfig(command=command, args=list(args), env=dict(env)) + return servers diff --git a/sucoder/mirror.py b/sucoder/mirror.py index 6c44542..37ec968 100644 --- a/sucoder/mirror.py +++ b/sucoder/mirror.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime as _dt +import json import logging import os import pwd @@ -1416,6 +1417,15 @@ def _apply_agent_flag_templates( tokens = self._render_flag_template(template, path=str(path)) command_list.extend(tokens) + # mcp_config intent + if templates.mcp_config: + mcp_config_path = self._resolve_mcp_config(ctx) + if mcp_config_path: + tokens = self._render_flag_template( + templates.mcp_config, path=str(mcp_config_path), + ) + command_list.extend(tokens) + return command_list def _resolved_writable_dirs( @@ -1508,6 +1518,46 @@ def _resolved_skill_paths_for_flags(self, ctx: MirrorContext) -> List[Path]: def _default_skills_dir() -> Path: return Path("~/.sucoder/skills").expanduser() + _SUCODER_MCP_FILENAME = ".sucoder-mcp.json" + + def _resolve_mcp_config(self, ctx: MirrorContext) -> Optional[Path]: + """Generate a ``.sucoder-mcp.json`` file from sucoder-config-level MCP servers. + + Returns the path to the generated file, or ``None`` if no servers + are configured. The file is added to the mirror's local git + exclude so it is never committed. + """ + servers = ctx.settings.mcp_servers + if not servers: + return None + + mirror_path = ctx.mirror_path + mcp_path = mirror_path / self._SUCODER_MCP_FILENAME + + payload = { + "mcpServers": { + name: { + "command": srv.command, + "args": srv.args, + **({"env": srv.env} if srv.env else {}), + } + for name, srv in servers.items() + } + } + + mcp_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + # Exclude from git so the generated file is never committed. + exclude_file = mirror_path / ".git" / "info" / "exclude" + if exclude_file.exists(): + existing = exclude_file.read_text(encoding="utf-8") + if self._SUCODER_MCP_FILENAME not in existing: + with exclude_file.open("a", encoding="utf-8") as fh: + fh.write(f"{self._SUCODER_MCP_FILENAME}\n") + + self.logger.info("Generated MCP config at %s with %d server(s)", mcp_path, len(servers)) + return mcp_path + def _wrap_with_nvm(self, command: Sequence[str], launcher: AgentLauncher) -> List[str]: """Wrap the agent command so it runs under a specific nvm-managed Node version. @@ -3064,4 +3114,5 @@ def _pick(field_name: str) -> Optional[str]: default_flag=_pick("default_flag"), skills=_pick("skills"), system_prompt=_pick("system_prompt"), + mcp_config=_pick("mcp_config"), ) diff --git a/tests/test_config.py b/tests/test_config.py index 6a7fec4..285613e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,7 @@ KNOWN_AGENTS, BranchPrefixes, ConfigError, + McpServerConfig, build_default_config, detect_agent_command, load_config, @@ -58,7 +59,10 @@ def test_load_config_success(tmp_path: Path) -> None: assert sample.agent_launcher.flags.default_flag == "{flag}" assert sample.agent_launcher.flags.skills is None assert sample.agent_launcher.flags.system_prompt is None + assert sample.agent_launcher.flags.mcp_config is None assert sample.skills == [] + assert sample.mcp_servers == {} + assert cfg.mcp_servers == {} assert cfg.system_prompt is None @@ -249,6 +253,7 @@ def test_load_config_agent_launcher_with_flags(tmp_path: Path) -> None: assert flags.workdir == "--here {path}" assert flags.default_flag == "--add {flag}" assert flags.skills == "--skills {path}" + assert flags.mcp_config is None def test_load_config_with_system_prompt(tmp_path: Path) -> None: @@ -520,3 +525,173 @@ def test_detect_agent_multiple_prompts(monkeypatch: pytest.MonkeyPatch, tmp_path assert result == ["claude"] # Verify it saved the preference assert pref_file.read_text(encoding="utf-8").strip() == "claude" + + +# ---- MCP server config tests ---- + + +def test_load_config_with_mcp_servers(tmp_path: Path) -> None: + config_path = write_config( + tmp_path, + """ +human_user: ligon +mirror_root: ./mirrors +mirrors: + sample: + canonical_repo: ./canonical + mcp_servers: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"] + github: + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + env: + GITHUB_TOKEN: "tok-123" +""", + ) + cfg = load_config(config_path) + sample = cfg.mirrors["sample"] + assert len(sample.mcp_servers) == 2 + + fs = sample.mcp_servers["filesystem"] + assert fs.command == "npx" + assert fs.args == ["-y", "@modelcontextprotocol/server-filesystem", "/data"] + assert fs.env == {} + + gh = sample.mcp_servers["github"] + assert gh.command == "npx" + assert gh.args == ["-y", "@modelcontextprotocol/server-github"] + assert gh.env == {"GITHUB_TOKEN": "tok-123"} + + +def test_load_config_mcp_servers_defaults_empty(tmp_path: Path) -> None: + config_path = write_config( + tmp_path, + """ +human_user: ligon +mirror_root: ./mirrors +mirrors: + sample: + canonical_repo: ./canonical +""", + ) + cfg = load_config(config_path) + assert cfg.mirrors["sample"].mcp_servers == {} + assert cfg.mcp_servers == {} + + +def test_load_config_global_mcp_servers_inherited(tmp_path: Path) -> None: + config_path = write_config( + tmp_path, + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: + shared-tool: + command: my-tool + args: ["--mode", "shared"] +mirrors: + sample: + canonical_repo: ./canonical +""", + ) + cfg = load_config(config_path) + # Global servers inherited by mirror that doesn't define its own + assert "shared-tool" in cfg.mirrors["sample"].mcp_servers + assert cfg.mirrors["sample"].mcp_servers["shared-tool"].command == "my-tool" + + +def test_load_config_per_mirror_mcp_servers_overrides_global(tmp_path: Path) -> None: + config_path = write_config( + tmp_path, + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: + shared-tool: + command: global-tool +mirrors: + sample: + canonical_repo: ./canonical + mcp_servers: + local-tool: + command: local-tool +""", + ) + cfg = load_config(config_path) + sample = cfg.mirrors["sample"] + # Per-mirror fully replaces global (same pattern as skills) + assert "local-tool" in sample.mcp_servers + assert "shared-tool" not in sample.mcp_servers + + +@pytest.mark.parametrize( + "yaml_content, message", + [ + ( + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: not-a-dict +""", + "`mcp_servers` must be a mapping", + ), + ( + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: + bad: + command: 123 +""", + "`mcp_servers.bad.command` must be a non-empty string", + ), + ( + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: + bad: + command: ok + args: "not-a-list" +""", + "`mcp_servers.bad.args` must be a list of strings", + ), + ( + """ +human_user: ligon +mirror_root: ./mirrors +mcp_servers: + bad: + command: ok + env: "not-a-dict" +""", + "`mcp_servers.bad.env` must be a mapping", + ), + ], +) +def test_load_config_invalid_mcp_servers( + tmp_path: Path, yaml_content: str, message: str, +) -> None: + config_path = write_config(tmp_path, yaml_content) + with pytest.raises(ConfigError, match=message): + load_config(config_path) + + +def test_load_config_mcp_config_flag_template(tmp_path: Path) -> None: + config_path = write_config( + tmp_path, + """ +human_user: ligon +mirror_root: ./mirrors +mirrors: + sample: + canonical_repo: ./canonical + agent_launcher: + flags: + mcp_config: "--mcp-config {path}" +""", + ) + cfg = load_config(config_path) + assert cfg.mirrors["sample"].agent_launcher.flags.mcp_config == "--mcp-config {path}" diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 1ca7203..00434f7 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -9,7 +9,7 @@ import pytest import sucoder.mirror as mirror -from sucoder.config import AgentLauncher, BranchPrefixes, Config, MirrorSettings, NvmConfig +from sucoder.config import AgentLauncher, BranchPrefixes, Config, McpServerConfig, MirrorSettings, NvmConfig from sucoder.executor import CommandError, CommandExecutor, CommandResult from sucoder.mirror import ( MirrorError, @@ -2675,3 +2675,162 @@ def test_worktrees_summary_include_main(tmp_path: Path) -> None: assert "main" in output # The "(main worktree)" label should appear assert "(main worktree)" in output + + +# ---- MCP config tests ---- + +import json + + +def test_resolve_mcp_config_returns_none_when_empty(tmp_path: Path) -> None: + """No MCP servers configured means no config file generated.""" + manager = build_manager(tmp_path) + ctx = manager.context_for("sample") + manager.ensure_clone(ctx) + + assert ctx.settings.mcp_servers == {} + result = manager._resolve_mcp_config(ctx) + assert result is None + + +def test_resolve_mcp_config_generates_json(tmp_path: Path) -> None: + """MCP servers in config produce a valid .sucoder-mcp.json file.""" + manager = build_manager(tmp_path) + ctx = manager.context_for("sample") + manager.ensure_clone(ctx) + + ctx.settings.mcp_servers = { + "filesystem": McpServerConfig( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem", "/data"], + ), + "github": McpServerConfig( + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + env={"GITHUB_TOKEN": "tok-123"}, + ), + } + + result = manager._resolve_mcp_config(ctx) + assert result is not None + assert result.name == ".sucoder-mcp.json" + assert result.exists() + + data = json.loads(result.read_text(encoding="utf-8")) + assert "mcpServers" in data + assert "filesystem" in data["mcpServers"] + assert data["mcpServers"]["filesystem"]["command"] == "npx" + assert data["mcpServers"]["filesystem"]["args"] == [ + "-y", "@modelcontextprotocol/server-filesystem", "/data", + ] + # env omitted when empty + assert "env" not in data["mcpServers"]["filesystem"] + + assert data["mcpServers"]["github"]["env"] == {"GITHUB_TOKEN": "tok-123"} + + # Verify git exclude + exclude_file = ctx.mirror_path / ".git" / "info" / "exclude" + assert ".sucoder-mcp.json" in exclude_file.read_text(encoding="utf-8") + + +def test_resolve_mcp_config_idempotent_exclude(tmp_path: Path) -> None: + """Calling _resolve_mcp_config twice doesn't duplicate the exclude entry.""" + manager = build_manager(tmp_path) + ctx = manager.context_for("sample") + manager.ensure_clone(ctx) + + ctx.settings.mcp_servers = { + "tool": McpServerConfig(command="my-tool"), + } + + manager._resolve_mcp_config(ctx) + manager._resolve_mcp_config(ctx) + + exclude_file = ctx.mirror_path / ".git" / "info" / "exclude" + content = exclude_file.read_text(encoding="utf-8") + assert content.count(".sucoder-mcp.json") == 1 + + +def test_merge_flag_templates_includes_mcp_config() -> None: + """mcp_config participates in the three-level merge.""" + from sucoder.config import AgentFlagTemplates + from sucoder.mirror import _merge_flag_templates + + per_mirror = AgentFlagTemplates() + global_config = AgentFlagTemplates() + profile = AgentFlagTemplates(mcp_config="--mcp-config {path}") + + merged = _merge_flag_templates(per_mirror, global_config, profile) + assert merged.mcp_config == "--mcp-config {path}" + + # Per-mirror overrides profile + per_mirror2 = AgentFlagTemplates(mcp_config="--custom-mcp {path}") + merged2 = _merge_flag_templates(per_mirror2, global_config, profile) + assert merged2.mcp_config == "--custom-mcp {path}" + + +def test_launch_agent_includes_mcp_config_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """When MCP servers are configured, --mcp-config appears in the Claude command.""" + manager = build_manager(tmp_path) + ctx = manager.context_for("sample") + manager.ensure_clone(ctx) + + ctx.settings.agent_launcher = AgentLauncher(command=["claude"]) + ctx.settings.mcp_servers = { + "test-tool": McpServerConfig(command="test-server", args=["--port", "8080"]), + } + + calls = [] + + def fake_run_agent(args, **kwargs): + calls.append(list(args)) + return CommandResult( + requested_args=list(args), + executed_args=list(args), + stdout="", + stderr="", + returncode=0, + ) + + monkeypatch.setattr(manager.executor, "run_agent", fake_run_agent) + + manager.launch_agent(ctx, sync=False) + + assert len(calls) == 1 + cmd = calls[0] + assert "--mcp-config" in cmd + mcp_idx = cmd.index("--mcp-config") + mcp_path = cmd[mcp_idx + 1] + assert mcp_path.endswith(".sucoder-mcp.json") + + # Verify the file contents + data = json.loads(Path(mcp_path).read_text(encoding="utf-8")) + assert data["mcpServers"]["test-tool"]["command"] == "test-server" + + +def test_launch_agent_no_mcp_flag_without_servers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Without MCP servers, no --mcp-config flag is added.""" + manager = build_manager(tmp_path) + ctx = manager.context_for("sample") + manager.ensure_clone(ctx) + + ctx.settings.agent_launcher = AgentLauncher(command=["claude"]) + + calls = [] + + def fake_run_agent(args, **kwargs): + calls.append(list(args)) + return CommandResult( + requested_args=list(args), + executed_args=list(args), + stdout="", + stderr="", + returncode=0, + ) + + monkeypatch.setattr(manager.executor, "run_agent", fake_run_agent) + + manager.launch_agent(ctx, sync=False) + + assert len(calls) == 1 + assert "--mcp-config" not in calls[0]