pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
1,203 lines • 60.1 kB
JavaScript
#!/usr/bin/env node
/**
* pi-lens MCP server — exposes pi-lens's analysis to any MCP client (Claude Code).
*
* Transport: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio framing),
* hand-rolled — no SDK dependency, so pi's `npm install --omit=dev` of the
* extension is byte-for-byte unchanged (pi never runs this server; only an MCP
* client does). The protocol surface a tools-only server needs is tiny and
* stable: `initialize`, `tools/list`, `tools/call` (+ `ping`).
*
* The tools route to the host-neutral facade (clients/mcp/analyze.ts) and the
* same dispatch/LSP/latency machinery pi-lens runs inside pi — which is what
* makes a *real review loop* possible: an MCP client observes a commit's real
* behavioral + perf impact first-hand, in the same latency.log schema, rather
* than inferring it from pasted logs.
*
* stdout carries ONLY JSON-RPC. Everything diagnostic goes to stderr — and we
* reroute console.log → stderr defensively so no transitively-loaded module can
* corrupt the message stream.
*/
import * as fs from "node:fs";
import * as net from "node:net";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { AstGrepClient } from "../clients/ast-grep-client.js";
import { CacheManager } from "../clients/cache-manager.js";
import { computeBuildStamp, STALE_SERVED_BY_FRESH, STALE_WARN_ONLY, StalenessGate, stalenessCheckEnabled, } from "./build-staleness.js";
import { analyzeFile, analyzeFileFresh, createMcpHost, diagnosticStats, ensureLspConfig, ipcPathForCwd, lspStatus, moduleReport, projectScan, readEnclosing, readSymbol, recentLatency, renderCompactModuleReport, resolveRebuildScript, resourceFootprint, runRebuild, runSessionStart, runTurnEnd, summarizeScan, symbolSearch, } from "../clients/lens-engine.js";
import { createAstGrepReplaceTool } from "../tools/ast-grep-replace.js";
import { createAstGrepSearchTool } from "../tools/ast-grep-search.js";
import { createLensDiagnosticsTool } from "../tools/lens-diagnostics.js";
import { createLspDiagnosticsTool } from "../tools/lsp-diagnostics.js";
import { createLspNavigationTool } from "../tools/lsp-navigation.js";
// Any stray stdout write corrupts the JSON-RPC stream; force it onto stderr.
console.log = (...args) => {
console.error(...args);
};
const SERVER_NAME = "pi-lens-mcp";
const SERVER_VERSION = "0.1.0";
// Echoed back to the client when it doesn't pin a version; the negotiation rule
// for a tools-only server is "mirror the client's requested version if present".
const FALLBACK_PROTOCOL_VERSION = "2025-06-18";
// --- Workspace resolution ----------------------------------------------------
function resolveDefaultCwd() {
const fromArg = process.argv
.find((arg) => arg.startsWith("--cwd="))
?.slice("--cwd=".length);
return fromArg ?? process.env.PI_LENS_MCP_CWD ?? process.cwd();
}
const DEFAULT_CWD = path.resolve(resolveDefaultCwd());
const lspReadyCwds = new Set();
// Where THIS server's code lives — used to resolve the fresh-mode worker (same
// build layout as the server) and the pi-lens repo root (for rebuilds).
const SERVER_FILE = fileURLToPath(import.meta.url);
const SERVER_DIR = path.dirname(SERVER_FILE);
const WORKER_PATH = path.join(SERVER_DIR, "worker.js");
const REBUILD_SCRIPT = resolveRebuildScript(SERVER_FILE);
// --- Warm build-staleness guard (#535) ---------------------------------------
//
// Captured ONCE at process start: this server's OWN entry file's mtime. A
// rebuild (`npm run build:dist`) or a `git merge`/checkout that lands new
// code changes SERVER_FILE's mtime on disk, but this already-running process
// keeps the old code loaded in memory — the exact "stale-warm-server" trap
// #535 documents (a post-#517 rebuild still answering with the pre-#517
// schema). `computeBuildStamp` returns undefined when SERVER_FILE can't be
// stat'd (e.g. an unusual packaging layout); the gate then degrades to
// "never stale" rather than false-flagging every call.
//
// `PI_LENS_MCP_STALENESS_STAT_PATH` (test-only override): points the stamp at
// a different file than SERVER_FILE. Exists so a staleness smoke test can
// simulate a rebuild by bumping ONE isolated file's mtime, instead of mutating
// the real `mcp/server.js` on disk — a shared file every OTHER concurrently-
// spawned server process in the same test run also stats against, which would
// otherwise make the staleness smoke test flip unrelated tests' expectations
// under parallel vitest execution.
const STALENESS_STAT_PATH = process.env.PI_LENS_MCP_STALENESS_STAT_PATH ?? SERVER_FILE;
const BUILD_STAMP = computeBuildStamp(STALENESS_STAT_PATH);
const STALENESS_GATE = new StalenessGate(BUILD_STAMP);
/**
* True when the warm server's loaded code is older than what's on disk right
* now. Mtime-gated (at most one `fs.stat` per second, like the #492
* cross-process reader) so a burst of tool calls costs one stat, not one per
* call. Disabled entirely by `PI_LENS_WARM_STALENESS_CHECK=0` (escape hatch).
*/
function isWarmBuildStale() {
if (!stalenessCheckEnabled())
return false;
return STALENESS_GATE.isStale();
}
function findRepoRoot(start) {
let dir = start;
for (let depth = 0; depth < 6; depth++) {
const pkgPath = path.join(dir, "package.json");
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.name === "pi-lens")
return dir;
}
catch {
// keep walking up
}
}
const parent = path.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
return path.resolve(start, "..", "..");
}
const REPO_ROOT = findRepoRoot(SERVER_DIR);
async function ensureReady(cwd) {
const normalized = path.resolve(cwd);
if (lspReadyCwds.has(normalized))
return;
try {
await ensureLspConfig(normalized);
}
catch (err) {
console.error(`[pi-lens-mcp] initLSPConfig failed for ${normalized}: ${err}`);
}
lspReadyCwds.add(normalized);
}
let autoSessionState = {
attempted: false,
succeeded: false,
firedAt: null,
error: null,
};
// Non-null while a run is in flight — guards both the `initialize` call site
// and the `tools/call` self-heal fallback against double-firing/races.
let autoSessionInFlight = null;
function maybeAutoSessionStart() {
if (process.env.PI_LENS_MCP_AUTO_SESSION !== "1")
return;
// Already running, or already completed successfully — nothing to do. A
// prior *failed* attempt is retried (this is the self-heal path).
if (autoSessionInFlight || autoSessionState.succeeded)
return;
autoSessionState = {
attempted: true,
succeeded: false,
firedAt: new Date().toISOString(),
error: null,
};
autoSessionInFlight = ensureReady(DEFAULT_CWD)
.then(() => runSessionStart(DEFAULT_CWD))
.then(() => {
autoSessionState = { ...autoSessionState, succeeded: true };
console.error("[pi-lens-mcp] auto session_start complete");
})
.catch((err) => {
autoSessionState = { ...autoSessionState, error: String(err) };
console.error(`[pi-lens-mcp] auto session_start failed: ${err}`);
})
.finally(() => {
autoSessionInFlight = null;
});
}
/**
* `pilens_health`-facing view of auto-session state. Returns `null` when
* `PI_LENS_MCP_AUTO_SESSION` isn't set at all, so "the feature is off" is
* distinguishable from "attempted and failed".
*/
function getAutoSessionStatus() {
if (process.env.PI_LENS_MCP_AUTO_SESSION !== "1")
return null;
return { ...autoSessionState };
}
// --- Warm side-channel (server side) ----------------------------------------
// A local IPC endpoint the PostToolUse-hook bin connects to, so inline feedback
// runs in THIS warm process (LSP-complete) instead of a cold hook process.
// Responses go over the socket — never stdout — so the MCP stream is untouched.
const IPC_PATH = ipcPathForCwd(DEFAULT_CWD);
function startIpcServer() {
// POSIX: a stale socket file blocks listen; remove it first. (Named pipes on
// Windows don't need this.)
if (process.platform !== "win32") {
try {
fs.unlinkSync(IPC_PATH);
}
catch {
// no stale socket — fine
}
}
const ipc = net.createServer((socket) => {
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1)
return;
const line = buffer.slice(0, newline);
void (async () => {
try {
// #535: the PostToolUse hook bin (mcp/analyze-cli.ts) already treats
// ANY error response as "no usable warm server" and falls back to
// its own cold, load-fresh-from-disk analysis path — so on a stale
// warm build, replying with an error IS the fresh-fork behavior for
// this channel, for free. No separate fresh-fork plumbing needed
// here: the client-side fallback already loads current code.
if (isWarmBuildStale()) {
console.error("[pi-lens-mcp] warm analyze: build stale, replying error so the hook falls back cold");
socket.end(`${JSON.stringify({ error: "warm build stale — falling back to cold analysis" })}\n`);
return;
}
const req = JSON.parse(line);
console.error(`[pi-lens-mcp] warm analyze: ${req.file}`);
// Warm = full LSP + an edit-detection path (register turn-state) +
// review-graph maintenance (#536 — this is an in-process, long-lived
// path, unlike the ephemeral `fresh` worker).
const result = await analyzeFile(req.file, req.cwd, {
registerTurnState: true,
updateGraph: true,
});
socket.end(`${JSON.stringify({ result })}\n`);
}
catch (err) {
socket.end(`${JSON.stringify({ error: String(err) })}\n`);
}
})();
});
socket.on("error", () => socket.destroy());
});
ipc.on("error", (err) => {
// Listener failure must not take down the MCP server — warm channel is an
// optimization; the hook falls back to cold analysis.
console.error(`[pi-lens-mcp] IPC listener unavailable: ${err}`);
});
ipc.listen(IPC_PATH, () => {
console.error(`[pi-lens-mcp] warm side-channel listening at ${IPC_PATH}`);
});
const cleanup = () => {
try {
ipc.close();
}
catch {
// ignore
}
if (process.platform !== "win32") {
try {
fs.unlinkSync(IPC_PATH);
}
catch {
// ignore
}
}
};
process.on("exit", cleanup);
}
function send(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function sendResult(id, result) {
send({ jsonrpc: "2.0", id, result });
}
function sendError(id, code, message) {
send({ jsonrpc: "2.0", id, error: { code, message } });
}
/**
* A tool result: human-readable text first, full JSON appended for the agent.
* `compact` omits indentation (#512) — for token-efficient tools like
* module_report the ~30% saved on the wire is worth losing pretty-printing
* for a payload the agent parses, not reads formatted.
*/
function toolText(summary, structured, compact = false) {
const text = structured === undefined
? summary
: `${summary}\n\n\`\`\`json\n${JSON.stringify(structured, compact ? undefined : null, compact ? undefined : 2)}\n\`\`\``;
return { content: [{ type: "text", text }] };
}
// --- Graph-staleness signal (#536) -------------------------------------------
//
// Extends #514's honesty-warning shape from "missing node" (module_report's
// existing `usedBy`-unavailable warning, #511) to "aging graph": when graph
// data IS present, a caller still can't tell whether it's fresh or stale
// without this. MCP-only per #536's decision — pi's graph is maintained
// per-edit (warm), so the same line there would be pure noise.
/** Below this age, no staleness note is added — a graph this fresh is never
* worth flagging even if the workspace has had zero pilens_analyze calls yet. */
const GRAPH_STALENESS_THRESHOLD_MS = 10 * 60_000; // 10 minutes
function formatRelativeAge(ms) {
if (ms < 60_000)
return `${Math.round(ms / 1000)}s ago`;
if (ms < 60 * 60_000)
return `${Math.round(ms / 60_000)}m ago`;
if (ms < 24 * 60 * 60_000)
return `${Math.round(ms / (60 * 60_000))}h ago`;
return `${Math.round(ms / (24 * 60 * 60_000))}d ago`;
}
/**
* Builds a staleness note for a graph-derived MCP result when its persisted
* timestamp is older than the threshold. Returns undefined when the timestamp
* is missing/unparseable (no graph consulted — the existing #511 "no node"
* warning already covers that case) or fresh enough not to flag.
*/
function graphStalenessNote(builtAtIso, label) {
if (!builtAtIso)
return undefined;
const builtAtMs = Date.parse(builtAtIso);
if (!Number.isFinite(builtAtMs))
return undefined;
const ageMs = Date.now() - builtAtMs;
if (ageMs < GRAPH_STALENESS_THRESHOLD_MS)
return undefined;
return (`${label} last updated ${formatRelativeAge(ageMs)}; run pilens_analyze ` +
"on recently-changed files, pilens_session_start, or pilens_rebuild to refresh it.");
}
// --- Tools -------------------------------------------------------------------
const cacheManager = new CacheManager();
// #536: investigated wiring the same `flushPending` 4th arg pi's index.ts passes
// (() => flushDebouncedToolResults()) before reading pilens_diagnostics. Verdict:
// genuinely not applicable here, not just unwired. `flushDebouncedToolResults`
// (clients/runtime-tool-result.ts) drains a module-level `debouncedPipelines` map
// that ONLY `handleToolResult` populates — pi's tool_result event handler, which
// this MCP process never calls (pilens_analyze routes through the independent
// clients/mcp/analyze.ts facade, calling dispatchLintWithResult directly, never
// handleToolResult). So that map is provably always empty in this process; a call
// to flushDebouncedToolResults() here would resolve immediately having flushed
// nothing — a no-op dressed as a fix, not a real parity gap. The 4th arg is left
// at its default (`async () => {}`, already a no-op) rather than importing and
// wiring a flush with nothing to flush.
const lensDiagnosticsTool = createLensDiagnosticsTool(cacheManager, () => DEFAULT_CWD);
const astGrepClient = new AstGrepClient();
const astGrepSearchTool = createAstGrepSearchTool(astGrepClient);
const astGrepReplaceTool = createAstGrepReplaceTool(astGrepClient);
const lspNavigationTool = createLspNavigationTool(createMcpHost().getFlag);
const lspDiagnosticsTool = createLspDiagnosticsTool();
// Wrapped pi tools already declare their params as typebox (which IS JSON
// Schema). Emit that directly as the MCP inputSchema (+ the MCP-only `cwd`)
// instead of hand-restating it — no drift between the tool and its schema.
function schemaWithCwd(parameters) {
const p = parameters;
return {
type: "object",
properties: {
...(p.properties ?? {}),
cwd: {
type: "string",
description: "Project root (defaults to the server workspace).",
},
},
...(p.required ? { required: p.required } : {}),
};
}
const TOOLS = [
{
name: "pilens_analyze",
description: "Run pi-lens's per-edit dispatch pipeline (LSP + linters + structural " +
"rules) on a single file and return its diagnostics plus the latency " +
"record for that dispatch (same schema as latency.log). The core review " +
"probe: shows a change's real behavioral + perf impact on a real file.",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: "Path to the file to analyze (absolute, or relative to cwd).",
},
cwd: {
type: "string",
description: "Project root. Defaults to the server's workspace.",
},
mode: {
type: "string",
enum: ["warm", "fresh"],
description: "warm (default): run in this server process — fast, warm LSP, but reflects the code the server was started with. fresh: fork a worker that loads the freshly-built code from disk — slower, but reflects the latest commit (the honest review loop; pair with pilens_rebuild).",
},
flags: {
type: "object",
description: "Optional pi-lens flag overrides for this run, e.g. {\"no-lsp\": true} to bench the non-LSP path.",
},
},
required: ["file"],
},
},
{
name: "pilens_diagnostics",
description: "Query pi-lens's diagnostic state across ALL runners (not just LSP). " +
"mode=delta (current turn, instant), mode=all (every dispatched file this " +
"session), mode=full (expensive project-wide active scan).",
inputSchema: schemaWithCwd(lensDiagnosticsTool.parameters),
},
{
name: "pilens_latency",
description: "Return recent dispatch latency reports (latency.log schema: per-file " +
"total duration + per-runner timings). The review-loop measurement surface.",
inputSchema: {
type: "object",
properties: {
limit: { type: "number", description: "Max reports to return (default 5)." },
file: { type: "string", description: "Only reports whose path ends with this." },
},
},
},
{
name: "pilens_rebuild",
description: "Rebuild pi-lens so subsequent `pilens_analyze mode=fresh` runs reflect " +
"the latest commit. Runs `npm run build` (in-place dev layout) or " +
"`npm run build:dist` (precompiled dist layout), matching how this server " +
"was launched. The missing link that makes the review loop honest: " +
"commit → pilens_rebuild → pilens_analyze mode=fresh.",
inputSchema: { type: "object", properties: {} },
},
{
name: "pilens_project_scan",
description: "Cheap project-wide scan (tree-sitter + fact rules) across source files, " +
"returning structural/quality diagnostics. Complements pilens_diagnostics " +
"mode=full (which adds active LSP).",
inputSchema: {
type: "object",
properties: {
cwd: { type: "string" },
maxFiles: { type: "number", description: "Cap files scanned." },
},
},
},
{
name: "pilens_symbol_search",
description: "Ranked identifier search over the persisted word index (BM25 + priors " +
"that demote tests/vendor and doc files). Answers 'which files are most " +
"relevant to <query>' by identifier — first step of the discovery funnel: " +
"symbol_search finds candidate files, pilens_module_report explains one, " +
"pilens_read_symbol reads a body. Complements grep (raw substrings) and " +
"LSP (exact symbols). Each hit's `startLine`/`endLine` mark its best-matching " +
"line (offset=startLine, limit=endLine-startLine+1 for a one-line peek) — " +
"use pilens_module_report on `file` for the real outline. Returns " +
"`available: false` with a retry hint if the index isn't built yet for this " +
"workspace (pilens_session_start builds it, or it self-builds in the background " +
"on first query).",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Identifier-ish query, e.g. 'authenticate user'.",
},
cwd: { type: "string" },
limit: {
type: "number",
description: "Max files to return (default 20).",
},
},
required: ["query"],
},
},
{
name: "pilens_module_report",
description: "Structured, navigable overview of a source module — a token-efficient " +
"substitute for reading the whole file. Returns each symbol's " +
"name/kind/signature/line-range (plus a first-line `doc` summary when a " +
"doc comment is attached), plus who-uses-this, risk flags, and ranked " +
"recommendedReads. To read a symbol's body: call pilens_read_symbol (or " +
"read) with offset=startLine, limit=endLine-startLine+1 on THIS report's " +
"`file` — those aren't repeated per symbol. Prefer this before a full " +
"read; then use pilens_read_symbol for the exact body. Single mode: " +
"tree-sitter outline + review-graph who-uses-this + inline executable " +
"extraction; degrades to outline-only when no cached graph is available " +
"(this path never calls LSP). `semantic.source` reports whether graph " +
"data was used. Pass `blastRadius: true` for the cross-file blast radius " +
"(transitive dependents as ranked file reads, read-only over the cached " +
'graph). `view: "compact"` returns a line-oriented text rendering ' +
"(cheapest option) instead of JSON. An outline shows shape, not bodies.",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: "File to report on (absolute or relative to cwd).",
},
cwd: { type: "string" },
maxRefsPerSymbol: {
type: "number",
description: "Cap who-uses-this entries per symbol (default 10).",
},
focus: {
type: "string",
description: "Optional task hint used only to rank recommendedReads (does not expand scope or trigger scans).",
},
view: {
type: "string",
enum: ["summary", "default", "compact"],
description: "Payload tier. summary returns top-level entries/recommendedReads with heavy callback/usedBy/blast-radius payloads omitted. compact (cheapest) returns a line-oriented TEXT rendering of the full report instead of JSON.",
},
blastRadius: {
type: "boolean",
description: "Include the cross-file blast radius: transitive dependents aggregated to ranked file reads. Read-only over the cached graph (omitted when cold).",
},
blastRadiusDepth: {
type: "number",
description: "Max hops for the blast-radius walk (default 3). Only used with blastRadius.",
},
},
required: ["file"],
},
},
{
name: "pilens_read_symbol",
description: "Return the verbatim source of a single named symbol " +
"(function/class/method/interface/type) in a file — a targeted, cheap " +
"alternative to reading the whole file. Pair with pilens_module_report: it " +
"finds the symbol, this shows its body. Includes an attached doc comment " +
"when one exists. Accepts a dotted `Class.method` name to resolve a " +
"member, falling back to a plain lookup when the qualifier doesn't " +
"resolve. A miss embeds the ~3 nearest symbol names in the file. When " +
"multiple same-file symbols share a name, the first is returned with an " +
"ambiguity note; pass `kind` to pick a specific one.",
inputSchema: {
type: "object",
properties: {
file: { type: "string", description: "File containing the symbol." },
symbol: {
type: "string",
description: "Exact symbol name to read, or a dotted `Class.method` to disambiguate a member.",
},
kind: {
type: "string",
description: "Optional kind filter (e.g. function, interface, class) to disambiguate when multiple same-file symbols share the requested name.",
},
cwd: { type: "string" },
},
required: ["file", "symbol"],
},
},
{
name: "pilens_read_enclosing",
description: "Return the verbatim source for the smallest useful symbol/callback " +
"enclosing a line in a file. Use after pilens_ast_grep_search, " +
"pilens_diagnostics, or pilens_lsp_navigation locations when you need " +
"exact body text without reading the whole file. Uses tree-sitter only — " +
"no LSP or graph build. MCP has no read-guard, so unlike the pi tool this " +
"does not record edit-coverage.",
inputSchema: {
type: "object",
properties: {
file: {
type: "string",
description: "Absolute or workspace-relative path to the source file.",
},
line: {
type: "number",
description: "1-based line number inside the desired symbol/callback.",
},
cwd: { type: "string" },
kinds: {
type: "array",
items: { type: "string" },
description: "Optional kind filter, e.g. function, method, callback, class.",
},
maxLines: {
type: "number",
description: "Optional maximum body size to return. Oversized matches obey onOversize.",
},
onOversize: {
type: "string",
enum: ["error", "slice", "outline"],
description: "Behavior when the enclosing body exceeds maxLines. error (default) returns metadata only; slice returns a bounded partial read around line; outline returns nested symbols/callbacks with read handles.",
},
aroundLine: {
type: "number",
description: "Maximum lines for onOversize=slice; defaults to maxLines, then 80.",
},
},
required: ["file", "line"],
},
},
{
name: "pilens_health",
description: "pi-lens runtime health for THIS server: alive LSP servers, last dispatch " +
"summary, session diagnostic counts, and the total CPU/RAM footprint " +
"attributable to pi-lens across every process it owns (host + LSP " +
"children) machine-wide, from the shared instance registry.",
inputSchema: { type: "object", properties: {} },
},
{
name: "pilens_session_start",
description: "Run pi-lens's real session_start lifecycle: warm the dominant-language " +
"LSP (so subsequent pilens_analyze is LSP-complete), establish the " +
"error-debt baseline (tests/build pass-state) + complexity baselines, and " +
"kick off knip/jscpd/type-coverage/dep/secrets project scans. Returns " +
"project guidance + baseline; scan results land in caches (query via " +
"pilens_diagnostics afterwards). Run once per workspace before reviewing.",
inputSchema: {
type: "object",
properties: { cwd: { type: "string" } },
},
},
{
name: "pilens_turn_end",
description: "Run pi-lens's real turn_end lifecycle over the files changed this turn: " +
"knip dead-code + jscpd duplication (incremental), circular-dep checks, " +
"tests on affected targets, cascade to dependents, and the actionable/" +
"code-quality warning aggregation. Returns the turn-end advisory + test " +
"findings. `files` is OPTIONAL — pilens_analyze (and the PostToolUse hook) " +
"auto-register edited files into turn-state, so you can call this with no " +
"args after a series of edits; pass `files` to add any not analyzed.",
inputSchema: {
type: "object",
properties: {
cwd: { type: "string" },
files: {
type: "array",
items: { type: "string" },
description: "Optional extra files to include (auto-registered ones are already picked up).",
},
},
},
},
{
name: "pilens_ast_grep_search",
description: "Structural (AST) code search via ast-grep — match by code structure, not " +
"text. Use meta-variables ($X) and AST context (e.g. 'console.log($MSG)', " +
"'function $NAME() { $$$ }'). Far more precise than grep for code shapes.",
inputSchema: schemaWithCwd(astGrepSearchTool.parameters),
},
{
name: "pilens_ast_grep_replace",
description: "Structural (AST) find-and-rewrite via ast-grep, e.g. pattern='var $X' " +
"rewrite='let $X'. DRY-RUN by default (apply=false shows the diff); set " +
"apply=true to write the changes to disk.",
inputSchema: schemaWithCwd(astGrepReplaceTool.parameters),
},
{
name: "pilens_lsp_navigation",
description: "LSP code navigation: definition, typeDefinition, declaration, " +
"references, hover, documentSymbol, " +
"workspaceSymbol, implementation, call hierarchy (prepareCallHierarchy/" +
"incomingCalls/outgoingCalls), rename, codeAction, executeCommand " +
"(allowlisted, dry-run by default) — exact + type-aware, " +
"~50ms. Use before changing a signature to see every caller.",
inputSchema: schemaWithCwd(lspNavigationTool.parameters),
},
{
name: "pilens_lsp_diagnostics",
description: "Pure LSP diagnostics for a file, directory, or batch of files (type " +
"errors only — narrower than pilens_diagnostics, which spans all runners).",
inputSchema: schemaWithCwd(lspDiagnosticsTool.parameters),
},
];
function formatAnalyze(result, cwd, mode, servedBy) {
// Surface the LSP outcome so a cold/indexing server's "0" is never silently
// read as "clean" — a known limit on large projects (warm mode / re-run once
// the persistent server has indexed gives complete LSP coverage).
const lspNote = result.lsp
? ` · lsp ${result.lsp.diagnosticCount} (${result.lsp.status}, ${result.lsp.durationMs}ms)`
: "";
const summary = `${path.relative(cwd, result.filePath) || result.filePath} [${mode}] — ` +
`${result.counts.blockers} blocking, ${result.counts.warnings} warning(s), ` +
`${result.counts.diagnostics} total` +
(result.latency ? ` · ${result.latency.totalDurationMs}ms` : "") +
lspNote +
(result.counts.fixed > 0 ? ` · ${result.counts.fixed} auto-fixed` : "") +
(servedBy ? `\n\nservedBy: ${servedBy}` : "");
return toolText(summary, servedBy ? { ...result, servedBy } : result);
}
async function callTool(name, args) {
if (name === "pilens_analyze") {
const file = args.file;
if (typeof file !== "string" || file.length === 0) {
return { ...toolText("pilens_analyze requires a 'file' string."), isError: true };
}
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const requestedMode = args.mode === "fresh" ? "fresh" : "warm";
const flags = args.flags && typeof args.flags === "object"
? args.flags
: undefined;
// #535: analyze is fresh-routable — it's a stateless per-file dispatch
// with no dependency on warm-process-only state (unlike module_report's
// review graph or the warm LSP fleet). So a stale warm build force-routes
// to fresh even when the caller asked for warm: analyze's whole value is
// its diagnostics being CORRECT, and warm-only side effects (turn-state
// registration, graph update) are worth losing for one call rather than
// silently answering with old dispatch logic.
const forcedFresh = requestedMode === "warm" && isWarmBuildStale();
const mode = requestedMode === "fresh" || forcedFresh ? "fresh" : "warm";
if (mode === "fresh") {
// Honest review loop: a forked worker loads the freshly-built code, so
// the result reflects the latest commit — not this long-lived server's
// in-memory image.
const outcome = await analyzeFileFresh(WORKER_PATH, file, cwd, { flags });
if (outcome.error || !outcome.result) {
return {
...toolText(`fresh analyze failed: ${outcome.error ?? "no result"}`),
isError: true,
};
}
return formatAnalyze(outcome.result, cwd, "fresh", forcedFresh ? STALE_SERVED_BY_FRESH : undefined);
}
await ensureReady(cwd);
// Warm = an edit-detection path: register the file so pilens_turn_end picks
// it up without an explicit file list, and maintain the review graph
// (#536) so pilens_module_report/pilens_symbol_search reflect files
// analyzed via MCP, not just session-start state. `fresh` (above) stays
// read-only — it's an ephemeral forked worker.
const result = await analyzeFile(file, cwd, {
flags,
registerTurnState: true,
updateGraph: true,
});
return formatAnalyze(result, cwd, "warm");
}
if (name === "pilens_rebuild") {
const outcome = await runRebuild(REPO_ROOT, REBUILD_SCRIPT);
const runCmd = `${outcome.packageManager} run ${outcome.script}`;
const headline = outcome.ok
? `✓ rebuild succeeded (${runCmd}, ${outcome.durationMs}ms). Fresh analyses now reflect the latest build.`
: `✗ rebuild FAILED (${runCmd}, ${outcome.durationMs}ms).`;
return {
...toolText(outcome.ok ? headline : `${headline}\n\n${outcome.output}`, {
ok: outcome.ok,
script: outcome.script,
packageManager: outcome.packageManager,
durationMs: outcome.durationMs,
repoRoot: REPO_ROOT,
}),
isError: !outcome.ok,
};
}
if (name === "pilens_project_scan") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
await ensureReady(cwd);
const maxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles)
? Math.max(1, Math.floor(args.maxFiles))
: undefined;
const snapshot = await projectScan(cwd, maxFiles);
const { deduped, byRule, byFile } = summarizeScan(snapshot.diagnostics);
const topRules = Object.entries(byRule).sort((a, b) => b[1] - a[1]);
const topFiles = Object.entries(byFile)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([file, count]) => ({ file: path.relative(cwd, file), count }));
const summaryLines = [
`Scanned ${snapshot.filesScanned} file(s) [${snapshot.runners.join(", ")}] → ` +
`${deduped.length} unique diagnostic(s)` +
(snapshot.diagnostics.length !== deduped.length
? ` (${snapshot.diagnostics.length} raw, ${snapshot.diagnostics.length - deduped.length} duplicate)`
: ""),
...topRules.slice(0, 12).map(([rule, count]) => ` ${count}× ${rule}`),
];
return toolText(summaryLines.join("\n"), {
filesScanned: snapshot.filesScanned,
runners: snapshot.runners,
uniqueDiagnostics: deduped.length,
rawDiagnostics: snapshot.diagnostics.length,
byRule,
topFiles,
sample: deduped.slice(0, 40),
});
}
if (name === "pilens_symbol_search") {
const query = typeof args.query === "string" ? args.query : "";
if (!query.trim())
return toolText("Provide a non-empty `query`.");
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const limit = typeof args.limit === "number" && Number.isFinite(args.limit)
? Math.max(1, Math.floor(args.limit))
: 20;
const { available, results, hint, snapshotGeneratedAt } = symbolSearch(query, cwd, limit);
if (!available) {
return toolText(hint ?? "No word index for this workspace yet — run pilens_session_start first.", { available: false, query, hint }, true);
}
const stalenessNote = graphStalenessNote(snapshotGeneratedAt, "Project snapshot");
if (results.length === 0) {
return toolText(`No files matched "${query}".` + (stalenessNote ? `\n\n${stalenessNote}` : ""), {
available: true,
query,
results: [],
...(stalenessNote ? { staleness: stalenessNote } : {}),
}, true);
}
const lines = [
`Top ${results.length} file(s) for "${query}":`,
...results.map((result, i) => ` ${i + 1}. ${path.relative(cwd, result.file)} ` +
`(score ${result.score.toFixed(2)}, ${result.hits} hit(s), ` +
`line ${result.startLine})`),
...(stalenessNote ? ["", stalenessNote] : []),
];
// Compact (unindented) JSON — matches the module_report / read_symbol
// convention (#517): an agent parses this payload, it doesn't read it
// formatted. Path is relative-to-cwd once per hit, no repeated per-hit
// `read` block — startLine/endLine already derive offset/limit.
return toolText(lines.join("\n"), {
query,
results: results.map((result) => ({
file: path.relative(cwd, result.file),
score: result.score,
hits: result.hits,
startLine: result.startLine,
endLine: result.endLine,
})),
...(stalenessNote ? { staleness: stalenessNote } : {}),
}, true);
}
if (name === "pilens_module_report") {
const file = typeof args.file === "string" ? args.file : "";
if (!file.trim())
return { ...toolText("Provide a `file`."), isError: true };
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const maxRefsPerSymbol = typeof args.maxRefsPerSymbol === "number" &&
Number.isFinite(args.maxRefsPerSymbol)
? Math.max(1, Math.floor(args.maxRefsPerSymbol))
: undefined;
const blastRadius = args.blastRadius === true;
const blastRadiusDepth = typeof args.blastRadiusDepth === "number" &&
Number.isFinite(args.blastRadiusDepth)
? Math.max(1, Math.floor(args.blastRadiusDepth))
: undefined;
const view = args.view === "summary" || args.view === "compact" ? args.view : undefined;
const focus = typeof args.focus === "string" ? args.focus : undefined;
const report = await moduleReport(file, cwd, {
maxRefsPerSymbol,
blastRadius,
blastRadiusDepth,
view,
focus,
});
if (!report.available) {
return {
...toolText(`No module report for ${path.relative(cwd, path.resolve(cwd, file))} — not a symbol-bearing file, or unreadable.`, report, true),
isError: true,
};
}
const graphStaleness = graphStalenessNote(report.graphBuiltAt, "Review graph");
const summary = `${path.relative(cwd, report.path) || report.path} [${report.staleness}] — ` +
`${report.summary.symbols} symbol(s), ${report.summary.exports} exported, ` +
`${report.api.length} in public API` +
(graphStaleness ? `\n\n${graphStaleness}` : "");
if (view === "compact") {
const compactText = renderCompactModuleReport(report);
return {
content: [
{
type: "text",
text: graphStaleness ? `${compactText}\n\n${graphStaleness}` : compactText,
},
],
};
}
// Compact (unindented) JSON — matches the pi tool's mirror (#512); an
// agent parses this payload, it doesn't read it formatted.
return toolText(summary, graphStaleness ? { ...report, graphStalenessNote: graphStaleness } : report, true);
}
if (name === "pilens_read_symbol") {
const file = typeof args.file === "string" ? args.file : "";
const symbol = typeof args.symbol === "string" ? args.symbol : "";
if (!file.trim() || !symbol.trim()) {
return { ...toolText("Provide `file` and `symbol`."), isError: true };
}
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const kind = typeof args.kind === "string" ? args.kind : undefined;
const result = await readSymbol(file, symbol, cwd, { kind });
if (!result.found) {
const suggestionSuffix = result.suggestions?.length
? ` Did you mean: ${result.suggestions.join(", ")}?`
: " Use pilens_module_report to list symbols.";
return {
...toolText(`Symbol "${symbol}" not found in ${path.basename(file)}.${suggestionSuffix}`, {
found: false,
...(result.suggestions ? { suggestions: result.suggestions } : {}),
}),
isError: true,
};
}
// Header line already states kind/name/path/range; a trailing JSON block
// restating those same fields is redundant on the wire (#512) — only
// `signature` was ever new, so fold it into the header text instead.
const sigSuffix = result.signature ? ` ${result.signature}` : "";
const ambiguityNote = result.ambiguous
? ` (${result.ambiguous.count} matches — returned the ${result.kind}; pass \`kind\` to disambiguate: ${result.ambiguous.kinds.join(", ")})`
: "";
const header = `${result.kind} ${result.name}${ambiguityNote}${sigSuffix} ${path.relative(cwd, result.path)}:${result.startLine}-${result.endLine}`;
return { content: [{ type: "text", text: `${header}\n\n${result.source ?? ""}` }] };
}
if (name === "pilens_read_enclosing") {
const file = typeof args.file === "string" ? args.file : "";
const line = typeof args.line === "number" ? args.line : Number.NaN;
if (!file.trim() || !Number.isFinite(line)) {
return { ...toolText("Provide `file` and a numeric `line`."), isError: true };
}
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const kinds = Array.isArray(args.kinds)
? args.kinds.filter((k) => typeof k === "string")
: undefined;
const maxLines = typeof args.maxLines === "number" && Number.isFinite(args.maxLines)
? Math.max(1, Math.floor(args.maxLines))
: undefined;
const onOversize = args.onOversize === "error" ||
args.onOversize === "slice" ||
args.onOversize === "outline"
? args.onOversize
: undefined;
const aroundLine = typeof args.aroundLine === "number" && Number.isFinite(args.aroundLine)
? Math.max(1, Math.floor(args.aroundLine))
: undefined;
const result = await readEnclosing(file, line, cwd, {
kinds,
maxLines,
onOversize,
aroundLine,
});
if (!result.found) {
const warningSuffix = result.warnings?.length
? ` Warnings: ${result.warnings.join("; ")}`
: "";
const outlineSuffix = result.outline?.length
? `\n\nNested outline:\n${JSON.stringify(result.outline)}`
: "";
const text = result.error
? `Could not read enclosing range in ${path.basename(file)}:${result.line}: ${result.error}${warningSuffix}${outlineSuffix}`
: `No enclosing symbol/callback found in ${path.basename(file)}:${result.line}.${warningSuffix}`;
return {
...toolText(text, { found: false, line: result.line }),
isError: true,
};
}
// Same #512 convention as pilens_read_symbol: the header line already
// states kind/name/path/range, so no trailing JSON restates them.
const range = result.partial
? `${result.startLine}-${result.endLine} (partial of ${result.enclosingStartLine}-${result.enclosingEndLine})`
: `${result.startLine}-${result.endLine}`;
const header = `${result.kind} ${result.name} ${path.relative(cwd, result.path)}:${range}`;
return { content: [{ type: "text", text: `${header}\n\n${result.source ?? ""}` }] };
}
if (name === "pilens_health") {
const { aliveClients, servers } = lspStatus();
const last = recentLatency(1)[0];
const stats = diagnosticStats();
const autoSession = getAutoSessionStatus();
// #620: best-effort — a footprint read failure must never break the rest
// of pilens_health's (much older, more load-bearing) reporting.
const footprint = await resourceFootprint().catch(() => null);
const lines = [
`LSP: ${aliveClients} alive client(s)`,
...servers.map((server) => ` ${server.connected ? "✓" : "✗"} ${server.serverId} (${server.root})`),
last
? `Last dispatch: ${path.basename(last.filePath)} — ${last.totalDurationMs}ms, ${last.totalDiagnostics} diagnostic(s)`
: "Last dispatch: none yet",
`Diagnostics this session: ${stats.totalShown} shown · ${stats.totalAutoFixed} auto-fixed · ${stats.totalUnresolved} unresolved`,
autoSession
? `Auto session_start: ${autoSession.succeeded ? "succeeded" : autoSession.error ? "FAILED" : autoSession.attempted ? "in progress" : "not yet attempted"}${autoSession.firedAt ? ` (fired ${autoSession.firedAt})` : ""}${autoSession.error ? ` — ${autoSession.error}` : ""}`
: "Auto session_start: disabled (PI_LENS_MCP_AUTO_SESSION not set)",
footprint
? `Resource footprint: ${footprint.instanceCount} pi-lens instance(s) · ` +
`${(footprint.totalRssBytes / 1024 / 1024).toFixed(0)}MB RSS · ` +
`${footprint.totalCpuPercent.toFixed(1)}% CPU · ` +
`${footprint.totalLspChildCount} LSP child process(es)`
: "Resource footprint: unavailable (instance registry unreadable)",
];
return toolText(lines.join("\n"), {
aliveClients,
servers,
lastDispatch: last
? {
filePath: last.filePath,
totalDurationMs: last.totalDurationMs,
totalDiagnostics: last.totalDiagnostics,
}
: undefined,
diagnostics: {
shown: stats.totalShown,
autoFixed: stats.totalAutoFixed,
unresolved: stats.totalUnresolved,
},
autoSession,
resourceFootprint: footprint,
});
}
if (name === "pilens_diagnostics") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
await ensureReady(cwd);
const out = (await lensDiagnosticsTool.execute("mcp", args, new AbortController().signal, undefined, { cwd }));
return { content: out.content };
}
if (name === "pilens_latency") {
const limit = typeof args.limit === "number" && Number.isFinite(args.limit)
? Math.max(1, Math.floor(args.limit))
: 5;
const fileFilter = typeof args.file === "string" ? args.file : undefined;
const recent = recentLatency(limit, fileFilter);
const summary = recent.length === 0
? "No dispatch latency reports yet."
: recent
.map((report) => `${path.basename(report.filePath)}: ${report.totalDurationMs}ms ` +
`(${report.totalDiagnostics} diag${report.stoppedEarly ? ", stopped early" : ""})`)
.join("\n");
return toolText(summary, recent);
}
if (name === "pilens_session_start") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
await ensureReady(cwd);
const outcome = await runSessionStart(cwd);
const lines = [
`Session started for ${cwd}.`,
`LSP: ${outcome.aliveLspClients} alive client(s) (warming continues in background).`,
outcome.errorDebtBaseline
? `Error-debt baseline: tests ${outcome.errorDebtBaseline.testsPassed ? "pass" : "FAIL"}, build ${outcome.errorDebtBaseline.buildPassed ? "pass" : "FAIL"}.`
: "Error-debt baseline: computing in background.",
"knip/jscpd/type-coverage/dep scans run in background — query pilens_diagnostics shortly.",
outcome.guidance ? `\n${outcome.guidance}` : "",
];
return toolText(lines.filter(Boolean).join("\n"), outcome);
}
if (name === "pilens_turn_end") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
await ensureReady(cwd);
const files = Array.isArray(args.files)
? args.files.filter((file) => typeof file === "string")
: [];
const outcome = await runTurnEnd(cwd, files);
const parts = [
`Turn-end over ${outcome.filesRegistered} file(s).`,
outcome.turnEnd ?? "No turn-end advisory.",
outcome.tests ? `\nTests:\n${outcome.tests}` : "",
];
return toolText(parts.filter(Boolean).join("\n"), outcome);
}
if (name === "pilens_ast_grep_search" || name === "pilens_ast_grep_replace") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
const tool = name === "pilens_ast_grep_search" ? astGrepSearchTool : astGrepReplaceTool;
const out = (await tool.execute("mcp", args, new AbortController().signal, undefined, { cwd }));
return { content: out.content };
}
if (name === "pilens_lsp_navigation" || name === "pilens_lsp_diagnostics") {
const cwd = typeof args.cwd === "string" ? args.cwd : DEFAULT_CWD;
await ensureReady(cwd);
const tool = name === "pilens_lsp_navigation" ? lspNavigationTool : lspDiagnosticsTool;
const out = (await tool.execute("mcp", args, new AbortController().signal, undefined, { cwd }));
return { content: out.content };
}
return { ...toolText(`Unknown tool: ${name}`), isError: true };
}
// --- Warm build-staleness — per-tool warn-only set (#535) --------------------
//
// `pilens_analyze` is handled specially above (force-routes to the existing
// `fresh` worker fork — it's a stateless per-file dispatch with no dependency
// on warm-process-only state). Every OTHER tool below either:
// - depends on state that only exists inside THIS long-lived process (the
// in-memory review graph built by warm `pilens_analyze` calls, the warm
// LSP client fleet, the latency/diagnostic counters, the CacheManager) —
// a fresh fork would start with none of that and answer differently, not
// "more correctly"; or
// - is cheap/rare enough (rebuild, session_start) that building bespoke
// fresh-fork plumbing isn't worth it yet.
// So the honest move for all of them is #535's "honest degrade": warn, don't
// silently serve, and don't pretend a fresh fork would help.
//
// pilens_module_report, pilens_symbol_search — warm review-graph / word-index
// cache is in-memory only; a fresh fork has an EMPTY graph, which is a
// worse answer than a stale-but-populated one with a warning attached.
// pilens_project_scan — CacheManager instance is warm-
// process state; scan results are cache-derived.
// pilens_health, pilens_latency — these tools report ON the warm
// process itself (alive LSP clients, this session's latency log) — the
// question "is this call's ANSWER stale" doesn't quite apply, but the code
// answering it might still be a stale build, so still worth a note.
// pilens_session_start, pilens_turn_end — mutate warm LSP/graph state;
// must run in-process, can't be forked fresh.
// pilens_ast_grep_search, pilens_ast_grep_replace,
// pilens_lsp_navigation, pilens_lsp_diagnostics — depend on the warm LSP
// fleet / ast-grep client instances; no fresh-fork machinery exists for
// them today (only pilens_analyze's worker.ts loads a fresh dispatch
// graph) and the LSP fleet specifically CANNOT be recreated cheaply per
// call, so warn is the only honest option.
// pilens_read_symbol, pilens_read_enclosing — stateless file reads, but no
// existing fresh-fork path either; warn rather than silently answer with
// however this stale build's tree-sitter/read-symbol logic behaves.
//
// `pilens_rebuild` is deliberately excluded: it doesn't answer with analysis
// at all (it shells out to `npm run build`/`build:dist`), and it's the very
// mechanism that CAUSES staleness — noting "stale" on the tool that fixes
// staleness would be confusing, not honest.
const WARN_ONLY_STALE_TOOLS = new Set([
"pilens_module_report",
"pilens_symbol_search",
"pilens_project_scan",
"pilens_health",
"pilens_latency",
"pilens_session_start",
"pilens_turn_end",
"pilens_ast_grep_search",
"pilens_ast_grep_replace",
"pilens_lsp_navigation",
"pilens_lsp_diagnostics",
"pilens_read_symbol",
"pilens_read_enclosing",
]);
/**
* Appends the warm-code-stale advisory to a tool result's text (and a
* `warmCodeStale: true` marker line) without disturbing its JSON payload
* shape — callers already parse the fenced JSON block by locating braces
* (see module_report/symbol_search callers), so appending plain text after it
* is safe.
*/
function withStaleWarning(result) {
if (result.content.length === 0)
return result;
const last = result.content[result.content.length - 1];
return {
...result,
content: [
...result.content.slice(0, -1),
{ ...last, text: `${last.text}\n\nwarmCodeStale: true\n${STALE_WARN_ONLY}` },
],
};
}
// --- Method dispatch ---------------------------------------------------------
async function handleRequest(request) {
const { id, method, params } = request;
const isNotification = id === undefined;
switch (method) {
case "initialize": {
const requested = params?.protocolVersion;
sendResult(id ?? null, {
protocolVersion: typeof requested === "string" ? requested : FALLBACK_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
});
maybeAutoSessionStart();
return;
}
case "notifications/initialized":
case "initialized":
return; // notification — no response
case "ping":
if (!isNotification)
sendResult(id ?? null, {});
return;
case "tools/list":
sendResult(id ?? null, { tools: TOOLS });
return;
case "tools/call": {
const name = params?.name;
const args = params?.arguments && typeof params.arguments === "object"
? params.arguments
: {};
if (typeof name !== "string") {
sendError(id ?? null, -32602, "tools/call requires a string 'name'");
return;
}
// #544 self-heal: if auto-session was supposed to fire on `initialize`
// (PI_LENS_MCP_AUTO_SESSION=1) but never completed successfully — never
// attempted, still in flight, or threw — nudge it here too. Cheap no-op
// once it has actually succeeded (see the in-flight/succeeded guard
// inside maybeAutoSessionStart), so this does NOT re-run session_start
// on every tool call, only until the connection's first success.
maybeAutoSessionStart();
try {
let result = await callTool(name, args);
// #535: pilens_analyze already self-routes (fresh-fork) when stale —
// see the forcedFresh branch inside callTool. Every other tool that
// depends on warm-only process state gets an honest-degrade warning
// instead, so the warm boundary never silently serves old code.
if (WARN_ONLY_STALE_TOOLS.has(name) &&
!result.isError &&
isWarmBuildStale()) {
result = withStaleWarning(result);
}
sendResult(id ?? null, result);
}
catch (err) {
// Surface as a tool error (isError), not a transport error, so the
// agent sees the message instead of a dead request.
sendResult(id ?? null, {
...toolText(`pi-lens tool '${name}' failed: ${err.message}`),
isError: true,
});
}
return;
}
default:
if (!isNotification)
sendError(id ?? null, -32601, `Method not found: ${method}`);
return;
}
}
// --- stdio read loop (newline-delimited JSON) --------------------------------
let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.length > 0) {
let request;
try {
request = JSON.parse(line);
}
catch {
sendError(null, -32700, "Parse error");
}
if (request)
void handleRequest(request);
}
newlineIndex = buffer.indexOf("\n");
}
});
process.stdin.on("end", () => process.exit(0));
startIpcServer();
console.error(`[pi-lens-mcp] ready (cwd=${DEFAULT_CWD})`);