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

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,22 @@ 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') {
140
- return;
141
- }
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.
139
+ function sendTaskProgress(extra, payload) {
140
+ const rawToken = extra._meta?.progressToken;
141
+ if (typeof rawToken !== 'string' && typeof rawToken !== 'number') {
142
+ return Promise.resolve();
160
143
  }
144
+ const params = {
145
+ progressToken: rawToken,
146
+ progress: payload.current,
147
+ ...(payload.total !== undefined ? { total: payload.total } : {}),
148
+ ...(payload.message !== undefined ? { message: payload.message } : {}),
149
+ };
150
+ return extra
151
+ .sendNotification({ method: 'notifications/progress', params })
152
+ .catch(() => {
153
+ // Progress notifications are best-effort; never fail tool execution.
154
+ });
161
155
  }
162
156
  function createProgressReporter(extra) {
163
157
  let lastCurrent = 0;
@@ -195,21 +189,26 @@ function normalizeProgressContext(context) {
195
189
  return `${compact.slice(0, 77)}...`;
196
190
  }
197
191
  function formatProgressStep(toolName, context, metadata) {
198
- const prefix = metadata === 'starting' ? '▸' : '◦';
199
- return `${prefix} ${toolName}: ${context} [${metadata}]`;
192
+ return `${toolName}: ${context} [${metadata}]`;
200
193
  }
201
194
  function friendlyModelName(model) {
202
195
  if (!model)
203
- return 'model';
204
- if (model.includes('pro'))
205
- return 'Pro';
206
- if (model.includes('flash'))
207
- return 'Flash';
208
- return 'model';
196
+ return 'calling model';
197
+ const normalized = model.toLowerCase();
198
+ if (normalized.includes('pro'))
199
+ return 'calling Pro';
200
+ if (normalized.includes('flash'))
201
+ return 'calling Flash';
202
+ return 'calling model';
209
203
  }
210
- function formatProgressCompletion(toolName, context, outcome, success = true) {
211
- const prefix = success ? '◆' : '◇';
212
- return `${prefix} ${toolName}: ${context} • ${outcome}`;
204
+ function formatProgressCompletion(toolName, context, outcome) {
205
+ return `🗒 ${toolName}: ${context} ${outcome}`;
206
+ }
207
+ function createFailureStatusMessage(outcome, errorMessage) {
208
+ if (outcome === 'cancelled') {
209
+ return `cancelled: ${errorMessage}`;
210
+ }
211
+ return errorMessage;
213
212
  }
214
213
  async function reportProgressStepUpdate(reportProgress, toolName, context, current, metadata) {
215
214
  await reportProgress({
@@ -218,13 +217,21 @@ async function reportProgressStepUpdate(reportProgress, toolName, context, curre
218
217
  message: formatProgressStep(toolName, context, metadata),
219
218
  });
220
219
  }
221
- async function reportProgressCompletionUpdate(reportProgress, toolName, context, outcome, success = true) {
220
+ async function reportProgressCompletionUpdate(reportProgress, toolName, context, outcome) {
222
221
  await reportProgress({
223
222
  current: TASK_PROGRESS_TOTAL,
224
223
  total: TASK_PROGRESS_TOTAL,
225
- message: formatProgressCompletion(toolName, context, outcome, success),
224
+ message: formatProgressCompletion(toolName, context, outcome),
226
225
  });
227
226
  }
227
+ async function reportSchemaRetryProgressBestEffort(reportProgress, toolName, context, retryCount, maxRetries) {
228
+ try {
229
+ await reportProgressStepUpdate(reportProgress, toolName, context, 3, `repairing schema retry ${retryCount}/${maxRetries}`);
230
+ }
231
+ catch {
232
+ // Progress updates are best-effort and must not interrupt retries.
233
+ }
234
+ }
228
235
  function toLoggingLevel(level) {
229
236
  switch (level) {
230
237
  case 'debug':
@@ -277,21 +284,23 @@ export function wrapToolHandler(options, handler) {
277
284
  const result = await handler(input, extra);
278
285
  // End progress (1/1)
279
286
  const outcome = result.isError ? 'failed' : 'completed';
280
- const success = !result.isError;
281
287
  await sendTaskProgress(extra, {
282
288
  current: 1,
283
289
  total: 1,
284
- message: formatProgressCompletion(options.toolName, context, outcome, success),
290
+ message: formatProgressCompletion(options.toolName, context, outcome),
285
291
  });
286
292
  return result;
287
293
  }
288
294
  catch (error) {
295
+ const errorMessage = getErrorMessage(error);
296
+ const failureMeta = classifyErrorMeta(error, errorMessage);
297
+ const outcome = failureMeta.kind === 'cancelled' ? 'cancelled' : 'failed';
289
298
  // Progress is best-effort; must never mask the original error.
290
299
  try {
291
300
  await sendTaskProgress(extra, {
292
301
  current: 1,
293
302
  total: 1,
294
- message: formatProgressCompletion(options.toolName, context, 'failed', false),
303
+ message: formatProgressCompletion(options.toolName, context, outcome),
295
304
  });
296
305
  }
297
306
  catch {
@@ -301,6 +310,21 @@ export function wrapToolHandler(options, handler) {
301
310
  }
302
311
  };
303
312
  }
313
+ async function validateRequest(config, inputRecord, ctx) {
314
+ if (config.requiresDiff) {
315
+ if (!ctx.diffSlot) {
316
+ return createNoDiffError();
317
+ }
318
+ const budgetError = validateDiffBudget(ctx.diffSlot.diff);
319
+ if (budgetError) {
320
+ return budgetError;
321
+ }
322
+ }
323
+ if (config.validateInput) {
324
+ return await config.validateInput(inputRecord, ctx);
325
+ }
326
+ return undefined;
327
+ }
304
328
  export function registerStructuredToolTask(server, config) {
305
329
  const responseSchema = createGeminiResponseSchema({
306
330
  geminiSchema: config.geminiSchema,
@@ -332,16 +356,19 @@ export function registerStructuredToolTask(server, config) {
332
356
  // statusMessage is best-effort; task may already be terminal.
333
357
  }
334
358
  };
359
+ const onLog = createGeminiLogger(server, task.taskId);
335
360
  const storeResultSafely = async (status, result) => {
336
361
  try {
337
362
  await extra.taskStore.storeTaskResult(task.taskId, status, result);
338
363
  }
339
- catch {
340
- // storing the result failed, possibly because the task is already marked as failed due to an uncaught error. There's not much we can do at this point, so we swallow the error to avoid unhandled rejections.
364
+ catch (storeErr) {
365
+ await onLog('error', {
366
+ event: 'store_result_failed',
367
+ error: getErrorMessage(storeErr),
368
+ });
341
369
  }
342
370
  };
343
371
  try {
344
- const onLog = createGeminiLogger(server, task.taskId);
345
372
  const inputRecord = parseToolInput(input, config.fullInputSchema);
346
373
  progressContext = normalizeProgressContext(config.progressContext?.(inputRecord));
347
374
  // Snapshot the diff slot ONCE before any async work so that
@@ -350,18 +377,16 @@ export function registerStructuredToolTask(server, config) {
350
377
  // could replace the slot and silently bypass the budget check.
351
378
  const ctx = { diffSlot: getDiff() };
352
379
  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
- }
380
+ const validationError = await validateRequest(config, inputRecord, ctx);
381
+ if (validationError) {
382
+ const validationMessage = validationError.structuredContent.error?.message ??
383
+ INPUT_VALIDATION_FAILED;
384
+ await updateStatusMessage(validationMessage);
385
+ await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, 'rejected');
386
+ await storeResultSafely('completed', validationError);
387
+ return;
363
388
  }
364
- await reportProgressStepUpdate(reportProgress, config.name, progressContext, 1, 'preparing');
389
+ await reportProgressStepUpdate(reportProgress, config.name, progressContext, 1, 'building prompt');
365
390
  const promptParts = config.buildPrompt(inputRecord, ctx);
366
391
  const { prompt } = promptParts;
367
392
  const { systemInstruction } = promptParts;
@@ -369,18 +394,18 @@ export function registerStructuredToolTask(server, config) {
369
394
  await reportProgressStepUpdate(reportProgress, config.name, progressContext, 2, modelLabel);
370
395
  let parsed;
371
396
  let retryPrompt = prompt;
372
- for (let attempt = 0; attempt <= MAX_SCHEMA_RETRIES; attempt += 1) {
397
+ const maxRetries = config.schemaRetries ?? geminiSchemaRetriesConfig.get();
398
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
373
399
  try {
374
400
  const raw = await generateStructuredJson(createGenerationRequest(config, { systemInstruction, prompt: retryPrompt }, responseSchema, onLog, extra.signal));
375
401
  if (attempt === 0) {
376
- await reportProgressStepUpdate(reportProgress, config.name, progressContext, 3, 'processing response');
402
+ await reportProgressStepUpdate(reportProgress, config.name, progressContext, 3, 'validating response');
377
403
  }
378
404
  parsed = config.resultSchema.parse(raw);
379
405
  break;
380
406
  }
381
407
  catch (error) {
382
- if (attempt >= MAX_SCHEMA_RETRIES ||
383
- !(error instanceof z.ZodError)) {
408
+ if (attempt >= maxRetries || !(error instanceof z.ZodError)) {
384
409
  throw error;
385
410
  }
386
411
  const errorMessage = getErrorMessage(error);
@@ -393,6 +418,8 @@ export function registerStructuredToolTask(server, config) {
393
418
  originalChars: errorMessage.length,
394
419
  },
395
420
  });
421
+ const retryCount = attempt + 1;
422
+ await reportSchemaRetryProgressBestEffort(reportProgress, config.name, progressContext, retryCount, maxRetries);
396
423
  retryPrompt = schemaRetryPrompt.prompt;
397
424
  }
398
425
  }
@@ -415,14 +442,24 @@ export function registerStructuredToolTask(server, config) {
415
442
  catch (error) {
416
443
  const errorMessage = getErrorMessage(error);
417
444
  const errorMeta = classifyErrorMeta(error, errorMessage);
418
- await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, 'failed', false);
419
- await updateStatusMessage(errorMessage);
445
+ const outcome = errorMeta.kind === 'cancelled' ? 'cancelled' : 'failed';
446
+ await updateStatusMessage(createFailureStatusMessage(outcome, errorMessage));
420
447
  await storeResultSafely('failed', createErrorToolResponse(config.errorCode, errorMessage, undefined, errorMeta));
448
+ await reportProgressCompletionUpdate(reportProgress, config.name, progressContext, outcome);
421
449
  }
422
450
  };
423
- queueMicrotask(() => {
424
- void runTask().catch((error) => {
425
- console.error(`[task-runner:${config.name}] ${getErrorMessage(error)}`);
451
+ setImmediate(() => {
452
+ void runTask().catch(async (error) => {
453
+ try {
454
+ await server.sendLoggingMessage({
455
+ level: 'error',
456
+ logger: 'task-runner',
457
+ data: { task: config.name, error: getErrorMessage(error) },
458
+ });
459
+ }
460
+ catch {
461
+ console.error(`[task-runner:${config.name}] ${getErrorMessage(error)}`);
462
+ }
426
463
  });
427
464
  });
428
465
  return { task };
@@ -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.3",
4
4
  "mcpName": "io.github.j0hanz/code-review-analyst",
5
5
  "description": "Gemini-powered MCP server for code review analysis.",
6
6
  "type": "module",