ADR 0074: Claude Code and GitHub Copilot co-own the shared .mcp.json, through one stdio writer
Status: accepted; extends ADR 0031 (one typed provider registry), ADR 0045 (the MCP server is core infrastructure, re-wired on every refresh), and the typed MCP status of ADR 0072
Context
§Phase C wires Cursor and GitHub Copilot. Both read the canonical AGENTS.md and the cross-tool .agents/skills/ natively (reuse-canonical, ADR 0070), so the only genuinely new per-agent work is the MCP registration.
Copilot's MCP target is unusual. Each agent discern had wired so far keeps its project MCP server in a file of its own — Claude .mcp.json, Codex .codex/config.toml, Gemini .gemini/settings.json — so each register() owned its file outright. The Copilot CLI instead reads the same project-committable .mcp.json Claude Code reads (its .github/mcp.json is silently ignored — copilot-cli #1886). So for the first time two providers target one committed file. Two failure modes fall out:
- If each provider wrote
.mcp.jsonthrough its own code, the two code paths could drift into non-identical entries, and the order they run in could decide which wins — a silent, order-dependent diff in a committed file. - Copilot must not write Claude's
enabledMcpjsonServerspre-approval key: Copilot gates committed config behind a one-time folder trust, not that Claude-specific settings key. CopyingregisterClaudeCodeMcpwholesale would seed a key that means nothing to Copilot.
Decision
§One shared writer emits the byte-identical .mcp.json entry, and both Claude and Copilot point at the same file through it; Copilot adds no pre-approval key.
registerStdioMcpJson(root, configFile, server)is the single function that writes{ type: "stdio", command, args }into a JSON file'smcpServers.<name>map — a deep-merge that preserves every other server and top-level key, idempotent, reportingfirstInstallwhen the server name was absent. It is the one place the.mcp.json-shaped entry is defined.- Claude Code, Cursor, and Copilot all register through it. Claude and Copilot pass the same
MCP_JSON_FILE(.mcp.json); Cursor passes its own.cursor/mcp.json(same shape, including the explicittype: "stdio"Cursor requires). So a.mcp.jsonco-owned by Claude and Copilot can only ever carry one byte-identicaldiscernentry: whichever provider wires second finds it already correct and writes nothing — order-independent, and a re-wire of both is a clean no-op. - Only Claude adds
enabledMcpjsonServers.registerClaudeCodeMcpcalls the shared writer for.mcp.json, then pre-approves the server by name in.claude/settings.json— its own step, layered on top.registerCopilotMcpis just the shared writer: Copilot's gate is the folder trust named in itstrusthint, so no settings key is written for it.
The explicit nos:
- Gemini stays separate.
.gemini/settings.jsonomitstype(Gemini infers stdio fromcommand) and is not a.mcp.json-shaped file, so it keeps its ownregisterGeminiMcp— the shared writer is for the three that emit the identical{ type, command, args }shape, not a universal MCP writer. - No second
.github/mcp.json. The Copilot CLI ignores it; writing it would be dead config. Copilot's session hook is a separate, discern-owned.github/hooks/discern.json(Copilot loads every.github/hooks/*.json).
Consequences
§- Two agents share one committed file, safely. Because the entry is produced by one writer and merged (never clobbered), a repo that configures both Claude and Copilot gets a single
mcpServers.discernentry no matter which provider runs first, and every subsequent refresh is a no-op. ThefirstInstallrestart signal still fires exactly once — for whichever provider adds the server. - One source for the
.mcp.jsonshape. The stdio entry is defined inregisterStdioMcpJsonalone, so Claude's and Copilot's (and Cursor's) entries cannot drift apart — the single-source guarantee the co-ownership needs. - Adding a future
.mcp.json-reading agent is a one-line provider entry. It points itsmcp.integrationatMCP_JSON_FILEthrough the shared writer and inherits the same idempotent co-ownership. - The trust gap stays honest per agent. Copilot's
trusthint names the folder-trust step (and the--allow-all-*headless bypass), sodoctorsurfaces why the committed.mcp.jsonis inert until trusted — distinct from Claude, which pre-approves and needs no trust prompt.
Alternatives considered
§- A bespoke
registerCopilotMcpcopied from Claude's. Rejected: two code paths writing the same committed file is exactly the drift the single-source policy forbids; a shared writer makes the entries provably identical. - Give Copilot its own MCP file (
.github/mcp.json). Rejected: the Copilot CLI ignores it (copilot-cli #1886), so the server would never load — dead config that only looks wired. - Have Copilot also write
enabledMcpjsonServers"to be safe". Rejected: that key is Claude's pre-approval mechanism and is meaningless to Copilot, which gates on folder trust; writing it would misrepresent how Copilot honours the server and churn a file for no effect.