codex-review-mcp 1.0.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/bin/codex-review-mcp +4 -0
- package/dist/mcp-server.js +43 -0
- package/dist/review/buildPrompt.js +13 -0
- package/dist/review/collectDiff.js +75 -0
- package/dist/review/formatOutput.js +10 -0
- package/dist/review/gatherContext.js +42 -0
- package/dist/review/invokeAgent.js +14 -0
- package/dist/tools/performCodeReview.js +24 -0
- package/dist/util/debug.js +15 -0
- package/package.json +50 -0
@@ -0,0 +1,43 @@
|
|
1
|
+
import 'dotenv/config';
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
4
|
+
import { z } from 'zod';
|
5
|
+
import { performCodeReview } from './tools/performCodeReview.js';
|
6
|
+
const server = new McpServer({ name: 'codex-review-mcp', version: '0.1.0' });
|
7
|
+
server.registerTool('perform_code_review', {
|
8
|
+
title: 'Perform Code Review',
|
9
|
+
description: 'Review git diffs in the current repo and return actionable Markdown feedback.',
|
10
|
+
inputSchema: {
|
11
|
+
target: z.enum(['staged', 'head', 'range']).default('head'),
|
12
|
+
baseRef: z.string().optional(),
|
13
|
+
headRef: z.string().optional(),
|
14
|
+
focus: z.string().optional(),
|
15
|
+
paths: z.array(z.string()).optional(),
|
16
|
+
maxTokens: z.number().optional(),
|
17
|
+
},
|
18
|
+
}, async (input, extra) => {
|
19
|
+
const reviewInput = {
|
20
|
+
target: input.target,
|
21
|
+
baseRef: input.baseRef,
|
22
|
+
headRef: input.headRef,
|
23
|
+
focus: input.focus,
|
24
|
+
paths: input.paths,
|
25
|
+
maxTokens: input.maxTokens,
|
26
|
+
};
|
27
|
+
const onProgress = async (message, progress, total) => {
|
28
|
+
// Attach to tool-call request via related request ID so clients can map progress
|
29
|
+
await server.server.notification({
|
30
|
+
method: 'notifications/progress',
|
31
|
+
params: {
|
32
|
+
progressToken: extra?._meta?.progressToken ?? extra.requestId,
|
33
|
+
progress,
|
34
|
+
total,
|
35
|
+
message,
|
36
|
+
},
|
37
|
+
}, { relatedRequestId: extra.requestId });
|
38
|
+
};
|
39
|
+
const markdown = await performCodeReview(reviewInput, onProgress);
|
40
|
+
return { content: [{ type: 'text', text: markdown, mimeType: 'text/markdown' }] };
|
41
|
+
});
|
42
|
+
const transport = new StdioServerTransport();
|
43
|
+
await server.connect(transport);
|
@@ -0,0 +1,13 @@
|
|
1
|
+
export function buildPrompt({ diffText, context, focus }) {
|
2
|
+
const focusLine = focus ? `Focus areas: ${focus}.` : '';
|
3
|
+
return [
|
4
|
+
'You are an expert AI code reviewer. Be concise, specific, and actionable.',
|
5
|
+
'Prefer minimal diffs and direct fixes. Respect project guidelines when provided.',
|
6
|
+
focusLine,
|
7
|
+
'\n---\nScope and Diff (unified=0):\n',
|
8
|
+
diffText,
|
9
|
+
context ? '\n---\nProject context and guidelines:\n' + context : '',
|
10
|
+
'\n---\nOutput strictly as Markdown with the following sections:\n',
|
11
|
+
'1) Title + scope summary\n2) Quick Summary (3–6 bullets)\n3) Issues table: severity | file:lines | category | explanation | suggested fix\n4) Inline suggested edits for top issues\n5) Positive notes\n6) Next steps',
|
12
|
+
].join('\n');
|
13
|
+
}
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import { execFile } from 'node:child_process';
|
2
|
+
import { promisify } from 'node:util';
|
3
|
+
import { promises as fs } from 'node:fs';
|
4
|
+
import { dirname, join } from 'node:path';
|
5
|
+
import { minimatch } from 'minimatch';
|
6
|
+
const exec = promisify(execFile);
|
7
|
+
const DEFAULT_IGNORES = [
|
8
|
+
'**/dist/**',
|
9
|
+
'**/build/**',
|
10
|
+
'**/*.lock',
|
11
|
+
];
|
12
|
+
function filterPaths(paths) {
|
13
|
+
if (!paths || paths.length === 0)
|
14
|
+
return undefined;
|
15
|
+
return paths.filter((p) => !DEFAULT_IGNORES.some((g) => minimatch(p, g)));
|
16
|
+
}
|
17
|
+
export async function collectDiff(input) {
|
18
|
+
async function findRepoRoot(startDir) {
|
19
|
+
let dir = startDir;
|
20
|
+
// Walk up to filesystem root (max ~25 hops as a safety guard)
|
21
|
+
for (let i = 0; i < 25; i++) {
|
22
|
+
try {
|
23
|
+
// Accept both .git directory and file (worktree) as signal of repo root
|
24
|
+
await fs.stat(join(dir, '.git'));
|
25
|
+
return dir;
|
26
|
+
}
|
27
|
+
catch {
|
28
|
+
const parent = dirname(dir);
|
29
|
+
if (parent === dir)
|
30
|
+
break;
|
31
|
+
dir = parent;
|
32
|
+
}
|
33
|
+
}
|
34
|
+
return null;
|
35
|
+
}
|
36
|
+
const args = ['diff', '--unified=0'];
|
37
|
+
switch (input.target) {
|
38
|
+
case 'staged':
|
39
|
+
args.splice(1, 0, '--staged');
|
40
|
+
break;
|
41
|
+
case 'head':
|
42
|
+
args.push('HEAD');
|
43
|
+
break;
|
44
|
+
case 'range':
|
45
|
+
if (!input.baseRef || !input.headRef) {
|
46
|
+
throw new Error('range target requires baseRef and headRef');
|
47
|
+
}
|
48
|
+
args.push(`${input.baseRef}...${input.headRef}`);
|
49
|
+
break;
|
50
|
+
}
|
51
|
+
const filtered = filterPaths(input.paths);
|
52
|
+
if (filtered && filtered.length)
|
53
|
+
args.push(...filtered);
|
54
|
+
try {
|
55
|
+
const preferredStart = process.env.CODEX_REPO_ROOT || process.env.WORKSPACE_ROOT || process.env.INIT_CWD || process.cwd();
|
56
|
+
const repoRoot = (await findRepoRoot(preferredStart)) || (await findRepoRoot(process.cwd())) || process.cwd();
|
57
|
+
const { stdout } = await exec('git', args, {
|
58
|
+
encoding: 'utf8',
|
59
|
+
maxBuffer: 10 * 1024 * 1024,
|
60
|
+
cwd: repoRoot,
|
61
|
+
});
|
62
|
+
// Drop obvious binary diffs
|
63
|
+
const text = stdout
|
64
|
+
.split('\n')
|
65
|
+
.filter((line) => !line.startsWith('Binary files '))
|
66
|
+
.join('\n');
|
67
|
+
// Enforce size cap ~300k chars
|
68
|
+
const cap = 300_000;
|
69
|
+
return text.length > cap ? text.slice(0, cap) + '\n<!-- diff truncated -->\n' : text;
|
70
|
+
}
|
71
|
+
catch (err) {
|
72
|
+
const msg = err?.stderr || err?.message || String(err);
|
73
|
+
throw new Error(`Failed to collect git diff: ${msg}`);
|
74
|
+
}
|
75
|
+
}
|
@@ -0,0 +1,10 @@
|
|
1
|
+
export function formatOutput(agentMarkdown) {
|
2
|
+
// For MVP, trust agent output if non-empty; otherwise return a minimal stub.
|
3
|
+
if (agentMarkdown && agentMarkdown.trim().length > 0)
|
4
|
+
return agentMarkdown;
|
5
|
+
return [
|
6
|
+
'# Code Review',
|
7
|
+
'',
|
8
|
+
'No issues detected or empty output from agent.',
|
9
|
+
].join('\n');
|
10
|
+
}
|
@@ -0,0 +1,42 @@
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
2
|
+
import { join } from 'node:path';
|
3
|
+
const CANDIDATE_FILES = [
|
4
|
+
'.cursor/rules/project.mdc',
|
5
|
+
'.cursor/rules/**/*.md',
|
6
|
+
'CODE_REVIEW.md',
|
7
|
+
'CONTRIBUTING.md',
|
8
|
+
'SECURITY.md',
|
9
|
+
'.eslintrc',
|
10
|
+
'.eslintrc.cjs',
|
11
|
+
'.eslintrc.js',
|
12
|
+
'.eslintrc.json',
|
13
|
+
'package.json',
|
14
|
+
'tsconfig.json',
|
15
|
+
];
|
16
|
+
async function readIfExists(path) {
|
17
|
+
try {
|
18
|
+
const data = await fs.readFile(path, 'utf8');
|
19
|
+
return data;
|
20
|
+
}
|
21
|
+
catch {
|
22
|
+
return null;
|
23
|
+
}
|
24
|
+
}
|
25
|
+
export async function gatherContext() {
|
26
|
+
const chunks = [];
|
27
|
+
for (const rel of CANDIDATE_FILES) {
|
28
|
+
// naive glob: only handle literal paths here to keep MVP simple
|
29
|
+
if (rel.includes('*'))
|
30
|
+
continue;
|
31
|
+
const data = await readIfExists(join(process.cwd(), rel));
|
32
|
+
if (!data)
|
33
|
+
continue;
|
34
|
+
// bound each file contribution to ~8k chars
|
35
|
+
const capped = data.slice(0, 8000);
|
36
|
+
chunks.push(`\n<!-- ${rel} -->\n${capped}`);
|
37
|
+
// global size guard ~50k chars
|
38
|
+
if (chunks.join('').length > 50_000)
|
39
|
+
break;
|
40
|
+
}
|
41
|
+
return chunks.join('\n');
|
42
|
+
}
|
@@ -0,0 +1,14 @@
|
|
1
|
+
import { Agent, run } from '@openai/agents';
|
2
|
+
import { debugLog } from '../util/debug.js';
|
3
|
+
export async function invokeAgent({ prompt, maxTokens }) {
|
4
|
+
const model = process.env.CODEX_MODEL || 'gpt-5-codex';
|
5
|
+
const agent = new Agent({
|
6
|
+
name: 'Code Reviewer',
|
7
|
+
instructions: 'You are a precise code-review agent. Follow the output contract exactly. Use minimal verbosity.',
|
8
|
+
model,
|
9
|
+
});
|
10
|
+
const result = await run(agent, prompt);
|
11
|
+
const out = result.finalOutput ?? '';
|
12
|
+
await debugLog(`Agent model=${model} outputLen=${out.length}`);
|
13
|
+
return out;
|
14
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import { collectDiff } from '../review/collectDiff.js';
|
2
|
+
import { gatherContext } from '../review/gatherContext.js';
|
3
|
+
import { buildPrompt } from '../review/buildPrompt.js';
|
4
|
+
import { invokeAgent } from '../review/invokeAgent.js';
|
5
|
+
import { formatOutput } from '../review/formatOutput.js';
|
6
|
+
import { debugLog } from '../util/debug.js';
|
7
|
+
export async function performCodeReview(input, onProgress) {
|
8
|
+
await onProgress?.('Collecting diff…', 10, 100);
|
9
|
+
const diffText = await collectDiff(input);
|
10
|
+
if (!diffText.trim()) {
|
11
|
+
throw new Error('No diff found. Ensure you have changes or a valid git range.');
|
12
|
+
}
|
13
|
+
await onProgress?.('Gathering context…', 30, 100);
|
14
|
+
const context = await gatherContext();
|
15
|
+
await onProgress?.('Building prompt…', 45, 100);
|
16
|
+
const prompt = buildPrompt({ diffText, context, focus: input.focus });
|
17
|
+
await onProgress?.('Calling GPT-5 Codex…', 65, 100);
|
18
|
+
const agentMd = await invokeAgent({ prompt, maxTokens: input.maxTokens });
|
19
|
+
await debugLog(`Review produced ${agentMd.length} chars`);
|
20
|
+
await onProgress?.('Formatting output…', 90, 100);
|
21
|
+
const out = formatOutput(agentMd);
|
22
|
+
await onProgress?.('Done', 100, 100);
|
23
|
+
return out;
|
24
|
+
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
2
|
+
import { homedir } from 'node:os';
|
3
|
+
import { join } from 'node:path';
|
4
|
+
const LOG_PATH = join(homedir(), '.codex-review-mcp-debug.log');
|
5
|
+
export async function debugLog(message) {
|
6
|
+
if (process.env.CODEX_REVIEW_MCP_DEBUG !== '1')
|
7
|
+
return;
|
8
|
+
const line = `[${new Date().toISOString()}] ${message}\n`;
|
9
|
+
try {
|
10
|
+
await appendFile(LOG_PATH, line, 'utf8');
|
11
|
+
}
|
12
|
+
catch {
|
13
|
+
// ignore logging failures in MVP
|
14
|
+
}
|
15
|
+
}
|
package/package.json
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
{
|
2
|
+
"name": "codex-review-mcp",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"main": "index.js",
|
5
|
+
"scripts": {
|
6
|
+
"build": "tsc",
|
7
|
+
"dev": "tsc -w",
|
8
|
+
"prepare": "npm run build",
|
9
|
+
"mcp": "node dist/mcp-server.js",
|
10
|
+
"agents:sample": "node agents-sample.mjs"
|
11
|
+
},
|
12
|
+
"repository": {
|
13
|
+
"type": "git",
|
14
|
+
"url": "git+https://github.com/pmerwin/codex-review-mcp.git"
|
15
|
+
},
|
16
|
+
"homepage": "https://github.com/pmerwin/codex-review-mcp#readme",
|
17
|
+
"bugs": {
|
18
|
+
"url": "https://github.com/pmerwin/codex-review-mcp/issues"
|
19
|
+
},
|
20
|
+
"files": [
|
21
|
+
"dist/**",
|
22
|
+
"bin/**",
|
23
|
+
"README.md",
|
24
|
+
"LICENSE"
|
25
|
+
],
|
26
|
+
"engines": {
|
27
|
+
"node": ">=18",
|
28
|
+
"npm": ">=9"
|
29
|
+
},
|
30
|
+
"keywords": [],
|
31
|
+
"author": "",
|
32
|
+
"license": "ISC",
|
33
|
+
"description": "",
|
34
|
+
"type": "module",
|
35
|
+
"bin": {
|
36
|
+
"codex-review-mcp": "./bin/codex-review-mcp"
|
37
|
+
},
|
38
|
+
"dependencies": {
|
39
|
+
"@modelcontextprotocol/sdk": "^1.19.1",
|
40
|
+
"@openai/agents": "^0.1.9",
|
41
|
+
"dotenv": "^17.2.3",
|
42
|
+
"minimatch": "^10.0.3",
|
43
|
+
"openai": "^6.2.0",
|
44
|
+
"zod": "^3.25.76"
|
45
|
+
},
|
46
|
+
"devDependencies": {
|
47
|
+
"@types/node": "^24.7.0",
|
48
|
+
"typescript": "^5.9.3"
|
49
|
+
}
|
50
|
+
}
|