Explorar o código

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>
Colby Mchenry hai 1 día
pai
achega
85a8f32fd9

+ 2 - 0
CHANGELOG.md

@@ -24,6 +24,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 - CodeGraph now understands **Lombok**-generated methods in Java. `@Getter`, `@Setter`, `@Data`, `@Value`, and `@Builder` generate getters, setters, `builder()`, `equals`/`hashCode`/`toString`, and the `@Slf4j` `log` field at compile time, so those methods never appear in the source — and a `user.getName()`, `User.builder()`, or `log.info(...)` call used to resolve to nothing, silently breaking call-chain analysis (the agent would conclude the method didn't exist and reconstruct it by hand). Those members are now indexed from the annotations and fields, so they appear in `codegraph search` and `codegraph_explore`/`codegraph_node`, and callers trace through them like any hand-written method. They're marked as Lombok-generated so they read as generated, not hand-written; a method you write yourself is never overridden, static fields get no accessor, and a class without Lombok is unaffected. Thanks @git87663849. (#912)
 - `codegraph_explore` now follows **C and C++ function-pointer dispatch**. C does polymorphism with function pointers: a struct carries a function-pointer field, concrete functions are registered into it through a table (`static struct cmd commands[] = {{"add", cmd_add}, …}`), a designated initializer (`.handler = on_open`), or an assignment, and the code dispatches indirectly (`p->fn(argv)`). None of that was visible to analysis — the indirect call resolved to nothing, so `git`'s command runner looked like it called nothing and a vtable's implementations had no callers. CodeGraph now links the dispatch site to the registered handlers, keyed by the struct field, so "what runs when this dispatches?" traces from `p->fn(...)` into every function registered for that field. This covers the command-table idiom (git, redis) and the ops-struct/vtable idiom (curl's content-encoders, protocol handlers), including the case where a generic hook slot is reassigned from a registry (`h->func = found->fn`). It stays precise — distinct function-pointer fields don't cross-link, a plain data field is never treated as a dispatch, and a project without function-pointer dispatch is unaffected. (#932)
 - `codegraph_explore` now follows **GoFrame** route bindings in Go. GoFrame's standard router wires routes reflectively: the path and method live in a `g.Meta` struct tag on a request type (`` g.Meta `path:"/user/sign-in" method:"post"` ``), the controller method that serves it is matched by that request type, and the two are joined at runtime by `group.Bind(...)` — so there was no path string and no edge from a route to its handler, and "where is `/user/sign-in` handled?" or "where are the routes bound to controllers?" could only be answered by reading. CodeGraph now indexes each `g.Meta` route as a real route node and links it to the controller method whose signature takes that request type, so a route resolves to its handler structurally in one `codegraph_explore` call. The link is by request type, not method name — so it's correct even when the two differ (a `DeptSearchReq` served by a `List` method); it tells apart the many identical request types a large app defines one-per-module (`cash.ListReq` vs `order.ListReq`) by package, including cloned addon modules; and a route whose handler isn't present is left unlinked rather than guessed. (#747)
+- The MCP server now works in monorepos and multi-project setups. Before, if you started CodeGraph somewhere with no `.codegraph/` of its own — most often a monorepo root where you only indexed individual services — the server exposed **no tools at all**, so your agent couldn't query CodeGraph even for the sub-projects that *were* indexed. Now the tools are always available: point a query at any indexed project with the `projectPath` argument (its path, or anywhere inside it) and CodeGraph answers from that project's index — for as many projects as you like in one session. It also means a project you index *after* the server started is picked up without restarting, where before the tools stayed hidden because the server only checked for an index once at launch. A project that genuinely has no index still cleanly tells your agent to use its built-in tools there (and that you can run `codegraph init` to enable it), so single-project use is unchanged. Thanks @MaiLunJiye. (#964)
+- The Claude front-load hook now finds your indexed sub-projects in a monorepo. The optional `UserPromptSubmit` hook that injects CodeGraph context for structural questions previously only looked for an index at or above your working directory — so if you opened the monorepo root but indexed individual packages (`packages/api`, `services/auth`), it found nothing and stayed silent exactly where it was most useful. It now also looks *into* sub-projects: a single indexed sub-project gets its context front-loaded automatically, and with several the hook front-loads the one your question names (and lists the rest so the agent can target them by `projectPath`). Single-project repos are unaffected, and the scan is bounded and skipped entirely outside a recognizable workspace root. (#964)
 
 - `codegraph_explore` now surfaces the right code in large multi-layer projects. When you ask a backend-flow question in a repo that pairs an API server with a big frontend that mirrors the same domain words — say an `app/` admin UI sitting over an `api/` server — the server-side file that genuinely matches several of your query's terms is no longer pushed out of the results by the larger, more interconnected frontend layer. A file corroborated by two or more distinct query terms is now kept in the answer even when a denser unrelated layer would otherwise crowd it out, so "how does X read items / handle the request" returns the service or handler that does the work instead of a wall of frontend views. Single-layer projects are unaffected; set `CODEGRAPH_RANK_NO_MULTITERM=1` to revert to the previous ranking.
 - Impact and blast-radius analysis for TypeScript, JavaScript, Go, Python, Rust, Ruby, C, Java, C#, PHP, Scala, Kotlin, Swift, Dart, and Pascal/Delphi now understands the readers of a constant. When you change a file-scope, package-level, module-level, or class-level constant — a config object, a lookup table, a shared constant — the other symbols in that file that read it now show up as affected, where before they were invisible (impact only followed calls, imports, and inheritance, so a constant's consumers looked like "nothing depends on this"). This makes `codegraph impact`, and the impact trail in `codegraph_explore`/`codegraph_node`, catch the "change this table, break its readers" class of change. It's on by default and adds no nodes to your graph; bundled/minified files and ambiguously-shadowed names are skipped to keep results precise. Set `CODEGRAPH_VALUE_REFS=0` to turn it off.

+ 1 - 1
CLAUDE.md

@@ -104,7 +104,7 @@ CodeGraph's only channels to influence the agent are low-salience: the MCP `init
 What works is meeting the agent where it already is:
 - **explore-flow** — `codegraph_explore` is the PRIMARY tool the agent reliably calls; its query is a precise bag of symbol names (incl. qualified `Class.method`) spanning the flow the agent is after; explore finds the call path _among those named symbols_ (riding synthesized edges) and leads its output with it. (`buildFlowFromNamedSymbols`: segment/co-naming disambiguation; ≤1 unnamed bridge so it never wanders a god-function's fan-out. Overload-aware: a PascalCase type token in the query biases an overloaded name to that type's own def — `DataRequest task` → DataRequest's `task`, not the abstract base; named-symbol files sort first.)
 - **Sufficiency** — make the tool's output complete enough that the agent stops. `codegraph_node` returns the full body + the caller/callee trail, and for an AMBIGUOUS name returns **every overload's body in one call** (so the agent never Reads a file to find the right overload — validated on Alamofire/gin). This is the after-explore depth tool (labeled SECONDARY).
-- **Errors teach abandonment** — one or two `isError: true` responses early in a session and the agent stops calling codegraph entirely (maintainer-observed, repeatedly). `isError` is reserved for genuine "stop trying" cases: security refusals (`PathRefusalError`) and real malfunctions (which carry a retry-once note). Every expected/recoverable condition — project not indexed, symbol not found, file not in the index — returns a **SUCCESS-shaped response carrying the guidance** (`NotIndexedError` → `textResult`, see `ToolHandler.execute`'s catch). The same principle session-wide: an **unindexed workspace serves an empty `tools/list` + a 2-line "inactive" instructions variant** instead of 8 tools that all fail — absence is the one signal an agent can't misread, and indexing is deliberately the user's call, never the agent's.
+- **Errors teach abandonment** — one or two `isError: true` responses early in a session and the agent stops calling codegraph entirely (maintainer-observed, repeatedly). `isError` is reserved for genuine "stop trying" cases: security refusals (`PathRefusalError`) and real malfunctions (which carry a retry-once note). Every expected/recoverable condition — project not indexed, symbol not found, file not in the index — returns a **SUCCESS-shaped response carrying the guidance** (`NotIndexedError` → `textResult`, see `ToolHandler.execute`'s catch). The same principle is why the tool surface is **always exposed, even at an un-indexed root** (the old empty-`tools/list` gate was removed in #964 — it broke monorepos where only sub-projects carry a `.codegraph/`, and hid the tools from a session that started before `codegraph init`): safety comes from the response SHAPE (success-shaped guidance, never `isError`), not from hiding tools. An un-indexed root's `initialize` sends a per-project variant (`SERVER_INSTRUCTIONS_NO_ROOT_INDEX` — "pass `projectPath` to a project that has a `.codegraph/`"), not an "inactive" note; indexing is still deliberately the user's call, never the agent's.
 
 What fails is the inverse — folding a precise answer into a **fuzzy-input** tool: the now-removed `codegraph_context` took a description, not symbols, so it couldn't disambiguate a flow's endpoints and surfaced the _wrong feature_ (which is why it was cut). Precise output needs precise input — explore takes a symbol bag for exactly this reason. (`codegraph_trace` was likewise removed: explore-flow does its job and the agent under-picked it.)
 

+ 2 - 2
README.md

@@ -426,7 +426,7 @@ CodeGraph's MCP server delivers its usage guidance to your agent **automatically
 - **Answer structural questions directly with CodeGraph** — it *is* the pre-built index, so a grep/read loop just repeats work it already did. Treat the returned source as already read.
 - **Reach for `codegraph_explore` for almost anything** — "how does X work", a flow/"how does X reach Y", or surveying an area. One call returns the relevant symbols' verbatim source grouped by file, the call paths between them (dynamic-dispatch hops included), and a blast-radius summary. Name a file or symbol in the query to read its current line-numbered source.
 - **Trust the results — don't re-verify with grep**, and check the staleness banner after edits.
-- In a workspace with no index, CodeGraph announces itself inactive and serves no tools — indexing stays your decision.
+- Works **per project**: query any project that has a `.codegraph/` index by passing `projectPath` — so a monorepo where only some services are indexed, or a second repo, works in one session. A path with no index returns clean guidance to use built-in tools; indexing stays your decision.
 
 The exact text is `src/mcp/server-instructions.ts` — the single source of truth for the main agent. Because subagents and non-MCP harnesses never see the MCP guidance, the installer also writes a short marker-fenced section into the agent's instructions file pointing at the `codegraph explore` CLI equivalent.
 
@@ -534,7 +534,7 @@ When running as an MCP server, CodeGraph exposes a **single tool** — `codegrap
 
 The other tools (`codegraph_node`, `codegraph_search`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact`, `codegraph_files`, `codegraph_status`) stay fully functional but **unlisted by default** — everything they return already arrives inline on `codegraph_explore` (its blast-radius section, the relationship map, a symbol's body as its callee list). Re-enable any of them for the MCP surface with the `CODEGRAPH_MCP_TOOLS` environment variable (e.g. `CODEGRAPH_MCP_TOOLS=explore,node,search,callers`), or use their CLI equivalents (`codegraph node` / `query` / `callers` / `callees` / `impact` / `files` / `status`).
 
-In a workspace with no `.codegraph/` index, the server announces itself inactive and lists **no** tools — agents work normally with their built-in tools, and indexing stays your decision.
+Even when the server's own root has no `.codegraph/` index, the tools stay available: pass `projectPath` to query any indexed project — a sub-service in a monorepo, or a second repo — in the same session. A path that has no index returns clean guidance to use built-in tools instead, so nothing fails loudly, and indexing stays your decision.
 
 ---
 

+ 130 - 0
__tests__/frontload-hook.test.ts

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

+ 57 - 17
__tests__/mcp-unindexed.test.ts

@@ -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 () => {

+ 50 - 28
src/bin/codegraph.ts

@@ -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
-
-      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`,
-          );
+      // 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
+
+      // 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 - 0
src/directory.ts

@@ -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
src/mcp/server-instructions.ts

@@ -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
src/mcp/session.ts

@@ -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
src/mcp/tools.ts

@@ -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);