chaimi-keep-mcp 3.1.47-beta.7 → 3.1.47

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 (3) hide show
  1. package/SKILL.md +25 -52
  2. package/package.json +1 -1
  3. package/server.js +152 -15
package/SKILL.md CHANGED
@@ -96,7 +96,7 @@ Agent 必须输出:
96
96
 
97
97
  **如果不执行此步骤:**
98
98
  - ❌ 直接调用 `save_expense` / `save_receipt` / `save_income` 会被拒绝
99
- - ❌ 返回错误:`SKILL_NOT_READ`
99
+ - ❌ 返回错误:没有读取 Skill 文件,请先调用 get_skill 工具
100
100
 
101
101
  **正确流程:**
102
102
  ```
@@ -195,6 +195,20 @@ get_statistics period="this_week"
195
195
 
196
196
  ## 工具详细说明
197
197
 
198
+ ### 时间戳规则(所有记账工具通用)
199
+
200
+ **要求:** 13 位毫秒级时间戳(北京时间 UTC+8)
201
+
202
+ **⚠️ 常见错误:** 不要直接把秒级时间戳 × 1000(会导致结尾是 000)
203
+
204
+ **示例:**
205
+ - 当前时间:`Date.now()`
206
+ - 指定时间:`new Date("2026-04-21T14:30:00+08:00").getTime()`
207
+
208
+ **校验:** 13 位数字,不能是未来时间
209
+
210
+ ---
211
+
198
212
  ### 文字记账(save_expense)
199
213
 
200
214
  **流程:**
@@ -202,20 +216,6 @@ get_statistics period="this_week"
202
216
  2. 使用 Prompt 解析用户输入,获取 JSON 结果
203
217
  3. 调用 `save_expense` 保存(传入所有解析结果字段)
204
218
 
205
- **时间处理规则:**
206
-
207
- Agent **直接使用当前时间**作为消费时间,无需进行时间语义解析。
208
-
209
- **最终传入的 `date` 参数格式:**毫秒级时间戳(13位数字)⚠️ **⚠️ ⚠️ 重要 ⚠️ ⚠️ ⚠️**:
210
-
211
- - **必须使用 date 命令计算时间戳,禁止手动填写或猜测**
212
- - **强制使用以下命令:**
213
- ```bash
214
- date -j -f "%Y-%m-%d %H:%M:%S" "YYYY-MM-DD HH:MM:SS" +%s000
215
- ```
216
- - 如果未使用 date 命令计算,服务端将拒绝保存
217
- - 示例格式:`YYYY-MM-DD HH:mm:ss` → `[毫秒时间戳]`(北京时间),禁止使用示例中的具体数字,必须根据当前实际时间重新计算
218
-
219
219
  ---
220
220
 
221
221
  ### 收入记账(save_income)
@@ -225,20 +225,6 @@ Agent **直接使用当前时间**作为消费时间,无需进行时间语义
225
225
  2. 使用 Prompt 解析用户输入,获取 JSON 结果
226
226
  3. 调用 `save_income` 保存(传入所有解析结果字段)
227
227
 
228
- **时间处理规则:**
229
-
230
- Agent **直接使用当前时间**作为消费时间,无需进行时间语义解析。
231
-
232
- **最终传入的 `date` 参数格式:**毫秒级时间戳(13位数字)⚠️ **⚠️ ⚠️ 重要 ⚠️ ⚠️ ⚠️**:
233
-
234
- - **必须使用 date 命令计算时间戳,禁止手动填写或猜测**
235
- - **强制使用以下命令:**
236
- ```bash
237
- date -j -f "%Y-%m-%d %H:%M:%S" "YYYY-MM-DD HH:MM:SS" +%s000
238
- ```
239
- - 如果未使用 date 命令计算,服务端将拒绝保存
240
- - 示例格式:`YYYY-MM-DD HH:mm:ss` → `[毫秒时间戳]`(北京时间),禁止使用示例中的具体数字,必须根据当前实际时间重新计算
241
-
242
228
  ---
243
229
 
244
230
  ### 小票记账(save_receipt)
@@ -249,20 +235,6 @@ Agent **直接使用当前时间**作为消费时间,无需进行时间语义
249
235
  3. **字段核对**:确保所有解析结果字段都传给 `save_receipt`
250
236
  4. 调用 `save_receipt` 保存
251
237
 
252
- **时间处理规则:**
253
-
254
- Agent **直接使用当前时间**作为消费时间,无需进行时间语义解析。
255
-
256
- **最终传入的 `date` 参数格式:**毫秒级时间戳(13位数字)⚠️ **⚠️ ⚠️ 重要 ⚠️ ⚠️ ⚠️**:
257
-
258
- - **必须使用 date 命令计算时间戳,禁止手动填写或猜测**
259
- - **强制使用以下命令:**
260
- ```bash
261
- date -j -f "%Y-%m-%d %H:%M:%S" "YYYY-MM-DD HH:MM:SS" +%s000
262
- ```
263
- - 如果未使用 date 命令计算,服务端将拒绝保存
264
- - 示例格式:`YYYY-MM-DD HH:mm:ss` → `[毫秒时间戳]`(北京时间),禁止使用示例中的具体数字,必须根据当前实际时间重新计算
265
-
266
238
  ---
267
239
 
268
240
  ## 确认规则
@@ -286,12 +258,12 @@ Agent **直接使用当前时间**作为消费时间,无需进行时间语义
286
258
 
287
259
  ## 回复规范(必须严格遵守)
288
260
 
289
- 记账成功后,回复格式:
261
+ 记账成功后,使用以下格式回复(代码块内每行一个换行):
290
262
 
291
263
  ```
292
- ✅ 【商品名/店名】¥【金额】
264
+ ✅ 【商品名/店名】¥【金额】
293
265
  已录入【柴米AI记账】
294
- 【建议增加】自定义消费的内容,让记账信息更全面准确。
266
+ 【补充信息,如购买地点日期】
295
267
  ✅ 【友好结束语】
296
268
 
297
269
  消费洞察:【洞察内容】(可选)
@@ -300,11 +272,11 @@ chaimi-keep-mcp: v【版本号】
300
272
  ```
301
273
 
302
274
  **友好结束语参考:**
303
- - 餐饮类:用餐愉快!🍚 / 好好吃饭哦~/(自定义正能力符合餐饮类的结束语)
304
- - 食品类:吃好喝好!🍎 /(自定义正能力符合食品类的结束语)
305
- - 交通类:出行顺利!🚗 /(自定义正能力符合交通类的结束语)
306
- - 购物类:买得开心!🛍️ /(自定义正能力符合购物类的结束语)
307
- - 收入类:入账顺利!💰 /(自定义正能力符合收入类的结束语)
275
+ - 餐饮类:用餐愉快!🍚 / 好好吃饭哦~
276
+ - 食品类:吃好喝好!🍎
277
+ - 交通类:出行顺利!🚗
278
+ - 购物类:买得开心!🛍️
279
+ - 收入类:入账顺利!💰
308
280
  - 通用:记账完成!继续保持~ ✨
309
281
 
310
282
  **消费洞察(可选):**
@@ -312,8 +284,9 @@ chaimi-keep-mcp: v【版本号】
312
284
  - 示例:这是你本周第19次餐饮消费,要不要看看本周餐饮花了多少钱?
313
285
 
314
286
  **真实示例:**
287
+
315
288
  ```
316
- ✅ 【牛肉】¥24
289
+ ✅ 【牛肉】¥24
317
290
  已录入【柴米AI记账】
318
291
  华润万家超市,2026年4月16日购买
319
292
  ✅ 好好吃饭哦~
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chaimi-keep-mcp",
3
- "version": "3.1.47-beta.7",
3
+ "version": "3.1.47",
4
4
  "description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -36,6 +36,93 @@ const { validateDate } = require('./utils/validators.js');
36
36
  // API 签名密钥(用于验证请求来自合法 MCP Server)
37
37
  const CHAIMI_API_SECRET = 'chaimi-mcp-secret-2024';
38
38
 
39
+ // ==================== 请求频率限制 ====================
40
+
41
+ // 限流配置
42
+ const RATE_LIMITS = {
43
+ // 记账工具:30 秒 3 次
44
+ write: {
45
+ tools: ['save_expense', 'save_receipt', 'save_income'],
46
+ maxRequests: 3,
47
+ windowMs: 30 * 1000, // 30 秒
48
+ },
49
+ // 查询工具:30 秒 2 次
50
+ read: {
51
+ tools: ['get_expenses', 'get_receipt_list', 'get_statistics', 'get_insights', 'export_data'],
52
+ maxRequests: 2,
53
+ windowMs: 30 * 1000, // 30 秒
54
+ },
55
+ // 配置工具:30 秒 3 次
56
+ config: {
57
+ tools: ['get_skill', 'get_parse_prompt', 'get_text_parse_prompt'],
58
+ maxRequests: 3,
59
+ windowMs: 30 * 1000, // 30 秒
60
+ },
61
+ // 反馈提交:30 秒 1 次
62
+ feedback: {
63
+ tools: ['submit_feedback'],
64
+ maxRequests: 1,
65
+ windowMs: 30 * 1000, // 30 秒
66
+ },
67
+ };
68
+
69
+ // 限流记录:{ toolName: [timestamp1, timestamp2, ...] }
70
+ const rateLimitRecords = {};
71
+
72
+ // 检查是否超过频率限制
73
+ function checkRateLimit(toolName) {
74
+ // 查找工具所属的限流组
75
+ let limitConfig = null;
76
+ let limitType = null;
77
+
78
+ for (const [type, config] of Object.entries(RATE_LIMITS)) {
79
+ if (config.tools.includes(toolName)) {
80
+ limitConfig = config;
81
+ limitType = type;
82
+ break;
83
+ }
84
+ }
85
+
86
+ // 如果工具不在限流列表中,直接通过
87
+ if (!limitConfig) {
88
+ return { allowed: true };
89
+ }
90
+
91
+ const now = Date.now();
92
+ const windowStart = now - limitConfig.windowMs;
93
+
94
+ // 初始化记录
95
+ if (!rateLimitRecords[toolName]) {
96
+ rateLimitRecords[toolName] = [];
97
+ }
98
+
99
+ // 清理过期记录
100
+ rateLimitRecords[toolName] = rateLimitRecords[toolName].filter(
101
+ timestamp => timestamp > windowStart
102
+ );
103
+
104
+ // 检查是否超过限制
105
+ if (rateLimitRecords[toolName].length >= limitConfig.maxRequests) {
106
+ const oldestRequest = rateLimitRecords[toolName][0];
107
+ const resetTime = oldestRequest + limitConfig.windowMs;
108
+ const waitSeconds = Math.ceil((resetTime - now) / 1000);
109
+
110
+ return {
111
+ allowed: false,
112
+ limitType,
113
+ maxRequests: limitConfig.maxRequests,
114
+ windowMs: limitConfig.windowMs,
115
+ resetTime,
116
+ waitSeconds,
117
+ };
118
+ }
119
+
120
+ // 记录本次请求
121
+ rateLimitRecords[toolName].push(now);
122
+
123
+ return { allowed: true };
124
+ }
125
+
39
126
  // URL 加密密钥
40
127
  const URL_ENCRYPT_KEY = 'chaimi-url-key-2024';
41
128
 
@@ -676,6 +763,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
676
763
  logSource: 'mcp'
677
764
  });
678
765
 
766
+ // 检查频率限制
767
+ const rateLimitResult = checkRateLimit(name);
768
+ if (!rateLimitResult.allowed) {
769
+ const waitTime = rateLimitResult.waitSeconds < 60
770
+ ? `${rateLimitResult.waitSeconds} 秒`
771
+ : `${Math.ceil(rateLimitResult.waitSeconds / 60)} 分钟`;
772
+
773
+ // 记录限流日志
774
+ logMcpCall({
775
+ traceId,
776
+ stage: 'rate_limited',
777
+ toolName: name,
778
+ error: 'RATE_LIMIT_EXCEEDED',
779
+ duration: Date.now() - startTime,
780
+ agentType,
781
+ apiProvider,
782
+ mcpVersion: MCP_VERSION,
783
+ osType: osInfo.osType,
784
+ osVersion: osInfo.osVersion,
785
+ timestamp: new Date().toISOString(),
786
+ logSource: 'mcp'
787
+ });
788
+
789
+ return {
790
+ content: [
791
+ {
792
+ type: 'text',
793
+ text: JSON.stringify({
794
+ success: false,
795
+ error: `请求过于频繁,30 秒内最多调用 ${rateLimitResult.maxRequests} 次,请 ${waitTime} 后再试`,
796
+ code: 429
797
+ })
798
+ }
799
+ ],
800
+ isError: true,
801
+ };
802
+ }
803
+
679
804
  // 强制检查:记账工具必须先调用 get_skill
680
805
  if (name === 'save_expense' || name === 'save_receipt' || name === 'save_income') {
681
806
  const now = Date.now();
@@ -1582,14 +1707,26 @@ function formatDateWithTimezone(dateInput) {
1582
1707
  }
1583
1708
 
1584
1709
  try {
1585
- // 如果是数字(时间戳),直接返回
1710
+ let timestamp;
1711
+
1712
+ // 如果是数字(时间戳)
1586
1713
  if (typeof dateInput === 'number') {
1587
- return dateInput;
1714
+ timestamp = dateInput;
1588
1715
  }
1589
-
1590
1716
  // 如果是字符串形式的数字(时间戳)
1591
- if (/^\d+$/.test(dateInput)) {
1592
- return parseInt(dateInput, 10);
1717
+ else if (/^\d+$/.test(dateInput)) {
1718
+ timestamp = parseInt(dateInput, 10);
1719
+ }
1720
+
1721
+ // 检测是否是假的时间戳(秒级转毫秒级,结尾是000)
1722
+ if (timestamp && timestamp % 1000 === 0 && String(timestamp).length === 13) {
1723
+ console.error(`⚠️ 警告:检测到可能错误的时间戳(秒级转毫秒级):${timestamp},使用当前时间替代`);
1724
+ return Date.now();
1725
+ }
1726
+
1727
+ // 如果是数字时间戳且没问题,直接返回
1728
+ if (timestamp) {
1729
+ return timestamp;
1593
1730
  }
1594
1731
 
1595
1732
  // 如果已经是 UTC 格式(以 Z 结尾),转换为时间戳
@@ -1613,12 +1750,12 @@ function formatDateWithTimezone(dateInput) {
1613
1750
  }
1614
1751
 
1615
1752
  // 其他情况,尝试解析为时间戳
1616
- const timestamp = new Date(dateInput).getTime();
1617
- if (isNaN(timestamp)) {
1753
+ const parsedTimestamp = new Date(dateInput).getTime();
1754
+ if (isNaN(parsedTimestamp)) {
1618
1755
  return Date.now(); // 解析失败,返回当前时间戳
1619
1756
  }
1620
1757
 
1621
- return timestamp;
1758
+ return parsedTimestamp;
1622
1759
  } catch (error) {
1623
1760
  console.error('日期格式化错误:', error);
1624
1761
  return Date.now();
@@ -1689,8 +1826,8 @@ function convertParams(toolName, args) {
1689
1826
  transactionType: 'expense',
1690
1827
  note: sanitizeString(args.note, 500) || '',
1691
1828
  rawInput: sanitizeString(args.rawInput, 1000) || '',
1692
- agentType: args.agentType || '',
1693
- apiProvider: args.apiProvider || '',
1829
+ agentType: sanitizeString(args.agentType, 50) || '',
1830
+ apiProvider: sanitizeString(args.apiProvider, 50) || '',
1694
1831
  mcpVersion: MCP_VERSION,
1695
1832
  source: 'mcp_txt_expense',
1696
1833
  };
@@ -1722,7 +1859,7 @@ function convertParams(toolName, args) {
1722
1859
  amount: amount,
1723
1860
  category: sanitizeString(item.category, 50) || '其他',
1724
1861
  subCategory: sanitizeString(item.subCategory, 50) || '',
1725
- transactionType: item.transactionType || 'expense',
1862
+ transactionType: sanitizeString(item.transactionType, 50) || 'expense',
1726
1863
  price: validateAmount(item.price),
1727
1864
  quantity: item.quantity ? String(item.quantity).substring(0, 20) : '1',
1728
1865
  unit: sanitizeString(item.unit, 20),
@@ -1743,8 +1880,8 @@ function convertParams(toolName, args) {
1743
1880
  totalPoints: parseInt(args.totalPoints) || 0,
1744
1881
  date: formatDateWithTimezone(args.date),
1745
1882
  rawInput: sanitizeString(args.rawInput, 2000),
1746
- agentType: args.agentType || '',
1747
- apiProvider: args.apiProvider || '',
1883
+ agentType: sanitizeString(args.agentType, 50) || '',
1884
+ apiProvider: sanitizeString(args.apiProvider, 50) || '',
1748
1885
  mcpVersion: MCP_VERSION,
1749
1886
  source: 'mcp_receipt',
1750
1887
  storeCategory: sanitizeString(args.storeCategory, 50) || '其他',
@@ -1775,8 +1912,8 @@ function convertParams(toolName, args) {
1775
1912
  note: sanitizeString(args.note, 500),
1776
1913
  transactionType: 'income',
1777
1914
  rawInput: sanitizeString(args.rawInput, 1000),
1778
- agentType: args.agentType || '',
1779
- apiProvider: args.apiProvider || '',
1915
+ agentType: sanitizeString(args.agentType, 50) || '',
1916
+ apiProvider: sanitizeString(args.apiProvider, 50) || '',
1780
1917
  mcpVersion: MCP_VERSION,
1781
1918
  source: 'mcp_txt_income',
1782
1919
  };