verify-training-package-mcp.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. 'use strict';
  2. // Validate the portable MCP entry shipped with a training package. The helper
  3. // speaks newline-delimited JSON-RPC over stdio and only reports protocol metadata/counts.
  4. const fs = require('fs');
  5. const path = require('path');
  6. const { spawn } = require('child_process');
  7. function readJson(filePath) {
  8. return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
  9. }
  10. function readMessage(stream, timeoutMs = 10000) {
  11. return new Promise((resolve, reject) => {
  12. let buffer = '';
  13. let timer = setTimeout(() => finish(new Error('MCP response timeout')), timeoutMs);
  14. const cleanup = () => {
  15. clearTimeout(timer);
  16. stream.off('data', onData);
  17. stream.off('error', onError);
  18. stream.off('end', onEnd);
  19. };
  20. const finish = (error, value) => {
  21. cleanup();
  22. if (error) reject(error);
  23. else resolve(value);
  24. };
  25. const onError = error => finish(error);
  26. const onEnd = () => finish(new Error('MCP process closed before response'));
  27. const onData = chunk => {
  28. buffer += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
  29. const newline = buffer.indexOf('\n');
  30. if (newline < 0) return;
  31. const line = buffer.slice(0, newline).replace(/\r$/, '');
  32. buffer = buffer.slice(newline + 1);
  33. if (!line.trim()) return;
  34. try { finish(null, JSON.parse(line)); }
  35. catch (error) { finish(new Error(`Invalid MCP JSON: ${error.message}`)); }
  36. };
  37. stream.on('data', onData);
  38. stream.on('error', onError);
  39. stream.on('end', onEnd);
  40. });
  41. }
  42. function sendMessage(stream, message) {
  43. stream.write(`${JSON.stringify(message)}\n`);
  44. }
  45. async function verify(packageDir) {
  46. const root = path.resolve(packageDir);
  47. const onboardingAssets = ['driver.min.js', 'driver.css', 'onboarding-guide.js', 'onboarding.css'];
  48. for (const asset of onboardingAssets) {
  49. const assetPath = path.join(root, 'web', asset);
  50. if (!fs.existsSync(assetPath) || fs.statSync(assetPath).size === 0) {
  51. throw new Error(`缺少新手引导前端资源:web/${asset}`);
  52. }
  53. }
  54. const configPath = path.join(root, '.mcp.json');
  55. if (!fs.existsSync(configPath)) throw new Error('缺少 .mcp.json');
  56. const config = readJson(configPath);
  57. const entry = config?.mcpServers?.['qiwei-assistant'];
  58. if (!entry) throw new Error('.mcp.json 未注册 qiwei-assistant');
  59. if (!Array.isArray(entry.args) || !entry.args.includes('mcp')) {
  60. throw new Error('qiwei-assistant 未配置 mcp 子命令');
  61. }
  62. const command = String(entry.command || '').trim();
  63. const executable = path.resolve(root, command.replace(/^\.([\\/])/, ''));
  64. if (!fs.existsSync(executable)) throw new Error(`MCP 可执行文件不存在:${path.basename(executable)}`);
  65. const child = spawn(executable, ['mcp'], {
  66. cwd: root,
  67. windowsHide: true,
  68. stdio: ['pipe', 'pipe', 'pipe'],
  69. env: {
  70. ...process.env,
  71. QIWEI_PACKAGE_ROOT: root,
  72. QIWEI_WORKSPACE_ROOT: root,
  73. QIWEI_OUTPUTS_DIR: path.join(root, 'outputs'),
  74. CLAUDE_CODE_WORKDIR: root,
  75. QIWEI_RUNTIME_CONFIG: path.join(root, 'qiwei.runtime.config.mjs'),
  76. },
  77. });
  78. let stderr = '';
  79. child.stderr.setEncoding('utf8');
  80. child.stderr.on('data', chunk => { stderr = `${stderr}${chunk}`.slice(-4000); });
  81. try {
  82. sendMessage(child.stdin, {
  83. jsonrpc: '2.0',
  84. id: 1,
  85. method: 'initialize',
  86. params: {
  87. protocolVersion: '2025-06-18',
  88. capabilities: {},
  89. clientInfo: { name: 'training-package-verifier', version: '1.0.0' },
  90. },
  91. });
  92. const initialized = await readMessage(child.stdout);
  93. if (initialized?.error) throw new Error(`MCP initialize failed: ${initialized.error.message || 'unknown error'}`);
  94. sendMessage(child.stdin, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
  95. sendMessage(child.stdin, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
  96. const listed = await readMessage(child.stdout);
  97. if (listed?.error) throw new Error(`MCP tools/list failed: ${listed.error.message || 'unknown error'}`);
  98. const tools = Array.isArray(listed?.result?.tools) ? listed.result.tools : [];
  99. if (!tools.length) throw new Error('MCP 工具列表为空');
  100. return {
  101. status: 'ok',
  102. server: initialized?.result?.serverInfo?.name || '',
  103. toolCount: tools.length,
  104. qiweiTools: tools.filter(tool => String(tool?.name || '').startsWith('qiwei_')).length,
  105. onboardingAssets,
  106. };
  107. } catch (error) {
  108. const detail = stderr.replace(/(?:r:|sk-)[A-Za-z0-9._:-]+/g, '[redacted]').trim();
  109. throw new Error(`${error.message}${detail ? ` (${detail})` : ''}`);
  110. } finally {
  111. child.kill();
  112. if (!child.killed) child.kill('SIGTERM');
  113. }
  114. }
  115. async function main() {
  116. const packageDir = process.argv[2];
  117. if (!packageDir) throw new Error('用法:node scripts/verify-training-package-mcp.js <培训包目录>');
  118. process.stdout.write(`${JSON.stringify(await verify(packageDir), null, 2)}\n`);
  119. }
  120. main().catch(error => {
  121. process.stderr.write(`${error.message}\n`);
  122. process.exit(1);
  123. });