Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion codegraph-skill/codegraph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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 `number<TAB>line` formatting. Set `includeGraphContext: true` to opt into direct graph context capped at 100 importers, imports, and symbols each.
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.
Expand Down
6 changes: 4 additions & 2 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `number<TAB>line` 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.
Expand All @@ -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. `artifact_build` refuses to write outputs from a stale MCP index; run `refresh_index` first after large change bursts.
`get_file` reads live bytes from disk after path confinement. Its `content` field uses `lineFormat: "number-tab-line"`: no padding, decimal line number, tab, then source line; a file-ending newline produces a final numbered empty line. Set `includeGraphContext: true` to opt into freshness checking and direct graph context for indexed files, capped at 100 importers, imports, and symbols each.
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
Expand All @@ -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` 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.
- `get_file` reads are byte- and line-bounded with `maxBytes`, `offset`, and `limit`.
- SQLite responses are row- and byte-bounded.

## Installer
Expand Down Expand Up @@ -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.
Expand Down
144 changes: 132 additions & 12 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,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";
Expand Down Expand Up @@ -74,6 +76,101 @@ import {
} from "./security.js";

export type CodegraphMcpWarmupMode = "off" | "base" | "symbols";
type CodegraphMcpFileGraphContext = {
usedBy: string[];
imports: string[];
symbols: Array<{ name: string; kind: string; line: number }>;
};

const FILE_GRAPH_CONTEXT_LIMIT = DEFAULT_MCP_COLLECTION_LIMIT;

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>): 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)),
).slice(0, FILE_GRAPH_CONTEXT_LIMIT);
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;
}),
).slice(0, FILE_GRAPH_CONTEXT_LIMIT);
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))
.slice(0, FILE_GRAPH_CONTEXT_LIMIT);

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;
}
Comment thread
Copilot marked this conversation as resolved.
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;
Expand Down Expand Up @@ -130,8 +227,11 @@ export type CodegraphMcpHandlers = {
}) => Promise<CodegraphMcpFreshResult<AgentPacketResponse>>;
get_file: (request: {
file: string;
offset?: number | undefined;
limit?: number | undefined;
maxBytes?: number | undefined;
}) => Promise<CodegraphMcpFreshResult<{ file: string; text: string; truncated: boolean }>>;
includeGraphContext?: boolean | undefined;
}) => Promise<CodegraphMcpFreshResult<CodegraphMcpFileViewResponse>>;
get_symbol: (request: { handle: string }) => Promise<CodegraphMcpFreshResult<AgentExplanation["target"]>>;
goto: (request: { file: string; line: number; column: number }) => Promise<CodegraphMcpFreshResult<GoToResult>>;
refs: (
Expand Down Expand Up @@ -268,6 +368,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);
Expand Down Expand Up @@ -511,18 +615,31 @@ function createCodegraphMcpHandlersForSession(
),

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);
// A plain get_file request returns live bytes and must not trigger workspace-wide
// freshness or indexing work. Graph context is explicitly opt-in.
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 {
file: resolvedFile.displayPath,
text: read.text,
truncated: read.truncated,
freshness: { state: "fresh" },
};
const buildView = (graphContext?: CodegraphMcpFileGraphContext): CodegraphMcpFileViewResponse =>
buildFileView({
file: resolvedFile.displayPath,
text: read.text,
truncated: read.truncated,
offset,
limit: lineLimit,
...(graphContext ? { graphContext } : {}),
});
if (!request.includeGraphContext) {
return { ...buildView(), freshness: { state: "fresh" } as const };
}
return await withFreshness(async () => {
const projectFile = await resolveProjectFile(rootRealPath, root, request.file);
const snapshot = await session.loadProject({ symbolGraph: "skip" });
return buildView(buildFileGraphContext(snapshot, projectFile, relative));
});
},

get_symbol: async (request) =>
Expand Down Expand Up @@ -1023,7 +1140,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({
Expand Down
12 changes: 10 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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", default: false },
},
["file"],
),
},
Expand Down
Loading