diffprism 0.48.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,23 @@
1
1
  import {
2
- isPrRef,
3
- parsePrRef
4
- } from "./chunk-24B33UN6.js";
2
+ isPrRef
3
+ } from "./chunk-EPU4F7WT.js";
5
4
  import {
5
+ DEFAULT_DIFF_REF,
6
+ DIFF_REF_DESCRIPTION,
7
+ REPORT_HINT,
8
+ ReviewTimeoutError,
9
+ ReviewerAskedError,
10
+ awaitingAgent,
11
+ currentVersion,
6
12
  ensureServer,
7
13
  isServerAlive,
8
- submitReviewToServer
9
- } from "./chunk-EPUCA2N5.js";
14
+ recordError,
15
+ submitReviewToServer,
16
+ waitForDecision
17
+ } from "./chunk-CU2BAL6Q.js";
10
18
  import {
11
19
  getDiff
12
- } from "./chunk-QGWYCEJN.js";
20
+ } from "./chunk-3GMPE2ZR.js";
13
21
  import {
14
22
  analyze
15
23
  } from "./chunk-DHCVZGHE.js";
@@ -19,385 +27,249 @@ import "./chunk-JSBRDJBE.js";
19
27
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
28
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
29
  import { z } from "zod";
22
- var lastGlobalSessionId = null;
23
- var lastGlobalServerInfo = null;
24
- async function resolveSessionId(explicitId, serverInfo) {
25
- if (explicitId) return explicitId;
26
- if (lastGlobalSessionId) return lastGlobalSessionId;
27
- try {
28
- const response = await fetch(
29
- `http://localhost:${serverInfo.httpPort}/api/reviews`
30
- );
31
- if (response.ok) {
32
- const data = await response.json();
33
- if (data.sessions.length > 0) {
34
- const sorted = [...data.sessions].sort((a, b) => b.createdAt - a.createdAt);
35
- return sorted[0].id;
36
- }
37
- }
38
- } catch {
39
- }
40
- return null;
30
+ var DEFAULT_WAIT_MS = 6e5;
31
+ var NO_SERVER = "No DiffPrism server is running. A review has to be open before this tool can act on it \u2014 open one with open_review, `diffprism review`, or the DiffPrism dashboard.";
32
+ function jsonResult(value) {
33
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
41
34
  }
42
- async function handleLocalReview(diffRef, options) {
43
- const serverInfo = await ensureServer({ silent: true });
44
- const { result, sessionId } = await submitReviewToServer(
45
- serverInfo,
46
- diffRef,
47
- {
48
- title: options.title,
49
- description: options.description,
50
- reasoning: options.reasoning,
51
- cwd: process.cwd(),
52
- annotations: options.annotations,
53
- diffRef,
54
- timeoutMs: options.timeoutMs ?? 0
55
- }
35
+ function toolError(text) {
36
+ return { content: [{ type: "text", text }], isError: true };
37
+ }
38
+ function errorMessage(err) {
39
+ return err instanceof Error ? err.message : String(err);
40
+ }
41
+ var targetParams = {
42
+ session_id: z.string().optional().describe("The review session to act on. Takes precedence over repo_path."),
43
+ repo_path: z.string().optional().describe(
44
+ "Any directory inside the repository whose review to act on. Defaults to the directory this agent is running in \u2014 pass it when working on a repo other than your own."
45
+ )
46
+ };
47
+ async function resolveTarget(serverInfo, params) {
48
+ if (params.session_id) {
49
+ return { sessionId: params.session_id };
50
+ }
51
+ const lookupPath = params.repo_path ?? process.cwd();
52
+ const response = await fetch(
53
+ `http://localhost:${serverInfo.httpPort}/api/reviews/resolve?path=${encodeURIComponent(lookupPath)}`
56
54
  );
57
- if (result) {
55
+ if (!response.ok) {
56
+ return { error: `Could not look up a review for ${lookupPath}: server returned ${response.status}.` };
57
+ }
58
+ const { repoRoot, sessions } = await response.json();
59
+ if (sessions.length === 1) {
60
+ return { sessionId: sessions[0].id };
61
+ }
62
+ const from = params.repo_path ? "repo_path" : "the current directory";
63
+ if (sessions.length === 0) {
58
64
  return {
59
- mcpResult: {
60
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
61
- },
62
- sessionId,
63
- serverInfo
65
+ error: `No review is open for ${repoRoot} (resolved from ${from}). Open one with open_review, \`diffprism review\`, or the DiffPrism dashboard \u2014 or pass session_id.`
64
66
  };
65
67
  }
68
+ const candidates = sessions.map((s) => ` ${s.id} ${s.title ?? "(untitled)"} (${[s.diffRef, s.status].filter(Boolean).join(", ")})`).join("\n");
66
69
  return {
67
- mcpResult: {
68
- content: [{
69
- type: "text",
70
- text: JSON.stringify({
71
- status: "session_created",
72
- sessionId,
73
- message: "Review session opened in DiffPrism dashboard. Use get_review_result to check for a decision."
74
- }, null, 2)
75
- }]
76
- },
77
- sessionId,
78
- serverInfo
70
+ error: `${sessions.length} reviews are open for ${repoRoot}. Pass session_id to choose one:
71
+ ${candidates}`
79
72
  };
80
73
  }
81
- async function handlePrReview(pr, options) {
82
- const { owner, repo, number } = parsePrRef(pr);
83
- const serverInfo = await ensureServer({ silent: true });
74
+ async function readThreads(serverInfo, sessionId) {
84
75
  const response = await fetch(
85
- `http://localhost:${serverInfo.httpPort}/api/pr/open`,
86
- {
87
- method: "POST",
88
- headers: { "Content-Type": "application/json" },
89
- body: JSON.stringify({ prUrl: pr })
90
- }
76
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`
91
77
  );
92
- const data = await response.json();
93
- if (!response.ok || !data.sessionId) {
94
- return {
95
- mcpResult: {
96
- content: [{ type: "text", text: `Error: ${data.error ?? "Failed to open PR"}` }],
97
- isError: true
98
- },
99
- sessionId: "",
100
- serverInfo
101
- };
78
+ if (!response.ok) {
79
+ return null;
102
80
  }
103
- const sessionId = data.sessionId;
104
- if (options.timeoutMs && options.timeoutMs > 0) {
105
- const start = Date.now();
106
- while (Date.now() - start < options.timeoutMs) {
107
- const resultResponse = await fetch(
108
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/result`
109
- );
110
- if (resultResponse.ok) {
111
- const resultData = await resultResponse.json();
112
- if (resultData.result) {
113
- return {
114
- mcpResult: {
115
- content: [{ type: "text", text: JSON.stringify(resultData.result, null, 2) }]
116
- },
117
- sessionId,
118
- serverInfo
119
- };
120
- }
121
- }
122
- await new Promise((resolve) => setTimeout(resolve, 2e3));
81
+ const { annotations } = await response.json();
82
+ return annotations.map((a) => ({ ...a, awaitingReply: awaitingAgent(a) }));
83
+ }
84
+ function reviewerAskedResult(err, waitWith) {
85
+ return jsonResult({
86
+ status: "reviewer_asked",
87
+ sessionId: err.sessionId,
88
+ threads: err.threads.map((t) => ({ ...t, awaitingReply: true })),
89
+ message: `The reviewer asked you something before deciding. Answer each thread with reply (session_id: ${err.sessionId}, annotation_id from threads) \u2014 change the code too if that's what they asked for. Then ${waitWith}. The review stays open; the decision still comes.`
90
+ });
91
+ }
92
+ async function withSession(params, run) {
93
+ try {
94
+ const serverInfo = await isServerAlive();
95
+ if (!serverInfo) {
96
+ return toolError(NO_SERVER);
97
+ }
98
+ const target = await resolveTarget(serverInfo, params);
99
+ if ("error" in target) {
100
+ return toolError(target.error);
123
101
  }
102
+ return await run({ serverInfo, sessionId: target.sessionId });
103
+ } catch (err) {
104
+ recordError("serve (MCP)", err);
105
+ return toolError(`Error: ${errorMessage(err)}
106
+ ${REPORT_HINT}`);
124
107
  }
125
- return {
126
- mcpResult: {
127
- content: [{
128
- type: "text",
129
- text: JSON.stringify({
130
- status: "session_created",
131
- sessionId,
132
- pr: `${owner}/${repo}#${number}`,
133
- fileCount: data.fileCount,
134
- localRepoConnected: !!data.localRepoPath,
135
- localRepoPath: data.localRepoPath,
136
- message: "PR review session opened in DiffPrism. Use get_pr_context, get_file_diff, get_file_context to explore the changes. Use add_review_comment to post findings."
137
- }, null, 2)
138
- }]
139
- },
140
- sessionId,
141
- serverInfo
142
- };
143
108
  }
109
+ var diffRefParam = z.string().optional().describe(DIFF_REF_DESCRIPTION);
110
+ var annotationSchema = z.object({
111
+ file: z.string().describe("File path within the diff"),
112
+ line: z.number().optional().describe("Line number to attach to (defaults to 1, for a note about the file as a whole)"),
113
+ body: z.string().describe("The finding, suggestion, or question"),
114
+ type: z.enum(["finding", "suggestion", "question", "warning"]).describe("Use 'warning' for anything the reviewer must look at \u2014 warnings flag the session for attention"),
115
+ confidence: z.number().min(0).max(1).optional().describe("Confidence in the finding (0-1, defaults to 1)"),
116
+ category: z.enum([
117
+ "security",
118
+ "performance",
119
+ "convention",
120
+ "correctness",
121
+ "complexity",
122
+ "test-coverage",
123
+ "documentation",
124
+ "other"
125
+ ]).optional().describe("Category of the finding (defaults to 'other')")
126
+ });
144
127
  async function startMcpServer() {
145
128
  const server = new McpServer({
146
129
  name: "diffprism",
147
- version: true ? "0.48.2" : "0.0.0-dev"
130
+ version: currentVersion()
148
131
  });
149
132
  server.tool(
150
133
  "open_review",
151
- "Open a review session in the DiffPrism dashboard for local git changes or a GitHub pull request. Returns immediately with the session ID after registering the session. Use `get_review_result` with `wait: true` when you need the reviewer's decision before proceeding.",
134
+ 'Open a review of local git changes in the DiffPrism dashboard and wait for the reviewer\'s decision. Blocks until they approve, request changes, or dismiss, then returns their ReviewResult (decision, inline comments, summary). If the reviewer asks you something first, returns status "reviewer_asked" with the threads: answer them with reply, then wait with get_review_result. Reviews are one per repo: opening again for the same repo updates the review already open instead of starting another, and keeps its annotations. Pass wait: false to get the session id back immediately instead. Pull requests are not opened here \u2014 open them with `diffprism review <PR URL>` or the dashboard, then use the PR tools.',
152
135
  {
153
- diff_ref: z.string().describe(
154
- 'Git diff reference: "staged", "unstaged", "working-copy" (staged+unstaged grouped), a ref range like "HEAD~3..HEAD", or a GitHub PR ref like "owner/repo#123" or a GitHub PR URL'
155
- ),
136
+ diff_ref: diffRefParam,
156
137
  title: z.string().optional().describe("Title for the review"),
157
138
  description: z.string().optional().describe("Description of the changes"),
158
- reasoning: z.string().optional().describe("Summarize what you were trying to accomplish in this session in plain English. This is displayed as the session subtitle in the DiffPrism dashboard and is the primary way users identify sessions at a glance. Always populate this."),
159
- post_to_github: z.boolean().optional().describe("Post the review back to GitHub after submission (only for PR refs, default: false)"),
160
- timeout_ms: z.number().optional().describe("How long to wait for a review decision (ms). Defaults to 0 (non-blocking, returns immediately after session creation). Set to a positive value to poll for a result up to that duration before returning."),
161
- annotations: z.array(
162
- z.object({
163
- file: z.string().describe("File path within the diff to annotate"),
164
- line: z.number().describe("Line number to annotate"),
165
- body: z.string().describe("The annotation text"),
166
- type: z.enum(["finding", "suggestion", "question", "warning"]).describe("Type of annotation"),
167
- confidence: z.number().min(0).max(1).optional().describe("Confidence in the finding (0-1, defaults to 1)"),
168
- category: z.enum([
169
- "security",
170
- "performance",
171
- "convention",
172
- "correctness",
173
- "complexity",
174
- "test-coverage",
175
- "documentation",
176
- "other"
177
- ]).optional().describe("Category of the finding (defaults to 'other')"),
178
- source_agent: z.string().optional().describe("Agent identifier (e.g., 'security-reviewer')")
179
- })
180
- ).optional().describe("Initial annotations to attach to the review")
139
+ reasoning: z.string().optional().describe(
140
+ "Summarize what you were trying to accomplish in plain English. Shown as the session subtitle in the dashboard \u2014 the main way a reviewer tells sessions apart. Always populate this."
141
+ ),
142
+ annotations: z.array(annotationSchema).optional().describe("Findings to attach to the review when it opens"),
143
+ wait: z.boolean().optional().describe(
144
+ "Wait for the reviewer's decision (default true). With false, returns the session id at once; check for a decision later with get_review_result."
145
+ ),
146
+ timeout_ms: z.number().optional().describe(
147
+ `How long to wait for a decision (default ${DEFAULT_WAIT_MS}ms). If it runs out the review stays open, and the session id comes back so you can check again with get_review_result.`
148
+ )
181
149
  },
182
- async ({ diff_ref, title, description, reasoning, post_to_github, timeout_ms, annotations }) => {
150
+ async ({ diff_ref = DEFAULT_DIFF_REF, title, description, reasoning, annotations, wait, timeout_ms }) => {
151
+ if (isPrRef(diff_ref)) {
152
+ return toolError(
153
+ "open_review does not open pull requests. Open a PR review with `diffprism review <PR URL>` or the DiffPrism dashboard, then use get_pr_context, get_file_diff, get_file_context and annotate on that session."
154
+ );
155
+ }
183
156
  try {
184
- let mcpResult;
185
- let sessionId;
186
- let serverInfo;
187
- if (isPrRef(diff_ref)) {
188
- ({ mcpResult, sessionId, serverInfo } = await handlePrReview(diff_ref, {
189
- title,
190
- reasoning,
191
- post_to_github,
192
- timeoutMs: timeout_ms
193
- }));
194
- } else {
195
- ({ mcpResult, sessionId, serverInfo } = await handleLocalReview(diff_ref, {
157
+ const serverInfo = await ensureServer({ silent: true });
158
+ const shouldWait = wait ?? true;
159
+ try {
160
+ const { result, sessionId } = await submitReviewToServer(serverInfo, diff_ref, {
196
161
  title,
197
162
  description,
198
163
  reasoning,
199
- annotations,
200
- timeoutMs: timeout_ms
201
- }));
202
- }
203
- if (sessionId) {
204
- lastGlobalSessionId = sessionId;
205
- lastGlobalServerInfo = serverInfo;
164
+ cwd: process.cwd(),
165
+ annotations: annotations?.map((a) => ({ ...a, line: a.line ?? 1 })),
166
+ diffRef: diff_ref,
167
+ timeoutMs: shouldWait ? timeout_ms ?? DEFAULT_WAIT_MS : 0
168
+ });
169
+ if (result) {
170
+ return jsonResult(result);
171
+ }
172
+ return jsonResult({
173
+ status: "open",
174
+ sessionId,
175
+ message: "Review is open in the DiffPrism dashboard. Check for a decision with get_review_result."
176
+ });
177
+ } catch (err) {
178
+ if (err instanceof ReviewerAskedError) {
179
+ return reviewerAskedResult(err, `wait for the decision with get_review_result (session_id: ${err.sessionId}, wait: true)`);
180
+ }
181
+ if (err instanceof ReviewTimeoutError) {
182
+ return jsonResult({
183
+ status: "timed_out",
184
+ sessionId: err.sessionId,
185
+ message: `No decision after ${Math.round(err.waitedMs / 1e3)}s. The reviewer may still be reading \u2014 the review is open in their browser. Wait with get_review_result (session_id: ${err.sessionId}, wait: true). Don't ask the user about it in the meantime: their decision is the answer. Calling open_review again with an unchanged diff is also safe \u2014 it returns a decision already given.`
186
+ });
187
+ }
188
+ throw err;
206
189
  }
207
- return mcpResult;
208
190
  } catch (err) {
209
- const message = err instanceof Error ? err.message : String(err);
210
- return {
211
- content: [
212
- {
213
- type: "text",
214
- text: `Error: ${message}`
215
- }
216
- ],
217
- isError: true
218
- };
191
+ recordError("serve (MCP) open_review", err);
192
+ return toolError(`Error: ${errorMessage(err)}
193
+ ${REPORT_HINT}`);
219
194
  }
220
195
  }
221
196
  );
222
197
  server.tool(
223
- "update_review_context",
224
- "Push reasoning/context to a running DiffPrism review session. Non-blocking \u2014 returns immediately. Updates the review UI with agent reasoning without opening a new review. Requires a prior `open_review` call in this session.",
198
+ "get_review_result",
199
+ 'Check the decision on a review that is already open \u2014 after open_review with wait: false, or after open_review timed out. Returns the ReviewResult once the reviewer has decided. Set wait: true to block until they do; the wait also ends with status "reviewer_asked" if they ask you something \u2014 reply, then wait again.',
225
200
  {
226
- reasoning: z.string().optional().describe("Agent reasoning about the current changes"),
227
- title: z.string().optional().describe("Updated title for the review"),
228
- description: z.string().optional().describe("Updated description of the changes")
201
+ ...targetParams,
202
+ wait: z.boolean().optional().describe("Block until a decision arrives (up to timeout)"),
203
+ timeout: z.number().optional().describe("Max wait in seconds when wait is true (default 300, max 600)")
229
204
  },
230
- async ({ reasoning, title, description }) => {
231
- try {
232
- const payload = {};
233
- if (reasoning !== void 0) payload.reasoning = reasoning;
234
- if (title !== void 0) payload.title = title;
235
- if (description !== void 0) payload.description = description;
236
- const serverInfo = lastGlobalServerInfo ?? await isServerAlive();
237
- if (!serverInfo || !lastGlobalSessionId) {
238
- return {
239
- content: [
240
- {
241
- type: "text",
242
- text: "No DiffPrism session is running. Use `open_review` to start a review."
243
- }
244
- ]
245
- };
246
- }
205
+ async ({ session_id, repo_path, wait, timeout }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
206
+ if (!wait) {
247
207
  const response = await fetch(
248
- `http://localhost:${serverInfo.httpPort}/api/reviews/${lastGlobalSessionId}/context`,
249
- {
250
- method: "POST",
251
- headers: { "Content-Type": "application/json" },
252
- body: JSON.stringify(payload)
253
- }
208
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/result`
254
209
  );
255
210
  if (!response.ok) {
256
- throw new Error(`Server returned ${response.status}`);
211
+ throw new Error(`Session not found: ${sessionId}`);
257
212
  }
258
- return {
259
- content: [
260
- {
261
- type: "text",
262
- text: "Context updated in DiffPrism session."
263
- }
264
- ]
265
- };
213
+ const { result } = await response.json();
214
+ return result ? jsonResult(result) : jsonResult({ status: "pending", sessionId, message: "No decision yet." });
215
+ }
216
+ try {
217
+ return jsonResult(await waitForDecision(serverInfo, sessionId, Math.min(timeout ?? 300, 600) * 1e3));
266
218
  } catch (err) {
267
- const message = err instanceof Error ? err.message : String(err);
268
- return {
269
- content: [
270
- {
271
- type: "text",
272
- text: `Error updating review context: ${message}`
273
- }
274
- ],
275
- isError: true
276
- };
219
+ if (err instanceof ReviewerAskedError) {
220
+ return reviewerAskedResult(err, "call get_review_result with wait: true again");
221
+ }
222
+ if (err instanceof ReviewTimeoutError) {
223
+ return jsonResult({
224
+ status: "pending",
225
+ sessionId,
226
+ message: "Still no decision; the review remains open in the reviewer's browser. Call get_review_result with wait: true again rather than asking the user \u2014 their decision is the answer."
227
+ });
228
+ }
229
+ throw err;
277
230
  }
278
- }
231
+ })
279
232
  );
280
233
  server.tool(
281
- "get_review_result",
282
- "Fetch the most recent review result from a DiffPrism session. Returns the reviewer's decision and comments if a review has been submitted, or a message indicating no pending result. Use wait=true to block until a result is available \u2014 this is the standard way to wait for a reviewer's decision after calling open_review.",
234
+ "update_review_context",
235
+ "Push reasoning, title, or description to an open review without opening a new one. Returns immediately.",
283
236
  {
284
- wait: z.boolean().optional().describe("If true, poll until a review result is available (blocks up to timeout)"),
285
- timeout: z.number().optional().describe("Max wait time in seconds when wait=true (default: 300, max: 600)")
237
+ ...targetParams,
238
+ reasoning: z.string().optional().describe("Agent reasoning about the current changes"),
239
+ title: z.string().optional().describe("Updated title for the review"),
240
+ description: z.string().optional().describe("Updated description of the changes")
286
241
  },
287
- async ({ wait, timeout }) => {
288
- try {
289
- const maxWaitMs = Math.min(timeout ?? 300, 600) * 1e3;
290
- const pollIntervalMs = 2e3;
291
- const serverInfo = lastGlobalServerInfo ?? await isServerAlive();
292
- if (!serverInfo || !lastGlobalSessionId) {
293
- return {
294
- content: [
295
- {
296
- type: "text",
297
- text: "No DiffPrism session is running. Use `open_review` to start a review."
298
- }
299
- ]
300
- };
301
- }
302
- if (wait) {
303
- const start = Date.now();
304
- while (Date.now() - start < maxWaitMs) {
305
- const response2 = await fetch(
306
- `http://localhost:${serverInfo.httpPort}/api/reviews/${lastGlobalSessionId}/result`
307
- );
308
- if (response2.ok) {
309
- const data = await response2.json();
310
- if (data.result) {
311
- return {
312
- content: [
313
- {
314
- type: "text",
315
- text: JSON.stringify(data.result, null, 2)
316
- }
317
- ]
318
- };
319
- }
320
- }
321
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
322
- }
323
- return {
324
- content: [
325
- {
326
- type: "text",
327
- text: "No review result received within timeout."
328
- }
329
- ]
330
- };
242
+ async ({ session_id, repo_path, reasoning, title, description }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
243
+ const payload = {};
244
+ if (reasoning !== void 0) payload.reasoning = reasoning;
245
+ if (title !== void 0) payload.title = title;
246
+ if (description !== void 0) payload.description = description;
247
+ const response = await fetch(
248
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/context`,
249
+ {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify(payload)
331
253
  }
332
- const response = await fetch(
333
- `http://localhost:${serverInfo.httpPort}/api/reviews/${lastGlobalSessionId}/result`
334
- );
335
- if (response.ok) {
336
- const data = await response.json();
337
- if (data.result) {
338
- return {
339
- content: [
340
- {
341
- type: "text",
342
- text: JSON.stringify(data.result, null, 2)
343
- }
344
- ]
345
- };
346
- }
347
- }
348
- return {
349
- content: [
350
- {
351
- type: "text",
352
- text: "No pending review result."
353
- }
354
- ]
355
- };
356
- } catch (err) {
357
- const message = err instanceof Error ? err.message : String(err);
358
- return {
359
- content: [
360
- {
361
- type: "text",
362
- text: `Error reading review result: ${message}`
363
- }
364
- ],
365
- isError: true
366
- };
254
+ );
255
+ if (!response.ok) {
256
+ return toolError(`Error updating review context: server returned ${response.status}`);
367
257
  }
368
- }
258
+ return jsonResult({ sessionId, updated: Object.keys(payload) });
259
+ })
369
260
  );
370
261
  server.tool(
371
262
  "get_diff",
372
263
  "Get a structured diff (DiffSet) for local git changes. Returns file-level and hunk-level change data as JSON without opening a browser. Use this to inspect what changed before deciding whether to open a full review.",
373
264
  {
374
- diff_ref: z.string().describe(
375
- 'Git diff reference: "staged", "unstaged", "working-copy" (staged+unstaged grouped), or a ref range like "HEAD~3..HEAD"'
376
- )
265
+ diff_ref: diffRefParam
377
266
  },
378
- async ({ diff_ref }) => {
267
+ async ({ diff_ref = DEFAULT_DIFF_REF }) => {
379
268
  try {
380
- const cwd = process.cwd();
381
- const { diffSet } = getDiff(diff_ref, { cwd });
382
- return {
383
- content: [
384
- {
385
- type: "text",
386
- text: JSON.stringify(diffSet, null, 2)
387
- }
388
- ]
389
- };
269
+ const { diffSet } = getDiff(diff_ref, { cwd: process.cwd() });
270
+ return jsonResult(diffSet);
390
271
  } catch (err) {
391
- const message = err instanceof Error ? err.message : String(err);
392
- return {
393
- content: [
394
- {
395
- type: "text",
396
- text: `Error: ${message}`
397
- }
398
- ],
399
- isError: true
400
- };
272
+ return toolError(`Error: ${errorMessage(err)}`);
401
273
  }
402
274
  }
403
275
  );
@@ -405,705 +277,302 @@ async function startMcpServer() {
405
277
  "analyze_diff",
406
278
  "Analyze local git changes and return a ReviewBriefing with summary, file triage (critical/notable/mechanical), impact detection (affected modules, tests, dependencies, breaking changes), complexity scores, test coverage gaps, and pattern flags (security issues, TODOs, console.logs). Same analysis shown in the DiffPrism briefing bar, but returned as JSON without opening a browser.",
407
279
  {
408
- diff_ref: z.string().describe(
409
- 'Git diff reference: "staged", "unstaged", "working-copy" (staged+unstaged grouped), or a ref range like "HEAD~3..HEAD"'
410
- )
280
+ diff_ref: diffRefParam
411
281
  },
412
- async ({ diff_ref }) => {
282
+ async ({ diff_ref = DEFAULT_DIFF_REF }) => {
413
283
  try {
414
- const cwd = process.cwd();
415
- const { diffSet } = getDiff(diff_ref, { cwd });
284
+ const { diffSet } = getDiff(diff_ref, { cwd: process.cwd() });
416
285
  if (diffSet.files.length === 0) {
417
- return {
418
- content: [
419
- {
420
- type: "text",
421
- text: JSON.stringify({
422
- summary: "No changes to analyze.",
423
- triage: { critical: [], notable: [], mechanical: [] },
424
- impact: {
425
- affectedModules: [],
426
- affectedTests: [],
427
- publicApiChanges: false,
428
- breakingChanges: [],
429
- newDependencies: []
430
- },
431
- verification: { testsPass: null, typeCheck: null, lintClean: null },
432
- fileStats: []
433
- }, null, 2)
434
- }
435
- ]
436
- };
286
+ return jsonResult({
287
+ summary: "No changes to analyze.",
288
+ triage: { critical: [], notable: [], mechanical: [] },
289
+ impact: {
290
+ affectedModules: [],
291
+ affectedTests: [],
292
+ publicApiChanges: false,
293
+ breakingChanges: [],
294
+ newDependencies: []
295
+ },
296
+ verification: { testsPass: null, typeCheck: null, lintClean: null },
297
+ fileStats: []
298
+ });
437
299
  }
438
- const briefing = analyze(diffSet);
439
- return {
440
- content: [
441
- {
442
- type: "text",
443
- text: JSON.stringify(briefing, null, 2)
444
- }
445
- ]
446
- };
300
+ return jsonResult(analyze(diffSet));
447
301
  } catch (err) {
448
- const message = err instanceof Error ? err.message : String(err);
449
- return {
450
- content: [
451
- {
452
- type: "text",
453
- text: `Error: ${message}`
454
- }
455
- ],
456
- isError: true
457
- };
302
+ return toolError(`Error: ${errorMessage(err)}`);
458
303
  }
459
304
  }
460
305
  );
461
306
  server.tool(
462
- "add_annotation",
463
- "Post a structured finding (annotation) to a review session. Use this to flag issues, suggest improvements, or ask questions about specific lines of code in a review. Requires a running global server (`diffprism server`).",
307
+ "annotate",
308
+ "Post findings to an open review. They appear inline on the diff in the DiffPrism dashboard in real time. Use type 'warning' for anything the reviewer must look at \u2014 warnings flag the session in the sidebar. Accepts one or many findings.",
464
309
  {
465
- session_id: z.string().describe("Review session ID from open_review"),
466
- file: z.string().describe("File path within the diff to annotate"),
467
- line: z.number().describe("Line number to annotate"),
468
- body: z.string().describe("The annotation text \u2014 your finding, suggestion, or question"),
469
- type: z.enum(["finding", "suggestion", "question", "warning"]).describe("Type of annotation"),
470
- confidence: z.number().min(0).max(1).optional().describe("Confidence in the finding (0-1, defaults to 1)"),
471
- category: z.enum([
472
- "security",
473
- "performance",
474
- "convention",
475
- "correctness",
476
- "complexity",
477
- "test-coverage",
478
- "documentation",
479
- "other"
480
- ]).optional().describe("Category of the finding (defaults to 'other')"),
481
- source_agent: z.string().optional().describe("Agent identifier (e.g., 'security-reviewer')")
310
+ ...targetParams,
311
+ annotations: z.array(annotationSchema).min(1).describe("One or more findings to post"),
312
+ source_agent: z.string().optional().describe("Who is posting these, e.g. 'security-reviewer'")
482
313
  },
483
- async ({
484
- session_id,
485
- file,
486
- line,
487
- body,
488
- type,
489
- confidence,
490
- category,
491
- source_agent
492
- }) => {
493
- try {
494
- const serverInfo = await isServerAlive();
495
- if (!serverInfo) {
496
- return {
497
- content: [
498
- {
499
- type: "text",
500
- text: "No global server running. Start one with `diffprism server`."
501
- }
502
- ],
503
- isError: true
504
- };
505
- }
314
+ async ({ session_id, repo_path, annotations, source_agent }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
315
+ const annotationIds = [];
316
+ const failed = [];
317
+ for (const annotation of annotations) {
318
+ const line = annotation.line ?? 1;
506
319
  const response = await fetch(
507
- `http://localhost:${serverInfo.httpPort}/api/reviews/${session_id}/annotations`,
320
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`,
508
321
  {
509
322
  method: "POST",
510
323
  headers: { "Content-Type": "application/json" },
511
324
  body: JSON.stringify({
512
- file,
325
+ file: annotation.file,
513
326
  line,
514
- body,
515
- type,
516
- confidence: confidence ?? 1,
517
- category: category ?? "other",
518
- source: {
519
- agent: source_agent ?? "unknown",
520
- tool: "add_annotation"
521
- }
327
+ body: annotation.body,
328
+ type: annotation.type,
329
+ confidence: annotation.confidence ?? 1,
330
+ category: annotation.category ?? "other",
331
+ source: { agent: source_agent ?? "unknown", tool: "annotate" }
522
332
  })
523
333
  }
524
334
  );
525
- if (!response.ok) {
526
- const errorData = await response.json().catch(() => ({}));
527
- const errorMsg = errorData.error ?? `Server returned ${response.status}`;
528
- return {
529
- content: [
530
- {
531
- type: "text",
532
- text: `Error: ${errorMsg}`
533
- }
534
- ],
535
- isError: true
536
- };
335
+ if (response.ok) {
336
+ annotationIds.push((await response.json()).annotationId);
337
+ } else {
338
+ const data = await response.json().catch(() => ({}));
339
+ failed.push({
340
+ file: annotation.file,
341
+ line,
342
+ error: data.error ?? `Server returned ${response.status}`
343
+ });
537
344
  }
538
- const data = await response.json();
539
- return {
540
- content: [
541
- {
542
- type: "text",
543
- text: JSON.stringify(
544
- { annotationId: data.annotationId, sessionId: session_id },
545
- null,
546
- 2
547
- )
548
- }
549
- ]
550
- };
551
- } catch (err) {
552
- const message = err instanceof Error ? err.message : String(err);
553
- return {
554
- content: [
555
- {
556
- type: "text",
557
- text: `Error: ${message}`
558
- }
559
- ],
560
- isError: true
561
- };
562
345
  }
563
- }
346
+ const result = { sessionId, annotationIds, ...failed.length > 0 ? { failed } : {} };
347
+ return annotationIds.length === 0 ? { ...jsonResult(result), isError: true } : jsonResult(result);
348
+ })
564
349
  );
565
350
  server.tool(
566
351
  "get_review_state",
567
- "Get the current state of a review session including session summary and annotations. Returns session metadata, status, and any agent annotations. Use this to check on a review's progress or read agent findings.",
568
- {
569
- session_id: z.string().optional().describe(
570
- "Review session ID. If omitted, uses the most recently created session."
571
- )
572
- },
573
- async ({ session_id }) => {
574
- try {
575
- const sessionId = session_id ?? lastGlobalSessionId;
576
- if (!sessionId) {
577
- return {
578
- content: [
579
- {
580
- type: "text",
581
- text: "No session ID provided and no recent session available."
582
- }
583
- ],
584
- isError: true
585
- };
586
- }
587
- const serverInfo = await isServerAlive();
588
- if (!serverInfo) {
589
- return {
590
- content: [
591
- {
592
- type: "text",
593
- text: "No global server running. Start one with `diffprism server`."
594
- }
595
- ],
596
- isError: true
597
- };
598
- }
599
- const [sessionResponse, annotationsResponse] = await Promise.all([
600
- fetch(
601
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}`
602
- ),
603
- fetch(
604
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`
605
- )
606
- ]);
607
- if (!sessionResponse.ok) {
608
- return {
609
- content: [
610
- {
611
- type: "text",
612
- text: `Session not found: ${sessionId}`
613
- }
614
- ],
615
- isError: true
616
- };
617
- }
618
- const session = await sessionResponse.json();
619
- const annotations = annotationsResponse.ok ? await annotationsResponse.json() : { annotations: [] };
620
- return {
621
- content: [
622
- {
623
- type: "text",
624
- text: JSON.stringify(
625
- {
626
- session,
627
- annotations: annotations.annotations
628
- },
629
- null,
630
- 2
631
- )
632
- }
633
- ]
634
- };
635
- } catch (err) {
636
- const message = err instanceof Error ? err.message : String(err);
637
- return {
638
- content: [
639
- {
640
- type: "text",
641
- text: `Error: ${message}`
642
- }
643
- ],
644
- isError: true
645
- };
352
+ "Get the state of an open review: session summary (status, decision, whether it has new changes or needs attention) and all annotations.",
353
+ { ...targetParams },
354
+ async ({ session_id, repo_path }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
355
+ const [sessionResponse, annotationsResponse] = await Promise.all([
356
+ fetch(`http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}`),
357
+ fetch(`http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`)
358
+ ]);
359
+ if (!sessionResponse.ok) {
360
+ return toolError(`Session not found: ${sessionId}`);
646
361
  }
647
- }
362
+ const session = await sessionResponse.json();
363
+ const { annotations } = annotationsResponse.ok ? await annotationsResponse.json() : { annotations: [] };
364
+ return jsonResult({ session, annotations });
365
+ })
648
366
  );
649
367
  server.tool(
650
- "flag_for_attention",
651
- "Mark specific files in a review session for human attention. Posts warning annotations for each flagged file. Use this to highlight files that need careful human review. Requires a running global server (`diffprism server`).",
368
+ "get_review_comments",
369
+ "Get every thread on an open review \u2014 agent findings and the reviewer's comments, each with its replies. `awaitingReply` marks threads where the reviewer spoke last and nobody has answered. Read these before adding your own.",
652
370
  {
653
- session_id: z.string().optional().describe(
654
- "Review session ID. If omitted, uses the most recently created session."
655
- ),
656
- files: z.array(
657
- z.object({
658
- path: z.string().describe("File path to flag for attention"),
659
- reason: z.string().describe("Why this file needs human attention"),
660
- line: z.number().optional().describe("Specific line to highlight (defaults to 1)")
661
- })
662
- ).describe("Files to flag for human attention"),
663
- source_agent: z.string().optional().describe("Agent identifier (e.g., 'security-reviewer')")
371
+ ...targetParams,
372
+ awaiting_reply: z.boolean().optional().describe("Only return threads waiting for an agent to reply")
664
373
  },
665
- async ({ session_id, files, source_agent }) => {
666
- try {
667
- const sessionId = session_id ?? lastGlobalSessionId;
668
- if (!sessionId) {
669
- return {
670
- content: [
671
- {
672
- type: "text",
673
- text: "No session ID provided and no recent session available."
674
- }
675
- ],
676
- isError: true
677
- };
678
- }
679
- const serverInfo = await isServerAlive();
680
- if (!serverInfo) {
681
- return {
682
- content: [
683
- {
684
- type: "text",
685
- text: "No global server running. Start one with `diffprism server`."
686
- }
687
- ],
688
- isError: true
689
- };
690
- }
691
- let flagged = 0;
692
- for (const file of files) {
693
- const response = await fetch(
694
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`,
695
- {
696
- method: "POST",
697
- headers: { "Content-Type": "application/json" },
698
- body: JSON.stringify({
699
- file: file.path,
700
- line: file.line ?? 1,
701
- body: file.reason,
702
- type: "warning",
703
- confidence: 1,
704
- category: "other",
705
- source: {
706
- agent: source_agent ?? "flag_for_attention",
707
- tool: "flag_for_attention"
708
- }
709
- })
710
- }
711
- );
712
- if (response.ok) {
713
- flagged++;
714
- }
715
- }
716
- return {
717
- content: [
718
- {
719
- type: "text",
720
- text: JSON.stringify({ flagged, sessionId }, null, 2)
721
- }
722
- ]
723
- };
724
- } catch (err) {
725
- const message = err instanceof Error ? err.message : String(err);
726
- return {
727
- content: [
728
- {
729
- type: "text",
730
- text: `Error: ${message}`
731
- }
732
- ],
733
- isError: true
734
- };
374
+ async ({ session_id, repo_path, awaiting_reply }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
375
+ const threads = await readThreads(serverInfo, sessionId);
376
+ if (!threads) {
377
+ return toolError(`Session not found: ${sessionId}`);
735
378
  }
736
- }
379
+ const annotations = awaiting_reply ? threads.filter((t) => t.awaitingReply) : threads;
380
+ return jsonResult({ sessionId, annotations });
381
+ })
737
382
  );
738
383
  server.tool(
739
- "get_pr_context",
740
- "Get a high-level overview of the active PR review session. Returns PR metadata (title, author, branches, URL), review briefing summary, file list with stats, and local repo path. Use this to orient yourself before diving into specific files.",
384
+ "reply",
385
+ "Reply to a thread on an open review \u2014 answer the reviewer's question, or follow up on a finding. The reply appears under the thread in the dashboard straight away.",
741
386
  {
742
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
387
+ ...targetParams,
388
+ annotation_id: z.string().describe("The thread to reply to (an annotation id from get_review_comments or wait_for_comments)"),
389
+ body: z.string().describe("Your reply"),
390
+ source_agent: z.string().optional().describe("Who is replying, e.g. 'pr-reviewer'")
743
391
  },
744
- async ({ session_id }) => {
745
- try {
746
- const serverInfo = await isServerAlive();
747
- if (!serverInfo) {
748
- return {
749
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
750
- isError: true
751
- };
752
- }
753
- const sessionId = await resolveSessionId(session_id, serverInfo);
754
- if (!sessionId) {
755
- return {
756
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
757
- isError: true
758
- };
759
- }
760
- const response = await fetch(
761
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
762
- );
763
- if (!response.ok) {
764
- return {
765
- content: [{ type: "text", text: `Session not found: ${sessionId}` }],
766
- isError: true
767
- };
392
+ async ({ session_id, repo_path, annotation_id, body, source_agent }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
393
+ const response = await fetch(
394
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations/${annotation_id}/replies`,
395
+ {
396
+ method: "POST",
397
+ headers: { "Content-Type": "application/json" },
398
+ body: JSON.stringify({ author: "agent", agent: source_agent ?? "unknown", body })
768
399
  }
769
- const data = await response.json();
770
- const { payload, projectPath } = data;
771
- const result = {
772
- sessionId,
773
- projectPath,
774
- localRepoConnected: !projectPath.startsWith("github:"),
775
- pr: payload.metadata.githubPr ?? null,
776
- title: payload.metadata.title,
777
- description: payload.metadata.description,
778
- briefingSummary: payload.briefing.summary,
779
- triage: payload.briefing.triage,
780
- files: payload.diffSet.files.map((f) => ({
781
- path: f.path,
782
- status: f.status,
783
- additions: f.additions,
784
- deletions: f.deletions,
785
- language: f.language
786
- })),
787
- totalFiles: payload.diffSet.files.length
788
- };
789
- return {
790
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
791
- };
792
- } catch (err) {
793
- const message = err instanceof Error ? err.message : String(err);
794
- return {
795
- content: [{ type: "text", text: `Error: ${message}` }],
796
- isError: true
797
- };
400
+ );
401
+ const data = await response.json().catch(() => ({}));
402
+ if (!response.ok) {
403
+ return toolError(`Error: ${data.error ?? `server returned ${response.status}`}`);
798
404
  }
799
- }
405
+ return jsonResult({ sessionId, annotationId: annotation_id, replyId: data.replyId });
406
+ })
800
407
  );
801
408
  server.tool(
802
- "get_file_diff",
803
- "Get the diff hunks for a specific file in the active review session. Returns the file's changes (additions, deletions, hunks with line-level detail) and its briefing categorization. Use this to focus on one file at a time.",
409
+ "wait_for_comments",
410
+ "Wait for the reviewer to write something you haven't answered \u2014 a new comment on a line, or a reply in a thread. Returns those threads as soon as there are any. Use it to hold a conversation during a PR review: wait, answer each thread with `reply`, then wait again. If it returns `timed_out`, nothing new was said; wait again rather than asking the user in the terminal.",
804
411
  {
805
- file: z.string().describe("File path within the diff (e.g., 'src/index.ts')"),
806
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
412
+ ...targetParams,
413
+ timeout: z.number().optional().describe(`Max wait in seconds (default ${DEFAULT_WAIT_MS / 1e3}, max 600)`)
807
414
  },
808
- async ({ file, session_id }) => {
809
- try {
810
- const serverInfo = await isServerAlive();
811
- if (!serverInfo) {
812
- return {
813
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
814
- isError: true
815
- };
816
- }
817
- const sessionId = await resolveSessionId(session_id, serverInfo);
818
- if (!sessionId) {
819
- return {
820
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
821
- isError: true
822
- };
823
- }
824
- const response = await fetch(
825
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
826
- );
827
- if (!response.ok) {
828
- return {
829
- content: [{ type: "text", text: `Session not found: ${sessionId}` }],
830
- isError: true
831
- };
832
- }
833
- const data = await response.json();
834
- const diffFile = data.payload.diffSet.files.find((f) => f.path === file);
835
- if (!diffFile) {
836
- const available = data.payload.diffSet.files.map((f) => f.path);
837
- return {
838
- content: [{ type: "text", text: `File not found in diff: "${file}". Available files:
839
- ${available.join("\n")}` }],
840
- isError: true
841
- };
415
+ async ({ session_id, repo_path, timeout }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
416
+ const maxWaitMs = Math.min(timeout ?? DEFAULT_WAIT_MS / 1e3, 600) * 1e3;
417
+ const start = Date.now();
418
+ while (true) {
419
+ const threads = await readThreads(serverInfo, sessionId);
420
+ if (!threads) {
421
+ return toolError(`Session not found: ${sessionId}`);
422
+ }
423
+ const waiting = threads.filter((t) => t.awaitingReply);
424
+ if (waiting.length > 0) {
425
+ return jsonResult({ sessionId, threads: waiting });
426
+ }
427
+ if (Date.now() - start >= maxWaitMs) {
428
+ return jsonResult({
429
+ status: "timed_out",
430
+ sessionId,
431
+ message: "The reviewer hasn't written anything new. Call wait_for_comments again to keep listening \u2014 don't ask them in the terminal."
432
+ });
842
433
  }
843
- const { triage } = data.payload.briefing;
844
- let category = "mechanical";
845
- if (triage.critical.some((c) => c.file === file)) category = "critical";
846
- else if (triage.notable.some((n) => n.file === file)) category = "notable";
847
- return {
848
- content: [{ type: "text", text: JSON.stringify({
849
- path: diffFile.path,
850
- oldPath: diffFile.oldPath,
851
- status: diffFile.status,
852
- language: diffFile.language,
853
- additions: diffFile.additions,
854
- deletions: diffFile.deletions,
855
- triageCategory: category,
856
- hunks: diffFile.hunks
857
- }, null, 2) }]
858
- };
859
- } catch (err) {
860
- const message = err instanceof Error ? err.message : String(err);
861
- return {
862
- content: [{ type: "text", text: `Error: ${message}` }],
863
- isError: true
864
- };
434
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
865
435
  }
866
- }
436
+ })
867
437
  );
868
438
  server.tool(
869
- "get_file_context",
870
- "Get the full content of a file from the local repository. Uses `git show` to read the file at the PR's head branch without switching branches. Requires the review session to be connected to a local repo (server must be running from within the repo clone).",
871
- {
872
- file: z.string().describe("File path relative to repo root (e.g., 'src/index.ts')"),
873
- ref: z.string().optional().describe("Git ref to read from (e.g., 'origin/main', 'HEAD'). Defaults to the PR's head branch if available, otherwise HEAD."),
874
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
875
- },
876
- async ({ file, ref, session_id }) => {
877
- try {
878
- const serverInfo = await isServerAlive();
879
- if (!serverInfo) {
880
- return {
881
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
882
- isError: true
883
- };
884
- }
885
- const sessionId = await resolveSessionId(session_id, serverInfo);
886
- if (!sessionId) {
887
- return {
888
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
889
- isError: true
890
- };
891
- }
892
- const response = await fetch(
893
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
894
- );
895
- if (!response.ok) {
896
- return {
897
- content: [{ type: "text", text: `Session not found: ${sessionId}` }],
898
- isError: true
899
- };
900
- }
901
- const data = await response.json();
902
- if (data.projectPath.startsWith("github:")) {
903
- return {
904
- content: [{ type: "text", text: "No local repo connected. Run the server from within a local clone of the repository to enable file context." }],
905
- isError: true
906
- };
907
- }
908
- const gitRef = ref ?? (data.payload.metadata.githubPr?.headBranch ? `origin/${data.payload.metadata.githubPr.headBranch}` : "HEAD");
909
- const { execSync } = await import("child_process");
910
- let content;
911
- try {
912
- content = execSync(`git show ${gitRef}:${file}`, {
913
- cwd: data.projectPath,
914
- encoding: "utf-8",
915
- stdio: ["pipe", "pipe", "pipe"],
916
- maxBuffer: 10 * 1024 * 1024
917
- // 10MB
918
- });
919
- } catch {
920
- const fs = await import("fs");
921
- const path = await import("path");
922
- const filePath = path.join(data.projectPath, file);
923
- try {
924
- content = fs.readFileSync(filePath, "utf-8");
925
- } catch {
926
- return {
927
- content: [{ type: "text", text: `File not found: "${file}" (tried git show ${gitRef}:${file} and working tree)` }],
928
- isError: true
929
- };
930
- }
931
- }
932
- return {
933
- content: [{ type: "text", text: JSON.stringify({
934
- file,
935
- ref: gitRef,
936
- projectPath: data.projectPath,
937
- content,
938
- lineCount: content.split("\n").length
939
- }, null, 2) }]
940
- };
941
- } catch (err) {
942
- const message = err instanceof Error ? err.message : String(err);
943
- return {
944
- content: [{ type: "text", text: `Error: ${message}` }],
945
- isError: true
946
- };
439
+ "get_user_focus",
440
+ "Get what the reviewer is looking at right now in the DiffPrism dashboard \u2014 the selected file and any focused line range. Use this to answer questions about the code they are actively reviewing.",
441
+ { ...targetParams },
442
+ async ({ session_id, repo_path }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
443
+ const response = await fetch(
444
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/focus`
445
+ );
446
+ if (!response.ok) {
447
+ return toolError(`Session not found: ${sessionId}`);
947
448
  }
948
- }
449
+ const data = await response.json();
450
+ return jsonResult({ sessionId, ...data });
451
+ })
949
452
  );
950
453
  server.tool(
951
- "add_review_comment",
952
- "Post a review comment to the active session. The comment appears in the DiffPrism browser UI in real-time as an inline annotation on the diff. Use this to leave findings, suggestions, or questions about specific lines of code.",
953
- {
954
- file: z.string().describe("File path within the diff"),
955
- line: z.number().describe("Line number to comment on"),
956
- body: z.string().describe("The comment text"),
957
- type: z.enum(["comment", "suggestion", "concern"]).optional().describe("Type of comment (default: 'comment')"),
958
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
959
- },
960
- async ({ file, line, body, type, session_id }) => {
961
- try {
962
- const serverInfo = await isServerAlive();
963
- if (!serverInfo) {
964
- return {
965
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
966
- isError: true
967
- };
968
- }
969
- const sessionId = await resolveSessionId(session_id, serverInfo);
970
- if (!sessionId) {
971
- return {
972
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
973
- isError: true
974
- };
975
- }
976
- const annotationType = type === "concern" ? "warning" : type === "suggestion" ? "suggestion" : "finding";
977
- const response = await fetch(
978
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`,
979
- {
980
- method: "POST",
981
- headers: { "Content-Type": "application/json" },
982
- body: JSON.stringify({
983
- file,
984
- line,
985
- body,
986
- type: annotationType,
987
- confidence: 1,
988
- category: "other",
989
- source: {
990
- agent: "ai-reviewer",
991
- tool: "add_review_comment"
992
- }
993
- })
994
- }
995
- );
996
- if (!response.ok) {
997
- const errorData = await response.json().catch(() => ({}));
998
- return {
999
- content: [{ type: "text", text: `Error: ${errorData.error ?? `Server returned ${response.status}`}` }],
1000
- isError: true
1001
- };
1002
- }
1003
- const data = await response.json();
1004
- return {
1005
- content: [{ type: "text", text: JSON.stringify({ annotationId: data.annotationId, sessionId }, null, 2) }]
1006
- };
1007
- } catch (err) {
1008
- const message = err instanceof Error ? err.message : String(err);
1009
- return {
1010
- content: [{ type: "text", text: `Error: ${message}` }],
1011
- isError: true
1012
- };
454
+ "get_pr_context",
455
+ "Get an overview of an open PR review: PR metadata (title, author, branches, URL), briefing summary, file list with stats, and the local repo path. Orient yourself with this before reading individual files. Open the PR review first with `diffprism review <PR URL>` or the dashboard.",
456
+ { ...targetParams },
457
+ async ({ session_id, repo_path }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
458
+ const response = await fetch(
459
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
460
+ );
461
+ if (!response.ok) {
462
+ return toolError(`Session not found: ${sessionId}`);
1013
463
  }
1014
- }
464
+ const { payload, projectPath } = await response.json();
465
+ return jsonResult({
466
+ sessionId,
467
+ projectPath,
468
+ localRepoConnected: !projectPath.startsWith("github:"),
469
+ pr: payload.metadata.githubPr ?? null,
470
+ title: payload.metadata.title,
471
+ description: payload.metadata.description,
472
+ briefingSummary: payload.briefing.summary,
473
+ triage: payload.briefing.triage,
474
+ files: payload.diffSet.files.map((f) => ({
475
+ path: f.path,
476
+ status: f.status,
477
+ additions: f.additions,
478
+ deletions: f.deletions,
479
+ language: f.language
480
+ })),
481
+ totalFiles: payload.diffSet.files.length
482
+ });
483
+ })
1015
484
  );
1016
485
  server.tool(
1017
- "get_review_comments",
1018
- "Get all comments and annotations on the active review session. Returns findings from agents and inline comments from human reviewers. Use this to see what has already been noted before adding your own comments.",
486
+ "get_file_diff",
487
+ "Get the diff hunks for one file in an open review, with its triage category. Use this to work through a review one file at a time.",
1019
488
  {
1020
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
489
+ file: z.string().describe("File path within the diff (e.g., 'src/index.ts')"),
490
+ ...targetParams
1021
491
  },
1022
- async ({ session_id }) => {
1023
- try {
1024
- const serverInfo = await isServerAlive();
1025
- if (!serverInfo) {
1026
- return {
1027
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
1028
- isError: true
1029
- };
1030
- }
1031
- const sessionId = await resolveSessionId(session_id, serverInfo);
1032
- if (!sessionId) {
1033
- return {
1034
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
1035
- isError: true
1036
- };
1037
- }
1038
- const response = await fetch(
1039
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/annotations`
1040
- );
1041
- if (!response.ok) {
1042
- return {
1043
- content: [{ type: "text", text: `Session not found: ${sessionId}` }],
1044
- isError: true
1045
- };
1046
- }
1047
- const data = await response.json();
1048
- return {
1049
- content: [{ type: "text", text: JSON.stringify({ sessionId, annotations: data.annotations }, null, 2) }]
1050
- };
1051
- } catch (err) {
1052
- const message = err instanceof Error ? err.message : String(err);
1053
- return {
1054
- content: [{ type: "text", text: `Error: ${message}` }],
1055
- isError: true
1056
- };
492
+ async ({ file, session_id, repo_path }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
493
+ const response = await fetch(
494
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
495
+ );
496
+ if (!response.ok) {
497
+ return toolError(`Session not found: ${sessionId}`);
1057
498
  }
1058
- }
499
+ const data = await response.json();
500
+ const diffFile = data.payload.diffSet.files.find((f) => f.path === file);
501
+ if (!diffFile) {
502
+ const available = data.payload.diffSet.files.map((f) => f.path);
503
+ return toolError(`File not found in diff: "${file}". Available files:
504
+ ${available.join("\n")}`);
505
+ }
506
+ const { triage } = data.payload.briefing;
507
+ let triageCategory = "mechanical";
508
+ if (triage.critical.some((c) => c.file === file)) triageCategory = "critical";
509
+ else if (triage.notable.some((n) => n.file === file)) triageCategory = "notable";
510
+ return jsonResult({
511
+ path: diffFile.path,
512
+ oldPath: diffFile.oldPath,
513
+ status: diffFile.status,
514
+ language: diffFile.language,
515
+ additions: diffFile.additions,
516
+ deletions: diffFile.deletions,
517
+ triageCategory,
518
+ hunks: diffFile.hunks
519
+ });
520
+ })
1059
521
  );
1060
522
  server.tool(
1061
- "get_user_focus",
1062
- "Get what the user is currently looking at in the DiffPrism review UI. Returns the file they have selected and any line range they are focused on. Use this to provide context-aware help \u2014 answer questions about the code the user is actively reviewing.",
523
+ "get_file_context",
524
+ "Get the full content of a file from the review's local repository, read with `git show` at the PR's head branch without switching branches. Needs the review to be connected to a local clone.",
1063
525
  {
1064
- session_id: z.string().optional().describe("Review session ID. If omitted, uses the most recently created session.")
526
+ file: z.string().describe("File path relative to repo root (e.g., 'src/index.ts')"),
527
+ ref: z.string().optional().describe("Git ref to read from (e.g., 'origin/main', 'HEAD'). Defaults to the PR's head branch if available, otherwise HEAD."),
528
+ ...targetParams
1065
529
  },
1066
- async ({ session_id }) => {
1067
- try {
1068
- const serverInfo = await isServerAlive();
1069
- if (!serverInfo) {
1070
- return {
1071
- content: [{ type: "text", text: "No global server running. Start one with `diffprism server`." }],
1072
- isError: true
1073
- };
1074
- }
1075
- const sessionId = await resolveSessionId(session_id, serverInfo);
1076
- if (!sessionId) {
1077
- return {
1078
- content: [{ type: "text", text: "No review session found. Open a PR review first with `diffprism review <PR URL>`." }],
1079
- isError: true
1080
- };
1081
- }
1082
- const response = await fetch(
1083
- `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/focus`
530
+ async ({ file, ref, session_id, repo_path }) => withSession({ session_id, repo_path }, async ({ serverInfo, sessionId }) => {
531
+ const response = await fetch(
532
+ `http://localhost:${serverInfo.httpPort}/api/reviews/${sessionId}/payload`
533
+ );
534
+ if (!response.ok) {
535
+ return toolError(`Session not found: ${sessionId}`);
536
+ }
537
+ const data = await response.json();
538
+ if (data.projectPath.startsWith("github:")) {
539
+ return toolError(
540
+ "No local repo connected. Run the server from within a local clone of the repository to enable file context."
1084
541
  );
1085
- if (!response.ok) {
1086
- return {
1087
- content: [{ type: "text", text: `Session not found: ${sessionId}` }],
1088
- isError: true
1089
- };
542
+ }
543
+ const gitRef = ref ?? (data.payload.metadata.githubPr?.headBranch ? `origin/${data.payload.metadata.githubPr.headBranch}` : "HEAD");
544
+ const { execSync } = await import("child_process");
545
+ let content;
546
+ try {
547
+ content = execSync(`git show ${gitRef}:${file}`, {
548
+ cwd: data.projectPath,
549
+ encoding: "utf-8",
550
+ stdio: ["pipe", "pipe", "pipe"],
551
+ maxBuffer: 10 * 1024 * 1024
552
+ });
553
+ } catch {
554
+ const fs = await import("fs");
555
+ const path = await import("path");
556
+ try {
557
+ content = fs.readFileSync(path.join(data.projectPath, file), "utf-8");
558
+ } catch {
559
+ return toolError(`File not found: "${file}" (tried git show ${gitRef}:${file} and working tree)`);
1090
560
  }
1091
- const data = await response.json();
1092
- return {
1093
- content: [{ type: "text", text: JSON.stringify({ sessionId, ...data }, null, 2) }]
1094
- };
1095
- } catch (err) {
1096
- const message = err instanceof Error ? err.message : String(err);
1097
- return {
1098
- content: [{ type: "text", text: `Error: ${message}` }],
1099
- isError: true
1100
- };
1101
561
  }
1102
- }
562
+ return jsonResult({
563
+ file,
564
+ ref: gitRef,
565
+ projectPath: data.projectPath,
566
+ content,
567
+ lineCount: content.split("\n").length
568
+ });
569
+ })
1103
570
  );
1104
571
  const transport = new StdioServerTransport();
1105
572
  await server.connect(transport);
1106
573
  }
1107
574
  export {
575
+ DEFAULT_WAIT_MS,
576
+ resolveTarget,
1108
577
  startMcpServer
1109
578
  };