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,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();
|