@vellumai/assistant 0.3.20 → 0.3.21

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,46 @@
1
+ import { z } from 'zod';
2
+
3
+ const McpStdioTransportSchema = z.object({
4
+ type: z.literal('stdio'),
5
+ command: z.string({ error: 'mcp transport command must be a string' }),
6
+ args: z.array(z.string()).default([]),
7
+ env: z.record(z.string(), z.string()).optional(),
8
+ });
9
+
10
+ const McpSseTransportSchema = z.object({
11
+ type: z.literal('sse'),
12
+ url: z.string({ error: 'mcp transport url must be a string' }),
13
+ headers: z.record(z.string(), z.string()).optional(),
14
+ });
15
+
16
+ const McpStreamableHttpTransportSchema = z.object({
17
+ type: z.literal('streamable-http'),
18
+ url: z.string({ error: 'mcp transport url must be a string' }),
19
+ headers: z.record(z.string(), z.string()).optional(),
20
+ });
21
+
22
+ export const McpTransportSchema = z.discriminatedUnion('type', [
23
+ McpStdioTransportSchema,
24
+ McpSseTransportSchema,
25
+ McpStreamableHttpTransportSchema,
26
+ ]);
27
+
28
+ export const McpServerConfigSchema = z.object({
29
+ transport: McpTransportSchema,
30
+ enabled: z.boolean({ error: 'mcp server enabled must be a boolean' }).default(true),
31
+ defaultRiskLevel: z.enum(['low', 'medium', 'high'], {
32
+ error: 'mcp server defaultRiskLevel must be one of: low, medium, high',
33
+ }).default('high'),
34
+ maxTools: z.number({ error: 'mcp server maxTools must be a number' }).int().positive().default(20),
35
+ allowedTools: z.array(z.string()).optional(),
36
+ blockedTools: z.array(z.string()).optional(),
37
+ });
38
+
39
+ export const McpConfigSchema = z.object({
40
+ servers: z.record(z.string(), McpServerConfigSchema).default({} as any),
41
+ globalMaxTools: z.number({ error: 'mcp globalMaxTools must be a number' }).int().positive().default(50),
42
+ });
43
+
44
+ export type McpTransport = z.infer<typeof McpTransportSchema>;
45
+ export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
46
+ export type McpConfig = z.infer<typeof McpConfigSchema>;
@@ -114,6 +114,16 @@ export type {
114
114
  export {
115
115
  SandboxConfigSchema,
116
116
  } from './sandbox-schema.js';
117
+ export type {
118
+ McpConfig,
119
+ McpServerConfig,
120
+ McpTransport,
121
+ } from './mcp-schema.js';
122
+ export {
123
+ McpConfigSchema,
124
+ McpServerConfigSchema,
125
+ McpTransportSchema,
126
+ } from './mcp-schema.js';
117
127
  export type {
118
128
  RemotePolicyConfig,
119
129
  RemoteProviderConfig,
@@ -152,6 +162,7 @@ import {
152
162
  TimeoutConfigSchema,
153
163
  UiConfigSchema,
154
164
  } from './core-schema.js';
165
+ import { McpConfigSchema } from './mcp-schema.js';
155
166
  import { MemoryConfigSchema } from './memory-schema.js';
156
167
  import { NotificationsConfigSchema } from './notifications-schema.js';
157
168
  import { SandboxConfigSchema } from './sandbox-schema.js';
@@ -213,6 +224,7 @@ export const AssistantConfigSchema = z.object({
213
224
  .default([]),
214
225
  heartbeat: HeartbeatConfigSchema.default({} as any),
215
226
  swarm: SwarmConfigSchema.default({} as any),
227
+ mcp: McpConfigSchema.default({} as any),
216
228
  skills: SkillsConfigSchema.default({} as any),
217
229
  workspaceGit: WorkspaceGitConfigSchema.default({} as any),
218
230
  calls: CallsConfigSchema.default({} as any),
@@ -12,8 +12,9 @@ You are helping your user connect a Telegram bot to the Vellum Assistant gateway
12
12
 
13
13
  Before beginning setup, verify these conditions are met:
14
14
 
15
- 1. **Gateway is running:** Run `curl -sf http://localhost:7830/healthz` — it should return OK. If it fails, tell the user to start the daemon with `vellum daemon start` and wait for it to become healthy before continuing.
15
+ 1. **Gateway API base URL is set and reachable:** Use the configured gateway URL in `GATEWAY_BASE_URL` (from Settings "Local Gateway Target"), then run `curl -sf "$GATEWAY_BASE_URL/healthz"` — it should return gateway health JSON (for example `{"status":"ok"}`). If it fails, tell the user to start the daemon with `vellum daemon start` and wait for it to become healthy before continuing.
16
16
  2. **Public ingress URL is configured.** The gateway webhook URL is derived from `${ingress.publicBaseUrl}/webhooks/telegram`. If the ingress URL is not configured, load and execute the **public-ingress** skill first (`skill_load` with `skill: "public-ingress"`) to set up an ngrok tunnel and persist the URL before continuing.
17
+ 3. **Use gateway control-plane routes only.** Telegram setup/config actions in this skill must call gateway endpoints under `/v1/integrations/telegram/*` — never call the daemon runtime port directly.
17
18
 
18
19
  ## What You Need
19
20
 
@@ -36,7 +37,7 @@ The token is collected securely via a system-level prompt and is never exposed i
36
37
  After the token is collected, call the composite setup endpoint which validates the token, stores credentials, and registers bot commands in a single request:
37
38
 
38
39
  ```bash
39
- curl -sf -X POST http://localhost:7830/v1/integrations/telegram/setup \
40
+ curl -sf -X POST "$GATEWAY_BASE_URL/v1/integrations/telegram/setup" \
40
41
  -H "Authorization: Bearer $(cat ~/.vellum/http-token)" \
41
42
  -H "Content-Type: application/json" \
42
43
  -d '{}'
@@ -97,7 +98,7 @@ If routing is misconfigured, inbound Telegram messages will be rejected and the
97
98
  Before reporting success, confirm the guardian binding was actually created. Check the guardian binding status:
98
99
 
99
100
  ```bash
100
- curl -sf http://localhost:7830/v1/integrations/guardian/status?channel=telegram \
101
+ curl -sf "$GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" \
101
102
  -H "Authorization: Bearer $(cat ~/.vellum/http-token)"
102
103
  ```
103
104
 
@@ -116,7 +117,7 @@ Summarize what was done:
116
117
  - Guardian identity: {verified | not configured}
117
118
  - Guardian verification status: {verified via outbound flow | skipped}
118
119
  - Routing configuration validated
119
- - To re-check guardian status later, use: `curl -sf http://localhost:7830/v1/integrations/guardian/status?channel=telegram -H "Authorization: Bearer $(cat ~/.vellum/http-token)"`
120
+ - To re-check guardian status later, use: `curl -sf "$GATEWAY_BASE_URL/v1/integrations/guardian/status?channel=telegram" -H "Authorization: Bearer $(cat ~/.vellum/http-token)"`
120
121
 
121
122
  The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the webhook secret changes later, the gateway's credential watcher will automatically re-register the webhook. If the ingress URL changes (e.g., tunnel restart), the assistant daemon triggers an immediate internal reconcile so the webhook re-registers automatically without a gateway restart.
122
123
 
@@ -10,6 +10,7 @@ You are helping your user manage trusted contacts and invite links for the Vellu
10
10
  ## Prerequisites
11
11
 
12
12
  - The gateway API is available at `http://localhost:7830` (or the configured gateway port).
13
+ - Use gateway control-plane routes only: this skill calls `/v1/ingress/*` and `/v1/integrations/telegram/config` on the gateway, never the daemon runtime port directly.
13
14
  - The bearer token is stored at `~/.vellum/http-token`. Read it with: `TOKEN=$(cat ~/.vellum/http-token)`.
14
15
 
15
16
  ## Concepts
@@ -54,6 +54,7 @@ import { createGuardianActionCopyGenerator, createGuardianFollowUpConversationGe
54
54
  import { initPairingHandlers } from './handlers/pairing.js';
55
55
  import { installCliLaunchers } from './install-cli-launchers.js';
56
56
  import type { ServerMessage } from './ipc-protocol.js';
57
+ import { getMcpServerManager } from '../mcp/manager.js';
57
58
  import { initializeProvidersAndTools, registerMessagingProviders,registerWatcherProviders } from './providers-setup.js';
58
59
  import { seedInterfaceFiles } from './seed-files.js';
59
60
  import { DaemonServer } from './server.js';
@@ -398,6 +399,12 @@ export async function runDaemon(): Promise<void> {
398
399
  server.setHeartbeatService(heartbeat);
399
400
  log.info({ enabled: heartbeatConfig.enabled, intervalMs: heartbeatConfig.intervalMs }, 'Heartbeat service configured');
400
401
 
402
+ // Retrieve the MCP manager if MCP servers were configured.
403
+ // The manager is a singleton created during initializeProvidersAndTools().
404
+ const mcpManager = config.mcp?.servers && Object.keys(config.mcp.servers).length > 0
405
+ ? getMcpServerManager()
406
+ : null;
407
+
401
408
  installShutdownHandlers({
402
409
  server,
403
410
  workspaceHeartbeat,
@@ -407,6 +414,7 @@ export async function runDaemon(): Promise<void> {
407
414
  scheduler,
408
415
  memoryWorker,
409
416
  qdrantManager,
417
+ mcpManager,
410
418
  cleanupPidFile,
411
419
  });
412
420
  } catch (err) {
@@ -1,4 +1,5 @@
1
1
  import type { AssistantConfig } from '../config/types.js';
2
+ import { getMcpServerManager } from '../mcp/manager.js';
2
3
  import { gmailMessagingProvider } from '../messaging/providers/gmail/adapter.js';
3
4
  import { slackProvider as slackMessagingProvider } from '../messaging/providers/slack/adapter.js';
4
5
  import { smsMessagingProvider } from '../messaging/providers/sms/adapter.js';
@@ -6,7 +7,8 @@ import { telegramBotMessagingProvider } from '../messaging/providers/telegram-bo
6
7
  import { whatsappMessagingProvider } from '../messaging/providers/whatsapp/adapter.js';
7
8
  import { registerMessagingProvider } from '../messaging/registry.js';
8
9
  import { initializeProviders } from '../providers/registry.js';
9
- import { initializeTools } from '../tools/registry.js';
10
+ import { createMcpToolsFromServer } from '../tools/mcp/mcp-tool-factory.js';
11
+ import { initializeTools, registerMcpTools } from '../tools/registry.js';
10
12
  import { getLogger } from '../util/logger.js';
11
13
  import { initWatcherEngine } from '../watcher/engine.js';
12
14
  import { registerWatcherProvider } from '../watcher/provider-registry.js';
@@ -19,9 +21,32 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac
19
21
  const log = getLogger('lifecycle');
20
22
 
21
23
  export async function initializeProvidersAndTools(config: AssistantConfig): Promise<void> {
24
+ console.log('[Daemon] Initializing providers and tools...');
22
25
  log.info('Daemon startup: initializing providers and tools');
23
26
  initializeProviders(config);
27
+ console.log('[Daemon] Providers initialized');
24
28
  await initializeTools();
29
+ console.log('[Daemon] Tools initialized');
30
+
31
+ // Start MCP servers and register their tools
32
+ if (config.mcp?.servers && Object.keys(config.mcp.servers).length > 0) {
33
+ console.log('[MCP] Initializing MCP servers:', Object.keys(config.mcp.servers).join(', '));
34
+ const manager = getMcpServerManager();
35
+ try {
36
+ const serverToolInfos = await manager.start(config.mcp);
37
+ for (const { serverId, serverConfig, tools } of serverToolInfos) {
38
+ console.log(`[MCP] Server "${serverId}" connected — discovered ${tools.length} tools:`, tools.map(t => t.name).join(', '));
39
+ const mcpTools = createMcpToolsFromServer(tools, serverId, serverConfig, manager);
40
+ registerMcpTools(mcpTools);
41
+ console.log(`[MCP] Registered ${mcpTools.length} tools from "${serverId}":`, mcpTools.map(t => t.name).join(', '));
42
+ }
43
+ } catch (err) {
44
+ console.error('[MCP] Server initialization failed:', err);
45
+ log.error({ err }, 'MCP server initialization failed — continuing without MCP tools');
46
+ }
47
+ }
48
+
49
+ console.log('[Daemon] Providers and tools initialization complete');
25
50
  log.info('Daemon startup: providers and tools initialized');
26
51
  }
27
52
 
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/node';
3
3
  import type { HeartbeatService } from '../heartbeat/heartbeat-service.js';
4
4
  import type { HookManager } from '../hooks/manager.js';
5
5
  import { getSqlite, resetDb } from '../memory/db.js';
6
+ import type { McpServerManager } from '../mcp/manager.js';
6
7
  import type { QdrantManager } from '../memory/qdrant-manager.js';
7
8
  import type { RuntimeHttpServer } from '../runtime/http-server.js';
8
9
  import { browserManager } from '../tools/browser/browser-manager.js';
@@ -22,6 +23,7 @@ export interface ShutdownDeps {
22
23
  scheduler: { stop(): void };
23
24
  memoryWorker: { stop(): void };
24
25
  qdrantManager: QdrantManager;
26
+ mcpManager: McpServerManager | null;
25
27
  cleanupPidFile: () => void;
26
28
  }
27
29
 
@@ -86,6 +88,15 @@ export function installShutdownHandlers(deps: ShutdownDeps): void {
86
88
  await browserManager.closeAllPages();
87
89
  deps.scheduler.stop();
88
90
  deps.memoryWorker.stop();
91
+
92
+ if (deps.mcpManager) {
93
+ try {
94
+ await deps.mcpManager.stop();
95
+ } catch (err) {
96
+ log.warn({ err }, 'MCP server manager shutdown failed (non-fatal)');
97
+ }
98
+ }
99
+
89
100
  await deps.qdrantManager.stop();
90
101
 
91
102
  // Checkpoint WAL and close SQLite so no writes are lost on exit.
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  import { registerEmailCommand } from './cli/email.js';
27
27
  import { registerInfluencerCommand } from './cli/influencer.js';
28
28
  import { registerMapCommand } from './cli/map.js';
29
+ import { registerMcpCommand } from './cli/mcp.js';
29
30
  import { registerSequenceCommand } from './cli/sequence.js';
30
31
  import { registerTwitterCommand } from './cli/twitter.js';
31
32
  import { registerHooksCommand } from './hooks/cli.js';
@@ -48,6 +49,7 @@ registerMemoryCommand(program);
48
49
  registerAuditCommand(program);
49
50
  registerDoctorCommand(program);
50
51
  registerHooksCommand(program);
52
+ registerMcpCommand(program);
51
53
  registerEmailCommand(program);
52
54
  registerAmazonCommand(program);
53
55
  registerCompletionsCommand(program);
@@ -0,0 +1,152 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
+
6
+ import type { McpTransport } from '../config/mcp-schema.js';
7
+ import { getLogger } from '../util/logger.js';
8
+
9
+ const log = getLogger('mcp-client');
10
+
11
+ const CONNECT_TIMEOUT_MS = 30_000;
12
+
13
+ export interface McpToolInfo {
14
+ name: string;
15
+ description: string;
16
+ inputSchema: Record<string, unknown>;
17
+ }
18
+
19
+ export interface McpCallResult {
20
+ content: string;
21
+ isError: boolean;
22
+ }
23
+
24
+ export class McpClient {
25
+ readonly serverId: string;
26
+ private client: Client;
27
+ private transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null = null;
28
+ private connected = false;
29
+
30
+ constructor(serverId: string) {
31
+ this.serverId = serverId;
32
+ this.client = new Client({
33
+ name: 'vellum-assistant',
34
+ version: '1.0.0',
35
+ });
36
+ }
37
+
38
+ async connect(transportConfig: McpTransport): Promise<void> {
39
+ if (this.connected) return;
40
+
41
+ console.log(`[MCP] Connecting to server "${this.serverId}"...`);
42
+ this.transport = this.createTransport(transportConfig);
43
+ try {
44
+ await Promise.race([
45
+ this.client.connect(this.transport),
46
+ new Promise<never>((_, reject) =>
47
+ setTimeout(() => reject(new Error(`MCP server "${this.serverId}" connection timed out after ${CONNECT_TIMEOUT_MS}ms`)), CONNECT_TIMEOUT_MS),
48
+ ),
49
+ ]);
50
+ } catch (err) {
51
+ // Clean up the transport on failure (e.g., kill spawned stdio process)
52
+ try { await this.client.close(); } catch { /* ignore cleanup errors */ }
53
+ this.transport = undefined;
54
+ throw err;
55
+ }
56
+ this.connected = true;
57
+ console.log(`[MCP] Server "${this.serverId}" connected successfully`);
58
+ log.info({ serverId: this.serverId }, 'MCP client connected');
59
+ }
60
+
61
+ async listTools(): Promise<McpToolInfo[]> {
62
+ if (!this.connected) {
63
+ throw new Error(`MCP client "${this.serverId}" is not connected`);
64
+ }
65
+
66
+ const result = await Promise.race([
67
+ this.client.listTools(),
68
+ new Promise<never>((_, reject) =>
69
+ setTimeout(() => reject(new Error(`MCP server "${this.serverId}" listTools timed out after ${CONNECT_TIMEOUT_MS}ms`)), CONNECT_TIMEOUT_MS),
70
+ ),
71
+ ]);
72
+ return result.tools.map((tool) => ({
73
+ name: tool.name,
74
+ description: tool.description ?? '',
75
+ inputSchema: tool.inputSchema as Record<string, unknown>,
76
+ }));
77
+ }
78
+
79
+ async callTool(name: string, args: Record<string, unknown>): Promise<McpCallResult> {
80
+ if (!this.connected) {
81
+ throw new Error(`MCP client "${this.serverId}" is not connected`);
82
+ }
83
+
84
+ const result = await this.client.callTool({ name, arguments: args });
85
+ const isError = result.isError === true;
86
+
87
+ // Handle structuredContent if present
88
+ if (result.structuredContent !== undefined && result.structuredContent !== null) {
89
+ return {
90
+ content: JSON.stringify(result.structuredContent),
91
+ isError,
92
+ };
93
+ }
94
+
95
+ // Concatenate all content blocks into a single string
96
+ const textParts: string[] = [];
97
+ if (Array.isArray(result.content)) {
98
+ for (const block of result.content) {
99
+ if (typeof block === 'object' && block !== null && 'type' in block) {
100
+ if (block.type === 'text' && 'text' in block) {
101
+ textParts.push(String(block.text));
102
+ } else if (block.type === 'resource' && 'resource' in block) {
103
+ const resource = block.resource as Record<string, unknown>;
104
+ textParts.push(typeof resource.text === 'string' ? resource.text : JSON.stringify(resource));
105
+ } else {
106
+ // For other content types (image, etc.), include type and any available data
107
+ textParts.push(`[${block.type} content: ${JSON.stringify(block)}]`);
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ return {
114
+ content: textParts.join('\n') || (isError ? 'Tool execution failed' : 'Tool executed successfully'),
115
+ isError,
116
+ };
117
+ }
118
+
119
+ async disconnect(): Promise<void> {
120
+ if (!this.connected) return;
121
+
122
+ try {
123
+ await this.client.close();
124
+ } catch (err) {
125
+ log.warn({ err, serverId: this.serverId }, 'Error closing MCP client');
126
+ }
127
+ this.connected = false;
128
+ this.transport = null;
129
+ log.info({ serverId: this.serverId }, 'MCP client disconnected');
130
+ }
131
+
132
+ private createTransport(config: McpTransport): StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport {
133
+ switch (config.type) {
134
+ case 'stdio':
135
+ return new StdioClientTransport({
136
+ command: config.command,
137
+ args: config.args,
138
+ env: config.env ? { ...process.env, ...config.env } as Record<string, string> : undefined,
139
+ });
140
+ case 'sse':
141
+ return new SSEClientTransport(
142
+ new URL(config.url),
143
+ { requestInit: config.headers ? { headers: config.headers } : undefined },
144
+ );
145
+ case 'streamable-http':
146
+ return new StreamableHTTPClientTransport(
147
+ new URL(config.url),
148
+ { requestInit: config.headers ? { headers: config.headers } : undefined },
149
+ );
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,139 @@
1
+ import type { McpConfig, McpServerConfig } from '../config/mcp-schema.js';
2
+ import { getLogger } from '../util/logger.js';
3
+ import { McpClient, type McpToolInfo } from './client.js';
4
+
5
+ const log = getLogger('mcp-manager');
6
+
7
+ export interface McpServerToolInfo {
8
+ serverId: string;
9
+ serverConfig: McpServerConfig;
10
+ tools: McpToolInfo[];
11
+ }
12
+
13
+ export class McpServerManager {
14
+ private clients = new Map<string, McpClient>();
15
+ private serverConfigs = new Map<string, McpServerConfig>();
16
+
17
+ async start(config: McpConfig): Promise<McpServerToolInfo[]> {
18
+ const results: McpServerToolInfo[] = [];
19
+
20
+ console.log(`[MCP] Starting ${Object.keys(config.servers).length} server(s)...`);
21
+ for (const [serverId, serverConfig] of Object.entries(config.servers)) {
22
+ if (!serverConfig.enabled) {
23
+ console.log(`[MCP] Server "${serverId}" is disabled, skipping`);
24
+ log.info({ serverId }, 'MCP server disabled, skipping');
25
+ continue;
26
+ }
27
+
28
+ try {
29
+ console.log(`[MCP] Starting server "${serverId}" (transport: ${serverConfig.transport.type})`);
30
+ const client = new McpClient(serverId);
31
+ await client.connect(serverConfig.transport);
32
+ this.clients.set(serverId, client);
33
+ this.serverConfigs.set(serverId, serverConfig);
34
+
35
+ let tools = await client.listTools();
36
+ log.info({ serverId, toolCount: tools.length }, 'MCP server tools discovered');
37
+
38
+ // Apply tool filtering
39
+ tools = this.filterTools(tools, serverConfig);
40
+
41
+ // Apply per-server maxTools limit
42
+ if (tools.length > serverConfig.maxTools) {
43
+ log.warn(
44
+ { serverId, discovered: tools.length, max: serverConfig.maxTools },
45
+ 'MCP server exceeded maxTools limit, truncating',
46
+ );
47
+ tools = tools.slice(0, serverConfig.maxTools);
48
+ }
49
+
50
+ results.push({ serverId, serverConfig, tools });
51
+ } catch (err) {
52
+ console.error(`[MCP] Failed to connect to server "${serverId}":`, err);
53
+ log.error({ err, serverId }, 'Failed to connect to MCP server');
54
+ // Clean up any partially-connected client
55
+ const staleClient = this.clients.get(serverId);
56
+ if (staleClient) {
57
+ try { await staleClient.disconnect(); } catch { /* ignore */ }
58
+ this.clients.delete(serverId);
59
+ this.serverConfigs.delete(serverId);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Apply global max tools limit
65
+ const totalTools = results.reduce((sum, r) => sum + r.tools.length, 0);
66
+ if (totalTools > config.globalMaxTools) {
67
+ log.warn(
68
+ { totalTools, globalMax: config.globalMaxTools },
69
+ 'Total MCP tools exceed globalMaxTools, truncating',
70
+ );
71
+ let remaining = config.globalMaxTools;
72
+ for (const result of results) {
73
+ if (remaining <= 0) {
74
+ result.tools = [];
75
+ } else if (result.tools.length > remaining) {
76
+ result.tools = result.tools.slice(0, remaining);
77
+ }
78
+ remaining -= result.tools.length;
79
+ }
80
+ }
81
+
82
+ return results;
83
+ }
84
+
85
+ async stop(): Promise<void> {
86
+ const disconnects = Array.from(this.clients.values()).map((client) =>
87
+ client.disconnect().catch((err) => {
88
+ log.warn({ err, serverId: client.serverId }, 'Error disconnecting MCP server');
89
+ }),
90
+ );
91
+ await Promise.all(disconnects);
92
+ this.clients.clear();
93
+ this.serverConfigs.clear();
94
+ log.info('All MCP servers disconnected');
95
+ }
96
+
97
+ async callTool(serverId: string, toolName: string, args: Record<string, unknown>) {
98
+ const client = this.clients.get(serverId);
99
+ if (!client) {
100
+ throw new Error(`MCP server "${serverId}" not found`);
101
+ }
102
+ return client.callTool(toolName, args);
103
+ }
104
+
105
+ getClient(serverId: string): McpClient | undefined {
106
+ return this.clients.get(serverId);
107
+ }
108
+
109
+ private filterTools(tools: McpToolInfo[], config: McpServerConfig): McpToolInfo[] {
110
+ let filtered = tools;
111
+
112
+ if (config.allowedTools) {
113
+ const allowed = new Set(config.allowedTools);
114
+ filtered = filtered.filter((t) => allowed.has(t.name));
115
+ }
116
+
117
+ if (config.blockedTools) {
118
+ const blocked = new Set(config.blockedTools);
119
+ filtered = filtered.filter((t) => !blocked.has(t.name));
120
+ }
121
+
122
+ return filtered;
123
+ }
124
+ }
125
+
126
+ // Singleton instance
127
+ let instance: McpServerManager | null = null;
128
+
129
+ export function getMcpServerManager(): McpServerManager {
130
+ if (!instance) {
131
+ instance = new McpServerManager();
132
+ }
133
+ return instance;
134
+ }
135
+
136
+ /** Reset singleton for testing. */
137
+ export function __resetMcpManagerForTesting(): void {
138
+ instance = null;
139
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readFileSync, statfsSync,statSync } from 'node:fs';
6
+ import { cpus, totalmem } from 'node:os';
6
7
  import { dirname,join } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
 
@@ -36,6 +37,76 @@ function getDiskSpaceInfo(): DiskSpaceInfo | null {
36
37
  }
37
38
  }
38
39
 
40
+ interface MemoryInfo {
41
+ currentMb: number;
42
+ maxMb: number;
43
+ }
44
+
45
+ // Read the container memory limit from cgroups if available, falling back to host total.
46
+ // cgroups v2: /sys/fs/cgroup/memory.max (returns "max" when unlimited)
47
+ // cgroups v1: /sys/fs/cgroup/memory/memory.limit_in_bytes (large sentinel when unlimited)
48
+ function getContainerMemoryLimitBytes(): number | null {
49
+ try {
50
+ const v2 = readFileSync('/sys/fs/cgroup/memory.max', 'utf-8').trim();
51
+ if (v2 !== 'max') {
52
+ const bytes = parseInt(v2, 10);
53
+ if (!isNaN(bytes) && bytes > 0) return bytes;
54
+ }
55
+ } catch { /* not available */ }
56
+ try {
57
+ const v1 = readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf-8').trim();
58
+ const bytes = parseInt(v1, 10);
59
+ // cgroups v1 uses a near-INT64_MAX sentinel when no limit is set
60
+ if (!isNaN(bytes) && bytes > 0 && bytes < totalmem() * 1.5) return bytes;
61
+ } catch { /* not available */ }
62
+ return null;
63
+ }
64
+
65
+ function getMemoryInfo(): MemoryInfo {
66
+ const bytesToMb = (b: number) => Math.round((b / (1024 * 1024)) * 100) / 100;
67
+ return {
68
+ currentMb: bytesToMb(process.memoryUsage().rss),
69
+ maxMb: bytesToMb(getContainerMemoryLimitBytes() ?? totalmem()),
70
+ };
71
+ }
72
+
73
+ interface CpuInfo {
74
+ currentPercent: number;
75
+ maxCores: number;
76
+ }
77
+
78
+ // Track CPU usage over a rolling window so /healthz reports near-real-time
79
+ // utilization instead of a lifetime average (total CPU time / total uptime).
80
+ const CPU_SAMPLE_INTERVAL_MS = 5_000;
81
+ let _lastCpuUsage: NodeJS.CpuUsage = process.cpuUsage();
82
+ let _lastCpuTime: number = Date.now();
83
+ let _cachedCpuPercent = 0;
84
+
85
+ // Kick off the background sampler. unref() so it never prevents process exit.
86
+ setInterval(() => {
87
+ const now = Date.now();
88
+ const newUsage = process.cpuUsage();
89
+ const elapsedMs = now - _lastCpuTime;
90
+ if (elapsedMs > 0) {
91
+ const deltaCpuUs =
92
+ (newUsage.user - _lastCpuUsage.user) +
93
+ (newUsage.system - _lastCpuUsage.system);
94
+ const deltaCpuMs = deltaCpuUs / 1000;
95
+ const numCores = cpus().length;
96
+ _cachedCpuPercent =
97
+ Math.round((deltaCpuMs / (elapsedMs * numCores)) * 10000) / 100;
98
+ }
99
+ _lastCpuUsage = newUsage;
100
+ _lastCpuTime = now;
101
+ }, CPU_SAMPLE_INTERVAL_MS).unref();
102
+
103
+ function getCpuInfo(): CpuInfo {
104
+ return {
105
+ currentPercent: _cachedCpuPercent,
106
+ maxCores: cpus().length,
107
+ };
108
+ }
109
+
39
110
  function getPackageVersion(): string | undefined {
40
111
  try {
41
112
  const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json');
@@ -52,6 +123,8 @@ export function handleHealth(): Response {
52
123
  timestamp: new Date().toISOString(),
53
124
  version: getPackageVersion(),
54
125
  disk: getDiskSpaceInfo(),
126
+ memory: getMemoryInfo(),
127
+ cpu: getCpuInfo(),
55
128
  });
56
129
  }
57
130