voyageai-cli 1.22.1 → 1.23.1

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,174 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Project init wizard step definitions.
5
+ *
6
+ * Surface-agnostic — consumed by CLI, Playground, and Desktop.
7
+ * Replaces the raw readline prompts in the old init command.
8
+ */
9
+
10
+ const { MODEL_CATALOG } = require('./catalog');
11
+ const { STRATEGIES } = require('./chunker');
12
+
13
+ /**
14
+ * Get available embedding models (non-legacy, non-unreleased).
15
+ */
16
+ function getEmbeddingModelOptions() {
17
+ return MODEL_CATALOG
18
+ .filter(m => m.type === 'embedding' && !m.legacy && !m.unreleased)
19
+ .map(m => ({
20
+ value: m.name,
21
+ label: m.name,
22
+ hint: `${m.shortFor || m.bestFor} — ${m.price}`,
23
+ }));
24
+ }
25
+
26
+ /**
27
+ * Get chunk strategy options.
28
+ */
29
+ function getStrategyOptions() {
30
+ const descriptions = {
31
+ fixed: 'Fixed character count',
32
+ sentence: 'Split on sentence boundaries',
33
+ paragraph: 'Split on paragraph boundaries',
34
+ recursive: 'Recursive splitting (recommended)',
35
+ markdown: 'Markdown-aware splitting',
36
+ };
37
+ return STRATEGIES.map(s => ({
38
+ value: s,
39
+ label: s,
40
+ hint: descriptions[s] || '',
41
+ }));
42
+ }
43
+
44
+ /**
45
+ * Get dimension options for the selected model.
46
+ */
47
+ function getDimensionOptions(answers) {
48
+ const model = answers.model;
49
+ const info = MODEL_CATALOG.find(m => m.name === model);
50
+ if (!info || !info.dimensions) {
51
+ return [
52
+ { value: '1024', label: '1024', hint: 'default' },
53
+ { value: '512', label: '512' },
54
+ { value: '256', label: '256' },
55
+ ];
56
+ }
57
+ // Parse dimensions string like "1024 (default), 256, 512, 2048"
58
+ const dims = info.dimensions.split(',').map(d => d.trim());
59
+ return dims.map(d => {
60
+ const isDefault = d.includes('default');
61
+ const val = d.replace(/[^0-9]/g, '');
62
+ return {
63
+ value: val,
64
+ label: val,
65
+ hint: isDefault ? 'default' : undefined,
66
+ };
67
+ });
68
+ }
69
+
70
+ const initSteps = [
71
+ // Embedding model
72
+ {
73
+ id: 'model',
74
+ label: 'Embedding model',
75
+ type: 'select',
76
+ options: () => getEmbeddingModelOptions(),
77
+ defaultValue: 'voyage-4-large',
78
+ required: true,
79
+ group: 'Embedding',
80
+ },
81
+
82
+ // MongoDB
83
+ {
84
+ id: 'db',
85
+ label: 'Database name',
86
+ type: 'text',
87
+ defaultValue: 'myapp',
88
+ placeholder: 'myapp',
89
+ required: true,
90
+ group: 'MongoDB Atlas',
91
+ },
92
+ {
93
+ id: 'collection',
94
+ label: 'Collection name',
95
+ type: 'text',
96
+ defaultValue: 'documents',
97
+ placeholder: 'documents',
98
+ required: true,
99
+ group: 'MongoDB Atlas',
100
+ },
101
+ {
102
+ id: 'field',
103
+ label: 'Embedding field',
104
+ type: 'text',
105
+ defaultValue: 'embedding',
106
+ placeholder: 'embedding',
107
+ group: 'MongoDB Atlas',
108
+ },
109
+ {
110
+ id: 'index',
111
+ label: 'Vector index name',
112
+ type: 'text',
113
+ defaultValue: 'vector_index',
114
+ placeholder: 'vector_index',
115
+ group: 'MongoDB Atlas',
116
+ },
117
+
118
+ // Dimensions
119
+ {
120
+ id: 'dimensions',
121
+ label: 'Dimensions',
122
+ type: 'select',
123
+ options: (answers) => getDimensionOptions(answers),
124
+ getDefault: (answers) => {
125
+ const info = MODEL_CATALOG.find(m => m.name === answers.model);
126
+ if (info && info.dimensions && info.dimensions.includes('1024')) return '1024';
127
+ return '512';
128
+ },
129
+ group: 'Embedding',
130
+ },
131
+
132
+ // Chunking
133
+ {
134
+ id: 'chunkStrategy',
135
+ label: 'Chunk strategy',
136
+ type: 'select',
137
+ options: () => getStrategyOptions(),
138
+ defaultValue: 'recursive',
139
+ group: 'Chunking',
140
+ },
141
+ {
142
+ id: 'chunkSize',
143
+ label: 'Chunk size (chars)',
144
+ type: 'text',
145
+ defaultValue: '512',
146
+ placeholder: '512',
147
+ validate: (v) => {
148
+ const n = parseInt(v, 10);
149
+ if (isNaN(n) || n < 50) return 'Must be a number ≥ 50';
150
+ return true;
151
+ },
152
+ group: 'Chunking',
153
+ },
154
+ {
155
+ id: 'chunkOverlap',
156
+ label: 'Chunk overlap (chars)',
157
+ type: 'text',
158
+ defaultValue: '50',
159
+ placeholder: '50',
160
+ validate: (v) => {
161
+ const n = parseInt(v, 10);
162
+ if (isNaN(n) || n < 0) return 'Must be a non-negative number';
163
+ return true;
164
+ },
165
+ group: 'Chunking',
166
+ },
167
+ ];
168
+
169
+ module.exports = {
170
+ initSteps,
171
+ getEmbeddingModelOptions,
172
+ getStrategyOptions,
173
+ getDimensionOptions,
174
+ };
@@ -0,0 +1,222 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Surface-agnostic wizard engine.
5
+ *
6
+ * Defines step schemas, validation, flow control (next/back/skip),
7
+ * and config resolution. UI renderers (CLI, Playground, Desktop)
8
+ * consume the same step definitions.
9
+ *
10
+ * A wizard is an ordered array of Step objects. The engine walks
11
+ * them forward/backward, skipping steps whose `skip` predicate
12
+ * returns true, and validating answers before advancing.
13
+ */
14
+
15
+ /**
16
+ * @typedef {Object} StepOption
17
+ * @property {string} value - stored value
18
+ * @property {string} label - display label
19
+ * @property {string} [hint] - secondary description
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} Step
24
+ * @property {string} id - unique key (becomes the answer key)
25
+ * @property {string} label - human-readable prompt
26
+ * @property {'select'|'text'|'password'|'confirm'} type
27
+ * @property {StepOption[]|function(answers,config):StepOption[]} [options] - for select type
28
+ * @property {*} [defaultValue] - static default
29
+ * @property {function(answers,config):*} [getDefault] - dynamic default
30
+ * @property {boolean} [required] - must have a value to advance
31
+ * @property {function(value,answers):true|string} [validate] - return true or error message
32
+ * @property {function(answers,config):boolean} [skip] - skip this step entirely
33
+ * @property {string} [group] - visual grouping label
34
+ * @property {string} [placeholder] - input placeholder / hint text
35
+ */
36
+
37
+ /**
38
+ * Resolve the effective options array for a step.
39
+ * @param {Step} step
40
+ * @param {object} answers - answers collected so far
41
+ * @param {object} config - existing configuration
42
+ * @returns {StepOption[]}
43
+ */
44
+ function resolveOptions(step, answers, config) {
45
+ if (typeof step.options === 'function') return step.options(answers, config);
46
+ return step.options || [];
47
+ }
48
+
49
+ /**
50
+ * Resolve the default value for a step.
51
+ * @param {Step} step
52
+ * @param {object} answers
53
+ * @param {object} config
54
+ * @returns {*}
55
+ */
56
+ function resolveDefault(step, answers, config) {
57
+ if (typeof step.getDefault === 'function') return step.getDefault(answers, config);
58
+ return step.defaultValue;
59
+ }
60
+
61
+ /**
62
+ * Determine whether a step should be skipped.
63
+ * @param {Step} step
64
+ * @param {object} answers
65
+ * @param {object} config
66
+ * @returns {boolean}
67
+ */
68
+ function shouldSkip(step, answers, config) {
69
+ if (typeof step.skip === 'function') return step.skip(answers, config);
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Validate a step's answer.
75
+ * @param {Step} step
76
+ * @param {*} value
77
+ * @param {object} answers
78
+ * @returns {true|string} true if valid, or error message string
79
+ */
80
+ function validateStep(step, value, answers) {
81
+ if (step.required && (value === undefined || value === null || value === '')) {
82
+ return `${step.label} is required`;
83
+ }
84
+ if (typeof step.validate === 'function') {
85
+ return step.validate(value, answers);
86
+ }
87
+ return true;
88
+ }
89
+
90
+ /**
91
+ * Compute the ordered list of non-skipped step indices.
92
+ * @param {Step[]} steps
93
+ * @param {object} answers
94
+ * @param {object} config
95
+ * @returns {number[]}
96
+ */
97
+ function activeIndices(steps, answers, config) {
98
+ const indices = [];
99
+ for (let i = 0; i < steps.length; i++) {
100
+ if (!shouldSkip(steps[i], answers, config)) {
101
+ indices.push(i);
102
+ }
103
+ }
104
+ return indices;
105
+ }
106
+
107
+ /**
108
+ * Walk a wizard definition and collect answers.
109
+ *
110
+ * This is the **headless engine**. It calls `renderer.prompt(step, context)`
111
+ * for each active step. The renderer returns:
112
+ * - A value (answer)
113
+ * - Symbol.for('back') → go to previous step
114
+ * - Symbol.for('cancel') → abort the wizard
115
+ *
116
+ * @param {Object} params
117
+ * @param {Step[]} params.steps - wizard step definitions
118
+ * @param {object} params.config - existing config (for skip/default resolution)
119
+ * @param {object} params.renderer - { prompt(step, ctx) → value|symbol, intro?, outro? }
120
+ * @param {object} [params.initial] - pre-filled answers
121
+ * @returns {Promise<{answers: object, cancelled: boolean}>}
122
+ */
123
+ async function runWizard({ steps, config = {}, renderer, initial = {} }) {
124
+ const answers = { ...initial };
125
+ const active = activeIndices(steps, answers, config);
126
+
127
+ if (active.length === 0) {
128
+ return { answers, cancelled: false };
129
+ }
130
+
131
+ if (renderer.intro) await renderer.intro(steps, config);
132
+
133
+ let pos = 0; // position within the active array
134
+
135
+ while (pos < active.length) {
136
+ const stepIdx = active[pos];
137
+ const step = steps[stepIdx];
138
+
139
+ // Recompute active list (answers may change skip predicates)
140
+ const currentActive = activeIndices(steps, answers, config);
141
+ if (!currentActive.includes(stepIdx)) {
142
+ pos++;
143
+ continue;
144
+ }
145
+
146
+ const options = resolveOptions(step, answers, config);
147
+ const defaultValue = answers[step.id] !== undefined
148
+ ? answers[step.id]
149
+ : resolveDefault(step, answers, config);
150
+
151
+ const result = await renderer.prompt(step, {
152
+ options,
153
+ defaultValue,
154
+ stepNumber: pos + 1,
155
+ totalSteps: currentActive.length,
156
+ isFirst: pos === 0,
157
+ isLast: pos === currentActive.length - 1,
158
+ answers,
159
+ config,
160
+ });
161
+
162
+ // Handle navigation
163
+ if (result === Symbol.for('cancel')) {
164
+ if (renderer.cancel) await renderer.cancel();
165
+ return { answers, cancelled: true };
166
+ }
167
+
168
+ if (result === Symbol.for('back')) {
169
+ if (pos > 0) pos--;
170
+ continue;
171
+ }
172
+
173
+ // Validate
174
+ const valid = validateStep(step, result, answers);
175
+ if (valid !== true) {
176
+ if (renderer.error) await renderer.error(valid);
177
+ continue; // re-prompt same step
178
+ }
179
+
180
+ answers[step.id] = result;
181
+ pos++;
182
+ }
183
+
184
+ if (renderer.outro) await renderer.outro(answers);
185
+
186
+ return { answers, cancelled: false };
187
+ }
188
+
189
+ /**
190
+ * Export step definitions as a plain serializable array
191
+ * (for web/desktop consumption). Strips functions, resolves
192
+ * options and defaults against the provided config.
193
+ *
194
+ * @param {Step[]} steps
195
+ * @param {object} config
196
+ * @returns {object[]}
197
+ */
198
+ function serializeSteps(steps, config = {}) {
199
+ const answers = {};
200
+ return steps
201
+ .filter(s => !shouldSkip(s, answers, config))
202
+ .map(s => ({
203
+ id: s.id,
204
+ label: s.label,
205
+ type: s.type,
206
+ required: !!s.required,
207
+ group: s.group || null,
208
+ placeholder: s.placeholder || null,
209
+ options: resolveOptions(s, answers, config),
210
+ defaultValue: resolveDefault(s, answers, config),
211
+ }));
212
+ }
213
+
214
+ module.exports = {
215
+ runWizard,
216
+ serializeSteps,
217
+ resolveOptions,
218
+ resolveDefault,
219
+ shouldSkip,
220
+ validateStep,
221
+ activeIndices,
222
+ };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+
5
+ /** vai_query input schema */
6
+ const querySchema = {
7
+ query: z.string().min(1).max(5000).describe('The question or search query in natural language'),
8
+ db: z.string().optional().describe('MongoDB database name. Uses vai config default if omitted.'),
9
+ collection: z.string().optional().describe('Collection with embedded documents. Uses vai config default if omitted.'),
10
+ limit: z.number().int().min(1).max(50).default(5).describe('Maximum number of results to return'),
11
+ model: z.string().optional().describe('Voyage AI embedding model. Default: voyage-4-large'),
12
+ rerank: z.boolean().default(true).describe('Whether to rerank results with Voyage AI reranker'),
13
+ filter: z.record(z.string(), z.unknown()).optional().describe("MongoDB pre-filter for vector search (e.g., { 'metadata.type': 'api-doc' })"),
14
+ };
15
+
16
+ /** vai_search input schema */
17
+ const searchSchema = {
18
+ query: z.string().min(1).max(5000).describe('Search query text'),
19
+ db: z.string().optional().describe('MongoDB database name'),
20
+ collection: z.string().optional().describe('Collection with embedded documents'),
21
+ limit: z.number().int().min(1).max(100).default(10).describe('Maximum results to return'),
22
+ model: z.string().optional().describe('Voyage AI embedding model'),
23
+ filter: z.record(z.string(), z.unknown()).optional().describe('MongoDB pre-filter for vector search'),
24
+ };
25
+
26
+ /** vai_rerank input schema */
27
+ const rerankSchema = {
28
+ query: z.string().min(1).max(5000).describe('The query to rank documents against'),
29
+ documents: z.array(z.string()).min(1).max(100).describe('Array of document texts to rerank'),
30
+ model: z.enum(['rerank-2.5', 'rerank-2.5-lite']).default('rerank-2.5')
31
+ .describe('Reranking model: rerank-2.5 (accurate) or rerank-2.5-lite (fast)'),
32
+ };
33
+
34
+ /** vai_embed input schema */
35
+ const embedSchema = {
36
+ text: z.string().min(1).max(32000).describe('Text to embed'),
37
+ model: z.string().default('voyage-4-large').describe('Voyage AI embedding model'),
38
+ inputType: z.enum(['document', 'query']).default('query')
39
+ .describe('Whether this text is a document or a query (affects embedding)'),
40
+ dimensions: z.number().int().optional().describe('Output dimensions (512 or 1024 for Matryoshka models)'),
41
+ };
42
+
43
+ /** vai_similarity input schema */
44
+ const similaritySchema = {
45
+ text1: z.string().min(1).max(32000).describe('First text'),
46
+ text2: z.string().min(1).max(32000).describe('Second text'),
47
+ model: z.string().default('voyage-4-large').describe('Voyage AI embedding model'),
48
+ };
49
+
50
+ /** vai_collections input schema */
51
+ const collectionsSchema = {
52
+ db: z.string().optional().describe('Database to list collections from. Uses vai config default if omitted.'),
53
+ };
54
+
55
+ /** vai_models input schema */
56
+ const modelsSchema = {
57
+ category: z.enum(['embedding', 'rerank', 'all']).default('all').describe('Filter by model category'),
58
+ };
59
+
60
+ /** vai_topics input schema */
61
+ const topicsSchema = {
62
+ search: z.string().optional().describe('Optional search term to filter topics. Omit to list all topics.'),
63
+ };
64
+
65
+ /** vai_explain input schema */
66
+ const explainSchema = {
67
+ topic: z.string().describe('Topic to explain — supports fuzzy matching. Use vai_topics to discover all available topics.'),
68
+ };
69
+
70
+ /** vai_estimate input schema */
71
+ const estimateSchema = {
72
+ docs: z.number().int().min(1).describe('Number of documents to embed'),
73
+ queries: z.number().int().min(0).default(0).describe('Number of queries per month'),
74
+ months: z.number().int().min(1).max(60).default(12).describe('Time horizon in months'),
75
+ };
76
+
77
+ /** vai_ingest input schema */
78
+ const ingestSchema = {
79
+ text: z.string().min(1).describe('Document text to ingest'),
80
+ db: z.string().optional().describe('MongoDB database name'),
81
+ collection: z.string().optional().describe('Collection to store documents in'),
82
+ source: z.string().optional().describe('Source identifier (e.g., filename, URL) for citation purposes'),
83
+ metadata: z.record(z.string(), z.unknown()).optional().describe('Additional metadata to store with the document'),
84
+ chunkStrategy: z.enum(['fixed', 'sentence', 'paragraph', 'recursive', 'markdown']).default('recursive')
85
+ .describe('Text chunking strategy'),
86
+ chunkSize: z.number().int().min(100).max(8000).default(512).describe('Target chunk size in characters'),
87
+ model: z.string().default('voyage-4-large').describe('Voyage AI embedding model'),
88
+ };
89
+
90
+ module.exports = {
91
+ querySchema,
92
+ searchSchema,
93
+ rerankSchema,
94
+ embedSchema,
95
+ similaritySchema,
96
+ collectionsSchema,
97
+ modelsSchema,
98
+ topicsSchema,
99
+ explainSchema,
100
+ estimateSchema,
101
+ ingestSchema,
102
+ };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+
3
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
4
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
+ const schemas = require('./schemas');
6
+ const { registerRetrievalTools } = require('./tools/retrieval');
7
+ const { registerEmbeddingTools } = require('./tools/embedding');
8
+ const { registerManagementTools } = require('./tools/management');
9
+ const { registerUtilityTools } = require('./tools/utility');
10
+ const { registerIngestTool } = require('./tools/ingest');
11
+
12
+ const VERSION = require('../../package.json').version;
13
+
14
+ /**
15
+ * Create and configure the MCP server with all tools registered.
16
+ * @returns {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer}
17
+ */
18
+ function createServer() {
19
+ const server = new McpServer({
20
+ name: 'vai-mcp-server',
21
+ version: VERSION,
22
+ });
23
+
24
+ // Register all tool domains
25
+ registerRetrievalTools(server, schemas);
26
+ registerEmbeddingTools(server, schemas);
27
+ registerManagementTools(server, schemas);
28
+ registerUtilityTools(server, schemas);
29
+ registerIngestTool(server, schemas);
30
+
31
+ return server;
32
+ }
33
+
34
+ /**
35
+ * Run the MCP server with stdio transport.
36
+ * The server reads JSON-RPC from stdin and writes to stdout.
37
+ */
38
+ async function runStdioServer() {
39
+ const server = createServer();
40
+ const transport = new StdioServerTransport();
41
+ await server.connect(transport);
42
+
43
+ if (process.env.VAI_MCP_VERBOSE) {
44
+ process.stderr.write(`vai MCP server v${VERSION} running on stdio\n`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Run the MCP server with HTTP transport (Streamable HTTP).
50
+ * @param {object} options
51
+ * @param {number} options.port
52
+ * @param {string} options.host
53
+ */
54
+ async function runHttpServer({ port = 3100, host = '127.0.0.1' } = {}) {
55
+ const express = require('express');
56
+ const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
57
+ const { getConfigValue } = require('../lib/config');
58
+ const crypto = require('crypto');
59
+
60
+ const app = express();
61
+ app.use(express.json({ limit: '5mb' }));
62
+
63
+ // Load server API keys
64
+ const serverKeys = getConfigValue('mcp-server-keys') || [];
65
+ const envKey = process.env.VAI_MCP_SERVER_KEY;
66
+ const allKeys = envKey ? [...serverKeys, envKey] : serverKeys;
67
+ const requireAuth = allKeys.length > 0;
68
+
69
+ /** Bearer token authentication middleware */
70
+ function authenticateRequest(req, res, next) {
71
+ if (!requireAuth) return next();
72
+ const authHeader = req.headers.authorization;
73
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
74
+ return res.status(401).json({ error: 'Missing or invalid Authorization header' });
75
+ }
76
+ const token = authHeader.slice(7);
77
+ if (!allKeys.includes(token)) {
78
+ return res.status(401).json({ error: 'Invalid API key' });
79
+ }
80
+ next();
81
+ }
82
+
83
+ // Health endpoint (unauthenticated)
84
+ const startTime = Date.now();
85
+ app.get('/health', async (_req, res) => {
86
+ const health = {
87
+ status: 'ok',
88
+ version: VERSION,
89
+ uptime: Math.floor((Date.now() - startTime) / 1000),
90
+ voyageAi: 'unknown',
91
+ mongodb: 'unknown',
92
+ };
93
+
94
+ // Check Voyage AI connectivity
95
+ try {
96
+ const { getConfigValue } = require('../lib/config');
97
+ const hasKey = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));
98
+ health.voyageAi = hasKey ? 'configured' : 'not configured';
99
+ } catch {
100
+ health.voyageAi = 'not configured';
101
+ }
102
+
103
+ // Check MongoDB connectivity
104
+ try {
105
+ const { getConfigValue } = require('../lib/config');
106
+ const hasUri = !!(process.env.MONGODB_URI || getConfigValue('mongodbUri'));
107
+ health.mongodb = hasUri ? 'configured' : 'not configured';
108
+ } catch {
109
+ health.mongodb = 'not configured';
110
+ }
111
+
112
+ res.json(health);
113
+ });
114
+
115
+ // MCP endpoint — stateless per-request transport
116
+ app.post('/mcp', authenticateRequest, async (req, res) => {
117
+ const server = createServer();
118
+ const transport = new StreamableHTTPServerTransport({
119
+ sessionIdGenerator: undefined, // stateless
120
+ });
121
+ res.on('close', () => transport.close());
122
+ await server.connect(transport);
123
+ await transport.handleRequest(req, res, req.body);
124
+ });
125
+
126
+ // Handle GET/DELETE for SSE (required by MCP spec for session management)
127
+ app.get('/mcp', (_req, res) => {
128
+ res.status(405).json({ error: 'Method not allowed. Use POST for MCP requests.' });
129
+ });
130
+ app.delete('/mcp', (_req, res) => {
131
+ res.status(405).json({ error: 'Method not allowed. Stateless server — no sessions to delete.' });
132
+ });
133
+
134
+ app.listen(port, host, () => {
135
+ const msg = `vai MCP server v${VERSION} running on http://${host}:${port}/mcp`;
136
+ if (process.env.VAI_MCP_VERBOSE) {
137
+ process.stderr.write(msg + '\n');
138
+ process.stderr.write(`Authentication: ${requireAuth ? 'enabled' : 'disabled (no keys configured)'}\n`);
139
+ process.stderr.write(`Health check: http://${host}:${port}/health\n`);
140
+ }
141
+ console.log(msg);
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Generate a new MCP server API key and store it in config.
147
+ */
148
+ function generateKey() {
149
+ const crypto = require('crypto');
150
+ const { getConfigValue, setConfigValue } = require('../lib/config');
151
+
152
+ const key = 'vai-mcp-key-' + crypto.randomBytes(24).toString('hex');
153
+ const keys = getConfigValue('mcp-server-keys') || [];
154
+ keys.push(key);
155
+ setConfigValue('mcp-server-keys', keys);
156
+
157
+ console.log(key);
158
+ console.log(`\nStored in ~/.vai/config.json. Total keys: ${keys.length}`);
159
+ console.log('Set as VAI_MCP_SERVER_KEY env var or use in client Authorization header.');
160
+ }
161
+
162
+ module.exports = { createServer, runStdioServer, runHttpServer, generateKey };