@j0hanz/code-review-analyst-mcp 1.5.2 → 1.6.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.
@@ -35,6 +35,8 @@ const DEFAULT_BATCH_MODE = 'off';
35
35
  const UNKNOWN_REQUEST_CONTEXT_VALUE = 'unknown';
36
36
  const RETRYABLE_NUMERIC_CODES = new Set([429, 500, 502, 503, 504]);
37
37
  const DIGITS_ONLY_PATTERN = /^\d+$/;
38
+ const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
39
+ const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
38
40
  const SLEEP_UNREF_OPTIONS = { ref: false };
39
41
  const maxConcurrentCallsConfig = createCachedEnvInt('MAX_CONCURRENT_CALLS', 10);
40
42
  const maxConcurrentBatchCallsConfig = createCachedEnvInt('MAX_CONCURRENT_BATCH_CALLS', 2);
@@ -129,16 +131,10 @@ function parseBooleanEnv(value) {
129
131
  if (normalized.length === 0) {
130
132
  return undefined;
131
133
  }
132
- if (normalized === '1' ||
133
- normalized === 'true' ||
134
- normalized === 'yes' ||
135
- normalized === 'on') {
134
+ if (TRUE_ENV_VALUES.has(normalized)) {
136
135
  return true;
137
136
  }
138
- if (normalized === '0' ||
139
- normalized === 'false' ||
140
- normalized === 'no' ||
141
- normalized === 'off') {
137
+ if (FALSE_ENV_VALUES.has(normalized)) {
142
138
  return false;
143
139
  }
144
140
  return undefined;
@@ -240,7 +236,7 @@ function logEvent(event, details) {
240
236
  ...details,
241
237
  });
242
238
  }
243
- function asRecord(value) {
239
+ function toRecord(value) {
244
240
  if (typeof value !== 'object' || value === null) {
245
241
  return undefined;
246
242
  }
@@ -262,12 +258,12 @@ async function emitGeminiLog(onLog, level, payload) {
262
258
  });
263
259
  }
264
260
  function getNestedError(error) {
265
- const record = asRecord(error);
261
+ const record = toRecord(error);
266
262
  if (!record) {
267
263
  return undefined;
268
264
  }
269
265
  const nested = record.error;
270
- const nestedRecord = asRecord(nested);
266
+ const nestedRecord = toRecord(nested);
271
267
  if (!nestedRecord) {
272
268
  return record;
273
269
  }
@@ -372,6 +368,14 @@ function parseStructuredResponse(responseText) {
372
368
  try {
373
369
  return JSON.parse(responseText);
374
370
  }
371
+ catch {
372
+ // fast-path failed; try extracting from markdown block
373
+ }
374
+ const jsonMatch = /```(?:json)?\n?([\s\S]*?)(?=\n?```)/u.exec(responseText);
375
+ const jsonText = jsonMatch?.[1] ?? responseText;
376
+ try {
377
+ return JSON.parse(jsonText);
378
+ }
375
379
  catch (error) {
376
380
  throw new Error(`Model produced invalid JSON: ${getErrorMessage(error)}`);
377
381
  }
@@ -571,88 +575,94 @@ async function waitForBatchConcurrencySlot(limit, requestSignal) {
571
575
  activeBatchCalls += 1;
572
576
  }, batchSlotWaiters, requestSignal);
573
577
  }
574
- function getBatchState(payload) {
575
- const record = asRecord(payload);
576
- if (!record) {
577
- return undefined;
578
- }
579
- const directState = toUpperStringCode(record.state);
580
- if (directState) {
581
- return directState;
582
- }
583
- const metadata = asRecord(record.metadata);
584
- if (!metadata) {
585
- return undefined;
586
- }
587
- return toUpperStringCode(metadata.state);
588
- }
589
- function extractBatchResponseText(payload) {
590
- const record = asRecord(payload);
591
- if (!record) {
592
- return undefined;
593
- }
594
- const inlineResponse = asRecord(record.inlineResponse);
595
- const inlineText = typeof inlineResponse?.text === 'string' ? inlineResponse.text : undefined;
596
- if (inlineText) {
597
- return inlineText;
598
- }
599
- const response = asRecord(record.response);
600
- if (!response) {
601
- return undefined;
602
- }
603
- const responseText = typeof response.text === 'string' ? response.text : undefined;
604
- if (responseText) {
578
+ const BatchHelper = {
579
+ getState(payload) {
580
+ const record = toRecord(payload);
581
+ if (!record) {
582
+ return undefined;
583
+ }
584
+ const directState = toUpperStringCode(record.state);
585
+ if (directState) {
586
+ return directState;
587
+ }
588
+ const metadata = toRecord(record.metadata);
589
+ if (!metadata) {
590
+ return undefined;
591
+ }
592
+ return toUpperStringCode(metadata.state);
593
+ },
594
+ getResponseText(payload) {
595
+ const record = toRecord(payload);
596
+ if (!record) {
597
+ return undefined;
598
+ }
599
+ const inlineResponse = toRecord(record.inlineResponse);
600
+ const inlineText = typeof inlineResponse?.text === 'string'
601
+ ? inlineResponse.text
602
+ : undefined;
603
+ if (inlineText) {
604
+ return inlineText;
605
+ }
606
+ const response = toRecord(record.response);
607
+ if (!response) {
608
+ return undefined;
609
+ }
610
+ const responseText = typeof response.text === 'string' ? response.text : undefined;
611
+ if (responseText) {
612
+ return responseText;
613
+ }
614
+ const { inlineResponses } = response;
615
+ if (!Array.isArray(inlineResponses) || inlineResponses.length === 0) {
616
+ return undefined;
617
+ }
618
+ const firstInline = toRecord(inlineResponses[0]);
619
+ return typeof firstInline?.text === 'string' ? firstInline.text : undefined;
620
+ },
621
+ getErrorDetail(payload) {
622
+ const record = toRecord(payload);
623
+ if (!record) {
624
+ return undefined;
625
+ }
626
+ const directError = toRecord(record.error);
627
+ const directMessage = typeof directError?.message === 'string'
628
+ ? directError.message
629
+ : undefined;
630
+ if (directMessage) {
631
+ return directMessage;
632
+ }
633
+ const metadata = toRecord(record.metadata);
634
+ const metadataError = toRecord(metadata?.error);
635
+ const metadataMessage = typeof metadataError?.message === 'string'
636
+ ? metadataError.message
637
+ : undefined;
638
+ if (metadataMessage) {
639
+ return metadataMessage;
640
+ }
641
+ const response = toRecord(record.response);
642
+ const responseError = toRecord(response?.error);
643
+ return typeof responseError?.message === 'string'
644
+ ? responseError.message
645
+ : undefined;
646
+ },
647
+ getSuccessResponseText(polled) {
648
+ const responseText = this.getResponseText(polled);
649
+ if (!responseText) {
650
+ const errorDetail = this.getErrorDetail(polled);
651
+ throw new Error(errorDetail
652
+ ? `Gemini batch request succeeded but returned no response text: ${errorDetail}`
653
+ : 'Gemini batch request succeeded but returned no response text.');
654
+ }
605
655
  return responseText;
606
- }
607
- const { inlineResponses } = response;
608
- if (!Array.isArray(inlineResponses) || inlineResponses.length === 0) {
609
- return undefined;
610
- }
611
- const firstInline = asRecord(inlineResponses[0]);
612
- return typeof firstInline?.text === 'string' ? firstInline.text : undefined;
613
- }
614
- function extractBatchErrorDetail(payload) {
615
- const record = asRecord(payload);
616
- if (!record) {
617
- return undefined;
618
- }
619
- const directError = asRecord(record.error);
620
- const directMessage = typeof directError?.message === 'string' ? directError.message : undefined;
621
- if (directMessage) {
622
- return directMessage;
623
- }
624
- const metadata = asRecord(record.metadata);
625
- const metadataError = asRecord(metadata?.error);
626
- const metadataMessage = typeof metadataError?.message === 'string'
627
- ? metadataError.message
628
- : undefined;
629
- if (metadataMessage) {
630
- return metadataMessage;
631
- }
632
- const response = asRecord(record.response);
633
- const responseError = asRecord(response?.error);
634
- return typeof responseError?.message === 'string'
635
- ? responseError.message
636
- : undefined;
637
- }
638
- function getBatchSuccessResponseText(polled) {
639
- const responseText = extractBatchResponseText(polled);
640
- if (!responseText) {
641
- const errorDetail = extractBatchErrorDetail(polled);
642
- throw new Error(errorDetail
643
- ? `Gemini batch request succeeded but returned no response text: ${errorDetail}`
644
- : 'Gemini batch request succeeded but returned no response text.');
645
- }
646
- return responseText;
647
- }
648
- function handleBatchTerminalState(state, payload) {
649
- if (state === 'JOB_STATE_FAILED' || state === 'JOB_STATE_CANCELLED') {
650
- const errorDetail = extractBatchErrorDetail(payload);
651
- throw new Error(errorDetail
652
- ? `Gemini batch request ended with state ${state}: ${errorDetail}`
653
- : `Gemini batch request ended with state ${state}.`);
654
- }
655
- }
656
+ },
657
+ handleTerminalState(state, payload) {
658
+ if (state === 'JOB_STATE_FAILED' || state === 'JOB_STATE_CANCELLED') {
659
+ const errorDetail = this.getErrorDetail(payload);
660
+ throw new Error(errorDetail
661
+ ? `Gemini batch request ended with state ${state}: ${errorDetail}`
662
+ : `Gemini batch request ended with state ${state}.`);
663
+ }
664
+ },
665
+ };
656
666
  async function pollBatchStatusWithRetries(batches, batchName, onLog, requestSignal) {
657
667
  const maxPollRetries = 2;
658
668
  for (let attempt = 0; attempt <= maxPollRetries; attempt += 1) {
@@ -717,7 +727,7 @@ async function runInlineBatchWithPolling(request, model, onLog) {
717
727
  ],
718
728
  };
719
729
  const createdJob = await batches.create(createPayload);
720
- const createdRecord = asRecord(createdJob);
730
+ const createdRecord = toRecord(createdJob);
721
731
  batchName =
722
732
  typeof createdRecord?.name === 'string' ? createdRecord.name : undefined;
723
733
  if (!batchName) {
@@ -740,13 +750,13 @@ async function runInlineBatchWithPolling(request, model, onLog) {
740
750
  throw new Error(`Gemini batch request timed out after ${formatNumber(timeoutMs)}ms.`);
741
751
  }
742
752
  const polled = await pollBatchStatusWithRetries(batches, batchName, onLog, request.signal);
743
- const state = getBatchState(polled);
753
+ const state = BatchHelper.getState(polled);
744
754
  if (state === 'JOB_STATE_SUCCEEDED') {
745
- const responseText = getBatchSuccessResponseText(polled);
755
+ const responseText = BatchHelper.getSuccessResponseText(polled);
746
756
  completed = true;
747
757
  return parseStructuredResponse(responseText);
748
758
  }
749
- handleBatchTerminalState(state, polled);
759
+ BatchHelper.handleTerminalState(state, polled);
750
760
  await sleep(pollIntervalMs, undefined, request.signal
751
761
  ? { ...SLEEP_UNREF_OPTIONS, signal: request.signal }
752
762
  : SLEEP_UNREF_OPTIONS);
@@ -2,12 +2,10 @@
2
2
  export const FLASH_MODEL = 'gemini-3-flash-preview';
3
3
  /** High-capability model for deep reasoning, quality inspection, and reliable code generation. */
4
4
  export const PRO_MODEL = 'gemini-3-pro-preview';
5
- /** Default hint for auto-detection. */
6
- const DEFAULT_DETECT_HINT = 'detect';
7
5
  /** Default language hint. */
8
- export const DEFAULT_LANGUAGE = DEFAULT_DETECT_HINT;
6
+ export const DEFAULT_LANGUAGE = 'detect';
9
7
  /** Default test-framework hint. */
10
- export const DEFAULT_FRAMEWORK = DEFAULT_DETECT_HINT;
8
+ export const DEFAULT_FRAMEWORK = 'detect';
11
9
  /** Extended timeout for Pro model calls (ms). */
12
10
  export const DEFAULT_TIMEOUT_PRO_MS = 120_000;
13
11
  export const MODEL_TIMEOUT_MS = {
@@ -29,6 +29,12 @@ export interface ToolContract {
29
29
  crossToolFlow: readonly string[];
30
30
  constraints?: readonly string[];
31
31
  }
32
+ interface StructuredToolRuntimeOptions {
33
+ thinkingLevel?: NonNullable<ToolContract['thinkingLevel']>;
34
+ temperature?: NonNullable<ToolContract['temperature']>;
35
+ deterministicJson?: NonNullable<ToolContract['deterministicJson']>;
36
+ }
37
+ export declare function buildStructuredToolRuntimeOptions(contract: Pick<ToolContract, 'thinkingLevel' | 'temperature' | 'deterministicJson'>): StructuredToolRuntimeOptions;
32
38
  export declare const TOOL_CONTRACTS: readonly [{
33
39
  readonly name: "generate_diff";
34
40
  readonly purpose: "Generate a diff of current changes and cache it server-side. MUST be called before any other tool. Uses git to capture unstaged or staged changes in the current working directory.";
@@ -97,7 +103,7 @@ export declare const TOOL_CONTRACTS: readonly [{
97
103
  readonly crossToolFlow: readonly ["Use before deep review to decide whether Pro analysis is needed."];
98
104
  }, {
99
105
  readonly name: "inspect_code_quality";
100
- readonly purpose: "Deep code review with optional full-file context.";
106
+ readonly purpose: "Deep code review over the cached diff; files are optional supplementary excerpts only.";
101
107
  readonly model: "gemini-3-pro-preview";
102
108
  readonly timeoutMs: 120000;
103
109
  readonly thinkingLevel: "high";
@@ -133,10 +139,10 @@ export declare const TOOL_CONTRACTS: readonly [{
133
139
  readonly type: "object[]";
134
140
  readonly required: false;
135
141
  readonly constraints: "1-20 files, 100K chars/file";
136
- readonly description: "Optional full file content context.";
142
+ readonly description: "Optional short excerpts for supplementary context only; avoid full files the diff is the primary source.";
137
143
  }];
138
144
  readonly outputShape: "{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}";
139
- readonly gotchas: readonly ["Requires generate_diff to be called first.", "Combined diff + file context is bounded by MAX_CONTEXT_CHARS.", "maxFindings caps output after generation."];
145
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "Combined diff + file context is bounded by MAX_CONTEXT_CHARS.", "maxFindings caps output after generation.", "files[] is token-expensive — omit unless the diff lacks critical structural context (e.g. class hierarchy, imports). Never pass full files."];
140
146
  readonly crossToolFlow: readonly ["findings[].title -> suggest_search_replace.findingTitle", "findings[].explanation -> suggest_search_replace.findingDetails"];
141
147
  readonly constraints: readonly ["Context budget (diff + files) < 500K chars."];
142
148
  }, {
@@ -245,3 +251,4 @@ export declare function getToolContracts(): readonly ToolContract[];
245
251
  export declare function getToolContract(toolName: string): ToolContract | undefined;
246
252
  export declare function requireToolContract(toolName: string): ToolContract;
247
253
  export declare function getToolContractNames(): string[];
254
+ export {};
@@ -9,6 +9,19 @@ export const INSPECTION_FOCUS_AREAS = [
9
9
  'maintainability',
10
10
  'concurrency',
11
11
  ];
12
+ export function buildStructuredToolRuntimeOptions(contract) {
13
+ const options = {};
14
+ if (contract.thinkingLevel !== undefined) {
15
+ options.thinkingLevel = contract.thinkingLevel;
16
+ }
17
+ if (contract.temperature !== undefined) {
18
+ options.temperature = contract.temperature;
19
+ }
20
+ if (contract.deterministicJson !== undefined) {
21
+ options.deterministicJson = contract.deterministicJson;
22
+ }
23
+ return options;
24
+ }
12
25
  export const TOOL_CONTRACTS = [
13
26
  {
14
27
  name: 'generate_diff',
@@ -105,7 +118,7 @@ export const TOOL_CONTRACTS = [
105
118
  },
106
119
  {
107
120
  name: 'inspect_code_quality',
108
- purpose: 'Deep code review with optional full-file context.',
121
+ purpose: 'Deep code review over the cached diff; files are optional supplementary excerpts only.',
109
122
  model: PRO_MODEL,
110
123
  timeoutMs: DEFAULT_TIMEOUT_PRO_MS,
111
124
  thinkingLevel: PRO_THINKING_LEVEL,
@@ -146,7 +159,7 @@ export const TOOL_CONTRACTS = [
146
159
  type: 'object[]',
147
160
  required: false,
148
161
  constraints: '1-20 files, 100K chars/file',
149
- description: 'Optional full file content context.',
162
+ description: 'Optional short excerpts for supplementary context only; avoid full files the diff is the primary source.',
150
163
  },
151
164
  ],
152
165
  outputShape: '{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}',
@@ -154,6 +167,7 @@ export const TOOL_CONTRACTS = [
154
167
  'Requires generate_diff to be called first.',
155
168
  'Combined diff + file context is bounded by MAX_CONTEXT_CHARS.',
156
169
  'maxFindings caps output after generation.',
170
+ 'files[] is token-expensive — omit unless the diff lacks critical structural context (e.g. class hierarchy, imports). Never pass full files.',
157
171
  ],
158
172
  crossToolFlow: [
159
173
  'findings[].title -> suggest_search_replace.findingTitle',
@@ -1,3 +1,4 @@
1
+ import type { CreateTaskRequestHandlerExtra } from '@modelcontextprotocol/sdk/experimental/tasks/interfaces.js';
1
2
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
3
4
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
@@ -34,6 +35,12 @@ interface ProgressExtra {
34
35
  params: ProgressNotificationParams;
35
36
  }) => Promise<void>;
36
37
  }
38
+ export interface ToolAnnotations {
39
+ readOnlyHint?: boolean;
40
+ idempotentHint?: boolean;
41
+ openWorldHint?: boolean;
42
+ destructiveHint?: boolean;
43
+ }
37
44
  export interface StructuredToolTaskConfig<TInput extends object = Record<string, unknown>, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult> {
38
45
  /** Tool name registered with the MCP server (e.g. 'analyze_pr_impact'). */
39
46
  name: string;
@@ -84,6 +91,8 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
84
91
  progressContext?: (input: TInput) => string;
85
92
  /** Optional short outcome suffix for the completion progress message (e.g., "3 findings"). */
86
93
  formatOutcome?: (result: TFinal) => string;
94
+ /** Optional MCP annotation overrides for this tool. */
95
+ annotations?: ToolAnnotations;
87
96
  /** Builds the system instruction and user prompt from parsed tool input. */
88
97
  buildPrompt: (input: TInput, ctx: ToolExecutionContext) => PromptParts;
89
98
  }
@@ -92,5 +101,27 @@ export declare function wrapToolHandler<TInput, TResult extends CallToolResult>(
92
101
  toolName: string;
93
102
  progressContext?: (input: TInput) => string;
94
103
  }, handler: (input: TInput, extra: ProgressExtra) => Promise<TResult> | TResult): (input: TInput, extra: ProgressExtra) => Promise<TResult>;
104
+ interface TaskLike {
105
+ taskId: string;
106
+ }
107
+ export declare class ToolTaskRunner<TInput extends object, TResult extends object, TFinal extends TResult> {
108
+ private readonly server;
109
+ private readonly config;
110
+ private readonly extra;
111
+ private readonly task;
112
+ private diffSlotSnapshot;
113
+ private hasSnapshot;
114
+ private readonly responseSchema;
115
+ private readonly onLog;
116
+ private readonly reportProgress;
117
+ private progressContext;
118
+ constructor(server: McpServer, config: StructuredToolTaskConfig<TInput, TResult, TFinal>, extra: CreateTaskRequestHandlerExtra, task: TaskLike);
119
+ setDiffSlotSnapshot(diffSlotSnapshot: DiffSlot | undefined): void;
120
+ private updateStatusMessage;
121
+ private storeResultSafely;
122
+ private executeValidation;
123
+ private executeModelCall;
124
+ run(input: unknown): Promise<void>;
125
+ }
95
126
  export declare function registerStructuredToolTask<TInput extends object, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult>(server: McpServer, config: StructuredToolTaskConfig<TInput, TResult, TFinal>): void;
96
127
  export {};