diff --git a/CHANGELOG.md b/CHANGELOG.md index 1815c4150..7c5e7e9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root. +- **`codegraph_explore` is loaded from the first prompt in Claude Code.** Claude Code defers every MCP tool behind a tool-search step, so a fresh session saw only the tool's name until the model searched for it, and the server's "call `codegraph_explore` instead of Read" had nothing loaded to act on. The tool now carries `anthropic/alwaysLoad` in its `_meta`, which exempts it on existing installs, and `codegraph install` writes `alwaysLoad: true` on the Claude Code server entry (re-run it to add the key). Copilot CLI's tool search holds MCP tools back the same way once ~30 tools are connected, so its entry now carries `deferTools: "never"`. (#1696) + - Fixed a long-running `codegraph ui` session serving a symbol that a sync had already deleted. The viewer keeps one connection to your index open, and its in-memory lookup didn't notice when another process — your agent's sync, or `codegraph sync` — rewrote the file underneath it, so a symbol screen could keep showing a body with no callers while search correctly reported it had moved. Because a symbol's identity includes the line it starts on, this happened after almost any edit above it. ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index a73d3b2bc..80726ecb4 100644 --- a/README.md +++ b/README.md @@ -508,12 +508,15 @@ npm install -g @colbymchenry/codegraph "codegraph": { "type": "stdio", "command": "codegraph", - "args": ["serve", "--mcp"] + "args": ["serve", "--mcp"], + "alwaysLoad": true } } } ``` +`alwaysLoad` keeps `codegraph_explore` loaded from the first prompt. Claude Code otherwise defers every MCP tool behind a tool-search step, so a fresh session sees only the tool's name until the model searches for it. + **Add to `~/.claude/settings.json` (optional, for auto-allow):** ```json { diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 4ec3e5903..469ac0083 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -976,6 +976,22 @@ describe('Installer targets — partial-state idempotency', () => { 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(); + // Exempt from Claude Code's tool-search deferral (#1696). + expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true); + }); + + it('claude: re-running install on an entry that predates alwaysLoad adds the key (#1696)', () => { + const claude = getTarget('claude')!; + fs.writeFileSync( + path.join(tmpCwd, '.mcp.json'), + JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, null, 2), + ); + const result = claude.install('local', { autoAllow: false }); + const mcp = result.files.find((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json')); + expect(mcp?.action).toBe('updated'); + const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8')); + expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); }); it('claude: install creates the CLAUDE.md codegraph block (#704)', () => { @@ -1010,6 +1026,7 @@ describe('Installer targets — partial-state idempotency', () => { claude.install('global', { autoAllow: false }); const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8')); expect(cfg.mcpServers.codegraph).toBeDefined(); + expect(cfg.mcpServers.codegraph.alwaysLoad).toBe(true); }); it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => { @@ -2180,7 +2197,7 @@ describe('Installer targets — Copilot family', () => { // ---- copilot-cli ---- - it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"])', () => { + it('copilot-cli: global install writes ~/.copilot/mcp-config.json with the documented entry shape (tools: ["*"], deferTools: "never")', () => { const t = getTarget('copilot-cli')!; const result = t.install('global', { autoAllow: true }); @@ -2193,9 +2210,26 @@ describe('Installer targets — Copilot family', () => { command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'], + // Exempt from Copilot CLI's tool search, the same way `alwaysLoad` exempts it in Claude Code (#1696). + deferTools: 'never', }); }); + it('copilot-cli: re-running install on an entry that predates deferTools adds the key (#1696)', () => { + const t = getTarget('copilot-cli')!; + const file = path.join(tmpHome, '.copilot', 'mcp-config.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + JSON.stringify({ mcpServers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'], tools: ['*'] } } }, null, 2), + ); + const result = t.install('global', { autoAllow: true }); + expect(result.files[0].action).toBe('updated'); + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.mcpServers.codegraph.deferTools).toBe('never'); + expect(cfg.mcpServers.codegraph.tools).toEqual(['*']); + }); + it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => { const t = getTarget('copilot-cli')!; expect(t.supportsLocation('local')).toBe(false); diff --git a/__tests__/mcp-tool-annotations.test.ts b/__tests__/mcp-tool-annotations.test.ts index 28dbe2593..2162e0023 100644 --- a/__tests__/mcp-tool-annotations.test.ts +++ b/__tests__/mcp-tool-annotations.test.ts @@ -12,6 +12,9 @@ * rewrites codegraph_explore's description via spread), and the no-default- * project surface (`withRequiredProjectPath`, which clones the schema). A drop in * any of those would silently re-block the tools in Ask mode. + * + * `codegraph_explore`'s `_meta` (`anthropic/alwaysLoad`, #1696) rides the same + * spreads, so each surface is checked for it here too. */ import { describe, it, expect, afterEach, beforeEach } from 'vitest'; import * as fs from 'fs'; @@ -34,6 +37,13 @@ function expectReadOnly(tool: ToolDefinition): void { expect(tool.annotations!.openWorldHint).toBe(false); } +/** Assert the explore tool in a `tools/list` surface is marked always-load for Claude Code (#1696). */ +function expectExploreAlwaysLoad(surface: ToolDefinition[]): void { + const explore = surface.find((t) => t.name === 'codegraph_explore'); + expect(explore, 'codegraph_explore is missing from the surface').toBeDefined(); + expect(explore!._meta).toEqual({ 'anthropic/alwaysLoad': true }); +} + describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { const original = process.env[ENV]; afterEach(() => { @@ -44,6 +54,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { it('every tool in the master array is annotated read-only', () => { expect(tools.length).toBeGreaterThan(0); for (const tool of tools) expectReadOnly(tool); + expectExploreAlwaysLoad(tools); }); it('the static proxy surface carries annotations on every exposed tool', () => { @@ -52,6 +63,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { const got = getStaticTools(); expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort()); for (const tool of got) expectReadOnly(tool); + expectExploreAlwaysLoad(got); }); it('the no-default-project surface keeps annotations through the schema clone', () => { @@ -65,6 +77,7 @@ describe('Read-only annotations on the codegraph MCP tools (#1018)', () => { // Sanity: this IS the clone path (projectPath got marked required). expect(tool.inputSchema.required ?? []).toContain('projectPath'); } + expectExploreAlwaysLoad(got); }); }); @@ -101,5 +114,6 @@ describe('Live tool surface keeps annotations with a project open (#1018)', () = expect(explore).toBeDefined(); expect(explore!.description).toMatch(/Budget: make at most/); expectReadOnly(explore!); + expectExploreAlwaysLoad(got); }); }); diff --git a/src/installer/targets/claude.ts b/src/installer/targets/claude.ts index e95b0a35d..1394791d7 100644 --- a/src/installer/targets/claude.ts +++ b/src/installer/targets/claude.ts @@ -41,6 +41,18 @@ import { CODEGRAPH_SECTION_START, } from '../instructions-template'; +/** + * The shared stdio entry plus `alwaysLoad: true`, Claude Code's exemption from + * tool-search deferral (https://code.claude.com/docs/en/mcp#exempt-a-server-from-deferral). + * `codegraph_explore` carries the same flag in its `_meta`, which covers an + * entry written before this key; the entry-level key additionally makes + * Claude Code wait for this server's tools at startup, so they are in the + * first prompt rather than listed after the server connects in the background. + */ +function getClaudeMcpServerConfig() { + return { ...getMcpServerConfig(), alwaysLoad: true }; +} + function configDir(loc: Location): string { return loc === 'global' ? path.join(os.homedir(), '.claude') @@ -211,7 +223,7 @@ class ClaudeCodeTarget implements AgentTarget { printConfig(loc: Location): string { const target = mcpJsonPath(loc); - const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + const snippet = JSON.stringify({ mcpServers: { codegraph: getClaudeMcpServerConfig() } }, null, 2); return `# Add to ${target}\n\n${snippet}\n`; } @@ -231,7 +243,7 @@ export function writeMcpEntry(loc: Location): WriteResult['files'][number] { const file = mcpJsonPath(loc); const existing = readJsonFile(file); const before = existing.mcpServers?.codegraph; - const after = getMcpServerConfig(); + const after = getClaudeMcpServerConfig(); if (jsonDeepEqual(before, after)) { // Already exactly what we'd write — preserve byte-identical file. diff --git a/src/installer/targets/copilot-cli.ts b/src/installer/targets/copilot-cli.ts index 5fa166e40..c355ca29b 100644 --- a/src/installer/targets/copilot-cli.ts +++ b/src/installer/targets/copilot-cli.ts @@ -95,9 +95,15 @@ function copilotOnPath(): boolean { return false; } -function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[] } { +/** + * The shared stdio entry, every tool allowed, and `deferTools: 'never'`: Copilot CLI's tool + * search (on by default from ~30 connected tools on Claude and GPT-5.4+ models) otherwise holds + * MCP tools back until the model searches for them, so `codegraph_explore` would be a name the + * model has to go looking for before it can follow the "call it instead of Read" instruction. + */ +function buildCopilotMcpConfig(): { type: string; command: string; args: string[]; tools: string[]; deferTools: 'never' } { const base = getMcpServerConfig(); - return { ...base, tools: ['*'] }; + return { ...base, tools: ['*'], deferTools: 'never' }; } class CopilotCliTarget implements AgentTarget { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b0585745e..48b9d493e 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -942,6 +942,12 @@ export interface ToolDefinition { }; /** Behavioral hints for clients (see {@link ToolAnnotations}). */ annotations?: ToolAnnotations; + /** + * MCP `_meta` on the tool definition. `anthropic/alwaysLoad: true` makes + * Claude Code load the tool at session start instead of deferring it behind + * its tool search (https://code.claude.com/docs/en/mcp#exempt-a-server-from-deferral). + */ + _meta?: Record; } /** @@ -1196,6 +1202,9 @@ export const tools: ToolDefinition[] = [ required: ['query'], }, annotations: READ_ONLY_ANNOTATIONS, + // Loaded from the first prompt in Claude Code, which otherwise defers every + // MCP tool behind a ToolSearch step (#1696). + _meta: { 'anthropic/alwaysLoad': true }, }, { name: 'codegraph_status',