开发一个本地MCP Server进行code review

背景

目前开发团队会定期使用AI提效进行code review,但由于每个开发人员使用的不同的AI编程工具,有不同的LLM和IDE,各自提交的审查报告风格和统计会因为LLM的不同而不统一。
尝试开发一个本地的MCP工具,这样大家可以用统一模型、统一工具进行code review工作,以保证使用相同的AI模型输出结果的质量和风格是区别不大的。
之所以开发本地的,是因为前期需要实验这个mcp工具的实际效果,不需要在工具中再调用git工具去线上取代码提交记录中的文件。

此工具的完整代码和使用示例已上传git,欢迎给个Star~~
https://github.com/wenkil/mcp_review_code_tool

开发调试过程

先通过官方文档改造一个mcp 服务器的ts代码(注意这里使用的新版SDK里的McpServer不是Server):
https://github.com/modelcontextprotocol/typescript-sdk

展开/收起
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "fs";
import path from "path";

/**
 * 日志级别枚举
 * @enum {string}
 */
enum LogLevel {
  DEBUG = "DEBUG",
  INFO = "INFO",
  WARN = "WARN",
  ERROR = "ERROR"
}

/**
 * 是否启用控制台日志
 * @type {boolean}
 */
const ENABLE_CONSOLE_LOG = true;

/**
 * 日志文件路径
 * @type {string}
 */
const LOG_FILE_PATH = "code-review-mcp.log";

/**
 * 代码评审的结果接口
 * @interface ReviewResult
 */
interface ReviewResult {
  filePath: string;
  llmResponse: string;
  message: string;
}

/**
 * 记录日志到文件和控制台
 * @param {string} message - 要记录的消息
 * @param {LogLevel} level - 日志级别
 */
async function logToFile(message: string, level: LogLevel = LogLevel.INFO) {
  const timestamp = new Date().toISOString();
  const logMessage = `[${timestamp}] [${level}]: ${message}`;
  
  try {
    await fs.appendFile(LOG_FILE_PATH, `${logMessage}\n`);
    
    // 同时输出到控制台,方便调试
    if (ENABLE_CONSOLE_LOG) {
      switch (level) {
        case LogLevel.ERROR:
          console.error(logMessage);
          break;
        case LogLevel.WARN:
          console.warn(logMessage);
          break;
        case LogLevel.DEBUG:
          console.debug(logMessage);
          break;
        default:
          console.log(logMessage);
      }
    }
  } catch (error) {
    console.error(`日志记录失败: ${error}`);
  }
}

/**
 * 读取文件内容
 * @param {string} filePath - 文件的绝对路径
 * @returns {Promise<string>} 文件内容
 */
async function readFileContent(filePath: string): Promise<string> {
  try {
    await logToFile(`开始读取文件: ${filePath}`, LogLevel.DEBUG);
    const content = await fs.readFile(filePath, "utf-8");
    await logToFile(`成功读取文件: ${filePath},大小: ${content.length} 字节`, LogLevel.DEBUG);
    return content;
  } catch (error) {
    await logToFile(`无法读取文件 ${filePath}: ${error}`, LogLevel.ERROR);
    throw new Error(`无法读取文件 ${filePath}: ${error}`);
  }
}

/**
 * 检查文件是否存在
 * @param {string} filePath - 文件的绝对路径 
 * @returns {Promise<boolean>} 文件是否存在
 */
async function fileExists(filePath: string): Promise<boolean> {
  try {
    await fs.access(filePath);
    await logToFile(`文件存在: ${filePath}`, LogLevel.DEBUG);
    return true;
  } catch {
    await logToFile(`文件不存在: ${filePath}`, LogLevel.WARN);
    return false;
  }
}

/**
 * 使用大语言模型分析代码
 * @param {string} filePath - 文件路径
 * @param {string} content - 文件内容
 * @returns {Promise<ReviewResult>} 代码分析结果
 */
async function analyzeLLM(filePath: string, content: string): Promise<ReviewResult> {
  try {
    await logToFile(`开始使用LLM分析文件: ${filePath}`, LogLevel.INFO);
    
    // 提取文件类型
    const extension = path.extname(filePath).toLowerCase();
    await logToFile(`文件类型: ${extension}`, LogLevel.DEBUG);

    // 构建提示词
    const prompt = `
    ## 根据要求对以下${extension}代码进行代码评审,识别潜在的问题。要求:
    1.**统计代码行数及注释率**:
      - 计算该文件的总行数。
      - 计算注释行数(如//、#或/* */等注释方式)。
      - 计算注释率 = 注释行数 / 总行数。
    2.**评价代码质量**:
      在以下六个方面进行评分(0到10分),并给出平均分:
      - **可读性(Readability)**:代码易于理解程度。
      - **一致性(Consistency)**:编码风格和命名的一致性。
      - **模块化(Modularity)**:代码分块和功能单元划分。
      - **可维护性(Maintainability)**:代码易于修改和扩展的能力。
      - **性能(Performance)**:代码执行效率。
      - **文档化(Documentation)**:代码附带的说明和文档质量。
    3. **生成总体报告**:
      - 将所有分步信息整理成一个markdown结构。
      - 内容应包括:第一部分:文件列表汇总(每个文件的总行数、注释率评分, 6维评分,得分比较低或者行数比较多,或者注释率比较低的用颜色高亮出来,比如黄色和红色);第二部分每一个文件的功能描述,以及在六个质量因素上的评分以及说明;第三部分,总结

      以下是要评审的代码:
      \`\`\`
      ${extension}
      ${content}
      \`\`\`
    `;

    await logToFile(`准备调用OpenRouter API`, LogLevel.DEBUG);
    
    // 调用免费OpenRouter API进行演示,实际可使用公司内部购买的LLM api
    const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer 这里需要去申请你自己的key',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        // "model": "qwen/qwen-2.5-coder-32b-instruct:free",
        "model": "deepseek/deepseek-chat-v3-0324:free",
        messages: [
          {
            role: 'user',
            content: prompt,
          },
        ],
      }),
    });

    if (!response.ok) {
      const errorText = `API请求失败: ${response.status} ${response.statusText}`;
      await logToFile(errorText, LogLevel.ERROR);
      throw new Error(errorText);
    }

    await logToFile(`OpenRouter API响应成功`, LogLevel.DEBUG);
    
    const data = await response.json();
    const llmResponse = data.choices[0].message.content;
    await logToFile(`获取到LLM响应,响应长度: ${llmResponse.length}字符`, LogLevel.DEBUG);

    return {
      filePath,
      llmResponse,
      message: `对 ${path.basename(filePath)} 的代码评审完成`,
    };
  } catch (error) {
    await logToFile(`LLM分析失败: ${error}`, LogLevel.ERROR);
    return {
      filePath,
      llmResponse: ``,
      message: `LLM分析失败: ${error}`,
    };
  }
}

/**
 * 创建MCP服务器实例
 */
const server = new McpServer({
  name: "代码评审工具",
  version: "1.0.0",
});

// 记录服务器启动日志
logToFile("MCP代码评审服务器正在启动...", LogLevel.INFO);

/**
 * 代码评审工具
 * @param {string[]} filePaths - 文件的绝对路径数组
 * @returns {Promise<{ content: { type: string, text: string }[] }>} 代码评审结果
 */
server.tool(
  "reviewCode_tool",
  "根据要求进行代码评审,总结代码质量",
  { filePaths: z.array(z.string()) },
  async ({ filePaths }) => {
    await logToFile(`代码评审工具被调用,文件数量: ${filePaths.length}`, LogLevel.INFO);
    await logToFile(`文件列表: ${JSON.stringify(filePaths)}`, LogLevel.DEBUG);

    const results: ReviewResult[] = [];
    const errors: string[] = [];

    for (const filePath of filePaths) {
      try {
        await logToFile(`开始处理文件: ${filePath}`, LogLevel.INFO);

        // 检查文件是否存在
        const exists = await fileExists(filePath);
        if (!exists) {
          const errorMsg = `文件不存在: ${filePath}`;
          errors.push(errorMsg);
          await logToFile(errorMsg, LogLevel.ERROR);
          continue;
        }

        // 读取文件内容
        const content = await readFileContent(filePath);

        // 使用LLM分析所有类型的文件
        const result = await analyzeLLM(filePath, content);
        await logToFile(`完成文件分析: ${filePath}`, LogLevel.INFO);

        results.push(result);
      } catch (error) {
        const errorMsg = `处理文件时出错 ${filePath}: ${error}`;
        await logToFile(errorMsg, LogLevel.ERROR);
        errors.push(errorMsg);
      }
    }

    // 构建响应
    const response = {
      results,
      errors,
      message: `已分析 ${results.length} 个文件,失败 ${errors.length} 个文件`,
    };

    await logToFile(`代码评审完成,结果: ${response.message}`, LogLevel.INFO);
    
    return {
      content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
    };
  }
);

// 注册服务器连接事件
// server.on("connected", () => {
//   logToFile("MCP服务器已连接,等待请求...", LogLevel.INFO);
// });

// server.on("error", (error) => {
//   logToFile(`MCP服务器错误: ${error}`, LogLevel.ERROR);
// });

const transport = new StdioServerTransport();
// 连接服务器并记录日志
try {
  await server.connect(transport);
  await logToFile("MCP服务器已启动并连接", LogLevel.INFO);
} catch (error) {
  await logToFile(`MCP服务器连接失败: ${error}`, LogLevel.ERROR);
}

第一个问题

经过多次测试,claude偶尔会出现路径主动把文件路径做了转换,导致tool找不到该文件进行读取:

展开/收起
工具参数
{
  "filePaths": [
    "/e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue",
    "/e:/DDI/project/Leader_Coach/src/views/recommend/recommend.ts"
  ]
}

工具的返回
{
  "results": [],
  "errors": [
    "文件不存在: /e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue",
    "文件不存在: /e:/DDI/project/Leader_Coach/src/views/recommend/recommend.ts"
  ],
  "message": "已分析 0 个文件,失败 0 个文件"
}

这时候找不到文件,message就不会显示分析了几个文件,失败了几个文件的提示;

优化方式,在tool代码增加路由格式转换:

展开/收起
import path from 'path';
import fs from 'fs';

// 示例路径(包含不同格式)
const testPaths = [
    "E:\\DDI\\project\\Leader_Coach\\src\\views\\recommend\\recommend.ts", // Windows原生
    "/e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue", // 类Unix风格
];

// 路径规范化函数
function normalizeCrossPlatformPath(rawPath) {
    if (rawPath.startsWith('/') && /^\/[a-zA-Z]:\//.test(rawPath)) {
        rawPath = rawPath.substring(1);
    }
    return path.normalize(rawPath.replace(/[\\/]+/g, path.sep));
}

// 检查文件是否存在
function checkFileExists(filePath) {
    const normalizedPath = normalizeCrossPlatformPath(filePath);
    console.log(`规范化结果: ${normalizedPath}`);
    return fs.existsSync(normalizedPath);
}

// 测试所有路径
testPaths.forEach(p => {
    console.log(`原始路径: ${p}`);
    console.log(`是否存在: ${checkFileExists(p)}\n`);
});

// 打印结果
// 原始路径: E:\DDI\project\Leader_Coach\src\views\recommend\recommend.ts
// 规范化结果: E:\DDI\project\Leader_Coach\src\views\recommend\recommend.ts
// 是否存在: true

// 原始路径: /e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue
// 规范化结果: e:\DDI\project\Leader_Coach\src\views\home\component\chatScrollButton\chatScrollButton.vue
// 是否存在: true

此时工具的完整调用记录:
展开/收起
ai调用工具传的参数:
{
  "filePaths": [
    "/e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue",
    "/e:/DDI/project/Leader_Coach/src/views/recommend/recommend.ts"
  ]
}

工具的返回
{
  "results": [
    {
      "filePath": "e:/DDI/project/Leader_Coach/src/views/home/component/chatScrollButton/chatScrollButton.vue",
      "llmResponse": "```markdown\n# 代码评审报告\n\n## 第一部分:文件汇总\n\n| 文件名 | 总行数 | 注释行数 | 注释率 | 可读性 | 一致性 | 模块化 | 可维护性 | 性能 | 文档化 | 平均分 |\n|--------|--------|----------|--------|--------|--------|--------|----------|------|--------|--------|\n| ChatScrollButton.vue | 56 | 7 | 12.5% | 9 | 9 | 8 | 8 | 8 | 8 | 8.3 |\n\n## 第二部分:详细评审\n\n### 文件功能描述\n这是一个Vue 3单文件组件,实现了一个带渐变边框效果的圆形滚动按钮,主要功能包括:\n- 通过`showGradient`属性控制是否显示旋转渐变边框\n- 内置悬停效果和默认边框样式\n- 包含向下的箭头图标\n\n### 质量评分说明\n\n1. **可读性(9/10)**\n   - 代码结构清晰,模板/脚本/样式分离良好\n   - CSS选择器命名语义化明确(如`.scroll-bottom-button-default`)\n   - 改进建议:动画关键帧可以添加注释说明\n\n2. **一致性(9/10)**\n   - 整体遵循Vue 3组合式API规范\n   - CSS变量命名风格统一(`--color-*`)\n   - 少量不一致:模板中同时使用了`:class`和`:style`绑定,建议统一使用一种方式\n\n3. **模块化(8/10)**\n   - 组件功能单一且完整\n   - 可以进一步拆分:将复杂的渐变边框CSS提取为mixin或工具类\n\n4. **可维护性(8/10)**\n   - 类型定义完善(TypeScript接口)\n   - 默认值处理规范\n   - 改进点:缺少emit事件定义说明\n\n5. **性能(8/10)**\n   - 使用了CSS动画而非JS动画\n   - 建议:`background-image`使用雪碧图优化\n\n6. **文档化(8/10)**\n   - 组件级注释完整(包含@description和@example)\n   - 属性有TSDoc注释\n   - 缺少:CSS复杂效果的实现原理说明\n\n## 第三部分:总结\n\n该组件整体质量较高,主要优点:\n- 良好的类型定义和文档注释\n- 视觉效果实现优雅(CSS渐变+遮罩技巧)\n- 响应式交互设计完整\n\n改进建议:\n1. 补充CSS动画实现的原理注释\n2. 统一样式控制方式(优先使用class)\n3. 考虑添加点击事件发射处理\n4. 图片资源建议使用雪碧图优化\n\n总体评分:8.3/10(优质组件,小优化空间)\n```",
      "message": "对 chatScrollButton.vue 的代码评审完成"
    },
    {
      "filePath": "e:/DDI/project/Leader_Coach/src/views/recommend/recommend.ts",
      "llmResponse": "## 代码评审报告\n\n### 第一部分:文件汇总\n\n| 文件类型 | 总行数 | 注释行数 | 注释率 | 可读性 | 一致性 | 模块化 | 可维护性 | 性能 | 文档化 | 平均分 |\n|----------|--------|----------|--------|--------|--------|--------|----------|------|--------|--------|\n| .ts      | 252    | 10       | 3.97%  | 7      | 8      | 6      | 7        | 8    | 5      | 6.8    |\n\n### 第二部分:详细分析\n\n#### 1. 文件功能描述\n这是一个Vue 3组件,主要功能是:\n- 实现一个推荐教练的表单页面\n- 包含职位选择、教练风格选择、管理话题选择等表单字段\n- 处理表单验证和提交\n- 与后端API交互获取推荐结果\n\n#### 2. 质量评分及说明\n\n**可读性 (7/10)**\n- 优点:变量命名清晰,基本能反映其用途\n- 缺点:长数组定义影响可读性,部分逻辑可以进一步拆分\n\n**一致性 (8/10)**\n- 优点:整体编码风格一致,使用Vue 3组合式API\n- 缺点:部分方法使用选项式API(methods),与setup()混合使用\n\n**模块化 (6/10)**\n- 优点:组件导入清晰\n- 缺点:大量选项数据直接定义在组件中,可以考虑外部化\n\n**可维护性 (7/10)**\n- 优点:功能划分明确\n- 缺点:表单验证逻辑较复杂,可以提取为独立函数\n\n**性能 (8/10)**\n- 优点:合理使用响应式数据\n- 缺点:watch中使用JSON.parse(JSON.stringify())可能影响性能\n\n**文档化 (5/10)**\n- 优点:关键方法有注释说明\n- 缺点:整体注释率低(3.97%),部分复杂逻辑缺乏解释\n\n### 第三部分:总结与建议\n\n1. **主要问题**:\n   - 注释率偏低(3.97%)\n   - 长数组定义影响可读性\n   - 混合使用组合式API和选项式API\n   - 表单验证逻辑可以优化\n\n2. **改进建议**:\n   - 将长数组(jobOptions等)提取到单独的文件或配置中\n   - 增加关键逻辑的注释说明\n   - 统一使用组合式API\n   - 提取表单验证逻辑为独立函数\n   - 考虑使用TypeScript接口定义复杂数据结构\n\n3. **亮点**:\n   - 整体结构清晰\n   - 响应式数据处理得当\n   - 错误处理机制完善\n\n总体评分为6.8分,是一个质量中等的组件,通过一些重构可以显著提高可维护性和可读性。",
      "message": "对 recommend.ts 的代码评审完成"
    }
  ],
  "errors": [],
  "message": "已分析 2 个文件,失败 0 个文件"
}

可以看到分析结果和分析记录(分析了几个文件等) 输出结果的markdown格式:
展开/收起

# 代码评审报告

## 第一部分:文件汇总

| 文件名 | 总行数 | 注释行数 | 注释率 | 可读性 | 一致性 | 模块化 | 可维护性 | 性能 | 文档化 | 平均分 |
|--------|--------|----------|--------|--------|--------|--------|----------|------|--------|--------|
| ChatScrollButton.vue | 56 | 7 | 12.5% | 9 | 9 | 8 | 8 | 8 | 8 | 8.3 |

## 第二部分:详细评审

### 文件功能描述
这是一个Vue 3单文件组件,实现了一个带渐变边框效果的圆形滚动按钮,主要功能包括:
- 通过`showGradient`属性控制是否显示旋转渐变边框
- 内置悬停效果和默认边框样式
- 包含向下的箭头图标

### 质量评分说明

1. **可读性(9/10)**
   - 代码结构清晰,模板/脚本/样式分离良好
   - CSS选择器命名语义化明确(如`.scroll-bottom-button-default`   - 改进建议:动画关键帧可以添加注释说明

2. **一致性(9/10)**
   - 整体遵循Vue 3组合式API规范
   - CSS变量命名风格统一(`--color-*`   - 少量不一致:模板中同时使用了`:class``:style`绑定,建议统一使用一种方式

3. **模块化(8/10)**
   - 组件功能单一且完整
   - 可以进一步拆分:将复杂的渐变边框CSS提取为mixin或工具类

4. **可维护性(8/10)**
   - 类型定义完善(TypeScript接口)
   - 默认值处理规范
   - 改进点:缺少emit事件定义说明

5. **性能(8/10)**
   - 使用了CSS动画而非JS动画
   - 建议:`background-image`使用雪碧图优化

6. **文档化(8/10)**
   - 组件级注释完整(包含@description和@example)
   - 属性有TSDoc注释
   - 缺少:CSS复杂效果的实现原理说明

## 第三部分:总结

该组件整体质量较高,主要优点:
- 良好的类型定义和文档注释
- 视觉效果实现优雅(CSS渐变+遮罩技巧)
- 响应式交互设计完整

改进建议:
1. 补充CSS动画实现的原理注释
2. 统一样式控制方式(优先使用class)
3. 考虑添加点击事件发射处理
4. 图片资源建议使用雪碧图优化

总体评分:8.3/10(优质组件,小优化空间)

---

## recommend.ts 代码评审报告

### 第一部分:文件汇总

| 文件类型 | 总行数 | 注释行数 | 注释率 | 可读性 | 一致性 | 模块化 | 可维护性 | 性能 | 文档化 | 平均分 |
|----------|--------|----------|--------|--------|--------|--------|----------|------|--------|--------|
| .ts      | 252    | 10       | 3.97%  | 7      | 8      | 6      | 7        | 8    | 5      | 6.8    |

### 第二部分:详细分析

#### 1. 文件功能描述
这是一个Vue 3组件,主要功能是:
- 实现一个推荐教练的表单页面
- 包含职位选择、教练风格选择、管理话题选择等表单字段
- 处理表单验证和提交
- 与后端API交互获取推荐结果

#### 2. 质量评分及说明

**可读性 (7/10)**
- 优点:变量命名清晰,基本能反映其用途
- 缺点:长数组定义影响可读性,部分逻辑可以进一步拆分

**一致性 (8/10)**
- 优点:整体编码风格一致,使用Vue 3组合式API
- 缺点:部分方法使用选项式API(methods),与setup()混合使用

**模块化 (6/10)**
- 优点:组件导入清晰
- 缺点:大量选项数据直接定义在组件中,可以考虑外部化

**可维护性 (7/10)**
- 优点:功能划分明确
- 缺点:表单验证逻辑较复杂,可以提取为独立函数

**性能 (8/10)**
- 优点:合理使用响应式数据
- 缺点:watch中使用JSON.parse(JSON.stringify())可能影响性能

**文档化 (5/10)**
- 优点:关键方法有注释说明
- 缺点:整体注释率低(3.97%),部分复杂逻辑缺乏解释

### 第三部分:总结与建议

1. **主要问题**   - 注释率偏低(3.97%)
   - 长数组定义影响可读性
   - 混合使用组合式API和选项式API
   - 表单验证逻辑可以优化

2. **改进建议**   - 将长数组(jobOptions等)提取到单独的文件或配置中
   - 增加关键逻辑的注释说明
   - 统一使用组合式API
   - 提取表单验证逻辑为独立函数
   - 考虑使用TypeScript接口定义复杂数据结构

3. **亮点**   - 整体结构清晰
   - 响应式数据处理得当
   - 错误处理机制完善

总体评分为6.8分,是一个质量中等的组件,通过一些重构可以显著提高可维护性和可读性。

第二个问题

再次经过多次测试,每次LLM统计行数和注释率都不一样,这里引入一个node-sloc的npm包进行处理文件统计的不规律的问题:

展开/收起
import { sloc } from 'node-sloc';

/**
 * 代码行数统计结果接口
 */
interface SlocResult {
  /** 统计的文件路径列表 */
  paths: string[];
  /** 统计的文件数量 */
  files: number;
  /** 代码行数(不包含注释和空行) */
  sloc: number;
  /** 注释行数 */
  comments: number;
  /** 空行数 */
  blank: number;
  /** 总行数(代码+注释+空行) */
  loc: number;
}

/**
 * 扩展的统计结果接口,包含注释率计算
 */
interface ExtendedSlocResult extends SlocResult {
  /** 注释行数占总行数的比率 */
  commentsToTotalRatio: number;
  /** 注释行数占代码行数的比率 */
  commentsToCodeRatio: number;
}

/**
 * 统计指定文件的代码行数和注释比率
 * @param filePath - 需要统计的文件路径
 * @returns Promise<ExtendedSlocResult> 返回统计结果,包含代码行数、注释行数、空行数及注释比率
 */
async function countCodeLines(filePath: string): Promise<ExtendedSlocResult> {
  const options = {
    path: filePath
  };

  try {
    const result = await sloc(options);
    
    if(!result) {
      throw new Error('统计结果为空');
    }
    // 计算注释比率
    const commentsToTotalRatio = Number((result.comments / result.loc).toFixed(2));
    const commentsToCodeRatio = Number((result.comments / result.sloc).toFixed(2));

    return {
      ...result,
      commentsToTotalRatio,
      commentsToCodeRatio
    };
  } catch (error) {
    throw new Error(`统计代码行数失败: ${error}`);
  }
}

export { countCodeLines };

// 使用示例
// const filePath = 'E:\\DDI\\project\\Leader_Coach\\src\\views\\recommend\\recommend.ts';
// countCodeLines(filePath)
//   .then((result) => {
//     console.log('文件路径:', result.paths);
//     console.log('文件数量:', result.files);
//     console.log('代码行数:', result.sloc);
//     console.log('注释行数:', result.comments);
//     console.log('空行数:', result.blank);
//     console.log('总行数:', result.loc);
//     console.log('注释行数/总行数:', `${result.commentsToTotalRatio * 100}%`);
//     console.log('注释行数/代码行数:', `${result.commentsToCodeRatio * 100}%`);
//   })
//   .catch((error) => {
//     console.error('错误:', error);
//   }); 

同一个文件两种效果对比:

第三个问题

再次测试,发现node-sloc默认不支持 .vue 文件,见文档地址:https://github.com/edvinh/node-sloc

这里经过配置,解决AI统计行数和注释率高低飘忽不定的这个问题:

const options = {
  path: filePath,
  // 手动添加对Vue文件的支持
  extensions: [ '.vue'],
  ignorePaths: ['node_modules', 'dist', 'build']
};


第四个问题

将这个mcp server给别人使用的问题:目前vscode(需要装插件)和cursor(新版本支持)支持MCP的json配置,也有一些集成工具比如Cherry Studio支持MCP配置(https://docs.cherry-ai.com/advanced-basic/mcp ),但目前看上去jetbrains的IDE还未支持,如果开发人员目前使用的是jetbrains公司下的IDE的话就没法使用这种json配置方式了,但可以通过本地启动一个调试工具页面来使用:

通过官方MCP Inspector调试工具(https://github.com/modelcontextprotocol/inspector )进行调试和使用MCP Server

先build ts文件,然后运行命令(node后面的参数为构建后的js路径):

npx -y @modelcontextprotocol/inspector node "dist/mcp_code_review_sse.js"

控制台输出:

Starting MCP inspector...
Proxy server listening on port 3000

🔍 MCP Inspector is up and running at http://localhost:5173 🚀

访问http://localhost:5173



这里报错【文件不存在】是因为参数里有双引号:

第五个问题

日志调试问题:server端打印的console.log代码不生效,且cursor配置server里带有console.log的mcp后会报错,但流程不影响,可以正常调用和输出,网上搜索也没有找到相关讨论;
然后看下面文档的要求像是需要由客户端来进行接收打印消息来调试的;


开发client文档
https://github.com/cyanheads/model-context-protocol-resources/blob/main/guides/mcp-client-development-guide.md

开发server文档
https://github.com/cyanheads/model-context-protocol-resources/blob/main/guides/mcp-server-development-guide.md

通过查看文档和源码,其实默认是可以通过consoel.error打印的内容被输出在页面上的,用这种方式把执行的每一步都用error的方式打印出来;官方目前只支持’debug’ | ‘info’ | ‘warn’ | ‘error’ ;

不同SDK的推送日志的方式

McpServer SDK

如果是使用官网demo里的的McpServer sdk:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

第一种方式:

打印error

如果想要有其他log level,需要自行查看源码或文档进行处理,这里只演示error

第二种方式,配置server的option:

首先对MCPserver的option进行设置:

设置logging 的 message 设置为true:

const server = new McpServer({
  name: "代码评审工具",
  version: "0.1.0",
}, {
  capabilities: {
      tools: {},
      logging: {
          message: true
      }
  }
});

在代码里tool接受参数的地方打印传参:

const exists = await fileExists(filePath);
console.error(filePaths);
server.server.sendLoggingMessage({
  level: "info",
  data: `文件存在: ${filePath}`,
});

低版本Server SDK

然后通过查看https://github.com/modelcontextprotocol/servers 一些开源的MCP Server的源码,发现他们使用的typescript sdk不是官网的方式

而是使用下面这种方式自行编写tool调用规则和参数校验等,这种也是支持使用console.error进行打印的,同时也可以使用推送log的方式:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";

如果是使用这种SDK,和上面McpServer一样对server进行配置:

把logging 的 message 设置为true:

const server = new Server(
  {
    name: "代码评审工具",
    version: "0.1.0",
  },
  {
    capabilities: {
      tools: {},
      logging: {
        message: true
      }
    }
  }
);

然后再使用这种方式推送log到客户端:

const { filePaths } = args;
server.sendLoggingMessage({
  level: "info",
  message: `开始评审文件: ${filePaths}`
});

使用这种自定义的SDK(非上面的McpServer)在cursor里也是可以正常使用的:

第六个问题

这一版改完,测试时偶尔会碰到页面执行超时,但cursor里测试流程正常:

神奇的是再稍等几分钟就能看到服务器推送到log到调试平台,里面是有打印LLM的审查结果输出的,但在页面上却是等不到LLM的返回就报timeout了,不确定是不是这个调试工具的延迟设置的问题还是代码上的某些地方的异步调用问题;(待深入排查)

经过查看和运行官方调试工具的源码https://github.com/modelcontextprotocol/inspector/tree/main

发现在APP.tsx里调用了工具方法,但没传超时时间,在封装的useConnection.ts脚本里设置的默认是10s,我手动改成100s,然后尝试:

正常的输出位置是在这里的,所以应该是官方需要解决的一个问题:

这个timeout暂时先忽略吧,看官方是否优化,如果要想在页面上知道是否已经结束(页面连个loading也没有,体验差劲哈哈哈),可以通过编写一个定时器,每隔一秒推送以下消息来知道当前还没结束:

server.server.sendLoggingMessage({
      level: "info",
      data: `等待LLM返回中`
    });

待发展问题 :

1.是否要部署到公司服务器,如果部署到网上需要的优化项

  • mcp.json需要带上token,加一层防护
  • 服务器上如何读取要review的代码
  • 服务器上需要将结果进行保存到某个地方

解决所有问题后完整代码

主文件:mcp_code_review.ts

import OpenAI from 'openai';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { promises as fs } from "fs";
import path from "path";
import { countCodeLines } from "./sloc_tool.js";

/**
 * 创建MCP服务器实例
 */
const server = new McpServer({
  name: "代码评审工具",
  version: "0.1.0",
}, {
  capabilities: {
    tools: {},
    logging: {
      message: true
    }
  }
});


// Check for API key and baseURL
// 这里调试时可以手动替换掉来自env的配置
//  || "11111111111111111111111"
//  || "https://xxxxxx/api/v1"
//  || "qwen/qwen-2.5-coder-32b-instruct:free"
const OPENAI_API_KEY = process.env.OPENAI_API_KEY!;
const OPENAI_API_BASE = process.env.OPENAI_API_BASE!;
const OPENAI_API_MODEL = process.env.OPENAI_API_MODEL!;
if (!OPENAI_API_KEY || !OPENAI_API_BASE) {
  console.error("Error: OPENAI_API_KEY or OPENAI_API_BASE environment variable is required");
  process.exit(1);
}

/**
 * 读取文件内容
 * @param {string} filePath - 文件的绝对路径
 * @returns {Promise<string>} 文件内容
 */
async function readFileContent(filePath: string): Promise<string> {
  try {
    const content = await fs.readFile(filePath, "utf-8");
    return content;
  } catch (error) {
    throw new Error(`无法读取文件 ${filePath}: ${error}`);
  }
}

/**
 * 路径规范化函数
 * @param {string} rawPath - 原始路径
 * @returns {string} 规范化后的路径
 */
function normalizeCrossPlatformPath(rawPath: string) {
  if (rawPath.startsWith('/') && /^\/[a-zA-Z]:\//.test(rawPath)) {
    rawPath = rawPath.substring(1);
  }
  return path.normalize(rawPath.replace(/[\\/]+/g, path.sep));
}

/**
 * 检查文件是否存在
 * @param {string} filePath - 文件的绝对路径 
 * @returns {Promise<boolean>} 文件是否存在
 */
async function fileExists(filePath: string): Promise<boolean> {
  try {
    await fs.access(filePath);
    return true;
  } catch (error) {
    return false;
  }
}

/**
 * 使用大语言模型分析代码
 * @param {string} filePath - 文件路径
 * @param {string} content - 文件内容
 */
async function analyzeLLM(filePath: string, content: string) {
  // 提取文件类型
  const extension = path.extname(filePath).toLowerCase();
  // 统计代码行数和注释率
  const { sloc, comments, blank, loc, commentsToTotalRatio, commentsToCodeRatio } = await countCodeLines(filePath);
  // 构建提示词
  const prompt = `
    ## 根据要求对以下${extension}代码进行代码评审,识别潜在的问题。
    以下是代码行数和注释率:
    文件名:${path.basename(filePath)}
    总行数:${loc}
    注释行数:${comments}
    代码行数:${sloc}
    总注释率(注释行数/总行数):${commentsToTotalRatio}
    代码行数注释率(注释行数/代码行数):${commentsToCodeRatio}
    要求:
    1.**评价代码质量**:
      在以下六个方面进行评分(0到10分),并给出平均分:
      - **可读性(Readability)**:代码易于理解程度。
      - **一致性(Consistency)**:编码风格和命名的一致性。
      - **模块化(Modularity)**:代码分块和功能单元划分。
      - **可维护性(Maintainability)**:代码易于修改和扩展的能力。
      - **性能(Performance)**:代码执行效率。
      - **文档化(Documentation)**:代码附带的说明和文档质量。
    2. **生成总体报告**:
      - 将所有分步信息整理成一个markdown结构。
      - 内容应包括:第一部分:文件列表汇总(每个文件的总行数、注释行数、代码行数、总注释率、代码行数注释率评分, 6维评分,得分比较低或者行数比较多;第二部分每一个文件的功能描述,以及在六个质量因素上的评分以及说明;第三部分,总结

      以下是要评审的代码:
      \`\`\`
      ${extension}
      ${content}
      \`\`\`
    `;
  const client = new OpenAI({
    apiKey: OPENAI_API_KEY,
    baseURL: OPENAI_API_BASE,
  });
  const llmResponse = await client.chat.completions.create({
    model: OPENAI_API_MODEL,
    temperature: 0.1,
    messages: [
      {
        role: 'user',
        content: prompt,
      },
    ],
  });
  server.server.sendLoggingMessage({
    level: "info",
    data: `llm结束: ${llmResponse.choices[0].message.content}`
  });
  let llmMessage = llmResponse.choices && llmResponse.choices.length > 0 ? llmResponse.choices[0].message.content : "";
  if (!llmMessage) {
    console.error(`LLM响应内容为空`);
    throw new Error("LLM响应为空");
  }
  return {
    filePath,
    llmResponse: llmMessage,
    message: `对 ${path.basename(filePath)} 的代码评审完成`,
  };
}

// 分离结果和错误
// 定义结果类型
type ProcessResult = {
  filePath: string;
  llmResponse: string;
  message: string;
  error?: string;
};

// 将文件处理逻辑封装成独立函数
async function processFile(fp: string): Promise<ProcessResult> {
  try {
    let rawPath = String.raw`${fp}`;// 使用原始字符串标记
    let filePath = normalizeCrossPlatformPath(rawPath);// 将所有反斜杠替换为正斜杠,Node.js可以在所有平台上理解

    // 检查文件是否存在
    const exists = await fileExists(filePath);
    server.server.sendLoggingMessage({
      level: "info",
      data: `需要review的文件是否存在: ${exists}`,
    });

    if (!exists) {
      const errorMsg = `文件不存在: ${filePath}`;
      console.error(errorMsg);
      return {
        filePath: fp,
        llmResponse: "",
        message: `LLM分析失败: ${errorMsg}`,
        error: errorMsg
      };
    }

    // 读取文件内容
    const content = await readFileContent(filePath);
    // 使用LLM分析文件
    const result = await analyzeLLM(filePath, content);
    return result;
  } catch (error) {
    const errorMsg = `LLM分析失败: ${fp}: ${error}`;
    console.error(errorMsg);
    return {
      filePath: fp,
      llmResponse: "",
      message: `LLM分析失败: ${error}`,
      error: errorMsg
    };
  }
}

/**
 * 代码评审工具
 * @param {string[]} filePaths - 文件的绝对路径数组
 * @returns {Promise<{ content: { type: string, text: string }[] }>} 代码评审结果
 */
server.tool(
  "reviewCode_tool",
  "工具描述:根据要求进行代码评审,总结代码质量,传参为filePaths数组,数组元素是每个文件的完整地址",
  { filePaths: z.array(z.string()) },
  async ({ filePaths }) => {
    // 使用 Promise.all 并行处理所有文件
    const processResults = await Promise.all(
      filePaths.map(fp => processFile(fp))
    );

    const results = processResults as ProcessResult[];
    const errors = processResults
      .filter((r: ProcessResult): r is ProcessResult & { error: string } => Boolean(r.error))
      .map(r => r.error);

    server.server.sendLoggingMessage({
      level: "info",
      data: `当前的results:${JSON.stringify(results)}`
    });

    // 构建响应
    const failedCount = results.filter(r => r.llmResponse === "").length;
    const response = {
      results,
      errors,
      message: `已分析 ${results.length} 个文件,失败 ${failedCount} 个文件`,
    };

    return {
      content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport)

调用的工具:sloc_tool.ts

import { sloc } from 'node-sloc';

/**
 * 代码行数统计结果接口
 */
interface SlocResult {
  /** 统计的文件路径列表 */
  paths: string[];
  /** 统计的文件数量 */
  files: number;
  /** 代码行数(不包含注释和空行) */
  sloc: number;
  /** 注释行数 */
  comments: number;
  /** 空行数 */
  blank: number;
  /** 总行数(代码+注释+空行) */
  loc: number;
}

/**
 * 扩展的统计结果接口,包含注释率计算
 */
interface ExtendedSlocResult extends SlocResult {
  /** 注释行数占总行数的比率 */
  commentsToTotalRatio: number;
  /** 注释行数占代码行数的比率 */
  commentsToCodeRatio: number;
}

/**
 * 统计指定文件的代码行数和注释比率
 * @param filePath - 需要统计的文件路径
 * @returns Promise<ExtendedSlocResult> 返回统计结果,包含代码行数、注释行数、空行数及注释比率
 */
async function countCodeLines(filePath: string): Promise<ExtendedSlocResult> {
  const options = {
    path: filePath,
    // 添加对Vue文件的支持
    extensions: ['.js', '.ts', '.jsx', '.tsx', '.vue', '.html', '.css'],
    ignorePaths: ['node_modules', 'dist', 'build']
  };

  try {
    const result = await sloc(options);
    
    if(!result) {
      throw new Error('统计结果为空');
    }
    // 计算注释比率
    const commentsToTotalRatio = Number((result.comments / result.loc).toFixed(2));
    const commentsToCodeRatio = Number((result.comments / result.sloc).toFixed(2));

    return {
      ...result,
      commentsToTotalRatio,
      commentsToCodeRatio
    };
  } catch (error) {
    throw new Error(`统计代码行数失败: ${error}`);
  }
}

export { countCodeLines };

// 使用示例 控制台运行tsx src/utils/sloc_tool.ts
// const filePath = 'E:\\DDI\\project\\Leader_Coach\\src\\views\\home\\component\\chatScrollButton\\chatScrollButton.vue';
// countCodeLines(filePath)
//   .then((result) => {
//     console.log('文件统计结果:');
//     console.log('文件路径:', result.paths);
//     console.log('文件数量:', result.files);
//     console.log('代码行数:', result.sloc);
//     console.log('注释行数:', result.comments);
//     console.log('空行数:', result.blank);
//     console.log('总行数:', result.loc);
//     console.log('注释行数/总行数:', `${result.commentsToTotalRatio * 100}%`);
//     console.log('注释行数/代码行数:', `${result.commentsToCodeRatio * 100}%`);
//   })
//   .catch((error) => {
//     console.error('错误:', error);
//   }); 

MCP Server使用步骤

  • 拉取MCP代码到本地

  • 根据自己的windows和mac环境对json进行区分配置(命令路径)

  • 然后将具体文件地址和要求给到AI

  • 等待输出

Cursor使用示例

配置json:

{
  "mcpServers": {
    "代码评审工具": {
      "command": "node",
      "args": ["your path/dist/mcp_code_review.js"],
      "cwd": "your path",
      "env": {
        "OPENAI_API_KEY": "your api key",
        "OPENAI_API_BASE": "https://openrouter.ai/api/v1",
        "OPENAI_API_MODEL": "qwen/qwen-2.5-coder-32b-instruct:free"
      }
    }
  }
}

cursor中启动MCP服务,会触发一个命令行窗口,运行过程中不要关闭

cursor里必须使用agent模式,并且使用claude模型,prompt如下:

E:\project\Leader_Coach\src\views\home\component\chatScrollButton\chatScrollButton.vue
E:\project\Leader_Coach\src\views\recommend\recommend.ts
使用代码审查工具对这以上文件路径进行code review
调用工具的要求:
- 取出工具返回的llmResponse字段里的内容,将完整内容提取到markdown里的code标签里一起回复
- 如果工具输出里errors有值,或者message里失败n个文件,直接将errors内容输出,这种情况不要总结代码

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!