Sfoglia il codice sorgente

fix(installer): write Claude project-local MCP config to .mcp.json (#207) (#209)

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) <noreply@anthropic.com>
Colby Mchenry 1 mese fa
parent
commit
79b9601aae

+ 11 - 0
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 `//`

+ 81 - 0
__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', () => {

+ 13 - 13
__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');

+ 3 - 1
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;
 }

+ 66 - 10
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);