s-app-scoder-agent 0.5.0 → 0.7.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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -435
  4. package/dist/agent.js +829 -168
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +216 -45
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +148 -11
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +537 -24
  30. package/dist/tui/app.js +615 -0
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +222 -0
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +342 -0
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/ui.js +43 -3
  48. package/dist/watch.js +52 -0
  49. package/package.json +18 -5
package/dist/mcp.js CHANGED
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Minimal MCP (Model Context Protocol) client for stdio servers.
2
+ * Minimal MCP (Model Context Protocol) client.
3
3
  *
4
- * Implements JSON-RPC 2.0 over stdin/stdout with line-delimited messages.
5
- * Connects to configured stdio servers, exposes their tools as
6
- * `mcp__<server>__<tool>` prefixed names in the agent's tool registry.
4
+ * M3: pluggable transport — `StdioTransport` (JSON-RPC 2.0 over stdin/stdout,
5
+ * line-delimited) và `HttpTransport` (Streamable HTTP: POST JSON-RPC, nhận
6
+ * JSON hoặc SSE stream, echo `Mcp-Session-Id`). Config `url` → http, ngược
7
+ * lại → stdio. Exposes discovered tools as `mcp__<server>__<tool>` prefixed
8
+ * names in the agent's tool registry.
7
9
  */
8
10
  import { spawn } from "node:child_process";
9
11
  import { CLIENT_NAME, getVersion } from "./version.js";
12
+ import { redactSecrets } from "./llm.js";
10
13
  export const MCP_TOOL_PREFIX = "mcp__";
11
14
  export function mcpToolKey(serverName, toolName) {
12
15
  return `${MCP_TOOL_PREFIX}${serverName}__${toolName}`;
@@ -19,38 +22,309 @@ function expandEnv(env) {
19
22
  }
20
23
  return out;
21
24
  }
22
- // ── JSON-RPC stdio connection ─────────────────────────────────────────────────
23
- export class McpConnection {
24
- name;
25
+ /** stdio transport — spawn process, line-delimited JSON-RPC trên stdin/stdout. */
26
+ export class StdioTransport {
25
27
  cfg;
28
+ kind = "stdio";
26
29
  proc = null;
27
- nextId = 1;
28
- pending = new Map();
30
+ /** Tail of the server's stderr (last 4KB) — surfaced on connect failures. */
31
+ stderrTail = "";
29
32
  buf = "";
30
- constructor(name, cfg) {
31
- this.name = name;
33
+ msgCb = null;
34
+ downCb = null;
35
+ constructor(cfg) {
32
36
  this.cfg = cfg;
33
37
  }
34
38
  get alive() {
35
39
  return this.proc !== null && this.proc.exitCode === null;
36
40
  }
37
- async connect(timeoutMs = 15_000) {
38
- const p = spawn(this.cfg.command, this.cfg.args ?? [], {
41
+ onMessage(cb) {
42
+ this.msgCb = cb;
43
+ }
44
+ onDown(cb) {
45
+ this.downCb = cb;
46
+ }
47
+ async start() {
48
+ const command = this.cfg.command;
49
+ if (!command)
50
+ throw new Error("MCP stdio transport requires \"command\" in the server config");
51
+ const p = spawn(command, this.cfg.args ?? [], {
39
52
  stdio: ["pipe", "pipe", "pipe"],
40
53
  env: { ...process.env, ...expandEnv(this.cfg.env) },
54
+ // Windows: npx/foo.cmd chỉ spawn được qua shell (libuv không tự tìm .cmd).
55
+ shell: process.platform === "win32",
41
56
  });
42
57
  this.proc = p;
43
- p.stderr?.on("data", () => { });
44
- p.on("error", (e) => this.drain(new Error(`spawn: ${e.message}`)));
45
- p.on("exit", () => this.drain(new Error("process exited")));
58
+ p.stderr?.on("data", (c) => {
59
+ // Keep the tail for diagnostics (server logs/help output on crash);
60
+ // otherwise stderr is intentionally ignored.
61
+ this.stderrTail = (this.stderrTail + c.toString("utf8")).slice(-4096);
62
+ });
63
+ p.on("error", (e) => this.downCb?.(new Error(`spawn: ${e.message}`)));
64
+ p.on("exit", (code) => this.downCb?.(new Error(`process exited (code ${code ?? "?"})`)));
46
65
  p.stdout?.on("data", (c) => this.onChunk(c));
66
+ }
67
+ send(msg) {
68
+ if (!this.proc?.stdin?.writable)
69
+ return Promise.resolve();
70
+ try {
71
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
72
+ }
73
+ catch { /* ignore */ }
74
+ return Promise.resolve();
75
+ }
76
+ close() {
77
+ if (this.proc) {
78
+ try {
79
+ this.proc.kill();
80
+ }
81
+ catch { /* already dead */ }
82
+ this.proc = null;
83
+ }
84
+ }
85
+ onChunk(chunk) {
86
+ this.buf += chunk.toString("utf8");
87
+ let i;
88
+ while ((i = this.buf.indexOf("\n")) !== -1) {
89
+ const line = this.buf.slice(0, i).trim();
90
+ this.buf = this.buf.slice(i + 1);
91
+ if (!line)
92
+ continue;
93
+ try {
94
+ this.msgCb?.(JSON.parse(line));
95
+ }
96
+ catch { /* skip malformed */ }
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * M3: Streamable HTTP transport — POST một JSON-RPC message, server trả về
102
+ * `application/json` (single response) hoặc `text/event-stream` (SSE; parse
103
+ * các `data:` events). Echo `Mcp-Session-Id` từ initialize trên các request
104
+ * sau. Stateless: lỗi request → reject call hiện tại; call sau tự retry
105
+ * (không cần background reconnect như stdio).
106
+ */
107
+ export class HttpTransport {
108
+ cfg;
109
+ kind = "http";
110
+ sessionId = null;
111
+ msgCb = null;
112
+ closed = false;
113
+ constructor(cfg) {
114
+ this.cfg = cfg;
115
+ }
116
+ get alive() {
117
+ return !this.closed;
118
+ }
119
+ onMessage(cb) {
120
+ this.msgCb = cb;
121
+ }
122
+ onDown(_cb) {
123
+ // HTTP là stateless — không có channel chết để báo.
124
+ }
125
+ async start() {
126
+ this.closed = false;
127
+ }
128
+ async send(msg) {
129
+ if (this.closed)
130
+ throw new Error(`MCP HTTP transport "${this.cfg.url}" closed`);
131
+ const res = await this.post(msg, this.sessionId);
132
+ // Session expired (server restart) → thử lại một lần không session.
133
+ if ((res.status === 404 || res.status === 400) && this.sessionId) {
134
+ this.sessionId = null;
135
+ const retry = await this.post(msg, null);
136
+ return this.consume(retry);
137
+ }
138
+ return this.consume(res);
139
+ }
140
+ close() {
141
+ this.closed = true;
142
+ this.sessionId = null;
143
+ }
144
+ async post(msg, sessionId) {
145
+ const url = this.cfg.url;
146
+ if (!url)
147
+ 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
+ const headers = {
150
+ "content-type": "application/json",
151
+ accept: "application/json, text/event-stream",
152
+ ...expandEnv(this.cfg.headers),
153
+ };
154
+ if (sessionId)
155
+ headers["mcp-session-id"] = sessionId;
156
+ try {
157
+ return await fetch(url, {
158
+ method: "POST",
159
+ headers,
160
+ body: JSON.stringify(msg),
161
+ signal: AbortSignal.timeout(120_000),
162
+ });
163
+ }
164
+ catch (e) {
165
+ throw new Error(`MCP HTTP "${url}": ${e.message}`, { cause: e });
166
+ }
167
+ }
168
+ async consume(res) {
169
+ const sid = res.headers.get("mcp-session-id");
170
+ if (sid)
171
+ this.sessionId = sid;
172
+ if (!res.ok) {
173
+ res.body?.cancel().catch(() => { });
174
+ throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
175
+ }
176
+ const ct = res.headers.get("content-type") ?? "";
177
+ if (ct.includes("text/event-stream")) {
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")) {
183
+ const body = (await res.text()).trim();
184
+ if (body) {
185
+ try {
186
+ this.msgCb?.(JSON.parse(body));
187
+ }
188
+ catch { /* skip malformed */ }
189
+ }
190
+ } // 202/no-content (notifications) — nothing to parse
191
+ }
192
+ }
193
+ /** Parse SSE text thành JSON-RPC messages (concat các dòng `data:` liên tiếp). */
194
+ export function parseSseMessages(text) {
195
+ const out = [];
196
+ for (const block of text.split(/\r?\n\r?\n/)) {
197
+ const data = block
198
+ .split(/\r?\n/)
199
+ .filter((l) => l.startsWith("data:"))
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 */ }
208
+ }
209
+ return out;
210
+ }
211
+ const RECONNECT_MAX = 5;
212
+ const RECONNECT_BASE_MS = 500; // backoff: 0.5s, 1s, 2s, 4s, 8s
213
+ export class McpConnection {
214
+ name;
215
+ cfg;
216
+ transport;
217
+ nextId = 1;
218
+ pending = new Map();
219
+ /** M1: current state machine position. */
220
+ state = "connecting";
221
+ /** M1: status/state sink (AgentEvent mcp_status). */
222
+ onState = null;
223
+ manuallyClosed = false;
224
+ reconnectAttempt = 0;
225
+ constructor(name, cfg) {
226
+ this.name = name;
227
+ this.cfg = cfg;
228
+ // M3: url → HTTP transport; ngược lại stdio (default).
229
+ const kind = cfg.type ?? (cfg.url ? "http" : "stdio");
230
+ if (kind === "http" && !cfg.url)
231
+ throw new Error(`MCP server "${name}": type "http" nhưng thiếu url`);
232
+ if (kind === "stdio" && !cfg.command)
233
+ throw new Error(`MCP server "${name}": thiếu command`);
234
+ this.transport = kind === "http" ? new HttpTransport(cfg) : new StdioTransport(cfg);
235
+ }
236
+ /** stdio process (null với HTTP) — dùng trong tests. */
237
+ get proc() {
238
+ return this.transport.kind === "stdio" ? this.transport.proc : null;
239
+ }
240
+ get stderrTail() {
241
+ return this.transport.kind === "stdio" ? this.transport.stderrTail : "";
242
+ }
243
+ get alive() {
244
+ return this.transport.alive;
245
+ }
246
+ setState(s, detail) {
247
+ this.state = s;
248
+ this.onState?.(s, detail);
249
+ }
250
+ async connect(timeoutMs = 15_000) {
251
+ this.manuallyClosed = false;
252
+ await this.startAndHandshake(timeoutMs);
253
+ }
254
+ async startAndHandshake(timeoutMs) {
255
+ await this.transport.start();
256
+ this.wireTransport();
47
257
  // MCP handshake: initialize + notifications/initialized
48
258
  await this.rpc("initialize", {
49
259
  protocolVersion: "2024-11-05",
50
260
  capabilities: {},
51
261
  clientInfo: { name: CLIENT_NAME, version: getVersion() },
52
262
  }, timeoutMs);
53
- this.send({ jsonrpc: "2.0", method: "notifications/initialized" });
263
+ void this.transport.send({ jsonrpc: "2.0", method: "notifications/initialized" }).catch(() => {
264
+ // notification — không có pending để reject; lỗi sẽ lộ ở call sau.
265
+ });
266
+ this.setState("ready", "connected");
267
+ }
268
+ /** Wire inbound messages + channel-death → reconnect (stdio only). */
269
+ wireTransport() {
270
+ this.transport.onMessage((msg) => {
271
+ if (msg.id === undefined)
272
+ return; // notification — nothing pending
273
+ const pending = this.pending.get(msg.id);
274
+ if (!pending)
275
+ return;
276
+ this.pending.delete(msg.id);
277
+ const err = msg.error;
278
+ if (err)
279
+ pending.reject(new Error(`${err.code}: ${err.message}`));
280
+ else
281
+ pending.resolve((msg.result ?? {}));
282
+ });
283
+ if (this.transport.kind === "stdio") {
284
+ this.transport.onDown((err) => {
285
+ // M1: exit → drain pending (reject rõ ràng) → reconnect, trừ khi
286
+ // kill() chủ đích.
287
+ this.drain(new Error(`MCP server "${this.name}" ${err.message}`));
288
+ if (this.manuallyClosed)
289
+ return;
290
+ void this.scheduleReconnect();
291
+ });
292
+ }
293
+ }
294
+ /** M1: exponential backoff reconnect — tools/list refresh do Manager lo. */
295
+ async scheduleReconnect() {
296
+ if (this.reconnectAttempt >= RECONNECT_MAX) {
297
+ this.setState("dead", `reconnect failed after ${RECONNECT_MAX} attempts`);
298
+ return;
299
+ }
300
+ this.reconnectAttempt++;
301
+ const delay = RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1);
302
+ this.setState("reconnecting", `attempt ${this.reconnectAttempt}/${RECONNECT_MAX} in ${delay}ms`);
303
+ await new Promise((r) => setTimeout(r, delay));
304
+ if (this.manuallyClosed)
305
+ return;
306
+ try {
307
+ await this.startAndHandshake(15_000);
308
+ this.reconnectAttempt = 0;
309
+ this.setState("ready", "reconnected");
310
+ }
311
+ catch (e) {
312
+ this.drain(e);
313
+ await this.scheduleReconnect();
314
+ }
315
+ }
316
+ /** M1: health check — ping không có response trong MCP spec stdio → dùng
317
+ * tools/list với timeout ngắn làm liveness probe. */
318
+ async healthy(timeoutMs = 5_000) {
319
+ if (!this.alive)
320
+ return false;
321
+ try {
322
+ await this.rpc("tools/list", {}, timeoutMs);
323
+ return true;
324
+ }
325
+ catch {
326
+ return false;
327
+ }
54
328
  }
55
329
  async listTools(timeoutMs = 120_000) {
56
330
  const res = await this.rpc("tools/list", {}, timeoutMs);
@@ -60,27 +334,17 @@ export class McpConnection {
60
334
  const res = await this.rpc("tools/call", { name, arguments: args }, timeoutMs);
61
335
  const isErr = res.isError === true;
62
336
  const parts = (res.content ?? []).map((c) => c.text ?? "").filter(Boolean);
63
- return { ok: !isErr, output: parts.join("\n") || "(empty result)" };
337
+ // H-13: MCP servers are third-party — redact secret shapes from output
338
+ // before it enters the conversation or logs.
339
+ return { ok: !isErr, output: redactSecrets(parts.join("\n")) || "(empty result)", structured: res.structuredContent };
64
340
  }
65
341
  kill() {
342
+ this.manuallyClosed = true;
343
+ this.setState("dead", "closed by client");
66
344
  this.drain(new Error("disconnected"));
67
- if (this.proc) {
68
- try {
69
- this.proc.kill();
70
- }
71
- catch { /* already dead */ }
72
- this.proc = null;
73
- }
345
+ this.transport.close();
74
346
  }
75
347
  // ── internals ─────────────────────────────────────────────────────────────
76
- send(msg) {
77
- if (!this.proc?.stdin?.writable)
78
- return;
79
- try {
80
- this.proc.stdin.write(JSON.stringify(msg) + "\n");
81
- }
82
- catch { /* ignore */ }
83
- }
84
348
  async rpc(method, params, timeoutMs) {
85
349
  if (!this.alive)
86
350
  throw new Error(`MCP server "${this.name}" not connected`);
@@ -94,36 +358,15 @@ export class McpConnection {
94
358
  resolve: (v) => { clearTimeout(timer); resolve(v); },
95
359
  reject: (e) => { clearTimeout(timer); reject(e); },
96
360
  });
97
- this.send({ jsonrpc: "2.0", id, method, params });
361
+ void Promise.resolve(this.transport.send({ jsonrpc: "2.0", id, method, params })).catch((e) => {
362
+ const p = this.pending.get(id);
363
+ if (p) {
364
+ this.pending.delete(id);
365
+ p.reject(e);
366
+ }
367
+ });
98
368
  });
99
369
  }
100
- onChunk(chunk) {
101
- this.buf += chunk.toString("utf8");
102
- let i;
103
- while ((i = this.buf.indexOf("\n")) !== -1) {
104
- const line = this.buf.slice(0, i).trim();
105
- this.buf = this.buf.slice(i + 1);
106
- if (!line)
107
- continue;
108
- let msg;
109
- try {
110
- msg = JSON.parse(line);
111
- }
112
- catch {
113
- continue;
114
- }
115
- if (msg.id === undefined)
116
- continue;
117
- const pending = this.pending.get(msg.id);
118
- if (!pending)
119
- continue;
120
- this.pending.delete(msg.id);
121
- if (msg.error)
122
- pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
123
- else
124
- pending.resolve(msg.result ?? {});
125
- }
126
- }
127
370
  drain(err) {
128
371
  for (const [, p] of this.pending)
129
372
  p.reject(err);
@@ -134,6 +377,10 @@ export class McpManager {
134
377
  configs;
135
378
  conns = new Map();
136
379
  tools = new Map();
380
+ /** Status sink (default console.error; the TUI routes it to a notice). */
381
+ onStatus = null;
382
+ /** Sidebar TUI: máy trạng thái per-server (connecting/ready/reconnecting/dead). */
383
+ onServerState = null;
137
384
  constructor(configs) {
138
385
  this.configs = configs;
139
386
  }
@@ -141,32 +388,75 @@ export class McpManager {
141
388
  async connectAll() {
142
389
  await Promise.allSettled(Object.entries(this.configs).map(async ([name, cfg]) => {
143
390
  const conn = new McpConnection(name, cfg);
391
+ this.wireReconnect(name, conn);
144
392
  await conn.connect();
145
- const defs = await conn.listTools();
146
- for (const def of defs) {
147
- const key = mcpToolKey(name, def.name);
148
- if (this.tools.has(key))
149
- throw new Error(`Duplicate MCP tool: ${key}`);
150
- this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
151
- }
393
+ await this.registerTools(name, conn);
152
394
  this.conns.set(name, conn);
153
395
  })).then((results) => {
396
+ const report = this.onStatus ?? ((line) => console.error(line));
154
397
  results.forEach((r, i) => {
155
398
  const name = Object.keys(this.configs)[i];
156
399
  if (r.status === "rejected") {
157
- console.error(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
400
+ this.onServerState?.(name, "dead", "connect failed");
401
+ report(`⚠ MCP server "${name}" unavailable: ${String(r.reason?.message).slice(0, 160)}`);
158
402
  }
159
403
  else {
160
404
  const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
161
- console.error(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
405
+ report(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"}`);
162
406
  }
163
407
  });
164
408
  });
165
409
  return this.tools.size;
166
410
  }
411
+ /** M1: surface connection state machine + refresh tools sau reconnect. */
412
+ wireReconnect(name, conn) {
413
+ conn.onState = (state, detail) => {
414
+ // Sidebar TUI: structured state trước — mọi transition đều thú vị với
415
+ // map mcpServers, còn chat notice chỉ với reconnect/dead/ready (dưới).
416
+ this.onServerState?.(name, state, detail);
417
+ const report = this.onStatus ?? ((line) => console.error(line));
418
+ if (state === "reconnecting")
419
+ report(`⚠ MCP "${name}" ${detail}`);
420
+ if (state === "dead")
421
+ report(`✘ MCP "${name}" dead: ${detail}`);
422
+ if (state === "ready" && detail === "reconnected") {
423
+ report(`✓ MCP "${name}" reconnected`);
424
+ // Refresh tool list — server có thể đăng ký bộ tool mới.
425
+ conn
426
+ .listTools()
427
+ .then((defs) => this.reregisterTools(name, conn, defs))
428
+ .catch(() => { });
429
+ }
430
+ };
431
+ }
432
+ async registerTools(name, conn) {
433
+ const defs = await conn.listTools();
434
+ this.reregisterTools(name, conn, defs);
435
+ }
436
+ /** Replace một server's tool registrations (reconnect refresh). */
437
+ reregisterTools(name, conn, defs) {
438
+ for (const [key, t] of [...this.tools]) {
439
+ if (t.serverName === name)
440
+ this.tools.delete(key);
441
+ }
442
+ for (const def of defs) {
443
+ const key = mcpToolKey(name, def.name);
444
+ this.tools.set(key, { conn, serverName: name, toolName: def.name, def });
445
+ }
446
+ }
167
447
  hasTool(name) {
168
448
  return this.tools.has(name);
169
449
  }
450
+ /** Per-server tool listing (for /mcp status display). */
451
+ listToolsByServer() {
452
+ const map = new Map();
453
+ for (const t of this.tools.values()) {
454
+ const arr = map.get(t.serverName) ?? [];
455
+ arr.push({ name: t.toolName, description: t.def.description, inputSchema: t.def.inputSchema });
456
+ map.set(t.serverName, arr);
457
+ }
458
+ return [...map.entries()].map(([server, tools]) => ({ server, tools }));
459
+ }
170
460
  getTool(name) {
171
461
  return this.tools.get(name);
172
462
  }
@@ -185,5 +475,42 @@ export class McpManager {
185
475
  for (const c of this.conns.values())
186
476
  c.kill();
187
477
  this.conns.clear();
478
+ this.tools.clear();
479
+ }
480
+ /** M4: config hot-reload — connect server mới, kill server đã bị xóa khỏi config. */
481
+ async applyConfig(configs) {
482
+ this.configs = configs;
483
+ // Server bị xóa → kill + unregister.
484
+ for (const [name, conn] of [...this.conns]) {
485
+ if (!configs[name]) {
486
+ conn.kill();
487
+ this.conns.delete(name);
488
+ for (const [key, t] of [...this.tools]) {
489
+ if (t.serverName === name)
490
+ this.tools.delete(key);
491
+ }
492
+ const report = this.onStatus ?? ((line) => console.error(line));
493
+ report(`✘ MCP "${name}" removed (config changed)`);
494
+ }
495
+ }
496
+ // Server mới → connect.
497
+ for (const [name, cfg] of Object.entries(configs)) {
498
+ if (this.conns.has(name))
499
+ continue;
500
+ const conn = new McpConnection(name, cfg);
501
+ this.wireReconnect(name, conn);
502
+ try {
503
+ await conn.connect();
504
+ await this.registerTools(name, conn);
505
+ this.conns.set(name, conn);
506
+ const report = this.onStatus ?? ((line) => console.error(line));
507
+ const n = [...this.tools.values()].filter((t) => t.serverName === name).length;
508
+ report(`✓ MCP "${name}" connected — ${n} tool${n === 1 ? "" : "s"} (hot-reload)`);
509
+ }
510
+ catch (e) {
511
+ const report = this.onStatus ?? ((line) => console.error(line));
512
+ report(`⚠ MCP server "${name}" unavailable: ${String(e.message).slice(0, 160)}`);
513
+ }
514
+ }
188
515
  }
189
516
  }