#!/usr/bin/env node /** * MCP stdio server: cross-host entry point for Claude Code and Codex (ADR-0004). * * Exposes the same `review` tool as the pi extension, backed by the web * surface only (neither host has a custom TUI component API). The tool call * blocks while the user reviews in the browser; line comments come back as * the tool result. MCP server processes are spawned by the host outside any * sandbox, so the ephemeral 127.0.0.1 server + browser open works everywhere. */ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { CommentStore, formatCommentsForAgent, pruneComments, userComments } from "./comments"; import { applyAgentResponses, conversationReviewItems, pruneAgentResponses } from "./conversations"; import { getHeadSha, listReviewItems, loadDiff, loadFileContent } from "./git"; import { loadReviewState, saveReviewState } from "./review-state"; import { dismissReviewFindings, reconcileReviewReport, reviewReportToPresentation, validateReviewFindingLocations, type ReviewReport, type ReviewReportInput, } from "./review-report"; import { stopsToReviewComments, type WalkthroughStop } from "./walkthrough"; import { openWebReview } from "./web-review"; /** Process-lifetime store: user comments survive reopen within the host session (ADR-0001). */ const commentStore = new CommentStore(); function textResult(text: string) { return { content: [{ type: "text" as const, text }] }; } const findingSchema = z.object({ identity: z.string().describe("Stable semantic slug based on symbol, invariant, and failure mode"), title: z.string(), claim: z.string(), impact: z.string(), evidence: z.string(), severity: z.enum(["critical", "high", "medium", "low", "info"]), category: z.string(), confidence: z.number().min(0).max(1), location: z.object({ sha: z.string().nullable().optional(), file: z.string(), line: z.number().int().positive(), side: z.enum(["new", "old"]).optional(), }), suggestion: z.string().optional(), }); const reportSchema = z.object({ mode: z.enum(["quick", "deep"]), summary: z.string(), findings: z.array(findingSchema), checks: z.array( z.object({ command: z.string(), status: z.enum(["passed", "failed", "skipped"]), summary: z.string(), }), ), coverage: z.object({ reviewedFiles: z.array(z.string()), skipped: z.array(z.object({ file: z.string(), reason: z.string() })), }), }); const addressedCommentSchema = z.object({ commentId: z.string().describe("Stable conversation id returned by the previous review call"), summary: z.string().describe("Concise account of how the agent handled this conversation"), changedLocations: z.array( z.object({ file: z.string().describe("Repo-relative file changed while addressing the comment"), line: z.number().int().positive().optional(), before: z.string().optional().describe("Relevant code before the change"), after: z.string().optional().describe("Relevant code after the change"), }), ), checks: z.array( z.object({ command: z.string(), status: z.enum(["passed", "failed", "skipped"]), summary: z.string(), }), ), }); const server = new McpServer({ name: "code-eye", version: "0.8.0" }); server.registerTool( "review", { title: "Code Review", description: "Submit an evidence-backed structured code review and open it in the user's browser. " + "Prefer the report field (mode, findings, checks, coverage); legacy summary/stops remain supported. " + "Use this after making changes when the user should review your work. The panel shows commits in base..HEAD " + "and uncommitted changes; findings point the user at verified risks with impact and evidence. " + "The user can leave line comments on the diff. " + "The tool blocks until the user closes the panel and returns any comments they left — address each one. " + "When you handle a returned conversation, call review again with an addressedComments entry for its stable " + "commentId, summary, changed locations, and checks. This records your response and marks it agent-addressed; " + "only the user can resolve it. " + "Comments the user marked resolved are not returned. Replies to your findings or walkthrough notes come back " + "tagged as questions or suggestion-adoption requests. " + "Closing with no conversations waiting for agent work means no further agent action is needed.", inputSchema: { report: reportSchema.optional().describe("Structured AI reviewer output (preferred over legacy stops)"), addressedComments: z .array(addressedCommentSchema) .optional() .describe("Evidence-backed responses to conversations returned by an earlier review round"), summary: z.string().optional().describe("One-paragraph overview of what changed and why"), stops: z .array( z.object({ title: z.string().describe("Short headline for this stop"), detail: z.string().describe("Why this needs attention; context the user needs"), sha: z .string() .optional() .describe("Short commit sha this stop belongs to; omit for uncommitted changes"), file: z.string().optional().describe("File path within that commit's diff"), line: z.number().optional().describe("Line number in the new version of the file"), kind: z.string().optional().describe("Category tag, e.g. change, risk, note"), severity: z .enum(["high", "medium", "low"]) .optional() .describe("Risk level for risky stops; shown as a colored chip and drives the risk filter"), suggestion: z .string() .optional() .describe( "Replacement code for the anchored line(s); shown read-only, the user can ask you to apply it", ), }), ) .optional(), }, }, async ({ report: reportInput, addressedComments, summary, stops }, extra) => { const cwd = process.cwd(); // Headless sessions (claude -p, codex exec, CI) have nobody to review; fail fast. if (process.env.CI) { return textResult( "Interactive review is not available in this environment (CI). Summarize the changes in chat instead.", ); } // ADR-0005: on-disk state feeds the delta item and seeds persisted comments. const state = await loadReviewState(cwd); let items = await listReviewItems(cwd, { since: state.lastReviewedHead }); if (items.length === 0) { const hasPendingConversation = state.comments.some( (comment) => comment.author === "user" && comment.status !== "resolved", ); if (!hasPendingConversation && !addressedComments?.length) { return textResult("Nothing to review: no changes or conversations are waiting for review."); } items = conversationReviewItems(state.comments); if (items.length === 0) { // Let applyAgentResponses below produce a precise unknown-id error. items = [{ sha: null, label: "conversation", subject: "Persisted review conversations" }]; } } // ADR-0005: reseed from disk on every call — the store only lives to // carry comments through one open/close cycle here. commentStore.replace(pruneComments(state.comments, items)); const applied = applyAgentResponses( commentStore.list(), pruneAgentResponses(state.agentResponses, commentStore.list()), addressedComments ?? [], ); commentStore.replace(applied.comments); const deltaSince = items.find((i) => i.since)?.since; let report: ReviewReport | undefined; let presentation: | ReturnType | undefined; if (reportInput) { const input = reportInput as ReviewReportInput; const generatedAt = new Date().toISOString(); const submitted = reconcileReviewReport(input, [], generatedAt); const locationErrors = await validateReviewFindingLocations(submitted, items, (sha) => sha === null && deltaSince ? loadDiff(cwd, null, deltaSince) : loadDiff(cwd, sha), ); if (locationErrors.length > 0) { throw new Error(`Invalid review finding locations:\n- ${locationErrors.join("\n- ")}`); } report = reconcileReviewReport(input, state.lastReport?.findings ?? [], generatedAt); presentation = reviewReportToPresentation(report, items); } else if (summary || stops?.length) { const all: WalkthroughStop[] = []; if (summary) all.push({ title: "Overview", detail: summary, kind: "overview" }); if (stops) all.push(...stops); presentation = stopsToReviewComments(all, items); } else if (state.lastReport) { report = state.lastReport; presentation = reviewReportToPresentation(report, items); } else { presentation = { comments: [], summaries: [] }; } // Heartbeat while the user reviews: hosts with an idle timeout (Claude // Code kills calls idle for 30min) count progress notifications as activity. const progressToken = extra._meta?.progressToken; const heartbeat = progressToken !== undefined ? setInterval(() => { void extra .sendNotification({ method: "notifications/progress", params: { progressToken, progress: 0, message: "Waiting for user review…" }, }) .catch(() => {}); }, 60_000) : undefined; try { const result = await openWebReview({ cwd, items, agentComments: presentation.comments, summaries: presentation.summaries, initialComments: commentStore.list(), agentResponses: applied.responses, loadDiffFor: (sha) => (sha === null && deltaSince ? loadDiff(cwd, null, deltaSince) : loadDiff(cwd, sha)), loadFileFor: (sha, file, side) => loadFileContent(cwd, sha, file, side, sha === null ? deltaSince : undefined), }); if (report && result.dismissedFindingIds.length > 0) { report = dismissReviewFindings(report, result.dismissedFindingIds); } // ADR-0002: only user comments persist and are fed back to the agent. const comments = userComments(result.comments); commentStore.replace(comments); // ADR-0005: persist across sessions; record HEAD for the next delta item. await saveReviewState(cwd, { version: 3, comments: commentStore.list(), agentResponses: pruneAgentResponses(result.agentResponses, commentStore.list()), lastReviewedHead: (await getHeadSha(cwd)) ?? undefined, ...(report || state.lastReport ? { lastReport: report ?? state.lastReport } : {}), }); return textResult(formatCommentsForAgent(comments)); } finally { if (heartbeat !== undefined) clearInterval(heartbeat); } }, ); await server.connect(new StdioServerTransport());