Skip to content

Commit 85a8f32

Browse files
colbymchenryclaude
andauthored
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>
1 parent 0a91d0f commit 85a8f32

10 files changed

Lines changed: 432 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2424
- 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)
2525
- `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)
2626
- `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)
27+
- 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)
28+
- 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)
2729

2830
- `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.
2931
- 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.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ CodeGraph's only channels to influence the agent are low-salience: the MCP `init
104104
What works is meeting the agent where it already is:
105105
- **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.)
106106
- **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).
107-
- **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.
107+
- **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.
108108

109109
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.)
110110

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,7 @@ CodeGraph's MCP server delivers its usage guidance to your agent **automatically
426426
- **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.
427427
- **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.
428428
- **Trust the results — don't re-verify with grep**, and check the staleness banner after edits.
429-
- In a workspace with no index, CodeGraph announces itself inactive and serves no tools — indexing stays your decision.
429+
- 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.
430430

431431
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.
432432

@@ -534,7 +534,7 @@ When running as an MCP server, CodeGraph exposes a **single tool** — `codegrap
534534

535535
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`).
536536

537-
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.
537+
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.
538538

539539
---
540540

__tests__/frontload-hook.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Front-load hook project resolution (#964).
3+
*
4+
* The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
5+
* for the RIGHT project — including the monorepo case where the agent's cwd is
6+
* an un-indexed workspace root and the index lives in a sub-project. These test
7+
* `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
8+
* logic), since the end-to-end hook is validated by a live agent run, not a
9+
* unit test.
10+
*/
11+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
12+
import * as fs from 'fs';
13+
import * as os from 'os';
14+
import * as path from 'path';
15+
import { planFrontload, findIndexedSubprojectRoots } from '../src/directory';
16+
17+
/** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
18+
function mkIndexed(dir: string): string {
19+
fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
20+
fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
21+
return dir;
22+
}
23+
/** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
24+
function mkWorkspaceRoot(dir: string): string {
25+
fs.mkdirSync(dir, { recursive: true });
26+
fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
27+
return dir;
28+
}
29+
30+
describe('planFrontload — front-load hook project resolution (#964)', () => {
31+
let tmp: string;
32+
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
33+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
34+
35+
it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
36+
mkIndexed(tmp);
37+
const plan = planFrontload(tmp, 'how does login work');
38+
expect(plan.exploreRoot).toBe(tmp);
39+
expect(plan.viaSubScan).toBe(false);
40+
expect(plan.nudgeProjects).toEqual([]);
41+
});
42+
43+
it('a nested file under an indexed project resolves up to that project', () => {
44+
mkIndexed(tmp);
45+
const nested = path.join(tmp, 'src', 'deep');
46+
fs.mkdirSync(nested, { recursive: true });
47+
expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
48+
});
49+
50+
it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
51+
mkWorkspaceRoot(tmp);
52+
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
53+
const plan = planFrontload(tmp, 'how does the request get handled');
54+
expect(plan.exploreRoot).toBe(api);
55+
expect(plan.viaSubScan).toBe(true);
56+
expect(plan.nudgeProjects).toEqual([]);
57+
});
58+
59+
it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
60+
mkWorkspaceRoot(tmp);
61+
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
62+
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
63+
const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
64+
expect(plan.exploreRoot).toBe(api);
65+
expect(plan.viaSubScan).toBe(true);
66+
expect(plan.nudgeProjects).toEqual([web]);
67+
});
68+
69+
it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
70+
mkWorkspaceRoot(tmp);
71+
mkIndexed(path.join(tmp, 'packages', 'api'));
72+
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
73+
const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
74+
expect(plan.exploreRoot).toBe(web);
75+
});
76+
77+
it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
78+
mkWorkspaceRoot(tmp);
79+
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
80+
const web = mkIndexed(path.join(tmp, 'packages', 'web'));
81+
const plan = planFrontload(tmp, 'how does authentication work end to end?');
82+
expect(plan.exploreRoot).toBeNull();
83+
expect(plan.viaSubScan).toBe(true);
84+
expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
85+
});
86+
87+
it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
88+
// Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
89+
mkIndexed(path.join(tmp, 'some', 'project'));
90+
const plan = planFrontload(tmp, 'how does it work');
91+
expect(plan.exploreRoot).toBeNull();
92+
expect(plan.nudgeProjects).toEqual([]);
93+
});
94+
95+
it('nothing indexed anywhere → no-op', () => {
96+
mkWorkspaceRoot(tmp);
97+
fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
98+
const plan = planFrontload(tmp, 'how does it work');
99+
expect(plan.exploreRoot).toBeNull();
100+
expect(plan.nudgeProjects).toEqual([]);
101+
});
102+
});
103+
104+
describe('findIndexedSubprojectRoots', () => {
105+
let tmp: string;
106+
beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
107+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
108+
109+
it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
110+
mkIndexed(path.join(tmp, 'packages', 'api'));
111+
mkIndexed(path.join(tmp, 'services', 'auth'));
112+
// Decoys that must NOT be scanned into.
113+
mkIndexed(path.join(tmp, 'node_modules', 'dep'));
114+
mkIndexed(path.join(tmp, '.git', 'x'));
115+
const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
116+
expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
117+
});
118+
119+
it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
120+
const api = mkIndexed(path.join(tmp, 'packages', 'api'));
121+
mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
122+
const found = findIndexedSubprojectRoots(tmp);
123+
expect(found).toEqual([api]);
124+
});
125+
126+
it('respects the depth bound', () => {
127+
mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
128+
expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
129+
});
130+
});

0 commit comments

Comments
 (0)