chaimi-keep-mcp 3.4.0 → 3.5.0-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/.env.example +24 -0
- package/bin/cli.js +1 -6
- package/oauth.js +5 -1
- package/package.json +1 -1
- package/server.js +104 -33
- package/utils/encryption.js +53 -0
- package/utils/keyManager.js +38 -0
package/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 环境配置示例
|
|
2
|
+
# 将此文件复制为 .env 并填入实际值
|
|
3
|
+
|
|
4
|
+
# 环境选择:development 或 production
|
|
5
|
+
NODE_ENV=production
|
|
6
|
+
|
|
7
|
+
# API 签名密钥(生产环境建议使用强密钥)
|
|
8
|
+
CHAIMI_API_SECRET=your-chaimi-api-secret-here
|
|
9
|
+
|
|
10
|
+
# URL 加密密钥(用于解密云函数 URL)
|
|
11
|
+
URL_ENCRYPT_KEY=your-url-encrypt-key-here
|
|
12
|
+
|
|
13
|
+
# ====================
|
|
14
|
+
# 开发环境配置
|
|
15
|
+
# ====================
|
|
16
|
+
# 开发环境的云函数 URL(仅 NODE_ENV=development 时使用)
|
|
17
|
+
# MCP_HUB_URL=http://localhost:3000/mcpHub
|
|
18
|
+
# MCP_OAUTH_URL=http://localhost:3000/mcpOAuth
|
|
19
|
+
# MCP_PROMPT_URL=http://localhost:3000/mcpPrompt
|
|
20
|
+
|
|
21
|
+
# ====================
|
|
22
|
+
# 生产环境配置
|
|
23
|
+
# ====================
|
|
24
|
+
# 生产环境使用加密存储在代码中的 URL,无需在此配置
|
package/bin/cli.js
CHANGED
|
@@ -21,12 +21,7 @@ const serverPath = path.join(__dirname, '..', 'server.js');
|
|
|
21
21
|
const DEFAULT_CHAIMI_CONFIG = {
|
|
22
22
|
command: 'chaimi-keep-mcp',
|
|
23
23
|
description: '柴米AI记账 MCP Server - 支持 AI 工具直接记账',
|
|
24
|
-
|
|
25
|
-
MCP_OAUTH_URL: 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpOAuth',
|
|
26
|
-
MCP_HUB_URL: 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpHub-mcp',
|
|
27
|
-
MCP_PROMPT_URL: 'https://cloud1-2gfe5jhjef06b85d-1412172089.ap-shanghai.app.tcloudbase.com/mcpPrompt'
|
|
28
|
-
},
|
|
29
|
-
// 首次授权时需要常驻运行,等待用户授权(3分钟轮询)
|
|
24
|
+
// 首次授权时需要常驻运行,等待用户授权(30分钟轮询)
|
|
30
25
|
// 授权成功后进程自动退出,下次调用时 mcporter 会重新启动
|
|
31
26
|
lifecycle: 'keep-alive'
|
|
32
27
|
};
|
package/oauth.js
CHANGED
|
@@ -505,7 +505,11 @@ class FileTokenStorage extends TokenStorage {
|
|
|
505
505
|
agentName: agentName || '柴米AI助手',
|
|
506
506
|
updatedAt: new Date().toISOString()
|
|
507
507
|
};
|
|
508
|
-
|
|
508
|
+
// 使用 JSON.stringify 并转义处理,确保中文正常显示(不转义为 \uXXXX)
|
|
509
|
+
const jsonStr = JSON.stringify(data, null, 2)
|
|
510
|
+
.replace(/\\u[\dA-F]{4}/gi, (match) =>
|
|
511
|
+
String.fromCharCode(parseInt(match.replace(/\\u/g, ''), 16)));
|
|
512
|
+
await this.fs.writeFile(agentNamePath, jsonStr, 'utf8');
|
|
509
513
|
console.error('✅ Agent 名称已保存:', agentNamePath);
|
|
510
514
|
} catch (err) {
|
|
511
515
|
console.error('❌ 保存 Agent 名称失败:', err.message);
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -48,8 +48,11 @@ const { configManager } = require('./utils/config.js');
|
|
|
48
48
|
// 导入校验工具
|
|
49
49
|
const { validateDate } = require('./utils/validators.js');
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
51
|
+
// 导入密钥管理工具
|
|
52
|
+
const { getOrCreateSecretKey } = require('./utils/keyManager.js');
|
|
53
|
+
|
|
54
|
+
// 导入加密工具
|
|
55
|
+
const { encrypt, decrypt } = require('./utils/encryption.js');
|
|
53
56
|
|
|
54
57
|
// ==================== 请求频率限制 ====================
|
|
55
58
|
|
|
@@ -138,36 +141,102 @@ function checkRateLimit(toolName) {
|
|
|
138
141
|
return { allowed: true };
|
|
139
142
|
}
|
|
140
143
|
|
|
141
|
-
//
|
|
142
|
-
|
|
144
|
+
// 获取 API 密钥(从环境变量或自动生成)
|
|
145
|
+
let CHAIMI_API_SECRET = process.env.CHAIMI_API_SECRET;
|
|
146
|
+
|
|
147
|
+
// ==================== 多环境配置 ====================
|
|
143
148
|
|
|
144
|
-
//
|
|
149
|
+
// 环境配置
|
|
150
|
+
const ENV = process.env.NODE_ENV || 'production';
|
|
151
|
+
|
|
152
|
+
// 加密的云函数 URL(开发和生产环境分开)
|
|
145
153
|
const ENCRYPTED_URLS = {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
154
|
+
development: {
|
|
155
|
+
hub: 'enc:v2:d397243fce051c611175302dea8a7a54:8f0bc1dc6966e9149e8d3ecae2467830:cf61c7a159154d15aa1502deec2519931090ff791be67e89fd4e3b20322b2bea8071be02d768ec10abdc50b2d3e76df6dc44a6d46d61c2743acdef428205cc0e3534c93d94857cc91985db5fd69745053df05a87',
|
|
156
|
+
oauth: 'enc:v2:bff470f73af1723d6e320d39df72b342:eefb409dff5a4713931caa3886f9159c:18d0a711f31a5ba7e068e43d8bcce6b8fc56513d44ff3d90ab006063461b44025387e52cb2ce5ffe471a8a8f4b993879dcaa485dd1e845c647be134595467c4a00efc736d15abfcaa8d8cf460365fbf597e0',
|
|
157
|
+
prompt: 'enc:v2:a058c504374087a36417280f0bc4288d:ed5fc65a697c1254e8aeb9faa7119ec8:b8831fd319c35bc01554546827ce31bd04d2039fb87f0b6a19b35850ec228b6988b2103b1cc45a7bd21b117b299c5095a44e1ee4a4c2adb04e24a401e1271aff912503e2abb9cec03764b774df53e60b0686d3'
|
|
158
|
+
},
|
|
159
|
+
production: {
|
|
160
|
+
hub: 'enc:v2:500682dfbd51aff69852abe39430da35:3d57b5ac7f798c6770209159b6dfd9fb:7b9733f27208809b761090f7628f3799aac12c957cdd45b3d512eb4f1f1c18f626afd0b73b12982500122b11e373e0a2a71f14cb6c5966cf98af9ae4b9e79fb575d1e2f42ec403690d5c7dcc519e9eaec21865eb',
|
|
161
|
+
oauth: 'enc:v2:30adcae20bc1452e8e8c7eff088e8b61:b8bedcc9985d1e10185f8ae5c845cc9c:1b2206380eddcfc4d00ed920f20f838bfc664e2350591666db5a5c2b044a88165e0e7f04c58e2d4f0764bb9872aeaf625a91f7a19ce6909264115e7ce70b35c0c081eb4170cac869d3f8b576b7b4dc41580e',
|
|
162
|
+
prompt: 'enc:v2:beba836814161d59df3a2220ebc500ef:6023bde15c74be6bec641044a3f854de:4421b9f985a1914c06868c8577e57e26e3fd4fe4392c2c94862322fd30258eccae68fce6fd56e9a35427a724d3c22b735e9b5a822d05be098ae5e0662ea41c786e4acea12e4d80b598b543d346d7ccbdc32623'
|
|
163
|
+
}
|
|
149
164
|
};
|
|
150
165
|
|
|
151
|
-
// 解密 URL
|
|
166
|
+
// 解密 URL 函数(支持 AES-256-GCM)
|
|
152
167
|
function decryptUrl(encrypted, key) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
if (!encrypted) return '';
|
|
169
|
+
|
|
170
|
+
if (encrypted.startsWith('enc:v2:')) {
|
|
171
|
+
return decrypt(encrypted.replace('enc:v2:', ''), key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (encrypted.startsWith('enc:v1:')) {
|
|
175
|
+
const urlEncryptKey = key || 'chaimi-url-key-2024';
|
|
176
|
+
const hex = encrypted.replace('enc:v1:', '');
|
|
177
|
+
let result = '';
|
|
178
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
179
|
+
const byte = parseInt(hex.substr(i, 2), 16);
|
|
180
|
+
result += String.fromCharCode(byte ^ urlEncryptKey.charCodeAt((i / 2) % urlEncryptKey.length));
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
158
183
|
}
|
|
159
|
-
|
|
184
|
+
|
|
185
|
+
return encrypted;
|
|
160
186
|
}
|
|
161
187
|
|
|
162
|
-
// 配置
|
|
163
|
-
const MCP_HUB_URL = process.env.MCP_HUB_URL || decryptUrl(ENCRYPTED_URLS.hub, URL_ENCRYPT_KEY);
|
|
164
|
-
const MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || decryptUrl(ENCRYPTED_URLS.prompt, URL_ENCRYPT_KEY);
|
|
165
|
-
const MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || decryptUrl(ENCRYPTED_URLS.oauth, URL_ENCRYPT_KEY);
|
|
188
|
+
// ==================== URL 配置 ====================
|
|
166
189
|
|
|
167
|
-
|
|
168
|
-
let
|
|
190
|
+
let MCP_HUB_URL;
|
|
191
|
+
let MCP_PROMPT_URL;
|
|
192
|
+
let MCP_OAUTH_URL;
|
|
193
|
+
let tokenEncryptionKey;
|
|
194
|
+
|
|
195
|
+
// 初始化配置
|
|
196
|
+
async function initConfig() {
|
|
197
|
+
// 获取 URL 加密密钥
|
|
198
|
+
const urlEncryptKey = process.env.URL_ENCRYPT_KEY || 'd2e4168144a50c6d8d0c4692cd73331eb2bc1db0cd727afaf29ff9645d480e59';
|
|
199
|
+
|
|
200
|
+
// 获取 Token 加密密钥
|
|
201
|
+
tokenEncryptionKey = getOrCreateSecretKey();
|
|
202
|
+
|
|
203
|
+
// 获取 API 密钥
|
|
204
|
+
if (!CHAIMI_API_SECRET) {
|
|
205
|
+
CHAIMI_API_SECRET = process.env.CHAIMI_API_SECRET || 'chaimi-mcp-secret-2024';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 配置 URL
|
|
209
|
+
const envUrls = ENCRYPTED_URLS[ENV] || ENCRYPTED_URLS.production;
|
|
210
|
+
MCP_HUB_URL = process.env.MCP_HUB_URL || decryptUrl(envUrls.hub, urlEncryptKey);
|
|
211
|
+
MCP_PROMPT_URL = process.env.MCP_PROMPT_URL || decryptUrl(envUrls.prompt, urlEncryptKey);
|
|
212
|
+
MCP_OAUTH_URL = process.env.MCP_OAUTH_URL || decryptUrl(envUrls.oauth, urlEncryptKey);
|
|
213
|
+
|
|
214
|
+
console.error(`✅ 已加载 ${ENV} 环境配置`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Token 缓存(加密存储)
|
|
218
|
+
let cachedEncryptedToken = null;
|
|
169
219
|
let tokenExpireTime = 0;
|
|
170
220
|
|
|
221
|
+
// 获取解密的 Token
|
|
222
|
+
function getCachedToken() {
|
|
223
|
+
if (!cachedEncryptedToken || !tokenEncryptionKey) return null;
|
|
224
|
+
try {
|
|
225
|
+
return decrypt(cachedEncryptedToken, tokenEncryptionKey);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 设置加密的 Token
|
|
232
|
+
function setCachedToken(token) {
|
|
233
|
+
if (!token || !tokenEncryptionKey) {
|
|
234
|
+
cachedEncryptedToken = null;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
cachedEncryptedToken = encrypt(token, tokenEncryptionKey);
|
|
238
|
+
}
|
|
239
|
+
|
|
171
240
|
// OAuth 管理器实例
|
|
172
241
|
let oauthManager = null;
|
|
173
242
|
|
|
@@ -213,8 +282,8 @@ async function initOAuthManager() {
|
|
|
213
282
|
// 检查 Token 是否过期(预留5分钟缓冲)
|
|
214
283
|
const expiresAt = new Date(existingToken.expiresAt).getTime();
|
|
215
284
|
if (expiresAt > Date.now() + 5 * 60 * 1000) {
|
|
216
|
-
// Token
|
|
217
|
-
|
|
285
|
+
// Token 有效,设置授权状态(加密存储)
|
|
286
|
+
setCachedToken(existingToken.accessToken);
|
|
218
287
|
tokenExpireTime = expiresAt;
|
|
219
288
|
authState.isAuthorized = true;
|
|
220
289
|
console.error('✅ 已从文件加载有效 Token,无需重新授权');
|
|
@@ -490,6 +559,7 @@ let lastRefreshTime = 0;
|
|
|
490
559
|
|
|
491
560
|
async function getToken() {
|
|
492
561
|
// 【优化1】优先检查内存中的 token 是否有效
|
|
562
|
+
const cachedToken = getCachedToken();
|
|
493
563
|
if (cachedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
|
|
494
564
|
if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
|
|
495
565
|
return cachedToken;
|
|
@@ -502,13 +572,13 @@ async function getToken() {
|
|
|
502
572
|
if (existingToken && existingToken.accessToken && existingToken.expiresAt) {
|
|
503
573
|
const expiresAt = new Date(existingToken.expiresAt).getTime();
|
|
504
574
|
if (expiresAt > Date.now() + 5 * 60 * 1000) {
|
|
505
|
-
// Token
|
|
506
|
-
|
|
575
|
+
// Token 有效,直接使用(加密存储)
|
|
576
|
+
setCachedToken(existingToken.accessToken);
|
|
507
577
|
tokenExpireTime = expiresAt;
|
|
508
578
|
authState.isAuthorized = true;
|
|
509
579
|
lastRefreshTime = Date.now();
|
|
510
580
|
console.error('✅ 已从文件加载有效 Token,无需重新授权');
|
|
511
|
-
return
|
|
581
|
+
return getCachedToken();
|
|
512
582
|
} else {
|
|
513
583
|
console.error('⏰ 保存的 Token 已过期,需要重新授权');
|
|
514
584
|
}
|
|
@@ -532,8 +602,8 @@ async function getToken() {
|
|
|
532
602
|
try {
|
|
533
603
|
const token = await oauthManager.pollForTokenOnce(savedAuthState.deviceCode);
|
|
534
604
|
if (token) {
|
|
535
|
-
//
|
|
536
|
-
|
|
605
|
+
// 授权成功(加密存储)
|
|
606
|
+
setCachedToken(token.accessToken);
|
|
537
607
|
tokenExpireTime = token.expiresAt;
|
|
538
608
|
authState.isAuthorized = true;
|
|
539
609
|
await oauthManager.tokenStorage.save(token);
|
|
@@ -543,7 +613,7 @@ async function getToken() {
|
|
|
543
613
|
await oauthManager.clearAuthState();
|
|
544
614
|
console.error('✅ 授权成功!可以继续使用记账功能');
|
|
545
615
|
// 返回特殊标记,让上层知道这是刚完成授权的情况
|
|
546
|
-
throw new Error(`AUTH_JUST_COMPLETED:${
|
|
616
|
+
throw new Error(`AUTH_JUST_COMPLETED:${getCachedToken()}`);
|
|
547
617
|
}
|
|
548
618
|
// 用户还未授权,返回同一个验证码
|
|
549
619
|
authState.deviceCode = savedAuthState.deviceCode;
|
|
@@ -573,11 +643,11 @@ async function getToken() {
|
|
|
573
643
|
|
|
574
644
|
try {
|
|
575
645
|
const oauthToken = await oauthManager.getValidToken();
|
|
576
|
-
|
|
646
|
+
setCachedToken(oauthToken.accessToken);
|
|
577
647
|
tokenExpireTime = oauthToken.expiresAt;
|
|
578
648
|
lastRefreshTime = Date.now();
|
|
579
649
|
await oauthManager.clearAuthState();
|
|
580
|
-
return
|
|
650
|
+
return getCachedToken();
|
|
581
651
|
} catch (err) {
|
|
582
652
|
// Token 获取失败,可能需要重新授权
|
|
583
653
|
authState.isAuthorized = false;
|
|
@@ -2363,6 +2433,7 @@ async function fetchAgentNameFromCloud(deviceCode) {
|
|
|
2363
2433
|
}
|
|
2364
2434
|
|
|
2365
2435
|
async function main() {
|
|
2436
|
+
await initConfig();
|
|
2366
2437
|
await initOAuthManager();
|
|
2367
2438
|
|
|
2368
2439
|
const transport = new StdioServerTransport();
|
|
@@ -2433,8 +2504,8 @@ async function pollForAuthInBackground(deviceCode, interval) {
|
|
|
2433
2504
|
const token = await oauthManager.pollForTokenOnce(deviceCode);
|
|
2434
2505
|
|
|
2435
2506
|
if (token) {
|
|
2436
|
-
//
|
|
2437
|
-
|
|
2507
|
+
// 授权成功(加密存储)
|
|
2508
|
+
setCachedToken(token.accessToken);
|
|
2438
2509
|
tokenExpireTime = token.expiresAt;
|
|
2439
2510
|
authState.isAuthorized = true;
|
|
2440
2511
|
authState.isWaiting = false;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
4
|
+
const IV_LENGTH = 16;
|
|
5
|
+
const TAG_LENGTH = 16;
|
|
6
|
+
const SALT = 'chaimi-mcp-salt-2026';
|
|
7
|
+
|
|
8
|
+
function encrypt(text, key) {
|
|
9
|
+
let derivedKey;
|
|
10
|
+
if (typeof key === 'string') {
|
|
11
|
+
derivedKey = crypto.scryptSync(key, SALT, 32);
|
|
12
|
+
} else {
|
|
13
|
+
derivedKey = key;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
17
|
+
const cipher = crypto.createCipheriv(ALGORITHM, derivedKey, iv);
|
|
18
|
+
|
|
19
|
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
|
20
|
+
encrypted += cipher.final('hex');
|
|
21
|
+
|
|
22
|
+
const authTag = cipher.getAuthTag();
|
|
23
|
+
|
|
24
|
+
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function decrypt(encryptedData, key) {
|
|
28
|
+
const parts = encryptedData.split(':');
|
|
29
|
+
if (parts.length !== 3) {
|
|
30
|
+
throw new Error('无效的加密格式');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let derivedKey;
|
|
34
|
+
if (typeof key === 'string') {
|
|
35
|
+
derivedKey = crypto.scryptSync(key, SALT, 32);
|
|
36
|
+
} else {
|
|
37
|
+
derivedKey = key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
41
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
42
|
+
const encrypted = Buffer.from(parts[2], 'hex');
|
|
43
|
+
|
|
44
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, derivedKey, iv);
|
|
45
|
+
decipher.setAuthTag(authTag);
|
|
46
|
+
|
|
47
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
48
|
+
decrypted += decipher.final('utf8');
|
|
49
|
+
|
|
50
|
+
return decrypted;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { encrypt, decrypt };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
const KEY_FILE = path.join(os.homedir(), '.chaimi-keep', '.mcp-secret-key');
|
|
7
|
+
const KEY_SIZE = 32; // 256 bits
|
|
8
|
+
|
|
9
|
+
function getOrCreateSecretKey() {
|
|
10
|
+
// 1. 优先使用环境变量
|
|
11
|
+
if (process.env.MCP_SECRET_KEY) {
|
|
12
|
+
return Buffer.from(process.env.MCP_SECRET_KEY, 'hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 2. 尝试从文件加载
|
|
16
|
+
if (fs.existsSync(KEY_FILE)) {
|
|
17
|
+
const keyHex = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
|
18
|
+
return Buffer.from(keyHex, 'hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 3. 自动生成新密钥
|
|
22
|
+
const newKey = crypto.randomBytes(KEY_SIZE);
|
|
23
|
+
const keyHex = newKey.toString('hex');
|
|
24
|
+
|
|
25
|
+
// 确保目录存在
|
|
26
|
+
const keyDir = path.dirname(KEY_FILE);
|
|
27
|
+
if (!fs.existsSync(keyDir)) {
|
|
28
|
+
fs.mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 写入文件,权限 0600
|
|
32
|
+
fs.writeFileSync(KEY_FILE, keyHex, { mode: 0o600 });
|
|
33
|
+
|
|
34
|
+
console.error('✅ 已自动生成安全密钥');
|
|
35
|
+
return newKey;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { getOrCreateSecretKey, KEY_FILE };
|