@bahulam/code 0.1.21 → 0.1.23

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.
@@ -22,7 +22,8 @@ import * as path from 'node:path';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
24
24
  import { composedToolName, validateCompose } from './pi-compose.mjs';
25
- import { bahulamHome } from '../core/paths.mjs';
25
+ import { expandStateContextTools } from './state-tools.mjs';
26
+ import { pluginDirs } from '../core/paths.mjs';
26
27
 
27
28
  const TOOL_NAME_RE = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
28
29
  const AGENT_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
@@ -182,6 +183,25 @@ export async function preflightPlugin(pluginDir, opts = {}) {
182
183
  }
183
184
  }
184
185
 
186
+ // 4b. Generated state tools. A plugin that declares config.state.context_tools
187
+ // gets read-only tools synthesized at load time (state-tools.mjs), so an
188
+ // agent may legitimately allowlist them. Without this, a plugin referencing
189
+ // its own generated tool fails preflight as "not defined by this plugin".
190
+ const stateToolNames = new Set();
191
+ for (const generated of expandStateContextTools(name, pluginDir, manifest.config?.state)) {
192
+ const gname = generated.name;
193
+ if (!gname) continue;
194
+ if (toolNames.has(gname) || composedToolNames.has(gname)) {
195
+ errors.push(`Generated state tool "${gname}" collides with an existing tool of the same name`);
196
+ continue;
197
+ }
198
+ if (RESERVED_TOOL_NAMES.has(gname)) {
199
+ errors.push(`Generated state tool "${gname}" shadows a built-in tool — rename it in config.state.context_tools`);
200
+ continue;
201
+ }
202
+ stateToolNames.add(gname);
203
+ }
204
+
185
205
  // 5. Sub-agent checks
186
206
  const agentSlugs = new Set();
187
207
  for (const [i, agent] of agents.entries()) {
@@ -207,7 +227,8 @@ export async function preflightPlugin(pluginDir, opts = {}) {
207
227
  }
208
228
  continue;
209
229
  }
210
- if (!toolNames.has(toolRef) && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
230
+ if (!toolNames.has(toolRef) && !stateToolNames.has(toolRef)
231
+ && !composedToolNames.has(toolRef) && !RESERVED_TOOL_NAMES.has(toolRef)) {
211
232
  errors.push(`Agent "${slug}": tool "${toolRef}" is not defined by this plugin and is not a built-in`);
212
233
  }
213
234
  }
@@ -245,12 +266,12 @@ export async function preflightPlugin(pluginDir, opts = {}) {
245
266
  * Convenience — collect installed plugin names from both search paths.
246
267
  * Used by the installer to detect collisions.
247
268
  */
248
- export function existingInstalledNames(cwd = process.cwd()) {
269
+ export function existingInstalledNames(_cwd = process.cwd()) {
249
270
  const names = [];
250
- for (const dir of [
251
- path.join(cwd, '.bahulam', 'plugins'),
252
- path.join(bahulamHome(), 'plugins'),
253
- ]) {
271
+ // Plugins live in one global root. `cwd` is kept only so existing call
272
+ // sites keep working; it must never influence discovery, since that
273
+ // cwd-dependence is the thing this removed.
274
+ for (const dir of pluginDirs()) {
254
275
  if (!fs.existsSync(dir)) continue;
255
276
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
256
277
  if (!entry.isDirectory() || entry.name.endsWith('.disabled')) continue;
@@ -9,24 +9,20 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import { parsePluginManifestFile, validatePluginManifest } from './manifest.mjs';
11
11
  import { expandComposedTools } from './pi-compose.mjs';
12
- import { bahulamHome } from '../core/paths.mjs';
13
-
14
- const DEFAULT_PLUGIN_DIRS = () => [
15
- path.join(process.cwd(), '.bahulam', 'plugins'),
16
- path.join(bahulamHome(), 'plugins'),
17
- ];
12
+ import { expandStateContextTools } from './state-tools.mjs';
13
+ import { pluginDirs as defaultPluginDirs } from '../core/paths.mjs';
18
14
 
19
15
  export class PluginRegistry {
20
16
  /**
21
17
  * @param {Object} [options]
22
- * @param {string[]} [options.pluginDirs] - Directories to scan (default: project .bahulam/plugins + ~/.bahulam/plugins)
18
+ * @param {string[]} [options.pluginDirs] - Directories to scan (default: ~/.bahulam/plugins)
23
19
  * @param {string[]} [options.disabled] - Plugin names to skip
24
20
  * @param {string[]} [options.enabled] - If provided, only these plugin names are loaded
25
21
  * @param {string[]} [options.active] - Alias for enabled
26
22
  * @param {string} [options.pluginDir] - Legacy single plugin dir (mapped to pluginDirs[0])
27
23
  */
28
24
  constructor({ pluginDirs, disabled = [], enabled = null, active = null, pluginDir } = {}) {
29
- this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : DEFAULT_PLUGIN_DIRS());
25
+ this.pluginDirs = pluginDirs || (pluginDir ? [pluginDir] : defaultPluginDirs());
30
26
  this.disabled = new Set(
31
27
  (Array.isArray(disabled) ? disabled : [])
32
28
  .map(s => String(s).trim().toLowerCase())
@@ -113,7 +109,7 @@ export class PluginRegistry {
113
109
  return false;
114
110
  }
115
111
 
116
- // Check for existing (first wins project overrides global)
112
+ // Duplicates are skipped the first manifest scanned wins.
117
113
  if (this.plugins.has(lowerName)) {
118
114
  return false; // silently skip duplicates
119
115
  }
@@ -165,6 +161,13 @@ export class PluginRegistry {
165
161
  plugin._dir,
166
162
  plugin.config?.composes || [],
167
163
  ));
164
+ // Manifest-declared state query tools. Synthesized rather than
165
+ // imported, so they carry `_state_tool` instead of a module path.
166
+ tools.push(...expandStateContextTools(
167
+ plugin.metadata?.name || '',
168
+ plugin._dir,
169
+ plugin.config?.state,
170
+ ));
168
171
  }
169
172
  return tools;
170
173
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Declared-state tool expansion.
3
+ *
4
+ * A plugin that declares tables in `config.state.tables` can ask the CLI
5
+ * to generate read-only query tools for the agent, instead of hand-writing
6
+ * a `tools/*.mjs` handler for every "let me look at my data" case. The
7
+ * manifest block looks like:
8
+ *
9
+ * state:
10
+ * tables:
11
+ * - name: questions
12
+ * columns: [...]
13
+ * context_tools:
14
+ * - name: list_questions
15
+ * table: questions
16
+ * description: List exam questions, optionally filtered by topic
17
+ * parameters:
18
+ * type: object
19
+ * properties:
20
+ * topic: { type: string }
21
+ * where: "topic = ?"
22
+ * params: [topic]
23
+ *
24
+ * `expandStateContextTools` turns that into registry tool entries. The
25
+ * execution side (tool-executor) recognizes the `_state_tool` marker and
26
+ * runs the query against the plugin's own state DB — so the same tool
27
+ * flows through every existing surface: `listPluginToolSchemas()`,
28
+ * the client_tools map, sub-agent tool allowlists, and `/tools`.
29
+ *
30
+ * This mirrors `pi-compose.mjs`: one expansion per composition kind, both
31
+ * feeding the same `PluginRegistry.listTools()` contract.
32
+ */
33
+
34
+ /** Tool names are passed to the model, so keep them in the same charset the rest of the CLI uses. */
35
+ function sanitizeToolName(value) {
36
+ return String(value || '').trim().replace(/[^A-Za-z0-9_]/g, '_');
37
+ }
38
+
39
+ /**
40
+ * Expand a plugin's declared `config.state.context_tools` into tool entries.
41
+ *
42
+ * Entries carry no `tool:` module path — they are synthesized, and
43
+ * `_state_tool` tells the executor to query the plugin's state DB rather
44
+ * than import a handler file.
45
+ *
46
+ * @param {string} pluginName
47
+ * @param {string} pluginDir
48
+ * @param {object|null|undefined} state normalized `config.state`
49
+ * @returns {object[]}
50
+ */
51
+ export function expandStateContextTools(pluginName, pluginDir, state) {
52
+ const tools = [];
53
+ const declared = Array.isArray(state?.context_tools) ? state.context_tools : [];
54
+ if (!pluginName || declared.length === 0) return tools;
55
+
56
+ const tableNames = new Set((state?.tables || []).map(t => t.name));
57
+
58
+ for (const tool of declared) {
59
+ const name = sanitizeToolName(tool?.name);
60
+ if (!name) continue;
61
+ // A generated tool must never reach past the plugin's own declared
62
+ // tables — that is the whole safety story for author-supplied `where`.
63
+ if (!tableNames.has(tool.table)) continue;
64
+
65
+ tools.push({
66
+ name,
67
+ description: tool.description || `List rows from ${tool.table}`,
68
+ input_schema: tool.parameters || { type: 'object', properties: {} },
69
+ // No module to load — signals "synthesized" to any consumer that
70
+ // would otherwise try to resolve a handler file.
71
+ tool: null,
72
+ plugin_name: pluginName,
73
+ _plugin_name: pluginName,
74
+ _plugin_dir: pluginDir,
75
+ _state_tool: {
76
+ plugin: pluginName,
77
+ table: tool.table,
78
+ where: tool.where || '',
79
+ params: Array.isArray(tool.params) ? tool.params : [],
80
+ limit: tool.limit || 50,
81
+ },
82
+ });
83
+ }
84
+
85
+ return tools;
86
+ }
@@ -206,17 +206,18 @@ function pluginDataDir(pluginName) {
206
206
  return dir;
207
207
  }
208
208
 
209
- function openDb(pluginName) {
210
- if (_handles.has(pluginName)) return _handles.get(pluginName);
211
- const dir = pluginDataDir(pluginName);
212
- const dbPath = path.join(dir, 'state.db');
213
- const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
214
- // WAL: multiple readers, one writer; robust against concurrent view+agent.
215
- db.exec('PRAGMA journal_mode = WAL');
216
- db.exec('PRAGMA synchronous = NORMAL');
217
- db.exec('PRAGMA foreign_keys = ON');
218
- // Bootstrap schema — idempotent so evolving plugins never crash on start.
219
- db.exec(`
209
+ function openDb(pluginName, { tables = [] } = {}) {
210
+ let handle = _handles.get(pluginName);
211
+ if (!handle) {
212
+ const dir = pluginDataDir(pluginName);
213
+ const dbPath = path.join(dir, 'state.db');
214
+ const db = DatabaseSync ? new DatabaseSync(dbPath) : new JsonStateDb(dbPath);
215
+ // WAL: multiple readers, one writer; robust against concurrent view+agent.
216
+ db.exec('PRAGMA journal_mode = WAL');
217
+ db.exec('PRAGMA synchronous = NORMAL');
218
+ db.exec('PRAGMA foreign_keys = ON');
219
+ // Bootstrap schema — idempotent so evolving plugins never crash on start.
220
+ db.exec(`
220
221
  CREATE TABLE IF NOT EXISTS kv (
221
222
  key TEXT PRIMARY KEY,
222
223
  value TEXT NOT NULL,
@@ -231,11 +232,99 @@ function openDb(pluginName) {
231
232
  CREATE INDEX IF NOT EXISTS records_stream_idx
232
233
  ON records(stream, id DESC);
233
234
  `);
234
- const handle = { db, dir, path: dbPath };
235
- _handles.set(pluginName, handle);
235
+ handle = { db, dir, path: dbPath, schemaSig: null, declaredTables: new Set() };
236
+ _handles.set(pluginName, handle);
237
+ }
238
+ // Declared tables are applied on every open call, not just the first —
239
+ // a plugin installed mid-session (or a version bump that adds columns)
240
+ // must still converge the DB. The signature check makes the repeat
241
+ // calls free once the schema already matches.
242
+ applyDeclaredSchema(handle, tables);
236
243
  return handle;
237
244
  }
238
245
 
246
+ function quoteIdent(name) {
247
+ return `"${String(name).replace(/"/g, '""')}"`;
248
+ }
249
+
250
+ /** Render one normalized column as the DDL fragment it contributes. */
251
+ function columnToSql(col) {
252
+ const parts = [quoteIdent(col.name), col.type];
253
+ if (col.primary) parts.push('PRIMARY KEY');
254
+ if (col.autoincrement) parts.push('AUTOINCREMENT');
255
+ if (col.not_null) parts.push('NOT NULL');
256
+ // `default` is author-supplied SQL text (e.g. "'medium'", "0"), which is
257
+ // why normalizeState stringifies it and why it is never parameterized.
258
+ if (col.default != null) parts.push(`DEFAULT ${col.default}`);
259
+ if (col.references) parts.push(`REFERENCES ${col.references}`);
260
+ return parts.join(' ');
261
+ }
262
+
263
+ /**
264
+ * Create/evolve the tables a plugin declared in `config.state.tables`.
265
+ *
266
+ * Additive by contract: a table that exists is left alone apart from
267
+ * genuinely new columns, which are appended with ALTER TABLE. Nothing is
268
+ * ever dropped or retyped, so bumping a plugin version can't cost the
269
+ * user their data. Re-running with an unchanged declaration is a no-op.
270
+ *
271
+ * Silently does nothing on the JSON fallback backend, where arbitrary SQL
272
+ * isn't available — plugins that need declared tables need node:sqlite.
273
+ */
274
+ function applyDeclaredSchema(handle, tables) {
275
+ if (!DatabaseSync) return;
276
+ if (!Array.isArray(tables) || tables.length === 0) {
277
+ handle.declaredTables = new Set();
278
+ handle.schemaSig = null;
279
+ return;
280
+ }
281
+ const sig = JSON.stringify(tables);
282
+ if (handle.schemaSig === sig) return;
283
+
284
+ const { db } = handle;
285
+ for (const table of tables) {
286
+ let existing = new Set();
287
+ try {
288
+ existing = new Set(db.prepare(`PRAGMA table_info(${quoteIdent(table.name)})`).all().map(r => String(r.name)));
289
+ } catch { /* table doesn't exist yet — created below */ }
290
+
291
+ try {
292
+ if (existing.size === 0) {
293
+ const defs = table.columns.map(columnToSql).join(', ');
294
+ db.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(table.name)} (${defs});`);
295
+ } else {
296
+ for (const col of table.columns) {
297
+ if (existing.has(col.name)) continue;
298
+ // SQLite refuses PRIMARY KEY / NOT NULL-without-default on ALTER
299
+ // TABLE ADD COLUMN. Strip those rather than fail the migration.
300
+ const addable = {
301
+ ...col,
302
+ primary: false,
303
+ autoincrement: false,
304
+ not_null: col.not_null && col.default != null,
305
+ };
306
+ db.exec(`ALTER TABLE ${quoteIdent(table.name)} ADD COLUMN ${columnToSql(addable)};`);
307
+ }
308
+ }
309
+ for (const index of (table.indexes || [])) {
310
+ const cols = index.columns.map(quoteIdent).join(', ');
311
+ const idxName = `${table.name}_${index.columns.join('_')}_idx`;
312
+ db.exec(
313
+ `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX IF NOT EXISTS ${quoteIdent(idxName)} `
314
+ + `ON ${quoteIdent(table.name)} (${cols});`,
315
+ );
316
+ }
317
+ } catch (err) {
318
+ if (process.env.DEBUG) {
319
+ console.error(`Failed to apply declared schema for table ${table.name}: ${err.message}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ handle.declaredTables = new Set(tables.map(t => t.name));
325
+ handle.schemaSig = sig;
326
+ }
327
+
239
328
  function now() { return new Date().toISOString(); }
240
329
 
241
330
  function deepMerge(base, patch) {
@@ -261,10 +350,15 @@ function deepMerge(base, patch) {
261
350
  * @param {(evt: {plugin: string, op: string, kind: 'kv'|'records', target: string, at: string}) => void} [opts.emit]
262
351
  * Called (debounced) after every write commits. The workspace server
263
352
  * turns this into an SSE `plugin_state_changed` event for the browser.
264
- * @returns proxy with { get, set, patch, append, list, query, delete, close, db, path }
353
+ * @param {object[]} [opts.tables]
354
+ * Tables declared in the plugin manifest's `config.state.tables`. They
355
+ * are created (and additively migrated) when the DB opens, so a plugin
356
+ * can own real domain tables — questions, documents, answers — without
357
+ * shipping its own migration logic.
358
+ * @returns proxy with { get, set, patch, append, list, query, delete, summary, readTable, close, db, path }
265
359
  */
266
- export function makePluginState(pluginName, { emit = null } = {}) {
267
- const { db, path: dbPath } = openDb(pluginName);
360
+ export function makePluginState(pluginName, { emit = null, tables = [] } = {}) {
361
+ const { db, path: dbPath, declaredTables } = openDb(pluginName, { tables });
268
362
 
269
363
  // One debounce timer per (kind, target). Fast writes coalesce into
270
364
  // exactly one plugin_state_changed event. Pending entry is stored so
@@ -398,6 +492,64 @@ export function makePluginState(pluginName, { emit = null } = {}) {
398
492
  return { changes: info.changes, lastInsertRowid: Number(info.lastInsertRowid) };
399
493
  },
400
494
 
495
+ /**
496
+ * Read the slices a manifest flagged as `context_always` so the agent
497
+ * can orient itself at session start without spending a tool call.
498
+ *
499
+ * Returns `{ kv: { <key>: value }, streams: { <stream>: [rows] } }`.
500
+ * Anything the declaration doesn't name is deliberately omitted —
501
+ * this is the "small and high-signal" tier, and its whole value is
502
+ * that it stays cheap.
503
+ *
504
+ * @param {object[]} decl normalized `config.state.context_always`
505
+ */
506
+ summary(decl = []) {
507
+ const out = { kv: {}, streams: {} };
508
+ for (const item of (Array.isArray(decl) ? decl : [])) {
509
+ if (!item || typeof item !== 'object') continue;
510
+ try {
511
+ if (item.kind === 'records' && item.stream) {
512
+ out.streams[item.stream] = this.list(item.stream, {
513
+ limit: item.limit || 5,
514
+ order: 'desc',
515
+ });
516
+ } else if (item.kind === 'kv' && item.key) {
517
+ out.kv[item.key] = this.get(item.key, null);
518
+ }
519
+ } catch { /* one unreadable slice must not blank the whole summary */ }
520
+ }
521
+ return out;
522
+ },
523
+
524
+ /**
525
+ * Read rows from a table the plugin declared in `config.state.tables`.
526
+ * Backs the auto-generated `context_tools`.
527
+ *
528
+ * Only declared tables are reachable, so a manifest typo can't turn a
529
+ * generated tool into arbitrary table access. `where` is plugin-authored
530
+ * SQL (same trust model as `query()`); values are always bound.
531
+ *
532
+ * @param {string} table
533
+ * @param {object} [opts]
534
+ * @param {string} [opts.where] e.g. "topic = ?"
535
+ * @param {any[]} [opts.params] bound positionally against `where`
536
+ * @param {number} [opts.limit]
537
+ * @returns {object[]}
538
+ */
539
+ readTable(table, { where = '', params = [], limit = 50 } = {}) {
540
+ if (!DatabaseSync) {
541
+ throw new Error('Declared state tables require the native node:sqlite backend');
542
+ }
543
+ const name = String(table || '');
544
+ if (!declaredTables.has(name)) {
545
+ throw new Error(`unknown declared state table: ${name || '(none)'}`);
546
+ }
547
+ const args = Array.isArray(params) ? params : [params];
548
+ const cap = Math.max(1, Math.min(1000, Math.floor(limit) || 50));
549
+ const clause = where ? ` WHERE ${where}` : '';
550
+ return db.prepare(`SELECT * FROM ${quoteIdent(name)}${clause} LIMIT ?`).all(...args, cap);
551
+ },
552
+
401
553
  /** Direct DatabaseSync handle for callers that know what they need. */
402
554
  get db() { return db; },
403
555
  /** Absolute path to the DB file on disk. */
@@ -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
@@ -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) {
@@ -111,7 +111,13 @@ export function createToolRegistry({
111
111
  if (!pluginName) return null;
112
112
  if (pluginStateHandles.has(pluginName)) return pluginStateHandles.get(pluginName);
113
113
  const { makePluginState } = await import('../plugins/state.mjs');
114
- const state = makePluginState(pluginName, { emit: stateEmit });
114
+ // Pass the plugin's declared tables through, otherwise this path
115
+ // opens the shared handle without them — and `applyDeclaredSchema`
116
+ // treats an empty list as "this plugin declares nothing", which
117
+ // would *un-apply* a schema the executor path had already applied.
118
+ const plugin = pluginRegistry?.get?.(pluginName) || null;
119
+ const tables = plugin?.config?.state?.tables || [];
120
+ const state = makePluginState(pluginName, { emit: stateEmit, tables });
115
121
  pluginStateHandles.set(pluginName, state);
116
122
  return state;
117
123
  }
@@ -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