fix(mcp): keep codegraph_explore loaded in Claude Code and Copilot CLI (#1696)

Claude Code defers every MCP tool behind ToolSearch by default, so a fresh session sees only the tool name until the model searches for it. The explore tool now carries `_meta: { "anthropic/alwaysLoad": true }`, which exempts it on existing installs, and the Claude Code installer target writes `alwaysLoad: true` on the server entry (re-running install adds the key to an older entry). Copilot CLI tool search holds MCP tools back the same way once ~30 tools are connected, so its entry carries `deferTools: "never"`.
This commit is contained in:
Aaron Queen
2026-09-08 03:23:33 -06:00
parent cd4e65b59c
commit 097cd19ad2
7 changed files with 86 additions and 6 deletions
+35 -1
View File
@@ -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);
+14
View File
@@ -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);
});
});