dsh-plugin-agentflow 0.0.0-stage → 0.2.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/README.md +88 -2
- package/cordis.patch.yml +195 -0
- package/index.js +448 -0
- package/lib/bridge.js +858 -0
- package/lib/client.js +4 -0
- package/package.json +42 -4
- package/runtime/boards.mjs +486 -0
- package/runtime/bridge.mjs +230 -0
- package/runtime/dsh-source.mjs +289 -0
- package/runtime/engine.mjs +352 -0
- package/runtime/index.html +3299 -0
- package/runtime/llm.mjs +252 -0
- package/runtime/mcp.mjs +823 -0
- package/runtime/preset-gen.mjs +1117 -0
- package/runtime/presets.mjs +399 -0
- package/runtime/run.mjs +76 -0
- package/runtime/server.mjs +1151 -0
- package/runtime/skills.mjs +131 -0
- package/runtime/store.mjs +103 -0
- package/runtime/verify.mjs +219 -0
- package/runtime/web/dist/assets/index-CkM6YPl4.js +348 -0
- package/runtime/web/dist/assets/index-DEZcpxgr.css +1 -0
- package/runtime/web/dist/index.html +13 -0
- package/scripts/build-client.mjs +36 -0
- package/scripts/sync-runtime.mjs +84 -0
package/runtime/mcp.mjs
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
// mcp.mjs —— 零依赖 MCP(Model Context Protocol)客户端 + 连接池
|
|
2
|
+
// 规范版本:2024-11-05
|
|
3
|
+
//
|
|
4
|
+
// 设计要点:
|
|
5
|
+
// 1. stdio 传输使用「换行分隔的 JSON-RPC 2.0」;server 混在 stdout 里的非 JSON 日志行跳过。
|
|
6
|
+
// 2. 每个请求带超时;超时 / 进程退出 / 关闭都会清理 pending,绝不留下永远不 resolve 的 Promise。
|
|
7
|
+
// 3. 子进程 stderr 必须持续消费并保留尾部:不消费的话管道写满会把子进程卡死(经典坑)。
|
|
8
|
+
// 4. 连接池按 name 复用健康连接,不重复 spawn;握手失败或进程退出后状态标 ok:false + error。
|
|
9
|
+
// 5. 同时支持 stdio 与 streamable-http(JSON 或 SSE 响应),HTTP 会跟随 Mcp-Session-Id。
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
14
|
+
const CLIENT_INFO = Object.freeze({ name: 'agentflow', version: '0.1.0' });
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 3_000;
|
|
18
|
+
const MAX_STDOUT_BUFFER = 8 * 1024 * 1024; // 单行未结束缓冲上限,防内存失控
|
|
19
|
+
const MAX_STDERR_BUFFER = 128 * 1024; // 保留 stderr 尾部用于诊断
|
|
20
|
+
const STDERR_TAIL_CHARS = 2_000;
|
|
21
|
+
const MAX_TOOL_PAGES = 100;
|
|
22
|
+
|
|
23
|
+
/** 默认日志:只有设置 MCP_DEBUG 时才输出,避免污染宿主程序输出。 */
|
|
24
|
+
function defaultLog(text) {
|
|
25
|
+
if (process.env.MCP_DEBUG) {
|
|
26
|
+
try {
|
|
27
|
+
process.stderr.write(`${text}\n`);
|
|
28
|
+
} catch {
|
|
29
|
+
/* 日志失败不影响协议 */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeLog(log, text) {
|
|
35
|
+
try {
|
|
36
|
+
log(text);
|
|
37
|
+
} catch {
|
|
38
|
+
/* 日志函数自身抛错时忽略 */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 把任意异常/值转成可读字符串。 */
|
|
43
|
+
function describeError(err) {
|
|
44
|
+
if (err instanceof Error) return err.message;
|
|
45
|
+
return String(err);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 把 tools/list 返回的工具整理成稳定结构。 */
|
|
49
|
+
function normalizeTool(tool) {
|
|
50
|
+
const t = tool && typeof tool === 'object' ? tool : {};
|
|
51
|
+
return {
|
|
52
|
+
name: typeof t.name === 'string' ? t.name : '',
|
|
53
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
54
|
+
inputSchema:
|
|
55
|
+
t.inputSchema && typeof t.inputSchema === 'object'
|
|
56
|
+
? t.inputSchema
|
|
57
|
+
: { type: 'object', properties: {} },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把 tools/call 返回的 content 数组拼成文本。
|
|
63
|
+
* text 直接取出;image/audio/resource 等给一个可读占位;未知类型保留 JSON。
|
|
64
|
+
*/
|
|
65
|
+
function contentToText(content) {
|
|
66
|
+
if (content === undefined || content === null) return '';
|
|
67
|
+
if (typeof content === 'string') return content;
|
|
68
|
+
if (!Array.isArray(content)) return JSON.stringify(content);
|
|
69
|
+
const parts = [];
|
|
70
|
+
for (const item of content) {
|
|
71
|
+
if (item === null || typeof item !== 'object') {
|
|
72
|
+
parts.push(String(item));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (item.type === 'text') {
|
|
76
|
+
if (typeof item.text === 'string') parts.push(item.text);
|
|
77
|
+
else if (item.text !== undefined) parts.push(JSON.stringify(item.text));
|
|
78
|
+
} else if (item.type === 'image') {
|
|
79
|
+
parts.push(`[image${item.mimeType ? ` ${item.mimeType}` : ''}]`);
|
|
80
|
+
} else if (item.type === 'audio') {
|
|
81
|
+
parts.push(`[audio${item.mimeType ? ` ${item.mimeType}` : ''}]`);
|
|
82
|
+
} else if (item.type === 'resource') {
|
|
83
|
+
const res = item.resource && typeof item.resource === 'object' ? item.resource : {};
|
|
84
|
+
if (typeof res.text === 'string') parts.push(res.text);
|
|
85
|
+
else if (typeof res.blob === 'string') parts.push(`[resource blob${res.mimeType ? ` ${res.mimeType}` : ''}]`);
|
|
86
|
+
else parts.push(`[resource${res.uri ? ` ${res.uri}` : ''}]`);
|
|
87
|
+
} else if (item.type === 'resource_link') {
|
|
88
|
+
parts.push(`[resource_link${item.uri ? ` ${item.uri}` : ''}]`);
|
|
89
|
+
} else {
|
|
90
|
+
parts.push(JSON.stringify(item));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 解析 SSE 文本,把每个 data 块交给 onMessage。 */
|
|
97
|
+
function parseSseText(text, onMessage, log) {
|
|
98
|
+
for (const block of String(text).split(/\r?\n\r?\n/)) {
|
|
99
|
+
const dataLines = [];
|
|
100
|
+
for (const line of block.split(/\r?\n/)) {
|
|
101
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
102
|
+
}
|
|
103
|
+
if (dataLines.length === 0) continue; // 只有 event:/id:/retry: 的块忽略
|
|
104
|
+
const payload = dataLines.join('\n').trim();
|
|
105
|
+
if (!payload) continue;
|
|
106
|
+
let msg;
|
|
107
|
+
try {
|
|
108
|
+
msg = JSON.parse(payload);
|
|
109
|
+
} catch {
|
|
110
|
+
log(`跳过非 JSON 的 SSE data: ${payload.slice(0, 200)}`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
onMessage(msg);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 连接基类:请求/响应匹配、超时、pending 清理、服务端请求兜底。 */
|
|
118
|
+
class BaseConnection {
|
|
119
|
+
constructor(name, spec, cfg) {
|
|
120
|
+
this.name = name;
|
|
121
|
+
this.spec = spec;
|
|
122
|
+
this.cfg = cfg;
|
|
123
|
+
this.kind = 'unknown';
|
|
124
|
+
this.ok = false;
|
|
125
|
+
this.failed = false;
|
|
126
|
+
this.closed = false;
|
|
127
|
+
this.closing = false;
|
|
128
|
+
this.error = null;
|
|
129
|
+
this.tools = [];
|
|
130
|
+
this.serverInfo = null;
|
|
131
|
+
this.protocolVersion = null;
|
|
132
|
+
this.initialized = false;
|
|
133
|
+
this.nextId = 1;
|
|
134
|
+
this.pending = new Map();
|
|
135
|
+
this.stdoutBuf = '';
|
|
136
|
+
this.stderrBuf = '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log(text) {
|
|
140
|
+
safeLog(this.cfg.log, `[mcp:${this.name}] ${text}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 由子类实现
|
|
144
|
+
async start() {}
|
|
145
|
+
|
|
146
|
+
// 由子类实现:返回 Promise
|
|
147
|
+
_send() {
|
|
148
|
+
return Promise.reject(new Error(`未实现的 MCP 传输: ${this.kind}`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 连接当前对外的信息。 */
|
|
152
|
+
info() {
|
|
153
|
+
return {
|
|
154
|
+
name: this.name,
|
|
155
|
+
tools: this.tools.map((t) => ({ ...t })),
|
|
156
|
+
serverInfo: this.serverInfo,
|
|
157
|
+
protocolVersion: this.protocolVersion,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 标记连接失败:状态置错误并 reject 所有 pending。 */
|
|
162
|
+
fail(reason) {
|
|
163
|
+
this.ok = false;
|
|
164
|
+
this.failed = true;
|
|
165
|
+
if (!this.error) this.error = reason;
|
|
166
|
+
this.log(reason);
|
|
167
|
+
this._rejectAll(reason);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 发一个 JSON-RPC 请求,按 id 等响应。
|
|
172
|
+
* 超时会清掉 pending 并 reject;进程退出/关闭时由 _rejectAll 统一 reject。
|
|
173
|
+
*/
|
|
174
|
+
async request(method, params, timeoutMs = this.cfg.requestTimeoutMs) {
|
|
175
|
+
if (this.closed) throw new Error(`MCP 连接已关闭: ${this.name}`);
|
|
176
|
+
if (this.failed) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`MCP server 不可用: ${this.name}${this.error ? ` — ${this.error}` : ''}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : this.cfg.requestTimeoutMs;
|
|
182
|
+
const id = this.nextId++;
|
|
183
|
+
const msg = { jsonrpc: '2.0', id, method };
|
|
184
|
+
if (params !== undefined) msg.params = params;
|
|
185
|
+
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const timer = setTimeout(() => {
|
|
188
|
+
const p = this.pending.get(id);
|
|
189
|
+
if (!p) return; // 已被响应/退出清理
|
|
190
|
+
this.pending.delete(id);
|
|
191
|
+
this.log(`请求超时: ${method} id=${id} (${ms}ms)`);
|
|
192
|
+
const err = new Error(`MCP 请求超时(${ms}ms): ${this.name} -> ${method}`);
|
|
193
|
+
err.mcpMethod = method;
|
|
194
|
+
err.mcpTimeout = true;
|
|
195
|
+
reject(err);
|
|
196
|
+
}, ms);
|
|
197
|
+
|
|
198
|
+
this.pending.set(id, { resolve, reject, timer, method });
|
|
199
|
+
|
|
200
|
+
let sent;
|
|
201
|
+
try {
|
|
202
|
+
sent = this._send(msg);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this._rejectPending(id, `发送 MCP 请求失败: ${this.name} -> ${method}: ${describeError(err)}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// HTTP 传输的发送过程可能异步失败(网络错误/非 2xx),要连带 reject 这个请求
|
|
208
|
+
Promise.resolve(sent).catch((err) => {
|
|
209
|
+
this._rejectPending(
|
|
210
|
+
id,
|
|
211
|
+
`发送 MCP 请求失败: ${this.name} -> ${method}: ${describeError(err)}`,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 发通知(无 id,不等响应)。 */
|
|
218
|
+
async notify(method, params) {
|
|
219
|
+
if (this.closed) throw new Error(`MCP 连接已关闭: ${this.name}`);
|
|
220
|
+
if (this.failed) {
|
|
221
|
+
throw new Error(`MCP server 不可用: ${this.name}${this.error ? ` — ${this.error}` : ''}`);
|
|
222
|
+
}
|
|
223
|
+
const msg = { jsonrpc: '2.0', method };
|
|
224
|
+
if (params !== undefined) msg.params = params;
|
|
225
|
+
await this._send(msg);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 列工具,自动跟随 nextCursor 翻页。 */
|
|
229
|
+
async listTools() {
|
|
230
|
+
const tools = [];
|
|
231
|
+
const seen = new Set();
|
|
232
|
+
let cursor;
|
|
233
|
+
for (let page = 0; page < MAX_TOOL_PAGES; page++) {
|
|
234
|
+
const result = await this.request('tools/list', cursor === undefined ? {} : { cursor });
|
|
235
|
+
if (Array.isArray(result?.tools)) {
|
|
236
|
+
for (const t of result.tools) tools.push(normalizeTool(t));
|
|
237
|
+
}
|
|
238
|
+
const next = typeof result?.nextCursor === 'string' ? result.nextCursor : '';
|
|
239
|
+
if (!next) return tools;
|
|
240
|
+
if (seen.has(next)) {
|
|
241
|
+
this.log(`tools/list 游标重复,停止翻页: ${next}`);
|
|
242
|
+
return tools;
|
|
243
|
+
}
|
|
244
|
+
seen.add(next);
|
|
245
|
+
cursor = next;
|
|
246
|
+
}
|
|
247
|
+
this.log(`tools/list 超过 ${MAX_TOOL_PAGES} 页,停止翻页`);
|
|
248
|
+
return tools;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 处理一条 JSON-RPC 消息(响应、通知、服务端请求)。 */
|
|
252
|
+
_onMessage(msg) {
|
|
253
|
+
if (Array.isArray(msg)) {
|
|
254
|
+
for (const item of msg) this._onMessage(item);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (!msg || typeof msg !== 'object') return;
|
|
258
|
+
|
|
259
|
+
// 有 method:通知或服务端发起的请求
|
|
260
|
+
if (typeof msg.method === 'string') {
|
|
261
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
262
|
+
this._handleServerRequest(msg);
|
|
263
|
+
} else {
|
|
264
|
+
this.log(`收到通知: ${msg.method}`);
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 响应必须带 id
|
|
270
|
+
if (msg.id === undefined || msg.id === null) {
|
|
271
|
+
this.log('收到既无 id 也无 method 的消息,忽略');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const p = this.pending.get(msg.id);
|
|
275
|
+
if (!p) {
|
|
276
|
+
// 典型情况:请求已超时,server 的迟到响应;跳过即可
|
|
277
|
+
this.log(`收到未知 id 的响应,忽略: ${String(msg.id)}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.pending.delete(msg.id);
|
|
281
|
+
clearTimeout(p.timer);
|
|
282
|
+
if (msg.error && typeof msg.error === 'object') {
|
|
283
|
+
const err = new Error(
|
|
284
|
+
`${this.name}: ${msg.error.message || 'MCP 调用失败'} (code ${msg.error.code})`,
|
|
285
|
+
);
|
|
286
|
+
err.code = msg.error.code;
|
|
287
|
+
err.data = msg.error.data;
|
|
288
|
+
err.mcpMethod = p.method;
|
|
289
|
+
p.reject(err);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
p.resolve(msg.result ?? {});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** server 主动发来的请求:只支持 ping,其余回 -32601,避免 server 干等。 */
|
|
296
|
+
_handleServerRequest(req) {
|
|
297
|
+
if (req.method === 'ping') {
|
|
298
|
+
this._send({ jsonrpc: '2.0', id: req.id, result: {} }).catch((err) => {
|
|
299
|
+
this.log(`回复 ping 失败: ${describeError(err)}`);
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.log(`不支持服务端请求 ${req.method},回 -32601`);
|
|
304
|
+
this._send({
|
|
305
|
+
jsonrpc: '2.0',
|
|
306
|
+
id: req.id,
|
|
307
|
+
error: { code: -32601, message: `Method not supported: ${req.method}` },
|
|
308
|
+
}).catch((err) => {
|
|
309
|
+
this.log(`回复服务端请求失败: ${describeError(err)}`);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 处理 stdout 一个文本块:按换行切消息,非 JSON 行跳过。 */
|
|
314
|
+
_onStdoutChunk(chunk) {
|
|
315
|
+
this.stdoutBuf += typeof chunk === 'string' ? chunk : String(chunk);
|
|
316
|
+
let idx;
|
|
317
|
+
while ((idx = this.stdoutBuf.indexOf('\n')) >= 0) {
|
|
318
|
+
const line = this.stdoutBuf.slice(0, idx).replace(/\r$/, '');
|
|
319
|
+
this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
|
|
320
|
+
this._onLine(line);
|
|
321
|
+
}
|
|
322
|
+
if (this.stdoutBuf.length > MAX_STDOUT_BUFFER) {
|
|
323
|
+
this.log(`stdout 单行超过 ${MAX_STDOUT_BUFFER} 字节,丢弃缓冲`);
|
|
324
|
+
this.stdoutBuf = '';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_onLine(line) {
|
|
329
|
+
const trimmed = line.trim();
|
|
330
|
+
if (!trimmed) return;
|
|
331
|
+
let msg;
|
|
332
|
+
try {
|
|
333
|
+
msg = JSON.parse(trimmed);
|
|
334
|
+
} catch {
|
|
335
|
+
this.log(`跳过非 JSON 日志行: ${trimmed.slice(0, 200)}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this._onMessage(msg);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** 持续消费 stderr,只保留尾部(防管道塞满 + 留诊断信息)。 */
|
|
342
|
+
_onStderrChunk(chunk) {
|
|
343
|
+
const text = typeof chunk === 'string' ? chunk : String(chunk);
|
|
344
|
+
this.stderrBuf += text;
|
|
345
|
+
if (this.stderrBuf.length > MAX_STDERR_BUFFER) {
|
|
346
|
+
this.stderrBuf = this.stderrBuf.slice(-MAX_STDERR_BUFFER);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
stderrTail() {
|
|
351
|
+
return this.stderrBuf.trim().slice(-STDERR_TAIL_CHARS);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_rejectPending(id, reason) {
|
|
355
|
+
const p = this.pending.get(id);
|
|
356
|
+
if (!p) return;
|
|
357
|
+
clearTimeout(p.timer);
|
|
358
|
+
this.pending.delete(id);
|
|
359
|
+
const err = new Error(reason);
|
|
360
|
+
err.mcpMethod = p.method;
|
|
361
|
+
p.reject(err);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_rejectAll(reason) {
|
|
365
|
+
if (this.pending.size === 0) return;
|
|
366
|
+
const entries = [...this.pending.values()];
|
|
367
|
+
this.pending.clear();
|
|
368
|
+
for (const p of entries) {
|
|
369
|
+
clearTimeout(p.timer);
|
|
370
|
+
const err = new Error(reason);
|
|
371
|
+
err.mcpMethod = p.method;
|
|
372
|
+
p.reject(err);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async close() {
|
|
377
|
+
this.closed = true;
|
|
378
|
+
this.ok = false;
|
|
379
|
+
this.failed = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** stdio 传输:spawn 子进程,用换行分隔 JSON-RPC。 */
|
|
384
|
+
class StdioConnection extends BaseConnection {
|
|
385
|
+
constructor(name, spec, cfg) {
|
|
386
|
+
super(name, spec, cfg);
|
|
387
|
+
this.kind = 'stdio';
|
|
388
|
+
this.child = null;
|
|
389
|
+
this.exitHandled = false;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async start() {
|
|
393
|
+
const spec = this.spec;
|
|
394
|
+
const command = String(spec.command);
|
|
395
|
+
const args = Array.isArray(spec.args) ? spec.args.map((a) => String(a)) : [];
|
|
396
|
+
const env = {
|
|
397
|
+
...process.env,
|
|
398
|
+
...(spec.env && typeof spec.env === 'object' ? spec.env : {}),
|
|
399
|
+
};
|
|
400
|
+
const child = spawn(command, args, {
|
|
401
|
+
env,
|
|
402
|
+
cwd: typeof spec.cwd === 'string' ? spec.cwd : undefined,
|
|
403
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
404
|
+
windowsHide: true,
|
|
405
|
+
});
|
|
406
|
+
this.child = child;
|
|
407
|
+
|
|
408
|
+
child.stdout?.setEncoding('utf8');
|
|
409
|
+
child.stderr?.setEncoding('utf8');
|
|
410
|
+
child.stdout?.on('data', (chunk) => this._onStdoutChunk(chunk));
|
|
411
|
+
// 关键:stderr 必须一直读,否则子进程写满管道后会阻塞
|
|
412
|
+
child.stderr?.on('data', (chunk) => this._onStderrChunk(chunk));
|
|
413
|
+
// stdin 的 EPIPE 等错误不处理会变成未捕获异常;这里吞掉,进程退出会另行通知
|
|
414
|
+
child.stdin?.on('error', (err) => {
|
|
415
|
+
this.log(`stdin 错误(进程可能已退出): ${describeError(err)}`);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
child.on('error', (err) => {
|
|
419
|
+
this._onExit(`MCP server 启动失败: ${describeError(err)}`);
|
|
420
|
+
});
|
|
421
|
+
child.on('exit', (code, signal) => {
|
|
422
|
+
this._onExit(this._exitReason(code, signal));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// 等 spawn 成功或失败,确保 connect 能立即把启动错误抛给调用方
|
|
426
|
+
await new Promise((resolve) => {
|
|
427
|
+
let settled = false;
|
|
428
|
+
const done = () => {
|
|
429
|
+
if (settled) return;
|
|
430
|
+
settled = true;
|
|
431
|
+
child.off('spawn', done);
|
|
432
|
+
child.off('error', done);
|
|
433
|
+
resolve();
|
|
434
|
+
};
|
|
435
|
+
child.once('spawn', done);
|
|
436
|
+
child.once('error', done);
|
|
437
|
+
});
|
|
438
|
+
if (this.failed) {
|
|
439
|
+
throw new Error(this.error || `MCP server 启动失败: ${this.name}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_exitReason(code, signal) {
|
|
444
|
+
const tail = this.stderrTail();
|
|
445
|
+
return (
|
|
446
|
+
`MCP server 进程退出 (code=${code}, signal=${signal})` +
|
|
447
|
+
(tail ? `,stderr: ${tail}` : '')
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_onExit(reason) {
|
|
452
|
+
if (this.exitHandled) return;
|
|
453
|
+
this.exitHandled = true;
|
|
454
|
+
this.child = null;
|
|
455
|
+
this.ok = false;
|
|
456
|
+
this.failed = true;
|
|
457
|
+
if (this.closing) {
|
|
458
|
+
this.closed = true;
|
|
459
|
+
this._rejectAll(`MCP 连接已关闭: ${this.name}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.error = reason;
|
|
463
|
+
this.log(reason);
|
|
464
|
+
this._rejectAll(reason);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_send(msg) {
|
|
468
|
+
const child = this.child;
|
|
469
|
+
if (!child || !child.stdin || child.stdin.destroyed || !child.stdin.writable) {
|
|
470
|
+
return Promise.reject(new Error(`无法发送 MCP 消息:${this.name} 的子进程未运行`));
|
|
471
|
+
}
|
|
472
|
+
const payload = `${JSON.stringify(msg)}\n`;
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
child.stdin.write(payload, 'utf8', (err) => {
|
|
475
|
+
if (err) reject(err);
|
|
476
|
+
else resolve();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async close() {
|
|
482
|
+
if (this.closed) return;
|
|
483
|
+
this.closing = true;
|
|
484
|
+
this._rejectAll(`MCP 连接已关闭: ${this.name}`);
|
|
485
|
+
const child = this.child;
|
|
486
|
+
if (!child) {
|
|
487
|
+
this.closed = true;
|
|
488
|
+
this.ok = false;
|
|
489
|
+
this.failed = true;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const exited = new Promise((resolve) => {
|
|
493
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
494
|
+
resolve();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
child.once('exit', resolve);
|
|
498
|
+
});
|
|
499
|
+
try {
|
|
500
|
+
child.stdin?.end();
|
|
501
|
+
} catch {
|
|
502
|
+
/* 忽略 */
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
child.kill('SIGTERM');
|
|
506
|
+
} catch {
|
|
507
|
+
/* 已退出则忽略 */
|
|
508
|
+
}
|
|
509
|
+
const force = setTimeout(() => {
|
|
510
|
+
try {
|
|
511
|
+
child.kill('SIGKILL');
|
|
512
|
+
} catch {
|
|
513
|
+
/* 忽略 */
|
|
514
|
+
}
|
|
515
|
+
}, this.cfg.closeTimeoutMs);
|
|
516
|
+
await Promise.race([
|
|
517
|
+
exited,
|
|
518
|
+
new Promise((resolve) => setTimeout(resolve, this.cfg.closeTimeoutMs + 500)),
|
|
519
|
+
]);
|
|
520
|
+
clearTimeout(force);
|
|
521
|
+
this.closed = true;
|
|
522
|
+
this.ok = false;
|
|
523
|
+
this.failed = true;
|
|
524
|
+
this.child = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** streamable-http 传输:POST JSON-RPC,响应可以是 JSON 或 SSE。 */
|
|
529
|
+
class HttpConnection extends BaseConnection {
|
|
530
|
+
constructor(name, spec, cfg) {
|
|
531
|
+
super(name, spec, cfg);
|
|
532
|
+
this.kind = 'http';
|
|
533
|
+
this.url = String(spec.url);
|
|
534
|
+
this.sessionId = null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async start() {
|
|
538
|
+
// HTTP 没有本地子进程,握手时自然验证可达性
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
_headers() {
|
|
542
|
+
const headers = {
|
|
543
|
+
'content-type': 'application/json',
|
|
544
|
+
accept: 'application/json, text/event-stream',
|
|
545
|
+
};
|
|
546
|
+
if (this.spec.headers && typeof this.spec.headers === 'object') {
|
|
547
|
+
Object.assign(headers, this.spec.headers);
|
|
548
|
+
}
|
|
549
|
+
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
|
550
|
+
// 初始化之后的请求按规范带上协议版本
|
|
551
|
+
if (this.initialized) headers['mcp-protocol-version'] = PROTOCOL_VERSION;
|
|
552
|
+
return headers;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async _send(msg) {
|
|
556
|
+
if (this.closed) throw new Error(`MCP 连接已关闭: ${this.name}`);
|
|
557
|
+
const res = await fetch(this.url, {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
headers: this._headers(),
|
|
560
|
+
body: JSON.stringify(msg),
|
|
561
|
+
signal: AbortSignal.timeout(this.cfg.requestTimeoutMs + 1000),
|
|
562
|
+
});
|
|
563
|
+
const sid = res.headers.get('mcp-session-id');
|
|
564
|
+
if (sid) this.sessionId = sid;
|
|
565
|
+
if (res.status === 202 || res.status === 204) return; // 通知的典型响应
|
|
566
|
+
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
let body = '';
|
|
569
|
+
try {
|
|
570
|
+
body = await res.text();
|
|
571
|
+
} catch {
|
|
572
|
+
/* 忽略读取失败 */
|
|
573
|
+
}
|
|
574
|
+
throw new Error(
|
|
575
|
+
`MCP HTTP 请求失败 ${res.status} ${res.statusText}: ${body.slice(0, 500)}`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const contentType = String(res.headers.get('content-type') || '').toLowerCase();
|
|
580
|
+
const raw = await res.text();
|
|
581
|
+
if (!raw.trim()) return;
|
|
582
|
+
if (contentType.includes('text/event-stream')) {
|
|
583
|
+
parseSseText(raw, (m) => this._onMessage(m), (text) => this.log(text));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (contentType.includes('json')) {
|
|
587
|
+
this._consumeJsonText(raw);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// 容忍服务器不设 content-type:按内容猜
|
|
591
|
+
const trimmed = raw.trim();
|
|
592
|
+
if (trimmed.startsWith('data:')) {
|
|
593
|
+
parseSseText(raw, (m) => this._onMessage(m), (text) => this.log(text));
|
|
594
|
+
} else {
|
|
595
|
+
this._consumeJsonText(raw);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
_consumeJsonText(text) {
|
|
600
|
+
try {
|
|
601
|
+
this._onMessage(JSON.parse(text));
|
|
602
|
+
return;
|
|
603
|
+
} catch {
|
|
604
|
+
/* 可能是一行一个 JSON,继续尝试 */
|
|
605
|
+
}
|
|
606
|
+
for (const line of text.split(/\r?\n/)) {
|
|
607
|
+
this._onLine(line);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async close() {
|
|
612
|
+
if (this.closed) return;
|
|
613
|
+
this.closing = true;
|
|
614
|
+
this._rejectAll(`MCP 连接已关闭: ${this.name}`);
|
|
615
|
+
if (this.sessionId) {
|
|
616
|
+
try {
|
|
617
|
+
const headers = this._headers();
|
|
618
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
619
|
+
await fetch(this.url, {
|
|
620
|
+
method: 'DELETE',
|
|
621
|
+
headers,
|
|
622
|
+
signal: AbortSignal.timeout(this.cfg.closeTimeoutMs),
|
|
623
|
+
});
|
|
624
|
+
} catch (err) {
|
|
625
|
+
this.log(`关闭 HTTP 会话失败(忽略): ${describeError(err)}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
this.closed = true;
|
|
629
|
+
this.ok = false;
|
|
630
|
+
this.failed = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** 根据 spec 创建连接。 */
|
|
635
|
+
function makeConnection(name, spec, cfg) {
|
|
636
|
+
if (!spec || typeof spec !== 'object') {
|
|
637
|
+
throw new Error(`MCP spec 无效: ${name}`);
|
|
638
|
+
}
|
|
639
|
+
const type = spec.type ?? (spec.url ? 'http' : spec.command ? 'stdio' : '');
|
|
640
|
+
if (type === 'http') {
|
|
641
|
+
if (!spec.url) throw new Error(`MCP http spec 缺少 url: ${name}`);
|
|
642
|
+
return new HttpConnection(name, spec, cfg);
|
|
643
|
+
}
|
|
644
|
+
if (type === 'stdio') {
|
|
645
|
+
if (!spec.command) throw new Error(`MCP stdio spec 缺少 command: ${name}`);
|
|
646
|
+
return new StdioConnection(name, spec, cfg);
|
|
647
|
+
}
|
|
648
|
+
throw new Error(`MCP spec 无效(需要 command 或 url): ${name}`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 创建一个 MCP 连接池。
|
|
653
|
+
* @param {object} [opts]
|
|
654
|
+
* @param {number} [opts.requestTimeoutMs=30000] 单请求超时
|
|
655
|
+
* @param {number} [opts.closeTimeoutMs=3000] 关闭时 SIGTERM 到 SIGKILL 的等待
|
|
656
|
+
* @param {(text:string)=>void} [opts.log] 日志回调(默认仅 MCP_DEBUG 时输出)
|
|
657
|
+
*/
|
|
658
|
+
export function createMcpPool(opts = {}) {
|
|
659
|
+
const cfg = {
|
|
660
|
+
requestTimeoutMs:
|
|
661
|
+
Number.isFinite(opts.requestTimeoutMs) && opts.requestTimeoutMs > 0
|
|
662
|
+
? opts.requestTimeoutMs
|
|
663
|
+
: DEFAULT_TIMEOUT_MS,
|
|
664
|
+
closeTimeoutMs:
|
|
665
|
+
Number.isFinite(opts.closeTimeoutMs) && opts.closeTimeoutMs > 0
|
|
666
|
+
? opts.closeTimeoutMs
|
|
667
|
+
: DEFAULT_CLOSE_TIMEOUT_MS,
|
|
668
|
+
log: typeof opts.log === 'function' ? opts.log : defaultLog,
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
/** @type {Map<string, BaseConnection>} */
|
|
672
|
+
const connections = new Map();
|
|
673
|
+
/** @type {Map<string, Promise<object>>} 正在连接的 name,避免并发重复 spawn */
|
|
674
|
+
const connecting = new Map();
|
|
675
|
+
|
|
676
|
+
/** 握手:initialize → notifications/initialized → tools/list。 */
|
|
677
|
+
async function handshake(conn) {
|
|
678
|
+
const init = await conn.request('initialize', {
|
|
679
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
680
|
+
capabilities: {},
|
|
681
|
+
clientInfo: { ...CLIENT_INFO },
|
|
682
|
+
});
|
|
683
|
+
conn.serverInfo =
|
|
684
|
+
init && typeof init.serverInfo === 'object' ? init.serverInfo : null;
|
|
685
|
+
conn.protocolVersion =
|
|
686
|
+
typeof init?.protocolVersion === 'string' ? init.protocolVersion : PROTOCOL_VERSION;
|
|
687
|
+
conn.initialized = true;
|
|
688
|
+
await conn.notify('notifications/initialized');
|
|
689
|
+
conn.tools = await conn.listTools();
|
|
690
|
+
conn.ok = true;
|
|
691
|
+
conn.error = null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** 连接(或复用)一个 MCP server。 */
|
|
695
|
+
async function connect(name, spec) {
|
|
696
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
697
|
+
throw new Error('MCP connect 需要非空 name');
|
|
698
|
+
}
|
|
699
|
+
const inflight = connecting.get(name);
|
|
700
|
+
if (inflight) return inflight;
|
|
701
|
+
|
|
702
|
+
const existing = connections.get(name);
|
|
703
|
+
if (existing && existing.ok && !existing.closed && !existing.failed) {
|
|
704
|
+
// 健康连接直接复用,绝不重复 spawn
|
|
705
|
+
return existing.info();
|
|
706
|
+
}
|
|
707
|
+
if (existing) {
|
|
708
|
+
// 上一次连接已坏:先收尸再重连
|
|
709
|
+
connections.delete(name);
|
|
710
|
+
await existing.close().catch(() => {});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const task = (async () => {
|
|
714
|
+
const conn = makeConnection(name, spec, cfg);
|
|
715
|
+
// 先登记:即使启动/握手失败也保留在池里,方便 status() 报告 ok:false + error
|
|
716
|
+
connections.set(name, conn);
|
|
717
|
+
try {
|
|
718
|
+
await conn.start();
|
|
719
|
+
} catch (err) {
|
|
720
|
+
const reason = `MCP 启动失败 (${name}): ${describeError(err)}`;
|
|
721
|
+
conn.fail(reason);
|
|
722
|
+
await conn.close().catch(() => {});
|
|
723
|
+
throw new Error(reason);
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
await handshake(conn);
|
|
727
|
+
safeLog(cfg.log, `[mcp:${name}] 已连接,工具 ${conn.tools.length} 个`);
|
|
728
|
+
return conn.info();
|
|
729
|
+
} catch (err) {
|
|
730
|
+
const reason = `MCP 握手失败 (${name}): ${describeError(err)}`;
|
|
731
|
+
conn.fail(reason);
|
|
732
|
+
await conn.close().catch(() => {});
|
|
733
|
+
throw new Error(reason);
|
|
734
|
+
}
|
|
735
|
+
})();
|
|
736
|
+
|
|
737
|
+
connecting.set(name, task);
|
|
738
|
+
try {
|
|
739
|
+
return await task;
|
|
740
|
+
} finally {
|
|
741
|
+
connecting.delete(name);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** 已连接(含失败)server 的当前状态。 */
|
|
746
|
+
function status() {
|
|
747
|
+
return [...connections.values()].map((conn) => ({
|
|
748
|
+
name: conn.name,
|
|
749
|
+
ok: Boolean(conn.ok && !conn.failed && !conn.closed),
|
|
750
|
+
error: conn.error,
|
|
751
|
+
tools: conn.tools.map((t) => ({ ...t })),
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** 执行工具,返回拼接后的文本。 */
|
|
756
|
+
async function callTool(serverName, toolName, args) {
|
|
757
|
+
const conn = connections.get(serverName);
|
|
758
|
+
if (!conn) {
|
|
759
|
+
throw new Error(`MCP server 未连接: ${serverName}(请先 connect)`);
|
|
760
|
+
}
|
|
761
|
+
if (conn.closed) throw new Error(`MCP 连接已关闭: ${serverName}`);
|
|
762
|
+
if (conn.failed) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`MCP server 不可用: ${serverName}${conn.error ? ` — ${conn.error}` : ''}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (typeof toolName !== 'string' || toolName === '') {
|
|
768
|
+
throw new Error('MCP callTool 需要非空 toolName');
|
|
769
|
+
}
|
|
770
|
+
const result = await conn.request('tools/call', {
|
|
771
|
+
name: toolName,
|
|
772
|
+
arguments: args && typeof args === 'object' ? args : {},
|
|
773
|
+
});
|
|
774
|
+
const text = contentToText(result?.content);
|
|
775
|
+
if (result?.isError) {
|
|
776
|
+
const err = new Error(
|
|
777
|
+
`MCP 工具返回错误 ${serverName}/${toolName}: ${text || '(无错误文本)'}`,
|
|
778
|
+
);
|
|
779
|
+
err.mcpToolError = true;
|
|
780
|
+
err.content = result.content;
|
|
781
|
+
throw err;
|
|
782
|
+
}
|
|
783
|
+
if (text) return text;
|
|
784
|
+
if (result?.structuredContent !== undefined) {
|
|
785
|
+
return JSON.stringify(result.structuredContent);
|
|
786
|
+
}
|
|
787
|
+
return '';
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** 查一个工具的 schema(供 llm.mjs 的 function calling 用);找不到返回 null。 */
|
|
791
|
+
function toolDef(serverName, toolName) {
|
|
792
|
+
const conn = connections.get(serverName);
|
|
793
|
+
if (!conn || conn.closed) return null;
|
|
794
|
+
const tool = conn.tools.find((t) => t.name === toolName);
|
|
795
|
+
return tool ? { ...tool } : null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** 关闭全部连接(stdio 杀进程;http 发 DELETE 结束会话)。 */
|
|
799
|
+
async function closeAll() {
|
|
800
|
+
// 等正在握手的连接落地,避免漏杀
|
|
801
|
+
const inflight = [...connecting.values()];
|
|
802
|
+
if (inflight.length > 0) {
|
|
803
|
+
await Promise.all(inflight.map((p) => p.catch(() => {})));
|
|
804
|
+
}
|
|
805
|
+
const all = [...connections.values()];
|
|
806
|
+
connections.clear();
|
|
807
|
+
await Promise.all(
|
|
808
|
+
all.map((conn) =>
|
|
809
|
+
conn.close().catch((err) => {
|
|
810
|
+
safeLog(cfg.log, `[mcp:${conn.name}] 关闭失败(忽略): ${describeError(err)}`);
|
|
811
|
+
}),
|
|
812
|
+
),
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** 调试用:拿到 stdio 子进程(测试里用来模拟「进程被杀」)。 */
|
|
817
|
+
function _getChild(serverName) {
|
|
818
|
+
const conn = connections.get(serverName);
|
|
819
|
+
return conn && conn.kind === 'stdio' ? conn.child : null;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return { connect, status, callTool, toolDef, closeAll, _getChild };
|
|
823
|
+
}
|