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
+22 -1
View File
@@ -71,8 +71,29 @@ typically one to a few calls; a grep/read exploration is dozens.
## Limitations
- If a tool reports the project isn't initialized, \`.codegraph/\` doesn't exist yet — offer to run \`codegraph init -i\` to build the index.
- If a tool reports a project isn't indexed (no \`.codegraph/\`), stop calling codegraph tools for that project for the rest of the session and use your built-in tools there instead. Indexing is the user's decision — mention they can run \`codegraph init\` if it comes up, but don't run it yourself.
- Index lags file writes by ~1 second.
- Cross-file resolution is best-effort name matching; ambiguous calls may return multiple candidates.
- No live correctness validation — that's still the TypeScript compiler / test suite / linter's job. Codegraph supplements those with structural context they don't have.
`;
/**
* Instructions variant sent when the workspace has NO codegraph index.
*
* Sending the full playbook ("lean on codegraph for everything") into a
* session where every call would fail wastes the agent's calls and — worse —
* the failures teach it codegraph is broken. The unindexed variant is a
* short, unambiguous "inactive this session" note; `tools/list` is gated to
* empty in the same state, so the agent has nothing to mis-call. Indexing is
* deliberately left to the user: the agent is told NOT to run init itself.
*/
export const SERVER_INSTRUCTIONS_UNINDEXED = `# Codegraph — inactive (workspace not indexed)
This workspace has no codegraph index (no \`.codegraph/\` directory), so no
codegraph tools are available this session. Work with your built-in tools as
usual.
Indexing is the user's decision — do not run it yourself. If the user asks
about codegraph, they can enable it by running \`codegraph init\` in the
project root and starting a new session.
`;
+23 -3
View File
@@ -16,8 +16,9 @@ import * as path from 'path';
import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } from './transport';
import { MCPEngine } from './engine';
import { tools } from './tools';
import { SERVER_INSTRUCTIONS } from './server-instructions';
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_UNINDEXED } from './server-instructions';
import { CodeGraphPackageVersion } from './version';
import { findNearestCodeGraphRoot } from '../directory';
/**
* MCP Server Info — kept on the session because some clients log it. The
@@ -178,12 +179,24 @@ export class MCPSession {
explicitPath = this.explicitProjectPath;
}
// Pick the instructions variant by the workspace's index state — a cheap
// synchronous walk-up (existsSync loop only, no DB open, so the #172
// respond-fast contract holds). An unindexed workspace gets the short
// "inactive this session" note instead of the full playbook: the playbook
// tells the agent to lean on tools that would all fail, and early failures
// teach the agent to abandon codegraph entirely. `tools/list` is gated the
// same way (empty list when unindexed). When no explicit path is known yet
// (roots/list dance pending), cwd is the best predictor of where the
// default project will resolve — and on a mismatch the worst case is the
// optimistic full playbook backstopped by the empty tool list.
const indexed = findNearestCodeGraphRoot(explicitPath ?? process.cwd()) !== null;
// Respond to the handshake BEFORE doing any heavy init — see issue #172.
this.transport.sendResult(request.id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
instructions: SERVER_INSTRUCTIONS,
instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_UNINDEXED,
});
if (explicitPath) {
@@ -196,8 +209,15 @@ export class MCPSession {
private async handleToolsList(request: JsonRpcRequest): Promise<void> {
await this.retryInitIfNeeded();
// An unindexed workspace serves an EMPTY tool list: absence is the one
// signal an agent can't misread. Listing 8 tools that all fail wastes the
// agent's calls and teaches it codegraph is broken (observed: one or two
// early isError responses and the agent stops calling codegraph for the
// whole session). A `codegraph init` run after the server started is
// picked up on the next tools/list — retryInitIfNeeded re-walks — though
// most hosts only request the list once per connection.
this.transport.sendResult(request.id, {
tools: this.engine.getToolHandler().getTools(),
tools: this.engine.hasDefaultCodeGraph() ? this.engine.getToolHandler().getTools() : [],
});
}
+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);