@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.
- package/bun.lock +139 -2
- package/package.json +2 -1
- package/src/cli/mcp.ts +58 -0
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/schema.ts +12 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/index.ts +2 -0
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/registry.ts +64 -1
- package/src/tools/types.ts +4 -2
|
@@ -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>;
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -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 {
|
|
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
|
|