diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 3993b734..035e0596 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -74,7 +74,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 `explore`, `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +Use MCP `explore`, `orient`, `search`, `get_file`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +Use `get_file` for bounded live file reads; its content uses exact `numberline` formatting and may include direct importers, imports, and symbols. 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. diff --git a/docs/coverage/js.md b/docs/coverage/js.md index 7187f8f7..72453f34 100644 --- a/docs/coverage/js.md +++ b/docs/coverage/js.md @@ -4,52 +4,52 @@ Source: `coverage/js/lcov.info` ## Summary -| Metric | Hit | Found | Coverage | -| --- | ---: | ---: | ---: | -| Lines | 19093 | 21175 | 90.17% | -| Functions | 3193 | 3399 | 93.94% | -| Branches | 14402 | 18543 | 77.67% | +| Metric | Hit | Found | Coverage | +| --------- | ----: | ----: | -------: | +| Lines | 19093 | 21175 | 90.17% | +| Functions | 3193 | 3399 | 93.94% | +| Branches | 14402 | 18543 | 77.67% | ## Least-covered Files -| File | Lines | Functions | Branches | -| --- | ---: | ---: | ---: | -| `src/cli/navigation.ts` | 44.64% | 50.00% | 46.88% | -| `src/languages/definitions/htmlStub.ts` | 50.00% | 50.00% | n/a | -| `src/cli/index.ts` | 54.29% | 33.33% | 48.84% | -| `src/languages/definitions/javascript.ts` | 62.50% | 83.33% | 55.56% | -| `src/util/projectFiles/parsers.ts` | 64.29% | 76.92% | 41.67% | -| `src/languages/definitions/typescript.ts` | 64.71% | 81.82% | 43.75% | -| `src/indexer/parsed-cache.ts` | 64.71% | 100.00% | 58.33% | -| `src/indexer.ts` | 66.67% | 66.67% | 100.00% | -| `src/languages/definitions/css.ts` | 66.67% | 0.00% | n/a | -| `src/languages/definitions/html.ts` | 66.67% | 0.00% | n/a | -| `src/languages/definitions/less.ts` | 66.67% | 0.00% | n/a | -| `src/languages/definitions/sql.ts` | 66.67% | 0.00% | n/a | -| `src/languages/definitions/svelte.ts` | 66.67% | 0.00% | n/a | -| `src/languages/definitions/vue.ts` | 66.67% | 0.00% | n/a | -| `src/drift/report.ts` | 67.44% | 100.00% | 46.58% | -| `src/cli/graph.ts` | 67.55% | 63.16% | 43.11% | -| `src/agent/handles.ts` | 68.00% | 91.67% | 43.18% | -| `src/mcp/server.ts` | 68.49% | 78.26% | 54.60% | -| `src/cli/context.ts` | 69.32% | 66.67% | 65.04% | -| `src/impact/testPatterns.ts` | 69.70% | 63.64% | 61.54% | +| File | Lines | Functions | Branches | +| ----------------------------------------- | -----: | --------: | -------: | +| `src/cli/navigation.ts` | 44.64% | 50.00% | 46.88% | +| `src/languages/definitions/htmlStub.ts` | 50.00% | 50.00% | n/a | +| `src/cli/index.ts` | 54.29% | 33.33% | 48.84% | +| `src/languages/definitions/javascript.ts` | 62.50% | 83.33% | 55.56% | +| `src/util/projectFiles/parsers.ts` | 64.29% | 76.92% | 41.67% | +| `src/languages/definitions/typescript.ts` | 64.71% | 81.82% | 43.75% | +| `src/indexer/parsed-cache.ts` | 64.71% | 100.00% | 58.33% | +| `src/indexer.ts` | 66.67% | 66.67% | 100.00% | +| `src/languages/definitions/css.ts` | 66.67% | 0.00% | n/a | +| `src/languages/definitions/html.ts` | 66.67% | 0.00% | n/a | +| `src/languages/definitions/less.ts` | 66.67% | 0.00% | n/a | +| `src/languages/definitions/sql.ts` | 66.67% | 0.00% | n/a | +| `src/languages/definitions/svelte.ts` | 66.67% | 0.00% | n/a | +| `src/languages/definitions/vue.ts` | 66.67% | 0.00% | n/a | +| `src/drift/report.ts` | 67.44% | 100.00% | 46.58% | +| `src/cli/graph.ts` | 67.55% | 63.16% | 43.11% | +| `src/agent/handles.ts` | 68.00% | 91.67% | 43.18% | +| `src/mcp/server.ts` | 68.49% | 78.26% | 54.60% | +| `src/cli/context.ts` | 69.32% | 66.67% | 65.04% | +| `src/impact/testPatterns.ts` | 69.70% | 63.64% | 61.54% | ## Type-Only Or Re-Export Files These files have line records but no function or branch records, so they are tracked outside the runtime ranking. -| File | Lines | Functions | Branches | -| --- | ---: | ---: | ---: | -| `src/languages/definitions/jsFamily.ts` | 100.00% | n/a | n/a | -| `src/duplicate-keywords.ts` | 100.00% | n/a | n/a | -| `src/impact/types.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/adoc.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/astro.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/hbs.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/markdown.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/mdx.ts` | 100.00% | n/a | n/a | -| `src/languages/definitions/rst.ts` | 100.00% | n/a | n/a | -| `src/util/graphOnlyExtensions.ts` | 100.00% | n/a | n/a | +| File | Lines | Functions | Branches | +| --------------------------------------- | ------: | --------: | -------: | +| `src/languages/definitions/jsFamily.ts` | 100.00% | n/a | n/a | +| `src/duplicate-keywords.ts` | 100.00% | n/a | n/a | +| `src/impact/types.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/adoc.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/astro.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/hbs.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/markdown.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/mdx.ts` | 100.00% | n/a | n/a | +| `src/languages/definitions/rst.ts` | 100.00% | n/a | n/a | +| `src/util/graphOnlyExtensions.ts` | 100.00% | n/a | n/a | Generated from LCOV by `node ./scripts/coverage-markdown.mjs`. diff --git a/docs/coverage/native.md b/docs/coverage/native.md index 8aed2743..998f0e35 100644 --- a/docs/coverage/native.md +++ b/docs/coverage/native.md @@ -4,23 +4,23 @@ Source: `coverage/native/lcov.info` ## Summary -| Metric | Hit | Found | Coverage | -| --- | ---: | ---: | ---: | -| Lines | 604 | 626 | 96.49% | -| Functions | 45 | 51 | 88.24% | -| Branches | 0 | 0 | n/a | +| Metric | Hit | Found | Coverage | +| --------- | --: | ----: | -------: | +| Lines | 604 | 626 | 96.49% | +| Functions | 45 | 51 | 88.24% | +| Branches | 0 | 0 | n/a | ## Least-covered Files -| File | Lines | Functions | Branches | -| --- | ---: | ---: | ---: | -| `packages/codegraph-native/src/lib.rs` | 89.47% | 70.00% | n/a | -| `packages/codegraph-native/src/parser_pool.rs` | 92.86% | 66.67% | n/a | -| `packages/codegraph-native/src/duplicate_tokens.rs` | 93.20% | 100.00% | n/a | -| `packages/codegraph-native/src/projection.rs` | 97.50% | 100.00% | n/a | -| `packages/codegraph-native/src/query.rs` | 99.31% | 92.86% | n/a | -| `packages/codegraph-native/src/duplicate_keywords.rs` | 100.00% | 100.00% | n/a | -| `packages/codegraph-native/src/languages.rs` | 100.00% | 100.00% | n/a | -| `packages/codegraph-native/src/types.rs` | 100.00% | 100.00% | n/a | +| File | Lines | Functions | Branches | +| ----------------------------------------------------- | ------: | --------: | -------: | +| `packages/codegraph-native/src/lib.rs` | 89.47% | 70.00% | n/a | +| `packages/codegraph-native/src/parser_pool.rs` | 92.86% | 66.67% | n/a | +| `packages/codegraph-native/src/duplicate_tokens.rs` | 93.20% | 100.00% | n/a | +| `packages/codegraph-native/src/projection.rs` | 97.50% | 100.00% | n/a | +| `packages/codegraph-native/src/query.rs` | 99.31% | 92.86% | n/a | +| `packages/codegraph-native/src/duplicate_keywords.rs` | 100.00% | 100.00% | n/a | +| `packages/codegraph-native/src/languages.rs` | 100.00% | 100.00% | n/a | +| `packages/codegraph-native/src/types.rs` | 100.00% | 100.00% | n/a | Generated from LCOV by `node ./scripts/coverage-markdown.mjs`. diff --git a/docs/mcp.md b/docs/mcp.md index 65936119..10e31759 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -39,7 +39,7 @@ The server exposes the same bounded primitives as the CLI and library session la - `orient`: compact first-turn repo context. - `packet_get`: bounded evidence packet by file path, symbol name, SQL object name, or stable target. - `search`: deterministic ranked search across paths, symbols, chunks, SQL objects, and graph context. -- `get_file`: bounded project file read. +- `get_file`: bounded project file read with `offset`/`limit` line pagination, exact `numberline` content, and optional direct graph context. - `get_symbol`: resolve a stable search or explain handle. - `goto`: definition lookup by file position. - `refs`: references by handle or file position. @@ -52,6 +52,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. +`get_file` reads live bytes from disk after path confinement, then adds freshness metadata and graph context when the file is indexed. Its `content` field uses `lineFormat: "number-tab-line"`: no padding, decimal line number, tab, then source line. 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 @@ -61,6 +62,7 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - Tools are read-only by default. - `artifact_build` requires `--allow-build`. - `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. +- `get_file` reads are byte- and line-bounded with `maxBytes`, `offset`, and `limit`. - SQLite responses are row- and byte-bounded. ## Installer @@ -208,7 +210,7 @@ When Codegraph MCP tools are available to an agent: 1. Start with `explore` for a broad question. 2. Use `orient` when you need a compact first-turn map rather than a question answer. -3. Use `search` to find anchors and `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. +3. Use `search` to find anchors and `get_file`, `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` 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; rebuild the artifact when it reports stale state. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d4de0a1d..d26ff3d7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -39,9 +39,11 @@ import { } from "./sqliteGuard.js"; import { DEFAULT_FILE_BYTES, + DEFAULT_FILE_LINES, DEFAULT_MCP_COLLECTION_LIMIT, listCodegraphMcpTools, MAX_FILE_BYTES, + MAX_FILE_LINES, MAX_MCP_COLLECTION_LIMIT, MCP_TOOLS, } from "./tools.js"; @@ -73,6 +75,100 @@ import { } from "./security.js"; export type CodegraphMcpWarmupMode = "off" | "base" | "symbols"; +type CodegraphMcpFileGraphContext = { + usedBy: string[]; + imports: string[]; + symbols: Array<{ name: string; kind: string; line: number }>; +}; + +type CodegraphMcpFileViewResponse = { + schemaVersion: 1; + file: string; + offset: number; + limit: number; + totalLines: number; + content: string; + lineFormat: "number-tab-line"; + text: string; + truncated: boolean; + graphContext?: CodegraphMcpFileGraphContext; + page?: { nextOffset?: number }; +}; + +function splitFileLines(text: string): string[] { + return text.split("\n"); +} + +function formatNumberedLines(lines: readonly string[], offset: number, limit: number): string { + const start = Math.max(0, offset - 1); + return lines + .slice(start, start + limit) + .map((line, index) => `${start + index + 1}\t${line}`) + .join("\n"); +} + +function uniqueSorted(values: Iterable): string[] { + return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right)); +} + +function buildFileGraphContext( + snapshot: AgentProjectSnapshot, + file: string, + relative: (file: string) => string, +): CodegraphMcpFileGraphContext | undefined { + const moduleIndex = snapshot.index.byFile.get(file); + if (!moduleIndex) return undefined; + + const usedBy = uniqueSorted( + snapshot.fileGraph.edges + .filter((edge) => edge.to.type === "file" && edge.to.path === file) + .map((edge) => relative(edge.from)), + ); + const imports = uniqueSorted( + moduleIndex.imports.map((importBinding) => { + const resolved = importBinding.resolved; + if (typeof resolved === "string") return relative(resolved); + if (resolved) return resolved.external; + return importBinding.from; + }), + ); + const symbols = moduleIndex.locals + .map((symbol) => ({ name: symbol.localName, kind: symbol.kind, line: symbol.range.start.line })) + .sort((left, right) => left.line - right.line || left.name.localeCompare(right.name)); + + return { usedBy, imports, symbols }; +} + +function buildFileView(args: { + file: string; + text: string; + truncated: boolean; + offset: number; + limit: number; + graphContext?: CodegraphMcpFileGraphContext | undefined; +}): CodegraphMcpFileViewResponse { + const lines = splitFileLines(args.text); + const start = Math.max(0, args.offset - 1); + let nextOffset: number | undefined; + if (start + args.limit < lines.length) { + nextOffset = start + args.limit + 1; + } else if (args.truncated) { + nextOffset = lines.length + 1; + } + return { + schemaVersion: 1, + file: args.file, + offset: args.offset, + limit: args.limit, + totalLines: lines.length, + content: formatNumberedLines(lines, args.offset, args.limit), + lineFormat: "number-tab-line", + text: args.text, + truncated: args.truncated, + ...(args.graphContext ? { graphContext: args.graphContext } : {}), + ...(nextOffset ? { page: { nextOffset } } : {}), + }; +} export type CodegraphMcpHandlerOptions = { root: string; @@ -129,8 +225,11 @@ export type CodegraphMcpHandlers = { }) => Promise>; get_file: (request: { file: string; + offset?: number | undefined; + limit?: number | undefined; maxBytes?: number | undefined; - }) => Promise>; + includeGraphContext?: boolean | undefined; + }) => Promise>; get_symbol: (request: { handle: string }) => Promise>; goto: (request: { file: string; line: number; column: number }) => Promise>; refs: ( @@ -266,6 +365,10 @@ function createCodegraphMcpHandlersForSession( if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; + const boundedPositiveLimit = (limit: number | undefined, fallback: number, max: number): number => { + if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; + return Math.min(max, Math.max(1, Math.floor(limit))); + }; const staleFreshness = (files: readonly string[], reason: string): AgentFreshnessResult => { const changedFiles = [...files].sort(); const boundedChangedFiles = changedFiles.slice(0, MAX_MCP_FRESHNESS_CHANGED_FILES); @@ -500,14 +603,26 @@ 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 rootRealPath = await realRoot; + const resolvedFile = await resolveReadableFile(rootRealPath, root, request.file); + const maxBytes = boundedPositiveLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); + const offset = boundedPositiveLimit(request.offset, 1, Number.MAX_SAFE_INTEGER); + const lineLimit = boundedPositiveLimit(request.limit, DEFAULT_FILE_LINES, MAX_FILE_LINES); const read = await readFilePrefix(resolvedFile.realPath, maxBytes); - return { + let graphContext: CodegraphMcpFileGraphContext | undefined; + if (request.includeGraphContext ?? true) { + const projectFile = await resolveProjectFile(rootRealPath, root, request.file); + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + graphContext = buildFileGraphContext(snapshot, projectFile, relative); + } + return buildFileView({ file: resolvedFile.displayPath, text: read.text, truncated: read.truncated, - }; + offset, + limit: lineLimit, + ...(graphContext ? { graphContext } : {}), + }); }), get_symbol: async (request) => @@ -994,7 +1109,10 @@ const packetGetSchema = z.object({ const getFileSchema = z.object({ file: z.string(), + offset: z.number().int().positive().optional(), + limit: z.number().int().positive().max(MAX_FILE_LINES).optional(), maxBytes: z.number().int().positive().optional(), + includeGraphContext: z.boolean().optional(), }); const handleSchema = z.object({ diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 63b6d986..2f045854 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -4,6 +4,8 @@ import { DEFAULT_SQLITE_ROW_LIMIT, MAX_SQLITE_ROW_LIMIT } from "./sqliteGuard.js export const DEFAULT_FILE_BYTES = 80_000; export const MAX_FILE_BYTES = 500_000; +export const DEFAULT_FILE_LINES = 2000; +export const MAX_FILE_LINES = 10_000; export const DEFAULT_MCP_COLLECTION_LIMIT = 100; export const MAX_MCP_COLLECTION_LIMIT = 500; @@ -84,9 +86,15 @@ export const MCP_TOOLS: Tool[] = [ }, { name: "get_file", - description: "Read a bounded project file by relative path.", + description: "Read a bounded project file by relative path with line pagination and optional graph context.", inputSchema: objectSchema( - { file: stringProperty, maxBytes: { type: "integer", minimum: 1, maximum: MAX_FILE_BYTES } }, + { + file: stringProperty, + offset: { type: "integer", minimum: 1, default: 1 }, + limit: { type: "integer", minimum: 1, maximum: MAX_FILE_LINES, default: DEFAULT_FILE_LINES }, + maxBytes: { type: "integer", minimum: 1, maximum: MAX_FILE_BYTES }, + includeGraphContext: { type: "boolean" }, + }, ["file"], ), }, diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 50fc7511..198768b9 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -675,6 +675,45 @@ describe("codegraph MCP handlers", () => { }); }); + it("returns numbered get_file content with stable line pagination", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-file-lines-")); + await fs.writeFile(path.join(root, "notes.txt"), "one\ntwo\nthree\n", "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); + + const result = await handlers.get_file({ file: "notes.txt", offset: 2, limit: 2, includeGraphContext: false }); + + expect(result).toMatchObject({ + schemaVersion: 1, + file: "notes.txt", + offset: 2, + limit: 2, + totalLines: 4, + content: "2\ttwo\n3\tthree", + lineFormat: "number-tab-line", + page: { nextOffset: 4 }, + }); + }); + + it("includes direct graph context for indexed source files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-file-graph-")); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.writeFile(path.join(root, "src", "auth.ts"), "export function validateUser() { return true; }\n", "utf8"); + await fs.writeFile( + path.join(root, "src", "server.ts"), + "import { validateUser } from './auth';\nexport const ok = validateUser();\n", + "utf8", + ); + const handlers = createCodegraphMcpHandlers({ root }); + + const result = await handlers.get_file({ file: "src/auth.ts", limit: 1 }); + + expect(result.content).toBe("1\texport function validateUser() { return true; }"); + expect(result.graphContext?.usedBy).toContain("src/server.ts"); + expect(result.graphContext?.symbols).toContainEqual( + expect.objectContaining({ name: "validateUser", kind: "function", line: 1 }), + ); + }); + it("does not split multi-byte UTF-8 characters in bounded get_file reads", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-file-utf8-bound-")); await fs.writeFile(path.join(root, "unicode.txt"), "abc😀def", "utf8"); @@ -987,9 +1026,12 @@ describe("codegraph MCP handlers", () => { await fs.writeFile(filePath, "export function readAfter() { return 'new bytes'; }\n", "utf8"); const read = await handlers.get_file({ file: "auth.ts" }); - expect(read).toEqual({ + expect(read).toMatchObject({ + schemaVersion: 1, file: "auth.ts", text: "export function readAfter() { return 'new bytes'; }\n", + content: "1\texport function readAfter() { return 'new bytes'; }\n2\t", + lineFormat: "number-tab-line", truncated: false, freshness: { state: "refreshed", changedFiles: ["auth.ts"] }, });