vcluster-yaml-mcp-server 0.1.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/README.md +292 -0
- package/package.json +52 -0
- package/src/cli-handlers.js +290 -0
- package/src/cli.js +128 -0
- package/src/formatters.js +168 -0
- package/src/github.js +170 -0
- package/src/http-server.js +67 -0
- package/src/index.js +11 -0
- package/src/middleware/auth.js +25 -0
- package/src/schema-validator.js +351 -0
- package/src/server.js +1006 -0
- package/src/snippet-validator.js +370 -0
- package/src/snippet-validator.test.js +366 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippet Validator - AJV-based validation for partial YAML configs
|
|
3
|
+
* Enables deterministic validation of vCluster config snippets without requiring full documents
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import Ajv from 'ajv';
|
|
7
|
+
import addFormats from 'ajv-formats';
|
|
8
|
+
import yaml from 'js-yaml';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validator cache to avoid recompiling schemas
|
|
12
|
+
* Maps schema section paths to compiled AJV validators
|
|
13
|
+
*/
|
|
14
|
+
class ValidatorCache {
|
|
15
|
+
constructor(maxSize = 20) {
|
|
16
|
+
this.cache = new Map();
|
|
17
|
+
this.maxSize = maxSize;
|
|
18
|
+
this.currentVersion = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get cached validator or return null
|
|
23
|
+
*/
|
|
24
|
+
get(sectionPath, version) {
|
|
25
|
+
if (this.currentVersion !== version) {
|
|
26
|
+
this.clear();
|
|
27
|
+
this.currentVersion = version;
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const key = `${version}:${sectionPath}`;
|
|
32
|
+
return this.cache.get(key) || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set validator in cache
|
|
37
|
+
*/
|
|
38
|
+
set(sectionPath, version, validator) {
|
|
39
|
+
const key = `${version}:${sectionPath}`;
|
|
40
|
+
|
|
41
|
+
// Evict oldest entry if cache is full
|
|
42
|
+
if (this.cache.size >= this.maxSize) {
|
|
43
|
+
const firstKey = this.cache.keys().next().value;
|
|
44
|
+
this.cache.delete(firstKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.cache.set(key, validator);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Clear cache (e.g., on version change)
|
|
52
|
+
*/
|
|
53
|
+
clear() {
|
|
54
|
+
this.cache.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get cache stats
|
|
59
|
+
*/
|
|
60
|
+
getStats() {
|
|
61
|
+
return {
|
|
62
|
+
size: this.cache.size,
|
|
63
|
+
maxSize: this.maxSize,
|
|
64
|
+
version: this.currentVersion
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Global validator cache instance
|
|
70
|
+
const validatorCache = new ValidatorCache(20);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect which schema section a snippet belongs to
|
|
74
|
+
* @param {Object} parsedSnippet - Parsed YAML snippet
|
|
75
|
+
* @param {Object} fullSchema - Full vCluster JSON schema
|
|
76
|
+
* @returns {string|null} Schema section path (e.g., "controlPlane", "sync.toHost")
|
|
77
|
+
*/
|
|
78
|
+
export function detectSchemaSection(parsedSnippet, fullSchema) {
|
|
79
|
+
if (!parsedSnippet || typeof parsedSnippet !== 'object') {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const topLevelKeys = Object.keys(parsedSnippet);
|
|
84
|
+
if (topLevelKeys.length === 0) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// First, check if top-level keys are valid schema sections
|
|
89
|
+
const schemaProps = fullSchema.properties || {};
|
|
90
|
+
const validTopLevelSections = Object.keys(schemaProps);
|
|
91
|
+
|
|
92
|
+
// Find matching top-level sections
|
|
93
|
+
const matchingSections = topLevelKeys.filter(key =>
|
|
94
|
+
validTopLevelSections.includes(key)
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (matchingSections.length > 0) {
|
|
98
|
+
// If multiple sections found, return the first one (user can provide hint)
|
|
99
|
+
return matchingSections[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If no top-level match, check if keys might be nested properties
|
|
103
|
+
// Look for potential parent sections by checking if keys exist in sub-properties
|
|
104
|
+
for (const section of validTopLevelSections) {
|
|
105
|
+
const sectionSchema = schemaProps[section];
|
|
106
|
+
if (sectionSchema && sectionSchema.properties) {
|
|
107
|
+
const hasMatch = topLevelKeys.some(key =>
|
|
108
|
+
key in sectionSchema.properties
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (hasMatch) {
|
|
112
|
+
// Check if ALL keys are in this section
|
|
113
|
+
const allMatch = topLevelKeys.every(key =>
|
|
114
|
+
key in sectionSchema.properties
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (allMatch) {
|
|
118
|
+
return section;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Couldn't detect section automatically
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract sub-schema for a specific section
|
|
130
|
+
* @param {Object} fullSchema - Full vCluster JSON schema
|
|
131
|
+
* @param {string} sectionPath - Dot-notation path (e.g., "controlPlane", "sync.toHost")
|
|
132
|
+
* @returns {Object|null} Isolated sub-schema for that section
|
|
133
|
+
*/
|
|
134
|
+
export function extractSubSchema(fullSchema, sectionPath) {
|
|
135
|
+
if (!sectionPath) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pathParts = sectionPath.split('.');
|
|
140
|
+
let current = fullSchema.properties || fullSchema;
|
|
141
|
+
|
|
142
|
+
// Navigate to the target schema node
|
|
143
|
+
for (let i = 0; i < pathParts.length; i++) {
|
|
144
|
+
const part = pathParts[i];
|
|
145
|
+
|
|
146
|
+
if (!current || !current[part]) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
current = current[part];
|
|
151
|
+
|
|
152
|
+
// If there are more parts to traverse, move into properties
|
|
153
|
+
if (i < pathParts.length - 1) {
|
|
154
|
+
if (current.properties) {
|
|
155
|
+
current = current.properties;
|
|
156
|
+
} else {
|
|
157
|
+
// Can't go deeper
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Return the schema node we found
|
|
164
|
+
return current;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create AJV instance configured for vCluster validation
|
|
169
|
+
* @returns {Ajv} Configured AJV instance
|
|
170
|
+
*/
|
|
171
|
+
function createAjvInstance() {
|
|
172
|
+
const ajv = new Ajv({
|
|
173
|
+
allErrors: true, // Return all errors, not just first
|
|
174
|
+
strict: false, // Allow unknown keywords
|
|
175
|
+
validateFormats: true // Validate format constraints
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
addFormats(ajv); // Add format validators (email, uri, etc.)
|
|
179
|
+
|
|
180
|
+
return ajv;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate YAML snippet against vCluster schema
|
|
185
|
+
* @param {string} snippetYaml - YAML snippet string
|
|
186
|
+
* @param {Object} fullSchema - Full vCluster JSON schema
|
|
187
|
+
* @param {string} version - vCluster version
|
|
188
|
+
* @param {string|null} sectionHint - Optional hint for schema section
|
|
189
|
+
* @returns {Object} Validation result with errors
|
|
190
|
+
*/
|
|
191
|
+
export function validateSnippet(snippetYaml, fullSchema, version, sectionHint = null) {
|
|
192
|
+
const startTime = Date.now();
|
|
193
|
+
|
|
194
|
+
// Parse YAML
|
|
195
|
+
let parsedSnippet;
|
|
196
|
+
try {
|
|
197
|
+
parsedSnippet = yaml.load(snippetYaml);
|
|
198
|
+
} catch (yamlError) {
|
|
199
|
+
return {
|
|
200
|
+
valid: false,
|
|
201
|
+
syntax_valid: false,
|
|
202
|
+
syntax_error: yamlError.message,
|
|
203
|
+
elapsed_ms: Date.now() - startTime
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!parsedSnippet || typeof parsedSnippet !== 'object') {
|
|
208
|
+
return {
|
|
209
|
+
valid: false,
|
|
210
|
+
error: 'Snippet must be a valid YAML object',
|
|
211
|
+
elapsed_ms: Date.now() - startTime
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Detect if this is a full document or a snippet
|
|
216
|
+
const topLevelKeys = Object.keys(parsedSnippet);
|
|
217
|
+
const schemaProps = fullSchema.properties || {};
|
|
218
|
+
const validTopLevelSections = Object.keys(schemaProps);
|
|
219
|
+
|
|
220
|
+
// Find all matching top-level sections
|
|
221
|
+
const matchingSections = topLevelKeys.filter(key =>
|
|
222
|
+
validTopLevelSections.includes(key)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Determine if this is a full document or single section snippet
|
|
226
|
+
const isFullDocument = matchingSections.length > 1;
|
|
227
|
+
const isSingleSectionAtRoot = matchingSections.length === 1;
|
|
228
|
+
|
|
229
|
+
let section = sectionHint;
|
|
230
|
+
let validationSchema;
|
|
231
|
+
let cacheKey;
|
|
232
|
+
|
|
233
|
+
if (isFullDocument) {
|
|
234
|
+
// Multiple top-level sections = validate as full document
|
|
235
|
+
section = '__full_document__';
|
|
236
|
+
cacheKey = `__full__:${version}`;
|
|
237
|
+
|
|
238
|
+
// Build schema with only the sections present in the snippet
|
|
239
|
+
const snippetSchema = {
|
|
240
|
+
type: 'object',
|
|
241
|
+
properties: {},
|
|
242
|
+
additionalProperties: false,
|
|
243
|
+
$defs: fullSchema.$defs || fullSchema.definitions
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
for (const key of matchingSections) {
|
|
247
|
+
snippetSchema.properties[key] = schemaProps[key];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
validationSchema = snippetSchema;
|
|
251
|
+
} else {
|
|
252
|
+
// Single section or nested snippet
|
|
253
|
+
if (!section) {
|
|
254
|
+
section = detectSchemaSection(parsedSnippet, fullSchema);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!section) {
|
|
258
|
+
return {
|
|
259
|
+
valid: false,
|
|
260
|
+
error: 'Could not detect schema section. Please provide a "section" hint.',
|
|
261
|
+
hint: 'Available sections: ' + validTopLevelSections.join(', '),
|
|
262
|
+
snippet_keys: topLevelKeys,
|
|
263
|
+
elapsed_ms: Date.now() - startTime
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
cacheKey = `${section}:${version}`;
|
|
268
|
+
|
|
269
|
+
// Extract sub-schema
|
|
270
|
+
const subSchema = extractSubSchema(fullSchema, section);
|
|
271
|
+
|
|
272
|
+
if (!subSchema) {
|
|
273
|
+
return {
|
|
274
|
+
valid: false,
|
|
275
|
+
error: `Section "${section}" not found in schema`,
|
|
276
|
+
available_sections: validTopLevelSections,
|
|
277
|
+
elapsed_ms: Date.now() - startTime
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Create schema wrapper for validation
|
|
282
|
+
const hasSectionKey = topLevelKeys.includes(section);
|
|
283
|
+
|
|
284
|
+
if (hasSectionKey) {
|
|
285
|
+
// Snippet includes the section key (e.g., "controlPlane: {...}")
|
|
286
|
+
validationSchema = {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
[section]: subSchema
|
|
290
|
+
},
|
|
291
|
+
additionalProperties: false,
|
|
292
|
+
$defs: fullSchema.$defs || fullSchema.definitions
|
|
293
|
+
};
|
|
294
|
+
} else {
|
|
295
|
+
// Snippet is the content of the section
|
|
296
|
+
if (subSchema.$ref) {
|
|
297
|
+
validationSchema = {
|
|
298
|
+
...subSchema,
|
|
299
|
+
$defs: fullSchema.$defs || fullSchema.definitions
|
|
300
|
+
};
|
|
301
|
+
} else {
|
|
302
|
+
validationSchema = subSchema;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check cache
|
|
308
|
+
let validate = validatorCache.get(cacheKey, version);
|
|
309
|
+
|
|
310
|
+
if (!validate) {
|
|
311
|
+
// Compile schema
|
|
312
|
+
const ajv = createAjvInstance();
|
|
313
|
+
try {
|
|
314
|
+
validate = ajv.compile(validationSchema);
|
|
315
|
+
validatorCache.set(cacheKey, version, validate);
|
|
316
|
+
} catch (compileError) {
|
|
317
|
+
return {
|
|
318
|
+
valid: false,
|
|
319
|
+
error: 'Schema compilation error',
|
|
320
|
+
details: compileError.message,
|
|
321
|
+
elapsed_ms: Date.now() - startTime
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Validate snippet
|
|
327
|
+
const valid = validate(parsedSnippet);
|
|
328
|
+
|
|
329
|
+
const result = {
|
|
330
|
+
valid,
|
|
331
|
+
syntax_valid: true,
|
|
332
|
+
section,
|
|
333
|
+
version,
|
|
334
|
+
elapsed_ms: Date.now() - startTime
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (!valid && validate.errors) {
|
|
338
|
+
// Format errors with snippet context
|
|
339
|
+
result.errors = validate.errors.map(err => {
|
|
340
|
+
const errorPath = err.instancePath ? `${section}${err.instancePath}` : section;
|
|
341
|
+
return {
|
|
342
|
+
path: errorPath,
|
|
343
|
+
message: err.message,
|
|
344
|
+
keyword: err.keyword,
|
|
345
|
+
params: err.params,
|
|
346
|
+
context: `in ${errorPath}`
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Add summary
|
|
351
|
+
result.summary = `Found ${result.errors.length} validation error(s) in section "${section}"`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get validator cache stats (for monitoring/debugging)
|
|
359
|
+
* @returns {Object} Cache statistics
|
|
360
|
+
*/
|
|
361
|
+
export function getCacheStats() {
|
|
362
|
+
return validatorCache.getStats();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Clear validator cache (useful for testing or version switches)
|
|
367
|
+
*/
|
|
368
|
+
export function clearCache() {
|
|
369
|
+
validatorCache.clear();
|
|
370
|
+
}
|