@bahulam/code 0.1.22 → 0.1.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `bahulam mcp` — manage MCP server registrations.
3
+ *
4
+ * bahulam mcp add <name> --command <cmd> [--args ...] [--env KEY=VAL ...]
5
+ * bahulam mcp add <name> --url <url> [--headers ...]
6
+ * bahulam mcp remove <name>
7
+ * bahulam mcp list
8
+ * bahulam mcp test <name>
9
+ *
10
+ * MCP servers are stored in ~/.claude/settings.json under mcpServers,
11
+ * matching the Claude Desktop / Cursor / Cline portable format.
12
+ * The settings loader chain reads this file at startup, and the MCP
13
+ * loader (src/mcp/loader.mjs) spawns and registers tools from it.
14
+ */
15
+
16
+ import * as fs from 'node:fs';
17
+ import * as path from 'node:path';
18
+ import * as os from 'node:os';
19
+
20
+ const RESET = '\x1b[0m';
21
+ const BOLD = '\x1b[1m';
22
+ const DIM = '\x1b[2m';
23
+ const CYAN = '\x1b[36m';
24
+ const GREEN = '\x1b[32m';
25
+ const YELLOW = '\x1b[33m';
26
+ const RED = '\x1b[31m';
27
+
28
+ function settingsFilePath() {
29
+ return path.join(os.homedir(), '.claude', 'settings.json');
30
+ }
31
+
32
+ function loadSettingsFile() {
33
+ const file = settingsFilePath();
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function saveSettingsFile(data) {
42
+ const file = settingsFilePath();
43
+ const dir = path.dirname(file);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ const parsed = {
50
+ subcommand: null,
51
+ name: null,
52
+ command: null,
53
+ args: [],
54
+ url: null,
55
+ env: {},
56
+ headers: {},
57
+ transport: null,
58
+ help: false,
59
+ json: false,
60
+ };
61
+ const positional = [];
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const arg = argv[i];
64
+ switch (arg) {
65
+ case '--help': case '-h': parsed.help = true; break;
66
+ case '--json': parsed.json = true; break;
67
+ case '--command': parsed.command = argv[++i]; break;
68
+ case '--url': parsed.url = argv[++i]; break;
69
+ case '--transport': parsed.transport = argv[++i]; break;
70
+ case '--env': {
71
+ const pair = argv[++i] || '';
72
+ const eq = pair.indexOf('=');
73
+ if (eq > 0) parsed.env[pair.slice(0, eq)] = pair.slice(eq + 1);
74
+ break;
75
+ }
76
+ case '--header': case '--headers': {
77
+ const pair = argv[++i] || '';
78
+ const eq = pair.indexOf(':');
79
+ if (eq > 0) parsed.headers[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
80
+ break;
81
+ }
82
+ case '--args': {
83
+ // Collect remaining positional args until next --flag
84
+ while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
85
+ parsed.args.push(argv[++i]);
86
+ }
87
+ break;
88
+ }
89
+ default:
90
+ if (!arg.startsWith('-')) positional.push(arg);
91
+ break;
92
+ }
93
+ }
94
+ parsed.subcommand = positional.shift() || null;
95
+ parsed.name = positional.shift() || null;
96
+ return parsed;
97
+ }
98
+
99
+ export async function handleMcpCommand(argv) {
100
+ const args = parseArgs(argv);
101
+
102
+ if (args.help || !args.subcommand) {
103
+ process.stderr.write(`
104
+ ${BOLD}bahulam mcp <subcommand>${RESET}
105
+
106
+ Manage MCP (Model Context Protocol) server registrations.
107
+
108
+ ${BOLD}Subcommands:${RESET}
109
+
110
+ ${CYAN}add${RESET} <name> ${DIM}--command <cmd> [--args a b c] [--env KEY=VAL ...]${RESET}
111
+ Register a stdio MCP server (spawns a child process).
112
+
113
+ ${CYAN}add${RESET} <name> ${DIM}--url <url> [--header "Key: Val" ...]${RESET}
114
+ Register a remote MCP server (SSE, WebSocket, or Streamable HTTP).
115
+ Transport is auto-detected: ws:// → WebSocket, /sse → SSE, else sHTTP.
116
+
117
+ ${CYAN}remove${RESET} <name> Unregister an MCP server.
118
+ ${CYAN}list${RESET} List all registered MCP servers.
119
+ ${CYAN}test${RESET} <name> Connect, list tools, and call one.
120
+
121
+ ${BOLD}Examples:${RESET}
122
+
123
+ ${DIM}# Supabase MCP (stdio)${RESET}
124
+ ${CYAN}bahulam mcp add supabase --command npx --args -y @supabase/mcp-server-supabase --env SUPABASE_ACCESS_TOKEN=sbp_xxx${RESET}
125
+
126
+ ${DIM}# Remote SSE server${RESET}
127
+ ${CYAN}bahulam mcp add myapi --url https://api.example.com/sse --header "Authorization: Bearer token123"${RESET}
128
+
129
+ ${DIM}# List and test${RESET}
130
+ ${CYAN}bahulam mcp list${RESET}
131
+ ${CYAN}bahulam mcp test supabase${RESET}
132
+
133
+ ${BOLD}Config location:${RESET} ${settingsFilePath()}
134
+
135
+ `);
136
+ if (!args.subcommand) process.exit(1);
137
+ return;
138
+ }
139
+
140
+ switch (args.subcommand) {
141
+ case 'add': return handleAdd(args);
142
+ case 'remove': case 'rm': return handleRemove(args);
143
+ case 'list': case 'ls': return handleList(args);
144
+ case 'test': return handleTest(args);
145
+ default:
146
+ process.stderr.write(`${RED}✗${RESET} Unknown subcommand: ${args.subcommand}\n`);
147
+ process.stderr.write(`Run ${CYAN}bahulam mcp --help${RESET} for usage.\n`);
148
+ process.exit(1);
149
+ }
150
+ }
151
+
152
+ function handleAdd(args) {
153
+ if (!args.name) {
154
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp add <name> --command <cmd> | --url <url>\n`);
155
+ process.exit(1);
156
+ }
157
+ if (!args.command && !args.url) {
158
+ process.stderr.write(`${RED}✗${RESET} Either --command or --url is required.\n`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const settings = loadSettingsFile();
163
+ if (!settings.mcpServers) settings.mcpServers = {};
164
+
165
+ const config = {};
166
+ if (args.command) {
167
+ config.command = args.command;
168
+ if (args.args.length > 0) config.args = args.args;
169
+ }
170
+ if (args.url) config.url = args.url;
171
+ if (Object.keys(args.env).length > 0) config.env = args.env;
172
+ if (Object.keys(args.headers).length > 0) config.headers = args.headers;
173
+ if (args.transport) config.transport = args.transport;
174
+
175
+ settings.mcpServers[args.name] = config;
176
+ saveSettingsFile(settings);
177
+
178
+ if (args.json) {
179
+ process.stdout.write(JSON.stringify({ ok: true, name: args.name, config }) + '\n');
180
+ return;
181
+ }
182
+
183
+ const type = config.command ? 'stdio' : 'remote';
184
+ const endpoint = config.command || config.url;
185
+ process.stderr.write(`${GREEN}✓${RESET} Registered MCP server ${BOLD}${args.name}${RESET} (${type}: ${endpoint})\n`);
186
+ process.stderr.write(` ${DIM}config${RESET} ${settingsFilePath()}\n`);
187
+ process.stderr.write(` ${DIM}test${RESET} ${CYAN}bahulam mcp test ${args.name}${RESET}\n\n`);
188
+ }
189
+
190
+ function handleRemove(args) {
191
+ if (!args.name) {
192
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp remove <name>\n`);
193
+ process.exit(1);
194
+ }
195
+ const settings = loadSettingsFile();
196
+ if (!settings.mcpServers?.[args.name]) {
197
+ process.stderr.write(`${YELLOW}!${RESET} No MCP server named "${args.name}" is registered.\n`);
198
+ process.exit(1);
199
+ }
200
+ delete settings.mcpServers[args.name];
201
+ saveSettingsFile(settings);
202
+
203
+ if (args.json) {
204
+ process.stdout.write(JSON.stringify({ ok: true, removed: args.name }) + '\n');
205
+ return;
206
+ }
207
+ process.stderr.write(`${GREEN}✓${RESET} Removed MCP server ${BOLD}${args.name}${RESET}\n`);
208
+ }
209
+
210
+ function handleList(args) {
211
+ const settings = loadSettingsFile();
212
+ const servers = settings.mcpServers || {};
213
+ const names = Object.keys(servers);
214
+
215
+ if (names.length === 0) {
216
+ process.stderr.write(`${DIM}No MCP servers registered.${RESET}\n`);
217
+ process.stderr.write(`Add one: ${CYAN}bahulam mcp add <name> --command <cmd>${RESET}\n\n`);
218
+ return;
219
+ }
220
+
221
+ if (args.json) {
222
+ process.stdout.write(JSON.stringify({ servers }) + '\n');
223
+ return;
224
+ }
225
+
226
+ process.stderr.write(`${BOLD}MCP Servers${RESET} (${names.length}):\n`);
227
+ for (const name of names) {
228
+ const cfg = servers[name];
229
+ const type = cfg.command ? 'stdio' : 'remote';
230
+ const endpoint = cfg.command || cfg.url || 'unknown';
231
+ const toolCount = cfg._tools ? ` (${cfg._tools} tools)` : '';
232
+ process.stderr.write(` ${CYAN}${name.padEnd(20)}${RESET} ${type.padEnd(7)} ${endpoint}${toolCount}\n`);
233
+ }
234
+ process.stderr.write(`\n${DIM}Config: ${settingsFilePath()}${RESET}\n\n`);
235
+ }
236
+
237
+ async function handleTest(args) {
238
+ if (!args.name) {
239
+ process.stderr.write(`${RED}✗${RESET} Server name required. Usage: bahulam mcp test <name>\n`);
240
+ process.exit(1);
241
+ }
242
+
243
+ const settings = loadSettingsFile();
244
+ const config = settings.mcpServers?.[args.name];
245
+ if (!config) {
246
+ process.stderr.write(`${RED}✗${RESET} No MCP server named "${args.name}" is registered.\n`);
247
+ process.exit(1);
248
+ }
249
+
250
+ process.stderr.write(`${DIM}Connecting to ${args.name}…${RESET}\n`);
251
+
252
+ try {
253
+ const { McpClient } = await import('../mcp/client.mjs');
254
+ const { loadMcpServers } = await import('../mcp/loader.mjs');
255
+
256
+ // Use the loader to handle env expansion
257
+ const fakeExecutor = {
258
+ registerMcpTool: () => true,
259
+ unregisterMcpServer: () => 0,
260
+ };
261
+ const mcp = await loadMcpServers(fakeExecutor, { mcpServers: { [args.name]: config } });
262
+
263
+ if (mcp.clients.length === 0) {
264
+ process.stderr.write(`${RED}✗${RESET} Failed to connect to "${args.name}".\n`);
265
+ process.stderr.write(`${DIM}Check that the command/URL is correct and any required env vars are set.${RESET}\n`);
266
+ process.exit(1);
267
+ }
268
+
269
+ const client = mcp.clients[0].client;
270
+ const tools = client.tools;
271
+
272
+ process.stderr.write(`${GREEN}✓${RESET} Connected! Server: ${BOLD}${client.serverInfo?.serverInfo?.name || args.name}${RESET}\n`);
273
+ process.stderr.write(` ${DIM}tools${RESET} ${tools.length}\n`);
274
+ for (const t of tools) {
275
+ process.stderr.write(` ${CYAN}${t.name}${RESET} — ${(t.description || '').slice(0, 70)}\n`);
276
+ }
277
+
278
+ // Try calling the first tool that looks like a list/query
279
+ const listTool = tools.find(t =>
280
+ /list|query|search|get|fetch/i.test(t.name) && !/delete|update|create|insert|drop/i.test(t.name)
281
+ );
282
+
283
+ if (listTool) {
284
+ process.stderr.write(`\n${DIM}Testing tool: ${listTool.name}…${RESET}\n`);
285
+ const result = await client.callTool(listTool.name, {});
286
+ const preview = String(result).slice(0, 500);
287
+ process.stderr.write(`${GREEN}✓${RESET} Result: ${preview}${preview.length >= 500 ? '…' : ''}\n`);
288
+ }
289
+
290
+ await mcp.disconnectAll();
291
+ process.stderr.write(`\n${GREEN}✓${RESET} Test passed.\n\n`);
292
+ } catch (err) {
293
+ process.stderr.write(`${RED}✗${RESET} Test failed: ${err.message}\n`);
294
+ process.exit(1);
295
+ }
296
+ }
@@ -18,6 +18,8 @@ import { persistProjectArtifacts } from './project-artifacts.mjs';
18
18
  import { BahulamAuth } from '../auth/bahulam-auth.mjs';
19
19
  import { ApprovalManager } from './approval.mjs';
20
20
  import { PluginRegistry } from '../plugins/registry.mjs';
21
+ import { loadSettings } from '../config/settings.mjs';
22
+ import { loadMcpServers } from '../mcp/loader.mjs';
21
23
  // daemon wiring — headless (and `bahulam daemonize`) also starts the socket
22
24
  // server + relay bridge when eventlog is enabled. Without this the daemon
23
25
  // is invisible to attach clients and to paired mobile devices.
@@ -98,6 +100,9 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
98
100
  return outcome;
99
101
  };
100
102
  toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
103
+ // Load MCP servers from settings chain (~/.claude/settings.json etc.)
104
+ const _settings = await loadSettings();
105
+ const _mcpClients = await loadMcpServers(toolExecutor, _settings);
101
106
  const timer = setTimeout(() => {
102
107
  emit({ type: 'timeout', duration_s: timeout });
103
108
  process.exit(2);
@@ -167,6 +172,10 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
167
172
  };
168
173
  toolExecutor = createToolExecutor({ pluginRegistry, delegateRunner: runDelegateFromTool });
169
174
 
175
+ // Load MCP servers from settings chain (~/.claude/settings.json etc.)
176
+ const _settings = await loadSettings();
177
+ const _mcpClients = await loadMcpServers(toolExecutor, _settings);
178
+
170
179
  // Auto-approve everything — no prompts
171
180
  const approval = new ApprovalManager({ autoApprove: true });
172
181
 
@@ -152,8 +152,13 @@ export function createToolExecutor({
152
152
  return project.resource.root;
153
153
  }
154
154
 
155
- async function commandCwd(args = {}) {
156
- return await resolvePath(args.cwd || null, args);
155
+ async function commandCwd(args = {}, { readOnly = false } = {}) {
156
+ try {
157
+ return await resolvePath(args.cwd || null, args);
158
+ } catch (err) {
159
+ if (readOnly) return args.cwd ? path.resolve(args.cwd) : process.cwd();
160
+ throw err;
161
+ }
157
162
  }
158
163
 
159
164
  function shellTargetPath(cwd, target) {
@@ -1390,7 +1395,7 @@ export function createToolExecutor({
1390
1395
  args._riskReason = classification.reason || shellCheck.reason;
1391
1396
  }
1392
1397
  args._classification = classification.classification; // 'safe' or 'contained'
1393
- const cwd = await commandCwd(args);
1398
+ const cwd = await commandCwd(args, { readOnly: classification.classification === 'safe' });
1394
1399
 
1395
1400
  // Background execution: start via the BackgroundTasks registry
1396
1401
  // and return immediately. Safety checks above still apply;
@@ -755,6 +755,13 @@ export class LocalAgentRelay {
755
755
  _makeWorkspaceSessionSubstrate(pluginRegistry) {
756
756
  return (agent, node, instruction, { scopedExecutor } = {}) => (async function* (relay) {
757
757
  const execContext = await relay._buildExecContext(instruction);
758
+ // Per-agent model override from YAML — same pattern as agents.mjs:330.
759
+ // If the agent definition declares a `model` field, it wins over
760
+ // the session default for this sub-agent's turn.
761
+ if (agent.model) execContext.model_override = agent.model;
762
+ if (agent.models && typeof agent.models === 'object' && Object.keys(agent.models).length) {
763
+ execContext.model_overrides = { ...(execContext.model_overrides || {}), ...agent.models };
764
+ }
758
765
  const slug = agent.slug || agent.command || agent.name || node?.agent_slug || node?.id || 'agent';
759
766
  execContext.sub_agent = {
760
767
  slug,
@@ -13,6 +13,7 @@
13
13
  import { spawn } from 'child_process';
14
14
 
15
15
  const MCP_PROTOCOL_VERSION = '2024-11-05';
16
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
16
17
 
17
18
  export class McpClient {
18
19
  /**
@@ -102,7 +103,9 @@ export class McpClient {
102
103
  }
103
104
  });
104
105
  this.connected = true;
105
- return this._initRemote();
106
+ await this._initRemote();
107
+ this._notifyRemote('notifications/initialized', {});
108
+ return this.serverInfo;
106
109
  }
107
110
 
108
111
  async _connectWebSocket() {
@@ -117,6 +120,7 @@ export class McpClient {
117
120
  capabilities: {},
118
121
  clientInfo: { name: 'bahulam-code', version: '2.0.0' },
119
122
  });
123
+ this._notifyRemote('notifications/initialized', {});
120
124
  return this.serverInfo;
121
125
  }
122
126
 
@@ -132,6 +136,7 @@ export class McpClient {
132
136
  capabilities: {},
133
137
  clientInfo: { name: 'bahulam-code', version: '2.0.0' },
134
138
  });
139
+ this._notifyRemote('notifications/initialized', {});
135
140
  return this.serverInfo;
136
141
  }
137
142
 
@@ -145,11 +150,39 @@ export class McpClient {
145
150
  return result;
146
151
  }
147
152
 
153
+ _notifyRemote(method, params) {
154
+ if (this.transport?.request) {
155
+ // WebSocket / sHTTP transports have native request() — use send()
156
+ // for notifications (no id, no response expected).
157
+ if (this.transport.send) {
158
+ this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
159
+ }
160
+ } else if (this.transport) {
161
+ // SSE transport — fire-and-forget via send()
162
+ this.transport.send({ jsonrpc: '2.0', method, params }).catch(() => {});
163
+ }
164
+ }
165
+
148
166
  async _transportRequest(method, params) {
149
167
  return new Promise((resolve, reject) => {
150
168
  const id = ++this.requestId;
151
- this.pending.set(id, { resolve, reject });
152
- this.transport.send({ jsonrpc: '2.0', id, method, params });
169
+ const timeout = setTimeout(() => {
170
+ if (this.pending.has(id)) {
171
+ this.pending.delete(id);
172
+ reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
173
+ }
174
+ }, DEFAULT_REQUEST_TIMEOUT_MS);
175
+ this.pending.set(id, {
176
+ resolve: (val) => { clearTimeout(timeout); resolve(val); },
177
+ reject: (err) => { clearTimeout(timeout); reject(err); },
178
+ });
179
+ this.transport.send({ jsonrpc: '2.0', id, method, params }).catch(err => {
180
+ if (this.pending.has(id)) {
181
+ this.pending.delete(id);
182
+ clearTimeout(timeout);
183
+ reject(err);
184
+ }
185
+ });
153
186
  });
154
187
  }
155
188
 
@@ -177,7 +210,14 @@ export class McpClient {
177
210
  result = await this._request('tools/call', params);
178
211
  }
179
212
  if (result?.content && Array.isArray(result.content)) {
180
- return result.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
213
+ const textParts = result.content.filter(c => c.type === 'text').map(c => c.text);
214
+ const imageParts = result.content.filter(c => c.type === 'image');
215
+ if (imageParts.length > 0 && textParts.length === 0) {
216
+ return result;
217
+ }
218
+ if (textParts.length > 0) {
219
+ return textParts.join('\n');
220
+ }
181
221
  }
182
222
  return result;
183
223
  }
@@ -222,7 +262,16 @@ export class McpClient {
222
262
  _request(method, params) {
223
263
  return new Promise((resolve, reject) => {
224
264
  const id = ++this.requestId;
225
- this.pending.set(id, { resolve, reject });
265
+ const timeout = setTimeout(() => {
266
+ if (this.pending.has(id)) {
267
+ this.pending.delete(id);
268
+ reject(new Error(`MCP request timeout: ${method} (${DEFAULT_REQUEST_TIMEOUT_MS}ms)`));
269
+ }
270
+ }, DEFAULT_REQUEST_TIMEOUT_MS);
271
+ this.pending.set(id, {
272
+ resolve: (val) => { clearTimeout(timeout); resolve(val); },
273
+ reject: (err) => { clearTimeout(timeout); reject(err); },
274
+ });
226
275
  const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
227
276
  this.process.stdin.write(msg + '\n');
228
277
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * MCP Settings Loader — bridges settings-based mcpServers to the tool executor.
3
+ *
4
+ * The agent-relay path already spawns MCP servers from plugin manifests.
5
+ * This module provides the same capability for CLI/REPL/headless mode by
6
+ * reading mcpServers from the settings chain (~/.claude/settings.json etc.)
7
+ * and optionally from ~/.bahulam/config.json.
8
+ *
9
+ * Usage:
10
+ * import { loadMcpServers } from '../mcp/loader.mjs';
11
+ * const mcpClients = await loadMcpServers(toolExecutor, settings);
12
+ * // ... later ...
13
+ * await mcpClients.disconnectAll();
14
+ */
15
+
16
+ import { McpClient } from './client.mjs';
17
+
18
+ /**
19
+ * Recursively expand ${VAR} patterns in a config object using process.env.
20
+ * Matches the behavior in agent-relay.mjs _expandEnvInMcpConfig.
21
+ * @param {*} value
22
+ * @returns {*}
23
+ */
24
+ function expandEnv(value) {
25
+ if (typeof value === 'string') {
26
+ return value.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '');
27
+ }
28
+ if (Array.isArray(value)) return value.map(expandEnv);
29
+ if (value && typeof value === 'object') {
30
+ return Object.fromEntries(
31
+ Object.entries(value).map(([k, v]) => [k, expandEnv(v)]),
32
+ );
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * Load and connect MCP servers from settings, registering their tools
39
+ * with the tool executor.
40
+ *
41
+ * @param {object} toolExecutor - the createToolExecutor() instance
42
+ * @param {object} settings - loaded settings (from loadSettings())
43
+ * @param {object} [options]
44
+ * @param {string} [options.pluginName='settings'] - namespace for tool registration
45
+ * @returns {Promise<{clients: Array, disconnectAll: Function}>}
46
+ */
47
+ export async function loadMcpServers(toolExecutor, settings, options = {}) {
48
+ const pluginName = options.pluginName || 'settings';
49
+ const servers = settings?.mcpServers || {};
50
+ const clients = [];
51
+
52
+ for (const [name, config] of Object.entries(servers)) {
53
+ if (!config || typeof config !== 'object') continue;
54
+ if (!config.command && !config.url) continue;
55
+
56
+ try {
57
+ // Merge the config's env block into process.env BEFORE expanding
58
+ // ${VAR} patterns in args/env. This matches the Claude Desktop
59
+ // convention: the env block sets vars for the spawned process
60
+ // AND for ${VAR} expansion in the same config.
61
+ if (config.env && typeof config.env === 'object') {
62
+ for (const [k, v] of Object.entries(config.env)) {
63
+ if (typeof v === 'string' && !process.env[k]) {
64
+ process.env[k] = v;
65
+ }
66
+ }
67
+ }
68
+ const expanded = expandEnv(config);
69
+ const client = new McpClient(expanded);
70
+ await client.connect();
71
+ const tools = await client.listTools();
72
+ for (const tool of tools) {
73
+ if (toolExecutor.registerMcpTool) {
74
+ toolExecutor.registerMcpTool(pluginName, name, tool.name, client, tool.inputSchema || {});
75
+ }
76
+ }
77
+ clients.push({ name, client });
78
+ if (process.env.MCP_DEBUG) {
79
+ process.stderr.write(`[mcp:settings] ${name}: ${tools.length} tools registered\n`);
80
+ }
81
+ } catch (err) {
82
+ // One server failure must never block the session.
83
+ if (process.env.MCP_DEBUG) {
84
+ process.stderr.write(`[mcp:settings] ${name} failed: ${err.message}\n`);
85
+ }
86
+ }
87
+ }
88
+
89
+ return {
90
+ clients,
91
+ async disconnectAll() {
92
+ for (const { name, client } of clients) {
93
+ try { toolExecutor?.unregisterMcpServer?.(pluginName, name); } catch {}
94
+ try { await client.disconnect(); } catch {}
95
+ }
96
+ },
97
+ };
98
+ }
@@ -16,6 +16,7 @@ export class StreamableHttpTransport {
16
16
  this.timeout = options.timeout || 30000;
17
17
  this.sessionId = options.sessionId || null;
18
18
  this.connected = false;
19
+ this.requestId = 0;
19
20
  }
20
21
 
21
22
  async connect() {
@@ -42,7 +43,7 @@ export class StreamableHttpTransport {
42
43
  * Collects all events and returns the final result.
43
44
  */
44
45
  async request(method, params) {
45
- const id = Date.now();
46
+ const id = ++this.requestId;
46
47
  const body = { jsonrpc: '2.0', id, method, params };
47
48
 
48
49
  const headers = {
@@ -238,6 +238,24 @@ const EXACT_SAFE = new Set([
238
238
  'python --version', 'python3 --version',
239
239
  ]);
240
240
 
241
+ /** Tools commonly invoked with -version/--version to probe the installed version.
242
+ * These are pure reads (no file access, no network) and should never require
243
+ * project-scoping — they probe the local system's SDK/toolchain. */
244
+ const VERSION_PROBE_TOOLS = new Set([
245
+ 'java', 'javac', 'python', 'python3', 'node', 'ruby', 'go', 'rustc',
246
+ 'deno', 'bun', 'php', 'perl', 'gcc', 'clang', 'make', 'cmake', 'mvn',
247
+ 'gradle', 'pip', 'npm', 'yarn', 'pnpm', 'cargo', 'swift', 'kotlin',
248
+ ]);
249
+
250
+ /** Absolute paths to system probes that are safe to invoke for read-only
251
+ * version/path queries. These live outside any project root and should not
252
+ * trigger project-scope errors. */
253
+ const SAFE_ABS_PROBE_PATHS = new Set([
254
+ '/usr/libexec/java_home',
255
+ '/usr/bin/xcode-select',
256
+ '/usr/bin/which',
257
+ ]);
258
+
241
259
  /**
242
260
  * Commands with allowed flags — allowlist approach.
243
261
  * Key: command name (or "git diff" for multi-word).
@@ -682,6 +700,23 @@ function classifySingleCommand(command) {
682
700
  return { classification: 'safe', reason: 'Echo without expansion' };
683
701
  }
684
702
 
703
+ // ── Version probe: <tool> -version / --version (pure OS read, no project needed) ──
704
+ if (VERSION_PROBE_TOOLS.has(baseCmd)) {
705
+ const token = tokenize(trimmed);
706
+ const flag = token[1] || '';
707
+ if (/^--?v(ersion)?$/.test(flag)) {
708
+ return { classification: 'safe', reason: `Version probe: ${baseCmd}` };
709
+ }
710
+ }
711
+
712
+ // ── Known system-probe absolute paths (e.g. /usr/libexec/java_home -V) ──
713
+ if (SAFE_ABS_PROBE_PATHS.has(baseCmd)) {
714
+ const rest = trimmed.slice(baseCmd.length).trim();
715
+ if (!rest || /^--?[a-z]/i.test(rest)) {
716
+ return { classification: 'safe', reason: `System probe: ${baseCmd}` };
717
+ }
718
+ }
719
+
685
720
  // ── Default: contained (unknown command, not explicitly blocked) ──
686
721
  return { classification: 'contained', reason: `Unknown command, defaulting to contained: ${baseCmd}` };
687
722
  }
@@ -291,6 +291,12 @@ async function main() {
291
291
  return;
292
292
  }
293
293
 
294
+ if (subcommand === 'mcp') {
295
+ const { handleMcpCommand } = await import('../commands/mcp.mjs');
296
+ await handleMcpCommand(subcommandArgs);
297
+ return;
298
+ }
299
+
294
300
  if (subcommand === 'plugin' || subcommand === 'plugins') {
295
301
  // `install`/`pull` moved to top-level. Detect the old form and redirect.
296
302
  if (subcommandArgs[0] === 'install' || subcommandArgs[0] === 'pull') {
@@ -338,6 +344,9 @@ async function main() {
338
344
  bahulam login Sign in via browser
339
345
  bahulam logout Sign out and clear credentials
340
346
  bahulam init Scaffold .bahulam config, memory, hooks, tasks
347
+ bahulam mcp add <name> ... Register an MCP server
348
+ bahulam mcp list List registered MCP servers
349
+ bahulam mcp test <name> Test an MCP server connection
341
350
  bahulam version Show version
342
351
 
343
352
  \x1b[1mDaemon:\x1b[0m
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Interactive per-role model form for /model (PRD-076 W7).
3
3
  *
4
- * ↑↓ picks a role row, ←→ cycles through [backend default] + the curated
4
+ * ↑↓ picks a role row, ←→ cycles through [backend default] + the available
5
5
  * platform catalog for that role, Enter applies to session overrides,
6
6
  * c resets every row to default, Esc cancels. Same raw-stdin overlay
7
7
  * pattern as the resume picker (repl-resume.mjs): pause readline, raw
@@ -35,19 +35,19 @@ function formatTokenLimit(value, label) {
35
35
  return `${Math.round(n)} ${label}`;
36
36
  }
37
37
 
38
- function optionRowsForRole(catalog, row) {
39
- const curated = (catalog || []).filter(m => m?.harness_validated && m?.id);
38
+ export function optionRowsForRole(catalog, row) {
39
+ const available = (catalog || []).filter(m => m?.id);
40
40
  const group = String(row?.optionGroup || 'text').toLowerCase();
41
41
  if (group === 'image_analysis') {
42
- return curated.filter(m => (
42
+ return available.filter(m => (
43
43
  ['image', 'multimodal'].includes(modelCategory(m))
44
44
  && !isImageGenerationModel(m)
45
45
  ));
46
46
  }
47
47
  if (group === 'image_generation') {
48
- return curated.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
48
+ return available.filter(m => modelCategory(m) === 'image' && isImageGenerationModel(m));
49
49
  }
50
- return curated.filter(m => ['text', 'chat'].includes(modelCategory(m)));
50
+ return available.filter(m => ['text', 'chat', 'multimodal'].includes(modelCategory(m)));
51
51
  }
52
52
 
53
53
  /**
@@ -63,11 +63,11 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
63
63
  if (!process.stdin.isTTY) return null;
64
64
  if (rl) rl.pause();
65
65
 
66
- const catalogRows = (catalog || []).filter(m => m?.harness_validated && m?.id);
66
+ const catalogRows = (catalog || []).filter(m => m?.id);
67
67
  const usingFallback = catalogRows.length === 0;
68
68
  const byId = new Map(catalogRows.map(m => [m.id, m]));
69
69
 
70
- // Per-row option list; a current override that isn't in the curated list
70
+ // Per-row option list; a current override that isn't in the catalog list
71
71
  // is appended so it stays visible and selectable.
72
72
  const rows = roles.map(r => {
73
73
  const optionIds = usingFallback
@@ -99,9 +99,7 @@ export async function pickModelOverridesForm({ rl, roles, catalog, fallbackIds,
99
99
  }
100
100
  const meta = byId.get(value);
101
101
  const badge = meta ? creditBadge(meta) : '';
102
- // Only flag uncurated picks when a curated catalog actually loaded
103
- // in fallback mode every option is a known backend model, not a stray.
104
- const flag = meta || usingFallback ? '' : c.yellow(' (uncurated)');
102
+ const flag = meta && meta.harness_validated === false ? c.yellow(' (uncurated)') : '';
105
103
  return `${c.brand(value)}${badge ? ` ${c.dim(badge)}` : ''}${flag}`;
106
104
  };
107
105
 
@@ -4398,11 +4398,51 @@ async function handleCommand(input, ctx) {
4398
4398
  process.stderr.write(`\n ${c.brand('Goodbye!')}\n\n`);
4399
4399
  process.exit(0);
4400
4400
 
4401
+ case '/mcp': {
4402
+ await handleMcpSlashCommand(rest, ctx);
4403
+ return;
4404
+ }
4405
+
4401
4406
  default:
4402
4407
  process.stderr.write(` ${c.gray(`Unknown: ${cmd}. Type /help.`)}\n`);
4403
4408
  }
4404
4409
  }
4405
4410
 
4411
+ /**
4412
+ * /mcp slash command — dispatches to the CLI's handleMcpCommand.
4413
+ * Supports: /mcp (status), /mcp add, /mcp remove, /mcp list, /mcp test.
4414
+ */
4415
+ async function handleMcpSlashCommand(rest, ctx) {
4416
+ const sub = String(rest || '').trim().split(/\s+/)[0]?.toLowerCase();
4417
+ if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
4418
+ // No subcommand → show connected server status from session state
4419
+ const mcpClients = ctx?.toolExecutor?._mcpClients || [];
4420
+ if (mcpClients.length === 0) {
4421
+ process.stderr.write(` ${c.dim('No MCP servers connected. Use:')} ${c.brand('/mcp add <name> --command <cmd> | --url <url>')}\n`);
4422
+ return;
4423
+ }
4424
+ process.stderr.write(`\n ${c.bold('MCP Servers')} (${mcpClients.length}):\n`);
4425
+ for (let i = 0; i < mcpClients.length; i++) {
4426
+ const cl = mcpClients[i];
4427
+ const name = cl.name || cl.config?.command || 'unknown';
4428
+ const endpoint = cl.config?.url || cl.config?.command || 'unknown';
4429
+ const status = cl.connected ? c.green('connected') : c.yellow('disconnected');
4430
+ process.stderr.write(` ${c.brand(String(i + 1).padStart(2))}. ${c.brand(name.padEnd(20))} ${status} ${c.dim(endpoint)}\n`);
4431
+ }
4432
+ process.stderr.write('\n');
4433
+ return;
4434
+ }
4435
+
4436
+ // Dispatch to the CLI handler — same code path as `bahulam mcp`
4437
+ try {
4438
+ const { handleMcpCommand } = await import('../commands/mcp.mjs');
4439
+ const mcpArgs = String(rest || '').trim().split(/\s+/);
4440
+ await handleMcpCommand(mcpArgs);
4441
+ } catch (err) {
4442
+ process.stderr.write(` ${c.red('✗')} ${c.dim(err.message)}\n`);
4443
+ }
4444
+ }
4445
+
4406
4446
  // ── Fetch User Profile ──
4407
4447
 
4408
4448
  async function fetchUser(ctx) {
@@ -10,6 +10,17 @@
10
10
  */
11
11
  import { spawn } from 'child_process';
12
12
 
13
+ /**
14
+ * Resolve shell binary and args for the current platform.
15
+ * Windows uses cmd.exe; Linux/macOS use bash.
16
+ */
17
+ function shellSpawnArgs(command) {
18
+ if (process.platform === 'win32') {
19
+ return ['cmd.exe', ['/d', '/s', '/c', command]];
20
+ }
21
+ return ['bash', ['-c', command]];
22
+ }
23
+
13
24
  // Strip ANSI escape sequences
14
25
  function stripAnsi(str) {
15
26
  // eslint-disable-next-line no-control-regex
@@ -73,7 +84,8 @@ export const BashTool = {
73
84
  let killTimer = null;
74
85
  let settled = false;
75
86
 
76
- const proc = spawn('bash', ['-c', input.command], {
87
+ const [shellBin, shellArgs] = shellSpawnArgs(input.command);
88
+ const proc = spawn(shellBin, shellArgs, {
77
89
  cwd: input.cwd,
78
90
  env: { ...process.env },
79
91
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -221,7 +233,8 @@ let bgJobId = 0;
221
233
 
222
234
  function runBackground(command, cwd) {
223
235
  const id = ++bgJobId;
224
- const proc = spawn('bash', ['-c', command], {
236
+ const [shellBin, shellArgs] = shellSpawnArgs(command);
237
+ const proc = spawn(shellBin, shellArgs, {
225
238
  cwd,
226
239
  detached: true,
227
240
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -345,15 +345,40 @@ export const COMMANDS = {
345
345
  },
346
346
 
347
347
  '/mcp': {
348
- description: 'Show MCP server status',
349
- handler(args, state) {
350
- if (!state._mcpClients || state._mcpClients.length === 0) {
351
- return 'No MCP servers connected.';
348
+ description: 'Manage MCP servers (add, remove, list, test, status)',
349
+ async handler(args, state) {
350
+ const sub = (args || '').trim().split(/\s+/)[0]?.toLowerCase();
351
+
352
+ // No subcommand → show status (default)
353
+ if (!sub || !['add', 'remove', 'rm', 'list', 'ls', 'test'].includes(sub)) {
354
+ if (!state._mcpClients || state._mcpClients.length === 0) {
355
+ return 'No MCP servers connected. Use: /mcp add <name> --command <cmd> | --url <url>';
356
+ }
357
+ const lines = state._mcpClients.map((c, i) => {
358
+ const name = c.name || c.config?.command || 'unknown';
359
+ const endpoint = c.config?.url || c.config?.command || 'unknown';
360
+ return ` ${i + 1}. ${name} (${endpoint}) — ${c.connected ? 'connected' : 'disconnected'}`;
361
+ });
362
+ return `MCP servers:\n${lines.join('\n')}`;
363
+ }
364
+
365
+ // Dispatch to the CLI handler — same code path as `bahulam mcp`
366
+ try {
367
+ const { handleMcpCommand } = await import('../commands/mcp.mjs');
368
+ const mcpArgs = (args || '').trim().split(/\s+/);
369
+ // Capture stdout/stderr from the handler
370
+ const origWrite = process.stdout.write.bind(process.stdout);
371
+ const origErrWrite = process.stderr.write.bind(process.stderr);
372
+ let captured = '';
373
+ process.stderr.write = (chunk) => { captured += chunk; return true; };
374
+ process.stdout.write = (chunk) => { captured += chunk; return true; };
375
+ await handleMcpCommand(mcpArgs);
376
+ process.stderr.write = origErrWrite;
377
+ process.stdout.write = origWrite;
378
+ return captured.trim() || 'Done.';
379
+ } catch (err) {
380
+ return `MCP command failed: ${err.message}`;
352
381
  }
353
- const lines = state._mcpClients.map((c, i) =>
354
- ` ${i + 1}. ${c.config?.command || 'unknown'} — ${c.connected ? 'connected' : 'disconnected'}`
355
- );
356
- return `MCP servers:\n${lines.join('\n')}`;
357
382
  },
358
383
  },
359
384
 
@@ -516,7 +541,7 @@ export const COMMANDS = {
516
541
  * @param {object} state - agent loop state
517
542
  * @returns {{ response: string, exit: boolean }}
518
543
  */
519
- export function executeCommand(input, state) {
544
+ export async function executeCommand(input, state) {
520
545
  const parts = input.split(/\s+/);
521
546
  const cmd = parts[0].toLowerCase();
522
547
  const args = parts.slice(1).join(' ');
@@ -526,7 +551,7 @@ export function executeCommand(input, state) {
526
551
  return { response: `Unknown command: ${cmd}. Type /help for available commands.`, exit: false };
527
552
  }
528
553
 
529
- const response = command.handler(args, state);
554
+ const response = await command.handler(args, state);
530
555
  return { response, exit: response === 'EXIT' };
531
556
  }
532
557