Răsfoiți Sursa

fix(mcp): adopt a single indexed sub-project below the server root + say when no project resolves (#1606, #1607) (#1614)

Fixes #1606 and #1607 together — the MCP server's root resolution never got the sub-project down-scan `planFrontload` gained in #964, and the resulting no-default state was completely silent.

## What changed

**Adoption (#1606).** A new `resolveServerRoot()` in `src/directory.ts` is the single resolution every server entry point now uses: up-walk first (`findNearestCodeGraphRoot`, the common case, unchanged), and when that misses, the existing bounded down-scan (`findIndexedSubprojectRoots` — depth 4, max 64, heavy dirs skipped). **Exactly one** indexed sub-project is unambiguous and is adopted as the default project — `open` → `startWatching` → `catchUpSync` → query pool, the full normal path. Zero or several candidates → no default, never a guess. Wired into:

- `MCPEngine.doInitialize()` and `retryInitializeSync()` — the retry path also picks up a child indexed *after* the server started (its down-scan is throttled to once per 5s so the persistent no-default state doesn't pay a directory walk per tool call; the up-walk still runs every time).
- `resolveDaemonRoot()` — the adopted root gets the shared daemon (one watcher, one writer, socket keyed on the child) instead of a direct-mode server per host, exactly as the issue suggested.
- `MCPSession.handleInitialize()` — the instructions variant is picked with the same resolution, so a workspace whose single child becomes the default gets the full single-project playbook. Race-free by construction: handshake and engine compute it independently, no ordering assumed.

**Workspace-root gate (the open question in #1606).** Decided deliberately: the down-scan runs only when the base has a workspace manifest (`looksLikeProjectRoot`, unchanged list) **or a `.git` entry** — the exact container shape that motivated the report — and never when the base is `$HOME` or the filesystem root. The gate lives in the new helper only; `planFrontload` and the prompt-hook are untouched, so #1454's surface is not widened.

**Diagnostics (#1607).** The no-root branch is no longer silent:

```
[CodeGraph MCP] No .codegraph/ at or above <searchFrom>: no default project, live sync disabled.
[CodeGraph MCP] Indexed sub-projects found: service-a, service-b. Pass `projectPath` per call, or launch with --path.
```

(second line only when the scan found candidates), plus one line naming the adopted child when adoption happens. The same fact is protocol-reachable: the "No CodeGraph project is loaded" tool response now lists the discovered sub-projects with `projectPath` guidance. The list is engine-maintained (initial resolve + throttled retry) — tool calls never scan — and the response stays SUCCESS-shaped (`NotIndexedError` → `textResult`, never `isError`).

## Tested

- New `__tests__/mcp-subproject-adoption.test.ts` (real spawned server over stdio, same harness as `mcp-roots.test.ts`): single child → tool call answers from it, full instructions, adoption stderr; two children → no default, both listed in the tool response and stderr, per-project instructions; no manifest/no `.git` → gate holds, no scan, plain one-line message.
- `mcp-subproject-adoption` + `mcp-roots` + `mcp-initialize` + `daemon-bind-failure`: **13/13 pass**.
- End-to-end repro harness against the built `dist/` on an unmodified-main build first (confirmed: empty stderr, no adoption, NO_ROOT instructions even with one adoptable child), then on this branch (all three shapes behave as above; catch-up sync runs on the adopted child).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
Colby Mchenry 1 săptămână în urmă
părinte
comite
a5c2709e6d
7 a modificat fișierele cu 358 adăugiri și 20 ștergeri
  1. 4 0
      CHANGELOG.md
  2. 188 0
      __tests__/mcp-subproject-adoption.test.ts
  3. 59 0
      src/directory.ts
  4. 51 4
      src/mcp/engine.ts
  5. 8 2
      src/mcp/index.ts
  6. 18 13
      src/mcp/session.ts
  7. 30 1
      src/mcp/tools.ts

+ 4 - 0
CHANGELOG.md

@@ -27,6 +27,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- The MCP server now finds your project when it's launched from a workspace folder above it: if the launch directory has no index of its own but exactly one indexed project sits below it (a repo container, an agent workspace, a monorepo root), that project becomes the session's default — live file watching and the shared daemon included — instead of every tool call failing until a `projectPath` or `--path` is supplied. Thanks @nakisen. (#1606)
+
+- When no project can be resolved at all, the MCP server now says so instead of starting silently: a startup log line names the directory it searched, and tool calls list the indexed sub-projects it can see nearby so you can pass one as `projectPath`. Previously the server looked healthy from the outside while every tool quietly had no project to answer from. Thanks @nakisen. (#1607)
+
 - Indexing no longer hangs on a Swift Vapor project containing a call with a long argument list. A single `.get(...)`-style call with many labeled arguments and no `use:` handler — the shape generated request builders produce — could stall `codegraph index`, `codegraph sync`, and the MCP server indefinitely. Route detection now handles such files in milliseconds, and every previously-recognized route shape still parses exactly as before. Thanks @maxmilian. (#1544) (Swift)
 
 - `codegraph status` now sees new files inside brand-new directories. Git reports an entirely-untracked directory as a single collapsed entry, so source files created there — a freshly scaffolded `frontend/`, for example — were missing from the pending-changes report, which could claim everything was up to date while those files had not yet been indexed. Thanks @maxmilian. (#1213)

+ 188 - 0
__tests__/mcp-subproject-adoption.test.ts

@@ -0,0 +1,188 @@
+/**
+ * MCP workspace sub-project adoption + no-default diagnostics (#1606, #1607).
+ *
+ * When an MCP host launches the server from a workspace root whose indexed
+ * projects live in CHILD directories (a repo container, a monorepo root), the
+ * upward walk finds nothing. The server now runs the same bounded down-scan
+ * the front-load hook uses:
+ *   - exactly ONE indexed sub-project  → adopted as the session's default;
+ *   - zero or several                  → no default, but the state is SAID:
+ *     stderr names what was searched/found, and tool calls list the indexed
+ *     sub-projects so the agent can pass one as `projectPath`;
+ *   - non-workspace base (no manifest, no .git) → no scan at all.
+ *
+ * Same real-subprocess harness as mcp-roots.test.ts — no mocking.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import { CodeGraph } from '../src';
+
+const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
+
+function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
+  // --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
+  // session in direct mode so no detached daemon outlives the test.
+  return spawn(process.execPath, [BIN, 'serve', '--mcp', '--no-watch'], {
+    cwd,
+    stdio: ['pipe', 'pipe', 'pipe'],
+    env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
+  }) as ChildProcessWithoutNullStreams;
+}
+
+function collectMessages(child: ChildProcessWithoutNullStreams): Array<Record<string, any>> {
+  const messages: Array<Record<string, any>> = [];
+  let buf = '';
+  child.stdout.on('data', (chunk) => {
+    buf += chunk.toString('utf8');
+    let idx;
+    while ((idx = buf.indexOf('\n')) !== -1) {
+      const line = buf.slice(0, idx).trim();
+      buf = buf.slice(idx + 1);
+      if (!line) continue;
+      try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
+    }
+  });
+  return messages;
+}
+
+function collectStderr(child: ChildProcessWithoutNullStreams): { text: () => string } {
+  let buf = '';
+  child.stderr.on('data', (chunk) => { buf += chunk.toString('utf8'); });
+  return { text: () => buf };
+}
+
+function waitForMessage(
+  messages: ReadonlyArray<Record<string, any>>,
+  predicate: (m: Record<string, any>) => boolean,
+  timeoutMs: number,
+): Promise<Record<string, any>> {
+  return new Promise((resolve, reject) => {
+    const started = Date.now();
+    const tick = () => {
+      const hit = messages.find(predicate);
+      if (hit) return resolve(hit);
+      if (Date.now() - started > timeoutMs) {
+        return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
+      }
+      setTimeout(tick, 20);
+    };
+    tick();
+  });
+}
+
+function send(child: ChildProcessWithoutNullStreams, msg: object): void {
+  child.stdin.write(JSON.stringify(msg) + '\n');
+}
+
+const CLIENT_INFO = { name: 'test', version: '0.0.0' };
+
+/** Create ws/<name> with one source file and an initialized .codegraph/. */
+async function makeIndexedChild(ws: string, name: string): Promise<string> {
+  const dir = path.join(ws, name);
+  fs.mkdirSync(dir, { recursive: true });
+  fs.writeFileSync(path.join(dir, 'a.ts'), `export function hello_${name}() { return 1; }\n`);
+  const cg = await CodeGraph.init(dir);
+  cg.close();
+  return dir;
+}
+
+/** initialize (no rootUri, no roots capability) → initialized → codegraph_status. */
+async function driveStatusCall(
+  child: ChildProcessWithoutNullStreams,
+  messages: Array<Record<string, any>>,
+): Promise<{ initResult: Record<string, any>; statusText: string }> {
+  send(child, {
+    jsonrpc: '2.0', id: 0, method: 'initialize',
+    params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
+  });
+  const initResult = await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
+  send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
+  send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
+  const resp = await waitForMessage(messages, (m) => m.id === 1, 10000);
+  return { initResult, statusText: resp.result.content[0].text as string };
+}
+
+describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (#1607)', () => {
+  let ws: string;
+  let child: ChildProcessWithoutNullStreams | null = null;
+
+  beforeEach(() => {
+    ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-'));
+  });
+
+  afterEach(() => {
+    if (child && !child.killed) {
+      child.kill('SIGKILL');
+      child = null;
+    }
+    fs.rmSync(ws, { recursive: true, force: true });
+  });
+
+  it('adopts the single indexed sub-project below a workspace root as the default project', async () => {
+    fs.mkdirSync(path.join(ws, '.git')); // workspace marker — no manifest needed
+    await makeIndexedChild(ws, 'service-a');
+
+    child = spawnServer(ws);
+    const messages = collectMessages(child);
+    const stderr = collectStderr(child);
+
+    const { initResult, statusText } = await driveStatusCall(child, messages);
+
+    // The default project works without any projectPath.
+    expect(statusText).toContain('CodeGraph Status');
+    expect(statusText).not.toContain('No CodeGraph project is loaded');
+    // The adoption is announced on stderr (#1607 discoverability).
+    expect(stderr.text()).toContain('adopted the single indexed sub-project');
+    expect(stderr.text()).toContain('service-a');
+    // Instructions match what the engine adopted: the FULL single-project
+    // playbook, not the per-project variant.
+    const instructions = initResult.result.instructions as string;
+    expect(instructions).not.toContain('per-project; pass projectPath');
+  }, 20000);
+
+  it('lists several indexed sub-projects instead of adopting one, in stderr and in tool responses', async () => {
+    fs.mkdirSync(path.join(ws, '.git'));
+    await makeIndexedChild(ws, 'service-a');
+    await makeIndexedChild(ws, 'service-b');
+
+    child = spawnServer(ws);
+    const messages = collectMessages(child);
+    const stderr = collectStderr(child);
+
+    const { initResult, statusText } = await driveStatusCall(child, messages);
+
+    // No default was adopted — ambiguous — but the state is said, not silent.
+    expect(statusText).toContain('No CodeGraph project is loaded');
+    // Protocol-reachable listing (#1607): the tool response names what IS there.
+    expect(statusText).toContain('Indexed sub-projects were found below it');
+    expect(statusText).toContain('service-a');
+    expect(statusText).toContain('service-b');
+    expect(statusText).toContain('projectPath');
+    // stderr carries the same facts for the host's log.
+    expect(stderr.text()).toContain('no default project, live sync disabled');
+    expect(stderr.text()).toContain('Indexed sub-projects found:');
+    // Ambiguous root → per-project instructions variant.
+    const instructions = initResult.result.instructions as string;
+    expect(instructions).toContain('per-project; pass projectPath');
+  }, 20000);
+
+  it('does not scan below a base that is not a workspace (no manifest, no .git)', async () => {
+    // NO .git and no manifest at ws — the gate must keep the scan off even
+    // though an indexed child exists.
+    await makeIndexedChild(ws, 'service-a');
+
+    child = spawnServer(ws);
+    const messages = collectMessages(child);
+    const stderr = collectStderr(child);
+
+    const { statusText } = await driveStatusCall(child, messages);
+
+    expect(statusText).toContain('No CodeGraph project is loaded');
+    expect(statusText).not.toContain('Indexed sub-projects were found below it');
+    expect(stderr.text()).toContain('no default project, live sync disabled');
+    expect(stderr.text()).not.toContain('Indexed sub-projects found:');
+  }, 20000);
+});

+ 59 - 0
src/directory.ts

@@ -233,6 +233,65 @@ export function findIndexedSubprojectRoots(
   return out;
 }
 
+/** Result of {@link resolveServerRoot}. */
+export interface ServerRootResolution {
+  /** The project root to serve as the default, or null when none resolved. */
+  root: string | null;
+  /** True when `root` was adopted from the down-scan rather than the up-walk. */
+  viaSubScan: boolean;
+  /**
+   * Indexed sub-projects the down-scan saw when it ran but could NOT adopt
+   * (zero or several candidates). Empty when the up-walk resolved or the scan
+   * was skipped. Callers surface these so "no default project" errors can say
+   * what IS reachable (#1607).
+   */
+  candidates: string[];
+}
+
+/**
+ * Whether `base` is a plausible workspace root for the sub-project down-scan.
+ * Mirrors `planFrontload`'s manifest gate, widened to accept a bare `.git`
+ * entry — the #1606 shape is a workspace container holding only agent config
+ * and a `.git`, with every build manifest living in the indexed children. The
+ * user's home directory and the filesystem root are never eligible: a stray
+ * manifest there must not turn server startup into a scan that could adopt an
+ * unrelated project (#1454 documents that failure mode for the prompt-hook).
+ */
+function eligibleForSubprojectScan(base: string): boolean {
+  if (base === path.parse(base).root) return false;
+  let home: string | null = null;
+  try { home = os.homedir(); } catch { home = null; }
+  if (home && (base === home || base === path.resolve(home))) return false;
+  if (looksLikeProjectRoot(base)) return true;
+  return fs.existsSync(path.join(base, '.git'));
+}
+
+/**
+ * Resolve the project root an MCP server should serve as its DEFAULT project
+ * (#1606). Up-walk first (`findNearestCodeGraphRoot` — the common case, and
+ * cheap). When nothing is indexed at or above `searchFrom`, run the bounded
+ * sub-project down-scan `planFrontload` already uses, behind the workspace
+ * gate above: EXACTLY ONE indexed sub-project is unambiguous and is adopted
+ * as the root; zero or several yield no root, with the candidates carried so
+ * the caller can name them instead of failing silently (#1607).
+ *
+ * `opts.subprojectScan: false` skips the down-scan entirely (the per-tool-call
+ * retry path throttles it; the up-walk always runs).
+ */
+export function resolveServerRoot(
+  searchFrom: string,
+  opts: { subprojectScan?: boolean } = {},
+): ServerRootResolution {
+  const up = findNearestCodeGraphRoot(searchFrom);
+  if (up) return { root: up, viaSubScan: false, candidates: [] };
+  if (opts.subprojectScan === false) return { root: null, viaSubScan: false, candidates: [] };
+  const base = path.resolve(searchFrom);
+  if (!eligibleForSubprojectScan(base)) return { root: null, viaSubScan: false, candidates: [] };
+  const subs = findIndexedSubprojectRoots(base);
+  if (subs.length === 1) return { root: subs[0]!, viaSubScan: true, candidates: subs };
+  return { root: null, viaSubScan: false, candidates: subs };
+}
+
 /**
  * Unicode-aware word-boundary emulation for the keyword lists below. JS's `\b`
  * is ASCII-only — it fires only at `[A-Za-z0-9_]` edges — so it can never bound

+ 51 - 4
src/mcp/engine.ts

@@ -11,8 +11,9 @@
  */
 
 import * as os from 'os';
+import * as path from 'path';
 import type CodeGraph from '../index';
-import { findNearestCodeGraphRoot } from '../directory';
+import { resolveServerRoot } from '../directory';
 import { watchDisabledReason } from '../sync';
 import { ToolHandler } from './tools';
 import { QueryPool, resolvePoolSize } from './query-pool';
@@ -26,6 +27,9 @@ import { QueryPool, resolvePoolSize } from './query-pool';
 const loadCodeGraph = (): typeof import('../index').default =>
   (require('../index') as typeof import('../index')).default;
 
+/** How often the per-tool-call retry may re-run the sub-project down-scan. */
+const RETRY_SUBSCAN_TTL_MS = 5_000;
+
 export interface MCPEngineOptions {
   /**
    * Whether to start the file watcher when initializing. Daemon and direct
@@ -59,6 +63,9 @@ export class MCPEngine {
   private projectPath: string | null = null;
   // Set on first `ensureInitialized` so subsequent sessions don't redo work.
   private initPromise: Promise<void> | null = null;
+  // Throttle for the retry path's sub-project down-scan (#1606) — the scan is
+  // bounded but shouldn't run on every tool call in the no-default state.
+  private lastRetrySubScanAt = 0;
   private watcherStarted = false;
   private opts: Required<MCPEngineOptions>;
   private closed = false;
@@ -158,8 +165,20 @@ export class MCPEngine {
     if (this.closed) return;
     if (this.toolHandler.hasDefaultCodeGraph()) return;
     this.toolHandler.setDefaultProjectHint(searchFrom);
-    const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
+    // Same resolution `doInitialize` used: up-walk, then the bounded workspace
+    // down-scan (#1606) — this retry is exactly the path that picks up a
+    // project (root or child) `codegraph init`'d after the server started. The
+    // down-scan is throttled so the persistent no-default state doesn't pay a
+    // directory walk on every tool call; the up-walk always runs.
+    const scanDue = Date.now() - this.lastRetrySubScanAt >= RETRY_SUBSCAN_TTL_MS;
+    const res = resolveServerRoot(searchFrom, { subprojectScan: scanDue });
+    if (scanDue) {
+      this.lastRetrySubScanAt = Date.now();
+      if (!res.root) this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
+    }
+    const resolvedRoot = res.root;
     if (!resolvedRoot) return;
+    if (res.viaSubScan) this.logSubprojectAdoption(searchFrom, resolvedRoot);
     try {
       // Close any previously failed instance to avoid leaking resources.
       if (this.cg) {
@@ -201,12 +220,32 @@ export class MCPEngine {
   private async doInitialize(searchFrom: string): Promise<void> {
     this.toolHandler.setDefaultProjectHint(searchFrom);
 
-    const resolvedRoot = findNearestCodeGraphRoot(searchFrom);
+    // Up-walk first; when nothing is indexed at or above searchFrom, a bounded
+    // down-scan may adopt a SINGLE indexed sub-project as the default (#1606 —
+    // the workspace-container shape where only children are indexed). Zero or
+    // several candidates → no default project, but SAY so (#1607): the silent
+    // variant of this state read as "CodeGraph is broken" and was diagnosable
+    // only by knowing to look for a missing ~/.codegraph/daemons/ entry.
+    const res = resolveServerRoot(searchFrom);
+    const resolvedRoot = res.root;
     if (!resolvedRoot) {
-      // No .codegraph/ above searchFrom. Sessions may still discover one later via roots/list
+      // Sessions may still discover a project later via roots/list, and the
+      // per-call retry re-resolves — this state is recoverable, hence stderr
+      // (not a failure) + candidates surfaced through the tool-call error.
       this.projectPath = searchFrom;
+      this.toolHandler.setKnownSubprojects(res.candidates, searchFrom);
+      process.stderr.write(
+        `[CodeGraph MCP] No .codegraph/ at or above ${searchFrom}: no default project, live sync disabled.\n`
+      );
+      if (res.candidates.length > 0) {
+        const rels = res.candidates.map((c) => path.relative(searchFrom, c) || '.');
+        process.stderr.write(
+          `[CodeGraph MCP] Indexed sub-projects found: ${rels.join(', ')}. Pass \`projectPath\` per call, or launch with --path.\n`
+        );
+      }
       return;
     }
+    if (res.viaSubScan) this.logSubprojectAdoption(searchFrom, resolvedRoot);
 
     this.projectPath = resolvedRoot;
     try {
@@ -221,6 +260,14 @@ export class MCPEngine {
     }
   }
 
+  /** One stderr line when the default project came from the down-scan (#1606). */
+  private logSubprojectAdoption(searchFrom: string, root: string): void {
+    const rel = path.relative(searchFrom, root) || root;
+    process.stderr.write(
+      `[CodeGraph MCP] No .codegraph/ at ${searchFrom}; adopted the single indexed sub-project ${rel} as the default project.\n`
+    );
+  }
+
   /**
    * Start file watching on the active CodeGraph instance. Idempotent — the
    * watcher is per-engine, not per-session, which is why the daemon path

+ 8 - 2
src/mcp/index.ts

@@ -37,7 +37,7 @@
 import * as fs from 'fs';
 import * as path from 'path';
 import { spawn, StdioOptions } from 'child_process';
-import { findNearestCodeGraphRoot, getCodeGraphDir } from '../directory';
+import { resolveServerRoot, getCodeGraphDir } from '../directory';
 import { StdioTransport } from './transport';
 import { MCPEngine } from './engine';
 import { MCPSession } from './session';
@@ -150,6 +150,12 @@ export function watchdogProgressPaths(root: string | null): { progressPaths?: st
  * that case the caller must run in direct mode, since the daemon lockfile
  * and socket both live under `.codegraph/`.
  *
+ * Uses the same resolution as the engine (#1606): up-walk first, then the
+ * bounded workspace down-scan that adopts a SINGLE indexed sub-project. A
+ * workspace root above one indexed child therefore gets the shared daemon
+ * (one watcher, one writer, keyed on the child) instead of a direct-mode
+ * server per host.
+ *
  * The result is canonicalized with `realpathSync` so every client converges on
  * the same socket/lock path regardless of how it expressed the path: a client
  * launched with cwd under a symlink (e.g. macOS `/var` → `/private/var`, where
@@ -159,7 +165,7 @@ export function watchdogProgressPaths(root: string | null): { progressPaths?: st
  */
 function resolveDaemonRoot(explicitPath: string | null): string | null {
   const candidate = explicitPath ?? process.cwd();
-  const root = findNearestCodeGraphRoot(candidate);
+  const root = resolveServerRoot(candidate).root;
   if (!root) return null;
   try { return fs.realpathSync(root); } catch { return root; }
 }

+ 18 - 13
src/mcp/session.ts

@@ -18,7 +18,7 @@ import { MCPEngine } from './engine';
 import { tools } from './tools';
 import { SERVER_INSTRUCTIONS, SERVER_INSTRUCTIONS_NO_ROOT_INDEX } from './server-instructions';
 import { CodeGraphPackageVersion } from './version';
-import { findNearestCodeGraphRoot } from '../directory';
+import { resolveServerRoot } from '../directory';
 import { getTelemetry, ClientInfo } from '../telemetry';
 import { getUpdateNotice } from '../upgrade/update-check';
 import { ExploreSessionState } from './explore-session-state';
@@ -230,18 +230,23 @@ export class MCPSession {
       explicitPath = this.explicitProjectPath;
     }
 
-    // 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). 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;
+    // Pick the instructions variant by the root's index state — synchronous
+    // and bounded (an existsSync walk-up plus, when that misses, the depth- and
+    // count-bounded workspace down-scan; no DB open, so the #172 respond-fast
+    // contract holds). This is the SAME resolution the engine's doInitialize
+    // runs (#1606), so the variant matches what the engine will actually adopt
+    // — a workspace whose single indexed sub-project becomes the default gets
+    // the full single-project playbook, race-free by construction (both sides
+    // compute it independently; no ordering between handshake and engine init
+    // is assumed). When the root ISN'T indexed (and nothing was adopted), 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 will resolve.
+    const indexed = resolveServerRoot(explicitPath ?? process.cwd()).root !== null;
 
     // Respond to the handshake BEFORE doing any heavy init — see issue #172.
     this.transport.sendResult(request.id, {

+ 30 - 1
src/mcp/tools.ts

@@ -82,7 +82,7 @@ export class NotIndexedError extends Error {}
  * retry guidance — abandoning this path is the desired agent reaction.
  */
 export class PathRefusalError extends Error {}
-import { resolve as resolvePath } from 'path';
+import { resolve as resolvePath, relative as relativePath } from 'path';
 
 /** Maximum output length to prevent context bloat (characters) */
 const MAX_OUTPUT_LENGTH = 15000;
@@ -1314,6 +1314,13 @@ export class ToolHandler {
   // The directory the server last searched for a default project. Surfaced in
   // the "not initialized" error so users can see why detection missed.
   private defaultProjectHint: string | null = null;
+  // Indexed sub-projects the engine's bounded down-scan saw below the search
+  // base when no default project resolved (#1607). Listed in the "not
+  // initialized" error so the fact is reachable through the protocol, not just
+  // the host's stderr capture. Engine-maintained (initial resolve + throttled
+  // retry) — tool calls themselves never scan.
+  private knownSubprojects: string[] = [];
+  private knownSubprojectsBase: string | null = null;
   // Per-start-path cache of the git worktree/index mismatch (issue #155). The
   // mismatch is a fixed property of (where the request came from → which
   // .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
@@ -1411,6 +1418,27 @@ export class ToolHandler {
     this.defaultProjectHint = searchedPath;
   }
 
+  /**
+   * Engine-only: record the indexed sub-projects the workspace down-scan saw
+   * when it could not adopt a default project (#1606/#1607). An empty list
+   * clears any previous note.
+   */
+  setKnownSubprojects(roots: string[], base: string): void {
+    this.knownSubprojects = roots;
+    this.knownSubprojectsBase = base;
+  }
+
+  /** One message line naming the indexed sub-projects, or '' when none known. */
+  private formatKnownSubprojects(): string {
+    if (this.knownSubprojects.length === 0) return '';
+    const base = this.knownSubprojectsBase;
+    const rels = this.knownSubprojects.map((r) => (base ? relativePath(base, r) || '.' : r));
+    return (
+      `Indexed sub-projects were found below it: ${rels.join(', ')} — ` +
+      'pass one of them (absolute, or resolved against that directory) as projectPath.\n'
+    );
+  }
+
   /**
    * Whether a default CodeGraph instance is available
    */
@@ -1533,6 +1561,7 @@ export class ToolHandler {
         throw new NotIndexedError(
           'No CodeGraph project is loaded for this session.\n' +
           `Searched for a .codegraph/ directory starting from: ${searched}\n` +
+          this.formatKnownSubprojects() +
           '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 ' +