feat(mcp): unindexed sessions go quiet — empty tools/list + inactive instructions, no-error policy (#769) (#817)

An MCP session in a workspace with no .codegraph/ previously got the full
"lean on codegraph for everything" playbook plus all 8 tools, then every
call returned isError — and one or two early errors teach an agent to
abandon codegraph for the whole session (maintainer-observed). Now the
initialize response picks an instructions variant by index state (cheap
sync walk-up, #172 respond-fast contract holds) and tools/list serves an
EMPTY list when unindexed: absence is the one signal an agent can't
misread. Indexing is deliberately the user's call — the inactive note
tells the agent not to run init itself.

No-error policy in the tool handler: expected/recoverable conditions
(NotIndexedError — cross-project query to an unindexed path, default-
project detection miss) return SUCCESS-shaped guidance instead of
isError; security refusals (PathRefusalError) stay hard errors without
retry encouragement; genuine internal failures keep isError but add a
retry-once note so a transient blip doesn't convert to permanent
abandonment. Principle recorded in CLAUDE.md.

Also: codegraph_search kind:"type" (advertised by its own schema enum)
silently matched nothing — now maps to type_alias; codegraph_explore's
query param no longer tells agents to run codegraph_search first
(contradicted explore's call-FIRST design); server-instructions
§Limitations rewords the unindexed case to stay-out-for-the-session.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-11 20:03:26 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0682681175
commit f9fcc2cd6a
6 changed files with 312 additions and 12 deletions
+52 -8
View File
@@ -28,6 +28,25 @@ import {
} from 'fs';
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { isGeneratedFile } from '../extraction/generated-detection';
/**
* An expected, recoverable "codegraph can't serve this" condition — most
* importantly a project with no index. The dispatch catch converts these to
* SUCCESS-shaped responses (guidance text, NO isError): an `isError: true`
* early in a session teaches the agent the toolset is broken and it stops
* calling codegraph entirely (observed repeatedly), which is exactly wrong
* for conditions the agent can simply work around (use built-in tools for
* that codebase / pass projectPath). isError is reserved for "stop trying"
* cases: security refusals ({@link PathRefusalError}) and genuine
* malfunctions.
*/
export class NotIndexedError extends Error {}
/**
* A security refusal (sensitive system path). Stays `isError: true` WITHOUT
* retry guidance — abandoning this path is the desired agent reaction.
*/
export class PathRefusalError extends Error {}
import { resolve as resolvePath } from 'path';
/** Maximum output length to prevent context bloat (characters) */
@@ -522,7 +541,7 @@ export const tools: ToolDefinition[] = [
properties: {
query: {
type: 'string',
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). Use codegraph_search first to find relevant names.',
description: 'Symbol names, file names, or short code terms to explore (e.g., "AuthService loginUser session-manager", "GraphTraverser BFS impact traversal.ts"). For a flow question, name the symbols spanning the flow (e.g. "mutateElement renderScene"). A natural-language question works too — no prior codegraph_search needed.',
},
maxFiles: {
type: 'number',
@@ -752,14 +771,16 @@ export class ToolHandler {
if (!projectPath) {
if (!this.cg) {
const searched = this.defaultProjectHint ?? process.cwd();
throw new Error(
throw new NotIndexedError(
'No CodeGraph project is loaded for this session.\n' +
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
'The index is likely fine — this is a working-directory detection issue: ' +
'If this project IS indexed, this is a working-directory detection issue: ' +
"the MCP client launched the server outside your project and didn't report the " +
'workspace root. Fix it either way:\n' +
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project"\n' +
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]'
' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
'If the project simply has no index, continue with your built-in tools (Read/Grep/Glob) ' +
"and don't call codegraph again this session — the user can run 'codegraph init' to enable it."
);
}
return this.cg;
@@ -778,7 +799,7 @@ export class ToolHandler {
if (existsSync(projectPath)) {
const pathError = validateProjectPath(projectPath);
if (pathError) {
throw new Error(pathError);
throw new PathRefusalError(pathError);
}
}
@@ -786,7 +807,12 @@ export class ToolHandler {
const resolvedRoot = findNearestCodeGraphRoot(projectPath);
if (!resolvedRoot) {
throw new Error(`CodeGraph not initialized in ${projectPath}. Run 'codegraph init' in that project first.`);
throw new NotIndexedError(
`The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` +
'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' +
"for that codebase instead, and don't call codegraph for it again this session. " +
"Indexing is the user's decision — they can run 'codegraph init' in that project to enable it."
);
}
// If the path resolves to the default project, reuse the already-open
@@ -1069,7 +1095,21 @@ export class ToolHandler {
const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
} catch (err) {
return this.errorResult(`Tool execution failed: ${err instanceof Error ? err.message : String(err)}`);
// Expected condition, not a malfunction: answer as a SUCCESS so the
// agent keeps trusting the toolset for projects that ARE indexed.
// (An isError here teaches session-long abandonment — see NotIndexedError.)
if (err instanceof NotIndexedError) {
return this.textResult(err.message);
}
// Security refusal: a clean error, no retry encouragement.
if (err instanceof PathRefusalError) {
return this.errorResult(err.message);
}
return this.errorResult(
`Tool execution failed: ${err instanceof Error ? err.message : String(err)}. ` +
'This is an internal codegraph error — retry the call once; if it persists, ' +
'continue without codegraph for this task.'
);
}
}
@@ -1081,7 +1121,11 @@ export class ToolHandler {
if (typeof query !== 'string') return query;
const cg = this.getCodeGraph(args.projectPath as string | undefined);
const kind = args.kind as string | undefined;
const rawKind = args.kind as string | undefined;
// The schema enum says 'type' (what agents naturally reach for); the
// NodeKind is 'type_alias'. Without the mapping, kind: "type" silently
// matched nothing — a filter value we advertise must work.
const kind = rawKind === 'type' ? 'type_alias' : rawKind;
const rawLimit = Number(args.limit) || 10;
const limit = clamp(rawLimit, 1, 100);