min-agent 0.2.1 → 0.3.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 (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +397 -139
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
package/dist/mcp.js CHANGED
@@ -2,59 +2,112 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
3
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5
- import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5
+ import { UnauthorizedError, auth as mcpAuth, } from "@modelcontextprotocol/sdk/client/auth.js";
6
6
  import { tool, jsonSchema } from "ai";
7
- import { readFileSync, existsSync, writeFileSync, mkdirSync } from "fs";
7
+ import { readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
8
8
  import path from "path";
9
+ import { createServer } from "http";
10
+ import { spawn } from "child_process";
11
+ import { Writable } from "stream";
12
+ import { randomUUID } from "crypto";
9
13
  import { truncateToolOutput } from "./tool-output.js";
10
14
  import { getConfigDir } from "./config.js";
15
+ import { log } from "./logger.js";
16
+ const STRUCTURED_STRING_MAX = 8000;
17
+ const STRUCTURED_ARRAY_MAX = 500;
18
+ /** Bound the size of MCP `structuredContent` while keeping its JSON shape (outputSchema validation). */
19
+ function truncateStructured(value) {
20
+ if (typeof value === "string") {
21
+ return value.length > STRUCTURED_STRING_MAX ? value.slice(0, STRUCTURED_STRING_MAX) + "… [truncated]" : value;
22
+ }
23
+ if (Array.isArray(value)) {
24
+ const sliced = value.slice(0, STRUCTURED_ARRAY_MAX);
25
+ return sliced.map(truncateStructured);
26
+ }
27
+ if (value !== null && typeof value === "object") {
28
+ const out = {};
29
+ for (const [k, v] of Object.entries(value))
30
+ out[k] = truncateStructured(v);
31
+ return out;
32
+ }
33
+ return value;
34
+ }
11
35
  const DEFAULT_TIMEOUT = 30000;
12
- function getMcpConfigPath() {
13
- // Check project-local first, then global
14
- const local = path.join(process.cwd(), ".min-agent", "mcp.json");
15
- if (existsSync(local))
16
- return local;
36
+ const STDERD_KEEP_LINES = 20;
37
+ const OAUTH_BROWSER_TIMEOUT_MS = 5 * 60 * 1000;
38
+ function globalMcpConfigPath() {
17
39
  return path.join(getConfigDir(), "mcp.json");
18
40
  }
19
- function getMcpConfigWritePath() {
20
- // Write to project-local if it exists, otherwise global
21
- const local = path.join(process.cwd(), ".min-agent", "mcp.json");
22
- if (existsSync(path.dirname(local)))
23
- return local;
24
- return path.join(getConfigDir(), "mcp.json");
41
+ function projectMcpConfigPath() {
42
+ return path.join(process.cwd(), ".min-agent", "mcp.json");
43
+ }
44
+ export function mcpConfigPathFor(scope) {
45
+ return scope === "project" ? projectMcpConfigPath() : globalMcpConfigPath();
25
46
  }
26
47
  let connectedServers = {};
27
- export function loadMcpConfig() {
28
- const globalPath = path.join(getConfigDir(), "mcp.json");
29
- const localPath = path.join(process.cwd(), ".min-agent", "mcp.json");
30
- let config = { mcpServers: {} };
31
- // Load global first
32
- if (existsSync(globalPath)) {
33
- try {
34
- const global = JSON.parse(readFileSync(globalPath, "utf-8"));
35
- config.mcpServers = { ...config.mcpServers, ...global.mcpServers };
36
- }
37
- catch { }
48
+ const mcpServerErrors = {};
49
+ /** Cache of built tool wrappers; invalidated when MCP servers (re)connect. */
50
+ let mcpToolsCache = null;
51
+ const configCaches = new Map();
52
+ function loadJsonConfig(filePath) {
53
+ const cache = configCaches.get(filePath);
54
+ try {
55
+ const st = statSync(filePath);
56
+ if (cache && cache.mtimeMs === st.mtimeMs && cache.size === st.size)
57
+ return cache;
58
+ const config = JSON.parse(readFileSync(filePath, "utf-8"));
59
+ const next = { mtimeMs: st.mtimeMs, size: st.size, config };
60
+ configCaches.set(filePath, next);
61
+ return next;
38
62
  }
39
- // Local overrides global
40
- if (existsSync(localPath) && localPath !== globalPath) {
41
- try {
42
- const local = JSON.parse(readFileSync(localPath, "utf-8"));
43
- config.mcpServers = { ...config.mcpServers, ...local.mcpServers };
44
- }
45
- catch { }
63
+ catch {
64
+ return null;
46
65
  }
47
- return config;
48
66
  }
49
- export function saveMcpConfig(config) {
50
- const configPath = path.join(getConfigDir(), "mcp.json");
67
+ /** Load a single scope's mcp.json (not merged). */
68
+ export function loadMcpConfigFile(scope) {
69
+ const cached = loadJsonConfig(mcpConfigPathFor(scope));
70
+ return cached?.config ?? { mcpServers: {} };
71
+ }
72
+ /** Load merged config: project-level entries override global ones. */
73
+ export function loadMcpConfig() {
74
+ return {
75
+ mcpServers: {
76
+ ...loadMcpConfigFile("global").mcpServers,
77
+ ...loadMcpConfigFile("project").mcpServers,
78
+ },
79
+ };
80
+ }
81
+ /** All configured servers with the scope each entry lives in (project wins over global). */
82
+ export function loadMcpConfigEntries() {
83
+ const global = loadMcpConfigFile("global");
84
+ const project = loadMcpConfigFile("project");
85
+ return Object.entries({ ...global.mcpServers, ...project.mcpServers }).map(([name, config]) => ({
86
+ name,
87
+ config,
88
+ scope: project.mcpServers[name] !== undefined ? "project" : "global",
89
+ }));
90
+ }
91
+ export function saveMcpConfig(config, scope = "global") {
92
+ const configPath = mcpConfigPathFor(scope);
51
93
  const dir = path.dirname(configPath);
52
94
  mkdirSync(dir, { recursive: true });
53
95
  writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
96
+ configCaches.delete(configPath);
54
97
  }
55
98
  export function isRemoteMcpConfig(config) {
56
99
  return typeof config.url === "string" && config.url.trim().length > 0;
57
100
  }
101
+ /** Effective timeouts for one server entry. */
102
+ export function resolveMcpTimeouts(config) {
103
+ return {
104
+ connectTimeout: config.connectTimeout ?? config.timeout ?? DEFAULT_TIMEOUT,
105
+ callTimeout: config.callTimeout ?? config.timeout ?? DEFAULT_TIMEOUT,
106
+ };
107
+ }
108
+ function configSignature(config) {
109
+ return JSON.stringify(config);
110
+ }
58
111
  function buildRemoteRequestInit(config) {
59
112
  const headers = new Headers(config.headers ?? {});
60
113
  const token = config.token?.trim();
@@ -65,7 +118,40 @@ function buildRemoteRequestInit(config) {
65
118
  return undefined;
66
119
  return { headers };
67
120
  }
68
- async function openStdioMcpServer(name, config) {
121
+ function createStderrSink(name) {
122
+ const lines = [];
123
+ let buffer = "";
124
+ const stream = new Writable({
125
+ write(chunk, _enc, cb) {
126
+ buffer += String(chunk);
127
+ let idx = -1;
128
+ while ((idx = buffer.indexOf("\n")) !== -1) {
129
+ const line = buffer.slice(0, idx).trim();
130
+ buffer = buffer.slice(idx + 1);
131
+ if (!line)
132
+ continue;
133
+ lines.push(line);
134
+ if (lines.length > STDERD_KEEP_LINES)
135
+ lines.shift();
136
+ log("warn", `mcp "${name}" stderr: ${line}`);
137
+ }
138
+ cb();
139
+ },
140
+ });
141
+ return { stream, lines };
142
+ }
143
+ /** List every tool, following pagination cursors. */
144
+ async function listAllTools(client) {
145
+ const all = [];
146
+ let cursor;
147
+ do {
148
+ const result = await client.listTools(cursor ? { cursor } : undefined);
149
+ all.push(...result.tools);
150
+ cursor = result.nextCursor;
151
+ } while (cursor);
152
+ return all;
153
+ }
154
+ async function openStdioMcpServer(name, config, onTransport) {
69
155
  // Support both formats:
70
156
  // { command: ["uvx", "mcp-server-time"] } — min-agent native
71
157
  // { command: "uvx", args: ["mcp-server-time"] } — opencode/claude style
@@ -84,18 +170,244 @@ async function openStdioMcpServer(name, config) {
84
170
  if (!cmd) {
85
171
  throw new Error(`MCP "${name}" has empty command`);
86
172
  }
173
+ const stderrSink = createStderrSink(name);
87
174
  const transport = new StdioClientTransport({
88
175
  command: cmd,
89
176
  args,
90
177
  env: { ...process.env, ...(config.environment ?? {}) },
91
178
  stderr: "pipe",
92
179
  });
180
+ // Drain the child's stderr so a chatty server can never block on a full pipe buffer.
181
+ transport.stderr?.pipe(stderrSink.stream);
182
+ onTransport?.(transport);
93
183
  const client = new Client({ name: "min-agent", version: "0.1.0" });
94
- await client.connect(transport);
95
- const { tools } = await client.listTools();
96
- return { client, transport, tools };
184
+ try {
185
+ await client.connect(transport);
186
+ const tools = await listAllTools(client);
187
+ return { client, transport, tools, callTimeout: resolveMcpTimeouts(config).callTimeout, signature: configSignature(config), stderrSink };
188
+ }
189
+ catch (err) {
190
+ 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")}` : "";
192
+ throw new Error(`${err?.message ?? String(err)}${tail}`);
193
+ }
97
194
  }
98
- async function openRemoteMcpServer(name, config) {
195
+ /**
196
+ * Persistent OAuth provider backed by `~/.min-agent/mcp-oauth/<name>.json`.
197
+ * Binds a localhost callback server on a random port so the browser can return
198
+ * the authorization code; the port is fixed per connection attempt so the
199
+ * registration metadata and the redirect stay consistent.
200
+ */
201
+ class McpOAuthProvider {
202
+ name;
203
+ serverConfig;
204
+ tokensStore;
205
+ clientInfo;
206
+ discoveryStore;
207
+ verifier;
208
+ pendingState;
209
+ pendingCode;
210
+ pendingError;
211
+ resolveCode;
212
+ rejectCode;
213
+ callbackServer;
214
+ callbackBase = "http://127.0.0.1";
215
+ redirectUrl = "";
216
+ constructor(name, serverConfig, storeFile) {
217
+ this.name = name;
218
+ this.serverConfig = serverConfig;
219
+ this.loadStore(storeFile);
220
+ }
221
+ static async create(name, serverConfig) {
222
+ const storeFile = path.join(getConfigDir(), "mcp-oauth", `${sanitize(name)}-${djb2(name).toString(16)}.json`);
223
+ const provider = new McpOAuthProvider(name, serverConfig, storeFile);
224
+ await provider.bindCallbackServer();
225
+ return provider;
226
+ }
227
+ storeFile() {
228
+ return path.join(getConfigDir(), "mcp-oauth", `${sanitize(this.name)}-${djb2(this.name).toString(16)}.json`);
229
+ }
230
+ loadStore(file) {
231
+ try {
232
+ const raw = JSON.parse(readFileSync(file, "utf-8"));
233
+ this.clientInfo = raw.clientInfo;
234
+ this.tokensStore = raw.tokens;
235
+ this.discoveryStore = raw.discovery;
236
+ }
237
+ catch { }
238
+ }
239
+ saveStore() {
240
+ const file = this.storeFile();
241
+ mkdirSync(path.dirname(file), { recursive: true });
242
+ writeFileSync(file, JSON.stringify({
243
+ ...(this.clientInfo ? { clientInfo: this.clientInfo } : {}),
244
+ ...(this.tokensStore ? { tokens: this.tokensStore } : {}),
245
+ ...(this.discoveryStore ? { discovery: this.discoveryStore } : {}),
246
+ }, null, 2), "utf-8");
247
+ }
248
+ settleCode(code) {
249
+ this.pendingCode = code;
250
+ this.resolveCode?.(code);
251
+ }
252
+ failCode(err) {
253
+ this.pendingError = err;
254
+ this.rejectCode?.(err);
255
+ }
256
+ bindCallbackServer() {
257
+ return new Promise((resolve, reject) => {
258
+ const server = createServer((req, res) => {
259
+ const requestUrl = new URL(req.url ?? "/", this.callbackBase);
260
+ const code = requestUrl.searchParams.get("code");
261
+ const state = requestUrl.searchParams.get("state");
262
+ const error = requestUrl.searchParams.get("error");
263
+ const body = code
264
+ ? "<html><body><h3>min-agent: authorization complete</h3><p>You can close this tab and return to the terminal.</p></body></html>"
265
+ : "<html><body><h3>min-agent: authorization failed</h3><p>Check the terminal for details.</p></body></html>";
266
+ res.writeHead(code ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" });
267
+ res.end(body);
268
+ if (this.pendingState && state && state !== this.pendingState) {
269
+ this.failCode(new Error("OAuth state mismatch — authorization attempt rejected"));
270
+ return;
271
+ }
272
+ if (code) {
273
+ this.settleCode(code);
274
+ }
275
+ else {
276
+ this.failCode(new Error(error ? `OAuth authorization failed: ${error}` : "OAuth authorization failed: no code returned"));
277
+ }
278
+ });
279
+ server.on("error", (err) => {
280
+ this.failCode(new Error(`OAuth callback server failed: ${err.message}`));
281
+ });
282
+ server.listen(0, "127.0.0.1", () => {
283
+ const address = server.address();
284
+ if (!address || typeof address === "string") {
285
+ reject(new Error("OAuth callback server could not bind a port"));
286
+ return;
287
+ }
288
+ this.callbackServer = server;
289
+ this.callbackBase = `http://127.0.0.1:${address.port}`;
290
+ this.redirectUrl = `${this.callbackBase}/callback`;
291
+ resolve();
292
+ });
293
+ });
294
+ }
295
+ close() {
296
+ this.callbackServer?.close();
297
+ this.callbackServer = undefined;
298
+ }
299
+ /** Resolves once the browser callback delivers the authorization code. */
300
+ waitForCode() {
301
+ if (this.pendingCode)
302
+ return Promise.resolve(this.pendingCode);
303
+ if (this.pendingError)
304
+ return Promise.reject(this.pendingError);
305
+ return new Promise((resolve, reject) => {
306
+ this.resolveCode = resolve;
307
+ this.rejectCode = reject;
308
+ });
309
+ }
310
+ get clientMetadata() {
311
+ const oauth = this.serverConfig.oauth;
312
+ const meta = {
313
+ redirect_uris: [this.redirectUrl],
314
+ client_name: "min-agent",
315
+ };
316
+ if (typeof oauth === "object" && oauth) {
317
+ if (oauth.clientId)
318
+ meta.client_id = oauth.clientId;
319
+ if (oauth.clientSecret)
320
+ meta.client_secret = oauth.clientSecret;
321
+ if (oauth.scope)
322
+ meta.scope = oauth.scope;
323
+ }
324
+ return meta;
325
+ }
326
+ clientInformation() {
327
+ if (!this.clientInfo)
328
+ return undefined;
329
+ const uris = "redirect_uris" in this.clientInfo ? this.clientInfo.redirect_uris : [];
330
+ return uris.includes(this.redirectUrl) ? this.clientInfo : undefined;
331
+ }
332
+ saveClientInformation(clientInformation) {
333
+ this.clientInfo = clientInformation;
334
+ this.saveStore();
335
+ }
336
+ tokens() {
337
+ return this.tokensStore;
338
+ }
339
+ saveTokens(tokens) {
340
+ this.tokensStore = tokens;
341
+ this.saveStore();
342
+ }
343
+ saveDiscoveryState(state) {
344
+ this.discoveryStore = state;
345
+ this.saveStore();
346
+ }
347
+ discoveryState() {
348
+ return this.discoveryStore;
349
+ }
350
+ saveCodeVerifier(codeVerifier) {
351
+ this.verifier = codeVerifier;
352
+ }
353
+ codeVerifier() {
354
+ if (!this.verifier)
355
+ throw new Error("No PKCE code verifier for this session");
356
+ return this.verifier;
357
+ }
358
+ state() {
359
+ this.pendingState = randomUUID();
360
+ return this.pendingState;
361
+ }
362
+ redirectToAuthorization(authorizationUrl) {
363
+ openBrowser(authorizationUrl.href);
364
+ }
365
+ }
366
+ function openBrowser(url) {
367
+ const platform = process.platform;
368
+ try {
369
+ if (platform === "darwin") {
370
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
371
+ }
372
+ else if (platform === "win32") {
373
+ spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
374
+ }
375
+ else {
376
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
377
+ }
378
+ }
379
+ catch (err) {
380
+ log("warn", `mcp oauth: failed to open browser: ${err.message}`);
381
+ }
382
+ }
383
+ /**
384
+ * Interactive OAuth flow for remote servers. Returns the provider to pass to
385
+ * the transport, or undefined when OAuth is not configured.
386
+ */
387
+ async function runOAuthFlow(name, config, baseUrl) {
388
+ if (!config.oauth || config.token?.trim())
389
+ return undefined;
390
+ const provider = await McpOAuthProvider.create(name, config);
391
+ try {
392
+ const result = await mcpAuth(provider, { serverUrl: baseUrl.href });
393
+ if (result === "REDIRECT") {
394
+ console.log(`\x1b[90m MCP "${name}" needs authorization — a browser tab was opened. Waiting for login…\x1b[0m`);
395
+ const code = await Promise.race([
396
+ provider.waitForCode(),
397
+ new Promise((_, reject) => {
398
+ setTimeout(() => reject(new Error("OAuth authorization timed out after 5 minutes")), OAUTH_BROWSER_TIMEOUT_MS);
399
+ }),
400
+ ]);
401
+ await mcpAuth(provider, { serverUrl: baseUrl.href, authorizationCode: code });
402
+ }
403
+ return provider;
404
+ }
405
+ catch (err) {
406
+ provider.close();
407
+ throw new Error(`MCP "${name}" OAuth failed: ${err?.message ?? String(err)}`);
408
+ }
409
+ }
410
+ async function openRemoteMcpServer(name, config, onTransport) {
99
411
  const rawUrl = config.url?.trim();
100
412
  if (!rawUrl) {
101
413
  throw new Error(`MCP "${name}" has empty url`);
@@ -112,19 +424,35 @@ async function openRemoteMcpServer(name, config) {
112
424
  }
113
425
  const requestInit = buildRemoteRequestInit(config);
114
426
  const mode = config.remoteTransport ?? "auto";
427
+ const oauthProvider = await runOAuthFlow(name, config, baseUrl);
428
+ const callTimeout = resolveMcpTimeouts(config).callTimeout;
115
429
  const connectStreamable = async () => {
116
430
  const client = new Client({ name: "min-agent", version: "0.1.0" });
117
- const transport = new StreamableHTTPClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
118
- await client.connect(transport);
119
- const { tools } = await client.listTools();
120
- return { client, transport, tools };
431
+ const transport = new StreamableHTTPClientTransport(baseUrl, requestInit || oauthProvider ? { requestInit, authProvider: oauthProvider } : undefined);
432
+ onTransport?.(transport);
433
+ try {
434
+ await client.connect(transport);
435
+ const tools = await listAllTools(client);
436
+ return { client, transport, tools, callTimeout, signature: configSignature(config), oauthProvider };
437
+ }
438
+ catch (err) {
439
+ await transport.close().catch(() => { });
440
+ throw err;
441
+ }
121
442
  };
122
443
  const connectSse = async () => {
123
444
  const client = new Client({ name: "min-agent", version: "0.1.0" });
124
- const transport = new SSEClientTransport(baseUrl, requestInit ? { requestInit } : undefined);
125
- await client.connect(transport);
126
- const { tools } = await client.listTools();
127
- return { client, transport, tools };
445
+ const transport = new SSEClientTransport(baseUrl, requestInit || oauthProvider ? { requestInit, authProvider: oauthProvider } : undefined);
446
+ onTransport?.(transport);
447
+ try {
448
+ await client.connect(transport);
449
+ const tools = await listAllTools(client);
450
+ return { client, transport, tools, callTimeout, signature: configSignature(config), oauthProvider };
451
+ }
452
+ catch (err) {
453
+ await transport.close().catch(() => { });
454
+ throw err;
455
+ }
128
456
  };
129
457
  try {
130
458
  if (mode === "streamable-http")
@@ -145,22 +473,23 @@ async function openRemoteMcpServer(name, config) {
145
473
  }
146
474
  }
147
475
  catch (err) {
476
+ const message = err instanceof Error ? err.message : String(err);
148
477
  // Handle OAuth/Unauthorized errors
149
- if (err instanceof UnauthorizedError || err?.message?.includes("Unauthorized") || err?.message?.includes("401")) {
478
+ if (err instanceof UnauthorizedError || message.includes("Unauthorized") || message.includes("401")) {
150
479
  if (config.oauth === false) {
151
480
  throw new Error(`MCP "${name}" requires authentication but OAuth is disabled in config`);
152
481
  }
153
482
  throw new Error(`MCP "${name}" requires authentication. Add a "token" field to the server config, or configure OAuth:\n` +
154
483
  ` min-agent mcp add ${name} --url ${rawUrl} --token YOUR_TOKEN`);
155
484
  }
156
- throw new Error(`MCP "${name}" remote connection failed: ${err?.message ?? String(err)}`);
485
+ throw new Error(`MCP "${name}" remote connection failed: ${message}`);
157
486
  }
158
487
  }
159
- async function openMcpServer(name, config) {
488
+ async function openMcpServer(name, config, onTransport) {
160
489
  if (isRemoteMcpConfig(config)) {
161
- return await openRemoteMcpServer(name, config);
490
+ return await openRemoteMcpServer(name, config, onTransport);
162
491
  }
163
- return await openStdioMcpServer(name, config);
492
+ return await openStdioMcpServer(name, config, onTransport);
164
493
  }
165
494
  /** One-line summary for CLI / logs (no secrets). */
166
495
  export function formatMcpServerBinding(config) {
@@ -171,93 +500,204 @@ export function formatMcpServerBinding(config) {
171
500
  const cmd = Array.isArray(config.command) ? config.command.join(" ") : `${config.command ?? ""} ${(config.args ?? []).join(" ")}`.trim();
172
501
  return cmd || "(no command)";
173
502
  }
503
+ async function openMcpServerWithTimeout(name, config) {
504
+ const timeout = resolveMcpTimeouts(config).connectTimeout;
505
+ let timer;
506
+ const transportHolder = { transport: null };
507
+ const timeoutPromise = new Promise((_, reject) => {
508
+ timer = setTimeout(() => reject(new Error(`connection timed out after ${timeout}ms`)), timeout);
509
+ });
510
+ const connectPromise = openMcpServer(name, config, (t) => {
511
+ transportHolder.transport = t;
512
+ });
513
+ connectPromise.catch(() => { });
514
+ try {
515
+ const server = await Promise.race([connectPromise, timeoutPromise]);
516
+ return { server };
517
+ }
518
+ catch (error) {
519
+ await transportHolder.transport?.close().catch(() => { });
520
+ return { error };
521
+ }
522
+ finally {
523
+ if (timer)
524
+ clearTimeout(timer);
525
+ }
526
+ }
174
527
  export async function connectMcpServer(name, config) {
175
528
  if (config.enabled === false)
176
529
  return null;
177
- const timeout = config.timeout ?? DEFAULT_TIMEOUT;
178
- try {
179
- const server = await Promise.race([
180
- openMcpServer(name, config),
181
- new Promise((_, reject) => setTimeout(() => reject(new Error(`connection timed out after ${timeout}ms`)), timeout)),
182
- ]);
530
+ const { server, error } = await openMcpServerWithTimeout(name, config);
531
+ if (server) {
532
+ delete mcpServerErrors[name];
183
533
  console.log(`\x1b[90m MCP "${name}" connected (${server.tools.length} tools)\x1b[0m`);
534
+ log("info", `mcp "${name}" connected (${server.tools.length} tools)`);
184
535
  return server;
185
536
  }
186
- catch (err) {
187
- console.error(`\x1b[31m MCP "${name}" failed: ${err.message}\x1b[0m`);
188
- return null;
189
- }
537
+ const message = error?.message ?? String(error);
538
+ mcpServerErrors[name] = message;
539
+ console.error(`\x1b[31m MCP "${name}" failed: ${message}\x1b[0m`);
540
+ log("error", `mcp "${name}" failed: ${message}`);
541
+ return null;
190
542
  }
191
543
  export async function checkMcpServer(name, config) {
192
544
  if (config.enabled === false) {
193
545
  return { name, enabled: false, ok: true, toolCount: 0 };
194
546
  }
195
- try {
196
- const server = await openMcpServer(name, config);
197
- const toolCount = server.tools.length;
198
- try {
199
- await server.client.close();
200
- }
201
- catch { }
202
- return { name, enabled: true, ok: true, toolCount };
203
- }
204
- catch (err) {
547
+ const { server, error } = await openMcpServerWithTimeout(name, config);
548
+ if (!server) {
205
549
  return {
206
550
  name,
207
551
  enabled: true,
208
552
  ok: false,
209
553
  toolCount: 0,
210
- error: err?.message ?? String(err),
554
+ error: error instanceof Error ? error.message : String(error),
211
555
  };
212
556
  }
557
+ const toolCount = server.tools.length;
558
+ try {
559
+ await server.client.close();
560
+ }
561
+ catch { }
562
+ return { name, enabled: true, ok: true, toolCount };
213
563
  }
214
564
  export async function checkAllMcpServers() {
215
- const config = loadMcpConfig();
216
- const results = [];
217
- for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
218
- results.push(await checkMcpServer(name, serverConfig));
219
- }
220
- return results;
565
+ const entries = loadMcpConfigEntries();
566
+ 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) });
221
568
  }
222
- export async function initMcp() {
223
- const config = loadMcpConfig();
224
- for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
225
- const server = await connectMcpServer(name, serverConfig);
226
- if (server)
227
- connectedServers[name] = server;
569
+ async function closeConnectedServer(name, server) {
570
+ try {
571
+ await server.client.close();
228
572
  }
573
+ catch { }
574
+ server.oauthProvider?.close();
575
+ delete connectedServers[name];
576
+ delete mcpServerErrors[name];
229
577
  }
230
578
  export async function shutdownMcp() {
579
+ mcpToolsCache = null;
580
+ await Promise.all(Object.entries(connectedServers).map(([name, server]) => closeConnectedServer(name, server)));
581
+ connectedServers = {};
582
+ }
583
+ let syncQueue = Promise.resolve();
584
+ /**
585
+ * Reconcile live connections with the current config: disconnect servers that
586
+ * were removed or changed, connect new/changed enabled ones. Safe to call
587
+ * repeatedly; used by `initMcp` and by `serve` after config mutations.
588
+ * Concurrent calls are serialized so parallel config writes cannot
589
+ * double-connect the same server.
590
+ */
591
+ export function syncMcpConnections() {
592
+ const run = syncQueue.then(() => syncMcpConnectionsNow());
593
+ syncQueue = run.catch(() => { });
594
+ return run;
595
+ }
596
+ async function syncMcpConnectionsNow() {
597
+ mcpToolsCache = null;
598
+ const entries = loadMcpConfigEntries();
231
599
  for (const [name, server] of Object.entries(connectedServers)) {
232
- try {
233
- await server.client.close();
600
+ const entry = entries.find((e) => e.name === name);
601
+ if (!entry || entry.config.enabled === false || configSignature(entry.config) !== server.signature) {
602
+ await closeConnectedServer(name, server);
234
603
  }
235
- catch { }
236
604
  }
237
- connectedServers = {};
605
+ const pending = entries.filter(({ name, config }) => {
606
+ if (config.enabled === false)
607
+ return false;
608
+ const live = connectedServers[name];
609
+ if (!live)
610
+ return true;
611
+ return configSignature(config) !== live.signature;
612
+ });
613
+ const settled = await Promise.allSettled(pending.map(async ({ name, config }) => {
614
+ const server = await connectMcpServer(name, config);
615
+ if (server)
616
+ connectedServers[name] = server;
617
+ }));
618
+ const failedNames = new Set();
619
+ settled.forEach((r, i) => {
620
+ if (r.status === "rejected") {
621
+ const name = pending[i].name;
622
+ failedNames.add(name);
623
+ mcpServerErrors[name] = String(r.reason);
624
+ console.error(`\x1b[31m MCP "${name}" failed: ${String(r.reason)}\x1b[0m`);
625
+ log("error", `mcp "${name}" failed: ${String(r.reason)}`);
626
+ }
627
+ });
628
+ return entries.map(({ name, config }) => {
629
+ const live = connectedServers[name];
630
+ if (config.enabled === false)
631
+ return { name, state: "disabled" };
632
+ if (live) {
633
+ const wasPending = pending.some((p) => p.name === name);
634
+ return { name, state: wasPending ? "connected" : "unchanged", tools: live.tools.length };
635
+ }
636
+ return {
637
+ name,
638
+ state: "failed",
639
+ ...(mcpServerErrors[name] ? { error: mcpServerErrors[name] } : {}),
640
+ };
641
+ });
642
+ }
643
+ export async function initMcp() {
644
+ mcpToolsCache = null;
645
+ await shutdownMcp();
646
+ await syncMcpConnections();
238
647
  }
239
648
  export function getMcpTools() {
649
+ if (mcpToolsCache)
650
+ return mcpToolsCache;
651
+ mcpToolsCache = buildMcpToolsMap(connectedServers);
652
+ return mcpToolsCache;
653
+ }
654
+ /**
655
+ * Build AI SDK tool wrappers for a set of connected MCP servers.
656
+ * Tool IDs are collision-safe (suffixed when sanitized names clash) and
657
+ * descriptions are prefixed with the owning server name.
658
+ */
659
+ export function buildMcpToolsMap(servers) {
240
660
  const tools = {};
241
- for (const [serverName, server] of Object.entries(connectedServers)) {
661
+ const used = new Set();
662
+ for (const [serverName, server] of Object.entries(servers)) {
242
663
  for (const mcpTool of server.tools) {
243
- const toolId = `${sanitize(serverName)}_${sanitize(mcpTool.name)}`;
664
+ let toolId = `${sanitize(serverName)}_${sanitize(mcpTool.name)}`;
665
+ let n = 2;
666
+ while (used.has(toolId))
667
+ toolId = `${sanitize(serverName)}_${sanitize(mcpTool.name)}_${n++}`;
668
+ used.add(toolId);
669
+ const baseSchema = (mcpTool.inputSchema ?? {});
244
670
  const schema = {
245
- ...mcpTool.inputSchema,
246
- type: "object",
247
- properties: (mcpTool.inputSchema?.properties ?? {}),
671
+ ...baseSchema,
672
+ ...(baseSchema.type ? {} : { type: "object" }),
673
+ ...(baseSchema.properties ? {} : { properties: {} }),
248
674
  };
675
+ const readOnly = mcpTool.annotations?.readOnlyHint === true;
676
+ const description = `[${serverName}] ${mcpTool.description ?? `MCP tool: ${mcpTool.name}`}` +
677
+ (readOnly ? " (read-only)" : "");
678
+ const outputSchema = mcpTool.outputSchema;
249
679
  tools[toolId] = tool({
250
- description: mcpTool.description ?? `MCP tool: ${mcpTool.name}`,
680
+ description,
251
681
  inputSchema: jsonSchema(schema),
252
- execute: async (args) => {
682
+ ...(outputSchema && outputSchema.type === "object"
683
+ ? { outputSchema: jsonSchema(outputSchema) }
684
+ : {}),
685
+ execute: async (args, options) => {
253
686
  try {
687
+ const signals = [AbortSignal.timeout(server.callTimeout)];
688
+ if (options?.abortSignal)
689
+ signals.push(options.abortSignal);
690
+ const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
254
691
  const result = await server.client.callTool({
255
692
  name: mcpTool.name,
256
- arguments: args,
257
- });
693
+ arguments: (args ?? {}),
694
+ }, undefined, { signal });
258
695
  if (result.isError) {
259
696
  return truncateToolOutput(`Error: ${JSON.stringify(result.content)}`, { direction: "head" }).content;
260
697
  }
698
+ if (result.structuredContent !== undefined) {
699
+ return truncateStructured(result.structuredContent);
700
+ }
261
701
  const content = result.content;
262
702
  const text = content
263
703
  .filter((c) => c.type === "text")
@@ -276,12 +716,13 @@ export function getMcpTools() {
276
716
  }
277
717
  export function getMcpStatus() {
278
718
  const status = {};
279
- const config = loadMcpConfig();
280
- for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
719
+ for (const { name, config } of loadMcpConfigEntries()) {
281
720
  const server = connectedServers[name];
282
721
  status[name] = {
283
722
  connected: !!server,
723
+ enabled: config.enabled !== false,
284
724
  tools: server?.tools.map((t) => t.name) ?? [],
725
+ ...(mcpServerErrors[name] ? { error: mcpServerErrors[name] } : {}),
285
726
  };
286
727
  }
287
728
  return status;
@@ -289,3 +730,9 @@ export function getMcpStatus() {
289
730
  function sanitize(s) {
290
731
  return s.replace(/[^a-zA-Z0-9_-]/g, "_");
291
732
  }
733
+ function djb2(s) {
734
+ let h = 5381;
735
+ for (let i = 0; i < s.length; i++)
736
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
737
+ return h;
738
+ }