min-agent 0.2.0 → 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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- 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,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 ||
|
|
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: ${
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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:
|
|
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
|
|
216
|
-
const
|
|
217
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
+
const used = new Set();
|
|
662
|
+
for (const [serverName, server] of Object.entries(servers)) {
|
|
242
663
|
for (const mcpTool of server.tools) {
|
|
243
|
-
|
|
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
|
-
...
|
|
246
|
-
type: "object",
|
|
247
|
-
|
|
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
|
|
680
|
+
description,
|
|
251
681
|
inputSchema: jsonSchema(schema),
|
|
252
|
-
|
|
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
|
|
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
|
+
}
|