#!/usr/bin/env node 'use strict'; /** * 智慧运营中台 · 数据报表 MCP Server(stdio JSON-RPC) * ---------------------------------------------------------------------------- * 把本地数据湖暴露成 MCP 工具,供 WorkBuddy 等 Agent 直接「查数」, * 与 napos-mcp(负责「取数」)形成闭环。零第三方依赖。 * * 注册(~/.workbuddy/mcp.json): * "napos-reports": { "command": "node", "args": ["<本项目路径>/reports-mcp.js"] } * * 工具: * reports_list_entities — 列出数据湖中的主体(含门店清单) * reports_summary — 区间 KPI 汇总 * reports_query — 查询明细/按门店汇总/按日期汇总 */ const naposService = require('./napos-service.js'); const nlQuery = require('./nl-query.js'); const TOOLS = [ { name: 'reports_list_entities', description: '列出本地数据湖中已沉淀的经营数据主体(商家账号),含每个主体的门店清单、数据覆盖的日期范围', inputSchema: { type: 'object', properties: {} }, }, { name: 'reports_summary', description: '查询某主体在指定日期区间的 KPI 汇总(营业额/收入/有效订单/曝光/下单人数/转化率/客单价)', inputSchema: { type: 'object', properties: { entity: { type: 'string', description: '主体 key(来自 reports_list_entities)' }, from: { type: 'string', description: '起始日 YYYYMMDD,缺省=可用区间起点' }, to: { type: 'string', description: '结束日 YYYYMMDD,缺省=可用区间终点' }, }, required: ['entity'], }, }, { name: 'reports_query', description: '查询经营数据行。mode=shop 按门店汇总(可排序取 TopN)、mode=date 按日期汇总、mode=detail 日期×门店明细', inputSchema: { type: 'object', properties: { entity: { type: 'string', description: '主体 key' }, from: { type: 'string', description: '起始日 YYYYMMDD' }, to: { type: 'string', description: '结束日 YYYYMMDD' }, mode: { type: 'string', enum: ['detail', 'shop', 'date'], description: '缺省 shop' }, shopIds: { type: 'array', items: { type: 'string' }, description: '可选,限定门店' }, q: { type: 'string', description: '可选,按门店名称模糊过滤' }, sort: { type: 'string', description: '排序指标 key,如 grossAmt' }, sortDir: { type: 'string', enum: ['asc', 'desc'] }, limit: { type: 'number', description: '返回行数上限,缺省 50' }, }, required: ['entity'], }, }, ]; function jsonOut(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); } function clampRange(entity) { const range = nlQuery.lakeDateRange(entity, naposService); return range; } function handleListEntities() { const entities = naposService.store.listEntities().map((e) => { const range = nlQuery.lakeDateRange(e.key, naposService); return { key: e.key, name: e.name, shopCount: (e.shops || []).length, chains: e.chains || [], shops: e.shops || [], dataRange: range, }; }); return { entities }; } function handleSummary(args) { const entity = String(args.entity || ''); const range = clampRange(entity); const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from; const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to; const daily = naposService.queryReport({ entity, from, to, scope: 'shop' }); return { entity, from, to, summary: daily.summary }; } function handleQuery(args) { const entity = String(args.entity || ''); const range = clampRange(entity); const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from; const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to; const mode = ['detail', 'shop', 'date'].includes(args.mode) ? args.mode : 'shop'; const limit = Math.min(Number(args.limit) > 0 ? Number(args.limit) : 50, 500); let result; if (mode === 'detail') { result = naposService.queryReport({ entity, from, to, scope: 'shop', shops: args.shopIds, q: args.q }); } else { result = naposService.aggregateReport({ entity, from, to, group: mode === 'date' ? 'date' : 'shop', shops: args.shopIds }); if (args.q) result.rows = result.rows.filter((r) => String(r.shopName || '').toLowerCase().includes(String(args.q).toLowerCase())); } let rows = result.rows || []; const sortKey = naposService.REPORT_COLUMNS.some((c) => c.key === args.sort) ? args.sort : null; if (sortKey) { const dir = args.sortDir === 'asc' ? 1 : -1; rows = rows.slice().sort((a, b) => { const an = Number(a[sortKey]), bn = Number(b[sortKey]); if (Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * dir; return String(a[sortKey] || '').localeCompare(String(b[sortKey] || ''), 'zh-CN') * dir; }); } return { entity, from, to, mode, columns: result.columns.map((c) => ({ key: c.key, label: c.label, type: c.type })), summary: result.summary, rows: rows.slice(0, limit), totalRows: rows.length, truncated: rows.length > limit, }; } function dispatch(name, args) { switch (name) { case 'reports_list_entities': return handleListEntities(); case 'reports_summary': return handleSummary(args || {}); case 'reports_query': return handleQuery(args || {}); default: throw new Error('未知工具: ' + name); } } let buffer = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { buffer += chunk; let idx; while ((idx = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, idx).trim(); buffer = buffer.slice(idx + 1); if (!line) continue; let msg = null; try { msg = JSON.parse(line); } catch { continue; } const { id, method, params } = msg; if (method === 'initialize') { jsonOut({ jsonrpc: '2.0', id, result: { protocolVersion: (params && params.protocolVersion) || '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'napos-reports', version: '1.0.0' }, }, }); } else if (method === 'notifications/initialized' || (method || '').startsWith('notifications/')) { // 通知无需回应 } else if (method === 'tools/list') { jsonOut({ jsonrpc: '2.0', id, result: { tools: TOOLS } }); } else if (method === 'tools/call') { try { const data = dispatch(params.name, params.arguments); jsonOut({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }, }); } catch (e) { jsonOut({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: '错误: ' + (e.message || e) }], isError: true }, }); } } else if (method === 'ping') { jsonOut({ jsonrpc: '2.0', id, result: {} }); } else if (id !== undefined) { jsonOut({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } }); } } }); process.stdin.on('end', () => process.exit(0));