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,362 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mcp-gov-proxy - MCP Governance Proxy
5
+ * Intercepts tool calls and checks permissions before forwarding to target MCP server
6
+ */
7
+
8
+ import { parseArgs } from 'node:util';
9
+ import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
10
+ import { spawn } from 'node:child_process';
11
+ import { createInterface } from 'node:readline';
12
+ import { dirname, join } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+ import { extractService, detectOperation } from '../src/operation-detector.js';
15
+
16
+ // Default audit log path
17
+ const DEFAULT_AUDIT_LOG = join(homedir(), '.mcp-gov', 'audit.log');
18
+
19
+ /**
20
+ * Parse command line arguments
21
+ * @returns {{ target: string, rules: string, service: string, log: string, help: boolean }}
22
+ */
23
+ function parseCliArgs() {
24
+ try {
25
+ const { values } = parseArgs({
26
+ options: {
27
+ service: {
28
+ type: 'string',
29
+ short: 's',
30
+ },
31
+ target: {
32
+ type: 'string',
33
+ short: 't',
34
+ },
35
+ rules: {
36
+ type: 'string',
37
+ short: 'r',
38
+ },
39
+ log: {
40
+ type: 'string',
41
+ short: 'l',
42
+ },
43
+ help: {
44
+ type: 'boolean',
45
+ short: 'h',
46
+ },
47
+ },
48
+ allowPositionals: false,
49
+ });
50
+
51
+ return values;
52
+ } catch (error) {
53
+ console.error(`Error parsing arguments: ${error.message}`);
54
+ process.exit(1);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Show usage information
60
+ */
61
+ function showUsage() {
62
+ console.log(`
63
+ Usage: mcp-gov-proxy [--service <name>] --target <command> --rules <rules.json> [--log <file>]
64
+
65
+ Options:
66
+ --service, -s Service name for rule matching (recommended, falls back to tool name prefix)
67
+ --target, -t Target MCP server command to wrap (required)
68
+ --rules, -r Path to rules.json file (required)
69
+ --log, -l Path to audit log file (optional, logs to file AND stderr)
70
+ --help, -h Show this help message
71
+
72
+ Description:
73
+ Intercepts MCP tool calls and checks permissions before forwarding to target server.
74
+ Provides audit logging and permission control based on rules.json.
75
+
76
+ IMPORTANT: Use --service to ensure correct rule matching. Without it, the service
77
+ name is extracted from tool name prefixes, which may not match your rules.
78
+
79
+ Examples:
80
+ mcp-gov-proxy --service filesystem --target "npx -y @modelcontextprotocol/server-filesystem" --rules rules.json
81
+ mcp-gov-proxy -s github -t "npx github-mcp" -r ./config/rules.json -l ~/.mcp-gov/audit.log
82
+ `);
83
+ }
84
+
85
+ /**
86
+ * Parse JSON-RPC message
87
+ * @param {string} line - Raw message line
88
+ * @returns {object|null} Parsed message or null if not valid JSON-RPC
89
+ */
90
+ function parseJsonRpcMessage(line) {
91
+ try {
92
+ const msg = JSON.parse(line);
93
+ if (msg.jsonrpc === '2.0' && msg.method) {
94
+ return msg;
95
+ }
96
+ return null;
97
+ } catch (e) {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Check if message is a tools/call method
104
+ * @param {object} message - Parsed JSON-RPC message
105
+ * @returns {boolean}
106
+ */
107
+ function isToolsCallMessage(message) {
108
+ return message && message.method === 'tools/call';
109
+ }
110
+
111
+ /**
112
+ * Load rules from JSON file
113
+ * @param {string} rulesPath - Path to rules.json
114
+ * @returns {object} Parsed rules object
115
+ */
116
+ function loadRules(rulesPath) {
117
+ try {
118
+ const rulesContent = readFileSync(rulesPath, 'utf-8');
119
+ return JSON.parse(rulesContent);
120
+ } catch (error) {
121
+ console.error(`Error loading rules file: ${error.message}`);
122
+ process.exit(1);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Check if operation is allowed based on rules
128
+ * @param {object} rules - Loaded rules object
129
+ * @param {string} service - Service name
130
+ * @param {string} operation - Operation type
131
+ * @returns {boolean} True if allowed, false if denied
132
+ */
133
+ function isOperationAllowed(rules, service, operation) {
134
+ // Support two rule formats:
135
+ // 1. Array format (from mcp-gov-wrap): { rules: [{service, operations[], permission}] }
136
+ // 2. Object format (legacy): { services: {service: {operations: {op: permission}}} }
137
+
138
+ // Try array format first (generated by mcp-gov-wrap)
139
+ if (rules.rules && Array.isArray(rules.rules)) {
140
+ for (const rule of rules.rules) {
141
+ if (rule.service === service && rule.operations && rule.operations.includes(operation)) {
142
+ return rule.permission !== 'deny';
143
+ }
144
+ }
145
+ // No matching rule found - default to allow
146
+ return true;
147
+ }
148
+
149
+ // Try object format (legacy)
150
+ if (!rules.services || !rules.services[service]) {
151
+ return true;
152
+ }
153
+
154
+ const serviceRules = rules.services[service];
155
+ if (!serviceRules.operations || !serviceRules.operations[operation]) {
156
+ return true;
157
+ }
158
+
159
+ const permission = serviceRules.operations[operation];
160
+ return permission !== 'deny';
161
+ }
162
+
163
+ /**
164
+ * Create a JSON-RPC error response
165
+ * @param {number|string} id - Request ID
166
+ * @param {string} message - Error message
167
+ * @returns {string} JSON-RPC error response
168
+ */
169
+ function createErrorResponse(id, message) {
170
+ const response = {
171
+ jsonrpc: '2.0',
172
+ id: id,
173
+ error: {
174
+ code: -32000,
175
+ message: message
176
+ }
177
+ };
178
+ return JSON.stringify(response);
179
+ }
180
+
181
+ /** @type {string|null} */
182
+ let auditLogPath = null;
183
+
184
+ /**
185
+ * Log audit information to stderr and optionally to file
186
+ * @param {string} toolName - Tool name
187
+ * @param {string} service - Service name
188
+ * @param {string} operation - Operation type
189
+ * @param {boolean} allowed - Whether operation was allowed
190
+ */
191
+ function logAudit(toolName, service, operation, allowed) {
192
+ const timestamp = new Date().toISOString();
193
+ const status = allowed ? 'ALLOWED' : 'DENIED';
194
+ const projectPath = process.cwd();
195
+ const logLine = `[AUDIT] ${timestamp} | ${status} | tool=${toolName} | service=${service} | operation=${operation} | project=${projectPath}`;
196
+
197
+ // Always log to stderr
198
+ console.error(logLine);
199
+
200
+ // Also log to file if configured
201
+ if (auditLogPath) {
202
+ try {
203
+ appendFileSync(auditLogPath, logLine + '\n');
204
+ } catch (e) {
205
+ console.error(`[AUDIT] Warning: Failed to write to log file: ${e.message}`);
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Start the proxy server
212
+ * @param {string} serviceName - Service name for rule matching
213
+ * @param {string} targetCommand - Command to spawn target MCP server
214
+ * @param {string} rulesPath - Path to rules.json file
215
+ * @param {string} logPath - Path to audit log file (optional override)
216
+ */
217
+ function startProxy(serviceName, targetCommand, rulesPath, logPath) {
218
+ // Set up audit logging - organize by service
219
+ // Default: ~/.mcp-gov/logs/<service>.log
220
+ if (logPath) {
221
+ auditLogPath = logPath;
222
+ } else {
223
+ const logDir = join(homedir(), '.mcp-gov', 'logs');
224
+ const serviceLogName = serviceName || 'unknown';
225
+ auditLogPath = join(logDir, `${serviceLogName}.log`);
226
+ }
227
+
228
+ // Ensure log directory exists
229
+ const logDir = dirname(auditLogPath);
230
+ if (!existsSync(logDir)) {
231
+ mkdirSync(logDir, { recursive: true });
232
+ }
233
+
234
+ // Load rules file
235
+ const rules = loadRules(rulesPath);
236
+
237
+ // Parse the target command (handle both "node server.js" and single commands)
238
+ const commandParts = targetCommand.split(/\s+/);
239
+ const command = commandParts[0];
240
+ const args = commandParts.slice(1);
241
+
242
+ // Spawn the target MCP server
243
+ const targetServer = spawn(command, args, {
244
+ stdio: ['pipe', 'pipe', 'pipe'],
245
+ });
246
+
247
+ // Log to stderr that proxy is ready
248
+ console.error('Proxy ready');
249
+
250
+ // Set up readline interface for line-by-line processing from stdin
251
+ const rl = createInterface({
252
+ input: process.stdin,
253
+ terminal: false
254
+ });
255
+
256
+ // Set up readline interface for target server stdout
257
+ const targetRl = createInterface({
258
+ input: targetServer.stdout,
259
+ terminal: false
260
+ });
261
+
262
+ // Forward stderr from target server to our stderr
263
+ targetServer.stderr.on('data', (data) => {
264
+ process.stderr.write(data);
265
+ });
266
+
267
+ // Forward stdout from target server to our stdout (line by line)
268
+ targetRl.on('line', (line) => {
269
+ // For now, just forward everything (interception logic comes in later tasks)
270
+ console.log(line);
271
+ });
272
+
273
+ // Process stdin messages
274
+ rl.on('line', (line) => {
275
+ // Parse JSON-RPC message
276
+ const message = parseJsonRpcMessage(line);
277
+
278
+ if (isToolsCallMessage(message)) {
279
+ // Extract tool name from params
280
+ const toolName = message.params?.name;
281
+
282
+ if (toolName) {
283
+ // Use provided service name, fallback to extracting from tool name for backward compatibility
284
+ const service = serviceName || extractService(toolName);
285
+ const operation = detectOperation(toolName);
286
+
287
+ // Check permissions
288
+ const allowed = isOperationAllowed(rules, service, operation);
289
+
290
+ // Log audit information
291
+ logAudit(toolName, service, operation, allowed);
292
+
293
+ if (allowed) {
294
+ // Allowed - forward to target server
295
+ targetServer.stdin.write(line + '\n');
296
+ } else {
297
+ // Denied - send error response
298
+ const errorResponse = createErrorResponse(
299
+ message.id,
300
+ `[MCP-GOV] Permission denied: ${service}.${operation} operation on tool ${toolName}`
301
+ );
302
+ console.log(errorResponse);
303
+ }
304
+ } else {
305
+ // No tool name, forward anyway
306
+ targetServer.stdin.write(line + '\n');
307
+ }
308
+ } else {
309
+ // Forward non-tools/call messages directly
310
+ targetServer.stdin.write(line + '\n');
311
+ }
312
+ });
313
+
314
+ // Handle target server exit
315
+ targetServer.on('close', (code) => {
316
+ console.error(`Target server exited with code ${code}`);
317
+ process.exit(code || 0);
318
+ });
319
+
320
+ // Handle target server errors
321
+ targetServer.on('error', (error) => {
322
+ console.error(`Error spawning target server: ${error.message}`);
323
+ process.exit(1);
324
+ });
325
+
326
+ // Handle proxy termination
327
+ process.on('SIGTERM', () => {
328
+ targetServer.kill('SIGTERM');
329
+ });
330
+
331
+ process.on('SIGINT', () => {
332
+ targetServer.kill('SIGINT');
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Main entry point
338
+ */
339
+ function main() {
340
+ const args = parseCliArgs();
341
+
342
+ if (args.help) {
343
+ showUsage();
344
+ process.exit(0);
345
+ }
346
+
347
+ if (!args.target) {
348
+ console.error('Error: --target is required');
349
+ console.error('Run with --help for usage information');
350
+ process.exit(1);
351
+ }
352
+
353
+ if (!args.rules) {
354
+ console.error('Error: --rules is required');
355
+ console.error('Run with --help for usage information');
356
+ process.exit(1);
357
+ }
358
+
359
+ startProxy(args.service, args.target, args.rules, args.log);
360
+ }
361
+
362
+ main();