@cqsjjb/meter-sphere-mcp-server 1.0.1 → 1.0.2

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/mcp-server.mjs CHANGED
@@ -1,928 +1,938 @@
1
- #!/usr/bin/env node
2
-
3
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import {
6
- CallToolRequestSchema,
7
- ListToolsRequestSchema,
8
- } from '@modelcontextprotocol/sdk/types.js';
9
- import http from 'http';
10
- import https from 'https';
11
- import fs from 'fs';
12
- import path from 'path';
13
- import os from 'os';
14
-
15
- /**
16
- * 获取API基础URL,从环境变量读取,默认值为 http://192.168.3.26:8081
17
- */
18
- function getApiBaseUrl() {
19
- return process.env.API_BASE_URL || 'http://192.168.3.26:8081';
20
- }
21
-
22
- /**
23
- * 获取AI模型配置,从环境变量读取
24
- */
25
- function getModelConfig() {
26
- return {
27
- baseURL: process.env.MODEL_BASE_URL,
28
- apiKey: process.env.MODEL_API_KEY,
29
- model: process.env.MODEL_ID
30
- };
31
- }
32
-
33
- /**
34
- * 获取进度文件路径
35
- * 优先级:环境变量 > 当前工作目录 > 用户主目录下的配置目录
36
- */
37
- function getProgressFilePath() {
38
- // 优先使用环境变量指定的目录
39
- if (process.env.PROGRESS_FILE_DIR) {
40
- return path.join(process.env.PROGRESS_FILE_DIR, 'test-progress.json');
41
- }
42
-
43
- // 其次使用当前工作目录(用户运行命令的目录,通常是项目根目录)
44
- // 这对于npm包来说是最合适的,因为进度文件应该跟随项目
45
- const cwd = process.cwd();
46
- if (cwd && cwd !== '/') {
47
- return path.join(cwd, 'test-progress.json');
48
- }
49
-
50
- // 最后回退到用户主目录下的配置目录
51
- const homeDir = os.homedir();
52
- const configDir = path.join(homeDir, '.config', 'meter-sphere-mcp');
53
-
54
- // 确保配置目录存在
55
- try {
56
- if (!fs.existsSync(configDir)) {
57
- fs.mkdirSync(configDir, { recursive: true });
58
- }
59
- } catch (error) {
60
- console.error('创建配置目录失败:', error);
61
- }
62
-
63
- return path.join(configDir, 'test-progress.json');
64
- }
65
-
66
- /**
67
- * 加载测试进度
68
- */
69
- function loadProgress() {
70
- const progressPath = getProgressFilePath();
71
- try {
72
- if (fs.existsSync(progressPath)) {
73
- const content = fs.readFileSync(progressPath, 'utf8');
74
- return JSON.parse(content);
75
- }
76
- } catch (error) {
77
- console.error('加载进度文件失败:', error);
78
- }
79
- return {
80
- testPlanId: null,
81
- lastUpdate: null,
82
- completed: [],
83
- total: 0,
84
- completedCount: 0
85
- };
86
- }
87
-
88
- /**
89
- * 保存测试进度
90
- */
91
- function saveProgress(progress) {
92
- const progressPath = getProgressFilePath();
93
- try {
94
- progress.lastUpdate = new Date().toISOString();
95
- fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2), 'utf8');
96
- } catch (error) {
97
- console.error('保存进度文件失败:', error);
98
- throw new Error(`保存进度失败: ${error.message}`);
99
- }
100
- }
101
-
102
- /**
103
- * 检查测试用例是否已完成
104
- */
105
- function isCompleted(testCaseId, progress) {
106
- return progress.completed.some(item => item.testCaseId === testCaseId);
107
- }
108
-
109
- /**
110
- * 标记测试用例为已完成
111
- */
112
- function markCompleted(testCaseId, priority, progress) {
113
- if (!isCompleted(testCaseId, progress)) {
114
- progress.completed.push({
115
- testCaseId: testCaseId,
116
- priority: priority,
117
- completedAt: new Date().toISOString()
118
- });
119
- progress.completedCount = progress.completed.length;
120
- saveProgress(progress);
121
- }
122
- }
123
-
124
- /**
125
- * 重置测试进度文件
126
- */
127
- function resetProgress(keepTestPlanId = false) {
128
- const currentProgress = loadProgress();
129
-
130
- const newProgress = {
131
- testPlanId: keepTestPlanId ? currentProgress.testPlanId : null,
132
- lastUpdate: null,
133
- completed: [],
134
- total: 0,
135
- completedCount: 0
136
- };
137
-
138
- try {
139
- saveProgress(newProgress);
140
- return newProgress;
141
- } catch (error) {
142
- console.error('重置进度文件失败:', error);
143
- throw new Error(`重置进度失败: ${error.message}`);
144
- }
145
- }
146
-
147
- /**
148
- * 发送 HTTP POST 请求
149
- */
150
- function httpPost(url, headers = {}, data = {}) {
151
- return new Promise((resolve, reject) => {
152
- const urlObj = new URL(url);
153
- const postData = JSON.stringify(data);
154
-
155
- const requestHeaders = {
156
- 'Content-Type': 'application/json;charset=UTF-8',
157
- 'Content-Length': Buffer.byteLength(postData),
158
- ...headers
159
- };
160
-
161
- const options = {
162
- hostname: urlObj.hostname,
163
- port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
164
- path: urlObj.pathname + (urlObj.search || ''),
165
- method: 'POST',
166
- headers: requestHeaders
167
- };
168
-
169
- const req = http.request(options, res => {
170
- const chunks = [];
171
- res.on('data', chunk => chunks.push(chunk));
172
- res.on('end', () => {
173
- const buffer = Buffer.concat(chunks);
174
- const responseData = buffer.toString('utf8');
175
- resolve(responseData);
176
- });
177
- });
178
-
179
- req.on('error', error => reject(error));
180
- req.write(postData);
181
- req.end();
182
- });
183
- }
184
-
185
- /**
186
- * 发送 HTTP GET 请求
187
- */
188
- function httpGet(url, headers = {}) {
189
- return new Promise((resolve, reject) => {
190
- const urlObj = new URL(url);
191
- const options = {
192
- hostname: urlObj.hostname,
193
- port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
194
- path: urlObj.pathname + (urlObj.search || ''),
195
- method: 'GET',
196
- headers: headers
197
- };
198
-
199
- const req = http.request(options, res => {
200
- const chunks = [];
201
- res.on('data', chunk => chunks.push(chunk));
202
- res.on('end', () => {
203
- const buffer = Buffer.concat(chunks);
204
- const responseData = buffer.toString('utf8');
205
- resolve(responseData);
206
- });
207
- });
208
-
209
- req.on('error', error => reject(error));
210
- req.end();
211
- });
212
- }
213
-
214
- /**
215
- * 解析URL获取查询参数(与前端index.html中的逻辑保持一致)
216
- */
217
- function parseQueryParams(url) {
218
- try {
219
- // 创建URL对象来解析URL
220
- const urlObj = new URL(url);
221
-
222
- // 获取标准位置的query参数(在?之后,在#之前)
223
- const params = {};
224
- urlObj.searchParams.forEach((value, key) => {
225
- params[key] = value;
226
- });
227
-
228
- // 检查hash中是否包含query参数(SPA路由格式)
229
- const hash = urlObj.hash;
230
- if (hash && hash.includes('?')) {
231
- // 提取hash中的query部分
232
- const hashQueryString = hash.substring(hash.indexOf('?') + 1);
233
- // 解析hash中的query参数
234
- const hashParams = new URLSearchParams(hashQueryString);
235
- hashParams.forEach((value, key) => {
236
- // 如果标准位置已有同名参数,hash中的参数会覆盖它
237
- params[key] = value;
238
- });
239
- }
240
-
241
- // 参数映射(与前端保持一致)
242
- if (params.id) {
243
- params.testPlanId = params.id;
244
- delete params.id;
245
- }
246
- if (params.pId) {
247
- params.project = params.pId;
248
- delete params.pId;
249
- }
250
- if (params.orgId) {
251
- params.organization = params.orgId;
252
- delete params.orgId;
253
- }
254
-
255
- return params;
256
- } catch (error) {
257
- throw new Error(`URL解析失败: ${error.message}`);
258
- }
259
- }
260
-
261
- /**
262
- * 生成AI测试提示语
263
- */
264
- function generateAIPrompt(detailData, testPlanCollectionName) {
265
- try {
266
- const name = detailData.name || '';
267
- const steps = detailData.steps || [];
268
- const stepsArray = typeof steps === 'string' ? JSON.parse(steps) : steps;
269
-
270
- if (!Array.isArray(stepsArray) || stepsArray.length === 0) {
271
- return `测试点:${testPlanCollectionName || ''}\n用例名称:${name}\n测试步骤:暂无测试步骤`;
272
- }
273
-
274
- const sortedSteps = stepsArray.sort((a, b) => (a.num || 0) - (b.num || 0));
275
- let prompt = `测试点:${testPlanCollectionName || ''}\n用例名称:${name}\n测试步骤:\n`;
276
-
277
- sortedSteps.forEach((step, index) => {
278
- const stepNum = index + 1;
279
- const desc = step.desc || '';
280
- const result = step.result || '';
281
- const stepText = `${desc}${result ? ' 期望结果: ' + result : ''}`;
282
- prompt += `${stepNum}. ${stepText}\n`;
283
- });
284
-
285
- return prompt.trim();
286
- } catch (error) {
287
- console.error('生成AI提示语错误:', error);
288
- return '生成提示语失败';
289
- }
290
- }
291
-
292
- /**
293
- * 获取优先级等级(用于排序)
294
- */
295
- function getPriorityLevel(priority) {
296
- const priorityMap = {
297
- 'P0': 0,
298
- 'P1': 1,
299
- 'P2': 2,
300
- 'P3': 3
301
- };
302
- return priorityMap[priority] !== undefined ? priorityMap[priority] : 999;
303
- }
304
-
305
- /**
306
- * 调用 AI 模型 API 进行流式分析
307
- */
308
- async function analyzeWithModel(promptText) {
309
- const config = getModelConfig();
310
-
311
- if (!config.apiKey) {
312
- throw new Error('缺少 MODEL_API_KEY 环境变量配置');
313
- }
314
-
315
- const analysisPrompt = `你是一个专业的测试用例分析专家。请仔细分析以下测试用例,将其中的测试步骤按照以下三个类别进行分类:
316
-
317
- **重要说明:**
318
- - 此工具仅用于**静态代码分析**,通过分析代码结构、逻辑、配置等来验证测试用例
319
- - **不涉及实际交互操作**,如页面点击、表单提交、实际运行等
320
- - 对于涉及用户交互、界面操作、实际运行验证的测试步骤,需要明确提示用户手动完成测试验证
321
-
322
- **分类说明:**
323
- 1. **配置优化** - 涉及系统配置、参数设置、选项调整、权限配置、环境配置等(可通过静态代码分析验证)
324
- 2. **前端交互功能** - 涉及用户界面操作、页面交互、表单填写、按钮点击、数据展示、样式验证等前端功能(需要用户手动验证交互效果)
325
- 3. **后端数据** - 涉及接口调用、数据处理、数据库操作、数据同步、API请求、数据查询等后端功能(可通过静态代码分析验证接口调用逻辑)
326
-
327
- **测试用例内容:**
328
- ${promptText}
329
-
330
- **要求:**
331
- 1. 仔细分析每个测试步骤,判断它主要属于哪个类别
332
- 2. 对于每个测试步骤,明确标注其类别(配置优化/前端交互功能/后端数据)
333
- 3. 如果某个步骤涉及多个方面,选择最主要的一个类别
334
- 4. 最后给出整体分类结论:这个用例主要属于哪个类别
335
- 5. **对于涉及交互的步骤,必须明确标注"需要用户手动验证交互效果"**
336
- 6. 明确指出哪些步骤可以通过静态代码分析完成,哪些需要用户手动测试验证
337
-
338
- **输出格式:**
339
- 请按照以下格式输出:
340
-
341
- 1. 测试点:[测试点名称]
342
- 2. 用例名称:[用例名称]
343
- 3. 测试类别:[前端交互功能 / 配置优化 / 后端数据]
344
- 4. 测试步骤分析
345
- - 可通过静态代码分析验证的步骤:[列出可通过代码分析验证的步骤]
346
- - 需要用户手动验证交互的步骤:[列出需要实际交互验证的步骤,并明确提示用户手动完成测试]
347
- 5. 静态代码分析建议:[针对可通过静态代码分析的部分,给出具体的代码检查建议]`;
348
-
349
- return new Promise((resolve, reject) => {
350
- const urlObj = new URL(`${config.baseURL}/chat/completions`);
351
- const postData = JSON.stringify({
352
- model: config.model,
353
- messages: [
354
- {
355
- role: 'user',
356
- content: analysisPrompt
357
- }
358
- ],
359
- stream: true,
360
- temperature: 0.3
361
- });
362
-
363
- const options = {
364
- hostname: urlObj.hostname,
365
- port: urlObj.port || 443,
366
- path: urlObj.pathname,
367
- method: 'POST',
368
- headers: {
369
- 'Content-Type': 'application/json',
370
- 'Authorization': `Bearer ${config.apiKey}`,
371
- 'Content-Length': Buffer.byteLength(postData)
372
- }
373
- };
374
-
375
- const req = https.request(options, (res) => {
376
- if (res.statusCode !== 200) {
377
- let errorData = '';
378
- res.on('data', chunk => errorData += chunk.toString());
379
- res.on('end', () => {
380
- reject(new Error(`AI模型API请求失败: ${res.statusCode} - ${errorData}`));
381
- });
382
- return;
383
- }
384
-
385
- let buffer = '';
386
- let fullContent = '';
387
-
388
- res.on('data', (chunk) => {
389
- buffer += chunk.toString();
390
- const lines = buffer.split('\n');
391
- buffer = lines.pop() || '';
392
-
393
- for (const line of lines) {
394
- if (line.trim() === '') continue;
395
- if (line.startsWith('data: ')) {
396
- const data = line.slice(6);
397
- if (data === '[DONE]') {
398
- resolve(fullContent);
399
- return;
400
- }
401
- try {
402
- const json = JSON.parse(data);
403
- const content = json.choices?.[0]?.delta?.content || '';
404
- if (content) {
405
- fullContent += content;
406
- }
407
- } catch (e) {
408
- // 忽略JSON解析错误
409
- }
410
- }
411
- }
412
- });
413
-
414
- res.on('end', () => {
415
- if (fullContent) {
416
- resolve(fullContent);
417
- } else {
418
- reject(new Error('AI模型API响应为空'));
419
- }
420
- });
421
- });
422
-
423
- req.on('error', (error) => {
424
- reject(new Error(`AI模型API请求错误: ${error.message}`));
425
- });
426
-
427
- req.write(postData);
428
- req.end();
429
- });
430
- }
431
-
432
- class MeterSphereMCPServer {
433
- constructor() {
434
- this.server = new Server(
435
- {
436
- name: 'meter-sphere-test-cases',
437
- version: '1.0.0',
438
- },
439
- {
440
- capabilities: {
441
- tools: {},
442
- },
443
- }
444
- );
445
-
446
- this.setupHandlers();
447
- }
448
-
449
- setupHandlers() {
450
- // 列出可用工具
451
- this.server.setRequestHandler(ListToolsRequestSchema, async () => {
452
- return {
453
- tools: [
454
- {
455
- name: 'get_test_list',
456
- description: '获取所有优先级的测试用例列表,按优先级排序(P0 > P1 > P2 > P3),生成TODO清单。支持过滤已完成用例和显示完成状态。配置信息从环境变量读取(PLATFORM_URL、X_AUTH_TOKEN、CSRF_TOKEN)。重要提示:此工具仅用于静态代码分析,不涉及实际交互测试。当用户要求"继续测试"、"继续自检"、"跳过已完成的"或类似需求时,必须设置 excludeCompleted=true 来排除已完成的测试用例,只返回未完成的用例列表。',
457
- inputSchema: {
458
- type: 'object',
459
- properties: {
460
- excludeCompleted: {
461
- type: 'boolean',
462
- description: '是否排除已完成的测试用例。默认为false(显示所有用例)。当用户要求继续测试、跳过已完成用例时,必须设置为true。设置为true后,返回的列表将只包含未完成的测试用例,方便继续测试工作。',
463
- },
464
- },
465
- required: [],
466
- },
467
- },
468
- {
469
- name: 'get_test_detail',
470
- description: '根据测试用例ID获取详细信息,包括测试步骤和AI测试提示语。会自动调用AI模型进行测试用例分析。此工具仅用于静态代码分析,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。对于涉及用户交互、界面操作、实际运行验证的测试用例,需要提示用户手动完成测试验证。配置信息从环境变量读取(PLATFORM_URL、X_AUTH_TOKEN、CSRF_TOKEN、MODEL_BASE_URL、MODEL_API_KEY、MODEL_ID)。',
471
- inputSchema: {
472
- type: 'object',
473
- properties: {
474
- testCaseId: {
475
- type: 'string',
476
- description: '测试用例ID',
477
- },
478
- testPlanCollectionName: {
479
- type: 'string',
480
- description: '测试点名称(从get_test_list返回)',
481
- },
482
- },
483
- required: ['testCaseId', 'testPlanCollectionName'],
484
- },
485
- },
486
- {
487
- name: 'mark_test_completed',
488
- description: '标记指定测试用例为已完成状态,用于进度跟踪。',
489
- inputSchema: {
490
- type: 'object',
491
- properties: {
492
- testCaseId: {
493
- type: 'string',
494
- description: '测试用例ID',
495
- },
496
- priority: {
497
- type: 'string',
498
- description: '测试用例优先级(P0/P1/P2/P3)',
499
- },
500
- },
501
- required: ['testCaseId', 'priority'],
502
- },
503
- },
504
- {
505
- name: 'get_test_progress',
506
- description: '获取当前测试进度,包括已完成和未完成的测试用例统计信息。',
507
- inputSchema: {
508
- type: 'object',
509
- properties: {},
510
- required: [],
511
- },
512
- },
513
- {
514
- name: 'reset_test_progress',
515
- description: '重置测试进度文件(test-progress.json),清空所有已完成的测试用例记录。可选择是否保留当前测试计划ID。',
516
- inputSchema: {
517
- type: 'object',
518
- properties: {
519
- keepTestPlanId: {
520
- type: 'boolean',
521
- description: '是否保留当前测试计划ID。默认为false(完全重置)。设置为true时,只清空已完成用例记录,但保留testPlanId。',
522
- },
523
- },
524
- required: [],
525
- },
526
- },
527
- ],
528
- };
529
- });
530
-
531
- // 处理工具调用
532
- this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
533
- const { name, arguments: args } = request.params;
534
-
535
- try {
536
- if (name === 'get_test_list') {
537
- return await this.handleGetTestList(args);
538
- } else if (name === 'get_test_detail') {
539
- return await this.handleGetTestDetail(args);
540
- } else if (name === 'mark_test_completed') {
541
- return await this.handleMarkTestCompleted(args);
542
- } else if (name === 'get_test_progress') {
543
- return await this.handleGetTestProgress(args);
544
- } else if (name === 'reset_test_progress') {
545
- return await this.handleResetTestProgress(args);
546
- } else {
547
- throw new Error(`未知工具: ${name}`);
548
- }
549
- } catch (error) {
550
- return {
551
- content: [
552
- {
553
- type: 'text',
554
- text: `错误: ${error.message}\n${error.stack || ''}`,
555
- },
556
- ],
557
- isError: true,
558
- };
559
- }
560
- });
561
- }
562
-
563
- async handleGetTestList(args) {
564
- const { excludeCompleted = false } = args || {};
565
-
566
- // 从环境变量读取配置
567
- const platformUrl = process.env.PLATFORM_URL;
568
- const xAuthToken = process.env.X_AUTH_TOKEN;
569
- const csrfToken = process.env.CSRF_TOKEN;
570
-
571
- if (!platformUrl || !xAuthToken || !csrfToken) {
572
- throw new Error('缺少必要配置。请在 Cursor 的 MCP 配置中通过环境变量设置 PLATFORM_URL、X_AUTH_TOKEN、CSRF_TOKEN');
573
- }
574
-
575
- // 解析URL获取参数
576
- const parsedParams = parseQueryParams(platformUrl);
577
- const { organization, project, testPlanId } = parsedParams;
578
-
579
- if (!organization || !project || !testPlanId) {
580
- throw new Error('URL中缺少必要参数(organization、project、testPlanId)');
581
- }
582
-
583
- // 加载进度
584
- const progress = loadProgress();
585
- if (progress.testPlanId && progress.testPlanId !== testPlanId) {
586
- // 如果testPlanId变化,重置进度
587
- progress.testPlanId = testPlanId;
588
- progress.completed = [];
589
- progress.completedCount = 0;
590
- saveProgress(progress);
591
- } else if (!progress.testPlanId) {
592
- progress.testPlanId = testPlanId;
593
- saveProgress(progress);
594
- }
595
-
596
- // 构建目标API URL
597
- const apiBaseUrl = getApiBaseUrl();
598
- const targetUrl = `${apiBaseUrl}/test-plan/functional/case/page`;
599
-
600
- const headers = {
601
- 'Csrf-token': csrfToken,
602
- 'organization': organization,
603
- 'project': project,
604
- 'x-auth-token': xAuthToken,
605
- };
606
-
607
- const requestData = {
608
- current: 1,
609
- pageSize: 500,
610
- sort: {},
611
- keyword: '',
612
- viewId: '',
613
- combineSearch: {
614
- searchMode: 'AND',
615
- conditions: [],
616
- },
617
- filter: {},
618
- treeType: 'COLLECTION',
619
- testPlanId: testPlanId,
620
- collectionId: '',
621
- projectId: '',
622
- };
623
-
624
- const response = await httpPost(targetUrl, headers, requestData);
625
- const data = JSON.parse(response);
626
-
627
- if (!data || !data.data || !data.data.list) {
628
- throw new Error('获取测试用例列表失败:响应数据格式不正确');
629
- }
630
-
631
- const testCases = data.data.list;
632
-
633
- // 处理所有测试用例,提取优先级并按优先级排序
634
- let processedTestCases = testCases
635
- .map(item => {
636
- const priority = (item.customFields && item.customFields[0] && item.customFields[0].defaultValue) || '-';
637
- return {
638
- id: item.id,
639
- name: item.name,
640
- priority: priority.toUpperCase(),
641
- testPlanCollectionName: item.testPlanCollectionName || '',
642
- createUserName: item.createUserName || '',
643
- moduleName: item.moduleName || '',
644
- };
645
- })
646
- .sort((a, b) => {
647
- return getPriorityLevel(a.priority) - getPriorityLevel(b.priority);
648
- });
649
-
650
- // 更新进度中的总数
651
- progress.total = processedTestCases.length;
652
- saveProgress(progress);
653
-
654
- // 如果excludeCompleted为true,过滤已完成的用例
655
- if (excludeCompleted) {
656
- processedTestCases = processedTestCases.filter(testCase => {
657
- return !isCompleted(testCase.id, progress);
658
- });
659
- }
660
-
661
- // 统计各优先级的数量
662
- const priorityCounts = {
663
- P0: 0,
664
- P1: 0,
665
- P2: 0,
666
- P3: 0,
667
- other: 0
668
- };
669
-
670
- processedTestCases.forEach(testCase => {
671
- const priority = testCase.priority;
672
- if (priorityCounts.hasOwnProperty(priority)) {
673
- priorityCounts[priority]++;
674
- } else {
675
- priorityCounts.other++;
676
- }
677
- });
678
-
679
- const totalCount = processedTestCases.length;
680
-
681
- // 生成统计信息
682
- const stats = [
683
- `共找到 (${totalCount}) 个测试用例;`,
684
- `P0: ${priorityCounts.P0}个`,
685
- `P1: ${priorityCounts.P1}个`,
686
- `P2: ${priorityCounts.P2}个`,
687
- `P3: ${priorityCounts.P3}个`
688
- ].join('\n');
689
-
690
- // 生成TODO清单格式:✅/⬜ 用例ID:1245456454654545(P0)
691
- const todoList = processedTestCases.map(testCase => {
692
- const status = isCompleted(testCase.id, progress) ? '✅' : '⬜';
693
- return `${status} 用例ID:${testCase.id}(${testCase.priority})`;
694
- }).join('\n');
695
-
696
- const progressInfo = excludeCompleted
697
- ? `\n(已过滤已完成用例,剩余 ${processedTestCases.length} 个未完成用例)\n\n⚠️ **提示**:此工具仅用于静态代码分析,不涉及实际交互测试。对于涉及交互的测试用例,需要用户手动完成测试验证。`
698
- : `\n(已完成: ${progress.completedCount}/${progress.total})\n\n⚠️ **提示**:此工具仅用于静态代码分析,不涉及实际交互测试。对于涉及交互的测试用例,需要用户手动完成测试验证。\n如需继续分析未完成的用例,请使用 excludeCompleted=true 参数调用此工具`;
699
-
700
- const result = `${stats}${progressInfo}\n\nTODO:\n${todoList}`;
701
-
702
- return {
703
- content: [
704
- {
705
- type: 'text',
706
- text: result,
707
- },
708
- ],
709
- };
710
- }
711
-
712
- async handleGetTestDetail(args) {
713
- const { testCaseId, testPlanCollectionName } = args;
714
-
715
- // 检查用例是否已完成
716
- const progress = loadProgress();
717
- const isCaseCompleted = isCompleted(testCaseId, progress);
718
- let completedInfo = '';
719
- if (isCaseCompleted) {
720
- const completedItem = progress.completed.find(item => item.testCaseId === testCaseId);
721
- const completedTime = completedItem ? new Date(completedItem.completedAt).toLocaleString('zh-CN') : '';
722
- completedInfo = `\n\n⚠️ **注意:此测试用例已完成** ✅\n完成时间:${completedTime}\n如需继续测试其他用例,请使用 get_test_list 工具并设置 excludeCompleted=true 获取未完成的用例列表。\n`;
723
- }
724
-
725
- // 从环境变量读取配置
726
- const platformUrl = process.env.PLATFORM_URL;
727
- const xAuthToken = process.env.X_AUTH_TOKEN;
728
- const csrfToken = process.env.CSRF_TOKEN;
729
-
730
- if (!platformUrl || !xAuthToken || !csrfToken) {
731
- throw new Error('缺少必要配置。请在 Cursor 的 MCP 配置中通过环境变量设置 PLATFORM_URL、X_AUTH_TOKEN、CSRF_TOKEN');
732
- }
733
-
734
- // 解析URL获取参数
735
- const parsedParams = parseQueryParams(platformUrl);
736
- const { organization, project } = parsedParams;
737
-
738
- if (!organization || !project) {
739
- throw new Error('URL中缺少必要参数(organization、project)');
740
- }
741
-
742
- // 构建目标API URL
743
- const apiBaseUrl = getApiBaseUrl();
744
- const targetUrl = `${apiBaseUrl}/test-plan/functional/case/detail/${testCaseId}`;
745
-
746
- const headers = {
747
- 'Csrf-token': csrfToken,
748
- 'organization': organization,
749
- 'project': project,
750
- 'x-auth-token': xAuthToken,
751
- };
752
-
753
- const response = await httpGet(targetUrl, headers);
754
- const data = JSON.parse(response);
755
-
756
- if (!data || !data.data) {
757
- throw new Error('获取测试用例详情失败:响应数据格式不正确');
758
- }
759
-
760
- const detailData = data.data;
761
- const aiPrompt = generateAIPrompt(detailData, testPlanCollectionName);
762
-
763
- // 尝试调用AI模型进行分析
764
- let modelAnalysis = '';
765
- try {
766
- modelAnalysis = await analyzeWithModel(aiPrompt);
767
- } catch (error) {
768
- console.error('AI模型分析失败:', error);
769
- modelAnalysis = `\n\n⚠️ AI模型分析失败: ${error.message}\n(如果未配置MODEL_API_KEY,可以忽略此错误)`;
770
- }
771
-
772
- // 添加AI测试提示语前缀,强调静态代码分析
773
- const fullPrompt = '**重要说明:此工具仅用于静态代码分析,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。**\n\n请根据下列提示语中的测试点、用例名称、测试类别、测试步骤、分析说明找到当前项目中对应的代码位置,进行静态代码检查:\n- 检查代码逻辑是否正确实现\n- 检查配置参数是否正确设置\n- 检查接口调用是否符合预期\n- 检查数据结构是否正确\n\n**对于涉及用户交互、界面操作、实际运行验证的测试步骤,请明确提示用户需要手动完成测试验证。**\n\n' + aiPrompt;
774
-
775
- let resultText = `## 测试用例详情\n\n` +
776
- `**用例ID**: ${testCaseId}${completedInfo}` +
777
- `## ⚠️ 重要提示\n\n**此工具仅用于静态代码分析**,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。对于涉及用户交互、界面操作、实际运行验证的测试步骤,需要用户手动完成测试验证。\n\n` +
778
- `## 测试内容\n\n\`\`\`\n${fullPrompt}\n\`\`\`\n`;
779
-
780
- if (modelAnalysis && !modelAnalysis.includes('AI模型分析失败')) {
781
- resultText += `\n## AI分析结果\n\n\`\`\`\n${modelAnalysis}\n\`\`\`\n`;
782
- }
783
-
784
- return {
785
- content: [
786
- {
787
- type: 'text',
788
- text: resultText
789
- },
790
- ],
791
- };
792
- }
793
-
794
- async handleMarkTestCompleted(args) {
795
- const { testCaseId, priority } = args;
796
-
797
- if (!testCaseId || !priority) {
798
- throw new Error('缺少必要参数:testCaseId 和 priority');
799
- }
800
-
801
- const progress = loadProgress();
802
- markCompleted(testCaseId, priority, progress);
803
-
804
- return {
805
- content: [
806
- {
807
- type: 'text',
808
- text: `✅ 已标记测试用例 ${testCaseId}(${priority}) 为已完成\n\n当前进度: ${progress.completedCount}/${progress.total}`
809
- },
810
- ],
811
- };
812
- }
813
-
814
- async handleGetTestProgress(args) {
815
- const progress = loadProgress();
816
-
817
- if (!progress.testPlanId || progress.total === 0) {
818
- return {
819
- content: [
820
- {
821
- type: 'text',
822
- text: '暂无测试进度记录。请先调用 get_test_list 获取测试用例列表。'
823
- },
824
- ],
825
- };
826
- }
827
-
828
- const completedCount = progress.completedCount;
829
- const total = progress.total;
830
- const remaining = total - completedCount;
831
- const percentage = total > 0 ? Math.round((completedCount / total) * 100) : 0;
832
-
833
- // 按优先级统计已完成用例
834
- const completedByPriority = {
835
- P0: 0,
836
- P1: 0,
837
- P2: 0,
838
- P3: 0,
839
- other: 0
840
- };
841
-
842
- progress.completed.forEach(item => {
843
- const priority = item.priority || 'other';
844
- if (completedByPriority.hasOwnProperty(priority)) {
845
- completedByPriority[priority]++;
846
- } else {
847
- completedByPriority.other++;
848
- }
849
- });
850
-
851
- const progressText = [
852
- `## 测试进度统计`,
853
- ``,
854
- `总用例数: ${total}`,
855
- `已完成: ${completedCount}`,
856
- `剩余: ${remaining}`,
857
- `完成率: ${percentage}%`,
858
- ``,
859
- `### 已完成用例按优先级分布:`,
860
- `P0: ${completedByPriority.P0}个`,
861
- `P1: ${completedByPriority.P1}个`,
862
- `P2: ${completedByPriority.P2}个`,
863
- `P3: ${completedByPriority.P3}个`,
864
- ``,
865
- `### 已完成用例列表:`,
866
- ...(progress.completed.length > 0
867
- ? progress.completed.map(item =>
868
- `✅ 用例ID:${item.testCaseId}(${item.priority}) - ${new Date(item.completedAt).toLocaleString('zh-CN')}`
869
- )
870
- : ['暂无']
871
- )
872
- ].join('\n');
873
-
874
- return {
875
- content: [
876
- {
877
- type: 'text',
878
- text: progressText
879
- },
880
- ],
881
- };
882
- }
883
-
884
- async handleResetTestProgress(args) {
885
- const { keepTestPlanId = false } = args || {};
886
-
887
- const progressPath = getProgressFilePath();
888
- const oldProgress = loadProgress();
889
-
890
- // 重置进度
891
- const newProgress = resetProgress(keepTestPlanId);
892
-
893
- const resetInfo = [
894
- `✅ 测试进度文件已重置`,
895
- ``,
896
- `重置前状态:`,
897
- `- 测试计划ID: ${oldProgress.testPlanId || '无'}`,
898
- `- 已完成用例数: ${oldProgress.completedCount}`,
899
- `- 总用例数: ${oldProgress.total}`,
900
- ``,
901
- `重置后状态:`,
902
- `- 测试计划ID: ${newProgress.testPlanId || '无'}${keepTestPlanId ? ' (已保留)' : ' (已清空)'}`,
903
- `- 已完成用例数: ${newProgress.completedCount}`,
904
- `- 总用例数: ${newProgress.total}`,
905
- ``,
906
- `进度文件路径: ${progressPath}`
907
- ].join('\n');
908
-
909
- return {
910
- content: [
911
- {
912
- type: 'text',
913
- text: resetInfo
914
- },
915
- ],
916
- };
917
- }
918
-
919
- async run() {
920
- const transport = new StdioServerTransport();
921
- await this.server.connect(transport);
922
- console.error('MeterSphere MCP Server 已启动');
923
- }
924
- }
925
-
926
- // 启动服务器
927
- const server = new MeterSphereMCPServer();
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import http from 'http';
10
+ import https from 'https';
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+ import os from 'os';
14
+
15
+ /**
16
+ * 获取平台配置,从环境变量读取(扁平化格式)
17
+ */
18
+ function getPlatformConfig() {
19
+ const xAuthToken = process.env.PLATFORM_X_AUTH_TOKEN;
20
+ const csrfToken = process.env.PLATFORM_CSRF_TOKEN;
21
+ const project = process.env.PLATFORM_PROJECT;
22
+ const organization = process.env.PLATFORM_ORGANIZATION;
23
+ const moduleIdsStr = process.env.PLATFORM_MODULE_IDS || '[]';
24
+
25
+ if (!xAuthToken || !csrfToken || !project || !organization) {
26
+ throw new Error('缺少必要配置。请在 Cursor 的 MCP 配置中通过环境变量设置 PLATFORM_X_AUTH_TOKEN、PLATFORM_CSRF_TOKEN、PLATFORM_PROJECT、PLATFORM_ORGANIZATION');
27
+ }
28
+
29
+ let moduleIds = [];
30
+ try {
31
+ moduleIds = JSON.parse(moduleIdsStr);
32
+ if (!Array.isArray(moduleIds)) {
33
+ moduleIds = [];
34
+ }
35
+ } catch (error) {
36
+ // 如果解析失败,使用空数组
37
+ moduleIds = [];
38
+ }
39
+
40
+ return {
41
+ xAuthToken,
42
+ csrfToken,
43
+ project,
44
+ organization,
45
+ moduleIds
46
+ };
47
+ }
48
+
49
+ /**
50
+ * 获取默认的AI分析提示语模板
51
+ */
52
+ function getDefaultAnalysisPromptTemplate() {
53
+ return `你是一个专业的测试用例分析专家。请仔细分析以下测试用例,将其中的测试步骤按照以下三个类别进行分类:
54
+
55
+ **重要说明:**
56
+ - 此工具仅用于**静态代码分析**,通过分析代码结构、逻辑、配置等来验证测试用例
57
+ - **不涉及实际交互操作**,如页面点击、表单提交、实际运行等
58
+ - 对于涉及用户交互、界面操作、实际运行验证的测试步骤,需要明确提示用户手动完成测试验证
59
+
60
+ **分类说明:**
61
+ 1. **配置优化** - 涉及系统配置、参数设置、选项调整、权限配置、环境配置等(可通过静态代码分析验证)
62
+ 2. **前端交互功能** - 涉及用户界面操作、页面交互、表单填写、按钮点击、数据展示、样式验证等前端功能(需要用户手动验证交互效果)
63
+ 3. **后端数据** - 涉及接口调用、数据处理、数据库操作、数据同步、API请求、数据查询等后端功能(可通过静态代码分析验证接口调用逻辑)
64
+
65
+ **测试用例内容:**
66
+ {{PROMPT_TEXT}}
67
+
68
+ **要求:**
69
+ 1. 仔细分析每个测试步骤,判断它主要属于哪个类别
70
+ 2. 对于每个测试步骤,明确标注其类别(配置优化/前端交互功能/后端数据)
71
+ 3. 如果某个步骤涉及多个方面,选择最主要的一个类别
72
+ 4. 最后给出整体分类结论:这个用例主要属于哪个类别
73
+ 5. **对于涉及交互的步骤,必须明确标注"需要用户手动验证交互效果"**
74
+ 6. 明确指出哪些步骤可以通过静态代码分析完成,哪些需要用户手动测试验证
75
+
76
+ **输出格式:**
77
+ 请按照以下格式输出:
78
+
79
+ 1. 测试点:[测试点名称]
80
+ 2. 用例名称:[用例名称]
81
+ 3. 测试类别:[前端交互功能 / 配置优化 / 后端数据]
82
+ 4. 测试步骤分析
83
+ - 可通过静态代码分析验证的步骤:[列出可通过代码分析验证的步骤]
84
+ - 需要用户手动验证交互的步骤:[列出需要实际交互验证的步骤,并明确提示用户手动完成测试]
85
+ 5. 静态代码分析建议:[针对可通过静态代码分析的部分,给出具体的代码检查建议]`;
86
+ }
87
+
88
+ /**
89
+ * 获取AI模型配置,从环境变量读取(扁平化格式)
90
+ */
91
+ function getModelConfig() {
92
+ const baseURL = process.env.MODEL_BASE_URL;
93
+ const apiKey = process.env.MODEL_API_KEY;
94
+ const model = process.env.MODEL_ID;
95
+ const analysisPromptTemplate = process.env.MODEL_ANALYSIS_PROMPT || getDefaultAnalysisPromptTemplate();
96
+
97
+ return {
98
+ baseURL: baseURL || null,
99
+ apiKey: apiKey || null,
100
+ model: model || null,
101
+ analysisPromptTemplate: analysisPromptTemplate
102
+ };
103
+ }
104
+
105
+ /**
106
+ * 获取进度文件路径
107
+ * 优先级:环境变量 > 当前工作目录 > 用户主目录下的配置目录
108
+ */
109
+ function getProgressFilePath() {
110
+ // 优先使用环境变量指定的目录
111
+ if (process.env.PROGRESS_FILE_DIR) {
112
+ return path.join(process.env.PROGRESS_FILE_DIR, 'test-progress.json');
113
+ }
114
+
115
+ // 其次使用当前工作目录(用户运行命令的目录,通常是项目根目录)
116
+ // 这对于npm包来说是最合适的,因为进度文件应该跟随项目
117
+ const cwd = process.cwd();
118
+ if (cwd && cwd !== '/') {
119
+ return path.join(cwd, 'test-progress.json');
120
+ }
121
+
122
+ // 最后回退到用户主目录下的配置目录
123
+ const homeDir = os.homedir();
124
+ const configDir = path.join(homeDir, '.config', 'meter-sphere-mcp');
125
+
126
+ // 确保配置目录存在
127
+ try {
128
+ if (!fs.existsSync(configDir)) {
129
+ fs.mkdirSync(configDir, { recursive: true });
130
+ }
131
+ } catch (error) {
132
+ console.error('创建配置目录失败:', error);
133
+ }
134
+
135
+ return path.join(configDir, 'test-progress.json');
136
+ }
137
+
138
+ /**
139
+ * 加载测试进度
140
+ */
141
+ function loadProgress() {
142
+ const progressPath = getProgressFilePath();
143
+ try {
144
+ if (fs.existsSync(progressPath)) {
145
+ const content = fs.readFileSync(progressPath, 'utf8');
146
+ return JSON.parse(content);
147
+ }
148
+ } catch (error) {
149
+ console.error('加载进度文件失败:', error);
150
+ }
151
+ return {
152
+ lastUpdate: null,
153
+ completed: [],
154
+ total: 0,
155
+ completedCount: 0
156
+ };
157
+ }
158
+
159
+ /**
160
+ * 保存测试进度
161
+ */
162
+ function saveProgress(progress) {
163
+ const progressPath = getProgressFilePath();
164
+ try {
165
+ progress.lastUpdate = new Date().toISOString();
166
+ fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2), 'utf8');
167
+ } catch (error) {
168
+ console.error('保存进度文件失败:', error);
169
+ throw new Error(`保存进度失败: ${error.message}`);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * 检查测试用例是否已完成
175
+ */
176
+ function isCompleted(testCaseId, progress) {
177
+ return progress.completed.some(item => item.testCaseId === testCaseId);
178
+ }
179
+
180
+ /**
181
+ * 标记测试用例为已完成
182
+ */
183
+ function markCompleted(testCaseId, priority, progress) {
184
+ if (!isCompleted(testCaseId, progress)) {
185
+ progress.completed.push({
186
+ testCaseId: testCaseId,
187
+ priority: priority,
188
+ completedAt: new Date().toISOString()
189
+ });
190
+ progress.completedCount = progress.completed.length;
191
+ saveProgress(progress);
192
+ }
193
+ }
194
+
195
+ /**
196
+ * 重置测试进度文件
197
+ */
198
+ function resetProgress() {
199
+ const newProgress = {
200
+ lastUpdate: null,
201
+ completed: [],
202
+ total: 0,
203
+ completedCount: 0
204
+ };
205
+
206
+ try {
207
+ saveProgress(newProgress);
208
+ return newProgress;
209
+ } catch (error) {
210
+ console.error('重置进度文件失败:', error);
211
+ throw new Error(`重置进度失败: ${error.message}`);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * 发送 HTTP POST 请求
217
+ */
218
+ function httpPost(url, headers = {}, data = {}) {
219
+ return new Promise((resolve, reject) => {
220
+ const urlObj = new URL(url);
221
+ const postData = JSON.stringify(data);
222
+
223
+ const requestHeaders = {
224
+ 'Content-Type': 'application/json;charset=UTF-8',
225
+ 'Content-Length': Buffer.byteLength(postData),
226
+ ...headers
227
+ };
228
+
229
+ const options = {
230
+ hostname: urlObj.hostname,
231
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
232
+ path: urlObj.pathname + (urlObj.search || ''),
233
+ method: 'POST',
234
+ headers: requestHeaders
235
+ };
236
+
237
+ const req = http.request(options, res => {
238
+ const chunks = [];
239
+ res.on('data', chunk => chunks.push(chunk));
240
+ res.on('end', () => {
241
+ const buffer = Buffer.concat(chunks);
242
+ const responseData = buffer.toString('utf8');
243
+ resolve(responseData);
244
+ });
245
+ });
246
+
247
+ req.on('error', error => reject(error));
248
+ req.write(postData);
249
+ req.end();
250
+ });
251
+ }
252
+
253
+ /**
254
+ * 发送 HTTP GET 请求
255
+ */
256
+ function httpGet(url, headers = {}) {
257
+ return new Promise((resolve, reject) => {
258
+ const urlObj = new URL(url);
259
+ const options = {
260
+ hostname: urlObj.hostname,
261
+ port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
262
+ path: urlObj.pathname + (urlObj.search || ''),
263
+ method: 'GET',
264
+ headers: headers
265
+ };
266
+
267
+ const req = http.request(options, res => {
268
+ const chunks = [];
269
+ res.on('data', chunk => chunks.push(chunk));
270
+ res.on('end', () => {
271
+ const buffer = Buffer.concat(chunks);
272
+ const responseData = buffer.toString('utf8');
273
+ resolve(responseData);
274
+ });
275
+ });
276
+
277
+ req.on('error', error => reject(error));
278
+ req.end();
279
+ });
280
+ }
281
+
282
+ /**
283
+ * 生成AI测试提示语
284
+ */
285
+ function generateAIPrompt(detailData, testPlanCollectionName) {
286
+ try {
287
+ const name = detailData.name || '';
288
+ const caseEditType = detailData.caseEditType || 'STEP';
289
+
290
+ let prompt = `测试点:${testPlanCollectionName || ''}\n用例名称:${name}\n测试步骤:\n`;
291
+ let hasSteps = false;
292
+
293
+ // 处理 STEP 类型:从 steps 字段解析
294
+ if (caseEditType === 'STEP') {
295
+ try {
296
+ const steps = detailData.steps || [];
297
+ let stepsArray = [];
298
+
299
+ if (typeof steps === 'string') {
300
+ try {
301
+ stepsArray = JSON.parse(steps);
302
+ } catch (e) {
303
+ // 如果解析失败,尝试作为空数组处理
304
+ stepsArray = [];
305
+ }
306
+ } else {
307
+ stepsArray = steps;
308
+ }
309
+
310
+ if (Array.isArray(stepsArray) && stepsArray.length > 0) {
311
+ const sortedSteps = stepsArray.sort((a, b) => (a.num || 0) - (b.num || 0));
312
+
313
+ sortedSteps.forEach((step, index) => {
314
+ const stepNum = index + 1;
315
+ const desc = step.desc || '';
316
+ const result = step.result || '';
317
+ const stepText = `${desc}${result ? ' 期望结果: ' + result : ''}`;
318
+ prompt += `${stepNum}. ${stepText}\n`;
319
+ hasSteps = true;
320
+ });
321
+ }
322
+ } catch (e) {
323
+ console.error('解析STEP类型步骤失败:', e);
324
+ }
325
+ }
326
+ // 处理 TEXT 类型:从 textDescription 和 expectedResult 字段解析
327
+ else if (caseEditType === 'TEXT') {
328
+ const textDescription = detailData.textDescription || '';
329
+ const expectedResult = detailData.expectedResult || '';
330
+
331
+ if (textDescription && textDescription.trim()) {
332
+ // 解析 textDescription,格式类似 "[1]步骤1\n[2]步骤2\n..."
333
+ const descLines = textDescription.split('\n').filter(line => line.trim());
334
+ const resultLines = expectedResult && expectedResult.trim() ? expectedResult.split('\n').filter(line => line.trim()) : [];
335
+
336
+ if (descLines.length > 0) {
337
+ descLines.forEach((line, index) => {
338
+ // 移除开头的 [数字] 标记,支持多种格式
339
+ const cleanLine = line.replace(/^\[\d+\]\s*/, '').trim();
340
+ const resultLine = resultLines[index] ? resultLines[index].replace(/^\[\d+\]\s*/, '').trim() : '';
341
+ const stepText = `${cleanLine}${resultLine ? ' 期望结果: ' + resultLine : ''}`;
342
+ prompt += `${index + 1}. ${stepText}\n`;
343
+ hasSteps = true;
344
+ });
345
+ }
346
+ }
347
+ }
348
+
349
+ if (!hasSteps) {
350
+ return `测试点:${testPlanCollectionName || ''}\n用例名称:${name}\n测试步骤:暂无测试步骤`;
351
+ }
352
+
353
+ return prompt.trim();
354
+ } catch (error) {
355
+ console.error('生成AI提示语错误:', error);
356
+ return '生成提示语失败';
357
+ }
358
+ }
359
+
360
+ /**
361
+ * 获取优先级等级(用于排序)
362
+ */
363
+ function getPriorityLevel(priority) {
364
+ const priorityMap = {
365
+ 'P0': 0,
366
+ 'P1': 1,
367
+ 'P2': 2,
368
+ 'P3': 3
369
+ };
370
+ return priorityMap[priority] !== undefined ? priorityMap[priority] : 999;
371
+ }
372
+
373
+ /**
374
+ * 调用 AI 模型 API 进行流式分析
375
+ */
376
+ /**
377
+ * 调用 AI 模型 API 进行流式分析
378
+ * @param {string} promptText - 测试用例提示语
379
+ * @returns {Promise<string|null>} 返回分析结果,如果未配置MODEL则返回null
380
+ */
381
+ async function analyzeWithModel(promptText) {
382
+ const config = getModelConfig();
383
+
384
+ // 如果MODEL配置不完整,返回null表示跳过AI分析
385
+ if (!config.baseURL || !config.apiKey || !config.model) {
386
+ return null;
387
+ }
388
+
389
+ // 使用配置的提示语模板,将 {{PROMPT_TEXT}} 替换为实际的测试用例内容
390
+ // 如果模板中没有 {{PROMPT_TEXT}} 占位符,则在末尾追加测试用例内容
391
+ let analysisPrompt = config.analysisPromptTemplate;
392
+ if (analysisPrompt.includes('{{PROMPT_TEXT}}')) {
393
+ analysisPrompt = analysisPrompt.replace('{{PROMPT_TEXT}}', promptText);
394
+ } else {
395
+ analysisPrompt = `${analysisPrompt}\n\n**测试用例内容:**\n${promptText}`;
396
+ }
397
+
398
+ return new Promise((resolve, reject) => {
399
+ const urlObj = new URL(`${config.baseURL}/chat/completions`);
400
+ const postData = JSON.stringify({
401
+ model: config.model,
402
+ messages: [
403
+ {
404
+ role: 'user',
405
+ content: analysisPrompt
406
+ }
407
+ ],
408
+ stream: true,
409
+ temperature: 0.3
410
+ });
411
+
412
+ const options = {
413
+ hostname: urlObj.hostname,
414
+ port: urlObj.port || 443,
415
+ path: urlObj.pathname,
416
+ method: 'POST',
417
+ headers: {
418
+ 'Content-Type': 'application/json',
419
+ 'Authorization': `Bearer ${config.apiKey}`,
420
+ 'Content-Length': Buffer.byteLength(postData)
421
+ }
422
+ };
423
+
424
+ const req = https.request(options, (res) => {
425
+ if (res.statusCode !== 200) {
426
+ let errorData = '';
427
+ res.on('data', chunk => errorData += chunk.toString());
428
+ res.on('end', () => {
429
+ reject(new Error(`AI模型API请求失败: ${res.statusCode} - ${errorData}`));
430
+ });
431
+ return;
432
+ }
433
+
434
+ let buffer = '';
435
+ let fullContent = '';
436
+
437
+ res.on('data', (chunk) => {
438
+ buffer += chunk.toString();
439
+ const lines = buffer.split('\n');
440
+ buffer = lines.pop() || '';
441
+
442
+ for (const line of lines) {
443
+ if (line.trim() === '') continue;
444
+ if (line.startsWith('data: ')) {
445
+ const data = line.slice(6);
446
+ if (data === '[DONE]') {
447
+ resolve(fullContent);
448
+ return;
449
+ }
450
+ try {
451
+ const json = JSON.parse(data);
452
+ const content = json.choices?.[0]?.delta?.content || '';
453
+ if (content) {
454
+ fullContent += content;
455
+ }
456
+ } catch (e) {
457
+ // 忽略JSON解析错误
458
+ }
459
+ }
460
+ }
461
+ });
462
+
463
+ res.on('end', () => {
464
+ if (fullContent) {
465
+ resolve(fullContent);
466
+ } else {
467
+ reject(new Error('AI模型API响应为空'));
468
+ }
469
+ });
470
+ });
471
+
472
+ req.on('error', (error) => {
473
+ reject(new Error(`AI模型API请求错误: ${error.message}`));
474
+ });
475
+
476
+ req.write(postData);
477
+ req.end();
478
+ });
479
+ }
480
+
481
+ class MeterSphereMCPServer {
482
+ constructor() {
483
+ this.server = new Server(
484
+ {
485
+ name: 'meter-sphere-test-cases',
486
+ version: '1.0.0',
487
+ },
488
+ {
489
+ capabilities: {
490
+ tools: {},
491
+ },
492
+ }
493
+ );
494
+
495
+ this.setupHandlers();
496
+ }
497
+
498
+ setupHandlers() {
499
+ // 列出可用工具
500
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
501
+ return {
502
+ tools: [
503
+ {
504
+ name: 'get_test_list',
505
+ description: '获取所有优先级的测试用例列表,按优先级排序(P0 > P1 > P2 > P3),生成TODO清单。支持过滤已完成用例和显示完成状态。配置信息从环境变量读取(PLATFORM配置必需,MODEL配置可选)。重要提示:此工具仅用于静态代码分析,不涉及实际交互测试。当用户要求"继续测试"、"继续自检"、"跳过已完成的"或类似需求时,必须设置 excludeCompleted=true 来排除已完成的测试用例,只返回未完成的用例列表。',
506
+ inputSchema: {
507
+ type: 'object',
508
+ properties: {
509
+ excludeCompleted: {
510
+ type: 'boolean',
511
+ description: '是否排除已完成的测试用例。默认为false(显示所有用例)。当用户要求继续测试、跳过已完成用例时,必须设置为true。设置为true后,返回的列表将只包含未完成的测试用例,方便继续测试工作。',
512
+ },
513
+ },
514
+ required: [],
515
+ },
516
+ },
517
+ {
518
+ name: 'get_test_detail',
519
+ description: '根据测试用例ID获取详细信息,包括测试步骤和AI测试提示语。如果配置了MODEL,会自动调用AI模型进行测试用例分析;如果未配置MODEL,则只返回测试用例详情。此工具仅用于静态代码分析,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。对于涉及用户交互、界面操作、实际运行验证的测试用例,需要提示用户手动完成测试验证。配置信息从环境变量读取(PLATFORM配置必需,MODEL配置可选)。',
520
+ inputSchema: {
521
+ type: 'object',
522
+ properties: {
523
+ testCaseId: {
524
+ type: 'string',
525
+ description: '测试用例ID',
526
+ },
527
+ testPlanCollectionName: {
528
+ type: 'string',
529
+ description: '测试点名称(从get_test_list返回)',
530
+ },
531
+ },
532
+ required: ['testCaseId', 'testPlanCollectionName'],
533
+ },
534
+ },
535
+ {
536
+ name: 'mark_test_completed',
537
+ description: '标记指定测试用例为已完成状态,用于进度跟踪。',
538
+ inputSchema: {
539
+ type: 'object',
540
+ properties: {
541
+ testCaseId: {
542
+ type: 'string',
543
+ description: '测试用例ID',
544
+ },
545
+ priority: {
546
+ type: 'string',
547
+ description: '测试用例优先级(P0/P1/P2/P3)',
548
+ },
549
+ },
550
+ required: ['testCaseId', 'priority'],
551
+ },
552
+ },
553
+ {
554
+ name: 'get_test_progress',
555
+ description: '获取当前测试进度,包括已完成和未完成的测试用例统计信息。',
556
+ inputSchema: {
557
+ type: 'object',
558
+ properties: {},
559
+ required: [],
560
+ },
561
+ },
562
+ {
563
+ name: 'reset_test_progress',
564
+ description: '重置测试进度文件(test-progress.json),清空所有已完成的测试用例记录。',
565
+ inputSchema: {
566
+ type: 'object',
567
+ properties: {},
568
+ required: [],
569
+ },
570
+ },
571
+ ],
572
+ };
573
+ });
574
+
575
+ // 处理工具调用
576
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
577
+ const { name, arguments: args } = request.params;
578
+
579
+ try {
580
+ if (name === 'get_test_list') {
581
+ return await this.handleGetTestList(args);
582
+ } else if (name === 'get_test_detail') {
583
+ return await this.handleGetTestDetail(args);
584
+ } else if (name === 'mark_test_completed') {
585
+ return await this.handleMarkTestCompleted(args);
586
+ } else if (name === 'get_test_progress') {
587
+ return await this.handleGetTestProgress(args);
588
+ } else if (name === 'reset_test_progress') {
589
+ return await this.handleResetTestProgress(args);
590
+ } else {
591
+ throw new Error(`未知工具: ${name}`);
592
+ }
593
+ } catch (error) {
594
+ return {
595
+ content: [
596
+ {
597
+ type: 'text',
598
+ text: `错误: ${error.message}\n${error.stack || ''}`,
599
+ },
600
+ ],
601
+ isError: true,
602
+ };
603
+ }
604
+ });
605
+ }
606
+
607
+ async handleGetTestList(args) {
608
+ const { excludeCompleted = false } = args || {};
609
+
610
+ // 从环境变量读取配置
611
+ const platformConfig = getPlatformConfig();
612
+
613
+ // 加载进度
614
+ const progress = loadProgress();
615
+
616
+ // 构建目标API URL(使用默认的 API 基础 URL)
617
+ const apiBaseUrl = 'http://192.168.3.26:8081';
618
+ const targetUrl = `${apiBaseUrl}/functional/case/page`;
619
+
620
+ const headers = {
621
+ 'Csrf-token': platformConfig.csrfToken,
622
+ 'organization': platformConfig.organization,
623
+ 'project': platformConfig.project,
624
+ 'x-auth-token': platformConfig.xAuthToken,
625
+ };
626
+
627
+ const requestData = {
628
+ current: 1,
629
+ pageSize: 500,
630
+ combineSearch: {
631
+ searchMode: 'AND',
632
+ conditions: [],
633
+ },
634
+ projectId: platformConfig.project,
635
+ };
636
+
637
+
638
+ // 如果配置了 MODULE_IDS,添加到请求参数中
639
+ if (platformConfig.moduleIds && platformConfig.moduleIds.length > 0) {
640
+ requestData.moduleIds = platformConfig.moduleIds;
641
+ }
642
+
643
+ const response = await httpPost(targetUrl, headers, requestData);
644
+ console.log(response);
645
+ const data = JSON.parse(response);
646
+
647
+ if (!data || !data.data || !data.data.list) {
648
+ throw new Error('获取测试用例列表失败:响应数据格式不正确');
649
+ }
650
+
651
+ const testCases = data.data.list;
652
+
653
+ // 处理所有测试用例,提取优先级并按优先级排序
654
+ let processedTestCases = testCases
655
+ .map(item => {
656
+ const priority = (item.customFields && item.customFields[0] && item.customFields[0].defaultValue) || '-';
657
+ return {
658
+ id: item.id,
659
+ name: item.name,
660
+ priority: priority.toUpperCase(),
661
+ testPlanCollectionName: item.testPlanCollectionName || '',
662
+ createUserName: item.createUserName || '',
663
+ moduleName: item.moduleName || '',
664
+ };
665
+ })
666
+ .sort((a, b) => {
667
+ return getPriorityLevel(a.priority) - getPriorityLevel(b.priority);
668
+ });
669
+
670
+ // 更新进度中的总数
671
+ progress.total = processedTestCases.length;
672
+ saveProgress(progress);
673
+
674
+ // 如果excludeCompleted为true,过滤已完成的用例
675
+ if (excludeCompleted) {
676
+ processedTestCases = processedTestCases.filter(testCase => {
677
+ return !isCompleted(testCase.id, progress);
678
+ });
679
+ }
680
+
681
+ // 统计各优先级的数量
682
+ const priorityCounts = {
683
+ P0: 0,
684
+ P1: 0,
685
+ P2: 0,
686
+ P3: 0,
687
+ other: 0
688
+ };
689
+
690
+ processedTestCases.forEach(testCase => {
691
+ const priority = testCase.priority;
692
+ if (priorityCounts.hasOwnProperty(priority)) {
693
+ priorityCounts[priority]++;
694
+ } else {
695
+ priorityCounts.other++;
696
+ }
697
+ });
698
+
699
+ const totalCount = processedTestCases.length;
700
+
701
+ // 生成统计信息
702
+ const stats = [
703
+ `共找到 (${totalCount}) 个测试用例;`,
704
+ `P0: ${priorityCounts.P0}个`,
705
+ `P1: ${priorityCounts.P1}个`,
706
+ `P2: ${priorityCounts.P2}个`,
707
+ `P3: ${priorityCounts.P3}个`
708
+ ].join('\n');
709
+
710
+ // 生成TODO清单格式:✅/⬜ 用例ID:1245456454654545(P0)
711
+ const todoList = processedTestCases.map(testCase => {
712
+ const status = isCompleted(testCase.id, progress) ? '✅' : '⬜';
713
+ return `${status} 用例ID:${testCase.id}(${testCase.priority})`;
714
+ }).join('\n');
715
+
716
+ const progressInfo = excludeCompleted
717
+ ? `\n(已过滤已完成用例,剩余 ${processedTestCases.length} 个未完成用例)\n\n⚠️ **提示**:此工具仅用于静态代码分析,不涉及实际交互测试。对于涉及交互的测试用例,需要用户手动完成测试验证。`
718
+ : `\n(已完成: ${progress.completedCount}/${progress.total})\n\n⚠️ **提示**:此工具仅用于静态代码分析,不涉及实际交互测试。对于涉及交互的测试用例,需要用户手动完成测试验证。\n如需继续分析未完成的用例,请使用 excludeCompleted=true 参数调用此工具`;
719
+
720
+ const result = `${stats}${progressInfo}\n\nTODO:\n${todoList}`;
721
+
722
+ return {
723
+ content: [
724
+ {
725
+ type: 'text',
726
+ text: result,
727
+ },
728
+ ],
729
+ };
730
+ }
731
+
732
+ async handleGetTestDetail(args) {
733
+ const { testCaseId, testPlanCollectionName } = args;
734
+
735
+ // 检查用例是否已完成
736
+ const progress = loadProgress();
737
+ const isCaseCompleted = isCompleted(testCaseId, progress);
738
+ let completedInfo = '';
739
+ if (isCaseCompleted) {
740
+ const completedItem = progress.completed.find(item => item.testCaseId === testCaseId);
741
+ const completedTime = completedItem ? new Date(completedItem.completedAt).toLocaleString('zh-CN') : '';
742
+ completedInfo = `\n\n⚠️ **注意:此测试用例已完成** ✅\n完成时间:${completedTime}\n如需继续测试其他用例,请使用 get_test_list 工具并设置 excludeCompleted=true 获取未完成的用例列表。\n`;
743
+ }
744
+
745
+ // 从环境变量读取配置
746
+ const platformConfig = getPlatformConfig();
747
+
748
+ // 构建目标API URL(使用默认的 API 基础 URL)
749
+ const apiBaseUrl = 'http://192.168.3.26:8081';
750
+ const targetUrl = `${apiBaseUrl}/functional/case/detail/${testCaseId}`;
751
+
752
+ const headers = {
753
+ 'Csrf-token': platformConfig.csrfToken,
754
+ 'organization': platformConfig.organization,
755
+ 'project': platformConfig.project,
756
+ 'x-auth-token': platformConfig.xAuthToken,
757
+ };
758
+
759
+ const response = await httpGet(targetUrl, headers);
760
+ const data = JSON.parse(response);
761
+
762
+ if (!data || !data.data) {
763
+ throw new Error('获取测试用例详情失败:响应数据格式不正确');
764
+ }
765
+
766
+ const detailData = data.data;
767
+ const aiPrompt = generateAIPrompt(detailData, testPlanCollectionName);
768
+
769
+ // 尝试调用AI模型进行分析(如果配置了MODEL)
770
+ let modelAnalysis = null;
771
+ try {
772
+ modelAnalysis = await analyzeWithModel(aiPrompt);
773
+ // 如果返回null,说明未配置MODEL,跳过AI分析
774
+ if (modelAnalysis === null) {
775
+ modelAnalysis = '';
776
+ }
777
+ } catch (error) {
778
+ console.error('AI模型分析失败:', error);
779
+ modelAnalysis = `\n\n⚠️ AI模型分析失败: ${error.message}`;
780
+ }
781
+
782
+ // 添加AI测试提示语前缀,强调静态代码分析
783
+ const fullPrompt = '**重要说明:此工具仅用于静态代码分析,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。**\n\n请根据下列提示语中的测试点、用例名称、测试类别、测试步骤、分析说明找到当前项目中对应的代码位置,进行静态代码检查:\n- 检查代码逻辑是否正确实现\n- 检查配置参数是否正确设置\n- 检查接口调用是否符合预期\n- 检查数据结构是否正确\n\n**对于涉及用户交互、界面操作、实际运行验证的测试步骤,请明确提示用户需要手动完成测试验证。**\n\n' + aiPrompt;
784
+
785
+ let resultText = `## 测试用例详情\n\n` +
786
+ `**用例ID**: ${testCaseId}${completedInfo}` +
787
+ `## ⚠️ 重要提示\n\n**此工具仅用于静态代码分析**,通过分析代码结构、逻辑、配置等来验证测试用例,不涉及实际交互操作。对于涉及用户交互、界面操作、实际运行验证的测试步骤,需要用户手动完成测试验证。\n\n` +
788
+ `## 测试内容\n\n\`\`\`\n${fullPrompt}\n\`\`\`\n`;
789
+
790
+ // 只有当AI分析成功时才显示AI分析结果
791
+ if (modelAnalysis && modelAnalysis.trim() && !modelAnalysis.includes('AI模型分析失败')) {
792
+ resultText += `\n## AI分析结果\n\n\`\`\`\n${modelAnalysis}\n\`\`\`\n`;
793
+ } else if (!modelAnalysis || modelAnalysis === '') {
794
+ // 如果未配置MODEL,添加提示信息
795
+ resultText += `\n\n💡 **提示**:未配置MODEL相关环境变量,AI分析功能已禁用。如需启用AI分析,请在配置中设置 MODEL_BASE_URL、MODEL_API_KEY、MODEL_ID。`;
796
+ }
797
+
798
+ return {
799
+ content: [
800
+ {
801
+ type: 'text',
802
+ text: resultText
803
+ },
804
+ ],
805
+ };
806
+ }
807
+
808
+ async handleMarkTestCompleted(args) {
809
+ const { testCaseId, priority } = args;
810
+
811
+ if (!testCaseId || !priority) {
812
+ throw new Error('缺少必要参数:testCaseId 和 priority');
813
+ }
814
+
815
+ const progress = loadProgress();
816
+ markCompleted(testCaseId, priority, progress);
817
+
818
+ return {
819
+ content: [
820
+ {
821
+ type: 'text',
822
+ text: `✅ 已标记测试用例 ${testCaseId}(${priority}) 为已完成\n\n当前进度: ${progress.completedCount}/${progress.total}`
823
+ },
824
+ ],
825
+ };
826
+ }
827
+
828
+ async handleGetTestProgress(args) {
829
+ const progress = loadProgress();
830
+
831
+ if (progress.total === 0) {
832
+ return {
833
+ content: [
834
+ {
835
+ type: 'text',
836
+ text: '暂无测试进度记录。请先调用 get_test_list 获取测试用例列表。'
837
+ },
838
+ ],
839
+ };
840
+ }
841
+
842
+ const completedCount = progress.completedCount;
843
+ const total = progress.total;
844
+ const remaining = total - completedCount;
845
+ const percentage = total > 0 ? Math.round((completedCount / total) * 100) : 0;
846
+
847
+ // 按优先级统计已完成用例
848
+ const completedByPriority = {
849
+ P0: 0,
850
+ P1: 0,
851
+ P2: 0,
852
+ P3: 0,
853
+ other: 0
854
+ };
855
+
856
+ progress.completed.forEach(item => {
857
+ const priority = item.priority || 'other';
858
+ if (completedByPriority.hasOwnProperty(priority)) {
859
+ completedByPriority[priority]++;
860
+ } else {
861
+ completedByPriority.other++;
862
+ }
863
+ });
864
+
865
+ const progressText = [
866
+ `## 测试进度统计`,
867
+ ``,
868
+ `总用例数: ${total}`,
869
+ `已完成: ${completedCount}`,
870
+ `剩余: ${remaining}`,
871
+ `完成率: ${percentage}%`,
872
+ ``,
873
+ `### 已完成用例按优先级分布:`,
874
+ `P0: ${completedByPriority.P0}个`,
875
+ `P1: ${completedByPriority.P1}个`,
876
+ `P2: ${completedByPriority.P2}个`,
877
+ `P3: ${completedByPriority.P3}个`,
878
+ ``,
879
+ `### 已完成用例列表:`,
880
+ ...(progress.completed.length > 0
881
+ ? progress.completed.map(item =>
882
+ `✅ 用例ID:${item.testCaseId}(${item.priority}) - ${new Date(item.completedAt).toLocaleString('zh-CN')}`
883
+ )
884
+ : ['暂无']
885
+ )
886
+ ].join('\n');
887
+
888
+ return {
889
+ content: [
890
+ {
891
+ type: 'text',
892
+ text: progressText
893
+ },
894
+ ],
895
+ };
896
+ }
897
+
898
+ async handleResetTestProgress(args) {
899
+ const progressPath = getProgressFilePath();
900
+ const oldProgress = loadProgress();
901
+
902
+ // 重置进度
903
+ const newProgress = resetProgress();
904
+
905
+ const resetInfo = [
906
+ `✅ 测试进度文件已重置`,
907
+ ``,
908
+ `重置前状态:`,
909
+ `- 已完成用例数: ${oldProgress.completedCount}`,
910
+ `- 总用例数: ${oldProgress.total}`,
911
+ ``,
912
+ `重置后状态:`,
913
+ `- 已完成用例数: ${newProgress.completedCount}`,
914
+ `- 总用例数: ${newProgress.total}`,
915
+ ``,
916
+ `进度文件路径: ${progressPath}`
917
+ ].join('\n');
918
+
919
+ return {
920
+ content: [
921
+ {
922
+ type: 'text',
923
+ text: resetInfo
924
+ },
925
+ ],
926
+ };
927
+ }
928
+
929
+ async run() {
930
+ const transport = new StdioServerTransport();
931
+ await this.server.connect(transport);
932
+ console.error('MeterSphere MCP Server 已启动');
933
+ }
934
+ }
935
+
936
+ // 启动服务器
937
+ const server = new MeterSphereMCPServer();
928
938
  server.run().catch(console.error);