@matterailab/orbcode 0.2.1 → 0.2.3

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.
@@ -0,0 +1,266 @@
1
+ import { addMcpServer, configPathForScope, loadMcpConfig, removeMcpServerAnyScope, } from "../mcp/config.js";
2
+ /**
3
+ * `orbcode mcp` subcommand — manage MCP servers from the command line, like
4
+ * Claude Code's `claude mcp add/remove/list`.
5
+ *
6
+ * Usage:
7
+ * orbcode mcp add <name> <command> [args...] stdio (default)
8
+ * orbcode mcp add --transport http <name> <url> http
9
+ * orbcode mcp add --transport sse <name> <url> sse
10
+ * orbcode mcp add -s <scope> -e KEY=value ... <name> ... with scope/env
11
+ * orbcode mcp add --header KEY=value ... <name> <url> http/sse headers
12
+ * orbcode mcp remove <name> remove from any scope
13
+ * orbcode mcp list list all servers
14
+ *
15
+ * Flags:
16
+ * -s, --scope <user|project|local> config scope (default: project)
17
+ * -t, --transport <stdio|http|sse> transport type (default: stdio)
18
+ * -e, --env KEY=value environment variable (repeatable, stdio)
19
+ * --header KEY=value HTTP header (repeatable, http/sse)
20
+ * --oauth enable OAuth for http/sse
21
+ * --oauth-scope <scope> OAuth scope (implies --oauth)
22
+ */
23
+ const VALID_SCOPES = ["user", "project", "local"];
24
+ const VALID_TRANSPORTS = ["stdio", "http", "sse"];
25
+ /** Parse a KEY=value pair from a flag argument. */
26
+ function parseKeyValue(arg) {
27
+ const idx = arg.indexOf("=");
28
+ if (idx === -1)
29
+ throw new Error(`Expected KEY=value, got "${arg}"`);
30
+ return [arg.slice(0, idx), arg.slice(idx + 1)];
31
+ }
32
+ /** Print usage and exit. */
33
+ function printMcpHelp() {
34
+ console.log(`orbcode mcp — manage MCP servers
35
+
36
+ Usage:
37
+ orbcode mcp add [options] <name> <command> [args...] add a stdio server
38
+ orbcode mcp add [options] --transport http <name> <url> add an http server
39
+ orbcode mcp add [options] --transport sse <name> <url> add an sse server
40
+ orbcode mcp remove <name> remove a server
41
+ orbcode mcp list list all servers
42
+
43
+ Options:
44
+ -s, --scope <user|project|local> config scope (default: project)
45
+ -t, --transport <stdio|http|sse> transport type (default: stdio)
46
+ -e, --env KEY=value environment variable (repeatable, stdio)
47
+ --header KEY=value HTTP header (repeatable, http/sse)
48
+ --oauth enable OAuth for http/sse
49
+ --oauth-scope <scope> OAuth scope (implies --oauth)
50
+
51
+ Scopes:
52
+ project .mcp.json in the current directory (shared, checked into git)
53
+ user ~/.orbcode/settings.json (applies to all projects on this machine)
54
+ local .orbcode/settings.json (per-project, per-machine, not checked in)`);
55
+ }
56
+ /** Handle `orbcode mcp ...`. Returns the process exit code. */
57
+ export async function runMcpCommand(args) {
58
+ const [subcommand, ...rest] = args;
59
+ switch (subcommand) {
60
+ case "add":
61
+ return runAdd(rest);
62
+ case "remove":
63
+ case "rm":
64
+ return runRemove(rest);
65
+ case "list":
66
+ case "ls":
67
+ return runList(rest);
68
+ case "help":
69
+ case "--help":
70
+ case "-h":
71
+ printMcpHelp();
72
+ return 0;
73
+ default:
74
+ console.error(`Unknown mcp subcommand: "${subcommand ?? ""}". Try: orbcode mcp help`);
75
+ return 1;
76
+ }
77
+ }
78
+ /** Parse the flags shared by add (scope, transport, env, headers, oauth). */
79
+ function parseAddFlags(args) {
80
+ let scope = "project";
81
+ let transport = "stdio";
82
+ const env = {};
83
+ const headers = {};
84
+ let oauth = false;
85
+ const positional = [];
86
+ // Flags must come before the server name. Once we hit the first positional
87
+ // (the server name), everything after it — including args that look like
88
+ // flags (-y, -f, --foo) — becomes the server's command/args. This matches
89
+ // Claude Code and lets stdio servers take their own flags.
90
+ let sawPositional = false;
91
+ for (let i = 0; i < args.length; i++) {
92
+ const arg = args[i];
93
+ if (sawPositional) {
94
+ // A "--" immediately after the server name is the separator between
95
+ // orbcode's flags and the server's command (e.g. `add name -- npx`),
96
+ // matching Claude Code's `claude mcp add <name> -- <command>`. Consume
97
+ // it so it isn't mistaken for the command. A later "--" is kept as a
98
+ // literal arg for the server command.
99
+ if (arg === "--" && positional.length === 1)
100
+ continue;
101
+ positional.push(arg);
102
+ continue;
103
+ }
104
+ if (arg === "-s" || arg === "--scope") {
105
+ const value = args[++i];
106
+ if (!value || !VALID_SCOPES.includes(value)) {
107
+ throw new Error(`Invalid scope "${value ?? ""}". Valid: ${VALID_SCOPES.join(", ")}`);
108
+ }
109
+ scope = value;
110
+ }
111
+ else if (arg === "-t" || arg === "--transport") {
112
+ const value = args[++i];
113
+ if (!value || !VALID_TRANSPORTS.includes(value)) {
114
+ throw new Error(`Invalid transport "${value ?? ""}". Valid: ${VALID_TRANSPORTS.join(", ")}`);
115
+ }
116
+ transport = value;
117
+ }
118
+ else if (arg === "-e" || arg === "--env") {
119
+ const value = args[++i];
120
+ if (!value)
121
+ throw new Error("Missing value for --env");
122
+ const [k, v] = parseKeyValue(value);
123
+ env[k] = v;
124
+ }
125
+ else if (arg === "--header") {
126
+ const value = args[++i];
127
+ if (!value)
128
+ throw new Error("Missing value for --header");
129
+ const [k, v] = parseKeyValue(value);
130
+ headers[k] = v;
131
+ }
132
+ else if (arg === "--oauth") {
133
+ oauth = true;
134
+ }
135
+ else if (arg === "--oauth-scope") {
136
+ const value = args[++i];
137
+ if (!value)
138
+ throw new Error("Missing value for --oauth-scope");
139
+ oauth = { scope: value };
140
+ }
141
+ else if (arg === "--") {
142
+ // Explicit separator: everything after is positional.
143
+ sawPositional = true;
144
+ }
145
+ else if (arg.startsWith("-") && arg.length > 1) {
146
+ throw new Error(`Unknown flag: ${arg}. Put flags before the server name, or use -- to separate.`);
147
+ }
148
+ else {
149
+ positional.push(arg);
150
+ sawPositional = true;
151
+ }
152
+ }
153
+ return { scope, transport, env, headers, oauth, positional };
154
+ }
155
+ /** `orbcode mcp add` — build a server config from flags and write it. */
156
+ function runAdd(args) {
157
+ let parsed;
158
+ try {
159
+ parsed = parseAddFlags(args);
160
+ }
161
+ catch (error) {
162
+ console.error(error.message);
163
+ return 1;
164
+ }
165
+ const { scope, transport, env, headers, oauth, positional } = parsed;
166
+ if (positional.length === 0) {
167
+ console.error("Missing server name. Usage: orbcode mcp add <name> <command> [args...]");
168
+ return 1;
169
+ }
170
+ const [name, ...rest] = positional;
171
+ let config;
172
+ if (transport === "http" || transport === "sse") {
173
+ if (rest.length === 0) {
174
+ console.error(`Missing URL for ${transport} transport. Usage: orbcode mcp add -t ${transport} <name> <url>`);
175
+ return 1;
176
+ }
177
+ const url = rest[0];
178
+ const cfg = { type: transport, url };
179
+ if (Object.keys(headers).length > 0)
180
+ cfg.headers = headers;
181
+ if (oauth !== false)
182
+ cfg.oauth = oauth;
183
+ config = cfg;
184
+ }
185
+ else {
186
+ // stdio
187
+ if (rest.length === 0) {
188
+ console.error("Missing command for stdio transport. Usage: orbcode mcp add <name> <command> [args...]");
189
+ return 1;
190
+ }
191
+ const [command, ...cmdArgs] = rest;
192
+ const cfg = { type: "stdio", command: command };
193
+ if (cmdArgs.length > 0)
194
+ cfg.args = cmdArgs;
195
+ if (Object.keys(env).length > 0)
196
+ cfg.env = env;
197
+ config = cfg;
198
+ }
199
+ let detail;
200
+ if (transport === "http" || transport === "sse") {
201
+ const cfg = config;
202
+ detail = `${transport} ${cfg.url}`;
203
+ }
204
+ else {
205
+ const cfg = config;
206
+ detail = `stdio ${cfg.command}${cfg.args?.length ? " " + cfg.args.join(" ") : ""}`;
207
+ }
208
+ try {
209
+ addMcpServer(process.cwd(), name, config, scope);
210
+ }
211
+ catch (error) {
212
+ console.error(error.message);
213
+ return 1;
214
+ }
215
+ const filePath = configPathForScope(process.cwd(), scope);
216
+ console.log(`Added ${transport === "http" || transport === "sse" ? transport.toUpperCase() : "stdio"} MCP server ${name}`);
217
+ if (transport === "http" || transport === "sse") {
218
+ console.log(` URL: ${config.url}`);
219
+ }
220
+ console.log(` Scope: ${scope}`);
221
+ console.log(` File modified: ${filePath}`);
222
+ return 0;
223
+ }
224
+ /** `orbcode mcp remove <name>` — remove from whichever scope it's in. */
225
+ function runRemove(args) {
226
+ if (args.length === 0) {
227
+ console.error("Missing server name. Usage: orbcode mcp remove <name>");
228
+ return 1;
229
+ }
230
+ const name = args[0];
231
+ const scope = removeMcpServerAnyScope(process.cwd(), name);
232
+ if (!scope) {
233
+ console.error(`MCP server "${name}" not found in any scope.`);
234
+ return 1;
235
+ }
236
+ const filePath = configPathForScope(process.cwd(), scope);
237
+ console.log(`Removed MCP server "${name}" from ${scope} scope.`);
238
+ console.log(` File modified: ${filePath}`);
239
+ return 0;
240
+ }
241
+ /** `orbcode mcp list` — print all configured servers with their scope + status. */
242
+ function runList(_args) {
243
+ const { servers } = loadMcpConfig(process.cwd());
244
+ const names = Object.keys(servers).sort();
245
+ if (names.length === 0) {
246
+ console.log("No MCP servers configured.");
247
+ console.log("Add one with: orbcode mcp add <name> <command> [args...]");
248
+ return 0;
249
+ }
250
+ for (const name of names) {
251
+ const cfg = servers[name];
252
+ const scope = cfg.scope;
253
+ let detail;
254
+ if (cfg.type === "http" || cfg.type === "sse") {
255
+ detail = `${cfg.type} ${cfg.url}`;
256
+ }
257
+ else {
258
+ // stdio (type undefined or "stdio")
259
+ const cmd = cfg.command;
260
+ const args = cfg.args ?? [];
261
+ detail = `stdio ${cmd}${args.length ? " " + args.join(" ") : ""}`;
262
+ }
263
+ console.log(` ${name.padEnd(20)} ${scope.padEnd(8)} ${detail}`);
264
+ }
265
+ return 0;
266
+ }
@@ -18,6 +18,8 @@ const SETTINGS_KEYS = [
18
18
  "baseUrl",
19
19
  "apiKey",
20
20
  "env",
21
+ "enabledMcpServers",
22
+ "disabledMcpServers",
21
23
  ];
22
24
  export function getConfigDir() {
23
25
  return process.env.MATTERAI_CONFIG_DIR || path.join(os.homedir(), ".orbcode");
@@ -150,6 +152,12 @@ export function loadSettings() {
150
152
  if (Array.isArray(fileSettings.customModels)) {
151
153
  customModels.push(...fileSettings.customModels);
152
154
  }
155
+ // MCP servers: merge across scopes (local overrides project overrides
156
+ // user on name collisions). The MCP config layer also reads .mcp.json;
157
+ // settings.json mcpServers are the per-user/per-machine override path.
158
+ if (fileSettings.mcpServers && typeof fileSettings.mcpServers === "object") {
159
+ settings.mcpServers = { ...(settings.mcpServers ?? {}), ...fileSettings.mcpServers };
160
+ }
153
161
  }
154
162
  if (customModels.length > 0) {
155
163
  settings.customModels = customModels;
@@ -210,3 +218,22 @@ export function saveSettings(settings) {
210
218
  };
211
219
  fs.writeFileSync(getConfigPath(), JSON.stringify(toPersist, null, "\t") + "\n", { mode: 0o600 });
212
220
  }
221
+ /**
222
+ * Persist the enabled/disabled MCP server lists to the local project
223
+ * settings.json (`.orbcode/settings.json`). These are per-project approval
224
+ * decisions, so they live in the local scope, not in config.json.
225
+ */
226
+ export function saveMcpApproval(cwd, enabled, disabled) {
227
+ const settingsPath = path.join(cwd, ".orbcode", "settings.json");
228
+ let existing = {};
229
+ try {
230
+ existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
231
+ }
232
+ catch {
233
+ // file may not exist yet
234
+ }
235
+ existing.enabledMcpServers = [...new Set(enabled)];
236
+ existing.disabledMcpServers = [...new Set(disabled)];
237
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
238
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, "\t") + "\n", { mode: 0o600 });
239
+ }
@@ -9,6 +9,8 @@ import { walkFiles } from "../tools/executors/listFiles.js";
9
9
  import { previewFileChange } from "../tools/executors/files.js";
10
10
  import { getSessionFilePath, saveSession } from "./sessions.js";
11
11
  import { HookRunner } from "./hooks.js";
12
+ import { loadMemoryFiles } from "../memory/loader.js";
13
+ import { loadSkills } from "../skills/loader.js";
12
14
  const MAX_STEPS_PER_TURN = 50;
13
15
  const RESULT_PREVIEW_LINES = 6;
14
16
  function detectRepo(cwd) {
@@ -77,6 +79,8 @@ export class Agent {
77
79
  title = "";
78
80
  createdAt = new Date().toISOString();
79
81
  hooks;
82
+ /** MCP server manager (may be undefined when MCP is disabled). */
83
+ mcp;
80
84
  /** SessionStart fires once, lazily, before the first turn of this instance. */
81
85
  sessionStarted = false;
82
86
  /** carries SessionStart additionalContext into the first user message */
@@ -107,7 +111,13 @@ export class Agent {
107
111
  this.firstMessageSent = this.messages.length > 0;
108
112
  }
109
113
  this.sessionApproveEdits = options.autoApproveEdits;
110
- this.systemPrompt = buildSystemPrompt(options.cwd);
114
+ this.mcp = options.mcp;
115
+ // Build the system prompt with AGENTS.md memory files and the skills
116
+ // catalog injected, so the model sees project/user instructions and
117
+ // knows which skills it can invoke.
118
+ const memoryFiles = loadMemoryFiles(options.cwd);
119
+ const skills = loadSkills(options.cwd);
120
+ this.systemPrompt = buildSystemPrompt(options.cwd, { memoryFiles, skills });
111
121
  this.client = createLLMClient({
112
122
  token: options.token,
113
123
  modelId: options.modelId,
@@ -353,15 +363,26 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
353
363
  async endSession(reason) {
354
364
  // Let in-flight Notification hooks settle before the final SessionEnd.
355
365
  await this.awaitBackground(3000);
356
- if (!this.hooks.hasHooks("SessionEnd"))
357
- return;
366
+ if (this.hooks.hasHooks("SessionEnd")) {
367
+ try {
368
+ await this.hooks.run("SessionEnd", { reason });
369
+ }
370
+ catch {
371
+ // a SessionEnd hook must never prevent the app from exiting
372
+ }
373
+ }
374
+ // Tear down MCP server connections so child processes / sockets don't leak.
358
375
  try {
359
- await this.hooks.run("SessionEnd", { reason });
376
+ await this.mcp?.stop();
360
377
  }
361
378
  catch {
362
- // a SessionEnd hook must never prevent the app from exiting
379
+ // best-effort
363
380
  }
364
381
  }
382
+ /** The MCP manager (for the TUI's /mcp command and approval flow). */
383
+ get mcpManager() {
384
+ return this.mcp;
385
+ }
365
386
  /** Summarize the conversation so far and replace history with the summary. */
366
387
  async compact() {
367
388
  const { onEvent } = this.options.callbacks;
@@ -452,7 +473,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
452
473
  let reasoningDetails;
453
474
  const toolCallsByIndex = new Map();
454
475
  let nextSyntheticIndex = 10000;
455
- const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(), signal);
476
+ const stream = this.client.createMessage(this.systemPrompt, this.outgoingMessages(), getActiveTools(this.mcp), signal);
456
477
  for await (const chunk of stream) {
457
478
  if (signal.aborted)
458
479
  throw new DOMException("aborted", "AbortError");
@@ -663,7 +684,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}`;
663
684
  this.sessionApproveCommands = true;
664
685
  }
665
686
  }
666
- const result = await executeTool(toolCall.name, args, this.toolContext());
687
+ const result = await executeTool(toolCall.name, args, this.toolContext(), this.mcp);
667
688
  // PostToolUse can feed extra context (or a block reason) back to the
668
689
  // model. PreToolUse additionalContext is delivered here too.
669
690
  let resultText = result.text;
package/dist/headless.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getModel, isValidAxonModel, usesAiSdk } from "./api/models.js";
2
2
  import { getAuthToken, getPendingProjectHooks, loadSettings } from "./config/settings.js";
3
3
  import { Agent } from "./core/agent.js";
4
+ import { McpManager } from "./mcp/manager.js";
4
5
  /** Non-interactive `orbcode -p "prompt"` mode: prints the final response to stdout. */
5
6
  export async function runHeadless(prompt, yolo) {
6
7
  const settings = loadSettings();
@@ -28,6 +29,21 @@ export async function runHeadless(prompt, yolo) {
28
29
  process.stderr.write(`note: ${pendingHooks.commands.length} project hook(s) in .orbcode/settings.json are untrusted and were skipped. ` +
29
30
  `Trust them in an interactive session, or set MATTERAI_TRUST_PROJECT_HOOKS=1.\n`);
30
31
  }
32
+ // Start MCP servers. In headless mode there's no interactive approval, so
33
+ // project-scope servers are only connected if they were previously approved
34
+ // (persisted in .orbcode/settings.json). Unapproved project servers are
35
+ // skipped with a note, matching the project-hooks behavior.
36
+ const mcp = new McpManager(process.cwd(), settings.disabledMcpServers ?? [], settings.enabledMcpServers ?? []);
37
+ const mcpSnapshot = await mcp.start();
38
+ const pendingMcp = mcp.getPendingApproval();
39
+ if (pendingMcp.length > 0) {
40
+ process.stderr.write(`note: ${pendingMcp.length} project MCP server(s) in .mcp.json are unapproved and were skipped. ` +
41
+ `Approve them in an interactive session with /mcp.\n`);
42
+ }
43
+ const connectedMcp = mcpSnapshot.servers.filter((s) => s.status === "connected").length;
44
+ if (connectedMcp > 0) {
45
+ process.stderr.write(`MCP: ${connectedMcp}/${mcpSnapshot.servers.length} server(s) connected.\n`);
46
+ }
31
47
  let exitCode = 0;
32
48
  // Only the final content is printed: either the attempt_completion result
33
49
  // or, failing that, the last assistant text. Intermediate text and tool
@@ -46,6 +62,7 @@ export async function runHeadless(prompt, yolo) {
46
62
  autoApproveEdits: yolo,
47
63
  autoApproveSafeCommands: yolo,
48
64
  hooks: settings.hooks,
65
+ mcp,
49
66
  callbacks: {
50
67
  onEvent: (event) => {
51
68
  switch (event.type) {
@@ -84,6 +101,7 @@ export async function runHeadless(prompt, yolo) {
84
101
  });
85
102
  await agent.runTurn(prompt);
86
103
  await agent.endSession("other");
104
+ await mcp.stop().catch(() => { });
87
105
  const finalContent = completionResult || lastText || textBuffer;
88
106
  if (finalContent) {
89
107
  process.stdout.write(finalContent.trimEnd() + "\n");
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { render } from "ink";
3
3
  import { PRODUCT_NAME, VERSION } from "./branding.js";
4
4
  import { App } from "./ui/App.js";
5
5
  import { runHeadless } from "./headless.js";
6
+ import { runMcpCommand } from "./commands/mcp.js";
6
7
  import { loadSessionById } from "./core/sessions.js";
7
8
  import { clearUpdateCache, compareVersions, fetchLatestNpmVersion, getGlobalInstallRoot, getUpdateInfo, isGlobalInstall, runNpmUpdate, } from "./utils/updateCheck.js";
8
9
  const PACKAGE_NAME = "@matterailab/orbcode";
@@ -15,6 +16,9 @@ Usage:
15
16
  orbcode login sign in to MatterAI
16
17
  orbcode update install the latest version from npm
17
18
  orbcode update --force force a global install even if this CLI doesn't look global
19
+ orbcode mcp add ... add an MCP server (see: orbcode mcp help)
20
+ orbcode mcp remove ... remove an MCP server
21
+ orbcode mcp list list configured MCP servers
18
22
  orbcode -p "<prompt>" run a single prompt non-interactively (prints only the final response)
19
23
  orbcode -p "…" --yolo non-interactive with auto-approved edits/commands
20
24
  orbcode --model <id> use a specific model for this run
@@ -86,6 +90,11 @@ async function main() {
86
90
  const code = await runUpdate(force);
87
91
  process.exit(code);
88
92
  }
93
+ // `orbcode mcp ...` — manage MCP servers from the command line (add/remove/list).
94
+ if (args[0] === "mcp") {
95
+ const code = await runMcpCommand(args.slice(1));
96
+ process.exit(code);
97
+ }
89
98
  const model = takeFlagValue(args, "model") ?? takeFlagValue(args, "m");
90
99
  if (model) {
91
100
  // loadSettings() treats MATTERAI_MODEL as the highest-precedence override,