vcluster-yaml-mcp-server 1.0.0 → 1.0.2
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 +6 -1
- package/package.json +3 -3
- package/src/cli-utils.js +44 -0
- package/src/cli.js +46 -6
- package/src/github.js +10 -0
- package/src/http-server.js +36 -4
- package/src/middleware/auth.js +20 -3
- package/src/server.js +0 -133
- package/src/snippet-validator.js +12 -0
package/README.md
CHANGED
|
@@ -71,11 +71,16 @@ The package also provides a standalone CLI for quick queries and validation with
|
|
|
71
71
|
|
|
72
72
|
```bash
|
|
73
73
|
# Quick start with npx (no installation)
|
|
74
|
-
npx -
|
|
74
|
+
npx -p vcluster-yaml-mcp-server vcluster-yaml query sync --format table
|
|
75
75
|
|
|
76
76
|
# Or install globally
|
|
77
77
|
npm install -g vcluster-yaml-mcp-server
|
|
78
78
|
vcluster-yaml query sync --format table
|
|
79
|
+
|
|
80
|
+
# Validate configurations with ease
|
|
81
|
+
vcluster-yaml validate my-config.yaml
|
|
82
|
+
cat my-config.yaml | vcluster-yaml validate -
|
|
83
|
+
vcluster-yaml validate my-config.yaml --schema-version v0.24.0
|
|
79
84
|
```
|
|
80
85
|
|
|
81
86
|
📖 **[Full CLI Documentation →](docs/CLI.md)**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vcluster-yaml-mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "MCP server for querying vcluster YAML configurations using jq",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"cli-table3": "^0.6.5",
|
|
33
33
|
"commander": "^13.0.0",
|
|
34
34
|
"express": "^4.18.2",
|
|
35
|
+
"express-rate-limit": "^8.1.0",
|
|
35
36
|
"js-yaml": "^4.1.0",
|
|
36
37
|
"node-fetch": "^3.3.2",
|
|
37
38
|
"node-jq": "^6.0.1"
|
|
@@ -46,7 +47,6 @@
|
|
|
46
47
|
"jsonschema": "^1.5.0",
|
|
47
48
|
"strip-ansi": "^7.1.0",
|
|
48
49
|
"supertest": "^7.1.4",
|
|
49
|
-
"vitest": "^3.2.4"
|
|
50
|
-
"z-schema": "^6.0.2"
|
|
50
|
+
"vitest": "^3.2.4"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/cli-utils.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI utility functions
|
|
3
|
+
* Provides helpers for stdin reading and file operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile } from 'fs/promises';
|
|
7
|
+
import { stdin } from 'process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read content from stdin
|
|
11
|
+
* Used for piping content to CLI commands
|
|
12
|
+
* @returns {Promise<string>} The content read from stdin
|
|
13
|
+
*/
|
|
14
|
+
export async function readStdin() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let data = '';
|
|
17
|
+
|
|
18
|
+
stdin.setEncoding('utf8');
|
|
19
|
+
stdin.on('data', chunk => data += chunk);
|
|
20
|
+
stdin.on('end', () => resolve(data));
|
|
21
|
+
stdin.on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read content from a file or stdin based on the file argument
|
|
27
|
+
* @param {string|undefined} file - File path, '-' for stdin, or undefined for stdin
|
|
28
|
+
* @returns {Promise<{content: string, source: string}>} The content and its source
|
|
29
|
+
*/
|
|
30
|
+
export async function readContentSource(file) {
|
|
31
|
+
// Read from stdin if no file provided or file is '-'
|
|
32
|
+
if (!file || file === '-') {
|
|
33
|
+
const content = await readStdin();
|
|
34
|
+
return { content, source: 'stdin' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read from file
|
|
38
|
+
try {
|
|
39
|
+
const content = await readFile(file, 'utf-8');
|
|
40
|
+
return { content, source: file };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(`Cannot read file '${file}': ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Command } from 'commander';
|
|
10
10
|
import { handleQuery, handleListVersions, handleValidate } from './cli-handlers.js';
|
|
11
11
|
import { formatOutput } from './formatters.js';
|
|
12
|
+
import { readContentSource } from './cli-utils.js';
|
|
12
13
|
|
|
13
14
|
const program = new Command();
|
|
14
15
|
|
|
@@ -22,8 +23,14 @@ program
|
|
|
22
23
|
.command('query <query>')
|
|
23
24
|
.description('Search for vCluster configuration fields')
|
|
24
25
|
.option('--file <file>', 'Configuration file to search', 'chart/values.yaml')
|
|
25
|
-
.option('--version <version>', 'vCluster version or branch', 'main')
|
|
26
|
+
.option('-s, --schema-version <version>', 'vCluster version or branch', 'main')
|
|
26
27
|
.option('-f, --format <format>', 'Output format (json, yaml, table)', 'json')
|
|
28
|
+
.addHelpText('after', `
|
|
29
|
+
Examples:
|
|
30
|
+
$ vcluster-yaml query sync
|
|
31
|
+
$ vcluster-yaml query sync --schema-version v0.24.0
|
|
32
|
+
$ vcluster-yaml query "controlPlane.replicas" --format table
|
|
33
|
+
`)
|
|
27
34
|
.action(async (query, options) => {
|
|
28
35
|
try {
|
|
29
36
|
// Validate format option
|
|
@@ -32,9 +39,15 @@ program
|
|
|
32
39
|
process.exit(1);
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
// Add validation for empty query
|
|
43
|
+
if (!query || query.trim() === '') {
|
|
44
|
+
console.error(`Error: Query cannot be empty. Try 'vcluster-yaml query sync' or see examples with --help`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
const result = await handleQuery(query, {
|
|
36
49
|
file: options.file,
|
|
37
|
-
version: options.
|
|
50
|
+
version: options.schemaVersion
|
|
38
51
|
});
|
|
39
52
|
|
|
40
53
|
if (!result.success) {
|
|
@@ -91,11 +104,22 @@ program
|
|
|
91
104
|
|
|
92
105
|
// Validate command
|
|
93
106
|
program
|
|
94
|
-
.command('validate
|
|
107
|
+
.command('validate [file]')
|
|
95
108
|
.description('Validate vCluster configuration')
|
|
96
|
-
.option('--version <version>', 'vCluster version for schema', 'main')
|
|
109
|
+
.option('-s, --schema-version <version>', 'vCluster version for schema', 'main')
|
|
97
110
|
.option('-f, --format <format>', 'Output format (json, yaml, table)', 'json')
|
|
98
|
-
.
|
|
111
|
+
.addHelpText('after', `
|
|
112
|
+
Arguments:
|
|
113
|
+
file YAML file to validate (use '-' for stdin, omit to read from stdin)
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
$ vcluster-yaml validate vcluster.yaml
|
|
117
|
+
$ vcluster-yaml validate vcluster.yaml --schema-version v0.24.0
|
|
118
|
+
$ cat vcluster.yaml | vcluster-yaml validate -
|
|
119
|
+
$ vcluster-yaml validate - < vcluster.yaml
|
|
120
|
+
$ vcluster-yaml validate vcluster.yaml --format table
|
|
121
|
+
`)
|
|
122
|
+
.action(async (file, options) => {
|
|
99
123
|
try {
|
|
100
124
|
// Validate format option
|
|
101
125
|
if (!['json', 'yaml', 'table'].includes(options.format)) {
|
|
@@ -103,8 +127,24 @@ program
|
|
|
103
127
|
process.exit(1);
|
|
104
128
|
}
|
|
105
129
|
|
|
130
|
+
// Read content from file or stdin
|
|
131
|
+
let content;
|
|
132
|
+
try {
|
|
133
|
+
const result = await readContentSource(file);
|
|
134
|
+
content = result.content;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error(`Error: ${error.message}`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for empty content
|
|
141
|
+
if (!content || content.trim() === '') {
|
|
142
|
+
console.error(`Error: No content to validate. Please provide a file path or pipe content via stdin.`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
106
146
|
const result = await handleValidate(content, {
|
|
107
|
-
version: options.
|
|
147
|
+
version: options.schemaVersion
|
|
108
148
|
});
|
|
109
149
|
|
|
110
150
|
// For validation, always output the result (even if not successful)
|
package/src/github.js
CHANGED
|
@@ -75,6 +75,16 @@ class GitHubClient {
|
|
|
75
75
|
|
|
76
76
|
// Get file content from GitHub
|
|
77
77
|
async getFileContent(path, ref = 'main') {
|
|
78
|
+
// Validate path - prevent path traversal
|
|
79
|
+
if (path.includes('..') || path.startsWith('/')) {
|
|
80
|
+
throw new Error('Invalid path: path traversal not allowed');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Validate ref format (branch, tag, or commit SHA)
|
|
84
|
+
if (!/^[\w.\/-]+$/.test(ref)) {
|
|
85
|
+
throw new Error('Invalid ref format');
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
const actualRef = ref;
|
|
79
89
|
const cacheKey = `file:${actualRef}:${path}`;
|
|
80
90
|
const cached = this.getFromCache(cacheKey);
|
package/src/http-server.js
CHANGED
|
@@ -3,16 +3,48 @@
|
|
|
3
3
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
4
|
import { createServer } from './server.js';
|
|
5
5
|
import express from 'express';
|
|
6
|
+
import rateLimit from 'express-rate-limit';
|
|
6
7
|
import { requireApiKey } from './middleware/auth.js';
|
|
7
8
|
|
|
8
9
|
const PORT = process.env.PORT || 3000;
|
|
9
10
|
const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true';
|
|
10
11
|
|
|
11
12
|
const app = express();
|
|
12
|
-
|
|
13
|
+
|
|
14
|
+
// Security headers
|
|
15
|
+
app.use((req, res, next) => {
|
|
16
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
17
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
18
|
+
res.removeHeader('X-Powered-By');
|
|
19
|
+
next();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Request size limiting
|
|
23
|
+
app.use(express.json({
|
|
24
|
+
limit: '1mb',
|
|
25
|
+
strict: true
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Rate limiting for general endpoints
|
|
29
|
+
const apiLimiter = rateLimit({
|
|
30
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
31
|
+
max: 500,
|
|
32
|
+
message: { error: 'Too many requests, please try again later' },
|
|
33
|
+
standardHeaders: true,
|
|
34
|
+
legacyHeaders: false
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Rate limiting for MCP endpoint (allows for interactive sessions)
|
|
38
|
+
const mcpLimiter = rateLimit({
|
|
39
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
40
|
+
max: 1000,
|
|
41
|
+
message: { error: 'Too many requests to MCP endpoint, please try again later' },
|
|
42
|
+
standardHeaders: true,
|
|
43
|
+
legacyHeaders: false
|
|
44
|
+
});
|
|
13
45
|
|
|
14
46
|
// Health check endpoint
|
|
15
|
-
app.get('/health', (_req, res) => {
|
|
47
|
+
app.get('/health', apiLimiter, (_req, res) => {
|
|
16
48
|
res.json({
|
|
17
49
|
status: 'ok',
|
|
18
50
|
name: 'vcluster-yaml-mcp-server',
|
|
@@ -56,8 +88,8 @@ const mcpHandler = async (req, res) => {
|
|
|
56
88
|
};
|
|
57
89
|
|
|
58
90
|
// Support both GET and POST for MCP endpoint
|
|
59
|
-
app.get('/mcp', REQUIRE_AUTH ? requireApiKey : (req, res, next) => next(), mcpHandler);
|
|
60
|
-
app.post('/mcp', REQUIRE_AUTH ? requireApiKey : (req, res, next) => next(), mcpHandler);
|
|
91
|
+
app.get('/mcp', mcpLimiter, REQUIRE_AUTH ? requireApiKey : (req, res, next) => next(), mcpHandler);
|
|
92
|
+
app.post('/mcp', mcpLimiter, REQUIRE_AUTH ? requireApiKey : (req, res, next) => next(), mcpHandler);
|
|
61
93
|
|
|
62
94
|
app.listen(PORT, () => {
|
|
63
95
|
console.log(`vcluster-yaml-mcp-server HTTP running on port ${PORT}`);
|
package/src/middleware/auth.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// Timing-safe string comparison
|
|
4
|
+
function secureCompare(a, b) {
|
|
5
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
6
|
+
try {
|
|
7
|
+
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// API key authentication middleware with timing-safe comparison
|
|
2
14
|
export function requireApiKey(req, res, next) {
|
|
3
15
|
const authHeader = req.headers['authorization'];
|
|
4
16
|
|
|
@@ -10,9 +22,14 @@ export function requireApiKey(req, res, next) {
|
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
const token = authHeader.substring(7);
|
|
13
|
-
const validTokens = (process.env.VALID_API_KEYS || '').split(',');
|
|
25
|
+
const validTokens = (process.env.VALID_API_KEYS || '').split(',').filter(t => t.length > 0);
|
|
26
|
+
|
|
27
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
28
|
+
const isValid = validTokens.some(validToken =>
|
|
29
|
+
validToken.length === token.length && secureCompare(validToken, token)
|
|
30
|
+
);
|
|
14
31
|
|
|
15
|
-
if (!
|
|
32
|
+
if (!isValid) {
|
|
16
33
|
return res.status(403).json({
|
|
17
34
|
error: 'Forbidden',
|
|
18
35
|
message: 'Invalid API key'
|
package/src/server.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
2
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
|
-
import jq from 'node-jq';
|
|
5
4
|
import { githubClient } from './github.js';
|
|
6
5
|
import { validateSnippet } from './snippet-validator.js';
|
|
7
6
|
|
|
@@ -591,77 +590,6 @@ export function createServer() {
|
|
|
591
590
|
};
|
|
592
591
|
}
|
|
593
592
|
|
|
594
|
-
case 'query-config': {
|
|
595
|
-
const version = args.version || 'main';
|
|
596
|
-
let yamlData;
|
|
597
|
-
|
|
598
|
-
// Load YAML data from GitHub or content
|
|
599
|
-
if (args.content) {
|
|
600
|
-
yamlData = yaml.load(args.content);
|
|
601
|
-
} else if (args.file) {
|
|
602
|
-
yamlData = await githubClient.getYamlContent(args.file, version);
|
|
603
|
-
} else {
|
|
604
|
-
// Default to chart/values.yaml
|
|
605
|
-
yamlData = await githubClient.getYamlContent('chart/values.yaml', version);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Convert YAML to JSON for jq processing
|
|
609
|
-
const jsonData = JSON.stringify(yamlData);
|
|
610
|
-
|
|
611
|
-
// Run jq query
|
|
612
|
-
const options = {
|
|
613
|
-
input: 'string',
|
|
614
|
-
output: args.raw ? 'string' : 'json'
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
const result = await jq.run(args.query, jsonData, options);
|
|
618
|
-
|
|
619
|
-
return {
|
|
620
|
-
content: [
|
|
621
|
-
{
|
|
622
|
-
type: 'text',
|
|
623
|
-
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
624
|
-
}
|
|
625
|
-
]
|
|
626
|
-
};
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
case 'get-config-value': {
|
|
630
|
-
const version = args.version || 'main';
|
|
631
|
-
let yamlData;
|
|
632
|
-
|
|
633
|
-
// Load YAML data from GitHub or content
|
|
634
|
-
if (args.content) {
|
|
635
|
-
yamlData = yaml.load(args.content);
|
|
636
|
-
} else if (args.file) {
|
|
637
|
-
yamlData = await githubClient.getYamlContent(args.file, version);
|
|
638
|
-
} else {
|
|
639
|
-
yamlData = await githubClient.getYamlContent('config/values.yaml', version);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Convert dot notation to jq query
|
|
643
|
-
const jqQuery = '.' + args.path.split('.').map(part => {
|
|
644
|
-
// Handle array indices
|
|
645
|
-
if (/^\d+$/.test(part)) {
|
|
646
|
-
return `[${part}]`;
|
|
647
|
-
}
|
|
648
|
-
// Handle special characters in keys
|
|
649
|
-
return `["${part}"]`;
|
|
650
|
-
}).join('');
|
|
651
|
-
|
|
652
|
-
const jsonData = JSON.stringify(yamlData);
|
|
653
|
-
const result = await jq.run(jqQuery, jsonData, { input: 'string', output: 'json' });
|
|
654
|
-
|
|
655
|
-
return {
|
|
656
|
-
content: [
|
|
657
|
-
{
|
|
658
|
-
type: 'text',
|
|
659
|
-
text: `Value at ${args.path}: ${JSON.stringify(result, null, 2)}`
|
|
660
|
-
}
|
|
661
|
-
]
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
593
|
case 'extract-validation-rules': {
|
|
666
594
|
const version = args.version || 'main';
|
|
667
595
|
const fileName = args.file || 'chart/values.yaml';
|
|
@@ -756,67 +684,6 @@ export function createServer() {
|
|
|
756
684
|
}
|
|
757
685
|
}
|
|
758
686
|
|
|
759
|
-
case 'search-config': {
|
|
760
|
-
const version = args.version || 'main';
|
|
761
|
-
let yamlData;
|
|
762
|
-
|
|
763
|
-
// Load YAML data from GitHub or content
|
|
764
|
-
if (args.content) {
|
|
765
|
-
yamlData = yaml.load(args.content);
|
|
766
|
-
} else if (args.file) {
|
|
767
|
-
yamlData = await githubClient.getYamlContent(args.file, version);
|
|
768
|
-
} else {
|
|
769
|
-
yamlData = await githubClient.getYamlContent('config/values.yaml', version);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
const searchTerm = args.search.toLowerCase();
|
|
773
|
-
const matches = [];
|
|
774
|
-
|
|
775
|
-
// Recursive search function
|
|
776
|
-
function searchObject(obj, path = '') {
|
|
777
|
-
if (obj && typeof obj === 'object') {
|
|
778
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
779
|
-
const currentPath = path ? `${path}.${key}` : key;
|
|
780
|
-
|
|
781
|
-
// Check if key matches
|
|
782
|
-
if (key.toLowerCase().includes(searchTerm)) {
|
|
783
|
-
if (args.keysOnly) {
|
|
784
|
-
matches.push(`Key: ${currentPath}`);
|
|
785
|
-
} else {
|
|
786
|
-
matches.push(`Key: ${currentPath} = ${JSON.stringify(value)}`);
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// Check if value matches (if not keysOnly)
|
|
791
|
-
if (!args.keysOnly && value !== null && value !== undefined) {
|
|
792
|
-
const valueStr = JSON.stringify(value).toLowerCase();
|
|
793
|
-
if (valueStr.includes(searchTerm)) {
|
|
794
|
-
matches.push(`Value at ${currentPath}: ${JSON.stringify(value)}`);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Recurse into objects
|
|
799
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
800
|
-
searchObject(value, currentPath);
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
searchObject(yamlData);
|
|
807
|
-
|
|
808
|
-
return {
|
|
809
|
-
content: [
|
|
810
|
-
{
|
|
811
|
-
type: 'text',
|
|
812
|
-
text: matches.length > 0
|
|
813
|
-
? `Found ${matches.length} match(es):\n${matches.join('\n')}`
|
|
814
|
-
: `No matches found for "${args.search}"`
|
|
815
|
-
}
|
|
816
|
-
]
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
|
-
|
|
820
687
|
default:
|
|
821
688
|
throw new Error(`Unknown tool: ${name}`);
|
|
822
689
|
}
|
package/src/snippet-validator.js
CHANGED
|
@@ -190,6 +190,18 @@ function createAjvInstance() {
|
|
|
190
190
|
*/
|
|
191
191
|
export function validateSnippet(snippetYaml, fullSchema, version, sectionHint = null) {
|
|
192
192
|
const startTime = Date.now();
|
|
193
|
+
const MAX_YAML_SIZE = 1024 * 1024; // 1MB
|
|
194
|
+
|
|
195
|
+
// Check YAML size limit
|
|
196
|
+
if (snippetYaml.length > MAX_YAML_SIZE) {
|
|
197
|
+
return {
|
|
198
|
+
valid: false,
|
|
199
|
+
error: 'YAML exceeds 1MB limit',
|
|
200
|
+
size_bytes: snippetYaml.length,
|
|
201
|
+
max_size_bytes: MAX_YAML_SIZE,
|
|
202
|
+
elapsed_ms: Date.now() - startTime
|
|
203
|
+
};
|
|
204
|
+
}
|
|
193
205
|
|
|
194
206
|
// Parse YAML
|
|
195
207
|
let parsedSnippet;
|