chaimi-bookkeeping-mcp 2.1.9 → 2.2.1

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 +33 -0
  2. package/oauth.js +89 -4
  3. package/package.json +1 -1
  4. package/server.js +102 -11
package/README.md CHANGED
@@ -111,8 +111,41 @@ A: `npm update -g chaimi-bookkeeping-mcp`
111
111
  - 邮箱:songyangx@gmail.com
112
112
  - npm 包地址:https://www.npmjs.com/package/chaimi-bookkeeping-mcp
113
113
 
114
+ ## 功能说明
115
+
116
+ ### 支持的记账类型
117
+
118
+ | 工具 | 用途 | 示例 |
119
+ |------|------|------|
120
+ | `save_expense` | 单商品支出 | "午餐 35 元" |
121
+ | `save_receipt` | 小票/多商品 | "超市购物:牛奶 10 元,面包 15 元" |
122
+ | `save_income` | **收入记录** | "工资到账 8000 元" |
123
+ | `get_expenses` | 查询记录 | "查看本月消费" |
124
+ | `get_statistics` | 消费统计 | "本月花了多少" |
125
+
126
+ ### 收入记账示例
127
+
128
+ 在 WorkBuddy/OpenClaw 中输入:
129
+
130
+ ```
131
+ 记录工资收入 8000 元
132
+ ```
133
+
134
+ 或:
135
+
136
+ ```
137
+ 今天收到奖金 3000
138
+ ```
139
+
140
+ AI 会自动调用 `save_income` 工具记录收入。
141
+
114
142
  ## 更新日志
115
143
 
144
+ ### v2.2.0 (2026-04-05)
145
+ - **新增收入记账功能** (`save_income`)
146
+ - 支持工资、奖金、红包、投资收益等收入类型
147
+ - 支出和收入统一存储,便于统计分析
148
+
116
149
  ### v2.1.0 (2026-04-03)
117
150
  - 升级 OAuth 2.0 Device Flow 认证
118
151
  - 支持 npm 全局安装
package/oauth.js CHANGED
@@ -69,10 +69,24 @@ class OAuthManager {
69
69
 
70
70
  console.log('✅ 获取设备码成功');
71
71
  console.log('');
72
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
73
- console.log(` 📱 验证码: ${deviceCodeRes.userCode}`);
74
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
75
- console.log('');
72
+
73
+ // 【柴米记账授权】格式化输出,确保Agent能识别
74
+ const authMessage = `
75
+ 【柴米记账授权】
76
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
77
+ 📱 验证码: ${deviceCodeRes.userCode}
78
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
79
+ 请在微信柴米记账小程序中输入此验证码完成授权
80
+ `;
81
+ console.log(authMessage);
82
+
83
+ // 保存授权状态,支持进程重启后复用
84
+ await this.saveAuthState({
85
+ deviceCode: deviceCodeRes.deviceCode,
86
+ userCode: deviceCodeRes.userCode,
87
+ timestamp: Date.now(),
88
+ status: 'pending'
89
+ });
76
90
 
77
91
  // 2. 根据环境选择交互方式
78
92
  if (useUrlScheme && deviceCodeRes.urlScheme) {
@@ -423,6 +437,77 @@ class OAuthManager {
423
437
  delay(ms) {
424
438
  return new Promise(resolve => setTimeout(resolve, ms));
425
439
  }
440
+
441
+ /**
442
+ * 保存授权状态到文件
443
+ * 支持进程重启后复用验证码
444
+ */
445
+ async saveAuthState(state) {
446
+ try {
447
+ const fs = require('fs').promises;
448
+ const path = require('path');
449
+ const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
450
+
451
+ // 确保目录存在
452
+ const dir = path.dirname(stateFile);
453
+ await fs.mkdir(dir, { recursive: true });
454
+
455
+ // 保存状态
456
+ await fs.writeFile(
457
+ stateFile,
458
+ JSON.stringify(state, null, 2),
459
+ { mode: 0o600 }
460
+ );
461
+ } catch (err) {
462
+ // 保存失败不影响主流程
463
+ console.warn('保存授权状态失败:', err.message);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * 加载授权状态
469
+ * 用于进程重启后恢复授权流程
470
+ */
471
+ async loadAuthState() {
472
+ try {
473
+ const fs = require('fs').promises;
474
+ const path = require('path');
475
+ const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
476
+
477
+ const data = await fs.readFile(stateFile, 'utf8');
478
+ return JSON.parse(data);
479
+ } catch (err) {
480
+ if (err.code === 'ENOENT') {
481
+ return null; // 文件不存在
482
+ }
483
+ console.warn('加载授权状态失败:', err.message);
484
+ return null;
485
+ }
486
+ }
487
+
488
+ /**
489
+ * 清除授权状态
490
+ * 授权成功或过期后清理
491
+ */
492
+ async clearAuthState() {
493
+ try {
494
+ const fs = require('fs').promises;
495
+ const path = require('path');
496
+ const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
497
+ await fs.unlink(stateFile);
498
+ } catch (err) {
499
+ // 文件不存在或其他错误,忽略
500
+ }
501
+ }
502
+
503
+ /**
504
+ * 检查授权状态是否过期
505
+ * 默认10分钟有效期
506
+ */
507
+ isAuthStateExpired(state, maxAge = 10 * 60 * 1000) {
508
+ if (!state || !state.timestamp) return true;
509
+ return Date.now() - state.timestamp > maxAge;
510
+ }
426
511
  }
427
512
 
428
513
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaimi-bookkeeping-mcp",
3
- "version": "2.1.9",
3
+ "version": "2.2.1",
4
4
  "description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -97,6 +97,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
97
97
  unit: { type: 'string', description: '单位(如:斤、个、瓶)' },
98
98
  category: { type: 'string', description: '分类(如:餐饮、食品、交通)' },
99
99
  store: { type: 'string', description: '商家名称' },
100
+ agentType: { type: 'string', description: 'Agent类型(如:workbuddy、claude、cursor)' },
101
+ apiProvider: { type: 'string', description: 'AI提供商' },
102
+ rawInput: { type: 'string', description: '用户原始输入' },
100
103
  },
101
104
  required: ['name', 'amount', 'price', 'quantity'],
102
105
  },
@@ -134,6 +137,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
134
137
  required: ['name', 'amount', 'price', 'quantity'],
135
138
  },
136
139
  },
140
+ agentType: { type: 'string', description: 'Agent类型(如:workbuddy、claude、cursor)' },
141
+ apiProvider: { type: 'string', description: 'AI提供商' },
142
+ rawInput: { type: 'string', description: '用户原始输入' },
137
143
  },
138
144
  required: ['store', 'items'],
139
145
  },
@@ -171,6 +177,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
171
177
  required: ['yearMonth'],
172
178
  },
173
179
  },
180
+ {
181
+ name: 'save_income',
182
+ description: '保存收入记录(工资、奖金、红包等)',
183
+ inputSchema: {
184
+ type: 'object',
185
+ properties: {
186
+ name: { type: 'string', description: '收入来源(如:工资、奖金、红包)' },
187
+ amount: { type: 'number', description: '收入金额' },
188
+ category: { type: 'string', description: '收入分类(如:工资、奖金、投资)' },
189
+ store: { type: 'string', description: '付款方(如:公司名称)' },
190
+ note: { type: 'string', description: '备注' },
191
+ agentType: { type: 'string', description: 'Agent类型(如:workbuddy、claude、cursor)' },
192
+ apiProvider: { type: 'string', description: 'AI提供商' },
193
+ rawInput: { type: 'string', description: '用户原始输入' },
194
+ },
195
+ required: ['name', 'amount'],
196
+ },
197
+ },
174
198
  ],
175
199
  };
176
200
  });
@@ -179,6 +203,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
179
203
  const toolMapping = {
180
204
  'save_expense': 'addExpense',
181
205
  'save_receipt': 'addReceipt',
206
+ 'save_income': 'addIncome',
182
207
  'get_expenses': 'getExpenses',
183
208
  'get_receipt_list': 'getExpenses',
184
209
  'get_statistics': 'getStatistics',
@@ -210,14 +235,30 @@ async function getToken() {
210
235
  cachedToken = oauthToken.accessToken;
211
236
  tokenExpireTime = oauthToken.expiresAt;
212
237
  lastRefreshTime = Date.now();
238
+ // 授权成功,清除授权状态
239
+ await oauthManager.clearAuthState();
213
240
  return cachedToken;
214
241
  } catch (err) {
215
- // OAuth Token 无效,尝试重新授权
216
- console.log('OAuth Token 无效,启动授权流程...');
242
+ // OAuth Token 无效,检查是否有未完成的授权
243
+ console.log('OAuth Token 无效,检查授权状态...');
244
+
245
+ // 检查是否有未完成的授权
246
+ const authState = await oauthManager.loadAuthState();
247
+ if (authState && !oauthManager.isAuthStateExpired(authState)) {
248
+ console.log(`检测到未完成的授权,验证码: ${authState.userCode}`);
249
+ console.log('请在微信柴米记账小程序中输入此验证码完成授权');
250
+ console.log('授权完成后,请重新调用工具');
251
+ throw new Error(`授权进行中,请使用验证码 ${authState.userCode} 完成授权`);
252
+ }
253
+
254
+ // 没有未完成的授权,启动新的授权流程
255
+ console.log('启动新的授权流程...');
217
256
  const newToken = await oauthManager.startAuthFlow();
218
257
  cachedToken = newToken.accessToken;
219
258
  tokenExpireTime = newToken.expiresAt;
220
259
  lastRefreshTime = Date.now();
260
+ // 授权成功,清除授权状态
261
+ await oauthManager.clearAuthState();
221
262
  return cachedToken;
222
263
  }
223
264
  }
@@ -270,6 +311,31 @@ async function callMcpHub(tool, params, token) {
270
311
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
271
312
  const { name, arguments: args } = request.params;
272
313
 
314
+ // 【强制日志】测试日志是否输出 - 写入文件
315
+ const fs = require('fs');
316
+
317
+ // 从环境变量获取 agentType 和 apiProvider
318
+ const agentTypeFromEnv = process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
319
+ const apiProviderFromEnv = process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
320
+
321
+ // 如果参数中没有,使用环境变量的值
322
+ if (!args.agentType && agentTypeFromEnv) {
323
+ args.agentType = agentTypeFromEnv;
324
+ }
325
+ if (!args.apiProvider && apiProviderFromEnv) {
326
+ args.apiProvider = apiProviderFromEnv;
327
+ }
328
+
329
+ const logMsg = `
330
+ [${new Date().toISOString()}] MCP 工具被调用
331
+ 工具名称: ${name}
332
+ 参数: ${JSON.stringify(args, null, 2)}
333
+ 环境变量 AGENT_TYPE: ${agentTypeFromEnv || '(未设置)'}
334
+ 环境变量 API_PROVIDER: ${apiProviderFromEnv || '(未设置)'}
335
+ `;
336
+ fs.appendFileSync('/tmp/mcp-debug.log', logMsg);
337
+ console.error(logMsg);
338
+
273
339
  try {
274
340
  // 获取 Token
275
341
  const token = await getToken();
@@ -290,14 +356,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
290
356
  let userMessage;
291
357
 
292
358
  // 【调试日志】打印原始传入参数
293
- console.log('\n========== MCP 参数调试日志 ==========');
294
- console.log('工具名称:', name);
295
- console.log('原始参数 agentType:', args.agentType || '(未传入)');
296
- console.log('原始参数 apiProvider:', args.apiProvider || '(未传入)');
297
- console.log('原始参数 rawInput:', args.rawInput ? args.rawInput.substring(0, 50) + '...' : '(未传入)');
298
- console.log('原始参数 llm_provider:', args.llm_provider || '(未传入)');
299
- console.log('原始参数 store:', args.store || '(未传入)');
300
- console.log('======================================\n');
359
+ console.error('\n========== MCP 参数调试日志 ==========');
360
+ console.error('工具名称:', name);
361
+ console.error('原始参数 agentType:', args.agentType || '(未传入)');
362
+ console.error('原始参数 apiProvider:', args.apiProvider || '(未传入)');
363
+ console.error('原始参数 rawInput:', args.rawInput ? args.rawInput.substring(0, 50) + '...' : '(未传入)');
364
+ console.error('原始参数 llm_provider:', args.llm_provider || '(未传入)');
365
+ console.error('原始参数 store:', args.store || '(未传入)');
366
+ console.error('======================================\n');
301
367
 
302
368
  // 根据工具类型选择处理流程
303
369
  switch (name) {
@@ -403,13 +469,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
403
469
  const toolName = toolMapping[name];
404
470
  const mcpParams = convertParams(name, processedArgs);
405
471
  result = await callMcpHub(toolName, mcpParams, token);
406
-
472
+
407
473
  if (result.success) {
408
474
  userMessage = `📈 统计查询成功`;
409
475
  }
410
476
  break;
411
477
  }
412
478
 
479
+ case 'save_income': {
480
+ // 收入记录直接调用 mcpHub
481
+ const mcpParams = convertParams('save_income', processedArgs);
482
+ result = await callMcpHub('addIncome', mcpParams, token);
483
+
484
+ // 格式化输出
485
+ if (result.success) {
486
+ userMessage = `✅ 收入记录成功\n\n| 来源 | 金额 | 分类 | 付款方 |\n|------|------|------|--------|\n| ${processedArgs.name} | ${processedArgs.amount}元 | ${processedArgs.category || '其他'} | ${processedArgs.store || '-'} |`;
487
+ }
488
+ break;
489
+ }
490
+
413
491
  default:
414
492
  throw new Error(`未知工具: ${name}`);
415
493
  }
@@ -583,6 +661,19 @@ function convertParams(toolName, args) {
583
661
  type: args.type,
584
662
  };
585
663
 
664
+ case 'save_income':
665
+ return {
666
+ name: sanitizeString(args.name, 100),
667
+ amount: validateAmount(args.amount),
668
+ category: sanitizeString(args.category, 50) || '其他',
669
+ store: sanitizeString(args.store, 100),
670
+ note: sanitizeString(args.note, 500),
671
+ transactionType: 'income',
672
+ rawInput: sanitizeString(args.rawInput, 1000),
673
+ agentType: args.agentType || '',
674
+ apiProvider: args.apiProvider || '',
675
+ };
676
+
586
677
  default:
587
678
  return args;
588
679
  }