agent-from-zero 0.33.1 → 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 CHANGED
@@ -8,10 +8,11 @@ import { runLoop, TerminateReason, MAX_RETRIES, killAllSubAgents } from "./loop.
8
8
  import { buildSystemMessage } from "./prompt.js"; // the constitution + optional AGENT.md project memory
9
9
  import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
10
10
  import { compactHistory, estimateHistoryTokens, COMPACT_AT } from "./context.js"; // for the manual /compact command
11
- import { newSessionId, saveSession, latestSession, listSessions, loadSession } from "./session.js"; // conversation persistence (project-local) + the /resume picker
11
+ import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
12
+ import { generateSessionTitle } from "./title.js"; // concise session name, generated after the first message
12
13
  import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
13
14
  import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
14
- import { connectMcpServers } from "./mcp.js"; // external tool servers (MCP)
15
+ import { connectMcpServers, listMcpServers, mcpActionsFor, runMcpAction } from "./mcp.js"; // external tool servers (MCP) + /mcp
15
16
  import { Judge } from "./judge.js"; // optional LLM permission classifier
16
17
  import { isPlanMode, setPlanMode } from "./permissions.js"; // plan mode: research-only until the user approves a plan
17
18
  import { undoLast, clearUndo, sessionChanges } from "./undo.js"; // /undo + /diff: take back, or review, this session's writes
@@ -71,6 +72,7 @@ const SESSION_HELP = `commands:
71
72
  /stats event counts for this session (local telemetry — nothing leaves this machine)
72
73
  /memory show the durable facts the agent remembers about this project
73
74
  /cost tokens, cache hit rate and estimated spend this session (local)
75
+ /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
74
76
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
75
77
  /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
76
78
  /bg list background tasks this session (run_bash_background) and their status
@@ -201,6 +203,8 @@ async function main() {
201
203
  let running = false; // is a task currently executing?
202
204
  let interrupted = false; // has the current task been interrupted?
203
205
  let controller = new AbortController(); // aborts the in-flight API request
206
+ let titleAttempted = false; // one-shot: name the session once, after the first real message
207
+ let pendingTitle; // the generated title, passed into the next save
204
208
  // ---- One-shot print mode -------------------------------------------------------
205
209
  // The same loop, the same permission gate (fail closed without a TTY), no REPL.
206
210
  // Designed for scripts: the exit code distinguishes success from every failure.
@@ -409,6 +413,51 @@ async function main() {
409
413
  }
410
414
  apply(models[choice]); // picker is session-only too; "/model save <name>" to persist
411
415
  };
416
+ // /mcp — list configured MCP servers + status, then manage one (like Claude Code).
417
+ const mcpStatusText = (status) => status === "connected" ? chalk.green("connected") : status === "needs-auth" ? chalk.yellow("needs auth") : status === "failed" ? chalk.red("failed") : chalk.dim("disabled");
418
+ const handleMcpCommand = async (arg) => {
419
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
420
+ if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
421
+ const name = parts.slice(1).join(" ");
422
+ const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
423
+ try {
424
+ console.log(chalk.dim(await runMcpAction(name, action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
425
+ }
426
+ catch (err) {
427
+ console.log(chalk.yellow(err.message));
428
+ }
429
+ return;
430
+ }
431
+ const servers = listMcpServers();
432
+ if (!servers.length) {
433
+ console.log(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
434
+ return;
435
+ }
436
+ console.log(chalk.dim("MCP servers:"));
437
+ const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s.status)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
438
+ for (const label of labels)
439
+ console.log(chalk.dim(` ${label}`));
440
+ if (!process.stdin.isTTY)
441
+ return;
442
+ const choice = await promptSelect(rl, labels.map((l) => l.replace(/ · /g, " — ")));
443
+ if (choice < 0) {
444
+ console.log(chalk.dim("(cancelled)"));
445
+ return;
446
+ }
447
+ const server = servers[choice];
448
+ const actions = mcpActionsFor(server);
449
+ const actionChoice = await promptSelect(rl, actions.map((a) => a.label));
450
+ if (actionChoice < 0) {
451
+ console.log(chalk.dim("(cancelled)"));
452
+ return;
453
+ }
454
+ try {
455
+ console.log(chalk.dim(await runMcpAction(server.name, actions[actionChoice].action, (url) => console.log(chalk.dim(`authenticate in your browser: ${url}`)))));
456
+ }
457
+ catch (err) {
458
+ console.log(chalk.yellow(err.message));
459
+ }
460
+ };
412
461
  // Handle a /slash command. Returns true if the line was a command.
413
462
  const handleCommand = async (line) => {
414
463
  // /model takes an optional argument (/model <name>), so it can't be a plain
@@ -442,6 +491,11 @@ async function main() {
442
491
  console.log(chalk.yellow(`\n⚠️ ${EXIT_NOTES[r.reason]}`));
443
492
  return true;
444
493
  }
494
+ // /mcp [reconnect|auth|enable|disable <name>]
495
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
496
+ await handleMcpCommand(line.slice("/mcp".length).trim());
497
+ return true;
498
+ }
445
499
  switch (line) {
446
500
  case "/skills": {
447
501
  if (!skills.length) {
@@ -468,6 +522,8 @@ async function main() {
468
522
  clearReasoning(); // drop the collapsed thinking too
469
523
  clearToolCalls(); // ...and the folded tool-call trace
470
524
  sessionId = newSessionId(); // a fresh conversation is a fresh session file
525
+ titleAttempted = false; // a fresh session gets its own title on its first message
526
+ pendingTitle = undefined;
471
527
  console.log(chalk.dim("(history cleared)")); // confirm the reset
472
528
  return true;
473
529
  case "/todos": {
@@ -589,6 +645,8 @@ async function main() {
589
645
  }
590
646
  messages = [{ role: "system", content: systemMessage }, ...chosen.messages]; // fresh constitution + saved turns
591
647
  sessionId = chosen.id; // keep appending to the resumed session's file
648
+ titleAttempted = false; // a resumed session without a title gets one on its next message
649
+ pendingTitle = chosen.title;
592
650
  forgetFilesExcept([]); // a resumed conversation must re-read files before editing them
593
651
  clearUndo(); // the previous session's writes are not ours to undo
594
652
  clearTodos(); // the resumed task starts without the old session's stale plan
@@ -713,6 +771,22 @@ async function main() {
713
771
  console.log(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
714
772
  if (refused.length)
715
773
  console.log(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
774
+ // Name the session once, after the first real user message. Same as the Ink
775
+ // REPL: a cheap model call distills the prompt into a 3-7 word title, fired
776
+ // and forgotten so it never delays the turn; a failure leaves the raw prompt.
777
+ if (!titleAttempted) {
778
+ titleAttempted = true;
779
+ if (!loadSession(sessionId)?.title) {
780
+ void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, line)
781
+ .then((title) => {
782
+ if (title) {
783
+ pendingTitle = title;
784
+ setSessionTitle(sessionId, title);
785
+ }
786
+ })
787
+ .catch(() => { });
788
+ }
789
+ }
716
790
  messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
717
791
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
718
792
  clearToolCalls(); // ...and Ctrl+T should reveal THIS turn's tool calls
@@ -730,7 +804,7 @@ async function main() {
730
804
  judge, // optional LLM classifier for the "ask" middle ground
731
805
  });
732
806
  running = false; // back at the prompt — Ctrl+C means "exit" again
733
- saveSession(sessionId, CONFIG.model, messages); // snapshot after every turn — crash-safe by construction
807
+ saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
734
808
  // Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
735
809
  // that saves durable facts — especially user corrections — so memory
736
810
  // captures itself instead of relying on the model to call `remember`.
package/dist/ink/app.js CHANGED
@@ -17,7 +17,8 @@ import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as
17
17
  import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
18
18
  import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
19
19
  import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
20
- import { newSessionId, saveSession, listSessions, loadSession } from "../session.js";
20
+ import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
21
+ import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
21
22
  import { isPlanMode, setPlanMode } from "../permissions.js";
22
23
  import { findSkill, skillInstructions } from "../skills.js";
23
24
  import { extractMemories } from "../memory.js";
@@ -25,7 +26,8 @@ import { displayWidth } from "../editor.js"; // display-width measurement (CJK-a
25
26
  import { runHooks } from "../hooks.js";
26
27
  import { emit } from "../telemetry.js";
27
28
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
28
- import { runInfoCommand } from "./commands.js"; // the non-interactive slash commands
29
+ import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
30
+ import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
29
31
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
30
32
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
31
33
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
@@ -117,6 +119,8 @@ export function App({ session, runTurn }) {
117
119
  const sink = useRef(makeInkSink({ setStatus, setLive, pushItem })).current;
118
120
  const history = useRef([]).current; // past prompts, for ↑/↓ recall
119
121
  const turn = useRef(null); // the in-flight turn, for Esc-interrupt
122
+ const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
123
+ const pendingTitle = useRef(undefined); // the generated title, passed into the next save
120
124
  useEffect(() => {
121
125
  const id = setInterval(() => setTick((t) => t + 1), 1000);
122
126
  return () => clearInterval(id);
@@ -164,7 +168,7 @@ export function App({ session, runTurn }) {
164
168
  .then(async (result) => {
165
169
  if (result.reason !== TerminateReason.Done)
166
170
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
167
- saveSession(sessionId, model, messages); // snapshot after every turn — crash-safe by construction
171
+ saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
168
172
  if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
169
173
  try {
170
174
  const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
@@ -225,15 +229,80 @@ export function App({ session, runTurn }) {
225
229
  note(chalk.dim(`(attached ${attached.length} file${attached.length === 1 ? "" : "s"}: ${attached.join(", ")})`));
226
230
  if (refused.length)
227
231
  note(chalk.yellow(`(refused secret file${refused.length === 1 ? "" : "s"}: ${refused.join(", ")})`));
232
+ // Name the session once, after the first real user message. A cheap model
233
+ // call distills the prompt into a 3-7 word title (like Claude Code's aiTitle);
234
+ // it runs fire-and-forget so it never delays the turn, and a failure just
235
+ // leaves the raw first prompt as the title. Resumed sessions that already
236
+ // have a title are left alone.
237
+ if (!titleAttempted.current) {
238
+ titleAttempted.current = true;
239
+ if (!loadSession(sessionId)?.title) {
240
+ void generateSessionTitle(client, CONFIG.subAgentModel || CONFIG.model, text)
241
+ .then((title) => {
242
+ if (title) {
243
+ pendingTitle.current = title;
244
+ setSessionTitle(sessionId, title);
245
+ }
246
+ })
247
+ .catch(() => { });
248
+ }
249
+ }
228
250
  runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
229
251
  };
230
252
  // Handle a /slash command. Returns true if the line was a command (handled).
253
+ // /mcp — list configured servers + status, then pick one to manage it.
254
+ const handleMcpCommand = async (arg) => {
255
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
256
+ // Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
257
+ if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
258
+ const name = parts.slice(1).join(" ");
259
+ setBusy(true);
260
+ try {
261
+ const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
262
+ const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
263
+ note(chalk.dim(msg));
264
+ }
265
+ catch (err) {
266
+ note(chalk.yellow(err.message));
267
+ }
268
+ finally {
269
+ setBusy(false);
270
+ }
271
+ return;
272
+ }
273
+ const servers = listMcpServers();
274
+ if (!servers.length) {
275
+ note(chalk.dim("(no MCP servers configured — add an mcpServers map to .mini-agent/settings.json or ~/.config/mini-agent/settings.json)"));
276
+ return;
277
+ }
278
+ const labels = servers.map((s) => `${s.name} · ${mcpStatusText(s)}${s.tools ? ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` : ""}${s.transport === "http" ? " · http" : " · stdio"}`);
279
+ openSelect("MCP servers (pick one to manage):", labels, (i) => {
280
+ if (i < 0)
281
+ return note(chalk.dim("(cancelled)"));
282
+ const server = servers[i];
283
+ const actions = mcpActionsFor(server);
284
+ openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
285
+ if (j < 0)
286
+ return note(chalk.dim("(cancelled)"));
287
+ setBusy(true);
288
+ runMcpAction(server.name, actions[j].action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)))
289
+ .then((msg) => note(chalk.dim(msg)))
290
+ .catch((err) => note(chalk.yellow(err.message)))
291
+ .finally(() => setBusy(false));
292
+ });
293
+ });
294
+ };
231
295
  const handleCommand = async (line) => {
232
296
  const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
233
297
  if (info !== null) {
234
298
  note(info);
235
299
  return true;
236
300
  }
301
+ // /mcp [reconnect|auth|enable|disable <name>]
302
+ if (line === "/mcp" || line.startsWith("/mcp ")) {
303
+ await handleMcpCommand(line.slice("/mcp".length).trim());
304
+ return true;
305
+ }
237
306
  // /model [name|save <name>]
238
307
  if (line === "/model" || line.startsWith("/model ")) {
239
308
  const arg = line.slice("/model".length).trim();
@@ -301,6 +370,8 @@ export function App({ session, runTurn }) {
301
370
  clearReasoning();
302
371
  clearToolCalls();
303
372
  setSessionId(newSessionId());
373
+ titleAttempted.current = false; // a fresh session gets its own title on its first message
374
+ pendingTitle.current = undefined;
304
375
  note(chalk.dim("(history cleared)"));
305
376
  return true;
306
377
  case "/plan":
@@ -349,6 +420,8 @@ export function App({ session, runTurn }) {
349
420
  messages.length = 0;
350
421
  messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
351
422
  setSessionId(chosen.id);
423
+ titleAttempted.current = false; // a resumed session without a title gets one on its next message
424
+ pendingTitle.current = chosen.title;
352
425
  forgetFilesExcept([]);
353
426
  clearUndo();
354
427
  clearTodos();
@@ -24,6 +24,7 @@ export const SESSION_HELP = `commands:
24
24
  /stats event counts for this session (local telemetry — nothing leaves this machine)
25
25
  /memory show the durable facts the agent remembers about this project
26
26
  /cost tokens, cache hit rate and estimated spend this session (local)
27
+ /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
27
28
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
28
29
  /todos show the agent's current task plan (it maintains one with todo_write on multi-step work)
29
30
  /bg list background tasks this session (run_bash_background) and their status
@@ -40,6 +41,19 @@ export const SESSION_HELP = `commands:
40
41
  keys (at the prompt):
41
42
  Ctrl+R reveal the model's thinking for the last answer (collapsed behind a spinner by default)
42
43
  Ctrl+T reveal the folded tool-call trace for the last answer`;
44
+ // Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
45
+ export function mcpStatusText(info) {
46
+ switch (info.status) {
47
+ case "connected":
48
+ return chalk.green("connected");
49
+ case "needs-auth":
50
+ return chalk.yellow("needs auth");
51
+ case "failed":
52
+ return chalk.red("failed");
53
+ case "disabled":
54
+ return chalk.dim("disabled");
55
+ }
56
+ }
43
57
  // Run a non-interactive command. Returns the note text to display, or null if
44
58
  // `line` isn't one of these (the App handles the rest).
45
59
  export function runInfoCommand(line, ctx) {
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
  }
package/dist/session.js CHANGED
@@ -15,16 +15,17 @@ export function newSessionId() {
15
15
  // history is not append-only — compaction rewrites it — so a snapshot is the
16
16
  // only representation that is always correct. Crash mid-write leaves the
17
17
  // previous snapshot intact.
18
- export function saveSession(id, model, messages) {
18
+ export function saveSession(id, model, messages, title) {
19
19
  const dir = sessionsDir();
20
20
  fs.mkdirSync(dir, { recursive: true }); // first save creates the directory
21
21
  const file = path.join(dir, `${id}.json`); // the real destination
22
- const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt
22
+ const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : null; // to preserve startedAt + title
23
23
  const data = {
24
24
  id,
25
25
  startedAt: existing?.startedAt ?? new Date().toISOString(), // first save stamps it, later saves keep it
26
26
  savedAt: new Date().toISOString(), // always refreshed
27
27
  model, // informational — resume does not force the same model
28
+ title: title ?? existing?.title, // a generated title sticks across saves
28
29
  messages: messages.filter((m) => m.role !== "system"), // the constitution is rebuilt fresh on resume (AGENT.md may have changed)
29
30
  };
30
31
  const tmp = `${file}.tmp`; // write next to the destination (same filesystem → rename is atomic)
@@ -32,6 +33,23 @@ export function saveSession(id, model, messages) {
32
33
  fs.renameSync(tmp, file); // atomic swap
33
34
  prune(); // keep the directory bounded
34
35
  }
36
+ // Overwrite just the title of an existing session. Used when the title model
37
+ // finishes AFTER the first save — the next saveSession would otherwise drop it.
38
+ export function setSessionTitle(id, title) {
39
+ const file = path.join(sessionsDir(), `${id}.json`);
40
+ let s;
41
+ try {
42
+ s = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ }
44
+ catch {
45
+ return; // the session hasn't been saved yet — the title will land on the next save
46
+ }
47
+ s.title = title;
48
+ s.savedAt = new Date().toISOString(); // a title is a change; keep the list ordering honest
49
+ const tmp = `${file}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(s)); // atomic, same as saveSession
51
+ fs.renameSync(tmp, file);
52
+ }
35
53
  // Session files on disk, newest (most recently saved) first. Shared by every
36
54
  // reader below. Skips tmp files and strangers.
37
55
  function sessionFilesNewestFirst() {
@@ -69,7 +87,8 @@ export function loadSession(id) {
69
87
  // A one-line, human-readable title for a session: the first thing the user
70
88
  // actually typed, with any @file attachment block stripped off and the text
71
89
  // flattened to a single trimmed line. This is what makes a session list
72
- // pickable instead of a wall of timestamps.
90
+ // pickable instead of a wall of timestamps. It's the FALLBACK when no concise
91
+ // generated title has been stored (see saveSession title + setSessionTitle).
73
92
  export function sessionTitle(messages) {
74
93
  const firstUser = messages.find((m) => m.role === "user");
75
94
  let text = typeof firstUser?.content === "string" ? firstUser.content : "";
@@ -86,7 +105,7 @@ export function listSessions(limit = 10) {
86
105
  break;
87
106
  try {
88
107
  const s = JSON.parse(fs.readFileSync(path.join(sessionsDir(), f), "utf8"));
89
- out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: sessionTitle(s.messages) });
108
+ out.push({ id: s.id, savedAt: s.savedAt, model: s.model, messageCount: s.messages.length, title: s.title ?? sessionTitle(s.messages) });
90
109
  }
91
110
  catch {
92
111
  /* skip a corrupt file */
package/dist/title.js ADDED
@@ -0,0 +1,49 @@
1
+ // One cheap model call that turns the user's first prompt into a short,
2
+ // pickable session name — the same idea Claude Code uses (a Haiku-tier call
3
+ // producing a 3-7 word sentence-case title, stored as the session's aiTitle).
4
+ // It runs once, fire-and-forget, after the first real user message, so it never
5
+ // blocks the turn and a failure silently falls back to the raw first prompt.
6
+ const TITLE_SYSTEM = `Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of the user's request. The title should be clear enough that the user recognizes the session in a list. Use sentence case: capitalize only the first word and proper nouns.
7
+ Return ONLY the title — no quotes, no leading/trailing punctuation, no explanation.
8
+
9
+ Good examples:
10
+ Fix login button on mobile
11
+ Add OAuth authentication
12
+ Debug failing CI tests
13
+ Refactor API client error handling
14
+
15
+ Bad (too vague): Code changes
16
+ Bad (too long): Investigate and fix the issue where the login button does not respond on mobile devices
17
+ Bad (wrong case): Fix Login Button On Mobile`;
18
+ // Strip the model's inevitable chatter: surrounding quotes, trailing periods,
19
+ // markdown fences, and any multi-line prose. Pure + lenient on purpose.
20
+ export function cleanSessionTitle(text) {
21
+ let t = text.replace(/^```[^\n]*\n?|\n?```$/g, ""); // drop a markdown fence if present
22
+ t = t.split("\n")[0] ?? ""; // first line only
23
+ t = t.replace(/^[\s"'“”‘’]+|[\s"'“”‘’.。]+$/g, "").trim(); // strip quotes + trailing punctuation
24
+ return t.replace(/\s+/g, " ").trim();
25
+ }
26
+ // Generate a session title from the first user message. Returns null on any
27
+ // failure (bad model response, network error) — callers fall back to the prompt.
28
+ export async function generateSessionTitle(client, model, firstPrompt) {
29
+ const trimmed = firstPrompt.replace(/\s+/g, " ").trim();
30
+ if (!trimmed)
31
+ return null;
32
+ let text = "";
33
+ try {
34
+ const res = await client.chat.completions.create({
35
+ model,
36
+ messages: [
37
+ { role: "system", content: TITLE_SYSTEM },
38
+ { role: "user", content: trimmed },
39
+ ],
40
+ stream: false,
41
+ });
42
+ text = res.choices?.[0]?.message?.content ?? "";
43
+ }
44
+ catch {
45
+ return null; // a failed title must never surface or break the session
46
+ }
47
+ const title = cleanSessionTitle(text);
48
+ return title || null;
49
+ }
package/dist/tools.js CHANGED
@@ -412,6 +412,11 @@ export const tools = {
412
412
  export function registerExternalTool(tool) {
413
413
  tools[tool.definition.function.name] = tool;
414
414
  }
415
+ // Remove an external tool by name (used when an MCP server is disconnected or
416
+ // reconnected — its stale tools must never stay registered under the same name).
417
+ export function unregisterExternalTool(name) {
418
+ delete tools[name];
419
+ }
415
420
  // The manuals sent to the model. A function (not a const) since Day 15: MCP
416
421
  // tools register after module load, so the list must be computed when asked.
417
422
  export function toolDefinitions() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "eval": "tsx --env-file=.env eval/run.ts",
18
18
  "build": "tsc -p tsconfig.build.json",
19
19
  "prepare": "npm run build",
20
- "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
20
+ "test": "tsx tests/tools.test.ts && tsx tests/permissions.test.ts && tsx tests/context.test.ts && tsx tests/hooks.test.ts && tsx tests/mcp.test.ts && tsx tests/judge.test.ts && tsx tests/memory.test.ts && tsx tests/cost.test.ts && tsx tests/validate.test.ts && tsx tests/plan.test.ts && tsx tests/diff.test.ts && tsx tests/undo.test.ts && tsx tests/tiering.test.ts && tsx tests/mcp-http.test.ts && tsx tests/mcp-oauth.test.ts && tsx tests/changes.test.ts && tsx tests/mentions.test.ts && tsx tests/session.test.ts && tsx tests/ui.test.ts && tsx tests/approval.test.ts && tsx tests/form.test.ts && tsx tests/model.test.ts && tsx tests/reasoning.test.ts && tsx tests/skills.test.ts && tsx tests/markdown.test.ts && tsx tests/editor.test.ts && tsx tests/todos.test.ts && tsx tests/background.test.ts && tsx tests/team.test.ts && tsx tests/board.test.ts && tsx tests/drop.test.ts && tsx tests/editline.test.ts && tsx tests/cron.test.ts"
21
21
  },
22
22
  "engines": {
23
23
  "node": ">=20.6"