@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 +141 -0
- package/dist/chunk-L3QWKSGY.mjs +297 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +396 -0
- package/dist/cli.mjs +107 -0
- package/dist/index.d.mts +157 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.js +329 -0
- package/dist/index.mjs +14 -0
- package/package.json +60 -0
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
|