aether-code 0.8.0 → 0.10.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.
@@ -14,9 +14,44 @@ import { runRepl } from "../src/repl.js";
14
14
  import { runSetup } from "../src/setup.js";
15
15
  import { fetchBalance, AetherError } from "../src/api.js";
16
16
  import { writeConfigFile, getConfig, CONFIG_PATH } from "../src/config.js";
17
+ import { loadMcpConfig, MCPManager } from "../src/mcp.js";
18
+ import { addServer, removeServer, listServers } from "../src/mcp-cli.js";
17
19
  import { c, errorLine, divider } from "../src/render.js";
18
20
 
19
- const VERSION = "0.3.0";
21
+ const VERSION = "0.10.0";
22
+
23
+ /**
24
+ * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
25
+ * MCPManager (possibly with zero servers) or null if no config exists.
26
+ * Prints a one-line summary so the user can see what attached.
27
+ */
28
+ async function bootMcp() {
29
+ let config;
30
+ try {
31
+ config = loadMcpConfig();
32
+ } catch (e) {
33
+ console.log(errorLine(`MCP config: ${e.message}`));
34
+ return null;
35
+ }
36
+ if (!config) return null;
37
+ const manager = new MCPManager();
38
+ const started = await manager.start(config);
39
+ const requested = Object.keys(config.mcpServers).length;
40
+ const failed = manager.startErrors.length;
41
+ if (started > 0 || failed > 0) {
42
+ const parts = [`${c.cyan("MCP")}`, `${started}/${requested} servers attached`];
43
+ const toolCount = manager.getToolDefinitions().length;
44
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
45
+ console.log(c.gray(parts.join(" · ")));
46
+ for (const { serverName, error } of manager.startErrors) {
47
+ console.log(c.gray(` ${c.yellow("!")} ${serverName}: ${error}`));
48
+ }
49
+ }
50
+ // Best-effort cleanup so child processes don't leak on normal exit.
51
+ process.on("exit", () => { manager.shutdown().catch(() => {}); });
52
+ process.on("SIGINT", () => { manager.shutdown().catch(() => {}); process.exit(130); });
53
+ return manager;
54
+ }
20
55
 
21
56
  const HELP = `${c.bold("aether")} — uncensored AI coding agent
22
57
 
@@ -30,6 +65,7 @@ ${c.bold("SUBCOMMANDS")}
30
65
  ${c.cyan("logout")} Clear saved API key
31
66
  ${c.cyan("balance")} Show plan + credit balance
32
67
  ${c.cyan("config")} show|set|set-base|path Manage config file
68
+ ${c.cyan("mcp")} list|add|remove Manage MCP server connections
33
69
 
34
70
  ${c.bold("EXAMPLES")}
35
71
  aether # interactive REPL
@@ -122,13 +158,18 @@ async function main() {
122
158
  await handleBalance();
123
159
  return;
124
160
  }
161
+ if (sub === "mcp") {
162
+ await handleMcp(args._.slice(1));
163
+ return;
164
+ }
125
165
 
126
166
  const prompt = args._.join(" ").trim();
127
167
 
128
168
  // No task → drop into interactive REPL (Claude-CLI-style)
129
169
  if (!prompt) {
130
170
  if (cwd !== process.cwd()) process.chdir(cwd);
131
- await runRepl({ cwd, autoYes, maxTurns });
171
+ const mcpManager = await bootMcp();
172
+ await runRepl({ cwd, autoYes, maxTurns, mcpManager });
132
173
  return;
133
174
  }
134
175
 
@@ -144,13 +185,16 @@ async function main() {
144
185
  console.log(c.gray(`task: `) + prompt);
145
186
  console.log(divider());
146
187
 
188
+ const mcpManager = await bootMcp();
147
189
  const result = await runAgent({
148
190
  initialPrompt: prompt,
149
191
  cwd,
150
192
  autoYes,
151
193
  unsafePaths,
152
194
  maxTurns,
195
+ mcpManager,
153
196
  });
197
+ if (mcpManager) await mcpManager.shutdown().catch(() => {});
154
198
 
155
199
  console.log("\n" + divider());
156
200
  if (result.ok) {
@@ -221,6 +265,95 @@ async function handleBalance() {
221
265
  }
222
266
  }
223
267
 
268
+ async function handleMcp(rest) {
269
+ const sub = (rest[0] || "list").toLowerCase();
270
+
271
+ if (sub === "list" || sub === "ls") {
272
+ const servers = listServers();
273
+ if (servers.length === 0) {
274
+ console.log(c.gray("No MCP servers configured."));
275
+ console.log(c.gray("Add one with:"));
276
+ console.log(c.gray(" aether mcp add <name> -- <command> [args...]"));
277
+ console.log(c.gray("Example:"));
278
+ console.log(
279
+ c.gray(' aether mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /path'),
280
+ );
281
+ return;
282
+ }
283
+ console.log(c.bold(`Configured MCP servers (${servers.length}):`));
284
+ for (const [name, cfg] of servers) {
285
+ const argsStr = cfg.args && cfg.args.length > 0 ? " " + cfg.args.join(" ") : "";
286
+ console.log(` ${c.cyan(name)}: ${cfg.command}${argsStr}`);
287
+ if (cfg.env && Object.keys(cfg.env).length > 0) {
288
+ for (const [k, v] of Object.entries(cfg.env)) {
289
+ console.log(c.gray(` env ${k}=${v}`));
290
+ }
291
+ }
292
+ }
293
+ return;
294
+ }
295
+
296
+ if (sub === "add") {
297
+ // Syntax: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]
298
+ const tail = rest.slice(1);
299
+ const dashIdx = tail.indexOf("--");
300
+ if (dashIdx === -1) {
301
+ die(
302
+ 'Usage: aether mcp add <name> [--env KEY=VAL]... -- <command> [args...]\n' +
303
+ 'Example: aether mcp add fs -- npx -y @modelcontextprotocol/server-filesystem /tmp',
304
+ );
305
+ }
306
+ const pre = tail.slice(0, dashIdx);
307
+ const post = tail.slice(dashIdx + 1);
308
+ const name = pre[0];
309
+ if (!name) die("aether mcp add: missing <name>");
310
+ if (post.length === 0) die("aether mcp add: missing <command> after '--'");
311
+
312
+ const env = {};
313
+ for (let i = 1; i < pre.length; i++) {
314
+ if (pre[i] === "--env") {
315
+ const kv = pre[++i];
316
+ if (!kv) die("--env needs a KEY=VAL argument");
317
+ const eq = kv.indexOf("=");
318
+ if (eq <= 0) die(`--env value must be KEY=VAL, got: ${kv}`);
319
+ env[kv.slice(0, eq)] = kv.slice(eq + 1);
320
+ } else {
321
+ die(`aether mcp add: unrecognized option "${pre[i]}" before the '--' separator`);
322
+ }
323
+ }
324
+
325
+ const command = post[0];
326
+ const cmdArgs = post.slice(1);
327
+ try {
328
+ const entry = addServer({ name, command, args: cmdArgs, env });
329
+ console.log(`${c.green("✓")} Added MCP server "${c.cyan(name)}".`);
330
+ const argsStr = entry.args && entry.args.length > 0 ? " " + entry.args.join(" ") : "";
331
+ console.log(c.gray(` ${entry.command}${argsStr}`));
332
+ console.log(c.gray("Restart the agent (or run `aether`) to attach it."));
333
+ } catch (e) {
334
+ die(e.message || String(e));
335
+ }
336
+ return;
337
+ }
338
+
339
+ if (sub === "remove" || sub === "rm" || sub === "delete") {
340
+ const name = rest[1];
341
+ if (!name) die("aether mcp remove: missing <name>");
342
+ try {
343
+ removeServer({ name });
344
+ console.log(`${c.green("✓")} Removed MCP server "${c.cyan(name)}".`);
345
+ } catch (e) {
346
+ die(e.message || String(e));
347
+ }
348
+ return;
349
+ }
350
+
351
+ die(
352
+ `aether mcp: unknown subcommand "${sub}".\n` +
353
+ "Try one of: list, add, remove.",
354
+ );
355
+ }
356
+
224
357
  main().catch((err) => {
225
358
  console.error(errorLine(err.message || String(err)));
226
359
  if (process.env.DEBUG) console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Uncensored AI coding agent for your terminal. Type `aether` to launch the interactive REPL — like Claude Code, with no refusal layer.",
5
5
  "homepage": "https://trynoguard.com",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "node": ">=18"
23
23
  },
24
24
  "scripts": {
25
- "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js",
25
+ "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js",
26
26
  "test": "node --test \"test/**/*.test.js\""
27
27
  },
28
28
  "keywords": [
@@ -35,5 +35,8 @@
35
35
  "cli",
36
36
  "tool-use",
37
37
  "agentic"
38
- ]
38
+ ],
39
+ "dependencies": {
40
+ "@modelcontextprotocol/sdk": "^1.29.0"
41
+ }
39
42
  }
package/src/agent.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { agentTurnStream, AetherError } from "./api.js";
6
6
  import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
7
+ import { unnamespaceToolName } from "./mcp.js";
7
8
  import { c, divider, turn, toolHeader, toolResult, errorLine } from "./render.js";
8
9
 
9
10
  const DEFAULT_MAX_TURNS = 25;
@@ -16,7 +17,17 @@ export async function runAgent({
16
17
  unsafePaths = false,
17
18
  maxTurns = DEFAULT_MAX_TURNS,
18
19
  onTokens = () => {},
20
+ // Optional MCPManager. When provided, its tools are merged into the agent's
21
+ // toolset and tool calls prefixed `mcp__` are routed to it instead of the
22
+ // built-in executeTool.
23
+ mcpManager = null,
19
24
  }) {
25
+ // Merge built-in tools with MCP-provided tools. MCP tools come second so
26
+ // any name collision (unlikely given namespacing, but defense in depth)
27
+ // resolves to the built-in.
28
+ const tools = mcpManager
29
+ ? [...TOOL_DEFINITIONS, ...mcpManager.getToolDefinitions()]
30
+ : TOOL_DEFINITIONS;
20
31
  // Two callers: one-shot (initialPrompt only, fresh conversation) and REPL
21
32
  // (priorMessages + initialPrompt to continue an ongoing chat).
22
33
  const messages = priorMessages
@@ -40,7 +51,7 @@ export async function runAgent({
40
51
  try {
41
52
  res = await agentTurnStream({
42
53
  messages,
43
- tools: TOOL_DEFINITIONS,
54
+ tools,
44
55
  onDelta: (text) => {
45
56
  if (!lastWasText) {
46
57
  process.stdout.write(" ");
@@ -96,7 +107,15 @@ export async function runAgent({
96
107
  console.log("");
97
108
  console.log(toolHeader(call.function.name, args));
98
109
 
99
- const result = await executeTool(call, { cwd, autoYes, unsafePaths });
110
+ // Route to MCP if the tool name is namespaced (mcp__server__tool);
111
+ // otherwise execute the built-in tool. unnamespaceToolName returns
112
+ // null for non-MCP names, which is our cheap dispatch test.
113
+ let result;
114
+ if (mcpManager && unnamespaceToolName(call.function.name)) {
115
+ result = await mcpManager.callTool(call.function.name, args);
116
+ } else {
117
+ result = await executeTool(call, { cwd, autoYes, unsafePaths });
118
+ }
100
119
  if (result.output) {
101
120
  const preview = result.output.length > 800 ? result.output.slice(0, 800) + "\n…(truncated)" : result.output;
102
121
  console.log(toolResult(preview, result.ok));
package/src/mcp-cli.js ADDED
@@ -0,0 +1,94 @@
1
+ // `aether mcp` subcommand machinery: add / remove / list MCP servers
2
+ // in ~/.aether/mcp.json without the user hand-editing JSON.
3
+ //
4
+ // All read/write goes through this module so the validation (delegated to
5
+ // mcp.js) is consistent with what MCPManager will accept at runtime.
6
+ // configPath is injectable for tests; production code uses DEFAULT_PATH.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { validateMcpConfig } from "./mcp.js";
12
+
13
+ const DEFAULT_PATH = path.join(os.homedir(), ".aether", "mcp.json");
14
+
15
+ function resolvePath(opts) {
16
+ return opts?.configPath || DEFAULT_PATH;
17
+ }
18
+
19
+ export function readConfig(opts) {
20
+ const p = resolvePath(opts);
21
+ if (!fs.existsSync(p)) return { mcpServers: {} };
22
+ let raw;
23
+ try {
24
+ raw = fs.readFileSync(p, "utf8");
25
+ } catch (e) {
26
+ throw new Error(`MCP config at ${p} unreadable: ${e.message}`);
27
+ }
28
+ try {
29
+ const parsed = JSON.parse(raw);
30
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== "object") {
31
+ return { ...parsed, mcpServers: {} };
32
+ }
33
+ return parsed;
34
+ } catch (e) {
35
+ throw new Error(`MCP config at ${p} is not valid JSON: ${e.message}`);
36
+ }
37
+ }
38
+
39
+ function writeConfig(config, opts) {
40
+ const p = resolvePath(opts);
41
+ fs.mkdirSync(path.dirname(p), { recursive: true });
42
+ // Pretty-print so users can read + hand-edit if they want, and trailing
43
+ // newline so diff tools don't bicker.
44
+ fs.writeFileSync(p, JSON.stringify(config, null, 2) + "\n", "utf8");
45
+ }
46
+
47
+ /**
48
+ * Add a server entry. Mutates ~/.aether/mcp.json (creating it + the dir
49
+ * if missing). Validates the full resulting config via validateMcpConfig
50
+ * so a bad add can't poison the file with a config the runtime would
51
+ * later reject.
52
+ */
53
+ export function addServer({ configPath, name, command, args = [], env = {} }) {
54
+ const cfg = readConfig({ configPath });
55
+ if (cfg.mcpServers[name]) {
56
+ throw new Error(
57
+ `MCP server "${name}" already configured. Remove it first with \`aether mcp remove ${name}\` or pick a different name.`,
58
+ );
59
+ }
60
+ const entry = { command };
61
+ if (args.length > 0) entry.args = args;
62
+ if (Object.keys(env).length > 0) entry.env = env;
63
+ const next = {
64
+ ...cfg,
65
+ mcpServers: { ...cfg.mcpServers, [name]: entry },
66
+ };
67
+ validateMcpConfig(next, configPath || "<add>"); // throws on schema failure
68
+ writeConfig(next, { configPath });
69
+ return entry;
70
+ }
71
+
72
+ /**
73
+ * Remove a server entry by name. Throws if it doesn't exist (so the user
74
+ * notices typos instead of silently no-op'ing).
75
+ */
76
+ export function removeServer({ configPath, name }) {
77
+ const cfg = readConfig({ configPath });
78
+ if (!cfg.mcpServers[name]) {
79
+ throw new Error(`MCP server "${name}" not configured.`);
80
+ }
81
+ const nextServers = { ...cfg.mcpServers };
82
+ delete nextServers[name];
83
+ const next = { ...cfg, mcpServers: nextServers };
84
+ writeConfig(next, { configPath });
85
+ }
86
+
87
+ /**
88
+ * Return [[name, entry], ...] for every configured server. Empty array
89
+ * when no config exists or mcpServers is empty.
90
+ */
91
+ export function listServers(opts) {
92
+ const cfg = readConfig(opts);
93
+ return Object.entries(cfg.mcpServers);
94
+ }
package/src/mcp.js ADDED
@@ -0,0 +1,259 @@
1
+ // MCP (Model Context Protocol) client manager.
2
+ //
3
+ // Lets aether-code consume any MCP server as agent tools. Users configure
4
+ // servers in ~/.aether/mcp.json (mirror of Claude Code's pattern):
5
+ //
6
+ // {
7
+ // "mcpServers": {
8
+ // "filesystem": {
9
+ // "command": "npx",
10
+ // "args": ["-y", "@modelcontextprotocol/server-filesystem", "/some/path"]
11
+ // },
12
+ // "ida": {
13
+ // "command": "python",
14
+ // "args": ["-m", "ida_pro_mcp"],
15
+ // "env": { "IDA_PATH": "/opt/ida" }
16
+ // }
17
+ // }
18
+ // }
19
+ //
20
+ // Each server's tools are exposed to the model namespaced as
21
+ // `mcp__<serverName>__<toolName>` so a `filesystem` server's `read_file`
22
+ // doesn't collide with our built-in `read_file`. The manager handles the
23
+ // JSON-RPC handshake, tool discovery, call routing, and cleanup on exit.
24
+ //
25
+ // Failure model is fail-soft per server: if `ida` fails to start (binary
26
+ // missing, init handshake errors out), we log it and continue with the
27
+ // servers that DID start. One bad server should not break the whole CLI.
28
+
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
34
+
35
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".aether", "mcp.json");
36
+ const NAMESPACE_SEP = "__";
37
+ const NAMESPACE_PREFIX = "mcp" + NAMESPACE_SEP;
38
+ const INIT_TIMEOUT_MS = 10_000;
39
+ const CALL_TIMEOUT_MS = 60_000;
40
+
41
+ /**
42
+ * Read + validate the MCP config file. Returns the parsed config or null if
43
+ * the file is missing (which is the normal case for users who don't use MCP).
44
+ * Throws on malformed JSON or schema violations so the user sees the error
45
+ * immediately instead of silently running without their servers.
46
+ */
47
+ export function loadMcpConfig(configPath) {
48
+ const p = configPath || DEFAULT_CONFIG_PATH;
49
+ if (!fs.existsSync(p)) return null;
50
+ let raw;
51
+ try {
52
+ raw = fs.readFileSync(p, "utf8");
53
+ } catch (e) {
54
+ throw new Error(`MCP config exists at ${p} but isn't readable: ${e.message}`);
55
+ }
56
+ let parsed;
57
+ try {
58
+ parsed = JSON.parse(raw);
59
+ } catch (e) {
60
+ throw new Error(`MCP config at ${p} is not valid JSON: ${e.message}`);
61
+ }
62
+ return validateMcpConfig(parsed, p);
63
+ }
64
+
65
+ export function validateMcpConfig(parsed, sourcePath = "(inline)") {
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ throw new Error(`MCP config at ${sourcePath} must be a JSON object`);
68
+ }
69
+ const servers = parsed.mcpServers;
70
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
71
+ throw new Error(`MCP config at ${sourcePath} needs an "mcpServers" object`);
72
+ }
73
+ for (const [name, cfg] of Object.entries(servers)) {
74
+ if (!/^[a-z0-9_-]{1,40}$/i.test(name)) {
75
+ throw new Error(
76
+ `MCP server name "${name}" invalid — must be 1-40 chars of [A-Za-z0-9_-]. Used in tool namespacing.`,
77
+ );
78
+ }
79
+ if (!cfg || typeof cfg !== "object") {
80
+ throw new Error(`MCP server "${name}" entry must be an object`);
81
+ }
82
+ if (typeof cfg.command !== "string" || cfg.command.length === 0) {
83
+ throw new Error(`MCP server "${name}" needs a non-empty "command" string`);
84
+ }
85
+ if (cfg.args !== undefined && !Array.isArray(cfg.args)) {
86
+ throw new Error(`MCP server "${name}".args must be an array of strings`);
87
+ }
88
+ if (Array.isArray(cfg.args) && cfg.args.some((a) => typeof a !== "string")) {
89
+ throw new Error(`MCP server "${name}".args must be an array of STRINGS`);
90
+ }
91
+ if (cfg.env !== undefined && (typeof cfg.env !== "object" || Array.isArray(cfg.env))) {
92
+ throw new Error(`MCP server "${name}".env must be an object {KEY: value}`);
93
+ }
94
+ }
95
+ return parsed;
96
+ }
97
+
98
+ /**
99
+ * Build the `mcp__<server>__<tool>` namespaced tool name. Defense against
100
+ * underscores in the user-defined server name accidentally collapsing the
101
+ * boundary (config validation already rejects those, but we double-check
102
+ * here since this string is what the model sees).
103
+ */
104
+ export function namespaceToolName(serverName, toolName) {
105
+ if (serverName.includes(NAMESPACE_SEP) || toolName.includes(NAMESPACE_SEP)) {
106
+ // Replace with a single underscore so the boundary stays unambiguous.
107
+ return (
108
+ NAMESPACE_PREFIX +
109
+ serverName.replaceAll(NAMESPACE_SEP, "_") +
110
+ NAMESPACE_SEP +
111
+ toolName.replaceAll(NAMESPACE_SEP, "_")
112
+ );
113
+ }
114
+ return NAMESPACE_PREFIX + serverName + NAMESPACE_SEP + toolName;
115
+ }
116
+
117
+ /**
118
+ * Inverse of namespaceToolName: split a `mcp__server__tool` name back into
119
+ * its parts. Returns null if the name doesn't follow the convention (i.e.
120
+ * it's a built-in tool, not an MCP one).
121
+ */
122
+ export function unnamespaceToolName(namespaced) {
123
+ if (!namespaced.startsWith(NAMESPACE_PREFIX)) return null;
124
+ const rest = namespaced.slice(NAMESPACE_PREFIX.length);
125
+ const sepIdx = rest.indexOf(NAMESPACE_SEP);
126
+ if (sepIdx <= 0 || sepIdx >= rest.length - NAMESPACE_SEP.length) return null;
127
+ return {
128
+ serverName: rest.slice(0, sepIdx),
129
+ toolName: rest.slice(sepIdx + NAMESPACE_SEP.length),
130
+ };
131
+ }
132
+
133
+ export class MCPManager {
134
+ constructor() {
135
+ this.servers = new Map(); // name -> { client, transport, tools: [...] }
136
+ this.startErrors = []; // [{ serverName, error }]
137
+ }
138
+
139
+ /**
140
+ * Start every server in the config. Failures are collected (in
141
+ * `startErrors`) rather than thrown so one bad server doesn't kill the
142
+ * CLI. Returns the count of successfully-started servers.
143
+ */
144
+ async start(config) {
145
+ if (!config || !config.mcpServers) return 0;
146
+ const entries = Object.entries(config.mcpServers);
147
+ await Promise.allSettled(
148
+ entries.map(async ([name, cfg]) => {
149
+ try {
150
+ await this.#startOne(name, cfg);
151
+ } catch (e) {
152
+ this.startErrors.push({ serverName: name, error: e.message || String(e) });
153
+ }
154
+ }),
155
+ );
156
+ return this.servers.size;
157
+ }
158
+
159
+ async #startOne(name, cfg) {
160
+ const transport = new StdioClientTransport({
161
+ command: cfg.command,
162
+ args: cfg.args ?? [],
163
+ env: { ...process.env, ...(cfg.env ?? {}) },
164
+ });
165
+ const client = new Client(
166
+ { name: "aether-code", version: "0.9.0" },
167
+ { capabilities: {} },
168
+ );
169
+ // Bound the init handshake so a hung server doesn't stall startup.
170
+ await withTimeout(client.connect(transport), INIT_TIMEOUT_MS, `MCP server "${name}" init`);
171
+ const toolList = await withTimeout(client.listTools(), INIT_TIMEOUT_MS, `MCP server "${name}" listTools`);
172
+ const tools = (toolList?.tools ?? []).map((t) => ({
173
+ originalName: t.name,
174
+ namespacedName: namespaceToolName(name, t.name),
175
+ description: t.description ?? "",
176
+ inputSchema: t.inputSchema ?? { type: "object", properties: {} },
177
+ }));
178
+ this.servers.set(name, { client, transport, tools });
179
+ }
180
+
181
+ /**
182
+ * Returns OpenAI-format tool definitions for every connected MCP tool,
183
+ * ready to merge into TOOL_DEFINITIONS for the agent loop.
184
+ */
185
+ getToolDefinitions() {
186
+ const out = [];
187
+ for (const { tools } of this.servers.values()) {
188
+ for (const t of tools) {
189
+ out.push({
190
+ type: "function",
191
+ function: {
192
+ name: t.namespacedName,
193
+ description: t.description || `(MCP tool ${t.originalName})`,
194
+ parameters: t.inputSchema,
195
+ },
196
+ });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Resolve a namespaced tool name to the right server + original name.
204
+ * Returns null if the name isn't an MCP tool or no server matches.
205
+ */
206
+ resolve(namespacedName) {
207
+ const split = unnamespaceToolName(namespacedName);
208
+ if (!split) return null;
209
+ const server = this.servers.get(split.serverName);
210
+ if (!server) return null;
211
+ return { server, serverName: split.serverName, toolName: split.toolName };
212
+ }
213
+
214
+ /**
215
+ * Invoke a namespaced MCP tool. Returns { ok, output } matching the
216
+ * shape executeTool() uses for built-in tools.
217
+ */
218
+ async callTool(namespacedName, args) {
219
+ const resolved = this.resolve(namespacedName);
220
+ if (!resolved) {
221
+ return { ok: false, output: `Unknown MCP tool: ${namespacedName}` };
222
+ }
223
+ try {
224
+ const result = await withTimeout(
225
+ resolved.server.client.callTool({
226
+ name: resolved.toolName,
227
+ arguments: args,
228
+ }),
229
+ CALL_TIMEOUT_MS,
230
+ `MCP tool ${namespacedName}`,
231
+ );
232
+ // MCP tool results are { content: [{type, text|data, ...}], isError? }
233
+ const textParts = (result?.content ?? [])
234
+ .filter((c) => c.type === "text")
235
+ .map((c) => c.text);
236
+ const text = textParts.join("\n") || JSON.stringify(result, null, 2);
237
+ return { ok: !result?.isError, output: text };
238
+ } catch (e) {
239
+ return { ok: false, output: `MCP call ${namespacedName} failed: ${e.message}` };
240
+ }
241
+ }
242
+
243
+ async shutdown() {
244
+ const tasks = [];
245
+ for (const { client } of this.servers.values()) {
246
+ tasks.push(client.close().catch(() => {}));
247
+ }
248
+ await Promise.allSettled(tasks);
249
+ this.servers.clear();
250
+ }
251
+ }
252
+
253
+ function withTimeout(promise, ms, label) {
254
+ let timer;
255
+ const timeout = new Promise((_, reject) => {
256
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
257
+ });
258
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
259
+ }
package/src/repl.js CHANGED
@@ -32,7 +32,7 @@ ${c.gray("Anything else is sent to the agent as your next message.")}
32
32
  ${c.gray("Conversation history is kept across messages until you /clear.")}
33
33
  `;
34
34
 
35
- export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTurns: initialMaxTurns }) {
35
+ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTurns: initialMaxTurns, mcpManager = null }) {
36
36
  const state = {
37
37
  cwd: initialCwd,
38
38
  autoYes: !!initialAutoYes,
@@ -125,6 +125,7 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, maxTur
125
125
  cwd: state.cwd,
126
126
  autoYes: state.autoYes,
127
127
  maxTurns: state.maxTurns,
128
+ mcpManager,
128
129
  });
129
130
 
130
131
  state.sessionCredits += result.totalCredits ?? 0;