agent-from-zero 0.32.4 → 0.34.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/dist/agent.js +100 -3
- package/dist/cron.js +280 -0
- package/dist/ink/app.js +166 -4
- package/dist/ink/commands.js +22 -0
- package/dist/ink/setup.js +8 -0
- package/dist/loop.js +24 -0
- package/dist/mcp.js +593 -40
- package/dist/permissions.js +11 -0
- package/dist/session.js +23 -4
- package/dist/title.js +49 -0
- package/dist/tools.js +62 -0
- package/package.json +2 -2
package/dist/mcp.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"; // stdio servers are subprocesses
|
|
2
|
+
import { createServer } from "node:http"; // the OAuth callback listener
|
|
3
|
+
import crypto from "node:crypto"; // PKCE + state nonces
|
|
4
|
+
import os from "node:os"; // home directory for token persistence
|
|
5
|
+
import path from "node:path"; // path joining
|
|
6
|
+
import fs from "node:fs"; // token file persistence
|
|
2
7
|
import chalk from "chalk"; // status lines
|
|
3
8
|
import { CONFIG } from "./config.js"; // configured servers
|
|
4
|
-
import { registerExternalTool } from "./tools.js"; // expose discovered tools
|
|
9
|
+
import { registerExternalTool, unregisterExternalTool } from "./tools.js"; // expose discovered tools
|
|
5
10
|
import { emit } from "./telemetry.js"; // observability
|
|
6
11
|
// MCP (Model Context Protocol) lets the agent borrow tools from external
|
|
7
12
|
// servers — a filesystem server, a GitHub server, a database server, anything.
|
|
@@ -16,8 +21,44 @@ import { emit } from "./telemetry.js"; // observability
|
|
|
16
21
|
//
|
|
17
22
|
// Discovered tools are named mcp__<server>__<tool> so they never collide with
|
|
18
23
|
// built-ins and the user can see (and gate) exactly where each came from.
|
|
24
|
+
//
|
|
25
|
+
// Since this file also drives the /mcp command, it keeps a REGISTRY of every
|
|
26
|
+
// configured server and its live connection state (connected / failed /
|
|
27
|
+
// needs-auth / disabled) so the UI can list them and reconnect/authenticate one
|
|
28
|
+
// at a time — the way Claude Code's /mcp does.
|
|
29
|
+
//
|
|
30
|
+
// Remote servers (Linear, Slack, GitHub…) are OAuth-protected. The flow is the
|
|
31
|
+
// MCP authorization spec end to end: the 401 challenge points at the resource
|
|
32
|
+
// metadata (RFC 9728), which names the authorization server (RFC 8414); we
|
|
33
|
+
// register a client dynamically (RFC 7591) unless the config carries a
|
|
34
|
+
// pre-registered one; then authorization-code + PKCE with a loopback redirect,
|
|
35
|
+
// the `resource` indicator (RFC 8707) on every token request, and refresh
|
|
36
|
+
// tokens so a session survives token expiry without another browser round.
|
|
19
37
|
const INIT_TIMEOUT_MS = 15_000; // a server that doesn't initialize in 15s is skipped
|
|
20
38
|
const CALL_TIMEOUT_MS = 60_000; // a single tool call may take this long
|
|
39
|
+
const OAUTH_CALLBACK_TIMEOUT_MS = 5 * 60_000; // how long the local callback waits for the browser
|
|
40
|
+
const PROTOCOL_VERSION = "2024-11-05"; // the MCP revision we speak in the handshake
|
|
41
|
+
const CLIENT_INFO = { name: "mini-agent", version: "0" }; // who we say we are in the handshake
|
|
42
|
+
// Thrown when an HTTP MCP server answers 401 — the caller marks the server as
|
|
43
|
+
// "needs auth" instead of "failed". The challenge header is parsed and kept:
|
|
44
|
+
// it is the AUTHORITATIVE pointer to the OAuth metadata. Guessing well-known
|
|
45
|
+
// paths is what breaks on servers like Slack, whose metadata sits at the origin
|
|
46
|
+
// root rather than under the /mcp path.
|
|
47
|
+
export class NeedsAuthError extends Error {
|
|
48
|
+
resourceMetadataUrl;
|
|
49
|
+
scope;
|
|
50
|
+
constructor(message, wwwAuthenticate) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "NeedsAuthError";
|
|
53
|
+
// The challenge is `Bearer key="value", key="value"…` — pull out the pairs we use.
|
|
54
|
+
for (const m of (wwwAuthenticate ?? "").matchAll(/(\w+)="([^"]*)"/g)) {
|
|
55
|
+
if (m[1] === "resource_metadata")
|
|
56
|
+
this.resourceMetadataUrl = m[2];
|
|
57
|
+
if (m[1] === "scope")
|
|
58
|
+
this.scope = m[2];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
21
62
|
// ---- stdio transport: one JSON object per line, to a subprocess --------------
|
|
22
63
|
class StdioTransport {
|
|
23
64
|
child;
|
|
@@ -100,9 +141,11 @@ class HttpTransport {
|
|
|
100
141
|
url;
|
|
101
142
|
headers;
|
|
102
143
|
sessionId; // assigned by the server on initialize, echoed on every later request
|
|
103
|
-
constructor(def) {
|
|
144
|
+
constructor(def, token) {
|
|
104
145
|
this.url = def.url;
|
|
105
146
|
this.headers = { ...def.headers };
|
|
147
|
+
if (token)
|
|
148
|
+
this.headers["authorization"] = `Bearer ${token}`; // OAuth access token
|
|
106
149
|
}
|
|
107
150
|
// The response to a POST may come back as a single JSON object OR as an SSE
|
|
108
151
|
// stream carrying it. Either way we want the JSON-RPC message matching our id.
|
|
@@ -118,6 +161,9 @@ class HttpTransport {
|
|
|
118
161
|
body: JSON.stringify(body),
|
|
119
162
|
signal: AbortSignal.timeout(timeoutMs),
|
|
120
163
|
});
|
|
164
|
+
if (res.status === 401) {
|
|
165
|
+
throw new NeedsAuthError(`MCP server at ${this.url} requires authentication`, res.headers.get("www-authenticate"));
|
|
166
|
+
}
|
|
121
167
|
const sid = res.headers.get("mcp-session-id"); // initialize hands us a session to keep
|
|
122
168
|
if (sid)
|
|
123
169
|
this.sessionId = sid;
|
|
@@ -143,6 +189,8 @@ class HttpTransport {
|
|
|
143
189
|
return msg ?? { id: payload.id, error: { message: `MCP request '${payload.method}' returned no response` } };
|
|
144
190
|
}
|
|
145
191
|
catch (err) {
|
|
192
|
+
if (err instanceof NeedsAuthError)
|
|
193
|
+
throw err; // propagate so the registry can mark needs-auth
|
|
146
194
|
return { id: payload.id, error: { message: `MCP HTTP request '${payload.method}' failed: ${err.message}` } };
|
|
147
195
|
}
|
|
148
196
|
}
|
|
@@ -150,6 +198,9 @@ class HttpTransport {
|
|
|
150
198
|
// Fire-and-forget; errors on a notification are not worth surfacing.
|
|
151
199
|
void this.post({ jsonrpc: "2.0", method, params }, INIT_TIMEOUT_MS, false).catch(() => { });
|
|
152
200
|
}
|
|
201
|
+
setToken(token) {
|
|
202
|
+
this.headers["authorization"] = `Bearer ${token}`; // a refreshed token; the session id stays valid
|
|
203
|
+
}
|
|
153
204
|
close() {
|
|
154
205
|
// Best-effort: tell the server to drop the session. No await, no error care.
|
|
155
206
|
if (this.sessionId) {
|
|
@@ -158,23 +209,23 @@ class HttpTransport {
|
|
|
158
209
|
}
|
|
159
210
|
}
|
|
160
211
|
// Choose the pipe from the config: a url means HTTP, otherwise spawn over stdio.
|
|
161
|
-
function makeTransport(def) {
|
|
162
|
-
return def.url ? new HttpTransport(def) : new StdioTransport(def);
|
|
212
|
+
function makeTransport(def, token) {
|
|
213
|
+
return def.url ? new HttpTransport(def, token) : new StdioTransport(def);
|
|
163
214
|
}
|
|
164
215
|
// The protocol layer: handshake, request ids, tool flattening. It talks to a
|
|
165
216
|
// Transport and is blind to whether that's a subprocess or an HTTP endpoint.
|
|
166
217
|
class McpClient {
|
|
167
218
|
transport;
|
|
168
219
|
nextId = 1; // JSON-RPC request id counter
|
|
169
|
-
constructor(def) {
|
|
170
|
-
this.transport = makeTransport(def);
|
|
220
|
+
constructor(def, token) {
|
|
221
|
+
this.transport = makeTransport(def, token);
|
|
171
222
|
}
|
|
172
223
|
request(method, params, timeoutMs) {
|
|
173
224
|
return this.transport.request({ jsonrpc: "2.0", id: this.nextId++, method, params }, timeoutMs);
|
|
174
225
|
}
|
|
175
226
|
// The MCP startup dance: initialize → initialized → list tools.
|
|
176
227
|
async start() {
|
|
177
|
-
const init = await this.request("initialize", { protocolVersion:
|
|
228
|
+
const init = await this.request("initialize", { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO }, INIT_TIMEOUT_MS);
|
|
178
229
|
if (init.error)
|
|
179
230
|
throw new Error(init.error.message); // server refused to initialize
|
|
180
231
|
this.transport.notify("notifications/initialized", {}); // tell the server we're ready
|
|
@@ -192,46 +243,548 @@ class McpClient {
|
|
|
192
243
|
const body = text || "(no output)";
|
|
193
244
|
return res.result?.isError ? `[error] ${body}` : body; // MCP can flag a logical error in a successful response
|
|
194
245
|
}
|
|
246
|
+
// Swap the bearer token after a refresh (a no-op for stdio).
|
|
247
|
+
setToken(token) {
|
|
248
|
+
this.transport.setToken?.(token);
|
|
249
|
+
}
|
|
195
250
|
kill() {
|
|
196
251
|
this.transport.close();
|
|
197
252
|
}
|
|
198
253
|
}
|
|
254
|
+
const registry = new Map();
|
|
255
|
+
// OAuth tokens persist to the global config dir so a reconnect across sessions
|
|
256
|
+
// doesn't force the browser flow again. Plain JSON (self-hosted CLI, not a
|
|
257
|
+
// hardened credential store — Claude Code uses the OS keychain); tokens are
|
|
258
|
+
// bearer secrets — treat accordingly. Next to the tokens we keep everything a
|
|
259
|
+
// refresh needs (token endpoint, client, resource) so it never rediscovers.
|
|
260
|
+
// MINI_AGENT_OAUTH_STORE relocates the file (the test suite points it at a
|
|
261
|
+
// scratch path so a fake server's tokens never land in the real store).
|
|
262
|
+
const OAUTH_STORE_PATH = process.env.MINI_AGENT_OAUTH_STORE || path.join(os.homedir(), ".config", "mini-agent", "mcp-oauth.json");
|
|
263
|
+
const expiryFrom = (t) => (typeof t.expires_in === "number" ? Date.now() + t.expires_in * 1000 : undefined);
|
|
264
|
+
function readOAuthStore() {
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(fs.readFileSync(OAUTH_STORE_PATH, "utf8"));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return {};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function writeOAuthStore(store) {
|
|
273
|
+
fs.mkdirSync(path.dirname(OAUTH_STORE_PATH), { recursive: true });
|
|
274
|
+
fs.writeFileSync(OAUTH_STORE_PATH, JSON.stringify(store, null, 2) + "\n");
|
|
275
|
+
}
|
|
276
|
+
const oauthStore = readOAuthStore();
|
|
277
|
+
function base64url(input) {
|
|
278
|
+
return Buffer.from(input).toString("base64url");
|
|
279
|
+
}
|
|
280
|
+
function pkcePair() {
|
|
281
|
+
const verifier = base64url(crypto.randomBytes(32));
|
|
282
|
+
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
|
|
283
|
+
return { verifier, challenge };
|
|
284
|
+
}
|
|
285
|
+
// Try to open a URL in the user's browser. Best-effort: if it fails, the caller
|
|
286
|
+
// has already printed the link so the user can click it themselves. Set
|
|
287
|
+
// MINI_AGENT_NO_BROWSER=1 to only print it (an SSH session, a test).
|
|
288
|
+
function openBrowser(url) {
|
|
289
|
+
if (process.env.MINI_AGENT_NO_BROWSER)
|
|
290
|
+
return;
|
|
291
|
+
const platform = process.platform;
|
|
292
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
293
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
294
|
+
try {
|
|
295
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
296
|
+
child.on("error", () => { });
|
|
297
|
+
child.unref();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* ignore — the printed URL is the fallback */
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function fetchJson(url) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(INIT_TIMEOUT_MS) });
|
|
306
|
+
if (!res.ok)
|
|
307
|
+
return null;
|
|
308
|
+
return (await res.json());
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Ask the server for its 401 challenge without a token — the cheapest way to
|
|
315
|
+
// learn where the metadata lives when no 401 was recorded this session (a
|
|
316
|
+
// re-authenticate while still connected). Empty when the server doesn't 401.
|
|
317
|
+
async function probeChallenge(serverUrl) {
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(serverUrl, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
322
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO } }),
|
|
323
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
324
|
+
});
|
|
325
|
+
if (res.status === 401)
|
|
326
|
+
return new NeedsAuthError("", res.headers.get("www-authenticate"));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
/* unreachable server — discovery falls back to the well-known paths */
|
|
330
|
+
}
|
|
331
|
+
return {};
|
|
332
|
+
}
|
|
333
|
+
// Discover the OAuth endpoints for an MCP server: protected-resource metadata
|
|
334
|
+
// (RFC 9728) → authorization-server metadata (RFC 8414). The 401 challenge says
|
|
335
|
+
// exactly where the resource metadata lives; the well-known paths under the
|
|
336
|
+
// server's origin are a fallback for servers that don't send one. Exported so
|
|
337
|
+
// the discovery step can be exercised on its own. Returns null when the server
|
|
338
|
+
// has no OAuth metadata at all.
|
|
339
|
+
export async function discoverOAuth(serverUrl, challenge = {}) {
|
|
340
|
+
const base = new URL(serverUrl);
|
|
341
|
+
const rsPath = base.pathname === "/" ? "" : base.pathname.replace(/\/$/, "");
|
|
342
|
+
const candidates = [
|
|
343
|
+
challenge.resourceMetadataUrl, // what the server told us — authoritative
|
|
344
|
+
`${base.origin}/.well-known/oauth-protected-resource${rsPath}`, // RFC 9728 path-scoped (Linear)
|
|
345
|
+
`${base.origin}/.well-known/oauth-protected-resource`, // RFC 9728 origin-wide (Slack)
|
|
346
|
+
].filter((u) => Boolean(u));
|
|
347
|
+
let prm = null;
|
|
348
|
+
for (const url of new Set(candidates)) {
|
|
349
|
+
prm = await fetchJson(url);
|
|
350
|
+
if (prm)
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
// The resource names its authorization server(s); default to the origin. A
|
|
354
|
+
// server with a path component would need the RFC 8414 path insertion — none
|
|
355
|
+
// of the servers we target has one, so keep it simple.
|
|
356
|
+
const authServers = Array.isArray(prm?.authorization_servers) ? prm.authorization_servers : [];
|
|
357
|
+
const authServer = (authServers[0] ?? base.origin).replace(/\/$/, "");
|
|
358
|
+
// RFC 8414 first, then the OpenID Connect location some servers use instead.
|
|
359
|
+
const metadata = (await fetchJson(`${authServer}/.well-known/oauth-authorization-server`)) ?? (await fetchJson(`${authServer}/.well-known/openid-configuration`));
|
|
360
|
+
if (!metadata)
|
|
361
|
+
return null;
|
|
362
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
363
|
+
const list = (v) => (Array.isArray(v) && v.length ? v : undefined);
|
|
364
|
+
const authorizationEndpoint = str(metadata.authorization_endpoint);
|
|
365
|
+
const tokenEndpoint = str(metadata.token_endpoint);
|
|
366
|
+
if (!authorizationEndpoint || !tokenEndpoint)
|
|
367
|
+
return null;
|
|
368
|
+
return {
|
|
369
|
+
authorizationEndpoint,
|
|
370
|
+
tokenEndpoint,
|
|
371
|
+
registrationEndpoint: str(metadata.registration_endpoint),
|
|
372
|
+
scopes: challenge.scope?.split(/\s+/).filter(Boolean) ?? list(prm?.scopes_supported) ?? list(metadata.scopes_supported),
|
|
373
|
+
resource: str(prm?.resource) ?? serverUrl,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// Bind the loopback listener for the authorization-code redirect. The browser
|
|
377
|
+
// is sent to http://localhost:<port>/callback (the redirect URI Claude Code
|
|
378
|
+
// registers too, so a pre-registered Slack client id works unchanged), and
|
|
379
|
+
// "localhost" may resolve to 127.0.0.1 or ::1 depending on browser and OS — so
|
|
380
|
+
// listen on both; the IPv6 bind is best-effort. `port` 0 = any free port (the
|
|
381
|
+
// DCR case, where we register whatever we got); a fixed port is what a
|
|
382
|
+
// pre-registered client demands. The code promise is armed on creation, BEFORE
|
|
383
|
+
// the browser opens, so a fast callback can't race us.
|
|
384
|
+
async function listenForCallback(port, state) {
|
|
385
|
+
let settle;
|
|
386
|
+
const handler = (req, res) => {
|
|
387
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
388
|
+
res.setHeader("connection", "close"); // one-shot listener: a kept-alive socket would deliver a LATER flow's callback (same fixed port) to this dead handler
|
|
389
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== state)
|
|
390
|
+
return void res.writeHead(404).end(); // not ours (favicon, a stale tab…)
|
|
391
|
+
res.setHeader("content-type", "text/html");
|
|
392
|
+
res.end("<h1>Connected</h1><p>You can close this window and return to the terminal.</p>");
|
|
393
|
+
// Settle only once the page has been flushed: settling tears the servers
|
|
394
|
+
// down, and tearing down before the write lands would blank the browser tab.
|
|
395
|
+
res.once("finish", () => {
|
|
396
|
+
const code = url.searchParams.get("code");
|
|
397
|
+
if (code)
|
|
398
|
+
settle?.resolve(code);
|
|
399
|
+
else
|
|
400
|
+
settle?.reject(new Error(`OAuth callback did not include a code: ${url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "unknown error"}`));
|
|
401
|
+
});
|
|
402
|
+
};
|
|
403
|
+
const v4 = createServer(handler);
|
|
404
|
+
const bound = await new Promise((resolve, reject) => {
|
|
405
|
+
v4.once("error", (err) => reject(new Error(`Could not bind the OAuth callback on port ${port || "(any)"}: ${err.message}`)));
|
|
406
|
+
v4.listen(port, "127.0.0.1", () => {
|
|
407
|
+
const addr = v4.address();
|
|
408
|
+
resolve(addr && typeof addr === "object" ? addr.port : port);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
const v6 = createServer(handler);
|
|
412
|
+
await new Promise((resolve) => {
|
|
413
|
+
v6.once("error", () => resolve()); // no IPv6 loopback here — the v4 listener carries it
|
|
414
|
+
v6.listen(bound, "::1", () => resolve());
|
|
415
|
+
});
|
|
416
|
+
const close = () => {
|
|
417
|
+
// Release the port for real: close() alone leaves open sockets alive, and a
|
|
418
|
+
// pre-registered client's next flow needs the SAME port back immediately.
|
|
419
|
+
for (const s of [v4, v6]) {
|
|
420
|
+
s.closeAllConnections();
|
|
421
|
+
s.close();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const code = new Promise((resolve, reject) => {
|
|
425
|
+
const timer = setTimeout(() => {
|
|
426
|
+
close();
|
|
427
|
+
reject(new Error("Timed out waiting for OAuth authorization in the browser"));
|
|
428
|
+
}, OAUTH_CALLBACK_TIMEOUT_MS);
|
|
429
|
+
settle = {
|
|
430
|
+
resolve: (c) => (clearTimeout(timer), close(), resolve(c)),
|
|
431
|
+
reject: (e) => (clearTimeout(timer), close(), reject(e)),
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
return { port: bound, close, code };
|
|
435
|
+
}
|
|
436
|
+
// Run the OAuth authorization-code flow for an HTTP server: PKCE always;
|
|
437
|
+
// dynamic client registration (RFC 7591) unless the config carries a
|
|
438
|
+
// pre-registered client; a loopback redirect; then the code → token exchange
|
|
439
|
+
// with the `resource` indicator (RFC 8707). When onUrl is provided, the
|
|
440
|
+
// authorization URL is handed back so the UI can print it in case the browser
|
|
441
|
+
// doesn't open. Resolves with everything the store needs to reconnect + refresh.
|
|
442
|
+
async function runOAuthFlow(def, challenge, onUrl) {
|
|
443
|
+
if (!def.url)
|
|
444
|
+
throw new Error("OAuth is only available for http MCP servers");
|
|
445
|
+
const discovery = await discoverOAuth(def.url, challenge);
|
|
446
|
+
if (!discovery)
|
|
447
|
+
throw new Error(`No OAuth metadata found for ${def.url}`);
|
|
448
|
+
const pkce = pkcePair();
|
|
449
|
+
const state = base64url(crypto.randomBytes(16));
|
|
450
|
+
// The redirect URI must match what the client registered: for DCR we register
|
|
451
|
+
// whatever port we were given; a pre-registered client (Slack) fixes the port.
|
|
452
|
+
const listener = await listenForCallback(def.oauth?.callbackPort ?? 0, state);
|
|
453
|
+
listener.code.catch(() => { }); // settled later; never an unhandled rejection if we throw before awaiting it
|
|
454
|
+
const redirectUri = `http://localhost:${listener.port}/callback`;
|
|
455
|
+
let clientId = def.oauth?.clientId ?? ""; // a pre-registered client skips DCR
|
|
456
|
+
let clientSecret = def.oauth?.clientSecret;
|
|
457
|
+
if (!clientId && discovery.registrationEndpoint) {
|
|
458
|
+
// Dynamic client registration — most MCP servers register a public client on the spot.
|
|
459
|
+
const regRes = await fetch(discovery.registrationEndpoint, {
|
|
460
|
+
method: "POST",
|
|
461
|
+
headers: { "content-type": "application/json" },
|
|
462
|
+
body: JSON.stringify({
|
|
463
|
+
redirect_uris: [redirectUri],
|
|
464
|
+
token_endpoint_auth_method: "none",
|
|
465
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
466
|
+
response_types: ["code"],
|
|
467
|
+
client_name: "mini-agent",
|
|
468
|
+
}),
|
|
469
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
470
|
+
}).catch((err) => {
|
|
471
|
+
throw new Error(`OAuth client registration failed for ${def.url}: ${err.message}`);
|
|
472
|
+
});
|
|
473
|
+
if (regRes.ok) {
|
|
474
|
+
const reg = (await regRes.json());
|
|
475
|
+
clientId = reg.client_id ?? "";
|
|
476
|
+
clientSecret = reg.client_secret;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!clientId) {
|
|
480
|
+
listener.close();
|
|
481
|
+
throw new Error(discovery.registrationEndpoint
|
|
482
|
+
? `OAuth client registration failed for ${def.url}`
|
|
483
|
+
: `${def.url} offers no dynamic client registration — set mcpServers.<name>.oauth.clientId (and callbackPort) to a pre-registered client`);
|
|
484
|
+
}
|
|
485
|
+
const authUrl = new URL(discovery.authorizationEndpoint);
|
|
486
|
+
authUrl.searchParams.set("response_type", "code");
|
|
487
|
+
authUrl.searchParams.set("client_id", clientId);
|
|
488
|
+
authUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
489
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
490
|
+
authUrl.searchParams.set("redirect_uri", redirectUri);
|
|
491
|
+
authUrl.searchParams.set("state", state);
|
|
492
|
+
authUrl.searchParams.set("resource", discovery.resource); // RFC 8707: bind the token to THIS server
|
|
493
|
+
const scopes = def.oauth?.scopes ?? discovery.scopes; // an explicit config list wins over what the server advertises
|
|
494
|
+
if (scopes?.length)
|
|
495
|
+
authUrl.searchParams.set("scope", scopes.join(" "));
|
|
496
|
+
onUrl?.(authUrl.href);
|
|
497
|
+
openBrowser(authUrl.href);
|
|
498
|
+
const code = await listener.code; // the listener closes itself once settled
|
|
499
|
+
const params = new URLSearchParams({
|
|
500
|
+
grant_type: "authorization_code",
|
|
501
|
+
code,
|
|
502
|
+
code_verifier: pkce.verifier,
|
|
503
|
+
redirect_uri: redirectUri,
|
|
504
|
+
client_id: clientId,
|
|
505
|
+
resource: discovery.resource,
|
|
506
|
+
});
|
|
507
|
+
if (clientSecret)
|
|
508
|
+
params.set("client_secret", clientSecret);
|
|
509
|
+
const tokenRes = await fetch(discovery.tokenEndpoint, {
|
|
510
|
+
method: "POST",
|
|
511
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
512
|
+
body: params.toString(),
|
|
513
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
514
|
+
});
|
|
515
|
+
if (!tokenRes.ok) {
|
|
516
|
+
throw new Error(`OAuth token exchange failed: HTTP ${tokenRes.status} ${(await tokenRes.text()).slice(0, 200)}`);
|
|
517
|
+
}
|
|
518
|
+
const tokens = (await tokenRes.json());
|
|
519
|
+
if (!tokens.access_token)
|
|
520
|
+
throw new Error(`OAuth token exchange returned no access_token: ${tokens.error_description ?? tokens.error ?? "unknown error"}`);
|
|
521
|
+
return {
|
|
522
|
+
accessToken: tokens.access_token,
|
|
523
|
+
refreshToken: tokens.refresh_token,
|
|
524
|
+
expiresAt: expiryFrom(tokens),
|
|
525
|
+
clientId,
|
|
526
|
+
clientSecret,
|
|
527
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
528
|
+
resource: discovery.resource,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function entryFor(name) {
|
|
532
|
+
let entry = registry.get(name);
|
|
533
|
+
if (!entry) {
|
|
534
|
+
const def = CONFIG.mcpServers[name];
|
|
535
|
+
if (!def)
|
|
536
|
+
throw new Error(`No MCP server named "${name}"`);
|
|
537
|
+
entry = { def, status: "failed", tools: 0, toolNames: [] };
|
|
538
|
+
registry.set(name, entry);
|
|
539
|
+
}
|
|
540
|
+
return entry;
|
|
541
|
+
}
|
|
542
|
+
function registerTools(name, specs, client) {
|
|
543
|
+
for (const spec of specs) {
|
|
544
|
+
const toolName = `mcp__${name}__${spec.name}`; // namespaced, collision-proof
|
|
545
|
+
const tool = {
|
|
546
|
+
definition: {
|
|
547
|
+
type: "function",
|
|
548
|
+
function: {
|
|
549
|
+
name: toolName,
|
|
550
|
+
description: `[MCP:${name}] ${spec.description ?? spec.name}`, // mark the origin in the manual
|
|
551
|
+
parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
// dispatch routes here; the permission gate ran first. A 401 mid-session
|
|
555
|
+
// (the access token expired or was revoked) gets ONE silent refresh and a
|
|
556
|
+
// retry; if that fails the server is flagged needs-auth and the model gets
|
|
557
|
+
// a readable error pointing at /mcp auth — a tool result never throws.
|
|
558
|
+
run: async (args) => {
|
|
559
|
+
try {
|
|
560
|
+
return await client.callTool(spec.name, args);
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
if (!(err instanceof NeedsAuthError))
|
|
564
|
+
throw err; // dispatch turns any other crash into text
|
|
565
|
+
const fresh = await refreshAccessToken(name);
|
|
566
|
+
if (fresh) {
|
|
567
|
+
client.setToken(fresh);
|
|
568
|
+
try {
|
|
569
|
+
return await client.callTool(spec.name, args);
|
|
570
|
+
}
|
|
571
|
+
catch (again) {
|
|
572
|
+
if (!(again instanceof NeedsAuthError))
|
|
573
|
+
throw again;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
markNeedsAuth(name, err);
|
|
577
|
+
return `[error] MCP server "${name}" needs authentication — run /mcp auth ${name}`;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
|
|
582
|
+
}
|
|
583
|
+
return specs.length;
|
|
584
|
+
}
|
|
585
|
+
// Flag a server as needing the browser flow and remember the challenge so
|
|
586
|
+
// /mcp auth knows where the OAuth metadata lives. Its tools stay registered:
|
|
587
|
+
// they answer with the readable error until the user authenticates.
|
|
588
|
+
function markNeedsAuth(name, challenge) {
|
|
589
|
+
const entry = entryFor(name);
|
|
590
|
+
entry.status = "needs-auth";
|
|
591
|
+
entry.error = challenge.message;
|
|
592
|
+
entry.challenge = challenge;
|
|
593
|
+
}
|
|
594
|
+
// Connect a single configured server and register its tools. Updates the
|
|
595
|
+
// registry with the live status. Used by connectMcpServers() at startup and by
|
|
596
|
+
// /mcp reconnect / enable / auth.
|
|
597
|
+
async function connectOne(name, retried = false) {
|
|
598
|
+
const entry = entryFor(name);
|
|
599
|
+
// Unregister any stale tools from a previous connection.
|
|
600
|
+
for (const n of entry.toolNames)
|
|
601
|
+
unregisterExternalTool(n);
|
|
602
|
+
entry.toolNames = [];
|
|
603
|
+
entry.client?.kill();
|
|
604
|
+
entry.client = undefined;
|
|
605
|
+
// A stored token we KNOW is expired is refreshed before connecting rather
|
|
606
|
+
// than burning a 401 round trip on it.
|
|
607
|
+
let stored = oauthStore[name];
|
|
608
|
+
if (stored?.refreshToken && stored.expiresAt && stored.expiresAt <= Date.now()) {
|
|
609
|
+
await refreshAccessToken(name);
|
|
610
|
+
stored = oauthStore[name];
|
|
611
|
+
}
|
|
612
|
+
const client = new McpClient(entry.def, stored?.accessToken);
|
|
613
|
+
try {
|
|
614
|
+
const specs = await client.start(); // handshake + discover
|
|
615
|
+
entry.client = client;
|
|
616
|
+
entry.tools = registerTools(name, specs, client);
|
|
617
|
+
entry.toolNames = specs.map((s) => `mcp__${name}__${s.name}`);
|
|
618
|
+
entry.status = "connected";
|
|
619
|
+
entry.error = undefined;
|
|
620
|
+
emit("agent_mcp_connected", { server: name, tools: specs.length, transport: entry.def.url ? "http" : "stdio" });
|
|
621
|
+
console.log(chalk.dim(`(mcp: ${name} — ${specs.length} tools${entry.def.url ? " over http" : ""})`)); // visible at startup / on reconnect
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
client.kill(); // don't leak the process/session
|
|
625
|
+
if (err instanceof NeedsAuthError) {
|
|
626
|
+
// A stale token (expired early, revoked): one refresh + retry before giving up.
|
|
627
|
+
if (!retried && stored?.refreshToken && (await refreshAccessToken(name)))
|
|
628
|
+
return connectOne(name, true);
|
|
629
|
+
markNeedsAuth(name, err);
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
entry.status = "failed";
|
|
633
|
+
entry.error = err.message;
|
|
634
|
+
}
|
|
635
|
+
emit("agent_mcp_failed", { server: name });
|
|
636
|
+
console.error(chalk.yellow(` [mcp] ${name} ${entry.status === "needs-auth" ? `needs authentication (run /mcp auth ${name})` : "failed to start"}: ${err.message}`));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
199
639
|
// Connect to every configured MCP server, register their tools, and return a
|
|
200
640
|
// cleanup function. Failures are isolated: a server that won't start is logged
|
|
201
641
|
// and skipped — it never stops the agent or the other servers.
|
|
202
642
|
export async function connectMcpServers() {
|
|
203
|
-
const entries = Object.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
643
|
+
const entries = Object.keys(CONFIG.mcpServers); // configured server names
|
|
644
|
+
for (const name of entries) {
|
|
645
|
+
await connectOne(name); // sequential: a slow start doesn't starve the next one
|
|
646
|
+
}
|
|
647
|
+
return () => {
|
|
648
|
+
for (const entry of registry.values())
|
|
649
|
+
entry.client?.kill(); // cleanup on exit
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
// ---- /mcp command support ----------------------------------------------------
|
|
653
|
+
export function listMcpServers() {
|
|
654
|
+
// Include every CONFIGURED server, even ones never attempted this session.
|
|
655
|
+
return Object.entries(CONFIG.mcpServers).map(([name, def]) => {
|
|
656
|
+
const entry = registry.get(name);
|
|
657
|
+
return {
|
|
658
|
+
name,
|
|
659
|
+
transport: def.url ? "http" : "stdio",
|
|
660
|
+
status: entry?.status ?? "failed",
|
|
661
|
+
tools: entry?.tools ?? 0,
|
|
662
|
+
error: entry?.error,
|
|
663
|
+
authenticated: Boolean(oauthStore[name]?.accessToken),
|
|
664
|
+
url: def.url,
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// Which actions make sense for a server in a given state. Mirrors Claude Code's
|
|
669
|
+
// per-server menu: authenticate when it needs auth, reconnect when it isn't,
|
|
670
|
+
// enable/disable to toggle it off/on.
|
|
671
|
+
export function mcpActionsFor(info) {
|
|
672
|
+
if (info.status === "disabled") {
|
|
673
|
+
return [{ label: "Enable", action: "enable" }];
|
|
674
|
+
}
|
|
675
|
+
const out = [];
|
|
676
|
+
if (info.transport === "http") {
|
|
677
|
+
out.push({ label: info.authenticated ? "Re-authenticate" : "Authenticate", action: "authenticate" });
|
|
678
|
+
}
|
|
679
|
+
out.push({ label: "Reconnect", action: "reconnect" });
|
|
680
|
+
out.push({ label: "Disable", action: "disable" });
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
// Trade the stored refresh token for a new access token, at the token endpoint
|
|
684
|
+
// recorded when the user authenticated — no rediscovery. A definitive rejection
|
|
685
|
+
// (400/401: the refresh token itself is dead) wipes the record so the UI tells
|
|
686
|
+
// the truth: the user has to authenticate again. Network trouble keeps it.
|
|
687
|
+
// Returns the new access token, or undefined when there is none to be had.
|
|
688
|
+
async function refreshAccessToken(name) {
|
|
689
|
+
const stored = oauthStore[name];
|
|
690
|
+
if (!stored?.refreshToken || !stored.tokenEndpoint)
|
|
691
|
+
return undefined;
|
|
692
|
+
const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: stored.refreshToken });
|
|
693
|
+
if (stored.clientId)
|
|
694
|
+
params.set("client_id", stored.clientId);
|
|
695
|
+
if (stored.clientSecret)
|
|
696
|
+
params.set("client_secret", stored.clientSecret);
|
|
697
|
+
if (stored.resource)
|
|
698
|
+
params.set("resource", stored.resource);
|
|
699
|
+
let res;
|
|
700
|
+
try {
|
|
701
|
+
res = await fetch(stored.tokenEndpoint, {
|
|
702
|
+
method: "POST",
|
|
703
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
704
|
+
body: params.toString(),
|
|
705
|
+
signal: AbortSignal.timeout(INIT_TIMEOUT_MS),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
return undefined; // offline / timeout — keep the record, it may work next time
|
|
710
|
+
}
|
|
711
|
+
if (res.status === 400 || res.status === 401) {
|
|
712
|
+
delete oauthStore[name]; // invalid_grant: the refresh token is gone for good
|
|
713
|
+
writeOAuthStore(oauthStore);
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
if (!res.ok)
|
|
717
|
+
return undefined; // a server hiccup — keep the record
|
|
718
|
+
const tokens = (await res.json().catch(() => ({})));
|
|
719
|
+
if (!tokens.access_token)
|
|
720
|
+
return undefined;
|
|
721
|
+
oauthStore[name] = { ...stored, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? stored.refreshToken, expiresAt: expiryFrom(tokens) };
|
|
722
|
+
writeOAuthStore(oauthStore);
|
|
723
|
+
return tokens.access_token;
|
|
724
|
+
}
|
|
725
|
+
// Authenticate a server via browser OAuth and store the tokens. Returns a
|
|
726
|
+
// human-readable result for the UI.
|
|
727
|
+
export async function authenticateMcpServer(name, onUrl) {
|
|
728
|
+
const def = CONFIG.mcpServers[name];
|
|
729
|
+
if (!def)
|
|
730
|
+
return `No MCP server named "${name}"`;
|
|
731
|
+
if (!def.url)
|
|
732
|
+
return `${name} is a stdio server — it needs no OAuth authentication`;
|
|
733
|
+
// Where the OAuth metadata lives: the last 401 told us; if none was seen this
|
|
734
|
+
// session (re-authenticating while connected), ask the server now.
|
|
735
|
+
const entry = entryFor(name);
|
|
736
|
+
const challenge = entry.challenge ?? (await probeChallenge(def.url));
|
|
737
|
+
oauthStore[name] = await runOAuthFlow(def, challenge, onUrl);
|
|
738
|
+
writeOAuthStore(oauthStore);
|
|
739
|
+
// Immediately reconnect with the fresh token so the server shows connected.
|
|
740
|
+
await connectOne(name);
|
|
741
|
+
const status = entry.status;
|
|
742
|
+
return status === "connected"
|
|
743
|
+
? `Authentication successful. Connected to ${name}.`
|
|
744
|
+
: `Authentication done, but ${name} is ${status} — use /mcp reconnect ${name} to retry.`;
|
|
745
|
+
}
|
|
746
|
+
// Disconnect and reconnect a single server (re-registering its tools). A stale
|
|
747
|
+
// stored token is refreshed inside connectOne, so this is just: connect again.
|
|
748
|
+
export async function reconnectMcpServer(name) {
|
|
749
|
+
const def = CONFIG.mcpServers[name];
|
|
750
|
+
if (!def)
|
|
751
|
+
return `No MCP server named "${name}"`;
|
|
752
|
+
await connectOne(name);
|
|
753
|
+
const info = listMcpServers().find((s) => s.name === name);
|
|
754
|
+
return info.status === "connected"
|
|
755
|
+
? `Connected to ${name} (${info.tools} tool${info.tools === 1 ? "" : "s"}).`
|
|
756
|
+
: `${name} is ${info.status}: ${info.error ?? "unknown error"}`;
|
|
757
|
+
}
|
|
758
|
+
// Toggle a server off/on (session-only — the config file is not rewritten).
|
|
759
|
+
export async function disableMcpServer(name) {
|
|
760
|
+
const entry = entryFor(name);
|
|
761
|
+
for (const n of entry.toolNames)
|
|
762
|
+
unregisterExternalTool(n);
|
|
763
|
+
entry.toolNames = [];
|
|
764
|
+
entry.client?.kill();
|
|
765
|
+
entry.client = undefined;
|
|
766
|
+
entry.status = "disabled";
|
|
767
|
+
entry.error = undefined;
|
|
768
|
+
return `Disabled ${name}.`;
|
|
769
|
+
}
|
|
770
|
+
export async function enableMcpServer(name) {
|
|
771
|
+
const entry = entryFor(name);
|
|
772
|
+
if (entry.status !== "disabled")
|
|
773
|
+
return `${name} is not disabled.`;
|
|
774
|
+
await connectOne(name);
|
|
775
|
+
const info = listMcpServers().find((s) => s.name === name);
|
|
776
|
+
return info.status === "connected" ? `Enabled ${name}.` : `${name} is ${info.status}: ${info.error ?? "unknown error"}`;
|
|
777
|
+
}
|
|
778
|
+
// Run one action from mcpActionsFor and return the note to display.
|
|
779
|
+
export async function runMcpAction(name, action, onUrl) {
|
|
780
|
+
switch (action) {
|
|
781
|
+
case "authenticate":
|
|
782
|
+
return authenticateMcpServer(name, onUrl);
|
|
783
|
+
case "reconnect":
|
|
784
|
+
return reconnectMcpServer(name);
|
|
785
|
+
case "disable":
|
|
786
|
+
return disableMcpServer(name);
|
|
787
|
+
case "enable":
|
|
788
|
+
return enableMcpServer(name);
|
|
235
789
|
}
|
|
236
|
-
return () => clients.forEach((c) => c.kill()); // the caller calls this on exit
|
|
237
790
|
}
|