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
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ' +
|
||||
|
||||
Reference in New Issue
Block a user