Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/library-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ const result = await queryGraphSqliteRaw(
console.log(result.columns, result.rows);
```

`queryGraphSqliteRaw()` is intentionally read-only. It accepts result-producing statements such as `SELECT` and `PRAGMA` and rejects mutating SQL. Pass `{ maxRows }` to bound raw result rows.
`queryGraphSqliteRaw()` is intentionally read-only. It accepts result-producing statements such as `SELECT` and `PRAGMA` and rejects mutating SQL. Its defaults bound rows, cells, and response bytes, and callers can further tighten `{ maxRows, maxBytes, maxCellBytes, deadlineMs }`. The 10-second default execution budget (`deadlineMs`) is enforced by running the query in a dedicated worker thread that is force-terminated on expiry, so it interrupts a query even mid-execution; in a degraded install where that worker asset cannot be located, the query instead runs in-process under a weaker per-row check that cannot interrupt a single blocking native call (a logged, one-time-per-process condition).

## SQL artifact facts

Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ An MCP `explore` request whose entire query resolves to an indexed project-relat
- Tool calls do not accept per-request root overrides.
- 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.
- `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. Each query has a 10-second execution deadline.
- `get_file` rejects raw reads and structural text-config summaries over the 16 MiB input limit. Accepted reads use separate output-page bounds from `maxBytes`, `offset`, and `limit`; binary input is rejected, and sensitive formats require `allowSensitive: true` for raw values.
- SQLite responses are row- and byte-bounded.

Expand Down
11 changes: 7 additions & 4 deletions src/agent/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from "node:path";
import type { AnalysisSummary } from "../analysisSummary.js";
import { getReverseDependencies, getShortestPath, type DependencyNode } from "../graphs/traversal.js";
import { defNodeId } from "../graphs/symbol-graph.js";
import { boundList } from "../presentation/bounds.js";
import type { BuildOptions } from "../indexer/types.js";
import { listCandidateTestFiles } from "../impact/context.js";
import { fileIdentityKey, normalizePath, toProjectDisplayPath } from "../util/paths.js";
Expand Down Expand Up @@ -483,11 +484,12 @@ function collectBlastRadius(
const summaries: AgentExploreBlastRadiusSummary[] = [];
for (const file of anchorFiles.slice(0, entryLimit)) {
const dependencies = getReverseDependencies(snapshot.fileGraph, file, { limit: dependencyLimit + 1, depth: 2 });
const visible = dependencies.slice(0, dependencyLimit).map((dependency) => formatDependency(snapshot, dependency));
const boundedDependencies = boundList(dependencies, dependencyLimit);
const visible = boundedDependencies.items.map((dependency) => formatDependency(snapshot, dependency));
summaries.push({
file: toProjectDisplayPath(snapshot.root, file),
reverseDependencies: visible,
omittedLowerBound: Math.max(0, dependencies.length - dependencyLimit),
omittedLowerBound: boundedDependencies.omitted,
});
}
return summaries;
Expand All @@ -511,9 +513,10 @@ function collectCandidateTests(
maxCandidates: snapshot.index.byFile.size,
projectRoot: snapshot.root,
});
const boundedCandidates = boundList(candidates, limit);
return {
items: candidates.slice(0, limit).map((candidate) => toProjectDisplayPath(snapshot.root, candidate.file)),
omittedCount: Math.max(0, candidates.length - limit),
items: boundedCandidates.items.map((candidate) => toProjectDisplayPath(snapshot.root, candidate.file)),
omittedCount: boundedCandidates.omitted,
};
}
function collectFollowUps(
Expand Down
20 changes: 12 additions & 8 deletions src/indexer/type-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SymbolEdge, SymbolGraph, SymbolNode } from "../graphs/symbol-graph.js";
import { boundList } from "../presentation/bounds.js";
import { resolveSymbolId } from "./symbols.js";
import type { ProjectIndex } from "./types.js";

Expand Down Expand Up @@ -106,12 +107,13 @@ export function findTypeHierarchy(
}

relations.sort((left, right) => compareRelations(graph, left, right));
const boundedRelations = boundList(relations, limit);
return {
status: "ok",
targetId,
direction,
relations: relations.slice(0, limit),
omitted: Math.max(0, relations.length - limit),
relations: boundedRelations.items,
omitted: boundedRelations.omitted,
limit,
};
}
Expand All @@ -137,11 +139,12 @@ export function findImplementations(
};
}
const matches = collectTypeImplementations(graph, hierarchy, targetId, rootRelations);
const boundedMatches = boundList(matches, limit);
return {
status: "ok",
targetId,
implementations: matches.slice(0, limit),
omitted: Math.max(0, matches.length - limit),
implementations: boundedMatches.items,
omitted: boundedMatches.omitted,
ambiguous: 0,
unresolved: [],
limit,
Expand Down Expand Up @@ -229,14 +232,15 @@ export function findImplementations(
}
const sortedUnresolved = unresolved.sort((left, right) => left.symbolId.localeCompare(right.symbolId));
const matches = [...memberMatches.values()].sort((left, right) => compareImplementationMatches(graph, left, right));
const truncated = Math.max(0, matches.length - limit);
const boundedMatches = boundList(matches, limit);
const boundedUnresolved = boundList(sortedUnresolved, limit);
return {
status: "ok",
targetId,
implementations: matches.slice(0, limit),
omitted: truncated + ambiguous,
implementations: boundedMatches.items,
omitted: boundedMatches.omitted + ambiguous,
ambiguous,
unresolved: sortedUnresolved.slice(0, limit),
unresolved: boundedUnresolved.items,
limit,
};
}
Expand Down
6 changes: 4 additions & 2 deletions src/indexer/workspace-symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ensureParsedContext } from "./parse-context.js";
import { getCachedScope } from "./navigation-references.js";
import { resolveImported } from "./navigation-resolve.js";
import { defNodeId } from "../graphs/symbol-graph.js";
import { boundList } from "../presentation/bounds.js";
import type { ImportBinding, ProjectIndex, SymbolDef, SymbolKind } from "./types.js";

export const DEFAULT_WORKSPACE_SYMBOL_LIMIT = 50;
Expand Down Expand Up @@ -100,12 +101,13 @@ export async function workspaceSymbols(
}

ranked.sort(compareRankedCandidates);
const symbols = ranked.slice(0, limit).map(({ candidate }) => candidate);
const boundedRanked = boundList(ranked, limit);
const symbols = boundedRanked.items.map(({ candidate }) => candidate);
return {
query,
symbols,
totalCandidates: ranked.length,
omitted: Math.max(0, ranked.length - symbols.length),
omitted: boundedRanked.omitted,
limit,
omittedImports,
importScanFailures,
Expand Down
69 changes: 41 additions & 28 deletions src/mcp/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,39 +28,52 @@ export async function readJsonRequestBody(
return { status: "too_large" };
}

const chunks: Buffer[] = [];
let bytes = 0;
let timedOut = false;
const deadline = setTimeout(() => {
timedOut = true;
request.destroy();
}, timeoutMs);
deadline.unref?.();
try {
for await (const chunk of request) {
return await new Promise<ParsedJsonBody>((resolve) => {
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
const deadline = setTimeout(() => settle({ status: "timeout" }, true), timeoutMs);
deadline.unref?.();

const cleanup = (): void => {
clearTimeout(deadline);
request.off("data", onData);
request.off("end", onEnd);
request.off("error", onFailure);
request.off("aborted", onFailure);
};
const settle = (result: ParsedJsonBody, drain: boolean): void => {
if (settled) return;
settled = true;
cleanup();
if (drain) request.resume();
resolve(result);
};
const onData = (chunk: string | Buffer): void => {
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
bytes += buffer.byteLength;
if (bytes > maxBytes) {
request.resume();
return { status: "too_large" };
settle({ status: "too_large" }, true);
return;
}
chunks.push(buffer);
}
} catch {
if (timedOut) return { status: "timeout" };
return { status: "invalid_json" };
} finally {
clearTimeout(deadline);
}
if (timedOut) return { status: "timeout" };

const rawBody = Buffer.concat(chunks).toString("utf8");
try {
const body: unknown = rawBody.length ? JSON.parse(rawBody) : null;
return { status: "ok", body };
} catch {
return { status: "invalid_json" };
}
};
const onEnd = (): void => {
const rawBody = Buffer.concat(chunks).toString("utf8");
try {
const body: unknown = rawBody.length ? JSON.parse(rawBody) : null;
settle({ status: "ok", body }, false);
} catch {
settle({ status: "invalid_json" }, false);
}
};
const onFailure = (): void => settle({ status: "invalid_json" }, true);

request.on("data", onData);
request.once("end", onEnd);
request.once("error", onFailure);
request.once("aborted", onFailure);
});
}

export function emptyAllowedHostHeaderRules(): AllowedHostHeaderRules {
Expand Down
21 changes: 18 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import { DEFAULT_WORKSPACE_SYMBOL_LIMIT, MAX_WORKSPACE_SYMBOL_LIMIT } from "../i
import type { BuildOptions, FindReferencesResult, GoToResult } from "../indexer/types.js";
import {
assertMcpSqliteQueryResourceBounded,
boundRawSqlResult,
DEFAULT_SQLITE_BYTE_LIMIT,
normalizeSqliteRowLimit,
} from "./sqliteGuard.js";
Expand Down Expand Up @@ -980,8 +979,9 @@ function createCodegraphMcpHandlersForSession(
}
const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], {
maxRows: normalizeSqliteRowLimit(request.limit),
maxBytes: DEFAULT_SQLITE_BYTE_LIMIT,
});
return { ...boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT), freshness: artifactFreshness };
return { ...result, truncated: Boolean(result.truncated), freshness: artifactFreshness };
},

refresh_index: async (request) => {
Expand Down Expand Up @@ -1377,9 +1377,16 @@ async function handleLegacyMcpHttpPost(
openSseStreams: 0,
};
sessionRef.current = session;
// The SDK transport reports every per-request validation rejection through onerror
// too (bad Accept header, wrong Content-Type, malformed JSON, an unsupported
// protocol version, ...) — each of those already answered its own request with a
// 4xx response and left the transport fully usable. Deleting the session here would
// tear down an otherwise healthy session over one malformed follow-up request. Only
// onclose reflects the transport actually shutting down (an explicit DELETE, an
// eviction we triggered, or a real fatal failure), so session teardown is driven by
// onclose alone; onerror only logs.
transport.onerror = (error) => {
console.error(`[codegraph] MCP HTTP session transport error: ${error.message}`);
if (initializedSessionId !== undefined) void sessionStore.delete(initializedSessionId);
};
transport.onclose = () => {
if (initializedSessionId !== undefined) void sessionStore.delete(initializedSessionId);
Expand All @@ -1388,6 +1395,14 @@ async function handleLegacyMcpHttpPost(
try {
await protocolServer.connect(transport);
await handleLegacyMcpSessionRequest(session, request, response, body);
if (initializedSessionId === undefined) {
// The transport answered a pre-session 4xx (invalid Accept header, wrong
// Content-Type, malformed JSON, ...) without throwing and without ever reaching
// onsessioninitialized, so nothing else releases this capacity reservation or
// closes this ad hoc protocol server/transport pair.
releaseCapacityReservation();
await closeMcpSession(session);
}
} catch (error) {
if (initializedSessionId !== undefined) {
await sessionStore.delete(initializedSessionId);
Expand Down
101 changes: 92 additions & 9 deletions src/sqlite/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,108 @@ import {
MAX_SQLITE_ROW_LIMIT,
normalizeSqliteRowLimit,
} from "./rowBounds.js";
import {
resolveRawSqlQueryWorkerPath,
runRawSqlQueryInWorker,
SqliteQueryDeadlineExceededError,
} from "./rawQueryWorkerPool.js";

export { queryGraphSqlite } from "./canned-query.js";
export { SqliteQueryDeadlineExceededError };

/** Hard wall-clock budget for a single raw `query_sqlite` execution — see the caveat on
* `queryGraphSqliteRaw` about when this is actually enforceable. */
export const DEFAULT_SQLITE_QUERY_DEADLINE_MS = 10_000;

export type QueryGraphSqliteRawOptions = {
maxRows?: number | undefined;
maxBytes?: number | undefined;
maxCellBytes?: number | undefined;
deadlineMs?: number | undefined;
};

let loggedInProcessDeadlineFallback = false;

/**
* Runs a bounded read-only raw SQL query.
*
* Preferred path: the query executes in a dedicated worker thread with a hard
* `deadlineMs` budget (`rawQueryWorkerPool.ts`). On expiry the worker thread is
* terminated outright, which stops the query even while it is blocked inside a single
* synchronous `DatabaseSync` call — a slow non-recursive statement (large join,
* `ORDER BY random()`, a recursive CTE, ...) cannot hold the deadline hostage.
*
* Degraded fallback: if the compiled worker asset cannot be located (a corrupted or
* partial install — the normal build/publish/standalone pipelines all ship it), the
* query instead runs in-process under a *per-row* elapsed-time budget. `node:sqlite`'s
* `DatabaseSync` exposes no interrupt/cancellation API, so once execution is inside a
* single synchronous native call there is nothing in-process that can preempt it —
* true enforcement genuinely requires the separate worker thread this fallback exists
* because it could not find. The per-row check is therefore strictly weaker, not just a
* smaller budget: it is only evaluated between rows the native iterator has already
* produced, so a statement that is slow to produce its very first row (a full scan
* before any match, an aggregate over a large recursive CTE, ...) blocks for its full
* cost before the deadline is ever checked. This fallback exists to keep the common
* case usable in a degraded install, not as a substitute for the worker deadline; a
* warning is logged once per process when it activates so a degraded install is
* observable rather than silently under-enforcing its documented time budget.
*/
export async function queryGraphSqliteRaw(
outputPath: string,
sql: string,
params: Array<string | number | null> = [],
options?: QueryGraphSqliteRawOptions,
): Promise<RawSqlResult> {
const maxRows = normalizeSqliteRowLimit(options?.maxRows ?? MAX_SQLITE_ROW_LIMIT);
const maxBytes = options?.maxBytes ?? DEFAULT_SQLITE_BYTE_LIMIT;
const maxCellBytes = options?.maxCellBytes ?? MAX_SQLITE_CELL_BYTES;
const deadlineMs = options?.deadlineMs ?? DEFAULT_SQLITE_QUERY_DEADLINE_MS;

try {
resolveRawSqlQueryWorkerPath();
} catch {
if (!loggedInProcessDeadlineFallback) {
loggedInProcessDeadlineFallback = true;
console.error(
"[codegraph] Raw SQLite query worker asset is unavailable; falling back to an in-process " +
"execution deadline that is only checked between produced rows and cannot interrupt a " +
"single blocking native call. Reinstall to restore the enforced worker-thread deadline.",
);
}
return await queryGraphSqliteRawInProcessBounded(outputPath, sql, params, {
maxRows,
maxBytes,
maxCellBytes,
deadlineMs,
});
}

return await runRawSqlQueryInWorker({ outputPath, sql, params, maxRows, maxBytes, maxCellBytes }, deadlineMs);
}

/** Degraded fallback for `queryGraphSqliteRaw` — see its doc comment for the enforcement
* caveat this path cannot avoid. */
async function queryGraphSqliteRawInProcessBounded(
outputPath: string,
sql: string,
params: Array<string | number | null>,
bounds: { maxRows: number; maxBytes: number; maxCellBytes: number; deadlineMs: number },
): Promise<RawSqlResult> {
return await withReadOnlySqliteDatabase(outputPath, (db) => {
try {
const stmt = db.prepare(sql);
assertReadOnlyQueryStatement(stmt);
const columns = stmt.columns().map((col) => col.name);
const maxRows = normalizeSqliteRowLimit(options?.maxRows ?? MAX_SQLITE_ROW_LIMIT);
const maxBytes = options?.maxBytes ?? DEFAULT_SQLITE_BYTE_LIMIT;
const maxCellBytes = options?.maxCellBytes ?? MAX_SQLITE_CELL_BYTES;

// Always stream via iterate so per-cell and cumulative budgets apply before append.
return collectBoundedRawSqlRows(columns, stmt.raw().iterate(params) as Iterable<Array<unknown>>, {
maxRows,
maxBytes,
maxCellBytes,
const deadlineAt = Date.now() + bounds.deadlineMs;
const rows = withPerRowDeadline(
stmt.raw().iterate(params) as Iterable<Array<unknown>>,
deadlineAt,
bounds.deadlineMs,
);
return collectBoundedRawSqlRows(columns, rows, {
maxRows: bounds.maxRows,
maxBytes: bounds.maxBytes,
maxCellBytes: bounds.maxCellBytes,
});
} catch (error) {
if (isReadOnlySqliteError(error)) {
Expand All @@ -46,3 +119,13 @@ export async function queryGraphSqliteRaw(
}
});
}

/** Throws once the wall-clock deadline has passed between two already-produced rows.
* See the fallback caveat on `queryGraphSqliteRaw`: a statement slow to produce its
* first row is not bounded here — only slow-*between*-rows iteration is caught. */
function* withPerRowDeadline<T>(rows: Iterable<T>, deadlineAt: number, deadlineMs: number): Generator<T> {
for (const row of rows) {
if (Date.now() > deadlineAt) throw new SqliteQueryDeadlineExceededError(deadlineMs);
yield row;
}
}
Loading