@j0hanz/code-review-analyst-mcp 1.4.1 → 1.4.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.
@@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events';
4
4
  import { performance } from 'node:perf_hooks';
5
5
  import { setTimeout as sleep } from 'node:timers/promises';
6
6
  import { debuglog } from 'node:util';
7
- import { GoogleGenAI, HarmBlockThreshold, HarmCategory } from '@google/genai';
7
+ import { FinishReason, GoogleGenAI, HarmBlockThreshold, HarmCategory, } from '@google/genai';
8
8
  import { createCachedEnvInt } from './env-config.js';
9
9
  import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
10
10
  // Lazy-cached: first call happens after parseCommandLineArgs() sets GEMINI_MODEL.
@@ -370,14 +370,20 @@ async function executeAttempt(request, model, timeoutMs, attempt, onLog) {
370
370
  const startedAt = performance.now();
371
371
  const response = await generateContentWithTimeout(request, model, timeoutMs);
372
372
  const latencyMs = Math.round(performance.now() - startedAt);
373
+ const finishReason = response.candidates?.[0]?.finishReason;
373
374
  await emitGeminiLog(onLog, 'info', {
374
375
  event: 'gemini_call',
375
376
  details: {
376
377
  attempt,
377
378
  latencyMs,
379
+ finishReason: finishReason ?? null,
378
380
  usageMetadata: response.usageMetadata ?? null,
379
381
  },
380
382
  });
383
+ if (finishReason === FinishReason.MAX_TOKENS) {
384
+ const limit = request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
385
+ throw new Error(`Response truncated: model output exceeds limit (maxOutputTokens=${formatNumber(limit)}). Increase maxOutputTokens or reduce prompt complexity.`);
386
+ }
381
387
  return parseStructuredResponse(response.text);
382
388
  }
383
389
  async function waitBeforeRetry(attempt, error, onLog, requestSignal) {
@@ -7,7 +7,7 @@ export declare const FLASH_THINKING_BUDGET: 8192;
7
7
  /** Thinking budget (tokens) for Pro model deep-analysis tasks (code quality inspection). */
8
8
  export declare const PRO_THINKING_BUDGET: 16384;
9
9
  /** Output cap for Flash triage tools (impact, summary). */
10
- export declare const FLASH_TRIAGE_MAX_OUTPUT_TOKENS: 2048;
10
+ export declare const FLASH_TRIAGE_MAX_OUTPUT_TOKENS: 4096;
11
11
  /** Output cap for API breaking-change detection (migration guidance needs room). */
12
12
  export declare const FLASH_API_BREAKING_MAX_OUTPUT_TOKENS: 4096;
13
13
  /** Output cap for test-plan generation (includes pseudocode snippets). */
@@ -7,7 +7,7 @@ const THINKING_BUDGET_TOKENS = {
7
7
  pro: 16_384,
8
8
  };
9
9
  const OUTPUT_TOKEN_BUDGET = {
10
- flashTriage: 2_048,
10
+ flashTriage: 4_096,
11
11
  flashTestPlan: 4_096,
12
12
  flashApiBreaking: 4_096,
13
13
  flashComplexity: 2_048,
@@ -1,3 +1,4 @@
1
+ export declare const INSPECTION_FOCUS_AREAS: readonly ["security", "correctness", "performance", "regressions", "tests", "maintainability", "concurrency"];
1
2
  export interface ToolParameterContract {
2
3
  name: string;
3
4
  type: string;
@@ -42,7 +43,7 @@ export declare const TOOL_CONTRACTS: readonly [{
42
43
  readonly purpose: "Assess severity, categories, breaking changes, and rollback complexity.";
43
44
  readonly model: "gemini-2.5-flash";
44
45
  readonly timeoutMs: 90000;
45
- readonly maxOutputTokens: 2048;
46
+ readonly maxOutputTokens: 4096;
46
47
  readonly params: readonly [{
47
48
  readonly name: "repository";
48
49
  readonly type: "string";
@@ -64,7 +65,7 @@ export declare const TOOL_CONTRACTS: readonly [{
64
65
  readonly purpose: "Produce PR summary, risk rating, and merge recommendation.";
65
66
  readonly model: "gemini-2.5-flash";
66
67
  readonly timeoutMs: 90000;
67
- readonly maxOutputTokens: 2048;
68
+ readonly maxOutputTokens: 4096;
68
69
  readonly params: readonly [{
69
70
  readonly name: "repository";
70
71
  readonly type: "string";
@@ -105,7 +106,7 @@ export declare const TOOL_CONTRACTS: readonly [{
105
106
  readonly type: "string[]";
106
107
  readonly required: false;
107
108
  readonly constraints: "1-12 items, 2-80 chars each";
108
- readonly description: "Focused inspection categories.";
109
+ readonly description: `Focused inspection categories (e.g. ${string}).`;
109
110
  }, {
110
111
  readonly name: "maxFindings";
111
112
  readonly type: "number";
@@ -1,5 +1,14 @@
1
1
  import { DEFAULT_TIMEOUT_PRO_MS, FLASH_API_BREAKING_MAX_OUTPUT_TOKENS, FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS, FLASH_MODEL, FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS, FLASH_THINKING_BUDGET, FLASH_TRIAGE_MAX_OUTPUT_TOKENS, PRO_MODEL, PRO_PATCH_MAX_OUTPUT_TOKENS, PRO_REVIEW_MAX_OUTPUT_TOKENS, PRO_THINKING_BUDGET, } from './model-config.js';
2
2
  const DEFAULT_TIMEOUT_FLASH_MS = 90_000;
3
+ export const INSPECTION_FOCUS_AREAS = [
4
+ 'security',
5
+ 'correctness',
6
+ 'performance',
7
+ 'regressions',
8
+ 'tests',
9
+ 'maintainability',
10
+ 'concurrency',
11
+ ];
3
12
  export const TOOL_CONTRACTS = [
4
13
  {
5
14
  name: 'generate_diff',
@@ -115,7 +124,7 @@ export const TOOL_CONTRACTS = [
115
124
  type: 'string[]',
116
125
  required: false,
117
126
  constraints: '1-12 items, 2-80 chars each',
118
- description: 'Focused inspection categories.',
127
+ description: `Focused inspection categories (e.g. ${INSPECTION_FOCUS_AREAS.join(', ')}).`,
119
128
  },
120
129
  {
121
130
  name: 'maxFindings',
@@ -1,5 +1,6 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
3
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
4
  import { z } from 'zod';
4
5
  import { type DiffSlot } from './diff-store.js';
5
6
  import { createErrorToolResponse } from './tool-response.js';
@@ -17,6 +18,22 @@ export interface PromptParts {
17
18
  export interface ToolExecutionContext {
18
19
  readonly diffSlot: DiffSlot | undefined;
19
20
  }
21
+ type ProgressToken = string | number;
22
+ interface ProgressNotificationParams {
23
+ progressToken: ProgressToken;
24
+ progress: number;
25
+ total?: number;
26
+ message?: string;
27
+ }
28
+ interface ProgressExtra {
29
+ _meta?: {
30
+ progressToken?: unknown;
31
+ };
32
+ sendNotification: (notification: {
33
+ method: 'notifications/progress';
34
+ params: ProgressNotificationParams;
35
+ }) => Promise<void>;
36
+ }
20
37
  export interface StructuredToolTaskConfig<TInput extends object = Record<string, unknown>, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult> {
21
38
  /** Tool name registered with the MCP server (e.g. 'analyze_pr_impact'). */
22
39
  name: string;
@@ -57,4 +74,9 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
57
74
  /** Builds the system instruction and user prompt from parsed tool input. */
58
75
  buildPrompt: (input: TInput, ctx: ToolExecutionContext) => PromptParts;
59
76
  }
77
+ export declare function wrapToolHandler<TInput, TResult extends CallToolResult>(options: {
78
+ toolName: string;
79
+ progressContext?: (input: TInput) => string;
80
+ }, handler: (input: TInput, extra: ProgressExtra) => Promise<TResult> | TResult): (input: TInput, extra: ProgressExtra) => Promise<TResult>;
60
81
  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;
82
+ export {};
@@ -218,6 +218,43 @@ function createGeminiLogger(server, taskId) {
218
218
  }
219
219
  };
220
220
  }
221
+ export function wrapToolHandler(options, handler) {
222
+ return async (input, extra) => {
223
+ const context = normalizeProgressContext(options.progressContext?.(input));
224
+ // Start progress (0/1)
225
+ await sendTaskProgress(extra, {
226
+ current: 0,
227
+ total: 1,
228
+ message: formatProgressStep(options.toolName, context, 'starting'),
229
+ });
230
+ try {
231
+ const result = await handler(input, extra);
232
+ // End progress (1/1)
233
+ const outcome = result.isError ? 'failed' : 'completed';
234
+ const success = !result.isError;
235
+ await sendTaskProgress(extra, {
236
+ current: 1,
237
+ total: 1,
238
+ message: formatProgressCompletion(options.toolName, context, outcome, success),
239
+ });
240
+ return result;
241
+ }
242
+ catch (error) {
243
+ // Progress is best-effort; must never mask the original error.
244
+ try {
245
+ await sendTaskProgress(extra, {
246
+ current: 1,
247
+ total: 1,
248
+ message: formatProgressCompletion(options.toolName, context, 'failed', false),
249
+ });
250
+ }
251
+ catch {
252
+ // Swallow progress delivery errors so the original error propagates.
253
+ }
254
+ throw error;
255
+ }
256
+ };
257
+ }
221
258
  export function registerStructuredToolTask(server, config) {
222
259
  const responseSchema = createGeminiResponseSchema({
223
260
  geminiSchema: config.geminiSchema,
@@ -1,2 +1,11 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare const PROMPT_DEFINITIONS: readonly [{
3
+ readonly name: "get-help";
4
+ readonly title: "Get Help";
5
+ readonly description: "Server instructions.";
6
+ }, {
7
+ readonly name: "review-guide";
8
+ readonly title: "Review Guide";
9
+ readonly description: "Workflow guide for tool/focus area.";
10
+ }];
2
11
  export declare function registerAllPrompts(server: McpServer, instructions: string): void;
@@ -1,20 +1,19 @@
1
1
  import { completable } from '@modelcontextprotocol/sdk/server/completable.js';
2
2
  import { z } from 'zod';
3
- import { getToolContract, getToolContractNames, } from '../lib/tool-contracts.js';
4
- const HELP_PROMPT_NAME = 'get-help';
5
- const HELP_PROMPT_TITLE = 'Get Help';
6
- const HELP_PROMPT_DESCRIPTION = 'Server instructions.';
7
- const REVIEW_GUIDE_PROMPT_NAME = 'review-guide';
8
- const REVIEW_GUIDE_PROMPT_TITLE = 'Review Guide';
9
- const REVIEW_GUIDE_PROMPT_DESCRIPTION = 'Workflow guide for tool/focus area.';
10
- const TOOLS = getToolContractNames();
11
- const FOCUS_AREAS = [
12
- 'security',
13
- 'correctness',
14
- 'performance',
15
- 'regressions',
16
- 'tests',
3
+ import { getToolContract, getToolContractNames, INSPECTION_FOCUS_AREAS, } from '../lib/tool-contracts.js';
4
+ export const PROMPT_DEFINITIONS = [
5
+ {
6
+ name: 'get-help',
7
+ title: 'Get Help',
8
+ description: 'Server instructions.',
9
+ },
10
+ {
11
+ name: 'review-guide',
12
+ title: 'Review Guide',
13
+ description: 'Workflow guide for tool/focus area.',
14
+ },
17
15
  ];
16
+ const TOOLS = getToolContractNames();
18
17
  const TOOL_DESCRIPTION_TEXT = 'Select tool for review guide.';
19
18
  const FOCUS_DESCRIPTION_TEXT = 'Select focus area.';
20
19
  const FOCUS_AREA_GUIDES = {
@@ -23,6 +22,8 @@ const FOCUS_AREA_GUIDES = {
23
22
  performance: 'Focus: Complexity, allocations, I/O, queries.',
24
23
  regressions: 'Focus: Behavior changes, guards, types, breaks.',
25
24
  tests: 'Focus: Coverage, edge cases, flakes, error paths.',
25
+ maintainability: 'Focus: Complexity, readability, structure, patterns.',
26
+ concurrency: 'Focus: Race conditions, deadlocks, shared state.',
26
27
  };
27
28
  function completeByPrefix(values, prefix) {
28
29
  const matches = [];
@@ -52,11 +53,12 @@ function getFocusAreaGuide(focusArea) {
52
53
  return getGuide(FOCUS_AREA_GUIDES, focusArea, (area) => `Focus on ${area} concerns.`);
53
54
  }
54
55
  function registerHelpPrompt(server, instructions) {
55
- server.registerPrompt(HELP_PROMPT_NAME, {
56
- title: HELP_PROMPT_TITLE,
57
- description: HELP_PROMPT_DESCRIPTION,
56
+ const def = PROMPT_DEFINITIONS[0];
57
+ server.registerPrompt(def.name, {
58
+ title: def.title,
59
+ description: def.description,
58
60
  }, () => ({
59
- description: HELP_PROMPT_DESCRIPTION,
61
+ description: def.description,
60
62
  messages: [
61
63
  {
62
64
  role: 'user',
@@ -85,12 +87,13 @@ function buildReviewGuideText(tool, focusArea) {
85
87
  `> Tip: Run \`get-help\` for full server documentation.`);
86
88
  }
87
89
  function registerReviewGuidePrompt(server) {
88
- server.registerPrompt(REVIEW_GUIDE_PROMPT_NAME, {
89
- title: REVIEW_GUIDE_PROMPT_TITLE,
90
- description: REVIEW_GUIDE_PROMPT_DESCRIPTION,
90
+ const def = PROMPT_DEFINITIONS[1];
91
+ server.registerPrompt(def.name, {
92
+ title: def.title,
93
+ description: def.description,
91
94
  argsSchema: {
92
95
  tool: completable(z.string().describe(TOOL_DESCRIPTION_TEXT), (value) => completeByPrefix(TOOLS, value)),
93
- focusArea: completable(z.string().describe(FOCUS_DESCRIPTION_TEXT), (value) => completeByPrefix(FOCUS_AREAS, value)),
96
+ focusArea: completable(z.string().describe(FOCUS_DESCRIPTION_TEXT), (value) => completeByPrefix(INSPECTION_FOCUS_AREAS, value)),
94
97
  },
95
98
  }, ({ tool, focusArea }) => ({
96
99
  description: `Code review guide: ${tool} / ${focusArea}`,
@@ -1,2 +1,12 @@
1
1
  import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export interface StaticResourceDef {
3
+ id: string;
4
+ uri: string;
5
+ title: string;
6
+ description: string;
7
+ priority: number;
8
+ content: () => string;
9
+ }
10
+ export declare const STATIC_RESOURCES: readonly StaticResourceDef[];
11
+ export declare const DIFF_RESOURCE_DESCRIPTION = "The most recently generated diff, cached by generate_diff. Read by all review tools automatically.";
2
12
  export declare function registerAllResources(server: McpServer, instructions: string): void;
@@ -9,7 +9,7 @@ const RESOURCE_AUDIENCE = ['assistant'];
9
9
  function createMarkdownContent(uri, text) {
10
10
  return { uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text };
11
11
  }
12
- const STATIC_RESOURCES = [
12
+ export const STATIC_RESOURCES = [
13
13
  {
14
14
  id: 'server-instructions',
15
15
  uri: 'internal://instructions',
@@ -77,10 +77,11 @@ function registerToolInfoResources(server) {
77
77
  return { contents: [createMarkdownContent(uri, text)] };
78
78
  });
79
79
  }
80
+ export const DIFF_RESOURCE_DESCRIPTION = 'The most recently generated diff, cached by generate_diff. Read by all review tools automatically.';
80
81
  function registerDiffResource(server) {
81
82
  server.registerResource('diff-current', new ResourceTemplate(DIFF_RESOURCE_URI, { list: undefined }), {
82
83
  title: 'Current Diff',
83
- description: 'The most recently generated diff, cached by generate_diff. Read by all review tools automatically.',
84
+ description: DIFF_RESOURCE_DESCRIPTION,
84
85
  mimeType: 'text/x-patch',
85
86
  annotations: {
86
87
  audience: ['assistant'],
@@ -1,16 +1,12 @@
1
1
  import { getToolContracts } from '../lib/tool-contracts.js';
2
+ import { PROMPT_DEFINITIONS } from '../prompts/index.js';
3
+ import { DIFF_RESOURCE_DESCRIPTION, STATIC_RESOURCES } from './index.js';
2
4
  import { getSharedConstraints } from './tool-info.js';
3
- const PROMPT_LIST = [
4
- '- `get-help`: Returns these server instructions.',
5
- '- `review-guide`: Workflow guide for a selected tool and focus area.',
6
- ];
5
+ const PROMPT_LIST = PROMPT_DEFINITIONS.map((def) => `- \`${def.name}\`: ${def.description}`);
7
6
  const RESOURCE_LIST = [
8
- '- `internal://instructions`: This document.',
9
- '- `internal://tool-catalog`: Tool matrix and cross-tool data flow.',
10
- '- `internal://workflows`: Recommended multi-step tool workflows.',
11
- '- `internal://server-config`: Runtime limits and model configuration.',
7
+ ...STATIC_RESOURCES.map((def) => `- \`${def.uri}\`: ${def.description}`),
12
8
  '- `internal://tool-info/{toolName}`: Per-tool contract details.',
13
- '- `diff://current`: Cached diff from the most recent generate_diff run.',
9
+ `- \`diff://current\`: ${DIFF_RESOURCE_DESCRIPTION}`,
14
10
  ];
15
11
  function formatParameterLine(parameter) {
16
12
  const required = parameter.required ? 'required' : 'optional';
@@ -51,7 +51,7 @@ export const InspectCodeQualityInputSchema = z.strictObject({
51
51
  .min(1)
52
52
  .max(INPUT_LIMITS.focusArea.maxItems)
53
53
  .optional()
54
- .describe('Review focus areas. Standard tags: security, performance, correctness, maintainability, concurrency. Omit for general review.'),
54
+ .describe('Review focus areas. Standard tags: security, correctness, performance, regressions, tests, maintainability, concurrency. Omit for general review.'),
55
55
  maxFindings: createOptionalBoundedInteger(INPUT_LIMITS.maxFindings.min, INPUT_LIMITS.maxFindings.max, 'Max findings (1-25). Default: 10.'),
56
56
  files: z
57
57
  .array(FileContextSchema)
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { cleanDiff, isEmptyDiff, NOISY_EXCLUDE_PATHSPECS, } from '../lib/diff-cleaner.js';
4
4
  import { computeDiffStatsFromFiles, parseDiffFiles, } from '../lib/diff-parser.js';
5
5
  import { DIFF_RESOURCE_URI, storeDiff } from '../lib/diff-store.js';
6
+ import { wrapToolHandler } from '../lib/tool-factory.js';
6
7
  import { createErrorToolResponse, createToolResponse, } from '../lib/tool-response.js';
7
8
  const GIT_TIMEOUT_MS = 30_000;
8
9
  const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
@@ -29,6 +30,9 @@ export function registerGenerateDiffTool(server) {
29
30
  .enum(['unstaged', 'staged'])
30
31
  .describe('"unstaged": working-tree changes not yet staged. "staged": changes added to the index with git add.'),
31
32
  },
33
+ }, wrapToolHandler({
34
+ toolName: 'generate_diff',
35
+ progressContext: (input) => input.mode,
32
36
  }, (input) => {
33
37
  const { mode } = input;
34
38
  const args = buildGitArgs(mode);
@@ -67,5 +71,5 @@ export function registerGenerateDiffTool(server) {
67
71
  message: summary,
68
72
  },
69
73
  }, summary);
70
- });
74
+ }));
71
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@j0hanz/code-review-analyst-mcp",
3
- "version": "1.4.1",
3
+ "version": "1.4.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",