diff --git a/CHANGELOG.md b/CHANGELOG.md
index 06ca319d98..b5cc6d5f5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -240,6 +240,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.
- **Next.js pages and their navigation are in the graph.** App Router pages (`app/(group)/blog/[slug]/page.tsx` → `/blog/:slug`) and Pages Router pages are routes bound to the component they export, and ``, an internal ``, `router.push` / `router.replace` (`next/navigation` and `next/router`), `redirect()` / `permanentRedirect()` and the middleware's `NextResponse.redirect(…)` are `navigates` edges between them. `app/api/**/route.ts` exports (`GET`, `POST`, …) are endpoints bound to their functions, and `pages/api/*` handlers are `ANY /api/…`. Re-index after upgrading.
diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts
index 5b8b305002..98ff5f199f 100644
--- a/__tests__/mcp-daemon.test.ts
+++ b/__tests__/mcp-daemon.test.ts
@@ -42,6 +42,7 @@ import { CodeGraph } from '../src';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';
import { once } from 'events';
+import { EXTRACTION_VERSION } from '../src/extraction/extraction-version';
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
import { recordSpawns, removeSpawnLog, settleLosingCandidates } from './daemon-candidates';
@@ -168,6 +169,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 */ } }
@@ -314,6 +326,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 0000000000..e56cb36dc0
--- /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 0000000000..de7c99d46c
--- /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 0000000000..d935ac21d6
--- /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 52e6b77fd5..c079d98ac0 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';
@@ -32,6 +34,10 @@ import { SERVER_INSTRUCTIONS } from './server-instructions';
import { getStaticTools } from './tools';
import { ErrorCodes } from './transport';
import { ExploreSessionState } from './explore-session-state';
+import {
+ INDEX_VERSION_WARNING_PREFIX,
+ IndexVersionWarningState,
+} from './index-version-warning';
import { getTelemetry, ClientInfo } from '../telemetry';
import { installMainThreadWatchdog, WatchdogHandle } from './liveness-watchdog';
import type { MCPEngine } from './engine';
@@ -277,6 +283,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;
@@ -348,7 +378,12 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
try {
const local = await ensureEngine();
const params = (msg.params || {}) as { name: string; arguments?: Record };
- const result = await local.getToolHandler().execute(params.name, params.arguments || {}, exploreSession);
+ const result = await local.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) {
@@ -508,6 +543,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 1d5bd79c38..a8aa4eab73 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 c8fec8d3e5..b05bbf3c25 100644
--- a/src/mcp/tools.ts
+++ b/src/mcp/tools.ts
@@ -61,6 +61,12 @@ import { getUpdateNotice } from '../upgrade/update-check';
import { measurePendingChanges } from './index-freshness';
import { validateAnswerFiles, type AnswerFile } from './answer-freshness';
import { ExploreDiagnostics } from './explore-diagnostics';
+import {
+ IndexVersionWarningState,
+ formatIndexBuildVersion,
+ formatIndexVersionWarning,
+ formatRunningVersion,
+} from './index-version-warning';
import {
EXPLORE_EMISSION_KEY,
EXPLORE_SESSION_VIEW_ARG,
@@ -2745,6 +2751,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.
*
@@ -2758,6 +2803,7 @@ export class ToolHandler {
toolName: string,
args: Record,
sessionState?: ExploreSessionState,
+ indexVersionWarnings?: IndexVersionWarningState,
): Promise {
if (this.closing) return this.textResult('This MCP session is closing; retry with a connected session.');
this.activeCalls++;
@@ -2819,7 +2865,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
@@ -2880,7 +2931,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.
@@ -8002,6 +8061,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).
@@ -8039,6 +8103,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