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,442 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mcp-gov-unwrap - MCP Server Unwrapper
5
+ * Unwraps MCP servers by restoring original configuration from _original field
6
+ */
7
+
8
+ import { parseArgs } from 'node:util';
9
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
10
+ import { exec } from 'node:child_process';
11
+ import { promisify } from 'node:util';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ /**
16
+ * Parse command line arguments
17
+ * @returns {{ config: string, tool: string, help: boolean }}
18
+ */
19
+ function parseCliArgs() {
20
+ try {
21
+ const { values } = parseArgs({
22
+ options: {
23
+ config: {
24
+ type: 'string',
25
+ short: 'c',
26
+ },
27
+ tool: {
28
+ type: 'string',
29
+ short: 't',
30
+ },
31
+ help: {
32
+ type: 'boolean',
33
+ short: 'h',
34
+ },
35
+ },
36
+ allowPositionals: false,
37
+ });
38
+
39
+ return values;
40
+ } catch (error) {
41
+ console.error(`Error parsing arguments: ${error.message}`);
42
+ process.exit(1);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Show usage information
48
+ */
49
+ function showUsage() {
50
+ console.log(`
51
+ Usage: mcp-gov-unwrap --config <config.json> [--tool <command>]
52
+
53
+ Options:
54
+ --config, -c Path to MCP config file (e.g., ~/.config/claude/config.json)
55
+ --tool, -t Tool command to execute after unwrapping (optional, e.g., "claude chat")
56
+ --help, -h Show this help message
57
+
58
+ Description:
59
+ Unwraps MCP servers by restoring original configuration from _original field.
60
+ Creates a timestamped backup of the config file before modification.
61
+ Supports both Claude Code format (projects.mcpServers) and flat format (mcpServers).
62
+ Only unwraps servers that have the _original field (previously wrapped by mcp-gov-wrap).
63
+
64
+ Examples:
65
+ # Unwrap all wrapped servers
66
+ mcp-gov-unwrap --config ~/.config/claude/config.json
67
+
68
+ # Unwrap servers and launch Claude Code
69
+ mcp-gov-unwrap --config ~/.config/claude/config.json --tool "claude chat"
70
+ `);
71
+ process.exit(0);
72
+ }
73
+
74
+ /**
75
+ * Validate required arguments
76
+ * @param {{ config?: string, tool?: string }} args
77
+ */
78
+ function validateArgs(args) {
79
+ const errors = [];
80
+
81
+ if (!args.config) {
82
+ errors.push('--config argument is required');
83
+ }
84
+
85
+ // --tool is optional for unwrap
86
+
87
+ if (errors.length > 0) {
88
+ console.error('Error: Missing required arguments\n');
89
+ errors.forEach(err => console.error(` ${err}`));
90
+ console.error('\nUse --help for usage information');
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Load and parse config file with format detection
97
+ * @param {string} configPath - Path to config.json
98
+ * @returns {{ allMcpServers: Array<{path: string, servers: Object}>, format: string, rawConfig: Object }} Config data and detected format
99
+ */
100
+ function loadConfig(configPath) {
101
+ // Check if file exists
102
+ if (!existsSync(configPath)) {
103
+ throw new Error(`Config file not found: ${configPath}`);
104
+ }
105
+
106
+ // Read and parse JSON
107
+ let configData;
108
+ try {
109
+ const content = readFileSync(configPath, 'utf8');
110
+ configData = JSON.parse(content);
111
+ } catch (error) {
112
+ if (error instanceof SyntaxError) {
113
+ throw new Error(`Invalid JSON in config file: ${error.message}`);
114
+ }
115
+ throw new Error(`Failed to read config file: ${error.message}`);
116
+ }
117
+
118
+ // Detect format and extract ALL mcpServers sections
119
+ let allMcpServers = [];
120
+ let format;
121
+
122
+ if (configData.projects && typeof configData.projects === 'object') {
123
+ // Multi-project format: { projects: { "/path1": { mcpServers: {...} }, "/path2": { mcpServers: {...} } } }
124
+ format = 'multi-project';
125
+ for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {
126
+ if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
127
+ allMcpServers.push({
128
+ path: projectPath,
129
+ servers: projectConfig.mcpServers
130
+ });
131
+ }
132
+ }
133
+ } else if (configData.mcpServers) {
134
+ // Flat format: { mcpServers: {...} }
135
+ format = 'flat';
136
+ allMcpServers.push({
137
+ path: 'root',
138
+ servers: configData.mcpServers
139
+ });
140
+ }
141
+
142
+ if (allMcpServers.length === 0) {
143
+ throw new Error('No mcpServers found in config. Config must contain "mcpServers" (flat) or "projects[*].mcpServers" (multi-project)');
144
+ }
145
+
146
+ return { allMcpServers, format, rawConfig: configData };
147
+ }
148
+
149
+ /**
150
+ * Check if a server is wrapped (has _original field)
151
+ * @param {Object} serverConfig - Server configuration object
152
+ * @returns {boolean} True if wrapped (has _original field)
153
+ */
154
+ function isServerWrapped(serverConfig) {
155
+ return serverConfig._original !== undefined;
156
+ }
157
+
158
+ /**
159
+ * Detect wrapped servers in config
160
+ * @param {Object} mcpServers - MCP servers configuration
161
+ * @returns {{ wrapped: string[], unwrapped: string[], malformed: string[] }} Lists of wrapped, unwrapped, and malformed server names
162
+ */
163
+ function detectWrappedServers(mcpServers) {
164
+ const wrapped = [];
165
+ const unwrapped = [];
166
+ const malformed = [];
167
+
168
+ for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
169
+ // Check if server looks wrapped (mcp-gov-proxy) but missing _original
170
+ if (serverConfig.command && serverConfig.command.includes('mcp-gov-proxy') && !serverConfig._original) {
171
+ malformed.push(serverName);
172
+ } else if (isServerWrapped(serverConfig)) {
173
+ wrapped.push(serverName);
174
+ } else {
175
+ unwrapped.push(serverName);
176
+ }
177
+ }
178
+
179
+ return { wrapped, unwrapped, malformed };
180
+ }
181
+
182
+ /**
183
+ * Unwrap a server configuration by restoring from _original
184
+ * @param {Object} serverConfig - Wrapped server configuration
185
+ * @returns {Object} Unwrapped server configuration
186
+ */
187
+ function unwrapServer(serverConfig) {
188
+ if (!serverConfig._original) {
189
+ throw new Error('Server does not have _original field, cannot unwrap');
190
+ }
191
+
192
+ // Restore original command and args
193
+ const unwrappedConfig = {
194
+ command: serverConfig._original.command,
195
+ args: serverConfig._original.args || []
196
+ };
197
+
198
+ // Preserve environment variables if they exist
199
+ if (serverConfig.env) {
200
+ unwrappedConfig.env = { ...serverConfig.env };
201
+ }
202
+
203
+ return unwrappedConfig;
204
+ }
205
+
206
+ /**
207
+ * Create a timestamped backup of the config file
208
+ * @param {string} configPath - Path to config file
209
+ * @returns {string} Path to backup file
210
+ */
211
+ function createBackup(configPath) {
212
+ const now = new Date();
213
+
214
+ // Format: YYYYMMDD-HHMMSS
215
+ const year = now.getFullYear();
216
+ const month = String(now.getMonth() + 1).padStart(2, '0');
217
+ const day = String(now.getDate()).padStart(2, '0');
218
+ const hour = String(now.getHours()).padStart(2, '0');
219
+ const minute = String(now.getMinutes()).padStart(2, '0');
220
+ const second = String(now.getSeconds()).padStart(2, '0');
221
+
222
+ const timestamp = `${year}${month}${day}-${hour}${minute}${second}`;
223
+ const backupPath = `${configPath}.backup-${timestamp}`;
224
+
225
+ copyFileSync(configPath, backupPath);
226
+
227
+ return backupPath;
228
+ }
229
+
230
+ /**
231
+ * Unwrap wrapped servers in the config
232
+ * @param {Object} config - Full config object with mcpServers
233
+ * @param {string[]} wrappedNames - Names of servers to unwrap
234
+ * @returns {Object} Modified config with unwrapped servers
235
+ */
236
+ function unwrapServers(config, wrappedNames) {
237
+ const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
238
+
239
+ // Get reference to mcpServers in the modified config
240
+ let mcpServers;
241
+ if (config.format === 'claude-code') {
242
+ mcpServers = modifiedConfig.projects.mcpServers;
243
+ } else {
244
+ mcpServers = modifiedConfig.mcpServers;
245
+ }
246
+
247
+ // Unwrap each wrapped server
248
+ for (const serverName of wrappedNames) {
249
+ const originalConfig = mcpServers[serverName];
250
+
251
+ try {
252
+ mcpServers[serverName] = unwrapServer(originalConfig);
253
+ } catch (error) {
254
+ console.warn(`Warning: Cannot unwrap ${serverName}: ${error.message}`);
255
+ // Skip this server, leave it as-is
256
+ }
257
+ }
258
+
259
+ return modifiedConfig;
260
+ }
261
+
262
+ /**
263
+ * Main entry point
264
+ */
265
+ async function main() {
266
+ const args = parseCliArgs();
267
+
268
+ if (args.help) {
269
+ showUsage();
270
+ }
271
+
272
+ validateArgs(args);
273
+
274
+ // Load config file
275
+ let config;
276
+ try {
277
+ config = loadConfig(args.config);
278
+ console.log(`Loaded config in ${config.format} format`);
279
+
280
+ // Count total servers across all projects
281
+ const totalServersCount = config.allMcpServers.reduce((sum, item) =>
282
+ sum + Object.keys(item.servers).length, 0);
283
+
284
+ if (config.format === 'multi-project') {
285
+ console.log(`Found ${config.allMcpServers.length} project(s) with ${totalServersCount} total MCP servers`);
286
+ } else {
287
+ console.log(`Found ${totalServersCount} MCP servers`);
288
+ }
289
+ } catch (error) {
290
+ console.error(`Error loading config: ${error.message}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ // Detect wrapped servers across ALL projects
295
+ let allWrapped = [];
296
+ let allUnwrapped = [];
297
+ let allMalformed = [];
298
+
299
+ for (const { path: projectPath, servers } of config.allMcpServers) {
300
+ const { wrapped, unwrapped, malformed } = detectWrappedServers(servers);
301
+ allWrapped.push(...wrapped.map(name => ({ project: projectPath, name })));
302
+ allUnwrapped.push(...unwrapped.map(name => ({ project: projectPath, name })));
303
+ allMalformed.push(...malformed.map(name => ({ project: projectPath, name })));
304
+ }
305
+
306
+ const totalServers = allWrapped.length + allUnwrapped.length + allMalformed.length;
307
+
308
+ if (totalServers === 0) {
309
+ console.log('No servers found in config');
310
+ } else {
311
+ console.log(`\nServer status (across all projects):`);
312
+ console.log(` Total: ${totalServers}`);
313
+ console.log(` Wrapped (can unwrap): ${allWrapped.length}`);
314
+ console.log(` Already unwrapped: ${allUnwrapped.length}`);
315
+
316
+ if (allMalformed.length > 0) {
317
+ console.log(` Warning - cannot unwrap (missing _original): ${allMalformed.length}`);
318
+ }
319
+
320
+ if (allUnwrapped.length > 0) {
321
+ console.log(`\nAlready unwrapped servers:`);
322
+ allUnwrapped.forEach(({ project, name }) => {
323
+ if (config.format === 'multi-project') {
324
+ console.log(` - ${name} (${project})`);
325
+ } else {
326
+ console.log(` - ${name}`);
327
+ }
328
+ });
329
+ }
330
+
331
+ if (allMalformed.length > 0) {
332
+ console.log(`\nWarning: These servers appear wrapped but are missing _original field (cannot unwrap):`);
333
+ allMalformed.forEach(({ project, name }) => {
334
+ if (config.format === 'multi-project') {
335
+ console.log(` - ${name} (${project})`);
336
+ } else {
337
+ console.log(` - ${name}`);
338
+ }
339
+ });
340
+ }
341
+
342
+ if (allWrapped.length > 0) {
343
+ console.log(`\nServers to unwrap:`);
344
+ allWrapped.forEach(({ project, name }) => {
345
+ if (config.format === 'multi-project') {
346
+ console.log(` - ${name} (${project})`);
347
+ } else {
348
+ console.log(` - ${name}`);
349
+ }
350
+ });
351
+ } else if (allMalformed.length === 0) {
352
+ console.log(`\nAll servers already unwrapped, no action needed`);
353
+ }
354
+ }
355
+
356
+ // Unwrap servers if needed
357
+ if (allWrapped.length > 0) {
358
+ console.log(`\nUnwrapping ${allWrapped.length} server(s)...`);
359
+
360
+ // Create backup before modifying
361
+ try {
362
+ const backupPath = createBackup(args.config);
363
+ console.log(`āœ“ Created backup: ${backupPath}`);
364
+ } catch (error) {
365
+ console.error(`Error creating backup: ${error.message}`);
366
+ process.exit(1);
367
+ }
368
+
369
+ // Unwrap servers in each project
370
+ const modifiedConfig = JSON.parse(JSON.stringify(config.rawConfig));
371
+
372
+ for (const { path: projectPath, servers } of config.allMcpServers) {
373
+ const { wrapped } = detectWrappedServers(servers);
374
+
375
+ if (wrapped.length > 0) {
376
+ // Get reference to this project's mcpServers
377
+ let targetServers;
378
+ if (config.format === 'multi-project') {
379
+ targetServers = modifiedConfig.projects[projectPath].mcpServers;
380
+ } else {
381
+ targetServers = modifiedConfig.mcpServers;
382
+ }
383
+
384
+ // Unwrap wrapped servers in this project
385
+ for (const serverName of wrapped) {
386
+ const originalConfig = targetServers[serverName];
387
+
388
+ try {
389
+ targetServers[serverName] = unwrapServer(originalConfig);
390
+ } catch (error) {
391
+ console.warn(`Warning: Cannot unwrap ${serverName}: ${error.message}`);
392
+ // Skip this server, leave it as-is
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // Write updated config
399
+ try {
400
+ writeFileSync(args.config, JSON.stringify(modifiedConfig, null, 2) + '\n');
401
+ console.log(`āœ“ Updated config file: ${args.config}`);
402
+ } catch (error) {
403
+ console.error(`Error writing config file: ${error.message}`);
404
+ process.exit(1);
405
+ }
406
+ }
407
+
408
+ // Execute tool command if provided
409
+ if (args.tool) {
410
+ console.log(`\nExecuting tool command: ${args.tool}`);
411
+ try {
412
+ const { stdout, stderr } = await execAsync(args.tool, {
413
+ shell: true,
414
+ encoding: 'utf8'
415
+ });
416
+
417
+ if (stdout) {
418
+ console.log(stdout);
419
+ }
420
+ if (stderr) {
421
+ console.error(stderr);
422
+ }
423
+ } catch (error) {
424
+ // exec throws on non-zero exit codes, but we still want to show output
425
+ if (error.stdout) {
426
+ console.log(error.stdout);
427
+ }
428
+ if (error.stderr) {
429
+ console.error(error.stderr);
430
+ }
431
+ console.error(`\nTool command exited with code ${error.code || 'unknown'}`);
432
+ process.exit(error.code || 1);
433
+ }
434
+ } else {
435
+ console.log(`\nāœ“ Unwrapping complete!`);
436
+ }
437
+ }
438
+
439
+ main().catch(error => {
440
+ console.error(`Fatal error: ${error.message}`);
441
+ process.exit(1);
442
+ });