chaimi-keep-mcp 3.1.24
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/.env.example +26 -0
- package/README.md +222 -0
- package/bin/cli.js +233 -0
- package/bin/get-auth-code.js +92 -0
- package/config.example.yaml +15 -0
- package/install.sh +268 -0
- package/oauth.js +493 -0
- package/package.json +42 -0
- package/server.js +1064 -0
package/server.js
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 柴米记账 MCP Server (Node.js 版本)
|
|
4
|
+
* 适配微信云函数 mcpHub
|
|
5
|
+
* 支持 Claude Desktop、Cursor、WorkBuddy、OpenClaw
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// 加载 .env 文件
|
|
9
|
+
try {
|
|
10
|
+
require('dotenv').config();
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// dotenv 未安装,忽略
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
16
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
17
|
+
const {
|
|
18
|
+
CallToolRequestSchema,
|
|
19
|
+
ListToolsRequestSchema,
|
|
20
|
+
} = require('@modelcontextprotocol/sdk/types.js');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
// 读取 package.json 获取版本
|
|
27
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
28
|
+
const MCP_VERSION = packageJson.version;
|
|
29
|
+
|
|
30
|
+
// 导入 OAuth 模块
|
|
31
|
+
const { OAuthManager, FileTokenStorage } = require('./oauth.js');
|
|
32
|
+
|
|
33
|
+
// API 签名密钥(用于验证请求来自合法 MCP Server)
|
|
34
|
+
const CHAIMI_API_SECRET = 'chaimi-mcp-secret-2024';
|
|
35
|
+
|
|
36
|
+
// URL 加密密钥
|
|
37
|
+
const URL_ENCRYPT_KEY = 'chaimi-url-key-2024';
|
|
38
|
+
|
|
39
|
+
// 加密的云函数 URL
|
|
40
|
+
const ENCRYPTED_URLS = {
|
|
41
|
+
hub: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f571320140b40044e05',
|
|
42
|
+
oauth: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f571327201c1901',
|
|
43
|
+
prompt: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f5713381306001959'
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 解密 URL 函数
|
|
47
|
+
function decryptUrl(encrypted, key) {
|
|
48
|
+
const hex = encrypted.replace('enc:v1:', '');
|
|
49
|
+
let result = '';
|
|
50
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
51
|
+
const byte = parseInt(hex.substr(i, 2), 16);
|
|
52
|
+
result += String.fromCharCode(byte ^ key.charCodeAt((i / 2) % key.length));
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 配置 - 微信云函数(运行时解密)
|
|
58
|
+
const MCP_HUB_URL = process.env.MCP_HUB_URL || decryptUrl(ENCRYPTED_URLS.hub, URL_ENCRYPT_KEY);
|
|
59
|
+
const MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || decryptUrl(ENCRYPTED_URLS.prompt, URL_ENCRYPT_KEY);
|
|
60
|
+
const MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || decryptUrl(ENCRYPTED_URLS.oauth, URL_ENCRYPT_KEY);
|
|
61
|
+
|
|
62
|
+
// Token 缓存
|
|
63
|
+
let cachedToken = null;
|
|
64
|
+
let tokenExpireTime = 0;
|
|
65
|
+
|
|
66
|
+
// OAuth 管理器实例
|
|
67
|
+
let oauthManager = null;
|
|
68
|
+
|
|
69
|
+
// 初始化 OAuth 管理器
|
|
70
|
+
function initOAuthManager() {
|
|
71
|
+
const tokenStorage = new FileTokenStorage(
|
|
72
|
+
path.join(os.homedir(), '.mcporter', 'oauth-token.json')
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
oauthManager = new OAuthManager({
|
|
76
|
+
mcpOAuthUrl: MCP_OAUTH_URL,
|
|
77
|
+
tokenStorage: tokenStorage,
|
|
78
|
+
// ⚠️ 重要:此回调在用户首次使用时显示验证码,切勿删除或清空!
|
|
79
|
+
// 如果删除,用户将无法看到授权验证码,导致无法使用 MCP Server
|
|
80
|
+
// 参见 FUNCTION_REGISTRY.md 中的 "验证码显示" 功能
|
|
81
|
+
// 注意:使用 console.error,避免污染 stdout(MCP 协议通信使用 stdout)
|
|
82
|
+
onQrCode: (qrData) => {
|
|
83
|
+
// 输出授权信息到控制台(用户可见)
|
|
84
|
+
console.error('\n' + '='.repeat(60));
|
|
85
|
+
console.error(' 柴米AI记账 MCP Server - 首次使用需要授权');
|
|
86
|
+
console.error('='.repeat(60));
|
|
87
|
+
console.error('');
|
|
88
|
+
console.error(` 验证码:${qrData.userCode}`);
|
|
89
|
+
console.error('');
|
|
90
|
+
console.error(' 请在"柴米AI记账"小程序中完成授权:');
|
|
91
|
+
console.error('');
|
|
92
|
+
console.error(' 1. 打开"柴米AI记账"小程序');
|
|
93
|
+
console.error(' 2. 点击"我的" → "🤖 Agent 授权"');
|
|
94
|
+
console.error(` 3. 输入验证码:${qrData.userCode}`);
|
|
95
|
+
console.error('');
|
|
96
|
+
console.error('='.repeat(60) + '\n');
|
|
97
|
+
},
|
|
98
|
+
// ⚠️ 重要:此回调在授权成功后通知用户,切勿删除!
|
|
99
|
+
onTokenReady: (token) => {
|
|
100
|
+
console.error('✅ 授权成功!Token 已保存。');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return oauthManager;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 创建 MCP Server
|
|
108
|
+
const server = new Server(
|
|
109
|
+
{
|
|
110
|
+
name: 'chaimi-keep-mcp',
|
|
111
|
+
version: MCP_VERSION,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
capabilities: {
|
|
115
|
+
tools: {},
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// 工具列表
|
|
121
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
122
|
+
return {
|
|
123
|
+
tools: [
|
|
124
|
+
{
|
|
125
|
+
name: 'save_expense',
|
|
126
|
+
description: '保存单商品消费记录(AI文字记账)。只需提供商品名称和金额,其他参数自动填充。示例:name="午餐", amount=35',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
name: { type: 'string', description: '商品名称(必填)' },
|
|
131
|
+
amount: { type: 'number', description: '金额(必填)' },
|
|
132
|
+
category: { type: 'string', description: '分类(可选,如:餐饮、食品、交通)' },
|
|
133
|
+
store: { type: 'string', description: '商家名称(可选)' },
|
|
134
|
+
date: { type: 'string', description: '消费时间(ISO格式)(可选)' },
|
|
135
|
+
note: { type: 'string', description: '备注(可选)' },
|
|
136
|
+
agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
137
|
+
apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
|
|
138
|
+
rawInput: { type: 'string', description: '【推荐自动填充】用户的原始输入内容,用于记录用户原始请求' },
|
|
139
|
+
mcp_version: { type: 'string', description: 'MCP Server 版本号(自动填充)' },
|
|
140
|
+
},
|
|
141
|
+
required: ['name', 'amount'],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'save_receipt',
|
|
146
|
+
description: '【v3.1.21】【图片小票专用】保存购物小票/发票/收据。⚠️ 重要:items数组中每个商品必须包含完整的4个字段:name(商品名称)、amount(金额=单价×数量)、price(单价)、quantity(数量)。示例:[{"name":"苹果","amount":5.5,"price":5.5,"quantity":1}]',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
store: { type: 'string', description: '商家名称(必填)' },
|
|
151
|
+
date: { type: 'string', description: '消费时间(ISO 8601 格式,必须包含日期和时间,如:2026-04-10T13:06:21)' },
|
|
152
|
+
totalAmount: { type: 'number', description: '总金额(所有商品amount之和)' },
|
|
153
|
+
originalAmount: { type: 'number', description: '应付金额(优惠前)' },
|
|
154
|
+
discountAmount: { type: 'number', description: '优惠金额' },
|
|
155
|
+
actualAmount: { type: 'number', description: '实付金额(优惠后)' },
|
|
156
|
+
paymentMethod: { type: 'string', description: '支付方式,如:微信支付、支付宝' },
|
|
157
|
+
receiptNo: { type: 'string', description: '小票编号' },
|
|
158
|
+
items: {
|
|
159
|
+
type: 'array',
|
|
160
|
+
description: '【必填】商品列表,必须是数组格式。每个商品必须包含:name、amount、price、quantity。⚠️ 注意:amount=price×quantity',
|
|
161
|
+
items: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
name: { type: 'string', description: '【必填】商品名称' },
|
|
165
|
+
originalName: { type: 'string', description: '原始商品名称(含规格)' },
|
|
166
|
+
price: { type: 'number', description: '【必填】单价' },
|
|
167
|
+
quantity: { type: 'number', description: '【必填】数量' },
|
|
168
|
+
unit: { type: 'string', description: '单位,如:斤、个、份' },
|
|
169
|
+
amount: { type: 'number', description: '【必填】金额,必须等于 price × quantity' },
|
|
170
|
+
weight: { type: 'string', description: '重量' },
|
|
171
|
+
marketPrice: { type: 'string', description: '市场单价(元/500g)' },
|
|
172
|
+
category: { type: 'string', description: '分类,如:餐饮、食品、购物' },
|
|
173
|
+
},
|
|
174
|
+
required: ['name', 'amount', 'price', 'quantity'],
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
agentType: { type: 'string', description: '【自动填充】Agent类型:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
178
|
+
apiProvider: { type: 'string', description: '【自动填充】AI服务提供商:anthropic、openai、doubao、aliyun' },
|
|
179
|
+
rawInput: { type: 'string', description: '【自动填充】用户的原始输入内容' },
|
|
180
|
+
mcp_version: { type: 'string', description: '【自动填充】MCP Server版本号' },
|
|
181
|
+
},
|
|
182
|
+
required: ['store', 'items'],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'get_expenses',
|
|
187
|
+
description: '查询消费记录',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
limit: { type: 'number', description: '返回数量限制', default: 10 },
|
|
192
|
+
source: { type: 'string', description: '来源筛选' },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'get_receipt_list',
|
|
198
|
+
description: '获取小票列表',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
limit: { type: 'number', description: '返回数量限制', default: 5 },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'get_statistics',
|
|
208
|
+
description: '获取消费统计',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: {
|
|
212
|
+
yearMonth: { type: 'string', description: '年月(如:2026-03)' },
|
|
213
|
+
type: { type: 'string', description: '统计类型:item/receipt/category' },
|
|
214
|
+
},
|
|
215
|
+
required: ['yearMonth'],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'save_income',
|
|
220
|
+
description: '保存收入记录(工资、奖金、红包等)',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
name: { type: 'string', description: '收入来源(如:工资、奖金、红包)' },
|
|
225
|
+
amount: { type: 'number', description: '收入金额' },
|
|
226
|
+
category: { type: 'string', description: '收入分类(如:工资、奖金、投资)' },
|
|
227
|
+
store: { type: 'string', description: '付款方(如:公司名称)' },
|
|
228
|
+
date: { type: 'string', description: '收入时间(ISO格式)(可选)' },
|
|
229
|
+
note: { type: 'string', description: '备注' },
|
|
230
|
+
agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
231
|
+
apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
|
|
232
|
+
rawInput: { type: 'string', description: '【推荐自动填充】用户的原始输入内容,用于记录用户原始请求' },
|
|
233
|
+
mcp_version: { type: 'string', description: 'MCP Server 版本号(自动填充)' },
|
|
234
|
+
},
|
|
235
|
+
required: ['name', 'amount'],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: 'get_parse_prompt',
|
|
240
|
+
description: '获取小票图片解析的Prompt模板,用于指导大模型如何格式化输出小票信息。返回的Prompt应作为system message,配合小票图片作为user message调用大模型',
|
|
241
|
+
inputSchema: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
type: { type: 'string', description: 'Prompt类型,如:parseReceipt', default: 'parseReceipt' },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'submit_feedback',
|
|
250
|
+
description: '提交反馈、建议或问题报告。当用户遇到记账问题、有功能建议或发现异常时,可使用此工具提交反馈。反馈类型包括:bug(问题报告)、feature(功能建议)、improvement(优化建议)、other(其他)。提交成功后会返回反馈编号,可用于查询处理进度。注意:反馈内容不要超过150个字符,超过部分会被截断',
|
|
251
|
+
inputSchema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
content: { type: 'string', description: '反馈内容,详细描述问题或建议(必填,最多150个字符)' },
|
|
255
|
+
type: { type: 'string', description: '反馈类型:bug(问题报告)、feature(功能建议)、improvement(优化建议)、other(其他),默认other', enum: ['bug', 'feature', 'improvement', 'other'], default: 'other' },
|
|
256
|
+
contact: { type: 'string', description: '联系方式(可选),如邮箱、微信等,方便后续沟通' },
|
|
257
|
+
context: { type: 'string', description: '上下文信息(可选),如相关记账记录ID、错误截图描述等' },
|
|
258
|
+
agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
259
|
+
apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
|
|
260
|
+
},
|
|
261
|
+
required: ['content'],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// 工具名称映射:MCP Tool -> mcpHub Tool
|
|
269
|
+
const toolMapping = {
|
|
270
|
+
'save_expense': 'addExpense',
|
|
271
|
+
'save_receipt': 'addReceipt',
|
|
272
|
+
'save_income': 'addIncome',
|
|
273
|
+
'get_expenses': 'getExpenses',
|
|
274
|
+
'get_receipt_list': 'getExpenses',
|
|
275
|
+
'get_statistics': 'getStatistics',
|
|
276
|
+
'submit_feedback': 'addFeedback',
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// 获取或刷新 Token(OAuth 2.0 Device Flow)
|
|
280
|
+
const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000;
|
|
281
|
+
let lastRefreshTime = 0;
|
|
282
|
+
|
|
283
|
+
async function getToken() {
|
|
284
|
+
// 检查是否已授权
|
|
285
|
+
if (!authState.isAuthorized) {
|
|
286
|
+
// 先检查是否有保存的授权状态(从文件加载)
|
|
287
|
+
const savedAuthState = await oauthManager.loadAuthState();
|
|
288
|
+
if (savedAuthState && savedAuthState.deviceCode) {
|
|
289
|
+
// 检查验证码是否已过期
|
|
290
|
+
if (oauthManager.isAuthStateExpired(savedAuthState)) {
|
|
291
|
+
console.error('⏰ 保存的验证码已过期,清除并生成新的验证码');
|
|
292
|
+
await oauthManager.clearAuthState();
|
|
293
|
+
// 继续执行,生成新的验证码
|
|
294
|
+
} else {
|
|
295
|
+
// 验证码未过期,尝试直接获取token(用户可能已授权)
|
|
296
|
+
try {
|
|
297
|
+
const token = await oauthManager.pollForTokenOnce(savedAuthState.deviceCode);
|
|
298
|
+
if (token) {
|
|
299
|
+
// 授权成功
|
|
300
|
+
cachedToken = token.accessToken;
|
|
301
|
+
tokenExpireTime = token.expiresAt;
|
|
302
|
+
authState.isAuthorized = true;
|
|
303
|
+
await oauthManager.tokenStorage.save(token);
|
|
304
|
+
await oauthManager.clearAuthState();
|
|
305
|
+
console.error('✅ 授权成功!可以继续使用记账功能');
|
|
306
|
+
// 返回特殊标记,让上层知道这是刚完成授权的情况
|
|
307
|
+
throw new Error(`AUTH_JUST_COMPLETED:${cachedToken}`);
|
|
308
|
+
}
|
|
309
|
+
// 用户还未授权,返回同一个验证码
|
|
310
|
+
authState.deviceCode = savedAuthState.deviceCode;
|
|
311
|
+
authState.userCode = savedAuthState.userCode;
|
|
312
|
+
throw new Error(`NEED_AUTH:${savedAuthState.userCode}`);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
if (err.message.startsWith('NEED_AUTH:') || err.message.startsWith('AUTH_JUST_COMPLETED:')) {
|
|
315
|
+
throw err;
|
|
316
|
+
}
|
|
317
|
+
// 其他错误,继续获取新的验证码
|
|
318
|
+
console.error('⚠️ 检查授权状态失败:', err.message);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 没有保存的授权状态或已过期,启动新的授权流程
|
|
324
|
+
await startAuthFlow();
|
|
325
|
+
|
|
326
|
+
// 返回验证码给Agent
|
|
327
|
+
throw new Error(`NEED_AUTH:${authState.userCode || '等待生成验证码'}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (cachedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
|
|
331
|
+
if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
|
|
332
|
+
return cachedToken;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!oauthManager) {
|
|
337
|
+
throw new Error('OAuth 管理器未初始化,请检查 MCP_OAUTH_URL 配置');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const oauthToken = await oauthManager.getValidToken();
|
|
342
|
+
cachedToken = oauthToken.accessToken;
|
|
343
|
+
tokenExpireTime = oauthToken.expiresAt;
|
|
344
|
+
lastRefreshTime = Date.now();
|
|
345
|
+
await oauthManager.clearAuthState();
|
|
346
|
+
return cachedToken;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
// Token 获取失败,可能需要重新授权
|
|
349
|
+
authState.isAuthorized = false;
|
|
350
|
+
authState.isWaiting = false;
|
|
351
|
+
throw new Error(`NEED_AUTH:授权已过期,请重新授权`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 调用 mcpPrompt 云函数(获取 Prompt 或校验数据)
|
|
356
|
+
async function callMcpPrompt(tool, params, token) {
|
|
357
|
+
const response = await fetch(MCP_PROMPT_URL, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: {
|
|
360
|
+
'Content-Type': 'application/json',
|
|
361
|
+
'Authorization': `Bearer ${token}`,
|
|
362
|
+
},
|
|
363
|
+
body: JSON.stringify({
|
|
364
|
+
tool: tool,
|
|
365
|
+
params: params,
|
|
366
|
+
}),
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
const errorText = await response.text();
|
|
371
|
+
throw new Error(`mcpPrompt 调用失败: ${errorText}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return await response.json();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 生成请求签名(用于验证请求来自合法 MCP Server)
|
|
378
|
+
function generateSignature(body, timestamp, secret) {
|
|
379
|
+
const payload = JSON.stringify(body) + timestamp;
|
|
380
|
+
return crypto
|
|
381
|
+
.createHmac('sha256', secret)
|
|
382
|
+
.update(payload)
|
|
383
|
+
.digest('hex');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 调用 mcpHub 云函数
|
|
387
|
+
async function callMcpHub(tool, params, token) {
|
|
388
|
+
const body = {
|
|
389
|
+
tool: tool,
|
|
390
|
+
params: params,
|
|
391
|
+
};
|
|
392
|
+
const timestamp = Date.now().toString();
|
|
393
|
+
const signature = generateSignature(body, timestamp, CHAIMI_API_SECRET);
|
|
394
|
+
|
|
395
|
+
const response = await fetch(MCP_HUB_URL, {
|
|
396
|
+
method: 'POST',
|
|
397
|
+
headers: {
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
'Authorization': `Bearer ${token}`,
|
|
400
|
+
'X-Chaimi-Signature': signature,
|
|
401
|
+
'X-Chaimi-Timestamp': timestamp,
|
|
402
|
+
},
|
|
403
|
+
body: JSON.stringify(body),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
if (!response.ok) {
|
|
407
|
+
const errorText = await response.text();
|
|
408
|
+
throw new Error(`mcpHub 调用失败: ${errorText}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return await response.json();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 工具调用处理
|
|
415
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
416
|
+
const { name, arguments: args } = request.params;
|
|
417
|
+
|
|
418
|
+
const agentTypeFromEnv = process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
|
|
419
|
+
const apiProviderFromEnv = process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
|
|
420
|
+
|
|
421
|
+
if (!args.agentType && agentTypeFromEnv) {
|
|
422
|
+
args.agentType = agentTypeFromEnv;
|
|
423
|
+
}
|
|
424
|
+
if (!args.apiProvider && apiProviderFromEnv) {
|
|
425
|
+
args.apiProvider = apiProviderFromEnv;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const token = await getToken();
|
|
430
|
+
|
|
431
|
+
let processedArgs = { ...args };
|
|
432
|
+
if (processedArgs.items && typeof processedArgs.items === 'string') {
|
|
433
|
+
try {
|
|
434
|
+
processedArgs.items = JSON.parse(processedArgs.items);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let result;
|
|
440
|
+
let userMessage;
|
|
441
|
+
|
|
442
|
+
switch (name) {
|
|
443
|
+
case 'save_expense': {
|
|
444
|
+
const mcpParams = convertParams('save_expense', processedArgs);
|
|
445
|
+
result = await callMcpHub('addExpense', mcpParams, token);
|
|
446
|
+
|
|
447
|
+
if (result.success) {
|
|
448
|
+
const displayName = processedArgs.name || '未知商品';
|
|
449
|
+
const displayAmount = processedArgs.amount || 0;
|
|
450
|
+
const displayCategory = processedArgs.category || '其他';
|
|
451
|
+
const displayStore = processedArgs.store || '-';
|
|
452
|
+
userMessage = `✅ 记账成功\n\n| 商品 | 金额 | 分类 | 商家 |\n|------|------|------|------|\n| ${displayName} | ${displayAmount}元 | ${displayCategory} | ${displayStore} |\n\n已保存到你的柴米记账小程序数据库。`;
|
|
453
|
+
|
|
454
|
+
if (!processedArgs.agentType || !processedArgs.apiProvider) {
|
|
455
|
+
userMessage += '\n\n💡 提示:传递agentType和apiProvider参数可解锁小程序记录来源统计功能,示例:agentType="openclaw", apiProvider="doubao"';
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
case 'save_receipt': {
|
|
462
|
+
// 0. 参数格式强制检查 - 确保 items 中每个商品都有完整的字段
|
|
463
|
+
if (processedArgs.items && Array.isArray(processedArgs.items)) {
|
|
464
|
+
const invalidItems = [];
|
|
465
|
+
processedArgs.items.forEach((item, index) => {
|
|
466
|
+
const missingFields = [];
|
|
467
|
+
if (!item.hasOwnProperty('name')) missingFields.push('name');
|
|
468
|
+
if (!item.hasOwnProperty('amount')) missingFields.push('amount');
|
|
469
|
+
if (!item.hasOwnProperty('price')) missingFields.push('price');
|
|
470
|
+
if (!item.hasOwnProperty('quantity')) missingFields.push('quantity');
|
|
471
|
+
|
|
472
|
+
if (missingFields.length > 0) {
|
|
473
|
+
invalidItems.push(`商品${index + 1}(${item.name || '未命名'})缺少字段: ${missingFields.join(', ')}`);
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (invalidItems.length > 0) {
|
|
478
|
+
result = {
|
|
479
|
+
success: false,
|
|
480
|
+
error: `参数格式错误:${invalidItems.join('; ')}。请更新您的 MCP Skill 或检查 items 数组格式。每个商品必须包含:name, amount, price, quantity`,
|
|
481
|
+
code: 400
|
|
482
|
+
};
|
|
483
|
+
userMessage = `❌ 保存失败\n\n错误:商品数据格式不完整\n\n${invalidItems.join('\n')}\n\n💡 解决方案:\n1. 请更新您的柴米记账 MCP Skill\n2. 确保每个商品包含完整的4个字段:name, amount, price, quantity\n3. amount 必须等于 price × quantity`;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 1. 调用 parseReceipt 重新提取小票信息(覆盖 Agent 传的数据)
|
|
489
|
+
if (processedArgs.rawInput) {
|
|
490
|
+
const parseResult = await callMcpPrompt(
|
|
491
|
+
'parseReceipt',
|
|
492
|
+
{
|
|
493
|
+
text: processedArgs.rawInput,
|
|
494
|
+
type: 'parseReceipt'
|
|
495
|
+
},
|
|
496
|
+
token
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
if (parseResult.success && parseResult.data) {
|
|
500
|
+
// 用 parseReceipt 提取的信息覆盖 Agent 传的参数
|
|
501
|
+
processedArgs = {
|
|
502
|
+
...processedArgs,
|
|
503
|
+
...parseResult.data
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 2. 调用 fillDefaults 补充默认值
|
|
509
|
+
const fillResult = await callMcpPrompt(
|
|
510
|
+
'fillDefaults',
|
|
511
|
+
{
|
|
512
|
+
data: processedArgs,
|
|
513
|
+
type: 'parseReceipt'
|
|
514
|
+
},
|
|
515
|
+
token
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
if (fillResult.success) {
|
|
519
|
+
processedArgs = {
|
|
520
|
+
...fillResult.data,
|
|
521
|
+
...processedArgs
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const mcpParams = convertParams('save_receipt', processedArgs);
|
|
526
|
+
result = await callMcpHub('addReceipt', mcpParams, token);
|
|
527
|
+
|
|
528
|
+
if (result.success) {
|
|
529
|
+
const totalAmount = processedArgs.totalAmount || processedArgs.items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
|
|
530
|
+
const itemsList = processedArgs.items?.map(item => `• ${item.name || '未知商品'} - ${item.amount || 0}元`).join('\n') || '暂无商品明细';
|
|
531
|
+
const storeName = processedArgs.store || '-';
|
|
532
|
+
const category = processedArgs.items?.[0]?.category || '其他';
|
|
533
|
+
userMessage = `✅ 小票记录成功\n\n| 项目 | 内容 |\n|------|------|\n| 商家 | ${storeName} |\n| 金额 | ${totalAmount}元 |\n| 分类 | ${category} |\n\n商品明细:\n${itemsList}\n\n已保存到你的柴米记账小程序数据库。`;
|
|
534
|
+
|
|
535
|
+
if (!processedArgs.agentType || !processedArgs.apiProvider) {
|
|
536
|
+
userMessage += '\n\n💡 提示:传递agentType和apiProvider参数可解锁小程序记录来源统计功能,示例:agentType="openclaw", apiProvider="doubao"';
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
case 'get_expenses':
|
|
543
|
+
case 'get_receipt_list': {
|
|
544
|
+
const toolName = toolMapping[name];
|
|
545
|
+
const mcpParams = convertParams(name, processedArgs);
|
|
546
|
+
result = await callMcpHub(toolName, mcpParams, token);
|
|
547
|
+
|
|
548
|
+
if (result.success) {
|
|
549
|
+
userMessage = `📊 消费记录查询成功\n共找到 ${result.data?.length || 0} 条记录`;
|
|
550
|
+
}
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
case 'get_statistics': {
|
|
555
|
+
const toolName = toolMapping[name];
|
|
556
|
+
const mcpParams = convertParams(name, processedArgs);
|
|
557
|
+
result = await callMcpHub(toolName, mcpParams, token);
|
|
558
|
+
|
|
559
|
+
if (result.success) {
|
|
560
|
+
userMessage = `📈 统计查询成功`;
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
case 'save_income': {
|
|
566
|
+
const mcpParams = convertParams('save_income', processedArgs);
|
|
567
|
+
result = await callMcpHub('addIncome', mcpParams, token);
|
|
568
|
+
|
|
569
|
+
if (result.success) {
|
|
570
|
+
const displayName = processedArgs.name || '未知来源';
|
|
571
|
+
const displayAmount = processedArgs.amount || 0;
|
|
572
|
+
const displayCategory = processedArgs.category || '其他';
|
|
573
|
+
const displayStore = processedArgs.store || '-';
|
|
574
|
+
userMessage = `✅ 收入记录成功\n\n| 来源 | 金额 | 分类 | 付款方 |\n|------|------|------|--------|\n| ${displayName} | ${displayAmount}元 | ${displayCategory} | ${displayStore} |`;
|
|
575
|
+
}
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
case 'get_parse_prompt': {
|
|
580
|
+
const promptType = args.type || 'parseReceipt';
|
|
581
|
+
const promptResult = await callMcpPrompt('getPrompt', { type: promptType }, token);
|
|
582
|
+
|
|
583
|
+
if (promptResult.success) {
|
|
584
|
+
result = {
|
|
585
|
+
success: true,
|
|
586
|
+
data: {
|
|
587
|
+
type: promptType,
|
|
588
|
+
version: promptResult.data.version,
|
|
589
|
+
systemPrompt: promptResult.data.systemPrompt,
|
|
590
|
+
userPromptTemplate: promptResult.data.userPromptTemplate,
|
|
591
|
+
examples: promptResult.data.examples,
|
|
592
|
+
instructions: '请将 systemPrompt 作为 system message,把小票图片作为 user message,调用你的大模型进行解析。解析完成后,调用 save_receipt 工具保存结果。'
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
userMessage = `✅ 获取解析Prompt成功\n\n版本: ${promptResult.data.version}\n\n## System Prompt\n\n请将以下内容作为 system message 发送给大模型:\n\n\`\`\`\n${promptResult.data.systemPrompt}\n\`\`\`\n\n## 使用说明\n\n1. 将上面的 System Prompt 作为 system message\n2. 将小票图片作为 user message\n3. 调用大模型解析\n4. 解析完成后,调用 save_receipt 工具保存结果\n\n## 示例\n\n${JSON.stringify(promptResult.data.examples, null, 2)}`;
|
|
596
|
+
} else {
|
|
597
|
+
result = { success: false, error: promptResult.error };
|
|
598
|
+
userMessage = `❌ 获取Prompt失败:${promptResult.error}`;
|
|
599
|
+
}
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
case 'submit_feedback': {
|
|
604
|
+
const feedbackData = {
|
|
605
|
+
content: processedArgs.content,
|
|
606
|
+
feedType: processedArgs.type || 'other',
|
|
607
|
+
contact: processedArgs.contact || '',
|
|
608
|
+
context: processedArgs.context || '',
|
|
609
|
+
agentType: processedArgs.agentType || '',
|
|
610
|
+
apiProvider: processedArgs.apiProvider || '',
|
|
611
|
+
mcp_version: MCP_VERSION,
|
|
612
|
+
userAgent: request.headers?.['user-agent'] || ''
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
result = await callMcpHub('addFeedback', feedbackData, token);
|
|
616
|
+
|
|
617
|
+
if (result.success) {
|
|
618
|
+
const typeText = {
|
|
619
|
+
bug: '问题报告',
|
|
620
|
+
feature: '功能建议',
|
|
621
|
+
improvement: '优化建议',
|
|
622
|
+
other: '其他反馈'
|
|
623
|
+
}[feedbackData.feedType] || '其他反馈';
|
|
624
|
+
|
|
625
|
+
userMessage = `✅ 反馈提交成功!\n\n感谢您的反馈,我们会尽快处理。\n\n━━━━━━━━━━━━━━\n📋 反馈编号:${result.data.feedbackId}\n📂 类型:${typeText}\n⏰ 时间:${new Date().toLocaleString('zh-CN')}\n━━━━━━━━━━━━━━\n\n💡 提示:\n- 反馈编号可用于查询处理进度\n- 如需补充信息,可再次提交并备注原编号\n- 我们会在小程序客服中回复您`;
|
|
626
|
+
} else {
|
|
627
|
+
userMessage = `❌ 反馈提交失败\n\n错误信息:${result.error || '未知错误'}\n\n💡 建议:\n- 检查网络连接后重试\n- 如持续失败,请稍后重试或联系客服`;
|
|
628
|
+
}
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
default:
|
|
633
|
+
throw new Error(`未知工具: ${name}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (!result.success) {
|
|
637
|
+
userMessage = `❌ 操作失败:${result.error || '未知错误'}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
userMessage += `\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}`;
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
content: [
|
|
644
|
+
{
|
|
645
|
+
type: 'text',
|
|
646
|
+
text: userMessage,
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
};
|
|
650
|
+
} catch (error) {
|
|
651
|
+
// 处理授权错误
|
|
652
|
+
if (error.message.startsWith('NEED_AUTH:')) {
|
|
653
|
+
const userCode = error.message.replace('NEED_AUTH:', '');
|
|
654
|
+
|
|
655
|
+
if (userCode && userCode !== '等待生成验证码') {
|
|
656
|
+
return {
|
|
657
|
+
content: [
|
|
658
|
+
{
|
|
659
|
+
type: 'text',
|
|
660
|
+
text: `🔐 首次使用需要授权
|
|
661
|
+
|
|
662
|
+
━━━━━━━━━━━━━━
|
|
663
|
+
📱 验证码:**${userCode}**
|
|
664
|
+
━━━━━━━━━━━━━━
|
|
665
|
+
|
|
666
|
+
请在"柴米AI记账"小程序中完成授权:
|
|
667
|
+
|
|
668
|
+
1️⃣ 打开微信小程序"柴米AI记账"
|
|
669
|
+
2️⃣ 点击"我的" → "🤖 Agent 授权"
|
|
670
|
+
3️⃣ 输入验证码:**${userCode}**
|
|
671
|
+
4️⃣ 点击确认授权
|
|
672
|
+
|
|
673
|
+
⚠️ 重要提示:
|
|
674
|
+
• 授权完成后,MCP Server 会自动退出(正常现象)
|
|
675
|
+
• 请重新发送您的记账指令,我才能为您完成记账
|
|
676
|
+
• 首次授权后,后续记账将无需再次授权
|
|
677
|
+
|
|
678
|
+
⏳ 验证码有效期:30分钟`,
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
};
|
|
682
|
+
} else {
|
|
683
|
+
return {
|
|
684
|
+
content: [
|
|
685
|
+
{
|
|
686
|
+
type: 'text',
|
|
687
|
+
text: `⏳ 正在准备授权,请稍等几秒后重试...`,
|
|
688
|
+
},
|
|
689
|
+
],
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 处理刚完成授权的情况
|
|
695
|
+
if (error.message.startsWith('AUTH_JUST_COMPLETED:')) {
|
|
696
|
+
return {
|
|
697
|
+
content: [
|
|
698
|
+
{
|
|
699
|
+
type: 'text',
|
|
700
|
+
text: `✅ 授权成功!
|
|
701
|
+
|
|
702
|
+
━━━━━━━━━━━━━━
|
|
703
|
+
🎉 恭喜!您已完成授权
|
|
704
|
+
━━━━━━━━━━━━━━
|
|
705
|
+
|
|
706
|
+
现在可以正常使用记账功能了!
|
|
707
|
+
|
|
708
|
+
请重新发送您的记账指令,例如:
|
|
709
|
+
• "记录一笔午餐 35元"
|
|
710
|
+
• "保存小票,商家是沃尔玛,商品有..."
|
|
711
|
+
|
|
712
|
+
首次授权后,后续记账将无需再次授权。`,
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
content: [
|
|
720
|
+
{
|
|
721
|
+
type: 'text',
|
|
722
|
+
text: `Error: ${error.message}`,
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
isError: true,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
function sanitizeString(str, maxLength = 200) {
|
|
731
|
+
if (!str || typeof str !== 'string') return '';
|
|
732
|
+
return str.replace(/<[^>]*>/g, '').substring(0, maxLength);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function validateAmount(amount) {
|
|
736
|
+
const num = parseFloat(amount);
|
|
737
|
+
return isNaN(num) || num < 0 ? 0 : num;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function validateQuantity(quantity) {
|
|
741
|
+
const num = parseFloat(quantity);
|
|
742
|
+
return isNaN(num) || num <= 0 ? 1 : num;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function extractWeightInGrams(weightStr) {
|
|
746
|
+
if (!weightStr || typeof weightStr !== 'string') return 0;
|
|
747
|
+
|
|
748
|
+
const match = weightStr.match(/(\d+\.?\d*)\s*(g|kg|斤|克|千克)/i);
|
|
749
|
+
if (!match) return 0;
|
|
750
|
+
|
|
751
|
+
const value = parseFloat(match[1]);
|
|
752
|
+
const unit = match[2].toLowerCase();
|
|
753
|
+
|
|
754
|
+
switch (unit) {
|
|
755
|
+
case 'g':
|
|
756
|
+
case '克':
|
|
757
|
+
return value;
|
|
758
|
+
case 'kg':
|
|
759
|
+
case '千克':
|
|
760
|
+
return value * 1000;
|
|
761
|
+
case '斤':
|
|
762
|
+
return value * 500;
|
|
763
|
+
default:
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function calculateMarketPrice(amount, weightStr) {
|
|
769
|
+
const amountNum = parseFloat(amount);
|
|
770
|
+
const weightInGrams = extractWeightInGrams(weightStr);
|
|
771
|
+
|
|
772
|
+
if (isNaN(amountNum) || amountNum <= 0 || weightInGrams <= 0) {
|
|
773
|
+
return '';
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const pricePer500g = (amountNum / weightInGrams) * 500;
|
|
777
|
+
return `${pricePer500g.toFixed(2)}元/500g`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function convertParams(toolName, args) {
|
|
781
|
+
switch (toolName) {
|
|
782
|
+
case 'save_expense': {
|
|
783
|
+
if (!args.name || typeof args.name !== 'string' || args.name.trim() === '') {
|
|
784
|
+
throw new Error('缺少必填参数:name(商品名称)');
|
|
785
|
+
}
|
|
786
|
+
if (args.amount === undefined || args.amount === null) {
|
|
787
|
+
throw new Error('缺少必填参数:amount(金额)');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const name = sanitizeString(args.name, 100);
|
|
791
|
+
const amount = validateAmount(args.amount);
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
name: name,
|
|
795
|
+
originalName: name,
|
|
796
|
+
amount: amount,
|
|
797
|
+
price: amount,
|
|
798
|
+
quantity: 1,
|
|
799
|
+
category: sanitizeString(args.category, 50) || '其他',
|
|
800
|
+
store: sanitizeString(args.store, 100) || '',
|
|
801
|
+
date: args.date,
|
|
802
|
+
transactionType: 'expense',
|
|
803
|
+
unit: '',
|
|
804
|
+
weight: '',
|
|
805
|
+
marketPrice: '',
|
|
806
|
+
note: sanitizeString(args.note, 500) || '',
|
|
807
|
+
rawInput: sanitizeString(args.rawInput, 1000) || '',
|
|
808
|
+
agentType: args.agentType || '',
|
|
809
|
+
apiProvider: args.apiProvider || '',
|
|
810
|
+
mcp_version: args.mcp_version || MCP_VERSION,
|
|
811
|
+
source: 'mcp_txt_expense',
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
case 'save_receipt': {
|
|
816
|
+
let items = args.items;
|
|
817
|
+
if (typeof items === 'string') {
|
|
818
|
+
try {
|
|
819
|
+
items = JSON.parse(items);
|
|
820
|
+
} catch (e) {
|
|
821
|
+
throw new Error('items 参数格式错误:必须是数组或 JSON 数组字符串');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!Array.isArray(items)) {
|
|
825
|
+
throw new Error('items 参数必须是数组');
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
items: items.map((item, index) => {
|
|
830
|
+
const weight = sanitizeString(item.weight, 50);
|
|
831
|
+
const amount = validateAmount(item.amount);
|
|
832
|
+
const marketPrice = sanitizeString(item.marketPrice, 50) || calculateMarketPrice(amount, weight);
|
|
833
|
+
|
|
834
|
+
return {
|
|
835
|
+
itemIndex: index,
|
|
836
|
+
name: sanitizeString(item.name, 100),
|
|
837
|
+
originalName: sanitizeString(item.originalName, 200) || sanitizeString(item.name, 100),
|
|
838
|
+
amount: amount,
|
|
839
|
+
category: sanitizeString(item.category, 50) || '其他',
|
|
840
|
+
subCategory: sanitizeString(item.subCategory, 50) || '',
|
|
841
|
+
transactionType: item.transactionType || 'expense',
|
|
842
|
+
price: validateAmount(item.price),
|
|
843
|
+
quantity: item.quantity ? String(item.quantity).substring(0, 20) : '1',
|
|
844
|
+
unit: sanitizeString(item.unit, 20),
|
|
845
|
+
weight: weight,
|
|
846
|
+
marketPrice: marketPrice,
|
|
847
|
+
note: sanitizeString(item.note, 500),
|
|
848
|
+
};
|
|
849
|
+
}),
|
|
850
|
+
store: sanitizeString(args.store, 100),
|
|
851
|
+
receiptNo: sanitizeString(args.receiptNo, 50),
|
|
852
|
+
totalAmount: validateAmount(args.totalAmount),
|
|
853
|
+
originalAmount: validateAmount(args.originalAmount),
|
|
854
|
+
discountAmount: validateAmount(args.discountAmount),
|
|
855
|
+
actualAmount: validateAmount(args.actualAmount),
|
|
856
|
+
paymentMethod: sanitizeString(args.paymentMethod, 50),
|
|
857
|
+
memberCardNo: sanitizeString(args.memberCardNo, 50),
|
|
858
|
+
currentPoints: parseInt(args.currentPoints) || 0,
|
|
859
|
+
totalPoints: parseInt(args.totalPoints) || 0,
|
|
860
|
+
date: args.date,
|
|
861
|
+
rawInput: sanitizeString(args.rawInput, 2000),
|
|
862
|
+
agentType: args.agentType || '',
|
|
863
|
+
apiProvider: args.apiProvider || '',
|
|
864
|
+
mcp_version: args.mcp_version || MCP_VERSION,
|
|
865
|
+
source: 'mcp_receipt',
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
case 'get_expenses':
|
|
870
|
+
return {
|
|
871
|
+
limit: Math.min(parseInt(args.limit) || 10, 100),
|
|
872
|
+
source: args.source,
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
case 'get_statistics':
|
|
876
|
+
return {
|
|
877
|
+
startDate: args.yearMonth ? `${args.yearMonth}-01` : undefined,
|
|
878
|
+
endDate: args.yearMonth ? getMonthEndDate(args.yearMonth) : undefined,
|
|
879
|
+
type: args.type,
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
case 'save_income':
|
|
883
|
+
return {
|
|
884
|
+
name: sanitizeString(args.name, 100),
|
|
885
|
+
amount: validateAmount(args.amount),
|
|
886
|
+
category: sanitizeString(args.category, 50) || '其他',
|
|
887
|
+
store: sanitizeString(args.store, 100),
|
|
888
|
+
date: args.date,
|
|
889
|
+
note: sanitizeString(args.note, 500),
|
|
890
|
+
transactionType: 'income',
|
|
891
|
+
rawInput: sanitizeString(args.rawInput, 1000),
|
|
892
|
+
agentType: args.agentType || '',
|
|
893
|
+
apiProvider: args.apiProvider || '',
|
|
894
|
+
mcp_version: args.mcp_version || MCP_VERSION,
|
|
895
|
+
source: 'mcp_txt_income',
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
default:
|
|
899
|
+
return args;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function getMonthEndDate(yearMonth) {
|
|
904
|
+
const [year, month] = yearMonth.split('-').map(Number);
|
|
905
|
+
const lastDay = new Date(year, month, 0).getDate();
|
|
906
|
+
return `${yearMonth}-${lastDay}`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// 授权状态管理
|
|
910
|
+
let authState = {
|
|
911
|
+
isAuthorized: false,
|
|
912
|
+
isWaiting: false,
|
|
913
|
+
userCode: null,
|
|
914
|
+
deviceCode: null,
|
|
915
|
+
error: null
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
async function main() {
|
|
919
|
+
initOAuthManager();
|
|
920
|
+
|
|
921
|
+
// 快速检查授权状态,不阻塞 Server 启动
|
|
922
|
+
// 使用 Promise.race 确保最多等待 2 秒
|
|
923
|
+
Promise.race([
|
|
924
|
+
checkAuthInBackground(),
|
|
925
|
+
new Promise(resolve => setTimeout(() => {
|
|
926
|
+
console.error('⏳ 授权检查超时,继续启动 Server');
|
|
927
|
+
resolve();
|
|
928
|
+
}, 2000))
|
|
929
|
+
]);
|
|
930
|
+
|
|
931
|
+
const transport = new StdioServerTransport();
|
|
932
|
+
await server.connect(transport);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// 后台检查授权状态
|
|
936
|
+
async function checkAuthInBackground() {
|
|
937
|
+
try {
|
|
938
|
+
const existingToken = await oauthManager.tokenStorage.load();
|
|
939
|
+
if (existingToken) {
|
|
940
|
+
// 已有token,直接使用
|
|
941
|
+
const validToken = await oauthManager.getValidToken();
|
|
942
|
+
cachedToken = validToken.accessToken;
|
|
943
|
+
tokenExpireTime = validToken.expiresAt;
|
|
944
|
+
authState.isAuthorized = true;
|
|
945
|
+
console.error('✅ 已加载保存的授权信息');
|
|
946
|
+
} else {
|
|
947
|
+
// 没有token,等待首次调用时再处理
|
|
948
|
+
console.error('⏳ 首次使用需要授权,将在调用工具时提示');
|
|
949
|
+
authState.isAuthorized = false;
|
|
950
|
+
}
|
|
951
|
+
} catch (err) {
|
|
952
|
+
console.error('⚠️ 授权检查失败:', err.message);
|
|
953
|
+
authState.isAuthorized = false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// 启动授权流程(获取验证码,启动后台轮询)
|
|
958
|
+
async function startAuthFlow() {
|
|
959
|
+
try {
|
|
960
|
+
// 先检查是否有保存的授权状态
|
|
961
|
+
const savedAuthState = await oauthManager.loadAuthState();
|
|
962
|
+
if (savedAuthState && savedAuthState.userCode) {
|
|
963
|
+
// 检查验证码是否过期
|
|
964
|
+
if (oauthManager.isAuthStateExpired(savedAuthState)) {
|
|
965
|
+
console.error('⏰ 验证码已过期,生成新的验证码');
|
|
966
|
+
await oauthManager.clearAuthState();
|
|
967
|
+
} else {
|
|
968
|
+
// 有保存的验证码,直接使用
|
|
969
|
+
authState.deviceCode = savedAuthState.deviceCode;
|
|
970
|
+
authState.userCode = savedAuthState.userCode;
|
|
971
|
+
authState.isWaiting = true;
|
|
972
|
+
console.error(`🔑 使用已保存的验证码:${savedAuthState.userCode}`);
|
|
973
|
+
// 启动后台轮询
|
|
974
|
+
pollForAuthInBackground(savedAuthState.deviceCode, 5000);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// 获取新的设备码
|
|
980
|
+
const deviceCodeRes = await oauthManager.requestDeviceCode(false);
|
|
981
|
+
authState.deviceCode = deviceCodeRes.deviceCode;
|
|
982
|
+
authState.userCode = deviceCodeRes.userCode;
|
|
983
|
+
authState.isWaiting = true;
|
|
984
|
+
|
|
985
|
+
// 保存授权状态(30分钟有效)
|
|
986
|
+
await oauthManager.saveAuthState({
|
|
987
|
+
deviceCode: deviceCodeRes.deviceCode,
|
|
988
|
+
userCode: deviceCodeRes.userCode,
|
|
989
|
+
expiresAt: Date.now() + deviceCodeRes.expiresIn * 1000
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
console.error(`🔑 验证码:${deviceCodeRes.userCode}`);
|
|
993
|
+
console.error('⏳ 请在小程序中完成授权,然后再次调用');
|
|
994
|
+
|
|
995
|
+
// 启动后台轮询(3分钟)
|
|
996
|
+
pollForAuthInBackground(deviceCodeRes.deviceCode, 5000);
|
|
997
|
+
|
|
998
|
+
} catch (err) {
|
|
999
|
+
authState.error = err.message;
|
|
1000
|
+
authState.isWaiting = false;
|
|
1001
|
+
console.error('❌ 启动授权失败:', err.message);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// 后台轮询授权状态(3分钟)
|
|
1006
|
+
async function pollForAuthInBackground(deviceCode, interval) {
|
|
1007
|
+
const maxAttempts = 36; // 3分钟(36次 × 5秒)
|
|
1008
|
+
let attempts = 0;
|
|
1009
|
+
|
|
1010
|
+
console.error('⏳ 等待用户授权中...(3分钟内有效)');
|
|
1011
|
+
|
|
1012
|
+
while (attempts < maxAttempts && authState.isWaiting) {
|
|
1013
|
+
attempts++;
|
|
1014
|
+
await delay(interval);
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
// 调用云函数检查授权状态
|
|
1018
|
+
const token = await oauthManager.pollForTokenOnce(deviceCode);
|
|
1019
|
+
|
|
1020
|
+
if (token) {
|
|
1021
|
+
// 授权成功
|
|
1022
|
+
cachedToken = token.accessToken;
|
|
1023
|
+
tokenExpireTime = token.expiresAt;
|
|
1024
|
+
authState.isAuthorized = true;
|
|
1025
|
+
authState.isWaiting = false;
|
|
1026
|
+
|
|
1027
|
+
// 保存token到文件
|
|
1028
|
+
await oauthManager.tokenStorage.save(token);
|
|
1029
|
+
// 清除授权状态
|
|
1030
|
+
await oauthManager.clearAuthState();
|
|
1031
|
+
|
|
1032
|
+
console.error('✅ 授权成功!下次调用将快速启动');
|
|
1033
|
+
|
|
1034
|
+
// 主动退出进程,让 mcporter 下次重新启动
|
|
1035
|
+
process.exit(0);
|
|
1036
|
+
}
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
if (err.message.includes('expired') || err.message.includes('invalid')) {
|
|
1039
|
+
authState.error = err.message;
|
|
1040
|
+
authState.isWaiting = false;
|
|
1041
|
+
console.error('❌ 授权失败:', err.message);
|
|
1042
|
+
process.exit(1);
|
|
1043
|
+
}
|
|
1044
|
+
// 继续轮询
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 3分钟超时
|
|
1049
|
+
authState.isWaiting = false;
|
|
1050
|
+
console.error('⏰ 授权超时,请再次调用获取新验证码');
|
|
1051
|
+
process.exit(0);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// 延迟函数
|
|
1055
|
+
function delay(ms) {
|
|
1056
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// 注意:使用 console.error,避免污染 stdout(MCP 协议通信使用 stdout)
|
|
1060
|
+
main().catch((err) => {
|
|
1061
|
+
console.error('❌ MCP Server 启动失败:', err.message);
|
|
1062
|
+
console.error('堆栈:', err.stack);
|
|
1063
|
+
process.exit(1);
|
|
1064
|
+
});
|