@scopeblind/protect-mcp 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.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @scopeblind/protect-mcp
2
+
3
+ Security gateway for MCP servers. Tool-level policies, rate limiting, and structured decision logging.
4
+
5
+ **Observe by default.** Wraps any MCP server as a transparent proxy. Logs every tool call. Optionally enforces policies.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # Observe mode — log all tool calls, allow everything through
11
+ npx @scopeblind/protect-mcp -- node my-server.js
12
+
13
+ # Enforce mode with a policy file
14
+ npx @scopeblind/protect-mcp --policy policy.json --enforce -- node my-server.js
15
+ ```
16
+
17
+ ## How It Works
18
+
19
+ protect-mcp sits between your MCP client and server as a stdio proxy:
20
+
21
+ ```
22
+ MCP Client <--stdin/stdout--> protect-mcp <--stdin/stdout--> your MCP server
23
+ ```
24
+
25
+ It intercepts `tools/call` JSON-RPC requests and:
26
+ - **Observe mode** (default): logs every tool call to stderr, allows everything through
27
+ - **Enforce mode**: applies policy rules, blocks denied tools, rate-limits others
28
+
29
+ All other MCP messages (`initialize`, `tools/list`, notifications) pass through transparently.
30
+
31
+ ## Policy File
32
+
33
+ Create a `policy.json`:
34
+
35
+ ```json
36
+ {
37
+ "tools": {
38
+ "dangerous_tool": { "require": "gateway", "rate_limit": "5/hour" },
39
+ "read_only_tool": { "require": "any" },
40
+ "destructive_tool": { "block": true },
41
+ "*": { "require": "any", "rate_limit": "100/hour" }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ### Policy Rules
47
+
48
+ | Field | Values | Description |
49
+ |-------|--------|-------------|
50
+ | `require` | `"gateway"`, `"any"`, `"none"` | Identity requirement (metadata only in v1 — not enforced until v2 SSE transport) |
51
+ | `rate_limit` | `"N/unit"` | Rate limit (e.g. `"5/hour"`, `"100/day"`, `"10/minute"`) |
52
+ | `block` | `true` | Explicitly block this tool |
53
+
54
+ > **v1 enforcement:** `block` and `rate_limit` are enforced in `--enforce` mode. `require` is recorded in decision logs for policy documentation but not enforced — per-request identity verification requires the SSE gateway mode planned for v2.
55
+
56
+ Tool names match exactly, with `"*"` as a wildcard fallback.
57
+
58
+ ## MCP Client Configuration
59
+
60
+ ### Claude Desktop
61
+
62
+ Add to `claude_desktop_config.json`:
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "my-protected-server": {
68
+ "command": "npx",
69
+ "args": [
70
+ "-y", "@scopeblind/protect-mcp",
71
+ "--policy", "/path/to/policy.json",
72
+ "--enforce",
73
+ "--", "node", "my-server.js"
74
+ ]
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ ### Cursor / VS Code
81
+
82
+ Same pattern — replace the server command with protect-mcp wrapping it.
83
+
84
+ ## Decision Logs
85
+
86
+ Every tool call emits a structured JSON log to stderr:
87
+
88
+ ```
89
+ [PROTECT_MCP] {"v":1,"tool":"dangerous_tool","decision":"deny","reason_code":"rate_limit_exceeded","policy_digest":"a1b2c3d4","request_id":"req_abc123","timestamp":1710000000,"mode":"enforce","rate_limit_remaining":0}
90
+ ```
91
+
92
+ ### Log Fields
93
+
94
+ | Field | Description |
95
+ |-------|-------------|
96
+ | `v` | Schema version (always `1`) |
97
+ | `tool` | Tool name that was called |
98
+ | `decision` | `"allow"` or `"deny"` |
99
+ | `reason_code` | `"policy_allow"`, `"policy_block"`, `"rate_limit_exceeded"`, `"observe_mode"`, `"default_allow"` |
100
+ | `policy_digest` | SHA-256 prefix of the policy file |
101
+ | `request_id` | Unique request identifier |
102
+ | `timestamp` | Unix timestamp (ms) |
103
+ | `mode` | `"observe"` or `"enforce"` |
104
+ | `rate_limit_remaining` | Remaining rate limit budget (if applicable) |
105
+
106
+ These are **decision logs**, not signed receipts. Cryptographically signed receipts require the ScopeBlind API (v2).
107
+
108
+ ## CLI Options
109
+
110
+ ```
111
+ protect-mcp [options] -- <command> [args...]
112
+
113
+ Options:
114
+ --policy <path> Policy JSON file (default: allow-all)
115
+ --slug <slug> ScopeBlind tenant slug (optional)
116
+ --enforce Enable enforcement mode (default: observe-only)
117
+ --verbose Enable debug logging
118
+ --help Show help
119
+ ```
120
+
121
+ ## Programmatic API
122
+
123
+ ```typescript
124
+ import { ProtectGateway, loadPolicy } from '@scopeblind/protect-mcp';
125
+
126
+ const { policy, digest } = loadPolicy('./policy.json');
127
+
128
+ const gateway = new ProtectGateway({
129
+ command: 'node',
130
+ args: ['my-server.js'],
131
+ policy,
132
+ policyDigest: digest,
133
+ enforce: true,
134
+ });
135
+
136
+ await gateway.start();
137
+ ```
138
+
139
+ ## License
140
+
141
+ FSL-1.1-MIT
@@ -0,0 +1,297 @@
1
+ // src/policy.ts
2
+ import { createHash } from "crypto";
3
+ import { readFileSync } from "fs";
4
+ function loadPolicy(path) {
5
+ const raw = readFileSync(path, "utf-8");
6
+ const parsed = JSON.parse(raw);
7
+ if (!parsed.tools || typeof parsed.tools !== "object") {
8
+ throw new Error(`Invalid policy file: missing "tools" object in ${path}`);
9
+ }
10
+ const policy = { tools: parsed.tools };
11
+ const digest = computePolicyDigest(policy);
12
+ return { policy, digest };
13
+ }
14
+ function computePolicyDigest(policy) {
15
+ const canonical = JSON.stringify(sortKeysDeep(policy));
16
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
17
+ }
18
+ function sortKeysDeep(obj) {
19
+ if (obj === null || typeof obj !== "object") return obj;
20
+ if (Array.isArray(obj)) return obj.map(sortKeysDeep);
21
+ const sorted = {};
22
+ for (const key of Object.keys(obj).sort()) {
23
+ sorted[key] = sortKeysDeep(obj[key]);
24
+ }
25
+ return sorted;
26
+ }
27
+ function getToolPolicy(toolName, policy) {
28
+ if (!policy) {
29
+ return { require: "any" };
30
+ }
31
+ if (policy.tools[toolName]) {
32
+ return policy.tools[toolName];
33
+ }
34
+ if (policy.tools["*"]) {
35
+ return policy.tools["*"];
36
+ }
37
+ return { require: "any" };
38
+ }
39
+ function parseRateLimit(spec) {
40
+ const match = spec.match(/^(\d+)\/(second|minute|hour|day)$/);
41
+ if (!match) {
42
+ throw new Error(`Invalid rate limit format: "${spec}". Expected "N/unit" (e.g. "5/hour")`);
43
+ }
44
+ const count = parseInt(match[1], 10);
45
+ const unit = match[2];
46
+ const windowMs = {
47
+ second: 1e3,
48
+ minute: 6e4,
49
+ hour: 36e5,
50
+ day: 864e5
51
+ };
52
+ return { count, windowMs: windowMs[unit] };
53
+ }
54
+ function checkRateLimit(key, limit, store) {
55
+ const now = Date.now();
56
+ const windowStart = now - limit.windowMs;
57
+ const timestamps = (store.get(key) || []).filter((t) => t > windowStart);
58
+ if (timestamps.length >= limit.count) {
59
+ store.set(key, timestamps);
60
+ return { allowed: false, remaining: 0 };
61
+ }
62
+ timestamps.push(now);
63
+ store.set(key, timestamps);
64
+ return { allowed: true, remaining: limit.count - timestamps.length };
65
+ }
66
+
67
+ // src/gateway.ts
68
+ import { spawn } from "child_process";
69
+ import { randomUUID } from "crypto";
70
+ import { createInterface } from "readline";
71
+ var ProtectGateway = class {
72
+ child = null;
73
+ config;
74
+ rateLimitStore = /* @__PURE__ */ new Map();
75
+ clientReader = null;
76
+ constructor(config) {
77
+ this.config = config;
78
+ }
79
+ /**
80
+ * Start the gateway: spawn child process and wire up message relay.
81
+ */
82
+ async start() {
83
+ const { command, args, verbose } = this.config;
84
+ if (verbose) {
85
+ this.log(`Starting gateway in ${this.config.enforce ? "enforce" : "observe"} mode`);
86
+ this.log(`Wrapping: ${command} ${args.join(" ")}`);
87
+ if (this.config.policy) {
88
+ this.log(`Policy digest: ${this.config.policyDigest}`);
89
+ }
90
+ }
91
+ this.child = spawn(command, args, {
92
+ stdio: ["pipe", "pipe", "pipe"],
93
+ env: { ...process.env }
94
+ });
95
+ if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
96
+ throw new Error("Failed to create pipes to child process");
97
+ }
98
+ this.child.stderr.on("data", (data) => {
99
+ process.stderr.write(data);
100
+ });
101
+ const childReader = createInterface({ input: this.child.stdout, crlfDelay: Infinity });
102
+ childReader.on("line", (line) => {
103
+ this.handleServerMessage(line);
104
+ });
105
+ this.clientReader = createInterface({ input: process.stdin, crlfDelay: Infinity });
106
+ this.clientReader.on("line", (line) => {
107
+ this.handleClientMessage(line);
108
+ });
109
+ this.child.on("exit", (code, signal) => {
110
+ if (this.config.verbose) {
111
+ this.log(`Child process exited (code=${code}, signal=${signal})`);
112
+ }
113
+ process.exit(code ?? 1);
114
+ });
115
+ this.child.on("error", (err) => {
116
+ this.log(`Child process error: ${err.message}`);
117
+ process.exit(1);
118
+ });
119
+ process.on("SIGINT", () => this.stop());
120
+ process.on("SIGTERM", () => this.stop());
121
+ process.stdin.on("end", () => {
122
+ if (this.config.verbose) {
123
+ this.log("Client stdin closed, closing child stdin");
124
+ }
125
+ if (this.child?.stdin?.writable) {
126
+ this.child.stdin.end();
127
+ }
128
+ });
129
+ }
130
+ /**
131
+ * Handle a message from the MCP client (stdin).
132
+ * Intercept tools/call requests; pass through everything else.
133
+ */
134
+ handleClientMessage(raw) {
135
+ const trimmed = raw.trim();
136
+ if (!trimmed) return;
137
+ let message;
138
+ try {
139
+ message = JSON.parse(trimmed);
140
+ } catch {
141
+ this.sendToChild(trimmed);
142
+ return;
143
+ }
144
+ if (message.method === "tools/call" && message.id !== void 0) {
145
+ const result = this.interceptToolCall(message);
146
+ if (result) {
147
+ this.sendToClient(JSON.stringify(result));
148
+ return;
149
+ }
150
+ }
151
+ this.sendToChild(trimmed);
152
+ }
153
+ /**
154
+ * Handle a message from the wrapped MCP server (child stdout).
155
+ * Forward to client (stdout) transparently.
156
+ */
157
+ handleServerMessage(raw) {
158
+ this.sendToClient(raw);
159
+ }
160
+ /**
161
+ * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
162
+ */
163
+ interceptToolCall(request) {
164
+ const toolName = request.params?.name || "unknown";
165
+ const requestId = randomUUID().slice(0, 12);
166
+ const toolPolicy = getToolPolicy(toolName, this.config.policy);
167
+ if (toolPolicy.block) {
168
+ this.emitDecisionLog({
169
+ tool: toolName,
170
+ decision: "deny",
171
+ reason_code: "policy_block",
172
+ request_id: requestId
173
+ });
174
+ if (this.config.enforce) {
175
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
176
+ }
177
+ return null;
178
+ }
179
+ if (toolPolicy.rate_limit) {
180
+ try {
181
+ const limit = parseRateLimit(toolPolicy.rate_limit);
182
+ const key = `tool:${toolName}`;
183
+ const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
184
+ if (!allowed) {
185
+ this.emitDecisionLog({
186
+ tool: toolName,
187
+ decision: "deny",
188
+ reason_code: "rate_limit_exceeded",
189
+ request_id: requestId,
190
+ rate_limit_remaining: 0
191
+ });
192
+ if (this.config.enforce) {
193
+ return this.makeErrorResponse(
194
+ request.id,
195
+ -32600,
196
+ `Tool "${toolName}" rate limit exceeded (${toolPolicy.rate_limit})`
197
+ );
198
+ }
199
+ return null;
200
+ }
201
+ this.emitDecisionLog({
202
+ tool: toolName,
203
+ decision: "allow",
204
+ reason_code: "policy_allow",
205
+ request_id: requestId,
206
+ rate_limit_remaining: remaining
207
+ });
208
+ } catch {
209
+ this.emitDecisionLog({
210
+ tool: toolName,
211
+ decision: "allow",
212
+ reason_code: "default_allow",
213
+ request_id: requestId
214
+ });
215
+ }
216
+ } else {
217
+ const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
218
+ this.emitDecisionLog({
219
+ tool: toolName,
220
+ decision: "allow",
221
+ reason_code: reasonCode,
222
+ request_id: requestId
223
+ });
224
+ }
225
+ return null;
226
+ }
227
+ /**
228
+ * Emit a structured decision log to stderr.
229
+ */
230
+ emitDecisionLog(entry) {
231
+ const log = {
232
+ v: 1,
233
+ tool: entry.tool || "unknown",
234
+ decision: entry.decision || "allow",
235
+ reason_code: entry.reason_code || "default_allow",
236
+ policy_digest: this.config.policyDigest,
237
+ request_id: entry.request_id || randomUUID().slice(0, 12),
238
+ timestamp: Date.now(),
239
+ mode: this.config.enforce ? "enforce" : "observe",
240
+ ...entry.rate_limit_remaining !== void 0 && { rate_limit_remaining: entry.rate_limit_remaining }
241
+ };
242
+ process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
243
+ `);
244
+ }
245
+ /**
246
+ * Create a JSON-RPC error response.
247
+ */
248
+ makeErrorResponse(id, code, message) {
249
+ return {
250
+ jsonrpc: "2.0",
251
+ id,
252
+ error: { code, message }
253
+ };
254
+ }
255
+ /**
256
+ * Send a message to the child process (wrapped MCP server).
257
+ */
258
+ sendToChild(message) {
259
+ if (this.child?.stdin?.writable) {
260
+ this.child.stdin.write(message + "\n");
261
+ }
262
+ }
263
+ /**
264
+ * Send a message to the MCP client (stdout).
265
+ */
266
+ sendToClient(message) {
267
+ process.stdout.write(message + "\n");
268
+ }
269
+ /**
270
+ * Log a message to stderr (debug output).
271
+ */
272
+ log(message) {
273
+ process.stderr.write(`[PROTECT_MCP] ${message}
274
+ `);
275
+ }
276
+ /**
277
+ * Stop the gateway: kill child process and exit.
278
+ */
279
+ stop() {
280
+ if (this.clientReader) {
281
+ this.clientReader.close();
282
+ }
283
+ if (this.child) {
284
+ this.child.kill("SIGTERM");
285
+ this.child = null;
286
+ }
287
+ process.exit(0);
288
+ }
289
+ };
290
+
291
+ export {
292
+ loadPolicy,
293
+ getToolPolicy,
294
+ parseRateLimit,
295
+ checkRateLimit,
296
+ ProtectGateway
297
+ };
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node