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 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 -y vcluster-yaml-mcp-server vcluster-yaml query sync --format table
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.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
  }
@@ -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.version
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 <content>')
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
- .action(async (content, options) => {
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.version
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);
@@ -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
- app.use(express.json());
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}`);
@@ -1,4 +1,16 @@
1
- // Simple API key authentication middleware
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 (!validTokens.includes(token)) {
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
  }
@@ -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;