Browse Source

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.<name>` 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 <colbymchenry@users.noreply.github.com>
Colby Mchenry 7 hours ago
parent
commit
a983a1bb68
4 changed files with 239 additions and 30 deletions
  1. 1 1
      AGENTS.md
  2. 1 1
      README.md
  3. 155 5
      __tests__/installer-targets.test.ts
  4. 82 23
      src/installer/targets/opencode.ts

+ 1 - 1
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 `<!-- CODEGRAPH_START -->`/`<!-- CODEGRAPH_END -->` 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.
 

+ 1 - 1
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**

+ 155 - 5
__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.<name>` with `disabled` / `codemode`. The
+// v1 `mcp.<name>` + `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)
 //

+ 82 - 23
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.<name>`
- * (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.<name>`), 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<string, any> {
   return result as Record<string, any>;
 }
 
-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<string, any>): 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);
   }