chaimi-keep-mcp 3.1.45-beta.6 → 3.1.46-beta.0
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/oauth.js +2 -5
- package/package.json +1 -2
- package/server.js +111 -427
- package/utils/validators.js +0 -49
package/oauth.js
CHANGED
|
@@ -312,19 +312,16 @@ class OAuthManager {
|
|
|
312
312
|
const fs = require('fs').promises;
|
|
313
313
|
const path = require('path');
|
|
314
314
|
const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
|
|
315
|
-
|
|
315
|
+
|
|
316
316
|
const dir = path.dirname(stateFile);
|
|
317
317
|
await fs.mkdir(dir, { recursive: true });
|
|
318
|
-
|
|
318
|
+
|
|
319
319
|
await fs.writeFile(
|
|
320
320
|
stateFile,
|
|
321
321
|
JSON.stringify(state, null, 2),
|
|
322
322
|
{ mode: 0o600 }
|
|
323
323
|
);
|
|
324
|
-
console.error('✅ 授权状态已保存');
|
|
325
324
|
} catch (err) {
|
|
326
|
-
console.error('❌ 保存授权状态失败:', err.message);
|
|
327
|
-
throw err;
|
|
328
325
|
}
|
|
329
326
|
}
|
|
330
327
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chaimi-keep-mcp",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.46-beta.0",
|
|
4
4
|
"description": "柴米记账 MCP Server - 支持 Claude、Cursor、OpenClaw、WorkBuddy 等 AI 工具直接记账",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
-
"utils/",
|
|
12
11
|
"server.js",
|
|
13
12
|
"oauth.js",
|
|
14
13
|
"SKILL.md",
|
package/server.js
CHANGED
|
@@ -5,119 +5,11 @@
|
|
|
5
5
|
* 支持 Claude Desktop、Cursor、WorkBuddy、OpenClaw
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
//
|
|
13
|
-
async function fetchConfigFromCloud() {
|
|
14
|
-
const configUrl = 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpHub-mcp';
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
console.error('🔧 首次启动,正在从云端获取配置...');
|
|
18
|
-
|
|
19
|
-
const https = require('https');
|
|
20
|
-
const response = await new Promise((resolve, reject) => {
|
|
21
|
-
const req = https.request(configUrl, {
|
|
22
|
-
method: 'POST',
|
|
23
|
-
headers: {
|
|
24
|
-
'Content-Type': 'application/json'
|
|
25
|
-
}
|
|
26
|
-
}, (res) => {
|
|
27
|
-
let data = '';
|
|
28
|
-
res.on('data', chunk => data += chunk);
|
|
29
|
-
res.on('end', () => {
|
|
30
|
-
try {
|
|
31
|
-
resolve(JSON.parse(data));
|
|
32
|
-
} catch (e) {
|
|
33
|
-
reject(new Error('配置解析失败'));
|
|
34
|
-
}
|
|
35
|
-
});
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
req.on('error', reject);
|
|
39
|
-
req.write(JSON.stringify({ action: 'getMcpConfig' }));
|
|
40
|
-
req.end();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
if (!response.success || !response.data) {
|
|
44
|
-
throw new Error('配置获取失败');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return response.data;
|
|
48
|
-
} catch (err) {
|
|
49
|
-
// 抛出错误让调用者处理,不要直接退出
|
|
50
|
-
throw new Error(`配置获取失败: ${err.message}`);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function initializeConfig() {
|
|
55
|
-
const fs = require('fs');
|
|
56
|
-
const path = require('path');
|
|
57
|
-
const os = require('os');
|
|
58
|
-
|
|
59
|
-
// XDG 标准配置目录
|
|
60
|
-
// Linux/macOS: ~/.config/chaimi-keep-mcp/
|
|
61
|
-
// Windows: %APPDATA%/chaimi-keep-mcp/ (通过设置 XDG_CONFIG_HOME 环境变量)
|
|
62
|
-
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
63
|
-
const configDir = xdgConfigHome
|
|
64
|
-
? path.join(xdgConfigHome, 'chaimi-keep-mcp')
|
|
65
|
-
: path.join(os.homedir(), '.config', 'chaimi-keep-mcp');
|
|
66
|
-
|
|
67
|
-
// 确保配置目录存在
|
|
68
|
-
if (!fs.existsSync(configDir)) {
|
|
69
|
-
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const envPath = path.join(configDir, envFile);
|
|
73
|
-
|
|
74
|
-
// 如果配置文件已存在,直接返回路径
|
|
75
|
-
if (fs.existsSync(envPath)) {
|
|
76
|
-
return envPath;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 首次启动,尝试从云端获取配置
|
|
80
|
-
let config;
|
|
81
|
-
try {
|
|
82
|
-
config = await fetchConfigFromCloud();
|
|
83
|
-
console.error('✅ 已从云端获取配置');
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error('❌ 无法从云端获取配置');
|
|
86
|
-
console.error(' 错误:' + err.message);
|
|
87
|
-
console.error('');
|
|
88
|
-
console.error('可能原因:');
|
|
89
|
-
console.error(' 1. 网络连接异常');
|
|
90
|
-
console.error(' 2. 云函数尚未部署或配置错误');
|
|
91
|
-
console.error('');
|
|
92
|
-
console.error('解决方案:');
|
|
93
|
-
console.error(' 1. 检查网络连接');
|
|
94
|
-
console.error(' 2. 确认云函数 mcpHub 已部署');
|
|
95
|
-
console.error(' 3. 联系开发者获取帮助');
|
|
96
|
-
process.exit(1);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// 生成配置文件
|
|
100
|
-
const envContent = `# 柴米记账 MCP Server - ${NODE_ENV} 环境配置
|
|
101
|
-
# 自动生成时间:${new Date().toISOString()}
|
|
102
|
-
|
|
103
|
-
# 云函数 URL
|
|
104
|
-
MCP_HUB_URL=${config.MCP_HUB_URL}
|
|
105
|
-
MCP_PROMPT_URL=${config.MCP_PROMPT_URL}
|
|
106
|
-
MCP_OAUTH_URL=${config.MCP_OAUTH_URL}
|
|
107
|
-
|
|
108
|
-
# API 签名密钥
|
|
109
|
-
CHAIMI_API_SECRET=${config.CHAIMI_API_SECRET}
|
|
110
|
-
|
|
111
|
-
# JWT 密钥
|
|
112
|
-
JWT_SECRET=${config.JWT_SECRET}
|
|
113
|
-
`;
|
|
114
|
-
|
|
115
|
-
fs.writeFileSync(envPath, envContent, { mode: 0o600 });
|
|
116
|
-
console.error('✅ 配置文件已自动生成!');
|
|
117
|
-
console.error(` 文件位置:${envPath}`);
|
|
118
|
-
console.error('');
|
|
119
|
-
|
|
120
|
-
return envPath;
|
|
8
|
+
// 加载 .env 文件
|
|
9
|
+
try {
|
|
10
|
+
require('dotenv').config();
|
|
11
|
+
} catch (e) {
|
|
12
|
+
// dotenv 未安装,忽略
|
|
121
13
|
}
|
|
122
14
|
|
|
123
15
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
@@ -138,90 +30,37 @@ const MCP_VERSION = packageJson.version;
|
|
|
138
30
|
// 导入 OAuth 模块
|
|
139
31
|
const { OAuthManager, FileTokenStorage } = require('./oauth.js');
|
|
140
32
|
|
|
141
|
-
// 导入工具函数
|
|
142
|
-
const { validateDate } = require('./utils/validators.js');
|
|
143
|
-
|
|
144
|
-
// ==================== 密钥管理 ====================
|
|
145
|
-
|
|
146
|
-
// Token 加密密钥文件路径
|
|
147
|
-
const SECRET_KEY_FILE = path.join(__dirname, '.mcp-secret-key');
|
|
148
|
-
|
|
149
|
-
// 自动生成或加载 Token 加密密钥
|
|
150
|
-
function getOrCreateSecretKey() {
|
|
151
|
-
// 1. 优先使用环境变量(高级用户可以手动配置)
|
|
152
|
-
if (process.env.MCP_SECRET_KEY) {
|
|
153
|
-
console.error('✅ 使用环境变量中的 Token 加密密钥');
|
|
154
|
-
return process.env.MCP_SECRET_KEY;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 2. 尝试从文件加载
|
|
158
|
-
if (fs.existsSync(SECRET_KEY_FILE)) {
|
|
159
|
-
const key = fs.readFileSync(SECRET_KEY_FILE, 'utf8').trim();
|
|
160
|
-
console.error('✅ 从本地文件加载 Token 加密密钥');
|
|
161
|
-
return key;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// 3. 自动生成新密钥(64 字符,256 位)
|
|
165
|
-
const newKey = crypto.randomBytes(32).toString('hex');
|
|
166
|
-
fs.writeFileSync(SECRET_KEY_FILE, newKey, { mode: 0o600 }); // 只有当前用户可读写
|
|
167
|
-
console.error('✅ 已自动生成 Token 加密密钥,保存在 .mcp-secret-key 文件中');
|
|
168
|
-
console.error('⚠️ 请勿删除此文件,否则已授权的 Token 将无法解密');
|
|
169
|
-
return newKey;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Token 加密密钥(自动生成或加载)
|
|
173
|
-
const SECRET_KEY = getOrCreateSecretKey();
|
|
174
|
-
|
|
175
33
|
// API 签名密钥(用于验证请求来自合法 MCP Server)
|
|
176
|
-
|
|
177
|
-
let CHAIMI_API_SECRET;
|
|
178
|
-
|
|
179
|
-
// ==================== 加密/解密函数 ====================
|
|
180
|
-
|
|
181
|
-
// AES-256-GCM 加密(用于 Token)
|
|
182
|
-
function encrypt(text, secretKey) {
|
|
183
|
-
const iv = crypto.randomBytes(16); // 随机初始化向量
|
|
184
|
-
const cipher = crypto.createCipheriv(
|
|
185
|
-
'aes-256-gcm',
|
|
186
|
-
Buffer.from(secretKey, 'hex'),
|
|
187
|
-
iv
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
191
|
-
encrypted += cipher.final('hex');
|
|
192
|
-
|
|
193
|
-
const authTag = cipher.getAuthTag(); // 认证标签(防篡改)
|
|
194
|
-
|
|
195
|
-
// 返回格式:iv:authTag:encrypted
|
|
196
|
-
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
197
|
-
}
|
|
34
|
+
const CHAIMI_API_SECRET = 'chaimi-mcp-secret-2024';
|
|
198
35
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
|
36
|
+
// URL 加密密钥
|
|
37
|
+
const URL_ENCRYPT_KEY = 'chaimi-url-key-2024';
|
|
202
38
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
|
|
210
|
-
|
|
211
|
-
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
212
|
-
decrypted += decipher.final('utf8');
|
|
39
|
+
// 加密的云函数 URL
|
|
40
|
+
const ENCRYPTED_URLS = {
|
|
41
|
+
hub: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f571320140b40044e05',
|
|
42
|
+
oauth: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f571327201c1901',
|
|
43
|
+
prompt: 'enc:v1:0b1c15191e53025a1100421e0148000057545156020903080f1d431054180f484819030203035158595043085d5801044c0502114c5b1e53441346150a01065811100d5e0e4b1a425f1f5f5713381306001959'
|
|
44
|
+
};
|
|
213
45
|
|
|
214
|
-
|
|
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;
|
|
215
55
|
}
|
|
216
56
|
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
let MCP_OAUTH_URL;
|
|
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);
|
|
222
61
|
|
|
223
|
-
// Token
|
|
224
|
-
let
|
|
62
|
+
// Token 缓存
|
|
63
|
+
let cachedToken = null;
|
|
225
64
|
let tokenExpireTime = 0;
|
|
226
65
|
|
|
227
66
|
// OAuth 管理器实例
|
|
@@ -269,8 +108,8 @@ async function initOAuthManager() {
|
|
|
269
108
|
// 检查 Token 是否过期(预留5分钟缓冲)
|
|
270
109
|
const expiresAt = new Date(existingToken.expiresAt).getTime();
|
|
271
110
|
if (expiresAt > Date.now() + 5 * 60 * 1000) {
|
|
272
|
-
// Token
|
|
273
|
-
|
|
111
|
+
// Token 有效,设置授权状态
|
|
112
|
+
cachedToken = existingToken.accessToken;
|
|
274
113
|
tokenExpireTime = expiresAt;
|
|
275
114
|
authState.isAuthorized = true;
|
|
276
115
|
console.error('✅ 已从文件加载有效 Token,无需重新授权');
|
|
@@ -532,103 +371,15 @@ const toolMapping = {
|
|
|
532
371
|
'export_data': 'exportData',
|
|
533
372
|
};
|
|
534
373
|
|
|
535
|
-
// ==================== 请求频率限制 ====================
|
|
536
|
-
|
|
537
|
-
// 限流配置
|
|
538
|
-
const RATE_LIMITS = {
|
|
539
|
-
// 记账工具:1 分钟 10 次
|
|
540
|
-
write: {
|
|
541
|
-
tools: ['save_expense', 'save_receipt', 'save_income'],
|
|
542
|
-
maxRequests: 10,
|
|
543
|
-
windowMs: 60 * 1000, // 1 分钟
|
|
544
|
-
},
|
|
545
|
-
// 查询工具:1 天 10 次
|
|
546
|
-
read: {
|
|
547
|
-
tools: ['get_expenses', 'get_receipt_list', 'get_statistics', 'get_insights', 'export_data'],
|
|
548
|
-
maxRequests: 10,
|
|
549
|
-
windowMs: 24 * 60 * 60 * 1000, // 1 天
|
|
550
|
-
},
|
|
551
|
-
// 配置读取工具:1 分钟 10 次
|
|
552
|
-
config: {
|
|
553
|
-
tools: ['get_skill', 'get_parse_prompt', 'get_text_parse_prompt'],
|
|
554
|
-
maxRequests: 10,
|
|
555
|
-
windowMs: 60 * 1000, // 1 分钟
|
|
556
|
-
},
|
|
557
|
-
// 反馈提交:1 天 5 次
|
|
558
|
-
feedback: {
|
|
559
|
-
tools: ['submit_feedback'],
|
|
560
|
-
maxRequests: 5,
|
|
561
|
-
windowMs: 24 * 60 * 60 * 1000, // 1 天
|
|
562
|
-
},
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
// 限流记录:{ toolName: [timestamp1, timestamp2, ...] }
|
|
566
|
-
const rateLimitRecords = {};
|
|
567
|
-
|
|
568
|
-
// 检查是否超过频率限制
|
|
569
|
-
function checkRateLimit(toolName) {
|
|
570
|
-
// 查找工具所属的限流组
|
|
571
|
-
let limitConfig = null;
|
|
572
|
-
let limitType = null;
|
|
573
|
-
|
|
574
|
-
for (const [type, config] of Object.entries(RATE_LIMITS)) {
|
|
575
|
-
if (config.tools.includes(toolName)) {
|
|
576
|
-
limitConfig = config;
|
|
577
|
-
limitType = type;
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// 如果工具不在限流列表中,直接通过
|
|
583
|
-
if (!limitConfig) {
|
|
584
|
-
return { allowed: true };
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const now = Date.now();
|
|
588
|
-
const windowStart = now - limitConfig.windowMs;
|
|
589
|
-
|
|
590
|
-
// 初始化记录
|
|
591
|
-
if (!rateLimitRecords[toolName]) {
|
|
592
|
-
rateLimitRecords[toolName] = [];
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// 清理过期记录
|
|
596
|
-
rateLimitRecords[toolName] = rateLimitRecords[toolName].filter(
|
|
597
|
-
timestamp => timestamp > windowStart
|
|
598
|
-
);
|
|
599
|
-
|
|
600
|
-
// 检查是否超过限制
|
|
601
|
-
if (rateLimitRecords[toolName].length >= limitConfig.maxRequests) {
|
|
602
|
-
const oldestRequest = rateLimitRecords[toolName][0];
|
|
603
|
-
const resetTime = oldestRequest + limitConfig.windowMs;
|
|
604
|
-
const waitSeconds = Math.ceil((resetTime - now) / 1000);
|
|
605
|
-
|
|
606
|
-
return {
|
|
607
|
-
allowed: false,
|
|
608
|
-
limitType,
|
|
609
|
-
maxRequests: limitConfig.maxRequests,
|
|
610
|
-
windowMs: limitConfig.windowMs,
|
|
611
|
-
resetTime,
|
|
612
|
-
waitSeconds,
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// 记录本次请求
|
|
617
|
-
rateLimitRecords[toolName].push(now);
|
|
618
|
-
|
|
619
|
-
return { allowed: true };
|
|
620
|
-
}
|
|
621
|
-
|
|
622
374
|
// 获取或刷新 Token(OAuth 2.0 Device Flow)
|
|
623
375
|
const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000;
|
|
624
376
|
let lastRefreshTime = 0;
|
|
625
377
|
|
|
626
378
|
async function getToken() {
|
|
627
379
|
// 步骤1:检查内存缓存 Token(最快路径)
|
|
628
|
-
if (
|
|
380
|
+
if (cachedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
|
|
629
381
|
if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
|
|
630
|
-
|
|
631
|
-
return decrypt(cachedEncryptedToken, SECRET_KEY);
|
|
382
|
+
return cachedToken;
|
|
632
383
|
}
|
|
633
384
|
}
|
|
634
385
|
|
|
@@ -639,14 +390,12 @@ async function getToken() {
|
|
|
639
390
|
// 步骤2:尝试从文件加载并刷新 Token
|
|
640
391
|
try {
|
|
641
392
|
const oauthToken = await oauthManager.getValidToken();
|
|
642
|
-
|
|
643
|
-
cachedEncryptedToken = encrypt(oauthToken.accessToken, SECRET_KEY);
|
|
393
|
+
cachedToken = oauthToken.accessToken;
|
|
644
394
|
tokenExpireTime = oauthToken.expiresAt;
|
|
645
395
|
lastRefreshTime = Date.now();
|
|
646
396
|
authState.isAuthorized = true;
|
|
647
397
|
await oauthManager.clearAuthState();
|
|
648
|
-
|
|
649
|
-
return decrypt(cachedEncryptedToken, SECRET_KEY);
|
|
398
|
+
return cachedToken;
|
|
650
399
|
} catch (err) {
|
|
651
400
|
// Token 无效,需要重新授权
|
|
652
401
|
console.error('⏰ Token 无效,需要重新授权:', err.message);
|
|
@@ -736,15 +485,10 @@ async function callMcpPrompt(tool, params, token) {
|
|
|
736
485
|
// 生成请求签名(用于验证请求来自合法 MCP Server)
|
|
737
486
|
function generateSignature(body, timestamp, secret) {
|
|
738
487
|
const payload = JSON.stringify(body) + timestamp;
|
|
739
|
-
|
|
488
|
+
return crypto
|
|
740
489
|
.createHmac('sha256', secret)
|
|
741
490
|
.update(payload)
|
|
742
491
|
.digest('hex');
|
|
743
|
-
// 调试日志(仅在开发环境显示)
|
|
744
|
-
if (process.env.DEBUG_MCP === 'true') {
|
|
745
|
-
console.error('[DEBUG] 生成签名:', { payload: payload.substring(0, 100), timestamp, signature: signature.substring(0, 16) + '...' });
|
|
746
|
-
}
|
|
747
|
-
return signature;
|
|
748
492
|
}
|
|
749
493
|
|
|
750
494
|
// MCP 调用日志记录函数
|
|
@@ -922,7 +666,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
922
666
|
const startTime = Date.now();
|
|
923
667
|
const traceId = generateTraceId();
|
|
924
668
|
const osInfo = getOSInfo();
|
|
925
|
-
|
|
669
|
+
|
|
926
670
|
// 提取元数据字段
|
|
927
671
|
const agentType = args.agentType || process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
|
|
928
672
|
const apiProvider = args.apiProvider || process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
|
|
@@ -942,44 +686,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
942
686
|
logSource: 'mcp'
|
|
943
687
|
});
|
|
944
688
|
|
|
945
|
-
// 检查频率限制
|
|
946
|
-
const rateLimitResult = checkRateLimit(name);
|
|
947
|
-
if (!rateLimitResult.allowed) {
|
|
948
|
-
const windowName = rateLimitResult.windowMs === 60 * 1000 ? '1 分钟' : '1 天';
|
|
949
|
-
const waitTime = rateLimitResult.waitSeconds < 60
|
|
950
|
-
? `${rateLimitResult.waitSeconds} 秒`
|
|
951
|
-
: `${Math.ceil(rateLimitResult.waitSeconds / 60)} 分钟`;
|
|
952
|
-
|
|
953
|
-
// 记录限流
|
|
954
|
-
logMcpCall({
|
|
955
|
-
traceId,
|
|
956
|
-
stage: 'rate_limited',
|
|
957
|
-
toolName: name,
|
|
958
|
-
error: 'RATE_LIMIT_EXCEEDED',
|
|
959
|
-
duration: Date.now() - startTime,
|
|
960
|
-
agentType,
|
|
961
|
-
apiProvider,
|
|
962
|
-
mcpVersion: MCP_VERSION,
|
|
963
|
-
osType: osInfo.osType,
|
|
964
|
-
osVersion: osInfo.osVersion,
|
|
965
|
-
timestamp: new Date().toISOString(),
|
|
966
|
-
logSource: 'mcp'
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
return {
|
|
970
|
-
content: [
|
|
971
|
-
{
|
|
972
|
-
type: 'text',
|
|
973
|
-
text: JSON.stringify({
|
|
974
|
-
success: false,
|
|
975
|
-
error: `请求过于频繁,${windowName}内最多调用 ${rateLimitResult.maxRequests} 次,请 ${waitTime} 后再试`,
|
|
976
|
-
code: 429
|
|
977
|
-
})
|
|
978
|
-
}
|
|
979
|
-
]
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
|
|
983
689
|
// 强制检查:记账工具必须先调用 get_skill
|
|
984
690
|
if (name === 'save_expense' || name === 'save_receipt' || name === 'save_income') {
|
|
985
691
|
const now = Date.now();
|
|
@@ -1033,13 +739,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1033
739
|
if (processedArgs.items && typeof processedArgs.items === 'string') {
|
|
1034
740
|
try {
|
|
1035
741
|
processedArgs.items = JSON.parse(processedArgs.items);
|
|
1036
|
-
|
|
1037
|
-
// 验证解析后的类型
|
|
1038
|
-
if (!Array.isArray(processedArgs.items)) {
|
|
1039
|
-
throw new Error('items 必须是数组');
|
|
1040
|
-
}
|
|
1041
742
|
} catch (e) {
|
|
1042
|
-
throw new Error(`items 参数格式错误:${e.message}`);
|
|
1043
743
|
}
|
|
1044
744
|
}
|
|
1045
745
|
|
|
@@ -1105,17 +805,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1105
805
|
userMessage = '❌ 记账失败:金额必须是正数';
|
|
1106
806
|
break;
|
|
1107
807
|
}
|
|
1108
|
-
|
|
1109
|
-
// 日期合理性检查
|
|
808
|
+
|
|
809
|
+
// P1: 日期合理性检查
|
|
1110
810
|
if (processedArgs.date) {
|
|
1111
|
-
const
|
|
1112
|
-
|
|
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
|
+
|
|
826
|
+
if (inputDate > now) {
|
|
827
|
+
result = {
|
|
828
|
+
success: false,
|
|
829
|
+
error: '日期不能是未来时间',
|
|
830
|
+
code: 400
|
|
831
|
+
};
|
|
832
|
+
userMessage = '❌ 记账失败:日期不能是未来时间';
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (inputDate < sixtyYearsAgo) {
|
|
1113
837
|
result = {
|
|
1114
838
|
success: false,
|
|
1115
|
-
error:
|
|
839
|
+
error: '日期不能是60年前',
|
|
1116
840
|
code: 400
|
|
1117
841
|
};
|
|
1118
|
-
userMessage =
|
|
842
|
+
userMessage = '❌ 记账失败:日期不能是60年前';
|
|
1119
843
|
break;
|
|
1120
844
|
}
|
|
1121
845
|
}
|
|
@@ -1244,17 +968,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1244
968
|
userMessage = `❌ 保存失败\n\n错误:商品数据格式不完整\n\n${invalidItems.join('\n')}\n\n💡 解决方案:\n1. 请更新您的柴米记账 MCP Skill\n2. 确保每个商品包含完整的5个字段:name, amount, price, quantity, category\n3. category 是记账的基本信息,不能为空`;
|
|
1245
969
|
break;
|
|
1246
970
|
}
|
|
1247
|
-
|
|
1248
|
-
// 日期合理性检查
|
|
971
|
+
|
|
972
|
+
// P1: 日期合理性检查
|
|
1249
973
|
if (processedArgs.date) {
|
|
1250
|
-
const
|
|
1251
|
-
|
|
974
|
+
const inputDate = new Date(processedArgs.date);
|
|
975
|
+
const now = new Date();
|
|
976
|
+
const sixtyYearsAgo = new Date();
|
|
977
|
+
sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
|
|
978
|
+
|
|
979
|
+
if (isNaN(inputDate.getTime())) {
|
|
980
|
+
result = {
|
|
981
|
+
success: false,
|
|
982
|
+
error: '日期格式无效',
|
|
983
|
+
code: 400
|
|
984
|
+
};
|
|
985
|
+
userMessage = '❌ 保存失败:日期格式无效,请使用毫秒级时间戳(13位数字,如:xxxxxxxxxxxxx)';
|
|
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) {
|
|
1252
1000
|
result = {
|
|
1253
1001
|
success: false,
|
|
1254
|
-
error:
|
|
1002
|
+
error: '日期不能是60年前',
|
|
1255
1003
|
code: 400
|
|
1256
1004
|
};
|
|
1257
|
-
userMessage =
|
|
1005
|
+
userMessage = '❌ 保存失败:日期不能是60年前,请检查小票日期是否正确';
|
|
1258
1006
|
break;
|
|
1259
1007
|
}
|
|
1260
1008
|
}
|
|
@@ -1692,11 +1440,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1692
1440
|
userMessage = `❌ 操作失败:${result.error || '未知错误'}`;
|
|
1693
1441
|
}
|
|
1694
1442
|
|
|
1443
|
+
// 构建完整的返回内容:userMessage + 完整的 result 数据
|
|
1444
|
+
const fullResponse = {
|
|
1445
|
+
userMessage: userMessage,
|
|
1446
|
+
result: result,
|
|
1447
|
+
mcpVersion: MCP_VERSION
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1695
1450
|
return {
|
|
1696
1451
|
content: [
|
|
1697
1452
|
{
|
|
1698
1453
|
type: 'text',
|
|
1699
|
-
text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${
|
|
1454
|
+
text: `${userMessage}\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}\n\n## 完整数据\n\`\`\`json\n${JSON.stringify(result, null, 2)}\n\`\`\``,
|
|
1700
1455
|
},
|
|
1701
1456
|
],
|
|
1702
1457
|
};
|
|
@@ -1803,24 +1558,11 @@ ${'='.repeat(60)}
|
|
|
1803
1558
|
};
|
|
1804
1559
|
}
|
|
1805
1560
|
|
|
1806
|
-
// 区分开发/生产环境,避免泄露内部错误信息
|
|
1807
|
-
const errorMessage = process.env.NODE_ENV === 'development'
|
|
1808
|
-
? `Error: ${error.message}`
|
|
1809
|
-
: '操作失败,请稍后重试或联系客服';
|
|
1810
|
-
|
|
1811
|
-
// 记录完整错误到日志(用于调试)
|
|
1812
|
-
console.error('Tool execution error:', {
|
|
1813
|
-
tool: request.params.name,
|
|
1814
|
-
error: error.message,
|
|
1815
|
-
stack: error.stack,
|
|
1816
|
-
timestamp: new Date().toISOString()
|
|
1817
|
-
});
|
|
1818
|
-
|
|
1819
1561
|
return {
|
|
1820
1562
|
content: [
|
|
1821
1563
|
{
|
|
1822
1564
|
type: 'text',
|
|
1823
|
-
text:
|
|
1565
|
+
text: `Error: ${error.message}`,
|
|
1824
1566
|
},
|
|
1825
1567
|
],
|
|
1826
1568
|
isError: true,
|
|
@@ -1828,31 +1570,8 @@ ${'='.repeat(60)}
|
|
|
1828
1570
|
}
|
|
1829
1571
|
});
|
|
1830
1572
|
|
|
1831
|
-
// 安全的 JSON 序列化函数,防止循环引用和超大数据导致内存溢出
|
|
1832
|
-
function safeStringify(obj, maxLength = 100000) {
|
|
1833
|
-
try {
|
|
1834
|
-
const str = JSON.stringify(obj, null, 2);
|
|
1835
|
-
if (str.length > maxLength) {
|
|
1836
|
-
return JSON.stringify({
|
|
1837
|
-
...obj,
|
|
1838
|
-
data: '[数据过大,已省略]',
|
|
1839
|
-
_note: `完整数据大小:${str.length} 字符`
|
|
1840
|
-
}, null, 2);
|
|
1841
|
-
}
|
|
1842
|
-
return str;
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
return JSON.stringify({ error: '数据序列化失败' });
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
1573
|
function sanitizeString(str, maxLength = 200) {
|
|
1849
1574
|
if (!str || typeof str !== 'string') return '';
|
|
1850
|
-
|
|
1851
|
-
// 硬性限制:输入不能超过 maxLength 的 10 倍,防止超大字符串导致内存溢出
|
|
1852
|
-
if (str.length > maxLength * 10) {
|
|
1853
|
-
throw new Error(`输入过长,最大允许 ${maxLength} 字符`);
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
1575
|
return str.replace(/<[^>]*>/g, '').substring(0, maxLength);
|
|
1857
1576
|
}
|
|
1858
1577
|
|
|
@@ -2098,36 +1817,6 @@ let authState = {
|
|
|
2098
1817
|
};
|
|
2099
1818
|
|
|
2100
1819
|
async function main() {
|
|
2101
|
-
// 首次启动时自动获取配置
|
|
2102
|
-
const envPath = await initializeConfig();
|
|
2103
|
-
|
|
2104
|
-
// 加载环境变量(无论文件是否存在都需要加载)
|
|
2105
|
-
require('dotenv').config({ path: envPath });
|
|
2106
|
-
|
|
2107
|
-
// 加载环境变量后赋值
|
|
2108
|
-
CHAIMI_API_SECRET = process.env.CHAIMI_API_SECRET;
|
|
2109
|
-
MCP_HUB_URL = process.env.MCP_HUB_URL;
|
|
2110
|
-
MCP_PROMPT_URL = process.env.MCP_PROMPT_URL;
|
|
2111
|
-
MCP_OAUTH_URL = process.env.MCP_OAUTH_URL;
|
|
2112
|
-
|
|
2113
|
-
// 验证必需的环境变量
|
|
2114
|
-
if (!CHAIMI_API_SECRET) {
|
|
2115
|
-
console.error('❌ 错误:缺少必需的环境变量 CHAIMI_API_SECRET');
|
|
2116
|
-
console.error(' 配置文件可能未正确生成,请检查 .env.development 文件');
|
|
2117
|
-
console.error(' 或手动运行:node setup.js');
|
|
2118
|
-
process.exit(1);
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
// 验证云函数 URL 配置
|
|
2122
|
-
if (!MCP_HUB_URL || !MCP_PROMPT_URL || !MCP_OAUTH_URL) {
|
|
2123
|
-
console.error('❌ 错误:缺少云函数 URL 配置');
|
|
2124
|
-
console.error(' 请在 .env 文件中配置以下环境变量:');
|
|
2125
|
-
console.error(' MCP_HUB_URL=https://your-cloud-function-url/mcpHub-mcp');
|
|
2126
|
-
console.error(' MCP_PROMPT_URL=https://your-cloud-function-url/mcpPrompt');
|
|
2127
|
-
console.error(' MCP_OAUTH_URL=https://your-cloud-function-url/mcpOAuth');
|
|
2128
|
-
process.exit(1);
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
1820
|
await initOAuthManager();
|
|
2132
1821
|
|
|
2133
1822
|
const transport = new StdioServerTransport();
|
|
@@ -2283,13 +1972,8 @@ function sanitizeLogParams(params) {
|
|
|
2283
1972
|
}
|
|
2284
1973
|
|
|
2285
1974
|
// 注意:使用 console.error,避免污染 stdout(MCP 协议通信使用 stdout)
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
});
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
// 启动服务器
|
|
2295
|
-
startServer();
|
|
1975
|
+
main().catch((err) => {
|
|
1976
|
+
console.error('❌ MCP Server 启动失败:', err.message);
|
|
1977
|
+
console.error('堆栈:', err.stack);
|
|
1978
|
+
process.exit(1);
|
|
1979
|
+
});
|
package/utils/validators.js
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 数据校验工具函数
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 校验日期合理性
|
|
7
|
-
* @param {number|string} dateInput - 日期(毫秒级时间戳或日期字符串)
|
|
8
|
-
* @param {string} fieldName - 字段名称(用于错误提示)
|
|
9
|
-
* @returns {Object} { valid: boolean, error?: string, message?: string }
|
|
10
|
-
*/
|
|
11
|
-
function validateDate(dateInput, fieldName = '日期') {
|
|
12
|
-
const inputDate = new Date(dateInput);
|
|
13
|
-
const now = new Date();
|
|
14
|
-
const sixtyYearsAgo = new Date();
|
|
15
|
-
sixtyYearsAgo.setFullYear(now.getFullYear() - 60);
|
|
16
|
-
|
|
17
|
-
// 校验 1:日期格式是否有效
|
|
18
|
-
if (isNaN(inputDate.getTime())) {
|
|
19
|
-
return {
|
|
20
|
-
valid: false,
|
|
21
|
-
error: '日期格式无效',
|
|
22
|
-
message: `❌ ${fieldName}格式无效,请使用毫秒级时间戳(13位数字)`
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// 校验 2:日期不能是未来时间
|
|
27
|
-
if (inputDate > now) {
|
|
28
|
-
return {
|
|
29
|
-
valid: false,
|
|
30
|
-
error: '日期不能是未来时间',
|
|
31
|
-
message: `❌ ${fieldName}不能是未来时间`
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// 校验 3:日期不能是 60 年前
|
|
36
|
-
if (inputDate < sixtyYearsAgo) {
|
|
37
|
-
return {
|
|
38
|
-
valid: false,
|
|
39
|
-
error: '日期不能是60年前',
|
|
40
|
-
message: `❌ ${fieldName}不能是60年前`
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return { valid: true };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
module.exports = {
|
|
48
|
-
validateDate
|
|
49
|
-
};
|