aipic-mcp-stdio 1.0.1 → 1.1.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/index.js ADDED
@@ -0,0 +1,588 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * 百度网盘 AI 修图 MCP —— stdio ↔ 本机 HTTP 代理。
6
+ *
7
+ * 为什么需要这一层:修图客户端的 MCP server 活在它自己的 Electron 主进程里(依赖 koffi
8
+ * 加载的 libaipic 句柄、passport 登录态、切空间要给渲染进程发 IPC),不可能被库库 spawn
9
+ * 成子进程。所以这里由库库引擎 spawn 一个瘦代理,把换行分隔的 JSON-RPC 转成对
10
+ * 127.0.0.1 的 HTTP 调用;凭据每次从客户端写的 endpoint 文件现读,天然跟着客户端重启
11
+ * 轮换,不像静态 HTTP 配置那样会过期。
12
+ *
13
+ * 几条硬约束(都是实测踩过的):
14
+ * - stdout 被协议独占,所有日志只能走 stderr,否则破坏帧
15
+ * - 无 id 的通知(notifications/*)一个字节都不能回
16
+ * - 并发响应必须整行原子写,否则两条响应会交织成坏帧
17
+ * - 必须比引擎的 90s 调用超时先超时,见 tool-policy.js 的说明
18
+ */
19
+
20
+ const http = require('node:http');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+ const policy = require('./tool-policy');
25
+
26
+ const PROTOCOL_VERSION = '2025-06-18';
27
+ const SERVER_NAME = 'aipic-mcp-stdio';
28
+ const SERVER_VERSION = '1.1.0';
29
+ const ENDPOINT_FILENAME = 'aipic-mcp-endpoint.json';
30
+ /** 客户端 productName(electron-builder.yml),userData 目录名即此值 */
31
+ const PRODUCT_NAME = 'NetdiskAIPhoto';
32
+
33
+ /** 自定义 JSON-RPC 错误码(-32000 段为实现自定义) */
34
+ const ERR = {
35
+ CLIENT_NOT_RUNNING: -32001,
36
+ ENDPOINT_MALFORMED: -32002,
37
+ UNAUTHORIZED: -32003,
38
+ CONFIRMATION_REQUIRED: -32004,
39
+ UPSTREAM_FAILED: -32005,
40
+ BATCH_TOO_LARGE: -32006,
41
+ CLIENT_CRASHED: -32007,
42
+ };
43
+
44
+ /**
45
+ * 代理自带的客户端状态探测工具。它是**本地应答**的:不进 tool-policy 分级名单、
46
+ * 不转发给客户端,因此在客户端没起时也能调用,让 AI 拿到诊断转告用户。
47
+ */
48
+ const STATUS_TOOL_NAME = 'aipic_client_status';
49
+ const STATUS_TOOL = {
50
+ name: STATUS_TOOL_NAME,
51
+ description:
52
+ '探测百度网盘AI修图客户端是否在运行。' +
53
+ '当其他工具报「客户端未在运行 / 可能已崩溃」时,可调用本工具获取诊断与操作建议。',
54
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
55
+ };
56
+
57
+ function log(...args) {
58
+ process.stderr.write(`[aipic-stdio] ${args.join(' ')}\n`);
59
+ }
60
+
61
+ /**
62
+ * endpoint 文件路径。客户端写在 app.getPath('userData'),各平台展开如下;
63
+ * 允许用 AIPIC_MCP_ENDPOINT_FILE 覆盖(调试 / 多实例)。
64
+ */
65
+ function endpointPath() {
66
+ if (process.env.AIPIC_MCP_ENDPOINT_FILE) {
67
+ return process.env.AIPIC_MCP_ENDPOINT_FILE;
68
+ }
69
+ if (process.platform === 'darwin') {
70
+ return path.join(os.homedir(), 'Library', 'Application Support', PRODUCT_NAME, ENDPOINT_FILENAME);
71
+ }
72
+ if (process.platform === 'win32') {
73
+ const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
74
+ return path.join(base, PRODUCT_NAME, ENDPOINT_FILENAME);
75
+ }
76
+ return path.join(os.homedir(), '.config', PRODUCT_NAME, ENDPOINT_FILENAME);
77
+ }
78
+
79
+ /**
80
+ * 尝试读 endpoint 文件:文件不存在返回 null(客户端没起/没装),
81
+ * 格式坏抛带 rpcCode=ENDPOINT_MALFORMED 的错误。
82
+ */
83
+ function tryReadEndpoint() {
84
+ let raw;
85
+ try {
86
+ raw = fs.readFileSync(endpointPath(), 'utf-8');
87
+ } catch (e) {
88
+ return null;
89
+ }
90
+ let json;
91
+ try {
92
+ json = JSON.parse(raw);
93
+ } catch (e) {
94
+ const err = new Error('endpoint 文件不是合法 JSON,客户端版本可能过旧或文件被改坏');
95
+ err.rpcCode = ERR.ENDPOINT_MALFORMED;
96
+ throw err;
97
+ }
98
+ const { host, port, path: rpcPath, token } = json || {};
99
+ if (
100
+ typeof host !== 'string' ||
101
+ typeof port !== 'number' ||
102
+ typeof rpcPath !== 'string' ||
103
+ typeof token !== 'string'
104
+ ) {
105
+ const err = new Error('endpoint 文件字段缺失或类型不对');
106
+ err.rpcCode = ERR.ENDPOINT_MALFORMED;
107
+ throw err;
108
+ }
109
+ return { host, port, path: rpcPath, token };
110
+ }
111
+
112
+ /** 读 endpoint;文件不存在时抛 CLIENT_NOT_RUNNING(给 forward 用) */
113
+ function readEndpoint() {
114
+ const ep = tryReadEndpoint();
115
+ if (!ep) {
116
+ const err = new Error('百度网盘AI修图客户端未在运行,请安装并启动客户端后重试');
117
+ err.rpcCode = ERR.CLIENT_NOT_RUNNING;
118
+ throw err;
119
+ }
120
+ return ep;
121
+ }
122
+
123
+ /** 单次 HTTP POST;只负责传输,不解释业务语义 */
124
+ function post(ep, body, timeoutMs) {
125
+ return new Promise((resolve, reject) => {
126
+ const payload = Buffer.from(JSON.stringify(body), 'utf-8');
127
+ const req = http.request(
128
+ {
129
+ host: ep.host,
130
+ port: ep.port,
131
+ path: ep.path,
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'Content-Length': payload.length,
136
+ Accept: 'application/json, text/event-stream',
137
+ Authorization: `Bearer ${ep.token}`,
138
+ },
139
+ timeout: timeoutMs,
140
+ },
141
+ res => {
142
+ const chunks = [];
143
+ res.on('data', c => chunks.push(c));
144
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf-8') }));
145
+ }
146
+ );
147
+ req.on('timeout', () => req.destroy(new Error(`UPSTREAM_TIMEOUT:${timeoutMs}ms`)));
148
+ req.on('error', reject);
149
+ req.end(payload);
150
+ });
151
+ }
152
+
153
+ /**
154
+ * 解析响应体。当前客户端只回 JSON,但不假设 content-type:MCP 规范允许
155
+ * text/event-stream,这里顺手兼容,避免客户端将来开 SSE 时代理整条链路失效。
156
+ */
157
+ function parseBody(text) {
158
+ const trimmed = (text || '').trim();
159
+ if (!trimmed) {
160
+ return null;
161
+ }
162
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
163
+ return JSON.parse(trimmed);
164
+ }
165
+ const dataLines = trimmed
166
+ .split('\n')
167
+ .filter(line => line.startsWith('data:'))
168
+ .map(line => line.slice(5).trim())
169
+ .filter(Boolean);
170
+ if (!dataLines.length) {
171
+ throw new Error(`上游返回非 JSON:${trimmed.slice(0, 200)}`);
172
+ }
173
+ return JSON.parse(dataLines.join(''));
174
+ }
175
+
176
+ /**
177
+ * 带 401 重试的转发。token 每次客户端启动都会轮换,常驻的代理进程必须能自愈;
178
+ * 但**只有 token 确实变了才重试一次**——没变还 401 说明是别的问题,继续重试只是空转。
179
+ */
180
+ async function forward(body, timeoutMs) {
181
+ let ep = readEndpoint();
182
+ let res;
183
+ try {
184
+ res = await post(ep, body, timeoutMs);
185
+ } catch (e) {
186
+ throw wrapConnError(e);
187
+ }
188
+ if (res.status === 401) {
189
+ const fresh = readEndpoint();
190
+ if (fresh.token === ep.token) {
191
+ const err = new Error('带 endpoint 文件里的 token 仍被客户端判 401,且 token 未变化');
192
+ err.rpcCode = ERR.UNAUTHORIZED;
193
+ throw err;
194
+ }
195
+ log('token 已轮换,重试一次');
196
+ ep = fresh;
197
+ try {
198
+ res = await post(ep, body, timeoutMs);
199
+ } catch (e) {
200
+ throw wrapConnError(e);
201
+ }
202
+ }
203
+ if (res.status === 401) {
204
+ const err = new Error('重读 endpoint 后仍然 401');
205
+ err.rpcCode = ERR.UNAUTHORIZED;
206
+ throw err;
207
+ }
208
+ if (res.status !== 200) {
209
+ // 只记状态码与长度:body 内容完全由上游决定,可能带业务数据,没必要进日志
210
+ const err = new Error(`上游 HTTP ${res.status}(body ${res.body.length} bytes)`);
211
+ err.rpcCode = ERR.UPSTREAM_FAILED;
212
+ throw err;
213
+ }
214
+ return parseBody(res.body);
215
+ }
216
+
217
+ /**
218
+ * 把网络层「连不上」的失败翻译成客户端崩溃的可操作提示。
219
+ * 只收「端口没监听 / 连接被掐断」这类明确信号;超时等其它网络错误原样放行,
220
+ * 让上层继续按 UPSTREAM_FAILED 处理(超时可能是慢操作,不能武断归因为崩溃)。
221
+ */
222
+ function wrapConnError(e) {
223
+ const code = e && e.code;
224
+ if (code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'EPIPE') {
225
+ const err = new Error('百度网盘AI修图客户端可能已崩溃或异常退出,请重启客户端后重试');
226
+ err.rpcCode = ERR.CLIENT_CRASHED;
227
+ return err;
228
+ }
229
+ return e;
230
+ }
231
+
232
+ /**
233
+ * 探测客户端状态,分三类:
234
+ * - not_running:endpoint 文件不存在(没装 / 没启动 / 正常退出),统一提示安装并启动;
235
+ * - crashed:endpoint 文件在,但端口连不上(崩溃 / 异常退出残留),提示重启;
236
+ * - running:端口有 HTTP 应答。
237
+ * 只在本地做(读文件 + 短超时探端口),不触发任何业务。
238
+ */
239
+ const PROBE_TIMEOUT_MS = 2000;
240
+
241
+ async function probeClient() {
242
+ let ep;
243
+ try {
244
+ ep = tryReadEndpoint();
245
+ } catch (e) {
246
+ return {
247
+ state: 'crashed',
248
+ hint: '客户端 endpoint 文件损坏(版本可能过旧或被改动),请重启或重装客户端',
249
+ };
250
+ }
251
+ if (!ep) {
252
+ return {
253
+ state: 'not_running',
254
+ hint: '百度网盘AI修图客户端未在运行,请安装并启动客户端后重试',
255
+ };
256
+ }
257
+ try {
258
+ // 用不存在的 method 探活:只要端口有 HTTP 应答就说明客户端在跑,body 忽略
259
+ await post(ep, { jsonrpc: '2.0', id: 0, method: 'ping', params: {} }, PROBE_TIMEOUT_MS);
260
+ return { state: 'running', hint: '百度网盘AI修图客户端运行正常' };
261
+ } catch (e) {
262
+ return {
263
+ state: 'crashed',
264
+ hint: '百度网盘AI修图客户端可能已崩溃或异常退出,请重启客户端后重试',
265
+ };
266
+ }
267
+ }
268
+
269
+ /** 状态工具的回包:与客户端工具一致的 content[].text 结构 */
270
+ function statusResult(id, status) {
271
+ return {
272
+ jsonrpc: '2.0',
273
+ id,
274
+ result: { content: [{ type: 'text', text: JSON.stringify(status) }] },
275
+ };
276
+ }
277
+
278
+ /** aipic_client_status:本地应答,任何时候(含客户端没起)都能调用 */
279
+ async function handleClientStatus(id) {
280
+ return statusResult(id, await probeClient());
281
+ }
282
+
283
+ /**
284
+ * tools/list:客户端在跑就转发并把状态工具并进去;没跑就只返回状态工具,
285
+ * 让 AI 至少能拿到一个可调用的工具去查状态、把「请安装/启动/重启」转告用户。
286
+ */
287
+ async function handleToolsList(id) {
288
+ const status = await probeClient();
289
+ if (status.state === 'running') {
290
+ const body = await forward(
291
+ { jsonrpc: '2.0', id, method: 'tools/list', params: {} },
292
+ policy.toolTimeoutMs('tools/list')
293
+ );
294
+ if (body && body.result && Array.isArray(body.result.tools)) {
295
+ body.result.tools = [...body.result.tools, STATUS_TOOL];
296
+ }
297
+ return body;
298
+ }
299
+ return { jsonrpc: '2.0', id, result: { tools: [STATUS_TOOL] } };
300
+ }
301
+
302
+ /**
303
+ * 裁掉返回体里的大字段。实测:回收站一次返回 271KB 里 99% 是 base64 缩略图;
304
+ * list_images 5 张图 82KB 里 94% 是缩略图;retouch 的 info 是整份算子参数几 KB。
305
+ * 这些对判断没用,但能把模型上下文吃光。
306
+ */
307
+ const BULKY_FIELDS = new Set(['thumbnail', 'thumbnails', 'info']);
308
+ const SLIM_LIMIT = 512;
309
+
310
+ function slim(node) {
311
+ if (Array.isArray(node)) {
312
+ return node.map(slim);
313
+ }
314
+ if (node && typeof node === 'object') {
315
+ const out = {};
316
+ for (const [k, v] of Object.entries(node)) {
317
+ if (BULKY_FIELDS.has(k)) {
318
+ const size = typeof v === 'string' ? v.length : JSON.stringify(v || '').length;
319
+ if (size > SLIM_LIMIT) {
320
+ out[k] = `<omitted:${size} bytes>`;
321
+ continue;
322
+ }
323
+ }
324
+ out[k] = slim(v);
325
+ }
326
+ return out;
327
+ }
328
+ return node;
329
+ }
330
+
331
+ /**
332
+ * 对 tools/call 的结果做瘦身。
333
+ *
334
+ * 注意业务数据的实际位置:客户端把整个业务 JSON **序列化成字符串**塞在
335
+ * `result.content[].text` 里(server.ts 的 `content: [{type:'text', text: JSON.stringify(result)}]`),
336
+ * 所以直接遍历 result 对象树是进不去的——必须先把 text 解回对象、裁完再 stringify 回去。
337
+ * 解不开(不是 JSON)就原样保留,不猜。
338
+ */
339
+ function slimToolResult(result) {
340
+ if (!result || !Array.isArray(result.content)) {
341
+ return slim(result);
342
+ }
343
+ const content = result.content.map(item => {
344
+ if (!item || item.type !== 'text' || typeof item.text !== 'string') {
345
+ return item;
346
+ }
347
+ let parsed;
348
+ try {
349
+ parsed = JSON.parse(item.text);
350
+ } catch (e) {
351
+ return item;
352
+ }
353
+ return { ...item, text: JSON.stringify(slim(parsed)) };
354
+ });
355
+ return { ...slim({ ...result, content: undefined }), content };
356
+ }
357
+
358
+ const SLIM_MARKER = '<omitted:';
359
+
360
+ /**
361
+ * 瘦身标记回灌检查。
362
+ *
363
+ * 瘦身把超过 512 字符的 `info` 换成 `<omitted:N bytes>`。模型手上只有这份被裁过的数据,
364
+ * 而 `modify_preset` 的 `info` 实际必填——于是它会把这句占位符当参数写回去。
365
+ * 实测客户端 `errno: 0` 照收,预设 4029 字符的算子参数被覆盖成 20 字符的占位符,不可恢复。
366
+ * 所以写路径上只要参数里带这个标记就拒掉,并告诉调用方去哪取完整值。
367
+ */
368
+ function findSlimMarker(node, path = '') {
369
+ if (typeof node === 'string') {
370
+ return node.startsWith(SLIM_MARKER) ? path || '(root)' : null;
371
+ }
372
+ if (Array.isArray(node)) {
373
+ for (let i = 0; i < node.length; i += 1) {
374
+ const hit = findSlimMarker(node[i], `${path}[${i}]`);
375
+ if (hit) {
376
+ return hit;
377
+ }
378
+ }
379
+ return null;
380
+ }
381
+ if (node && typeof node === 'object') {
382
+ for (const [k, v] of Object.entries(node)) {
383
+ const hit = findSlimMarker(v, path ? `${path}.${k}` : k);
384
+ if (hit) {
385
+ return hit;
386
+ }
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+
392
+ /** 整行原子写 stdout:一次 write 一条完整消息,避免并发响应交织成坏帧 */
393
+ function send(msg) {
394
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
395
+ }
396
+
397
+ function rpcError(id, code, message) {
398
+ return { jsonrpc: '2.0', id, error: { code, message } };
399
+ }
400
+
401
+ /** 工具级错误走 result.isError(MCP 约定),模型能看懂并自行调整,而不是当成协议错误 */
402
+ function toolError(id, message) {
403
+ return {
404
+ jsonrpc: '2.0',
405
+ id,
406
+ result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: message }) }] },
407
+ };
408
+ }
409
+
410
+ /** write / destructive 工具的串行队列:客户端对同一项目的并发写没有保证 */
411
+ let writeQueue = Promise.resolve();
412
+
413
+ function serialize(fn) {
414
+ const queued = writeQueue.catch(() => undefined).then(fn);
415
+ writeQueue = queued;
416
+ return queued;
417
+ }
418
+
419
+ async function handleToolsCall(msg) {
420
+ const name = msg.params && msg.params.name;
421
+ const args = (msg.params && msg.params.arguments) || {};
422
+ if (typeof name !== 'string') {
423
+ return rpcError(msg.id, -32602, 'invalid tools/call params: name is required');
424
+ }
425
+ // 破坏性工具无条件拦下,且不看 params 里有没有 confirm——那个字段模型可以自己补
426
+ if (policy.requiresConfirmation(name)) {
427
+ return toolError(
428
+ msg.id,
429
+ `CONFIRMATION_REQUIRED: ${name} 会丢数据,代理层禁止由模型直接调用。` + '请引导用户在修图客户端 UI 里操作。'
430
+ );
431
+ }
432
+ const tooBig = policy.batchLimit(name, args);
433
+ if (tooBig) {
434
+ return toolError(msg.id, tooBig);
435
+ }
436
+
437
+ // 写路径禁止回灌瘦身占位符(会把预设参数等大字段覆盖成那句占位符,且客户端不校验)
438
+ if (policy.toolTier(name) !== 'read') {
439
+ const at = findSlimMarker(args);
440
+ if (at) {
441
+ return toolError(
442
+ msg.id,
443
+ `SLIMMED_VALUE_WRITEBACK: 参数 ${at} 的值是返回体瘦身留下的占位符(${SLIM_MARKER}…),` +
444
+ '不是真实数据,写回去会覆盖原字段。请先取未瘦身的完整值' +
445
+ '(预设 info 用 aipic_list_presets 并关掉瘦身读取),或者省略该参数。'
446
+ );
447
+ }
448
+ }
449
+
450
+ // 已知会崩客户端的工具(SIGABRT + 孤儿进程,崩后 MCP 服务不恢复)
451
+ if (policy.isUnsafe(name)) {
452
+ return toolError(
453
+ msg.id,
454
+ `UNSAFE_TOOL: ${name} 曾稳定触发客户端崩溃且崩后 MCP 服务不恢复,` +
455
+ '代理层禁止调用。请引导用户在修图客户端 UI 里操作。'
456
+ );
457
+ }
458
+ // 客户端比代理新时会出现未知工具。toolTier() 把未知归为 write(串行),这里记一条便于排查
459
+ if (!policy.isKnown(name)) {
460
+ log(`未知工具 ${name}:代理名单未覆盖,按 write 串行处理;请同步更新 tool-policy.js`);
461
+ }
462
+
463
+ const run = () => forward(msg, policy.toolTimeoutMs(name));
464
+ const body = policy.requiresSerialExecution(name) ? await serialize(run) : await run();
465
+ if (body && body.result) {
466
+ body.result = slimToolResult(body.result);
467
+ }
468
+ return body;
469
+ }
470
+
471
+ async function dispatch(msg) {
472
+ // initialize 本地应答:代理自己就是 server 门面,不需要把握手透传给客户端
473
+ if (msg.method === 'initialize') {
474
+ return {
475
+ jsonrpc: '2.0',
476
+ id: msg.id,
477
+ result: {
478
+ protocolVersion: PROTOCOL_VERSION,
479
+ capabilities: { tools: {} },
480
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
481
+ },
482
+ };
483
+ }
484
+ if (msg.method === 'tools/list') {
485
+ return handleToolsList(msg.id);
486
+ }
487
+ if (msg.method === 'tools/call') {
488
+ const name = msg.params && msg.params.name;
489
+ if (name === STATUS_TOOL_NAME) {
490
+ return handleClientStatus(msg.id);
491
+ }
492
+ return handleToolsCall(msg);
493
+ }
494
+ return rpcError(msg.id, -32601, `method not found: ${msg.method}`);
495
+ }
496
+
497
+ async function onMessage(line) {
498
+ let msg;
499
+ try {
500
+ msg = JSON.parse(line);
501
+ } catch (e) {
502
+ send(rpcError(null, -32700, `parse error: ${e.message}`));
503
+ return;
504
+ }
505
+ // 批量数组:MCP 2025-06-18 已移除 JSON-RPC batch 支持。静默丢弃会让调用方一直等,
506
+ // 所以明确回一条 -32600,id 取不到就给 null。
507
+ if (Array.isArray(msg)) {
508
+ const first = msg.find(item => item && item.id !== undefined && item.id !== null);
509
+ send(rpcError(first ? first.id : null, -32600, 'JSON-RPC batch 不受支持(MCP 2025-06-18 已移除),请逐条发送'));
510
+ return;
511
+ }
512
+ // 通知(无 id)不回包。多回一条 id:null 的响应会让上游收到孤儿响应
513
+ const isNotification = msg.id === undefined || msg.id === null;
514
+ inFlight += 1;
515
+ try {
516
+ const reply = await dispatch(msg);
517
+ if (!isNotification && reply) {
518
+ send(reply);
519
+ }
520
+ } catch (e) {
521
+ if (isNotification) {
522
+ log(`通知处理失败(不回包): ${e.message}`);
523
+ } else {
524
+ send(rpcError(msg.id, e.rpcCode || ERR.UPSTREAM_FAILED, e.message));
525
+ }
526
+ } finally {
527
+ inFlight -= 1;
528
+ if (inFlight === 0 && drain) {
529
+ drain();
530
+ }
531
+ }
532
+ }
533
+
534
+ // ---- stdin 按行切帧 ----
535
+ // 不用 readline:它对超长行和 \r\n 的处理随版本有差异,这里自己按 \n 切,顺手剥掉 \r。
536
+ // 每条消息独立 await,彼此不阻塞(引擎会并发下发多个 tools/call)。
537
+ let buffer = '';
538
+ /** 在途请求数:EOF 时要等它们落地再退,否则响应会被丢掉 */
539
+ let inFlight = 0;
540
+ let drain = null;
541
+ /** EOF 后等在途请求的上限:取最长的工具档位 80s + 余量 */
542
+ const DRAIN_TIMEOUT_MS = 90000;
543
+
544
+ process.stdin.setEncoding('utf-8');
545
+ process.stdin.on('data', chunk => {
546
+ buffer += chunk;
547
+ let idx = buffer.indexOf('\n');
548
+ while (idx >= 0) {
549
+ const line = buffer.slice(0, idx).replace(/\r$/, '').trim();
550
+ buffer = buffer.slice(idx + 1);
551
+ if (line) {
552
+ void onMessage(line);
553
+ }
554
+ idx = buffer.indexOf('\n');
555
+ }
556
+ });
557
+
558
+ // stdin EOF 即退出:引擎靠进程退出判断 server 停止。非预期退出会被上报成
559
+ // STOPPED/ERROR 进而触发 onDrop 重连,所以只在这里退,别在别处 process.exit。
560
+ // 但不能立刻退——EOF 可能在某次 tools/call 还在等上游时就到了(`echo ... | node index.js`
561
+ // 就是这种时序),直接 exit 会把已经发出去的调用结果丢掉,上游只看到"没有响应"。
562
+ process.stdin.on('end', async () => {
563
+ const rest = buffer.trim();
564
+ if (rest) {
565
+ void onMessage(rest);
566
+ }
567
+ if (inFlight > 0) {
568
+ log(`EOF,等 ${inFlight} 个在途请求落地`);
569
+ await new Promise(resolve => {
570
+ const timer = setTimeout(() => {
571
+ log('在途请求等待超时,仍然退出');
572
+ resolve();
573
+ }, DRAIN_TIMEOUT_MS);
574
+ drain = () => {
575
+ clearTimeout(timer);
576
+ resolve();
577
+ };
578
+ });
579
+ }
580
+ process.exit(0);
581
+ });
582
+
583
+ process.on('uncaughtException', e => {
584
+ log(`uncaught: ${e && e.stack ? e.stack : e}`);
585
+ });
586
+ process.on('unhandledRejection', e => {
587
+ log(`unhandledRejection: ${e && e.stack ? e.stack : e}`);
588
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aipic-mcp-stdio",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "stdio <-> HTTP proxy for the Baidu Netdisk AI Photo (NetdiskAIPhoto) local MCP server",
5
5
  "keywords": [
6
6
  "mcp",
@@ -13,17 +13,14 @@
13
13
  "registry": "https://registry.npmjs.org/"
14
14
  },
15
15
  "bin": {
16
- "aipic-mcp-stdio": "bin/aipic-mcp-stdio.js"
16
+ "aipic-mcp-stdio": "index.js"
17
17
  },
18
18
  "engines": {
19
19
  "node": ">=18"
20
20
  },
21
21
  "files": [
22
- "bin"
22
+ "index.js",
23
+ "tool-policy.js"
23
24
  ],
24
- "dependencies": {},
25
- "optionalDependencies": {
26
- "aipic-mcp-stdio-darwin-arm64": "1.0.1",
27
- "aipic-mcp-stdio-win32-x64": "1.0.1"
28
- }
25
+ "dependencies": {}
29
26
  }
package/tool-policy.js ADDED
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MCP 工具危险分级与超时档位。
5
+ *
6
+ * 背景:代理把 tools/call 直通给修图客户端,而模型可以自由决定调哪个工具、带什么参数。
7
+ * 对于会丢数据的工具,「让模型自己传 confirm=true」等于没有防护——模型完全可能为了
8
+ * 跑通流程自行补上确认标记。因此把管控放在代理进程:destructive 级工具一律先拒绝。
9
+ *
10
+ * 分级只依据工具名,不解析参数:工具名是 MCP 协议里唯一稳定的标识。
11
+ *
12
+ * 四份名单必须覆盖客户端全部工具(当前 75 个 = 7 destructive + 35 write + 33 read,
13
+ * 其中 1 个 read 同时在 UNSAFE 名单里)。新增工具时与 src/main/mcp/features/<feature>/tools.ts 的注册
14
+ * 同一个 CR 改完,`npm run verify` 里有覆盖率断言兜底。
15
+ */
16
+
17
+ /**
18
+ * 不可恢复或会丢数据的工具。
19
+ * purge_* 是物理删除;delete_preset* / delete_project 删业务实体;
20
+ * clear_image_cache 会清空修图参数缓存,等于放弃已有修图结果。
21
+ */
22
+ const DESTRUCTIVE_TOOLS = new Set([
23
+ 'aipic_purge_images',
24
+ 'aipic_purge_recycle_projects',
25
+ 'aipic_delete_project',
26
+ 'aipic_delete_preset',
27
+ 'aipic_delete_preset_group',
28
+ 'aipic_clear_image_cache',
29
+ // 进回收站可恢复,但仍从图库消失,需用户确认
30
+ 'aipic_delete_images',
31
+ ]);
32
+
33
+ /**
34
+ * 已知会让客户端崩溃的工具:曾稳定触发 SIGABRT(libaipic 的 std::mutex)并留下孤儿进程,
35
+ * 崩溃后 MCP 服务不恢复而 endpoint 文件仍在。
36
+ *
37
+ * 原 aipic_control_export_tasks 的「第二个崩溃入口」经真机复现未触发(完成/失败/暂停任务
38
+ * × type 0/1/2 全不崩,retry 还能正常恢复 PAUSED),已移出 unsafe、归入 write(控制导出
39
+ * 任务状态属写操作)。若后续出现崩溃,再回填此名单。
40
+ */
41
+ const UNSAFE_TOOLS = new Set([]);
42
+
43
+ /** 会写入数据的工具:需要串行执行,避免并发写 workspace / filecache 互相踩 */
44
+ const WRITE_TOOLS = new Set([
45
+ 'aipic_create_project',
46
+ 'aipic_rename_project',
47
+ 'aipic_batch_import',
48
+ 'aipic_retouch_with_preset',
49
+ 'aipic_batch_retouch_with_preset',
50
+ // 异步批量修图:提交动作本身也是写(会占住客户端的单任务槽位)
51
+ 'aipic_batch_retouch_start',
52
+ // preview 内部也会 switchImage + 提交渲染,与批量任务并发会互相抢「当前图」
53
+ 'aipic_preview_retouch',
54
+ 'aipic_sync_preset',
55
+ 'aipic_add_preset',
56
+ 'aipic_modify_preset',
57
+ 'aipic_create_preset_group',
58
+ 'aipic_modify_preset_group',
59
+ 'aipic_share_preset',
60
+ 'aipic_import_preset_token',
61
+ 'aipic_export_start',
62
+ 'aipic_mark_images',
63
+ 'aipic_relink_image',
64
+ 'aipic_restore_recycle_projects',
65
+ 'aipic_switch_space',
66
+ 'aipic_import_from_netdisk',
67
+ // AI挑图/筛图
68
+ 'aipic_start_filter',
69
+ 'aipic_restart_filter',
70
+ 'aipic_stop_filter',
71
+ 'aipic_pause_filter',
72
+ 'aipic_resume_filter',
73
+ 'aipic_mark_filter_best',
74
+ // 在线选片
75
+ 'aipic_select_switch_state',
76
+ 'aipic_match_source',
77
+ // AI多人分片
78
+ 'aipic_add_virtual_person',
79
+ 'aipic_merge_persons',
80
+ 'aipic_rename_person',
81
+ 'aipic_delete_persons',
82
+ 'aipic_sync_portrait_name',
83
+ 'aipic_share_portrait',
84
+ // AI创意融图
85
+ 'aipic_create_rongtu_project',
86
+ 'aipic_rongtu_imgcreate',
87
+ // 导出任务控制(retry/pause/delete 会改任务状态,属写操作)
88
+ 'aipic_control_export_tasks',
89
+ ]);
90
+
91
+ /** 只读工具:可并发。显式列出而不是「非 write 即 read」,这样新增工具会落到未知分支。 */
92
+ const READ_TOOLS = new Set([
93
+ 'aipic_login_status',
94
+ 'aipic_get_user_profile',
95
+ 'aipic_get_account_context',
96
+ 'aipic_get_current_space',
97
+ 'aipic_list_spaces',
98
+ 'aipic_get_ticket_balance',
99
+ 'aipic_get_ticket_cost',
100
+ 'aipic_list_projects',
101
+ 'aipic_list_images',
102
+ 'aipic_get_detection',
103
+ 'aipic_list_presets',
104
+ 'aipic_get_custom_presets',
105
+ 'aipic_get_recommended_presets',
106
+ 'aipic_list_preset_groups',
107
+ 'aipic_list_export_tasks',
108
+ 'aipic_get_export_fail_info',
109
+ 'aipic_list_recycle_projects',
110
+ 'aipic_get_retouch_task',
111
+ 'aipic_list_cloud_images',
112
+ 'aipic_get_netdisk_import_task',
113
+ // AI挑图/筛图
114
+ 'aipic_list_filter_results',
115
+ // 在线选片
116
+ 'aipic_list_select_all',
117
+ 'aipic_list_selected',
118
+ 'aipic_list_delivered',
119
+ 'aipic_get_select_image_meta',
120
+ // AI多人分片
121
+ 'aipic_list_persons',
122
+ 'aipic_get_person_items',
123
+ // AI创意融图
124
+ 'aipic_list_rongtu_projects',
125
+ 'aipic_get_rongtu_info',
126
+ 'aipic_list_rongtu_results',
127
+ 'aipic_rongtu_allow_create',
128
+ ]);
129
+
130
+ /**
131
+ * 慢工具超时档位(毫秒)。统一超时会误杀切图/渲染类调用,也会让快调用挂太久。
132
+ *
133
+ * 硬约束:**每一档都必须小于 90s**。库库引擎侧 McpService 的 callMethod 超时是
134
+ * DEFAULT_CALL_TIMEOUT_MS = 90_000 且不可由连接器配置覆盖;一旦引擎先超时,它会丢弃
135
+ * pending,之后真正的回包没人接——表现成「调用报超时但操作其实成功了」,模型会重试,
136
+ * 重试可能重复扣券。所以代理必须比引擎先超时,把失败语义收在自己手里。
137
+ */
138
+ const TIMEOUT_OVERRIDES_MS = {
139
+ // 切图 + 渲染,实测单张 75ms~数秒(大 RAW 更慢);留足余量但必须 < 90s
140
+ aipic_retouch_with_preset: 80000,
141
+ aipic_batch_retouch_with_preset: 80000,
142
+ // 只提交,真实耗时在 get_retouch_task 轮询里
143
+ aipic_batch_retouch_start: 30000,
144
+ // 纯内存查询
145
+ aipic_get_retouch_task: 10000,
146
+ // mode=seg 实测 0.65~1.29s
147
+ aipic_get_detection: 60000,
148
+ // 仅提交任务,真实耗时在轮询里
149
+ aipic_export_start: 60000,
150
+ };
151
+
152
+ const DEFAULT_TIMEOUT_MS = 30000;
153
+
154
+ /**
155
+ * `batch_retouch_with_preset` 的批量上限。
156
+ *
157
+ * 客户端该工具是同步 for 循环逐张真修,批量大了会撞穿引擎 90s 硬超时。实测 30 张小图
158
+ * 2.2s(75ms/张),但大 RAW 慢得多,所以保留上限,超过就引导走异步版或 sync_preset。
159
+ */
160
+ const BATCH_RETOUCH_MAX = 30;
161
+
162
+ /** 工具名是否在任一名单里。不在 = 客户端比代理新,走保守分支。 */
163
+ function isKnown(toolName) {
164
+ return DESTRUCTIVE_TOOLS.has(toolName) || WRITE_TOOLS.has(toolName) || READ_TOOLS.has(toolName);
165
+ }
166
+
167
+ /**
168
+ * 危险分级。
169
+ *
170
+ * **未知工具按 write 处理**(串行 + 默认超时),而不是按 read 放行。
171
+ * 原因:代理随库库安装包分发,客户端可能先升级并带来新工具。如果未知工具默认可并发,
172
+ * 一个新的写工具就会绕过串行化去并发改同一个项目。宁可把个别新增只读工具串行化
173
+ * (代价可忽略),也不能让写工具漏网。真正会丢数据的新工具仍需在下个版本显式进
174
+ * DESTRUCTIVE_TOOLS 才能被拦住——所以升级客户端后要同步更新这份名单。
175
+ */
176
+ function toolTier(toolName) {
177
+ if (DESTRUCTIVE_TOOLS.has(toolName)) {
178
+ return 'destructive';
179
+ }
180
+ if (WRITE_TOOLS.has(toolName)) {
181
+ return 'write';
182
+ }
183
+ if (READ_TOOLS.has(toolName)) {
184
+ return 'read';
185
+ }
186
+ return 'write';
187
+ }
188
+
189
+ function toolTimeoutMs(toolName) {
190
+ return TIMEOUT_OVERRIDES_MS[toolName] || DEFAULT_TIMEOUT_MS;
191
+ }
192
+
193
+ /** write / destructive / 未知 需要串行;只有明确的 read 才并发 */
194
+ function requiresSerialExecution(toolName) {
195
+ return toolTier(toolName) !== 'read';
196
+ }
197
+
198
+ /**
199
+ * 这个工具是否必须先拿到用户确认才能执行。
200
+ *
201
+ * destructive 级工具在这一层是**无条件拒绝**的:不接受任何「已确认」入参,因为 params
202
+ * 由模型生成,任何基于入参的确认标记都可被模型自行补上。用户确认后的重放需要一条
203
+ * 库库侧 renderer 才能触发、由 main 下发一次性令牌的独立通道,那条通道尚不存在。
204
+ */
205
+ function requiresConfirmation(toolName) {
206
+ return toolTier(toolName) === 'destructive';
207
+ }
208
+
209
+ /** 已知会崩客户端的工具,一律拦下 */
210
+ function isUnsafe(toolName) {
211
+ return UNSAFE_TOOLS.has(toolName);
212
+ }
213
+
214
+ /** 返回 null 表示放行;否则返回要回给模型的拒绝原因 */
215
+ function batchLimit(toolName, args) {
216
+ if (toolName !== 'aipic_batch_retouch_with_preset') {
217
+ return null;
218
+ }
219
+ const ids = args && args.file_ids;
220
+ if (!Array.isArray(ids) || ids.length <= BATCH_RETOUCH_MAX) {
221
+ return null;
222
+ }
223
+ return (
224
+ `BATCH_TOO_LARGE: batch_retouch_with_preset 单次最多 ${BATCH_RETOUCH_MAX} 张` +
225
+ `(本次 ${ids.length} 张)。该工具在客户端是逐张同步真修,超过上限有撞穿引擎 90s 硬超时的风险。` +
226
+ '改用这两条之一:' +
227
+ '(1)只是要把同一套参数铺到多张图 → aipic_sync_preset,走 filecache 批量写、' +
228
+ '不切图不渲染,28 张约 0.5s,比循环真修快约 7 倍;' +
229
+ '(2)确实需要每张都渲染 → aipic_batch_retouch_start 提交异步任务拿 task_id,' +
230
+ '再用 aipic_get_retouch_task 轮询(status 只有 2/3 是终态)。'
231
+ );
232
+ }
233
+
234
+ /** 四张名单的并集(去重:unsafe 的工具同时也在 write 里),给 check-tool-sync.js 对账用 */
235
+ function allKnownTools() {
236
+ return [...new Set([...DESTRUCTIVE_TOOLS, ...UNSAFE_TOOLS, ...WRITE_TOOLS, ...READ_TOOLS])];
237
+ }
238
+
239
+ module.exports = {
240
+ DESTRUCTIVE_TOOLS,
241
+ UNSAFE_TOOLS,
242
+ WRITE_TOOLS,
243
+ READ_TOOLS,
244
+ BATCH_RETOUCH_MAX,
245
+ allKnownTools,
246
+ isKnown,
247
+ isUnsafe,
248
+ toolTier,
249
+ toolTimeoutMs,
250
+ requiresSerialExecution,
251
+ requiresConfirmation,
252
+ batchLimit,
253
+ };
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // 分发器:按当前平台/架构拉起对应平台包里预编译好的二进制。
5
- // 敏感逻辑(护栏分级、endpoint 读取、stdin 帧解析等)全在二进制里,这里只负责选包并透传 stdio。
6
- const { spawn } = require('node:child_process');
7
-
8
- const pkgName = `aipic-mcp-stdio-${process.platform}-${process.arch}`;
9
- let binPath;
10
- try {
11
- binPath = require(pkgName);
12
- } catch (err) {
13
- process.stderr.write(
14
- `aipic-mcp-stdio: 当前平台 ${process.platform}-${process.arch} 没有预编译二进制(${pkgName} 未安装)。\n`
15
- );
16
- process.exit(1);
17
- }
18
-
19
- const child = spawn(binPath, [], { stdio: 'inherit' });
20
-
21
- child.on('error', err => {
22
- process.stderr.write(`aipic-mcp-stdio: 启动二进制失败:${err.message}\n`);
23
- process.exit(1);
24
- });
25
-
26
- child.on('exit', (code, signal) => {
27
- if (signal) {
28
- process.kill(process.pid, signal);
29
- } else {
30
- process.exit(code == null ? 1 : code);
31
- }
32
- });