@j0hanz/code-review-analyst-mcp 1.6.4 → 1.7.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.
@@ -38,6 +38,7 @@ const DIGITS_ONLY_PATTERN = /^\d+$/;
38
38
  const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
39
39
  const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
40
40
  const SLEEP_UNREF_OPTIONS = { ref: false };
41
+ const JSON_CODE_BLOCK_PATTERN = /```(?:json)?\n?([\s\S]*?)(?=\n?```)/u;
41
42
  const maxConcurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', 10);
42
43
  const maxConcurrentBatchCallsConfig = createCachedEnvInt('MAX_CONCURRENT_BATCH_CALLS', 2);
43
44
  const concurrencyWaitMsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS_WAIT_MS', 2_000);
@@ -45,8 +46,8 @@ const batchPollIntervalMsConfig = createCachedEnvInt('GEMINI_BATCH_POLL_INTERVAL
45
46
  const batchTimeoutMsConfig = createCachedEnvInt('GEMINI_BATCH_TIMEOUT_MS', 120_000);
46
47
  let activeCalls = 0;
47
48
  let activeBatchCalls = 0;
48
- const slotWaiters = [];
49
- const batchSlotWaiters = [];
49
+ const slotWaiters = new Set();
50
+ const batchSlotWaiters = new Set();
50
51
  const RETRYABLE_TRANSIENT_CODES = new Set([
51
52
  'RESOURCE_EXHAUSTED',
52
53
  'UNAVAILABLE',
@@ -54,6 +55,36 @@ const RETRYABLE_TRANSIENT_CODES = new Set([
54
55
  'INTERNAL',
55
56
  'ABORTED',
56
57
  ]);
58
+ function getWaiterCount(waiters) {
59
+ return waiters instanceof Set ? waiters.size : waiters.length;
60
+ }
61
+ function addWaiter(waiters, waiter) {
62
+ if (waiters instanceof Set) {
63
+ waiters.add(waiter);
64
+ return;
65
+ }
66
+ waiters.push(waiter);
67
+ }
68
+ function removeWaiter(waiters, waiter) {
69
+ if (waiters instanceof Set) {
70
+ waiters.delete(waiter);
71
+ return;
72
+ }
73
+ const index = waiters.indexOf(waiter);
74
+ if (index !== -1) {
75
+ waiters.splice(index, 1);
76
+ }
77
+ }
78
+ function popNextWaiter(waiters) {
79
+ if (waiters instanceof Set) {
80
+ const next = waiters.values().next().value;
81
+ if (next !== undefined) {
82
+ waiters.delete(next);
83
+ }
84
+ return next;
85
+ }
86
+ return waiters.shift();
87
+ }
57
88
  const SAFETY_CATEGORIES = [
58
89
  HarmCategory.HARM_CATEGORY_HATE_SPEECH,
59
90
  HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
@@ -371,7 +402,7 @@ function parseStructuredResponse(responseText) {
371
402
  catch {
372
403
  // fast-path failed; try extracting from markdown block
373
404
  }
374
- const jsonMatch = /```(?:json)?\n?([\s\S]*?)(?=\n?```)/u.exec(responseText);
405
+ const jsonMatch = JSON_CODE_BLOCK_PATTERN.exec(responseText);
375
406
  const jsonText = jsonMatch?.[1] ?? responseText;
376
407
  try {
377
408
  return JSON.parse(jsonText);
@@ -501,13 +532,13 @@ function canRetryAttempt(attempt, maxRetries, error) {
501
532
  return attempt < maxRetries && shouldRetry(error);
502
533
  }
503
534
  function tryWakeNextWaiter() {
504
- const next = slotWaiters.shift();
535
+ const next = popNextWaiter(slotWaiters);
505
536
  if (next !== undefined) {
506
537
  next();
507
538
  }
508
539
  }
509
540
  async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestSignal) {
510
- if (waiters.length === 0 && getActiveCount() < limit) {
541
+ if (getWaiterCount(waiters) === 0 && getActiveCount() < limit) {
511
542
  acquireSlot();
512
543
  return;
513
544
  }
@@ -526,12 +557,9 @@ async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestS
526
557
  acquireSlot();
527
558
  resolve();
528
559
  };
529
- waiters.push(waiter);
530
- const removeWaiter = () => {
531
- const idx = waiters.indexOf(waiter);
532
- if (idx !== -1) {
533
- waiters.splice(idx, 1);
534
- }
560
+ addWaiter(waiters, waiter);
561
+ const removeCurrentWaiter = () => {
562
+ removeWaiter(waiters, waiter);
535
563
  };
536
564
  const detachAbortListener = () => {
537
565
  if (requestSignal) {
@@ -542,7 +570,7 @@ async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestS
542
570
  if (settled)
543
571
  return;
544
572
  settled = true;
545
- removeWaiter();
573
+ removeCurrentWaiter();
546
574
  detachAbortListener();
547
575
  reject(new Error(formatConcurrencyLimitErrorMessage(limit, waitLimitMs)));
548
576
  }, waitLimitMs);
@@ -551,7 +579,7 @@ async function waitForSlot(limit, getActiveCount, acquireSlot, waiters, requestS
551
579
  if (settled)
552
580
  return;
553
581
  settled = true;
554
- removeWaiter();
582
+ removeCurrentWaiter();
555
583
  clearTimeout(deadlineTimer);
556
584
  reject(new Error('Gemini request was cancelled.'));
557
585
  };
@@ -566,7 +594,7 @@ async function waitForConcurrencySlot(limit, requestSignal) {
566
594
  }, slotWaiters, requestSignal);
567
595
  }
568
596
  function tryWakeNextBatchWaiter() {
569
- const next = batchSlotWaiters.shift();
597
+ const next = popNextWaiter(batchSlotWaiters);
570
598
  if (next !== undefined) {
571
599
  next();
572
600
  }
@@ -770,7 +798,7 @@ async function runInlineBatchWithPolling(request, model, onLog) {
770
798
  export function getGeminiQueueSnapshot() {
771
799
  return {
772
800
  activeCalls,
773
- waitingCalls: slotWaiters.length,
801
+ waitingCalls: slotWaiters.size,
774
802
  };
775
803
  }
776
804
  export async function generateStructuredJson(request) {
@@ -793,7 +821,7 @@ export async function generateStructuredJson(request) {
793
821
  await safeCallOnLog(onLog, 'info', {
794
822
  event: 'gemini_queue_acquired',
795
823
  queueWaitMs,
796
- waitingCalls: batchMode === 'inline' ? batchSlotWaiters.length : slotWaiters.length,
824
+ waitingCalls: batchMode === 'inline' ? batchSlotWaiters.size : slotWaiters.size,
797
825
  activeCalls,
798
826
  activeBatchCalls,
799
827
  mode: batchMode,
@@ -111,11 +111,13 @@ export declare class ToolTaskRunner<TInput extends object, TResult extends objec
111
111
  private readonly task;
112
112
  private diffSlotSnapshot;
113
113
  private hasSnapshot;
114
- private readonly responseSchema;
114
+ private responseSchema;
115
115
  private readonly onLog;
116
116
  private readonly reportProgress;
117
117
  private progressContext;
118
+ private lastStatusMessage;
118
119
  constructor(server: McpServer, config: StructuredToolTaskConfig<TInput, TResult, TFinal>, extra: CreateTaskRequestHandlerExtra, task: TaskLike);
120
+ setResponseSchemaOverride(responseSchema: Record<string, unknown>): void;
119
121
  setDiffSlotSnapshot(diffSlotSnapshot: DiffSlot | undefined): void;
120
122
  private updateStatusMessage;
121
123
  private storeResultSafely;
@@ -28,6 +28,9 @@ const DEFAULT_SCHEMA_RETRY_ERROR_CHARS = 1_500;
28
28
  const schemaRetryErrorCharsConfig = createCachedEnvInt('MAX_SCHEMA_RETRY_ERROR_CHARS', DEFAULT_SCHEMA_RETRY_ERROR_CHARS);
29
29
  const DETERMINISTIC_JSON_RETRY_NOTE = 'Deterministic JSON mode: keep key names exactly as schema-defined and preserve stable field ordering.';
30
30
  const JSON_PARSE_ERROR_PATTERN = /model produced invalid json/i;
31
+ const MODEL_IMMEDIATE_RESPONSE_META_KEY = 'io.modelcontextprotocol/model-immediate-response';
32
+ const responseSchemaCache = new WeakMap();
33
+ const progressReporterCache = new WeakMap();
31
34
  function buildToolAnnotations(annotations) {
32
35
  if (!annotations) {
33
36
  return {
@@ -49,6 +52,18 @@ function createGeminiResponseSchema(config) {
49
52
  const sourceSchema = config.geminiSchema ?? config.resultSchema;
50
53
  return stripJsonSchemaConstraints(z.toJSONSchema(sourceSchema));
51
54
  }
55
+ function getCachedGeminiResponseSchema(config) {
56
+ const cached = responseSchemaCache.get(config);
57
+ if (cached) {
58
+ return cached;
59
+ }
60
+ const responseSchema = createGeminiResponseSchema({
61
+ geminiSchema: config.geminiSchema,
62
+ resultSchema: config.resultSchema,
63
+ });
64
+ responseSchemaCache.set(config, responseSchema);
65
+ return responseSchema;
66
+ }
52
67
  function parseToolInput(input, fullInputSchema) {
53
68
  return fullInputSchema.parse(input);
54
69
  }
@@ -165,24 +180,14 @@ function isRetryableUpstreamMessage(message) {
165
180
  return (RETRYABLE_UPSTREAM_ERROR_PATTERN.test(message) ||
166
181
  BUSY_ERROR_PATTERN.test(message));
167
182
  }
168
- function sendTaskProgress(extra, payload) {
183
+ function createProgressReporter(extra) {
169
184
  const rawToken = extra._meta?.progressToken;
170
185
  if (typeof rawToken !== 'string' && typeof rawToken !== 'number') {
171
- return Promise.resolve();
186
+ return async () => {
187
+ // Request did not provide a progress token.
188
+ };
172
189
  }
173
- const params = {
174
- progressToken: rawToken,
175
- progress: payload.current,
176
- ...(payload.total !== undefined ? { total: payload.total } : {}),
177
- ...(payload.message !== undefined ? { message: payload.message } : {}),
178
- };
179
- return extra
180
- .sendNotification({ method: 'notifications/progress', params })
181
- .catch(() => {
182
- // Progress notifications are best-effort; never fail tool execution.
183
- });
184
- }
185
- function createProgressReporter(extra) {
190
+ const progressToken = rawToken;
186
191
  let lastCurrent = 0;
187
192
  let didSendTerminal = false;
188
193
  return async (payload) => {
@@ -200,13 +205,36 @@ function createProgressReporter(extra) {
200
205
  if (payload.message !== undefined) {
201
206
  progressPayload.message = payload.message;
202
207
  }
203
- await sendTaskProgress(extra, progressPayload);
208
+ const params = {
209
+ progressToken,
210
+ progress: progressPayload.current,
211
+ ...(progressPayload.total !== undefined
212
+ ? { total: progressPayload.total }
213
+ : {}),
214
+ ...(progressPayload.message !== undefined
215
+ ? { message: progressPayload.message }
216
+ : {}),
217
+ };
218
+ await extra
219
+ .sendNotification({ method: 'notifications/progress', params })
220
+ .catch(() => {
221
+ // Progress notifications are best-effort; never fail tool execution.
222
+ });
204
223
  lastCurrent = current;
205
224
  if (total !== undefined && total === current) {
206
225
  didSendTerminal = true;
207
226
  }
208
227
  };
209
228
  }
229
+ function getOrCreateProgressReporter(extra) {
230
+ const cached = progressReporterCache.get(extra);
231
+ if (cached) {
232
+ return cached;
233
+ }
234
+ const created = createProgressReporter(extra);
235
+ progressReporterCache.set(extra, created);
236
+ return created;
237
+ }
210
238
  function normalizeProgressContext(context) {
211
239
  const compact = context?.replace(/\s+/g, ' ').trim();
212
240
  if (!compact) {
@@ -240,8 +268,10 @@ function createFailureStatusMessage(outcome, errorMessage) {
240
268
  return errorMessage;
241
269
  }
242
270
  async function sendSingleStepProgress(extra, toolName, context, current, state) {
243
- await sendTaskProgress(extra, {
271
+ const reporter = getOrCreateProgressReporter(extra);
272
+ await reporter({
244
273
  current,
274
+ total: 1,
245
275
  message: current === 0
246
276
  ? formatProgressStep(toolName, context, state)
247
277
  : formatProgressCompletion(toolName, context, state),
@@ -377,26 +407,32 @@ export class ToolTaskRunner {
377
407
  onLog;
378
408
  reportProgress;
379
409
  progressContext;
410
+ lastStatusMessage;
380
411
  constructor(server, config, extra, task) {
381
412
  this.server = server;
382
413
  this.config = config;
383
414
  this.extra = extra;
384
415
  this.task = task;
385
- this.responseSchema = createGeminiResponseSchema({
386
- geminiSchema: config.geminiSchema,
387
- resultSchema: config.resultSchema,
388
- });
416
+ this.responseSchema = getCachedGeminiResponseSchema(config);
389
417
  this.onLog = createGeminiLogger(server, task.taskId);
390
418
  this.reportProgress = createProgressReporter(extra);
391
419
  this.progressContext = DEFAULT_PROGRESS_CONTEXT;
392
420
  }
421
+ setResponseSchemaOverride(responseSchema) {
422
+ this.responseSchema = responseSchema;
423
+ responseSchemaCache.set(this.config, responseSchema);
424
+ }
393
425
  setDiffSlotSnapshot(diffSlotSnapshot) {
394
426
  this.diffSlotSnapshot = diffSlotSnapshot;
395
427
  this.hasSnapshot = true;
396
428
  }
397
429
  async updateStatusMessage(message) {
430
+ if (this.lastStatusMessage === message) {
431
+ return;
432
+ }
398
433
  try {
399
434
  await this.extra.taskStore.updateTaskStatus(this.task.taskId, 'working', message);
435
+ this.lastStatusMessage = message;
400
436
  }
401
437
  catch {
402
438
  // Best-effort
@@ -524,6 +560,10 @@ export class ToolTaskRunner {
524
560
  }
525
561
  }
526
562
  export function registerStructuredToolTask(server, config) {
563
+ const responseSchema = createGeminiResponseSchema({
564
+ geminiSchema: config.geminiSchema,
565
+ resultSchema: config.resultSchema,
566
+ });
527
567
  server.experimental.tasks.registerToolTask(config.name, {
528
568
  title: config.title,
529
569
  description: config.description,
@@ -540,6 +580,7 @@ export function registerStructuredToolTask(server, config) {
540
580
  // preserves task-level TOCTOU safety without deep-clone overhead.
541
581
  const diffSlotSnapshot = currentDiff;
542
582
  const runner = new ToolTaskRunner(server, config, extra, task);
583
+ runner.setResponseSchemaOverride(responseSchema);
543
584
  runner.setDiffSlotSnapshot(diffSlotSnapshot);
544
585
  setImmediate(() => {
545
586
  void runner.run(input).catch(async (error) => {
@@ -555,7 +596,12 @@ export function registerStructuredToolTask(server, config) {
555
596
  }
556
597
  });
557
598
  });
558
- return { task };
599
+ return {
600
+ task,
601
+ _meta: {
602
+ [MODEL_IMMEDIATE_RESPONSE_META_KEY]: `${config.name} accepted as task ${task.taskId}`,
603
+ },
604
+ };
559
605
  },
560
606
  getTask: async (_input, extra) => {
561
607
  return await extra.taskStore.getTask(extra.taskId);
@@ -9,12 +9,19 @@ import { DefaultOutputSchema } from '../schemas/outputs.js';
9
9
  const GIT_TIMEOUT_MS = 30_000;
10
10
  const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
11
11
  const execFileAsync = promisify(execFile);
12
- async function findGitRoot() {
12
+ const gitRootByCwd = new Map();
13
+ async function findGitRoot(cwd = process.cwd()) {
14
+ const cached = gitRootByCwd.get(cwd);
15
+ if (cached) {
16
+ return cached;
17
+ }
13
18
  const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
14
- cwd: process.cwd(),
19
+ cwd,
15
20
  encoding: 'utf8',
16
21
  });
17
- return stdout.trim();
22
+ const gitRoot = stdout.trim();
23
+ gitRootByCwd.set(cwd, gitRoot);
24
+ return gitRoot;
18
25
  }
19
26
  function buildGitArgs(mode) {
20
27
  const args = ['diff', '--no-color', '--no-ext-diff'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/code-review-analyst-mcp",
3
- "version": "1.6.4",
3
+ "version": "1.7.0",
4
4
  "mcpName": "io.github.j0hanz/code-review-analyst",
5
5
  "description": "Gemini-powered MCP server for code review analysis.",
6
6
  "type": "module",