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:
co-authored by
Claude Opus 4.8
parent
0a91d0f512
commit
85a8f32fd9
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Front-load hook project resolution (#964).
|
||||
*
|
||||
* The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
|
||||
* for the RIGHT project — including the monorepo case where the agent's cwd is
|
||||
* an un-indexed workspace root and the index lives in a sub-project. These test
|
||||
* `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
|
||||
* logic), since the end-to-end hook is validated by a live agent run, not a
|
||||
* unit test.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { planFrontload, findIndexedSubprojectRoots } from '../src/directory';
|
||||
|
||||
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
|
||||
function mkIndexed(dir: string): string {
|
||||
fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
|
||||
return dir;
|
||||
}
|
||||
/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
|
||||
function mkWorkspaceRoot(dir: string): string {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('planFrontload — front-load hook project resolution (#964)', () => {
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
|
||||
mkIndexed(tmp);
|
||||
const plan = planFrontload(tmp, 'how does login work');
|
||||
expect(plan.exploreRoot).toBe(tmp);
|
||||
expect(plan.viaSubScan).toBe(false);
|
||||
expect(plan.nudgeProjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('a nested file under an indexed project resolves up to that project', () => {
|
||||
mkIndexed(tmp);
|
||||
const nested = path.join(tmp, 'src', 'deep');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
|
||||
});
|
||||
|
||||
it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
|
||||
mkWorkspaceRoot(tmp);
|
||||
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
const plan = planFrontload(tmp, 'how does the request get handled');
|
||||
expect(plan.exploreRoot).toBe(api);
|
||||
expect(plan.viaSubScan).toBe(true);
|
||||
expect(plan.nudgeProjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
|
||||
mkWorkspaceRoot(tmp);
|
||||
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
|
||||
const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
|
||||
expect(plan.exploreRoot).toBe(api);
|
||||
expect(plan.viaSubScan).toBe(true);
|
||||
expect(plan.nudgeProjects).toEqual([web]);
|
||||
});
|
||||
|
||||
it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
|
||||
mkWorkspaceRoot(tmp);
|
||||
mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
|
||||
const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
|
||||
expect(plan.exploreRoot).toBe(web);
|
||||
});
|
||||
|
||||
it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
|
||||
mkWorkspaceRoot(tmp);
|
||||
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
|
||||
const plan = planFrontload(tmp, 'how does authentication work end to end?');
|
||||
expect(plan.exploreRoot).toBeNull();
|
||||
expect(plan.viaSubScan).toBe(true);
|
||||
expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
|
||||
});
|
||||
|
||||
it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
|
||||
// Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
|
||||
mkIndexed(path.join(tmp, 'some', 'project'));
|
||||
const plan = planFrontload(tmp, 'how does it work');
|
||||
expect(plan.exploreRoot).toBeNull();
|
||||
expect(plan.nudgeProjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('nothing indexed anywhere → no-op', () => {
|
||||
mkWorkspaceRoot(tmp);
|
||||
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
|
||||
const plan = planFrontload(tmp, 'how does it work');
|
||||
expect(plan.exploreRoot).toBeNull();
|
||||
expect(plan.nudgeProjects).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findIndexedSubprojectRoots', () => {
|
||||
let tmp: string;
|
||||
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
|
||||
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
|
||||
mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
mkIndexed(path.join(tmp, 'services', 'auth'));
|
||||
// Decoys that must NOT be scanned into.
|
||||
mkIndexed(path.join(tmp, 'node_modules', 'dep'));
|
||||
mkIndexed(path.join(tmp, '.git', 'x'));
|
||||
const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
|
||||
expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
|
||||
});
|
||||
|
||||
it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
|
||||
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
|
||||
mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
|
||||
const found = findIndexedSubprojectRoots(tmp);
|
||||
expect(found).toEqual([api]);
|
||||
});
|
||||
|
||||
it('respects the depth bound', () => {
|
||||
mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
|
||||
expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Unindexed-workspace session policy tests.
|
||||
* No-root-index session policy tests (#964).
|
||||
*
|
||||
* An MCP session attached to a workspace with no .codegraph/ must go quiet
|
||||
* rather than fail loudly: `initialize` returns the short "inactive"
|
||||
* instructions variant (not the full playbook), `tools/list` returns an
|
||||
* EMPTY list, and a tool call that still arrives (cross-project
|
||||
* `projectPath`, or a host that skips tools/list) answers with a
|
||||
* SUCCESS-shaped guidance message — never `isError: true`. One or two early
|
||||
* isError responses teach an agent to abandon codegraph for the whole
|
||||
* session; that observed failure mode is what this suite guards.
|
||||
* A server whose own root has no .codegraph/ still exposes its tools — gating
|
||||
* tool AVAILABILITY on whether `./` is indexed broke monorepos (only
|
||||
* sub-projects indexed) and hid the tools from a session that started before
|
||||
* `codegraph init`. So `initialize` returns the per-project instructions
|
||||
* variant (not the full single-project playbook, and NOT an "inactive" note),
|
||||
* `tools/list` exposes the tool surface, and a query against an indexed project
|
||||
* by `projectPath` works even with no default project. Safety is preserved by
|
||||
* the response SHAPE, not by hiding tools: a call against an un-indexed path
|
||||
* returns SUCCESS-shaped guidance ("pass projectPath / run codegraph init"),
|
||||
* never `isError: true` — one or two early isError responses teach an agent to
|
||||
* abandon codegraph for the whole session, and that failure mode is still
|
||||
* guarded below.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
||||
@@ -82,7 +86,7 @@ function initializeParams(projectPath: string) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('Unindexed-workspace session policy', () => {
|
||||
describe('No-root-index session policy', () => {
|
||||
let tempDir: string;
|
||||
let child: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
@@ -106,26 +110,61 @@ describe('Unindexed-workspace session policy', () => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
});
|
||||
|
||||
it('initialize returns the short "inactive" instructions, not the playbook', async () => {
|
||||
it('initialize returns the per-project instructions (not "inactive", not the full playbook)', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const x = 1;\n');
|
||||
child = spawnServer(tempDir);
|
||||
|
||||
const res = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
|
||||
const instructions = (res.result as { instructions: string }).instructions;
|
||||
|
||||
expect(instructions).toMatch(/inactive/i);
|
||||
// No longer an "inactive, do nothing" note — the tools are available.
|
||||
expect(instructions).not.toMatch(/inactive/i);
|
||||
// It steers the agent to target a project explicitly via projectPath...
|
||||
expect(instructions).toMatch(/projectPath/);
|
||||
expect(instructions).toMatch(/codegraph_explore/);
|
||||
expect(instructions).toMatch(/codegraph init/);
|
||||
// The full playbook must NOT be sent into a session where every call fails
|
||||
expect(instructions).not.toMatch(/How to query/);
|
||||
expect(instructions).not.toMatch(/codegraph_explore/);
|
||||
// ...but it is NOT the full single-project playbook (that's sent only when
|
||||
// the root itself is indexed — keeps the common case tight).
|
||||
expect(instructions).not.toMatch(/## How to query/);
|
||||
});
|
||||
|
||||
it('tools/list returns an EMPTY list when the workspace has no index', async () => {
|
||||
it('tools/list exposes the tools even when the server root has no index (#964)', async () => {
|
||||
child = spawnServer(tempDir);
|
||||
await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
|
||||
|
||||
const res = await request(child, { id: 1, method: 'tools/list' });
|
||||
expect((res.result as { tools: unknown[] }).tools).toEqual([]);
|
||||
const tools = (res.result as { tools: Array<{ name: string }> }).tools;
|
||||
expect(tools.length).toBeGreaterThanOrEqual(1);
|
||||
expect(tools.map((t) => t.name)).toContain('codegraph_explore');
|
||||
});
|
||||
|
||||
it('a query by projectPath reaches an INDEXED sub-project of an unindexed root (monorepo) (#964)', async () => {
|
||||
// The server root (tempDir) has no index; an indexed sub-project lives
|
||||
// under it — exactly the monorepo shape. The query must resolve to the
|
||||
// sub-project's .codegraph/ and return real results. Run through the real
|
||||
// spawned server (a second-project open can't be exercised in-process under
|
||||
// vitest — see mcp-toolhandler cache notes — but a child process can).
|
||||
const svc = path.join(tempDir, 'service_a');
|
||||
fs.mkdirSync(svc);
|
||||
fs.writeFileSync(
|
||||
path.join(svc, 'auth.ts'),
|
||||
'export function validateToken(t: string): boolean { return !!t; }\n'
|
||||
);
|
||||
const cg = await CodeGraph.init(svc, { index: true });
|
||||
cg.close();
|
||||
|
||||
child = spawnServer(tempDir);
|
||||
await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
|
||||
|
||||
const res = await request(child, {
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'codegraph_search', arguments: { query: 'validateToken', projectPath: svc } },
|
||||
});
|
||||
const result = res.result as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(result.content[0]!.text).toMatch(/validateToken/);
|
||||
expect(result.content[0]!.text).not.toMatch(/isn't indexed/);
|
||||
});
|
||||
|
||||
it('an INDEXED workspace still gets the full playbook and the explore tool', async () => {
|
||||
@@ -180,6 +219,7 @@ describe('No-error policy on expected conditions', () => {
|
||||
expect(res.content[0]!.text).toMatch(/projectPath/);
|
||||
});
|
||||
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'sensitive-path refusal stays a hard error (no retry encouragement)',
|
||||
async () => {
|
||||
|
||||
Reference in New Issue
Block a user