@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,141 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
7
+
8
+ const CLI = join(import.meta.dir, '..', 'index.ts');
9
+
10
+ let testDataDir: string;
11
+ let configPath: string;
12
+
13
+ function runMcpList(args: string[] = []): { stdout: string; exitCode: number } {
14
+ const result = spawnSync('bun', ['run', CLI, 'mcp', 'list', ...args], {
15
+ encoding: 'utf-8',
16
+ timeout: 10_000,
17
+ env: { ...process.env, BASE_DATA_DIR: testDataDir },
18
+ });
19
+ return {
20
+ stdout: (result.stdout ?? '').toString(),
21
+ exitCode: result.status ?? 1,
22
+ };
23
+ }
24
+
25
+ function writeConfig(config: Record<string, unknown>): void {
26
+ writeFileSync(configPath, JSON.stringify(config), 'utf-8');
27
+ }
28
+
29
+ describe('vellum mcp list', () => {
30
+ beforeAll(() => {
31
+ testDataDir = join(tmpdir(), `vellum-mcp-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
32
+ const workspaceDir = join(testDataDir, '.vellum', 'workspace');
33
+ mkdirSync(workspaceDir, { recursive: true });
34
+ configPath = join(workspaceDir, 'config.json');
35
+ writeConfig({});
36
+ });
37
+
38
+ afterAll(() => {
39
+ rmSync(testDataDir, { recursive: true, force: true });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ writeConfig({});
44
+ });
45
+
46
+ test('shows message when no MCP servers configured', () => {
47
+ const { stdout, exitCode } = runMcpList();
48
+ expect(exitCode).toBe(0);
49
+ expect(stdout).toContain('No MCP servers configured');
50
+ });
51
+
52
+ test('lists configured servers', () => {
53
+ writeConfig({
54
+ mcp: {
55
+ servers: {
56
+ 'test-server': {
57
+ transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
58
+ enabled: true,
59
+ defaultRiskLevel: 'medium',
60
+ },
61
+ },
62
+ },
63
+ });
64
+
65
+ const { stdout, exitCode } = runMcpList();
66
+ expect(exitCode).toBe(0);
67
+ expect(stdout).toContain('1 MCP server(s) configured');
68
+ expect(stdout).toContain('test-server');
69
+ expect(stdout).toContain('streamable-http');
70
+ expect(stdout).toContain('https://example.com/mcp');
71
+ expect(stdout).toContain('medium');
72
+ });
73
+
74
+ test('shows disabled status', () => {
75
+ writeConfig({
76
+ mcp: {
77
+ servers: {
78
+ 'disabled-server': {
79
+ transport: { type: 'sse', url: 'https://example.com/sse' },
80
+ enabled: false,
81
+ defaultRiskLevel: 'high',
82
+ },
83
+ },
84
+ },
85
+ });
86
+
87
+ const { stdout, exitCode } = runMcpList();
88
+ expect(exitCode).toBe(0);
89
+ expect(stdout).toContain('disabled');
90
+ });
91
+
92
+ test('shows stdio command info', () => {
93
+ writeConfig({
94
+ mcp: {
95
+ servers: {
96
+ 'stdio-server': {
97
+ transport: { type: 'stdio', command: 'npx', args: ['-y', 'some-mcp-server'] },
98
+ enabled: true,
99
+ defaultRiskLevel: 'low',
100
+ },
101
+ },
102
+ },
103
+ });
104
+
105
+ const { stdout, exitCode } = runMcpList();
106
+ expect(exitCode).toBe(0);
107
+ expect(stdout).toContain('stdio-server');
108
+ expect(stdout).toContain('stdio');
109
+ expect(stdout).toContain('npx -y some-mcp-server');
110
+ expect(stdout).toContain('low');
111
+ });
112
+
113
+ test('--json outputs valid JSON', () => {
114
+ writeConfig({
115
+ mcp: {
116
+ servers: {
117
+ 'json-server': {
118
+ transport: { type: 'streamable-http', url: 'https://example.com/mcp' },
119
+ enabled: true,
120
+ defaultRiskLevel: 'high',
121
+ },
122
+ },
123
+ },
124
+ });
125
+
126
+ const { stdout, exitCode } = runMcpList(['--json']);
127
+ expect(exitCode).toBe(0);
128
+ const parsed = JSON.parse(stdout);
129
+ expect(Array.isArray(parsed)).toBe(true);
130
+ expect(parsed).toHaveLength(1);
131
+ expect(parsed[0].id).toBe('json-server');
132
+ expect(parsed[0].transport.url).toBe('https://example.com/mcp');
133
+ });
134
+
135
+ test('--json outputs empty array when no servers', () => {
136
+ const { stdout, exitCode } = runMcpList(['--json']);
137
+ expect(exitCode).toBe(0);
138
+ const parsed = JSON.parse(stdout);
139
+ expect(parsed).toEqual([]);
140
+ });
141
+ });
package/src/cli/mcp.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { Command } from 'commander';
2
+
3
+ import { loadRawConfig, saveRawConfig } from '../config/loader.js';
4
+ import type { McpConfig, McpServerConfig } from '../config/mcp-schema.js';
5
+ import { getCliLogger } from '../util/logger.js';
6
+
7
+ const log = getCliLogger('cli');
8
+
9
+ export function registerMcpCommand(program: Command): void {
10
+ const mcp = program.command('mcp').description('Manage MCP (Model Context Protocol) servers');
11
+
12
+ mcp
13
+ .command('list')
14
+ .description('List configured MCP servers and their status')
15
+ .option('--json', 'Output as JSON')
16
+ .action((opts: { json?: boolean }) => {
17
+ const raw = loadRawConfig();
18
+ const mcpConfig = raw.mcp as Partial<McpConfig> | undefined;
19
+ const servers = mcpConfig?.servers ?? {};
20
+ const entries = Object.entries(servers) as [string, McpServerConfig][];
21
+
22
+ if (entries.length === 0) {
23
+ if (opts.json) {
24
+ process.stdout.write(JSON.stringify([], null, 2) + '\n');
25
+ } else {
26
+ log.info('No MCP servers configured.');
27
+ }
28
+ return;
29
+ }
30
+
31
+ if (opts.json) {
32
+ const result = entries
33
+ .filter(([, config]) => config && typeof config === 'object')
34
+ .map(([id, config]) => ({ id, ...config }));
35
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
36
+ return;
37
+ }
38
+
39
+ log.info(`${entries.length} MCP server(s) configured:\n`);
40
+ for (const [id, cfg] of entries) {
41
+ if (!cfg || typeof cfg !== 'object') {
42
+ log.info(` ${id} (invalid config — skipped)\n`);
43
+ continue;
44
+ }
45
+ const enabled = cfg.enabled !== false;
46
+ const transport = cfg.transport;
47
+ const risk = cfg.defaultRiskLevel ?? 'high';
48
+ const status = enabled ? '✓ enabled' : '✗ disabled';
49
+
50
+ log.info(` ${id}`);
51
+ log.info(` Status: ${status}`);
52
+ log.info(` Transport: ${transport?.type ?? 'unknown'}`);
53
+ if (transport?.type === 'stdio') {
54
+ log.info(` Command: ${transport.command} ${(transport.args ?? []).join(' ')}`);
55
+ } else if (transport && 'url' in transport) {
56
+ log.info(` URL: ${transport.url}`);
57
+ }
58
+ log.info(` Risk: ${risk}`);
59
+ if (cfg.allowedTools) log.info(` Allowed: ${cfg.allowedTools.join(', ')}`);
60
+ if (cfg.blockedTools) log.info(` Blocked: ${cfg.blockedTools.join(', ')}`);
61
+ log.info('');
62
+ }
63
+ });
64
+
65
+ mcp
66
+ .command('add <name>')
67
+ .description('Add an MCP server configuration')
68
+ .requiredOption('-t, --transport-type <type>', 'Transport type: stdio, sse, or streamable-http')
69
+ .option('-u, --url <url>', 'Server URL (for sse/streamable-http)')
70
+ .option('-c, --command <cmd>', 'Command to run (for stdio)')
71
+ .option('-a, --args <args...>', 'Command arguments (for stdio)')
72
+ .option('-r, --risk <level>', 'Default risk level: low, medium, or high', 'high')
73
+ .option('--disabled', 'Add as disabled')
74
+ .action((name: string, opts: {
75
+ transportType: string;
76
+ url?: string;
77
+ command?: string;
78
+ args?: string[];
79
+ risk: string;
80
+ disabled?: boolean;
81
+ }) => {
82
+ const raw = loadRawConfig();
83
+ if (!raw.mcp) raw.mcp = { servers: {} };
84
+ const mcpConfig = raw.mcp as Record<string, unknown>;
85
+ if (!mcpConfig.servers) mcpConfig.servers = {};
86
+ const servers = mcpConfig.servers as Record<string, unknown>;
87
+
88
+ if (servers[name]) {
89
+ log.error(`MCP server "${name}" already exists. Remove it first with: vellum mcp remove ${name}`);
90
+ return;
91
+ }
92
+
93
+ let transport: Record<string, unknown>;
94
+ switch (opts.transportType) {
95
+ case 'stdio':
96
+ if (!opts.command) {
97
+ log.error('--command is required for stdio transport');
98
+ return;
99
+ }
100
+ transport = { type: 'stdio', command: opts.command, args: opts.args ?? [] };
101
+ break;
102
+ case 'sse':
103
+ case 'streamable-http':
104
+ if (!opts.url) {
105
+ log.error(`--url is required for ${opts.transportType} transport`);
106
+ return;
107
+ }
108
+ transport = { type: opts.transportType, url: opts.url };
109
+ break;
110
+ default:
111
+ log.error(`Unknown transport type: ${opts.transportType}. Must be stdio, sse, or streamable-http`);
112
+ return;
113
+ }
114
+
115
+ if (!['low', 'medium', 'high'].includes(opts.risk)) {
116
+ log.error(`Invalid risk level: ${opts.risk}. Must be low, medium, or high`);
117
+ return;
118
+ }
119
+
120
+ servers[name] = {
121
+ transport,
122
+ enabled: !opts.disabled,
123
+ defaultRiskLevel: opts.risk,
124
+ };
125
+
126
+ saveRawConfig(raw);
127
+ log.info(`Added MCP server "${name}" (${opts.transportType})`);
128
+ log.info('Restart the daemon for changes to take effect: vellum daemon restart');
129
+ });
130
+ }
@@ -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
+ }