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 Mchenry
2026-06-23 12:57:47 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0a91d0f512
commit 85a8f32fd9
10 changed files with 431 additions and 87 deletions
+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,