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>
This commit is contained in:
co-authored by
Colby McHenry
parent
28033f62f8
commit
a983a1bb68
@@ -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.
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
@@ -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, {
|
||||
let updated = text;
|
||||
if (config.mcp?.servers?.codegraph) {
|
||||
const edits = modify(updated, ['mcp', 'servers', 'codegraph'], undefined, {
|
||||
formattingOptions: FORMATTING,
|
||||
});
|
||||
let updated = applyEdits(text, edits);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user