From 1949166132692c318f267dd73183a43177377d6d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:16:44 -0400 Subject: [PATCH 01/15] Add MCP freshness checks --- codegraph-skill/codegraph/SKILL.md | 1 + docs/library-api.md | 5 +- docs/mcp.md | 12 +- src/agent.ts | 9 +- src/agent/session.ts | 166 +++++++++++-- src/index.ts | 9 +- src/mcp/server.ts | 377 ++++++++++++++++------------- tests/agent-session.test.ts | 24 ++ tests/helpers/agent.ts | 46 ++-- tests/mcp-server.test.ts | 65 +++-- 10 files changed, 471 insertions(+), 243 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index f614ddb9..16a9bea6 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -66,6 +66,7 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. If MCP tools are available, prefer them over repeated CLI invocations. Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, and `review` first. +After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` means use `refresh_index` or live file reads before trusting indexed context. Fall back to CLI when MCP is unavailable. ## Discovery diff --git a/docs/library-api.md b/docs/library-api.md index e16e58a8..374fdb1d 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -162,7 +162,7 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: +`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot; MCP-created sessions use automatic freshness checks. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: ```ts import { createCodegraphMcpHandlers } from "@lzehrung/codegraph"; @@ -178,11 +178,12 @@ const orient = await handlers.orient({ includeRoots: ["src"], budget: "small" }) const packet = await handlers.packet_get({ target: orient.focus.find((entry) => entry.file)!.file! }); const refs = await handlers.refs({ handle: search.results[0]!.handle }); const rows = await handlers.query_sqlite({ query: "select path from files", limit: 5 }); +console.log(search.freshness.state); console.log(packet.kind, refs.references, rows.rows); ``` `serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. `query_sqlite` is read-only and row- and byte-bounded; `artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. -MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. +MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. Index-backed MCP responses include `freshness` metadata so hosts can distinguish fresh, auto-refreshed, and stale snapshots. See [MCP server](./mcp.md) for CLI server setup and client configuration examples. diff --git a/docs/mcp.md b/docs/mcp.md index 800072ed..beb46171 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -49,7 +49,8 @@ The server exposes the same bounded primitives as the CLI and library session la - `artifact_build`: artifact creation, available only with write access enabled. MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. -Use `refresh_index` after changing files while the server is running, or when you need a fresh cache-backed snapshot without restarting the MCP process. +Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale` with changed file paths when applicable. +Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. ## Safety @@ -195,9 +196,10 @@ When Codegraph MCP tools are available to an agent: 1. Start with `orient`. 2. Use `search` to find anchors. 3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. -4. Use `impact` and `review` for git-range risk analysis. -5. Use `query_sqlite` only for read-only artifact inspection. -6. Use `refresh_index` after changing files while a long-running server is active. -7. Use `artifact_build` only when write access was intentionally enabled. +4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` means the tool reported changed files without silently trusting old index data. +5. Use `impact` and `review` for git-range risk analysis. +6. Use `query_sqlite` only for read-only artifact inspection. +7. Use `refresh_index` when you need an explicit rebuild. +8. Use `artifact_build` only when write access was intentionally enabled. Fall back to CLI commands when MCP tools are unavailable. diff --git a/src/agent.ts b/src/agent.ts index 65b31881..06e72765 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,5 +1,12 @@ export { createAgentSession } from "./agent/session.js"; -export type { AgentProjectSnapshot, AgentSession, AgentSessionOptions } from "./agent/session.js"; +export type { + AgentFreshnessPolicy, + AgentFreshnessResult, + AgentProjectSnapshot, + AgentSession, + AgentSessionFreshnessOptions, + AgentSessionOptions, +} from "./agent/session.js"; export { orientCodegraph } from "./agent/orient.js"; export type { AgentModuleSummary, diff --git a/src/agent/session.ts b/src/agent/session.ts index 67d3d9dc..33a07aed 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,3 +1,5 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; import { buildProjectIndexIncremental } from "../indexer/build-index.js"; import type { BuildOptions, BuildReport, ProjectIndex } from "../indexer/types.js"; import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; @@ -23,27 +25,116 @@ export type AgentLoadProjectOptions = { symbolGraph?: "eager" | "skip"; }; +export type AgentFreshnessPolicy = "manual" | "check" | "auto"; + +export type AgentFreshnessResult = + | { state: "fresh" } + | { state: "refreshed"; changedFiles: string[] } + | { state: "stale"; changedFiles: string[]; reason: string }; + +export type AgentSessionFreshnessOptions = { + policy?: AgentFreshnessPolicy; + maxAutoRefreshFiles?: number; + maxAutoRefreshBytes?: number; +}; + export type AgentSessionOptions = { root: string; discovery?: ProjectFileDiscoveryOptions; buildOptions?: BuildOptions; useConfig?: boolean; + freshness?: AgentSessionFreshnessOptions; }; export type AgentSession = { root?: string; listFiles?: () => Promise; loadProject: (loadOptions?: AgentLoadProjectOptions) => Promise; + checkFreshness?: () => Promise; invalidate: () => void; }; -type AgentProjectBaseSnapshot = Omit; - const EMPTY_SYMBOL_GRAPH: SymbolGraph = { nodes: new Map(), edges: [], }; const AGENT_NATIVE_WORKER_AUTO_FILE_THRESHOLD = 250; +const DEFAULT_MAX_AUTO_REFRESH_FILES = 50; +const DEFAULT_MAX_AUTO_REFRESH_BYTES = 2_000_000; + +type AgentProjectBaseSnapshot = Omit; + +type AgentFileSignature = { + file: string; + size: number; + mtimeMs: number; +}; + +type AgentDiscoverySettings = { + discoveryOptions?: ProjectFileDiscoveryOptions; +}; + +type AgentFreshnessDiff = { + changedFiles: string[]; + changedBytes: number; +}; + +async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Promise { + const useConfig = options.useConfig ?? true; + const config = useConfig ? await loadCodegraphConfig(options.root) : {}; + const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); + const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); + const discoveryOptions = hasDiscoveryOptions(discovery) + ? { ...discovery, globRoot: discovery.globRoot ?? options.root } + : undefined; + return discoveryOptions ? { discoveryOptions } : {}; +} + +async function collectAgentFileSignatures(files: readonly string[]): Promise> { + const signatures = new Map(); + await Promise.all( + files.map(async (file) => { + const resolvedFile = path.resolve(file); + try { + const stat = await fsp.stat(resolvedFile); + if (!stat.isFile()) return; + signatures.set(resolvedFile, { + file: resolvedFile, + size: stat.size, + mtimeMs: stat.mtimeMs, + }); + } catch { + return; + } + }), + ); + return signatures; +} + +function diffAgentFileSignatures( + previous: ReadonlyMap, + current: ReadonlyMap, +): AgentFreshnessDiff { + const changedFiles: string[] = []; + let changedBytes = 0; + for (const [file, currentSignature] of current.entries()) { + const previousSignature = previous.get(file); + if ( + !previousSignature || + previousSignature.size !== currentSignature.size || + previousSignature.mtimeMs !== currentSignature.mtimeMs + ) { + changedFiles.push(file); + changedBytes += currentSignature.size; + } + } + for (const file of previous.keys()) { + if (current.has(file)) continue; + changedFiles.push(file); + } + changedFiles.sort(); + return { changedFiles, changedBytes }; +} export function createAgentSession(options: AgentSessionOptions): AgentSession { let cachedFiles: Promise | undefined; @@ -51,18 +142,21 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { let cachedSymbolGraph: Promise | undefined; let cachedEagerSnapshot: Promise | undefined; let cachedSkippedSnapshot: Promise | undefined; + let cachedFileSignatures: Map | undefined; + + const invalidate = (): void => { + cachedFiles = undefined; + cachedBase = undefined; + cachedSymbolGraph = undefined; + cachedEagerSnapshot = undefined; + cachedSkippedSnapshot = undefined; + cachedFileSignatures = undefined; + }; const loadFiles = async (): Promise => { if (cachedFiles) return cachedFiles; const loadPromise = (async () => { - const useConfig = options.useConfig ?? true; - const config = useConfig ? await loadCodegraphConfig(options.root) : {}; - const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); - const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); - const discoveryOptions = hasDiscoveryOptions(discovery) - ? { ...discovery, globRoot: discovery.globRoot ?? options.root } - : undefined; - + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); return await listProjectFiles(options.root, undefined, discoveryOptions); })(); cachedFiles = loadPromise; @@ -75,14 +169,9 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const loadBase = async (): Promise => { if (cachedBase) return cachedBase; const loadPromise = (async () => { - const useConfig = options.useConfig ?? true; - const config = useConfig ? await loadCodegraphConfig(options.root) : {}; - const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); - const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); - const discoveryOptions = hasDiscoveryOptions(discovery) - ? { ...discovery, globRoot: discovery.globRoot ?? options.root } - : undefined; + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); const files = await loadFiles(); + cachedFileSignatures = await collectAgentFileSignatures(files); const buildOptions: BuildOptions & { files: string[] } = { ...options.buildOptions, cache: options.buildOptions?.cache ?? "disk", @@ -151,16 +240,45 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { return await cachedEagerSnapshot; }; + const checkFreshness = async (): Promise => { + const policy = options.freshness?.policy ?? "check"; + if (policy === "manual") return { state: "fresh" }; + if (!cachedBase || !cachedFileSignatures) return { state: "fresh" }; + await cachedBase; + + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); + const currentFiles = await listProjectFiles(options.root, undefined, discoveryOptions); + const currentSignatures = await collectAgentFileSignatures(currentFiles); + const diff = diffAgentFileSignatures(cachedFileSignatures, currentSignatures); + if (!diff.changedFiles.length) return { state: "fresh" }; + + const changedFiles = diff.changedFiles.map((file) => { + const relativeFile = path.relative(options.root, file).replace(/\\/g, "/"); + if (!relativeFile || relativeFile.startsWith("../")) return file.replace(/\\/g, "/"); + return relativeFile; + }); + if (policy === "check") { + return { state: "stale", changedFiles, reason: "session snapshot is older than files on disk" }; + } + + const maxFiles = options.freshness?.maxAutoRefreshFiles ?? DEFAULT_MAX_AUTO_REFRESH_FILES; + const maxBytes = options.freshness?.maxAutoRefreshBytes ?? DEFAULT_MAX_AUTO_REFRESH_BYTES; + if (diff.changedFiles.length > maxFiles) { + return { state: "stale", changedFiles, reason: `changed file count exceeds ${maxFiles}` }; + } + if (diff.changedBytes > maxBytes) { + return { state: "stale", changedFiles, reason: `changed byte count exceeds ${maxBytes}` }; + } + + invalidate(); + return { state: "refreshed", changedFiles }; + }; + return { root: options.root, listFiles: loadFiles, loadProject, - invalidate: () => { - cachedFiles = undefined; - cachedBase = undefined; - cachedSymbolGraph = undefined; - cachedEagerSnapshot = undefined; - cachedSkippedSnapshot = undefined; - }, + checkFreshness, + invalidate, }; } diff --git a/src/index.ts b/src/index.ts index 7fafd763..a147ea94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -226,7 +226,14 @@ export { /** Agent project snapshots and cached service-layer helpers. */ export { createAgentSession } from "./agent/session.js"; -export type { AgentProjectSnapshot, AgentSession, AgentSessionOptions } from "./agent/session.js"; +export type { + AgentFreshnessPolicy, + AgentFreshnessResult, + AgentProjectSnapshot, + AgentSession, + AgentSessionFreshnessOptions, + AgentSessionOptions, +} from "./agent/session.js"; /** Agent first-turn orientation packets with file-path follow-up targets. */ export { orientCodegraph } from "./agent/orient.js"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 222ed7a3..d96f8fdf 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,8 +27,8 @@ import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../revie import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; -import type { AgentSession } from "../agent/session.js"; -import type { BuildOptions } from "../indexer/types.js"; +import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; +import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { assertMcpSqliteQueryResourceBounded, boundRawSqlResult, @@ -98,6 +98,8 @@ export type CodegraphMcpHttpServer = CodegraphMcpHttpServerInfo & { close: () => Promise; }; +export type CodegraphMcpFreshResult = T & { freshness: AgentFreshnessResult }; + export type CodegraphMcpHandlers = { search: (request: { query: string; @@ -105,45 +107,45 @@ export type CodegraphMcpHandlers = { from?: string | undefined; depth?: number | undefined; limit?: number | undefined; - }) => Promise; + }) => Promise>; orient: (request: { includeRoots?: string[] | undefined; budget?: AgentOrientBudget | undefined; - }) => Promise; + }) => Promise>; packet_get: (request: { target: string; maxSymbols?: number | undefined; maxSnippets?: number | undefined; maxDuplicates?: number | undefined; - }) => Promise; + }) => Promise>; get_file: (request: { file: string; maxBytes?: number | undefined; - }) => Promise<{ file: string; text: string; truncated: boolean }>; - get_symbol: (request: { handle: string }) => Promise; - goto: (request: { - file: string; - line: number; - column: number; - }) => Promise>>; + }) => Promise>; + get_symbol: (request: { handle: string }) => Promise>; + goto: (request: { file: string; line: number; column: number }) => Promise>; refs: ( request: | { handle: string; limit?: number | undefined } | { file: string; line: number; column: number; limit?: number | undefined }, - ) => Promise<{ references: AgentExplanationReference[] }>; + ) => Promise>; deps: (request: { file: string; depth?: number | undefined; limit?: number | undefined; - }) => Promise<{ dependencies: Array<{ file: string; depth: number }> }>; + }) => Promise }>>; rdeps: (request: { file: string; depth?: number | undefined; limit?: number | undefined; - }) => Promise<{ reverseDependencies: Array<{ file: string; depth: number }> }>; - path: (request: { from: string; to: string }) => Promise<{ path: string[] | null }>; - impact: (request: { base: string; head: string }) => Promise; - review: (request: { base: string; head: string; reviewDepth?: ReviewDepth | undefined }) => Promise; + }) => Promise }>>; + path: (request: { from: string; to: string }) => Promise>; + impact: (request: { base: string; head: string }) => Promise>; + review: (request: { + base: string; + head: string; + reviewDepth?: ReviewDepth | undefined; + }) => Promise>; refresh_index: (request: { warmup?: CodegraphMcpWarmupMode | undefined }) => Promise<{ refreshed: true; warmup: CodegraphMcpWarmupMode; @@ -160,7 +162,7 @@ export type CodegraphMcpHandlers = { report?: boolean | undefined; questions?: boolean | undefined; force?: boolean | undefined; - }) => Promise; + }) => Promise>; }; type McpDependencyRequest = { @@ -184,14 +186,18 @@ function createCodegraphMcpSession(options: CodegraphMcpHandlerOptions, root: st assertMcpSessionOptions(options); return ( options.session ?? - createAgentSession({ root, ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}) }) + createAgentSession({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + freshness: { policy: "auto" }, + }) ); } function startCodegraphMcpWarmup( session: AgentSession, warmup: CodegraphMcpWarmupMode | undefined, -): Promise>> | undefined { +): Promise | undefined { if (warmup === "base") { return session.loadProject({ symbolGraph: "skip" }); } @@ -239,10 +245,18 @@ function createCodegraphMcpHandlersForSession( if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; + const withFreshness = async ( + run: () => Promise, + ): Promise => { + const freshness = session.checkFreshness ? await session.checkFreshness() : { state: "fresh" as const }; + const result = await run(); + return { ...result, freshness }; + }; + const collectMcpDependencyEntries = async ( request: McpDependencyRequest, collectEntries: ( - graph: Awaited>["fileGraph"], + graph: AgentProjectSnapshot["fileGraph"], file: string, options: { depth?: number; limit: number }, ) => DependencyNode[], @@ -264,141 +278,163 @@ function createCodegraphMcpHandlersForSession( return { search: async (request) => - await searchCodegraphWithSession(session, { - root, - query: request.query, - ...(request.mode !== undefined ? { mode: request.mode } : {}), - ...(request.from !== undefined ? { from: request.from } : {}), - ...(request.depth !== undefined ? { depth: request.depth } : {}), - ...(request.limit !== undefined ? { limit: request.limit } : {}), - }), + await withFreshness( + async () => + await searchCodegraphWithSession(session, { + root, + query: request.query, + ...(request.mode !== undefined ? { mode: request.mode } : {}), + ...(request.from !== undefined ? { from: request.from } : {}), + ...(request.depth !== undefined ? { depth: request.depth } : {}), + ...(request.limit !== undefined ? { limit: request.limit } : {}), + }), + ), orient: async (request) => - await orientCodegraphWithSession(session, { - root, - ...(request.includeRoots !== undefined ? { includeRoots: request.includeRoots } : {}), - ...(request.budget !== undefined ? { budget: request.budget } : {}), - }), + await withFreshness( + async () => + await orientCodegraphWithSession(session, { + root, + ...(request.includeRoots !== undefined ? { includeRoots: request.includeRoots } : {}), + ...(request.budget !== undefined ? { budget: request.budget } : {}), + }), + ), packet_get: async (request) => - await getCodegraphPacketWithSession(session, { - root, - target: request.target, - ...(request.maxSymbols !== undefined ? { maxSymbols: request.maxSymbols } : {}), - ...(request.maxSnippets !== undefined ? { maxSnippets: request.maxSnippets } : {}), - ...(request.maxDuplicates !== undefined ? { maxDuplicates: request.maxDuplicates } : {}), + await withFreshness( + async () => + await getCodegraphPacketWithSession(session, { + root, + target: request.target, + ...(request.maxSymbols !== undefined ? { maxSymbols: request.maxSymbols } : {}), + ...(request.maxSnippets !== undefined ? { maxSnippets: request.maxSnippets } : {}), + ...(request.maxDuplicates !== undefined ? { maxDuplicates: request.maxDuplicates } : {}), + }), + ), + + get_file: async (request) => + await withFreshness(async () => { + const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); + const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); + const read = await readFilePrefix(resolvedFile.realPath, maxBytes); + return { + file: resolvedFile.displayPath, + text: read.text, + truncated: read.truncated, + }; }), - get_file: async (request) => { - const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); - const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); - const read = await readFilePrefix(resolvedFile.realPath, maxBytes); - return { - file: resolvedFile.displayPath, - text: read.text, - truncated: read.truncated, - }; - }, - - get_symbol: async (request) => { - const explanation = await explainCodegraphTargetWithSession(session, { root, target: request.handle }); - return explanation.target; - }, - - goto: async (request) => { - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - return await goToDefinition(snapshot.index, { - file: await resolveProjectFile(await realRoot, root, request.file), - line: request.line, - column: request.column, - }); - }, - - refs: async (request) => { - const handle = "handle" in request ? request.handle : undefined; - const file = "file" in request ? request.file : undefined; - const line = "line" in request ? request.line : undefined; - const column = "column" in request ? request.column : undefined; - const hasAnyPosition = file !== undefined || line !== undefined || column !== undefined; - const hasCompletePosition = file !== undefined && line !== undefined && column !== undefined; - if (handle !== undefined && hasAnyPosition) { - throw new Error("refs requires either handle or file, line, and column."); - } - if (handle === undefined && !hasCompletePosition) { - throw new Error("refs requires either handle or file, line, and column."); - } + get_symbol: async (request) => + await withFreshness(async () => { + const explanation = await explainCodegraphTargetWithSession(session, { root, target: request.handle }); + return explanation.target; + }), - if (handle !== undefined) { - const explanation = await explainCodegraphTargetWithSession(session, { - root, - target: handle, - maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + goto: async (request) => + await withFreshness(async () => { + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + return await goToDefinition(snapshot.index, { + file: await resolveProjectFile(await realRoot, root, request.file), + line: request.line, + column: request.column, }); - return { references: explanation.references }; - } - if (file === undefined || line === undefined || column === undefined) { - throw new Error("refs requires either handle or file, line, and column."); - } + }), - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - const referenceOptions = { - maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), - }; - const result = await findReferences( - snapshot.index, - { - file: await resolveProjectFile(await realRoot, root, file), - line, - column, - }, - referenceOptions, - ); - if (result.status !== "ok") return { references: [] }; - return { - references: result.references.map((reference) => ({ - file: relative(reference.file), - range: reference.range, - })), - }; - }, + refs: async (request) => + await withFreshness(async () => { + const handle = "handle" in request ? request.handle : undefined; + const file = "file" in request ? request.file : undefined; + const line = "line" in request ? request.line : undefined; + const column = "column" in request ? request.column : undefined; + const hasAnyPosition = file !== undefined || line !== undefined || column !== undefined; + const hasCompletePosition = file !== undefined && line !== undefined && column !== undefined; + if (handle !== undefined && hasAnyPosition) { + throw new Error("refs requires either handle or file, line, and column."); + } + if (handle === undefined && !hasCompletePosition) { + throw new Error("refs requires either handle or file, line, and column."); + } + + if (handle !== undefined) { + const explanation = await explainCodegraphTargetWithSession(session, { + root, + target: handle, + maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + }); + return { references: explanation.references }; + } + if (file === undefined || line === undefined || column === undefined) { + throw new Error("refs requires either handle or file, line, and column."); + } + + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const referenceOptions = { + maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + }; + const result = await findReferences( + snapshot.index, + { + file: await resolveProjectFile(await realRoot, root, file), + line, + column, + }, + referenceOptions, + ); + if (result.status !== "ok") return { references: [] }; + return { + references: result.references.map((reference) => ({ + file: relative(reference.file), + range: reference.range, + })), + }; + }), - deps: async (request) => { - const dependencies = await collectMcpDependencyEntries(request, getDependencies); - return { dependencies }; - }, + deps: async (request) => + await withFreshness(async () => { + const dependencies = await collectMcpDependencyEntries(request, getDependencies); + return { dependencies }; + }), - rdeps: async (request) => { - const reverseDependencies = await collectMcpDependencyEntries(request, getReverseDependencies); - return { reverseDependencies }; - }, + rdeps: async (request) => + await withFreshness(async () => { + const reverseDependencies = await collectMcpDependencyEntries(request, getReverseDependencies); + return { reverseDependencies }; + }), - path: async (request) => { - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - const result = getShortestPath( - snapshot.fileGraph, - await resolveProjectFile(await realRoot, root, request.from), - await resolveProjectFile(await realRoot, root, request.to), - ); - return { - path: result ? result.map(relative) : null, - }; - }, + path: async (request) => + await withFreshness(async () => { + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const result = getShortestPath( + snapshot.fileGraph, + await resolveProjectFile(await realRoot, root, request.from), + await resolveProjectFile(await realRoot, root, request.to), + ); + return { + path: result ? result.map(relative) : null, + }; + }), impact: async (request) => - await buildReviewReport(root, { - ...options.buildOptions, - gitBase: request.base, - gitHead: request.head, - reviewDepth: "minimal", - }), + await withFreshness( + async () => + await buildReviewReport(root, { + ...options.buildOptions, + gitBase: request.base, + gitHead: request.head, + reviewDepth: "minimal", + }), + ), review: async (request) => - await buildReviewReport(root, { - ...options.buildOptions, - gitBase: request.base, - gitHead: request.head, - ...(request.reviewDepth !== undefined ? { reviewDepth: request.reviewDepth } : {}), - }), + await withFreshness( + async () => + await buildReviewReport(root, { + ...options.buildOptions, + gitBase: request.base, + gitHead: request.head, + ...(request.reviewDepth !== undefined ? { reviewDepth: request.reviewDepth } : {}), + }), + ), query_sqlite: async (request) => { if (!sqlitePath) { @@ -420,35 +456,36 @@ function createCodegraphMcpHandlersForSession( return { refreshed: true, warmup }; }, - artifact_build: async (request) => { - if (readOnly) { - throw new Error("artifact_build is disabled in read-only MCP mode."); - } - const outDir = - request.outDir !== undefined - ? await assertWritableDirectoryRealPathWithinRoot( - await realRoot, - root, - request.outDir, - "Artifact output directory", - ) - : undefined; - const result = await buildCodegraphArtifactWithSession(session, { - root, - ...(outDir !== undefined ? { outDir } : {}), - ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), - ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), - ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), - ...(request.report !== undefined ? { report: request.report } : {}), - ...(request.questions !== undefined ? { questions: request.questions } : {}), - ...(request.force !== undefined ? { force: request.force } : {}), - }); - const sqliteArtifact = result.artifacts.sqlite; - if (sqliteArtifact) { - sqlitePath = path.join(result.outDir, sqliteArtifact); - } - return result; - }, + artifact_build: async (request) => + await withFreshness(async () => { + if (readOnly) { + throw new Error("artifact_build is disabled in read-only MCP mode."); + } + const outDir = + request.outDir !== undefined + ? await assertWritableDirectoryRealPathWithinRoot( + await realRoot, + root, + request.outDir, + "Artifact output directory", + ) + : undefined; + const result = await buildCodegraphArtifactWithSession(session, { + root, + ...(outDir !== undefined ? { outDir } : {}), + ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), + ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), + ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), + ...(request.report !== undefined ? { report: request.report } : {}), + ...(request.questions !== undefined ? { questions: request.questions } : {}), + ...(request.force !== undefined ? { force: request.force } : {}), + }); + const sqliteArtifact = result.artifacts.sqlite; + if (sqliteArtifact) { + sqlitePath = path.join(result.outDir, sqliteArtifact); + } + return result; + }), }; } diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 976c76f9..9e614ac2 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -156,4 +156,28 @@ describe("agent session", () => { expect(files).toContain(keptFile.replace(/\\/g, "/")); expect(files).not.toContain(ignoredFile.replace(/\\/g, "/")); }); + + it("reports stale file edits in check policy without invalidating cached project state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-check-fresh-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function oldSymbol() { return 1; }\n", "utf8"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + const session = createAgentSession({ root, freshness: { policy: "check" } }); + const cached = await session.loadProject({ symbolGraph: "skip" }); + if (!session.checkFreshness) { + throw new Error("agent session should expose freshness checks"); + } + + await fs.writeFile(filePath, "export function editedSymbol() { return 22; }\n", "utf8"); + const freshness = await session.checkFreshness(); + const afterCheck = await session.loadProject({ symbolGraph: "skip" }); + + expect(freshness).toEqual({ + state: "stale", + changedFiles: ["auth.ts"], + reason: "session snapshot is older than files on disk", + }); + expect(afterCheck).toBe(cached); + expect(buildSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/helpers/agent.ts b/tests/helpers/agent.ts index 90dbc799..6a5d4a7c 100644 --- a/tests/helpers/agent.ts +++ b/tests/helpers/agent.ts @@ -4,25 +4,37 @@ export function countingSession(session: AgentSession): { session: AgentSession; let cached: Promise | undefined; let cachedMode: AgentLoadProjectOptions["symbolGraph"] | undefined; let loadCount = 0; - return { - session: { - ...(session.root ? { root: session.root } : {}), - ...(session.listFiles ? { listFiles: session.listFiles } : {}), - loadProject: async (options) => { - const mode = options?.symbolGraph ?? "eager"; - if (!cached || cachedMode !== mode) { - loadCount += 1; - cachedMode = mode; - cached = session.loadProject(options); - } - return await cached; - }, - invalidate: () => { + const countedSession: AgentSession = { + ...(session.root ? { root: session.root } : {}), + ...(session.listFiles ? { listFiles: session.listFiles } : {}), + loadProject: async (options) => { + const mode = options?.symbolGraph ?? "eager"; + if (!cached || cachedMode !== mode) { + loadCount += 1; + cachedMode = mode; + cached = session.loadProject(options); + } + return await cached; + }, + invalidate: () => { + cached = undefined; + cachedMode = undefined; + session.invalidate(); + }, + }; + const checkFreshness = session.checkFreshness; + if (checkFreshness) { + countedSession.checkFreshness = async () => { + const freshness = await checkFreshness(); + if (freshness.state === "refreshed") { cached = undefined; cachedMode = undefined; - session.invalidate(); - }, - }, + } + return freshness; + }; + } + return { + session: countedSession, loads: () => loadCount, }; } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index fa1c8484..26a74853 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createAgentSession } from "../src/agent/session.js"; -import { createCodegraphMcpHandlers, listCodegraphMcpTools, startCodegraphMcpHttpServer } from "../src/mcp/server.js"; +import { + createCodegraphMcpHandlers, + listCodegraphMcpTools, + startCodegraphMcpHttpServer, + type CodegraphMcpHandlers, +} from "../src/mcp/server.js"; import * as symbolGraphBuild from "../src/graphs/symbol-graph-detailed.js"; import { countingSession } from "./helpers/agent.js"; import { createArtifactOutputWithStaleFile, createLinkedTempRoot, isSymlinkUnavailable } from "./helpers/filesystem.js"; @@ -509,7 +514,7 @@ describe("codegraph MCP handlers", () => { const result = await handlers.get_file({ file: "large.txt", maxBytes: 5 }); - expect(result).toEqual({ + expect(result).toMatchObject({ file: "large.txt", text: "abcde", truncated: true, @@ -523,7 +528,7 @@ describe("codegraph MCP handlers", () => { const result = await handlers.get_file({ file: "unicode.txt", maxBytes: 5 }); - expect(result).toEqual({ + expect(result).toMatchObject({ file: "unicode.txt", text: "abc", truncated: true, @@ -662,7 +667,7 @@ describe("codegraph MCP handlers", () => { const scenarios: Array<{ name: string; - run: (handlers: ReturnType) => Promise; + run: (handlers: CodegraphMcpHandlers) => Promise; }> = [ { name: "goto", @@ -728,37 +733,51 @@ describe("codegraph MCP handlers", () => { expect(counted.loads()).toBe(0); }); - it("refresh_index invalidates stale MCP session snapshots", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-refresh-index-")); + it("auto-refreshes added files before MCP search", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-add-")); await fs.writeFile(path.join(root, "auth.ts"), "export const ok = 1;\n", "utf8"); const handlers = createCodegraphMcpHandlers({ root }); const before = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); - await fs.writeFile(path.join(root, "late.ts"), "export const lateSymbol = 1;\n", "utf8"); - const stale = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); - const refresh = await handlers.refresh_index({ warmup: "base" }); - const fresh = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(path.join(root, "late.ts"), "export function lateSymbol() { return 1; }\n", "utf8"); + const after = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); expect(before.results.some((result) => result.label === "lateSymbol")).toBe(false); - expect(stale.results.some((result) => result.label === "lateSymbol")).toBe(false); - expect(refresh).toEqual({ refreshed: true, warmup: "base" }); - expect(fresh.results.some((result) => result.label === "lateSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "lateSymbol")).toBe(true); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); - it("refresh_index reloads edited files in stale MCP session snapshots", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-refresh-edit-")); + it("auto-refreshes edited files before MCP search and reports root-relative changed paths", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-edit-")); const filePath = path.join(root, "auth.ts"); - await fs.writeFile(filePath, "export const oldSymbol = 1;\n", "utf8"); + await fs.writeFile(filePath, "export function oldSymbol() { return 1; }\n", "utf8"); const handlers = createCodegraphMcpHandlers({ root }); - await handlers.search({ query: "oldSymbol", mode: "symbol", limit: 5 }); - await fs.writeFile(filePath, "export const editedSymbol = 1;\n", "utf8"); - const stale = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); - await handlers.refresh_index({}); - const fresh = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); + const before = await handlers.search({ query: "oldSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, "export function editedSymbol() { return 2; }\n", "utf8"); + const after = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); + + expect(before.results.some((result) => result.label === "oldSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "editedSymbol")).toBe(true); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["auth.ts"] }); + }); + + it("returns live file bytes and freshness metadata after MCP file edits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function readBefore() { return 'old'; }\n", "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); - expect(stale.results.some((result) => result.label === "editedSymbol")).toBe(false); - expect(fresh.results.some((result) => result.label === "editedSymbol")).toBe(true); + await handlers.search({ query: "readBefore", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, "export function readAfter() { return 'new bytes'; }\n", "utf8"); + const read = await handlers.get_file({ file: "auth.ts" }); + + expect(read).toEqual({ + file: "auth.ts", + text: "export function readAfter() { return 'new bytes'; }\n", + truncated: false, + freshness: { state: "refreshed", changedFiles: ["auth.ts"] }, + }); }); it("refresh_index clears stale SQLite artifact state", async () => { From b8bb529fa8027100c7944d58a562d318c3ee7a3d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:30:54 -0400 Subject: [PATCH 02/15] Fix MCP freshness review findings --- codegraph-skill/codegraph/SKILL.md | 4 +-- docs/library-api.md | 11 +++--- docs/mcp.md | 12 +++---- src/agent/session.ts | 51 ++++++++++++++++++++++---- src/mcp/server.ts | 58 +++++++++++++++++++++++++++--- tests/agent-session.test.ts | 2 ++ tests/mcp-server.test.ts | 54 ++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 22 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 16a9bea6..a49a93d6 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -65,8 +65,8 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. ## MCP If MCP tools are available, prefer them over repeated CLI invocations. -Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, and `review` first. -After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` means use `refresh_index` or live file reads before trusting indexed context. +Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` includes a reason plus bounded changed-file metadata before indexed context is trusted. Fall back to CLI when MCP is unavailable. ## Discovery diff --git a/docs/library-api.md b/docs/library-api.md index 374fdb1d..3feacdde 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -162,7 +162,9 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot; MCP-created sessions use automatic freshness checks. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: +`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. +Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot. `check` reports stale state without invalidating, `auto` invalidates for bounded changes, and stale results include `changedFileCount`, `omittedChangedFileCount`, `reason`, and a bounded changed-file sample. +Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: ```ts import { createCodegraphMcpHandlers } from "@lzehrung/codegraph"; @@ -179,11 +181,12 @@ const packet = await handlers.packet_get({ target: orient.focus.find((entry) => const refs = await handlers.refs({ handle: search.results[0]!.handle }); const rows = await handlers.query_sqlite({ query: "select path from files", limit: 5 }); console.log(search.freshness.state); -console.log(packet.kind, refs.references, rows.rows); +console.log(packet.kind, refs.references, rows.rows, rows.freshness.state); ``` -`serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. `query_sqlite` is read-only and row- and byte-bounded; `artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. -MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. Index-backed MCP responses include `freshness` metadata so hosts can distinguish fresh, auto-refreshed, and stale snapshots. +`serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. +`query_sqlite` is read-only and row- and byte-bounded. It returns freshness metadata for fresh artifact reads, refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled, and rejects stale artifact queries it cannot refresh safely. +`artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. See [MCP server](./mcp.md) for CLI server setup and client configuration examples. diff --git a/docs/mcp.md b/docs/mcp.md index beb46171..f2ef908b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,13 +44,13 @@ The server exposes the same bounded primitives as the CLI and library session la - `refs`: references by handle or file position. - `deps`, `rdeps`, `path`: dependency navigation. - `impact`, `review`: git-range risk and review context. -- `query_sqlite`: bounded read-only SQLite artifact query. +- `query_sqlite`: bounded read-only SQLite artifact query with freshness metadata. - `refresh_index`: invalidate the in-memory session and optionally rebuild the base or symbol snapshot. - `artifact_build`: artifact creation, available only with write access enabled. MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. -Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale` with changed file paths when applicable. -Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. +Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale`; stale responses also include `changedFileCount`, `omittedChangedFileCount`, and a bounded changed-file sample. +Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. `query_sqlite` refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled; otherwise it refuses to serve stale artifact rows. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. ## Safety @@ -59,7 +59,7 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - Tool calls do not accept per-request root overrides. - Tools are read-only by default. - `artifact_build` requires `--allow-build`. -- `query_sqlite` rejects mutating SQL, recursive queries, and synthetic payload functions. +- `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. - SQLite responses are row- and byte-bounded. ## Client Configuration Examples @@ -196,9 +196,9 @@ When Codegraph MCP tools are available to an agent: 1. Start with `orient`. 2. Use `search` to find anchors. 3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. -4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` means the tool reported changed files without silently trusting old index data. +4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` includes a reason plus a bounded changed-file sample. 5. Use `impact` and `review` for git-range risk analysis. -6. Use `query_sqlite` only for read-only artifact inspection. +6. Use `query_sqlite` only for read-only artifact inspection; rebuild the artifact when it reports stale state. 7. Use `refresh_index` when you need an explicit rebuild. 8. Use `artifact_build` only when write access was intentionally enabled. diff --git a/src/agent/session.ts b/src/agent/session.ts index 33a07aed..3f2972cb 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -30,7 +30,13 @@ export type AgentFreshnessPolicy = "manual" | "check" | "auto"; export type AgentFreshnessResult = | { state: "fresh" } | { state: "refreshed"; changedFiles: string[] } - | { state: "stale"; changedFiles: string[]; reason: string }; + | { + state: "stale"; + changedFiles: string[]; + changedFileCount: number; + omittedChangedFileCount: number; + reason: string; + }; export type AgentSessionFreshnessOptions = { policy?: AgentFreshnessPolicy; @@ -61,6 +67,7 @@ const EMPTY_SYMBOL_GRAPH: SymbolGraph = { const AGENT_NATIVE_WORKER_AUTO_FILE_THRESHOLD = 250; const DEFAULT_MAX_AUTO_REFRESH_FILES = 50; const DEFAULT_MAX_AUTO_REFRESH_BYTES = 2_000_000; +const DEFAULT_MAX_FRESHNESS_CHANGED_FILES = 25; type AgentProjectBaseSnapshot = Omit; @@ -90,6 +97,25 @@ async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Prom return discoveryOptions ? { discoveryOptions } : {}; } +function isMissingStatRace(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (!("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + +function summarizeChangedFiles(files: readonly string[]): { + changedFiles: string[]; + changedFileCount: number; + omittedChangedFileCount: number; +} { + const changedFiles = files.slice(0, DEFAULT_MAX_FRESHNESS_CHANGED_FILES); + return { + changedFiles, + changedFileCount: files.length, + omittedChangedFileCount: Math.max(0, files.length - changedFiles.length), + }; +} + async function collectAgentFileSignatures(files: readonly string[]): Promise> { const signatures = new Map(); await Promise.all( @@ -103,8 +129,9 @@ async function collectAgentFileSignatures(files: readonly string[]): Promise maxFiles) { - return { state: "stale", changedFiles, reason: `changed file count exceeds ${maxFiles}` }; + return { + state: "stale", + ...summarizeChangedFiles(changedFiles), + reason: `changed file count exceeds ${maxFiles}`, + }; } if (diff.changedBytes > maxBytes) { - return { state: "stale", changedFiles, reason: `changed byte count exceeds ${maxBytes}` }; + return { + state: "stale", + ...summarizeChangedFiles(changedFiles), + reason: `changed byte count exceeds ${maxBytes}`, + }; } invalidate(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d96f8fdf..668637b0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -154,7 +154,7 @@ export type CodegraphMcpHandlers = { query: string; params?: Array | undefined; limit?: number | undefined; - }) => Promise; + }) => Promise>; artifact_build: (request: { outDir?: string | undefined; sqlite?: boolean | undefined; @@ -239,19 +239,66 @@ function createCodegraphMcpHandlersForSession( ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; let sqlitePath = configuredSqlitePath; + let sqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; + const checkMcpFreshness = async (): Promise => { + if (session.checkFreshness) return await session.checkFreshness(); + return { + state: "stale", + changedFiles: [], + changedFileCount: 0, + omittedChangedFileCount: 0, + reason: "freshness check unavailable", + }; + }; const withFreshness = async ( run: () => Promise, ): Promise => { - const freshness = session.checkFreshness ? await session.checkFreshness() : { state: "fresh" as const }; + const freshness = await checkMcpFreshness(); const result = await run(); return { ...result, freshness }; }; + const formatSqliteFreshnessError = (freshness: AgentFreshnessResult): string => { + if (freshness.state === "fresh") return "SQLite artifact freshness check unexpectedly failed."; + const reason = freshness.state === "stale" ? freshness.reason : "workspace changed after artifact build"; + const changed = freshness.changedFiles.length ? ` Changed files: ${freshness.changedFiles.join(", ")}.` : ""; + const omitted = + freshness.state === "stale" && freshness.omittedChangedFileCount + ? ` Omitted changed files: ${freshness.omittedChangedFileCount}.` + : ""; + return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; + }; + const refreshSqliteArtifactForQuery = async (freshness: AgentFreshnessResult): Promise => { + if (freshness.state === "fresh") return; + if (!sqlitePath || !sqliteOutDir || readOnly || path.basename(sqlitePath) !== "codegraph.sqlite") { + throw new Error(formatSqliteFreshnessError(freshness)); + } + if (freshness.state === "stale") { + throw new Error(formatSqliteFreshnessError(freshness)); + } + const outDir = await assertWritableDirectoryRealPathWithinRoot( + await realRoot, + root, + sqliteOutDir, + "Artifact output directory", + ); + const result = await buildCodegraphArtifactWithSession(session, { + root, + outDir, + filterOutDir: outDir, + sqlite: true, + force: true, + }); + const sqliteArtifact = result.artifacts.sqlite; + if (!sqliteArtifact) throw new Error("SQLite artifact refresh did not produce a SQLite file."); + sqlitePath = path.join(result.outDir, sqliteArtifact); + sqliteOutDir = result.outDir; + }; const collectMcpDependencyEntries = async ( request: McpDependencyRequest, @@ -440,12 +487,14 @@ function createCodegraphMcpHandlersForSession( if (!sqlitePath) { throw new Error("No SQLite artifact is available. Run artifact_build first or pass artifactPath."); } - const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); assertMcpSqliteQueryResourceBounded(request.query); + const freshness = await checkMcpFreshness(); + await refreshSqliteArtifactForQuery(freshness); + const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], { maxRows: normalizeSqliteRowLimit(request.limit), }); - return boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT); + return { ...boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT), freshness }; }, refresh_index: async (request) => { @@ -483,6 +532,7 @@ function createCodegraphMcpHandlersForSession( const sqliteArtifact = result.artifacts.sqlite; if (sqliteArtifact) { sqlitePath = path.join(result.outDir, sqliteArtifact); + sqliteOutDir = result.outDir; } return result; }), diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 9e614ac2..071d0cf0 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -175,6 +175,8 @@ describe("agent session", () => { expect(freshness).toEqual({ state: "stale", changedFiles: ["auth.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, reason: "session snapshot is older than files on disk", }); expect(afterCheck).toBe(cached); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 26a74853..0030018a 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -395,6 +395,20 @@ describe("codegraph MCP handlers", () => { expect(result.rowLimit).toBe(1); }); + it("refreshes the SQLite artifact before query_sqlite after small workspace edits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-fresh-")); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const handlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("two.ts"))).toBe(true); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -762,6 +776,46 @@ describe("codegraph MCP handlers", () => { expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["auth.ts"] }); }); + it("auto-refreshes deleted files before MCP search", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-delete-")); + const filePath = path.join(root, "remove.ts"); + await fs.writeFile(filePath, "export function removedSymbol() { return 1; }\n", "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); + + const before = await handlers.search({ query: "removedSymbol", mode: "symbol", limit: 5 }); + await fs.unlink(filePath); + const after = await handlers.search({ query: "removedSymbol", mode: "symbol", limit: 5 }); + + expect(before.results.some((result) => result.label === "removedSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "removedSymbol")).toBe(false); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["remove.ts"] }); + }); + + it("reports bounded stale metadata when MCP auto-refresh thresholds are exceeded", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-burst-")); + await fs.writeFile(path.join(root, "auth.ts"), "export function cachedSymbol() { return 1; }\n", "utf8"); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshFiles: 1 } }); + const handlers = createCodegraphMcpHandlers({ root, session }); + + await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + await Promise.all( + Array.from({ length: 30 }, async (_, index) => { + const suffix = String(index).padStart(2, "0"); + await fs.writeFile(path.join(root, `late-${suffix}.ts`), `export const late${suffix} = ${index};\n`, "utf8"); + }), + ); + const after = await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + + expect(after.results.some((result) => result.label === "cachedSymbol")).toBe(true); + expect(after.freshness).toEqual({ + state: "stale", + changedFiles: Array.from({ length: 25 }, (_, index) => `late-${String(index).padStart(2, "0")}.ts`), + changedFileCount: 30, + omittedChangedFileCount: 5, + reason: "changed file count exceeds 1", + }); + }); + it("returns live file bytes and freshness metadata after MCP file edits", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); const filePath = path.join(root, "auth.ts"); From 054a213d9d4542b2ed4c1f299a2dc40a905f1a90 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:46:47 -0400 Subject: [PATCH 03/15] Harden SQLite artifact freshness --- docs/cli.md | 5 +- src/mcp/server.ts | 150 ++++++++++++++++++++++++++++++++++----- src/sqlite.ts | 2 +- src/sqlite/write.ts | 31 ++++++++ tests/mcp-server.test.ts | 36 ++++++++++ 5 files changed, 202 insertions(+), 22 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 413d3304..d5d95387 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -263,11 +263,12 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - `mcp serve` exposes navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. - MCP uses stdio by default or Streamable HTTP with `--port `. - Startup is lazy by default; `--warmup` builds the base session cache before serving requests, and `--warmup-symbols` also builds the detailed symbol graph. -- Use `refresh_index` after changing files while a long-running server is active. +- Index-backed responses include `freshness`; small file changes auto-refresh, while stale responses include a reason, total changed-file count, and a bounded changed-file sample. +- Use `refresh_index` to force a rebuild, reset SQLite artifact state, or recover after stale change bursts. - HTTP serves `/mcp`, validates Host headers, and binds to `127.0.0.1` unless `--host ` is passed. - MCP file and artifact paths are confined to `--root` after realpath resolution. - MCP tools are read-only by default; `--allow-build` enables artifact output only. -- `query_sqlite` is row- and byte-bounded and rejects synthetic payload functions. +- `query_sqlite` is row- and byte-bounded, returns freshness metadata, rejects synthetic payload functions, and refuses stale artifact rows it cannot refresh safely. See [docs/mcp.md](./mcp.md) for client configuration examples. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 668637b0..ab94c8f3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -24,7 +24,8 @@ import type { AgentSearchMode, AgentSearchResponse } from "../agent/search.js"; import { getDependencies, getReverseDependencies, getShortestPath, type DependencyNode } from "../graphs/queries.js"; import { findReferences, goToDefinition } from "../indexer/navigation.js"; import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../review.js"; -import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; +import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; +import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; @@ -176,6 +177,14 @@ type McpDependencyEntry = { depth: number; }; +type SqliteArtifactFileSignature = { + path: string; + size: number; + mtimeMs: number; +}; + +const MAX_MCP_FRESHNESS_CHANGED_FILES = 25; + function assertMcpSessionOptions(options: CodegraphMcpHandlerOptions): void { if (options.session !== undefined && options.buildOptions !== undefined) { throw new Error("MCP server options cannot combine a prebuilt session with buildOptions."); @@ -238,24 +247,30 @@ function createCodegraphMcpHandlersForSession( const configuredSqlitePath = options.artifactPath ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; + const configuredSqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; let sqlitePath = configuredSqlitePath; - let sqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; + let sqliteOutDir = configuredSqliteOutDir; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; - const checkMcpFreshness = async (): Promise => { - if (session.checkFreshness) return await session.checkFreshness(); + const staleFreshness = (files: readonly string[], reason: string): AgentFreshnessResult => { + const changedFiles = [...files].sort(); + const boundedChangedFiles = changedFiles.slice(0, MAX_MCP_FRESHNESS_CHANGED_FILES); return { state: "stale", - changedFiles: [], - changedFileCount: 0, - omittedChangedFileCount: 0, - reason: "freshness check unavailable", + changedFiles: boundedChangedFiles, + changedFileCount: changedFiles.length, + omittedChangedFileCount: Math.max(0, changedFiles.length - boundedChangedFiles.length), + reason, }; }; + const checkMcpFreshness = async (): Promise => { + if (session.checkFreshness) return await session.checkFreshness(); + return staleFreshness([], "freshness check unavailable"); + }; const withFreshness = async ( run: () => Promise, ): Promise => { @@ -273,14 +288,12 @@ function createCodegraphMcpHandlersForSession( : ""; return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; }; - const refreshSqliteArtifactForQuery = async (freshness: AgentFreshnessResult): Promise => { - if (freshness.state === "fresh") return; - if (!sqlitePath || !sqliteOutDir || readOnly || path.basename(sqlitePath) !== "codegraph.sqlite") { - throw new Error(formatSqliteFreshnessError(freshness)); - } - if (freshness.state === "stale") { - throw new Error(formatSqliteFreshnessError(freshness)); - } + const canRefreshSqliteArtifact = (): boolean => { + if (!sqlitePath || !sqliteOutDir || readOnly) return false; + return path.basename(sqlitePath) === "codegraph.sqlite"; + }; + const rebuildSqliteArtifactForQuery = async (): Promise => { + if (!sqliteOutDir) throw new Error("SQLite artifact output directory is unavailable."); const outDir = await assertWritableDirectoryRealPathWithinRoot( await realRoot, root, @@ -299,6 +312,99 @@ function createCodegraphMcpHandlersForSession( sqlitePath = path.join(result.outDir, sqliteArtifact); sqliteOutDir = result.outDir; }; + const refreshSqliteArtifactForQuery = async ( + freshness: AgentFreshnessResult, + options?: { allowStaleRebuild?: boolean }, + ): Promise => { + if (freshness.state === "fresh") return freshness; + if (freshness.state === "stale" && !options?.allowStaleRebuild) { + throw new Error(formatSqliteFreshnessError(freshness)); + } + if (!canRefreshSqliteArtifact()) throw new Error(formatSqliteFreshnessError(freshness)); + await rebuildSqliteArtifactForQuery(); + return { state: "refreshed", changedFiles: freshness.changedFiles }; + }; + const readSqliteArtifactSignatures = async ( + realSqlitePath: string, + ): Promise => { + const result = await queryGraphSqliteRaw( + realSqlitePath, + "SELECT value FROM graph_metadata WHERE key = ?;", + [SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY], + { maxRows: 1 }, + ); + const rawValue = result.rows[0]?.[0]; + if (typeof rawValue !== "string") return null; + const parsed: unknown = JSON.parse(rawValue); + if (!Array.isArray(parsed)) return null; + const signatures: SqliteArtifactFileSignature[] = []; + for (const value of parsed) { + if (!isPlainRecord(value)) continue; + if (typeof value.path !== "string") continue; + if (typeof value.size !== "number" || typeof value.mtimeMs !== "number") continue; + signatures.push({ path: value.path, size: value.size, mtimeMs: value.mtimeMs }); + } + return signatures; + }; + const isFileInsideDirectory = (file: string, directory: string): boolean => { + const relativeFile = path.relative(directory, file); + if (!relativeFile) return true; + return !relativeFile.startsWith("..") && !path.isAbsolute(relativeFile); + }; + const collectCurrentSqliteArtifactSignatures = async (): Promise> => { + if (!session.listFiles) throw new Error("MCP session does not expose file discovery for SQLite freshness."); + const outputDirectories: string[] = []; + if (sqliteOutDir) { + outputDirectories.push(sqliteOutDir); + const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); + outputDirectories.push(lexicalOutDir); + } + const currentFiles = (await session.listFiles()).filter( + (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), + ); + const signatures = new Map(); + await Promise.all( + currentFiles.map(async (file) => { + try { + const stat = await fs.stat(file); + if (!stat.isFile()) return; + signatures.set(relative(file), { path: file, size: stat.size, mtimeMs: stat.mtimeMs }); + } catch (error) { + if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + return; + } + throw error; + } + }), + ); + return signatures; + }; + const checkSqliteArtifactFreshness = async (realSqlitePath: string): Promise => { + const storedSignatures = await readSqliteArtifactSignatures(realSqlitePath); + if (!storedSignatures) return staleFreshness([], "SQLite artifact has no freshness baseline"); + const storedByFile = new Map(); + for (const signature of storedSignatures) { + storedByFile.set(relative(signature.path), signature); + } + const currentByFile = await collectCurrentSqliteArtifactSignatures(); + const changedFiles: string[] = []; + for (const [file, currentSignature] of currentByFile.entries()) { + const storedSignature = storedByFile.get(file); + if (!storedSignature) { + changedFiles.push(file); + continue; + } + if (storedSignature.size !== currentSignature.size || storedSignature.mtimeMs !== currentSignature.mtimeMs) { + changedFiles.push(file); + } + } + for (const file of storedByFile.keys()) { + if (currentByFile.has(file)) continue; + changedFiles.push(file); + } + if (!changedFiles.length) return { state: "fresh" }; + return staleFreshness(changedFiles, "SQLite artifact is older than files on disk"); + }; const collectMcpDependencyEntries = async ( request: McpDependencyRequest, @@ -488,9 +594,14 @@ function createCodegraphMcpHandlersForSession( throw new Error("No SQLite artifact is available. Run artifact_build first or pass artifactPath."); } assertMcpSqliteQueryResourceBounded(request.query); - const freshness = await checkMcpFreshness(); - await refreshSqliteArtifactForQuery(freshness); - const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + let freshness = await checkMcpFreshness(); + freshness = await refreshSqliteArtifactForQuery(freshness); + let realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + const artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); + if (artifactFreshness.state !== "fresh") { + freshness = await refreshSqliteArtifactForQuery(artifactFreshness, { allowStaleRebuild: true }); + realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + } const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], { maxRows: normalizeSqliteRowLimit(request.limit), }); @@ -501,6 +612,7 @@ function createCodegraphMcpHandlersForSession( const warmup = request.warmup ?? "off"; session.invalidate(); sqlitePath = configuredSqlitePath; + sqliteOutDir = configuredSqliteOutDir; await startCodegraphMcpWarmup(session, warmup); return { refreshed: true, warmup }; }, diff --git a/src/sqlite.ts b/src/sqlite.ts index 49bb8b0b..ab8f0a23 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -1,3 +1,3 @@ export type { GraphQueryResult, RawSqlResult, SqliteGraphOptions, SqliteGraphUpdateOptions } from "./sqlite/types.js"; -export { writeGraphSqlite, updateGraphSqlite } from "./sqlite/write.js"; +export { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, writeGraphSqlite, updateGraphSqlite } from "./sqlite/write.js"; export { queryGraphSqlite, queryGraphSqliteRaw } from "./sqlite/query.js"; diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 5162f59b..146b24c1 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { type SymbolGraph, type SymbolNode } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import type { SqliteDatabase } from "../sqlite-driver.js"; @@ -5,6 +6,34 @@ import type { SqliteGraphOptions, SqliteGraphUpdateOptions } from "./types.js"; import { execRowsParams } from "./common.js"; import { withSqliteDatabase } from "./database.js"; +export const SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY = "artifact_file_signatures_v1"; + +type SqliteArtifactFileSignature = { + path: string; + size: number; + mtimeMs: number; +}; + +async function collectSqliteArtifactFileSignatures(files: Iterable): Promise { + const signatures: SqliteArtifactFileSignature[] = []; + await Promise.all( + [...files].map(async (file) => { + const stat = await fs.stat(file); + if (!stat.isFile()) return; + signatures.push({ path: file, size: stat.size, mtimeMs: stat.mtimeMs }); + }), + ); + signatures.sort((left, right) => left.path.localeCompare(right.path)); + return signatures; +} + +function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly SqliteArtifactFileSignature[]): void { + db.prepare("INSERT OR REPLACE INTO graph_metadata (key, value) VALUES (?, ?);").run([ + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, + JSON.stringify(signatures), + ]); +} + const collectSymbolIdsForFiles = (symbolGraph: SymbolGraph, changedSet: Set): Set => { const ids = new Set(); for (const [id, node] of symbolGraph.nodes.entries()) { @@ -212,6 +241,7 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { }; export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { + const fileSignatures = await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); @@ -239,6 +269,7 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); }); + it("refuses stale configured SQLite artifacts in read-only mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact is stale[\s\S]*two\.ts/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -816,6 +831,27 @@ describe("codegraph MCP handlers", () => { }); }); + it("reports stale metadata when MCP auto-refresh byte thresholds are exceeded", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-bytes-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function cachedSymbol() { return 1; }\n", "utf8"); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshBytes: 5 } }); + const handlers = createCodegraphMcpHandlers({ root, session }); + + await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, `export function largeSymbol() { return "${"x".repeat(64)}"; }\n`, "utf8"); + const after = await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + + expect(after.results.some((result) => result.label === "cachedSymbol")).toBe(true); + expect(after.freshness).toEqual({ + state: "stale", + changedFiles: ["auth.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, + reason: "changed byte count exceeds 5", + }); + }); + it("returns live file bytes and freshness metadata after MCP file edits", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); const filePath = path.join(root, "auth.ts"); From 2216acdda0b1d6048bff5a0a7d878e82207d5c2c Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:03:06 -0400 Subject: [PATCH 04/15] Fix SQLite artifact freshness edge cases --- src/agent/artifact.ts | 10 ++++++++++ src/agent/session.ts | 14 +++++++++----- src/mcp/server.ts | 22 ++++++++++++++-------- src/sqlite/types.ts | 1 + src/sqlite/write.ts | 16 +++++++++++++++- tests/mcp-server.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 606b0a82..2ede538c 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -114,11 +114,21 @@ export async function buildCodegraphArtifactWithSession( const artifacts: CodegraphArtifactBuildResult["artifacts"] = {}; if (selected.sqlite) { + let fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> | undefined; + if (snapshot.fileSignatures) { + fileSignatures = []; + for (const file of snapshot.fileGraph.nodes) { + const signature = snapshot.fileSignatures.get(file); + if (!signature) continue; + fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); + } + } const outputPath = path.join(outDir, SQLITE_FILE); await writeGraphSqlite({ fileGraph: snapshot.fileGraph, symbolGraph: snapshot.symbolGraph, outputPath, + ...(fileSignatures ? { fileSignatures } : {}), }); artifacts.sqlite = SQLITE_FILE; } diff --git a/src/agent/session.ts b/src/agent/session.ts index 3f2972cb..222b1bf1 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -19,6 +19,7 @@ export type AgentProjectSnapshot = { symbolGraph: SymbolGraph; buildReport?: BuildReport; analysis: AnalysisSummary; + fileSignatures?: ReadonlyMap; }; export type AgentLoadProjectOptions = { @@ -71,7 +72,7 @@ const DEFAULT_MAX_FRESHNESS_CHANGED_FILES = 25; type AgentProjectBaseSnapshot = Omit; -type AgentFileSignature = { +export type AgentFileSignature = { file: string; size: number; mtimeMs: number; @@ -97,6 +98,11 @@ async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Prom return discoveryOptions ? { discoveryOptions } : {}; } +export async function listAgentSessionFiles(options: AgentSessionOptions): Promise { + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); + return await listProjectFiles(options.root, undefined, discoveryOptions); +} + function isMissingStatRace(error: unknown): boolean { if (!(error instanceof Error)) return false; if (!("code" in error)) return false; @@ -182,10 +188,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const loadFiles = async (): Promise => { if (cachedFiles) return cachedFiles; - const loadPromise = (async () => { - const { discoveryOptions } = await resolveAgentDiscoverySettings(options); - return await listProjectFiles(options.root, undefined, discoveryOptions); - })(); + const loadPromise = listAgentSessionFiles(options); cachedFiles = loadPromise; loadPromise.catch(() => { if (cachedFiles === loadPromise) cachedFiles = undefined; @@ -223,6 +226,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { fileLookup: createAgentFileLookup(files), index, fileGraph, + fileSignatures: cachedFileSignatures, buildReport, analysis: summarizeAnalysis({ index, report: buildReport }), }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ab94c8f3..ed81fdfd 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,7 +27,7 @@ import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../revie import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; -import { createAgentSession } from "../agent/session.js"; +import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { @@ -248,8 +248,10 @@ function createCodegraphMcpHandlersForSession( ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; const configuredSqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; + const configuredSqliteCanRefresh = options.artifactPath ? !/\.(sqlite|db)$/i.test(options.artifactPath) : false; let sqlitePath = configuredSqlitePath; let sqliteOutDir = configuredSqliteOutDir; + let sqliteCanRefresh = configuredSqliteCanRefresh; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { @@ -289,7 +291,7 @@ function createCodegraphMcpHandlersForSession( return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; }; const canRefreshSqliteArtifact = (): boolean => { - if (!sqlitePath || !sqliteOutDir || readOnly) return false; + if (!sqlitePath || !sqliteOutDir || readOnly || !sqliteCanRefresh) return false; return path.basename(sqlitePath) === "codegraph.sqlite"; }; const rebuildSqliteArtifactForQuery = async (): Promise => { @@ -314,10 +316,10 @@ function createCodegraphMcpHandlersForSession( }; const refreshSqliteArtifactForQuery = async ( freshness: AgentFreshnessResult, - options?: { allowStaleRebuild?: boolean }, + refreshOptions?: { allowStaleRebuild?: boolean }, ): Promise => { if (freshness.state === "fresh") return freshness; - if (freshness.state === "stale" && !options?.allowStaleRebuild) { + if (freshness.state === "stale" && !refreshOptions?.allowStaleRebuild) { throw new Error(formatSqliteFreshnessError(freshness)); } if (!canRefreshSqliteArtifact()) throw new Error(formatSqliteFreshnessError(freshness)); @@ -352,16 +354,18 @@ function createCodegraphMcpHandlersForSession( return !relativeFile.startsWith("..") && !path.isAbsolute(relativeFile); }; const collectCurrentSqliteArtifactSignatures = async (): Promise> => { - if (!session.listFiles) throw new Error("MCP session does not expose file discovery for SQLite freshness."); const outputDirectories: string[] = []; if (sqliteOutDir) { outputDirectories.push(sqliteOutDir); const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const currentFiles = (await session.listFiles()).filter( - (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), - ); + const currentFiles = ( + await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }) + ).filter((file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory))); const signatures = new Map(); await Promise.all( currentFiles.map(async (file) => { @@ -613,6 +617,7 @@ function createCodegraphMcpHandlersForSession( session.invalidate(); sqlitePath = configuredSqlitePath; sqliteOutDir = configuredSqliteOutDir; + sqliteCanRefresh = configuredSqliteCanRefresh; await startCodegraphMcpWarmup(session, warmup); return { refreshed: true, warmup }; }, @@ -645,6 +650,7 @@ function createCodegraphMcpHandlersForSession( if (sqliteArtifact) { sqlitePath = path.join(result.outDir, sqliteArtifact); sqliteOutDir = result.outDir; + sqliteCanRefresh = true; } return result; }), diff --git a/src/sqlite/types.ts b/src/sqlite/types.ts index ea8a5430..b360064e 100644 --- a/src/sqlite/types.ts +++ b/src/sqlite/types.ts @@ -5,6 +5,7 @@ export type SqliteGraphOptions = { fileGraph: Graph; symbolGraph: SymbolGraph; outputPath: string; + fileSignatures?: Iterable<{ path: string; size: number; mtimeMs: number }>; }; export type SqliteGraphUpdateOptions = { diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 146b24c1..ccb2c2ff 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -27,6 +27,18 @@ async function collectSqliteArtifactFileSignatures(files: Iterable): Pro return signatures; } +function normalizeSqliteArtifactFileSignatures( + signatures: Iterable, +): SqliteArtifactFileSignature[] { + const normalized = [...signatures].map((signature) => ({ + path: signature.path, + size: signature.size, + mtimeMs: signature.mtimeMs, + })); + normalized.sort((left, right) => left.path.localeCompare(right.path)); + return normalized; +} + function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly SqliteArtifactFileSignature[]): void { db.prepare("INSERT OR REPLACE INTO graph_metadata (key, value) VALUES (?, ?);").run([ SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, @@ -241,7 +253,9 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { }; export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { - const fileSignatures = await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); + const fileSignatures = options.fileSignatures + ? normalizeSqliteArtifactFileSignatures(options.fileSignatures) + : await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 29a77ede..534ad71c 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { request as httpRequest } from "node:http"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, vi } from "vitest"; import { createAgentSession } from "../src/agent/session.js"; import { @@ -409,6 +410,26 @@ describe("codegraph MCP handlers", () => { expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); }); + it("refreshes the SQLite artifact before query_sqlite after edits and deletions", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-fresh-edit-delete-")); + const keptFile = path.join(root, "one.ts"); + const removedFile = path.join(root, "remove.ts"); + await fs.writeFile(keptFile, "export function oldName() { return 1; }\n"); + await fs.writeFile(removedFile, "export function removedName() { return 2; }\n"); + const handlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + await fs.writeFile(keptFile, "export function editedName() { return 3; }\n"); + await fs.unlink(removedFile); + const result = await handlers.query_sqlite({ query: "SELECT name FROM symbols ORDER BY name;" }); + const names = result.rows.map((row) => String(row[0])); + + expect(names).toContain("editedName"); + expect(names).not.toContain("oldName"); + expect(names).not.toContain("removedName"); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["one.ts", "remove.ts"] }); + }); + it("refuses stale configured SQLite artifacts in read-only mode", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); const outDir = path.join(root, "out"); @@ -424,6 +445,25 @@ describe("codegraph MCP handlers", () => { ); }); + it("refuses older SQLite artifacts without freshness metadata in read-only mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-legacy-artifact-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); + try { + db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + } finally { + db.close(); + } + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact has no freshness baseline/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); From 84531fbb68c7be9708365934f1e5ec4a2db27cf7 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:25:41 -0400 Subject: [PATCH 05/15] Complete SQLite artifact freshness hardening --- src/agent/artifact.ts | 16 +++++++----- src/agent/session.ts | 2 ++ src/mcp/server.ts | 16 +++++++----- src/sqlite/write.ts | 6 +++-- tests/helpers/agent.ts | 1 + tests/mcp-server.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 2ede538c..511f3578 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -114,14 +114,16 @@ export async function buildCodegraphArtifactWithSession( const artifacts: CodegraphArtifactBuildResult["artifacts"] = {}; if (selected.sqlite) { - let fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> | undefined; - if (snapshot.fileSignatures) { - fileSignatures = []; - for (const file of snapshot.fileGraph.nodes) { - const signature = snapshot.fileSignatures.get(file); - if (!signature) continue; - fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); + if (!snapshot.fileSignatures) { + throw new Error("SQLite artifact freshness signatures are unavailable."); + } + const fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> = []; + for (const file of snapshot.fileGraph.nodes) { + const signature = snapshot.fileSignatures.get(file); + if (!signature) { + throw new Error(`SQLite artifact freshness signature is missing for ${file}.`); } + fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); } const outputPath = path.join(outDir, SQLITE_FILE); await writeGraphSqlite({ diff --git a/src/agent/session.ts b/src/agent/session.ts index 222b1bf1..616c10fe 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -55,6 +55,7 @@ export type AgentSessionOptions = { export type AgentSession = { root?: string; + discoverFiles?: () => Promise; listFiles?: () => Promise; loadProject: (loadOptions?: AgentLoadProjectOptions) => Promise; checkFreshness?: () => Promise; @@ -319,6 +320,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { return { root: options.root, + discoverFiles: () => listAgentSessionFiles(options), listFiles: loadFiles, loadProject, checkFreshness, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ed81fdfd..054ba593 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -360,12 +360,16 @@ function createCodegraphMcpHandlersForSession( const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const currentFiles = ( - await listAgentSessionFiles({ - root, - ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), - }) - ).filter((file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory))); + const discoverFiles = + session.discoverFiles ?? + (async (): Promise => + await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + })); + const currentFiles = (await discoverFiles()).filter( + (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), + ); const signatures = new Map(); await Promise.all( currentFiles.map(async (file) => { diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index ccb2c2ff..e64897f6 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -255,7 +255,7 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const fileSignatures = options.fileSignatures ? normalizeSqliteArtifactFileSignatures(options.fileSignatures) - : await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); + : undefined; await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); @@ -283,7 +283,9 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const mode = options?.symbolGraph ?? "eager"; if (!cached || cachedMode !== mode) { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 534ad71c..00bfec0b 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -464,6 +464,62 @@ describe("codegraph MCP handlers", () => { ); }); + it("rebuilds stale configured SQLite artifact bundles in writable mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-writable-bundle-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); + try { + db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + } finally { + db.close(); + } + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, readOnly: false }); + const result = await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("two.ts"))).toBe(true); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: [] }); + }); + + it("refuses stale explicit SQLite artifact files in writable mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-explicit-file-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ + root, + artifactPath: path.join(outDir, "codegraph.sqlite"), + readOnly: false, + }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact is stale[\s\S]*two\.ts/, + ); + }); + + it("uses prebuilt session discovery for SQLite artifact freshness", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-session-discovery-")); + await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); + await fs.writeFile(path.join(root, "ignored.ts"), "export const ignored = 2;\n"); + const session = createAgentSession({ root, discovery: { ignoreGlobs: ["ignored.ts"] } }); + const handlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("keep.ts"))).toBe(true); + expect(paths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(result.freshness).toEqual({ state: "fresh" }); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); From 1ee87803f76e1f2861f6a5b86bc1597e5c961277 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:35:26 -0400 Subject: [PATCH 06/15] Close SQLite freshness review gaps --- src/mcp/server.ts | 20 ++++++++++++-------- src/sqlite/write.ts | 6 ++++++ tests/mcp-server.test.ts | 24 +++++++++++++++++------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 054ba593..4612a149 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -360,14 +360,18 @@ function createCodegraphMcpHandlersForSession( const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const discoverFiles = - session.discoverFiles ?? - (async (): Promise => - await listAgentSessionFiles({ - root, - ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), - })); - const currentFiles = (await discoverFiles()).filter( + let discoveredFiles: string[]; + if (session.discoverFiles) { + discoveredFiles = await session.discoverFiles(); + } else if (options.session) { + throw new Error("MCP session does not expose live file discovery for SQLite freshness."); + } else { + discoveredFiles = await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }); + } + const currentFiles = discoveredFiles.filter( (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), ); const signatures = new Map(); diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index e64897f6..5d5ace99 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -46,6 +46,10 @@ function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly Sq ]); } +function clearArtifactFileSignatures(db: SqliteDatabase): void { + db.prepare("DELETE FROM graph_metadata WHERE key = ?;").run([SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY]); +} + const collectSymbolIdsForFiles = (symbolGraph: SymbolGraph, changedSet: Set): Set => { const ids = new Set(); for (const [id, node] of symbolGraph.nodes.entries()) { @@ -285,6 +289,8 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-session-discovery-")); await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); await fs.writeFile(path.join(root, "ignored.ts"), "export const ignored = 2;\n"); - const session = createAgentSession({ root, discovery: { ignoreGlobs: ["ignored.ts"] } }); + const session = createAgentSession({ + root, + discovery: { ignoreGlobs: ["ignored.ts"] }, + freshness: { policy: "auto" }, + }); const handlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); - const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); - const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); - - expect(paths.some((file) => file.endsWith("keep.ts"))).toBe(true); - expect(paths.some((file) => file.endsWith("ignored.ts"))).toBe(false); - expect(result.freshness).toEqual({ state: "fresh" }); + const initial = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + await fs.writeFile(path.join(root, "late.ts"), "export const late = 3;\n"); + const refreshed = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const initialPaths = initial.rows.map((row) => normalizeSqlitePath(row[0])); + const refreshedPaths = refreshed.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(initialPaths.some((file) => file.endsWith("keep.ts"))).toBe(true); + expect(initialPaths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(initial.freshness).toEqual({ state: "fresh" }); + expect(refreshedPaths.some((file) => file.endsWith("late.ts"))).toBe(true); + expect(refreshedPaths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(refreshed.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); it("bounds query_sqlite bytes for MCP responses", async () => { From 1a072465ca481eafcaae985a617bd0354e22547a Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:46:22 -0400 Subject: [PATCH 07/15] Cover SQLite freshness edge cases --- tests/mcp-server.test.ts | 21 ++++++++++++++++++++- tests/sqlite.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index c3f65ef6..50fc7511 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, vi } from "vitest"; -import { createAgentSession } from "../src/agent/session.js"; +import { createAgentSession, type AgentSession } from "../src/agent/session.js"; import { createCodegraphMcpHandlers, listCodegraphMcpTools, @@ -530,6 +530,25 @@ describe("codegraph MCP handlers", () => { expect(refreshed.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); + it("rejects prebuilt SQLite sessions without live discovery", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-no-discovery-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const backingSession = createAgentSession({ root }); + const session: AgentSession = { + loadProject: backingSession.loadProject, + checkFreshness: async () => ({ state: "fresh" }), + invalidate: backingSession.invalidate, + }; + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, session }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /does not expose live file discovery/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index 1c698d95..7041241d 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -10,6 +10,7 @@ import { updateGraphSqlite, queryGraphSqlite, queryGraphSqliteRaw, + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, } from "../src/index.js"; import { mkTmpDir, normalizeTestPath } from "./helpers/filesystem.js"; @@ -70,6 +71,36 @@ export function run() { helper(); new Widget(); } db.close(); }); + it("clears artifact freshness metadata on unsigned full rewrites", async () => { + const root = await mkTmpDir("dg-sqlite-freshness-rewrite-"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export const one = 1;\n", "utf8"); + const index = await buildProjectIndex(root); + const sgraph = await buildSymbolGraphDetailed(index); + const dbPath = path.join(root, "graph.sqlite"); + const stat = await fsp.stat(mainPath); + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + fileSignatures: [{ path: mainPath, size: stat.size, mtimeMs: stat.mtimeMs }], + }); + let db = new DatabaseSync(dbPath); + expect(dbQuery(db, "SELECT value FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';")).toHaveLength(1); + db.close(); + + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + }); + db = new DatabaseSync(dbPath); + expect( + dbQuery(db, `SELECT value FROM graph_metadata WHERE key = '${SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY}';`), + ).toHaveLength(0); + db.close(); + }); + it("keeps full SQLite exports idempotent across repeated writes", async () => { const root = await mkTmpDir("dg-sqlite-idempotent-"); await fsp.writeFile( From 876279560ba58873598ac716162965b11f914a5c Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 09:17:12 -0400 Subject: [PATCH 08/15] Address PR review: bound stat concurrency, guard signature parse, drop unused helper --- src/agent/session.ts | 35 +++++++++++++------------- src/mcp/server.ts | 35 +++++++++++++++----------- src/sqlite/write.ts | 14 ----------- tests/mcp-server.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 46 deletions(-) diff --git a/src/agent/session.ts b/src/agent/session.ts index 616c10fe..d4a0d0f5 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -6,6 +6,7 @@ import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; +import { mapLimit } from "../util/concurrency.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; import { createAgentFileLookup } from "./normalize.js"; import { summarizeAnalysis, type AnalysisSummary } from "../analysisSummary.js"; @@ -123,25 +124,25 @@ function summarizeChangedFiles(files: readonly string[]): { }; } +const FILE_SIGNATURE_STAT_CONCURRENCY = 64; + async function collectAgentFileSignatures(files: readonly string[]): Promise> { const signatures = new Map(); - await Promise.all( - files.map(async (file) => { - const resolvedFile = path.resolve(file); - try { - const stat = await fsp.stat(resolvedFile); - if (!stat.isFile()) return; - signatures.set(resolvedFile, { - file: resolvedFile, - size: stat.size, - mtimeMs: stat.mtimeMs, - }); - } catch (error) { - if (isMissingStatRace(error)) return; - throw new Error(`Unable to verify freshness for ${resolvedFile}`, { cause: error }); - } - }), - ); + await mapLimit([...files], FILE_SIGNATURE_STAT_CONCURRENCY, async (file) => { + const resolvedFile = path.resolve(file); + try { + const stat = await fsp.stat(resolvedFile); + if (!stat.isFile()) return; + signatures.set(resolvedFile, { + file: resolvedFile, + size: stat.size, + mtimeMs: stat.mtimeMs, + }); + } catch (error) { + if (isMissingStatRace(error)) return; + throw new Error(`Unable to verify freshness for ${resolvedFile}`, { cause: error }); + } + }); return signatures; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4612a149..b2b3ae47 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,6 +28,7 @@ import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, queryGraphSqliteRaw, type import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; +import { mapLimit } from "../util/concurrency.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { @@ -184,6 +185,7 @@ type SqliteArtifactFileSignature = { }; const MAX_MCP_FRESHNESS_CHANGED_FILES = 25; +const SQLITE_ARTIFACT_STAT_CONCURRENCY = 64; function assertMcpSessionOptions(options: CodegraphMcpHandlerOptions): void { if (options.session !== undefined && options.buildOptions !== undefined) { @@ -271,7 +273,7 @@ function createCodegraphMcpHandlersForSession( }; const checkMcpFreshness = async (): Promise => { if (session.checkFreshness) return await session.checkFreshness(); - return staleFreshness([], "freshness check unavailable"); + return { state: "fresh" }; }; const withFreshness = async ( run: () => Promise, @@ -337,7 +339,12 @@ function createCodegraphMcpHandlersForSession( ); const rawValue = result.rows[0]?.[0]; if (typeof rawValue !== "string") return null; - const parsed: unknown = JSON.parse(rawValue); + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch { + return null; + } if (!Array.isArray(parsed)) return null; const signatures: SqliteArtifactFileSignature[] = []; for (const value of parsed) { @@ -375,20 +382,18 @@ function createCodegraphMcpHandlersForSession( (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), ); const signatures = new Map(); - await Promise.all( - currentFiles.map(async (file) => { - try { - const stat = await fs.stat(file); - if (!stat.isFile()) return; - signatures.set(relative(file), { path: file, size: stat.size, mtimeMs: stat.mtimeMs }); - } catch (error) { - if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { - return; - } - throw error; + await mapLimit(currentFiles, SQLITE_ARTIFACT_STAT_CONCURRENCY, async (file) => { + try { + const stat = await fs.stat(file); + if (!stat.isFile()) return; + signatures.set(relative(file), { path: file, size: stat.size, mtimeMs: stat.mtimeMs }); + } catch (error) { + if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + return; } - }), - ); + throw error; + } + }); return signatures; }; const checkSqliteArtifactFreshness = async (realSqlitePath: string): Promise => { diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 5d5ace99..d504a018 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { type SymbolGraph, type SymbolNode } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import type { SqliteDatabase } from "../sqlite-driver.js"; @@ -14,19 +13,6 @@ type SqliteArtifactFileSignature = { mtimeMs: number; }; -async function collectSqliteArtifactFileSignatures(files: Iterable): Promise { - const signatures: SqliteArtifactFileSignature[] = []; - await Promise.all( - [...files].map(async (file) => { - const stat = await fs.stat(file); - if (!stat.isFile()) return; - signatures.push({ path: file, size: stat.size, mtimeMs: stat.mtimeMs }); - }), - ); - signatures.sort((left, right) => left.path.localeCompare(right.path)); - return signatures; -} - function normalizeSqliteArtifactFileSignatures( signatures: Iterable, ): SqliteArtifactFileSignature[] { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 50fc7511..a7977b5f 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -12,6 +12,7 @@ import { type CodegraphMcpHandlers, } from "../src/mcp/server.js"; import * as symbolGraphBuild from "../src/graphs/symbol-graph-detailed.js"; +import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY } from "../src/sqlite.js"; import { countingSession } from "./helpers/agent.js"; import { createArtifactOutputWithStaleFile, createLinkedTempRoot, isSymlinkUnavailable } from "./helpers/filesystem.js"; @@ -1171,6 +1172,59 @@ describe("codegraph MCP handlers", () => { expect(paths.some((file) => file.endsWith("auth.ts"))).toBeTruthy(); expect(paths.some((file) => file.includes("/out/") || file.endsWith("/out/old.ts"))).toBe(false); }); + + it("query_sqlite treats corrupted SQLite freshness metadata as a missing baseline", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-corrupt-metadata-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); + try { + const updated = db + .prepare("UPDATE graph_metadata SET value = ? WHERE key = ?;") + .run("this-is-not-json", SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY); + if (Number(updated.changes) === 0) { + db.prepare("INSERT OR REPLACE INTO graph_metadata (key, value) VALUES (?, ?);").run( + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, + "this-is-not-json", + ); + } + } finally { + db.close(); + } + + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /no freshness baseline/i, + ); + + let caught: unknown; + try { + await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).not.toMatch(/JSON|Unexpected token/i); + }); + + it("reports fresh when a prebuilt MCP session has no freshness check", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-session-no-freshness-check-")); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const backingSession = createAgentSession({ root }); + const session: AgentSession = { + loadProject: backingSession.loadProject, + invalidate: backingSession.invalidate, + }; + const handlers = createCodegraphMcpHandlers({ root, session }); + + const read = await handlers.get_file({ file: "one.ts" }); + + expect(read.freshness).toEqual({ state: "fresh" }); + }); }); function normalizeSqlitePath(value: unknown): string { From ecea3d14957b3114aed70e658bbba60480e9349d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 5 Jul 2026 19:05:32 -0400 Subject: [PATCH 09/15] Address PR review round 2: normalize signature keys, simplify spread, export metadata key --- src/agent/artifact.ts | 2 +- src/agent/session.ts | 3 ++- src/index.ts | 1 + tests/sqlite.test.ts | 4 +++- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 511f3578..a90295c9 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -130,7 +130,7 @@ export async function buildCodegraphArtifactWithSession( fileGraph: snapshot.fileGraph, symbolGraph: snapshot.symbolGraph, outputPath, - ...(fileSignatures ? { fileSignatures } : {}), + fileSignatures, }); artifacts.sqlite = SQLITE_FILE; } diff --git a/src/agent/session.ts b/src/agent/session.ts index d4a0d0f5..2bde1a6e 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -7,6 +7,7 @@ import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { mapLimit } from "../util/concurrency.js"; +import { normalizePath } from "../util/paths.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; import { createAgentFileLookup } from "./normalize.js"; import { summarizeAnalysis, type AnalysisSummary } from "../analysisSummary.js"; @@ -129,7 +130,7 @@ const FILE_SIGNATURE_STAT_CONCURRENCY = 64; async function collectAgentFileSignatures(files: readonly string[]): Promise> { const signatures = new Map(); await mapLimit([...files], FILE_SIGNATURE_STAT_CONCURRENCY, async (file) => { - const resolvedFile = path.resolve(file); + const resolvedFile = normalizePath(path.resolve(file)); try { const stat = await fsp.stat(resolvedFile); if (!stat.isFile()) return; diff --git a/src/index.ts b/src/index.ts index a147ea94..f70c8f33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -316,6 +316,7 @@ export { updateGraphSqlite, queryGraphSqlite, queryGraphSqliteRaw, + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, type SqliteGraphOptions, type SqliteGraphUpdateOptions, type GraphQueryResult, diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index 7041241d..a0c18fb2 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -86,7 +86,9 @@ export function run() { helper(); new Widget(); } fileSignatures: [{ path: mainPath, size: stat.size, mtimeMs: stat.mtimeMs }], }); let db = new DatabaseSync(dbPath); - expect(dbQuery(db, "SELECT value FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';")).toHaveLength(1); + expect( + dbQuery(db, `SELECT value FROM graph_metadata WHERE key = '${SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY}';`), + ).toHaveLength(1); db.close(); await writeGraphSqlite({ From ab35d9cb2277bfc50254b153d2e6b354dab8cf78 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 5 Jul 2026 19:26:42 -0400 Subject: [PATCH 10/15] Address PR review round 3: artifact-first query_sqlite freshness, cheap get_file reads --- src/mcp/server.ts | 41 ++++++++++++++++++++++++---------------- tests/mcp-server.test.ts | 34 +++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b2b3ae47..7013aa83 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -482,17 +482,20 @@ function createCodegraphMcpHandlersForSession( }), ), - get_file: async (request) => - await withFreshness(async () => { - const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); - const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); - const read = await readFilePrefix(resolvedFile.realPath, maxBytes); - return { - file: resolvedFile.displayPath, - text: read.text, - truncated: read.truncated, - }; - }), + get_file: async (request) => { + // get_file returns live bytes read directly from disk and never consults the indexed + // session, so it must not trigger a workspace-wide freshness scan or rebuild. The bytes + // are always current, so report fresh without re-stating the project. + const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); + const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); + const read = await readFilePrefix(resolvedFile.realPath, maxBytes); + return { + file: resolvedFile.displayPath, + text: read.text, + truncated: read.truncated, + freshness: { state: "fresh" }, + }; + }, get_symbol: async (request) => await withFreshness(async () => { @@ -611,18 +614,24 @@ function createCodegraphMcpHandlersForSession( throw new Error("No SQLite artifact is available. Run artifact_build first or pass artifactPath."); } assertMcpSqliteQueryResourceBounded(request.query); - let freshness = await checkMcpFreshness(); - freshness = await refreshSqliteArtifactForQuery(freshness); + // The artifact's own baseline decides whether the query can be served; session freshness + // only decides whether an automatic rebuild is safe. A fresh artifact is served even when + // the in-memory session snapshot is stale, and an unsafe session refresh burst blocks the + // automatic rebuild instead of silently rebuilding from stale in-memory state. + const sessionFreshness = await checkMcpFreshness(); let realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); - const artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); + let artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); if (artifactFreshness.state !== "fresh") { - freshness = await refreshSqliteArtifactForQuery(artifactFreshness, { allowStaleRebuild: true }); + if (sessionFreshness.state === "stale") { + throw new Error(formatSqliteFreshnessError(sessionFreshness)); + } + artifactFreshness = await refreshSqliteArtifactForQuery(artifactFreshness, { allowStaleRebuild: true }); realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); } const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], { maxRows: normalizeSqliteRowLimit(request.limit), }); - return { ...boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT), freshness }; + return { ...boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT), freshness: artifactFreshness }; }, refresh_index: async (request) => { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index a7977b5f..2b636c39 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -550,6 +550,32 @@ describe("codegraph MCP handlers", () => { ); }); + it("serves a fresh SQLite artifact even when the session snapshot is stale", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-session-fresh-artifact-")); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const outDir = path.join(root, "out"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + const backingSession = createAgentSession({ root }); + const session: AgentSession = { + ...backingSession, + checkFreshness: async () => ({ + state: "stale", + changedFiles: ["phantom.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, + reason: "forced stale for test", + }), + }; + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, session }); + + const result = await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + + expect(result.rows.map((row) => normalizeSqlitePath(row[0])).some((file) => file.endsWith("one.ts"))).toBe(true); + expect(result.freshness).toEqual({ state: "fresh" }); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -992,7 +1018,7 @@ describe("codegraph MCP handlers", () => { file: "auth.ts", text: "export function readAfter() { return 'new bytes'; }\n", truncated: false, - freshness: { state: "refreshed", changedFiles: ["auth.ts"] }, + freshness: { state: "fresh" }, }); }); @@ -1213,7 +1239,7 @@ describe("codegraph MCP handlers", () => { it("reports fresh when a prebuilt MCP session has no freshness check", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-session-no-freshness-check-")); - await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + await fs.writeFile(path.join(root, "one.ts"), "export function lonelySymbol() { return 1; }\n"); const backingSession = createAgentSession({ root }); const session: AgentSession = { loadProject: backingSession.loadProject, @@ -1221,9 +1247,9 @@ describe("codegraph MCP handlers", () => { }; const handlers = createCodegraphMcpHandlers({ root, session }); - const read = await handlers.get_file({ file: "one.ts" }); + const result = await handlers.search({ query: "lonelySymbol", mode: "symbol", limit: 5 }); - expect(read.freshness).toEqual({ state: "fresh" }); + expect(result.freshness).toEqual({ state: "fresh" }); }); }); From 8ec50794b90f00d07afb90220d2b40a6d8afe135 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sun, 5 Jul 2026 23:55:09 -0400 Subject: [PATCH 11/15] Address PR review round 4 freshness fixes --- codegraph-skill/codegraph/SKILL.md | 1 + docs/cli.md | 2 +- docs/library-api.md | 2 +- docs/mcp.md | 4 +- src/mcp/server.ts | 73 +++++++++++++++++------------- src/sqlite/write.ts | 1 + tests/mcp-server.test.ts | 16 +++---- tests/sqlite.test.ts | 46 +++++++++++++++---- 8 files changed, 91 insertions(+), 54 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index a49a93d6..1fb8ebc4 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -67,6 +67,7 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. If MCP tools are available, prefer them over repeated CLI invocations. Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` includes a reason plus bounded changed-file metadata before indexed context is trusted. +Run `refresh_index` before `artifact_build` when MCP reports a stale index; artifact writes refuse stale snapshots. Fall back to CLI when MCP is unavailable. ## Discovery diff --git a/docs/cli.md b/docs/cli.md index d5d95387..d266021b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -267,7 +267,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - Use `refresh_index` to force a rebuild, reset SQLite artifact state, or recover after stale change bursts. - HTTP serves `/mcp`, validates Host headers, and binds to `127.0.0.1` unless `--host ` is passed. - MCP file and artifact paths are confined to `--root` after realpath resolution. -- MCP tools are read-only by default; `--allow-build` enables artifact output only. +- MCP tools are read-only by default; `--allow-build` enables artifact output only when the MCP index is fresh or auto-refreshed. - `query_sqlite` is row- and byte-bounded, returns freshness metadata, rejects synthetic payload functions, and refuses stale artifact rows it cannot refresh safely. See [docs/mcp.md](./mcp.md) for client configuration examples. diff --git a/docs/library-api.md b/docs/library-api.md index 3feacdde..48c6fb26 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -186,7 +186,7 @@ console.log(packet.kind, refs.references, rows.rows, rows.freshness.state); `serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. `query_sqlite` is read-only and row- and byte-bounded. It returns freshness metadata for fresh artifact reads, refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled, and rejects stale artifact queries it cannot refresh safely. -`artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. +`artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`; it refuses to write outputs from a stale MCP index until `refresh_index` succeeds. MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. See [MCP server](./mcp.md) for CLI server setup and client configuration examples. diff --git a/docs/mcp.md b/docs/mcp.md index f2ef908b..24e5500b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -50,7 +50,7 @@ The server exposes the same bounded primitives as the CLI and library session la MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale`; stale responses also include `changedFileCount`, `omittedChangedFileCount`, and a bounded changed-file sample. -Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. `query_sqlite` refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled; otherwise it refuses to serve stale artifact rows. +Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. `query_sqlite` refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled; otherwise it refuses to serve stale artifact rows. `artifact_build` refuses to write outputs from a stale MCP index; run `refresh_index` first after large change bursts. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. ## Safety @@ -58,7 +58,7 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - File and artifact paths are confined to `--root` after realpath resolution. - Tool calls do not accept per-request root overrides. - Tools are read-only by default. -- `artifact_build` requires `--allow-build`. +- `artifact_build` requires `--allow-build` and a fresh or auto-refreshed MCP index. - `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. - SQLite responses are row- and byte-bounded. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7013aa83..5a7dd64e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -644,38 +644,47 @@ function createCodegraphMcpHandlersForSession( return { refreshed: true, warmup }; }, - artifact_build: async (request) => - await withFreshness(async () => { - if (readOnly) { - throw new Error("artifact_build is disabled in read-only MCP mode."); - } - const outDir = - request.outDir !== undefined - ? await assertWritableDirectoryRealPathWithinRoot( - await realRoot, - root, - request.outDir, - "Artifact output directory", - ) - : undefined; - const result = await buildCodegraphArtifactWithSession(session, { - root, - ...(outDir !== undefined ? { outDir } : {}), - ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), - ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), - ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), - ...(request.report !== undefined ? { report: request.report } : {}), - ...(request.questions !== undefined ? { questions: request.questions } : {}), - ...(request.force !== undefined ? { force: request.force } : {}), - }); - const sqliteArtifact = result.artifacts.sqlite; - if (sqliteArtifact) { - sqlitePath = path.join(result.outDir, sqliteArtifact); - sqliteOutDir = result.outDir; - sqliteCanRefresh = true; - } - return result; - }), + artifact_build: async (request) => { + const freshness = await checkMcpFreshness(); + if (freshness.state === "stale") { + const changed = freshness.changedFiles.length ? ` Changed files: ${freshness.changedFiles.join(", ")}.` : ""; + const omitted = freshness.omittedChangedFileCount + ? ` Omitted changed files: ${freshness.omittedChangedFileCount}.` + : ""; + throw new Error( + `Cannot build artifacts from a stale MCP index; run refresh_index first. ${freshness.reason}.${changed}${omitted}`, + ); + } + if (readOnly) { + throw new Error("artifact_build is disabled in read-only MCP mode."); + } + const outDir = + request.outDir !== undefined + ? await assertWritableDirectoryRealPathWithinRoot( + await realRoot, + root, + request.outDir, + "Artifact output directory", + ) + : undefined; + const result = await buildCodegraphArtifactWithSession(session, { + root, + ...(outDir !== undefined ? { outDir } : {}), + ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), + ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), + ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), + ...(request.report !== undefined ? { report: request.report } : {}), + ...(request.questions !== undefined ? { questions: request.questions } : {}), + ...(request.force !== undefined ? { force: request.force } : {}), + }); + const sqliteArtifact = result.artifacts.sqlite; + if (sqliteArtifact) { + sqlitePath = path.join(result.outDir, sqliteArtifact); + sqliteOutDir = result.outDir; + sqliteCanRefresh = true; + } + return { ...result, freshness }; + }, }; } diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index d504a018..ea37c911 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -337,6 +337,7 @@ export async function updateGraphSqlite(options: SqliteGraphUpdateOptions): Prom } deleteUnreferencedExternalFiles(db); + clearArtifactFileSignatures(db); recordGraphSnapshot(db, { mode: "incremental", diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 2b636c39..9c7e6fb2 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -454,7 +454,7 @@ describe("codegraph MCP handlers", () => { await buildHandlers.artifact_build({ outDir, sqlite: true }); const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); try { - db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + db.prepare("DELETE FROM graph_metadata WHERE key = ?;").run(SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY); } finally { db.close(); } @@ -473,7 +473,7 @@ describe("codegraph MCP handlers", () => { await buildHandlers.artifact_build({ outDir, sqlite: true }); const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); try { - db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + db.prepare("DELETE FROM graph_metadata WHERE key = ?;").run(SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY); } finally { db.close(); } @@ -1109,7 +1109,7 @@ describe("codegraph MCP handlers", () => { } }); - it("uses the MCP session snapshot when artifact_build is enabled", async () => { + it("refuses artifact_build when the MCP session snapshot is stale before creating output", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-artifact-session-")); await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); const counted = countingSession(createAgentSession({ root })); @@ -1117,12 +1117,12 @@ describe("codegraph MCP handlers", () => { await handlers.search({ query: "validate user", limit: 5 }); await fs.writeFile(path.join(root, "late.ts"), "export const late = 1;\n"); - await handlers.artifact_build({ outDir: path.join(root, "out"), graphJson: true }); + const outDir = path.join(root, "out"); - const graph = JSON.parse(await fs.readFile(path.join(root, "out", "graph.json"), "utf8")) as { - graph: { files: string[] }; - }; - expect(graph.graph.files.some((file) => file.endsWith("late.ts"))).toBe(false); + await expect(handlers.artifact_build({ outDir, graphJson: true })).rejects.toThrow( + /Cannot build artifacts from a stale MCP index[\s\S]*late\.ts/, + ); + await expect(fs.readdir(outDir)).rejects.toMatchObject({ code: "ENOENT" }); expect(counted.loads()).toBe(1); }); diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index a0c18fb2..f82106c8 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -21,6 +21,13 @@ const dbQuery = (db: DatabaseSync, sql: string): string[] => { return rows.map((row) => String(row[0])); }; +const artifactFreshnessMetadataRows = async (dbPath: string): Promise>> => { + const result = await queryGraphSqliteRaw(dbPath, "SELECT value FROM graph_metadata WHERE key = ?;", [ + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, + ]); + return result.rows; +}; + describe("SQLite graph export", () => { it("writes tables, indexes, and supports basic queries", async () => { const root = await mkTmpDir("dg-sqlite-"); @@ -85,22 +92,41 @@ export function run() { helper(); new Widget(); } outputPath: dbPath, fileSignatures: [{ path: mainPath, size: stat.size, mtimeMs: stat.mtimeMs }], }); - let db = new DatabaseSync(dbPath); - expect( - dbQuery(db, `SELECT value FROM graph_metadata WHERE key = '${SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY}';`), - ).toHaveLength(1); - db.close(); + expect(await artifactFreshnessMetadataRows(dbPath)).toHaveLength(1); await writeGraphSqlite({ fileGraph: index.graph, symbolGraph: sgraph, outputPath: dbPath, }); - db = new DatabaseSync(dbPath); - expect( - dbQuery(db, `SELECT value FROM graph_metadata WHERE key = '${SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY}';`), - ).toHaveLength(0); - db.close(); + expect(await artifactFreshnessMetadataRows(dbPath)).toHaveLength(0); + }); + + it("clears artifact freshness metadata on incremental updates", async () => { + const root = await mkTmpDir("dg-sqlite-freshness-update-"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export const one = 1;\n", "utf8"); + let index = await buildProjectIndex(root); + let sgraph = await buildSymbolGraphDetailed(index); + const dbPath = path.join(root, "graph.sqlite"); + const stat = await fsp.stat(mainPath); + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + fileSignatures: [{ path: mainPath, size: stat.size, mtimeMs: stat.mtimeMs }], + }); + expect(await artifactFreshnessMetadataRows(dbPath)).toHaveLength(1); + await fsp.writeFile(mainPath, "export const two = 2;\n", "utf8"); + index = await buildProjectIndex(root); + sgraph = await buildSymbolGraphDetailed(index); + await updateGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + changedFiles: [normalizeTestPath(mainPath)], + }); + expect(await artifactFreshnessMetadataRows(dbPath)).toHaveLength(0); }); it("keeps full SQLite exports idempotent across repeated writes", async () => { From fdd1a73e3ad231ec7059010d9ed91709feeb3f6a Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 00:20:37 -0400 Subject: [PATCH 12/15] Normalize session freshness paths --- src/agent/session.ts | 8 ++------ tests/agent-session.test.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/agent/session.ts b/src/agent/session.ts index 2bde1a6e..2d29db35 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -7,7 +7,7 @@ import { type SymbolGraph } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import { listProjectFiles, type ProjectFileDiscoveryOptions } from "../util/projectFiles.js"; import { mapLimit } from "../util/concurrency.js"; -import { normalizePath } from "../util/paths.js"; +import { normalizePath, toProjectDisplayPath } from "../util/paths.js"; import { hasDiscoveryOptions, loadCodegraphConfig, mergeDiscoveryOptions } from "../config.js"; import { createAgentFileLookup } from "./normalize.js"; import { summarizeAnalysis, type AnalysisSummary } from "../analysisSummary.js"; @@ -286,11 +286,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const diff = diffAgentFileSignatures(cachedFileSignatures, currentSignatures); if (!diff.changedFiles.length) return { state: "fresh" }; - const changedFiles = diff.changedFiles.map((file) => { - const relativeFile = path.relative(options.root, file).replace(/\\/g, "/"); - if (!relativeFile || relativeFile.startsWith("../")) return file.replace(/\\/g, "/"); - return relativeFile; - }); + const changedFiles = diff.changedFiles.map((file) => toProjectDisplayPath(options.root, file)); if (policy === "check") { return { state: "stale", diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 071d0cf0..c9c67a0b 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -157,12 +157,14 @@ describe("agent session", () => { expect(files).not.toContain(ignoredFile.replace(/\\/g, "/")); }); - it("reports stale file edits in check policy without invalidating cached project state", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-check-fresh-")); - const filePath = path.join(root, "auth.ts"); + it("reports stale file edits as normalized project display paths without invalidating cached project state", async () => { + const absoluteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-check-fresh-")); + const relativeRoot = path.relative(process.cwd(), absoluteRoot); + const filePath = path.join(absoluteRoot, "src", "auth.ts"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, "export function oldSymbol() { return 1; }\n", "utf8"); const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); - const session = createAgentSession({ root, freshness: { policy: "check" } }); + const session = createAgentSession({ root: relativeRoot, useConfig: false, freshness: { policy: "check" } }); const cached = await session.loadProject({ symbolGraph: "skip" }); if (!session.checkFreshness) { throw new Error("agent session should expose freshness checks"); @@ -174,7 +176,7 @@ describe("agent session", () => { expect(freshness).toEqual({ state: "stale", - changedFiles: ["auth.ts"], + changedFiles: ["src/auth.ts"], changedFileCount: 1, omittedChangedFileCount: 0, reason: "session snapshot is older than files on disk", From 30d7d8240282be10d3d01dd88cc067abc94194aa Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 00:41:53 -0400 Subject: [PATCH 13/15] Refine freshness stale guidance --- src/agent/session.ts | 3 ++- src/mcp/server.ts | 3 ++- tests/agent-session.test.ts | 26 ++++++++++++++++++++++++++ tests/mcp-server.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/agent/session.ts b/src/agent/session.ts index 2d29db35..60eedba3 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -164,9 +164,10 @@ function diffAgentFileSignatures( changedBytes += currentSignature.size; } } - for (const file of previous.keys()) { + for (const [file, previousSignature] of previous.entries()) { if (current.has(file)) continue; changedFiles.push(file); + changedBytes += previousSignature.size; } changedFiles.sort(); return { changedFiles, changedBytes }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5a7dd64e..dda7da46 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -290,7 +290,8 @@ function createCodegraphMcpHandlersForSession( freshness.state === "stale" && freshness.omittedChangedFileCount ? ` Omitted changed files: ${freshness.omittedChangedFileCount}.` : ""; - return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; + const action = freshness.state === "stale" ? "run refresh_index, then artifact_build" : "run artifact_build"; + return `SQLite artifact is stale; ${action} before query_sqlite. ${reason}.${changed}${omitted}`; }; const canRefreshSqliteArtifact = (): boolean => { if (!sqlitePath || !sqliteOutDir || readOnly || !sqliteCanRefresh) return false; diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index c9c67a0b..46002c5f 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -184,4 +184,30 @@ describe("agent session", () => { expect(afterCheck).toBe(cached); expect(buildSpy).toHaveBeenCalledTimes(1); }); + + it("counts deleted file bytes against auto-refresh limits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-delete-bytes-")); + const removedFile = path.join(root, "large.ts"); + await fs.writeFile(removedFile, `export const payload = "${"x".repeat(64)}";\n`, "utf8"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshBytes: 16 } }); + const cached = await session.loadProject({ symbolGraph: "skip" }); + if (!session.checkFreshness) { + throw new Error("agent session should expose freshness checks"); + } + + await fs.unlink(removedFile); + const freshness = await session.checkFreshness(); + const afterCheck = await session.loadProject({ symbolGraph: "skip" }); + + expect(freshness).toEqual({ + state: "stale", + changedFiles: ["large.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, + reason: "changed byte count exceeds 16", + }); + expect(afterCheck).toBe(cached); + expect(buildSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 9c7e6fb2..a24e30a1 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -431,6 +431,36 @@ describe("codegraph MCP handlers", () => { expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["one.ts", "remove.ts"] }); }); + it("guides stale SQLite rebuilds through refresh_index before artifact_build when the session is stale", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-session-guidance-")); + const outDir = path.join(root, "out"); + const removedFile = path.join(root, "remove.ts"); + await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); + await fs.writeFile(removedFile, `export const payload = "${"x".repeat(64)}";\n`); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshBytes: 16 } }); + const handlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); + await handlers.artifact_build({ outDir, sqlite: true }); + + await fs.unlink(removedFile); + let caught: unknown; + try { + await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + if (!(caught instanceof Error)) { + throw new Error("query_sqlite should reject stale SQLite rebuilds with an Error"); + } + expect(caught.message).toMatch(/SQLite artifact is stale/); + expect(caught.message).toMatch(/run refresh_index, then artifact_build before query_sqlite/); + expect(caught.message).toMatch(/changed byte count exceeds 16/); + expect(caught.message).toMatch(/remove\.ts/); + expect(caught.message.indexOf("refresh_index")).toBeLessThan(caught.message.indexOf("artifact_build")); + expect(caught.message.indexOf("artifact_build")).toBeLessThan(caught.message.indexOf("query_sqlite")); + }); + it("refuses stale configured SQLite artifacts in read-only mode", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); const outDir = path.join(root, "out"); From bdde4469a57dc20b9b3ceeb5c33bad6503322865 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 00:59:43 -0400 Subject: [PATCH 14/15] Fix read-only MCP stale guidance --- src/mcp/server.ts | 14 +++++--- tests/mcp-server.test.ts | 77 ++++++++++++++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index dda7da46..5d37d836 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -290,7 +290,13 @@ function createCodegraphMcpHandlersForSession( freshness.state === "stale" && freshness.omittedChangedFileCount ? ` Omitted changed files: ${freshness.omittedChangedFileCount}.` : ""; - const action = freshness.state === "stale" ? "run refresh_index, then artifact_build" : "run artifact_build"; + let action = "run artifact_build"; + if (readOnly) { + action = "rebuild the artifact with write access enabled"; + } + if (freshness.state === "stale") { + action = `run refresh_index, then ${action}`; + } return `SQLite artifact is stale; ${action} before query_sqlite. ${reason}.${changed}${omitted}`; }; const canRefreshSqliteArtifact = (): boolean => { @@ -646,6 +652,9 @@ function createCodegraphMcpHandlersForSession( }, artifact_build: async (request) => { + if (readOnly) { + throw new Error("artifact_build is disabled in read-only MCP mode."); + } const freshness = await checkMcpFreshness(); if (freshness.state === "stale") { const changed = freshness.changedFiles.length ? ` Changed files: ${freshness.changedFiles.join(", ")}.` : ""; @@ -656,9 +665,6 @@ function createCodegraphMcpHandlersForSession( `Cannot build artifacts from a stale MCP index; run refresh_index first. ${freshness.reason}.${changed}${omitted}`, ); } - if (readOnly) { - throw new Error("artifact_build is disabled in read-only MCP mode."); - } const outDir = request.outDir !== undefined ? await assertWritableDirectoryRealPathWithinRoot( diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index a24e30a1..5da20f60 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -454,14 +454,14 @@ describe("codegraph MCP handlers", () => { throw new Error("query_sqlite should reject stale SQLite rebuilds with an Error"); } expect(caught.message).toMatch(/SQLite artifact is stale/); - expect(caught.message).toMatch(/run refresh_index, then artifact_build before query_sqlite/); + expect(caught.message).toMatch(/refresh_index[\s\S]*artifact_build[\s\S]*query_sqlite/); expect(caught.message).toMatch(/changed byte count exceeds 16/); expect(caught.message).toMatch(/remove\.ts/); expect(caught.message.indexOf("refresh_index")).toBeLessThan(caught.message.indexOf("artifact_build")); expect(caught.message.indexOf("artifact_build")).toBeLessThan(caught.message.indexOf("query_sqlite")); }); - it("refuses stale configured SQLite artifacts in read-only mode", async () => { + it("guides read-only stale SQLite artifacts to rebuild with write access enabled", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); const outDir = path.join(root, "out"); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -469,11 +469,55 @@ describe("codegraph MCP handlers", () => { await buildHandlers.artifact_build({ outDir, sqlite: true }); await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); - const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, readOnly: true }); + let caught: unknown; + try { + await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + } catch (error) { + caught = error; + } - await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( - /SQLite artifact is stale[\s\S]*two\.ts/, - ); + expect(caught).toBeInstanceOf(Error); + if (!(caught instanceof Error)) { + throw new Error("query_sqlite should reject stale SQLite artifacts with an Error"); + } + expect(caught.message).toMatch(/SQLite artifact is stale/); + expect(caught.message).toMatch(/rebuild[\s\S]*write access enabled/i); + expect(caught.message).toMatch(/two\.ts/); + expect(caught.message).not.toMatch(/artifact_build before query_sqlite/i); + }); + + it("guides read-only stale SQLite rebuilds through refresh_index when the session is stale", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-readonly-stale-session-")); + const outDir = path.join(root, "out"); + const removedFile = path.join(root, "remove.ts"); + await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); + await fs.writeFile(removedFile, `export const payload = "${"x".repeat(64)}";\n`); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshBytes: 16 } }); + const buildHandlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + await fs.unlink(removedFile); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, readOnly: true, session }); + let caught: unknown; + try { + await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + if (!(caught instanceof Error)) { + throw new Error("query_sqlite should reject stale read-only rebuilds with an Error"); + } + expect(caught.message).toMatch(/SQLite artifact is stale/); + expect(caught.message).toMatch(/refresh_index/i); + expect(caught.message).toMatch(/rebuild[\s\S]*write access enabled/i); + expect(caught.message).toMatch(/changed byte count exceeds 16/); + expect(caught.message).toMatch(/remove\.ts/); + const writeAccessIndex = caught.message.toLowerCase().indexOf("write access enabled"); + expect(caught.message.indexOf("refresh_index")).toBeLessThan(writeAccessIndex); + expect(caught.message).not.toMatch(/artifact_build before query_sqlite/i); }); it("refuses older SQLite artifacts without freshness metadata in read-only mode", async () => { @@ -657,19 +701,24 @@ describe("codegraph MCP handlers", () => { ).resolves.toEqual(expect.objectContaining({ rows: [[1]] })); }); - it("disables artifact builds by default and in explicit read-only mode", async () => { + it("disables artifact builds in read-only mode before checking freshness", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-readonly-")); await fs.writeFile(path.join(root, "auth.ts"), "export function validateUser(id: number) { return id > 0; }\n"); + const backingSession = createAgentSession({ root }); + let freshnessChecks = 0; + const session: AgentSession = { + ...backingSession, + checkFreshness: async () => { + freshnessChecks += 1; + throw new Error("checkFreshness should not run before read-only artifact_build rejection"); + }, + }; - const defaultHandlers = createCodegraphMcpHandlers({ root }); - await expect(defaultHandlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true })).rejects.toThrow( - /read-only/i, - ); - - const readOnlyHandlers = createCodegraphMcpHandlers({ root, readOnly: true }); + const readOnlyHandlers = createCodegraphMcpHandlers({ root, readOnly: true, session }); await expect(readOnlyHandlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true })).rejects.toThrow( - /read-only/i, + /artifact_build is disabled in read-only MCP mode/i, ); + expect(freshnessChecks).toBe(0); }); it("rejects artifact paths outside the root", async () => { From ed24f518be09800bc50b659f5908043b2e4ed742 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 01:12:42 -0400 Subject: [PATCH 15/15] Avoid session scan for fresh SQLite artifacts --- src/mcp/server.ts | 2 +- tests/mcp-server.test.ts | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5d37d836..9df1e466 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -625,10 +625,10 @@ function createCodegraphMcpHandlersForSession( // only decides whether an automatic rebuild is safe. A fresh artifact is served even when // the in-memory session snapshot is stale, and an unsafe session refresh burst blocks the // automatic rebuild instead of silently rebuilding from stale in-memory state. - const sessionFreshness = await checkMcpFreshness(); let realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); let artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); if (artifactFreshness.state !== "fresh") { + const sessionFreshness = await checkMcpFreshness(); if (sessionFreshness.state === "stale") { throw new Error(formatSqliteFreshnessError(sessionFreshness)); } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 5da20f60..0bb167e5 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -624,23 +624,21 @@ describe("codegraph MCP handlers", () => { ); }); - it("serves a fresh SQLite artifact even when the session snapshot is stale", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-session-fresh-artifact-")); + it("serves a fresh SQLite artifact without checking session freshness", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-fresh-artifact-no-session-check-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); const outDir = path.join(root, "out"); const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); await buildHandlers.artifact_build({ outDir, sqlite: true }); const backingSession = createAgentSession({ root }); + let freshnessChecks = 0; const session: AgentSession = { ...backingSession, - checkFreshness: async () => ({ - state: "stale", - changedFiles: ["phantom.ts"], - changedFileCount: 1, - omittedChangedFileCount: 0, - reason: "forced stale for test", - }), + checkFreshness: async () => { + freshnessChecks += 1; + throw new Error("query_sqlite should inspect fresh SQLite artifact metadata before session freshness"); + }, }; const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, session }); @@ -648,6 +646,7 @@ describe("codegraph MCP handlers", () => { expect(result.rows.map((row) => normalizeSqlitePath(row[0])).some((file) => file.endsWith("one.ts"))).toBe(true); expect(result.freshness).toEqual({ state: "fresh" }); + expect(freshnessChecks).toBe(0); }); it("bounds query_sqlite bytes for MCP responses", async () => {