voyageai-cli 1.27.0 → 1.28.0
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/package.json +1 -1
- package/src/commands/config.js +33 -0
- package/src/commands/mcp-server.js +4 -1
- package/src/commands/playground.js +19 -2
- package/src/commands/workflow.js +45 -0
- package/src/lib/api.js +40 -2
- package/src/lib/workflow.js +98 -2
- package/src/mcp/server.js +15 -2
- package/src/mcp/sse-transport.js +112 -0
- package/src/playground/index.html +1197 -232
package/package.json
CHANGED
package/src/commands/config.js
CHANGED
|
@@ -67,6 +67,39 @@ function registerConfig(program) {
|
|
|
67
67
|
const storedValue = key === 'default-dimensions' ? parseInt(value, 10) : value;
|
|
68
68
|
setConfigValue(internalKey, storedValue);
|
|
69
69
|
console.log(ui.success(`Set ${ui.cyan(key)} = ${SECRET_KEYS.has(internalKey) ? ui.dim(maskSecret(String(storedValue))) : storedValue}`));
|
|
70
|
+
|
|
71
|
+
// When setting an API key, auto-configure the matching base URL
|
|
72
|
+
if (internalKey === 'apiKey') {
|
|
73
|
+
const { identifyKey, ATLAS_API_BASE, VOYAGE_API_BASE } = require('../lib/api');
|
|
74
|
+
const keyInfo = identifyKey(storedValue);
|
|
75
|
+
const currentBase = getConfigValue('baseUrl');
|
|
76
|
+
|
|
77
|
+
if (keyInfo.type === 'atlas') {
|
|
78
|
+
if (currentBase !== ATLAS_API_BASE) {
|
|
79
|
+
setConfigValue('baseUrl', ATLAS_API_BASE);
|
|
80
|
+
console.log('');
|
|
81
|
+
console.log(ui.success(`Auto-configured base URL → ${ui.cyan(ATLAS_API_BASE)}`));
|
|
82
|
+
}
|
|
83
|
+
console.log('');
|
|
84
|
+
console.log(` 🔑 ${ui.bold('MongoDB Atlas key detected')} (al-*)`);
|
|
85
|
+
console.log(` Endpoint: ${ui.cyan(ATLAS_API_BASE)}`);
|
|
86
|
+
console.log(` Atlas provides Voyage AI models with Atlas-native billing.`);
|
|
87
|
+
console.log(` All vai features work identically on both endpoints.`);
|
|
88
|
+
} else if (keyInfo.type === 'voyage') {
|
|
89
|
+
if (currentBase !== VOYAGE_API_BASE) {
|
|
90
|
+
setConfigValue('baseUrl', VOYAGE_API_BASE);
|
|
91
|
+
console.log('');
|
|
92
|
+
console.log(ui.success(`Auto-configured base URL → ${ui.cyan(VOYAGE_API_BASE)}`));
|
|
93
|
+
}
|
|
94
|
+
console.log('');
|
|
95
|
+
console.log(` 🔑 ${ui.bold('Voyage AI key detected')} (pa-*)`);
|
|
96
|
+
console.log(` Endpoint: ${ui.cyan(VOYAGE_API_BASE)}`);
|
|
97
|
+
} else {
|
|
98
|
+
console.log('');
|
|
99
|
+
console.log(` ⚠️ Unrecognized key prefix. Expected ${ui.cyan('al-*')} (Atlas) or ${ui.cyan('pa-*')} (Voyage AI).`);
|
|
100
|
+
console.log(` Make sure your base URL matches: ${ui.cyan('vai config set base-url <url>')}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
70
103
|
});
|
|
71
104
|
|
|
72
105
|
// ── config get [key] ──
|
|
@@ -14,6 +14,7 @@ function registerMcpServer(program) {
|
|
|
14
14
|
.option('--host <address>', 'Bind address (http transport only)', '127.0.0.1')
|
|
15
15
|
.option('--db <name>', 'Default MongoDB database for tools')
|
|
16
16
|
.option('--collection <name>', 'Default collection for tools')
|
|
17
|
+
.option('--no-sse', 'Disable SSE transport (SSE is enabled by default for HTTP)')
|
|
17
18
|
.option('--verbose', 'Enable debug logging to stderr')
|
|
18
19
|
.action(async (opts) => {
|
|
19
20
|
if (opts.verbose) {
|
|
@@ -27,7 +28,9 @@ function registerMcpServer(program) {
|
|
|
27
28
|
const { runStdioServer, runHttpServer } = require('../mcp/server');
|
|
28
29
|
|
|
29
30
|
if (opts.transport === 'http') {
|
|
30
|
-
|
|
31
|
+
// SSE is enabled by default for HTTP transport (required for n8n, etc.)
|
|
32
|
+
// Use --no-sse to disable if needed
|
|
33
|
+
await runHttpServer({ port: opts.port, host: opts.host, sse: opts.sse !== false });
|
|
31
34
|
} else if (opts.transport === 'stdio') {
|
|
32
35
|
await runStdioServer();
|
|
33
36
|
} else {
|
|
@@ -14,13 +14,16 @@ function registerPlayground(program) {
|
|
|
14
14
|
.command('playground')
|
|
15
15
|
.description('Launch interactive web playground for Voyage AI')
|
|
16
16
|
.option('-p, --port <port>', 'Port to serve on', '3333')
|
|
17
|
+
.option('--host <address>', 'Bind address', '127.0.0.1')
|
|
17
18
|
.option('--no-open', 'Skip auto-opening browser')
|
|
18
19
|
.action(async (opts) => {
|
|
19
20
|
const port = parseInt(opts.port, 10) || 3333;
|
|
21
|
+
const host = opts.host || '127.0.0.1';
|
|
20
22
|
const server = createPlaygroundServer();
|
|
21
23
|
|
|
22
|
-
server.listen(port, () => {
|
|
23
|
-
const
|
|
24
|
+
server.listen(port, host, () => {
|
|
25
|
+
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
|
26
|
+
const url = `http://${displayHost}:${port}`;
|
|
24
27
|
console.log(`🧭 Playground running at ${url} — Press Ctrl+C to stop`);
|
|
25
28
|
|
|
26
29
|
if (opts.open !== false) {
|
|
@@ -366,6 +369,20 @@ function createPlaygroundServer() {
|
|
|
366
369
|
return;
|
|
367
370
|
}
|
|
368
371
|
|
|
372
|
+
// API: List example workflows (must be before the :name route)
|
|
373
|
+
if (req.method === 'GET' && req.url === '/api/workflows/examples') {
|
|
374
|
+
try {
|
|
375
|
+
const { listExampleWorkflows } = require('../lib/workflow');
|
|
376
|
+
const examples = listExampleWorkflows();
|
|
377
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
378
|
+
res.end(JSON.stringify({ examples }));
|
|
379
|
+
} catch (err) {
|
|
380
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
381
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
369
386
|
// API: Get a specific workflow by name
|
|
370
387
|
if (req.method === 'GET' && req.url?.startsWith('/api/workflows/')) {
|
|
371
388
|
const name = decodeURIComponent(req.url.replace('/api/workflows/', ''));
|
package/src/commands/workflow.js
CHANGED
|
@@ -22,6 +22,45 @@ function collectInputs(pair, prev) {
|
|
|
22
22
|
return prev;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Interactively prompt the user for missing workflow inputs using @clack/prompts.
|
|
27
|
+
* Only prompts for inputs not already provided via --input flags.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} definition - Workflow definition
|
|
30
|
+
* @param {object} existingInputs - Inputs already provided via --input
|
|
31
|
+
* @returns {Promise<object>} Merged inputs (existing + prompted)
|
|
32
|
+
*/
|
|
33
|
+
async function promptForInputs(definition, existingInputs) {
|
|
34
|
+
const { buildInputSteps } = require('../lib/workflow');
|
|
35
|
+
const { createCLIRenderer } = require('../lib/wizard-cli');
|
|
36
|
+
const { runWizard } = require('../lib/wizard');
|
|
37
|
+
|
|
38
|
+
const allSteps = buildInputSteps(definition);
|
|
39
|
+
// Only prompt for inputs not already provided
|
|
40
|
+
const steps = allSteps.filter(s => !(s.id in existingInputs));
|
|
41
|
+
if (steps.length === 0) return existingInputs;
|
|
42
|
+
|
|
43
|
+
const renderer = createCLIRenderer({
|
|
44
|
+
title: `${definition.name || 'Workflow'} inputs`,
|
|
45
|
+
doneMessage: 'Inputs ready.',
|
|
46
|
+
showBackHint: true,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const { answers, cancelled } = await runWizard({
|
|
50
|
+
steps,
|
|
51
|
+
config: {},
|
|
52
|
+
renderer,
|
|
53
|
+
initial: {},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (cancelled) {
|
|
57
|
+
console.log(pc.dim('Cancelled.'));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { ...existingInputs, ...answers };
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
/**
|
|
26
65
|
* Register the workflow command on a Commander program.
|
|
27
66
|
* @param {import('commander').Command} program
|
|
@@ -43,6 +82,7 @@ function registerWorkflow(program) {
|
|
|
43
82
|
.option('--quiet', 'Suppress progress output', false)
|
|
44
83
|
.option('--dry-run', 'Show execution plan without running', false)
|
|
45
84
|
.option('--verbose', 'Show step details', false)
|
|
85
|
+
.option('--no-interactive', 'Disable interactive input prompting')
|
|
46
86
|
.action(async (file, opts) => {
|
|
47
87
|
const { loadWorkflow, executeWorkflow, buildExecutionPlan, validateWorkflow } = require('../lib/workflow');
|
|
48
88
|
|
|
@@ -64,6 +104,11 @@ function registerWorkflow(program) {
|
|
|
64
104
|
|
|
65
105
|
const workflowName = definition.name || file;
|
|
66
106
|
|
|
107
|
+
// Interactive prompting for missing inputs
|
|
108
|
+
if (opts.interactive !== false && process.stdin.isTTY) {
|
|
109
|
+
opts.input = await promptForInputs(definition, opts.input);
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
if (opts.dryRun) {
|
|
68
113
|
// Dry run: show plan
|
|
69
114
|
const layers = buildExecutionPlan(definition.steps);
|
package/src/lib/api.js
CHANGED
|
@@ -4,10 +4,24 @@ const ATLAS_API_BASE = 'https://ai.mongodb.com/v1';
|
|
|
4
4
|
const VOYAGE_API_BASE = 'https://api.voyageai.com/v1';
|
|
5
5
|
const MAX_RETRIES = 3;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Identify the key type from its prefix.
|
|
9
|
+
* @param {string} key
|
|
10
|
+
* @returns {{ type: 'atlas'|'voyage'|'unknown', label: string, expectedBase: string }}
|
|
11
|
+
*/
|
|
12
|
+
function identifyKey(key) {
|
|
13
|
+
if (key.startsWith('al-')) {
|
|
14
|
+
return { type: 'atlas', label: 'MongoDB Atlas', expectedBase: ATLAS_API_BASE };
|
|
15
|
+
}
|
|
16
|
+
if (key.startsWith('pa-')) {
|
|
17
|
+
return { type: 'voyage', label: 'Voyage AI (direct)', expectedBase: VOYAGE_API_BASE };
|
|
18
|
+
}
|
|
19
|
+
return { type: 'unknown', label: 'Unknown provider', expectedBase: ATLAS_API_BASE };
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/**
|
|
8
23
|
* Resolve the API base URL.
|
|
9
24
|
* Priority: VOYAGE_API_BASE env → config baseUrl → auto-detect from key prefix.
|
|
10
|
-
* Keys starting with 'pa-' that work on Voyage platform use VOYAGE_API_BASE.
|
|
11
25
|
* @returns {string}
|
|
12
26
|
*/
|
|
13
27
|
function getApiBase() {
|
|
@@ -20,6 +34,10 @@ function getApiBase() {
|
|
|
20
34
|
const configBase = getConfigValue('baseUrl');
|
|
21
35
|
if (configBase) return configBase.replace(/\/+$/, '');
|
|
22
36
|
|
|
37
|
+
// Auto-detect from key prefix
|
|
38
|
+
const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
|
|
39
|
+
if (key) return identifyKey(key).expectedBase;
|
|
40
|
+
|
|
23
41
|
// Default to Atlas endpoint
|
|
24
42
|
return ATLAS_API_BASE;
|
|
25
43
|
}
|
|
@@ -29,7 +47,7 @@ const API_BASE = ATLAS_API_BASE;
|
|
|
29
47
|
|
|
30
48
|
/**
|
|
31
49
|
* Get the Voyage API key or exit with a helpful error.
|
|
32
|
-
*
|
|
50
|
+
* Validates that the key prefix matches the configured base URL and warns on mismatch.
|
|
33
51
|
* @returns {string}
|
|
34
52
|
*/
|
|
35
53
|
function requireApiKey() {
|
|
@@ -43,6 +61,25 @@ function requireApiKey() {
|
|
|
43
61
|
' or Voyage AI platform > Dashboard > API Keys';
|
|
44
62
|
throw new Error(msg);
|
|
45
63
|
}
|
|
64
|
+
|
|
65
|
+
// Validate key/endpoint match and warn on mismatch
|
|
66
|
+
const base = getApiBase();
|
|
67
|
+
const keyInfo = identifyKey(key);
|
|
68
|
+
|
|
69
|
+
if (keyInfo.type !== 'unknown' && keyInfo.expectedBase !== base) {
|
|
70
|
+
const mismatch =
|
|
71
|
+
`\n⚠️ API key/endpoint mismatch detected!\n` +
|
|
72
|
+
` Key type: ${keyInfo.label} (${key.slice(0, 5)}...)\n` +
|
|
73
|
+
` Endpoint: ${base}\n` +
|
|
74
|
+
` Expected: ${keyInfo.expectedBase}\n\n` +
|
|
75
|
+
` This will likely cause a 401 or 403 error.\n\n` +
|
|
76
|
+
` Fix: Update your base URL to match your key:\n` +
|
|
77
|
+
` vai config set base-url ${keyInfo.expectedBase}\n\n` +
|
|
78
|
+
` Or switch to a ${base.includes('ai.mongodb.com') ? 'MongoDB Atlas' : 'Voyage AI'} key:\n` +
|
|
79
|
+
` vai config set api-key <your-${base.includes('ai.mongodb.com') ? 'atlas' : 'voyage'}-key>\n`;
|
|
80
|
+
process.stderr.write(mismatch);
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
return key;
|
|
47
84
|
}
|
|
48
85
|
|
|
@@ -162,6 +199,7 @@ module.exports = {
|
|
|
162
199
|
API_BASE,
|
|
163
200
|
ATLAS_API_BASE,
|
|
164
201
|
VOYAGE_API_BASE,
|
|
202
|
+
identifyKey,
|
|
165
203
|
getApiBase,
|
|
166
204
|
requireApiKey,
|
|
167
205
|
apiRequest,
|
package/src/lib/workflow.js
CHANGED
|
@@ -92,10 +92,12 @@ function validateWorkflow(definition) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Check template references point to known step IDs or reserved prefixes
|
|
95
|
+
// "item" and "index" are injected by forEach at runtime
|
|
96
|
+
const forEachVars = step.forEach ? new Set(['item', 'index']) : new Set();
|
|
95
97
|
if (step.inputs) {
|
|
96
98
|
const deps = extractDependencies(step.inputs);
|
|
97
99
|
for (const dep of deps) {
|
|
98
|
-
if (!stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
100
|
+
if (!forEachVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
99
101
|
errors.push(`${stepPrefix}: references unknown step "${dep}"`);
|
|
100
102
|
}
|
|
101
103
|
}
|
|
@@ -1162,6 +1164,36 @@ function coerceInput(value, type) {
|
|
|
1162
1164
|
return value;
|
|
1163
1165
|
}
|
|
1164
1166
|
|
|
1167
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1168
|
+
// Input Schema Helpers
|
|
1169
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Convert a workflow's `inputs` object into wizard-engine-compatible step definitions.
|
|
1173
|
+
* Used by both CLI (via @clack/prompts) and playground (via input modal) to
|
|
1174
|
+
* prompt users for missing inputs before execution.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {object} definition - Workflow definition with an `inputs` property
|
|
1177
|
+
* @returns {import('./wizard').Step[]}
|
|
1178
|
+
*/
|
|
1179
|
+
function buildInputSteps(definition) {
|
|
1180
|
+
if (!definition.inputs) return [];
|
|
1181
|
+
return Object.entries(definition.inputs).map(([key, spec]) => ({
|
|
1182
|
+
id: key,
|
|
1183
|
+
label: spec.description || key,
|
|
1184
|
+
type: 'text',
|
|
1185
|
+
required: !!spec.required,
|
|
1186
|
+
placeholder: spec.type === 'number' ? 'number' : (spec.type || 'string'),
|
|
1187
|
+
defaultValue: spec.default !== undefined ? String(spec.default) : undefined,
|
|
1188
|
+
validate: (val) => {
|
|
1189
|
+
if (spec.type === 'number' && val && isNaN(Number(val))) {
|
|
1190
|
+
return 'Must be a number';
|
|
1191
|
+
}
|
|
1192
|
+
return true;
|
|
1193
|
+
},
|
|
1194
|
+
}));
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1165
1197
|
// ════════════════════════════════════════════════════════════════════
|
|
1166
1198
|
// Built-in Templates
|
|
1167
1199
|
// ════════════════════════════════════════════════════════════════════
|
|
@@ -1196,8 +1228,60 @@ function listBuiltinWorkflows() {
|
|
|
1196
1228
|
});
|
|
1197
1229
|
}
|
|
1198
1230
|
|
|
1231
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1232
|
+
// Example Workflows
|
|
1233
|
+
// ════════════════════════════════════════════════════════════════════
|
|
1234
|
+
|
|
1235
|
+
const EXAMPLE_CATEGORIES = {
|
|
1236
|
+
'search-filter-transform': 'Retrieval',
|
|
1237
|
+
'conditional-fallback-search': 'Retrieval',
|
|
1238
|
+
'multi-query-fusion': 'Retrieval',
|
|
1239
|
+
'rag-with-guardrails': 'RAG',
|
|
1240
|
+
'question-answer-with-citations': 'RAG',
|
|
1241
|
+
'topic-deep-dive': 'RAG',
|
|
1242
|
+
'dedup-and-ingest': 'Ingestion',
|
|
1243
|
+
'content-quality-gate': 'Ingestion',
|
|
1244
|
+
'embedding-model-comparison': 'Analysis',
|
|
1245
|
+
'batch-similarity-check': 'Analysis',
|
|
1246
|
+
'collection-inventory': 'Analysis',
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Get the path to the example workflows directory.
|
|
1251
|
+
*/
|
|
1252
|
+
function getExamplesDir() {
|
|
1253
|
+
return path.join(__dirname, '..', '..', 'examples', 'workflows');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1199
1256
|
/**
|
|
1200
|
-
*
|
|
1257
|
+
* List example workflow files with category metadata.
|
|
1258
|
+
* @returns {Array<{ name: string, description: string, file: string, category: string, isExample: boolean }>}
|
|
1259
|
+
*/
|
|
1260
|
+
function listExampleWorkflows() {
|
|
1261
|
+
const dir = getExamplesDir();
|
|
1262
|
+
if (!fs.existsSync(dir)) return [];
|
|
1263
|
+
|
|
1264
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
1265
|
+
return files.map(f => {
|
|
1266
|
+
try {
|
|
1267
|
+
const def = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
1268
|
+
const stem = f.replace('.json', '');
|
|
1269
|
+
return {
|
|
1270
|
+
name: stem,
|
|
1271
|
+
description: def.description || def.name || f,
|
|
1272
|
+
file: f,
|
|
1273
|
+
category: EXAMPLE_CATEGORIES[stem] || 'Other',
|
|
1274
|
+
isExample: true,
|
|
1275
|
+
};
|
|
1276
|
+
} catch {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
}).filter(Boolean);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Load a workflow definition from a file path, built-in template name,
|
|
1284
|
+
* or example workflow name.
|
|
1201
1285
|
*
|
|
1202
1286
|
* @param {string} nameOrPath - File path or template name (e.g., "multi-collection-search")
|
|
1203
1287
|
* @returns {object} Parsed workflow definition
|
|
@@ -1216,6 +1300,13 @@ function loadWorkflow(nameOrPath) {
|
|
|
1216
1300
|
return JSON.parse(content);
|
|
1217
1301
|
}
|
|
1218
1302
|
|
|
1303
|
+
// Try as an example workflow name
|
|
1304
|
+
const examplePath = path.join(getExamplesDir(), `${nameOrPath}.json`);
|
|
1305
|
+
if (fs.existsSync(examplePath)) {
|
|
1306
|
+
const content = fs.readFileSync(examplePath, 'utf8');
|
|
1307
|
+
return JSON.parse(content);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1219
1310
|
// Try with .json extension appended
|
|
1220
1311
|
const withJson = nameOrPath.endsWith('.json') ? nameOrPath : `${nameOrPath}.json`;
|
|
1221
1312
|
if (fs.existsSync(withJson)) {
|
|
@@ -1249,8 +1340,13 @@ module.exports = {
|
|
|
1249
1340
|
|
|
1250
1341
|
// Templates
|
|
1251
1342
|
listBuiltinWorkflows,
|
|
1343
|
+
listExampleWorkflows,
|
|
1252
1344
|
loadWorkflow,
|
|
1253
1345
|
getWorkflowsDir,
|
|
1346
|
+
getExamplesDir,
|
|
1347
|
+
|
|
1348
|
+
// Input helpers
|
|
1349
|
+
buildInputSteps,
|
|
1254
1350
|
|
|
1255
1351
|
// Constants
|
|
1256
1352
|
VAI_TOOLS,
|
package/src/mcp/server.js
CHANGED
|
@@ -51,7 +51,7 @@ async function runStdioServer() {
|
|
|
51
51
|
* @param {number} options.port
|
|
52
52
|
* @param {string} options.host
|
|
53
53
|
*/
|
|
54
|
-
async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
|
|
54
|
+
async function runHttpServer({ port = 3100, host = '127.0.0.1', sse = false } = {}) {
|
|
55
55
|
const express = require('express');
|
|
56
56
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
57
57
|
const { getConfigValue } = require('../lib/config');
|
|
@@ -131,8 +131,21 @@ async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
|
|
|
131
131
|
res.status(405).json({ error: 'Method not allowed. Stateless server — no sessions to delete.' });
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
// SSE transport (opt-in via --sse flag)
|
|
135
|
+
if (sse) {
|
|
136
|
+
const { setupSSE, getSessionCount } = require('./sse-transport');
|
|
137
|
+
setupSSE(app, authenticateRequest);
|
|
138
|
+
|
|
139
|
+
// Add SSE session count endpoint
|
|
140
|
+
app.get('/health/sse', (_req, res) => {
|
|
141
|
+
res.json({ sseSessions: getSessionCount() });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
app.listen(port, host, () => {
|
|
135
|
-
const
|
|
146
|
+
const transports = ['Streamable HTTP (POST /mcp)'];
|
|
147
|
+
if (sse) transports.push('SSE (GET /sse)');
|
|
148
|
+
const msg = `vai MCP server v${VERSION} running on http://${host}:${port}\n Transports: ${transports.join(', ')}`;
|
|
136
149
|
if (process.env.VAI_MCP_VERBOSE) {
|
|
137
150
|
process.stderr.write(msg + '\n');
|
|
138
151
|
process.stderr.write(`Authentication: ${requireAuth ? 'enabled' : 'disabled (no keys configured)'}\n`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SSEServerTransport } = require('@modelcontextprotocol/sdk/server/sse.js');
|
|
4
|
+
const { createServer } = require('./server');
|
|
5
|
+
|
|
6
|
+
/** @type {Map<string, { server: any, transport: SSEServerTransport, createdAt: number, lastActivity: number }>} */
|
|
7
|
+
const sessions = new Map();
|
|
8
|
+
|
|
9
|
+
const MAX_SESSIONS = 50;
|
|
10
|
+
const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Prune idle or expired SSE sessions.
|
|
14
|
+
*/
|
|
15
|
+
function pruneSessions() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
for (const [id, entry] of sessions) {
|
|
18
|
+
if (now - entry.lastActivity > IDLE_TIMEOUT_MS) {
|
|
19
|
+
if (process.env.VAI_MCP_VERBOSE) {
|
|
20
|
+
process.stderr.write(`SSE session ${id} idle-pruned after ${Math.floor((now - entry.lastActivity) / 1000)}s\n`);
|
|
21
|
+
}
|
|
22
|
+
try { entry.transport.close(); } catch { /* ignore */ }
|
|
23
|
+
sessions.delete(id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Run pruning every 5 minutes
|
|
29
|
+
let _pruneInterval = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register SSE transport routes on an Express app.
|
|
33
|
+
*
|
|
34
|
+
* GET /sse — Client opens an SSE stream (returns event stream + session ID)
|
|
35
|
+
* POST /messages — Client sends JSON-RPC messages to a session
|
|
36
|
+
*
|
|
37
|
+
* @param {import('express').Express} app
|
|
38
|
+
* @param {Function} authenticateRequest — Express middleware for bearer auth
|
|
39
|
+
*/
|
|
40
|
+
function setupSSE(app, authenticateRequest) {
|
|
41
|
+
// Start the pruning interval
|
|
42
|
+
if (!_pruneInterval) {
|
|
43
|
+
_pruneInterval = setInterval(pruneSessions, 5 * 60 * 1000);
|
|
44
|
+
_pruneInterval.unref(); // Don't keep process alive just for pruning
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// SSE connection endpoint — client GETs this to open a stream
|
|
48
|
+
app.get('/sse', authenticateRequest, async (req, res) => {
|
|
49
|
+
// Enforce max concurrent sessions
|
|
50
|
+
pruneSessions();
|
|
51
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
52
|
+
return res.status(503).json({
|
|
53
|
+
error: `Too many active SSE sessions (max ${MAX_SESSIONS}). Try again later.`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const server = createServer();
|
|
58
|
+
const transport = new SSEServerTransport('/messages', res);
|
|
59
|
+
const sessionId = transport.sessionId;
|
|
60
|
+
|
|
61
|
+
sessions.set(sessionId, {
|
|
62
|
+
server,
|
|
63
|
+
transport,
|
|
64
|
+
createdAt: Date.now(),
|
|
65
|
+
lastActivity: Date.now(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (process.env.VAI_MCP_VERBOSE) {
|
|
69
|
+
process.stderr.write(`SSE session ${sessionId} connected (active: ${sessions.size})\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Clean up on disconnect
|
|
73
|
+
res.on('close', () => {
|
|
74
|
+
sessions.delete(sessionId);
|
|
75
|
+
try { transport.close(); } catch { /* ignore */ }
|
|
76
|
+
if (process.env.VAI_MCP_VERBOSE) {
|
|
77
|
+
process.stderr.write(`SSE session ${sessionId} disconnected (active: ${sessions.size})\n`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await server.connect(transport);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// SSE message endpoint — client POSTs JSON-RPC messages here
|
|
85
|
+
app.post('/messages', authenticateRequest, async (req, res) => {
|
|
86
|
+
const sessionId = req.query.sessionId;
|
|
87
|
+
if (!sessionId) {
|
|
88
|
+
return res.status(400).json({ error: 'Missing sessionId query parameter' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entry = sessions.get(sessionId);
|
|
92
|
+
if (!entry) {
|
|
93
|
+
return res.status(404).json({
|
|
94
|
+
error: 'Session not found. It may have expired or been disconnected.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Update last activity for idle tracking
|
|
99
|
+
entry.lastActivity = Date.now();
|
|
100
|
+
|
|
101
|
+
await entry.transport.handlePostMessage(req, res, req.body);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get current SSE session count (for health endpoint).
|
|
107
|
+
*/
|
|
108
|
+
function getSessionCount() {
|
|
109
|
+
return sessions.size;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { setupSSE, getSessionCount };
|