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.
@@ -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
+ }