diff --git a/CHANGELOG.md b/CHANGELOG.md
index e62e6d5..da91bb5 100644
--- a/CHANGELOG.md
+++ b/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.
diff --git a/CLAUDE.md b/CLAUDE.md
index 0a1369b..a063956 100644
--- a/CLAUDE.md
+++ b/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.)
diff --git a/README.md b/README.md
index 9bc3a4e..35f4b62 100644
--- a/README.md
+++ b/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.
---
diff --git a/__tests__/frontload-hook.test.ts b/__tests__/frontload-hook.test.ts
new file mode 100644
index 0000000..43943d4
--- /dev/null
+++ b/__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([]);
+ });
+});
diff --git a/__tests__/mcp-unindexed.test.ts b/__tests__/mcp-unindexed.test.ts
index 5194193..efc4e67 100644
--- a/__tests__/mcp-unindexed.test.ts
+++ b/__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 () => {
diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts
index 8fad0c0..4c5658c 100644
--- a/src/bin/codegraph.ts
+++ b/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
+ // 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(
- `\n${body}\n\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(
+ `\n${body}${others}\n\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(
+ `\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:") +
+ `\n`,
+ );
}
} catch {
// Degradable by contract: never surface an error to the prompt pipeline.
diff --git a/src/directory.ts b/src/directory.ts
index af4a722..bf3a8ab 100644
--- a/src/directory.ts
+++ b/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,
diff --git a/src/mcp/server-instructions.ts b/src/mcp/server-instructions.ts
index dc42ef4..88f6f2e 100644
--- a/src/mcp/server-instructions.ts
+++ b/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).
`;
diff --git a/src/mcp/session.ts b/src/mcp/session.ts
index a197087..cea2262 100644
--- a/src/mcp/session.ts
+++ b/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 {
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(),
});
}
diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts
index 707e642..f9fc57c 100644
--- a/src/mcp/tools.ts
+++ b/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);