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