@j0hanz/code-review-analyst-mcp 1.3.0 → 1.4.1

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.
@@ -0,0 +1,12 @@
1
+ export declare const NOISY_EXCLUDE_PATHSPECS: readonly [":(exclude)package-lock.json", ":(exclude)yarn.lock", ":(exclude)pnpm-lock.yaml", ":(exclude)bun.lockb", ":(exclude)*.lock", ":(exclude)dist/", ":(exclude)build/", ":(exclude)out/", ":(exclude).next/", ":(exclude)coverage/", ":(exclude)*.min.js", ":(exclude)*.min.css", ":(exclude)*.map"];
2
+ /**
3
+ * Split raw unified diff into per-file sections and strip:
4
+ * - Binary file sections ("Binary files a/... and b/... differ")
5
+ * - GIT binary patch sections
6
+ * - Mode-only sections (permission changes with no content hunks)
7
+ *
8
+ * Does NOT modify content lines (+ / - / space) to preserve verbatim
9
+ * accuracy required by suggest_search_replace.
10
+ */
11
+ export declare function cleanDiff(raw: string): string;
12
+ export declare function isEmptyDiff(diff: string): boolean;
@@ -0,0 +1,51 @@
1
+ export const NOISY_EXCLUDE_PATHSPECS = [
2
+ ':(exclude)package-lock.json',
3
+ ':(exclude)yarn.lock',
4
+ ':(exclude)pnpm-lock.yaml',
5
+ ':(exclude)bun.lockb',
6
+ ':(exclude)*.lock',
7
+ ':(exclude)dist/',
8
+ ':(exclude)build/',
9
+ ':(exclude)out/',
10
+ ':(exclude).next/',
11
+ ':(exclude)coverage/',
12
+ ':(exclude)*.min.js',
13
+ ':(exclude)*.min.css',
14
+ ':(exclude)*.map',
15
+ ];
16
+ // Regex patterns to identify noisy diff file sections.
17
+ const BINARY_FILE_LINE = /^Binary files .+ differ$/m;
18
+ const GIT_BINARY_PATCH = /^GIT binary patch/m;
19
+ const HAS_HUNK = /^@@/m;
20
+ const HAS_OLD_MODE = /^old mode /m;
21
+ /**
22
+ * Split raw unified diff into per-file sections and strip:
23
+ * - Binary file sections ("Binary files a/... and b/... differ")
24
+ * - GIT binary patch sections
25
+ * - Mode-only sections (permission changes with no content hunks)
26
+ *
27
+ * Does NOT modify content lines (+ / - / space) to preserve verbatim
28
+ * accuracy required by suggest_search_replace.
29
+ */
30
+ export function cleanDiff(raw) {
31
+ if (!raw)
32
+ return '';
33
+ // Split on the start of each "diff --git" header, keeping the header.
34
+ const sections = raw.split(/(?=^diff --git )/m);
35
+ const cleaned = sections.filter((section) => {
36
+ if (!section.trim())
37
+ return false;
38
+ if (BINARY_FILE_LINE.test(section))
39
+ return false;
40
+ if (GIT_BINARY_PATCH.test(section))
41
+ return false;
42
+ // Drop mode-only sections that have no actual content hunks.
43
+ if (HAS_OLD_MODE.test(section) && !HAS_HUNK.test(section))
44
+ return false;
45
+ return true;
46
+ });
47
+ return cleaned.join('').trim();
48
+ }
49
+ export function isEmptyDiff(diff) {
50
+ return diff.trim().length === 0;
51
+ }
@@ -0,0 +1,22 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { createErrorToolResponse } from './tool-response.js';
3
+ export declare const DIFF_RESOURCE_URI = "diff://current";
4
+ export interface DiffStats {
5
+ files: number;
6
+ added: number;
7
+ deleted: number;
8
+ }
9
+ export interface DiffSlot {
10
+ diff: string;
11
+ stats: DiffStats;
12
+ generatedAt: string;
13
+ mode: string;
14
+ }
15
+ /** Call once during server setup so the store can emit resource-updated notifications. */
16
+ export declare function initDiffStore(server: McpServer): void;
17
+ export declare function storeDiff(data: DiffSlot): void;
18
+ export declare function getDiff(): DiffSlot | undefined;
19
+ export declare function hasDiff(): boolean;
20
+ /** Test-only: directly set or clear the diff slot without emitting resource-updated. */
21
+ export declare function setDiffForTesting(data: DiffSlot | undefined): void;
22
+ export declare function createNoDiffError(): ReturnType<typeof createErrorToolResponse>;
@@ -0,0 +1,28 @@
1
+ import { createErrorToolResponse } from './tool-response.js';
2
+ export const DIFF_RESOURCE_URI = 'diff://current';
3
+ let slot;
4
+ let sendResourceUpdated;
5
+ /** Call once during server setup so the store can emit resource-updated notifications. */
6
+ export function initDiffStore(server) {
7
+ const inner = server.server;
8
+ sendResourceUpdated = inner.sendResourceUpdated.bind(inner);
9
+ }
10
+ export function storeDiff(data) {
11
+ slot = data;
12
+ void sendResourceUpdated?.({ uri: DIFF_RESOURCE_URI }).catch(() => {
13
+ // Notification is best-effort; never block the tool response.
14
+ });
15
+ }
16
+ export function getDiff() {
17
+ return slot;
18
+ }
19
+ export function hasDiff() {
20
+ return slot !== undefined;
21
+ }
22
+ /** Test-only: directly set or clear the diff slot without emitting resource-updated. */
23
+ export function setDiffForTesting(data) {
24
+ slot = data;
25
+ }
26
+ export function createNoDiffError() {
27
+ 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' });
28
+ }
@@ -8,9 +8,12 @@ export interface ToolParameterContract {
8
8
  export interface ToolContract {
9
9
  name: string;
10
10
  purpose: string;
11
+ /** Set to 'none' for synchronous (non-Gemini) tools. */
11
12
  model: string;
13
+ /** Set to 0 for synchronous (non-Gemini) tools. */
12
14
  timeoutMs: number;
13
15
  thinkingBudget?: number;
16
+ /** Set to 0 for synchronous (non-Gemini) tools. */
14
17
  maxOutputTokens: number;
15
18
  params: readonly ToolParameterContract[];
16
19
  outputShape: string;
@@ -19,18 +22,28 @@ export interface ToolContract {
19
22
  constraints?: readonly string[];
20
23
  }
21
24
  export declare const TOOL_CONTRACTS: readonly [{
25
+ readonly name: "generate_diff";
26
+ 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.";
27
+ readonly model: "none";
28
+ readonly timeoutMs: 0;
29
+ readonly maxOutputTokens: 0;
30
+ readonly params: readonly [{
31
+ readonly name: "mode";
32
+ readonly type: "string";
33
+ readonly required: true;
34
+ readonly constraints: "'unstaged' | 'staged'";
35
+ readonly description: "'unstaged': working tree changes not yet staged. 'staged': changes added to the index (git add).";
36
+ }];
37
+ readonly outputShape: "{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}";
38
+ readonly gotchas: readonly ["Must be called first — all other tools return E_NO_DIFF if no diff is cached.", "Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.", "Empty diff (no changes) returns E_NO_CHANGES."];
39
+ readonly crossToolFlow: readonly ["Caches diff at diff://current — consumed automatically by all review tools."];
40
+ }, {
22
41
  readonly name: "analyze_pr_impact";
23
42
  readonly purpose: "Assess severity, categories, breaking changes, and rollback complexity.";
24
43
  readonly model: "gemini-2.5-flash";
25
44
  readonly timeoutMs: 90000;
26
45
  readonly maxOutputTokens: 2048;
27
46
  readonly params: readonly [{
28
- readonly name: "diff";
29
- readonly type: "string";
30
- readonly required: true;
31
- readonly constraints: "10-120K chars";
32
- readonly description: "Unified diff text.";
33
- }, {
34
47
  readonly name: "repository";
35
48
  readonly type: "string";
36
49
  readonly required: true;
@@ -44,7 +57,7 @@ export declare const TOOL_CONTRACTS: readonly [{
44
57
  readonly description: "Primary language hint.";
45
58
  }];
46
59
  readonly outputShape: "{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}";
47
- readonly gotchas: readonly ["Flash triage tool optimized for speed.", "Diff-only analysis (no full-file context)."];
60
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "Flash triage tool optimized for speed."];
48
61
  readonly crossToolFlow: readonly ["severity/categories feed triage and merge-gate decisions."];
49
62
  }, {
50
63
  readonly name: "generate_review_summary";
@@ -53,12 +66,6 @@ export declare const TOOL_CONTRACTS: readonly [{
53
66
  readonly timeoutMs: 90000;
54
67
  readonly maxOutputTokens: 2048;
55
68
  readonly params: readonly [{
56
- readonly name: "diff";
57
- readonly type: "string";
58
- readonly required: true;
59
- readonly constraints: "10-120K chars";
60
- readonly description: "Unified diff text.";
61
- }, {
62
69
  readonly name: "repository";
63
70
  readonly type: "string";
64
71
  readonly required: true;
@@ -72,7 +79,7 @@ export declare const TOOL_CONTRACTS: readonly [{
72
79
  readonly description: "Primary language hint.";
73
80
  }];
74
81
  readonly outputShape: "{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}";
75
- readonly gotchas: readonly ["stats are computed locally from the diff.", "Flash triage tool optimized for speed."];
82
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "stats are computed locally from the diff."];
76
83
  readonly crossToolFlow: readonly ["Use before deep review to decide whether Pro analysis is needed."];
77
84
  }, {
78
85
  readonly name: "inspect_code_quality";
@@ -82,12 +89,6 @@ export declare const TOOL_CONTRACTS: readonly [{
82
89
  readonly thinkingBudget: 16384;
83
90
  readonly maxOutputTokens: 8192;
84
91
  readonly params: readonly [{
85
- readonly name: "diff";
86
- readonly type: "string";
87
- readonly required: true;
88
- readonly constraints: "10-120K chars";
89
- readonly description: "Unified diff text.";
90
- }, {
91
92
  readonly name: "repository";
92
93
  readonly type: "string";
93
94
  readonly required: true;
@@ -119,7 +120,7 @@ export declare const TOOL_CONTRACTS: readonly [{
119
120
  readonly description: "Optional full file content context.";
120
121
  }];
121
122
  readonly outputShape: "{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}";
122
- readonly gotchas: readonly ["Combined diff + file context is bounded by MAX_CONTEXT_CHARS.", "maxFindings caps output after generation."];
123
+ 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."];
123
124
  readonly crossToolFlow: readonly ["findings[].title -> suggest_search_replace.findingTitle", "findings[].explanation -> suggest_search_replace.findingDetails"];
124
125
  readonly constraints: readonly ["Context budget (diff + files) < 500K chars."];
125
126
  }, {
@@ -130,12 +131,6 @@ export declare const TOOL_CONTRACTS: readonly [{
130
131
  readonly thinkingBudget: 16384;
131
132
  readonly maxOutputTokens: 4096;
132
133
  readonly params: readonly [{
133
- readonly name: "diff";
134
- readonly type: "string";
135
- readonly required: true;
136
- readonly constraints: "10-120K chars";
137
- readonly description: "Unified diff containing the target issue.";
138
- }, {
139
134
  readonly name: "findingTitle";
140
135
  readonly type: "string";
141
136
  readonly required: true;
@@ -149,7 +144,7 @@ export declare const TOOL_CONTRACTS: readonly [{
149
144
  readonly description: "Detailed finding context.";
150
145
  }];
151
146
  readonly outputShape: "{summary, blocks[], validationChecklist[]}";
152
- readonly gotchas: readonly ["One finding per call to avoid mixed patch intent.", "search must be exact whitespace-preserving match."];
147
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "One finding per call to avoid mixed patch intent.", "search must be exact whitespace-preserving match."];
153
148
  readonly crossToolFlow: readonly ["Consumes findings from inspect_code_quality for targeted fixes."];
154
149
  readonly constraints: readonly ["One finding per call; verbatim search match required."];
155
150
  }, {
@@ -160,12 +155,6 @@ export declare const TOOL_CONTRACTS: readonly [{
160
155
  readonly thinkingBudget: 8192;
161
156
  readonly maxOutputTokens: 4096;
162
157
  readonly params: readonly [{
163
- readonly name: "diff";
164
- readonly type: "string";
165
- readonly required: true;
166
- readonly constraints: "10-120K chars";
167
- readonly description: "Unified diff text.";
168
- }, {
169
158
  readonly name: "repository";
170
159
  readonly type: "string";
171
160
  readonly required: true;
@@ -191,7 +180,7 @@ export declare const TOOL_CONTRACTS: readonly [{
191
180
  readonly description: "Post-generation cap applied to test cases.";
192
181
  }];
193
182
  readonly outputShape: "{summary, testCases[], coverageSummary}";
194
- readonly gotchas: readonly ["maxTestCases caps output after generation."];
183
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "maxTestCases caps output after generation."];
195
184
  readonly crossToolFlow: readonly ["Pair with inspect_code_quality to validate high-risk paths."];
196
185
  }, {
197
186
  readonly name: "analyze_time_space_complexity";
@@ -201,12 +190,6 @@ export declare const TOOL_CONTRACTS: readonly [{
201
190
  readonly thinkingBudget: 8192;
202
191
  readonly maxOutputTokens: 2048;
203
192
  readonly params: readonly [{
204
- readonly name: "diff";
205
- readonly type: "string";
206
- readonly required: true;
207
- readonly constraints: "10-120K chars";
208
- readonly description: "Unified diff text.";
209
- }, {
210
193
  readonly name: "language";
211
194
  readonly type: "string";
212
195
  readonly required: false;
@@ -214,7 +197,7 @@ export declare const TOOL_CONTRACTS: readonly [{
214
197
  readonly description: "Primary language hint.";
215
198
  }];
216
199
  readonly outputShape: "{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}";
217
- readonly gotchas: readonly ["Analyzes only changed code visible in the diff."];
200
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "Analyzes only changed code visible in the diff."];
218
201
  readonly crossToolFlow: readonly ["Use for algorithmic/performance-sensitive changes."];
219
202
  }, {
220
203
  readonly name: "detect_api_breaking_changes";
@@ -223,12 +206,6 @@ export declare const TOOL_CONTRACTS: readonly [{
223
206
  readonly timeoutMs: 90000;
224
207
  readonly maxOutputTokens: 4096;
225
208
  readonly params: readonly [{
226
- readonly name: "diff";
227
- readonly type: "string";
228
- readonly required: true;
229
- readonly constraints: "10-120K chars";
230
- readonly description: "Unified diff text.";
231
- }, {
232
209
  readonly name: "language";
233
210
  readonly type: "string";
234
211
  readonly required: false;
@@ -236,7 +213,7 @@ export declare const TOOL_CONTRACTS: readonly [{
236
213
  readonly description: "Primary language hint.";
237
214
  }];
238
215
  readonly outputShape: "{hasBreakingChanges, breakingChanges[]}";
239
- readonly gotchas: readonly ["Targets public API contracts over internal refactors."];
216
+ readonly gotchas: readonly ["Requires generate_diff to be called first.", "Targets public API contracts over internal refactors."];
240
217
  readonly crossToolFlow: readonly ["Run before merge for API-surface-sensitive changes."];
241
218
  }];
242
219
  export declare function getToolContracts(): readonly ToolContract[];
@@ -1,6 +1,31 @@
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
3
  export const TOOL_CONTRACTS = [
4
+ {
5
+ name: 'generate_diff',
6
+ 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.',
7
+ model: 'none',
8
+ timeoutMs: 0,
9
+ maxOutputTokens: 0,
10
+ params: [
11
+ {
12
+ name: 'mode',
13
+ type: 'string',
14
+ required: true,
15
+ constraints: "'unstaged' | 'staged'",
16
+ description: "'unstaged': working tree changes not yet staged. 'staged': changes added to the index (git add).",
17
+ },
18
+ ],
19
+ outputShape: '{ok, result: {diffRef, stats{files, added, deleted}, generatedAt, mode, message}}',
20
+ gotchas: [
21
+ 'Must be called first — all other tools return E_NO_DIFF if no diff is cached.',
22
+ 'Noisy files (lock files, dist/, build/, minified assets) are excluded automatically.',
23
+ 'Empty diff (no changes) returns E_NO_CHANGES.',
24
+ ],
25
+ crossToolFlow: [
26
+ 'Caches diff at diff://current — consumed automatically by all review tools.',
27
+ ],
28
+ },
4
29
  {
5
30
  name: 'analyze_pr_impact',
6
31
  purpose: 'Assess severity, categories, breaking changes, and rollback complexity.',
@@ -8,13 +33,6 @@ export const TOOL_CONTRACTS = [
8
33
  timeoutMs: DEFAULT_TIMEOUT_FLASH_MS,
9
34
  maxOutputTokens: FLASH_TRIAGE_MAX_OUTPUT_TOKENS,
10
35
  params: [
11
- {
12
- name: 'diff',
13
- type: 'string',
14
- required: true,
15
- constraints: '10-120K chars',
16
- description: 'Unified diff text.',
17
- },
18
36
  {
19
37
  name: 'repository',
20
38
  type: 'string',
@@ -32,8 +50,8 @@ export const TOOL_CONTRACTS = [
32
50
  ],
33
51
  outputShape: '{severity, categories[], summary, breakingChanges[], affectedAreas[], rollbackComplexity}',
34
52
  gotchas: [
53
+ 'Requires generate_diff to be called first.',
35
54
  'Flash triage tool optimized for speed.',
36
- 'Diff-only analysis (no full-file context).',
37
55
  ],
38
56
  crossToolFlow: [
39
57
  'severity/categories feed triage and merge-gate decisions.',
@@ -46,13 +64,6 @@ export const TOOL_CONTRACTS = [
46
64
  timeoutMs: DEFAULT_TIMEOUT_FLASH_MS,
47
65
  maxOutputTokens: FLASH_TRIAGE_MAX_OUTPUT_TOKENS,
48
66
  params: [
49
- {
50
- name: 'diff',
51
- type: 'string',
52
- required: true,
53
- constraints: '10-120K chars',
54
- description: 'Unified diff text.',
55
- },
56
67
  {
57
68
  name: 'repository',
58
69
  type: 'string',
@@ -70,8 +81,8 @@ export const TOOL_CONTRACTS = [
70
81
  ],
71
82
  outputShape: '{summary, overallRisk, keyChanges[], recommendation, stats{filesChanged, linesAdded, linesRemoved}}',
72
83
  gotchas: [
84
+ 'Requires generate_diff to be called first.',
73
85
  'stats are computed locally from the diff.',
74
- 'Flash triage tool optimized for speed.',
75
86
  ],
76
87
  crossToolFlow: [
77
88
  'Use before deep review to decide whether Pro analysis is needed.',
@@ -85,13 +96,6 @@ export const TOOL_CONTRACTS = [
85
96
  thinkingBudget: PRO_THINKING_BUDGET,
86
97
  maxOutputTokens: PRO_REVIEW_MAX_OUTPUT_TOKENS,
87
98
  params: [
88
- {
89
- name: 'diff',
90
- type: 'string',
91
- required: true,
92
- constraints: '10-120K chars',
93
- description: 'Unified diff text.',
94
- },
95
99
  {
96
100
  name: 'repository',
97
101
  type: 'string',
@@ -130,6 +134,7 @@ export const TOOL_CONTRACTS = [
130
134
  ],
131
135
  outputShape: '{summary, overallRisk, findings[], testsNeeded[], contextualInsights[], totalFindings}',
132
136
  gotchas: [
137
+ 'Requires generate_diff to be called first.',
133
138
  'Combined diff + file context is bounded by MAX_CONTEXT_CHARS.',
134
139
  'maxFindings caps output after generation.',
135
140
  ],
@@ -147,13 +152,6 @@ export const TOOL_CONTRACTS = [
147
152
  thinkingBudget: PRO_THINKING_BUDGET,
148
153
  maxOutputTokens: PRO_PATCH_MAX_OUTPUT_TOKENS,
149
154
  params: [
150
- {
151
- name: 'diff',
152
- type: 'string',
153
- required: true,
154
- constraints: '10-120K chars',
155
- description: 'Unified diff containing the target issue.',
156
- },
157
155
  {
158
156
  name: 'findingTitle',
159
157
  type: 'string',
@@ -171,6 +169,7 @@ export const TOOL_CONTRACTS = [
171
169
  ],
172
170
  outputShape: '{summary, blocks[], validationChecklist[]}',
173
171
  gotchas: [
172
+ 'Requires generate_diff to be called first.',
174
173
  'One finding per call to avoid mixed patch intent.',
175
174
  'search must be exact whitespace-preserving match.',
176
175
  ],
@@ -187,13 +186,6 @@ export const TOOL_CONTRACTS = [
187
186
  thinkingBudget: FLASH_THINKING_BUDGET,
188
187
  maxOutputTokens: FLASH_TEST_PLAN_MAX_OUTPUT_TOKENS,
189
188
  params: [
190
- {
191
- name: 'diff',
192
- type: 'string',
193
- required: true,
194
- constraints: '10-120K chars',
195
- description: 'Unified diff text.',
196
- },
197
189
  {
198
190
  name: 'repository',
199
191
  type: 'string',
@@ -224,7 +216,10 @@ export const TOOL_CONTRACTS = [
224
216
  },
225
217
  ],
226
218
  outputShape: '{summary, testCases[], coverageSummary}',
227
- gotchas: ['maxTestCases caps output after generation.'],
219
+ gotchas: [
220
+ 'Requires generate_diff to be called first.',
221
+ 'maxTestCases caps output after generation.',
222
+ ],
228
223
  crossToolFlow: [
229
224
  'Pair with inspect_code_quality to validate high-risk paths.',
230
225
  ],
@@ -237,13 +232,6 @@ export const TOOL_CONTRACTS = [
237
232
  thinkingBudget: FLASH_THINKING_BUDGET,
238
233
  maxOutputTokens: FLASH_COMPLEXITY_MAX_OUTPUT_TOKENS,
239
234
  params: [
240
- {
241
- name: 'diff',
242
- type: 'string',
243
- required: true,
244
- constraints: '10-120K chars',
245
- description: 'Unified diff text.',
246
- },
247
235
  {
248
236
  name: 'language',
249
237
  type: 'string',
@@ -253,7 +241,10 @@ export const TOOL_CONTRACTS = [
253
241
  },
254
242
  ],
255
243
  outputShape: '{timeComplexity, spaceComplexity, explanation, potentialBottlenecks[], isDegradation}',
256
- gotchas: ['Analyzes only changed code visible in the diff.'],
244
+ gotchas: [
245
+ 'Requires generate_diff to be called first.',
246
+ 'Analyzes only changed code visible in the diff.',
247
+ ],
257
248
  crossToolFlow: ['Use for algorithmic/performance-sensitive changes.'],
258
249
  },
259
250
  {
@@ -263,13 +254,6 @@ export const TOOL_CONTRACTS = [
263
254
  timeoutMs: DEFAULT_TIMEOUT_FLASH_MS,
264
255
  maxOutputTokens: FLASH_API_BREAKING_MAX_OUTPUT_TOKENS,
265
256
  params: [
266
- {
267
- name: 'diff',
268
- type: 'string',
269
- required: true,
270
- constraints: '10-120K chars',
271
- description: 'Unified diff text.',
272
- },
273
257
  {
274
258
  name: 'language',
275
259
  type: 'string',
@@ -279,7 +263,10 @@ export const TOOL_CONTRACTS = [
279
263
  },
280
264
  ],
281
265
  outputShape: '{hasBreakingChanges, breakingChanges[]}',
282
- gotchas: ['Targets public API contracts over internal refactors.'],
266
+ gotchas: [
267
+ 'Requires generate_diff to be called first.',
268
+ 'Targets public API contracts over internal refactors.',
269
+ ],
283
270
  crossToolFlow: ['Run before merge for API-surface-sensitive changes.'],
284
271
  },
285
272
  ];
@@ -1,11 +1,22 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ZodRawShapeCompat } from '@modelcontextprotocol/sdk/server/zod-compat.js';
3
3
  import { z } from 'zod';
4
+ import { type DiffSlot } from './diff-store.js';
4
5
  import { createErrorToolResponse } from './tool-response.js';
5
6
  export interface PromptParts {
6
7
  systemInstruction: string;
7
8
  prompt: string;
8
9
  }
10
+ /**
11
+ * Immutable snapshot of server-side state captured once at the start of a
12
+ * tool execution, before `validateInput` runs. Threading it through both
13
+ * `validateInput` and `buildPrompt` eliminates the TOCTOU gap that would
14
+ * otherwise allow a concurrent `generate_diff` call to replace the cached
15
+ * diff between the budget check and prompt assembly.
16
+ */
17
+ export interface ToolExecutionContext {
18
+ readonly diffSlot: DiffSlot | undefined;
19
+ }
9
20
  export interface StructuredToolTaskConfig<TInput extends object = Record<string, unknown>, TResult extends object = Record<string, unknown>, TFinal extends TResult = TResult> {
10
21
  /** Tool name registered with the MCP server (e.g. 'analyze_pr_impact'). */
11
22
  name: string;
@@ -24,9 +35,9 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
24
35
  /** Stable error code returned on failure (e.g. 'E_INSPECT_QUALITY'). */
25
36
  errorCode: string;
26
37
  /** Optional post-processing hook called after resultSchema.parse(). The return value replaces the parsed result. */
27
- transformResult?: (input: TInput, result: TResult) => TFinal;
38
+ transformResult?: (input: TInput, result: TResult, ctx: ToolExecutionContext) => TFinal;
28
39
  /** Optional validation hook for input parameters. */
29
- validateInput?: (input: TInput) => Promise<ReturnType<typeof createErrorToolResponse> | undefined> | ReturnType<typeof createErrorToolResponse> | undefined;
40
+ validateInput?: (input: TInput, ctx: ToolExecutionContext) => Promise<ReturnType<typeof createErrorToolResponse> | undefined> | ReturnType<typeof createErrorToolResponse> | undefined;
30
41
  /** Optional Gemini model to use (e.g. 'gemini-2.5-pro'). */
31
42
  model?: string;
32
43
  /** Optional thinking budget in tokens. */
@@ -44,6 +55,6 @@ export interface StructuredToolTaskConfig<TInput extends object = Record<string,
44
55
  /** Optional short outcome suffix for the completion progress message (e.g., "3 findings"). */
45
56
  formatOutcome?: (result: TFinal) => string;
46
57
  /** Builds the system instruction and user prompt from parsed tool input. */
47
- buildPrompt: (input: TInput) => PromptParts;
58
+ buildPrompt: (input: TInput, ctx: ToolExecutionContext) => PromptParts;
48
59
  }
49
60
  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;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { DefaultOutputSchema } from '../schemas/outputs.js';
3
+ import { getDiff } from './diff-store.js';
3
4
  import { getErrorMessage, RETRYABLE_UPSTREAM_ERROR_PATTERN } from './errors.js';
4
5
  import { stripJsonSchemaConstraints } from './gemini-schema.js';
5
6
  import { generateStructuredJson, getCurrentRequestId } from './gemini.js';
@@ -260,9 +261,14 @@ export function registerStructuredToolTask(server, config) {
260
261
  const onLog = createGeminiLogger(server, task.taskId);
261
262
  const inputRecord = parseToolInput(input, config.fullInputSchema);
262
263
  progressContext = normalizeProgressContext(config.progressContext?.(inputRecord));
264
+ // Snapshot the diff slot ONCE before any async work so that
265
+ // validateInput and buildPrompt observe the same state. Without
266
+ // this, a concurrent generate_diff call between the two awaits
267
+ // could replace the slot and silently bypass the budget check.
268
+ const ctx = { diffSlot: getDiff() };
263
269
  await reportProgressStepUpdate(reportProgress, config.name, progressContext, 0, 'starting');
264
270
  if (config.validateInput) {
265
- const validationError = await config.validateInput(inputRecord);
271
+ const validationError = await config.validateInput(inputRecord, ctx);
266
272
  if (validationError) {
267
273
  const validationMessage = validationError.structuredContent.error?.message ??
268
274
  INPUT_VALIDATION_FAILED;
@@ -273,7 +279,7 @@ export function registerStructuredToolTask(server, config) {
273
279
  }
274
280
  }
275
281
  await reportProgressStepUpdate(reportProgress, config.name, progressContext, 1, 'preparing');
276
- const promptParts = config.buildPrompt(inputRecord);
282
+ const promptParts = config.buildPrompt(inputRecord, ctx);
277
283
  const { prompt } = promptParts;
278
284
  const { systemInstruction } = promptParts;
279
285
  const modelLabel = friendlyModelName(config.model);
@@ -306,7 +312,7 @@ export function registerStructuredToolTask(server, config) {
306
312
  throw new Error('Unexpected state: parsed result is undefined');
307
313
  }
308
314
  const finalResult = (config.transformResult
309
- ? config.transformResult(inputRecord, parsed)
315
+ ? config.transformResult(inputRecord, parsed, ctx)
310
316
  : parsed);
311
317
  const textContent = config.formatOutput
312
318
  ? config.formatOutput(finalResult)
@@ -1,4 +1,5 @@
1
1
  import { ResourceTemplate, } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { DIFF_RESOURCE_URI, getDiff } from '../lib/diff-store.js';
2
3
  import { buildServerConfig } from './server-config.js';
3
4
  import { buildToolCatalog } from './tool-catalog.js';
4
5
  import { getToolInfo, getToolInfoNames } from './tool-info.js';
@@ -76,10 +77,28 @@ function registerToolInfoResources(server) {
76
77
  return { contents: [createMarkdownContent(uri, text)] };
77
78
  });
78
79
  }
80
+ function registerDiffResource(server) {
81
+ server.registerResource('diff-current', new ResourceTemplate(DIFF_RESOURCE_URI, { list: undefined }), {
82
+ title: 'Current Diff',
83
+ description: 'The most recently generated diff, cached by generate_diff. Read by all review tools automatically.',
84
+ mimeType: 'text/x-patch',
85
+ annotations: {
86
+ audience: ['assistant'],
87
+ priority: 1.0,
88
+ },
89
+ }, (uri) => {
90
+ const slot = getDiff();
91
+ const text = slot
92
+ ? `# Diff — ${slot.mode} — ${slot.generatedAt}\n# ${slot.stats.files} file(s), +${slot.stats.added} -${slot.stats.deleted}\n\n${slot.diff}`
93
+ : '# No diff cached. Call generate_diff first.';
94
+ return { contents: [{ uri: uri.href, mimeType: 'text/x-patch', text }] };
95
+ });
96
+ }
79
97
  export function registerAllResources(server, instructions) {
80
98
  for (const def of STATIC_RESOURCES) {
81
99
  const override = def.id === 'server-instructions' ? instructions : undefined;
82
100
  registerStaticResource(server, def, override);
83
101
  }
84
102
  registerToolInfoResources(server);
103
+ registerDiffResource(server);
85
104
  }
@@ -10,6 +10,7 @@ const RESOURCE_LIST = [
10
10
  '- `internal://workflows`: Recommended multi-step tool workflows.',
11
11
  '- `internal://server-config`: Runtime limits and model configuration.',
12
12
  '- `internal://tool-info/{toolName}`: Per-tool contract details.',
13
+ '- `diff://current`: Cached diff from the most recent generate_diff run.',
13
14
  ];
14
15
  function formatParameterLine(parameter) {
15
16
  const required = parameter.required ? 'required' : 'optional';
@@ -17,6 +18,15 @@ function formatParameterLine(parameter) {
17
18
  }
18
19
  function formatToolSection(contract) {
19
20
  const parameterLines = contract.params.map((parameter) => formatParameterLine(parameter));
21
+ if (contract.model === 'none') {
22
+ // Synchronous built-in tool (no Gemini call)
23
+ return `### \`${contract.name}\`
24
+ - Purpose: ${contract.purpose}
25
+ - Model: \`none\` (synchronous built-in)
26
+ - Parameters:
27
+ ${parameterLines.join('\n')}
28
+ - Output shape: \`${contract.outputShape}\``;
29
+ }
20
30
  const thinkingLine = contract.thinkingBudget === undefined
21
31
  ? '- Thinking budget: disabled'
22
32
  : `- Thinking budget: ${contract.thinkingBudget}`;
@@ -35,6 +35,7 @@ export function buildServerConfig() {
35
35
  const defaultModel = getModelOverride();
36
36
  const safetyThreshold = getSafetyThreshold();
37
37
  const toolRows = getToolContracts()
38
+ .filter((contract) => contract.model !== 'none')
38
39
  .map((contract) => {
39
40
  return `| \`${contract.name}\` | \`${contract.model}\` | ${formatThinkingBudget(contract.thinkingBudget)} | ${formatTimeout(contract.timeoutMs)} | ${formatNumber(contract.maxOutputTokens)} |`;
40
41
  })