voyageai-cli 1.26.1 → 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/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
- * Checks: env var config file.
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,
@@ -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
- * Load a workflow definition from a file path or built-in template name.
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 msg = `vai MCP server v${VERSION} running on http://${host}:${port}/mcp`;
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 };
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file