min-agent 0.3.0 → 0.4.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 (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/markdown.js CHANGED
@@ -52,7 +52,15 @@ export class MarkdownRenderer {
52
52
  break;
53
53
  const line = this.buffer.slice(0, nlIdx);
54
54
  this.buffer = this.buffer.slice(nlIdx + 1);
55
- // Table handling: collect rows, render when table ends
55
+ if (this.inCodeBlock || line.startsWith("```")) {
56
+ if (this.inTable) {
57
+ output += this.renderTable(c);
58
+ this.tableRows = [];
59
+ this.inTable = false;
60
+ }
61
+ output += this.formatLine(line, c) + "\n";
62
+ continue;
63
+ }
56
64
  if (this.isTableRow(line)) {
57
65
  if (this.isTableSeparator(line)) {
58
66
  this.inTable = true;
@@ -127,29 +135,47 @@ export class MarkdownRenderer {
127
135
  return /^\s*\|[\s:]*-+[\s:|-]*\|\s*$/.test(line);
128
136
  }
129
137
  parseTableRow(line) {
130
- return line.trim().slice(1, -1).split("|").map((cell) => cell.trim());
138
+ return line
139
+ .trim()
140
+ .slice(1, -1)
141
+ .split("|")
142
+ .map((cell) => cell.trim());
131
143
  }
132
144
  /** Pad string to target display width */
145
+ clipToWidth(str, targetWidth) {
146
+ let width = 0;
147
+ let out = "";
148
+ for (const ch of str) {
149
+ const w = displayWidth(ch);
150
+ if (width + w > targetWidth)
151
+ break;
152
+ out += ch;
153
+ width += w;
154
+ }
155
+ return out;
156
+ }
133
157
  padToWidth(str, targetWidth) {
134
- const currentWidth = displayWidth(str);
135
- const padding = targetWidth - currentWidth;
136
- return padding > 0 ? str + " ".repeat(padding) : str;
158
+ const clipped = this.clipToWidth(str, targetWidth);
159
+ const padding = targetWidth - displayWidth(clipped);
160
+ return padding > 0 ? clipped + " ".repeat(padding) : clipped;
137
161
  }
138
162
  renderTable(c) {
139
163
  if (this.tableRows.length === 0)
140
164
  return "";
141
- // Calculate column widths based on display width (CJK-aware)
142
- const colCount = Math.max(...this.tableRows.map((r) => r.length));
165
+ const colCount = Math.max(0, ...this.tableRows.map((r) => r.length));
166
+ if (colCount === 0)
167
+ return "";
143
168
  const widths = Array(colCount).fill(0);
169
+ const maxCol = 64;
144
170
  for (const row of this.tableRows) {
145
171
  for (let i = 0; i < row.length; i++) {
146
- widths[i] = Math.max(widths[i], displayWidth(row[i] ?? ""));
172
+ widths[i] = Math.min(maxCol, Math.max(widths[i], displayWidth(row[i] ?? "")));
147
173
  }
148
174
  }
149
175
  const lines = [];
150
- const top = `${c.dim}┌${widths.map((w) => "─".repeat(w + 2)).join("┬")}┐${c.reset}`;
151
- const mid = `${c.dim}├${widths.map((w) => "─".repeat(w + 2)).join("┼")}┤${c.reset}`;
152
- const bot = `${c.dim}└${widths.map((w) => "─".repeat(w + 2)).join("┴")}┘${c.reset}`;
176
+ const top = `${c.dim}┌${widths.map((w) => "─".repeat(Math.max(0, w + 2))).join("┬")}┐${c.reset}`;
177
+ const mid = `${c.dim}├${widths.map((w) => "─".repeat(Math.max(0, w + 2))).join("┼")}┤${c.reset}`;
178
+ const bot = `${c.dim}└${widths.map((w) => "─".repeat(Math.max(0, w + 2))).join("┴")}┘${c.reset}`;
153
179
  lines.push(top);
154
180
  for (let r = 0; r < this.tableRows.length; r++) {
155
181
  const row = this.tableRows[r];
package/dist/mcp.js CHANGED
@@ -32,6 +32,10 @@ function truncateStructured(value) {
32
32
  }
33
33
  return value;
34
34
  }
35
+ const MAX_LISTED_RESOURCES = 200;
36
+ const MAX_LISTED_TEMPLATES = 100;
37
+ const MAX_LISTED_PROMPTS = 100;
38
+ const MAX_RESOURCE_BLOB_CHARS = 2048;
35
39
  const DEFAULT_TIMEOUT = 30000;
36
40
  const STDERD_KEEP_LINES = 20;
37
41
  const OAUTH_BROWSER_TIMEOUT_MS = 5 * 60 * 1000;
@@ -48,6 +52,7 @@ let connectedServers = {};
48
52
  const mcpServerErrors = {};
49
53
  /** Cache of built tool wrappers; invalidated when MCP servers (re)connect. */
50
54
  let mcpToolsCache = null;
55
+ let mcpReadOnlyToolIds = new Set();
51
56
  const configCaches = new Map();
52
57
  function loadJsonConfig(filePath) {
53
58
  const cache = configCaches.get(filePath);
@@ -151,13 +156,100 @@ async function listAllTools(client) {
151
156
  } while (cursor);
152
157
  return all;
153
158
  }
154
- async function openStdioMcpServer(name, config, onTransport) {
159
+ async function pageAll(fetchPage, cap) {
160
+ const all = [];
161
+ let cursor;
162
+ do {
163
+ const page = await fetchPage(cursor);
164
+ all.push(...page.items);
165
+ cursor = page.nextCursor;
166
+ if (all.length >= cap)
167
+ return all.slice(0, cap);
168
+ } while (cursor);
169
+ return all;
170
+ }
171
+ export async function discoverMcpExtras(client) {
172
+ const caps = client.getServerCapabilities();
173
+ let resources = [];
174
+ let resourceTemplates = [];
175
+ let prompts = [];
176
+ if (caps?.resources) {
177
+ try {
178
+ resources = await pageAll(async (cursor) => {
179
+ const result = await client.listResources(cursor ? { cursor } : undefined);
180
+ return {
181
+ items: result.resources.map((r) => ({
182
+ uri: r.uri,
183
+ name: r.name,
184
+ ...(r.description ? { description: r.description } : {}),
185
+ ...(r.mimeType ? { mimeType: r.mimeType } : {}),
186
+ })),
187
+ nextCursor: result.nextCursor,
188
+ };
189
+ }, MAX_LISTED_RESOURCES);
190
+ }
191
+ catch {
192
+ resources = [];
193
+ }
194
+ try {
195
+ resourceTemplates = await pageAll(async (cursor) => {
196
+ const result = await client.listResourceTemplates(cursor ? { cursor } : undefined);
197
+ return {
198
+ items: result.resourceTemplates.map((t) => ({
199
+ uriTemplate: t.uriTemplate,
200
+ name: t.name,
201
+ ...(t.description ? { description: t.description } : {}),
202
+ ...(t.mimeType ? { mimeType: t.mimeType } : {}),
203
+ })),
204
+ nextCursor: result.nextCursor,
205
+ };
206
+ }, MAX_LISTED_TEMPLATES);
207
+ }
208
+ catch {
209
+ resourceTemplates = [];
210
+ }
211
+ }
212
+ if (caps?.prompts) {
213
+ try {
214
+ prompts = await pageAll(async (cursor) => {
215
+ const result = await client.listPrompts(cursor ? { cursor } : undefined);
216
+ return {
217
+ items: result.prompts.map((p) => ({
218
+ name: p.name,
219
+ ...(p.description ? { description: p.description } : {}),
220
+ ...(p.arguments && p.arguments.length > 0 ? { arguments: p.arguments } : {}),
221
+ })),
222
+ nextCursor: result.nextCursor,
223
+ };
224
+ }, MAX_LISTED_PROMPTS);
225
+ }
226
+ catch {
227
+ prompts = [];
228
+ }
229
+ }
230
+ return { resources, resourceTemplates, prompts };
231
+ }
232
+ async function connectedFromClient(client, transport, config, extra = {}) {
233
+ const tools = await listAllTools(client);
234
+ const extras = await discoverMcpExtras(client);
235
+ return {
236
+ client,
237
+ transport,
238
+ tools,
239
+ ...extras,
240
+ callTimeout: resolveMcpTimeouts(config).callTimeout,
241
+ signature: configSignature(config),
242
+ ...extra,
243
+ };
244
+ }
245
+ async function openStdioMcpServer(name, config, hooks) {
155
246
  // Support both formats:
156
247
  // { command: ["uvx", "mcp-server-time"] } — min-agent native
157
248
  // { command: "uvx", args: ["mcp-server-time"] } — opencode/claude style
158
249
  let cmd;
159
250
  let args;
160
251
  if (Array.isArray(config.command)) {
252
+ ;
161
253
  [cmd, ...args] = config.command;
162
254
  }
163
255
  else if (typeof config.command === "string") {
@@ -179,16 +271,17 @@ async function openStdioMcpServer(name, config, onTransport) {
179
271
  });
180
272
  // Drain the child's stderr so a chatty server can never block on a full pipe buffer.
181
273
  transport.stderr?.pipe(stderrSink.stream);
182
- onTransport?.(transport);
274
+ hooks?.onTransport?.(transport);
183
275
  const client = new Client({ name: "min-agent", version: "0.1.0" });
184
276
  try {
185
277
  await client.connect(transport);
186
- const tools = await listAllTools(client);
187
- return { client, transport, tools, callTimeout: resolveMcpTimeouts(config).callTimeout, signature: configSignature(config), stderrSink };
278
+ return await connectedFromClient(client, transport, config, { stderrSink });
188
279
  }
189
280
  catch (err) {
190
281
  await transport.close().catch(() => { });
191
- const tail = stderrSink.lines.length ? `\nServer stderr (last ${stderrSink.lines.length} lines):\n${stderrSink.lines.map((l) => ` ${l}`).join("\n")}` : "";
282
+ const tail = stderrSink.lines.length
283
+ ? `\nServer stderr (last ${stderrSink.lines.length} lines):\n${stderrSink.lines.map((l) => ` ${l}`).join("\n")}`
284
+ : "";
192
285
  throw new Error(`${err?.message ?? String(err)}${tail}`);
193
286
  }
194
287
  }
@@ -407,7 +500,7 @@ async function runOAuthFlow(name, config, baseUrl) {
407
500
  throw new Error(`MCP "${name}" OAuth failed: ${err?.message ?? String(err)}`);
408
501
  }
409
502
  }
410
- async function openRemoteMcpServer(name, config, onTransport) {
503
+ async function openRemoteMcpServer(name, config, hooks) {
411
504
  const rawUrl = config.url?.trim();
412
505
  if (!rawUrl) {
413
506
  throw new Error(`MCP "${name}" has empty url`);
@@ -425,15 +518,17 @@ async function openRemoteMcpServer(name, config, onTransport) {
425
518
  const requestInit = buildRemoteRequestInit(config);
426
519
  const mode = config.remoteTransport ?? "auto";
427
520
  const oauthProvider = await runOAuthFlow(name, config, baseUrl);
428
- const callTimeout = resolveMcpTimeouts(config).callTimeout;
521
+ // Expose the provider so an outer timeout can dispose it; on success the
522
+ // ConnectedServer takes ownership (closeConnectedServer closes it).
523
+ if (oauthProvider)
524
+ hooks?.onOAuthProvider?.(oauthProvider);
429
525
  const connectStreamable = async () => {
430
526
  const client = new Client({ name: "min-agent", version: "0.1.0" });
431
527
  const transport = new StreamableHTTPClientTransport(baseUrl, requestInit || oauthProvider ? { requestInit, authProvider: oauthProvider } : undefined);
432
- onTransport?.(transport);
528
+ hooks?.onTransport?.(transport);
433
529
  try {
434
530
  await client.connect(transport);
435
- const tools = await listAllTools(client);
436
- return { client, transport, tools, callTimeout, signature: configSignature(config), oauthProvider };
531
+ return await connectedFromClient(client, transport, config, { oauthProvider });
437
532
  }
438
533
  catch (err) {
439
534
  await transport.close().catch(() => { });
@@ -443,11 +538,10 @@ async function openRemoteMcpServer(name, config, onTransport) {
443
538
  const connectSse = async () => {
444
539
  const client = new Client({ name: "min-agent", version: "0.1.0" });
445
540
  const transport = new SSEClientTransport(baseUrl, requestInit || oauthProvider ? { requestInit, authProvider: oauthProvider } : undefined);
446
- onTransport?.(transport);
541
+ hooks?.onTransport?.(transport);
447
542
  try {
448
543
  await client.connect(transport);
449
- const tools = await listAllTools(client);
450
- return { client, transport, tools, callTimeout, signature: configSignature(config), oauthProvider };
544
+ return await connectedFromClient(client, transport, config, { oauthProvider });
451
545
  }
452
546
  catch (err) {
453
547
  await transport.close().catch(() => { });
@@ -473,6 +567,9 @@ async function openRemoteMcpServer(name, config, onTransport) {
473
567
  }
474
568
  }
475
569
  catch (err) {
570
+ // The connection failed, so nobody else owns the provider — close its
571
+ // callback server instead of leaking the port.
572
+ oauthProvider?.close();
476
573
  const message = err instanceof Error ? err.message : String(err);
477
574
  // Handle OAuth/Unauthorized errors
478
575
  if (err instanceof UnauthorizedError || message.includes("Unauthorized") || message.includes("401")) {
@@ -485,11 +582,11 @@ async function openRemoteMcpServer(name, config, onTransport) {
485
582
  throw new Error(`MCP "${name}" remote connection failed: ${message}`);
486
583
  }
487
584
  }
488
- async function openMcpServer(name, config, onTransport) {
585
+ async function openMcpServer(name, config, hooks) {
489
586
  if (isRemoteMcpConfig(config)) {
490
- return await openRemoteMcpServer(name, config, onTransport);
587
+ return await openRemoteMcpServer(name, config, hooks);
491
588
  }
492
- return await openStdioMcpServer(name, config, onTransport);
589
+ return await openStdioMcpServer(name, config, hooks);
493
590
  }
494
591
  /** One-line summary for CLI / logs (no secrets). */
495
592
  export function formatMcpServerBinding(config) {
@@ -497,18 +594,26 @@ export function formatMcpServerBinding(config) {
497
594
  const mode = config.remoteTransport ?? "auto";
498
595
  return `${config.url} [remote:${mode}]`;
499
596
  }
500
- const cmd = Array.isArray(config.command) ? config.command.join(" ") : `${config.command ?? ""} ${(config.args ?? []).join(" ")}`.trim();
597
+ const cmd = Array.isArray(config.command)
598
+ ? config.command.join(" ")
599
+ : `${config.command ?? ""} ${(config.args ?? []).join(" ")}`.trim();
501
600
  return cmd || "(no command)";
502
601
  }
503
602
  async function openMcpServerWithTimeout(name, config) {
504
603
  const timeout = resolveMcpTimeouts(config).connectTimeout;
505
604
  let timer;
506
605
  const transportHolder = { transport: null };
606
+ const oauthHolder = { provider: null };
507
607
  const timeoutPromise = new Promise((_, reject) => {
508
608
  timer = setTimeout(() => reject(new Error(`connection timed out after ${timeout}ms`)), timeout);
509
609
  });
510
- const connectPromise = openMcpServer(name, config, (t) => {
511
- transportHolder.transport = t;
610
+ const connectPromise = openMcpServer(name, config, {
611
+ onTransport: (t) => {
612
+ transportHolder.transport = t;
613
+ },
614
+ onOAuthProvider: (p) => {
615
+ oauthHolder.provider = p;
616
+ },
512
617
  });
513
618
  connectPromise.catch(() => { });
514
619
  try {
@@ -517,6 +622,12 @@ async function openMcpServerWithTimeout(name, config) {
517
622
  }
518
623
  catch (error) {
519
624
  await transportHolder.transport?.close().catch(() => { });
625
+ oauthHolder.provider?.close();
626
+ // The abandoned connect may still resolve later (e.g. a slow OAuth login);
627
+ // dispose the result immediately so neither the client nor the OAuth
628
+ // callback server leaks. The registry is untouched: this server was never
629
+ // registered, and a newer connection may own the name by now.
630
+ void connectPromise.then((server) => disposeServerResources(server)).catch(() => { });
520
631
  return { error };
521
632
  }
522
633
  finally {
@@ -530,8 +641,8 @@ export async function connectMcpServer(name, config) {
530
641
  const { server, error } = await openMcpServerWithTimeout(name, config);
531
642
  if (server) {
532
643
  delete mcpServerErrors[name];
533
- console.log(`\x1b[90m MCP "${name}" connected (${server.tools.length} tools)\x1b[0m`);
534
- log("info", `mcp "${name}" connected (${server.tools.length} tools)`);
644
+ console.log(`\x1b[90m MCP "${name}" connected (${server.tools.length} tools, ${server.resources.length} resources, ${server.prompts.length} prompts)\x1b[0m`);
645
+ log("info", `mcp "${name}" connected (${server.tools.length} tools, ${server.resources.length} resources, ${server.prompts.length} prompts)`);
535
646
  return server;
536
647
  }
537
648
  const message = error?.message ?? String(error);
@@ -559,19 +670,26 @@ export async function checkMcpServer(name, config) {
559
670
  await server.client.close();
560
671
  }
561
672
  catch { }
673
+ server.oauthProvider?.close();
562
674
  return { name, enabled: true, ok: true, toolCount };
563
675
  }
564
676
  export async function checkAllMcpServers() {
565
677
  const entries = loadMcpConfigEntries();
566
678
  const settled = await Promise.allSettled(entries.map(({ name, config }) => checkMcpServer(name, config)));
567
- return settled.map((r, i) => r.status === "fulfilled" ? r.value : { name: entries[i]?.name ?? "unknown", enabled: true, ok: false, toolCount: 0, error: String(r.reason) });
679
+ return settled.map((r, i) => r.status === "fulfilled"
680
+ ? r.value
681
+ : { name: entries[i]?.name ?? "unknown", enabled: true, ok: false, toolCount: 0, error: String(r.reason) });
568
682
  }
569
- async function closeConnectedServer(name, server) {
683
+ /** Close client + OAuth provider without touching the connection registry. */
684
+ async function disposeServerResources(server) {
570
685
  try {
571
686
  await server.client.close();
572
687
  }
573
688
  catch { }
574
689
  server.oauthProvider?.close();
690
+ }
691
+ async function closeConnectedServer(name, server) {
692
+ await disposeServerResources(server);
575
693
  delete connectedServers[name];
576
694
  delete mcpServerErrors[name];
577
695
  }
@@ -648,15 +766,20 @@ export async function initMcp() {
648
766
  export function getMcpTools() {
649
767
  if (mcpToolsCache)
650
768
  return mcpToolsCache;
651
- mcpToolsCache = buildMcpToolsMap(connectedServers);
769
+ mcpReadOnlyToolIds = new Set();
770
+ mcpToolsCache = buildMcpToolsMap(connectedServers, mcpReadOnlyToolIds);
652
771
  return mcpToolsCache;
653
772
  }
773
+ export function getMcpReadOnlyToolIds() {
774
+ getMcpTools();
775
+ return mcpReadOnlyToolIds;
776
+ }
654
777
  /**
655
778
  * Build AI SDK tool wrappers for a set of connected MCP servers.
656
779
  * Tool IDs are collision-safe (suffixed when sanitized names clash) and
657
780
  * descriptions are prefixed with the owning server name.
658
781
  */
659
- export function buildMcpToolsMap(servers) {
782
+ export function buildMcpToolsMap(servers, readOnlyIds) {
660
783
  const tools = {};
661
784
  const used = new Set();
662
785
  for (const [serverName, server] of Object.entries(servers)) {
@@ -673,15 +796,14 @@ export function buildMcpToolsMap(servers) {
673
796
  ...(baseSchema.properties ? {} : { properties: {} }),
674
797
  };
675
798
  const readOnly = mcpTool.annotations?.readOnlyHint === true;
676
- const description = `[${serverName}] ${mcpTool.description ?? `MCP tool: ${mcpTool.name}`}` +
677
- (readOnly ? " (read-only)" : "");
799
+ if (readOnly)
800
+ readOnlyIds?.add(toolId);
801
+ const description = `[${serverName}] ${mcpTool.description ?? `MCP tool: ${mcpTool.name}`}` + (readOnly ? " (read-only)" : "");
678
802
  const outputSchema = mcpTool.outputSchema;
679
803
  tools[toolId] = tool({
680
804
  description,
681
805
  inputSchema: jsonSchema(schema),
682
- ...(outputSchema && outputSchema.type === "object"
683
- ? { outputSchema: jsonSchema(outputSchema) }
684
- : {}),
806
+ ...(outputSchema && outputSchema.type === "object" ? { outputSchema: jsonSchema(outputSchema) } : {}),
685
807
  execute: async (args, options) => {
686
808
  try {
687
809
  const signals = [AbortSignal.timeout(server.callTimeout)];
@@ -722,11 +844,187 @@ export function getMcpStatus() {
722
844
  connected: !!server,
723
845
  enabled: config.enabled !== false,
724
846
  tools: server?.tools.map((t) => t.name) ?? [],
847
+ resource_count: server?.resources.length ?? 0,
848
+ prompt_count: server?.prompts.length ?? 0,
725
849
  ...(mcpServerErrors[name] ? { error: mcpServerErrors[name] } : {}),
726
850
  };
727
851
  }
728
852
  return status;
729
853
  }
854
+ export function listMcpCatalog(serverName) {
855
+ const names = serverName ? [serverName] : Object.keys(connectedServers);
856
+ return {
857
+ servers: names.flatMap((name) => {
858
+ const server = connectedServers[name];
859
+ if (!server)
860
+ return [];
861
+ return [{ name, resources: server.resources, templates: server.resourceTemplates, prompts: server.prompts }];
862
+ }),
863
+ };
864
+ }
865
+ function connectedNames() {
866
+ return Object.keys(connectedServers);
867
+ }
868
+ function resolveCatalogServer(serverName, kind) {
869
+ if (serverName) {
870
+ const server = connectedServers[serverName];
871
+ if (!server)
872
+ return `MCP server "${serverName}" is not connected. Connected: ${connectedNames().join(", ") || "none"}`;
873
+ return server;
874
+ }
875
+ const names = connectedNames();
876
+ if (names.length === 0)
877
+ return "No MCP servers connected.";
878
+ if (names.length === 1)
879
+ return connectedServers[names[0]];
880
+ return `Multiple MCP servers are connected (${names.join(", ")}). Pass server to choose which ${kind} to use.`;
881
+ }
882
+ export async function readMcpResource(uri, serverName) {
883
+ const resolved = resolveCatalogServer(serverName, "resource");
884
+ if (typeof resolved === "string")
885
+ return resolved;
886
+ try {
887
+ const result = await resolved.client.readResource({ uri }, { timeout: resolved.callTimeout });
888
+ return formatResourceContents(result.contents);
889
+ }
890
+ catch (err) {
891
+ return `Failed to read resource: ${err instanceof Error ? err.message : String(err)}`;
892
+ }
893
+ }
894
+ export async function getMcpPrompt(name, args, serverName) {
895
+ const resolved = resolveCatalogServer(serverName, "prompt");
896
+ if (typeof resolved === "string")
897
+ return resolved;
898
+ try {
899
+ const result = await resolved.client.getPrompt({ name, ...(args && Object.keys(args).length > 0 ? { arguments: args } : {}) }, { timeout: resolved.callTimeout });
900
+ return formatPromptResult(result);
901
+ }
902
+ catch (err) {
903
+ return `Failed to get prompt: ${err instanceof Error ? err.message : String(err)}`;
904
+ }
905
+ }
906
+ export function formatResourceContents(contents) {
907
+ if (contents.length === 0)
908
+ return "(empty resource)";
909
+ return contents
910
+ .map((part) => {
911
+ if (typeof part.text === "string")
912
+ return part.text;
913
+ if (typeof part.blob === "string") {
914
+ if (part.blob.length <= MAX_RESOURCE_BLOB_CHARS)
915
+ return part.blob;
916
+ return `[binary ${part.mimeType ?? "application/octet-stream"} ${part.blob.length} chars, omitted]`;
917
+ }
918
+ return JSON.stringify(part);
919
+ })
920
+ .join("\n\n");
921
+ }
922
+ export function formatPromptResult(result) {
923
+ const header = result.description ? `${result.description}\n\n` : "";
924
+ const body = result.messages
925
+ .map((m) => {
926
+ const content = m.content;
927
+ if (content &&
928
+ typeof content === "object" &&
929
+ "type" in content &&
930
+ content.type === "text" &&
931
+ "text" in content) {
932
+ return `${m.role}: ${String(content.text)}`;
933
+ }
934
+ return `${m.role}: ${JSON.stringify(content)}`;
935
+ })
936
+ .join("\n");
937
+ return header + body;
938
+ }
939
+ export function getMcpCatalogTools() {
940
+ if (Object.keys(connectedServers).length === 0)
941
+ return {};
942
+ const listResources = tool({
943
+ description: "List MCP resources and resource URI templates from connected servers. Use this before mcp_read_resource. Optional server name limits the listing to one server.",
944
+ inputSchema: jsonSchema({
945
+ type: "object",
946
+ properties: {
947
+ server: { type: "string", description: "MCP server name. Omit to list every connected server." },
948
+ },
949
+ }),
950
+ execute: async ({ server }) => {
951
+ const catalog = listMcpCatalog(server);
952
+ if (catalog.servers.length === 0) {
953
+ return server ? `MCP server "${server}" is not connected.` : "No MCP servers connected.";
954
+ }
955
+ const lines = catalog.servers.flatMap((s) => {
956
+ const res = s.resources.map((r) => ` resource ${r.uri} ${r.name}${r.description ? ` — ${r.description}` : ""}`);
957
+ const templates = s.templates.map((t) => ` template ${t.uriTemplate} ${t.name}${t.description ? ` — ${t.description}` : ""}`);
958
+ if (res.length === 0 && templates.length === 0)
959
+ return [`[${s.name}] (no resources)`];
960
+ return [`[${s.name}]`, ...res, ...templates];
961
+ });
962
+ return truncateToolOutput(lines.join("\n"), { direction: "head" }).content;
963
+ },
964
+ });
965
+ const readResource = tool({
966
+ description: "Read an MCP resource by URI. Use mcp_list_resources first. Pass server when more than one MCP server is connected.",
967
+ inputSchema: jsonSchema({
968
+ type: "object",
969
+ properties: {
970
+ uri: { type: "string", description: "Resource URI from mcp_list_resources" },
971
+ server: { type: "string", description: "MCP server name when multiple servers are connected" },
972
+ },
973
+ required: ["uri"],
974
+ }),
975
+ execute: async ({ uri, server }) => truncateToolOutput(await readMcpResource(uri, server), { direction: "head" }).content,
976
+ });
977
+ const listPrompts = tool({
978
+ description: "List MCP prompt templates from connected servers. Use this before mcp_get_prompt. Optional server name limits the listing to one server.",
979
+ inputSchema: jsonSchema({
980
+ type: "object",
981
+ properties: {
982
+ server: { type: "string", description: "MCP server name. Omit to list every connected server." },
983
+ },
984
+ }),
985
+ execute: async ({ server }) => {
986
+ const catalog = listMcpCatalog(server);
987
+ if (catalog.servers.length === 0) {
988
+ return server ? `MCP server "${server}" is not connected.` : "No MCP servers connected.";
989
+ }
990
+ const lines = catalog.servers.flatMap((s) => {
991
+ if (s.prompts.length === 0)
992
+ return [`[${s.name}] (no prompts)`];
993
+ return [
994
+ `[${s.name}]`,
995
+ ...s.prompts.map((p) => {
996
+ const args = p.arguments?.map((a) => `${a.name}${a.required ? "*" : ""}`).join(", ");
997
+ return ` ${p.name}${args ? `(${args})` : ""}${p.description ? ` — ${p.description}` : ""}`;
998
+ }),
999
+ ];
1000
+ });
1001
+ return truncateToolOutput(lines.join("\n"), { direction: "head" }).content;
1002
+ },
1003
+ });
1004
+ const getPrompt = tool({
1005
+ description: "Fill an MCP prompt template. Use mcp_list_prompts first. Pass server when more than one MCP server is connected.",
1006
+ inputSchema: jsonSchema({
1007
+ type: "object",
1008
+ properties: {
1009
+ name: { type: "string", description: "Prompt name from mcp_list_prompts" },
1010
+ server: { type: "string", description: "MCP server name when multiple servers are connected" },
1011
+ arguments: {
1012
+ type: "object",
1013
+ additionalProperties: { type: "string" },
1014
+ description: "Prompt arguments as string values",
1015
+ },
1016
+ },
1017
+ required: ["name"],
1018
+ }),
1019
+ execute: async ({ name, server, arguments: promptArgs }) => truncateToolOutput(await getMcpPrompt(name, promptArgs, server), { direction: "head" }).content,
1020
+ });
1021
+ return {
1022
+ mcp_list_resources: listResources,
1023
+ mcp_read_resource: readResource,
1024
+ mcp_list_prompts: listPrompts,
1025
+ mcp_get_prompt: getPrompt,
1026
+ };
1027
+ }
730
1028
  function sanitize(s) {
731
1029
  return s.replace(/[^a-zA-Z0-9_-]/g, "_");
732
1030
  }