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/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: "2024-11-05", capabilities: {}, clientInfo: { name: "mini-agent", version: "0" } }, INIT_TIMEOUT_MS);
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.entries(CONFIG.mcpServers); // [name, def] pairs
204
- if (!entries.length)
205
- return () => { }; // the common case: nothing configured
206
- const clients = []; // for cleanup
207
- for (const [serverName, def] of entries) {
208
- const client = new McpClient(def);
209
- try {
210
- const specs = await client.start(); // handshake + discover
211
- clients.push(client);
212
- for (const spec of specs) {
213
- const toolName = `mcp__${serverName}__${spec.name}`; // namespaced, collision-proof
214
- const tool = {
215
- definition: {
216
- type: "function",
217
- function: {
218
- name: toolName,
219
- description: `[MCP:${serverName}] ${spec.description ?? spec.name}`, // mark the origin in the manual
220
- parameters: spec.inputSchema ?? { type: "object", properties: {} }, // pass the server's schema straight through
221
- },
222
- },
223
- run: async (args) => client.callTool(spec.name, args), // dispatch routes here; the permission gate ran first
224
- };
225
- registerExternalTool(tool); // now indistinguishable from a built-in to the rest of the system
226
- }
227
- emit("agent_mcp_connected", { server: serverName, tools: specs.length, transport: def.url ? "http" : "stdio" });
228
- console.log(chalk.dim(`(mcp: ${serverName} — ${specs.length} tools${def.url ? " over http" : ""})`)); // visible at startup
229
- }
230
- catch (err) {
231
- client.kill(); // don't leak the process/session
232
- emit("agent_mcp_failed", { server: serverName });
233
- console.error(chalk.yellow(` [mcp] ${serverName} failed to start (skipped): ${err.message}`)); // loud, but not fatal
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
  }