From 85a8f32fd9a3c5730949fd3235d8713c10c9b66a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 23 Jun 2026 12:57:47 -0500 Subject: [PATCH] fix(mcp): serve tools without a root index + make the front-load hook monorepo-aware (#964) (#966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 2 + CLAUDE.md | 2 +- README.md | 4 +- __tests__/frontload-hook.test.ts | 130 +++++++++++++++++++++++++++++++ __tests__/mcp-unindexed.test.ts | 74 ++++++++++++++---- src/bin/codegraph.ts | 76 +++++++++++------- src/directory.ts | 130 +++++++++++++++++++++++++++++++ src/mcp/server-instructions.ts | 40 ++++++---- src/mcp/session.ts | 43 +++++----- src/mcp/tools.ts | 17 ++-- 10 files changed, 431 insertions(+), 87 deletions(-) create mode 100644 __tests__/frontload-hook.test.ts 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);