Skip to content

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.json through 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 enabledMcpjsonServers pre-approval key: Copilot gates committed config behind a one-time folder trust, not that Claude-specific settings key. Copying registerClaudeCodeMcp wholesale 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's mcpServers.<name> map — a deep-merge that preserves every other server and top-level key, idempotent, reporting firstInstall when 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 explicit type: "stdio" Cursor requires). So a .mcp.json co-owned by Claude and Copilot can only ever carry one byte-identical discern entry: 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. registerClaudeCodeMcp calls the shared writer for .mcp.json, then pre-approves the server by name in .claude/settings.json — its own step, layered on top. registerCopilotMcp is just the shared writer: Copilot's gate is the folder trust named in its trust hint, so no settings key is written for it.

The explicit nos:

  • Gemini stays separate. .gemini/settings.json omits type (Gemini infers stdio from command) and is not a .mcp.json-shaped file, so it keeps its own registerGeminiMcp — 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.discern entry no matter which provider runs first, and every subsequent refresh is a no-op. The firstInstall restart signal still fires exactly once — for whichever provider adds the server.
  • One source for the .mcp.json shape. The stdio entry is defined in registerStdioMcpJson alone, 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 its mcp.integration at MCP_JSON_FILE through the shared writer and inherits the same idempotent co-ownership.
  • The trust gap stays honest per agent. Copilot's trust hint names the folder-trust step (and the --allow-all-* headless bypass), so doctor surfaces why the committed .mcp.json is inert until trusted — distinct from Claude, which pre-approves and needs no trust prompt.

Alternatives considered

§
  • A bespoke registerCopilotMcp copied 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.
choose openEsc close