Skip to main content
Glama

Frontend Test Generation & Code Review MCP Server

index.ts21.4 kB
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import dotenv from 'dotenv'; import { getEnv, validateAiConfig } from './config/env.js'; import { loadConfig } from './config/loader.js'; import { logger } from './utils/logger.js'; import { PhabricatorClient } from './clients/phabricator.js'; import { OpenAIClient } from './clients/openai.js'; import { EmbeddingClient } from './clients/embedding.js'; import { Cache } from './cache/cache.js'; import { StateManager } from './state/manager.js'; import { FetchDiffTool } from './tools/fetch-diff.js'; import { ReviewDiffTool } from './tools/review-diff.js'; import { GenerateTestsTool } from './tools/generate-tests.js'; import { AnalyzeTestMatrixTool } from './tools/analyze-test-matrix.js'; import { PublishCommentsTool } from './tools/publish-comments.js'; import { TestMatrixAnalyzer } from './agents/test-matrix-analyzer.js'; import { detectProjectTestStack } from './tools/detect-stack.js'; import { ResolvePathTool } from './tools/resolve-path.js'; dotenv.config(); let config: ReturnType<typeof loadConfig>; let phabClient: PhabricatorClient; let openaiClient: OpenAIClient; let embeddingClient: EmbeddingClient; let cache: Cache; let stateManager: StateManager; let fetchDiffTool: FetchDiffTool; let reviewDiffTool: ReviewDiffTool; let generateTestsTool: GenerateTestsTool; let analyzeTestMatrixTool: AnalyzeTestMatrixTool; let publishCommentsTool: PublishCommentsTool; let resolvePathTool: ResolvePathTool; function initialize() { try { getEnv(); config = loadConfig(); const validation = validateAiConfig({ llm: { apiKey: config.llm.apiKey, baseURL: config.llm.baseURL, model: config.llm.model, }, embedding: { baseURL: config.embedding.baseURL || config.llm.baseURL, model: config.embedding.model, enabled: config.embedding.enabled, }, }); if (validation.errors.length > 0) { logger.error('AI configuration validation failed', { errors: validation.errors }); throw new Error( `AI 配置验证失败:\n${validation.errors.map(e => ` - ${e}`).join('\n')}` ); } if (validation.warnings.length > 0) { logger.warn('AI configuration warnings', { warnings: validation.warnings }); } phabClient = new PhabricatorClient({ host: config.phabricator.host, token: config.phabricator.token, }); openaiClient = new OpenAIClient({ apiKey: config.llm.apiKey, baseURL: config.llm.baseURL, model: config.llm.model, temperature: config.llm.temperature, topP: config.llm.topP, maxTokens: config.llm.maxTokens, }); embeddingClient = new EmbeddingClient({ apiKey: config.llm.apiKey, baseURL: config.embedding.baseURL || config.llm.baseURL, model: config.embedding.model, }); cache = new Cache({ dir: config.cache.dir, ttl: config.cache.ttl, }); stateManager = new StateManager({ dir: config.state.dir, }); fetchDiffTool = new FetchDiffTool(phabClient, cache); publishCommentsTool = new PublishCommentsTool(phabClient, stateManager, embeddingClient); reviewDiffTool = new ReviewDiffTool( fetchDiffTool, stateManager, publishCommentsTool, openaiClient, embeddingClient, config ); resolvePathTool = new ResolvePathTool(); const testMatrixAnalyzer = new TestMatrixAnalyzer(openaiClient); analyzeTestMatrixTool = new AnalyzeTestMatrixTool( fetchDiffTool, resolvePathTool, stateManager, testMatrixAnalyzer ); generateTestsTool = new GenerateTestsTool( fetchDiffTool, stateManager, openaiClient, embeddingClient, config ); logger.info('Initialization complete'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Initialization failed', { error: errorMessage, stack: error instanceof Error ? error.stack : undefined }); throw error; } } const server = new Server( { name: 'fe-testgen-mcp', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'detect-project-test-stack', description: '探测项目测试技术栈(Vitest/Jest)', inputSchema: { type: 'object', properties: { repoRoot: { type: 'string', description: '项目根目录路径(可选)', }, }, }, }, { name: 'fetch-diff', description: '从 Phabricator 获取完整的 diff 内容(包括所有变更细节)。\n\n' + '💡 使用场景:\n' + '1. 在调用其他工具前,先查看 diff 的完整信息\n' + '2. 了解变更的具体内容、文件路径和统计信息\n' + '3. 仅需查看 diff 内容,不执行其他操作\n\n' + '📤 输出信息(完整且详细):\n' + '• Revision 标题和描述\n' + '• 文件路径列表\n' + '• 变更类型(新增/修改/删除)\n' + '• 增删行数统计\n' + '• 每个文件的 hunks(包含具体的变更行内容)\n' + '• 完整的 diff 文本(带行号,标准 unified diff 格式)\n\n' + '⚠️ 重要提示:\n' + '• 此工具返回的信息已经包含所有变更细节\n' + '• hunks 字段包含每一行的具体变更(+/- 前缀)\n' + '• fullDiff 字段包含完整的 diff 文本\n' + '• 无需使用 git show、git diff 等命令\n' + '• Revision ID(如 D551414)不是 git commit hash,不能用于 git 命令', inputSchema: { type: 'object', properties: { revisionId: { type: 'string', description: 'Revision ID(如 D551414,这是 Phabricator ID,不是 git commit hash)', }, forceRefresh: { type: 'boolean', description: '强制刷新缓存', }, }, required: ['revisionId'], }, }, { name: 'resolve-path', description: '【高级工具】将相对路径解析为绝对路径,并返回检测到的项目根目录。\n\n' + '⚠️ 注意:此工具主要供内部使用,一般情况下无需直接调用。\n' + '其他工具(如 analyze-test-matrix、review-frontend-diff)已经内置了路径解析功能。\n\n' + '使用场景:\n' + '• 调试路径解析问题\n' + '• 验证项目根目录检测是否正确\n' + '• 在自定义工作流中需要手动解析路径', inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: '相对路径数组(相对于项目根目录/子包)', }, projectRoot: { type: 'string', description: '项目根目录路径(可选),优先级最高', }, }, required: ['paths'], }, }, { name: 'review-frontend-diff', description: '审查前端代码变更。\n\n' + '📋 推荐工作流程:\n' + '1. 先调用 fetch-diff 查看 diff 内容\n' + '2. 调用此工具进行代码审查\n\n' + '⚙️ 自动执行的步骤:\n' + '• 获取 diff 内容\n' + '• 识别审查主题\n' + '• 执行代码审查\n' + '• (可选)发布评论到 Phabricator\n\n' + '✨ 特性:\n' + '• 支持增量去重,避免重复评论\n' + '• 自动识别审查主题(React、TypeScript、性能等)', inputSchema: { type: 'object', properties: { revisionId: { type: 'string', description: 'Revision ID', }, topics: { type: 'array', items: { type: 'string' }, description: '手动指定主题(可选)', }, mode: { type: 'string', enum: ['incremental', 'full'], description: '模式:增量或全量', }, publish: { type: 'boolean', description: '是否发布评论', }, forceRefresh: { type: 'boolean', description: '强制刷新缓存', }, }, required: ['revisionId'], }, }, { name: 'analyze-test-matrix', description: '分析代码变更的功能清单和测试矩阵(测试用例生成的第一步)。\n\n' + '📋 推荐工作流程:\n' + '1. 先调用 fetch-diff 查看 diff 内容和文件路径\n' + '2. 执行 pwd 命令获取当前工作目录(项目根目录)\n' + '3. 调用此工具,传入 projectRoot 参数\n\n' + '⚙️ 自动执行的步骤:\n' + '• 获取 diff 内容\n' + '• 使用提供的 projectRoot 解析文件路径\n' + '• 检测测试框架\n' + '• 分析测试矩阵\n\n' + '💡 提示:projectRoot 参数是可选的,但强烈建议提供。\n' + '如果不提供,系统会尝试自动检测,但可能失败。', inputSchema: { type: 'object', properties: { revisionId: { type: 'string', description: 'Revision ID', }, projectRoot: { type: 'string', description: '项目根目录的绝对路径(通过 pwd 命令获取)', }, forceRefresh: { type: 'boolean', description: '强制刷新缓存', }, }, required: ['revisionId'], }, }, { name: 'generate-tests', description: '基于测试矩阵生成具体的单元测试代码。\n' + '⚠️ 前置要求:必须先调用 analyze-test-matrix 生成测试矩阵。\n\n' + '📋 推荐工作流程:\n' + '1. 调用 analyze-test-matrix(传入 projectRoot)\n' + '2. 从返回结果中获取 projectRoot 字段的值\n' + '3. 调用此工具时,传入相同的 projectRoot 值\n\n' + '⚙️ 自动执行的步骤:\n' + '• 加载已保存的测试矩阵\n' + '• 生成具体的测试用例代码\n' + '• 应用增量去重(如果是增量模式)\n\n' + '🔴 重要:projectRoot 参数是必需的!\n' + 'analyze-test-matrix 的返回结果中包含 projectRoot 字段,\n' + '你必须将该值传递给此工具,否则会导致项目根目录检测失败。', inputSchema: { type: 'object', properties: { revisionId: { type: 'string', description: 'Revision ID', }, projectRoot: { type: 'string', description: '项目根目录的绝对路径(必须与 analyze-test-matrix 使用相同的值,否则会报错)', }, scenarios: { type: 'array', items: { type: 'string' }, description: '手动指定测试场景(可选)', }, mode: { type: 'string', enum: ['incremental', 'full'], description: '模式:增量或全量', }, maxTests: { type: 'number', description: '最大测试数量', }, forceRefresh: { type: 'boolean', description: '强制刷新缓存', }, }, required: ['revisionId'], }, }, { name: 'publish-phabricator-comments', description: '发布评论到 Phabricator', inputSchema: { type: 'object', properties: { revisionId: { type: 'string', description: 'Revision ID', }, comments: { type: 'array', items: { type: 'object', properties: { file: { type: 'string' }, line: { type: 'number' }, message: { type: 'string' }, issueId: { type: 'string' }, }, required: ['file', 'line', 'message', 'issueId'], }, }, message: { type: 'string', description: '总体评论', }, incremental: { type: 'boolean', description: '增量模式', }, }, required: ['revisionId', 'comments'], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; logger.info(`Handling CallTool action for tool '${name}'`, { args }); try { switch (name) { case 'detect-project-test-stack': { const repoRoot = (args as { repoRoot?: string })?.repoRoot; const stack = await detectProjectTestStack(repoRoot); logger.info(`Tool '${name}' completed successfully`); return { content: [ { type: 'text', text: JSON.stringify(stack, null, 2), }, ], }; } case 'fetch-diff': { const { revisionId, forceRefresh } = args as { revisionId: string; forceRefresh?: boolean; }; const diff = await fetchDiffTool.fetch({ revisionId, forceRefresh }); const frontendDiff = fetchDiffTool.filterFrontendFiles(diff); logger.info(`Tool '${name}' completed successfully`, { revisionId: diff.revisionId, filesCount: frontendDiff.files.length }); return { content: [ { type: 'text', text: JSON.stringify({ revisionId: diff.revisionId, diffId: diff.diffId, title: diff.title, summary: diff.summary, files: frontendDiff.files.map(f => ({ path: f.path, changeType: f.changeType, additions: f.additions, deletions: f.deletions, hunks: f.hunks.map(h => ({ oldStart: h.oldStart, oldLines: h.oldLines, newStart: h.newStart, newLines: h.newLines, content: h.lines.join('\n'), })), })), // 提供完整的 diff 文本(带行号) fullDiff: frontendDiff.numberedRaw || frontendDiff.raw, }, null, 2), }, ], }; } case 'resolve-path': { const input = args as { paths: string[]; projectRoot?: string; }; const result = await resolvePathTool.resolve({ paths: input.paths, projectRoot: input.projectRoot, }); logger.info(`Tool '${name}' completed successfully`, { root: result.root, paths: result.resolved.length, }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'review-frontend-diff': { const input = args as { revisionId: string; topics?: string[]; mode?: 'incremental' | 'full'; publish?: boolean; forceRefresh?: boolean; }; const result = await reviewDiffTool.review({ revisionId: input.revisionId, topics: input.topics, mode: input.mode || 'incremental', publish: input.publish || false, forceRefresh: input.forceRefresh || false, }); logger.info(`Tool '${name}' completed successfully`, { revisionId: input.revisionId, issuesCount: result.issues.length, published: input.publish }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'analyze-test-matrix': { const input = args as { revisionId: string; projectRoot?: string; forceRefresh?: boolean; }; const result = await analyzeTestMatrixTool.analyze({ revisionId: input.revisionId, projectRoot: input.projectRoot, forceRefresh: input.forceRefresh || false, }); logger.info(`Tool '${name}' completed successfully`, { revisionId: input.revisionId, projectRoot: input.projectRoot, features: result.matrix.summary.totalFeatures, scenarios: result.matrix.summary.totalScenarios, estimatedTests: result.matrix.summary.estimatedTests }); // 在返回结果中包含 projectRoot,以便后续工具使用 const resultWithProjectRoot = { ...result, projectRoot: input.projectRoot, // 添加 projectRoot 到返回结果 }; return { content: [ { type: 'text', text: JSON.stringify(resultWithProjectRoot, null, 2), }, ], }; } case 'generate-tests': { const input = args as { revisionId: string; projectRoot?: string; scenarios?: string[]; mode?: 'incremental' | 'full'; maxTests?: number; forceRefresh?: boolean; }; const result = await generateTestsTool.generate({ revisionId: input.revisionId, projectRoot: input.projectRoot, scenarios: input.scenarios, mode: input.mode || 'incremental', maxTests: input.maxTests, forceRefresh: input.forceRefresh || false, }); logger.info(`Tool '${name}' completed successfully`, { revisionId: input.revisionId, testsCount: result.tests.length, scenarios: result.identifiedScenarios }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } case 'publish-phabricator-comments': { const input = args as { revisionId: string; comments: Array<{ file: string; line: number; message: string; issueId: string; }>; message?: string; incremental?: boolean; }; const result = await publishCommentsTool.publish({ revisionId: input.revisionId, comments: input.comments, message: input.message, incremental: input.incremental !== undefined ? input.incremental : true, }); logger.info(`Tool '${name}' completed successfully`, { revisionId: input.revisionId, published: result.published, skipped: result.skipped }); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { const errorDetails = error instanceof Error ? { message: error.message, stack: error.stack, name: error.name, } : { raw: String(error) }; logger.error(`Tool ${name} failed`, { error: errorDetails, args, }); return { content: [ { type: 'text', text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }, null, 2), }, ], isError: true, }; } }); async function main() { try { initialize(); const transport = new StdioServerTransport(); await server.connect(transport); logger.info('fe-testgen-mcp server started'); } catch (error) { logger.error('Server failed to start', { error }); process.exit(1); } } main().catch((error) => { logger.error('Fatal error', { error }); process.exit(1); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/NorthSeacoder/fe-testgen-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server