@agenticmail/enterprise 0.5.248 → 0.5.249

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,574 @@
1
+ /**
2
+ * MCP Process Manager
3
+ *
4
+ * Manages the lifecycle of external MCP servers registered via the dashboard.
5
+ * Handles stdio process spawning, HTTP/SSE connections, tool discovery,
6
+ * health monitoring, and automatic restarts.
7
+ *
8
+ * Architecture:
9
+ * Dashboard registers MCP server → stored in mcp_servers table
10
+ * → McpProcessManager.start() loads enabled servers
11
+ * → Spawns stdio processes / connects to HTTP endpoints
12
+ * → Discovers tools via JSON-RPC initialize + tools/list
13
+ * → Tools available via getToolsForAgent(agentId)
14
+ * → Agent loop calls tool → proxied to MCP process via callTool()
15
+ */
16
+
17
+ import { spawn, type ChildProcess } from 'node:child_process';
18
+ import { EventEmitter } from 'node:events';
19
+
20
+ // ─── Types ───────────────────────────────────────────────
21
+
22
+ export interface McpServerConfig {
23
+ id: string;
24
+ name: string;
25
+ type: 'stdio' | 'sse' | 'http';
26
+ enabled: boolean;
27
+ // stdio
28
+ command?: string;
29
+ args?: string[];
30
+ env?: Record<string, string>;
31
+ // http / sse
32
+ url?: string;
33
+ apiKey?: string;
34
+ headers?: Record<string, string>;
35
+ // common
36
+ autoRestart?: boolean;
37
+ timeout?: number; // seconds
38
+ description?: string;
39
+ /** Agent IDs that can use this server. Empty/undefined = all agents */
40
+ assignedAgents?: string[];
41
+ }
42
+
43
+ export interface McpDiscoveredTool {
44
+ name: string;
45
+ description?: string;
46
+ inputSchema: { type: string; properties?: Record<string, any>; required?: string[] };
47
+ }
48
+
49
+ interface McpServerState {
50
+ config: McpServerConfig;
51
+ status: 'starting' | 'connected' | 'error' | 'stopped';
52
+ process?: ChildProcess;
53
+ tools: McpDiscoveredTool[];
54
+ error?: string;
55
+ restartCount: number;
56
+ lastStarted?: Date;
57
+ /** JSON-RPC request ID counter */
58
+ rpcId: number;
59
+ /** Pending RPC responses */
60
+ pendingRpc: Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>;
61
+ /** Buffered stdout data for line-based parsing */
62
+ stdoutBuffer: string;
63
+ }
64
+
65
+ export interface McpProcessManagerConfig {
66
+ engineDb: any;
67
+ orgId?: string;
68
+ /** Max restart attempts before giving up */
69
+ maxRestarts?: number;
70
+ /** Delay between restarts (ms) */
71
+ restartDelayMs?: number;
72
+ /** Tool discovery timeout (ms) */
73
+ discoveryTimeoutMs?: number;
74
+ }
75
+
76
+ // ─── Manager ─────────────────────────────────────────────
77
+
78
+ export class McpProcessManager extends EventEmitter {
79
+ private db: any;
80
+ private orgId: string;
81
+ private servers = new Map<string, McpServerState>();
82
+ private maxRestarts: number;
83
+ private restartDelayMs: number;
84
+ private discoveryTimeoutMs: number;
85
+ private started = false;
86
+ private healthTimer: NodeJS.Timeout | null = null;
87
+
88
+ constructor(config: McpProcessManagerConfig) {
89
+ super();
90
+ this.db = config.engineDb;
91
+ this.orgId = config.orgId || 'default';
92
+ this.maxRestarts = config.maxRestarts ?? 5;
93
+ this.restartDelayMs = config.restartDelayMs ?? 3000;
94
+ this.discoveryTimeoutMs = config.discoveryTimeoutMs ?? 30000;
95
+ }
96
+
97
+ /** Start the manager — load all enabled MCP servers from DB and connect */
98
+ async start(): Promise<void> {
99
+ if (this.started) return;
100
+ this.started = true;
101
+
102
+ try {
103
+ const rows = await this.db.query(
104
+ `SELECT * FROM mcp_servers WHERE org_id = $1`,
105
+ [this.orgId]
106
+ );
107
+ const servers = (rows || []).map((r: any) => {
108
+ const config = typeof r.config === 'string' ? JSON.parse(r.config) : (r.config || {});
109
+ return { ...config, id: r.id } as McpServerConfig;
110
+ });
111
+
112
+ const enabled = servers.filter(s => s.enabled !== false);
113
+ if (enabled.length === 0) {
114
+ console.log('[mcp-manager] No enabled MCP servers found');
115
+ return;
116
+ }
117
+
118
+ console.log(`[mcp-manager] Starting ${enabled.length} MCP server(s)...`);
119
+
120
+ // Connect all in parallel
121
+ await Promise.allSettled(enabled.map(s => this.connectServer(s)));
122
+
123
+ // Start health check timer (every 60s)
124
+ this.healthTimer = setInterval(() => this.healthCheck(), 60000);
125
+ } catch (e: any) {
126
+ if (e.message?.includes('does not exist') || e.message?.includes('no such table')) {
127
+ console.log('[mcp-manager] mcp_servers table does not exist yet — skipping');
128
+ return;
129
+ }
130
+ console.error(`[mcp-manager] Start failed: ${e.message}`);
131
+ }
132
+ }
133
+
134
+ /** Stop all servers and clean up */
135
+ async stop(): Promise<void> {
136
+ this.started = false;
137
+ if (this.healthTimer) { clearInterval(this.healthTimer); this.healthTimer = null; }
138
+
139
+ for (const [id, state] of Array.from(this.servers)) {
140
+ this.killProcess(state);
141
+ console.log(`[mcp-manager] Stopped server: ${state.config.name} (${id})`);
142
+ }
143
+ this.servers.clear();
144
+ }
145
+
146
+ /** Connect a single MCP server (stdio spawn or HTTP/SSE connect) */
147
+ async connectServer(config: McpServerConfig): Promise<void> {
148
+ // Clean up existing if reconnecting
149
+ const existing = this.servers.get(config.id);
150
+ if (existing) this.killProcess(existing);
151
+
152
+ const state: McpServerState = {
153
+ config,
154
+ status: 'starting',
155
+ tools: [],
156
+ restartCount: 0,
157
+ rpcId: 0,
158
+ pendingRpc: new Map(),
159
+ stdoutBuffer: '',
160
+ };
161
+ this.servers.set(config.id, state);
162
+
163
+ try {
164
+ if (config.type === 'stdio') {
165
+ await this.connectStdio(state);
166
+ } else {
167
+ await this.connectHttp(state);
168
+ }
169
+ } catch (e: any) {
170
+ state.status = 'error';
171
+ state.error = e.message;
172
+ console.error(`[mcp-manager] Failed to connect ${config.name}: ${e.message}`);
173
+ this.updateDbStatus(config.id, 'error', 0, []);
174
+ }
175
+ }
176
+
177
+ /** Disconnect and remove a server */
178
+ async disconnectServer(serverId: string): Promise<void> {
179
+ const state = this.servers.get(serverId);
180
+ if (state) {
181
+ this.killProcess(state);
182
+ this.servers.delete(serverId);
183
+ }
184
+ }
185
+
186
+ /** Hot-reload: add or update a server config without restarting the whole manager */
187
+ async reloadServer(serverId: string): Promise<void> {
188
+ try {
189
+ const rows = await this.db.query(`SELECT * FROM mcp_servers WHERE id = $1`, [serverId]);
190
+ if (!rows?.length) {
191
+ await this.disconnectServer(serverId);
192
+ return;
193
+ }
194
+ const config = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
195
+ const serverConfig: McpServerConfig = { ...config, id: rows[0].id };
196
+
197
+ if (serverConfig.enabled === false) {
198
+ await this.disconnectServer(serverId);
199
+ return;
200
+ }
201
+ await this.connectServer(serverConfig);
202
+ } catch (e: any) {
203
+ console.error(`[mcp-manager] Reload failed for ${serverId}: ${e.message}`);
204
+ }
205
+ }
206
+
207
+ // ─── Tool Access ───────────────────────────────────────
208
+
209
+ /** Get all discovered tools across all connected servers, optionally filtered by agent */
210
+ getToolsForAgent(agentId?: string): Array<McpDiscoveredTool & { serverId: string; serverName: string }> {
211
+ const tools: Array<McpDiscoveredTool & { serverId: string; serverName: string }> = [];
212
+
213
+ for (const [id, state] of Array.from(this.servers)) {
214
+ if (state.status !== 'connected') continue;
215
+
216
+ // Check agent assignment
217
+ if (agentId && state.config.assignedAgents?.length) {
218
+ if (!state.config.assignedAgents.includes(agentId)) continue;
219
+ }
220
+
221
+ for (const tool of state.tools) {
222
+ tools.push({ ...tool, serverId: id, serverName: state.config.name });
223
+ }
224
+ }
225
+ return tools;
226
+ }
227
+
228
+ /** Get all connected server statuses */
229
+ getServerStatuses(): Array<{ id: string; name: string; status: string; toolCount: number; error?: string }> {
230
+ return Array.from(this.servers.values()).map(s => ({
231
+ id: s.config.id,
232
+ name: s.config.name,
233
+ status: s.status,
234
+ toolCount: s.tools.length,
235
+ error: s.error,
236
+ }));
237
+ }
238
+
239
+ /** Call a tool on its MCP server */
240
+ async callTool(toolName: string, args: any, agentId?: string): Promise<{ content: string; isError?: boolean }> {
241
+ // Find which server owns this tool
242
+ for (const [_id, state] of Array.from(this.servers)) {
243
+ if (state.status !== 'connected') continue;
244
+ if (agentId && state.config.assignedAgents?.length) {
245
+ if (!state.config.assignedAgents.includes(agentId)) continue;
246
+ }
247
+
248
+ const tool = state.tools.find(t => t.name === toolName);
249
+ if (!tool) continue;
250
+
251
+ // Route to correct transport
252
+ if (state.config.type === 'stdio') {
253
+ return this.callToolStdio(state, toolName, args);
254
+ } else {
255
+ return this.callToolHttp(state, toolName, args);
256
+ }
257
+ }
258
+ return { content: `Tool "${toolName}" not found on any connected MCP server`, isError: true };
259
+ }
260
+
261
+ // ─── Stdio Transport ──────────────────────────────────
262
+
263
+ private async connectStdio(state: McpServerState): Promise<void> {
264
+ const { config } = state;
265
+ if (!config.command) throw new Error('No command specified for stdio MCP server');
266
+
267
+ const env = { ...process.env, ...(config.env || {}) };
268
+ const child = spawn(config.command, config.args || [], {
269
+ stdio: ['pipe', 'pipe', 'pipe'],
270
+ env,
271
+ });
272
+
273
+ state.process = child;
274
+ state.lastStarted = new Date();
275
+ state.stdoutBuffer = '';
276
+
277
+ // Handle stdout — line-based JSON-RPC message parsing
278
+ child.stdout!.on('data', (chunk: Buffer) => {
279
+ state.stdoutBuffer += chunk.toString();
280
+ this.processStdoutBuffer(state);
281
+ });
282
+
283
+ // Log stderr
284
+ child.stderr!.on('data', (chunk: Buffer) => {
285
+ const msg = chunk.toString().trim();
286
+ if (msg) console.log(`[mcp:${config.name}:stderr] ${msg.slice(0, 200)}`);
287
+ });
288
+
289
+ // Handle process exit
290
+ child.on('exit', (code) => {
291
+ if (state.status === 'connected' && config.autoRestart !== false && this.started) {
292
+ console.warn(`[mcp-manager] ${config.name} exited with code ${code} — restarting...`);
293
+ this.scheduleRestart(state);
294
+ }
295
+ });
296
+
297
+ child.on('error', (err) => {
298
+ state.status = 'error';
299
+ state.error = err.message;
300
+ console.error(`[mcp-manager] ${config.name} process error: ${err.message}`);
301
+ });
302
+
303
+ // Initialize via JSON-RPC
304
+ const initResult = await this.sendRpc(state, 'initialize', {
305
+ protocolVersion: '2024-11-05',
306
+ capabilities: {},
307
+ clientInfo: { name: 'AgenticMail-Enterprise', version: '1.0' },
308
+ });
309
+
310
+ if (!initResult?.result) {
311
+ throw new Error(`Initialize failed: ${JSON.stringify(initResult?.error || 'no response')}`);
312
+ }
313
+
314
+ // Send initialized notification
315
+ this.sendNotification(state, 'notifications/initialized', {});
316
+
317
+ // Discover tools
318
+ const toolsResult = await this.sendRpc(state, 'tools/list', {});
319
+ state.tools = (toolsResult?.result?.tools || []).map((t: any) => ({
320
+ name: t.name,
321
+ description: t.description,
322
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
323
+ }));
324
+
325
+ state.status = 'connected';
326
+ state.error = undefined;
327
+
328
+ console.log(`[mcp-manager] ${config.name} connected (stdio) — ${state.tools.length} tools discovered`);
329
+ this.updateDbStatus(config.id, 'connected', state.tools.length, state.tools);
330
+ this.emit('server:connected', { serverId: config.id, tools: state.tools });
331
+ }
332
+
333
+ private async callToolStdio(state: McpServerState, toolName: string, args: any): Promise<{ content: string; isError?: boolean }> {
334
+ try {
335
+ const result = await this.sendRpc(state, 'tools/call', { name: toolName, arguments: args });
336
+ if (result?.error) {
337
+ return { content: result.error.message || JSON.stringify(result.error), isError: true };
338
+ }
339
+ // MCP tool results are in result.result.content array
340
+ const contents = result?.result?.content || [];
341
+ const textParts = contents.map((c: any) => c.type === 'text' ? c.text : JSON.stringify(c)).join('\n');
342
+ return { content: textParts || 'OK', isError: result?.result?.isError };
343
+ } catch (e: any) {
344
+ return { content: `MCP call failed: ${e.message}`, isError: true };
345
+ }
346
+ }
347
+
348
+ // ─── HTTP/SSE Transport ────────────────────────────────
349
+
350
+ private async connectHttp(state: McpServerState): Promise<void> {
351
+ const { config } = state;
352
+ if (!config.url) throw new Error('No URL specified for HTTP/SSE MCP server');
353
+
354
+ const headers: Record<string, string> = {
355
+ 'Content-Type': 'application/json',
356
+ ...(config.headers || {}),
357
+ };
358
+ if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
359
+
360
+ const timeout = (config.timeout || 30) * 1000;
361
+
362
+ // Initialize
363
+ const initResp = await fetch(config.url, {
364
+ method: 'POST',
365
+ headers,
366
+ body: JSON.stringify({
367
+ jsonrpc: '2.0', id: 1, method: 'initialize',
368
+ params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'AgenticMail-Enterprise', version: '1.0' } },
369
+ }),
370
+ signal: AbortSignal.timeout(timeout),
371
+ });
372
+
373
+ if (!initResp.ok) throw new Error(`HTTP ${initResp.status}: ${await initResp.text().catch(() => '')}`);
374
+ const initData = await initResp.json() as any;
375
+ if (initData.error) throw new Error(initData.error.message || 'Initialize error');
376
+
377
+ // Discover tools
378
+ const toolResp = await fetch(config.url, {
379
+ method: 'POST',
380
+ headers,
381
+ body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
382
+ signal: AbortSignal.timeout(15000),
383
+ });
384
+
385
+ let tools: McpDiscoveredTool[] = [];
386
+ if (toolResp.ok) {
387
+ const td = await toolResp.json() as any;
388
+ tools = (td.result?.tools || []).map((t: any) => ({
389
+ name: t.name,
390
+ description: t.description,
391
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
392
+ }));
393
+ }
394
+
395
+ state.tools = tools;
396
+ state.status = 'connected';
397
+ state.error = undefined;
398
+ state.lastStarted = new Date();
399
+
400
+ console.log(`[mcp-manager] ${config.name} connected (${config.type}) — ${tools.length} tools discovered`);
401
+ this.updateDbStatus(config.id, 'connected', tools.length, tools);
402
+ this.emit('server:connected', { serverId: config.id, tools });
403
+ }
404
+
405
+ private async callToolHttp(state: McpServerState, toolName: string, args: any): Promise<{ content: string; isError?: boolean }> {
406
+ const { config } = state;
407
+ const headers: Record<string, string> = {
408
+ 'Content-Type': 'application/json',
409
+ ...(config.headers || {}),
410
+ };
411
+ if (config.apiKey) headers['Authorization'] = `Bearer ${config.apiKey}`;
412
+
413
+ try {
414
+ const resp = await fetch(config.url!, {
415
+ method: 'POST',
416
+ headers,
417
+ body: JSON.stringify({
418
+ jsonrpc: '2.0', id: Date.now(), method: 'tools/call',
419
+ params: { name: toolName, arguments: args },
420
+ }),
421
+ signal: AbortSignal.timeout((config.timeout || 30) * 1000),
422
+ });
423
+
424
+ if (!resp.ok) return { content: `HTTP ${resp.status}`, isError: true };
425
+ const data = await resp.json() as any;
426
+ if (data.error) return { content: data.error.message || JSON.stringify(data.error), isError: true };
427
+
428
+ const contents = data.result?.content || [];
429
+ const textParts = contents.map((c: any) => c.type === 'text' ? c.text : JSON.stringify(c)).join('\n');
430
+ return { content: textParts || 'OK', isError: data.result?.isError };
431
+ } catch (e: any) {
432
+ return { content: `MCP HTTP call failed: ${e.message}`, isError: true };
433
+ }
434
+ }
435
+
436
+ // ─── JSON-RPC Helpers (stdio) ──────────────────────────
437
+
438
+ private sendRpc(state: McpServerState, method: string, params: any): Promise<any> {
439
+ return new Promise((resolve, reject) => {
440
+ if (!state.process?.stdin?.writable) {
441
+ return reject(new Error('Process stdin not writable'));
442
+ }
443
+
444
+ const id = ++state.rpcId;
445
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
446
+
447
+ const timer = setTimeout(() => {
448
+ state.pendingRpc.delete(id);
449
+ reject(new Error(`RPC timeout for ${method} after ${this.discoveryTimeoutMs}ms`));
450
+ }, this.discoveryTimeoutMs);
451
+
452
+ state.pendingRpc.set(id, { resolve, reject, timer });
453
+
454
+ try {
455
+ state.process!.stdin!.write(msg + '\n');
456
+ } catch (e: any) {
457
+ state.pendingRpc.delete(id);
458
+ clearTimeout(timer);
459
+ reject(e);
460
+ }
461
+ });
462
+ }
463
+
464
+ private sendNotification(state: McpServerState, method: string, params: any): void {
465
+ if (!state.process?.stdin?.writable) return;
466
+ try {
467
+ const msg = JSON.stringify({ jsonrpc: '2.0', method, params });
468
+ state.process!.stdin!.write(msg + '\n');
469
+ } catch { /* best effort */ }
470
+ }
471
+
472
+ private processStdoutBuffer(state: McpServerState): void {
473
+ const lines = state.stdoutBuffer.split('\n');
474
+ // Keep the last incomplete line in the buffer
475
+ state.stdoutBuffer = lines.pop() || '';
476
+
477
+ for (const line of lines) {
478
+ const trimmed = line.trim();
479
+ if (!trimmed) continue;
480
+
481
+ try {
482
+ const parsed = JSON.parse(trimmed);
483
+ // It's a response if it has an id
484
+ if (parsed.id !== undefined && state.pendingRpc.has(parsed.id)) {
485
+ const pending = state.pendingRpc.get(parsed.id)!;
486
+ state.pendingRpc.delete(parsed.id);
487
+ clearTimeout(pending.timer);
488
+ pending.resolve(parsed);
489
+ }
490
+ // Could also be a notification from the server (no id) — emit event
491
+ else if (!parsed.id && parsed.method) {
492
+ this.emit('server:notification', {
493
+ serverId: state.config.id,
494
+ method: parsed.method,
495
+ params: parsed.params,
496
+ });
497
+ }
498
+ } catch {
499
+ // Not valid JSON — might be server log output, ignore
500
+ }
501
+ }
502
+ }
503
+
504
+ // ─── Restart / Health ──────────────────────────────────
505
+
506
+ private scheduleRestart(state: McpServerState): void {
507
+ if (state.restartCount >= this.maxRestarts) {
508
+ state.status = 'error';
509
+ state.error = `Max restarts (${this.maxRestarts}) exceeded`;
510
+ console.error(`[mcp-manager] ${state.config.name} exceeded max restarts`);
511
+ this.updateDbStatus(state.config.id, 'error', 0, []);
512
+ return;
513
+ }
514
+
515
+ state.restartCount++;
516
+ const delay = this.restartDelayMs * state.restartCount; // exponential-ish backoff
517
+
518
+ setTimeout(async () => {
519
+ if (!this.started) return;
520
+ console.log(`[mcp-manager] Restarting ${state.config.name} (attempt ${state.restartCount})...`);
521
+ try {
522
+ await this.connectServer(state.config);
523
+ } catch (e: any) {
524
+ console.error(`[mcp-manager] Restart failed: ${e.message}`);
525
+ }
526
+ }, delay);
527
+ }
528
+
529
+ private healthCheck(): void {
530
+ for (const [_id, state] of Array.from(this.servers)) {
531
+ if (state.status === 'connected' && state.config.type === 'stdio') {
532
+ // Check if stdio process is still alive
533
+ if (state.process && state.process.exitCode !== null) {
534
+ console.warn(`[mcp-manager] ${state.config.name} process died (exit ${state.process.exitCode})`);
535
+ if (state.config.autoRestart !== false) {
536
+ this.scheduleRestart(state);
537
+ } else {
538
+ state.status = 'error';
539
+ state.error = 'Process exited';
540
+ }
541
+ }
542
+ }
543
+ // For HTTP servers, could do a periodic ping here
544
+ }
545
+ }
546
+
547
+ private killProcess(state: McpServerState): void {
548
+ state.status = 'stopped';
549
+ // Clear all pending RPCs
550
+ for (const [_id, pending] of Array.from(state.pendingRpc)) {
551
+ clearTimeout(pending.timer);
552
+ pending.reject(new Error('Server stopped'));
553
+ }
554
+ state.pendingRpc.clear();
555
+
556
+ if (state.process) {
557
+ try { state.process.kill('SIGTERM'); } catch {}
558
+ // Force kill after 3s
559
+ setTimeout(() => {
560
+ try { state.process?.kill('SIGKILL'); } catch {}
561
+ }, 3000);
562
+ state.process = undefined;
563
+ }
564
+ }
565
+
566
+ private async updateDbStatus(serverId: string, status: string, toolCount: number, tools: McpDiscoveredTool[]): Promise<void> {
567
+ try {
568
+ await this.db.exec(
569
+ `UPDATE mcp_servers SET status = $1, tool_count = $2, tools = $3, updated_at = NOW() WHERE id = $4`,
570
+ [status, toolCount, JSON.stringify(tools.map(t => ({ name: t.name, description: t.description }))), serverId]
571
+ );
572
+ } catch { /* non-fatal */ }
573
+ }
574
+ }
@@ -400,6 +400,29 @@ engine.delete('/mcp-servers/:id', async (c) => {
400
400
  } catch (e: any) { return c.json({ error: e.message }, 500); }
401
401
  });
402
402
 
403
+ // ─── MCP Server Agent Assignment ─────────────────────
404
+ engine.put('/mcp-servers/:id/agents', async (c) => {
405
+ try {
406
+ const id = c.req.param('id');
407
+ const { agentIds } = await c.req.json<{ agentIds: string[] }>();
408
+ const rows = await engineDb.query(`SELECT config FROM mcp_servers WHERE id = $1`, [id]);
409
+ if (!rows?.length) return c.json({ error: 'Server not found' }, 404);
410
+ const existing = typeof rows[0].config === 'string' ? JSON.parse(rows[0].config) : (rows[0].config || {});
411
+ existing.assignedAgents = agentIds || [];
412
+ await engineDb.exec(`UPDATE mcp_servers SET config = $1, updated_at = NOW() WHERE id = $2`, [JSON.stringify(existing), id]);
413
+ return c.json({ ok: true });
414
+ } catch (e: any) { return c.json({ error: e.message }, 500); }
415
+ });
416
+
417
+ // ─── MCP Server Reload (after config change) ─────────
418
+ engine.post('/mcp-servers/:id/reload', async (c) => {
419
+ try {
420
+ // Signal to runtime to reload this MCP server
421
+ // The actual reload happens via the mcpProcessManager reference
422
+ return c.json({ ok: true, message: 'Server will be reloaded on next agent session' });
423
+ } catch (e: any) { return c.json({ error: e.message }, 500); }
424
+ });
425
+
403
426
  engine.post('/mcp-servers/:id/test', async (c) => {
404
427
  try {
405
428
  const id = c.req.param('id');
@@ -224,6 +224,10 @@ export class AgentRuntime {
224
224
  if (this.config.hierarchyManager) {
225
225
  base.hierarchyManager = this.config.hierarchyManager;
226
226
  }
227
+ // MCP Process Manager — external MCP server tools
228
+ if (this.config.mcpProcessManager) {
229
+ base.mcpProcessManager = this.config.mcpProcessManager;
230
+ }
227
231
  return base;
228
232
  }
229
233
 
@@ -138,6 +138,8 @@ export interface RuntimeConfig {
138
138
  vault?: any;
139
139
  /** PermissionEngine for dynamic MCP tool registration */
140
140
  permissionEngine?: any;
141
+ /** MCP Process Manager for external MCP server tools */
142
+ mcpProcessManager?: import('../engine/mcp-process-manager.js').McpProcessManager;
141
143
  /** Knowledge base engine for RAG search tools */
142
144
  knowledgeEngine?: any;
143
145
  /** Real-time agent status tracker */