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/src/cli.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * vCluster CLI - Standalone command-line interface
5
+ * Provides query, validate, and list-versions commands
6
+ * Wraps MCP server functionality with user-friendly CLI
7
+ */
8
+
9
+ import { Command } from 'commander';
10
+ import { handleQuery, handleListVersions, handleValidate } from './cli-handlers.js';
11
+ import { formatOutput } from './formatters.js';
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('vcluster-yaml')
17
+ .description('vCluster YAML configuration CLI')
18
+ .version('0.1.0');
19
+
20
+ // Query command
21
+ program
22
+ .command('query <query>')
23
+ .description('Search for vCluster configuration fields')
24
+ .option('--file <file>', 'Configuration file to search', 'chart/values.yaml')
25
+ .option('--version <version>', 'vCluster version or branch', 'main')
26
+ .option('-f, --format <format>', 'Output format (json, yaml, table)', 'json')
27
+ .action(async (query, options) => {
28
+ try {
29
+ // Validate format option
30
+ if (!['json', 'yaml', 'table'].includes(options.format)) {
31
+ console.error(`Error: Invalid format "${options.format}". Must be one of: json, yaml, table`);
32
+ process.exit(1);
33
+ }
34
+
35
+ const result = await handleQuery(query, {
36
+ file: options.file,
37
+ version: options.version
38
+ });
39
+
40
+ if (!result.success) {
41
+ // Error case - output error message and exit with code 1
42
+ if (options.format === 'json') {
43
+ console.log(JSON.stringify(result, null, 2));
44
+ } else {
45
+ console.error(result.error);
46
+ }
47
+ process.exit(1);
48
+ }
49
+
50
+ const output = formatOutput(result, options.format, 'query');
51
+ console.log(output);
52
+ process.exit(0);
53
+ } catch (error) {
54
+ console.error(`Error: ${error.message}`);
55
+ process.exit(1);
56
+ }
57
+ });
58
+
59
+ // List versions command
60
+ program
61
+ .command('list-versions')
62
+ .description('List available vCluster versions')
63
+ .option('-f, --format <format>', 'Output format (json, yaml, table)', 'json')
64
+ .action(async (options) => {
65
+ try {
66
+ // Validate format option
67
+ if (!['json', 'yaml', 'table'].includes(options.format)) {
68
+ console.error(`Error: Invalid format "${options.format}". Must be one of: json, yaml, table`);
69
+ process.exit(1);
70
+ }
71
+
72
+ const result = await handleListVersions();
73
+
74
+ if (!result.success) {
75
+ if (options.format === 'json') {
76
+ console.log(JSON.stringify(result, null, 2));
77
+ } else {
78
+ console.error(result.error);
79
+ }
80
+ process.exit(1);
81
+ }
82
+
83
+ const output = formatOutput(result, options.format, 'list-versions');
84
+ console.log(output);
85
+ process.exit(0);
86
+ } catch (error) {
87
+ console.error(`Error: ${error.message}`);
88
+ process.exit(1);
89
+ }
90
+ });
91
+
92
+ // Validate command
93
+ program
94
+ .command('validate <content>')
95
+ .description('Validate vCluster configuration')
96
+ .option('--version <version>', 'vCluster version for schema', 'main')
97
+ .option('-f, --format <format>', 'Output format (json, yaml, table)', 'json')
98
+ .action(async (content, options) => {
99
+ try {
100
+ // Validate format option
101
+ if (!['json', 'yaml', 'table'].includes(options.format)) {
102
+ console.error(`Error: Invalid format "${options.format}". Must be one of: json, yaml, table`);
103
+ process.exit(1);
104
+ }
105
+
106
+ const result = await handleValidate(content, {
107
+ version: options.version
108
+ });
109
+
110
+ // For validation, always output the result (even if not successful)
111
+ // The formatOutput will handle error cases appropriately
112
+ const output = formatOutput(result, options.format, 'validate');
113
+ console.log(output);
114
+
115
+ // Exit with code 1 if validation failed OR if we couldn't load schema
116
+ if (!result.success || !result.valid) {
117
+ process.exit(1);
118
+ }
119
+
120
+ process.exit(0);
121
+ } catch (error) {
122
+ console.error(`Error: ${error.message}`);
123
+ process.exit(1);
124
+ }
125
+ });
126
+
127
+ // Parse arguments
128
+ program.parse();
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Output formatters for CLI
3
+ * Provides JSON, YAML, and table formatting for command outputs
4
+ */
5
+
6
+ import yaml from 'js-yaml';
7
+ import Table from 'cli-table3';
8
+ import chalk from 'chalk';
9
+
10
+ /**
11
+ * Format output as JSON
12
+ * Always returns valid JSON that can be parsed with JSON.parse()
13
+ */
14
+ export function formatJSON(data) {
15
+ return JSON.stringify(data, null, 2);
16
+ }
17
+
18
+ /**
19
+ * Format output as YAML
20
+ * Always returns valid YAML that can be parsed with yaml.load()
21
+ */
22
+ export function formatYAML(data) {
23
+ return yaml.dump(data, {
24
+ indent: 2,
25
+ lineWidth: 120,
26
+ noRefs: true
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Format query results as a table
32
+ * Returns formatted table with box-drawing characters and colored headers
33
+ */
34
+ export function formatQueryTable(results, metadata) {
35
+ // Handle empty results
36
+ if (!results || results.length === 0) {
37
+ return `No results found for query: "${metadata.query}"`;
38
+ }
39
+
40
+ const table = new Table({
41
+ head: [
42
+ chalk.cyan('Field'),
43
+ chalk.cyan('Value'),
44
+ chalk.cyan('Type'),
45
+ chalk.cyan('Description')
46
+ ],
47
+ style: {
48
+ head: [],
49
+ border: []
50
+ }
51
+ });
52
+
53
+ results.forEach(result => {
54
+ table.push([
55
+ result.field || result.path || '',
56
+ formatValue(result.value),
57
+ result.type || '',
58
+ result.description || ''
59
+ ]);
60
+ });
61
+
62
+ return table.toString();
63
+ }
64
+
65
+ /**
66
+ * Format list-versions results as a table
67
+ */
68
+ export function formatVersionsTable(versions) {
69
+ if (!versions || versions.length === 0) {
70
+ return 'No versions found';
71
+ }
72
+
73
+ const table = new Table({
74
+ head: [chalk.cyan('Version')],
75
+ style: {
76
+ head: [],
77
+ border: []
78
+ }
79
+ });
80
+
81
+ versions.forEach(version => {
82
+ table.push([version]);
83
+ });
84
+
85
+ return table.toString();
86
+ }
87
+
88
+ /**
89
+ * Format validation results as a table
90
+ */
91
+ export function formatValidationTable(data) {
92
+ if (data.valid) {
93
+ return chalk.green('✓ Configuration is valid');
94
+ }
95
+
96
+ let output = chalk.red('✗ Configuration has errors:\n');
97
+
98
+ const table = new Table({
99
+ head: [
100
+ chalk.cyan('Path'),
101
+ chalk.cyan('Error'),
102
+ chalk.cyan('Type')
103
+ ],
104
+ style: {
105
+ head: [],
106
+ border: []
107
+ }
108
+ });
109
+
110
+ data.errors.forEach(error => {
111
+ table.push([
112
+ error.path || 'root',
113
+ error.message || '',
114
+ error.type || ''
115
+ ]);
116
+ });
117
+
118
+ output += table.toString();
119
+ return output;
120
+ }
121
+
122
+ /**
123
+ * Helper function to format values for table display
124
+ * Truncates long values and handles different types
125
+ */
126
+ function formatValue(value) {
127
+ if (value === null || value === undefined) {
128
+ return '';
129
+ }
130
+
131
+ if (typeof value === 'object') {
132
+ const str = JSON.stringify(value);
133
+ return str.length > 50 ? str.substring(0, 47) + '...' : str;
134
+ }
135
+
136
+ const str = String(value);
137
+ return str.length > 50 ? str.substring(0, 47) + '...' : str;
138
+ }
139
+
140
+ /**
141
+ * Main formatter function
142
+ * Routes to appropriate formatter based on format option
143
+ */
144
+ export function formatOutput(data, format, command) {
145
+ switch (format) {
146
+ case 'json':
147
+ return formatJSON(data);
148
+
149
+ case 'yaml':
150
+ return formatYAML(data);
151
+
152
+ case 'table':
153
+ // Route to appropriate table formatter based on command/data shape
154
+ if (command === 'query' || data.results !== undefined) {
155
+ return formatQueryTable(data.results || [], data.metadata || {});
156
+ } else if (command === 'list-versions' || data.versions !== undefined) {
157
+ return formatVersionsTable(data.versions || []);
158
+ } else if (command === 'validate' || data.valid !== undefined) {
159
+ return formatValidationTable(data);
160
+ } else {
161
+ // Fallback: format entire data object as JSON in table
162
+ return formatJSON(data);
163
+ }
164
+
165
+ default:
166
+ throw new Error(`Unknown format: ${format}`);
167
+ }
168
+ }
package/src/github.js ADDED
@@ -0,0 +1,170 @@
1
+ import fetch from 'node-fetch';
2
+ import yaml from 'js-yaml';
3
+
4
+ const GITHUB_API_BASE = 'https://api.github.com';
5
+ const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
6
+ const REPO_OWNER = 'loft-sh';
7
+ const REPO_NAME = 'vcluster';
8
+
9
+ // Cache for fetched content (15 minute TTL)
10
+ const cache = new Map();
11
+ const CACHE_TTL = 15 * 60 * 1000; // 15 minutes
12
+
13
+ class GitHubClient {
14
+ constructor() {
15
+ this.defaultBranch = 'main';
16
+ }
17
+
18
+ // Get list of available tags (versions)
19
+ async getTags() {
20
+ const cacheKey = 'tags';
21
+ const cached = this.getFromCache(cacheKey);
22
+ if (cached) return cached;
23
+
24
+ try {
25
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/tags`, {
26
+ headers: {
27
+ 'Accept': 'application/vnd.github.v3+json',
28
+ 'User-Agent': 'vcluster-yaml-mcp-server'
29
+ }
30
+ });
31
+
32
+ if (!response.ok) {
33
+ throw new Error(`GitHub API error: ${response.statusText}`);
34
+ }
35
+
36
+ const tags = await response.json();
37
+ const tagNames = tags.map(tag => tag.name);
38
+
39
+ this.setCache(cacheKey, tagNames);
40
+ return tagNames;
41
+ } catch (error) {
42
+ console.error('Error fetching tags:', error);
43
+ return [];
44
+ }
45
+ }
46
+
47
+ // Get list of branches
48
+ async getBranches() {
49
+ const cacheKey = 'branches';
50
+ const cached = this.getFromCache(cacheKey);
51
+ if (cached) return cached;
52
+
53
+ try {
54
+ const response = await fetch(`${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/branches`, {
55
+ headers: {
56
+ 'Accept': 'application/vnd.github.v3+json',
57
+ 'User-Agent': 'vcluster-yaml-mcp-server'
58
+ }
59
+ });
60
+
61
+ if (!response.ok) {
62
+ throw new Error(`GitHub API error: ${response.statusText}`);
63
+ }
64
+
65
+ const branches = await response.json();
66
+ const branchNames = branches.map(branch => branch.name);
67
+
68
+ this.setCache(cacheKey, branchNames);
69
+ return branchNames;
70
+ } catch (error) {
71
+ console.error('Error fetching branches:', error);
72
+ return ['main'];
73
+ }
74
+ }
75
+
76
+ // Get file content from GitHub
77
+ async getFileContent(path, ref = 'main') {
78
+ const actualRef = ref;
79
+ const cacheKey = `file:${actualRef}:${path}`;
80
+ const cached = this.getFromCache(cacheKey);
81
+ if (cached) return cached;
82
+
83
+ try {
84
+ const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${actualRef}/${path}`;
85
+ const response = await fetch(url, {
86
+ headers: {
87
+ 'User-Agent': 'vcluster-yaml-mcp-server'
88
+ }
89
+ });
90
+
91
+ if (!response.ok) {
92
+ if (response.status === 404) {
93
+ throw new Error(`File not found: ${path} (ref: ${actualRef})`);
94
+ }
95
+ throw new Error(`GitHub error: ${response.statusText}`);
96
+ }
97
+
98
+ const content = await response.text();
99
+ this.setCache(cacheKey, content);
100
+ return content;
101
+ } catch (error) {
102
+ throw new Error(`Failed to fetch ${path}: ${error.message}`);
103
+ }
104
+ }
105
+
106
+ // Get parsed YAML content
107
+ async getYamlContent(path, ref = null) {
108
+ const content = await this.getFileContent(path, ref);
109
+ try {
110
+ return yaml.load(content);
111
+ } catch (error) {
112
+ throw new Error(`Failed to parse YAML from ${path}: ${error.message}`);
113
+ }
114
+ }
115
+
116
+ // Get vcluster configuration files
117
+ async getVClusterConfigs(ref = null) {
118
+ const configs = {};
119
+
120
+ // Known vcluster config files
121
+ const configPaths = [
122
+ 'chart/values.yaml',
123
+ 'chart/values.schema.json',
124
+ 'config/values.yaml',
125
+ 'values.schema.json'
126
+ ];
127
+
128
+ for (const path of configPaths) {
129
+ try {
130
+ if (path.endsWith('.yaml') || path.endsWith('.yml')) {
131
+ configs[path] = await this.getYamlContent(path, ref);
132
+ } else if (path.endsWith('.json')) {
133
+ const content = await this.getFileContent(path, ref);
134
+ configs[path] = JSON.parse(content);
135
+ }
136
+ } catch (error) {
137
+ // File might not exist in this version, skip it
138
+ console.debug(`Skipping ${path}: ${error.message}`);
139
+ }
140
+ }
141
+
142
+ return configs;
143
+ }
144
+
145
+ // Cache helpers
146
+ getFromCache(key) {
147
+ const item = cache.get(key);
148
+ if (!item) return null;
149
+
150
+ if (Date.now() - item.timestamp > CACHE_TTL) {
151
+ cache.delete(key);
152
+ return null;
153
+ }
154
+
155
+ return item.data;
156
+ }
157
+
158
+ setCache(key, data) {
159
+ cache.set(key, {
160
+ data,
161
+ timestamp: Date.now()
162
+ });
163
+ }
164
+
165
+ clearCache() {
166
+ cache.clear();
167
+ }
168
+ }
169
+
170
+ export const githubClient = new GitHubClient();
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { createServer } from './server.js';
5
+ import express from 'express';
6
+ import { requireApiKey } from './middleware/auth.js';
7
+
8
+ const PORT = process.env.PORT || 3000;
9
+ const REQUIRE_AUTH = process.env.REQUIRE_AUTH === 'true';
10
+
11
+ const app = express();
12
+ app.use(express.json());
13
+
14
+ // Health check endpoint
15
+ app.get('/health', (_req, res) => {
16
+ res.json({
17
+ status: 'ok',
18
+ name: 'vcluster-yaml-mcp-server',
19
+ version: '0.1.0',
20
+ timestamp: new Date().toISOString()
21
+ });
22
+ });
23
+
24
+ // Root endpoint info
25
+ app.get('/', (_req, res) => {
26
+ res.json({
27
+ name: 'vcluster-yaml-mcp-server',
28
+ version: '0.1.0',
29
+ description: 'MCP server for querying vCluster YAML configurations',
30
+ endpoints: {
31
+ mcp: '/mcp',
32
+ health: '/health'
33
+ },
34
+ documentation: 'https://github.com/Piotr1215/vcluster-yaml-mcp-server'
35
+ });
36
+ });
37
+
38
+ // MCP endpoint with Streamable HTTP transport
39
+ const mcpHandler = async (req, res) => {
40
+ console.log(`MCP ${req.method} request received`);
41
+
42
+ // Create new transport per request to prevent ID collisions
43
+ const transport = new StreamableHTTPServerTransport({
44
+ sessionIdGenerator: undefined,
45
+ enableJsonResponse: true
46
+ });
47
+
48
+ // Cleanup on connection close
49
+ res.on('close', () => {
50
+ transport.close();
51
+ });
52
+
53
+ const server = createServer();
54
+ await server.connect(transport);
55
+ await transport.handleRequest(req, res, req.body);
56
+ };
57
+
58
+ // 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);
61
+
62
+ app.listen(PORT, () => {
63
+ console.log(`vcluster-yaml-mcp-server HTTP running on port ${PORT}`);
64
+ console.log(`Health check: http://localhost:${PORT}/health`);
65
+ console.log(`MCP endpoint: http://localhost:${PORT}/mcp`);
66
+ console.log(`Transport: Streamable HTTP (MCP 2025-03-26)`);
67
+ });
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { createServer } from './server.js';
5
+
6
+ // Create server (no config path needed - uses GitHub)
7
+ const server = createServer();
8
+
9
+ // Start the server
10
+ const transport = new StdioServerTransport();
11
+ await server.connect(transport);
@@ -0,0 +1,25 @@
1
+ // Simple API key authentication middleware
2
+ export function requireApiKey(req, res, next) {
3
+ const authHeader = req.headers['authorization'];
4
+
5
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
6
+ return res.status(401).json({
7
+ error: 'Unauthorized',
8
+ message: 'Missing or invalid Authorization header'
9
+ });
10
+ }
11
+
12
+ const token = authHeader.substring(7);
13
+ const validTokens = (process.env.VALID_API_KEYS || '').split(',');
14
+
15
+ if (!validTokens.includes(token)) {
16
+ return res.status(403).json({
17
+ error: 'Forbidden',
18
+ message: 'Invalid API key'
19
+ });
20
+ }
21
+
22
+ // Optional: Track usage per token
23
+ req.apiKey = token;
24
+ next();
25
+ }