@vellumai/assistant 0.3.20 → 0.3.22

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,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
 
@@ -0,0 +1,100 @@
1
+ import type { McpServerConfig } from '../../config/mcp-schema.js';
2
+ import type { McpServerManager } from '../../mcp/manager.js';
3
+ import { RiskLevel } from '../../permissions/types.js';
4
+ import type { ToolDefinition } from '../../providers/types.js';
5
+ import type { Tool, ToolContext, ToolExecutionResult } from '../types.js';
6
+
7
+ const riskMap: Record<string, RiskLevel> = {
8
+ low: RiskLevel.Low,
9
+ medium: RiskLevel.Medium,
10
+ high: RiskLevel.High,
11
+ };
12
+
13
+ /**
14
+ * Create a namespaced tool name to prevent collisions across MCP servers
15
+ * and with core/skill tools.
16
+ */
17
+ export function mcpToolName(serverId: string, toolName: string): string {
18
+ return `mcp__${serverId}__${toolName}`;
19
+ }
20
+
21
+ /**
22
+ * Parse a namespaced MCP tool name back into serverId and original tool name.
23
+ * Returns null if the name doesn't match the MCP naming convention.
24
+ */
25
+ export function parseMcpToolName(name: string): { serverId: string; toolName: string } | null {
26
+ if (!name.startsWith('mcp__')) return null;
27
+ const prefixRemoved = name.slice(5); // remove 'mcp__'
28
+ const firstSep = prefixRemoved.indexOf('__');
29
+ if (firstSep === -1) return null;
30
+ return {
31
+ serverId: prefixRemoved.slice(0, firstSep),
32
+ toolName: prefixRemoved.slice(firstSep + 2),
33
+ };
34
+ }
35
+
36
+ export interface McpToolMetadata {
37
+ name: string;
38
+ description: string;
39
+ inputSchema: Record<string, unknown>;
40
+ }
41
+
42
+ /**
43
+ * Create a Tool object from MCP tool metadata.
44
+ * The tool delegates execution to the McpServerManager.
45
+ */
46
+ export function createMcpTool(
47
+ metadata: McpToolMetadata,
48
+ serverId: string,
49
+ serverConfig: McpServerConfig,
50
+ manager: McpServerManager,
51
+ ): Tool {
52
+ const namespacedName = mcpToolName(serverId, metadata.name);
53
+ const riskLevel = riskMap[serverConfig.defaultRiskLevel] ?? RiskLevel.High;
54
+
55
+ return {
56
+ name: namespacedName,
57
+ description: metadata.description,
58
+ category: 'mcp',
59
+ defaultRiskLevel: riskLevel,
60
+ origin: 'mcp',
61
+ ownerMcpServerId: serverId,
62
+ executionTarget: 'host',
63
+
64
+ getDefinition(): ToolDefinition {
65
+ return {
66
+ name: namespacedName,
67
+ description: metadata.description,
68
+ input_schema: metadata.inputSchema as ToolDefinition['input_schema'],
69
+ };
70
+ },
71
+
72
+ async execute(input: Record<string, unknown>, _context: ToolContext): Promise<ToolExecutionResult> {
73
+ try {
74
+ const result = await manager.callTool(serverId, metadata.name, input);
75
+ return {
76
+ content: result.content,
77
+ isError: result.isError,
78
+ };
79
+ } catch (err) {
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ return {
82
+ content: `MCP tool execution failed: ${message}`,
83
+ isError: true,
84
+ };
85
+ }
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Create Tool objects from all tools provided by an MCP server.
92
+ */
93
+ export function createMcpToolsFromServer(
94
+ tools: McpToolMetadata[],
95
+ serverId: string,
96
+ serverConfig: McpServerConfig,
97
+ manager: McpServerManager,
98
+ ): Tool[] {
99
+ return tools.map((tool) => createMcpTool(tool, serverId, serverConfig, manager));
100
+ }
@@ -121,7 +121,7 @@ export function registerSkillTools(newTools: Tool[]): Tool[] {
121
121
  for (const tool of newTools) {
122
122
  const existing = tools.get(tool.name);
123
123
  if (existing) {
124
- const existingIsCore = existing.origin !== 'skill';
124
+ const existingIsCore = existing.origin === 'core' || !existing.origin;
125
125
  if (existingIsCore) {
126
126
  log.warn(
127
127
  { toolName: tool.name, skillId: tool.ownerSkillId },
@@ -176,6 +176,69 @@ export function unregisterSkillTools(skillId: string): void {
176
176
  }
177
177
  }
178
178
 
179
+ /**
180
+ * Register multiple MCP-origin tools at once.
181
+ * Skips any tool whose name collides with a core tool (logs a warning).
182
+ * Throws if a tool name collides with a tool owned by a different MCP server.
183
+ */
184
+ export function registerMcpTools(newTools: Tool[]): Tool[] {
185
+ const accepted: Tool[] = [];
186
+ for (const tool of newTools) {
187
+ const existing = tools.get(tool.name);
188
+ if (existing) {
189
+ const existingIsCore = existing.origin === 'core' || !existing.origin;
190
+ if (existingIsCore) {
191
+ log.warn(
192
+ { toolName: tool.name, serverId: tool.ownerMcpServerId },
193
+ `MCP server "${tool.ownerMcpServerId}" tried to register tool "${tool.name}" which conflicts with a core tool. Skipping.`,
194
+ );
195
+ continue;
196
+ }
197
+ if (existing.origin === 'skill') {
198
+ log.warn(
199
+ { toolName: tool.name, serverId: tool.ownerMcpServerId, skillId: existing.ownerSkillId },
200
+ `MCP server "${tool.ownerMcpServerId}" tried to register tool "${tool.name}" which conflicts with skill tool from "${existing.ownerSkillId}". Skipping.`,
201
+ );
202
+ continue;
203
+ }
204
+ if (existing.origin === 'mcp' && existing.ownerMcpServerId !== tool.ownerMcpServerId) {
205
+ throw new Error(
206
+ `MCP tool "${tool.name}" is already registered by MCP server "${existing.ownerMcpServerId}"`,
207
+ );
208
+ }
209
+ }
210
+ accepted.push(tool);
211
+ }
212
+
213
+ for (const tool of accepted) {
214
+ tools.set(tool.name, tool);
215
+ log.info({ name: tool.name, ownerMcpServerId: tool.ownerMcpServerId }, 'MCP tool registered');
216
+ }
217
+
218
+ return accepted;
219
+ }
220
+
221
+ /**
222
+ * Unregister all tools belonging to a specific MCP server.
223
+ */
224
+ export function unregisterMcpTools(serverId: string): void {
225
+ for (const [name, tool] of tools) {
226
+ if (tool.origin === 'mcp' && tool.ownerMcpServerId === serverId) {
227
+ tools.delete(name);
228
+ log.info({ name, serverId }, 'MCP tool unregistered');
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Return the names of all currently registered MCP-origin tools.
235
+ */
236
+ export function getMcpToolNames(): string[] {
237
+ return Array.from(tools.values())
238
+ .filter((t) => t.origin === 'mcp')
239
+ .map((t) => t.name);
240
+ }
241
+
179
242
  /**
180
243
  * Return the names of all currently registered skill-origin tools.
181
244
  */
@@ -170,10 +170,12 @@ export interface Tool {
170
170
  defaultRiskLevel: RiskLevel;
171
171
  /** When set to 'proxy', the tool is forwarded to a connected client rather than executed locally. */
172
172
  executionMode?: 'local' | 'proxy';
173
- /** Whether this tool is a core built-in or provided by a skill. */
174
- origin?: 'core' | 'skill';
173
+ /** Whether this tool is a core built-in, provided by a skill, or from an MCP server. */
174
+ origin?: 'core' | 'skill' | 'mcp';
175
175
  /** If origin is 'skill', the ID of the owning skill. */
176
176
  ownerSkillId?: string;
177
+ /** If origin is 'mcp', the ID of the owning MCP server. */
178
+ ownerMcpServerId?: string;
177
179
  /** Content-hash of the owning skill's source at registration time. */
178
180
  ownerSkillVersionHash?: string;
179
181
  /** Whether the owning skill is bundled with the daemon (trusted first-party). */