Merge pull request #1697 from bompus/fix/claude-always-load
fix(mcp): keep codegraph_explore loaded in Claude Code and Copilot CLI
This commit is contained in:
@@ -234,6 +234,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.
|
- **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.
|
- 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
|
## [1.6.0] - 2026-08-26
|
||||||
|
|||||||
@@ -508,12 +508,15 @@ npm install -g @colbymchenry/codegraph
|
|||||||
"codegraph": {
|
"codegraph": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "codegraph",
|
"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):**
|
**Add to `~/.claude/settings.json` (optional, for auto-allow):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -983,6 +983,22 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
|
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
|
||||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
|
||||||
expect(cfg.mcpServers.codegraph).toBeDefined();
|
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)', () => {
|
it('claude: install creates the CLAUDE.md codegraph block (#704)', () => {
|
||||||
@@ -1017,6 +1033,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
|||||||
claude.install('global', { autoAllow: false });
|
claude.install('global', { autoAllow: false });
|
||||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
|
const cfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude.json'), 'utf-8'));
|
||||||
expect(cfg.mcpServers.codegraph).toBeDefined();
|
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', () => {
|
it('claude: local install migrates a legacy ./.claude.json codegraph entry into ./.mcp.json', () => {
|
||||||
@@ -2330,7 +2347,7 @@ describe('Installer targets — Copilot family', () => {
|
|||||||
|
|
||||||
// ---- copilot-cli ----
|
// ---- 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 t = getTarget('copilot-cli')!;
|
||||||
const result = t.install('global', { autoAllow: true });
|
const result = t.install('global', { autoAllow: true });
|
||||||
|
|
||||||
@@ -2343,9 +2360,26 @@ describe('Installer targets — Copilot family', () => {
|
|||||||
command: 'codegraph',
|
command: 'codegraph',
|
||||||
args: ['serve', '--mcp'],
|
args: ['serve', '--mcp'],
|
||||||
tools: ['*'],
|
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', () => {
|
it('copilot-cli: is global-only — local install skips with a clear note, uninstall is a no-op', () => {
|
||||||
const t = getTarget('copilot-cli')!;
|
const t = getTarget('copilot-cli')!;
|
||||||
expect(t.supportsLocation('local')).toBe(false);
|
expect(t.supportsLocation('local')).toBe(false);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
* rewrites codegraph_explore's description via spread), and the no-default-
|
* rewrites codegraph_explore's description via spread), and the no-default-
|
||||||
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
|
* project surface (`withRequiredProjectPath`, which clones the schema). A drop in
|
||||||
* any of those would silently re-block the tools in Ask mode.
|
* 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 { describe, it, expect, afterEach, beforeEach } from 'vitest';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -34,6 +37,13 @@ function expectReadOnly(tool: ToolDefinition): void {
|
|||||||
expect(tool.annotations!.openWorldHint).toBe(false);
|
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)', () => {
|
describe('Read-only annotations on the codegraph MCP tools (#1018)', () => {
|
||||||
const original = process.env[ENV];
|
const original = process.env[ENV];
|
||||||
afterEach(() => {
|
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', () => {
|
it('every tool in the master array is annotated read-only', () => {
|
||||||
expect(tools.length).toBeGreaterThan(0);
|
expect(tools.length).toBeGreaterThan(0);
|
||||||
for (const tool of tools) expectReadOnly(tool);
|
for (const tool of tools) expectReadOnly(tool);
|
||||||
|
expectExploreAlwaysLoad(tools);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the static proxy surface carries annotations on every exposed tool', () => {
|
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();
|
const got = getStaticTools();
|
||||||
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
|
expect(got.map((t) => t.name).sort()).toEqual(tools.map((t) => t.name).sort());
|
||||||
for (const tool of got) expectReadOnly(tool);
|
for (const tool of got) expectReadOnly(tool);
|
||||||
|
expectExploreAlwaysLoad(got);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the no-default-project surface keeps annotations through the schema clone', () => {
|
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).
|
// Sanity: this IS the clone path (projectPath got marked required).
|
||||||
expect(tool.inputSchema.required ?? []).toContain('projectPath');
|
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).toBeDefined();
|
||||||
expect(explore!.description).toMatch(/Budget: make at most/);
|
expect(explore!.description).toMatch(/Budget: make at most/);
|
||||||
expectReadOnly(explore!);
|
expectReadOnly(explore!);
|
||||||
|
expectExploreAlwaysLoad(got);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ import {
|
|||||||
CODEGRAPH_SECTION_START,
|
CODEGRAPH_SECTION_START,
|
||||||
} from '../instructions-template';
|
} 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 {
|
function configDir(loc: Location): string {
|
||||||
return loc === 'global'
|
return loc === 'global'
|
||||||
? path.join(os.homedir(), '.claude')
|
? path.join(os.homedir(), '.claude')
|
||||||
@@ -211,7 +223,7 @@ class ClaudeCodeTarget implements AgentTarget {
|
|||||||
|
|
||||||
printConfig(loc: Location): string {
|
printConfig(loc: Location): string {
|
||||||
const target = mcpJsonPath(loc);
|
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`;
|
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 file = mcpJsonPath(loc);
|
||||||
const existing = readJsonFile(file);
|
const existing = readJsonFile(file);
|
||||||
const before = existing.mcpServers?.codegraph;
|
const before = existing.mcpServers?.codegraph;
|
||||||
const after = getMcpServerConfig();
|
const after = getClaudeMcpServerConfig();
|
||||||
|
|
||||||
if (jsonDeepEqual(before, after)) {
|
if (jsonDeepEqual(before, after)) {
|
||||||
// Already exactly what we'd write — preserve byte-identical file.
|
// Already exactly what we'd write — preserve byte-identical file.
|
||||||
|
|||||||
@@ -95,9 +95,15 @@ function copilotOnPath(): boolean {
|
|||||||
return false;
|
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();
|
const base = getMcpServerConfig();
|
||||||
return { ...base, tools: ['*'] };
|
return { ...base, tools: ['*'], deferTools: 'never' };
|
||||||
}
|
}
|
||||||
|
|
||||||
class CopilotCliTarget implements AgentTarget {
|
class CopilotCliTarget implements AgentTarget {
|
||||||
|
|||||||
@@ -1077,6 +1077,12 @@ export interface ToolDefinition {
|
|||||||
};
|
};
|
||||||
/** Behavioral hints for clients (see {@link ToolAnnotations}). */
|
/** Behavioral hints for clients (see {@link ToolAnnotations}). */
|
||||||
annotations?: 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<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1331,6 +1337,9 @@ export const tools: ToolDefinition[] = [
|
|||||||
required: ['query'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
annotations: READ_ONLY_ANNOTATIONS,
|
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',
|
name: 'codegraph_status',
|
||||||
|
|||||||
Reference in New Issue
Block a user