diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fded9..5f10e1a 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/__tests__/mcp-subproject-adoption.test.ts b/__tests__/mcp-subproject-adoption.test.ts new file mode 100644 index 0000000..39abac0 --- /dev/null +++ b/__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> { + const messages: Array> = []; + 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>, + predicate: (m: Record) => boolean, + timeoutMs: number, +): Promise> { + 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/ with one source file and an initialized .codegraph/. */ +async function makeIndexedChild(ws: string, name: string): Promise { + 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>, +): Promise<{ initResult: Record; 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); +}); diff --git a/src/directory.ts b/src/directory.ts index fd4a1aa..ff5c3b8 100644 --- a/src/directory.ts +++ b/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 diff --git a/src/mcp/engine.ts b/src/mcp/engine.ts index 9ee132d..8f2e5b6 100644 --- a/src/mcp/engine.ts +++ b/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 | 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; 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 { 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 diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 9711210..3f57024 100644 --- a/src/mcp/index.ts +++ b/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; } } diff --git a/src/mcp/session.ts b/src/mcp/session.ts index 866e001..1d5bd79 100644 --- a/src/mcp/session.ts +++ b/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, { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4ad7e64..b7ad62e 100644 --- a/src/mcp/tools.ts +++ b/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 ' +