From a983a1bb68a0210154c32bc3378600ce93a72ac5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 8 Sep 2026 08:41:01 -0500 Subject: [PATCH] fix(installer): write OpenCode 2 native MCP shape with codemode:false (#1698) (#1768) OpenCode 2 exposes MCP tools through Code Mode by default; `codemode: false` only survives on `mcp.servers.` with `disabled`. The installer wrote the v1 `mcp.codegraph` + `enabled` shape, so the opt-out was dropped on normalize. Write `mcp.servers.codegraph` with `disabled: false` and `codemode: false`, migrate a leftover v1 entry on re-install, uninstall either shape, and keep printConfig / README / AGENTS.md in sync. Co-authored-by: Colby McHenry --- AGENTS.md | 2 +- README.md | 2 +- __tests__/installer-targets.test.ts | 160 +++++++++++++++++++++++++++- src/installer/targets/opencode.ts | 105 ++++++++++++++---- 4 files changed, 239 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea6186f..6e42d21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Defined in `src/types.ts`. Both extractors and resolvers must use these exact st - `targets/types.ts` defines the `AgentTarget` interface — adding a 5th agent (Continue, Zed, Windsurf…) is **one new file in `targets/` + one entry in `registry.ts`**. Each target owns its config-file location and MCP-server JSON/TOML/JSONC writing. (Targets no longer write an instructions file — see below.) - Current targets: `claude.ts`, `cursor.ts`, `codex.ts`, `opencode.ts`. - `targets/toml.ts` is a hand-rolled TOML serializer scoped to `[mcp_servers.codegraph]` (used by Codex). Sibling tables and `[[array_of_tables]]` are preserved verbatim. No new dependency. -- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. +- opencode reads `opencode.jsonc` by default; the installer prefers existing `.jsonc`, falls back to `.json`, and creates `.jsonc` for greenfield installs. Edits are surgical via `jsonc-parser` so user comments and formatting survive install/re-install/uninstall round-trips. The MCP entry is OpenCode 2's native `mcp.servers.codegraph` with `disabled: false` and `codemode: false` (so `codegraph_explore` stays on the native tool list); a pre-#1698 `mcp.codegraph` + `enabled` entry is migrated on re-install and removed by uninstall. - `instructions-template.ts` no longer holds an instructions body — it exports only the ``/`` markers. The installer **stopped writing** a `## CodeGraph` block into each agent's instructions file (`CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.config/opencode/AGENTS.md` / `~/.gemini/GEMINI.md` / `.cursor/rules/codegraph.mdc` / Kiro steering doc) because it duplicated the MCP `initialize` instructions verbatim (issue #529). Each target's `install` (self-heal on upgrade) and `uninstall` use the markers to **strip** a block a previous install left behind. `server-instructions.ts` is the single source of truth for agent-facing guidance. - All installer changes need matching coverage in `__tests__/installer-targets.test.ts` — there are ~47 parameterized contract tests covering install idempotency, sibling preservation, uninstall reverses install, byte-equal re-runs returning `unchanged`, and partial-state recovery for Codex. diff --git a/README.md b/README.md index 80fa506..1f13305 100644 --- a/README.md +++ b/README.md @@ -854,7 +854,7 @@ is written): - **Claude Code** - **Cursor** - **Codex CLI** -- **opencode** +- **opencode** — MCP entry is OpenCode 2's `mcp.servers.codegraph` with `codemode: false` (keeps `codegraph_explore` on the native tool list; `codegraph install` migrates the older `mcp.codegraph` shape) - **Hermes Agent** - **Gemini CLI** - **Antigravity IDE** diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 4ec3e59..cd9a32c 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -138,6 +138,8 @@ describe('Installer targets — contract', () => { // opencode uses `mcp` not `mcpServers`. Match its shape too. if (target.id === 'opencode') { delete seed.mcpServers; + // Keep a v1-shaped sibling — real configs mix shapes during the + // OpenCode 1→2 transition; install must not disturb it (#1698). seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } // VS Code's mcp.json uses `servers`; the JetBrains Copilot @@ -153,7 +155,10 @@ describe('Installer targets — contract', () => { const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); - expect(after.mcp.codegraph).toBeDefined(); + expect(after.mcp.servers.codegraph).toBeDefined(); + expect(after.mcp.servers.codegraph.codemode).toBe(false); + expect(after.mcp.servers.codegraph.disabled).toBe(false); + expect(after.mcp.codegraph).toBeUndefined(); } else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') { expect(after.servers.other).toBeDefined(); expect(after.servers.codegraph).toBeDefined(); @@ -875,7 +880,7 @@ describe('Installer targets — partial-state idempotency', () => { expect(body).toContain(' telegram:\n - hermes-telegram'); }); - it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { + it('opencode: uninstall removes only mcp.servers.codegraph, preserves comments and siblings', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); fs.mkdirSync(dir, { recursive: true }); @@ -892,13 +897,15 @@ describe('Installer targets — partial-state idempotency', () => { ].join('\n')); opencode.install('global', { autoAllow: true }); - const afterInstall = fs.readFileSync(file, 'utf-8'); - expect(afterInstall).toContain('"codegraph"'); - expect(afterInstall).toContain('"other"'); + const afterInstall = parseJsonc(fs.readFileSync(file, 'utf-8')); + expect(afterInstall.mcp.servers.codegraph).toBeDefined(); + expect(afterInstall.mcp.servers.codegraph.codemode).toBe(false); + expect(afterInstall.mcp.other).toBeDefined(); opencode.uninstall('global'); const afterUninstall = fs.readFileSync(file, 'utf-8'); expect(afterUninstall).not.toContain('codegraph'); + expect(afterUninstall).not.toContain('"servers"'); expect(afterUninstall).toContain('// important comment'); expect(afterUninstall).toContain('"other"'); }); @@ -1813,6 +1820,149 @@ function listAllFiles(dir: string): string[] { return out; } +// --------------------------------------------------------------------------- +// opencode OpenCode 2 native MCP shape (#1698) +// +// OpenCode 2 reads `mcp.servers.` with `disabled` / `codemode`. The +// v1 `mcp.` + `enabled` shape still connects but drops `codemode` +// during normalization — so the installer must write the native shape and +// migrate/uninstall either. +// --------------------------------------------------------------------------- +describe('Installer targets — opencode native MCP shape (#1698)', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + const configFile = () => path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc'); + + it('install writes mcp.servers.codegraph with disabled:false and codemode:false', () => { + const opencode = getTarget('opencode')!; + opencode.install('global', { autoAllow: true }); + const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf-8')); + expect(cfg.mcp.codegraph).toBeUndefined(); + expect(cfg.mcp.servers.codegraph).toEqual({ + type: 'local', + command: ['codegraph', 'serve', '--mcp'], + disabled: false, + codemode: false, + }); + }); + + it('printConfig shows the native OpenCode 2 shape', () => { + const out = getTarget('opencode')!.printConfig('global'); + expect(out).toContain('"servers"'); + expect(out).toContain('"codemode": false'); + expect(out).toContain('"disabled": false'); + expect(out).not.toContain('"enabled"'); + // No v1 top-level mcp.codegraph key in the snippet. + expect(out).not.toMatch(/"mcp"\s*:\s*\{\s*"codegraph"/); + }); + + it('re-install migrates a v1 mcp.codegraph entry to mcp.servers.codegraph', () => { + const dir = path.dirname(configFile()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(configFile(), [ + '{', + ' // keep me', + ' "$schema": "https://opencode.ai/config.json",', + ' "mcp": {', + ' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },', + ' "other": { "type": "local", "command": ["x"], "enabled": true }', + ' }', + '}', + '', + ].join('\n')); + + const opencode = getTarget('opencode')!; + expect(opencode.detect('global').alreadyConfigured).toBe(true); + + const result = opencode.install('global', { autoAllow: true }); + expect(result.files.find((f) => f.path === configFile())!.action).toBe('updated'); + + const text = fs.readFileSync(configFile(), 'utf-8'); + expect(text).toContain('// keep me'); + const cfg = parseJsonc(text); + expect(cfg.mcp.codegraph).toBeUndefined(); + expect(cfg.mcp.other).toBeDefined(); + expect(cfg.mcp.servers.codegraph).toEqual({ + type: 'local', + command: ['codegraph', 'serve', '--mcp'], + disabled: false, + codemode: false, + }); + + // Idempotent after migration. + const second = opencode.install('global', { autoAllow: true }); + expect(second.files.find((f) => f.path === configFile())!.action).toBe('unchanged'); + }); + + it('uninstall removes a leftover v1 mcp.codegraph entry', () => { + const dir = path.dirname(configFile()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(configFile(), [ + '{', + ' // keep me', + ' "$schema": "https://opencode.ai/config.json",', + ' "mcp": {', + ' "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true },', + ' "other": { "type": "local", "command": ["x"], "enabled": true }', + ' }', + '}', + '', + ].join('\n')); + + const opencode = getTarget('opencode')!; + opencode.uninstall('global'); + const text = fs.readFileSync(configFile(), 'utf-8'); + expect(text).toContain('// keep me'); + expect(text).toContain('"other"'); + expect(text).not.toContain('codegraph'); + expect(opencode.detect('global').alreadyConfigured).toBe(false); + }); + + it('uninstall removes a native mcp.servers.codegraph entry and an emptied servers wrapper', () => { + const dir = path.dirname(configFile()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(configFile(), JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + mcp: { + servers: { + codegraph: { + type: 'local', + command: ['codegraph', 'serve', '--mcp'], + disabled: false, + codemode: false, + }, + }, + }, + }, null, 2) + '\n'); + + const opencode = getTarget('opencode')!; + opencode.uninstall('global'); + const text = fs.readFileSync(configFile(), 'utf-8'); + expect(text).not.toContain('codegraph'); + expect(text).not.toContain('"servers"'); + expect(text).not.toContain('"mcp"'); + expect(opencode.detect('global').alreadyConfigured).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // opencode global config path — XDG on every platform (#535) // diff --git a/src/installer/targets/opencode.ts b/src/installer/targets/opencode.ts index a44558a..9fbe3aa 100644 --- a/src/installer/targets/opencode.ts +++ b/src/installer/targets/opencode.ts @@ -19,15 +19,26 @@ * instructions — same convention Codex CLI uses. * - No permissions concept. * - * Config shape uses opencode's wrapper: + * Config shape uses OpenCode 2's native wrapper (also read by 1.18+): * { * "$schema": "https://opencode.ai/config.json", - * "mcp": { "codegraph": { "type": "local", "command": [...], "enabled": true } } + * "mcp": { + * "servers": { + * "codegraph": { + * "type": "local", + * "command": [...], + * "disabled": false, + * "codemode": false + * } + * } + * } * } * - * The shape differs from Claude/Cursor — opencode uses `mcp.` - * (not `mcpServers`), takes `command` as a string array combining - * binary + args, and includes an explicit `enabled` flag. + * OpenCode 2 puts servers under `mcp.servers` (not `mcp.`), uses + * `disabled` instead of `enabled`, and defaults tools through Code Mode — + * `codemode: false` keeps `codegraph_explore` on the provider's native + * tool list (#1698). Pre-#1698 installs wrote the v1 `mcp.codegraph` + + * `enabled` shape; re-install migrates, uninstall removes either. * * Reads + writes go through `jsonc-parser` so any `//` and `/* *\/` * comments the user has added to their `.jsonc` survive idempotent @@ -115,14 +126,27 @@ function parseConfig(text: string): Record { return result as Record; } -function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } { +function getOpencodeServerEntry(): { + type: string; + command: string[]; + disabled: boolean; + codemode: boolean; +} { return { type: 'local', command: ['codegraph', 'serve', '--mcp'], - enabled: true, + disabled: false, + // Keep codegraph_explore on the native tool list — OpenCode 2's + // default Code Mode would otherwise hide the one-tool server (#1698). + codemode: false, }; } +/** True when either the OpenCode 2 native entry or a pre-#1698 v1 entry is present. */ +function hasCodegraphEntry(config: Record): boolean { + return !!(config.mcp?.servers?.codegraph || config.mcp?.codegraph); +} + const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; class OpencodeTarget implements AgentTarget { @@ -137,7 +161,7 @@ class OpencodeTarget implements AgentTarget { detect(loc: Location): DetectionResult { const file = configPath(loc); const config = parseConfig(readConfigText(file)); - const alreadyConfigured = !!config.mcp?.codegraph; + const alreadyConfigured = hasCodegraphEntry(config); // Global: the XDG dir is what current opencode creates on first run; the // legacy %APPDATA% dir still counts as "opencode present" so a re-install // can sweep the stale pre-#535 entry out of it. @@ -176,7 +200,7 @@ class OpencodeTarget implements AgentTarget { const target = configPath(loc); const snippet = JSON.stringify({ $schema: 'https://opencode.ai/config.json', - mcp: { codegraph: getOpencodeServerEntry() }, + mcp: { servers: { codegraph: getOpencodeServerEntry() } }, }, null, 2); return `# Add to ${target}\n\n${snippet}\n`; } @@ -199,10 +223,12 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] { } const config = parseConfig(text); - const before = config.mcp?.codegraph; + const before = config.mcp?.servers?.codegraph; const after = getOpencodeServerEntry(); + const hasLegacy = !!config.mcp?.codegraph; - if (jsonDeepEqual(before, after)) { + // Native entry already matches and no v1 leftover → nothing to do. + if (jsonDeepEqual(before, after) && !hasLegacy) { return { path: file, action: 'unchanged' }; } @@ -214,9 +240,18 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] { text = applyEdits(text, schemaEdits); } + // Migrate pre-#1698 `mcp.codegraph` (+ enabled) off the file so OpenCode 2 + // keeps only the native entry where `codemode` survives normalization. + if (hasLegacy) { + const legacyEdits = modify(text, ['mcp', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + text = applyEdits(text, legacyEdits); + } + // Surgical edit — preserves comments, formatting, and order of // every key we don't touch. - const edits = modify(text, ['mcp', 'codegraph'], after, { + const edits = modify(text, ['mcp', 'servers', 'codegraph'], after, { formattingOptions: FORMATTING, }); const updated = applyEdits(text, edits); @@ -226,26 +261,50 @@ function writeMcpEntry(loc: Location): WriteResult['files'][number] { } /** - * Surgically drop `mcp.codegraph` from one config file. Leaves sibling - * servers, comments, and formatting untouched; drops an emptied `mcp` - * wrapper too. Shared by uninstall and the legacy-%APPDATA% sweep. + * Surgically drop our CodeGraph entry from one config file — either the + * OpenCode 2 native `mcp.servers.codegraph` or a pre-#1698 `mcp.codegraph`. + * Leaves sibling servers, comments, and formatting untouched; drops emptied + * `mcp.servers` / `mcp` wrappers too. Shared by uninstall and the + * legacy-%APPDATA% sweep. */ function removeMcpEntryAt(file: string): WriteResult['files'][number] { if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; - const text = readConfigText(file); + let text = readConfigText(file); const config = parseConfig(text); - if (!config.mcp?.codegraph) return { path: file, action: 'not-found' }; + if (!hasCodegraphEntry(config)) return { path: file, action: 'not-found' }; - let edits = modify(text, ['mcp', 'codegraph'], undefined, { - formattingOptions: FORMATTING, - }); - let updated = applyEdits(text, edits); + let updated = text; + if (config.mcp?.servers?.codegraph) { + const edits = modify(updated, ['mcp', 'servers', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + updated = applyEdits(updated, edits); + } + // Re-parse after the native removal so a file that held BOTH shapes + // (unusual, but possible mid-migration) still drops the v1 leftover. + const mid = parseConfig(updated); + if (mid.mcp?.codegraph) { + const edits = modify(updated, ['mcp', 'codegraph'], undefined, { + formattingOptions: FORMATTING, + }); + updated = applyEdits(updated, edits); + } + + // If `mcp.servers` is now an empty object, drop that wrapper. + let afterParsed = parseConfig(updated); + if (afterParsed.mcp?.servers && typeof afterParsed.mcp.servers === 'object' && + Object.keys(afterParsed.mcp.servers).length === 0) { + const edits = modify(updated, ['mcp', 'servers'], undefined, { + formattingOptions: FORMATTING, + }); + updated = applyEdits(updated, edits); + afterParsed = parseConfig(updated); + } // If `mcp` is now an empty object, drop the wrapper too. - const afterParsed = parseConfig(updated); if (afterParsed.mcp && typeof afterParsed.mcp === 'object' && Object.keys(afterParsed.mcp).length === 0) { - edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING }); + const edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING }); updated = applyEdits(updated, edits); }