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.
- package/package.json +4 -2
- package/src/cli.js +4 -0
- package/src/commands/chat.js +503 -0
- package/src/commands/demo.js +75 -0
- package/src/commands/embed.js +10 -0
- package/src/commands/index.js +1 -1
- package/src/commands/init.js +34 -97
- package/src/commands/mcp-server.js +49 -0
- package/src/commands/ping.js +54 -2
- package/src/commands/pipeline.js +17 -3
- package/src/commands/playground.js +186 -0
- package/src/commands/purge.js +3 -1
- package/src/commands/refresh.js +3 -1
- package/src/commands/rerank.js +10 -0
- package/src/commands/scaffold.js +1 -2
- package/src/lib/api.js +6 -8
- package/src/lib/chat.js +252 -0
- package/src/lib/codegen.js +5 -4
- package/src/lib/config.js +5 -1
- package/src/lib/cost.js +352 -0
- package/src/lib/explanations.js +74 -0
- package/src/lib/history.js +260 -0
- package/src/lib/llm.js +485 -0
- package/src/lib/preflight.js +281 -0
- package/src/lib/prompt.js +111 -0
- package/src/lib/wizard-cli.js +135 -0
- package/src/lib/wizard-steps-chat.js +171 -0
- package/src/lib/wizard-steps-init.js +174 -0
- package/src/lib/wizard.js +222 -0
- package/src/mcp/schemas/index.js +102 -0
- package/src/mcp/server.js +162 -0
- package/src/mcp/tools/embedding.js +67 -0
- package/src/mcp/tools/ingest.js +89 -0
- package/src/mcp/tools/management.js +132 -0
- package/src/mcp/tools/retrieval.js +209 -0
- package/src/mcp/tools/utility.js +219 -0
- package/src/playground/index.html +1195 -199
|
@@ -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 };
|