@permission-protocol/mcp-guard 0.1.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.
Files changed (59) hide show
  1. package/README.md +224 -0
  2. package/assets/demo.gif +0 -0
  3. package/bin/mcp-guard +2 -0
  4. package/demo/_demo-scenario.sh +43 -0
  5. package/dist/example/server.d.ts +1 -0
  6. package/dist/example/server.js +54 -0
  7. package/dist/example/server.js.map +1 -0
  8. package/dist/src/approval-server.d.ts +4 -0
  9. package/dist/src/approval-server.js +128 -0
  10. package/dist/src/approval-server.js.map +1 -0
  11. package/dist/src/approval-ui.d.ts +1 -0
  12. package/dist/src/approval-ui.js +158 -0
  13. package/dist/src/approval-ui.js.map +1 -0
  14. package/dist/src/cli.d.ts +1 -0
  15. package/dist/src/cli.js +82 -0
  16. package/dist/src/cli.js.map +1 -0
  17. package/dist/src/config.d.ts +13 -0
  18. package/dist/src/config.js +48 -0
  19. package/dist/src/config.js.map +1 -0
  20. package/dist/src/engine.d.ts +7 -0
  21. package/dist/src/engine.js +25 -0
  22. package/dist/src/engine.js.map +1 -0
  23. package/dist/src/pending.d.ts +20 -0
  24. package/dist/src/pending.js +44 -0
  25. package/dist/src/pending.js.map +1 -0
  26. package/dist/src/proxy.d.ts +2 -0
  27. package/dist/src/proxy.js +147 -0
  28. package/dist/src/proxy.js.map +1 -0
  29. package/dist/src/receipt.d.ts +15 -0
  30. package/dist/src/receipt.js +34 -0
  31. package/dist/src/receipt.js.map +1 -0
  32. package/dist/src/tests/config.test.d.ts +1 -0
  33. package/dist/src/tests/config.test.js +63 -0
  34. package/dist/src/tests/config.test.js.map +1 -0
  35. package/dist/src/tests/engine.test.d.ts +1 -0
  36. package/dist/src/tests/engine.test.js +46 -0
  37. package/dist/src/tests/engine.test.js.map +1 -0
  38. package/dist/src/tests/receipt.test.d.ts +1 -0
  39. package/dist/src/tests/receipt.test.js +44 -0
  40. package/dist/src/tests/receipt.test.js.map +1 -0
  41. package/docs/index.html +771 -0
  42. package/example/README.md +38 -0
  43. package/example/pp.config.yaml +15 -0
  44. package/example/server.ts +52 -0
  45. package/example/test.sh +64 -0
  46. package/package.json +29 -0
  47. package/permission-protocol-mcp-guard-0.1.0.tgz +0 -0
  48. package/src/approval-server.ts +136 -0
  49. package/src/approval-ui.ts +154 -0
  50. package/src/cli.ts +84 -0
  51. package/src/config.ts +68 -0
  52. package/src/engine.ts +31 -0
  53. package/src/pending.ts +65 -0
  54. package/src/proxy.ts +173 -0
  55. package/src/receipt.ts +52 -0
  56. package/src/tests/config.test.ts +61 -0
  57. package/src/tests/engine.test.ts +47 -0
  58. package/src/tests/receipt.test.ts +44 -0
  59. package/tsconfig.json +16 -0
@@ -0,0 +1,38 @@
1
+ # MCP Guard Example
2
+
3
+ Run this demo in 2 minutes.
4
+
5
+ ## Prerequisites
6
+
7
+ - Node.js 20+
8
+ - npm
9
+
10
+ ## Steps
11
+
12
+ ```bash
13
+ # 1. Install dependencies
14
+ cd /path/to/mcp-guard
15
+ npm install
16
+
17
+ # 2. Build
18
+ npm run build
19
+
20
+ # 3. Run the demo
21
+ bash example/test.sh
22
+ ```
23
+
24
+ ## What happens
25
+
26
+ The demo sends three `tools/call` requests through `mcp-guard`:
27
+
28
+ | Tool | Config Rule | Result |
29
+ |------|-----------|--------|
30
+ | `send_email` | allow | ✅ Forwarded to server, response returned |
31
+ | `delete_user_data` | block | 🚫 Blocked, JSON-RPC error returned |
32
+ | `deploy_production` | require_approval | ⏸️ Held, JSON-RPC error returned |
33
+
34
+ Each decision generates a receipt written to stderr and `pp-receipts.jsonl`.
35
+
36
+ ## Config
37
+
38
+ See `pp.config.yaml` for the policy rules used in this demo.
@@ -0,0 +1,15 @@
1
+ default_action: allow
2
+ mode: enforce # or "observe" to log decisions without blocking
3
+
4
+ rules:
5
+ - id: allow-company-email
6
+ tool: send_email
7
+ action: allow
8
+
9
+ - id: block-delete-user-data
10
+ tool: delete_user_data
11
+ action: block
12
+
13
+ - id: require-approval-prod-deploy
14
+ tool: deploy_production
15
+ action: require_approval
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Tiny MCP server for testing mcp-guard.
3
+ * Responds to tools/call, tools/list, and initialize.
4
+ * Newline-delimited JSON-RPC over stdio.
5
+ */
6
+ import { createInterface } from 'node:readline';
7
+
8
+ const TOOLS = [
9
+ { name: 'send_email', description: 'Send an email', inputSchema: { type: 'object', properties: { to: { type: 'string' }, body: { type: 'string' } } } },
10
+ { name: 'delete_user_data', description: 'Delete all user data', inputSchema: { type: 'object', properties: { user_id: { type: 'string' } } } },
11
+ { name: 'deploy_production', description: 'Deploy to production', inputSchema: { type: 'object', properties: { version: { type: 'string' } } } },
12
+ ];
13
+
14
+ const rl = createInterface({ input: process.stdin });
15
+
16
+ rl.on('line', (line) => {
17
+ if (!line.trim()) return;
18
+
19
+ let msg: any;
20
+ try {
21
+ msg = JSON.parse(line);
22
+ } catch {
23
+ return;
24
+ }
25
+
26
+ if (msg.method === 'initialize') {
27
+ respond(msg.id, {
28
+ protocolVersion: '2024-11-05',
29
+ capabilities: { tools: {} },
30
+ serverInfo: { name: 'example-server', version: '0.1.0' },
31
+ });
32
+ } else if (msg.method === 'tools/list') {
33
+ respond(msg.id, { tools: TOOLS });
34
+ } else if (msg.method === 'tools/call') {
35
+ const toolName = msg.params?.name ?? 'unknown';
36
+ respond(msg.id, {
37
+ content: [{ type: 'text', text: `Executed ${toolName} successfully` }],
38
+ });
39
+ } else if (msg.method === 'notifications/initialized') {
40
+ // No response needed for notifications
41
+ } else {
42
+ respondError(msg.id, -32601, `Method not found: ${msg.method}`);
43
+ }
44
+ });
45
+
46
+ function respond(id: any, result: any) {
47
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
48
+ }
49
+
50
+ function respondError(id: any, code: number, message: string) {
51
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
52
+ }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ ROOT_DIR="$(dirname "$SCRIPT_DIR")"
6
+
7
+ echo "=== MCP Guard Demo ==="
8
+ echo ""
9
+ echo "Building..."
10
+ cd "$ROOT_DIR"
11
+ npm run build --silent 2>/dev/null
12
+
13
+ echo "Sending test requests through mcp-guard proxy..."
14
+ echo ""
15
+
16
+ # Create a temp file for responses
17
+ RESPONSES=$(mktemp)
18
+ RECEIPTS=$(mktemp)
19
+
20
+ # Send three tool calls through the proxy
21
+ {
22
+ # 1. Initialize
23
+ echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}}}'
24
+ sleep 0.3
25
+
26
+ # 2. Allowed: send_email
27
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"send_email","arguments":{"to":"user@example.com","body":"Hello!"}}}'
28
+ sleep 0.3
29
+
30
+ # 3. Blocked: delete_user_data
31
+ echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"delete_user_data","arguments":{"user_id":"123"}}}'
32
+ sleep 0.3
33
+
34
+ # 4. Held: deploy_production
35
+ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy_production","arguments":{"version":"1.0.0"}}}'
36
+ sleep 0.3
37
+ } | node dist/src/cli.js --config example/pp.config.yaml --agent-id demo-agent -- node dist/example/server.js > "$RESPONSES" 2>"$RECEIPTS"
38
+
39
+ echo "--- Responses (stdout) ---"
40
+ cat "$RESPONSES" | while IFS= read -r line; do
41
+ echo "$line" | python3 -m json.tool 2>/dev/null || echo "$line"
42
+ done
43
+
44
+ echo ""
45
+ echo "--- Receipts (stderr) ---"
46
+ grep "receipt:" "$RECEIPTS" | sed 's/\[mcp-guard\] receipt: //' | while IFS= read -r line; do
47
+ echo "$line" | python3 -m json.tool 2>/dev/null || echo "$line"
48
+ done
49
+
50
+ echo ""
51
+ echo "--- Guard Log ---"
52
+ grep -v "receipt:" "$RECEIPTS" || true
53
+
54
+ rm -f "$RESPONSES" "$RECEIPTS"
55
+ echo ""
56
+ echo "=== Approval Flow ==="
57
+ echo ""
58
+ echo "To try the approval UI, run:"
59
+ echo " node dist/src/cli.js --config example/pp.config.yaml --approval-port 3100 --agent-id demo-agent -- node dist/example/server.js"
60
+ echo ""
61
+ echo "Then open http://localhost:3100 and send a require_approval tool call."
62
+ echo "The proxy will hold the response until you approve or deny in the UI."
63
+ echo ""
64
+ echo "Done!"
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@permission-protocol/mcp-guard",
3
+ "version": "0.1.0",
4
+ "description": "MCP Guard blocks dangerous AI agent actions using a simple YAML config file.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "main": "dist/src/proxy.js",
9
+ "bin": {
10
+ "mcp-guard": "bin/mcp-guard"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "node --test dist/src/tests/*.test.js",
15
+ "demo": "npm run build && bash example/test.sh",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "keywords": ["mcp", "guard", "proxy", "ai", "safety", "permission-protocol"],
19
+ "license": "MIT",
20
+ "dependencies": {
21
+ "yaml": "^2.7.0",
22
+ "uuid": "^11.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "@types/uuid": "^10.0.0",
27
+ "typescript": "^5.7.0"
28
+ }
29
+ }
@@ -0,0 +1,136 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
2
+ import { getPending, getAction, resolveAction, recentResolved } from './pending.js';
3
+ import { getApprovalHTML } from './approval-ui.js';
4
+ import type { Receipt } from './receipt.js';
5
+
6
+ /** Recent receipts store (ring buffer) */
7
+ const recentReceipts: Receipt[] = [];
8
+ const MAX_RECEIPTS = 50;
9
+
10
+ export function pushReceipt(receipt: Receipt): void {
11
+ recentReceipts.push(receipt);
12
+ if (recentReceipts.length > MAX_RECEIPTS) recentReceipts.shift();
13
+ }
14
+
15
+ /** Callback invoked when an action is approved — proxy registers this */
16
+ let onApproveCallback: ((id: string) => void) | null = null;
17
+
18
+ export function setOnApprove(cb: (id: string) => void): void {
19
+ onApproveCallback = cb;
20
+ }
21
+
22
+ function parseBody(req: IncomingMessage): Promise<string> {
23
+ return new Promise((resolve, reject) => {
24
+ const chunks: Buffer[] = [];
25
+ req.on('data', (c: Buffer) => chunks.push(c));
26
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
27
+ req.on('error', reject);
28
+ });
29
+ }
30
+
31
+ function json(res: ServerResponse, status: number, data: unknown): void {
32
+ const body = JSON.stringify(data);
33
+ res.writeHead(status, {
34
+ 'Content-Type': 'application/json',
35
+ 'Access-Control-Allow-Origin': '*',
36
+ });
37
+ res.end(body);
38
+ }
39
+
40
+ function text(res: ServerResponse, status: number, msg: string): void {
41
+ res.writeHead(status, { 'Content-Type': 'text/plain' });
42
+ res.end(msg);
43
+ }
44
+
45
+ export function startApprovalServer(port: number): void {
46
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
47
+ const url = req.url ?? '/';
48
+ const method = req.method ?? 'GET';
49
+
50
+ // CORS preflight
51
+ if (method === 'OPTIONS') {
52
+ res.writeHead(204, {
53
+ 'Access-Control-Allow-Origin': '*',
54
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
55
+ 'Access-Control-Allow-Headers': 'Content-Type',
56
+ });
57
+ res.end();
58
+ return;
59
+ }
60
+
61
+ try {
62
+ // GET / — Approval UI
63
+ if (method === 'GET' && url === '/') {
64
+ res.writeHead(200, { 'Content-Type': 'text/html' });
65
+ res.end(getApprovalHTML());
66
+ return;
67
+ }
68
+
69
+ // GET /api/pending
70
+ if (method === 'GET' && url === '/api/pending') {
71
+ const pending = getPending().map(p => ({
72
+ id: p.id,
73
+ timestamp: p.timestamp,
74
+ tool_name: p.tool_name,
75
+ tool_args: p.tool_args,
76
+ agent_id: p.agent_id,
77
+ rule_id: p.rule_id,
78
+ status: p.status,
79
+ }));
80
+ json(res, 200, pending);
81
+ return;
82
+ }
83
+
84
+ // POST /api/approve/:id
85
+ const approveMatch = url.match(/^\/api\/approve\/([a-f0-9-]+)$/);
86
+ if (method === 'POST' && approveMatch) {
87
+ const id = approveMatch[1];
88
+ const action = resolveAction(id, 'approved');
89
+ if (!action) {
90
+ json(res, 404, { error: 'Not found or already resolved' });
91
+ return;
92
+ }
93
+ process.stderr.write(`[mcp-guard] Approved: ${action.tool_name} (${id})\n`);
94
+ // Notify proxy to forward the original request
95
+ if (onApproveCallback) onApproveCallback(id);
96
+ json(res, 200, { id, status: 'approved' });
97
+ return;
98
+ }
99
+
100
+ // POST /api/deny/:id
101
+ const denyMatch = url.match(/^\/api\/deny\/([a-f0-9-]+)$/);
102
+ if (method === 'POST' && denyMatch) {
103
+ const id = denyMatch[1];
104
+ const action = resolveAction(id, 'denied');
105
+ if (!action) {
106
+ json(res, 404, { error: 'Not found or already resolved' });
107
+ return;
108
+ }
109
+ process.stderr.write(`[mcp-guard] Denied: ${action.tool_name} (${id})\n`);
110
+ // Return JSON-RPC error to the waiting caller
111
+ const errResp = JSON.stringify({
112
+ jsonrpc: '2.0',
113
+ id: action.jsonrpcId,
114
+ error: { code: -32002, message: 'Denied by administrator' },
115
+ });
116
+ action.resolve(errResp);
117
+ json(res, 200, { id, status: 'denied' });
118
+ return;
119
+ }
120
+
121
+ // GET /api/receipts
122
+ if (method === 'GET' && url === '/api/receipts') {
123
+ json(res, 200, recentReceipts.slice(-50).reverse());
124
+ return;
125
+ }
126
+
127
+ text(res, 404, 'Not found');
128
+ } catch (err: any) {
129
+ text(res, 500, err.message ?? 'Internal error');
130
+ }
131
+ });
132
+
133
+ server.listen(port, () => {
134
+ process.stderr.write(`[mcp-guard] Approval UI: http://localhost:${port}\n`);
135
+ });
136
+ }
@@ -0,0 +1,154 @@
1
+ export function getApprovalHTML(): string {
2
+ return `<!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <title>MCP Guard — Approval Queue</title>
8
+ <style>
9
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
12
+ background: #0d1117; color: #c9d1d9; line-height: 1.5;
13
+ padding: 24px; max-width: 900px; margin: 0 auto;
14
+ }
15
+ h1 { color: #58a6ff; font-size: 24px; margin-bottom: 4px; }
16
+ .subtitle { color: #8b949e; font-size: 14px; margin-bottom: 24px; }
17
+ .badge {
18
+ display: inline-block; padding: 2px 8px; border-radius: 12px;
19
+ font-size: 12px; font-weight: 600; text-transform: uppercase;
20
+ }
21
+ .badge-pending { background: #d29922; color: #0d1117; }
22
+ .badge-approved { background: #238636; color: #fff; }
23
+ .badge-denied { background: #da3633; color: #fff; }
24
+ .badge-allowed { background: #238636; color: #fff; }
25
+ .badge-blocked { background: #da3633; color: #fff; }
26
+ .badge-held_for_approval { background: #d29922; color: #0d1117; }
27
+ .card {
28
+ border: 1px solid #30363d; border-radius: 8px; padding: 16px;
29
+ margin-bottom: 12px; background: #161b22;
30
+ }
31
+ .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
32
+ .tool-name { font-size: 18px; font-weight: 700; color: #f0f6fc; }
33
+ .meta { font-size: 13px; color: #8b949e; margin-bottom: 8px; }
34
+ .meta span { margin-right: 16px; }
35
+ .args-toggle {
36
+ background: none; border: 1px solid #30363d; color: #58a6ff;
37
+ padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
38
+ margin-bottom: 8px;
39
+ }
40
+ .args-toggle:hover { border-color: #58a6ff; }
41
+ .args-content {
42
+ display: none; background: #0d1117; border: 1px solid #30363d;
43
+ border-radius: 4px; padding: 12px; margin-bottom: 12px;
44
+ font-family: 'SF Mono', Consolas, monospace; font-size: 13px;
45
+ overflow-x: auto; white-space: pre-wrap; word-break: break-all;
46
+ }
47
+ .args-content.open { display: block; }
48
+ .actions { display: flex; gap: 8px; }
49
+ .btn {
50
+ padding: 6px 16px; border-radius: 6px; border: none;
51
+ font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.15s;
52
+ }
53
+ .btn:hover { opacity: 0.85; }
54
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
55
+ .btn-approve { background: #238636; color: #fff; }
56
+ .btn-deny { background: #da3633; color: #fff; }
57
+ .empty {
58
+ text-align: center; padding: 40px; color: #8b949e;
59
+ border: 1px dashed #30363d; border-radius: 8px; margin-bottom: 24px;
60
+ }
61
+ h2 { color: #c9d1d9; font-size: 18px; margin: 24px 0 12px; }
62
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
63
+ th { text-align: left; padding: 8px; border-bottom: 2px solid #30363d; color: #8b949e; font-weight: 600; }
64
+ td { padding: 8px; border-bottom: 1px solid #21262d; }
65
+ .pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #3fb950; margin-right: 8px; animation: pulse 2s infinite; }
66
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
67
+ .status-bar { display: flex; align-items: center; font-size: 12px; color: #8b949e; margin-bottom: 16px; }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <h1>MCP Guard — Approval Queue</h1>
72
+ <p class="subtitle">Human-in-the-loop approval for AI tool calls</p>
73
+ <div class="status-bar"><span class="pulse"></span> Polling every 3s</div>
74
+
75
+ <div id="pending"></div>
76
+ <h2>Recent Decisions</h2>
77
+ <div id="receipts"></div>
78
+
79
+ <script>
80
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
81
+
82
+ function toggleArgs(btn) {
83
+ const c = btn.nextElementSibling;
84
+ c.classList.toggle('open');
85
+ btn.textContent = c.classList.contains('open') ? '▾ Hide arguments' : '▸ Show arguments';
86
+ }
87
+
88
+ async function act(id, action) {
89
+ const btns = document.querySelectorAll('[data-id="'+id+'"]');
90
+ btns.forEach(b => b.disabled = true);
91
+ try {
92
+ const r = await fetch('/api/' + action + '/' + id, { method: 'POST' });
93
+ if (!r.ok) { const e = await r.text(); alert('Error: ' + e); }
94
+ } catch(e) { alert('Network error'); }
95
+ refresh();
96
+ }
97
+
98
+ async function refresh() {
99
+ try {
100
+ const [pRes, rRes] = await Promise.all([
101
+ fetch('/api/pending'), fetch('/api/receipts')
102
+ ]);
103
+ const pending = await pRes.json();
104
+ const receipts = await rRes.json();
105
+
106
+ const pEl = document.getElementById('pending');
107
+ if (pending.length === 0) {
108
+ pEl.innerHTML = '<div class="empty">No pending approvals</div>';
109
+ } else {
110
+ pEl.innerHTML = pending.map(p => \`
111
+ <div class="card">
112
+ <div class="card-header">
113
+ <span class="tool-name">\${esc(p.tool_name)}</span>
114
+ <span class="badge badge-pending">pending</span>
115
+ </div>
116
+ <div class="meta">
117
+ <span>Agent: <strong>\${esc(p.agent_id)}</strong></span>
118
+ <span>Rule: <strong>\${esc(p.rule_id)}</strong></span>
119
+ <span>\${esc(new Date(p.timestamp).toLocaleTimeString())}</span>
120
+ </div>
121
+ <button class="args-toggle" onclick="toggleArgs(this)">▸ Show arguments</button>
122
+ <div class="args-content">\${esc(JSON.stringify(p.tool_args, null, 2))}</div>
123
+ <div class="actions">
124
+ <button class="btn btn-approve" data-id="\${p.id}" onclick="act('\${p.id}','approve')">✅ Approve</button>
125
+ <button class="btn btn-deny" data-id="\${p.id}" onclick="act('\${p.id}','deny')">❌ Deny</button>
126
+ </div>
127
+ </div>
128
+ \`).join('');
129
+ }
130
+
131
+ const rEl = document.getElementById('receipts');
132
+ if (receipts.length === 0) {
133
+ rEl.innerHTML = '<div class="empty">No recent decisions</div>';
134
+ } else {
135
+ rEl.innerHTML = \`<table>
136
+ <thead><tr><th>Time</th><th>Tool</th><th>Decision</th><th>Agent</th><th>Rule</th></tr></thead>
137
+ <tbody>\${receipts.map(r => \`<tr>
138
+ <td>\${esc(new Date(r.timestamp).toLocaleTimeString())}</td>
139
+ <td><strong>\${esc(r.tool_name)}</strong></td>
140
+ <td><span class="badge badge-\${r.decision}">\${esc(r.decision)}</span></td>
141
+ <td>\${esc(r.agent_id)}</td>
142
+ <td>\${esc(r.rule_id || '—')}</td>
143
+ </tr>\`).join('')}</tbody>
144
+ </table>\`;
145
+ }
146
+ } catch(e) { /* retry next cycle */ }
147
+ }
148
+
149
+ refresh();
150
+ setInterval(refresh, 3000);
151
+ </script>
152
+ </body>
153
+ </html>`;
154
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { loadConfig } from './config.js';
2
+ import { startProxy } from './proxy.js';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { dirname } from 'node:path';
7
+
8
+ export function main(): void {
9
+ const args = process.argv.slice(2);
10
+
11
+ if (args[0] === 'demo') {
12
+ // When running via npx @permission-protocol/mcp-guard demo
13
+ const scriptPath = join(__dirname, '../../example/test.sh');
14
+ const result = spawnSync('bash', [scriptPath], { stdio: 'inherit' });
15
+ process.exit(result.status ?? 0);
16
+ }
17
+
18
+ let configPath = './pp.config.yaml';
19
+ let agentId = 'unknown';
20
+ let modeOverride: 'enforce' | 'observe' | undefined;
21
+ let approvalPort: number | undefined;
22
+ let serverCommand: string[] = [];
23
+
24
+ const dashDashIndex = args.indexOf('--');
25
+ const cliArgs = dashDashIndex >= 0 ? args.slice(0, dashDashIndex) : args;
26
+ serverCommand = dashDashIndex >= 0 ? args.slice(dashDashIndex + 1) : [];
27
+
28
+ for (let i = 0; i < cliArgs.length; i++) {
29
+ if (cliArgs[i] === '--config' && cliArgs[i + 1]) {
30
+ configPath = cliArgs[++i];
31
+ } else if (cliArgs[i] === '--agent-id' && cliArgs[i + 1]) {
32
+ agentId = cliArgs[++i];
33
+ } else if (cliArgs[i] === '--mode' && cliArgs[i + 1]) {
34
+ const m = cliArgs[++i];
35
+ if (m === 'enforce' || m === 'observe') modeOverride = m;
36
+ } else if (cliArgs[i] === '--approval-port' && cliArgs[i + 1]) {
37
+ approvalPort = parseInt(cliArgs[++i], 10);
38
+ if (isNaN(approvalPort)) {
39
+ process.stderr.write('[mcp-guard] Error: --approval-port must be a number\n');
40
+ process.exit(1);
41
+ }
42
+ } else if (cliArgs[i] === '--help' || cliArgs[i] === '-h') {
43
+ printHelp();
44
+ process.exit(0);
45
+ }
46
+ }
47
+
48
+ if (serverCommand.length === 0) {
49
+ process.stderr.write('[mcp-guard] Error: No server command. Use: mcp-guard [options] -- <server command>\n');
50
+ process.exit(1);
51
+ }
52
+
53
+ const config = loadConfig(configPath);
54
+ if (modeOverride) config.mode = modeOverride;
55
+ process.stderr.write(`[mcp-guard] Loaded ${config.rules.length} rules (default: ${config.default_action}, mode: ${config.mode})\n`);
56
+ process.stderr.write(`[mcp-guard] Starting server: ${serverCommand.join(' ')}\n`);
57
+
58
+ startProxy(config, agentId, serverCommand, approvalPort);
59
+ }
60
+
61
+ function printHelp(): void {
62
+ const help = `
63
+ mcp-guard — MCP tool-call policy proxy
64
+
65
+ Usage:
66
+ mcp-guard [options] -- <server command>
67
+
68
+ Options:
69
+ --config <path> Path to config file (default: ./pp.config.yaml)
70
+ --agent-id <id> Agent identifier for receipts (default: "unknown")
71
+ --mode <mode> enforce or observe (overrides config; default: enforce)
72
+ --approval-port <port> Enable approval UI on this port (e.g. 3100)
73
+ -h, --help Show this help
74
+
75
+ Example:
76
+ mcp-guard --config pp.config.yaml -- node my-mcp-server.js
77
+
78
+ # With approval UI:
79
+ mcp-guard --config pp.config.yaml --approval-port 3100 -- node my-mcp-server.js
80
+ `.trim();
81
+ console.log(help);
82
+ }
83
+
84
+ main();
package/src/config.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse } from 'yaml';
3
+
4
+ export type Action = 'allow' | 'block' | 'require_approval';
5
+
6
+ export interface Rule {
7
+ id: string;
8
+ tool: string;
9
+ action: Action;
10
+ }
11
+
12
+ export type Mode = 'enforce' | 'observe';
13
+
14
+ export interface Config {
15
+ default_action: Action;
16
+ mode: Mode;
17
+ rules: Rule[];
18
+ }
19
+
20
+ const VALID_ACTIONS: Action[] = ['allow', 'block', 'require_approval'];
21
+
22
+ export function loadConfig(configPath: string): Config {
23
+ let raw: string;
24
+ try {
25
+ raw = readFileSync(configPath, 'utf-8');
26
+ } catch (err: any) {
27
+ throw new Error(`Failed to read config file: ${configPath} (${err.code ?? err.message})`);
28
+ }
29
+
30
+ const parsed = parse(raw);
31
+
32
+ if (!parsed || typeof parsed !== 'object') {
33
+ throw new Error('Config must be a YAML object');
34
+ }
35
+
36
+ if (!VALID_ACTIONS.includes(parsed.default_action)) {
37
+ throw new Error(`Invalid default_action: "${parsed.default_action}". Must be one of: ${VALID_ACTIONS.join(', ')}`);
38
+ }
39
+
40
+ const VALID_MODES: Mode[] = ['enforce', 'observe'];
41
+ const mode: Mode = parsed.mode ?? 'enforce';
42
+ if (!VALID_MODES.includes(mode)) {
43
+ throw new Error(`Invalid mode: "${parsed.mode}". Must be one of: ${VALID_MODES.join(', ')}`);
44
+ }
45
+
46
+ if (!Array.isArray(parsed.rules)) {
47
+ throw new Error('Config must include a "rules" array');
48
+ }
49
+
50
+ const rules: Rule[] = parsed.rules.map((r: any, i: number) => {
51
+ if (!r.id || typeof r.id !== 'string') {
52
+ throw new Error(`Rule ${i} missing valid "id"`);
53
+ }
54
+ if (!r.tool || typeof r.tool !== 'string') {
55
+ throw new Error(`Rule ${i} missing valid "tool"`);
56
+ }
57
+ if (!VALID_ACTIONS.includes(r.action)) {
58
+ throw new Error(`Rule ${i} has invalid action: "${r.action}"`);
59
+ }
60
+ return { id: r.id, tool: r.tool, action: r.action as Action };
61
+ });
62
+
63
+ return {
64
+ default_action: parsed.default_action as Action,
65
+ mode,
66
+ rules,
67
+ };
68
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Config } from './config.js';
2
+
3
+ export interface Decision {
4
+ decision: 'allowed' | 'blocked' | 'held_for_approval';
5
+ rule_id: string | null;
6
+ reason: string;
7
+ }
8
+
9
+ const ACTION_TO_DECISION = {
10
+ allow: 'allowed',
11
+ block: 'blocked',
12
+ require_approval: 'held_for_approval',
13
+ } as const;
14
+
15
+ export function evaluate(toolName: string, _toolArgs: Record<string, unknown> | undefined, config: Config): Decision {
16
+ for (const rule of config.rules) {
17
+ if (rule.tool === toolName) {
18
+ return {
19
+ decision: ACTION_TO_DECISION[rule.action],
20
+ rule_id: rule.id,
21
+ reason: `Matched rule "${rule.id}"`,
22
+ };
23
+ }
24
+ }
25
+
26
+ return {
27
+ decision: ACTION_TO_DECISION[config.default_action],
28
+ rule_id: null,
29
+ reason: `No matching rule; default action "${config.default_action}"`,
30
+ };
31
+ }