Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
36 changes: 35 additions & 1 deletion __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 });

Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions __tests__/mcp-tool-annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(() => {
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});
16 changes: 14 additions & 2 deletions src/installer/targets/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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`;
}

Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions src/installer/targets/copilot-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/**
Expand Down Expand Up @@ -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',
Expand Down