8.2 KiB
0014: Per-CLI MCP server injection for passthrough mode
Status: accepted Date: 2026-05-29 Area: backend
Context
Kandev runs agent CLIs in two modes: ACP (kandev speaks the Agent Client Protocol to the CLI and passes MCP servers via session/new) and passthrough (the raw CLI runs directly in a PTY and the user interacts with its native TUI). MCP servers configured on an agent profile's settings page were only wired into ACP mode. In passthrough mode the single writePassthroughMCPConfig helper hardcoded one server — kandev's own HTTP tools server — into a Claude-shaped JSON file, gated on a MCPConfigFlag Param that only claude-acp declared. Codex, Cursor, and OpenCode passthrough sessions received no MCP servers at all, and even Claude never received the profile's configured servers.
The naive fix — "write a JSON file and pass --mcp-config" — only works for Claude. Each CLI loads MCP servers differently, uses a different config schema, and has a different "don't touch the user's global config" escape hatch:
- Claude Code —
--mcp-config <file>flag (variadic, additive);{"mcpServers": {...}}schema. - Codex — no MCP-config-file flag at all; servers come from
~/.codex/config.tomlor repeatable-c key=valueCLI overrides. - Cursor (
cursor-agent) — no MCP flag and no reliable MCP env var; only auto-discovers a project-local.cursor/mcp.json. - OpenCode — no config CLI flag; config file is selected via the
OPENCODE_CONFIGenv var; schema uses anmcpblock withlocal/remotetypes.
A hard requirement was that kandev must never modify the user's global CLI config (~/.claude.json, ~/.codex/config.toml, ~/.cursor/mcp.json, ~/.config/opencode).
Decision
Each passthrough-capable agent declares a PassthroughMCPStrategy that materializes the resolved MCP server list into that CLI's native shape (config files, CLI args, and/or env vars), without writing to the user's global config. The runtime resolves servers once and delegates emission to the strategy.
The strategy interface (internal/agent/mcpconfig/passthrough.go) is pure — it returns descriptors; the runtime performs all I/O:
type PassthroughMCPStrategy interface {
BuildPassthroughMCP(servers []types.McpServer, paths PassthroughPaths) (PassthroughArtifacts, error)
}
// PassthroughArtifacts: Files (to write, with SkipIfExists), Args (appended to cmd), Env (merged into process env)
Four implementations:
| CLI | Mechanism | Schema | Global config touched |
|---|---|---|---|
| Claude | temp file + --mcp-config <path> args |
{"mcpServers":{…}}, streamable_http→streamable-http |
No |
| Codex | repeated -c mcp_servers.<name>.<key>=<json> args |
transport inferred from command vs url; no type key |
No |
| Cursor | project-local <workspace>/.cursor/mcp.json (merged into an existing file, else created) |
{"mcpServers":{…}} |
No |
| OpenCode | temp file + OPENCODE_CONFIG env var |
{"mcp":{name:{type:"local"|"remote",…}}} |
No |
Runtime wiring (internal/agent/runtime/lifecycle/manager_passthrough.go, applyPassthroughMCP):
- Resolve once, reuse the ACP path. Profile servers come from the same
resolveMcpServersWithParamsACP already uses (transport policy, allow/deny, URL rewrite, env injection all apply identically). Kandev's own HTTP tools server is prepended; a profile server namedkandevis dropped so it cannot shadow the real one. - Strategy emits, runtime writes. Files are written 0600 under a kandev-owned temp dir (Claude/OpenCode) or the worktree (Cursor); kandev-created files are tracked (unioned across relaunch/resume/restart) in
execution.Metadatafor cleanup. An existing symlink at a target path is never written or read through.MergeKeyfiles (Cursor) merge into an existing user file and are not tracked for cleanup. Strategy env is merged inbuildPassthroughEnv; strategy args are appended last to the command. Each strategy alsoDescribe()s its mechanism, surfaced on the profile MCP card (passthrough_config.mcp_injection) so users see how MCP is wired. - Cleanup removes only kandev-written files when the execution is removed.
The old MCPConfigFlag Param / CmdBuilder.MCPConfig / PassthroughOptions.MCPConfigPath mechanism was removed in favor of MCPStrategy and PassthroughOptions.MCPArgs.
Key sub-decisions
- MCP args are appended last in the built command. Claude's
--mcp-configis variadic and would otherwise swallow a positional prompt as an extra config path. Codex's-coverrides are order-insensitive, so trailing placement is safe for both. (Only Claude and Codex emit args; Cursor/OpenCode use files/env.) - Claude injection is additive (no
--strict-mcp-config). Kandev's servers merge with the user's~/.claude.jsonand project.mcp.jsonrather than replacing them.ClaudeStrategy{Strict: true}is available if isolation is ever wanted, but the friendlier default preserves the user's own servers. - Cursor merges into an existing
.cursor/mcp.json. When the file is absent kandev creates it; when present, kandev's servers are merged into itsmcpServers(user entries preserved, ours win on name collision) viaPassthroughConfigFile.MergeKey, so kandev tools are available even when the user has their own project config. An existing symlink at the path is refused (never written/read through), and a merged user file is not tracked for cleanup. (Earlier this skipped-if-exists, which left Cursor without kandev tools whenever a project file pre-existed.) - Passthrough must open the agent updates stream for MCP to work. The agentctl instance serves
/mcpand proxies tool calls to the backend over the agent updates WebSocket, drained only while that stream is open. The ACP path opens it; passthrough originally opened only the workspace stream, sotools/listworked (local) buttools/callhung.applyPassthroughMCP's launch/resume/fallback paths now also open a passthrough-safe MCP stream (connectMCPStream) — it wires the MCP handler but is log-only on disconnect (it must not mark the execution failed, since passthrough completion is PTY-idle).
Consequences
- All four CLIs receive kandev's tools server plus the profile's MCP servers in passthrough mode; previously three received nothing.
- Adding a passthrough CLI with a new MCP mechanism = implement one
PassthroughMCPStrategyand set it on the agent'sPassthroughConfig. No runtime changes. - The kandev HTTP server requires the standalone port; passthrough launch still errors when the port is unavailable (preserved behavior).
execution.Metadatastores the written-file list and env map; getters decode both in-memory ([]string,map[string]string) and JSON-rehydrated ([]interface{},map[string]interface{}) shapes so cleanup survives a backend restart.- A
.cursor/mcp.jsonkandev merged into (rather than created) persists after the session with kandev's entry; it is re-merged (port refreshed) on the next launch. Un-merging on teardown was judged not worth the complexity.
Alternatives considered
CODEX_HOMEpointing at a kandev temp dir (rejected). It relocates Codex's entire config dir — includingauth.json— so a bare temp dir leaves Codex unauthenticated, forcing kandev to seed/symlink the user's credentials. Repeatable-coverrides inject only the MCP servers, touch nothing else, and never write to disk.- Keep the single Claude-shaped writer and bolt on per-CLI special cases (rejected). The output schemas and delivery mechanisms diverge enough (TOML-via-flags vs JSON file vs env-selected JSON vs project file) that a strategy per agent is clearer than branching inside one writer.
- A
kindenum dispatched by the runtime (rejected). A true strategy object per agent keeps the per-CLI emission logic with its schema and avoids a growing switch in the runtime. - Emit Codex's
experimental_use_rmcp_clientfor remote servers (deferred). Some intermediate Codex versions gated streamable-HTTP MCP behind that flag; current Codex loadsurlservers natively, and kandev launches the latest vianpx. Emitting a global experimental flag risks affecting the user's other servers or erroring on versions where the key changed. Documented at the call site instead.