chaimi-keep-mcp 3.1.44 → 3.1.45-beta.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.
- package/package.json +2 -1
- package/server.js +331 -90
- package/utils/validators.js +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chaimi-keep-mcp",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.45-beta.1",
|
|
4
4
|
"description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
+
"utils/",
|
|
11
12
|
"server.js",
|
|
12
13
|
"oauth.js",
|
|
13
14
|
"SKILL.md",
|
package/server.js
CHANGED
|
@@ -5,11 +5,27 @@
|
|
|
5
5
|
* 支持 Claude Desktop、Cursor、WorkBuddy、OpenClaw
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// 加载环境变量 - 根据 NODE_ENV 自动选择配置文件
|
|
9
|
+
const NODE_ENV = process.env.NODE_ENV || 'development';
|
|
10
|
+
const envFile = `.env.${NODE_ENV}`;
|
|
11
|
+
|
|
9
12
|
try {
|
|
10
|
-
require('dotenv')
|
|
13
|
+
const dotenv = require('dotenv');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const envPath = path.join(__dirname, envFile);
|
|
18
|
+
|
|
19
|
+
if (fs.existsSync(envPath)) {
|
|
20
|
+
dotenv.config({ path: envPath });
|
|
21
|
+
console.log(`✅ 已加载 ${NODE_ENV} 环境配置`);
|
|
22
|
+
} else {
|
|
23
|
+
console.warn(`⚠️ 警告:${envFile} 文件不存在,请运行 setup.js 生成配置`);
|
|
24
|
+
// 尝试加载默认 .env 文件
|
|
25
|
+
dotenv.config();
|
|
26
|
+
}
|
|
11
27
|
} catch (e) {
|
|
12
|
-
|
|
28
|
+
console.warn('⚠️ 警告:dotenv 未安装或加载失败');
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
@@ -30,11 +46,106 @@ const MCP_VERSION = packageJson.version;
|
|
|
30
46
|
// 导入 OAuth 模块
|
|
31
47
|
const { OAuthManager, FileTokenStorage } = require('./oauth.js');
|
|
32
48
|
|
|
49
|
+
// 导入工具函数
|
|
50
|
+
const { validateDate } = require('./utils/validators.js');
|
|
51
|
+
|
|
52
|
+
// ==================== 密钥管理 ====================
|
|
53
|
+
|
|
54
|
+
// Token 加密密钥文件路径
|
|
55
|
+
const SECRET_KEY_FILE = path.join(__dirname, '.mcp-secret-key');
|
|
56
|
+
|
|
57
|
+
// 自动生成或加载 Token 加密密钥
|
|
58
|
+
function getOrCreateSecretKey() {
|
|
59
|
+
// 1. 优先使用环境变量(高级用户可以手动配置)
|
|
60
|
+
if (process.env.MCP_SECRET_KEY) {
|
|
61
|
+
console.error('✅ 使用环境变量中的 Token 加密密钥');
|
|
62
|
+
return process.env.MCP_SECRET_KEY;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. 尝试从文件加载
|
|
66
|
+
if (fs.existsSync(SECRET_KEY_FILE)) {
|
|
67
|
+
const key = fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim();
|
|
68
|
+
console.error('✅ 从本地文件加载 Token 加密密钥');
|
|
69
|
+
return key;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. 自动生成新密钥(64 字符,256 位)
|
|
73
|
+
const newKey = crypto.randomBytes(32).toString('hex');
|
|
74
|
+
fs.writeFileSync(SECRET_KEY_FILE, newKey, { mode: 0o600 }); // 只有当前用户可读写
|
|
75
|
+
console.error('✅ 已自动生成 Token 加密密钥,保存在 .mcp-secret-key 文件中');
|
|
76
|
+
console.error('⚠️ 请勿删除此文件,否则已授权的 Token 将无法解密');
|
|
77
|
+
return newKey;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Token 加密密钥(自动生成或加载)
|
|
81
|
+
const SECRET_KEY = getOrCreateSecretKey();
|
|
82
|
+
|
|
33
83
|
// API 签名密钥(用于验证请求来自合法 MCP Server)
|
|
34
|
-
const CHAIMI_API_SECRET =
|
|
84
|
+
const CHAIMI_API_SECRET = process.env.CHAIMI_API_SECRET;
|
|
35
85
|
|
|
36
86
|
// URL 加密密钥
|
|
37
|
-
const URL_ENCRYPT_KEY =
|
|
87
|
+
const URL_ENCRYPT_KEY = process.env.URL_ENCRYPT_KEY;
|
|
88
|
+
|
|
89
|
+
// 验证必需的环境变量
|
|
90
|
+
if (!CHAIMI_API_SECRET || !URL_ENCRYPT_KEY) {
|
|
91
|
+
console.error('❌ 错误:缺少必需的环境变量');
|
|
92
|
+
console.error(' 请运行以下命令生成配置:');
|
|
93
|
+
console.error(' node setup.js');
|
|
94
|
+
console.error('');
|
|
95
|
+
console.error(' 然后使用以下命令启动:');
|
|
96
|
+
console.error(' NODE_ENV=development node server.js # 开发环境');
|
|
97
|
+
console.error(' NODE_ENV=production node server.js # 生产环境');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ==================== 加密/解密函数 ====================
|
|
102
|
+
|
|
103
|
+
// AES-256-GCM 加密(用于 Token)
|
|
104
|
+
function encrypt(text, secretKey) {
|
|
105
|
+
const iv = crypto.randomBytes(16); // 随机初始化向量
|
|
106
|
+
const cipher = crypto.createCipheriv(
|
|
107
|
+
'aes-256-gcm',
|
|
108
|
+
Buffer.from(secretKey, 'hex'),
|
|
109
|
+
iv
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
113
|
+
encrypted += cipher.final('hex');
|
|
114
|
+
|
|
115
|
+
const authTag = cipher.getAuthTag(); // 认证标签(防篡改)
|
|
116
|
+
|
|
117
|
+
// 返回格式:iv:authTag:encrypted
|
|
118
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// AES-256-GCM 解密(用于 Token)
|
|
122
|
+
function decrypt(encryptedText, secretKey) {
|
|
123
|
+
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
|
124
|
+
|
|
125
|
+
const decipher = crypto.createDecipheriv(
|
|
126
|
+
'aes-256-gcm',
|
|
127
|
+
Buffer.from(secretKey, 'hex'),
|
|
128
|
+
Buffer.from(ivHex, 'hex')
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
132
|
+
|
|
133
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
134
|
+
decrypted += decipher.final('utf8');
|
|
135
|
+
|
|
136
|
+
return decrypted;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 旧版 XOR 解密(向后兼容)
|
|
140
|
+
function decryptOldXOR(encrypted, key) {
|
|
141
|
+
const hex = encrypted.replace('enc:v1:', '');
|
|
142
|
+
let result = '';
|
|
143
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
144
|
+
const byte = parseInt(hex.substr(i, 2), 16);
|
|
145
|
+
result += String.fromCharCode(byte ^ key.charCodeAt((i / 2) % key.length));
|
|
146
|
+
}
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
38
149
|
|
|
39
150
|
// 加密的云函数 URL
|
|
40
151
|
const ENCRYPTED_URLS = {
|
|
@@ -43,15 +154,30 @@ const ENCRYPTED_URLS = {
|
|
|
43
154
|
prompt: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f5713381306001959'
|
|
44
155
|
};
|
|
45
156
|
|
|
46
|
-
// 解密 URL
|
|
157
|
+
// 解密 URL 函数(兼容新旧版本)
|
|
47
158
|
function decryptUrl(encrypted, key) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
159
|
+
// 新版本(AES-256-GCM)
|
|
160
|
+
if (encrypted.startsWith('enc:v2:')) {
|
|
161
|
+
const parts = encrypted.replace('enc:v2:', '').split(':');
|
|
162
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
163
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
164
|
+
const ciphertext = parts[2];
|
|
165
|
+
|
|
166
|
+
const keyBuffer = crypto.scryptSync(key, 'salt', 32);
|
|
167
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, iv);
|
|
168
|
+
decipher.setAuthTag(authTag);
|
|
169
|
+
|
|
170
|
+
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
|
|
171
|
+
decrypted += decipher.final('utf8');
|
|
172
|
+
return decrypted;
|
|
53
173
|
}
|
|
54
|
-
|
|
174
|
+
|
|
175
|
+
// 旧版本(XOR)- 向后兼容
|
|
176
|
+
if (encrypted.startsWith('enc:v1:')) {
|
|
177
|
+
return decryptOldXOR(encrypted, key);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new Error('不支持的加密格式');
|
|
55
181
|
}
|
|
56
182
|
|
|
57
183
|
// 配置 - 微信云函数(运行时解密)
|
|
@@ -59,8 +185,8 @@ const MCP_HUB_URL = process.env.MCP_HUB_URL || decryptUrl(ENCRYPTED_URLS.hub, UR
|
|
|
59
185
|
const MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || decryptUrl(ENCRYPTED_URLS.prompt, URL_ENCRYPT_KEY);
|
|
60
186
|
const MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || decryptUrl(ENCRYPTED_URLS.oauth, URL_ENCRYPT_KEY);
|
|
61
187
|
|
|
62
|
-
// Token
|
|
63
|
-
let
|
|
188
|
+
// Token 缓存(加密存储)
|
|
189
|
+
let cachedEncryptedToken = null;
|
|
64
190
|
let tokenExpireTime = 0;
|
|
65
191
|
|
|
66
192
|
// OAuth 管理器实例
|
|
@@ -108,8 +234,8 @@ async function initOAuthManager() {
|
|
|
108
234
|
// 检查 Token 是否过期(预留5分钟缓冲)
|
|
109
235
|
const expiresAt = new Date(existingToken.expiresAt).getTime();
|
|
110
236
|
if (expiresAt > Date.now() + 5 * 60 * 1000) {
|
|
111
|
-
// Token
|
|
112
|
-
|
|
237
|
+
// Token 有效,加密后缓存
|
|
238
|
+
cachedEncryptedToken = encrypt(existingToken.accessToken, SECRET_KEY);
|
|
113
239
|
tokenExpireTime = expiresAt;
|
|
114
240
|
authState.isAuthorized = true;
|
|
115
241
|
console.error('✅ 已从文件加载有效 Token,无需重新授权');
|
|
@@ -371,15 +497,103 @@ const toolMapping = {
|
|
|
371
497
|
'export_data': 'exportData',
|
|
372
498
|
};
|
|
373
499
|
|
|
500
|
+
// ==================== 请求频率限制 ====================
|
|
501
|
+
|
|
502
|
+
// 限流配置
|
|
503
|
+
const RATE_LIMITS = {
|
|
504
|
+
// 记账工具:1 分钟 10 次
|
|
505
|
+
write: {
|
|
506
|
+
tools: ['save_expense', 'save_receipt', 'save_income'],
|
|
507
|
+
maxRequests: 10,
|
|
508
|
+
windowMs: 60 * 1000, // 1 分钟
|
|
509
|
+
},
|
|
510
|
+
// 查询工具:1 天 10 次
|
|
511
|
+
read: {
|
|
512
|
+
tools: ['get_expenses', 'get_receipt_list', 'get_statistics', 'get_insights', 'export_data'],
|
|
513
|
+
maxRequests: 10,
|
|
514
|
+
windowMs: 24 * 60 * 60 * 1000, // 1 天
|
|
515
|
+
},
|
|
516
|
+
// 配置读取工具:1 分钟 10 次
|
|
517
|
+
config: {
|
|
518
|
+
tools: ['get_skill', 'get_parse_prompt', 'get_text_parse_prompt'],
|
|
519
|
+
maxRequests: 10,
|
|
520
|
+
windowMs: 60 * 1000, // 1 分钟
|
|
521
|
+
},
|
|
522
|
+
// 反馈提交:1 天 5 次
|
|
523
|
+
feedback: {
|
|
524
|
+
tools: ['submit_feedback'],
|
|
525
|
+
maxRequests: 5,
|
|
526
|
+
windowMs: 24 * 60 * 60 * 1000, // 1 天
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// 限流记录:{ toolName: [timestamp1, timestamp2, ...] }
|
|
531
|
+
const rateLimitRecords = {};
|
|
532
|
+
|
|
533
|
+
// 检查是否超过频率限制
|
|
534
|
+
function checkRateLimit(toolName) {
|
|
535
|
+
// 查找工具所属的限流组
|
|
536
|
+
let limitConfig = null;
|
|
537
|
+
let limitType = null;
|
|
538
|
+
|
|
539
|
+
for (const [type, config] of Object.entries(RATE_LIMITS)) {
|
|
540
|
+
if (config.tools.includes(toolName)) {
|
|
541
|
+
limitConfig = config;
|
|
542
|
+
limitType = type;
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 如果工具不在限流列表中,直接通过
|
|
548
|
+
if (!limitConfig) {
|
|
549
|
+
return { allowed: true };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
const windowStart = now - limitConfig.windowMs;
|
|
554
|
+
|
|
555
|
+
// 初始化记录
|
|
556
|
+
if (!rateLimitRecords[toolName]) {
|
|
557
|
+
rateLimitRecords[toolName] = [];
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 清理过期记录
|
|
561
|
+
rateLimitRecords[toolName] = rateLimitRecords[toolName].filter(
|
|
562
|
+
timestamp => timestamp > windowStart
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// 检查是否超过限制
|
|
566
|
+
if (rateLimitRecords[toolName].length >= limitConfig.maxRequests) {
|
|
567
|
+
const oldestRequest = rateLimitRecords[toolName][0];
|
|
568
|
+
const resetTime = oldestRequest + limitConfig.windowMs;
|
|
569
|
+
const waitSeconds = Math.ceil((resetTime - now) / 1000);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
allowed: false,
|
|
573
|
+
limitType,
|
|
574
|
+
maxRequests: limitConfig.maxRequests,
|
|
575
|
+
windowMs: limitConfig.windowMs,
|
|
576
|
+
resetTime,
|
|
577
|
+
waitSeconds,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 记录本次请求
|
|
582
|
+
rateLimitRecords[toolName].push(now);
|
|
583
|
+
|
|
584
|
+
return { allowed: true };
|
|
585
|
+
}
|
|
586
|
+
|
|
374
587
|
// 获取或刷新 Token(OAuth 2.0 Device Flow)
|
|
375
588
|
const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000;
|
|
376
589
|
let lastRefreshTime = 0;
|
|
377
590
|
|
|
378
591
|
async function getToken() {
|
|
379
592
|
// 步骤1:检查内存缓存 Token(最快路径)
|
|
380
|
-
if (
|
|
593
|
+
if (cachedEncryptedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
|
|
381
594
|
if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
|
|
382
|
-
|
|
595
|
+
// 解密后返回
|
|
596
|
+
return decrypt(cachedEncryptedToken, SECRET_KEY);
|
|
383
597
|
}
|
|
384
598
|
}
|
|
385
599
|
|
|
@@ -390,12 +604,14 @@ async function getToken() {
|
|
|
390
604
|
// 步骤2:尝试从文件加载并刷新 Token
|
|
391
605
|
try {
|
|
392
606
|
const oauthToken = await oauthManager.getValidToken();
|
|
393
|
-
|
|
607
|
+
// 加密后缓存
|
|
608
|
+
cachedEncryptedToken = encrypt(oauthToken.accessToken, SECRET_KEY);
|
|
394
609
|
tokenExpireTime = oauthToken.expiresAt;
|
|
395
610
|
lastRefreshTime = Date.now();
|
|
396
611
|
authState.isAuthorized = true;
|
|
397
612
|
await oauthManager.clearAuthState();
|
|
398
|
-
|
|
613
|
+
// 解密后返回
|
|
614
|
+
return decrypt(cachedEncryptedToken, SECRET_KEY);
|
|
399
615
|
} catch (err) {
|
|
400
616
|
// Token 无效,需要重新授权
|
|
401
617
|
console.error('⏰ Token 无效,需要重新授权:', err.message);
|
|
@@ -666,7 +882,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
666
882
|
const startTime = Date.now();
|
|
667
883
|
const traceId = generateTraceId();
|
|
668
884
|
const osInfo = getOSInfo();
|
|
669
|
-
|
|
885
|
+
|
|
670
886
|
// 提取元数据字段
|
|
671
887
|
const agentType = args.agentType || process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
|
|
672
888
|
const apiProvider = args.apiProvider || process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
|
|
@@ -686,6 +902,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
686
902
|
logSource: 'mcp'
|
|
687
903
|
});
|
|
688
904
|
|
|
905
|
+
// 检查频率限制
|
|
906
|
+
const rateLimitResult = checkRateLimit(name);
|
|
907
|
+
if (!rateLimitResult.allowed) {
|
|
908
|
+
const windowName = rateLimitResult.windowMs === 60 * 1000 ? '1 分钟' : '1 天';
|
|
909
|
+
const waitTime = rateLimitResult.waitSeconds < 60
|
|
910
|
+
? `${rateLimitResult.waitSeconds} 秒`
|
|
911
|
+
: `${Math.ceil(rateLimitResult.waitSeconds / 60)} 分钟`;
|
|
912
|
+
|
|
913
|
+
// 记录限流
|
|
914
|
+
logMcpCall({
|
|
915
|
+
traceId,
|
|
916
|
+
stage: 'rate_limited',
|
|
917
|
+
toolName: name,
|
|
918
|
+
error: 'RATE_LIMIT_EXCEEDED',
|
|
919
|
+
duration: Date.now() - startTime,
|
|
920
|
+
agentType,
|
|
921
|
+
apiProvider,
|
|
922
|
+
mcpVersion: MCP_VERSION,
|
|
923
|
+
osType: osInfo.osType,
|
|
924
|
+
osVersion: osInfo.osVersion,
|
|
925
|
+
timestamp: new Date().toISOString(),
|
|
926
|
+
logSource: 'mcp'
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
content: [
|
|
931
|
+
{
|
|
932
|
+
type: 'text',
|
|
933
|
+
text: JSON.stringify({
|
|
934
|
+
success: false,
|
|
935
|
+
error: `请求过于频繁,${windowName}内最多调用 ${rateLimitResult.maxRequests} 次,请 ${waitTime} 后再试`,
|
|
936
|
+
code: 429
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
]
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
689
943
|
// 强制检查:记账工具必须先调用 get_skill
|
|
690
944
|
if (name === 'save_expense' || name === 'save_receipt' || name === 'save_income') {
|
|
691
945
|
const now = Date.now();
|
|
@@ -739,7 +993,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
739
993
|
if (processedArgs.items && typeof processedArgs.items === 'string') {
|
|
740
994
|
try {
|
|
741
995
|
processedArgs.items = JSON.parse(processedArgs.items);
|
|
996
|
+
|
|
997
|
+
// 验证解析后的类型
|
|
998
|
+
if (!Array.isArray(processedArgs.items)) {
|
|
999
|
+
throw new Error('items 必须是数组');
|
|
1000
|
+
}
|
|
742
1001
|
} catch (e) {
|
|
1002
|
+
throw new Error(`items 参数格式错误:${e.message}`);
|
|
743
1003
|
}
|
|
744
1004
|
}
|
|
745
1005
|
|
|
@@ -805,41 +1065,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
805
1065
|
userMessage = '❌ 记账失败:金额必须是正数';
|
|
806
1066
|
break;
|
|
807
1067
|
}
|
|
808
|
-
|
|
809
|
-
// P1: 日期合理性检查
|
|
810
|
-
if (processedArgs.date) {
|
|
811
|
-
const inputDate = new Date(processedArgs.date);
|
|
812
|
-
const now = new Date();
|
|
813
|
-
const sixtyYearsAgo = new Date();
|
|
814
|
-
sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
|
|
815
|
-
|
|
816
|
-
if (isNaN(inputDate.getTime())) {
|
|
817
|
-
result = {
|
|
818
|
-
success: false,
|
|
819
|
-
error: '日期格式无效',
|
|
820
|
-
code: 400
|
|
821
|
-
};
|
|
822
|
-
userMessage = '❌ 记账失败:日期格式无效,请使用毫秒级时间戳(13位数字,如:xxxxxxxxxxxxx)';
|
|
823
|
-
break;
|
|
824
|
-
}
|
|
825
1068
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
code: 400
|
|
831
|
-
};
|
|
832
|
-
userMessage = '❌ 记账失败:日期不能是未来时间';
|
|
833
|
-
break;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
if (inputDate < sixtyYearsAgo) {
|
|
1069
|
+
// 日期合理性检查
|
|
1070
|
+
if (processedArgs.date) {
|
|
1071
|
+
const validation = validateDate(processedArgs.date, '消费时间');
|
|
1072
|
+
if (!validation.valid) {
|
|
837
1073
|
result = {
|
|
838
1074
|
success: false,
|
|
839
|
-
error:
|
|
1075
|
+
error: validation.message,
|
|
840
1076
|
code: 400
|
|
841
1077
|
};
|
|
842
|
-
userMessage =
|
|
1078
|
+
userMessage = `❌ 记账失败:${validation.message}`;
|
|
843
1079
|
break;
|
|
844
1080
|
}
|
|
845
1081
|
}
|
|
@@ -968,41 +1204,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
968
1204
|
userMessage = `❌ 保存失败\n\n错误:商品数据格式不完整\n\n${invalidItems.join('\n')}\n\n💡 解决方案:\n1. 请更新您的柴米记账 MCP Skill\n2. 确保每个商品包含完整的5个字段:name, amount, price, quantity, category\n3. category 是记账的基本信息,不能为空`;
|
|
969
1205
|
break;
|
|
970
1206
|
}
|
|
971
|
-
|
|
972
|
-
//
|
|
1207
|
+
|
|
1208
|
+
// 日期合理性检查
|
|
973
1209
|
if (processedArgs.date) {
|
|
974
|
-
const
|
|
975
|
-
|
|
976
|
-
const sixtyYearsAgo = new Date();
|
|
977
|
-
sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
|
|
978
|
-
|
|
979
|
-
if (isNaN(inputDate.getTime())) {
|
|
1210
|
+
const validation = validateDate(processedArgs.date, '小票日期');
|
|
1211
|
+
if (!validation.valid) {
|
|
980
1212
|
result = {
|
|
981
1213
|
success: false,
|
|
982
|
-
error:
|
|
1214
|
+
error: validation.message,
|
|
983
1215
|
code: 400
|
|
984
1216
|
};
|
|
985
|
-
userMessage =
|
|
986
|
-
break;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
if (inputDate > now) {
|
|
990
|
-
result = {
|
|
991
|
-
success: false,
|
|
992
|
-
error: '日期不能是未来时间',
|
|
993
|
-
code: 400
|
|
994
|
-
};
|
|
995
|
-
userMessage = '❌ 保存失败:日期不能是未来时间,请检查小票日期是否正确';
|
|
996
|
-
break;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
if (inputDate < sixtyYearsAgo) {
|
|
1000
|
-
result = {
|
|
1001
|
-
success: false,
|
|
1002
|
-
error: '日期不能是60年前',
|
|
1003
|
-
code: 400
|
|
1004
|
-
};
|
|
1005
|
-
userMessage = '❌ 保存失败:日期不能是60年前,请检查小票日期是否正确';
|
|
1217
|
+
userMessage = `❌ 保存失败:${validation.message},请检查小票日期是否正确`;
|
|
1006
1218
|
break;
|
|
1007
1219
|
}
|
|
1008
1220
|
}
|
|
@@ -1440,18 +1652,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1440
1652
|
userMessage = `❌ 操作失败:${result.error || '未知错误'}`;
|
|
1441
1653
|
}
|
|
1442
1654
|
|
|
1443
|
-
// 构建完整的返回内容:userMessage + 完整的 result 数据
|
|
1444
|
-
const fullResponse = {
|
|
1445
|
-
userMessage: userMessage,
|
|
1446
|
-
result: result,
|
|
1447
|
-
mcpVersion: MCP_VERSION
|
|
1448
|
-
};
|
|
1449
|
-
|
|
1450
1655
|
return {
|
|
1451
1656
|
content: [
|
|
1452
1657
|
{
|
|
1453
1658
|
type: 'text',
|
|
1454
|
-
text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${
|
|
1659
|
+
text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${safeStringify(result)}\n\`\`\``,
|
|
1455
1660
|
},
|
|
1456
1661
|
],
|
|
1457
1662
|
};
|
|
@@ -1558,11 +1763,24 @@ ${'='.repeat(60)}
|
|
|
1558
1763
|
};
|
|
1559
1764
|
}
|
|
1560
1765
|
|
|
1766
|
+
// 区分开发/生产环境,避免泄露内部错误信息
|
|
1767
|
+
const errorMessage = process.env.NODE_ENV === 'development'
|
|
1768
|
+
? `Error: ${error.message}`
|
|
1769
|
+
: '操作失败,请稍后重试或联系客服';
|
|
1770
|
+
|
|
1771
|
+
// 记录完整错误到日志(用于调试)
|
|
1772
|
+
console.error('Tool execution error:', {
|
|
1773
|
+
tool: request.params.name,
|
|
1774
|
+
error: error.message,
|
|
1775
|
+
stack: error.stack,
|
|
1776
|
+
timestamp: new Date().toISOString()
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1561
1779
|
return {
|
|
1562
1780
|
content: [
|
|
1563
1781
|
{
|
|
1564
1782
|
type: 'text',
|
|
1565
|
-
text:
|
|
1783
|
+
text: errorMessage,
|
|
1566
1784
|
},
|
|
1567
1785
|
],
|
|
1568
1786
|
isError: true,
|
|
@@ -1570,8 +1788,31 @@ ${'='.repeat(60)}
|
|
|
1570
1788
|
}
|
|
1571
1789
|
});
|
|
1572
1790
|
|
|
1791
|
+
// 安全的 JSON 序列化函数,防止循环引用和超大数据导致内存溢出
|
|
1792
|
+
function safeStringify(obj, maxLength = 100000) {
|
|
1793
|
+
try {
|
|
1794
|
+
const str = JSON.stringify(obj, null, 2);
|
|
1795
|
+
if (str.length > maxLength) {
|
|
1796
|
+
return JSON.stringify({
|
|
1797
|
+
...obj,
|
|
1798
|
+
data: '[数据过大,已省略]',
|
|
1799
|
+
_note: `完整数据大小:${str.length} 字符`
|
|
1800
|
+
}, null, 2);
|
|
1801
|
+
}
|
|
1802
|
+
return str;
|
|
1803
|
+
} catch (error) {
|
|
1804
|
+
return JSON.stringify({ error: '数据序列化失败' });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1573
1808
|
function sanitizeString(str, maxLength = 200) {
|
|
1574
1809
|
if (!str || typeof str !== 'string') return '';
|
|
1810
|
+
|
|
1811
|
+
// 硬性限制:输入不能超过 maxLength 的 10 倍,防止超大字符串导致内存溢出
|
|
1812
|
+
if (str.length > maxLength * 10) {
|
|
1813
|
+
throw new Error(`输入过长,最大允许 ${maxLength} 字符`);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1575
1816
|
return str.replace(/<[^>]*>/g, '').substring(0, maxLength);
|
|
1576
1817
|
}
|
|
1577
1818
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据校验工具函数
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 校验日期合理性
|
|
7
|
+
* @param {number|string} dateInput - 日期(毫秒级时间戳或日期字符串)
|
|
8
|
+
* @param {string} fieldName - 字段名称(用于错误提示)
|
|
9
|
+
* @returns {Date} 校验通过后的 Date 对象
|
|
10
|
+
* @throws {Error} 校验失败时抛出错误
|
|
11
|
+
*/
|
|
12
|
+
function validateDate(dateInput, fieldName = '日期') {
|
|
13
|
+
if (!dateInput) {
|
|
14
|
+
return new Date(); // 默认当前时间
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const inputDate = new Date(dateInput);
|
|
18
|
+
const now = new Date();
|
|
19
|
+
const sixtyYearsAgo = new Date();
|
|
20
|
+
sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
|
|
21
|
+
|
|
22
|
+
// 校验 1:日期格式是否有效
|
|
23
|
+
if (isNaN(inputDate.getTime())) {
|
|
24
|
+
throw new Error(`${fieldName}格式无效,请使用毫秒级时间戳(13位数字,如:${Date.now()})`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 校验 2:日期不能是未来时间
|
|
28
|
+
if (inputDate > now) {
|
|
29
|
+
throw new Error(`${fieldName}不能是未来时间`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 校验 3:日期不能是 60 年前
|
|
33
|
+
if (inputDate < sixtyYearsAgo) {
|
|
34
|
+
throw new Error(`${fieldName}不能是60年前`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return inputDate;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
validateDate
|
|
42
|
+
};
|