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
+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() : [],
});
}