aether-code 0.8.0 → 0.9.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,43 @@ 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";
17
18
  import { c, errorLine, divider } from "../src/render.js";
18
19
 
19
- const VERSION = "0.3.0";
20
+ const VERSION = "0.9.0";
21
+
22
+ /**
23
+ * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
24
+ * MCPManager (possibly with zero servers) or null if no config exists.
25
+ * Prints a one-line summary so the user can see what attached.
26
+ */
27
+ async function bootMcp() {
28
+ let config;
29
+ try {
30
+ config = loadMcpConfig();
31
+ } catch (e) {
32
+ console.log(errorLine(`MCP config: ${e.message}`));
33
+ return null;
34
+ }
35
+ if (!config) return null;
36
+ const manager = new MCPManager();
37
+ const started = await manager.start(config);
38
+ const requested = Object.keys(config.mcpServers).length;
39
+ const failed = manager.startErrors.length;
40
+ if (started > 0 || failed > 0) {
41
+ const parts = [`${c.cyan("MCP")}`, `${started}/${requested} servers attached`];
42
+ const toolCount = manager.getToolDefinitions().length;
43
+ if (toolCount > 0) parts.push(`${toolCount} tools`);
44
+ console.log(c.gray(parts.join(" · ")));
45
+ for (const { serverName, error } of manager.startErrors) {
46
+ console.log(c.gray(` ${c.yellow("!")} ${serverName}: ${error}`));
47
+ }
48
+ }
49
+ // Best-effort cleanup so child processes don't leak on normal exit.
50
+ process.on("exit", () => { manager.shutdown().catch(() => {}); });
51
+ process.on("SIGINT", () => { manager.shutdown().catch(() => {}); process.exit(130); });
52
+ return manager;
53
+ }
20
54
 
21
55
  const HELP = `${c.bold("aether")} — uncensored AI coding agent
22
56
 
@@ -128,7 +162,8 @@ async function main() {
128
162
  // No task → drop into interactive REPL (Claude-CLI-style)
129
163
  if (!prompt) {
130
164
  if (cwd !== process.cwd()) process.chdir(cwd);
131
- await runRepl({ cwd, autoYes, maxTurns });
165
+ const mcpManager = await bootMcp();
166
+ await runRepl({ cwd, autoYes, maxTurns, mcpManager });
132
167
  return;
133
168
  }
134
169
 
@@ -144,13 +179,16 @@ async function main() {
144
179
  console.log(c.gray(`task: `) + prompt);
145
180
  console.log(divider());
146
181
 
182
+ const mcpManager = await bootMcp();
147
183
  const result = await runAgent({
148
184
  initialPrompt: prompt,
149
185
  cwd,
150
186
  autoYes,
151
187
  unsafePaths,
152
188
  maxTurns,
189
+ mcpManager,
153
190
  });
191
+ if (mcpManager) await mcpManager.shutdown().catch(() => {});
154
192
 
155
193
  console.log("\n" + divider());
156
194
  if (result.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
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.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;