mocode-ai 0.6.7 → 0.6.9
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 +9 -4
- package/README.zh-CN.md +5 -0
- package/dist/agent/core.js +5 -5
- package/dist/agent/index.js +10 -8
- package/dist/agent/spawn.js +1 -5
- package/dist/commands/config.js +9 -7
- package/dist/config/index.js +49 -41
- package/dist/context/budget.js +2 -3
- package/dist/i18n/index.js +450 -0
- package/dist/index.js +8 -4
- package/dist/llm/index.js +19 -16
- package/dist/mcp/client.js +389 -0
- package/dist/mcp/config.js +128 -0
- package/dist/mcp/index.js +105 -0
- package/dist/mcp/registry.js +5 -0
- package/dist/mcp/types.js +1 -0
- package/dist/permissions/index.js +16 -12
- package/dist/pet/state.js +2 -1
- package/dist/repl/index.js +210 -102
- package/dist/rollback/index.js +311 -119
- package/dist/session/persist.js +11 -5
- package/dist/tools/builtins/ask-human.js +4 -3
- package/dist/tools/builtins/run-command.js +6 -5
- package/dist/tools/builtins/task.js +5 -4
- package/dist/tools/registry.js +58 -22
- package/dist/tools/result.js +7 -0
- package/dist/ui/intervention.js +12 -7
- package/dist/ui/layout.js +4 -3
- package/dist/ui/prompt.js +83 -28
- package/dist/ui/render.js +15 -12
- package/package.json +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
const PROTOCOL_VERSION = '2025-03-26';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
class McpTransport {
|
|
5
|
+
receive;
|
|
6
|
+
constructor(receive) {
|
|
7
|
+
this.receive = receive;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
class StdioTransport extends McpTransport {
|
|
11
|
+
spec;
|
|
12
|
+
child = null;
|
|
13
|
+
buffer = Buffer.alloc(0);
|
|
14
|
+
constructor(receive, spec) {
|
|
15
|
+
super(receive);
|
|
16
|
+
this.spec = spec;
|
|
17
|
+
}
|
|
18
|
+
async start() {
|
|
19
|
+
if (this.child)
|
|
20
|
+
return;
|
|
21
|
+
const shell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(this.spec.command);
|
|
22
|
+
const child = spawn(this.spec.command, this.spec.args ?? [], {
|
|
23
|
+
cwd: this.spec.cwd,
|
|
24
|
+
env: { ...process.env, ...this.spec.env },
|
|
25
|
+
shell,
|
|
26
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
});
|
|
29
|
+
this.child = child;
|
|
30
|
+
child.stdout?.on('data', (chunk) => this.onData(chunk));
|
|
31
|
+
child.stderr?.on('data', () => { });
|
|
32
|
+
child.on('exit', (code, signal) => { this.child = null; if (code !== 0 && signal == null)
|
|
33
|
+
this.receive({ error: { message: `stdio server 已退出 (${code})` } }); });
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
const onSpawn = () => { child.off('error', onError); resolve(); };
|
|
36
|
+
const onError = (error) => { child.off('spawn', onSpawn); reject(error); };
|
|
37
|
+
child.once('spawn', onSpawn);
|
|
38
|
+
child.once('error', onError);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async send(message) {
|
|
42
|
+
if (!this.child?.stdin?.writable)
|
|
43
|
+
throw new Error('MCP stdio server 未连接');
|
|
44
|
+
const body = `${JSON.stringify(message)}\n`;
|
|
45
|
+
// MCP stdio 传输使用 NDJSON;接收端仍兼容 Content-Length,方便接入历史 LSP 风格服务。
|
|
46
|
+
await new Promise((resolve, reject) => this.child.stdin.write(body, (error) => error ? reject(error) : resolve()));
|
|
47
|
+
}
|
|
48
|
+
async close() {
|
|
49
|
+
const child = this.child;
|
|
50
|
+
this.child = null;
|
|
51
|
+
if (!child || child.killed)
|
|
52
|
+
return;
|
|
53
|
+
child.kill();
|
|
54
|
+
}
|
|
55
|
+
onData(chunk) {
|
|
56
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
57
|
+
while (this.buffer.length > 0) {
|
|
58
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
59
|
+
if (headerEnd === -1) {
|
|
60
|
+
const newline = this.buffer.indexOf('\n');
|
|
61
|
+
if (newline === -1)
|
|
62
|
+
return;
|
|
63
|
+
const line = this.buffer.subarray(0, newline).toString('utf8').trim();
|
|
64
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
65
|
+
this.receiveJson(line);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const header = this.buffer.subarray(0, headerEnd).toString('ascii');
|
|
69
|
+
const match = /content-length\s*:\s*(\d+)/i.exec(header);
|
|
70
|
+
if (!match) {
|
|
71
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const length = Number(match[1]);
|
|
75
|
+
const bodyStart = headerEnd + 4;
|
|
76
|
+
if (this.buffer.length < bodyStart + length)
|
|
77
|
+
return;
|
|
78
|
+
this.receiveJson(this.buffer.subarray(bodyStart, bodyStart + length).toString('utf8'));
|
|
79
|
+
this.buffer = this.buffer.subarray(bodyStart + length);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
receiveJson(raw) {
|
|
83
|
+
try {
|
|
84
|
+
this.receive(JSON.parse(raw));
|
|
85
|
+
}
|
|
86
|
+
catch { /* 忽略 server 的非协议 stdout,避免打断会话。 */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
class StreamableHttpTransport extends McpTransport {
|
|
90
|
+
spec;
|
|
91
|
+
sessionId = null;
|
|
92
|
+
constructor(receive, spec) {
|
|
93
|
+
super(receive);
|
|
94
|
+
this.spec = spec;
|
|
95
|
+
}
|
|
96
|
+
async start() { }
|
|
97
|
+
async send(message) {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => controller.abort(), timeoutFor(this.spec));
|
|
100
|
+
try {
|
|
101
|
+
const headers = {
|
|
102
|
+
accept: 'application/json, text/event-stream',
|
|
103
|
+
'content-type': 'application/json',
|
|
104
|
+
...this.spec.headers,
|
|
105
|
+
};
|
|
106
|
+
if (this.sessionId)
|
|
107
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
108
|
+
const response = await fetch(this.spec.url, {
|
|
109
|
+
method: 'POST', headers, body: JSON.stringify(message), signal: controller.signal, redirect: 'error',
|
|
110
|
+
});
|
|
111
|
+
this.captureSession(response);
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
114
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
115
|
+
if (contentType.includes('text/event-stream')) {
|
|
116
|
+
await consumeSse(response.body, (event) => this.receiveEvent(event), controller.signal);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const text = await response.text();
|
|
120
|
+
if (text.trim())
|
|
121
|
+
this.receiveEvent({ event: 'message', data: text });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async close() { this.sessionId = null; }
|
|
129
|
+
captureSession(response) { this.sessionId = response.headers.get('mcp-session-id') ?? this.sessionId; }
|
|
130
|
+
receiveEvent(event) { try {
|
|
131
|
+
this.receive(JSON.parse(event.data));
|
|
132
|
+
}
|
|
133
|
+
catch { /* 非 JSON SSE event 无法作为 MCP 响应。 */ } }
|
|
134
|
+
}
|
|
135
|
+
class SseTransport extends McpTransport {
|
|
136
|
+
spec;
|
|
137
|
+
endpoint = null;
|
|
138
|
+
controller = null;
|
|
139
|
+
connecting = null;
|
|
140
|
+
resolveEndpoint = null;
|
|
141
|
+
rejectEndpoint = null;
|
|
142
|
+
constructor(receive, spec) {
|
|
143
|
+
super(receive);
|
|
144
|
+
this.spec = spec;
|
|
145
|
+
}
|
|
146
|
+
async start() {
|
|
147
|
+
if (this.connecting)
|
|
148
|
+
return this.connecting;
|
|
149
|
+
this.controller = new AbortController();
|
|
150
|
+
this.connecting = new Promise((resolve, reject) => { this.resolveEndpoint = resolve; this.rejectEndpoint = reject; });
|
|
151
|
+
void this.open();
|
|
152
|
+
return this.connecting;
|
|
153
|
+
}
|
|
154
|
+
async send(message) {
|
|
155
|
+
await this.start();
|
|
156
|
+
if (!this.endpoint)
|
|
157
|
+
throw new Error('SSE MCP server 未提供 message endpoint');
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timer = setTimeout(() => controller.abort(), timeoutFor(this.spec));
|
|
160
|
+
try {
|
|
161
|
+
const response = await fetch(this.endpoint, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json', ...this.spec.headers },
|
|
164
|
+
body: JSON.stringify(message), signal: controller.signal, redirect: 'error',
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok)
|
|
167
|
+
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async close() {
|
|
174
|
+
this.controller?.abort();
|
|
175
|
+
this.controller = null;
|
|
176
|
+
this.endpoint = null;
|
|
177
|
+
this.connecting = null;
|
|
178
|
+
}
|
|
179
|
+
async open() {
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetch(this.spec.url, {
|
|
182
|
+
headers: { accept: 'text/event-stream', ...this.spec.headers }, signal: this.controller?.signal, redirect: 'error',
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok)
|
|
185
|
+
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
186
|
+
await consumeSse(response.body, (event) => {
|
|
187
|
+
if (event.event === 'endpoint') {
|
|
188
|
+
this.endpoint = new URL(event.data, this.spec.url);
|
|
189
|
+
this.resolveEndpoint?.();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
this.receive(JSON.parse(event.data));
|
|
194
|
+
}
|
|
195
|
+
catch { /* 忽略非 MCP SSE event。 */ }
|
|
196
|
+
}, this.controller?.signal);
|
|
197
|
+
if (!this.endpoint)
|
|
198
|
+
throw new Error('SSE MCP server 在连接关闭前未提供 endpoint');
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (!this.controller?.signal.aborted)
|
|
202
|
+
this.rejectEndpoint?.(asError(error));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** 统一的 MCP client:完成 initialize、tools/list、tools/call 及请求超时/abort 管理。 */
|
|
207
|
+
export class McpClient {
|
|
208
|
+
name;
|
|
209
|
+
spec;
|
|
210
|
+
transport;
|
|
211
|
+
pending = new Map();
|
|
212
|
+
nextId = 1;
|
|
213
|
+
initialized = false;
|
|
214
|
+
closed = false;
|
|
215
|
+
cachedTools = [];
|
|
216
|
+
constructor(name, spec) {
|
|
217
|
+
this.name = name;
|
|
218
|
+
this.spec = spec;
|
|
219
|
+
const receive = (message) => this.onMessage(message);
|
|
220
|
+
// 向后兼容早期仅提供 command/args 的 stdio 配置;新配置显式写 transport 更清晰。
|
|
221
|
+
this.transport = spec.transport === 'stdio' || ('command' in spec && typeof spec.command === 'string')
|
|
222
|
+
? new StdioTransport(receive, spec)
|
|
223
|
+
: spec.transport === 'sse'
|
|
224
|
+
? new SseTransport(receive, spec)
|
|
225
|
+
: new StreamableHttpTransport(receive, spec);
|
|
226
|
+
}
|
|
227
|
+
isReady() { return this.initialized && !this.closed; }
|
|
228
|
+
async initialize() {
|
|
229
|
+
if (this.isReady())
|
|
230
|
+
return;
|
|
231
|
+
this.closed = false;
|
|
232
|
+
await this.transport.start();
|
|
233
|
+
const result = await this.request('initialize', {
|
|
234
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
235
|
+
capabilities: {},
|
|
236
|
+
clientInfo: { name: 'mocode-ai', version: '0.6.8' },
|
|
237
|
+
});
|
|
238
|
+
if (!result || typeof result !== 'object')
|
|
239
|
+
throw new Error('MCP initialize 返回了无效响应');
|
|
240
|
+
await this.notify('notifications/initialized', {});
|
|
241
|
+
this.initialized = true;
|
|
242
|
+
await this.listTools();
|
|
243
|
+
}
|
|
244
|
+
async listTools() {
|
|
245
|
+
if (this.closed)
|
|
246
|
+
throw new Error('MCP client 已关闭');
|
|
247
|
+
const tools = [];
|
|
248
|
+
let cursor;
|
|
249
|
+
do {
|
|
250
|
+
const result = await this.request('tools/list', cursor ? { cursor } : {});
|
|
251
|
+
if (!Array.isArray(result.tools))
|
|
252
|
+
throw new Error('MCP tools/list 返回了无效 tools');
|
|
253
|
+
for (const tool of result.tools) {
|
|
254
|
+
if (!isRecord(tool) || typeof tool.name !== 'string')
|
|
255
|
+
continue;
|
|
256
|
+
tools.push({
|
|
257
|
+
name: tool.name,
|
|
258
|
+
description: typeof tool.description === 'string' ? tool.description : undefined,
|
|
259
|
+
inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : { type: 'object', properties: {} },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
cursor = typeof result.nextCursor === 'string' && result.nextCursor ? result.nextCursor : undefined;
|
|
263
|
+
} while (cursor);
|
|
264
|
+
this.cachedTools = tools;
|
|
265
|
+
return tools;
|
|
266
|
+
}
|
|
267
|
+
async callTool(name, arguments_, signal) {
|
|
268
|
+
if (!this.isReady())
|
|
269
|
+
throw new Error(`MCP server ${this.name} 未连接`);
|
|
270
|
+
const result = await this.request('tools/call', { name, arguments: arguments_ }, signal);
|
|
271
|
+
return {
|
|
272
|
+
content: Array.isArray(result.content) ? result.content : [],
|
|
273
|
+
...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
|
|
274
|
+
...(result.isError === true ? { isError: true } : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
async close() {
|
|
278
|
+
this.closed = true;
|
|
279
|
+
this.initialized = false;
|
|
280
|
+
for (const [id] of this.pending)
|
|
281
|
+
this.fail(id, new Error('MCP client 已关闭'));
|
|
282
|
+
await this.transport.close();
|
|
283
|
+
}
|
|
284
|
+
async notify(method, params) {
|
|
285
|
+
await this.transport.send({ jsonrpc: '2.0', method, params });
|
|
286
|
+
}
|
|
287
|
+
request(method, params, signal) {
|
|
288
|
+
if (this.closed)
|
|
289
|
+
return Promise.reject(new Error('MCP client 已关闭'));
|
|
290
|
+
const id = this.nextId++;
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const timer = setTimeout(() => this.fail(id, new Error(`MCP ${method} 请求超时`)), timeoutFor(this.spec));
|
|
293
|
+
const onAbort = () => this.fail(id, new Error(`MCP ${method} 已中断`));
|
|
294
|
+
const pending = { resolve: (value) => resolve(value), reject, timer, signal, onAbort };
|
|
295
|
+
this.pending.set(id, pending);
|
|
296
|
+
if (signal) {
|
|
297
|
+
if (signal.aborted)
|
|
298
|
+
return onAbort();
|
|
299
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
300
|
+
}
|
|
301
|
+
void this.transport.send({ jsonrpc: '2.0', id, method, params }).catch((error) => this.fail(id, asError(error)));
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
onMessage(raw) {
|
|
305
|
+
if (!isRecord(raw) || (typeof raw.id !== 'number' && typeof raw.id !== 'string'))
|
|
306
|
+
return;
|
|
307
|
+
const id = typeof raw.id === 'number' ? raw.id : Number(raw.id);
|
|
308
|
+
if (!Number.isInteger(id) || !this.pending.has(id))
|
|
309
|
+
return;
|
|
310
|
+
if (isRecord(raw.error)) {
|
|
311
|
+
this.fail(id, new Error(typeof raw.error.message === 'string' ? raw.error.message : 'MCP JSON-RPC error'));
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
this.succeed(id, raw.result);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
succeed(id, value) {
|
|
318
|
+
const pending = this.pending.get(id);
|
|
319
|
+
if (!pending)
|
|
320
|
+
return;
|
|
321
|
+
this.pending.delete(id);
|
|
322
|
+
clearTimeout(pending.timer);
|
|
323
|
+
if (pending.signal && pending.onAbort)
|
|
324
|
+
pending.signal.removeEventListener('abort', pending.onAbort);
|
|
325
|
+
pending.resolve(value);
|
|
326
|
+
}
|
|
327
|
+
fail(id, error) {
|
|
328
|
+
const pending = this.pending.get(id);
|
|
329
|
+
if (!pending)
|
|
330
|
+
return;
|
|
331
|
+
this.pending.delete(id);
|
|
332
|
+
clearTimeout(pending.timer);
|
|
333
|
+
if (pending.signal && pending.onAbort)
|
|
334
|
+
pending.signal.removeEventListener('abort', pending.onAbort);
|
|
335
|
+
pending.reject(error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function timeoutFor(spec) {
|
|
339
|
+
return spec.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
340
|
+
}
|
|
341
|
+
async function consumeSse(body, onEvent, signal) {
|
|
342
|
+
if (!body)
|
|
343
|
+
throw new Error('MCP SSE 响应没有 body');
|
|
344
|
+
const reader = body.getReader();
|
|
345
|
+
const decoder = new TextDecoder();
|
|
346
|
+
let buffered = '';
|
|
347
|
+
try {
|
|
348
|
+
while (true) {
|
|
349
|
+
if (signal?.aborted)
|
|
350
|
+
throw new Error('MCP SSE 请求已中断');
|
|
351
|
+
const { done, value } = await reader.read();
|
|
352
|
+
buffered += decoder.decode(value, { stream: !done });
|
|
353
|
+
let boundary;
|
|
354
|
+
while ((boundary = buffered.search(/\r?\n\r?\n/)) !== -1) {
|
|
355
|
+
const block = buffered.slice(0, boundary);
|
|
356
|
+
const separatorLength = buffered[boundary] === '\r' ? (buffered[boundary + 2] === '\r' ? 4 : 3) : (buffered[boundary + 1] === '\r' ? 3 : 2);
|
|
357
|
+
buffered = buffered.slice(boundary + separatorLength);
|
|
358
|
+
const event = parseSseBlock(block);
|
|
359
|
+
if (event)
|
|
360
|
+
onEvent(event);
|
|
361
|
+
}
|
|
362
|
+
if (done)
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
const finalEvent = parseSseBlock(buffered);
|
|
366
|
+
if (finalEvent)
|
|
367
|
+
onEvent(finalEvent);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
reader.releaseLock();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function parseSseBlock(block) {
|
|
374
|
+
let event = 'message';
|
|
375
|
+
const data = [];
|
|
376
|
+
for (const line of block.split(/\r?\n/)) {
|
|
377
|
+
if (line.startsWith('event:'))
|
|
378
|
+
event = line.slice(6).trim();
|
|
379
|
+
else if (line.startsWith('data:'))
|
|
380
|
+
data.push(line.slice(5).trimStart());
|
|
381
|
+
}
|
|
382
|
+
return data.length ? { event, data: data.join('\n') } : null;
|
|
383
|
+
}
|
|
384
|
+
function isRecord(value) {
|
|
385
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
386
|
+
}
|
|
387
|
+
function asError(value) {
|
|
388
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
389
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* 加载 MCP server 定义。支持 MCP_CONFIG_PATH 指向的 JSON 文件,以及 MCP_SERVERS JSON 环境变量;
|
|
5
|
+
* 后者按 server name 覆盖前者,便于把 token 仅放在本机环境变量中。
|
|
6
|
+
*
|
|
7
|
+
* JSON 格式兼容常见的 { "mcpServers": { name: { command/url/... } } },也允许直接 server map。
|
|
8
|
+
*/
|
|
9
|
+
export function readMcpServers() {
|
|
10
|
+
const warnings = [];
|
|
11
|
+
const servers = new Map();
|
|
12
|
+
const fromFile = process.env.MCP_CONFIG_PATH;
|
|
13
|
+
if (fromFile) {
|
|
14
|
+
try {
|
|
15
|
+
const target = path.resolve(process.cwd(), expandEnv(fromFile));
|
|
16
|
+
mergeServerMap(JSON.parse(fs.readFileSync(target, 'utf8')), servers, warnings, target);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
warnings.push(`无法读取 MCP_CONFIG_PATH: ${formatError(error)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (process.env.MCP_SERVERS) {
|
|
23
|
+
try {
|
|
24
|
+
mergeServerMap(JSON.parse(process.env.MCP_SERVERS), servers, warnings, 'MCP_SERVERS');
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
warnings.push(`MCP_SERVERS 不是合法 JSON: ${formatError(error)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { servers: Array.from(servers.values()).filter((server) => !server.disabled), warnings };
|
|
31
|
+
}
|
|
32
|
+
function mergeServerMap(raw, target, warnings, source) {
|
|
33
|
+
const root = isRecord(raw) && isRecord(raw.mcpServers) ? raw.mcpServers : raw;
|
|
34
|
+
if (Array.isArray(root)) {
|
|
35
|
+
for (const entry of root) {
|
|
36
|
+
if (!isRecord(entry) || typeof entry.name !== 'string') {
|
|
37
|
+
warnings.push(`${source} 中的 MCP 数组项必须含 name`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const parsed = parseServer(entry.name, entry, source, warnings);
|
|
41
|
+
if (parsed)
|
|
42
|
+
target.set(parsed.name, parsed);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!isRecord(root)) {
|
|
47
|
+
warnings.push(`${source} 必须是 MCP server 对象或 { mcpServers: ... }`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const [name, entry] of Object.entries(root)) {
|
|
51
|
+
const parsed = parseServer(name, entry, source, warnings);
|
|
52
|
+
if (parsed)
|
|
53
|
+
target.set(parsed.name, parsed);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function parseServer(name, raw, source, warnings) {
|
|
57
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(name) || !isRecord(raw)) {
|
|
58
|
+
warnings.push(`${source} 中的 MCP server 名称或配置无效: ${name}`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const transport = normalizeTransport(raw.transport ?? raw.type, raw);
|
|
62
|
+
const requestTimeoutMs = positiveNumber(raw.requestTimeoutMs ?? raw.timeoutMs);
|
|
63
|
+
const common = { name, transport, disabled: raw.disabled === true, ...(requestTimeoutMs ? { requestTimeoutMs } : {}) };
|
|
64
|
+
if (transport === 'stdio') {
|
|
65
|
+
if (typeof raw.command !== 'string' || !raw.command.trim()) {
|
|
66
|
+
warnings.push(`${source}.${name} (stdio) 缺少 command`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
...common,
|
|
71
|
+
transport,
|
|
72
|
+
command: expandEnv(raw.command),
|
|
73
|
+
args: stringArray(raw.args),
|
|
74
|
+
env: stringRecord(raw.env),
|
|
75
|
+
cwd: typeof raw.cwd === 'string' ? path.resolve(process.cwd(), expandEnv(raw.cwd)) : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (typeof raw.url !== 'string') {
|
|
79
|
+
warnings.push(`${source}.${name} (${transport}) 缺少 url`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(expandEnv(raw.url));
|
|
84
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
85
|
+
throw new Error('只允许 http 或 https URL');
|
|
86
|
+
return { ...common, transport, url: url.toString(), headers: stringRecord(raw.headers) };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
warnings.push(`${source}.${name} 的远程 URL 无效: ${formatError(error)}`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function normalizeTransport(value, raw) {
|
|
94
|
+
if (value === 'sse')
|
|
95
|
+
return 'sse';
|
|
96
|
+
if (value === 'streamable-http' || value === 'http' || value === 'streamableHttp')
|
|
97
|
+
return 'streamable-http';
|
|
98
|
+
return typeof raw.command === 'string' ? 'stdio' : 'streamable-http';
|
|
99
|
+
}
|
|
100
|
+
function positiveNumber(value) {
|
|
101
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
102
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
|
103
|
+
}
|
|
104
|
+
function stringArray(value) {
|
|
105
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
106
|
+
? value.map(expandEnv)
|
|
107
|
+
: undefined;
|
|
108
|
+
}
|
|
109
|
+
function stringRecord(value) {
|
|
110
|
+
if (!isRecord(value))
|
|
111
|
+
return undefined;
|
|
112
|
+
const result = {};
|
|
113
|
+
for (const [key, item] of Object.entries(value)) {
|
|
114
|
+
if (typeof item === 'string')
|
|
115
|
+
result[key] = expandEnv(item);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
/** 展开 ${NAME};缺失变量保留为空,避免把宿主环境变量字面量送往远端。 */
|
|
120
|
+
function expandEnv(value) {
|
|
121
|
+
return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => process.env[key] ?? '');
|
|
122
|
+
}
|
|
123
|
+
function isRecord(value) {
|
|
124
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
function formatError(error) {
|
|
127
|
+
return error instanceof Error ? error.message : String(error);
|
|
128
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readMcpServers } from './config.js';
|
|
2
|
+
import { McpClient } from './client.js';
|
|
3
|
+
const clients = new Map();
|
|
4
|
+
let startupWarnings = [];
|
|
5
|
+
/** 连接所有已配置 server;单个连接失败不阻断 CLI,其余 server 仍可用。 */
|
|
6
|
+
export async function initializeAllMcp() {
|
|
7
|
+
await closeAllMcp();
|
|
8
|
+
const { servers, warnings } = readMcpServers();
|
|
9
|
+
startupWarnings = [...warnings];
|
|
10
|
+
const connected = [];
|
|
11
|
+
for (const spec of servers) {
|
|
12
|
+
const client = new McpClient(spec.name, spec);
|
|
13
|
+
try {
|
|
14
|
+
await client.initialize();
|
|
15
|
+
clients.set(spec.name, client);
|
|
16
|
+
connected.push(spec.name);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
await client.close().catch(() => undefined);
|
|
20
|
+
startupWarnings.push(`MCP ${spec.name} 连接失败: ${formatError(error)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { connected, warnings: [...startupWarnings] };
|
|
24
|
+
}
|
|
25
|
+
/** 把已发现的远程工具包装为内部 Tool;所有 MCP 工具默认 dangerous,逐次经权限面板确认。 */
|
|
26
|
+
export function getMcpTools() {
|
|
27
|
+
const usedNames = new Set();
|
|
28
|
+
const tools = [];
|
|
29
|
+
for (const client of clients.values()) {
|
|
30
|
+
const serverPart = sanitizeName(client.name);
|
|
31
|
+
for (const remote of client.cachedTools) {
|
|
32
|
+
const localName = `mcp__${serverPart}__${sanitizeName(remote.name)}`;
|
|
33
|
+
if (usedNames.has(localName)) {
|
|
34
|
+
startupWarnings.push(`MCP ${client.name} 的工具 ${remote.name} 名称冲突,已忽略`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
usedNames.add(localName);
|
|
38
|
+
tools.push({
|
|
39
|
+
name: localName,
|
|
40
|
+
description: `[MCP: ${client.name}] ${remote.description || `调用 ${remote.name}`}`,
|
|
41
|
+
parameters: remote.inputSchema && typeof remote.inputSchema === 'object'
|
|
42
|
+
? remote.inputSchema
|
|
43
|
+
: { type: 'object', properties: {} },
|
|
44
|
+
// 远程/本地 server 均可能读写系统或网络;协议没有副作用声明,必须保守处理。
|
|
45
|
+
risk: 'dangerous',
|
|
46
|
+
execute: async (args, ctx) => formatToolResult(await client.callTool(remote.name, args, ctx?.signal)),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tools;
|
|
51
|
+
}
|
|
52
|
+
export async function closeAllMcp() {
|
|
53
|
+
const active = Array.from(clients.values());
|
|
54
|
+
clients.clear();
|
|
55
|
+
await Promise.allSettled(active.map((client) => client.close()));
|
|
56
|
+
}
|
|
57
|
+
export function getMcpWarnings() { return [...startupWarnings]; }
|
|
58
|
+
function sanitizeName(name) {
|
|
59
|
+
return name.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'tool';
|
|
60
|
+
}
|
|
61
|
+
function formatToolResult(result) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
for (const item of result.content) {
|
|
64
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
65
|
+
parts.push(safeJson(item));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const content = item;
|
|
69
|
+
if (content.type === 'text' && typeof content.text === 'string') {
|
|
70
|
+
parts.push(content.text);
|
|
71
|
+
}
|
|
72
|
+
else if (content.type === 'image') {
|
|
73
|
+
parts.push(`[MCP image: ${typeof content.mimeType === 'string' ? content.mimeType : 'unknown'},已省略二进制数据]`);
|
|
74
|
+
}
|
|
75
|
+
else if (content.type === 'resource_link') {
|
|
76
|
+
parts.push(`[MCP resource: ${String(content.name ?? content.uri ?? 'unknown')}]`);
|
|
77
|
+
}
|
|
78
|
+
else if (content.type === 'resource' && content.resource && typeof content.resource === 'object') {
|
|
79
|
+
const resource = content.resource;
|
|
80
|
+
parts.push(typeof resource.text === 'string' ? resource.text : `[MCP resource: ${String(resource.uri ?? 'unknown')}]`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
parts.push(safeJson(content));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (result.structuredContent !== undefined)
|
|
87
|
+
parts.push(`\nstructuredContent:\n${safeJson(result.structuredContent)}`);
|
|
88
|
+
const body = parts.join('\n').trim() || '(MCP 工具未返回内容)';
|
|
89
|
+
return result.isError ? `MCP 工具报告错误:\n${body}` : body;
|
|
90
|
+
}
|
|
91
|
+
function safeJson(value) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(value, null, 2);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function formatError(error) {
|
|
100
|
+
return error instanceof Error ? error.message : String(error);
|
|
101
|
+
}
|
|
102
|
+
/** 仅供 MCP smoke test 注入已握手的假 client;生产路径必须走 initializeAllMcp。 */
|
|
103
|
+
export function __testInjectClient(client) {
|
|
104
|
+
clients.set(client.name, client);
|
|
105
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|