smoke-mcp.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. #!/usr/bin/env node
  2. const path = require('path');
  3. const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
  4. const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
  5. const { version } = require('../package.json');
  6. async function main() {
  7. const cwd = path.resolve(__dirname, '..');
  8. const client = new Client({
  9. name: 'voc-intelligence-smoke',
  10. version
  11. });
  12. const transport = new StdioClientTransport({
  13. command: process.execPath,
  14. args: ['mcp/src/server.js'],
  15. cwd,
  16. stderr: 'pipe'
  17. });
  18. const stderr = transport.stderr;
  19. if (stderr) {
  20. stderr.on('data', chunk => {
  21. process.stderr.write(chunk);
  22. });
  23. }
  24. await client.connect(transport);
  25. try {
  26. const tools = await client.listTools();
  27. const toolNames = tools.tools.map(tool => tool.name);
  28. if (!toolNames.includes('fmode_image_analysis')) {
  29. throw new Error(`Expected tool fmode_image_analysis, got: ${toolNames.join(', ')}`);
  30. }
  31. const imageTool = tools.tools.find(tool => tool.name === 'fmode_image_analysis');
  32. const imageSchema = JSON.stringify(imageTool?.inputSchema || {});
  33. if (!imageSchema.includes('imagePath') || !imageSchema.includes('fmodeToken')) {
  34. throw new Error('Expected fmode_image_analysis to accept imagePath and fmodeToken inputs');
  35. }
  36. const trendTool = tools.tools.find(tool => tool.name === 'voc_xiaohongshu_trend_run');
  37. const trendSchema = JSON.stringify(trendTool?.inputSchema || {});
  38. if (!trendTool || !trendSchema.includes('xiaohongshuToken') || !trendSchema.includes('vocToken')) {
  39. throw new Error('Expected voc_xiaohongshu_trend_run to accept simple request token inputs xiaohongshuToken and vocToken');
  40. }
  41. if (!toolNames.includes('voc_xiaohongshu_trend_run')) {
  42. throw new Error(`Expected tool voc_xiaohongshu_trend_run, got: ${toolNames.join(', ')}`);
  43. }
  44. if (!toolNames.includes('voc_xiaohongshu_preference_update')) {
  45. throw new Error(`Expected tool voc_xiaohongshu_preference_update, got: ${toolNames.join(', ')}`);
  46. }
  47. if (!toolNames.includes('voc_problem_deep_dive_run')) {
  48. throw new Error(`Expected tool voc_problem_deep_dive_run, got: ${toolNames.join(', ')}`);
  49. }
  50. if (!toolNames.includes('voc_issue_pool_run')) {
  51. throw new Error(`Expected tool voc_issue_pool_run, got: ${toolNames.join(', ')}`);
  52. }
  53. if (!toolNames.includes('voc_content_plan_run')) {
  54. throw new Error(`Expected tool voc_content_plan_run, got: ${toolNames.join(', ')}`);
  55. }
  56. if (!toolNames.includes('voc_speaking_script_run')) {
  57. throw new Error(`Expected tool voc_speaking_script_run, got: ${toolNames.join(', ')}`);
  58. }
  59. if (!toolNames.includes('voc_competitor_map_run')) {
  60. throw new Error(`Expected tool voc_competitor_map_run, got: ${toolNames.join(', ')}`);
  61. }
  62. if (!toolNames.includes('voc_business_workflow_run')) {
  63. throw new Error(`Expected tool voc_business_workflow_run, got: ${toolNames.join(', ')}`);
  64. }
  65. const issuePoolTool = tools.tools.find(tool => tool.name === 'voc_issue_pool_run');
  66. const issuePoolSchema = JSON.stringify(issuePoolTool?.inputSchema || {});
  67. if (!issuePoolSchema.includes('evidence') || !issuePoolSchema.includes('resolvedIssues')) {
  68. throw new Error('Expected voc_issue_pool_run to expose evidence and status inputs');
  69. }
  70. const deepDiveTool = tools.tools.find(tool => tool.name === 'voc_problem_deep_dive_run');
  71. const deepDiveSchema = JSON.stringify(deepDiveTool?.inputSchema || {});
  72. if (!deepDiveSchema.includes('project') || !deepDiveSchema.includes('feedback') || !deepDiveSchema.includes('blockedActions')) {
  73. throw new Error('Expected voc_problem_deep_dive_run to expose scoped memory and feedback inputs');
  74. }
  75. const preferenceResult = await client.callTool({
  76. name: 'voc_xiaohongshu_preference_update',
  77. arguments: {
  78. message: '保留奶油风和全屋定制翻车方向,不要纯风格美图,下一个版本更偏门店转化话术。',
  79. memory: '../outputs/claude-code-xhs-mcp-smoke/xiaohongshu-trend-memory.json'
  80. }
  81. });
  82. const preferenceStructured = preferenceResult.structuredContent || {};
  83. if (preferenceStructured.status !== 'ok') {
  84. throw new Error(`Expected ok preference result, got: ${JSON.stringify(preferenceStructured)}`);
  85. }
  86. const sentinelToken = 'SMOKE_REQUEST_TOKEN_VALUE';
  87. const tokenCheckResult = await client.callTool({
  88. name: 'voc_xiaohongshu_token_check',
  89. arguments: {
  90. vocToken: sentinelToken
  91. }
  92. });
  93. const tokenStructured = tokenCheckResult.structuredContent || {};
  94. const tokenText = JSON.stringify(tokenStructured);
  95. if (tokenStructured.status !== 'ok' || tokenStructured.configured !== true) {
  96. throw new Error(`Expected request-scoped token to be accepted, got: ${tokenText}`);
  97. }
  98. if (tokenText.includes(sentinelToken)) {
  99. throw new Error('Request-scoped token leaked in MCP token check result');
  100. }
  101. const imageNoToken = await client.callTool({
  102. name: 'fmode_image_analysis',
  103. arguments: {
  104. images: ['data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='],
  105. prompt: '识别这张测试图片',
  106. allowEnvToken: false
  107. }
  108. });
  109. if (!imageNoToken.structuredContent || imageNoToken.structuredContent.status !== 'needs_token') {
  110. throw new Error('image analysis no-token path returned unexpected structured content');
  111. }
  112. if (Array.isArray(imageNoToken.structuredContent.errors) && imageNoToken.structuredContent.errors.length !== 0) {
  113. throw new Error('image analysis no-token path should keep errors empty');
  114. }
  115. const result = await client.callTool({
  116. name: 'voc_xiaohongshu_trend_run',
  117. arguments: {
  118. collectionMode: 'sample',
  119. profile: 'memory-templates/xiaohongshu-trend-profile.json',
  120. memory: '../outputs/claude-code-xhs-mcp-smoke/xiaohongshu-trend-memory.json',
  121. output: '../outputs/claude-code-xhs-mcp-smoke'
  122. }
  123. });
  124. const structured = result.structuredContent || {};
  125. if (structured.status !== 'ok') {
  126. throw new Error(`Expected ok result, got: ${JSON.stringify(structured)}`);
  127. }
  128. const assistantMessage = String(structured.assistantMessage || '');
  129. const evidenceFields = ['## 证据样本', '原帖', '原文摘录', '高赞评论'];
  130. const missingEvidenceFields = evidenceFields.filter(field => !assistantMessage.includes(field));
  131. if (missingEvidenceFields.length) {
  132. throw new Error(`Expected evidence card fields in sample report, missing: ${missingEvidenceFields.join(', ')}`);
  133. }
  134. if (/xiaohongshu\.com\/explore\//.test(assistantMessage)) {
  135. throw new Error('Sample report should not contain /explore/ Xiaohongshu links');
  136. }
  137. const assetFiles = (structured.files || []).filter(file => /[\\/]assets[\\/]/.test(file));
  138. if (!assetFiles.length) {
  139. throw new Error('Expected cached asset files in sample report result');
  140. }
  141. const issuePoolResult = await client.callTool({
  142. name: 'voc_issue_pool_run',
  143. arguments: {
  144. project: 'MCP样板门店',
  145. industry: '本地生活餐饮',
  146. scenario: '门店经营',
  147. audience: '到店顾客',
  148. evidence: [
  149. '排队久,但是不知道值不值得等。',
  150. '人均有点高,第一次来不知道怎么点不踩雷。',
  151. '服务忙起来没人理。'
  152. ]
  153. }
  154. });
  155. const issuePoolStructured = issuePoolResult.structuredContent || {};
  156. if (issuePoolStructured.status !== 'ok' ||
  157. !String(issuePoolStructured.assistantMessage || '').includes('## 问题优先级') ||
  158. !issuePoolStructured.data?.issues?.length) {
  159. throw new Error(`Expected issue pool result, got: ${JSON.stringify(issuePoolStructured)}`);
  160. }
  161. const deepDiveResult = await client.callTool({
  162. name: 'voc_problem_deep_dive_run',
  163. arguments: {
  164. issue: '排队',
  165. project: 'MCP样板门店',
  166. industry: '本地生活餐饮',
  167. scenario: '门店经营',
  168. audience: '到店顾客',
  169. evidence: ['排队久,但是不知道值不值得等。'],
  170. feedback: '老板不想做等位饮品,更想先改评论区回复和预点单'
  171. }
  172. });
  173. const deepDiveStructured = deepDiveResult.structuredContent || {};
  174. const deepDiveMessage = String(deepDiveStructured.assistantMessage || '');
  175. if (deepDiveStructured.status !== 'ok' ||
  176. !deepDiveMessage.includes('## 可执行解决动作') ||
  177. !deepDiveMessage.includes('## 优先级判断') ||
  178. !deepDiveStructured.summary?.memoryPath) {
  179. throw new Error(`Expected boss-oriented deep dive result, got: ${JSON.stringify(deepDiveStructured)}`);
  180. }
  181. if (!deepDiveStructured.data?.memory?.blockedActions?.includes('等位饮品') ||
  182. !deepDiveStructured.data?.memory?.preferredActions?.includes('预点单')) {
  183. throw new Error('Expected deep dive MCP call to parse feedback into scoped memory');
  184. }
  185. if (!JSON.stringify(deepDiveStructured.nextActions || []).includes('live')) {
  186. throw new Error('Expected deep dive next actions to hand off to live collection instead of trapping the workflow');
  187. }
  188. const contentPlanResult = await client.callTool({
  189. name: 'voc_content_plan_run',
  190. arguments: {
  191. brand: 'MCP样板门店',
  192. industry: '本地生活餐饮',
  193. issues: ['第一次来怕踩雷', '觉得人均贵', '怕排队久']
  194. }
  195. });
  196. const contentPlanStructured = contentPlanResult.structuredContent || {};
  197. const contentPlanMessage = String(contentPlanStructured.assistantMessage || '');
  198. if (contentPlanStructured.status !== 'ok' ||
  199. !contentPlanMessage.includes('7 天 VOC 内容选题') ||
  200. !contentPlanMessage.includes('前 3 秒钩子') ||
  201. !contentPlanStructured.data?.plan?.length) {
  202. throw new Error(`Expected VOC content plan result, got: ${JSON.stringify(contentPlanStructured)}`);
  203. }
  204. const teaContentPlanResult = await client.callTool({
  205. name: 'voc_content_plan_run',
  206. arguments: {
  207. brand: '茶饮新客转化',
  208. industry: '茶饮',
  209. issues: [
  210. '第一次来怎么点最稳',
  211. '18-22 元贵在哪/值在哪',
  212. '券后价和套餐怎么买最划算',
  213. '茶底/甜度/配料怎么选',
  214. '哪几款不容易踩雷',
  215. '外卖到手怎么避免翻车',
  216. '买一送一/团购规则怎么讲清楚'
  217. ],
  218. evidenceText: '小红书评论:波霸奶茶用的什么茶底?我要避雷!!下午喝的,晚上睁眼到天亮。抖音线索:9.9 新品、11.9 单人下午茶、21 元买一送一能触发注意,但还需要解释规则、口味和是否有坑。'
  219. }
  220. });
  221. const teaContentPlanStructured = teaContentPlanResult.structuredContent || {};
  222. const teaContentPlanMessage = String(teaContentPlanStructured.assistantMessage || '');
  223. if (teaContentPlanStructured.status !== 'ok' ||
  224. !teaContentPlanMessage.includes('茶底怎么选') ||
  225. !teaContentPlanMessage.includes('买一送一到底怎么算') ||
  226. teaContentPlanMessage.includes('招牌菜') ||
  227. teaContentPlanMessage.includes('下饭菜')) {
  228. throw new Error(`Expected tea-specific VOC content plan result, got: ${JSON.stringify(teaContentPlanStructured)}`);
  229. }
  230. const speakingScriptResult = await client.callTool({
  231. name: 'voc_speaking_script_run',
  232. arguments: {
  233. brand: 'MCP样板门店',
  234. industry: '本地生活餐饮',
  235. topic: '第一次来怎么点不踩雷',
  236. userIssue: '第一次来怕点错',
  237. evidence: ['第一次来不知道怎么点,怕踩雷'],
  238. feedback: '开头更狠一点,老板视角,少讲概念,多给具体场景',
  239. memory: '../outputs/claude-code-xhs-mcp-smoke/voc-speaking-script-memory.json',
  240. finalize: true
  241. }
  242. });
  243. const speakingScriptStructured = speakingScriptResult.structuredContent || {};
  244. const speakingScriptMessage = String(speakingScriptStructured.assistantMessage || '');
  245. if (speakingScriptStructured.status !== 'ok' ||
  246. !speakingScriptMessage.includes('VOC 口播脚本共创') ||
  247. !speakingScriptMessage.includes('60 秒口播稿') ||
  248. !speakingScriptMessage.includes('30 秒压缩版') ||
  249. !speakingScriptStructured.data?.memory?.finalizedScripts?.length ||
  250. !speakingScriptStructured.data?.memory?.preferredStyles?.includes('老板视角')) {
  251. throw new Error(`Expected VOC speaking script result, got: ${JSON.stringify(speakingScriptStructured)}`);
  252. }
  253. const competitorMapResult = await client.callTool({
  254. name: 'voc_competitor_map_run',
  255. arguments: {
  256. brand: 'MCP样板门店',
  257. city: '南昌',
  258. category: '餐饮'
  259. }
  260. });
  261. const competitorMapStructured = competitorMapResult.structuredContent || {};
  262. const competitorMapMessage = String(competitorMapStructured.assistantMessage || '');
  263. if (competitorMapStructured.status !== 'ok' ||
  264. !competitorMapMessage.includes('竞品图谱') ||
  265. !competitorMapMessage.includes('错位竞争') ||
  266. !competitorMapStructured.data?.competitors?.length) {
  267. throw new Error(`Expected VOC competitor map result, got: ${JSON.stringify(competitorMapStructured)}`);
  268. }
  269. const businessWorkflowResult = await client.callTool({
  270. name: 'voc_business_workflow_run',
  271. arguments: {
  272. brand: 'MCP样板门店',
  273. industry: '本地生活餐饮',
  274. platform: 'douyin',
  275. collectionMode: 'sample',
  276. keywords: ['南昌菜', '第一次来怎么点', '排队']
  277. }
  278. });
  279. const businessWorkflowStructured = businessWorkflowResult.structuredContent || {};
  280. const businessWorkflowMessage = String(businessWorkflowStructured.assistantMessage || '');
  281. if (businessWorkflowStructured.status !== 'ok' ||
  282. !businessWorkflowMessage.includes('VOC经营闭环') ||
  283. businessWorkflowStructured.summary?.workflowStage !== 'business_workflow_first_round') {
  284. throw new Error(`Expected VOC business workflow result, got: ${JSON.stringify(businessWorkflowStructured)}`);
  285. }
  286. console.log(JSON.stringify({
  287. status: 'ok',
  288. tools: toolNames,
  289. assistantMessagePreview: assistantMessage.slice(0, 120),
  290. deepDivePreview: deepDiveMessage.slice(0, 120),
  291. contentPlanPreview: contentPlanMessage.slice(0, 120),
  292. speakingScriptPreview: speakingScriptMessage.slice(0, 120),
  293. competitorMapPreview: competitorMapMessage.slice(0, 120),
  294. businessWorkflowPreview: businessWorkflowMessage.slice(0, 120),
  295. preferenceMemoryApplied: Boolean(structured.summary && structured.summary.preferenceMemoryApplied),
  296. files: structured.files || []
  297. }, null, 2));
  298. } finally {
  299. await client.close();
  300. }
  301. }
  302. main().catch(error => {
  303. console.error(error);
  304. process.exit(1);
  305. });