chaimi-bookkeeping-mcp 2.3.7 → 2.3.10
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 +9 -192
- package/package.json +1 -1
- package/server.js +23 -241
- package/test-mcp.sh +26 -0
- package/test-parent-process.js +49 -0
package/oauth.js
CHANGED
|
@@ -13,74 +13,37 @@ class OAuthManager {
|
|
|
13
13
|
constructor(config) {
|
|
14
14
|
this.mcpOAuthUrl = config.mcpOAuthUrl;
|
|
15
15
|
this.tokenStorage = config.tokenStorage;
|
|
16
|
-
this.onQrCode = config.onQrCode;
|
|
17
|
-
this.onTokenReady = config.onTokenReady;
|
|
18
|
-
this.preferUrlScheme = config.preferUrlScheme !== false;
|
|
16
|
+
this.onQrCode = config.onQrCode;
|
|
17
|
+
this.onTokenReady = config.onTokenReady;
|
|
18
|
+
this.preferUrlScheme = config.preferUrlScheme !== false;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* 检测当前环境是否支持浏览器/URL Scheme
|
|
23
|
-
*/
|
|
24
21
|
async detectEnvironment() {
|
|
25
|
-
// 检测是否有 DISPLAY 环境变量(Linux GUI)
|
|
26
22
|
if (process.env.DISPLAY) {
|
|
27
23
|
return { supportsBrowser: true, platform: 'linux-gui' };
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
// 检测是否为 macOS
|
|
31
26
|
if (process.platform === 'darwin') {
|
|
32
27
|
return { supportsBrowser: true, platform: 'macos' };
|
|
33
28
|
}
|
|
34
29
|
|
|
35
|
-
// 检测是否为 Windows
|
|
36
30
|
if (process.platform === 'win32') {
|
|
37
31
|
return { supportsBrowser: true, platform: 'windows' };
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
// 检测是否有 BROWSER 环境变量
|
|
41
34
|
if (process.env.BROWSER) {
|
|
42
35
|
return { supportsBrowser: true, platform: 'custom' };
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
// 默认认为不支持浏览器(纯 CLI 环境如 Claude Desktop)
|
|
46
38
|
return { supportsBrowser: false, platform: 'cli' };
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
/**
|
|
50
|
-
* 启动 OAuth 授权流程
|
|
51
|
-
* 自动检测环境,选择最佳交互方式
|
|
52
|
-
*/
|
|
53
41
|
async startAuthFlow() {
|
|
54
42
|
try {
|
|
55
|
-
console.log('\n');
|
|
56
|
-
console.log('╔════════════════════════════════════════════════════════╗');
|
|
57
|
-
console.log('║ 🔴 【重要】首次使用需要完成 OAuth 授权 ║');
|
|
58
|
-
console.log('║ ⏱️ 授权流程约需 1-2 分钟,请耐心等待 ║');
|
|
59
|
-
console.log('╚════════════════════════════════════════════════════════╝');
|
|
60
|
-
console.log('\n');
|
|
61
|
-
|
|
62
|
-
// 检测环境
|
|
63
43
|
const env = await this.detectEnvironment();
|
|
64
|
-
console.log(`检测到环境: ${env.platform} (${env.supportsBrowser ? '支持浏览器' : '纯终端'})`);
|
|
65
|
-
|
|
66
|
-
// 1. 获取设备码(根据环境决定是否请求 URL Scheme)
|
|
67
44
|
const useUrlScheme = this.preferUrlScheme && env.supportsBrowser;
|
|
68
45
|
const deviceCodeRes = await this.requestDeviceCode(useUrlScheme);
|
|
69
46
|
|
|
70
|
-
console.log('✅ 获取设备码成功');
|
|
71
|
-
console.log('');
|
|
72
|
-
|
|
73
|
-
// 【柴米记账授权】格式化输出,确保Agent能识别
|
|
74
|
-
const authMessage = `
|
|
75
|
-
【柴米记账授权】
|
|
76
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
77
|
-
📱 验证码: ${deviceCodeRes.userCode}
|
|
78
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
79
|
-
请在微信柴米记账小程序中输入此验证码完成授权
|
|
80
|
-
`;
|
|
81
|
-
console.log(authMessage);
|
|
82
|
-
|
|
83
|
-
// 保存授权状态,支持进程重启后复用
|
|
84
47
|
await this.saveAuthState({
|
|
85
48
|
deviceCode: deviceCodeRes.deviceCode,
|
|
86
49
|
userCode: deviceCodeRes.userCode,
|
|
@@ -88,88 +51,42 @@ class OAuthManager {
|
|
|
88
51
|
status: 'pending'
|
|
89
52
|
});
|
|
90
53
|
|
|
91
|
-
// 2. 根据环境选择交互方式
|
|
92
54
|
if (useUrlScheme && deviceCodeRes.urlScheme) {
|
|
93
|
-
// 方式A:URL Scheme(浏览器环境)
|
|
94
55
|
await this.authorizeWithUrlScheme(deviceCodeRes);
|
|
95
56
|
} else {
|
|
96
|
-
// 方式B:纯 Device Flow(CLI环境)
|
|
97
57
|
await this.authorizeWithDeviceFlow(deviceCodeRes);
|
|
98
58
|
}
|
|
99
59
|
|
|
100
|
-
// 3. 轮询获取 Token
|
|
101
|
-
console.log('');
|
|
102
|
-
console.log('⏳ 等待用户授权,请勿关闭窗口...');
|
|
103
|
-
console.log(' (请在手机微信中完成授权操作)');
|
|
104
|
-
console.log(' 授权完成后会自动继续,无需手动告知');
|
|
105
|
-
console.log('');
|
|
106
60
|
const token = await this.pollForToken(
|
|
107
61
|
deviceCodeRes.deviceCode,
|
|
108
|
-
2000
|
|
62
|
+
2000
|
|
109
63
|
);
|
|
110
64
|
|
|
111
|
-
// 4. 存储 Token
|
|
112
65
|
await this.tokenStorage.save(token);
|
|
113
66
|
|
|
114
|
-
console.log('');
|
|
115
|
-
console.log('╔════════════════════════════════════════════════════════╗');
|
|
116
|
-
console.log('║ ✅ 授权成功!现在可以开始使用柴米记账 MCP Server ║');
|
|
117
|
-
console.log('╚════════════════════════════════════════════════════════╝');
|
|
118
|
-
console.log('');
|
|
119
|
-
|
|
120
67
|
if (this.onTokenReady) {
|
|
121
68
|
this.onTokenReady(token);
|
|
122
69
|
}
|
|
123
70
|
|
|
124
71
|
return token;
|
|
125
72
|
} catch (err) {
|
|
126
|
-
console.error('\n❌ 授权失败:', err.message);
|
|
127
|
-
console.log('========================================\n');
|
|
128
73
|
throw err;
|
|
129
74
|
}
|
|
130
75
|
}
|
|
131
76
|
|
|
132
|
-
/**
|
|
133
|
-
* 使用 URL Scheme 方式授权(浏览器环境)
|
|
134
|
-
* PC端:显示二维码 + 验证码
|
|
135
|
-
* 手机端:打开跳转链接
|
|
136
|
-
*/
|
|
137
77
|
async authorizeWithUrlScheme(deviceCodeRes) {
|
|
138
|
-
// 检测是否为移动端环境
|
|
139
78
|
const isMobile = this.detectMobileEnvironment();
|
|
140
79
|
|
|
141
80
|
if (isMobile) {
|
|
142
|
-
// 手机端:打开 URL Scheme 跳转链接
|
|
143
|
-
console.log('\n📱 请在微信中完成授权');
|
|
144
|
-
console.log('正在尝试自动打开微信...\n');
|
|
145
|
-
|
|
146
81
|
const url = deviceCodeRes.urlScheme || deviceCodeRes.verificationUriFull;
|
|
147
82
|
|
|
148
83
|
try {
|
|
149
84
|
await this.openBrowser(url);
|
|
150
|
-
console.log('✅ 已尝试打开微信');
|
|
151
|
-
console.log('请在微信中完成授权\n');
|
|
152
85
|
} catch (err) {
|
|
153
|
-
console.log('⚠️ 无法自动打开微信,请手动操作:');
|
|
154
|
-
console.log(`1. 复制链接: ${url}`);
|
|
155
|
-
console.log('2. 在微信中打开该链接\n');
|
|
156
86
|
}
|
|
157
87
|
} else {
|
|
158
|
-
// PC端:显示二维码 + 验证码
|
|
159
|
-
console.log('\n💻 请在手机微信中完成授权');
|
|
160
|
-
console.log('========================================');
|
|
161
|
-
console.log('');
|
|
162
|
-
|
|
163
|
-
// 生成小程序二维码链接
|
|
164
88
|
const qrCodeUrl = `https://mcp.chaihuo.com/auth?deviceCode=${deviceCodeRes.deviceCode}&userCode=${deviceCodeRes.userCode}`;
|
|
165
89
|
|
|
166
|
-
// 显示二维码(使用 ASCII 艺术或链接)
|
|
167
|
-
console.log('请使用微信扫描下方二维码:');
|
|
168
|
-
console.log('');
|
|
169
|
-
console.log(` ${qrCodeUrl}`);
|
|
170
|
-
console.log('');
|
|
171
|
-
|
|
172
|
-
// 如果有二维码回调,显示图形二维码
|
|
173
90
|
if (this.onQrCode) {
|
|
174
91
|
await this.onQrCode({
|
|
175
92
|
userCode: deviceCodeRes.userCode,
|
|
@@ -179,81 +96,41 @@ class OAuthManager {
|
|
|
179
96
|
isPC: true
|
|
180
97
|
});
|
|
181
98
|
}
|
|
182
|
-
|
|
183
|
-
console.log('========================================');
|
|
184
|
-
console.log('');
|
|
185
|
-
console.log('或者手动输入验证码:');
|
|
186
|
-
console.log(` 验证码: ${deviceCodeRes.userCode}`);
|
|
187
|
-
console.log('');
|
|
188
|
-
console.log('操作步骤:');
|
|
189
|
-
console.log('1. 打开微信,进入"柴米记账"小程序');
|
|
190
|
-
console.log('2. 点击"我的" → "🤖 Agent 授权"');
|
|
191
|
-
console.log(`3. 输入验证码: ${deviceCodeRes.userCode}`);
|
|
192
|
-
console.log('4. 点击"确认授权"');
|
|
193
|
-
console.log('');
|
|
194
99
|
}
|
|
195
100
|
}
|
|
196
101
|
|
|
197
|
-
/**
|
|
198
|
-
* 检测是否为移动端环境
|
|
199
|
-
*/
|
|
200
102
|
detectMobileEnvironment() {
|
|
201
|
-
// 检测 User Agent
|
|
202
103
|
const userAgent = process.env.USER_AGENT || '';
|
|
203
104
|
if (/iPhone|iPad|iPod|Android|Mobile/i.test(userAgent)) {
|
|
204
105
|
return true;
|
|
205
106
|
}
|
|
206
107
|
|
|
207
|
-
// 检测是否有移动端特定的环境变量
|
|
208
108
|
if (process.env.TERM_PROGRAM === 'Apple_Terminal' && process.platform === 'darwin') {
|
|
209
|
-
// macOS 终端,可能是 iTerm2 等,需要进一步判断
|
|
210
|
-
// 这里假设 PC 端
|
|
211
109
|
return false;
|
|
212
110
|
}
|
|
213
111
|
|
|
214
|
-
// 默认认为是 PC 端(保守策略)
|
|
215
|
-
// 因为 PC 端显示二维码是通用的,手机端打开链接需要确认是移动端
|
|
216
112
|
return false;
|
|
217
113
|
}
|
|
218
114
|
|
|
219
|
-
/**
|
|
220
|
-
* 使用纯 Device Flow 方式授权(CLI环境)
|
|
221
|
-
*/
|
|
222
115
|
async authorizeWithDeviceFlow(deviceCodeRes) {
|
|
223
|
-
console.log(`\n验证地址: ${deviceCodeRes.verificationUri}\n`);
|
|
224
|
-
|
|
225
|
-
// 显示二维码(如果提供了回调)
|
|
226
116
|
if (this.onQrCode) {
|
|
227
117
|
await this.onQrCode({
|
|
228
118
|
userCode: deviceCodeRes.userCode,
|
|
229
119
|
verificationUri: deviceCodeRes.verificationUri,
|
|
230
120
|
deviceCode: deviceCodeRes.deviceCode
|
|
231
121
|
});
|
|
232
|
-
} else {
|
|
233
|
-
// 默认输出到控制台
|
|
234
|
-
console.log('请按以下步骤完成授权:');
|
|
235
|
-
console.log('1. 打开手机微信,搜索小程序"柴米记账"');
|
|
236
|
-
console.log('2. 进入"设置" → "授权管理"');
|
|
237
|
-
console.log(`3. 输入验证码: ${deviceCodeRes.userCode}`);
|
|
238
|
-
console.log('4. 点击"确认授权"\n');
|
|
239
122
|
}
|
|
240
123
|
}
|
|
241
124
|
|
|
242
|
-
/**
|
|
243
|
-
* 尝试打开浏览器
|
|
244
|
-
*/
|
|
245
125
|
async openBrowser(url) {
|
|
246
126
|
const platform = process.platform;
|
|
247
127
|
let command;
|
|
248
128
|
|
|
249
129
|
if (platform === 'darwin') {
|
|
250
|
-
// macOS
|
|
251
130
|
command = `open "${url}"`;
|
|
252
131
|
} else if (platform === 'win32') {
|
|
253
|
-
// Windows
|
|
254
132
|
command = `start "" "${url}"`;
|
|
255
133
|
} else if (platform === 'linux') {
|
|
256
|
-
// Linux
|
|
257
134
|
command = `xdg-open "${url}"`;
|
|
258
135
|
} else {
|
|
259
136
|
throw new Error('不支持的平台');
|
|
@@ -262,10 +139,6 @@ class OAuthManager {
|
|
|
262
139
|
await execPromise(command);
|
|
263
140
|
}
|
|
264
141
|
|
|
265
|
-
/**
|
|
266
|
-
* 请求设备码
|
|
267
|
-
* @param {boolean} useUrlScheme - 是否请求 URL Scheme
|
|
268
|
-
*/
|
|
269
142
|
async requestDeviceCode(useUrlScheme = false) {
|
|
270
143
|
const response = await fetch(this.mcpOAuthUrl, {
|
|
271
144
|
method: 'POST',
|
|
@@ -290,17 +163,13 @@ class OAuthManager {
|
|
|
290
163
|
return result.data;
|
|
291
164
|
}
|
|
292
165
|
|
|
293
|
-
/**
|
|
294
|
-
* 轮询获取 Token
|
|
295
|
-
*/
|
|
296
166
|
async pollForToken(deviceCode, interval) {
|
|
297
|
-
const maxAttempts = 60;
|
|
167
|
+
const maxAttempts = 60;
|
|
298
168
|
let attempts = 0;
|
|
299
169
|
|
|
300
170
|
while (attempts < maxAttempts) {
|
|
301
171
|
attempts++;
|
|
302
172
|
|
|
303
|
-
// 等待间隔时间
|
|
304
173
|
await this.delay(interval);
|
|
305
174
|
|
|
306
175
|
try {
|
|
@@ -320,7 +189,6 @@ class OAuthManager {
|
|
|
320
189
|
const result = await response.json();
|
|
321
190
|
|
|
322
191
|
if (result.success) {
|
|
323
|
-
// 获取 Token 成功
|
|
324
192
|
return {
|
|
325
193
|
accessToken: result.data.accessToken,
|
|
326
194
|
refreshToken: result.data.refreshToken,
|
|
@@ -330,11 +198,7 @@ class OAuthManager {
|
|
|
330
198
|
};
|
|
331
199
|
}
|
|
332
200
|
|
|
333
|
-
// 处理错误
|
|
334
201
|
if (result.error === 'authorization_pending') {
|
|
335
|
-
// 仍在等待授权,继续轮询
|
|
336
|
-
const elapsedSeconds = attempts * (interval / 1000);
|
|
337
|
-
process.stdout.write(`\r 等待中... ${elapsedSeconds}秒(请在小程完成授权)`);
|
|
338
202
|
continue;
|
|
339
203
|
}
|
|
340
204
|
|
|
@@ -351,17 +215,12 @@ class OAuthManager {
|
|
|
351
215
|
if (err.message.includes('expired') || err.message.includes('invalid')) {
|
|
352
216
|
throw err;
|
|
353
217
|
}
|
|
354
|
-
// 网络错误,继续轮询
|
|
355
|
-
console.warn(`轮询出错: ${err.message},继续尝试...`);
|
|
356
218
|
}
|
|
357
219
|
}
|
|
358
220
|
|
|
359
221
|
throw new Error('授权超时,请重新尝试');
|
|
360
222
|
}
|
|
361
223
|
|
|
362
|
-
/**
|
|
363
|
-
* 刷新 Access Token
|
|
364
|
-
*/
|
|
365
224
|
async refreshToken() {
|
|
366
225
|
const currentToken = await this.tokenStorage.load();
|
|
367
226
|
|
|
@@ -392,7 +251,6 @@ class OAuthManager {
|
|
|
392
251
|
throw new Error(`刷新 Token 失败: ${result.error}`);
|
|
393
252
|
}
|
|
394
253
|
|
|
395
|
-
// 更新 Token
|
|
396
254
|
const newToken = {
|
|
397
255
|
accessToken: result.data.accessToken,
|
|
398
256
|
refreshToken: result.data.refreshToken,
|
|
@@ -403,19 +261,12 @@ class OAuthManager {
|
|
|
403
261
|
|
|
404
262
|
await this.tokenStorage.save(newToken);
|
|
405
263
|
|
|
406
|
-
console.log('✅ Token 刷新成功');
|
|
407
|
-
|
|
408
264
|
return newToken;
|
|
409
265
|
} catch (err) {
|
|
410
|
-
console.error('刷新 Token 失败:', err.message);
|
|
411
266
|
throw err;
|
|
412
267
|
}
|
|
413
268
|
}
|
|
414
269
|
|
|
415
|
-
/**
|
|
416
|
-
* 获取有效的 Access Token
|
|
417
|
-
* 如果即将过期,自动刷新
|
|
418
|
-
*/
|
|
419
270
|
async getValidToken() {
|
|
420
271
|
const token = await this.tokenStorage.load();
|
|
421
272
|
|
|
@@ -423,53 +274,36 @@ class OAuthManager {
|
|
|
423
274
|
throw new Error('没有存储的 Token,请先授权');
|
|
424
275
|
}
|
|
425
276
|
|
|
426
|
-
|
|
427
|
-
const refreshThreshold = 5 * 60 * 1000; // 5分钟
|
|
277
|
+
const refreshThreshold = 5 * 60 * 1000;
|
|
428
278
|
if (Date.now() + refreshThreshold > token.expiresAt) {
|
|
429
|
-
console.log('Token 即将过期,自动刷新...');
|
|
430
279
|
return await this.refreshToken();
|
|
431
280
|
}
|
|
432
281
|
|
|
433
282
|
return token;
|
|
434
283
|
}
|
|
435
284
|
|
|
436
|
-
/**
|
|
437
|
-
* 延迟函数
|
|
438
|
-
*/
|
|
439
285
|
delay(ms) {
|
|
440
286
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
441
287
|
}
|
|
442
288
|
|
|
443
|
-
/**
|
|
444
|
-
* 保存授权状态到文件
|
|
445
|
-
* 支持进程重启后复用验证码
|
|
446
|
-
*/
|
|
447
289
|
async saveAuthState(state) {
|
|
448
290
|
try {
|
|
449
291
|
const fs = require('fs').promises;
|
|
450
292
|
const path = require('path');
|
|
451
293
|
const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
|
|
452
294
|
|
|
453
|
-
// 确保目录存在
|
|
454
295
|
const dir = path.dirname(stateFile);
|
|
455
296
|
await fs.mkdir(dir, { recursive: true });
|
|
456
297
|
|
|
457
|
-
// 保存状态
|
|
458
298
|
await fs.writeFile(
|
|
459
299
|
stateFile,
|
|
460
300
|
JSON.stringify(state, null, 2),
|
|
461
301
|
{ mode: 0o600 }
|
|
462
302
|
);
|
|
463
303
|
} catch (err) {
|
|
464
|
-
// 保存失败不影响主流程
|
|
465
|
-
console.warn('保存授权状态失败:', err.message);
|
|
466
304
|
}
|
|
467
305
|
}
|
|
468
306
|
|
|
469
|
-
/**
|
|
470
|
-
* 加载授权状态
|
|
471
|
-
* 用于进程重启后恢复授权流程
|
|
472
|
-
*/
|
|
473
307
|
async loadAuthState() {
|
|
474
308
|
try {
|
|
475
309
|
const fs = require('fs').promises;
|
|
@@ -480,17 +314,12 @@ class OAuthManager {
|
|
|
480
314
|
return JSON.parse(data);
|
|
481
315
|
} catch (err) {
|
|
482
316
|
if (err.code === 'ENOENT') {
|
|
483
|
-
return null;
|
|
317
|
+
return null;
|
|
484
318
|
}
|
|
485
|
-
console.warn('加载授权状态失败:', err.message);
|
|
486
319
|
return null;
|
|
487
320
|
}
|
|
488
321
|
}
|
|
489
322
|
|
|
490
|
-
/**
|
|
491
|
-
* 清除授权状态
|
|
492
|
-
* 授权成功或过期后清理
|
|
493
|
-
*/
|
|
494
323
|
async clearAuthState() {
|
|
495
324
|
try {
|
|
496
325
|
const fs = require('fs').promises;
|
|
@@ -498,24 +327,15 @@ class OAuthManager {
|
|
|
498
327
|
const stateFile = path.join(require('os').homedir(), '.mcporter', 'auth-state.json');
|
|
499
328
|
await fs.unlink(stateFile);
|
|
500
329
|
} catch (err) {
|
|
501
|
-
// 文件不存在或其他错误,忽略
|
|
502
330
|
}
|
|
503
331
|
}
|
|
504
332
|
|
|
505
|
-
/**
|
|
506
|
-
* 检查授权状态是否过期
|
|
507
|
-
* 默认10分钟有效期
|
|
508
|
-
*/
|
|
509
333
|
isAuthStateExpired(state, maxAge = 10 * 60 * 1000) {
|
|
510
334
|
if (!state || !state.timestamp) return true;
|
|
511
335
|
return Date.now() - state.timestamp > maxAge;
|
|
512
336
|
}
|
|
513
337
|
}
|
|
514
338
|
|
|
515
|
-
/**
|
|
516
|
-
* Token 存储接口(基类)
|
|
517
|
-
* 子类需要实现 save 和 load 方法
|
|
518
|
-
*/
|
|
519
339
|
class TokenStorage {
|
|
520
340
|
async save(token) {
|
|
521
341
|
throw new Error('TokenStorage.save 必须被子类实现');
|
|
@@ -530,9 +350,6 @@ class TokenStorage {
|
|
|
530
350
|
}
|
|
531
351
|
}
|
|
532
352
|
|
|
533
|
-
/**
|
|
534
|
-
* 文件存储实现
|
|
535
|
-
*/
|
|
536
353
|
class FileTokenStorage extends TokenStorage {
|
|
537
354
|
constructor(filePath) {
|
|
538
355
|
super();
|
|
@@ -547,7 +364,7 @@ class FileTokenStorage extends TokenStorage {
|
|
|
547
364
|
await this.fs.writeFile(
|
|
548
365
|
this.filePath,
|
|
549
366
|
JSON.stringify(token, null, 2),
|
|
550
|
-
{ mode: 0o600 }
|
|
367
|
+
{ mode: 0o600 }
|
|
551
368
|
);
|
|
552
369
|
}
|
|
553
370
|
|
|
@@ -557,7 +374,7 @@ class FileTokenStorage extends TokenStorage {
|
|
|
557
374
|
return JSON.parse(data);
|
|
558
375
|
} catch (err) {
|
|
559
376
|
if (err.code === 'ENOENT') {
|
|
560
|
-
return null;
|
|
377
|
+
return null;
|
|
561
378
|
}
|
|
562
379
|
throw err;
|
|
563
380
|
}
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -25,7 +25,6 @@ const fs = require('fs');
|
|
|
25
25
|
// 读取 package.json 获取版本
|
|
26
26
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
27
27
|
const MCP_VERSION = packageJson.version;
|
|
28
|
-
console.log('柴米记账 MCP Server 版本:', MCP_VERSION);
|
|
29
28
|
|
|
30
29
|
// 导入 OAuth 模块
|
|
31
30
|
const { OAuthManager, FileTokenStorage } = require('./oauth.js');
|
|
@@ -52,16 +51,8 @@ function initOAuthManager() {
|
|
|
52
51
|
mcpOAuthUrl: MCP_OAUTH_URL,
|
|
53
52
|
tokenStorage: tokenStorage,
|
|
54
53
|
onQrCode: (qrData) => {
|
|
55
|
-
// 显示授权信息
|
|
56
|
-
console.log('\n========================================');
|
|
57
|
-
console.log('🔐 请完成授权');
|
|
58
|
-
console.log('========================================');
|
|
59
|
-
console.log(`验证码: ${qrData.userCode}`);
|
|
60
|
-
console.log(`验证地址: ${qrData.verificationUri}`);
|
|
61
|
-
console.log('========================================\n');
|
|
62
54
|
},
|
|
63
55
|
onTokenReady: (token) => {
|
|
64
|
-
console.log('✅ Token 已就绪,可以开始使用');
|
|
65
56
|
}
|
|
66
57
|
});
|
|
67
58
|
|
|
@@ -85,27 +76,6 @@ const server = new Server(
|
|
|
85
76
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
86
77
|
return {
|
|
87
78
|
tools: [
|
|
88
|
-
/*
|
|
89
|
-
{
|
|
90
|
-
name: 'quick_book',
|
|
91
|
-
description: '【推荐首选】极简快捷记账 - 自动识别支出/收入,智能匹配分类。仅需 name 和 amount 两个参数,其他自动补全。输入如:"午餐 30 元"、"工资 8000 元"、"木屋烧烤 35 元"',
|
|
92
|
-
inputSchema: {
|
|
93
|
-
type: 'object',
|
|
94
|
-
properties: {
|
|
95
|
-
name: { type: 'string', description: '商品/服务名称或自然语言描述(必填)' },
|
|
96
|
-
amount: { type: 'number', description: '金额(必填)' },
|
|
97
|
-
category: { type: 'string', description: '分类(可选,系统自动推荐)' },
|
|
98
|
-
store: { type: 'string', description: '商家名称(可选)' },
|
|
99
|
-
note: { type: 'string', description: '备注(可选)' },
|
|
100
|
-
agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
101
|
-
apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
|
|
102
|
-
rawInput: { type: 'string', description: '【推荐自动填充】用户的原始输入内容,用于记录用户原始请求' },
|
|
103
|
-
mcp_version: { type: 'string', description: 'MCP Server 版本号(自动填充)' },
|
|
104
|
-
},
|
|
105
|
-
required: ['name', 'amount'],
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
*/
|
|
109
79
|
{
|
|
110
80
|
name: 'save_expense',
|
|
111
81
|
description: '保存单商品消费记录(AI文字记账)。只需提供商品名称和金额,其他参数自动填充。示例:name="午餐", amount=35',
|
|
@@ -127,7 +97,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
127
97
|
},
|
|
128
98
|
{
|
|
129
99
|
name: 'save_receipt',
|
|
130
|
-
description: '
|
|
100
|
+
description: '【图片小票专用】保存购物小票/发票/收据(支持单商品或多商品)。当用户提供图片形式的小票、发票时,无论商品数量多少(1条或多条),都必须使用此接口。可自动识别商家名称、商品明细、金额、优惠等信息。典型场景:超市购物小票、餐厅发票、线上订单截图、手写收据照片等',
|
|
131
101
|
inputSchema: {
|
|
132
102
|
type: 'object',
|
|
133
103
|
properties: {
|
|
@@ -210,9 +180,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
210
180
|
category: { type: 'string', description: '收入分类(如:工资、奖金、投资)' },
|
|
211
181
|
store: { type: 'string', description: '付款方(如:公司名称)' },
|
|
212
182
|
note: { type: 'string', description: '备注' },
|
|
213
|
-
agentType: { type: 'string', description: 'Agent
|
|
214
|
-
apiProvider: { type: 'string', description: 'AI
|
|
215
|
-
rawInput: { type: 'string', description: '
|
|
183
|
+
agentType: { type: 'string', description: '【推荐自动填充】Agent类型,如:claude-desktop、cursor、openclaw、workbuddy、trae' },
|
|
184
|
+
apiProvider: { type: 'string', description: '【推荐自动填充】AI服务提供商,如:anthropic、openai、doubao、aliyun' },
|
|
185
|
+
rawInput: { type: 'string', description: '【推荐自动填充】用户的原始输入内容,用于记录用户原始请求' },
|
|
216
186
|
mcp_version: { type: 'string', description: 'MCP Server 版本号(自动填充)' },
|
|
217
187
|
},
|
|
218
188
|
required: ['name', 'amount'],
|
|
@@ -222,9 +192,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
222
192
|
};
|
|
223
193
|
});
|
|
224
194
|
|
|
225
|
-
// 工具名称映射:
|
|
195
|
+
// 工具名称映射:MCP Tool -> mcpHub Tool
|
|
226
196
|
const toolMapping = {
|
|
227
|
-
'quick_book': 'addExpense', // quick_book 内部会判断支出/收入
|
|
228
197
|
'save_expense': 'addExpense',
|
|
229
198
|
'save_receipt': 'addReceipt',
|
|
230
199
|
'save_income': 'addIncome',
|
|
@@ -234,26 +203,16 @@ const toolMapping = {
|
|
|
234
203
|
};
|
|
235
204
|
|
|
236
205
|
// 获取或刷新 Token(OAuth 2.0 Device Flow)
|
|
237
|
-
|
|
238
|
-
const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000; // 2小时
|
|
206
|
+
const TOKEN_REFRESH_INTERVAL = 2 * 60 * 60 * 1000;
|
|
239
207
|
let lastRefreshTime = 0;
|
|
240
208
|
|
|
241
209
|
async function getToken() {
|
|
242
|
-
// 检查缓存的 Token 是否有效(提前5分钟过期)
|
|
243
210
|
if (cachedToken && tokenExpireTime > Date.now() + 5 * 60 * 1000) {
|
|
244
|
-
// 检查是否需要主动刷新(超过24小时)
|
|
245
211
|
if (Date.now() - lastRefreshTime < TOKEN_REFRESH_INTERVAL) {
|
|
246
212
|
return cachedToken;
|
|
247
213
|
}
|
|
248
|
-
// 超过2小时,尝试刷新
|
|
249
|
-
console.log('Token 超过2小时,尝试自动刷新...');
|
|
250
|
-
} else if (cachedToken && tokenExpireTime <= Date.now() + 5 * 60 * 1000) {
|
|
251
|
-
// Token 已过期,添加友好提示
|
|
252
|
-
console.log('🔐 Token 已过期,需要重新授权');
|
|
253
|
-
console.log('请使用 mcporter 调用 柴米记账.save_expense() 开始授权');
|
|
254
214
|
}
|
|
255
215
|
|
|
256
|
-
// 使用 OAuth 获取有效 Token
|
|
257
216
|
if (!oauthManager) {
|
|
258
217
|
throw new Error('OAuth 管理器未初始化,请检查 MCP_OAUTH_URL 配置');
|
|
259
218
|
}
|
|
@@ -263,31 +222,18 @@ async function getToken() {
|
|
|
263
222
|
cachedToken = oauthToken.accessToken;
|
|
264
223
|
tokenExpireTime = oauthToken.expiresAt;
|
|
265
224
|
lastRefreshTime = Date.now();
|
|
266
|
-
// 授权成功,清除授权状态
|
|
267
225
|
await oauthManager.clearAuthState();
|
|
268
226
|
return cachedToken;
|
|
269
227
|
} catch (err) {
|
|
270
|
-
// OAuth Token 无效,检查是否有未完成的授权
|
|
271
|
-
console.log('🔐 Token 已过期,需要重新授权');
|
|
272
|
-
console.log('请使用 mcporter 调用 柴米记账.save_expense() 开始授权');
|
|
273
|
-
console.log('OAuth Token 无效,检查授权状态...');
|
|
274
|
-
|
|
275
|
-
// 检查是否有未完成的授权
|
|
276
228
|
const authState = await oauthManager.loadAuthState();
|
|
277
229
|
if (authState && !oauthManager.isAuthStateExpired(authState)) {
|
|
278
|
-
console.log(`检测到未完成的授权,验证码: ${authState.userCode}`);
|
|
279
|
-
console.log('请在微信柴米记账小程序中输入此验证码完成授权');
|
|
280
|
-
console.log('授权完成后,请重新调用工具');
|
|
281
230
|
throw new Error(`授权进行中,请使用验证码 ${authState.userCode} 完成授权`);
|
|
282
231
|
}
|
|
283
232
|
|
|
284
|
-
// 没有未完成的授权,启动新的授权流程
|
|
285
|
-
console.log('启动新的授权流程...');
|
|
286
233
|
const newToken = await oauthManager.startAuthFlow();
|
|
287
234
|
cachedToken = newToken.accessToken;
|
|
288
235
|
tokenExpireTime = newToken.expiresAt;
|
|
289
236
|
lastRefreshTime = Date.now();
|
|
290
|
-
// 授权成功,清除授权状态
|
|
291
237
|
await oauthManager.clearAuthState();
|
|
292
238
|
return cachedToken;
|
|
293
239
|
}
|
|
@@ -341,14 +287,9 @@ async function callMcpHub(tool, params, token) {
|
|
|
341
287
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
342
288
|
const { name, arguments: args } = request.params;
|
|
343
289
|
|
|
344
|
-
// 【强制日志】测试日志是否输出 - 写入文件
|
|
345
|
-
const fs = require('fs');
|
|
346
|
-
|
|
347
|
-
// 从环境变量获取 agentType 和 apiProvider
|
|
348
290
|
const agentTypeFromEnv = process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || '';
|
|
349
291
|
const apiProviderFromEnv = process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || '';
|
|
350
292
|
|
|
351
|
-
// 如果参数中没有,使用环境变量的值
|
|
352
293
|
if (!args.agentType && agentTypeFromEnv) {
|
|
353
294
|
args.agentType = agentTypeFromEnv;
|
|
354
295
|
}
|
|
@@ -356,138 +297,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
356
297
|
args.apiProvider = apiProviderFromEnv;
|
|
357
298
|
}
|
|
358
299
|
|
|
359
|
-
const logMsg = `
|
|
360
|
-
[${new Date().toISOString()}] MCP 工具被调用
|
|
361
|
-
工具名称: ${name}
|
|
362
|
-
参数: ${JSON.stringify(args, null, 2)}
|
|
363
|
-
环境变量 AGENT_TYPE: ${agentTypeFromEnv || '(未设置)'}
|
|
364
|
-
环境变量 API_PROVIDER: ${apiProviderFromEnv || '(未设置)'}
|
|
365
|
-
`;
|
|
366
|
-
fs.appendFileSync('/tmp/mcp-debug.log', logMsg);
|
|
367
|
-
console.error(logMsg);
|
|
368
|
-
|
|
369
300
|
try {
|
|
370
|
-
// 获取 Token
|
|
371
301
|
const token = await getToken();
|
|
372
302
|
|
|
373
|
-
// 处理参数
|
|
374
303
|
const processedArgs = { ...args };
|
|
375
304
|
if (processedArgs.items && typeof processedArgs.items === 'string') {
|
|
376
305
|
try {
|
|
377
306
|
processedArgs.items = JSON.parse(processedArgs.items);
|
|
378
307
|
} catch (e) {
|
|
379
|
-
// 解析失败,保持原样
|
|
380
308
|
}
|
|
381
309
|
}
|
|
382
310
|
|
|
383
|
-
|
|
384
|
-
|
|
385
311
|
let result;
|
|
386
312
|
let userMessage;
|
|
387
313
|
|
|
388
|
-
// 【调试日志】打印原始传入参数
|
|
389
|
-
console.error('\n========== MCP 参数调试日志 ==========');
|
|
390
|
-
console.error('工具名称:', name);
|
|
391
|
-
console.error('原始参数 agentType:', args.agentType || '(未传入)');
|
|
392
|
-
console.error('原始参数 apiProvider:', args.apiProvider || '(未传入)');
|
|
393
|
-
console.error('原始参数 rawInput:', args.rawInput ? args.rawInput.substring(0, 50) + '...' : '(未传入)');
|
|
394
|
-
console.error('原始参数 llm_provider:', args.llm_provider || '(未传入)');
|
|
395
|
-
console.error('原始参数 store:', args.store || '(未传入)');
|
|
396
|
-
console.error('======================================\n');
|
|
397
|
-
|
|
398
|
-
// 根据工具类型选择处理流程
|
|
399
314
|
switch (name) {
|
|
400
|
-
/*
|
|
401
|
-
case 'quick_book': {
|
|
402
|
-
// 极简快捷记账:自动识别支出/收入,智能匹配分类
|
|
403
|
-
console.log('处理极简快捷记账...');
|
|
404
|
-
|
|
405
|
-
// 1. 从环境变量补充参数(确保上报)
|
|
406
|
-
const agentType = process.env.AGENT_TYPE || process.env.MCP_AGENT_TYPE || args.agentType || '';
|
|
407
|
-
const apiProvider = process.env.API_PROVIDER || process.env.MCP_API_PROVIDER || args.apiProvider || '';
|
|
408
|
-
const rawInput = args.rawInput || args.name;
|
|
409
|
-
|
|
410
|
-
// 2. 判断是支出还是收入
|
|
411
|
-
const isIncome = isIncomeName(args.name);
|
|
412
|
-
|
|
413
|
-
// 3. 智能匹配分类
|
|
414
|
-
const category = args.category || getCategory(args.name);
|
|
415
|
-
|
|
416
|
-
// 4. 自动补全参数
|
|
417
|
-
const completedArgs = {
|
|
418
|
-
name: sanitizeString(args.name, 100),
|
|
419
|
-
amount: validateAmount(args.amount),
|
|
420
|
-
price: args.price || validateAmount(args.amount),
|
|
421
|
-
quantity: args.quantity || 1,
|
|
422
|
-
unit: args.unit || '个',
|
|
423
|
-
category: category,
|
|
424
|
-
store: sanitizeString(args.store, 100) || '未知商家',
|
|
425
|
-
note: sanitizeString(args.note, 500),
|
|
426
|
-
agentType: agentType,
|
|
427
|
-
apiProvider: apiProvider,
|
|
428
|
-
rawInput: rawInput,
|
|
429
|
-
mcp_version: MCP_VERSION,
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
// 5. 根据类型选择接口
|
|
433
|
-
if (isIncome) {
|
|
434
|
-
// 收入记账
|
|
435
|
-
console.log('识别为收入,调用 save_income...');
|
|
436
|
-
const mcpParams = convertParams('save_income', completedArgs);
|
|
437
|
-
result = await callMcpHub('addIncome', mcpParams, token);
|
|
438
|
-
|
|
439
|
-
if (result.success) {
|
|
440
|
-
userMessage = `✅ 记账成功\n\n| 收入来源 | 金额 | 分类 |\n|------|------|------|\n| ${completedArgs.name} | ${completedArgs.amount}元 | ${completedArgs.category || '其他收入'} |\n\n已保存到你的柴米记账小程序数据库。`;
|
|
441
|
-
}
|
|
442
|
-
} else {
|
|
443
|
-
// 支出记账
|
|
444
|
-
console.log('识别为支出,调用 save_expense...');
|
|
445
|
-
const mcpParams = convertParams('save_expense', completedArgs);
|
|
446
|
-
result = await callMcpHub('addExpense', mcpParams, token);
|
|
447
|
-
|
|
448
|
-
if (result.success) {
|
|
449
|
-
userMessage = `✅ 记账成功\n\n| 商品 | 金额 | 分类 | 商家 |\n|------|------|------|------|\n| ${completedArgs.name} | ${completedArgs.amount}元 | ${completedArgs.category || '其他'} | ${completedArgs.store} |\n\n已保存到你的柴米记账小程序数据库。`;
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
*/
|
|
455
|
-
|
|
456
315
|
case 'save_expense': {
|
|
457
|
-
// 步骤1:校验数据完整性
|
|
458
|
-
console.log('校验消费数据完整性...');
|
|
459
|
-
const validationResult = await callMcpPrompt(
|
|
460
|
-
'validateResult',
|
|
461
|
-
{
|
|
462
|
-
data: processedArgs,
|
|
463
|
-
type: 'parseText'
|
|
464
|
-
},
|
|
465
|
-
token
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
if (validationResult.success && !validationResult.data.valid) {
|
|
469
|
-
console.log('数据不完整,尝试补充默认值:', validationResult.data);
|
|
470
|
-
// 调用 fillDefaults 补充缺失字段
|
|
471
|
-
const fillResult = await callMcpPrompt(
|
|
472
|
-
'fillDefaults',
|
|
473
|
-
{
|
|
474
|
-
data: processedArgs,
|
|
475
|
-
type: 'parseText'
|
|
476
|
-
},
|
|
477
|
-
token
|
|
478
|
-
);
|
|
479
|
-
|
|
480
|
-
if (fillResult.success) {
|
|
481
|
-
processedArgs = fillResult.data;
|
|
482
|
-
console.log('补充后的数据:', processedArgs);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// 步骤2:转换参数并调用 mcpHub 保存
|
|
487
316
|
const mcpParams = convertParams('save_expense', processedArgs);
|
|
488
317
|
result = await callMcpHub('addExpense', mcpParams, token);
|
|
489
318
|
|
|
490
|
-
// 格式化输出
|
|
491
319
|
if (result.success) {
|
|
492
320
|
const displayName = processedArgs.name || '未知商品';
|
|
493
321
|
const displayAmount = processedArgs.amount || 0;
|
|
@@ -495,7 +323,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
495
323
|
const displayStore = processedArgs.store || '-';
|
|
496
324
|
userMessage = `✅ 记账成功\n\n| 商品 | 金额 | 分类 | 商家 |\n|------|------|------|------|\n| ${displayName} | ${displayAmount}元 | ${displayCategory} | ${displayStore} |\n\n已保存到你的柴米记账小程序数据库。`;
|
|
497
325
|
|
|
498
|
-
// 提示Agent可以传递agentType和apiProvider参数
|
|
499
326
|
if (!processedArgs.agentType || !processedArgs.apiProvider) {
|
|
500
327
|
userMessage += '\n\n💡 提示:传递agentType和apiProvider参数可解锁小程序记录来源统计功能,示例:agentType="openclaw", apiProvider="doubao"';
|
|
501
328
|
}
|
|
@@ -504,8 +331,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
504
331
|
}
|
|
505
332
|
|
|
506
333
|
case 'save_receipt': {
|
|
507
|
-
// 步骤1:校验数据完整性
|
|
508
|
-
console.log('校验小票数据完整性...');
|
|
509
334
|
const validationResult = await callMcpPrompt(
|
|
510
335
|
'validateResult',
|
|
511
336
|
{
|
|
@@ -516,8 +341,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
516
341
|
);
|
|
517
342
|
|
|
518
343
|
if (validationResult.success && !validationResult.data.valid) {
|
|
519
|
-
console.log('数据不完整,尝试补充默认值:', validationResult.data);
|
|
520
|
-
// 调用 fillDefaults 补充缺失字段
|
|
521
344
|
const fillResult = await callMcpPrompt(
|
|
522
345
|
'fillDefaults',
|
|
523
346
|
{
|
|
@@ -528,16 +351,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
528
351
|
);
|
|
529
352
|
|
|
530
353
|
if (fillResult.success) {
|
|
354
|
+
const savedAgentType = processedArgs.agentType;
|
|
355
|
+
const savedApiProvider = processedArgs.apiProvider;
|
|
356
|
+
const savedRawInput = processedArgs.rawInput;
|
|
531
357
|
processedArgs = fillResult.data;
|
|
532
|
-
|
|
358
|
+
if (savedAgentType) {
|
|
359
|
+
processedArgs.agentType = savedAgentType;
|
|
360
|
+
}
|
|
361
|
+
if (savedApiProvider) {
|
|
362
|
+
processedArgs.apiProvider = savedApiProvider;
|
|
363
|
+
}
|
|
364
|
+
if (savedRawInput) {
|
|
365
|
+
processedArgs.rawInput = savedRawInput;
|
|
366
|
+
}
|
|
533
367
|
}
|
|
534
368
|
}
|
|
535
369
|
|
|
536
|
-
// 步骤2:转换参数并调用 mcpHub 保存
|
|
537
370
|
const mcpParams = convertParams('save_receipt', processedArgs);
|
|
538
371
|
result = await callMcpHub('addReceipt', mcpParams, token);
|
|
539
372
|
|
|
540
|
-
// 格式化输出
|
|
541
373
|
if (result.success) {
|
|
542
374
|
const totalAmount = processedArgs.totalAmount || processedArgs.items?.reduce((sum, item) => sum + (item.amount || 0), 0) || 0;
|
|
543
375
|
const itemsList = processedArgs.items?.map(item => `• ${item.name || '未知商品'} - ${item.amount || 0}元`).join('\n') || '暂无商品明细';
|
|
@@ -545,7 +377,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
545
377
|
const category = processedArgs.items?.[0]?.category || '其他';
|
|
546
378
|
userMessage = `✅ 小票记录成功\n\n| 项目 | 内容 |\n|------|------|\n| 商家 | ${storeName} |\n| 金额 | ${totalAmount}元 |\n| 分类 | ${category} |\n\n商品明细:\n${itemsList}\n\n已保存到你的柴米记账小程序数据库。`;
|
|
547
379
|
|
|
548
|
-
// 提示Agent可以传递agentType和apiProvider参数
|
|
549
380
|
if (!processedArgs.agentType || !processedArgs.apiProvider) {
|
|
550
381
|
userMessage += '\n\n💡 提示:传递agentType和apiProvider参数可解锁小程序记录来源统计功能,示例:agentType="openclaw", apiProvider="doubao"';
|
|
551
382
|
}
|
|
@@ -555,7 +386,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
555
386
|
|
|
556
387
|
case 'get_expenses':
|
|
557
388
|
case 'get_receipt_list': {
|
|
558
|
-
// 查询类工具直接调用 mcpHub
|
|
559
389
|
const toolName = toolMapping[name];
|
|
560
390
|
const mcpParams = convertParams(name, processedArgs);
|
|
561
391
|
result = await callMcpHub(toolName, mcpParams, token);
|
|
@@ -567,7 +397,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
567
397
|
}
|
|
568
398
|
|
|
569
399
|
case 'get_statistics': {
|
|
570
|
-
// 查询类工具直接调用 mcpHub
|
|
571
400
|
const toolName = toolMapping[name];
|
|
572
401
|
const mcpParams = convertParams(name, processedArgs);
|
|
573
402
|
result = await callMcpHub(toolName, mcpParams, token);
|
|
@@ -579,11 +408,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
579
408
|
}
|
|
580
409
|
|
|
581
410
|
case 'save_income': {
|
|
582
|
-
// 收入记录直接调用 mcpHub
|
|
583
411
|
const mcpParams = convertParams('save_income', processedArgs);
|
|
584
412
|
result = await callMcpHub('addIncome', mcpParams, token);
|
|
585
413
|
|
|
586
|
-
// 格式化输出
|
|
587
414
|
if (result.success) {
|
|
588
415
|
const displayName = processedArgs.name || '未知来源';
|
|
589
416
|
const displayAmount = processedArgs.amount || 0;
|
|
@@ -598,12 +425,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
598
425
|
throw new Error(`未知工具: ${name}`);
|
|
599
426
|
}
|
|
600
427
|
|
|
601
|
-
// 处理错误
|
|
602
428
|
if (!result.success) {
|
|
603
429
|
userMessage = `❌ 操作失败:${result.error || '未知错误'}`;
|
|
604
430
|
}
|
|
605
431
|
|
|
606
|
-
// 添加版本信息提示
|
|
607
432
|
userMessage += `\n\n---\n📦 柴米记账 MCP v${MCP_VERSION}`;
|
|
608
433
|
|
|
609
434
|
return {
|
|
@@ -615,7 +440,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
615
440
|
],
|
|
616
441
|
};
|
|
617
442
|
} catch (error) {
|
|
618
|
-
console.error('工具调用错误:', error);
|
|
619
443
|
return {
|
|
620
444
|
content: [
|
|
621
445
|
{
|
|
@@ -628,10 +452,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
628
452
|
}
|
|
629
453
|
});
|
|
630
454
|
|
|
631
|
-
// 输入验证和过滤函数
|
|
632
455
|
function sanitizeString(str, maxLength = 200) {
|
|
633
456
|
if (!str || typeof str !== 'string') return '';
|
|
634
|
-
// 去除 HTML 标签,防止 XSS
|
|
635
457
|
return str.replace(/<[^>]*>/g, '').substring(0, maxLength);
|
|
636
458
|
}
|
|
637
459
|
|
|
@@ -645,18 +467,15 @@ function validateQuantity(quantity) {
|
|
|
645
467
|
return isNaN(num) || num <= 0 ? 1 : num;
|
|
646
468
|
}
|
|
647
469
|
|
|
648
|
-
// 从重量字符串中提取克数
|
|
649
470
|
function extractWeightInGrams(weightStr) {
|
|
650
471
|
if (!weightStr || typeof weightStr !== 'string') return 0;
|
|
651
472
|
|
|
652
|
-
// 匹配数字+单位,如 "300g", "2kg", "1.5斤"
|
|
653
473
|
const match = weightStr.match(/(\d+\.?\d*)\s*(g|kg|斤|克|千克)/i);
|
|
654
474
|
if (!match) return 0;
|
|
655
475
|
|
|
656
476
|
const value = parseFloat(match[1]);
|
|
657
477
|
const unit = match[2].toLowerCase();
|
|
658
478
|
|
|
659
|
-
// 转换为克
|
|
660
479
|
switch (unit) {
|
|
661
480
|
case 'g':
|
|
662
481
|
case '克':
|
|
@@ -665,13 +484,12 @@ function extractWeightInGrams(weightStr) {
|
|
|
665
484
|
case '千克':
|
|
666
485
|
return value * 1000;
|
|
667
486
|
case '斤':
|
|
668
|
-
return value * 500;
|
|
487
|
+
return value * 500;
|
|
669
488
|
default:
|
|
670
489
|
return value;
|
|
671
490
|
}
|
|
672
491
|
}
|
|
673
492
|
|
|
674
|
-
// 计算市场单价(元/500g)
|
|
675
493
|
function calculateMarketPrice(amount, weightStr) {
|
|
676
494
|
const amountNum = parseFloat(amount);
|
|
677
495
|
const weightInGrams = extractWeightInGrams(weightStr);
|
|
@@ -680,16 +498,13 @@ function calculateMarketPrice(amount, weightStr) {
|
|
|
680
498
|
return '';
|
|
681
499
|
}
|
|
682
500
|
|
|
683
|
-
// 计算每500g的价格
|
|
684
501
|
const pricePer500g = (amountNum / weightInGrams) * 500;
|
|
685
502
|
return `${pricePer500g.toFixed(2)}元/500g`;
|
|
686
503
|
}
|
|
687
504
|
|
|
688
|
-
// 参数转换函数
|
|
689
505
|
function convertParams(toolName, args) {
|
|
690
506
|
switch (toolName) {
|
|
691
507
|
case 'save_expense': {
|
|
692
|
-
// 必填参数校验
|
|
693
508
|
if (!args.name || typeof args.name !== 'string' || args.name.trim() === '') {
|
|
694
509
|
throw new Error('缺少必填参数:name(商品名称)');
|
|
695
510
|
}
|
|
@@ -704,15 +519,14 @@ function convertParams(toolName, args) {
|
|
|
704
519
|
name: name,
|
|
705
520
|
originalName: name,
|
|
706
521
|
amount: amount,
|
|
707
|
-
price: amount,
|
|
708
|
-
quantity: 1,
|
|
522
|
+
price: amount,
|
|
523
|
+
quantity: 1,
|
|
709
524
|
category: sanitizeString(args.category, 50) || '其他',
|
|
710
525
|
store: sanitizeString(args.store, 100) || '',
|
|
711
|
-
|
|
526
|
+
transactionType: 'expense',
|
|
712
527
|
unit: '',
|
|
713
528
|
weight: '',
|
|
714
529
|
marketPrice: '',
|
|
715
|
-
date: args.date,
|
|
716
530
|
note: sanitizeString(args.note, 500) || '',
|
|
717
531
|
rawInput: sanitizeString(args.rawInput, 1000) || '',
|
|
718
532
|
agentType: args.agentType || '',
|
|
@@ -723,7 +537,6 @@ function convertParams(toolName, args) {
|
|
|
723
537
|
}
|
|
724
538
|
|
|
725
539
|
case 'save_receipt': {
|
|
726
|
-
// 处理 items 可能是 JSON 字符串的情况
|
|
727
540
|
let items = args.items;
|
|
728
541
|
if (typeof items === 'string') {
|
|
729
542
|
try {
|
|
@@ -740,7 +553,6 @@ function convertParams(toolName, args) {
|
|
|
740
553
|
items: items.map((item, index) => {
|
|
741
554
|
const weight = sanitizeString(item.weight, 50);
|
|
742
555
|
const amount = validateAmount(item.amount);
|
|
743
|
-
// 优先使用传入的 marketPrice,如果没有则自动计算
|
|
744
556
|
const marketPrice = sanitizeString(item.marketPrice, 50) || calculateMarketPrice(amount, weight);
|
|
745
557
|
|
|
746
558
|
return {
|
|
@@ -769,7 +581,6 @@ function convertParams(toolName, args) {
|
|
|
769
581
|
memberCardNo: sanitizeString(args.memberCardNo, 50),
|
|
770
582
|
currentPoints: parseInt(args.currentPoints) || 0,
|
|
771
583
|
totalPoints: parseInt(args.totalPoints) || 0,
|
|
772
|
-
date: args.date,
|
|
773
584
|
rawInput: sanitizeString(args.rawInput, 2000),
|
|
774
585
|
agentType: args.agentType || '',
|
|
775
586
|
apiProvider: args.apiProvider || '',
|
|
@@ -811,61 +622,32 @@ function convertParams(toolName, args) {
|
|
|
811
622
|
}
|
|
812
623
|
}
|
|
813
624
|
|
|
814
|
-
// 获取月份最后一天
|
|
815
625
|
function getMonthEndDate(yearMonth) {
|
|
816
626
|
const [year, month] = yearMonth.split('-').map(Number);
|
|
817
627
|
const lastDay = new Date(year, month, 0).getDate();
|
|
818
628
|
return `${yearMonth}-${lastDay}`;
|
|
819
629
|
}
|
|
820
630
|
|
|
821
|
-
// 启动服务器
|
|
822
631
|
async function main() {
|
|
823
|
-
console.error('========================================');
|
|
824
|
-
console.error('柴米记账 MCP Server 启动中...');
|
|
825
|
-
console.error('========================================\n');
|
|
826
|
-
|
|
827
|
-
// 初始化 OAuth 管理器
|
|
828
|
-
console.error('🔄 初始化 OAuth 2.0 认证...');
|
|
829
632
|
initOAuthManager();
|
|
830
633
|
|
|
831
|
-
// 尝试加载已有 Token 或启动授权流程
|
|
832
634
|
try {
|
|
833
635
|
const existingToken = await oauthManager.tokenStorage.load();
|
|
834
636
|
if (existingToken) {
|
|
835
|
-
console.error('✅ 发现已存储的 OAuth Token');
|
|
836
|
-
// 验证 Token 是否有效
|
|
837
637
|
const validToken = await oauthManager.getValidToken();
|
|
838
|
-
console.error('✅ OAuth Token 验证通过');
|
|
839
638
|
cachedToken = validToken.accessToken;
|
|
840
639
|
tokenExpireTime = validToken.expiresAt;
|
|
841
640
|
} else {
|
|
842
|
-
console.error('\n⚠️ 首次使用,需要完成 OAuth 授权');
|
|
843
|
-
console.error('请按以下步骤操作:');
|
|
844
|
-
console.error('1. 等待授权信息出现');
|
|
845
|
-
console.error('2. 在小程序中完成授权');
|
|
846
|
-
console.error('3. 授权成功后即可使用\n');
|
|
847
|
-
|
|
848
|
-
// 启动授权流程(这会阻塞,直到授权完成)
|
|
849
641
|
const newToken = await oauthManager.startAuthFlow();
|
|
850
642
|
cachedToken = newToken.accessToken;
|
|
851
643
|
tokenExpireTime = newToken.expiresAt;
|
|
852
644
|
}
|
|
853
645
|
} catch (err) {
|
|
854
|
-
console.error('\n❌ OAuth 初始化失败:', err.message);
|
|
855
|
-
console.error('请检查 MCP_OAUTH_URL 配置是否正确\n');
|
|
856
646
|
throw err;
|
|
857
647
|
}
|
|
858
648
|
|
|
859
|
-
console.error('\n========================================');
|
|
860
|
-
console.error('启动 MCP Server...');
|
|
861
|
-
console.error('========================================\n');
|
|
862
|
-
|
|
863
649
|
const transport = new StdioServerTransport();
|
|
864
650
|
await server.connect(transport);
|
|
865
|
-
console.error('✅ 柴米记账 MCP Server 已启动');
|
|
866
|
-
console.error(`MCP_HUB_URL: ${MCP_HUB_URL}`);
|
|
867
|
-
console.error(`MCP_PROMPT_URL: ${MCP_PROMPT_URL}`);
|
|
868
|
-
console.error(`MCP_OAUTH_URL: ${MCP_OAUTH_URL}`);
|
|
869
651
|
}
|
|
870
652
|
|
|
871
|
-
main().catch(
|
|
653
|
+
main().catch(() => {});
|
package/test-mcp.sh
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# MCP Server 本地测试脚本
|
|
4
|
+
|
|
5
|
+
echo "=== 柴米记账 MCP Server 本地测试 ==="
|
|
6
|
+
echo ""
|
|
7
|
+
|
|
8
|
+
# 设置环境变量(测试环境)
|
|
9
|
+
export MCP_HUB_URL="https://chaihuo-xxx.ap-shanghai.app.tcloudbase.com/mcpHub-mcp"
|
|
10
|
+
export MCP_PROMPT_URL="https://chaihuo-xxx.ap-shanghai.app.tcloudbase.com/mcpPrompt"
|
|
11
|
+
export MCP_OAUTH_URL="https://chaihuo-xxx.ap-shanghai.app.tcloudbase.com/mcpOAuth"
|
|
12
|
+
export AGENT_TYPE="test"
|
|
13
|
+
export API_PROVIDER="test"
|
|
14
|
+
|
|
15
|
+
echo "环境变量设置:"
|
|
16
|
+
echo " MCP_HUB_URL: $MCP_HUB_URL"
|
|
17
|
+
echo " AGENT_TYPE: $AGENT_TYPE"
|
|
18
|
+
echo " API_PROVIDER: $API_PROVIDER"
|
|
19
|
+
echo ""
|
|
20
|
+
|
|
21
|
+
# 测试 MCP Server 启动
|
|
22
|
+
echo "=== 测试 MCP Server 启动 ==="
|
|
23
|
+
echo "按 Ctrl+C 停止测试"
|
|
24
|
+
echo ""
|
|
25
|
+
|
|
26
|
+
node server.js
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
console.log('='.repeat(60));
|
|
7
|
+
console.log('父进程信息检测工具');
|
|
8
|
+
console.log('='.repeat(60));
|
|
9
|
+
|
|
10
|
+
console.log('\n1. 当前进程信息:');
|
|
11
|
+
console.log(` PID: ${process.pid}`);
|
|
12
|
+
console.log(` 父进程 PID (PPID): ${process.ppid}`);
|
|
13
|
+
console.log(` 当前平台: ${process.platform}`);
|
|
14
|
+
|
|
15
|
+
console.log('\n2. 尝试获取父进程信息:');
|
|
16
|
+
|
|
17
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
18
|
+
try {
|
|
19
|
+
const psOutput = execSync(`ps -p ${process.ppid} -o pid,ppid,command`, { encoding: 'utf8' });
|
|
20
|
+
console.log(' ' + psOutput.replace(/\n/g, '\n '));
|
|
21
|
+
|
|
22
|
+
const ppidOutput = execSync(`ps -p ${process.ppid} -o command=`, { encoding: 'utf8' }).trim();
|
|
23
|
+
console.log(`\n 父进程命令: ${ppidOutput}`);
|
|
24
|
+
|
|
25
|
+
const guessAgentType = (cmd) => {
|
|
26
|
+
if (/claude/i.test(cmd)) return 'claude-desktop';
|
|
27
|
+
if (/cursor/i.test(cmd)) return 'cursor';
|
|
28
|
+
if (/continue/i.test(cmd)) return 'continue';
|
|
29
|
+
if (/zed/i.test(cmd)) return 'zed';
|
|
30
|
+
if (/workbuddy/i.test(cmd)) return 'workbuddy';
|
|
31
|
+
if (/openclaw/i.test(cmd)) return 'openclaw';
|
|
32
|
+
return 'unknown';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
console.log(` 猜测的 Agent 类型: ${guessAgentType(ppidOutput)}`);
|
|
36
|
+
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.log(` 获取父进程信息失败: ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
} else if (process.platform === 'win32') {
|
|
41
|
+
try {
|
|
42
|
+
const wmicOutput = execSync(`wmic process where processid=${process.ppid} get processid,parentprocessid,commandline`, { encoding: 'utf8' });
|
|
43
|
+
console.log(' ' + wmicOutput.replace(/\n/g, '\n '));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.log(` 获取父进程信息失败: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('\n' + '='.repeat(60));
|