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.
@@ -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
+ });