@j0hanz/code-review-analyst-mcp 1.5.1 → 1.5.2

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.
@@ -16,9 +16,9 @@ export interface DiffSlot {
16
16
  }
17
17
  /** Call once during server setup so the store can emit resource-updated notifications. */
18
18
  export declare function initDiffStore(server: McpServer): void;
19
- export declare function storeDiff(data: DiffSlot): void;
20
- export declare function getDiff(): DiffSlot | undefined;
21
- export declare function hasDiff(): boolean;
19
+ export declare function storeDiff(data: DiffSlot, key?: string): void;
20
+ export declare function getDiff(key?: string): DiffSlot | undefined;
21
+ export declare function hasDiff(key?: string): boolean;
22
22
  /** Test-only: directly set or clear the diff slot without emitting resource-updated. */
23
- export declare function setDiffForTesting(data: DiffSlot | undefined): void;
23
+ export declare function setDiffForTesting(data: DiffSlot | undefined, key?: string): void;
24
24
  export declare function createNoDiffError(): ReturnType<typeof createErrorToolResponse>;
@@ -1,27 +1,32 @@
1
1
  import { createErrorToolResponse } from './tool-response.js';
2
2
  export const DIFF_RESOURCE_URI = 'diff://current';
3
- let slot;
3
+ const diffSlots = new Map();
4
4
  let sendResourceUpdated;
5
5
  /** Call once during server setup so the store can emit resource-updated notifications. */
6
6
  export function initDiffStore(server) {
7
7
  const inner = server.server;
8
8
  sendResourceUpdated = inner.sendResourceUpdated.bind(inner);
9
9
  }
10
- export function storeDiff(data) {
11
- slot = data;
10
+ export function storeDiff(data, key = process.cwd()) {
11
+ diffSlots.set(key, data);
12
12
  void sendResourceUpdated?.({ uri: DIFF_RESOURCE_URI }).catch(() => {
13
- // Notification is best-effort; never block the tool response.
13
+ // Ignore errors sending resource-updated, which can happen if the server is not fully initialized yet.
14
14
  });
15
15
  }
16
- export function getDiff() {
17
- return slot;
16
+ export function getDiff(key = process.cwd()) {
17
+ return diffSlots.get(key);
18
18
  }
19
- export function hasDiff() {
20
- return slot !== undefined;
19
+ export function hasDiff(key = process.cwd()) {
20
+ return diffSlots.has(key);
21
21
  }
22
22
  /** Test-only: directly set or clear the diff slot without emitting resource-updated. */
23
- export function setDiffForTesting(data) {
24
- slot = data;
23
+ export function setDiffForTesting(data, key = process.cwd()) {
24
+ if (data) {
25
+ diffSlots.set(key, data);
26
+ }
27
+ else {
28
+ diffSlots.delete(key);
29
+ }
25
30
  }
26
31
  export function createNoDiffError() {
27
32
  return createErrorToolResponse('E_NO_DIFF', 'No diff cached. You must call the generate_diff tool before using any review tool. Run generate_diff with mode="unstaged" or mode="staged" to capture the current branch changes, then retry this tool.', undefined, { retryable: false, kind: 'validation' });
@@ -352,8 +352,6 @@ function buildGenerationConfig(request, abortSignal) {
352
352
  responseMimeType: 'application/json',
353
353
  responseSchema: applyResponseKeyOrdering(request.responseSchema, request.responseKeyOrdering),
354
354
  safetySettings: getSafetySettings(getSafetyThreshold()),
355
- topP: 0.95,
356
- topK: 40,
357
355
  abortSignal,
358
356
  };
359
357
  if (request.systemInstruction) {
@@ -416,6 +414,14 @@ async function executeAttempt(request, model, timeoutMs, attempt, onLog) {
416
414
  const response = await generateContentWithTimeout(request, model, timeoutMs);
417
415
  const latencyMs = Math.round(performance.now() - startedAt);
418
416
  const finishReason = response.candidates?.[0]?.finishReason;
417
+ let thoughts;
418
+ const parts = response.candidates?.[0]?.content?.parts;
419
+ if (Array.isArray(parts)) {
420
+ const thoughtParts = parts.filter((p) => p.thought === true && typeof p.text === 'string');
421
+ if (thoughtParts.length > 0) {
422
+ thoughts = thoughtParts.map((p) => p.text).join('\n\n');
423
+ }
424
+ }
419
425
  await emitGeminiLog(onLog, 'info', {
420
426
  event: 'gemini_call',
421
427
  details: {
@@ -423,6 +429,7 @@ async function executeAttempt(request, model, timeoutMs, attempt, onLog) {
423
429
  latencyMs,
424
430
  finishReason: finishReason ?? null,
425
431
  usageMetadata: response.usageMetadata ?? null,
432
+ ...(thoughts ? { thoughts } : {}),
426
433
  },
427
434
  });
428
435
  if (finishReason === FinishReason.MAX_TOKENS) {
@@ -457,33 +464,34 @@ async function waitBeforeRetry(attempt, error, onLog, requestSignal) {
457
464
  throw sleepError;
458
465
  }
459
466
  }
460
- async function throwGeminiFailure(maxRetries, lastError, onLog) {
461
- const attempts = maxRetries + 1;
467
+ async function throwGeminiFailure(attemptsMade, lastError, onLog) {
462
468
  const message = getErrorMessage(lastError);
463
469
  await emitGeminiLog(onLog, 'error', {
464
470
  event: 'gemini_failure',
465
471
  details: {
466
472
  error: message,
467
- attempts,
473
+ attempts: attemptsMade,
468
474
  },
469
475
  });
470
- throw new Error(`Gemini request failed after ${attempts} attempts: ${message}`, { cause: lastError });
476
+ throw new Error(`Gemini request failed after ${attemptsMade} attempts: ${message}`, { cause: lastError });
471
477
  }
472
478
  async function runWithRetries(request, model, timeoutMs, maxRetries, onLog) {
473
479
  let lastError;
474
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
480
+ let attempt = 0;
481
+ for (; attempt <= maxRetries; attempt += 1) {
475
482
  try {
476
483
  return await executeAttempt(request, model, timeoutMs, attempt, onLog);
477
484
  }
478
485
  catch (error) {
479
486
  lastError = error;
480
487
  if (!canRetryAttempt(attempt, maxRetries, error)) {
488
+ attempt += 1; // Count this attempt before breaking
481
489
  break;
482
490
  }
483
491
  await waitBeforeRetry(attempt, error, onLog, request.signal);
484
492
  }
485
493
  }
486
- return throwGeminiFailure(maxRetries, lastError, onLog);
494
+ return throwGeminiFailure(attempt, lastError, onLog);
487
495
  }
488
496
  function canRetryAttempt(attempt, maxRetries, error) {
489
497
  return attempt < maxRetries && shouldRetry(error);
@@ -20,7 +20,7 @@ export declare const PRO_THINKING_LEVEL: "high";
20
20
  /** Output cap for Flash API breaking-change detection. */
21
21
  export declare const FLASH_API_BREAKING_MAX_OUTPUT_TOKENS: 4096;
22
22
  /** Output cap for Flash complexity analysis. */
23
- export declare const FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS: 2048;
23
+ export declare const FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS: 4096;
24
24
  /** Output cap for Flash test-plan generation. */
25
25
  export declare const FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS: 8192;
26
26
  /** Output cap for Flash triage tools. */
@@ -28,7 +28,7 @@ const THINKING_LEVELS = {
28
28
  // Thinking budget in tokens for Flash and Pro tools. Note that these are not hard limits, but rather guidelines to encourage concise responses and manage latency/cost.
29
29
  const OUTPUT_TOKEN_BUDGET = {
30
30
  flashApiBreaking: 4_096,
31
- flashComplexity: 2_048,
31
+ flashComplexity: 4_096,
32
32
  flashTestPlan: 8_192,
33
33
  flashTriage: 4_096,
34
34
  proPatch: 8_192,
@@ -18,9 +18,7 @@ export interface ToolContract {
18
18
  maxOutputTokens: number;
19
19
  /**
20
20
  * Sampling temperature for the Gemini call.
21
- * Lower values (0.0–0.1) favour deterministic structured output;
22
- * higher values (0.2) add diversity for creative synthesis tasks.
23
- * Omit to use the global default (0.2).
21
+ * Gemini 3 recommends 1.0 for all tasks.
24
22
  */
25
23
  temperature?: number;
26
24
  /** Enables deterministic JSON guidance and schema key ordering. */
@@ -210,7 +208,7 @@ export declare const TOOL_CONTRACTS: readonly [{
210
208
  readonly model: "gemini-3-flash-preview";
211
209
  readonly timeoutMs: 90000;
212
210
  readonly thinkingLevel: "medium";
213
- readonly maxOutputTokens: 2048;
211
+ readonly maxOutputTokens: 4096;
214
212
  readonly temperature: 1;
215
213
  readonly deterministicJson: true;
216
214
  readonly params: readonly [{
@@ -55,6 +55,10 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
55
55
  transformResult?: (input: TInput, result: TResult, ctx: ToolExecutionContext) => TFinal;
56
56
  /** Optional validation hook for input parameters. */
57
57
  validateInput?: (input: TInput, ctx: ToolExecutionContext) => Promise<ReturnType<typeof createErrorToolResponse> | undefined> | ReturnType<typeof createErrorToolResponse> | undefined;
58
+ /** Optional flag to enforce diff presence and budget check before tool execution. */
59
+ requiresDiff?: boolean;
60
+ /** Optional override for schema validation retries. Defaults to GEMINI_SCHEMA_RETRIES env var. */
61
+ schemaRetries?: number;
58
62
  /** Optional Gemini model to use (e.g. 'gemini-3-pro-preview'). */
59
63
  model?: string;
60
64
  /** Optional thinking level. */
@@ -65,9 +69,7 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
65
69
  maxOutputTokens?: number;
66
70
  /**
67
71
  * Optional sampling temperature for this tool's Gemini call.
68
- * Lower values (0.0–0.1) favour determinism for structured extraction;
69
- * higher values (0.2) add useful diversity for creative synthesis tasks.
70
- * Falls back to the global default (0.2) when omitted.
72
+ * Gemini 3 recommends 1.0 for all tasks.
71
73
  */
72
74
  temperature?: number;
73
75
  /** Optional opt-in to Gemini thought output. Defaults to false. */
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { DefaultOutputSchema } from '../schemas/outputs.js';
3
- import { getDiff } from './diff-store.js';
3
+ import { validateDiffBudget } from './diff-budget.js';
4
+ import { createNoDiffError, getDiff } from './diff-store.js';
4
5
  import { createCachedEnvInt } from './env-config.js';
5
6
  import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
6
7
  import { stripJsonSchemaConstraints } from './gemini-schema.js';
@@ -14,7 +15,8 @@ const CANCELLED_ERROR_PATTERN = /cancelled|canceled/i;
14
15
  const TIMEOUT_ERROR_PATTERN = /timed out|timeout/i;
15
16
  const BUDGET_ERROR_PATTERN = /exceeds limit|max allowed size|input too large/i;
16
17
  const BUSY_ERROR_PATTERN = /too many concurrent/i;
17
- const MAX_SCHEMA_RETRIES = 1;
18
+ const DEFAULT_SCHEMA_RETRIES = 1;
19
+ const geminiSchemaRetriesConfig = createCachedEnvInt('GEMINI_SCHEMA_RETRIES', DEFAULT_SCHEMA_RETRIES);
18
20
  const DEFAULT_SCHEMA_RETRY_ERROR_CHARS = 1_500;
19
21
  const schemaRetryErrorCharsConfig = createCachedEnvInt('MAX_SCHEMA_RETRY_ERROR_CHARS', DEFAULT_SCHEMA_RETRY_ERROR_CHARS);
20
22
  const DETERMINISTIC_JSON_RETRY_NOTE = 'Deterministic JSON mode: keep key names exactly as schema-defined and preserve stable field ordering.';
@@ -134,30 +136,15 @@ function isRetryableUpstreamMessage(message) {
134
136
  return (RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message) ||
135
137
  BUSY_ERROR_PATTERN.test(message));
136
138
  }
137
- async function sendTaskProgress(extra, payload) {
138
- const progressToken = extra._meta?.progressToken;
139
- if (typeof progressToken !== 'string' && typeof progressToken !== 'number') {
139
+ function ignoreProgressInput(value) {
140
+ if (value === null) {
140
141
  return;
141
142
  }
142
- try {
143
- const params = {
144
- progressToken,
145
- progress: payload.current,
146
- };
147
- if (payload.total !== undefined) {
148
- params.total = payload.total;
149
- }
150
- if (payload.message !== undefined) {
151
- params.message = payload.message;
152
- }
153
- await extra.sendNotification({
154
- method: 'notifications/progress',
155
- params,
156
- });
157
- }
158
- catch {
159
- // Progress is best-effort; never fail the tool call.
160
- }
143
+ }
144
+ function sendTaskProgress(extra, payload) {
145
+ ignoreProgressInput(extra);
146
+ ignoreProgressInput(payload);
147
+ return Promise.resolve();
161
148
  }
162
149
  function createProgressReporter(extra) {
163
150
  let lastCurrent = 0;
@@ -195,21 +182,26 @@ function normalizeProgressContext(context) {
195
182
  return `${compact.slice(0, 77)}...`;
196
183
  }
197
184
  function formatProgressStep(toolName, context, metadata) {
198
- const prefix = metadata === 'starting' ? '▸' : '◦';
199
- return `${prefix} ${toolName}: ${context} [${metadata}]`;
185
+ return `${toolName}: ${context} [${metadata}]`;
200
186
  }
201
187
  function friendlyModelName(model) {
202
188
  if (!model)
203
- return 'model';
204
- if (model.includes('pro'))
205
- return 'Pro';
206
- if (model.includes('flash'))
207
- return 'Flash';
208
- return 'model';
189
+ return 'calling model';
190
+ const normalized = model.toLowerCase();
191
+ if (normalized.includes('pro'))
192
+ return 'calling Pro';
193
+ if (normalized.includes('flash'))
194
+ return 'calling Flash';
195
+ return 'calling model';
196
+ }
197
+ function formatProgressCompletion(toolName, context, outcome) {
198
+ return `🗒 ${toolName}: ${context} • ${outcome}`;
209
199
  }
210
- function formatProgressCompletion(toolName, context, outcome, success = true) {
211
- const prefix = success ? '' : '◇';
212
- return `${prefix} ${toolName}: ${context} • ${outcome}`;
200
+ function createFailureStatusMessage(outcome, errorMessage) {
201
+ if (outcome === 'cancelled') {
202
+ return `cancelled: ${errorMessage}`;
203
+ }
204
+ return errorMessage;
213
205
  }
214
206
  async function reportProgressStepUpdate(reportProgress, toolName, context, current, metadata) {
215
207
  await reportProgress({
@@ -218,13 +210,21 @@ async function reportProgressStepUpdate(reportProgress, toolName, context, curre
218
210
  message: formatProgressStep(toolName, context, metadata),
219
211
  });
220
212
  }
221
- async function reportProgressCompletionUpdate(reportProgress, toolName, context, outcome, success = true) {
213
+ async function reportProgressCompletionUpdate(reportProgress, toolName, context, outcome) {
222
214
  await reportProgress({
223
215
  current: TASK_PROGRESS_TOTAL,
224
216
  total: TASK_PROGRESS_TOTAL,
225
- message: formatProgressCompletion(toolName, context, outcome, success),
217
+ message: formatProgressCompletion(toolName, context, outcome),
226
218
  });
227
219
  }
220
+ async function reportSchemaRetryProgressBestEffort(reportProgress, toolName, context, retryCount, maxRetries) {
221
+ try {
222
+ await reportProgressStepUpdate(reportProgress, toolName, context, 3, `repairing schema retry ${retryCount}/${maxRetries}`);
223
+ }
224
+ catch {
225
+ // Progress updates are best-effort and must not interrupt retries.
226
+ }
227
+ }
228
228
  function toLoggingLevel(level) {
229
229
  switch (level) {
230
230
  case 'debug':
@@ -277,21 +277,23 @@ export function wrapToolHandler(options, handler) {
277
277
  const result = await handler(input, extra);
278
278
  // End progress (1/1)
279
279
  const outcome = result.isError ? 'failed' : 'completed';
280
- const success = !result.isError;
281
280
  await sendTaskProgress(extra, {
282
281
  current: 1,
283
282
  total: 1,
284
- message: formatProgressCompletion(options.toolName, context, outcome, success),
283
+ message: formatProgressCompletion(options.toolName, context, outcome),
285
284
  });
286
285
  return result;
287
286
  }
288
287
  catch (error) {
288
+ const errorMessage = getErrorMessage(error);
289
+ const failureMeta = classifyErrorMeta(error, errorMessage);
290
+ const outcome = failureMeta.kind === 'cancelled' ? 'cancelled' : 'failed';
289
291
  // Progress is best-effort; must never mask the original error.
290
292
  try {
291
293
  await sendTaskProgress(extra, {
292
294
  current: 1,
293
295
  total: 1,
294
- message: formatProgressCompletion(options.toolName, context, 'failed', false),
296
+ message: formatProgressCompletion(options.toolName, context, outcome),
295
297
  });
296
298
  }
297
299
  catch {
@@ -301,6 +303,21 @@ export function wrapToolHandler(options, handler) {
301
303
  }
302
304
  };
303
305
  }
306
+ async function validateRequest(config, inputRecord, ctx) {
307
+ if (config.requiresDiff) {
308
+ if (!ctx.diffSlot) {
309
+ return createNoDiffError();
310
+ }
311
+ const budgetError = validateDiffBudget(ctx.diffSlot.diff);
312
+ if (budgetError) {
313
+ return budgetError;
314
+ }
315
+ }
316
+ if (config.validateInput) {
317
+ return await config.validateInput(inputRecord, ctx);
318
+ }
319
+ return undefined;
320
+ }
304
321
  export function registerStructuredToolTask(server, config) {
305
322
  const responseSchema = createGeminiResponseSchema({
306
323
  geminiSchema: config.geminiSchema,
@@ -350,18 +367,16 @@ export function registerStructuredToolTask(server, config) {
350
367
  // could replace the slot and silently bypass the budget check.
351
368
  const ctx = { diffSlot: getDiff() };
352
369
  await reportProgressStepUpdate(reportProgress, config.name, progressContext, 0, 'starting');
353
- if (config.validateInput) {
354
- const validationError = await config.validateInput(inputRecord, ctx);
355
- if (validationError) {
356
- const validationMessage = validationError.structuredContent.error?.message ??
357
- INPUT_VALIDATION_FAILED;
358
- await updateStatusMessage(validationMessage);
359
- await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, 'rejected', false);
360
- await storeResultSafely('completed', validationError);
361
- return;
362
- }
370
+ const validationError = await validateRequest(config, inputRecord, ctx);
371
+ if (validationError) {
372
+ const validationMessage = validationError.structuredContent.error?.message ??
373
+ INPUT_VALIDATION_FAILED;
374
+ await updateStatusMessage(validationMessage);
375
+ await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, 'rejected');
376
+ await storeResultSafely('completed', validationError);
377
+ return;
363
378
  }
364
- await reportProgressStepUpdate(reportProgress, config.name, progressContext, 1, 'preparing');
379
+ await reportProgressStepUpdate(reportProgress, config.name, progressContext, 1, 'building prompt');
365
380
  const promptParts = config.buildPrompt(inputRecord, ctx);
366
381
  const { prompt } = promptParts;
367
382
  const { systemInstruction } = promptParts;
@@ -369,18 +384,18 @@ export function registerStructuredToolTask(server, config) {
369
384
  await reportProgressStepUpdate(reportProgress, config.name, progressContext, 2, modelLabel);
370
385
  let parsed;
371
386
  let retryPrompt = prompt;
372
- for (let attempt = 0; attempt <= MAX_SCHEMA_RETRIES; attempt += 1) {
387
+ const maxRetries = config.schemaRetries ?? geminiSchemaRetriesConfig.get();
388
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
373
389
  try {
374
390
  const raw = await generateStructuredJson(createGenerationRequest(config, { systemInstruction, prompt: retryPrompt }, responseSchema, onLog, extra.signal));
375
391
  if (attempt === 0) {
376
- await reportProgressStepUpdate(reportProgress, config.name, progressContext, 3, 'processing response');
392
+ await reportProgressStepUpdate(reportProgress, config.name, progressContext, 3, 'validating response');
377
393
  }
378
394
  parsed = config.resultSchema.parse(raw);
379
395
  break;
380
396
  }
381
397
  catch (error) {
382
- if (attempt >= MAX_SCHEMA_RETRIES ||
383
- !(error instanceof z.ZodError)) {
398
+ if (attempt >= maxRetries || !(error instanceof z.ZodError)) {
384
399
  throw error;
385
400
  }
386
401
  const errorMessage = getErrorMessage(error);
@@ -393,6 +408,8 @@ export function registerStructuredToolTask(server, config) {
393
408
  originalChars: errorMessage.length,
394
409
  },
395
410
  });
411
+ const retryCount = attempt + 1;
412
+ await reportSchemaRetryProgressBestEffort(reportProgress, config.name, progressContext, retryCount, maxRetries);
396
413
  retryPrompt = schemaRetryPrompt.prompt;
397
414
  }
398
415
  }
@@ -415,9 +432,10 @@ export function registerStructuredToolTask(server, config) {
415
432
  catch (error) {
416
433
  const errorMessage = getErrorMessage(error);
417
434
  const errorMeta = classifyErrorMeta(error, errorMessage);
418
- await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, 'failed', false);
419
- await updateStatusMessage(errorMessage);
435
+ const outcome = errorMeta.kind === 'cancelled' ? 'cancelled' : 'failed';
436
+ await updateStatusMessage(createFailureStatusMessage(outcome, errorMessage));
420
437
  await storeResultSafely('failed', createErrorToolResponse(config.errorCode, errorMessage, undefined, errorMeta));
438
+ await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, outcome);
421
439
  }
422
440
  };
423
441
  queueMicrotask(() => {
@@ -1,5 +1,3 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
- import { createNoDiffError } from '../lib/diff-store.js';
3
1
  import { requireToolContract } from '../lib/tool-contracts.js';
4
2
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
5
3
  import { AnalyzeComplexityInputSchema } from '../schemas/inputs.js';
@@ -32,12 +30,7 @@ export function registerAnalyzeComplexityTool(server) {
32
30
  ...(TOOL_CONTRACT.deterministicJson !== undefined
33
31
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
34
32
  : undefined),
35
- validateInput: (_input, ctx) => {
36
- const slot = ctx.diffSlot;
37
- if (!slot)
38
- return createNoDiffError();
39
- return validateDiffBudget(slot.diff);
40
- },
33
+ requiresDiff: true,
41
34
  formatOutcome: (result) => result.isDegradation
42
35
  ? 'Performance degradation detected'
43
36
  : 'No degradation',
@@ -1,6 +1,4 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
1
  import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff-parser.js';
3
- import { createNoDiffError } from '../lib/diff-store.js';
4
2
  import { requireToolContract } from '../lib/tool-contracts.js';
5
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
6
4
  import { AnalyzePrImpactInputSchema } from '../schemas/inputs.js';
@@ -36,12 +34,7 @@ export function registerAnalyzePrImpactTool(server) {
36
34
  ...(TOOL_CONTRACT.deterministicJson !== undefined
37
35
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
38
36
  : undefined),
39
- validateInput: (_input, ctx) => {
40
- const slot = ctx.diffSlot;
41
- if (!slot)
42
- return createNoDiffError();
43
- return validateDiffBudget(slot.diff);
44
- },
37
+ requiresDiff: true,
45
38
  formatOutcome: (result) => `severity: ${result.severity}`,
46
39
  formatOutput: (result) => `Impact Analysis (${result.severity}): ${result.summary}`,
47
40
  buildPrompt: (input, ctx) => {
@@ -1,5 +1,3 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
- import { createNoDiffError } from '../lib/diff-store.js';
3
1
  import { requireToolContract } from '../lib/tool-contracts.js';
4
2
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
5
3
  import { DetectApiBreakingInputSchema } from '../schemas/inputs.js';
@@ -32,12 +30,7 @@ export function registerDetectApiBreakingTool(server) {
32
30
  ...(TOOL_CONTRACT.deterministicJson !== undefined
33
31
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
34
32
  : undefined),
35
- validateInput: (_input, ctx) => {
36
- const slot = ctx.diffSlot;
37
- if (!slot)
38
- return createNoDiffError();
39
- return validateDiffBudget(slot.diff);
40
- },
33
+ requiresDiff: true,
41
34
  formatOutcome: (result) => `${result.breakingChanges.length} breaking change(s) found`,
42
35
  formatOutput: (result) => result.hasBreakingChanges
43
36
  ? `API Breaking Changes: ${result.breakingChanges.length} found.`
@@ -1,4 +1,5 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
2
3
  import { z } from 'zod';
3
4
  import { cleanDiff, isEmptyDiff, NOISY_EXCLUDE_PATHSPECS, } from '../lib/diff-cleaner.js';
4
5
  import { computeDiffStatsFromFiles, parseDiffFiles, } from '../lib/diff-parser.js';
@@ -7,6 +8,7 @@ import { wrapToolHandler } from '../lib/tool-factory.js';
7
8
  import { createErrorToolResponse, createToolResponse, } from '../lib/tool-response.js';
8
9
  const GIT_TIMEOUT_MS = 30_000;
9
10
  const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
11
+ const execFileAsync = promisify(execFile);
10
12
  function buildGitArgs(mode) {
11
13
  const args = ['diff', '--no-color', '--no-ext-diff'];
12
14
  if (mode === 'staged') {
@@ -33,43 +35,45 @@ export function registerGenerateDiffTool(server) {
33
35
  }, wrapToolHandler({
34
36
  toolName: 'generate_diff',
35
37
  progressContext: (input) => input.mode,
36
- }, (input) => {
38
+ }, async (input) => {
37
39
  const { mode } = input;
38
40
  const args = buildGitArgs(mode);
39
- // spawnSync with an explicit args array — no shell, no interpolation.
40
- // 'git' is resolved via PATH which is controlled by the server environment.
41
- // eslint-disable-next-line sonarjs/no-os-command-from-path
42
- const result = spawnSync('git', args, {
43
- cwd: process.cwd(),
44
- encoding: 'utf8',
45
- maxBuffer: GIT_MAX_BUFFER,
46
- timeout: GIT_TIMEOUT_MS,
47
- });
48
- if (result.error) {
49
- return createErrorToolResponse('E_GENERATE_DIFF', `Failed to run git: ${result.error.message}. Ensure git is installed and the working directory is a git repository.`, undefined, { retryable: false, kind: 'internal' });
41
+ try {
42
+ // execFileAsync with an explicit args array no shell, no interpolation.
43
+ // 'git' is resolved via PATH which is controlled by the server environment.
44
+ const { stdout } = await execFileAsync('git', args, {
45
+ cwd: process.cwd(),
46
+ encoding: 'utf8',
47
+ maxBuffer: GIT_MAX_BUFFER,
48
+ timeout: GIT_TIMEOUT_MS,
49
+ });
50
+ const cleaned = cleanDiff(stdout);
51
+ if (isEmptyDiff(cleaned)) {
52
+ return createErrorToolResponse('E_NO_CHANGES', `No ${mode} changes found in the current branch. Make sure you have changes that are ${describeModeHint(mode)}.`, undefined, { retryable: false, kind: 'validation' });
53
+ }
54
+ const parsedFiles = parseDiffFiles(cleaned);
55
+ const stats = computeDiffStatsFromFiles(parsedFiles);
56
+ const generatedAt = new Date().toISOString();
57
+ storeDiff({ diff: cleaned, parsedFiles, stats, generatedAt, mode });
58
+ const summary = `Diff cached at ${DIFF_RESOURCE_URI} — ${stats.files} file(s), +${stats.added} -${stats.deleted}. All review tools are now ready.`;
59
+ return createToolResponse({
60
+ ok: true,
61
+ result: {
62
+ diffRef: DIFF_RESOURCE_URI,
63
+ stats,
64
+ generatedAt,
65
+ mode,
66
+ message: summary,
67
+ },
68
+ }, summary);
50
69
  }
51
- if (result.status !== 0) {
52
- const stderr = result.stderr.trim();
53
- return createErrorToolResponse('E_GENERATE_DIFF', `git exited with code ${String(result.status)}: ${stderr || 'unknown error'}. Ensure the working directory is a git repository.`, undefined, { retryable: false, kind: 'internal' });
70
+ catch (error) {
71
+ const err = error;
72
+ if (err.code && typeof err.code === 'number') {
73
+ const stderr = err.stderr ? err.stderr.trim() : '';
74
+ return createErrorToolResponse('E_GENERATE_DIFF', `git exited with code ${String(err.code)}: ${stderr || 'unknown error'}. Ensure the working directory is a git repository.`, undefined, { retryable: false, kind: 'internal' });
75
+ }
76
+ return createErrorToolResponse('E_GENERATE_DIFF', `Failed to run git: ${err.message}. Ensure git is installed and the working directory is a git repository.`, undefined, { retryable: false, kind: 'internal' });
54
77
  }
55
- const cleaned = cleanDiff(result.stdout);
56
- if (isEmptyDiff(cleaned)) {
57
- return createErrorToolResponse('E_NO_CHANGES', `No ${mode} changes found in the current branch. Make sure you have changes that are ${describeModeHint(mode)}.`, undefined, { retryable: false, kind: 'validation' });
58
- }
59
- const parsedFiles = parseDiffFiles(cleaned);
60
- const stats = computeDiffStatsFromFiles(parsedFiles);
61
- const generatedAt = new Date().toISOString();
62
- storeDiff({ diff: cleaned, parsedFiles, stats, generatedAt, mode });
63
- const summary = `Diff cached at ${DIFF_RESOURCE_URI} — ${stats.files} file(s), +${stats.added} -${stats.deleted}. All review tools are now ready.`;
64
- return createToolResponse({
65
- ok: true,
66
- result: {
67
- diffRef: DIFF_RESOURCE_URI,
68
- stats,
69
- generatedAt,
70
- mode,
71
- message: summary,
72
- },
73
- }, summary);
74
78
  }));
75
79
  }
@@ -1,5 +1,3 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
- import { createNoDiffError } from '../lib/diff-store.js';
3
1
  import { requireToolContract } from '../lib/tool-contracts.js';
4
2
  import { registerStructuredToolTask, } from '../lib/tool-factory.js';
5
3
  import { GenerateReviewSummaryInputSchema } from '../schemas/inputs.js';
@@ -49,12 +47,7 @@ export function registerGenerateReviewSummaryTool(server) {
49
47
  ...(TOOL_CONTRACT.deterministicJson !== undefined
50
48
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
51
49
  : undefined),
52
- validateInput: (_input, ctx) => {
53
- const slot = ctx.diffSlot;
54
- if (!slot)
55
- return createNoDiffError();
56
- return validateDiffBudget(slot.diff);
57
- },
50
+ requiresDiff: true,
58
51
  formatOutcome: (result) => `risk: ${result.overallRisk}`,
59
52
  transformResult: (_input, result, ctx) => {
60
53
  const { files, added, deleted } = getDiffStats(ctx);
@@ -1,6 +1,4 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
1
  import { computeDiffStatsAndPathsFromFiles } from '../lib/diff-parser.js';
3
- import { createNoDiffError } from '../lib/diff-store.js';
4
2
  import { requireToolContract } from '../lib/tool-contracts.js';
5
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
6
4
  import { GenerateTestPlanInputSchema } from '../schemas/inputs.js';
@@ -36,12 +34,7 @@ export function registerGenerateTestPlanTool(server) {
36
34
  ...(TOOL_CONTRACT.deterministicJson !== undefined
37
35
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
38
36
  : undefined),
39
- validateInput: (_input, ctx) => {
40
- const slot = ctx.diffSlot;
41
- if (!slot)
42
- return createNoDiffError();
43
- return validateDiffBudget(slot.diff);
44
- },
37
+ requiresDiff: true,
45
38
  formatOutcome: (result) => `${result.testCases.length} test cases`,
46
39
  formatOutput: (result) => `Test Plan: ${result.summary}\n${result.testCases.length} cases proposed.`,
47
40
  transformResult: (input, result) => {
@@ -1,7 +1,5 @@
1
1
  import { validateContextBudget } from '../lib/context-budget.js';
2
- import { validateDiffBudget } from '../lib/diff-budget.js';
3
2
  import { computeDiffStatsAndSummaryFromFiles } from '../lib/diff-parser.js';
4
- import { createNoDiffError } from '../lib/diff-store.js';
5
3
  import { requireToolContract } from '../lib/tool-contracts.js';
6
4
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
7
5
  import { InspectCodeQualityInputSchema } from '../schemas/inputs.js';
@@ -76,14 +74,10 @@ export function registerInspectCodeQualityTool(server) {
76
74
  const fileCount = input.files?.length;
77
75
  return fileCount ? `+${fileCount} files` : '';
78
76
  },
77
+ requiresDiff: true,
79
78
  validateInput: (input, ctx) => {
80
- const slot = ctx.diffSlot;
81
- if (!slot)
82
- return createNoDiffError();
83
- const diffError = validateDiffBudget(slot.diff);
84
- if (diffError)
85
- return diffError;
86
- return validateContextBudget(slot.diff, input.files);
79
+ // Diff presence and budget checked by requiresDiff: true
80
+ return validateContextBudget(ctx.diffSlot?.diff ?? '', input.files);
87
81
  },
88
82
  formatOutcome: (result) => `${result.findings.length} findings, risk: ${result.overallRisk}`,
89
83
  formatOutput: (result) => {
@@ -1,6 +1,4 @@
1
- import { validateDiffBudget } from '../lib/diff-budget.js';
2
1
  import { extractChangedPathsFromFiles } from '../lib/diff-parser.js';
3
- import { createNoDiffError } from '../lib/diff-store.js';
4
2
  import { requireToolContract } from '../lib/tool-contracts.js';
5
3
  import { registerStructuredToolTask } from '../lib/tool-factory.js';
6
4
  import { SuggestSearchReplaceInputSchema } from '../schemas/inputs.js';
@@ -36,12 +34,7 @@ export function registerSuggestSearchReplaceTool(server) {
36
34
  ...(TOOL_CONTRACT.deterministicJson !== undefined
37
35
  ? { deterministicJson: TOOL_CONTRACT.deterministicJson }
38
36
  : undefined),
39
- validateInput: (_input, ctx) => {
40
- const slot = ctx.diffSlot;
41
- if (!slot)
42
- return createNoDiffError();
43
- return validateDiffBudget(slot.diff);
44
- },
37
+ requiresDiff: true,
45
38
  formatOutcome: (result) => formatPatchCount(result.blocks.length),
46
39
  formatOutput: (result) => {
47
40
  const count = result.blocks.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/code-review-analyst-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "mcpName": "io.github.j0hanz/code-review-analyst",
5
5
  "description": "Gemini-powered MCP server for code review analysis.",
6
6
  "type": "module",