codex-review-mcp 1.1.1 → 1.3.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.
- package/dist/mcp-server.js +36 -23
- package/dist/review/collectDiff.js +28 -4
- package/dist/review/gatherContext.js +1 -0
- package/dist/review/invokeAgent.js +10 -4
- package/dist/review/invokeAgent.test.js +77 -0
- package/dist/tools/performCodeReview.js +5 -1
- package/dist/tools/performCodeReview.test.js +88 -0
- package/package.json +10 -4
package/dist/mcp-server.js
CHANGED
@@ -17,29 +17,42 @@ server.registerTool('perform_code_review', {
|
|
17
17
|
workspaceDir: z.string().optional().describe('Absolute path to the workspace/repository directory. If not provided, attempts to detect from environment or current working directory.'),
|
18
18
|
},
|
19
19
|
}, async (input, extra) => {
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
20
|
+
try {
|
21
|
+
const reviewInput = {
|
22
|
+
target: input.target,
|
23
|
+
baseRef: input.baseRef,
|
24
|
+
headRef: input.headRef,
|
25
|
+
focus: input.focus,
|
26
|
+
paths: input.paths,
|
27
|
+
maxTokens: input.maxTokens,
|
28
|
+
workspaceDir: input.workspaceDir,
|
29
|
+
};
|
30
|
+
const onProgress = async (message, progress, total) => {
|
31
|
+
// Attach to tool-call request via related request ID so clients can map progress
|
32
|
+
await server.server.notification({
|
33
|
+
method: 'notifications/progress',
|
34
|
+
params: {
|
35
|
+
progressToken: extra?._meta?.progressToken ?? extra?.requestId,
|
36
|
+
progress,
|
37
|
+
total,
|
38
|
+
message,
|
39
|
+
},
|
40
|
+
}, extra?.requestId ? { relatedRequestId: extra.requestId } : undefined);
|
41
|
+
};
|
42
|
+
const markdown = await performCodeReview(reviewInput, onProgress);
|
43
|
+
return { content: [{ type: 'text', text: markdown, mimeType: 'text/markdown' }] };
|
44
|
+
}
|
45
|
+
catch (error) {
|
46
|
+
const errorMessage = error?.message || String(error);
|
47
|
+
return {
|
48
|
+
content: [{
|
49
|
+
type: 'text',
|
50
|
+
text: `❌ Code Review Failed\n\n${errorMessage}`,
|
51
|
+
mimeType: 'text/markdown'
|
52
|
+
}],
|
53
|
+
isError: true
|
54
|
+
};
|
55
|
+
}
|
43
56
|
});
|
44
57
|
const transport = new StdioServerTransport();
|
45
58
|
await server.connect(transport);
|
@@ -54,8 +54,14 @@ export async function collectDiff(input, workspaceDir) {
|
|
54
54
|
// Branch doesn't exist, try next
|
55
55
|
}
|
56
56
|
}
|
57
|
-
// Last resort: use HEAD~1 as baseline
|
58
|
-
|
57
|
+
// Last resort: use HEAD~1 as baseline if it exists
|
58
|
+
try {
|
59
|
+
await exec('git', ['rev-parse', '--verify', 'HEAD~1'], { cwd: repoRoot });
|
60
|
+
return 'HEAD~1';
|
61
|
+
}
|
62
|
+
catch {
|
63
|
+
return null;
|
64
|
+
}
|
59
65
|
}
|
60
66
|
async function hasUncommittedChanges(repoRoot) {
|
61
67
|
try {
|
@@ -75,6 +81,15 @@ export async function collectDiff(input, workspaceDir) {
|
|
75
81
|
return null;
|
76
82
|
}
|
77
83
|
}
|
84
|
+
async function hasHeadCommit(repoRoot) {
|
85
|
+
try {
|
86
|
+
await exec('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repoRoot });
|
87
|
+
return true;
|
88
|
+
}
|
89
|
+
catch {
|
90
|
+
return false;
|
91
|
+
}
|
92
|
+
}
|
78
93
|
// Priority order: explicit workspaceDir param > env vars > process.cwd()
|
79
94
|
const preferredStart = workspaceDir || process.env.CODEX_REPO_ROOT || process.env.WORKSPACE_ROOT || process.env.INIT_CWD || process.cwd();
|
80
95
|
const preferredRoot = await findRepoRoot(preferredStart);
|
@@ -88,13 +103,22 @@ export async function collectDiff(input, workspaceDir) {
|
|
88
103
|
// Auto mode: detect what to review
|
89
104
|
const hasChanges = await hasUncommittedChanges(repoRoot);
|
90
105
|
if (hasChanges) {
|
91
|
-
// Review uncommitted changes vs HEAD
|
92
|
-
|
106
|
+
// Review uncommitted changes vs HEAD (or staged if no commits yet)
|
107
|
+
if (await hasHeadCommit(repoRoot)) {
|
108
|
+
args.push('HEAD');
|
109
|
+
}
|
110
|
+
else {
|
111
|
+
args.splice(1, 0, '--staged');
|
112
|
+
}
|
93
113
|
}
|
94
114
|
else {
|
95
115
|
// No uncommitted changes, review branch vs default
|
96
116
|
const currentBranch = await getCurrentBranch(repoRoot);
|
97
117
|
const defaultBranch = await detectDefaultBranch(repoRoot);
|
118
|
+
if (!defaultBranch) {
|
119
|
+
// Can't determine default branch - nothing to review
|
120
|
+
return '';
|
121
|
+
}
|
98
122
|
if (currentBranch === defaultBranch) {
|
99
123
|
// On default branch with no changes - nothing to review
|
100
124
|
return '';
|
@@ -7,8 +7,14 @@ export async function invokeAgent({ prompt, maxTokens }) {
|
|
7
7
|
instructions: 'You are a precise code-review agent. Follow the output contract exactly. Use minimal verbosity.',
|
8
8
|
model,
|
9
9
|
});
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
10
|
+
try {
|
11
|
+
const result = await run(agent, prompt);
|
12
|
+
const out = result.finalOutput ?? '';
|
13
|
+
await debugLog(`Agent model=${model} outputLen=${out.length}`);
|
14
|
+
return out;
|
15
|
+
}
|
16
|
+
catch (error) {
|
17
|
+
await debugLog(`Agent error: ${error?.message || String(error)}`);
|
18
|
+
throw new Error(`OpenAI API error: ${error?.message || 'Unknown error occurred'}`);
|
19
|
+
}
|
14
20
|
}
|
@@ -0,0 +1,77 @@
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
2
|
+
import { invokeAgent } from './invokeAgent.js';
|
3
|
+
import { run } from '@openai/agents';
|
4
|
+
// Mock the OpenAI agents SDK
|
5
|
+
vi.mock('@openai/agents', () => ({
|
6
|
+
Agent: vi.fn().mockImplementation((config) => config),
|
7
|
+
run: vi.fn(),
|
8
|
+
}));
|
9
|
+
vi.mock('../util/debug', () => ({
|
10
|
+
debugLog: vi.fn(),
|
11
|
+
}));
|
12
|
+
describe('invokeAgent', () => {
|
13
|
+
beforeEach(() => {
|
14
|
+
vi.clearAllMocks();
|
15
|
+
});
|
16
|
+
it('should successfully return review output', async () => {
|
17
|
+
const mockOutput = '# Code Review\n\nLooks great!';
|
18
|
+
vi.mocked(run).mockResolvedValue({
|
19
|
+
finalOutput: mockOutput,
|
20
|
+
});
|
21
|
+
const result = await invokeAgent({ prompt: 'Review this code' });
|
22
|
+
expect(result).toBe(mockOutput);
|
23
|
+
expect(run).toHaveBeenCalledOnce();
|
24
|
+
});
|
25
|
+
it('should handle empty finalOutput', async () => {
|
26
|
+
vi.mocked(run).mockResolvedValue({
|
27
|
+
finalOutput: null,
|
28
|
+
});
|
29
|
+
const result = await invokeAgent({ prompt: 'Review this code' });
|
30
|
+
expect(result).toBe('');
|
31
|
+
});
|
32
|
+
it('should catch and wrap OpenAI errors', async () => {
|
33
|
+
const mockError = new Error('Rate limit exceeded');
|
34
|
+
vi.mocked(run).mockRejectedValue(mockError);
|
35
|
+
await expect(invokeAgent({ prompt: 'Review this code' })).rejects.toThrow('OpenAI API error: Rate limit exceeded');
|
36
|
+
});
|
37
|
+
it('should handle unknown error types', async () => {
|
38
|
+
vi.mocked(run).mockRejectedValue('Unknown error');
|
39
|
+
await expect(invokeAgent({ prompt: 'Review this code' })).rejects.toThrow('OpenAI API error: Unknown error');
|
40
|
+
});
|
41
|
+
it('should use CODEX_MODEL env var if set', async () => {
|
42
|
+
const originalEnv = process.env.CODEX_MODEL;
|
43
|
+
process.env.CODEX_MODEL = 'gpt-4';
|
44
|
+
vi.mocked(run).mockResolvedValue({
|
45
|
+
finalOutput: 'Review',
|
46
|
+
});
|
47
|
+
await invokeAgent({ prompt: 'Review this code' });
|
48
|
+
// Agent should be created with the model from env
|
49
|
+
const { Agent } = await import('@openai/agents');
|
50
|
+
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({
|
51
|
+
model: 'gpt-4',
|
52
|
+
}));
|
53
|
+
// Restore env
|
54
|
+
if (originalEnv) {
|
55
|
+
process.env.CODEX_MODEL = originalEnv;
|
56
|
+
}
|
57
|
+
else {
|
58
|
+
delete process.env.CODEX_MODEL;
|
59
|
+
}
|
60
|
+
});
|
61
|
+
it('should default to gpt-5-codex when no env var', async () => {
|
62
|
+
const originalEnv = process.env.CODEX_MODEL;
|
63
|
+
delete process.env.CODEX_MODEL;
|
64
|
+
vi.mocked(run).mockResolvedValue({
|
65
|
+
finalOutput: 'Review',
|
66
|
+
});
|
67
|
+
await invokeAgent({ prompt: 'Review this code' });
|
68
|
+
const { Agent } = await import('@openai/agents');
|
69
|
+
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({
|
70
|
+
model: 'gpt-5-codex',
|
71
|
+
}));
|
72
|
+
// Restore env
|
73
|
+
if (originalEnv) {
|
74
|
+
process.env.CODEX_MODEL = originalEnv;
|
75
|
+
}
|
76
|
+
});
|
77
|
+
});
|
@@ -14,7 +14,11 @@ export async function performCodeReview(input, onProgress) {
|
|
14
14
|
const context = await gatherContext();
|
15
15
|
await onProgress?.('Building prompt…', 45, 100);
|
16
16
|
const prompt = buildPrompt({ diffText, context, focus: input.focus });
|
17
|
-
|
17
|
+
// Count lines in the diff for progress message
|
18
|
+
const lineCount = diffText.split('\n').length;
|
19
|
+
const addedLines = diffText.split('\n').filter(line => line.startsWith('+')).length;
|
20
|
+
const removedLines = diffText.split('\n').filter(line => line.startsWith('-')).length;
|
21
|
+
await onProgress?.(`Calling GPT-5 Codex with ~${lineCount} lines (+${addedLines} -${removedLines})…`, 65, 100);
|
18
22
|
const agentMd = await invokeAgent({ prompt, maxTokens: input.maxTokens });
|
19
23
|
await debugLog(`Review produced ${agentMd.length} chars`);
|
20
24
|
await onProgress?.('Formatting output…', 90, 100);
|
@@ -0,0 +1,88 @@
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
2
|
+
import { performCodeReview } from './performCodeReview.js';
|
3
|
+
import * as collectDiffModule from '../review/collectDiff.js';
|
4
|
+
import * as gatherContextModule from '../review/gatherContext.js';
|
5
|
+
import * as invokeAgentModule from '../review/invokeAgent.js';
|
6
|
+
// Mock the dependencies
|
7
|
+
vi.mock('../review/collectDiff');
|
8
|
+
vi.mock('../review/gatherContext');
|
9
|
+
vi.mock('../review/invokeAgent');
|
10
|
+
vi.mock('../util/debug', () => ({
|
11
|
+
debugLog: vi.fn(),
|
12
|
+
}));
|
13
|
+
describe('performCodeReview', () => {
|
14
|
+
const mockDiff = `diff --git a/test.ts b/test.ts
|
15
|
+
+++ b/test.ts
|
16
|
+
+export function hello() {
|
17
|
+
+ return "world";
|
18
|
+
+}`;
|
19
|
+
beforeEach(() => {
|
20
|
+
vi.clearAllMocks();
|
21
|
+
});
|
22
|
+
it('should call progress callback with correct sequence', async () => {
|
23
|
+
// Setup mocks
|
24
|
+
vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
|
25
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
26
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
|
27
|
+
// Track progress calls
|
28
|
+
const progressCalls = [];
|
29
|
+
const onProgress = vi.fn(async (message, progress, total) => {
|
30
|
+
progressCalls.push({ message, progress, total });
|
31
|
+
});
|
32
|
+
// Execute
|
33
|
+
await performCodeReview({ target: 'auto' }, onProgress);
|
34
|
+
// Verify progress sequence
|
35
|
+
expect(progressCalls.length).toBeGreaterThan(0);
|
36
|
+
expect(progressCalls[0]).toEqual({ message: 'Collecting diff…', progress: 10, total: 100 });
|
37
|
+
// Find the "Calling GPT-5 Codex" message
|
38
|
+
const codexCall = progressCalls.find(call => call.message.includes('Calling GPT-5 Codex'));
|
39
|
+
expect(codexCall).toBeDefined();
|
40
|
+
expect(codexCall?.message).toMatch(/Calling GPT-5 Codex with ~\d+ lines/);
|
41
|
+
expect(codexCall?.progress).toBe(65);
|
42
|
+
// Verify final progress
|
43
|
+
const lastCall = progressCalls[progressCalls.length - 1];
|
44
|
+
expect(lastCall).toEqual({ message: 'Done', progress: 100, total: 100 });
|
45
|
+
});
|
46
|
+
it('should include line count in GPT-5 Codex progress message', async () => {
|
47
|
+
vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
|
48
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
49
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
|
50
|
+
const progressCalls = [];
|
51
|
+
const onProgress = vi.fn(async (message, progress) => {
|
52
|
+
progressCalls.push({ message, progress });
|
53
|
+
});
|
54
|
+
await performCodeReview({ target: 'auto' }, onProgress);
|
55
|
+
const codexCall = progressCalls.find(call => call.message.includes('Calling GPT-5 Codex'));
|
56
|
+
expect(codexCall?.message).toMatch(/\+\d+ -\d+\)/);
|
57
|
+
});
|
58
|
+
it('should throw error when diff is empty', async () => {
|
59
|
+
vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue('');
|
60
|
+
await expect(performCodeReview({ target: 'auto' })).rejects.toThrow('No changes to review');
|
61
|
+
});
|
62
|
+
it('should propagate errors from invokeAgent', async () => {
|
63
|
+
vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
|
64
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
65
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockRejectedValue(new Error('OpenAI API error: Rate limit exceeded'));
|
66
|
+
await expect(performCodeReview({ target: 'auto' })).rejects.toThrow('OpenAI API error');
|
67
|
+
});
|
68
|
+
it('should call collectDiff with correct workspaceDir', async () => {
|
69
|
+
const collectDiffSpy = vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
|
70
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
71
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
|
72
|
+
await performCodeReview({ target: 'auto', workspaceDir: '/test/path' });
|
73
|
+
expect(collectDiffSpy).toHaveBeenCalledWith(expect.objectContaining({ target: 'auto', workspaceDir: '/test/path' }), '/test/path');
|
74
|
+
});
|
75
|
+
it('should handle all target modes', async () => {
|
76
|
+
vi.spyOn(collectDiffModule, 'collectDiff').mockResolvedValue(mockDiff);
|
77
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
78
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review');
|
79
|
+
const targets = ['auto', 'staged', 'head', 'range'];
|
80
|
+
for (const target of targets) {
|
81
|
+
const input = target === 'range'
|
82
|
+
? { target, baseRef: 'main', headRef: 'feature' }
|
83
|
+
: { target };
|
84
|
+
await performCodeReview(input);
|
85
|
+
expect(collectDiffModule.collectDiff).toHaveBeenCalled();
|
86
|
+
}
|
87
|
+
});
|
88
|
+
});
|
package/package.json
CHANGED
@@ -1,13 +1,17 @@
|
|
1
1
|
{
|
2
2
|
"name": "codex-review-mcp",
|
3
|
-
"version": "1.
|
3
|
+
"version": "1.3.0",
|
4
4
|
"main": "index.js",
|
5
5
|
"scripts": {
|
6
6
|
"build": "tsc",
|
7
7
|
"dev": "tsc -w",
|
8
8
|
"prepare": "npm run build",
|
9
9
|
"mcp": "node dist/mcp-server.js",
|
10
|
-
"agents:sample": "node agents-sample.mjs"
|
10
|
+
"agents:sample": "node agents-sample.mjs",
|
11
|
+
"test": "vitest run",
|
12
|
+
"test:watch": "vitest",
|
13
|
+
"test:ui": "vitest --ui",
|
14
|
+
"test:coverage": "vitest run --coverage"
|
11
15
|
},
|
12
16
|
"repository": {
|
13
17
|
"type": "git",
|
@@ -42,7 +46,7 @@
|
|
42
46
|
"description": "MCP server for AI-powered code reviews using GPT-5 Codex",
|
43
47
|
"type": "module",
|
44
48
|
"bin": {
|
45
|
-
"codex-review-mcp": "
|
49
|
+
"codex-review-mcp": "bin/codex-review-mcp"
|
46
50
|
},
|
47
51
|
"dependencies": {
|
48
52
|
"@modelcontextprotocol/sdk": "^1.19.1",
|
@@ -54,6 +58,8 @@
|
|
54
58
|
},
|
55
59
|
"devDependencies": {
|
56
60
|
"@types/node": "^24.7.0",
|
57
|
-
"
|
61
|
+
"@vitest/ui": "^3.2.4",
|
62
|
+
"typescript": "^5.9.3",
|
63
|
+
"vitest": "^3.2.4"
|
58
64
|
}
|
59
65
|
}
|