@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.
- package/dist/agent-heartbeat-SLOAWBEN.js +510 -0
- package/dist/agent-tools-V3J26M57.js +13871 -0
- package/dist/chunk-PNBWEEJZ.js +1224 -0
- package/dist/chunk-RNREJCPG.js +4466 -0
- package/dist/chunk-XWVKQO6P.js +3778 -0
- package/dist/cli-agent-6DT3UZHY.js +1735 -0
- package/dist/cli-serve-SUPVRGDZ.js +114 -0
- package/dist/cli.js +3 -3
- package/dist/dashboard/pages/skill-connections.js +48 -4
- package/dist/index.js +3 -3
- package/dist/mcp-process-manager-J2XATJVY.js +425 -0
- package/dist/mcp-server-tools-EDTW6FE2.js +61 -0
- package/dist/routes-D65Q3W4Z.js +13509 -0
- package/dist/runtime-M2GFJRL3.js +45 -0
- package/dist/server-DT5K3D55.js +15 -0
- package/dist/setup-6EUJP3OZ.js +20 -0
- package/package.json +1 -1
- package/src/agent-tools/index.ts +20 -0
- package/src/agent-tools/tools/mcp-server-tools.ts +91 -0
- package/src/cli-agent.ts +19 -0
- package/src/dashboard/pages/skill-connections.js +48 -4
- package/src/engine/mcp-process-manager.ts +574 -0
- package/src/engine/routes.ts +23 -0
- package/src/runtime/index.ts +4 -0
- package/src/runtime/types.ts +2 -0
|
@@ -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
|
+
}
|
package/src/engine/routes.ts
CHANGED
|
@@ -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');
|
package/src/runtime/index.ts
CHANGED
|
@@ -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
|
|
package/src/runtime/types.ts
CHANGED
|
@@ -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 */
|