Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.mcp.json filter=git-crypt diff=git-crypt
19 changes: 19 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
74 changes: 74 additions & 0 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <your-gpg-key-id>

# 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
Expand Down
12 changes: 12 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<token>"
71 changes: 69 additions & 2 deletions sucoder/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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,
),
}

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -373,14 +389,18 @@ 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
if data.get("agent_launcher") is not None:
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:
Expand All @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand All @@ -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,
)

Expand Down Expand Up @@ -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"),
)


Expand Down Expand Up @@ -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
51 changes: 51 additions & 0 deletions sucoder/mirror.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import datetime as _dt
import json
import logging
import os
import pwd
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"),
)
Loading
Loading