s-app-scoder-agent 0.8.20 → 0.9.2
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 +50 -6
- package/dist/agent.js +754 -79
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +37 -11
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/command-registry.js +297 -9
- package/dist/tui/commands.js +224 -36
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -7,15 +7,30 @@
|
|
|
7
7
|
* lại → stdio. Exposes discovered tools as `mcp__<server>__<tool>` prefixed
|
|
8
8
|
* names in the agent's tool registry.
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { CLIENT_NAME, getVersion } from "./version.js";
|
|
12
12
|
import { redactSecrets } from "./llm.js";
|
|
13
13
|
export const MCP_TOOL_PREFIX = "mcp__";
|
|
14
14
|
export function mcpToolKey(serverName, toolName) {
|
|
15
15
|
return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
|
|
16
16
|
}
|
|
17
|
+
/** Phase 4: generic MCP resource/prompt tools (advertised only when available). */
|
|
18
|
+
export const MCP_RESOURCE_TOOL_NAMES = new Set([
|
|
19
|
+
"mcp_list_resources",
|
|
20
|
+
"mcp_read_resource",
|
|
21
|
+
"mcp_list_prompts",
|
|
22
|
+
"mcp_get_prompt",
|
|
23
|
+
]);
|
|
24
|
+
const schemaObj = (properties, required) => ({
|
|
25
|
+
type: "object",
|
|
26
|
+
properties,
|
|
27
|
+
required,
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
});
|
|
30
|
+
const strProp = (description) => ({ type: "string", description });
|
|
31
|
+
const boolProp = (description) => ({ type: "boolean", description });
|
|
17
32
|
/** Expand {env:VAR} references in config env values. */
|
|
18
|
-
function expandEnv(env) {
|
|
33
|
+
export function expandEnv(env) {
|
|
19
34
|
const out = {};
|
|
20
35
|
for (const [k, v] of Object.entries(env ?? {})) {
|
|
21
36
|
out[k] = v.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, n) => process.env[n] ?? "");
|
|
@@ -74,12 +89,33 @@ export class StdioTransport {
|
|
|
74
89
|
return Promise.resolve();
|
|
75
90
|
}
|
|
76
91
|
close() {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
const p = this.proc;
|
|
93
|
+
this.proc = null;
|
|
94
|
+
if (!p)
|
|
95
|
+
return;
|
|
96
|
+
// Release pipe handles so the parent process can exit immediately.
|
|
97
|
+
// On Windows shell:true (cmd.exe wrapper), p.kill() only kills cmd.exe —
|
|
98
|
+
// the real node child is orphaned and keeps stdout open. Use taskkill /T
|
|
99
|
+
// to kill the whole process tree; this also works for the plain node case.
|
|
100
|
+
try {
|
|
101
|
+
p.stdin?.end();
|
|
102
|
+
}
|
|
103
|
+
catch { /* ignore */ }
|
|
104
|
+
try {
|
|
105
|
+
p.stdout?.destroy();
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
108
|
+
try {
|
|
109
|
+
p.stderr?.destroy();
|
|
110
|
+
}
|
|
111
|
+
catch { /* ignore */ }
|
|
112
|
+
try {
|
|
113
|
+
p.kill();
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
if (process.platform === "win32" && p.pid) {
|
|
117
|
+
// Fire-and-forget; ignore errors — process may already be gone.
|
|
118
|
+
spawnSync("taskkill", ["/pid", String(p.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
83
119
|
}
|
|
84
120
|
}
|
|
85
121
|
onChunk(chunk) {
|
|
@@ -128,11 +164,11 @@ export class HttpTransport {
|
|
|
128
164
|
async send(msg) {
|
|
129
165
|
if (this.closed)
|
|
130
166
|
throw new Error(`MCP HTTP transport "${this.cfg.url}" closed`);
|
|
131
|
-
const res = await this.post(msg, this.sessionId);
|
|
167
|
+
const res = await this.post(msg, this.sessionId, undefined);
|
|
132
168
|
// Session expired (server restart) → thử lại một lần không session.
|
|
133
169
|
if ((res.status === 404 || res.status === 400) && this.sessionId) {
|
|
134
170
|
this.sessionId = null;
|
|
135
|
-
const retry = await this.post(msg, null);
|
|
171
|
+
const retry = await this.post(msg, null, undefined);
|
|
136
172
|
return this.consume(retry);
|
|
137
173
|
}
|
|
138
174
|
return this.consume(res);
|
|
@@ -141,11 +177,13 @@ export class HttpTransport {
|
|
|
141
177
|
this.closed = true;
|
|
142
178
|
this.sessionId = null;
|
|
143
179
|
}
|
|
144
|
-
|
|
180
|
+
defaultTimeout() {
|
|
181
|
+
return this.cfg.timeoutMs ?? 120_000;
|
|
182
|
+
}
|
|
183
|
+
async post(msg, sessionId, signal) {
|
|
145
184
|
const url = this.cfg.url;
|
|
146
185
|
if (!url)
|
|
147
186
|
throw new Error("MCP HTTP transport requires \"url\" in the server config");
|
|
148
|
-
// M3: headers hỗ trợ {env:VAR} substitution như env — không hardcode secrets.
|
|
149
187
|
const headers = {
|
|
150
188
|
"content-type": "application/json",
|
|
151
189
|
accept: "application/json, text/event-stream",
|
|
@@ -154,11 +192,17 @@ export class HttpTransport {
|
|
|
154
192
|
if (sessionId)
|
|
155
193
|
headers["mcp-session-id"] = sessionId;
|
|
156
194
|
try {
|
|
195
|
+
// Combine the caller's abort signal (MCP A cancellation) with an idle
|
|
196
|
+
// timeout into one AbortSignal. AbortSignal.any needs ≥1 real signal.
|
|
197
|
+
const signals = [AbortSignal.timeout(this.defaultTimeout())];
|
|
198
|
+
if (signal)
|
|
199
|
+
signals.push(signal);
|
|
200
|
+
const combined = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
|
|
157
201
|
return await fetch(url, {
|
|
158
202
|
method: "POST",
|
|
159
203
|
headers,
|
|
160
204
|
body: JSON.stringify(msg),
|
|
161
|
-
signal:
|
|
205
|
+
signal: combined,
|
|
162
206
|
});
|
|
163
207
|
}
|
|
164
208
|
catch (e) {
|
|
@@ -174,12 +218,7 @@ export class HttpTransport {
|
|
|
174
218
|
throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
|
|
175
219
|
}
|
|
176
220
|
const ct = res.headers.get("content-type") ?? "";
|
|
177
|
-
if (ct.includes("
|
|
178
|
-
const text = await res.text();
|
|
179
|
-
for (const m of parseSseMessages(text))
|
|
180
|
-
this.msgCb?.(m);
|
|
181
|
-
}
|
|
182
|
-
else if (ct.includes("application/json")) {
|
|
221
|
+
if (ct.includes("application/json")) {
|
|
183
222
|
const body = (await res.text()).trim();
|
|
184
223
|
if (body) {
|
|
185
224
|
try {
|
|
@@ -187,24 +226,76 @@ export class HttpTransport {
|
|
|
187
226
|
}
|
|
188
227
|
catch { /* skip malformed */ }
|
|
189
228
|
}
|
|
190
|
-
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (ct.includes("text/event-stream")) {
|
|
232
|
+
// MCP A: incremental SSE — stream body, emit each JSON-RPC message as
|
|
233
|
+
// its `data:` event completes, instead of buffering the entire response.
|
|
234
|
+
await this.consumeSseStream(res.body);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// 202/no-content (notifications) — nothing to parse
|
|
238
|
+
}
|
|
239
|
+
/** Incremental SSE: read the body as a stream and emit each JSON-RPC message
|
|
240
|
+
* as its SSE block (blank-line-separated) completes — no full-body buffer. */
|
|
241
|
+
async consumeSseStream(body) {
|
|
242
|
+
if (!body)
|
|
243
|
+
return;
|
|
244
|
+
const reader = body.getReader();
|
|
245
|
+
const decoder = new TextDecoder();
|
|
246
|
+
let buf = "";
|
|
247
|
+
try {
|
|
248
|
+
for (;;) {
|
|
249
|
+
const { done, value } = await reader.read();
|
|
250
|
+
if (done)
|
|
251
|
+
break;
|
|
252
|
+
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
|
|
253
|
+
let sep;
|
|
254
|
+
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
|
255
|
+
const block = buf.slice(0, sep);
|
|
256
|
+
buf = buf.slice(sep + 2);
|
|
257
|
+
const msg = parseSseEvent(block);
|
|
258
|
+
if (msg)
|
|
259
|
+
this.msgCb?.(msg);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
reader.releaseLock();
|
|
265
|
+
}
|
|
266
|
+
// Flush any trailing partial SSE block at EOF (no trailing \n\n).
|
|
267
|
+
if (buf.trim()) {
|
|
268
|
+
const msg = parseSseEvent(buf);
|
|
269
|
+
if (msg)
|
|
270
|
+
this.msgCb?.(msg);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Parse a single SSE block (blank-line-separated events) into one JSON-RPC message.
|
|
275
|
+
* Normalizes CRLF → LF before splitting on the event separator. */
|
|
276
|
+
export function parseSseEvent(block) {
|
|
277
|
+
const data = block
|
|
278
|
+
.replace(/\r\n/g, "\n")
|
|
279
|
+
.split(/\n/)
|
|
280
|
+
.filter((l) => l.startsWith("data:"))
|
|
281
|
+
.map((l) => l.slice(5).trim())
|
|
282
|
+
.join("\n");
|
|
283
|
+
if (!data)
|
|
284
|
+
return null;
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(data);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return null;
|
|
191
290
|
}
|
|
192
291
|
}
|
|
193
|
-
/** Parse SSE text thành JSON-RPC messages
|
|
292
|
+
/** Parse SSE text (legacy buffer-based) thành JSON-RPC messages. */
|
|
194
293
|
export function parseSseMessages(text) {
|
|
195
294
|
const out = [];
|
|
196
295
|
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
.map((l) => l.slice(5).trim())
|
|
201
|
-
.join("\n");
|
|
202
|
-
if (!data)
|
|
203
|
-
continue;
|
|
204
|
-
try {
|
|
205
|
-
out.push(JSON.parse(data));
|
|
206
|
-
}
|
|
207
|
-
catch { /* skip malformed */ }
|
|
296
|
+
const m = parseSseEvent(block);
|
|
297
|
+
if (m)
|
|
298
|
+
out.push(m);
|
|
208
299
|
}
|
|
209
300
|
return out;
|
|
210
301
|
}
|
|
@@ -220,6 +311,14 @@ export class McpConnection {
|
|
|
220
311
|
state = "connecting";
|
|
221
312
|
/** M1: status/state sink (AgentEvent mcp_status). */
|
|
222
313
|
onState = null;
|
|
314
|
+
/** MCP A: inbound server notifications (tools/list_changed, progress…). */
|
|
315
|
+
onNotification = null;
|
|
316
|
+
/** Server-reported metadata from `initialize` (surfaced on /mcp). */
|
|
317
|
+
serverInfo = {};
|
|
318
|
+
capabilities = {};
|
|
319
|
+
instructions = null;
|
|
320
|
+
/** Protocol version negotiated with the server. */
|
|
321
|
+
protocolVersion = "2024-11-05";
|
|
223
322
|
manuallyClosed = false;
|
|
224
323
|
reconnectAttempt = 0;
|
|
225
324
|
constructor(name, cfg) {
|
|
@@ -254,12 +353,17 @@ export class McpConnection {
|
|
|
254
353
|
async startAndHandshake(timeoutMs) {
|
|
255
354
|
await this.transport.start();
|
|
256
355
|
this.wireTransport();
|
|
257
|
-
// MCP handshake: initialize + notifications/initialized
|
|
258
|
-
|
|
356
|
+
// MCP handshake: initialize + notifications/initialized. Negotiate the
|
|
357
|
+
// highest protocol version we support that the server accepts.
|
|
358
|
+
const initRes = await this.rpc("initialize", {
|
|
259
359
|
protocolVersion: "2024-11-05",
|
|
260
360
|
capabilities: {},
|
|
261
361
|
clientInfo: { name: CLIENT_NAME, version: getVersion() },
|
|
262
362
|
}, timeoutMs);
|
|
363
|
+
this.protocolVersion = initRes.protocolVersion ?? "2024-11-05";
|
|
364
|
+
this.capabilities = initRes.capabilities ?? {};
|
|
365
|
+
this.serverInfo = initRes.serverInfo ?? {};
|
|
366
|
+
this.instructions = initRes.instructions ?? null;
|
|
263
367
|
void this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }).catch(() => {
|
|
264
368
|
// notification — không có pending để reject; lỗi sẽ lộ ở call sau.
|
|
265
369
|
});
|
|
@@ -268,8 +372,12 @@ export class McpConnection {
|
|
|
268
372
|
/** Wire inbound messages + channel-death → reconnect (stdio only). */
|
|
269
373
|
wireTransport() {
|
|
270
374
|
this.transport.onMessage((msg) => {
|
|
271
|
-
if (msg.id === undefined)
|
|
272
|
-
|
|
375
|
+
if (msg.id === undefined) {
|
|
376
|
+
// Server notifications (tools/list_changed, progress, message) —
|
|
377
|
+
// route them to the manager so it can react (re-register tools etc.).
|
|
378
|
+
this.onNotification?.(msg);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
273
381
|
const pending = this.pending.get(msg.id);
|
|
274
382
|
if (!pending)
|
|
275
383
|
return;
|
|
@@ -313,30 +421,70 @@ export class McpConnection {
|
|
|
313
421
|
await this.scheduleReconnect();
|
|
314
422
|
}
|
|
315
423
|
}
|
|
316
|
-
/**
|
|
317
|
-
* tools/list với timeout ngắn làm liveness probe. */
|
|
424
|
+
/** MCP A: liveness probe — prefer ping (official MCP spec), fallback tools/list. */
|
|
318
425
|
async healthy(timeoutMs = 5_000) {
|
|
319
426
|
if (!this.alive)
|
|
320
427
|
return false;
|
|
321
428
|
try {
|
|
322
|
-
|
|
429
|
+
// ping was added in MCP 2025-03; fall back to tools/list for older servers.
|
|
430
|
+
if (this.capabilities.ping === true) {
|
|
431
|
+
await this.rpc("ping", {}, timeoutMs);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
await this.rpc("tools/list", {}, timeoutMs);
|
|
435
|
+
}
|
|
323
436
|
return true;
|
|
324
437
|
}
|
|
325
438
|
catch {
|
|
326
439
|
return false;
|
|
327
440
|
}
|
|
328
441
|
}
|
|
442
|
+
/** MCP A: explicit ping. */
|
|
443
|
+
async ping(timeoutMs = 5_000) {
|
|
444
|
+
await this.rpc("ping", {}, timeoutMs);
|
|
445
|
+
}
|
|
329
446
|
async listTools(timeoutMs = 120_000) {
|
|
330
447
|
const res = await this.rpc("tools/list", {}, timeoutMs);
|
|
331
448
|
return (res.tools ?? []).filter((t) => typeof t.name === "string");
|
|
332
449
|
}
|
|
450
|
+
// ── Phase 4: resources ────────────────────────────────────────────────────
|
|
451
|
+
async listResources(timeoutMs = 30_000) {
|
|
452
|
+
const res = await this.rpc("resources/list", {}, timeoutMs);
|
|
453
|
+
return (res.resources ?? []).filter((r) => typeof r.uri === "string");
|
|
454
|
+
}
|
|
455
|
+
async listResourceTemplates(timeoutMs = 30_000) {
|
|
456
|
+
const res = await this.rpc("resources/templates/list", {}, timeoutMs);
|
|
457
|
+
return (res.resourceTemplates ?? []).filter((r) => typeof r.uriTemplate === "string");
|
|
458
|
+
}
|
|
459
|
+
async readResource(uri, timeoutMs = 30_000) {
|
|
460
|
+
const res = await this.rpc("resources/read", { uri }, timeoutMs);
|
|
461
|
+
return { contents: res.contents ?? [] };
|
|
462
|
+
}
|
|
463
|
+
async subscribeResource(uri, timeoutMs = 30_000) {
|
|
464
|
+
await this.rpc("resources/subscribe", { uri }, timeoutMs);
|
|
465
|
+
}
|
|
466
|
+
async unsubscribeResource(uri, timeoutMs = 30_000) {
|
|
467
|
+
await this.rpc("resources/unsubscribe", { uri }, timeoutMs);
|
|
468
|
+
}
|
|
469
|
+
// ── Phase 4: prompts ──────────────────────────────────────────────────────
|
|
470
|
+
async listPrompts(timeoutMs = 30_000) {
|
|
471
|
+
const res = await this.rpc("prompts/list", {}, timeoutMs);
|
|
472
|
+
return (res.prompts ?? []).filter((p) => typeof p.name === "string");
|
|
473
|
+
}
|
|
474
|
+
async getPrompt(name, args, timeoutMs = 30_000) {
|
|
475
|
+
const res = await this.rpc("prompts/get", { name, arguments: args ?? {} }, timeoutMs);
|
|
476
|
+
return { description: res.description, messages: res.messages ?? [] };
|
|
477
|
+
}
|
|
333
478
|
async callTool(name, args, timeoutMs = 120_000) {
|
|
334
479
|
const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
|
|
335
480
|
const isErr = res.isError === true;
|
|
336
481
|
const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
|
|
337
482
|
// H-13: MCP servers are third-party — redact secret shapes from output
|
|
338
483
|
// before it enters the conversation or logs.
|
|
339
|
-
|
|
484
|
+
const structured = res.structuredContent
|
|
485
|
+
? JSON.parse(redactSecrets(JSON.stringify(res.structuredContent)))
|
|
486
|
+
: undefined;
|
|
487
|
+
return { ok: !isErr, output: redactSecrets(parts.join("\n")) || "(empty result)", structured };
|
|
340
488
|
}
|
|
341
489
|
kill() {
|
|
342
490
|
this.manuallyClosed = true;
|
|
@@ -381,21 +529,47 @@ export class McpManager {
|
|
|
381
529
|
onStatus = null;
|
|
382
530
|
/** Sidebar TUI: máy trạng thái per-server (connecting/ready/reconnecting/dead). */
|
|
383
531
|
onServerState = null;
|
|
532
|
+
/** Phase 4: server-pushed resource updates (after resources/subscribe). */
|
|
533
|
+
onResourceUpdated = null;
|
|
534
|
+
/** Phase 4: progress notifications from long-running MCP tool calls. */
|
|
535
|
+
onProgress = null;
|
|
536
|
+
/** Phase 4: per-server resource/template cache (refreshed on list_changed). */
|
|
537
|
+
resources = new Map();
|
|
538
|
+
/** Phase 4: per-server prompt cache. */
|
|
539
|
+
prompts = new Map();
|
|
540
|
+
/** Phase 4: URIs subscribed per server. */
|
|
541
|
+
subscriptions = new Map();
|
|
384
542
|
constructor(configs) {
|
|
385
543
|
this.configs = configs;
|
|
386
544
|
}
|
|
387
545
|
/** Connect all servers; skip failures with a warning. Returns discovered tool count. */
|
|
388
546
|
async connectAll() {
|
|
547
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
389
548
|
await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
|
|
549
|
+
// MCP B: `enabled:false` and `trust:"readonly"` servers are NOT spawned —
|
|
550
|
+
// readonly mode must never start an external process. Report them as
|
|
551
|
+
// skipped so /mcp can still list them.
|
|
552
|
+
if (cfg.enabled === false || cfg.trust === "readonly") {
|
|
553
|
+
if (cfg.enabled === false)
|
|
554
|
+
report(`· MCP "${name}" skipped (enabled: false)`);
|
|
555
|
+
else
|
|
556
|
+
report(`· MCP "${name}" skipped (trust: readonly — not spawned)`);
|
|
557
|
+
this.onServerState?.(name, "disabled", cfg.enabled === false ? "enabled:false" : "trust:readonly");
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
390
560
|
const conn = new McpConnection(name, cfg);
|
|
391
561
|
this.wireReconnect(name, conn);
|
|
392
562
|
await conn.connect();
|
|
393
563
|
await this.registerTools(name, conn);
|
|
564
|
+
await this.refreshResources(name, conn);
|
|
565
|
+
await this.refreshPrompts(name, conn);
|
|
394
566
|
this.conns.set(name, conn);
|
|
395
567
|
})).then((results) => {
|
|
396
|
-
const report = this.onStatus ?? ((line) => console.error(line));
|
|
397
568
|
results.forEach((r, i) => {
|
|
398
569
|
const name = Object.keys(this.configs)[i];
|
|
570
|
+
const cfg = this.configs[name];
|
|
571
|
+
if (cfg?.enabled === false || cfg?.trust === "readonly")
|
|
572
|
+
return; // already reported as skipped
|
|
399
573
|
if (r.status === "rejected") {
|
|
400
574
|
this.onServerState?.(name, "dead", "connect failed");
|
|
401
575
|
report(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
|
|
@@ -408,7 +582,7 @@ export class McpManager {
|
|
|
408
582
|
});
|
|
409
583
|
return this.tools.size;
|
|
410
584
|
}
|
|
411
|
-
/** M1: surface connection state machine + refresh tools sau reconnect. */
|
|
585
|
+
/** M1: surface connection state machine + refresh tools sau reconnect + wire notifications. */
|
|
412
586
|
wireReconnect(name, conn) {
|
|
413
587
|
conn.onState = (state, detail) => {
|
|
414
588
|
// Sidebar TUI: structured state trước — mọi transition đều thú vị với
|
|
@@ -421,11 +595,59 @@ export class McpManager {
|
|
|
421
595
|
report(`✘ MCP "${name}" dead: ${detail}`);
|
|
422
596
|
if (state === "ready" && detail === "reconnected") {
|
|
423
597
|
report(`✓ MCP "${name}" reconnected`);
|
|
424
|
-
// Refresh tool
|
|
598
|
+
// Refresh tool + resource + prompt lists — the server may have changed.
|
|
425
599
|
conn
|
|
426
600
|
.listTools()
|
|
427
601
|
.then((defs) => this.reregisterTools(name, conn, defs))
|
|
428
602
|
.catch(() => { });
|
|
603
|
+
void this.refreshResources(name, conn);
|
|
604
|
+
void this.refreshPrompts(name, conn);
|
|
605
|
+
for (const uri of this.subscriptions.get(name) ?? []) {
|
|
606
|
+
void conn.subscribeResource(uri).catch(() => { });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
// MCP A: react to server notifications — tools/list_changed re-registers
|
|
611
|
+
// the tool set (no full reconnect needed); progress/message are surfaced
|
|
612
|
+
// as status lines but don't disrupt anything.
|
|
613
|
+
conn.onNotification = (msg) => {
|
|
614
|
+
const method = String(msg.method ?? "");
|
|
615
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
616
|
+
const params = (msg.params ?? {});
|
|
617
|
+
if (method === "notifications/tools/list_changed" || method === "tools/list_changed") {
|
|
618
|
+
conn
|
|
619
|
+
.listTools()
|
|
620
|
+
.then((defs) => {
|
|
621
|
+
this.reregisterTools(name, conn, defs);
|
|
622
|
+
report(`↻ MCP "${name}" tools changed — re-registered ${defs.length}`);
|
|
623
|
+
})
|
|
624
|
+
.catch(() => { });
|
|
625
|
+
}
|
|
626
|
+
else if (method === "notifications/resources/list_changed" || method === "resources/list_changed") {
|
|
627
|
+
void this.refreshResources(name, conn).then(() => {
|
|
628
|
+
const n = this.resources.get(name)?.resources.length ?? 0;
|
|
629
|
+
report(`↻ MCP "${name}" resources changed — ${n} resource${n === 1 ? "" : "s"}`);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
else if (method === "notifications/prompts/list_changed" || method === "prompts/list_changed") {
|
|
633
|
+
void this.refreshPrompts(name, conn).then(() => {
|
|
634
|
+
const n = this.prompts.get(name)?.length ?? 0;
|
|
635
|
+
report(`↻ MCP "${name}" prompts changed — ${n} prompt${n === 1 ? "" : "s"}`);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
else if (method === "notifications/resources/updated" || method === "resources/updated") {
|
|
639
|
+
const uri = typeof params.uri === "string" ? params.uri : "";
|
|
640
|
+
if (uri) {
|
|
641
|
+
this.onResourceUpdated?.(name, uri);
|
|
642
|
+
report(`↻ MCP "${name}" resource updated: ${uri}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
else if (method === "notifications/progress" || method === "progress") {
|
|
646
|
+
// Long-running tool progress — forwarded to the UI hook (no chat spam).
|
|
647
|
+
this.onProgress?.(name, params);
|
|
648
|
+
}
|
|
649
|
+
else if (method === "notifications/message" || method === "message") {
|
|
650
|
+
report(`ℹ MCP "${name}": ${String(params.message ?? "message")}`);
|
|
429
651
|
}
|
|
430
652
|
};
|
|
431
653
|
}
|
|
@@ -433,13 +655,64 @@ export class McpManager {
|
|
|
433
655
|
const defs = await conn.listTools();
|
|
434
656
|
this.reregisterTools(name, conn, defs);
|
|
435
657
|
}
|
|
436
|
-
/**
|
|
658
|
+
/** Phase 4: cache resources + templates when the server advertises them. */
|
|
659
|
+
async refreshResources(name, conn) {
|
|
660
|
+
if (conn.capabilities.resources == null) {
|
|
661
|
+
this.resources.delete(name);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
let resources = [];
|
|
665
|
+
let templates = [];
|
|
666
|
+
try {
|
|
667
|
+
resources = await conn.listResources();
|
|
668
|
+
}
|
|
669
|
+
catch { /* optional method */ }
|
|
670
|
+
try {
|
|
671
|
+
templates = await conn.listResourceTemplates();
|
|
672
|
+
}
|
|
673
|
+
catch { /* optional method */ }
|
|
674
|
+
this.resources.set(name, { resources, templates });
|
|
675
|
+
}
|
|
676
|
+
/** Phase 4: cache prompts when the server advertises them. */
|
|
677
|
+
async refreshPrompts(name, conn) {
|
|
678
|
+
if (conn.capabilities.prompts == null) {
|
|
679
|
+
this.prompts.delete(name);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
this.prompts.set(name, await conn.listPrompts());
|
|
684
|
+
}
|
|
685
|
+
catch { /* optional method */ }
|
|
686
|
+
}
|
|
687
|
+
/** MCP A: apply per-server tool include/exclude filters to a tool list. */
|
|
688
|
+
filterToolDefs(name, defs) {
|
|
689
|
+
const filter = this.configs[name]?.tools;
|
|
690
|
+
if (!filter)
|
|
691
|
+
return defs;
|
|
692
|
+
const include = filter.include?.filter(Boolean) ?? [];
|
|
693
|
+
const exclude = new Set((filter.exclude ?? []).map((s) => s.toLowerCase()));
|
|
694
|
+
const match = (pat, n) => {
|
|
695
|
+
if (!pat.includes("*"))
|
|
696
|
+
return n === pat.toLowerCase();
|
|
697
|
+
const rx = new RegExp("^" + pat.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
|
|
698
|
+
return rx.test(n);
|
|
699
|
+
};
|
|
700
|
+
return defs.filter((t) => {
|
|
701
|
+
const n = t.name.toLowerCase();
|
|
702
|
+
if (include.length > 0 && !include.some((pat) => match(pat, n)))
|
|
703
|
+
return false;
|
|
704
|
+
if ([...exclude].some((e) => match(e, n)))
|
|
705
|
+
return false;
|
|
706
|
+
return true;
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/** Replace một server's tool registrations (reconnect refresh) — honor config filters. */
|
|
437
710
|
reregisterTools(name, conn, defs) {
|
|
438
711
|
for (const [key, t] of [...this.tools]) {
|
|
439
712
|
if (t.serverName === name)
|
|
440
713
|
this.tools.delete(key);
|
|
441
714
|
}
|
|
442
|
-
for (const def of defs) {
|
|
715
|
+
for (const def of this.filterToolDefs(name, defs)) {
|
|
443
716
|
const key = mcpToolKey(name, def.name);
|
|
444
717
|
this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
|
|
445
718
|
}
|
|
@@ -460,6 +733,211 @@ export class McpManager {
|
|
|
460
733
|
getTool(name) {
|
|
461
734
|
return this.tools.get(name);
|
|
462
735
|
}
|
|
736
|
+
/** MCP A: connection state + metadata for /mcp status display.
|
|
737
|
+
* connected is based on McpConnection.state, not tool count. */
|
|
738
|
+
serverStates() {
|
|
739
|
+
return [...this.conns.entries()].map(([name, conn]) => ({
|
|
740
|
+
name,
|
|
741
|
+
state: conn.state,
|
|
742
|
+
serverInfo: conn.serverInfo,
|
|
743
|
+
capabilities: conn.capabilities,
|
|
744
|
+
instructions: conn.instructions,
|
|
745
|
+
toolCount: [...this.tools.values()].filter((t) => t.serverName === name).length,
|
|
746
|
+
resourceCount: this.resources.get(name)?.resources.length ?? 0,
|
|
747
|
+
promptCount: this.prompts.get(name)?.length ?? 0,
|
|
748
|
+
}));
|
|
749
|
+
}
|
|
750
|
+
/** Phase 4: cached resources + templates per server. */
|
|
751
|
+
listResourcesByServer() {
|
|
752
|
+
return [...this.resources.entries()].map(([server, r]) => ({ server, ...r }));
|
|
753
|
+
}
|
|
754
|
+
/** Phase 4: cached prompts per server. */
|
|
755
|
+
listPromptsByServer() {
|
|
756
|
+
return [...this.prompts.entries()].map(([server, prompts]) => ({ server, prompts }));
|
|
757
|
+
}
|
|
758
|
+
hasResources() {
|
|
759
|
+
return this.resources.size > 0;
|
|
760
|
+
}
|
|
761
|
+
hasPrompts() {
|
|
762
|
+
return this.prompts.size > 0;
|
|
763
|
+
}
|
|
764
|
+
/** Phase 4: read a resource; optionally subscribe to its update notifications. */
|
|
765
|
+
async readResource(server, uri, opts = {}) {
|
|
766
|
+
const conn = this.conns.get(server);
|
|
767
|
+
if (!conn)
|
|
768
|
+
return { ok: false, output: `ERROR: MCP server "${server}" is not connected.` };
|
|
769
|
+
try {
|
|
770
|
+
const res = await conn.readResource(uri);
|
|
771
|
+
if (opts.subscribe) {
|
|
772
|
+
try {
|
|
773
|
+
await conn.subscribeResource(uri);
|
|
774
|
+
const set = this.subscriptions.get(server) ?? new Set();
|
|
775
|
+
set.add(uri);
|
|
776
|
+
this.subscriptions.set(server, set);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
// Subscription is best-effort; the read itself already succeeded.
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
const parts = res.contents.map((c) => {
|
|
783
|
+
if (typeof c.text === "string")
|
|
784
|
+
return c.text;
|
|
785
|
+
if (typeof c.blob === "string") {
|
|
786
|
+
return `[binary ${c.mimeType ?? "application/octet-stream"} — ${c.blob.length} base64 chars]`;
|
|
787
|
+
}
|
|
788
|
+
return "";
|
|
789
|
+
}).filter(Boolean);
|
|
790
|
+
return { ok: true, output: redactSecrets(parts.join("\n")) || "(empty resource)" };
|
|
791
|
+
}
|
|
792
|
+
catch (err) {
|
|
793
|
+
return { ok: false, output: `ERROR: resources/read failed: ${err.message}` };
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** Phase 4: render a server prompt template into text messages. */
|
|
797
|
+
async getPrompt(server, name, args) {
|
|
798
|
+
const conn = this.conns.get(server);
|
|
799
|
+
if (!conn)
|
|
800
|
+
return { ok: false, output: `ERROR: MCP server "${server}" is not connected.` };
|
|
801
|
+
try {
|
|
802
|
+
const res = await conn.getPrompt(name, args);
|
|
803
|
+
const msgs = res.messages.map((m) => {
|
|
804
|
+
const content = m.content;
|
|
805
|
+
const text = content && content.type === "text" && typeof content.text === "string"
|
|
806
|
+
? content.text
|
|
807
|
+
: JSON.stringify(m.content);
|
|
808
|
+
return `[${m.role ?? "user"}] ${text}`;
|
|
809
|
+
});
|
|
810
|
+
const header = res.description ? `${res.description}\n\n` : "";
|
|
811
|
+
return { ok: true, output: redactSecrets(header + (msgs.join("\n\n") || "(empty prompt)")) };
|
|
812
|
+
}
|
|
813
|
+
catch (err) {
|
|
814
|
+
return { ok: false, output: `ERROR: prompts/get failed: ${err.message}` };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/** Phase 4: true when `name` is a generic resource/prompt tool. */
|
|
818
|
+
isResourceTool(name) {
|
|
819
|
+
return MCP_RESOURCE_TOOL_NAMES.has(name);
|
|
820
|
+
}
|
|
821
|
+
/** Phase 4: specs for resource/prompt tools — advertised only when available. */
|
|
822
|
+
resourceToolSpecs() {
|
|
823
|
+
const specs = [];
|
|
824
|
+
if (this.hasResources()) {
|
|
825
|
+
specs.push({
|
|
826
|
+
type: "function",
|
|
827
|
+
function: {
|
|
828
|
+
name: "mcp_list_resources",
|
|
829
|
+
description: "List MCP resources and resource templates exposed by connected servers. " +
|
|
830
|
+
"Use before mcp_read_resource when you don't know the URI.",
|
|
831
|
+
parameters: schemaObj({ server: strProp("Optional server name — omit to list all servers") }, []),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
specs.push({
|
|
835
|
+
type: "function",
|
|
836
|
+
function: {
|
|
837
|
+
name: "mcp_read_resource",
|
|
838
|
+
description: "Read one MCP resource by URI (resources/read). Pass subscribe=true to receive " +
|
|
839
|
+
"resources/updated notifications for that URI.",
|
|
840
|
+
parameters: schemaObj({
|
|
841
|
+
server: strProp("Server name that owns the resource"),
|
|
842
|
+
uri: strProp("Resource URI (from mcp_list_resources or a resource template)"),
|
|
843
|
+
subscribe: boolProp("Subscribe to updates for this URI (default: false)"),
|
|
844
|
+
}, ["server", "uri"]),
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
if (this.hasPrompts()) {
|
|
849
|
+
specs.push({
|
|
850
|
+
type: "function",
|
|
851
|
+
function: {
|
|
852
|
+
name: "mcp_list_prompts",
|
|
853
|
+
description: "List prompt templates exposed by connected MCP servers.",
|
|
854
|
+
parameters: schemaObj({ server: strProp("Optional server name — omit to list all servers") }, []),
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
specs.push({
|
|
858
|
+
type: "function",
|
|
859
|
+
function: {
|
|
860
|
+
name: "mcp_get_prompt",
|
|
861
|
+
description: "Render one MCP prompt template (prompts/get) into text messages.",
|
|
862
|
+
parameters: schemaObj({
|
|
863
|
+
server: strProp("Server name that owns the prompt"),
|
|
864
|
+
name: strProp("Prompt name (from mcp_list_prompts)"),
|
|
865
|
+
arguments: {
|
|
866
|
+
type: "object",
|
|
867
|
+
description: "Prompt arguments (string values)",
|
|
868
|
+
additionalProperties: { type: "string" },
|
|
869
|
+
},
|
|
870
|
+
}, ["server", "name"]),
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
return specs;
|
|
875
|
+
}
|
|
876
|
+
/** Phase 4: dispatch a generic resource/prompt tool call. */
|
|
877
|
+
async invokeResourceTool(name, args) {
|
|
878
|
+
switch (name) {
|
|
879
|
+
case "mcp_list_resources": {
|
|
880
|
+
const server = typeof args.server === "string" ? args.server : undefined;
|
|
881
|
+
const rows = this.listResourcesByServer().filter((r) => !server || r.server === server);
|
|
882
|
+
if (rows.length === 0)
|
|
883
|
+
return { ok: true, output: "(no MCP resources)" };
|
|
884
|
+
return {
|
|
885
|
+
ok: true,
|
|
886
|
+
output: rows.map(({ server: s, resources, templates }) => {
|
|
887
|
+
const lines = [`## ${s}`];
|
|
888
|
+
for (const r of resources) {
|
|
889
|
+
lines.push(`- ${r.uri}${r.name ? ` — ${r.name}` : ""}${r.mimeType ? ` (${r.mimeType})` : ""}`);
|
|
890
|
+
}
|
|
891
|
+
for (const t of templates) {
|
|
892
|
+
lines.push(`- template: ${t.uriTemplate}${t.name ? ` — ${t.name}` : ""}`);
|
|
893
|
+
}
|
|
894
|
+
return lines.join("\n");
|
|
895
|
+
}).join("\n\n"),
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
case "mcp_read_resource": {
|
|
899
|
+
const server = typeof args.server === "string" ? args.server : "";
|
|
900
|
+
const uri = typeof args.uri === "string" ? args.uri : "";
|
|
901
|
+
if (!server || !uri)
|
|
902
|
+
return { ok: false, output: "ERROR: mcp_read_resource requires server and uri." };
|
|
903
|
+
return this.readResource(server, uri, { subscribe: args.subscribe === true });
|
|
904
|
+
}
|
|
905
|
+
case "mcp_list_prompts": {
|
|
906
|
+
const server = typeof args.server === "string" ? args.server : undefined;
|
|
907
|
+
const rows = this.listPromptsByServer().filter((r) => !server || r.server === server);
|
|
908
|
+
if (rows.length === 0)
|
|
909
|
+
return { ok: true, output: "(no MCP prompts)" };
|
|
910
|
+
return {
|
|
911
|
+
ok: true,
|
|
912
|
+
output: rows.map(({ server: s, prompts }) => {
|
|
913
|
+
const lines = [`## ${s}`];
|
|
914
|
+
for (const p of prompts) {
|
|
915
|
+
const params = (p.arguments ?? []).map((a) => `${a.name}${a.required ? "" : "?"}`).join(", ");
|
|
916
|
+
lines.push(`- ${p.name}${params ? ` (${params})` : ""}${p.description ? ` — ${p.description}` : ""}`);
|
|
917
|
+
}
|
|
918
|
+
return lines.join("\n");
|
|
919
|
+
}).join("\n\n"),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
case "mcp_get_prompt": {
|
|
923
|
+
const server = typeof args.server === "string" ? args.server : "";
|
|
924
|
+
const promptName = typeof args.name === "string" ? args.name : "";
|
|
925
|
+
if (!server || !promptName)
|
|
926
|
+
return { ok: false, output: "ERROR: mcp_get_prompt requires server and name." };
|
|
927
|
+
const rawArgs = args.arguments;
|
|
928
|
+
const promptArgs = {};
|
|
929
|
+
if (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs)) {
|
|
930
|
+
for (const [k, v] of Object.entries(rawArgs)) {
|
|
931
|
+
if (typeof v === "string")
|
|
932
|
+
promptArgs[k] = v;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return this.getPrompt(server, promptName, promptArgs);
|
|
936
|
+
}
|
|
937
|
+
default:
|
|
938
|
+
return { ok: false, output: `ERROR: unknown MCP resource tool "${name}".` };
|
|
939
|
+
}
|
|
940
|
+
}
|
|
463
941
|
/** Convert all discovered MCP tools to OpenAI function-calling specs. */
|
|
464
942
|
toToolSpecs() {
|
|
465
943
|
return [...this.tools.values()].map(({ conn: _, serverName, toolName, def }) => ({
|
|
@@ -476,39 +954,60 @@ export class McpManager {
|
|
|
476
954
|
c.kill();
|
|
477
955
|
this.conns.clear();
|
|
478
956
|
this.tools.clear();
|
|
957
|
+
this.resources.clear();
|
|
958
|
+
this.prompts.clear();
|
|
959
|
+
this.subscriptions.clear();
|
|
479
960
|
}
|
|
480
|
-
/** M4: config hot-reload — connect server mới, kill server đã bị xóa khỏi config. */
|
|
961
|
+
/** M4: config hot-reload — connect server mới, kill server đã bị xóa/changed khỏi config. */
|
|
481
962
|
async applyConfig(configs) {
|
|
482
|
-
this.
|
|
483
|
-
//
|
|
963
|
+
const report = this.onStatus ?? ((line) => console.error(line));
|
|
964
|
+
// A server's transport-effective config changed → restart it (kill + reconnect).
|
|
965
|
+
const configChanged = (prev, next) => {
|
|
966
|
+
const pick = (c) => JSON.stringify([c.command, c.url, c.type, c.args, c.env ?? {}, c.headers ?? {}, c.timeoutMs ?? null, c.tools ?? {}, c.enabled ?? true, c.trust ?? "auto"]);
|
|
967
|
+
return pick(prev) !== pick(next);
|
|
968
|
+
};
|
|
969
|
+
// Server bị xóa hoặc đổi config → kill + unregister.
|
|
484
970
|
for (const [name, conn] of [...this.conns]) {
|
|
485
|
-
if (!configs[name]) {
|
|
971
|
+
if (!configs[name] || configChanged(this.configs[name], configs[name])) {
|
|
486
972
|
conn.kill();
|
|
487
973
|
this.conns.delete(name);
|
|
488
974
|
for (const [key, t] of [...this.tools]) {
|
|
489
975
|
if (t.serverName === name)
|
|
490
976
|
this.tools.delete(key);
|
|
491
977
|
}
|
|
492
|
-
|
|
493
|
-
|
|
978
|
+
this.resources.delete(name);
|
|
979
|
+
this.prompts.delete(name);
|
|
980
|
+
this.subscriptions.delete(name);
|
|
981
|
+
if (configs[name] && configChanged(this.configs[name], configs[name])) {
|
|
982
|
+
report(`↻ MCP "${name}" config changed — restarting`);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
report(`✘ MCP "${name}" removed (config changed)`);
|
|
986
|
+
}
|
|
494
987
|
}
|
|
495
988
|
}
|
|
496
|
-
|
|
989
|
+
this.configs = configs;
|
|
990
|
+
// Server mới → connect (except enabled:false / trust:readonly — never spawn).
|
|
497
991
|
for (const [name, cfg] of Object.entries(configs)) {
|
|
498
992
|
if (this.conns.has(name))
|
|
499
993
|
continue;
|
|
994
|
+
if (cfg.enabled === false || cfg.trust === "readonly") {
|
|
995
|
+
this.onServerState?.(name, "disabled", cfg.enabled === false ? "enabled:false" : "trust:readonly");
|
|
996
|
+
report(`· MCP "${name}" skipped (${cfg.enabled === false ? "enabled: false" : "trust: readonly — not spawned"})`);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
500
999
|
const conn = new McpConnection(name, cfg);
|
|
501
1000
|
this.wireReconnect(name, conn);
|
|
502
1001
|
try {
|
|
503
1002
|
await conn.connect();
|
|
504
1003
|
await this.registerTools(name, conn);
|
|
1004
|
+
await this.refreshResources(name, conn);
|
|
1005
|
+
await this.refreshPrompts(name, conn);
|
|
505
1006
|
this.conns.set(name, conn);
|
|
506
|
-
const report = this.onStatus ?? ((line) => console.error(line));
|
|
507
1007
|
const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
|
|
508
1008
|
report(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"} (hot-reload)`);
|
|
509
1009
|
}
|
|
510
1010
|
catch (e) {
|
|
511
|
-
const report = this.onStatus ?? ((line) => console.error(line));
|
|
512
1011
|
report(`⚠ MCP server "${name}" unavailable: ${String(e.message).slice(0, 160)}`);
|
|
513
1012
|
}
|
|
514
1013
|
}
|