s-app-scoder-agent 0.8.21 → 0.9.3

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/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
- if (this.proc) {
78
- try {
79
- this.proc.kill();
80
- }
81
- catch { /* already dead */ }
82
- this.proc = null;
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
- async post(msg, sessionId) {
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: AbortSignal.timeout(120_000),
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("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")) {
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
- } // 202/no-content (notifications) — nothing to parse
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 (concat các dòng `data:` liên tiếp). */
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 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 */ }
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
- await this.rpc("initialize", {
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
- return; // notification — nothing pending
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
- /** 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. */
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
- await this.rpc("tools/list", {}, timeoutMs);
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
- return { ok: !isErr, output: redactSecrets(parts.join("\n")) || "(empty result)", structured: res.structuredContent };
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 list — server có thể đăng ký bộ tool mới.
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
- /** Replace một server's tool registrations (reconnect refresh). */
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.configs = configs;
483
- // Server bị xóa → kill + unregister.
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
- const report = this.onStatus ?? ((line) => console.error(line));
493
- report(`✘ MCP "${name}" removed (config changed)`);
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
- // Server mới → connect.
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
  }