mcp-gov 1.0.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/LICENSE +21 -0
- package/README.md +1247 -0
- package/bin/mcp-gov-proxy.js +362 -0
- package/bin/mcp-gov-unwrap.js +442 -0
- package/bin/mcp-gov-wrap.js +766 -0
- package/package.json +53 -0
- package/postinstall.js +24 -0
- package/src/index.js +199 -0
- package/src/operation-detector.js +67 -0
- package/src/operation-keywords.js +75 -0
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-gov-wrap - Generic MCP Server Wrapper
|
|
5
|
+
* Auto-discovers MCP servers and wraps them with governance proxy
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { exec, spawn } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
import { resolve, dirname, join } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { extractService, detectOperation } from '../src/operation-detector.js';
|
|
15
|
+
|
|
16
|
+
const execAsync = promisify(exec);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse command line arguments
|
|
20
|
+
* @returns {{ config: string, rules: string, tool: string, help: boolean }}
|
|
21
|
+
*/
|
|
22
|
+
function parseCliArgs() {
|
|
23
|
+
try {
|
|
24
|
+
const { values } = parseArgs({
|
|
25
|
+
options: {
|
|
26
|
+
config: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
short: 'c',
|
|
29
|
+
},
|
|
30
|
+
rules: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
short: 'r',
|
|
33
|
+
},
|
|
34
|
+
tool: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
short: 't',
|
|
37
|
+
},
|
|
38
|
+
help: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
short: 'h',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
allowPositionals: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return values;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`Error parsing arguments: ${error.message}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Show usage information
|
|
55
|
+
*/
|
|
56
|
+
function showUsage() {
|
|
57
|
+
console.log(`
|
|
58
|
+
Usage: mcp-gov-wrap --config <config.json> [--rules <rules.json>] [--tool <command>]
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--config, -c Path to MCP config file (e.g., ~/.config/claude/config.json)
|
|
62
|
+
--rules, -r Path to governance rules file (optional, defaults to ~/.mcp-gov/rules.json)
|
|
63
|
+
--tool, -t Tool command to execute after wrapping (optional, e.g., "claude chat")
|
|
64
|
+
--help, -h Show this help message
|
|
65
|
+
|
|
66
|
+
Description:
|
|
67
|
+
Auto-discovers unwrapped MCP servers in config and wraps them with governance proxy.
|
|
68
|
+
If rules file doesn't exist, generates one with safe defaults (allow read/write, deny delete/admin/execute).
|
|
69
|
+
On subsequent runs, detects new servers and adds rules for them (delta approach).
|
|
70
|
+
Creates a timestamped backup of the config file before modification.
|
|
71
|
+
Supports both Claude Code format (projects.mcpServers) and flat format (mcpServers).
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
# Wrap servers (minimal - uses defaults)
|
|
75
|
+
mcp-gov-wrap --config ~/.config/claude/config.json
|
|
76
|
+
|
|
77
|
+
# Wrap servers and launch Claude Code
|
|
78
|
+
mcp-gov-wrap --config ~/.config/claude/config.json --tool "claude chat"
|
|
79
|
+
|
|
80
|
+
# Wrap servers with custom rules
|
|
81
|
+
mcp-gov-wrap --config ~/.config/claude/config.json --rules ~/custom-rules.json
|
|
82
|
+
`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate required arguments
|
|
88
|
+
* @param {{ config?: string, rules?: string, tool?: string }} args
|
|
89
|
+
*/
|
|
90
|
+
function validateArgs(args) {
|
|
91
|
+
const errors = [];
|
|
92
|
+
|
|
93
|
+
if (!args.config) {
|
|
94
|
+
errors.push('--config argument is required');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --rules is now optional, will default to ~/.mcp-gov/rules.json
|
|
98
|
+
// --tool is now optional
|
|
99
|
+
|
|
100
|
+
if (errors.length > 0) {
|
|
101
|
+
console.error('Error: Missing required arguments\n');
|
|
102
|
+
errors.forEach(err => console.error(` ${err}`));
|
|
103
|
+
console.error('\nUse --help for usage information');
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load and parse config file with format detection
|
|
110
|
+
* @param {string} configPath - Path to config.json
|
|
111
|
+
* @returns {{ allMcpServers: Array<{path: string, servers: Object}>, format: string, rawConfig: Object }} Config data and detected format
|
|
112
|
+
*/
|
|
113
|
+
function loadConfig(configPath) {
|
|
114
|
+
// Check if file exists
|
|
115
|
+
if (!existsSync(configPath)) {
|
|
116
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Read and parse JSON
|
|
120
|
+
let configData;
|
|
121
|
+
try {
|
|
122
|
+
const content = readFileSync(configPath, 'utf8');
|
|
123
|
+
configData = JSON.parse(content);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (error instanceof SyntaxError) {
|
|
126
|
+
throw new Error(`Invalid JSON in config file: ${error.message}`);
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`Failed to read config file: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Detect format and extract ALL mcpServers sections
|
|
132
|
+
let allMcpServers = [];
|
|
133
|
+
let format;
|
|
134
|
+
|
|
135
|
+
if (configData.projects && typeof configData.projects === 'object') {
|
|
136
|
+
// Multi-project format: { projects: { "/path1": { mcpServers: {...} }, "/path2": { mcpServers: {...} } } }
|
|
137
|
+
format = 'multi-project';
|
|
138
|
+
for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {
|
|
139
|
+
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
|
140
|
+
allMcpServers.push({
|
|
141
|
+
path: projectPath,
|
|
142
|
+
servers: projectConfig.mcpServers
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} else if (configData.mcpServers) {
|
|
147
|
+
// Flat format: { mcpServers: {...} }
|
|
148
|
+
format = 'flat';
|
|
149
|
+
allMcpServers.push({
|
|
150
|
+
path: 'root',
|
|
151
|
+
servers: configData.mcpServers
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (allMcpServers.length === 0) {
|
|
156
|
+
throw new Error('No mcpServers found in config. Config must contain "mcpServers" (flat) or "projects[*].mcpServers" (multi-project)');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { allMcpServers, format, rawConfig: configData };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Load and validate rules file
|
|
164
|
+
* @param {string} rulesPath - Path to rules.json
|
|
165
|
+
* @returns {Object} Parsed rules object
|
|
166
|
+
*/
|
|
167
|
+
function loadAndValidateRules(rulesPath) {
|
|
168
|
+
// Check if file exists
|
|
169
|
+
if (!existsSync(rulesPath)) {
|
|
170
|
+
throw new Error(`Rules file not found: ${rulesPath}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Read and parse JSON
|
|
174
|
+
let rulesData;
|
|
175
|
+
try {
|
|
176
|
+
const content = readFileSync(rulesPath, 'utf8');
|
|
177
|
+
rulesData = JSON.parse(content);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error instanceof SyntaxError) {
|
|
180
|
+
throw new Error(`Invalid JSON in rules file: ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`Failed to read rules file: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Validate rules structure
|
|
186
|
+
if (!rulesData.rules || !Array.isArray(rulesData.rules)) {
|
|
187
|
+
throw new Error('Rules file must contain a "rules" array');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Validate each rule
|
|
191
|
+
rulesData.rules.forEach((rule, index) => {
|
|
192
|
+
if (!rule.service) {
|
|
193
|
+
throw new Error(`Rule at index ${index}: "service" field is required`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!rule.operations || !Array.isArray(rule.operations)) {
|
|
197
|
+
throw new Error(`Rule at index ${index}: "operations" field is required and must be an array`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!rule.permission) {
|
|
201
|
+
throw new Error(`Rule at index ${index}: "permission" field is required`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (rule.permission !== 'allow' && rule.permission !== 'deny') {
|
|
205
|
+
throw new Error(`Rule at index ${index}: "permission" must be "allow" or "deny", got "${rule.permission}"`);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return rulesData;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if a server is already wrapped with mcp-gov-proxy
|
|
214
|
+
* @param {Object} serverConfig - Server configuration object
|
|
215
|
+
* @returns {boolean} True if already wrapped
|
|
216
|
+
*/
|
|
217
|
+
function isServerWrapped(serverConfig) {
|
|
218
|
+
if (!serverConfig.command) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
return serverConfig.command.includes('mcp-gov-proxy');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Detect unwrapped servers in config
|
|
226
|
+
* @param {Object} mcpServers - MCP servers configuration
|
|
227
|
+
* @returns {{ wrapped: string[], unwrapped: string[] }} Lists of wrapped and unwrapped server names
|
|
228
|
+
*/
|
|
229
|
+
function detectUnwrappedServers(mcpServers) {
|
|
230
|
+
const wrapped = [];
|
|
231
|
+
const unwrapped = [];
|
|
232
|
+
|
|
233
|
+
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
234
|
+
if (isServerWrapped(serverConfig)) {
|
|
235
|
+
wrapped.push(serverName);
|
|
236
|
+
} else {
|
|
237
|
+
unwrapped.push(serverName);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { wrapped, unwrapped };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Wrap a server configuration with mcp-gov-proxy
|
|
246
|
+
* @param {string} serverName - Name of the server (key in mcpServers)
|
|
247
|
+
* @param {Object} serverConfig - Original server configuration
|
|
248
|
+
* @param {string} rulesPath - Absolute path to rules.json
|
|
249
|
+
* @returns {Object} Wrapped server configuration
|
|
250
|
+
*/
|
|
251
|
+
function wrapServer(serverName, serverConfig, rulesPath) {
|
|
252
|
+
// Build target command from original config
|
|
253
|
+
let targetCommand = serverConfig.command || '';
|
|
254
|
+
|
|
255
|
+
// Append original args if they exist
|
|
256
|
+
if (serverConfig.args && Array.isArray(serverConfig.args)) {
|
|
257
|
+
targetCommand += ' ' + serverConfig.args.join(' ');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
targetCommand = targetCommand.trim();
|
|
261
|
+
|
|
262
|
+
// Create wrapped configuration
|
|
263
|
+
const wrappedConfig = {
|
|
264
|
+
command: 'mcp-gov-proxy',
|
|
265
|
+
args: [
|
|
266
|
+
'--service', serverName,
|
|
267
|
+
'--target', targetCommand,
|
|
268
|
+
'--rules', rulesPath
|
|
269
|
+
],
|
|
270
|
+
_original: {
|
|
271
|
+
command: serverConfig.command,
|
|
272
|
+
args: serverConfig.args || []
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// Preserve environment variables if they exist
|
|
277
|
+
if (serverConfig.env) {
|
|
278
|
+
wrappedConfig.env = { ...serverConfig.env };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return wrappedConfig;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Create a timestamped backup of the config file
|
|
286
|
+
* @param {string} configPath - Path to config file
|
|
287
|
+
* @returns {string} Path to backup file
|
|
288
|
+
*/
|
|
289
|
+
function createBackup(configPath) {
|
|
290
|
+
const now = new Date();
|
|
291
|
+
|
|
292
|
+
// Format: YYYYMMDD-HHMMSS
|
|
293
|
+
const year = now.getFullYear();
|
|
294
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
295
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
296
|
+
const hour = String(now.getHours()).padStart(2, '0');
|
|
297
|
+
const minute = String(now.getMinutes()).padStart(2, '0');
|
|
298
|
+
const second = String(now.getSeconds()).padStart(2, '0');
|
|
299
|
+
|
|
300
|
+
const timestamp = `${year}${month}${day}-${hour}${minute}${second}`;
|
|
301
|
+
const backupPath = `${configPath}.backup-${timestamp}`;
|
|
302
|
+
|
|
303
|
+
copyFileSync(configPath, backupPath);
|
|
304
|
+
|
|
305
|
+
return backupPath;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Wrap unwrapped servers in the config
|
|
310
|
+
* @param {Object} config - Full config object with mcpServers
|
|
311
|
+
* @param {string[]} unwrappedNames - Names of servers to wrap
|
|
312
|
+
* @param {string} rulesPath - Absolute path to rules.json
|
|
313
|
+
* @returns {Object} Modified config with wrapped servers
|
|
314
|
+
*/
|
|
315
|
+
function wrapServers(config, unwrappedNames, rulesPath) {
|
|
316
|
+
const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
|
|
317
|
+
|
|
318
|
+
// Get reference to mcpServers in the modified config
|
|
319
|
+
let mcpServers;
|
|
320
|
+
if (config.format === 'claude-code') {
|
|
321
|
+
mcpServers = modifiedConfig.projects.mcpServers;
|
|
322
|
+
} else {
|
|
323
|
+
mcpServers = modifiedConfig.mcpServers;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Wrap each unwrapped server
|
|
327
|
+
for (const serverName of unwrappedNames) {
|
|
328
|
+
const originalConfig = mcpServers[serverName];
|
|
329
|
+
mcpServers[serverName] = wrapServer(serverName, originalConfig, rulesPath);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return modifiedConfig;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Discover tools from an MCP server by spawning it and querying tools/list
|
|
337
|
+
* @param {Object} serverConfig - Server configuration {command, args}
|
|
338
|
+
* @param {string} serverName - Name of the server
|
|
339
|
+
* @returns {Promise<string[]>} Array of tool names
|
|
340
|
+
*/
|
|
341
|
+
async function discoverServerTools(serverConfig, serverName) {
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
// Build command
|
|
344
|
+
const command = serverConfig.command || '';
|
|
345
|
+
const args = serverConfig.args || [];
|
|
346
|
+
|
|
347
|
+
// Spawn the server
|
|
348
|
+
const child = spawn(command, args, {
|
|
349
|
+
env: { ...process.env, ...serverConfig.env },
|
|
350
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
let toolsList = [];
|
|
354
|
+
let responseBuffer = '';
|
|
355
|
+
let timeout;
|
|
356
|
+
|
|
357
|
+
// Set timeout for discovery (5 seconds)
|
|
358
|
+
timeout = setTimeout(() => {
|
|
359
|
+
child.kill();
|
|
360
|
+
console.error(` Warning: Discovery timeout for ${serverName}, using service-level defaults`);
|
|
361
|
+
resolve([]);
|
|
362
|
+
}, 5000);
|
|
363
|
+
|
|
364
|
+
// Send tools/list request
|
|
365
|
+
const listRequest = JSON.stringify({
|
|
366
|
+
jsonrpc: '2.0',
|
|
367
|
+
method: 'tools/list',
|
|
368
|
+
id: 1
|
|
369
|
+
}) + '\n';
|
|
370
|
+
|
|
371
|
+
child.stdin.write(listRequest);
|
|
372
|
+
|
|
373
|
+
// Read response
|
|
374
|
+
child.stdout.on('data', (data) => {
|
|
375
|
+
responseBuffer += data.toString();
|
|
376
|
+
|
|
377
|
+
// Try to parse JSON-RPC response
|
|
378
|
+
const lines = responseBuffer.split('\n');
|
|
379
|
+
for (const line of lines) {
|
|
380
|
+
if (!line.trim()) continue;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const response = JSON.parse(line);
|
|
384
|
+
if (response.id === 1 && response.result && response.result.tools) {
|
|
385
|
+
toolsList = response.result.tools.map(t => t.name);
|
|
386
|
+
clearTimeout(timeout);
|
|
387
|
+
child.kill();
|
|
388
|
+
resolve(toolsList);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
// Not valid JSON yet, continue buffering
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
child.on('error', (err) => {
|
|
398
|
+
clearTimeout(timeout);
|
|
399
|
+
console.error(` Warning: Failed to discover tools from ${serverName}: ${err.message}`);
|
|
400
|
+
resolve([]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
child.on('close', (code) => {
|
|
404
|
+
clearTimeout(timeout);
|
|
405
|
+
if (toolsList.length === 0) {
|
|
406
|
+
console.error(` Warning: No tools discovered from ${serverName}, using service-level defaults`);
|
|
407
|
+
}
|
|
408
|
+
resolve(toolsList);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Generate default rules for a service with safe defaults
|
|
415
|
+
* @param {string} serviceName - Service name
|
|
416
|
+
* @param {string[]} tools - Array of tool names
|
|
417
|
+
* @returns {Object[]} Array of rule objects
|
|
418
|
+
*/
|
|
419
|
+
function generateDefaultRules(serviceName, tools) {
|
|
420
|
+
const rules = [];
|
|
421
|
+
const safeDefaults = {
|
|
422
|
+
read: 'allow',
|
|
423
|
+
write: 'allow',
|
|
424
|
+
delete: 'deny',
|
|
425
|
+
execute: 'deny',
|
|
426
|
+
admin: 'deny'
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
if (tools.length === 0) {
|
|
430
|
+
// No tools discovered, create service-level rules
|
|
431
|
+
for (const [operation, permission] of Object.entries(safeDefaults)) {
|
|
432
|
+
if (permission === 'deny') {
|
|
433
|
+
rules.push({
|
|
434
|
+
service: serviceName,
|
|
435
|
+
operations: [operation],
|
|
436
|
+
permission: permission,
|
|
437
|
+
reason: `${operation.charAt(0).toUpperCase() + operation.slice(1)} operations denied by default for safety`
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
// Create rules based on discovered tools
|
|
443
|
+
const toolsByOperation = { read: [], write: [], delete: [], execute: [], admin: [] };
|
|
444
|
+
|
|
445
|
+
tools.forEach(toolName => {
|
|
446
|
+
const operation = detectOperation(toolName);
|
|
447
|
+
if (toolsByOperation[operation]) {
|
|
448
|
+
toolsByOperation[operation].push(toolName);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Create rules for each operation type that has tools
|
|
453
|
+
for (const [operation, permission] of Object.entries(safeDefaults)) {
|
|
454
|
+
if (toolsByOperation[operation].length > 0) {
|
|
455
|
+
const rule = {
|
|
456
|
+
service: serviceName,
|
|
457
|
+
operations: [operation],
|
|
458
|
+
permission: permission
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (permission === 'deny') {
|
|
462
|
+
rule.reason = `${operation.charAt(0).toUpperCase() + operation.slice(1)} operations denied by default for safety`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
rules.push(rule);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return rules;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Ensure rules file exists, generate with safe defaults if needed
|
|
475
|
+
* @param {string} rulesPath - Path to rules.json
|
|
476
|
+
* @param {Object} mcpServers - MCP servers configuration
|
|
477
|
+
* @returns {Promise<Object>} Loaded or generated rules
|
|
478
|
+
*/
|
|
479
|
+
async function ensureRulesExist(rulesPath, mcpServers) {
|
|
480
|
+
// Ensure directory exists
|
|
481
|
+
const rulesDir = dirname(rulesPath);
|
|
482
|
+
if (!existsSync(rulesDir)) {
|
|
483
|
+
mkdirSync(rulesDir, { recursive: true });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check if rules file exists
|
|
487
|
+
if (existsSync(rulesPath)) {
|
|
488
|
+
// Load existing rules and check for new servers
|
|
489
|
+
const existingRules = loadAndValidateRules(rulesPath);
|
|
490
|
+
const existingServices = new Set(existingRules.rules.map(r => r.service));
|
|
491
|
+
|
|
492
|
+
// Find new servers not in rules
|
|
493
|
+
const allServers = Object.keys(mcpServers);
|
|
494
|
+
const newServers = allServers.filter(serverName => !existingServices.has(serverName));
|
|
495
|
+
|
|
496
|
+
if (newServers.length === 0) {
|
|
497
|
+
console.log(`Using rules from: ${rulesPath}`);
|
|
498
|
+
return existingRules;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Discover and add rules for new servers
|
|
502
|
+
console.log(`\nDiscovered ${newServers.length} new server(s) not in rules:`);
|
|
503
|
+
newServers.forEach(name => console.log(` - ${name}`));
|
|
504
|
+
console.log('\nGenerating safe defaults for new servers...');
|
|
505
|
+
|
|
506
|
+
const newRules = [];
|
|
507
|
+
for (const serverName of newServers) {
|
|
508
|
+
const serverConfig = mcpServers[serverName];
|
|
509
|
+
console.log(` Discovering tools from ${serverName}...`);
|
|
510
|
+
|
|
511
|
+
const tools = await discoverServerTools(serverConfig, serverName);
|
|
512
|
+
const rules = generateDefaultRules(serverName, tools);
|
|
513
|
+
newRules.push(...rules);
|
|
514
|
+
|
|
515
|
+
console.log(` ā Added ${rules.length} rule(s) for ${serverName}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Merge with existing rules
|
|
519
|
+
const mergedRules = {
|
|
520
|
+
_comment: 'Auto-generated governance rules. Edit as needed.',
|
|
521
|
+
_location: rulesPath,
|
|
522
|
+
rules: [...existingRules.rules, ...newRules]
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// Save merged rules
|
|
526
|
+
writeFileSync(rulesPath, JSON.stringify(mergedRules, null, 2) + '\n');
|
|
527
|
+
console.log(`\nā Updated rules file: ${rulesPath}`);
|
|
528
|
+
console.log('\nTo customize governance rules, edit: ' + rulesPath);
|
|
529
|
+
|
|
530
|
+
return mergedRules;
|
|
531
|
+
} else {
|
|
532
|
+
// First run - generate rules for all servers
|
|
533
|
+
console.log('\nNo rules file found - generating with safe defaults...');
|
|
534
|
+
|
|
535
|
+
const allRules = [];
|
|
536
|
+
const serverNames = Object.keys(mcpServers);
|
|
537
|
+
|
|
538
|
+
if (serverNames.length === 0) {
|
|
539
|
+
console.log('No MCP servers found in config');
|
|
540
|
+
const emptyRules = {
|
|
541
|
+
_comment: 'Auto-generated governance rules. Add servers and run again.',
|
|
542
|
+
_location: rulesPath,
|
|
543
|
+
rules: []
|
|
544
|
+
};
|
|
545
|
+
writeFileSync(rulesPath, JSON.stringify(emptyRules, null, 2) + '\n');
|
|
546
|
+
return emptyRules;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log(`Discovering tools from ${serverNames.length} server(s)...`);
|
|
550
|
+
|
|
551
|
+
for (const serverName of serverNames) {
|
|
552
|
+
const serverConfig = mcpServers[serverName];
|
|
553
|
+
console.log(` Discovering ${serverName}...`);
|
|
554
|
+
|
|
555
|
+
const tools = await discoverServerTools(serverConfig, serverName);
|
|
556
|
+
const rules = generateDefaultRules(serverName, tools);
|
|
557
|
+
allRules.push(...rules);
|
|
558
|
+
|
|
559
|
+
if (tools.length > 0) {
|
|
560
|
+
console.log(` ā Found ${tools.length} tool(s), generated ${rules.length} rule(s)`);
|
|
561
|
+
} else {
|
|
562
|
+
console.log(` ā Generated ${rules.length} service-level rule(s)`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Create rules file
|
|
567
|
+
const rulesData = {
|
|
568
|
+
_comment: 'Auto-generated governance rules. Edit as needed.',
|
|
569
|
+
_location: rulesPath,
|
|
570
|
+
rules: allRules
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
writeFileSync(rulesPath, JSON.stringify(rulesData, null, 2) + '\n');
|
|
574
|
+
console.log(`\nā Generated rules file: ${rulesPath}`);
|
|
575
|
+
|
|
576
|
+
// Show summary
|
|
577
|
+
const deniedOps = allRules.filter(r => r.permission === 'deny');
|
|
578
|
+
const allowedOps = allRules.filter(r => r.permission === 'allow');
|
|
579
|
+
console.log(`\nSafe defaults applied:`);
|
|
580
|
+
console.log(` ā Allow: ${allowedOps.map(r => r.operations).flat().join(', ')}`);
|
|
581
|
+
console.log(` ā Deny: ${deniedOps.map(r => r.operations).flat().join(', ')}`);
|
|
582
|
+
console.log('\nTo customize governance rules, edit: ' + rulesPath);
|
|
583
|
+
|
|
584
|
+
return rulesData;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Main entry point
|
|
590
|
+
*/
|
|
591
|
+
async function main() {
|
|
592
|
+
const args = parseCliArgs();
|
|
593
|
+
|
|
594
|
+
if (args.help) {
|
|
595
|
+
showUsage();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
validateArgs(args);
|
|
599
|
+
|
|
600
|
+
// Load config file
|
|
601
|
+
let config;
|
|
602
|
+
try {
|
|
603
|
+
config = loadConfig(args.config);
|
|
604
|
+
console.log(`Loaded config in ${config.format} format`);
|
|
605
|
+
|
|
606
|
+
// Count total servers across all projects
|
|
607
|
+
const totalServersCount = config.allMcpServers.reduce((sum, item) =>
|
|
608
|
+
sum + Object.keys(item.servers).length, 0);
|
|
609
|
+
|
|
610
|
+
if (config.format === 'multi-project') {
|
|
611
|
+
console.log(`Found ${config.allMcpServers.length} project(s) with ${totalServersCount} total MCP servers`);
|
|
612
|
+
} else {
|
|
613
|
+
console.log(`Found ${totalServersCount} MCP servers`);
|
|
614
|
+
}
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error(`Error loading config: ${error.message}`);
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Determine rules path (use provided or default to ~/.mcp-gov/rules.json)
|
|
621
|
+
const rulesPath = args.rules || join(homedir(), '.mcp-gov', 'rules.json');
|
|
622
|
+
|
|
623
|
+
// Collect all servers from all projects for rules generation
|
|
624
|
+
const allServers = {};
|
|
625
|
+
for (const { servers } of config.allMcpServers) {
|
|
626
|
+
Object.assign(allServers, servers);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Ensure rules file exists (generate if needed with delta approach)
|
|
630
|
+
let rules;
|
|
631
|
+
try {
|
|
632
|
+
rules = await ensureRulesExist(rulesPath, allServers);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
console.error(`Error with rules: ${error.message}`);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Detect unwrapped servers across ALL projects
|
|
639
|
+
let allWrapped = [];
|
|
640
|
+
let allUnwrapped = [];
|
|
641
|
+
|
|
642
|
+
for (const { path: projectPath, servers } of config.allMcpServers) {
|
|
643
|
+
const { wrapped, unwrapped } = detectUnwrappedServers(servers);
|
|
644
|
+
allWrapped.push(...wrapped.map(name => ({ project: projectPath, name })));
|
|
645
|
+
allUnwrapped.push(...unwrapped.map(name => ({ project: projectPath, name })));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const totalServers = allWrapped.length + allUnwrapped.length;
|
|
649
|
+
|
|
650
|
+
if (totalServers === 0) {
|
|
651
|
+
console.log('No servers found in config');
|
|
652
|
+
} else {
|
|
653
|
+
console.log(`\nServer status (across all projects):`);
|
|
654
|
+
console.log(` Total: ${totalServers}`);
|
|
655
|
+
console.log(` Already wrapped: ${allWrapped.length}`);
|
|
656
|
+
console.log(` Need wrapping: ${allUnwrapped.length}`);
|
|
657
|
+
|
|
658
|
+
if (allWrapped.length > 0) {
|
|
659
|
+
console.log(`\nAlready wrapped servers:`);
|
|
660
|
+
allWrapped.forEach(({ project, name }) => {
|
|
661
|
+
if (config.format === 'multi-project') {
|
|
662
|
+
console.log(` - ${name} (${project})`);
|
|
663
|
+
} else {
|
|
664
|
+
console.log(` - ${name}`);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (allUnwrapped.length > 0) {
|
|
670
|
+
console.log(`\nServers to wrap:`);
|
|
671
|
+
allUnwrapped.forEach(({ project, name }) => {
|
|
672
|
+
if (config.format === 'multi-project') {
|
|
673
|
+
console.log(` - ${name} (${project})`);
|
|
674
|
+
} else {
|
|
675
|
+
console.log(` - ${name}`);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
} else {
|
|
679
|
+
console.log(`\nAll servers already wrapped, no action needed`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Wrap servers if needed
|
|
684
|
+
if (allUnwrapped.length > 0) {
|
|
685
|
+
console.log(`\nWrapping ${allUnwrapped.length} server(s)...`);
|
|
686
|
+
|
|
687
|
+
// Create backup before modifying
|
|
688
|
+
try {
|
|
689
|
+
const backupPath = createBackup(args.config);
|
|
690
|
+
console.log(`ā Created backup: ${backupPath}`);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
console.error(`Error creating backup: ${error.message}`);
|
|
693
|
+
process.exit(1);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Get absolute path for rules
|
|
697
|
+
const absoluteRulesPath = resolve(rulesPath);
|
|
698
|
+
|
|
699
|
+
// Wrap servers in each project
|
|
700
|
+
const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
|
|
701
|
+
|
|
702
|
+
for (const { path: projectPath, servers } of config.allMcpServers) {
|
|
703
|
+
const { unwrapped } = detectUnwrappedServers(servers);
|
|
704
|
+
|
|
705
|
+
if (unwrapped.length > 0) {
|
|
706
|
+
// Get reference to this project's mcpServers
|
|
707
|
+
let targetServers;
|
|
708
|
+
if (config.format === 'multi-project') {
|
|
709
|
+
targetServers = modifiedConfig.projects[projectPath].mcpServers;
|
|
710
|
+
} else {
|
|
711
|
+
targetServers = modifiedConfig.mcpServers;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Wrap unwrapped servers in this project
|
|
715
|
+
for (const serverName of unwrapped) {
|
|
716
|
+
const originalConfig = targetServers[serverName];
|
|
717
|
+
targetServers[serverName] = wrapServer(serverName, originalConfig, absoluteRulesPath);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Write updated config
|
|
723
|
+
try {
|
|
724
|
+
writeFileSync(args.config, JSON.stringify(modifiedConfig, null, 2) + '\n');
|
|
725
|
+
console.log(`ā Updated config file: ${args.config}`);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
console.error(`Error writing config file: ${error.message}`);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Execute tool command if provided
|
|
733
|
+
if (args.tool) {
|
|
734
|
+
console.log(`\nExecuting tool command: ${args.tool}`);
|
|
735
|
+
try {
|
|
736
|
+
const { stdout, stderr } = await execAsync(args.tool, {
|
|
737
|
+
shell: true,
|
|
738
|
+
encoding: 'utf8'
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
if (stdout) {
|
|
742
|
+
console.log(stdout);
|
|
743
|
+
}
|
|
744
|
+
if (stderr) {
|
|
745
|
+
console.error(stderr);
|
|
746
|
+
}
|
|
747
|
+
} catch (error) {
|
|
748
|
+
// exec throws on non-zero exit codes, but we still want to show output
|
|
749
|
+
if (error.stdout) {
|
|
750
|
+
console.log(error.stdout);
|
|
751
|
+
}
|
|
752
|
+
if (error.stderr) {
|
|
753
|
+
console.error(error.stderr);
|
|
754
|
+
}
|
|
755
|
+
console.error(`\nTool command exited with code ${error.code || 'unknown'}`);
|
|
756
|
+
process.exit(error.code || 1);
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
console.log(`\nā Wrapping complete!`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
main().catch(error => {
|
|
764
|
+
console.error(`Fatal error: ${error.message}`);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
});
|