smoke-mcp.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  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 speakingScriptResult = await client.callTool({
  205. name: 'voc_speaking_script_run',
  206. arguments: {
  207. brand: 'MCP示例品牌',
  208. industry: '家居定制',
  209. topic: '第一次买怎么选不踩雷',
  210. userIssue: '第一次买怕选错',
  211. evidence: ['第一次买不知道怎么选,怕踩雷'],
  212. feedback: '开头更狠一点,老板视角,少讲概念,多给具体场景',
  213. memory: '../outputs/claude-code-xhs-mcp-smoke/voc-speaking-script-memory.json',
  214. finalize: true
  215. }
  216. });
  217. const speakingScriptStructured = speakingScriptResult.structuredContent || {};
  218. const speakingScriptMessage = String(speakingScriptStructured.assistantMessage || '');
  219. if (speakingScriptStructured.status !== 'ok' ||
  220. !speakingScriptMessage.includes('VOC 口播脚本共创') ||
  221. !speakingScriptMessage.includes('60 秒口播稿') ||
  222. !speakingScriptMessage.includes('30 秒压缩版') ||
  223. !speakingScriptStructured.data?.memory?.finalizedScripts?.length ||
  224. !speakingScriptStructured.data?.memory?.preferredStyles?.includes('老板视角')) {
  225. throw new Error(`Expected VOC speaking script result, got: ${JSON.stringify(speakingScriptStructured)}`);
  226. }
  227. const competitorMapResult = await client.callTool({
  228. name: 'voc_competitor_map_run',
  229. arguments: {
  230. brand: 'MCP示例品牌',
  231. city: '本地',
  232. category: '家居定制'
  233. }
  234. });
  235. const competitorMapStructured = competitorMapResult.structuredContent || {};
  236. const competitorMapMessage = String(competitorMapStructured.assistantMessage || '');
  237. if (competitorMapStructured.status !== 'ok' ||
  238. !competitorMapMessage.includes('竞品图谱') ||
  239. !competitorMapMessage.includes('错位竞争') ||
  240. !competitorMapStructured.data?.competitors?.length) {
  241. throw new Error(`Expected VOC competitor map result, got: ${JSON.stringify(competitorMapStructured)}`);
  242. }
  243. const businessWorkflowResult = await client.callTool({
  244. name: 'voc_business_workflow_run',
  245. arguments: {
  246. brand: 'MCP示例品牌',
  247. industry: '家居定制',
  248. platform: 'douyin',
  249. collectionMode: 'sample',
  250. keywords: ['家居定制怎么选', '第一次买怎么对比', '售后']
  251. }
  252. });
  253. const businessWorkflowStructured = businessWorkflowResult.structuredContent || {};
  254. const businessWorkflowMessage = String(businessWorkflowStructured.assistantMessage || '');
  255. if (businessWorkflowStructured.status !== 'ok' ||
  256. !businessWorkflowMessage.includes('VOC经营闭环') ||
  257. businessWorkflowStructured.summary?.workflowStage !== 'business_workflow_first_round') {
  258. throw new Error(`Expected VOC business workflow result, got: ${JSON.stringify(businessWorkflowStructured)}`);
  259. }
  260. for (const tool of ['voc_api_search', 'voc_api_doc', 'voc_api_call']) {
  261. if (!toolNames.includes(tool)) {
  262. throw new Error(`Expected catalog tool ${tool}, got: ${toolNames.join(', ')}`);
  263. }
  264. }
  265. const apiSearchResult = await client.callTool({
  266. name: 'voc_api_search',
  267. arguments: { platform: 'douyin' }
  268. });
  269. const apiSearchStructured = apiSearchResult.structuredContent || {};
  270. if (apiSearchStructured.status !== 'ok' || !apiSearchStructured.data?.endpoints?.length) {
  271. throw new Error(`Expected voc_api_search to list douyin endpoints, got: ${JSON.stringify(apiSearchStructured)}`);
  272. }
  273. const taobaoDetailSearchResult = await client.callTool({
  274. name: 'voc_api_search',
  275. arguments: { platform: 'taobao', query: '淘宝 商品详情' }
  276. });
  277. const taobaoDetailEndpoints = taobaoDetailSearchResult.structuredContent?.data?.endpoints || [];
  278. const taobaoDetailIds = taobaoDetailEndpoints.map(endpoint => endpoint.id);
  279. if (taobaoDetailIds[0] !== 'taobao.get_item_detail_v6') {
  280. throw new Error(`Expected current Taobao endpoints with v6 preferred for product details, got: ${taobaoDetailIds.join(', ')}`);
  281. }
  282. const taobaoCatalogResult = await client.callTool({
  283. name: 'voc_api_search',
  284. arguments: { platform: 'taobao' }
  285. });
  286. const taobaoCatalogIds = (taobaoCatalogResult.structuredContent?.data?.endpoints || []).map(endpoint => endpoint.id);
  287. for (const id of ['taobao.get_item_detail_v3', 'taobao.get_item_detail_v6', 'taobao.get_shop_item_list_v4']) {
  288. if (!taobaoCatalogIds.includes(id)) {
  289. throw new Error(`Expected current Taobao catalog to include ${id}, got: ${taobaoCatalogIds.join(', ')}`);
  290. }
  291. }
  292. const taobaoSalesSearchResult = await client.callTool({
  293. name: 'voc_api_search',
  294. arguments: { platform: 'taobao', query: '淘宝 页面销量' }
  295. });
  296. const taobaoSalesEndpoints = taobaoSalesSearchResult.structuredContent?.data?.endpoints || [];
  297. if (taobaoSalesEndpoints[0]?.id !== 'taobao.get_item_detail_v3' || taobaoSalesEndpoints[0]?.deprecated) {
  298. throw new Error(`Expected Taobao v3 to be preferred for page sales, got: ${JSON.stringify(taobaoSalesEndpoints)}`);
  299. }
  300. const taobaoCouponPriceSearchResult = await client.callTool({
  301. name: 'voc_api_search',
  302. arguments: { platform: 'taobao', query: '淘宝 券后价' }
  303. });
  304. const taobaoCouponPriceEndpoints = taobaoCouponPriceSearchResult.structuredContent?.data?.endpoints || [];
  305. if (taobaoCouponPriceEndpoints[0]?.id !== 'taobao.get_item_detail_v4') {
  306. throw new Error(`Expected Taobao v4 to be preferred for coupon prices, got: ${JSON.stringify(taobaoCouponPriceEndpoints)}`);
  307. }
  308. const taobaoV9DocResult = await client.callTool({
  309. name: 'voc_api_doc',
  310. arguments: { id: 'taobao.get_item_detail_v9' }
  311. });
  312. const taobaoV9Endpoint = taobaoV9DocResult.structuredContent?.data?.endpoint || {};
  313. if (!String(taobaoV9Endpoint.responseHint || '').includes('totalCount=') ||
  314. Number(taobaoV9Endpoint.recommendationPriority) >= 0) {
  315. throw new Error(`Expected Taobao v9 field semantics and compatibility priority, got: ${JSON.stringify(taobaoV9Endpoint)}`);
  316. }
  317. const apiDocResult = await client.callTool({
  318. name: 'voc_api_doc',
  319. arguments: { id: 'douyin.search_general' }
  320. });
  321. const apiDocStructured = apiDocResult.structuredContent || {};
  322. if (apiDocStructured.status !== 'ok' ||
  323. !String(apiDocStructured.data?.endpoint?.proxyPath || '').includes('fetch_general_search_v2')) {
  324. throw new Error(`Expected voc_api_doc to return douyin search doc, got: ${JSON.stringify(apiDocStructured)}`);
  325. }
  326. const apiCallSentinel = 'sk-SMOKE_WRONG_TOKEN_TYPE';
  327. const apiCallResult = await client.callTool({
  328. name: 'voc_api_call',
  329. arguments: { id: 'douyin.search_general', params: { keyword: '律师' }, newapiToken: apiCallSentinel, vocToken: 'r:SMOKE_INVALID_SESSION_TOKEN' }
  330. });
  331. const apiCallStructured = apiCallResult.structuredContent || {};
  332. if (apiCallStructured.status !== 'needs_valid_token') {
  333. throw new Error(`Expected voc_api_call to map invalid sk-/r: tokens to needs_valid_token (token problem, not 余额不足), got: ${JSON.stringify(apiCallStructured)}`);
  334. }
  335. if (JSON.stringify(apiCallStructured).includes(apiCallSentinel) || JSON.stringify(apiCallStructured).includes('r:SMOKE_INVALID_SESSION_TOKEN')) {
  336. throw new Error('voc_api_call leaked the request-scoped token');
  337. }
  338. console.log(JSON.stringify({
  339. status: 'ok',
  340. tools: toolNames,
  341. assistantMessagePreview: assistantMessage.slice(0, 120),
  342. deepDivePreview: deepDiveMessage.slice(0, 120),
  343. contentPlanPreview: contentPlanMessage.slice(0, 120),
  344. speakingScriptPreview: speakingScriptMessage.slice(0, 120),
  345. competitorMapPreview: competitorMapMessage.slice(0, 120),
  346. businessWorkflowPreview: businessWorkflowMessage.slice(0, 120),
  347. preferenceMemoryApplied: Boolean(structured.summary && structured.summary.preferenceMemoryApplied),
  348. files: structured.files || []
  349. }, null, 2));
  350. } finally {
  351. await client.close();
  352. }
  353. }
  354. main().catch(error => {
  355. console.error(error);
  356. process.exit(1);
  357. });