fix(mcp): serve tools without a root index + make the front-load hook monorepo-aware (#964) (#966)

The MCP server gated tool availability on whether the server root had a
.codegraph/ index, so in a monorepo where only sub-projects are indexed the
agent saw zero tools — and couldn't reach an indexed sub-project even by
projectPath. A session started before `codegraph init` also never surfaced the
tools afterward. The Claude front-load hook had the mirror gap: it only walked
UP for an index, so it stayed silent at a monorepo root.

MCP server:
- Always expose the tool surface; when the root isn't indexed, send a
  per-project instructions variant (pass projectPath) instead of the
  "inactive" note. Safety comes from response SHAPE (success-shaped guidance,
  never isError), not from hiding tools.
- Reword the no-default-project guidance to be per-project, not per-session,
  and sharpen the projectPath schema description.

Front-load hook (UserPromptSubmit):
- Scan DOWN (bounded depth, workspace-root-gated) for indexed sub-projects and
  shape the injection by topology: front-load the one the prompt names, nudge
  about the rest, or list them when ambiguous.

Verified: full suite (1703 passed); a live two-package monorepo run confirms the
hook front-loads the correct sub-project with no cross-package leakage. The
front-load's net speed effect is the existing multi-file-vs-single-file
tradeoff, unchanged by this work.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby MchenryandClaude Opus 4.8 authored and GitHub committed 2026-06-23 12:57:47 -05:00
1 parent 0a91d0f512
commit 85a8f32fd9
10 files changed
+431 -87

No files matched your search

+49 -27
View File
@@ -26,7 +26,7 @@
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot } from '../directory';
import { getCodeGraphDir, isInitialized, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload } from '../directory';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
import { getGlyphs } from '../ui/glyphs';
@@ -1059,34 +1059,56 @@ program
const STRUCTURAL = /\b(how|where|trace|flow|path|reach(?:es|ed)?|call(?:s|ed|er|ers|ee)?|depend|impact|affect|wired?|connect|implement|architect|structure|breaks?|what calls|why does)\b/i;
if (!prompt || !STRUCTURAL.test(prompt)) return;
// Find an indexed project: cwd, then walk up a few levels.
let root: string | null = null;
let dir = path.resolve(String(input.cwd || process.cwd()));
for (let i = 0; i < 6; i++) {
if (isInitialized(dir)) { root = dir; break; }
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
if (!root) return; // not indexed — the agent's normal tools apply
// Decide what to inject, shaped by WHERE the index(es) are: the nearest
// indexed ancestor of cwd, or — when cwd is an un-indexed workspace root
// whose indexed project(s) live in sub-dirs (the monorepo case, #964) —
// the sub-project the prompt points at, plus a `projectPath` nudge for any
// others. Without the down-scan the hook injected nothing at a monorepo
// root (it only walked up), so the validated adoption lever never fired
// exactly where the agent most needs it.
const plan = planFrontload(String(input.cwd || process.cwd()), prompt);
if (!plan.exploreRoot && plan.nudgeProjects.length === 0) return; // nothing reachable — the agent's normal tools apply
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(root);
try {
const { ToolHandler } = await import('../mcp/tools');
const handler = new ToolHandler(cg);
const result = await handler.execute('codegraph_explore', { query: prompt });
const text = result.content[0]?.text ?? '';
if (!result.isError && text.trim()) {
// Cap the injection so a large-repo explore can't flood the prompt.
const MAX = 16000;
const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text;
process.stdout.write(
`<codegraph_context note="Structural context from CodeGraph for this prompt — treat returned source as already read; call codegraph_explore for more.">\n${body}\n</codegraph_context>\n`,
);
// A "pass projectPath" line for indexed sub-projects we did NOT front-load.
// Follow-up codegraph_explore calls against a sub-project (cwd isn't its
// index root) need an explicit projectPath, so spell it out.
const nudge = (projects: string[], lead: string): string =>
`${lead}\n${projects.map((p) => ` - projectPath: "${p}"`).join('\n')}\n`;
if (plan.exploreRoot) {
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(plan.exploreRoot);
try {
const { ToolHandler } = await import('../mcp/tools');
const handler = new ToolHandler(cg);
const result = await handler.execute('codegraph_explore', { query: prompt });
const text = result.content[0]?.text ?? '';
if (!result.isError && text.trim()) {
// Cap the injection so a large-repo explore can't flood the prompt.
const MAX = 16000;
const body = text.length > MAX ? `${text.slice(0, MAX)}\n…(truncated; call codegraph_explore for the rest)` : text;
// For a front-loaded SUB-project, a follow-up explore needs its path.
const more = plan.viaSubScan
? `call codegraph_explore with projectPath: "${plan.exploreRoot}" for more`
: 'call codegraph_explore for more';
const others = plan.nudgeProjects.length
? `\n${nudge(plan.nudgeProjects, 'Other indexed projects in this workspace — pass projectPath to query them:')}`
: '';
process.stdout.write(
`<codegraph_context note="Structural context from CodeGraph for this prompt — treat returned source as already read; ${more}.">\n${body}${others}\n</codegraph_context>\n`,
);
}
} finally {
cg.destroy();
}
} finally {
cg.destroy();
} else {
// Several indexed sub-projects, none a clear match — don't guess; tell
// the agent they exist and how to query one.
process.stdout.write(
`<codegraph_context note="CodeGraph is available for this workspace's indexed sub-projects — query one by passing projectPath to codegraph_explore.">\n` +
nudge(plan.nudgeProjects, "This workspace's CodeGraph indexes live in sub-projects. To use CodeGraph, call codegraph_explore with the projectPath of the relevant one:") +
`</codegraph_context>\n`,
);
}
} catch {
// Degradable by contract: never surface an error to the prompt pipeline.
+130
View File
@@ -176,6 +176,136 @@ export function findNearestCodeGraphRoot(startPath: string): string | null {
return null;
}
/** Heavy/irrelevant directory names the sub-project scan never descends into. */
const SUBPROJECT_SCAN_SKIP = new Set([
'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target',
'vendor', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.cache', 'coverage',
'.venv', 'venv', '__pycache__', '.turbo', '.idea', '.vscode', 'tmp', 'temp',
]);
/** Manifests that mark a directory as a project/workspace root. The down-scan
* is gated on one of these so a non-project cwd (e.g. `$HOME`) is a cheap
* no-op instead of a deep filesystem crawl. */
const WORKSPACE_ROOT_MANIFESTS = [
'package.json', 'pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json',
'go.work', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts',
'settings.gradle', 'pyproject.toml', 'composer.json', 'Gemfile', 'rush.json',
'WORKSPACE', 'WORKSPACE.bazel',
];
function looksLikeProjectRoot(dir: string): boolean {
return WORKSPACE_ROOT_MANIFESTS.some((m) => fs.existsSync(path.join(dir, m)));
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Indexed sub-project roots beneath `root` (bounded breadth-first scan). For
* the monorepo case behind #964: the index lives in a CHILD
* (`packages/x/.codegraph/`), not at the workspace root the agent's cwd points
* at. Descent stops at the first indexed directory on a branch (a project's
* own sub-dirs aren't separate projects) and is bounded by depth + count so it
* never turns into a full-tree crawl on a large repo.
*/
export function findIndexedSubprojectRoots(
root: string,
opts: { maxDepth?: number; max?: number } = {},
): string[] {
const maxDepth = opts.maxDepth ?? 4;
const max = opts.max ?? 64;
const out: string[] = [];
const walk = (dir: string, depth: number): void => {
if (out.length >= max || depth > maxDepth) return;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (out.length >= max) return;
if (!e.isDirectory()) continue;
if (e.name.startsWith('.') || SUBPROJECT_SCAN_SKIP.has(e.name)) continue;
const child = path.join(dir, e.name);
if (isInitialized(child)) { out.push(child); continue; } // don't descend into an indexed project
walk(child, depth + 1);
}
};
walk(root, 1);
return out;
}
/**
* What the front-load hook should do for a prompt issued from a directory.
*/
export interface FrontloadPlan {
/** Open + explore this project and inject its source as context. `null` when
* there's no single project to front-load (none indexed, or several indexed
* sub-projects with no clear match — see {@link nudgeProjects}). */
exploreRoot: string | null;
/** Indexed sub-projects to surface in a "pass `projectPath`" nudge: the rest
* of a monorepo's indexed projects alongside `exploreRoot`, or — when no one
* project clearly matches — the full list (with `exploreRoot` null). */
nudgeProjects: string[];
/** True when the plan came from scanning DOWN into sub-projects (cwd itself
* is not under any index) — the monorepo case, where a follow-up
* `codegraph_explore` needs an explicit `projectPath`. */
viaSubScan: boolean;
}
/**
* Decide what the front-load hook injects for a `prompt` issued from `cwd`,
* shaped by where the `.codegraph/` index(es) actually are:
* 1. **cwd (or an ancestor) is indexed** → front-load that project. The
* normal single-project / nested-file case.
* 2. **cwd isn't indexed but looks like a workspace root** → the indexes live
* in sub-projects (the monorepo case behind #964). One indexed
* sub-project → front-load it; several → front-load the one the prompt
* names (by relative path like `packages/api`, or package directory name)
* and nudge about the rest; several with no match → nudge the full list so
* the agent passes `projectPath`, rather than guessing wrong.
* 3. **nothing indexed reachable** → do nothing (the agent's own tools apply).
*/
export function planFrontload(cwd: string, prompt: string): FrontloadPlan {
const none: FrontloadPlan = { exploreRoot: null, nudgeProjects: [], viaSubScan: false };
// 1. up-walk — nearest indexed ancestor (incl. cwd). Cheap; covers the common
// single-project case without a down-scan.
let dir = path.resolve(cwd);
for (let i = 0; i < 6; i++) {
if (isInitialized(dir)) return { exploreRoot: dir, nudgeProjects: [], viaSubScan: false };
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
// 2. down-scan — only from something that looks like a workspace root, so a
// non-project cwd (e.g. $HOME) is a cheap no-op, not a deep crawl.
const base = path.resolve(cwd);
if (!looksLikeProjectRoot(base)) return none;
const subs = findIndexedSubprojectRoots(base);
if (subs.length === 0) return none;
if (subs.length === 1) return { exploreRoot: subs[0]!, nudgeProjects: [], viaSubScan: true };
// Several indexed sub-projects — pick the one the prompt points at, if any.
const p = prompt.toLowerCase();
let best: { root: string; score: number; relLen: number } | null = null;
for (const s of subs) {
const rel = path.relative(base, s);
const relLc = rel.split(path.sep).join('/').toLowerCase();
const name = path.basename(s).toLowerCase();
let score = 0;
if (relLc && p.includes(relLc)) score = 10; // "packages/api"
else if (name.length >= 3 && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(p)) score = 5; // "api"
if (score > 0 && (!best || score > best.score || (score === best.score && rel.length < best.relLen))) {
best = { root: s, score, relLen: rel.length };
}
}
if (best) {
return { exploreRoot: best.root, nudgeProjects: subs.filter((s) => s !== best!.root), viaSubScan: true };
}
// No clear match — nudge the full list rather than front-load a guess.
return { exploreRoot: null, nudgeProjects: subs, viaSubScan: true };
}
/**
* Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every
* transient file in the index dir — the database, `daemon.pid`, the socket,
+26 -14
View File
@@ -70,22 +70,34 @@ calls; a grep/read exploration is dozens.
`;
/**
* Instructions variant sent when the workspace has NO codegraph index.
* Instructions variant sent when the server's own root 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.
* The tools are still exposed (gating tool availability on whether `./` has an
* index is the bug behind #964: it breaks monorepos where only sub-projects are
* indexed, and a server that started before `codegraph init` never surfaces the
* tools afterward). Instead of an "inactive" note, this variant tells the agent
* codegraph works **per project**: there's no default project to query, so pass
* a `projectPath` to any project that HAS a `.codegraph/`. The full single-
* project playbook ({@link SERVER_INSTRUCTIONS}) is sent instead when the root
* IS indexed, so the common case stays tight.
*/
export const SERVER_INSTRUCTIONS_UNINDEXED = `# Codegraph — inactive (workspace not indexed)
export const SERVER_INSTRUCTIONS_NO_ROOT_INDEX = `# Codegraph — available (per-project; pass projectPath)
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.
Codegraph is a SQLite knowledge graph of a codebase's symbols, edges, and
files: one \`codegraph_explore\` call returns the verbatim, line-numbered source
of the relevant symbols PLUS the call paths between them and a blast-radius
summary — replacing a grep + Read loop with one round-trip.
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.
This server started somewhere with no \`.codegraph/\` of its own, so there is no
default project — but the tools are available and work **per project**:
- To query a project that HAS a \`.codegraph/\` index (e.g. a service inside a
monorepo, or a second repo), pass its path as \`projectPath\` to
\`codegraph_explore\` (and any other codegraph tool). Codegraph resolves the
nearest \`.codegraph/\` at or above that path and answers from it — for as many
projects as you like in one session.
- For a project with no \`.codegraph/\`, use your built-in tools (Read/Grep/Glob)
for that project. Indexing is the user's decision — don't run it yourself, but
if it comes up they can run \`codegraph init\` in a project to enable codegraph
there (a new index is picked up live, no restart).
`;
+24 -19
View File
@@ -16,7 +16,7 @@ import * as path from 'path';
import { JsonRpcRequest, JsonRpcNotification, JsonRpcTransport, ErrorCodes } from './transport';
import { MCPEngine } from './engine';
import { tools } from './tools';
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_UNINDEXED } from './server-instructions';
import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server-instructions';
import { CodeGraphPackageVersion } from './version';
import { findNearestCodeGraphRoot } from '../directory';
import { getTelemetry, ClientInfo } from '../telemetry';
@@ -189,16 +189,17 @@ export class MCPSession {
explicitPath = this.explicitProjectPath;
}
// Pick the instructions variant by the workspace's index state — a cheap
// Pick the instructions variant by the root'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.
// respond-fast contract holds). When the root IS indexed, send the full
// single-project playbook. When it ISN'T, send the per-project variant
// (tools are still exposed — see handleToolsList): it tells the agent there
// is no default project and to pass `projectPath` to any project that has a
// `.codegraph/`. Gating tool AVAILABILITY on whether `./` is indexed was the
// #964 bug — it broke monorepos (only sub-projects indexed) and never
// surfaced the tools after a mid-session `codegraph init`. When no explicit
// path is known yet (roots/list dance pending), cwd is the best predictor of
// where the default project will resolve.
const indexed = findNearestCodeGraphRoot(explicitPath ?? process.cwd()) !== null;
// Respond to the handshake BEFORE doing any heavy init — see issue #172.
@@ -206,7 +207,7 @@ export class MCPSession {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_UNINDEXED,
instructions: indexed ? SERVER_INSTRUCTIONS : SERVER_INSTRUCTIONS_NO_ROOT_INDEX,
});
if (explicitPath) {
@@ -219,15 +220,19 @@ 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.
// Always expose the tools — even when the server root has no index. Gating
// availability on whether `./` is indexed (the old behavior) breaks the
// monorepo case where only sub-projects carry a `.codegraph/` (the agent
// saw zero tools and couldn't even reach an indexed sub-project by
// `projectPath`), and it hides the tools from a session that started before
// the user ran `codegraph init` (most hosts request the list once, so the
// freshly-built index never surfaces). #964. The not-indexed case is still
// safe: a call against an un-indexed path returns SUCCESS-shaped guidance
// ("pass projectPath / run codegraph init"), never `isError`, so it can't
// teach the agent to abandon codegraph. `getTools()` returns the default
// surface even before a project is open.
this.transport.sendResult(request.id, {
tools: this.engine.hasDefaultCodeGraph() ? this.engine.getToolHandler().getTools() : [],
tools: this.engine.getToolHandler().getTools(),
});
}
+10 -7
View File
@@ -438,7 +438,7 @@ export interface ToolResult {
*/
const projectPathProperty: PropertySchema = {
type: 'string',
description: 'Path to a different project with .codegraph/ initialized. If omitted, uses current project. Use this to query other codebases.',
description: 'Absolute path to the project to query (or any directory inside it) — codegraph uses the nearest .codegraph/ index at or above that path. Omit to use this session\'s default project. Pass it to query a second codebase, or when the server root has no index of its own (e.g. a monorepo where only sub-projects are indexed, so there is no default project).',
};
/**
@@ -888,13 +888,16 @@ export class ToolHandler {
throw new NotIndexedError(
'No CodeGraph project is loaded for this session.\n' +
`Searched for a .codegraph/ directory starting from: ${searched}\n` +
'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' +
'Either the server root has no index of its own (e.g. a monorepo where only ' +
"sub-projects are indexed), or the MCP client launched the server outside your " +
'project without reporting the workspace root. Either way, target the project ' +
'explicitly:\n' +
' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
'(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' +
' • 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."
'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
"project (the user can run 'codegraph init' there to enable it) — you can still query " +
'other indexed projects by projectPath in the same session.'
);
}
return this.freshen(this.cg);