@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.
@@ -0,0 +1,157 @@
1
+ interface ProtectPolicy {
2
+ tools: Record<string, ToolPolicy>;
3
+ }
4
+ interface ToolPolicy {
5
+ /**
6
+ * Identity requirement for this tool.
7
+ * 'gateway' = must pass through this gateway. 'any' = no restriction. 'none' = no identity needed.
8
+ *
9
+ * NOTE: v1 does not enforce this field — it is metadata only, logged in decision entries
10
+ * for policy documentation purposes. Per-request identity enforcement requires the
11
+ * SSE transport mode planned for v2.
12
+ */
13
+ require?: 'gateway' | 'any' | 'none';
14
+ /** Rate limit spec, e.g. "5/hour", "100/day", "10/minute" */
15
+ rate_limit?: string;
16
+ /** Explicitly block this tool */
17
+ block?: boolean;
18
+ }
19
+ interface RateLimit {
20
+ count: number;
21
+ windowMs: number;
22
+ }
23
+ interface JsonRpcRequest {
24
+ jsonrpc: '2.0';
25
+ id: string | number;
26
+ method: string;
27
+ params?: Record<string, unknown>;
28
+ }
29
+ interface JsonRpcResponse {
30
+ jsonrpc: '2.0';
31
+ id: string | number;
32
+ result?: unknown;
33
+ error?: {
34
+ code: number;
35
+ message: string;
36
+ data?: unknown;
37
+ };
38
+ }
39
+ interface DecisionLog {
40
+ /** Schema version */
41
+ v: 1;
42
+ /** Tool name that was called */
43
+ tool: string;
44
+ /** Decision: allow or deny */
45
+ decision: 'allow' | 'deny';
46
+ /** Why this decision was made */
47
+ reason_code: 'policy_allow' | 'policy_block' | 'rate_limit_exceeded' | 'observe_mode' | 'default_allow';
48
+ /** SHA-256 digest of the canonicalized policy file */
49
+ policy_digest: string;
50
+ /** Unique request identifier */
51
+ request_id: string;
52
+ /** Unix timestamp (ms) */
53
+ timestamp: number;
54
+ /** Remaining rate limit budget (if rate limit is configured) */
55
+ rate_limit_remaining?: number;
56
+ /** Operating mode */
57
+ mode: 'observe' | 'enforce';
58
+ }
59
+ interface ProtectConfig {
60
+ /** Command to spawn (first element of child process) */
61
+ command: string;
62
+ /** Arguments for the child process */
63
+ args: string[];
64
+ /** Loaded policy (or null for allow-all) */
65
+ policy: ProtectPolicy | null;
66
+ /** Computed policy digest */
67
+ policyDigest: string;
68
+ /** ScopeBlind tenant slug (optional, for future API integration) */
69
+ slug?: string;
70
+ /** Whether to enforce policy (default: false = observe mode) */
71
+ enforce?: boolean;
72
+ /** Verbose debug logging to stderr */
73
+ verbose?: boolean;
74
+ }
75
+
76
+ /**
77
+ * ProtectGateway — stdio MITM proxy for MCP servers.
78
+ *
79
+ * Sits between an MCP client (stdin/stdout) and a wrapped MCP server (child process).
80
+ * Intercepts `tools/call` requests for policy enforcement and decision logging.
81
+ * Passes through all other JSON-RPC messages transparently.
82
+ */
83
+ declare class ProtectGateway {
84
+ private child;
85
+ private config;
86
+ private rateLimitStore;
87
+ private clientReader;
88
+ constructor(config: ProtectConfig);
89
+ /**
90
+ * Start the gateway: spawn child process and wire up message relay.
91
+ */
92
+ start(): Promise<void>;
93
+ /**
94
+ * Handle a message from the MCP client (stdin).
95
+ * Intercept tools/call requests; pass through everything else.
96
+ */
97
+ private handleClientMessage;
98
+ /**
99
+ * Handle a message from the wrapped MCP server (child stdout).
100
+ * Forward to client (stdout) transparently.
101
+ */
102
+ private handleServerMessage;
103
+ /**
104
+ * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
105
+ */
106
+ private interceptToolCall;
107
+ /**
108
+ * Emit a structured decision log to stderr.
109
+ */
110
+ private emitDecisionLog;
111
+ /**
112
+ * Create a JSON-RPC error response.
113
+ */
114
+ private makeErrorResponse;
115
+ /**
116
+ * Send a message to the child process (wrapped MCP server).
117
+ */
118
+ private sendToChild;
119
+ /**
120
+ * Send a message to the MCP client (stdout).
121
+ */
122
+ private sendToClient;
123
+ /**
124
+ * Log a message to stderr (debug output).
125
+ */
126
+ private log;
127
+ /**
128
+ * Stop the gateway: kill child process and exit.
129
+ */
130
+ stop(): void;
131
+ }
132
+
133
+ /**
134
+ * Load and validate a policy file. Returns the policy and its digest.
135
+ */
136
+ declare function loadPolicy(path: string): {
137
+ policy: ProtectPolicy;
138
+ digest: string;
139
+ };
140
+ /**
141
+ * Get the policy for a specific tool. Falls back to "*" wildcard, then default-allow.
142
+ */
143
+ declare function getToolPolicy(toolName: string, policy: ProtectPolicy | null): ToolPolicy;
144
+ /**
145
+ * Parse a rate limit spec like "5/hour", "100/day", "10/minute".
146
+ */
147
+ declare function parseRateLimit(spec: string): RateLimit;
148
+ /**
149
+ * In-memory sliding window rate limiter.
150
+ * Returns { allowed, remaining } based on recent invocations.
151
+ */
152
+ declare function checkRateLimit(key: string, limit: RateLimit, store: Map<string, number[]>): {
153
+ allowed: boolean;
154
+ remaining: number;
155
+ };
156
+
157
+ export { type DecisionLog, type JsonRpcRequest, type JsonRpcResponse, type ProtectConfig, ProtectGateway, type ProtectPolicy, type RateLimit, type ToolPolicy, checkRateLimit, getToolPolicy, loadPolicy, parseRateLimit };
package/dist/index.js ADDED
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ProtectGateway: () => ProtectGateway,
24
+ checkRateLimit: () => checkRateLimit,
25
+ getToolPolicy: () => getToolPolicy,
26
+ loadPolicy: () => loadPolicy,
27
+ parseRateLimit: () => parseRateLimit
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/gateway.ts
32
+ var import_node_child_process = require("child_process");
33
+ var import_node_crypto2 = require("crypto");
34
+ var import_node_readline = require("readline");
35
+
36
+ // src/policy.ts
37
+ var import_node_crypto = require("crypto");
38
+ var import_node_fs = require("fs");
39
+ function loadPolicy(path) {
40
+ const raw = (0, import_node_fs.readFileSync)(path, "utf-8");
41
+ const parsed = JSON.parse(raw);
42
+ if (!parsed.tools || typeof parsed.tools !== "object") {
43
+ throw new Error(`Invalid policy file: missing "tools" object in ${path}`);
44
+ }
45
+ const policy = { tools: parsed.tools };
46
+ const digest = computePolicyDigest(policy);
47
+ return { policy, digest };
48
+ }
49
+ function computePolicyDigest(policy) {
50
+ const canonical = JSON.stringify(sortKeysDeep(policy));
51
+ return (0, import_node_crypto.createHash)("sha256").update(canonical).digest("hex").slice(0, 16);
52
+ }
53
+ function sortKeysDeep(obj) {
54
+ if (obj === null || typeof obj !== "object") return obj;
55
+ if (Array.isArray(obj)) return obj.map(sortKeysDeep);
56
+ const sorted = {};
57
+ for (const key of Object.keys(obj).sort()) {
58
+ sorted[key] = sortKeysDeep(obj[key]);
59
+ }
60
+ return sorted;
61
+ }
62
+ function getToolPolicy(toolName, policy) {
63
+ if (!policy) {
64
+ return { require: "any" };
65
+ }
66
+ if (policy.tools[toolName]) {
67
+ return policy.tools[toolName];
68
+ }
69
+ if (policy.tools["*"]) {
70
+ return policy.tools["*"];
71
+ }
72
+ return { require: "any" };
73
+ }
74
+ function parseRateLimit(spec) {
75
+ const match = spec.match(/^(\d+)\/(second|minute|hour|day)$/);
76
+ if (!match) {
77
+ throw new Error(`Invalid rate limit format: "${spec}". Expected "N/unit" (e.g. "5/hour")`);
78
+ }
79
+ const count = parseInt(match[1], 10);
80
+ const unit = match[2];
81
+ const windowMs = {
82
+ second: 1e3,
83
+ minute: 6e4,
84
+ hour: 36e5,
85
+ day: 864e5
86
+ };
87
+ return { count, windowMs: windowMs[unit] };
88
+ }
89
+ function checkRateLimit(key, limit, store) {
90
+ const now = Date.now();
91
+ const windowStart = now - limit.windowMs;
92
+ const timestamps = (store.get(key) || []).filter((t) => t > windowStart);
93
+ if (timestamps.length >= limit.count) {
94
+ store.set(key, timestamps);
95
+ return { allowed: false, remaining: 0 };
96
+ }
97
+ timestamps.push(now);
98
+ store.set(key, timestamps);
99
+ return { allowed: true, remaining: limit.count - timestamps.length };
100
+ }
101
+
102
+ // src/gateway.ts
103
+ var ProtectGateway = class {
104
+ child = null;
105
+ config;
106
+ rateLimitStore = /* @__PURE__ */ new Map();
107
+ clientReader = null;
108
+ constructor(config) {
109
+ this.config = config;
110
+ }
111
+ /**
112
+ * Start the gateway: spawn child process and wire up message relay.
113
+ */
114
+ async start() {
115
+ const { command, args, verbose } = this.config;
116
+ if (verbose) {
117
+ this.log(`Starting gateway in ${this.config.enforce ? "enforce" : "observe"} mode`);
118
+ this.log(`Wrapping: ${command} ${args.join(" ")}`);
119
+ if (this.config.policy) {
120
+ this.log(`Policy digest: ${this.config.policyDigest}`);
121
+ }
122
+ }
123
+ this.child = (0, import_node_child_process.spawn)(command, args, {
124
+ stdio: ["pipe", "pipe", "pipe"],
125
+ env: { ...process.env }
126
+ });
127
+ if (!this.child.stdin || !this.child.stdout || !this.child.stderr) {
128
+ throw new Error("Failed to create pipes to child process");
129
+ }
130
+ this.child.stderr.on("data", (data) => {
131
+ process.stderr.write(data);
132
+ });
133
+ const childReader = (0, import_node_readline.createInterface)({ input: this.child.stdout, crlfDelay: Infinity });
134
+ childReader.on("line", (line) => {
135
+ this.handleServerMessage(line);
136
+ });
137
+ this.clientReader = (0, import_node_readline.createInterface)({ input: process.stdin, crlfDelay: Infinity });
138
+ this.clientReader.on("line", (line) => {
139
+ this.handleClientMessage(line);
140
+ });
141
+ this.child.on("exit", (code, signal) => {
142
+ if (this.config.verbose) {
143
+ this.log(`Child process exited (code=${code}, signal=${signal})`);
144
+ }
145
+ process.exit(code ?? 1);
146
+ });
147
+ this.child.on("error", (err) => {
148
+ this.log(`Child process error: ${err.message}`);
149
+ process.exit(1);
150
+ });
151
+ process.on("SIGINT", () => this.stop());
152
+ process.on("SIGTERM", () => this.stop());
153
+ process.stdin.on("end", () => {
154
+ if (this.config.verbose) {
155
+ this.log("Client stdin closed, closing child stdin");
156
+ }
157
+ if (this.child?.stdin?.writable) {
158
+ this.child.stdin.end();
159
+ }
160
+ });
161
+ }
162
+ /**
163
+ * Handle a message from the MCP client (stdin).
164
+ * Intercept tools/call requests; pass through everything else.
165
+ */
166
+ handleClientMessage(raw) {
167
+ const trimmed = raw.trim();
168
+ if (!trimmed) return;
169
+ let message;
170
+ try {
171
+ message = JSON.parse(trimmed);
172
+ } catch {
173
+ this.sendToChild(trimmed);
174
+ return;
175
+ }
176
+ if (message.method === "tools/call" && message.id !== void 0) {
177
+ const result = this.interceptToolCall(message);
178
+ if (result) {
179
+ this.sendToClient(JSON.stringify(result));
180
+ return;
181
+ }
182
+ }
183
+ this.sendToChild(trimmed);
184
+ }
185
+ /**
186
+ * Handle a message from the wrapped MCP server (child stdout).
187
+ * Forward to client (stdout) transparently.
188
+ */
189
+ handleServerMessage(raw) {
190
+ this.sendToClient(raw);
191
+ }
192
+ /**
193
+ * Intercept a tools/call request. Returns a JSON-RPC error response if denied, null if allowed.
194
+ */
195
+ interceptToolCall(request) {
196
+ const toolName = request.params?.name || "unknown";
197
+ const requestId = (0, import_node_crypto2.randomUUID)().slice(0, 12);
198
+ const toolPolicy = getToolPolicy(toolName, this.config.policy);
199
+ if (toolPolicy.block) {
200
+ this.emitDecisionLog({
201
+ tool: toolName,
202
+ decision: "deny",
203
+ reason_code: "policy_block",
204
+ request_id: requestId
205
+ });
206
+ if (this.config.enforce) {
207
+ return this.makeErrorResponse(request.id, -32600, `Tool "${toolName}" is blocked by policy`);
208
+ }
209
+ return null;
210
+ }
211
+ if (toolPolicy.rate_limit) {
212
+ try {
213
+ const limit = parseRateLimit(toolPolicy.rate_limit);
214
+ const key = `tool:${toolName}`;
215
+ const { allowed, remaining } = checkRateLimit(key, limit, this.rateLimitStore);
216
+ if (!allowed) {
217
+ this.emitDecisionLog({
218
+ tool: toolName,
219
+ decision: "deny",
220
+ reason_code: "rate_limit_exceeded",
221
+ request_id: requestId,
222
+ rate_limit_remaining: 0
223
+ });
224
+ if (this.config.enforce) {
225
+ return this.makeErrorResponse(
226
+ request.id,
227
+ -32600,
228
+ `Tool "${toolName}" rate limit exceeded (${toolPolicy.rate_limit})`
229
+ );
230
+ }
231
+ return null;
232
+ }
233
+ this.emitDecisionLog({
234
+ tool: toolName,
235
+ decision: "allow",
236
+ reason_code: "policy_allow",
237
+ request_id: requestId,
238
+ rate_limit_remaining: remaining
239
+ });
240
+ } catch {
241
+ this.emitDecisionLog({
242
+ tool: toolName,
243
+ decision: "allow",
244
+ reason_code: "default_allow",
245
+ request_id: requestId
246
+ });
247
+ }
248
+ } else {
249
+ const reasonCode = this.config.enforce ? "policy_allow" : "observe_mode";
250
+ this.emitDecisionLog({
251
+ tool: toolName,
252
+ decision: "allow",
253
+ reason_code: reasonCode,
254
+ request_id: requestId
255
+ });
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Emit a structured decision log to stderr.
261
+ */
262
+ emitDecisionLog(entry) {
263
+ const log = {
264
+ v: 1,
265
+ tool: entry.tool || "unknown",
266
+ decision: entry.decision || "allow",
267
+ reason_code: entry.reason_code || "default_allow",
268
+ policy_digest: this.config.policyDigest,
269
+ request_id: entry.request_id || (0, import_node_crypto2.randomUUID)().slice(0, 12),
270
+ timestamp: Date.now(),
271
+ mode: this.config.enforce ? "enforce" : "observe",
272
+ ...entry.rate_limit_remaining !== void 0 && { rate_limit_remaining: entry.rate_limit_remaining }
273
+ };
274
+ process.stderr.write(`[PROTECT_MCP] ${JSON.stringify(log)}
275
+ `);
276
+ }
277
+ /**
278
+ * Create a JSON-RPC error response.
279
+ */
280
+ makeErrorResponse(id, code, message) {
281
+ return {
282
+ jsonrpc: "2.0",
283
+ id,
284
+ error: { code, message }
285
+ };
286
+ }
287
+ /**
288
+ * Send a message to the child process (wrapped MCP server).
289
+ */
290
+ sendToChild(message) {
291
+ if (this.child?.stdin?.writable) {
292
+ this.child.stdin.write(message + "\n");
293
+ }
294
+ }
295
+ /**
296
+ * Send a message to the MCP client (stdout).
297
+ */
298
+ sendToClient(message) {
299
+ process.stdout.write(message + "\n");
300
+ }
301
+ /**
302
+ * Log a message to stderr (debug output).
303
+ */
304
+ log(message) {
305
+ process.stderr.write(`[PROTECT_MCP] ${message}
306
+ `);
307
+ }
308
+ /**
309
+ * Stop the gateway: kill child process and exit.
310
+ */
311
+ stop() {
312
+ if (this.clientReader) {
313
+ this.clientReader.close();
314
+ }
315
+ if (this.child) {
316
+ this.child.kill("SIGTERM");
317
+ this.child = null;
318
+ }
319
+ process.exit(0);
320
+ }
321
+ };
322
+ // Annotate the CommonJS export names for ESM import in node:
323
+ 0 && (module.exports = {
324
+ ProtectGateway,
325
+ checkRateLimit,
326
+ getToolPolicy,
327
+ loadPolicy,
328
+ parseRateLimit
329
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ import {
2
+ ProtectGateway,
3
+ checkRateLimit,
4
+ getToolPolicy,
5
+ loadPolicy,
6
+ parseRateLimit
7
+ } from "./chunk-L3QWKSGY.mjs";
8
+ export {
9
+ ProtectGateway,
10
+ checkRateLimit,
11
+ getToolPolicy,
12
+ loadPolicy,
13
+ parseRateLimit
14
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@scopeblind/protect-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Security gateway for MCP servers. Tool-level policies, rate limiting, and structured decision logging. Observe-by-default.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "module": "dist/index.mjs",
8
+ "bin": {
9
+ "protect-mcp": "./dist/cli.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.mjs",
15
+ "require": "./dist/index.js"
16
+ }
17
+ },
18
+ "scripts": {
19
+ "build": "tsup src/index.ts src/cli.ts --format cjs,esm --dts --clean",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md",
25
+ "dist/cli.js"
26
+ ],
27
+ "keywords": [
28
+ "scopeblind",
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "security",
32
+ "gateway",
33
+ "rate-limiting",
34
+ "policy",
35
+ "audit",
36
+ "decision-log",
37
+ "tool-protection",
38
+ "ai-agent",
39
+ "llm-gateway"
40
+ ],
41
+ "author": "Tom Farley <tommy@scopeblind.com>",
42
+ "license": "FSL-1.1-MIT",
43
+ "homepage": "https://www.scopeblind.com",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/tomjwxf/scopeblind-gateway"
47
+ },
48
+ "bugs": {
49
+ "url": "https://github.com/tomjwxf/scopeblind-gateway/issues"
50
+ },
51
+ "dependencies": {},
52
+ "devDependencies": {
53
+ "tsup": "^8.0.0",
54
+ "typescript": "^5.0.0",
55
+ "@types/node": "^20.0.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=18.0.0"
59
+ }
60
+ }