chaimi-bookkeeping-mcp 3.1.21 → 3.1.23

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.
Files changed (4) hide show
  1. package/README.md +20 -0
  2. package/oauth.js +72 -2
  3. package/package.json +1 -1
  4. package/server.js +134 -35
package/README.md CHANGED
@@ -8,6 +8,15 @@
8
8
  - macOS / Windows / Linux
9
9
  - 微信(已安装"柴米AI记账"小程序)
10
10
 
11
+ ## ⚠️ 重要提示(Agent开发者必读)
12
+
13
+ **如果您是 AI Agent 开发者,请注意:**
14
+
15
+ 1. **不要缓存工具定义**:MCP 工具定义可能会更新,请在每次调用前通过 `mcporter list` 获取最新定义
16
+ 2. **关注版本号**:工具描述中包含版本号(如【v3.1.21】),版本变化时请更新您的 Skill
17
+ 3. **save_receipt 工具要求**:items 数组中每个商品必须包含完整的4个字段:`name`、`amount`、`price`、`quantity`
18
+ 4. **自动填充字段**:`agentType`、`apiProvider`、`rawInput`、`mcp_version` 为自动填充字段,无需用户输入
19
+
11
20
  ## 快速安装
12
21
 
13
22
  ### 方式一:npm 全局安装(推荐)
@@ -134,6 +143,17 @@ AI 会自动调用 `save_income` 工具记录收入。
134
143
 
135
144
  ## 更新日志
136
145
 
146
+ ### v3.1.23 (2026-04-13)
147
+ - **安全** 新增请求签名验证,防止 Agent 直接 curl 访问云函数
148
+ - **安全** Token 加密存储(AES-256-CBC),绑定机器标识
149
+ - **安全** URL 加密存储,防止明文暴露
150
+ - **新增** SKILL.md 定义安全约束和触发规则
151
+ - **优化** 授权提示文案,增加 AUTH_JUST_COMPLETED 标记
152
+
153
+ ### v3.1.22 (2026-04-12)
154
+ - **修复** fillDefaults 金额计算逻辑(根据 price × quantity 计算 amount)
155
+ - **修复** 解决辣可可小票价格显示为0元的问题
156
+
137
157
  ### v3.1.21 (2026-04-12)
138
158
  - **修复** 版本号同步更新
139
159
 
package/oauth.js CHANGED
@@ -8,6 +8,7 @@ const fetch = require('node-fetch');
8
8
  const { exec } = require('child_process');
9
9
  const util = require('util');
10
10
  const execPromise = util.promisify(exec);
11
+ const crypto = require('crypto');
11
12
 
12
13
  class OAuthManager {
13
14
  constructor(config) {
@@ -375,20 +376,73 @@ class TokenStorage {
375
376
  }
376
377
  }
377
378
 
379
+ // 生成机器唯一标识(用于加密密钥)
380
+ function getMachineId() {
381
+ const os = require('os');
382
+ const interfaces = os.networkInterfaces();
383
+ let macAddress = '';
384
+
385
+ // 获取第一个非本地 MAC 地址
386
+ for (const name of Object.keys(interfaces)) {
387
+ for (const iface of interfaces[name]) {
388
+ if (iface.mac && iface.mac !== '00:00:00:00:00:00') {
389
+ macAddress = iface.mac;
390
+ break;
391
+ }
392
+ }
393
+ if (macAddress) break;
394
+ }
395
+
396
+ // 结合主机名和平台信息生成密钥
397
+ const keyMaterial = `${macAddress}-${os.hostname()}-${os.platform()}`;
398
+ return crypto.createHash('sha256').update(keyMaterial).digest('hex').substring(0, 32);
399
+ }
400
+
401
+ // 加密 Token
402
+ function encryptToken(token, key) {
403
+ const iv = crypto.randomBytes(16);
404
+ const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv);
405
+ let encrypted = cipher.update(JSON.stringify(token), 'utf8', 'hex');
406
+ encrypted += cipher.final('hex');
407
+ return iv.toString('hex') + ':' + encrypted;
408
+ }
409
+
410
+ // 解密 Token
411
+ function decryptToken(encryptedData, key) {
412
+ const parts = encryptedData.split(':');
413
+ const iv = Buffer.from(parts[0], 'hex');
414
+ const encrypted = parts[1];
415
+ const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key), iv);
416
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
417
+ decrypted += decipher.final('utf8');
418
+ return JSON.parse(decrypted);
419
+ }
420
+
378
421
  class FileTokenStorage extends TokenStorage {
379
422
  constructor(filePath) {
380
423
  super();
381
424
  this.filePath = filePath;
382
425
  this.fs = require('fs').promises;
383
426
  this.path = require('path');
427
+ this.key = getMachineId();
384
428
  }
385
429
 
386
430
  async save(token) {
387
431
  const dir = this.path.dirname(this.filePath);
388
432
  await this.fs.mkdir(dir, { recursive: true });
433
+
434
+ // 加密 Token 后保存
435
+ const encrypted = encryptToken(token, this.key);
436
+ const data = {
437
+ version: '2.0',
438
+ encrypted: encrypted,
439
+ algorithm: 'aes-256-cbc',
440
+ updatedAt: new Date().toISOString()
441
+ };
442
+
389
443
  await this.fs.writeFile(
390
444
  this.filePath,
391
- JSON.stringify(token, null, 2),
445
+ JSON.stringify(data, null, 2),
392
446
  { mode: 0o600 }
393
447
  );
394
448
  }
@@ -396,7 +450,23 @@ class FileTokenStorage extends TokenStorage {
396
450
  async load() {
397
451
  try {
398
452
  const data = await this.fs.readFile(this.filePath, 'utf8');
399
- return JSON.parse(data);
453
+ const parsed = JSON.parse(data);
454
+
455
+ // 向后兼容:检测旧版明文格式
456
+ if (!parsed.version || parsed.version === '1.0') {
457
+ console.error('检测到旧版 Token 格式,自动升级...');
458
+ // 返回明文数据,但下次保存时会自动加密
459
+ return {
460
+ accessToken: parsed.accessToken,
461
+ refreshToken: parsed.refreshToken,
462
+ expiresAt: parsed.expiresAt,
463
+ expiresIn: parsed.expiresIn,
464
+ tokenType: parsed.tokenType
465
+ };
466
+ }
467
+
468
+ // 新版加密格式,解密后返回
469
+ return decryptToken(parsed.encrypted, this.key);
400
470
  } catch (err) {
401
471
  if (err.code === 'ENOENT') {
402
472
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaimi-bookkeeping-mcp",
3
- "version": "3.1.21",
3
+ "version": "3.1.23",
4
4
  "description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -21,6 +21,7 @@ const {
21
21
  const path = require('path');
22
22
  const os = require('os');
23
23
  const fs = require('fs');
24
+ const crypto = require('crypto');
24
25
 
25
26
  // 读取 package.json 获取版本
26
27
  const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
@@ -29,10 +30,34 @@ const MCP_VERSION = packageJson.version;
29
30
  // 导入 OAuth 模块
30
31
  const { OAuthManager, FileTokenStorage } = require('./oauth.js');
31
32
 
32
- // 配置 - 微信云函数
33
- const MCP_HUB_URL = process.env.MCP_HUB_URL || 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpHub-mcp';
34
- const MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpPrompt';
35
- const MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpOAuth';
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);
36
61
 
37
62
  // Token 缓存
38
63
  let cachedToken = null;
@@ -118,41 +143,41 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
118
143
  },
119
144
  {
120
145
  name: 'save_receipt',
121
- description: '【图片小票专用】保存购物小票/发票/收据(支持单商品或多商品)。当用户提供图片形式的小票、发票时,无论商品数量多少(1条或多条),都必须使用此接口。可自动识别商家名称、商品明细、金额、优惠等信息。典型场景:超市购物小票、餐厅发票、线上订单截图、手写收据照片等',
146
+ description: '【v3.1.21】【图片小票专用】保存购物小票/发票/收据。⚠️ 重要:items数组中每个商品必须包含完整的4个字段:name(商品名称)、amount(金额=单价×数量)、price(单价)、quantity(数量)。示例:[{"name":"苹果","amount":5.5,"price":5.5,"quantity":1}]',
122
147
  inputSchema: {
123
148
  type: 'object',
124
149
  properties: {
125
- store: { type: 'string', description: '商家名称' },
150
+ store: { type: 'string', description: '商家名称(必填)' },
126
151
  date: { type: 'string', description: '消费时间(ISO 8601 格式,必须包含日期和时间,如:2026-04-10T13:06:21)' },
127
- totalAmount: { type: 'number', description: '总金额' },
128
- originalAmount: { type: 'number', description: '应付金额' },
152
+ totalAmount: { type: 'number', description: '总金额(所有商品amount之和)' },
153
+ originalAmount: { type: 'number', description: '应付金额(优惠前)' },
129
154
  discountAmount: { type: 'number', description: '优惠金额' },
130
- actualAmount: { type: 'number', description: '实付金额' },
131
- paymentMethod: { type: 'string', description: '支付方式' },
155
+ actualAmount: { type: 'number', description: '实付金额(优惠后)' },
156
+ paymentMethod: { type: 'string', description: '支付方式,如:微信支付、支付宝' },
132
157
  receiptNo: { type: 'string', description: '小票编号' },
133
158
  items: {
134
159
  type: 'array',
135
- description: '商品列表,支持两种格式:1) 直接数组:[{"name":"苹果","amount":5.5}] 2) JSON字符串:"[{\"name\":\"苹果\",\"amount\":5.5}]"(部分Agent如Claude Code可能传递JSON字符串)',
160
+ description: '【必填】商品列表,必须是数组格式。每个商品必须包含:nameamount、price、quantity。⚠️ 注意:amount=price×quantity',
136
161
  items: {
137
162
  type: 'object',
138
163
  properties: {
139
- name: { type: 'string', description: '商品名称' },
140
- originalName: { type: 'string', description: '原始商品名称' },
141
- price: { type: 'number', description: '单价' },
142
- quantity: { type: 'number', description: '数量' },
143
- unit: { type: 'string', description: '单位' },
144
- amount: { type: 'number', description: '金额' },
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' },
145
170
  weight: { type: 'string', description: '重量' },
146
- marketPrice: { type: 'string', description: '市场单价' },
147
- category: { type: 'string', description: '分类' },
171
+ marketPrice: { type: 'string', description: '市场单价(元/500g)' },
172
+ category: { type: 'string', description: '分类,如:餐饮、食品、购物' },
148
173
  },
149
174
  required: ['name', 'amount', 'price', 'quantity'],
150
175
  },
151
176
  },
152
- agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
153
- apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
154
- rawInput: { type: 'string', description: '【推荐自动填充】用户的原始输入内容,用于记录用户原始请求' },
155
- mcp_version: { type: 'string', description: 'MCP Server 版本号(自动填充)' },
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版本号' },
156
181
  },
157
182
  required: ['store', 'items'],
158
183
  },
@@ -278,14 +303,15 @@ async function getToken() {
278
303
  await oauthManager.tokenStorage.save(token);
279
304
  await oauthManager.clearAuthState();
280
305
  console.error('✅ 授权成功!可以继续使用记账功能');
281
- return cachedToken;
306
+ // 返回特殊标记,让上层知道这是刚完成授权的情况
307
+ throw new Error(`AUTH_JUST_COMPLETED:${cachedToken}`);
282
308
  }
283
309
  // 用户还未授权,返回同一个验证码
284
310
  authState.deviceCode = savedAuthState.deviceCode;
285
311
  authState.userCode = savedAuthState.userCode;
286
312
  throw new Error(`NEED_AUTH:${savedAuthState.userCode}`);
287
313
  } catch (err) {
288
- if (err.message.startsWith('NEED_AUTH:')) {
314
+ if (err.message.startsWith('NEED_AUTH:') || err.message.startsWith('AUTH_JUST_COMPLETED:')) {
289
315
  throw err;
290
316
  }
291
317
  // 其他错误,继续获取新的验证码
@@ -348,18 +374,33 @@ async function callMcpPrompt(tool, params, token) {
348
374
  return await response.json();
349
375
  }
350
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
+
351
386
  // 调用 mcpHub 云函数
352
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
+
353
395
  const response = await fetch(MCP_HUB_URL, {
354
396
  method: 'POST',
355
397
  headers: {
356
398
  'Content-Type': 'application/json',
357
399
  'Authorization': `Bearer ${token}`,
400
+ 'X-Chaimi-Signature': signature,
401
+ 'X-Chaimi-Timestamp': timestamp,
358
402
  },
359
- body: JSON.stringify({
360
- tool: tool,
361
- params: params,
362
- }),
403
+ body: JSON.stringify(body),
363
404
  });
364
405
 
365
406
  if (!response.ok) {
@@ -418,6 +459,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
418
459
  }
419
460
 
420
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
+
421
488
  // 1. 调用 parseReceipt 重新提取小票信息(覆盖 Agent 传的数据)
422
489
  if (processedArgs.rawInput) {
423
490
  const parseResult = await callMcpPrompt(
@@ -592,15 +659,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
592
659
  type: 'text',
593
660
  text: `🔐 首次使用需要授权
594
661
 
595
- 验证码:**${userCode}**
662
+ ━━━━━━━━━━━━━━
663
+ 📱 验证码:**${userCode}**
664
+ ━━━━━━━━━━━━━━
596
665
 
597
666
  请在"柴米AI记账"小程序中完成授权:
598
- 1. 打开微信小程序"柴米AI记账"
599
- 2. 点击"我的" → "🤖 Agent 授权"
600
- 3. 输入验证码:**${userCode}**
601
- 4. 点击确认授权
602
667
 
603
- 授权完成后,请再次发送记账指令,我将自动继续。`,
668
+ 1️⃣ 打开微信小程序"柴米AI记账"
669
+ 2️⃣ 点击"我的" → "🤖 Agent 授权"
670
+ 3️⃣ 输入验证码:**${userCode}**
671
+ 4️⃣ 点击确认授权
672
+
673
+ ⚠️ 重要提示:
674
+ • 授权完成后,MCP Server 会自动退出(正常现象)
675
+ • 请重新发送您的记账指令,我才能为您完成记账
676
+ • 首次授权后,后续记账将无需再次授权
677
+
678
+ ⏳ 验证码有效期:30分钟`,
604
679
  },
605
680
  ],
606
681
  };
@@ -616,6 +691,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
616
691
  }
617
692
  }
618
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
+
619
718
  return {
620
719
  content: [
621
720
  {