From a049860c5f589eb961aabdc455faec83fbdd9c5c Mon Sep 17 00:00:00 2001 From: Christopher Beaulieu Date: Sat, 12 Sep 2026 18:13:29 -0400 Subject: [PATCH] feat(mcp): warn when project indexes need rebuilding --- CHANGELOG.md | 2 + __tests__/mcp-daemon.test.ts | 111 +++++++ __tests__/mcp-index-version-warning.test.ts | 270 ++++++++++++++++++ .../2026-09-12-mcp-index-version-warning.md | 189 ++++++++++++ src/mcp/index-version-warning.ts | 44 +++ src/mcp/proxy.ts | 38 ++- src/mcp/session.ts | 10 +- src/mcp/tools.ts | 73 ++++- 8 files changed, 733 insertions(+), 4 deletions(-) create mode 100644 __tests__/mcp-index-version-warning.test.ts create mode 100644 docs/superpowers/plans/2026-09-12-mcp-index-version-warning.md create mode 100644 src/mcp/index-version-warning.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b68b091..676545357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- **MCP clients now know when an index needs a rebuild.** The first successful tool response for a project whose index predates the running extraction engine carries a one-time warning with the available build details and the `codegraph index` command. Each project is tracked independently in each MCP session, including projects selected with `projectPath`; `codegraph_status` always reports the index-build version, running version, and whether rebuilding is recommended. Detection is local and never starts a rebuild automatically. (#1852) + - **Codex and Astra read project guidance from `AGENTS.md`.** The canonical agent guide now lives in `AGENTS.md` (with a nested `docs/AGENTS.md` for long validation notes); `CLAUDE.md` is a thin `@AGENTS.md` wrapper for Claude Code. Codex/Astra no longer miss the old CLAUDE-only instructions. - **A big screen's picture stops wrapping into a column.** How wide a screen's lines run before they wrap was worked out with a formula, and the formula was wrong for the way these pictures are actually drawn: a part of a screen spends lines on its own structure — a step that fires things gets a line to itself, and what it fires starts another — so estimating the lines from the boxes alone badly undercounted them, and one screen's 98 boxes wrapped into a 4,356px column. Laying a picture out is cheap and exact, so the widths are now simply tried and the one that comes out closest to the shape of a window is kept. Across one app's 51 screens the tallest picture went from 4,356px to 3,796px, total height fell 8%, and — because a shorter picture is also a picture whose lines have less far to go — lines running over other boxes fell by a third and lines crossing each other went from 13 to 5. diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index c73ac564c..7576a0250 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -40,6 +40,7 @@ import * as path from 'path'; import { CodeGraph } from '../src'; import { getDaemonSocketPath } from '../src/mcp/daemon-paths'; import { CodeGraphPackageVersion } from '../src/mcp/version'; +import { EXTRACTION_VERSION } from '../src/extraction/extraction-version'; const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); @@ -162,6 +163,17 @@ function countListeningLines(root: string): number { return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length; } +function stampIndexStale(cg: CodeGraph, version: string): void { + const metadata = cg as unknown as { + queries: { setMetadata(key: string, value: string): void }; + }; + metadata.queries.setMetadata('indexed_with_version', version); + metadata.queries.setMetadata( + 'indexed_with_extraction_version', + String(EXTRACTION_VERSION - 1), + ); +} + function killTree(...procs: ChildProcessWithoutNullStreams[]): void { for (const p of procs) { if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } } @@ -238,6 +250,105 @@ describe('Shared MCP daemon (issue #411)', () => { expect(readLockPid(realRoot)).toBe(daemonPid); }, 40000); + it('warns once per proxied client when the shared project index is stale (#1852)', async () => { + fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n'); + const indexed = await CodeGraph.open(tempDir); + await indexed.indexAll(); + stampIndexStale(indexed, '0.1.0'); + indexed.close(); + + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; + const first = spawnServer(tempDir, env); + const second = spawnServer(tempDir, env); + servers.push(first, second); + sendInitialize(first.child, `file://${tempDir}`, 1); + sendInitialize(second.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(first.stdout, 1), 10000); + await waitFor(() => findResponse(second.stdout, 1), 10000); + await waitFor(() => first.stderr.some((line) => line.includes('Attached to shared daemon')), 8000); + await waitFor(() => second.stderr.some((line) => line.includes('Attached to shared daemon')), 8000); + + const callSearch = (server: SpawnedServer, id: number) => { + sendMessage(server.child, { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'codegraph_search', arguments: { query: 'alpha' } }, + }); + }; + const responseText = (response: any): string => response.result.content[0].text; + + callSearch(first, 2); + const firstInitial = await waitFor(() => findResponse(first.stdout, 2), 10000); + + callSearch(first, 3); + callSearch(second, 2); + const firstRepeat = await waitFor(() => findResponse(first.stdout, 3), 10000); + const secondInitial = await waitFor(() => findResponse(second.stdout, 2), 10000); + + expect(responseText(firstInitial)).toMatch(/index predates the running extraction engine/i); + expect(responseText(firstRepeat)).not.toMatch(/index predates/i); + expect(responseText(secondInitial)).toMatch(/index predates the running extraction engine/i); + expect(countListeningLines(realRoot)).toBe(1); + }, 45000); + + it('does not repeat a daemon-delivered warning after proxy failover (#1852)', async () => { + fs.writeFileSync(path.join(tempDir, 'alpha.ts'), 'export function alpha() { return 1; }\n'); + const indexed = await CodeGraph.open(tempDir); + await indexed.indexAll(); + stampIndexStale(indexed, '0.1.0'); + indexed.close(); + + const otherRoot = path.join(tempDir, 'other'); + fs.mkdirSync(otherRoot); + fs.writeFileSync(path.join(otherRoot, 'bravo.ts'), 'export function bravo() { return 2; }\n'); + const other = await CodeGraph.init(otherRoot, { index: false }); + await other.indexAll(); + stampIndexStale(other, '0.2.0'); + other.close(); + + const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '30000', CODEGRAPH_PPID_POLL_MS: '5000' }; + const server = spawnServer(tempDir, env); + servers.push(server); + sendInitialize(server.child, `file://${tempDir}`, 1); + await waitFor(() => findResponse(server.stdout, 1), 10000); + await waitFor(() => server.stderr.some((line) => line.includes('Attached to shared daemon')), 8000); + await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000); + const daemonPid = readLockPid(realRoot)!; + + sendMessage(server.child, { + jsonrpc: '2.0', id: 2, method: 'tools/call', + params: { name: 'codegraph_search', arguments: { query: 'alpha' } }, + }); + const throughDaemon = await waitFor(() => findResponse(server.stdout, 2), 10000); + expect(throughDaemon.result.content[0].text).toMatch(/index predates/i); + + process.kill(daemonPid, 'SIGTERM'); + expect(await waitProcessExit(daemonPid, 8000)).toBe(true); + await waitFor( + () => server.stderr.some((line) => line.includes('serving this session in-process')), + 8000, + ); + + sendMessage(server.child, { + jsonrpc: '2.0', id: 3, method: 'tools/call', + params: { name: 'codegraph_search', arguments: { query: 'alpha' } }, + }); + const afterFailover = await waitFor(() => findResponse(server.stdout, 3), 10000); + expect(afterFailover.result.content[0].text).not.toMatch(/index predates/i); + + sendMessage(server.child, { + jsonrpc: '2.0', id: 4, method: 'tools/call', + params: { + name: 'codegraph_search', + arguments: { query: 'bravo', projectPath: otherRoot }, + }, + }); + const otherProject = await waitFor(() => findResponse(server.stdout, 4), 10000); + expect(otherProject.result.content[0].text).toMatch(/index predates/i); + expect(otherProject.result.content[0].text).toContain('CodeGraph v0.2.0'); + }, 45000); + it('concurrent launchers converge on a single daemon (lockfile race — must-fix 1)', async () => { const env = { CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS: '15000' }; diff --git a/__tests__/mcp-index-version-warning.test.ts b/__tests__/mcp-index-version-warning.test.ts new file mode 100644 index 000000000..e56cb36dc --- /dev/null +++ b/__tests__/mcp-index-version-warning.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { + ToolHandler, + __setLoadCodeGraphForTests, + type ToolResult, +} from '../src/mcp/tools'; +import { EXTRACTION_VERSION } from '../src/extraction/extraction-version'; +import { IndexVersionWarningState } from '../src/mcp/index-version-warning'; +import { MCPSession } from '../src/mcp/session'; +import type { MCPEngine } from '../src/mcp/engine'; +import type { + JsonRpcNotification, + JsonRpcRequest, + JsonRpcTransport, +} from '../src/mcp/transport'; + +type MetadataWriter = { + queries: { setMetadata(key: string, value: string): void }; +}; + +function resultText(result: ToolResult): string { + const first = result.content[0]; + return first?.type === 'text' ? first.text : ''; +} + +function stampStale(cg: CodeGraph, version = '0.1.0'): void { + const writer = cg as unknown as MetadataWriter; + writer.queries.setMetadata('indexed_with_version', version); + writer.queries.setMetadata( + 'indexed_with_extraction_version', + String(EXTRACTION_VERSION - 1), + ); +} + +function fakeTransport(): JsonRpcTransport & { + deliver(message: JsonRpcRequest): Promise; + results: unknown[]; +} { + let handler: ((message: JsonRpcRequest | JsonRpcNotification) => Promise) | null = null; + const results: unknown[] = []; + return { + start(next) { handler = next; }, + stop() { /* nothing to tear down */ }, + send() { /* unused */ }, + notify() { /* unused */ }, + async request() { return {}; }, + sendResult(_id, result) { results.push(result); }, + sendError() { /* unused */ }, + results, + async deliver(message) { await handler?.(message); }, + }; +} + +function searchCall(id: number): JsonRpcRequest { + return { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'codegraph_search', arguments: { query: 'alpha' } }, + }; +} + +describe('MCP index extraction-version warning (#1852)', () => { + let root: string; + let cg: CodeGraph; + let handler: ToolHandler; + const extraGraphs: CodeGraph[] = []; + const extraRoots: string[] = []; + + beforeEach(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-version-')); + fs.writeFileSync(path.join(root, 'alpha.ts'), 'export function alpha() { return 1; }\n'); + cg = await CodeGraph.init(root, { index: false }); + await cg.indexAll(); + handler = new ToolHandler(cg); + __setLoadCodeGraphForTests(CodeGraph); + }); + + afterEach(() => { + __setLoadCodeGraphForTests(null); + try { handler.closeAll(); } catch { /* best effort */ } + for (const graph of extraGraphs) { + try { graph.close(); } catch { /* best effort */ } + } + try { cg.close(); } catch { /* best effort */ } + for (const dir of extraRoots) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } + try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } + }); + + it('warns once on the first successful response for a stale project', async () => { + stampStale(cg); + const state = new IndexVersionWarningState(); + + const first = await handler.execute( + 'codegraph_search', + { query: 'alpha' }, + undefined, + state, + ); + const second = await handler.execute( + 'codegraph_search', + { query: 'alpha' }, + undefined, + state, + ); + + expect(resultText(first)).toMatch(/index predates the running extraction engine/i); + expect(resultText(first)).toContain('CodeGraph v0.1.0'); + expect(resultText(first)).toContain(`extraction ${EXTRACTION_VERSION - 1}`); + expect(resultText(first)).toContain(`extraction ${EXTRACTION_VERSION}`); + expect(resultText(first)).toContain('codegraph index'); + expect(resultText(second)).not.toMatch(/index predates the running extraction engine/i); + }); + + it('does not consume the warning on an error response', async () => { + stampStale(cg); + const state = new IndexVersionWarningState(); + + const invalid = await handler.execute( + 'codegraph_search', + { query: '' }, + undefined, + state, + ); + expect(invalid.isError).toBe(true); + expect(resultText(invalid)).not.toMatch(/index predates/i); + + const successful = await handler.execute( + 'codegraph_search', + { query: 'alpha' }, + undefined, + state, + ); + expect(resultText(successful)).toMatch(/index predates the running extraction engine/i); + }); + + it('does not warn for a current or never-indexed project', async () => { + const current = await handler.execute( + 'codegraph_search', + { query: 'alpha' }, + undefined, + new IndexVersionWarningState(), + ); + expect(resultText(current)).not.toMatch(/index predates/i); + + const currentStatus = await handler.execute( + 'codegraph_status', + {}, + undefined, + new IndexVersionWarningState(), + ); + expect(resultText(currentStatus)).toContain( + `**Index built with:** CodeGraph v`, + ); + expect(resultText(currentStatus)).toContain( + `**Running CodeGraph:** v`, + ); + expect(resultText(currentStatus)).toContain( + `**Re-index recommended:** no`, + ); + + const uninitializedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-uninitialized-')); + extraRoots.push(uninitializedRoot); + fs.writeFileSync(path.join(uninitializedRoot, 'pending.ts'), 'export const pending = true;\n'); + const uninitialized = await CodeGraph.init(uninitializedRoot, { index: false }); + extraGraphs.push(uninitialized); + expect(uninitialized.isIndexStale()).toBe(false); + + const uninitializedResult = await new ToolHandler(uninitialized).execute( + 'codegraph_search', + { query: 'pending' }, + undefined, + new IndexVersionWarningState(), + ); + expect(resultText(uninitializedResult)).not.toMatch(/index predates/i); + + const uninitializedStatus = await new ToolHandler(uninitialized).execute( + 'codegraph_status', + {}, + undefined, + new IndexVersionWarningState(), + ); + expect(resultText(uninitializedStatus)).toContain('**Index built with:** not indexed yet'); + expect(resultText(uninitializedStatus)).toContain('**Re-index recommended:** no'); + }); + + it('tracks explicit projectPath projects independently by resolved root', async () => { + stampStale(cg, '0.1.0'); + const otherRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-version-other-')); + extraRoots.push(otherRoot); + fs.mkdirSync(path.join(otherRoot, 'src')); + fs.writeFileSync(path.join(otherRoot, 'src', 'bravo.ts'), 'export function bravo() { return 2; }\n'); + const other = await CodeGraph.init(otherRoot, { index: false }); + extraGraphs.push(other); + await other.indexAll(); + stampStale(other, '0.2.0'); + + const state = new IndexVersionWarningState(); + const defaultFirst = await handler.execute( + 'codegraph_search', + { query: 'alpha' }, + undefined, + state, + ); + const otherFirst = await handler.execute( + 'codegraph_search', + { query: 'bravo', projectPath: otherRoot }, + undefined, + state, + ); + const otherAlias = await handler.execute( + 'codegraph_search', + { query: 'bravo', projectPath: path.join(otherRoot, 'src') }, + undefined, + state, + ); + + expect(resultText(defaultFirst)).toContain('CodeGraph v0.1.0'); + expect(resultText(otherFirst)).toContain('CodeGraph v0.2.0'); + expect(resultText(otherAlias)).not.toMatch(/index predates/i); + }); + + it('always reports build and running versions in status', async () => { + stampStale(cg, '0.1.0'); + const state = new IndexVersionWarningState(); + + const first = await handler.execute('codegraph_status', {}, undefined, state); + const second = await handler.execute('codegraph_status', {}, undefined, state); + + expect(resultText(first)).toMatch(/index predates the running extraction engine/i); + for (const result of [first, second]) { + const text = resultText(result); + expect(text).toContain(`**Index built with:** CodeGraph v0.1.0 (extraction ${EXTRACTION_VERSION - 1})`); + expect(text).toMatch(new RegExp(`\\*\\*Running CodeGraph:\\*\\* v.+ \\(extraction ${EXTRACTION_VERSION}\\)`)); + expect(text).toContain('**Re-index recommended:** yes — run `codegraph index`'); + } + expect(resultText(second)).not.toMatch(/index predates the running extraction engine/i); + }); + + it('keeps warnings independent for daemon clients sharing one engine', async () => { + stampStale(cg); + const engine = { + ensureInitialized: async () => { /* already initialized */ }, + hasDefaultCodeGraph: () => true, + getProjectPath: () => root, + retryInitializeSync: () => { /* already initialized */ }, + getToolHandler: () => handler, + } as unknown as MCPEngine; + const transportA = fakeTransport(); + const transportB = fakeTransport(); + const sessionA = new MCPSession(transportA, engine); + const sessionB = new MCPSession(transportB, engine); + sessionA.start(); + sessionB.start(); + + await transportA.deliver(searchCall(1)); + await transportA.deliver(searchCall(2)); + await transportB.deliver(searchCall(3)); + + expect(resultText(transportA.results[0] as ToolResult)).toMatch(/index predates/i); + expect(resultText(transportA.results[1] as ToolResult)).not.toMatch(/index predates/i); + expect(resultText(transportB.results[0] as ToolResult)).toMatch(/index predates/i); + }); +}); diff --git a/docs/superpowers/plans/2026-09-12-mcp-index-version-warning.md b/docs/superpowers/plans/2026-09-12-mcp-index-version-warning.md new file mode 100644 index 000000000..de7c99d46 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-mcp-index-version-warning.md @@ -0,0 +1,189 @@ +# MCP Index-Version Warning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Warn each MCP client once per stale project, while making `codegraph_status` always report index-build and running extraction versions. + +**Architecture:** Keep warning bookkeeping in a small state object owned by each MCP client session, because one daemon shares a `ToolHandler` across clients (`src/mcp/session.ts:L107-L122`). Apply the warning in `ToolHandler` after a successful result so direct calls, pooled daemon calls, and proxy fallbacks share one local compatibility check (`src/mcp/tools.ts:L2098-L2184`). Keep initialization unchanged and perform no network request or re-index. + +**Tech Stack:** TypeScript, Vitest, MCP JSON-RPC, CodeGraph SQLite metadata. + +**Spec:** [upstream issue #1852](https://github.com/colbymchenry/codegraph/issues/1852) (fetched 2026-09-12) + +## Global Constraints + +- Warn on the first successful MCP tool response for a stale index, once per resolved project root per client session. [#1852](https://github.com/colbymchenry/codegraph/issues/1852) +- Treat explicit `projectPath` projects as independent warning buckets. [#1852](https://github.com/colbymchenry/codegraph/issues/1852) +- Do not warn for current or uninitialized indexes; `CodeGraph.isIndexStale()` already implements that distinction (`src/index.ts:L1233-L1242`). +- Include available package-build and extraction-version details plus `codegraph index` guidance. [#1852](https://github.com/colbymchenry/codegraph/issues/1852) +- Preserve the nonblocking initialize response (`src/mcp/session.ts:L199-L252`) and perform no network access or automatic indexing. [#1852](https://github.com/colbymchenry/codegraph/issues/1852) + +--- + +### Task 1: Session-owned warning state and direct behavior + +**Files:** +- Create: `src/mcp/index-version-warning.ts` +- Create: `__tests__/mcp-index-version-warning.test.ts` +- Modify: `src/mcp/tools.ts:1-70,1465-1810,2098-2191,6519-6637` + +**Interfaces:** +- Consumes: `CodeGraph.getProjectRoot()`, `getIndexBuildInfo()`, and `isIndexStale()` (`src/index.ts:L1224-L1242`). +- Produces: `IndexVersionWarningState.claim(projectRoot: string): boolean` and `formatIndexVersionWarning(...)`. + +- [x] **Step 1: Write failing direct and multi-project tests** + +```ts +const state = new IndexVersionWarningState(); +const first = await handler.execute('codegraph_search', { query: 'alpha' }, undefined, state); +const second = await handler.execute('codegraph_search', { query: 'alpha' }, undefined, state); +expect(first.content[0].text).toMatch(/index predates/i); +expect(first.content[0].text).toContain('codegraph index'); +expect(second.content[0].text).not.toMatch(/index predates/i); + +const other = await handler.execute( + 'codegraph_search', + { query: 'bravo', projectPath: otherRoot }, + undefined, + state, +); +expect(other.content[0].text).toMatch(/index predates/i); +``` + +Also assert that an `isError` response does not consume the warning, and that current and never-indexed projects never warn. + +- [x] **Step 2: Run the new test and verify RED** + +Run: `npx vitest run __tests__/mcp-index-version-warning.test.ts --maxWorkers=1 --minWorkers=1` + +Expected: FAIL because `IndexVersionWarningState` and the fourth `execute` argument do not exist. + +- [x] **Step 3: Implement the state and response decorator** + +```ts +export class IndexVersionWarningState { + private readonly warnedProjects = new Set(); + + claim(projectRoot: string): boolean { + if (this.warnedProjects.has(projectRoot)) return false; + this.warnedProjects.add(projectRoot); + return true; + } +} +``` + +In `ToolHandler.execute`, decorate only successful results. Resolve the selected `CodeGraph`, check `isIndexStale()`, key by its resolved root, and prepend the formatted warning only when `claim()` returns true. Apply the decorator after worktree/file-staleness notices and to the `codegraph_status` early-return path. + +- [x] **Step 4: Add permanent status details** + +```ts +const build = cg.getIndexBuildInfo(); +const stale = cg.isIndexStale(); +lines.push( + `**Index built with:** ${formatBuildVersion(build)}`, + `**Running CodeGraph:** v${CodeGraphPackageVersion} (extraction ${EXTRACTION_VERSION})`, + `**Re-index recommended:** ${stale ? 'yes — run `codegraph index`' : 'no'}`, +); +``` + +Assert these lines for both current and stale indexes. Status must retain them on every call even after the one-time banner has been consumed. + +- [x] **Step 5: Run focused tests and verify GREEN** + +Run: `npx vitest run __tests__/mcp-index-version-warning.test.ts __tests__/mcp-staleness-banner.test.ts __tests__/upgrade.test.ts --maxWorkers=1 --minWorkers=1` + +Expected: PASS. + +### Task 2: Direct, daemon-session, and proxy-fallback wiring + +**Files:** +- Modify: `src/mcp/session.ts:107-313` +- Modify: `src/mcp/proxy.ts:217-290` +- Modify: `__tests__/mcp-index-version-warning.test.ts` +- Modify: `__tests__/mcp-daemon.test.ts` + +**Interfaces:** +- Consumes: `ToolHandler.execute(toolName, args, exploreState, indexVersionWarningState)` from Task 1. +- Produces: one `IndexVersionWarningState` for every `MCPSession` and one for each in-process proxy fallback. + +- [x] **Step 1: Write failing session-isolation coverage** + +```ts +await transportA.deliver(call(1)); +await transportA.deliver(call(2)); +await transportB.deliver(call(3)); +expect(textFor(transportA.results[0])).toMatch(/index predates/i); +expect(textFor(transportA.results[1])).not.toMatch(/index predates/i); +expect(textFor(transportB.results[0])).toMatch(/index predates/i); +``` + +Use two `MCPSession` objects sharing one engine/handler to model the daemon's one-engine/many-client architecture documented in `src/mcp/engine.ts:L1-L10`. + +- [x] **Step 2: Run the session test and verify RED** + +Run: `npx vitest run __tests__/mcp-index-version-warning.test.ts --maxWorkers=1 --minWorkers=1` + +Expected: FAIL because the shared handler currently has no per-client warning state. + +- [x] **Step 3: Wire fresh state into both execution paths** + +```ts +private readonly indexVersionWarnings = new IndexVersionWarningState(); + +const result = await this.engine.getToolHandler().execute( + toolName, + toolArgs, + this.exploreSession, + this.indexVersionWarnings, +); +``` + +Instantiate the same state beside `exploreSession` in `runLocalHandshakeProxy` and pass it when the proxy serves locally (`src/mcp/proxy.ts:L237-L275`). Forwarded proxy calls remain byte-transparent and receive the daemon session's state. + +- [x] **Step 4: Add one real proxy/daemon regression test** + +Start two proxy clients against one stale indexed project. Assert each client's first successful `codegraph_search` response contains the warning, client A's second response does not, and only one daemon was created. This pins the live proxy-to-daemon route already exercised by `__tests__/mcp-daemon.test.ts:L199-L236`. + +- [x] **Step 5: Run direct and daemon coverage** + +Run: `npx vitest run __tests__/mcp-index-version-warning.test.ts __tests__/mcp-daemon.test.ts --maxWorkers=1 --minWorkers=1` + +Expected: PASS. If Windows teardown produces the known upstream `EPERM` failure, preserve the assertion result and cite open PR #1717 rather than changing production behavior. + +### Task 3: User-facing release note and verification + +**Files:** +- Modify: `CHANGELOG.md:1-30` + +**Interfaces:** +- Consumes: warning/status behavior from Tasks 1-2. +- Produces: release-facing explanation of the local warning and manual rebuild command. + +- [x] **Step 1: Add changelog text** + +```md +- MCP clients now receive a one-time warning per project when an index predates the running extraction engine, including the available build details and the `codegraph index` command needed to rebuild it. `codegraph_status` always reports both versions and whether rebuilding is recommended. Detection is local and never starts a rebuild automatically. (#1852) +``` + +- [x] **Step 2: Run build and focused regression suite** + +Run: `npm run build` + +Run: `npx vitest run __tests__/mcp-index-version-warning.test.ts __tests__/mcp-staleness-banner.test.ts __tests__/mcp-daemon.test.ts __tests__/upgrade.test.ts --maxWorkers=1 --minWorkers=1` + +Expected: build and focused tests pass, apart from any separately identified upstream baseline failure. + +- [x] **Step 3: Audit the diff and artifact references** + +Run: `git diff --check` + +Run: `git diff upstream/main...HEAD --stat` + +Confirm every file named by the changelog or plan either exists in `git ls-tree HEAD` or is part of the pending diff. + +- [x] **Step 4: Commit the completed feature** + +```bash +git add CHANGELOG.md src/mcp/index-version-warning.ts src/mcp/session.ts src/mcp/proxy.ts src/mcp/tools.ts __tests__/mcp-index-version-warning.test.ts __tests__/mcp-daemon.test.ts docs/superpowers/plans/2026-09-12-mcp-index-version-warning.md +git commit -m "feat(mcp): warn when project indexes need rebuilding" +``` diff --git a/src/mcp/index-version-warning.ts b/src/mcp/index-version-warning.ts new file mode 100644 index 000000000..d935ac21d --- /dev/null +++ b/src/mcp/index-version-warning.ts @@ -0,0 +1,44 @@ +import { EXTRACTION_VERSION } from '../extraction/extraction-version'; +import { CodeGraphPackageVersion } from './version'; + +export interface IndexBuildInfo { + version: string | null; + extractionVersion: number | null; +} + +export const INDEX_VERSION_WARNING_PREFIX = + "⚠️ This project's CodeGraph index predates the running extraction engine."; + +/** Per-MCP-client bookkeeping for the one-time stale-index warning. */ +export class IndexVersionWarningState { + private readonly warnedProjects = new Set(); + + /** Claim the warning for a resolved project root. True only on the first claim. */ + claim(projectRoot: string): boolean { + if (this.warnedProjects.has(projectRoot)) return false; + this.warnedProjects.add(projectRoot); + return true; + } +} + +/** Human-readable engine identity recorded in an index's metadata. */ +export function formatIndexBuildVersion(build: IndexBuildInfo): string { + const extraction = build.extractionVersion ?? 'unknown'; + if (!build.version) return `an earlier CodeGraph version (extraction ${extraction})`; + return `CodeGraph v${build.version.replace(/^v/, '')} (extraction ${extraction})`; +} + +/** Human-readable identity of the process currently serving MCP. */ +export function formatRunningVersion(): string { + return `v${CodeGraphPackageVersion.replace(/^v/, '')} (extraction ${EXTRACTION_VERSION})`; +} + +/** Actionable warning prepended to the first successful response for a stale project. */ +export function formatIndexVersionWarning(build: IndexBuildInfo): string { + return ( + `${INDEX_VERSION_WARNING_PREFIX} ` + + `It was built with ${formatIndexBuildVersion(build)}; the running engine is ` + + `CodeGraph ${formatRunningVersion()}. Run \`codegraph index\` in the project to ` + + 'rebuild it before relying on newly supported symbols and relationships.' + ); +} diff --git a/src/mcp/proxy.ts b/src/mcp/proxy.ts index 27e684e51..9865aba7b 100644 --- a/src/mcp/proxy.ts +++ b/src/mcp/proxy.ts @@ -20,6 +20,8 @@ import * as fs from 'fs'; import * as net from 'net'; +import * as path from 'path'; +import { findNearestCodeGraphRoot } from '../directory'; import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags'; import { DaemonClientHello, DaemonHello, MAX_HELLO_LINE_BYTES } from './daemon'; import { EARLY_PPID } from './early-ppid'; @@ -31,6 +33,10 @@ import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session import { SERVER_INSTRUCTIONS } from './server-instructions'; import { getStaticTools } from './tools'; import { ExploreSessionState } from './explore-session-state'; +import { + INDEX_VERSION_WARNING_PREFIX, + IndexVersionWarningState, +} from './index-version-warning'; import { getTelemetry, ClientInfo } from '../telemetry'; import type { MCPEngine } from './engine'; @@ -235,6 +241,30 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< // Only the daemon-unavailable fallback below uses it; when the daemon is up, // the tracking happens on the daemon's own MCPSession. const exploreSession = new ExploreSessionState(); + // The fallback is still one MCP client session. Keep its one-time warnings + // local just as MCPSession does when the shared daemon is available. + const indexVersionWarnings = new IndexVersionWarningState(); + const recordDaemonWarning = (requestLine: string | undefined, response: JsonRpc): void => { + if (!requestLine) return; + const result = response.result as { content?: Array<{ type?: unknown; text?: unknown }> } | undefined; + const first = result?.content?.[0]; + if (first?.type !== 'text' || typeof first.text !== 'string') return; + if (!first.text.startsWith(INDEX_VERSION_WARNING_PREFIX)) return; + + let request: JsonRpc; + try { request = JSON.parse(requestLine) as JsonRpc; } catch { return; } + const params = request.params as { + arguments?: { projectPath?: unknown }; + } | undefined; + const requestedPath = params?.arguments?.projectPath; + let root = deps.root; + if (typeof requestedPath === 'string') { + const resolved = findNearestCodeGraphRoot(requestedPath); + if (!resolved) return; + root = resolved; + } + indexVersionWarnings.claim(path.resolve(root)); + }; const trackInflight = (line: string): void => { try { const m = JSON.parse(line) as JsonRpc; @@ -266,7 +296,12 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< try { await ensureEngine(); const params = (msg.params || {}) as { name: string; arguments?: Record }; - const result = await engine!.getToolHandler().execute(params.name, params.arguments || {}, exploreSession); + const result = await engine!.getToolHandler().execute( + params.name, + params.arguments || {}, + exploreSession, + indexVersionWarnings, + ); writeClient({ jsonrpc: '2.0', id, result }); getTelemetry().recordUsage('mcp_tool', params.name, !result.isError, telemetryClient); } catch (err) { @@ -373,6 +408,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise< try { resp = JSON.parse(line) as JsonRpc; } catch { /* not JSON — relay verbatim */ } if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] daemon->proxy ${line.slice(0, 80)}\n`); if (resp && resp.id !== undefined && ('result' in resp || 'error' in resp)) { + recordDaemonWarning(inflight.get(resp.id), resp); inflight.delete(resp.id); // answered — no longer in flight // Suppress the daemon's reply to the initialize we forwarded to prime it // (the client already got the local handshake response). diff --git a/src/mcp/session.ts b/src/mcp/session.ts index 1d5bd79c3..a8aa4eab7 100644 --- a/src/mcp/session.ts +++ b/src/mcp/session.ts @@ -22,6 +22,7 @@ import { resolveServerRoot } from '../directory'; import { getTelemetry, ClientInfo } from '../telemetry'; import { getUpdateNotice } from '../upgrade/update-check'; import { ExploreSessionState } from './explore-session-state'; +import { IndexVersionWarningState } from './index-version-warning'; /** * MCP Server Info — kept on the session because some clients log it. The @@ -120,6 +121,8 @@ export class MCPSession { * the session — a reconnecting client starts clean. */ private readonly exploreSession = new ExploreSessionState(); + /** One-time stale-index warnings already delivered to this MCP client. */ + private readonly indexVersionWarnings = new IndexVersionWarningState(); constructor( private transport: JsonRpcTransport, @@ -310,7 +313,12 @@ export class MCPSession { await this.retryInitIfNeeded(); if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} dispatch\n`); - const result = await this.engine.getToolHandler().execute(toolName, toolArgs, this.exploreSession); + const result = await this.engine.getToolHandler().execute( + toolName, + toolArgs, + this.exploreSession, + this.indexVersionWarnings, + ); if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} done\n`); this.transport.sendResult(request.id, result); // After the reply is on the wire — telemetry must never delay a tool diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 42fdd2288..1b1fb7cca 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -50,6 +50,12 @@ import { } from '../graph/named-symbol-flow'; import { getUpdateNotice } from '../upgrade/update-check'; import { ExploreDiagnostics } from './explore-diagnostics'; +import { + IndexVersionWarningState, + formatIndexBuildVersion, + formatIndexVersionWarning, + formatRunningVersion, +} from './index-version-warning'; import { EXPLORE_EMISSION_KEY, EXPLORE_SESSION_VIEW_ARG, @@ -2091,6 +2097,45 @@ export class ToolHandler { return { ...result, content: [{ type: 'text', text: composed }, ...rest] }; } + /** + * Warn this MCP client once for each selected project whose persisted + * extraction version predates the running engine. The state belongs to the + * client session, never this shared handler: one daemon serves many clients. + */ + private withIndexVersionNotice( + result: ToolResult, + projectPath: string | undefined, + state: IndexVersionWarningState | undefined, + ): ToolResult { + if (!state || result.isError) return result; + const [first, ...rest] = result.content; + if (!first || first.type !== 'text') return result; + + let cg: CodeGraph; + try { + cg = this.getCodeGraph(projectPath); + if (!cg.isIndexStale()) return result; + } catch { + // No initialized project (or a project that cannot be opened) has no + // trustworthy build metadata to report and must not consume a warning. + return result; + } + + let root: string; + try { + root = resolvePath(cg.getProjectRoot()); + } catch { + return result; + } + if (!state.claim(root)) return result; + + const warning = formatIndexVersionWarning(cg.getIndexBuildInfo()); + return { + ...result, + content: [{ type: 'text', text: `${warning}\n\n${first.text}` }, ...rest], + }; + } + /** * Execute a tool by name. * @@ -2104,6 +2149,7 @@ export class ToolHandler { toolName: string, args: Record, sessionState?: ExploreSessionState, + indexVersionWarnings?: IndexVersionWarningState, ): Promise { try { // Block the first tool call on the engine's post-open reconcile so we @@ -2149,7 +2195,12 @@ export class ToolHandler { // a worker (whose read connection has no watcher). It also skips the // auto-banner wrapper to avoid duplicating its own pending-files section. if (toolName === 'codegraph_status') { - return await this.handleStatus(args); + const result = await this.handleStatus(args); + return this.withIndexVersionNotice( + result, + args.projectPath as string | undefined, + indexVersionWarnings, + ); } // Read tools: off-load the CPU-heavy dispatch to the worker pool when one @@ -2181,7 +2232,15 @@ export class ToolHandler { // caller passed session state. const result = this.takeExploreEmission(raw, sessionState); const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined); - return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); + const withStaleness = this.withStalenessNotice( + withWorktree, + args.projectPath as string | undefined, + ); + return this.withIndexVersionNotice( + withStaleness, + args.projectPath as string | undefined, + indexVersionWarnings, + ); } catch (err) { // Expected condition, not a malfunction: answer as a SUCCESS so the // agent keeps trusting the toolset for projects that ARE indexed. @@ -6530,6 +6589,11 @@ export class ToolHandler { } catch { /* closed instance — leave as is */ } } const stats = cg.getStats(); + const buildInfo = cg.getIndexBuildInfo(); + const reindexRecommended = cg.isIndexStale(); + const indexBuildLabel = cg.getLastIndexedAt() == null + ? 'not indexed yet' + : formatIndexBuildVersion(buildInfo); // Warn when this index actually belongs to a different git working tree // (e.g. the server resolved up from a nested worktree to the main checkout). @@ -6555,6 +6619,11 @@ export class ToolHandler { // Surface the active SQLite backend (node:sqlite, Node's built-in real // SQLite — full WAL + FTS5, no native build). lines.push(`**Backend:** node:sqlite (Node built-in) — full WAL + FTS5`); + lines.push( + `**Index built with:** ${indexBuildLabel}`, + `**Running CodeGraph:** ${formatRunningVersion()}`, + `**Re-index recommended:** ${reindexRecommended ? 'yes — run `codegraph index`' : 'no'}`, + ); // Effective journal mode. 'wal' ⇒ concurrent reads never block on a writer; // anything else ⇒ they can ("database is locked"). node:sqlite supports WAL