vcluster-yaml-mcp-server 0.1.0 → 1.0.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/README.md +1 -1
- package/package.json +3 -3
- 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,7 +71,7 @@ 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vcluster-yaml-mcp-server",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1",
|
|
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/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;
|