From 79b9601aae5a2cadbcbf66fd64615c23cc7aaf55 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 20 May 2026 09:47:03 -0500 Subject: [PATCH] fix(installer): write Claude project-local MCP config to .mcp.json (#207) (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-local installs wrote the MCP server to ./.claude.json, which Claude Code never reads — project-scoped servers must live in .mcp.json. The codegraph tools silently never loaded until users renamed the file by hand. Local installs now write ./.mcp.json and migrate any stale ./.claude.json entry on install and uninstall (siblings preserved). Global installs (~/.claude.json, user scope) were already correct. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 11 ++++ __tests__/installer-targets.test.ts | 81 +++++++++++++++++++++++++++++ __tests__/installer.test.ts | 26 ++++----- src/installer/config-writer.ts | 4 +- src/installer/targets/claude.ts | 76 +++++++++++++++++++++++---- 5 files changed, 174 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c32c15..2f99385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,17 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Thanks to [@essopsp](https://github.com/essopsp) for the repro. ### Fixed +- **Installer (Claude Code)**: project-local installs (`Just this project`) + now write the MCP server to `.mcp.json` in the project root — the file + Claude Code actually reads for project-scoped servers. Previously they + wrote `.claude.json`, which Claude Code ignores, so the codegraph tools + silently never appeared and you had to rename the file by hand to make it + work. Re-running `codegraph install` (or `codegraph init`) on an affected + project migrates the stale `.claude.json` entry into `.mcp.json` + automatically; uninstall cleans up both. Global (`All projects`) installs + were unaffected — they correctly target `~/.claude.json`. Closes + [#207](https://github.com/colbymchenry/codegraph/issues/207). Thanks to + [@Jhsmit](https://github.com/Jhsmit) for the report and the workaround. - **MCP**: source-omission markers in `codegraph_explore` and `codegraph_context` output are now language-neutral (`... (gap) ...`, `... (trimmed) ...`, `... (truncated) ...`) instead of C-style `//` diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 89ba629..d2ee23e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -352,6 +352,87 @@ describe('Installer targets — partial-state idempotency', () => { const after = fs.readFileSync(tomlPath, 'utf-8'); expect(after).not.toContain('enabled = true'); }); + + it('claude: local install writes ./.mcp.json (project scope), not ./.claude.json', () => { + const claude = getTarget('claude')!; + const result = claude.install('local', { autoAllow: false }); + // The MCP entry lands in ./.mcp.json — the file Claude Code reads. + expect(result.files.some((f) => f.path.endsWith('/.mcp.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false); + const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: global install targets ~/.claude.json (user scope)', () => { + const claude = getTarget('claude')!; + claude.install('global', { autoAllow: false }); + const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => { + const claude = getTarget('claude')!; + const legacy = path.join(tmpCwd, '.claude.json'); + fs.writeFileSync( + legacy, + JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2), + ); + + claude.install('local', { autoAllow: false }); + + // codegraph now lives in .mcp.json; the legacy file (which held only + // codegraph) is gone. + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers.codegraph).toBeDefined(); + expect(fs.existsSync(legacy)).toBe(false); + }); + + it('claude: legacy ./.claude.json migration preserves sibling servers and unrelated keys', () => { + const claude = getTarget('claude')!; + const legacy = path.join(tmpCwd, '.claude.json'); + fs.writeFileSync( + legacy, + JSON.stringify({ + mcpServers: { + codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] }, + other: { command: 'x' }, + }, + somethingElse: true, + }, null, 2), + ); + + claude.install('local', { autoAllow: false }); + + // Only codegraph is stripped from the legacy file; siblings survive. + const after = JSON.parse(fs.readFileSync(legacy, 'utf-8')); + expect(after.mcpServers.codegraph).toBeUndefined(); + expect(after.mcpServers.other).toBeDefined(); + expect(after.somethingElse).toBe(true); + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers.codegraph).toBeDefined(); + }); + + it('claude: uninstall strips codegraph from ./.mcp.json and a legacy ./.claude.json', () => { + const claude = getTarget('claude')!; + // A user left with both the working .mcp.json and a stale .claude.json. + fs.writeFileSync( + path.join(tmpCwd, '.mcp.json'), + JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' } } }, null, 2), + ); + fs.writeFileSync( + path.join(tmpCwd, '.claude.json'), + JSON.stringify({ mcpServers: { codegraph: { command: 'codegraph' }, other: { command: 'x' } } }, null, 2), + ); + + claude.uninstall('local'); + + const mcp = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(mcp.mcpServers).toBeUndefined(); + const legacy = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.claude.json'), 'utf-8')); + expect(legacy.mcpServers.codegraph).toBeUndefined(); + expect(legacy.mcpServers.other).toBeDefined(); + }); }); describe('Installer targets — registry', () => { diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 1e0a90e..728ed7c 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -48,21 +48,21 @@ describe('Installer Config Writer', () => { describe('readJsonFile error handling', () => { it('should return empty object for non-existent file', () => { - // writeMcpConfig reads claude.json - if it doesn't exist, it should create it + // writeMcpConfig reads .mcp.json - if it doesn't exist, it should create it writeMcpConfig('local'); - const claudeJson = path.join(tempDir, '.claude.json'); - expect(fs.existsSync(claudeJson)).toBe(true); + const mcpJson = path.join(tempDir, '.mcp.json'); + expect(fs.existsSync(mcpJson)).toBe(true); - const content = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')); + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); expect(content.mcpServers).toBeDefined(); expect(content.mcpServers.codegraph).toBeDefined(); }); it('should handle corrupted JSON by creating backup', () => { - // Create a corrupted claude.json - const claudeJson = path.join(tempDir, '.claude.json'); - fs.writeFileSync(claudeJson, '{ this is not valid json !!!'); + // Create a corrupted .mcp.json + const mcpJson = path.join(tempDir, '.mcp.json'); + fs.writeFileSync(mcpJson, '{ this is not valid json !!!'); // Suppress console.warn during test const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -76,28 +76,28 @@ describe('Installer Config Writer', () => { expect(warnMsg).toContain('Warning'); // Backup should exist - expect(fs.existsSync(claudeJson + '.backup')).toBe(true); + expect(fs.existsSync(mcpJson + '.backup')).toBe(true); // Original backup content should be the corrupted content - const backup = fs.readFileSync(claudeJson + '.backup', 'utf-8'); + const backup = fs.readFileSync(mcpJson + '.backup', 'utf-8'); expect(backup).toContain('this is not valid json'); // New file should be valid JSON with codegraph config - const content = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')); + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); expect(content.mcpServers.codegraph).toBeDefined(); warnSpy.mockRestore(); }); it('should preserve existing valid config when adding codegraph', () => { - const claudeJson = path.join(tempDir, '.claude.json'); - fs.writeFileSync(claudeJson, JSON.stringify({ + const mcpJson = path.join(tempDir, '.mcp.json'); + fs.writeFileSync(mcpJson, JSON.stringify({ mcpServers: { other: { command: 'other-tool' } }, customField: 'preserved', }, null, 2)); writeMcpConfig('local'); - const content = JSON.parse(fs.readFileSync(claudeJson, 'utf-8')); + const content = JSON.parse(fs.readFileSync(mcpJson, 'utf-8')); expect(content.mcpServers.codegraph).toBeDefined(); expect(content.mcpServers.other).toBeDefined(); expect(content.customField).toBe('preserved'); diff --git a/src/installer/config-writer.ts b/src/installer/config-writer.ts index c1f8abc..e9c9e93 100644 --- a/src/installer/config-writer.ts +++ b/src/installer/config-writer.ts @@ -46,9 +46,11 @@ export function writeClaudeMd(location: InstallLocation): { created: boolean; up } export function hasMcpConfig(location: InstallLocation): boolean { + // local scope lives in ./.mcp.json (project scope); global is the + // user-scope ~/.claude.json. Mirrors the Claude target's paths. const file = location === 'global' ? path.join(os.homedir(), '.claude.json') - : path.join(process.cwd(), '.claude.json'); + : path.join(process.cwd(), '.mcp.json'); const config = readJsonFile(file); return !!config.mcpServers?.codegraph; } diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index dcd5c8a..80e2c9d 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -1,16 +1,20 @@ /** - * Claude Code target — the historical default. Writes: + * Claude Code target. Writes: * - * - MCP server entry to `~/.claude.json` (global) or - * `./.claude.json` (local). + * - MCP server entry to `~/.claude.json` (global = user scope, loads + * in every project) or `./.mcp.json` (local = project scope, the + * file Claude Code actually reads for a single project). See the + * scope table at https://code.claude.com/docs/en/mcp. * - Permissions to `~/.claude/settings.json` (global) or * `./.claude/settings.json` (local), gated on `autoAllow`. * - Instructions to `~/.claude/CLAUDE.md` (global) or * `./.claude/CLAUDE.md` (local). * - * All paths and shapes ported verbatim from the original - * `config-writer.ts` so existing Claude Code installs upgrade in - * place — no migration on disk required. + * Earlier versions wrote the local MCP entry to `./.claude.json` — a + * file Claude Code never reads — so the server silently never loaded + * until the user manually renamed it to `.mcp.json` (issue #207). We + * now write `./.mcp.json` and migrate any stale `./.claude.json` entry + * out of the way on install and uninstall. */ import * as fs from 'fs'; @@ -45,9 +49,22 @@ function configDir(loc: Location): string { : path.join(process.cwd(), '.claude'); } function mcpJsonPath(loc: Location): string { + // global → ~/.claude.json (user scope: visible in every project). + // local → ./.mcp.json (project scope: the ONLY project-level MCP + // file Claude Code reads — NOT ./.claude.json, which it ignores). return loc === 'global' ? path.join(os.homedir(), '.claude.json') - : path.join(process.cwd(), '.claude.json'); + : path.join(process.cwd(), '.mcp.json'); +} +/** + * Where pre-#207 installers wrote the local MCP entry. Claude Code + * never reads a project-level `./.claude.json`, so we migrate the + * codegraph entry out of it on install and strip it on uninstall. + * Only the project-local path is legacy — global `~/.claude.json` is + * the correct user-scope location and is left untouched. + */ +function legacyLocalMcpPath(): string { + return path.join(process.cwd(), '.claude.json'); } function settingsJsonPath(loc: Location): string { return path.join(configDir(loc), 'settings.json'); @@ -84,6 +101,14 @@ class ClaudeCodeTarget implements AgentTarget { // 1. MCP server entry files.push(writeMcpEntry(loc)); + // 1b. Migrate away any stale ./.claude.json left by a pre-#207 + // local install, so the project isn't left with two competing + // (one dead) MCP configs. + if (loc === 'local') { + const migrated = cleanupLegacyLocalMcp(); + if (migrated) files.push(migrated); + } + // 2. Permissions (only when autoAllow) if (opts.autoAllow) { files.push(writePermissionsEntry(loc)); @@ -112,6 +137,13 @@ class ClaudeCodeTarget implements AgentTarget { files.push({ path: mcpPath, action: 'not-found' }); } + // 1b. Also strip the codegraph entry from a legacy ./.claude.json + // so uninstall fully reverses a pre-#207 local install. + if (loc === 'local') { + const migrated = cleanupLegacyLocalMcp(); + if (migrated) files.push(migrated); + } + // 2. Permissions const settingsPath = settingsJsonPath(loc); const settings = readJsonFile(settingsPath); @@ -173,9 +205,10 @@ export function writeMcpEntry(loc: Location): WriteResult['files'][number] { return { path: file, action: 'unchanged' }; } // 'created' here means: the file itself did not exist before this - // write. A pre-existing `.claude.json` containing other MCP servers - // (no `codegraph` key) is 'updated', not 'created' — we're adding - // an entry to a file that was already there. Codex uses a different + // write. A pre-existing MCP JSON file (`~/.claude.json` globally, + // `./.mcp.json` locally) containing other MCP servers (no + // `codegraph` key) is 'updated', not 'created' — we're adding an + // entry to a file that was already there. Codex uses a different // idiom (empty-content => 'created') because its config.toml is // ours alone to manage. const action: 'created' | 'updated' = before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); @@ -185,6 +218,29 @@ export function writeMcpEntry(loc: Location): WriteResult['files'][number] { return { path: file, action }; } +/** + * Strip the codegraph entry from a legacy project-local + * `./.claude.json` (written by pre-#207 installers, which Claude Code + * never read). Surgical: only our `codegraph` key is removed; sibling + * MCP servers and any unrelated keys are preserved, and the file is + * deleted only when removal leaves it completely empty. Returns the + * file action for reporting, or `null` when there's nothing to migrate. + */ +function cleanupLegacyLocalMcp(): WriteResult['files'][number] | null { + const file = legacyLocalMcpPath(); + if (!fs.existsSync(file)) return null; + const config = readJsonFile(file); + if (!config.mcpServers?.codegraph) return null; + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) delete config.mcpServers; + if (Object.keys(config).length === 0) { + try { fs.unlinkSync(file); } catch { /* ignore */ } + } else { + writeJsonFile(file, config); + } + return { path: file, action: 'removed' }; +} + export function writePermissionsEntry(loc: Location): WriteResult['files'][number] { const file = settingsJsonPath(loc); const settings = readJsonFile(file);