protect-mcp 0.1.0 → 0.2.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 CHANGED
@@ -1,44 +1,147 @@
1
- # @scopeblind/protect-mcp
1
+ # protect-mcp
2
2
 
3
- Security gateway for MCP servers. Tool-level policies, rate limiting, and structured decision logging.
3
+ Security gateway for MCP servers. Tool-level policies, trust-tier gating, credential isolation, and signed decision receipts.
4
4
 
5
- **Observe by default.** Wraps any MCP server as a transparent proxy. Logs every tool call. Optionally enforces policies.
5
+ **Shadow mode by default.** Wraps any MCP server as a transparent proxy. Logs every tool call. Signs every decision. Optionally enforces policies.
6
6
 
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- # Observe mode — log all tool calls, allow everything through
11
- npx @scopeblind/protect-mcp -- node my-server.js
10
+ # Shadow mode — log and sign every tool call, enforce nothing
11
+ npx protect-mcp -- node my-server.js
12
+
13
+ # Initialize a new project with keys + config template
14
+ npx protect-mcp init
12
15
 
13
16
  # Enforce mode with a policy file
14
- npx @scopeblind/protect-mcp --policy policy.json --enforce -- node my-server.js
17
+ npx protect-mcp --policy policy.json --enforce -- node my-server.js
15
18
  ```
16
19
 
17
- ## How It Works
20
+ ## What It Does
18
21
 
19
22
  protect-mcp sits between your MCP client and server as a stdio proxy:
20
23
 
21
24
  ```
22
- MCP Client <--stdin/stdout--> protect-mcp <--stdin/stdout--> your MCP server
25
+ MCP Client stdin/stdout protect-mcp stdin/stdout your MCP server
23
26
  ```
24
27
 
25
28
  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
29
+ - **Shadow mode** (default): logs every tool call, signs a decision receipt, allows everything through
30
+ - **Enforce mode**: applies policy rules block, rate-limit, or gate by trust tier
28
31
 
29
32
  All other MCP messages (`initialize`, `tools/list`, notifications) pass through transparently.
30
33
 
31
- ## Policy File
34
+ ## Key Features
35
+
36
+ ### Trust-Tier Gating
37
+
38
+ Agents present signed manifests. protect-mcp evaluates them into trust tiers and gates tool access accordingly:
39
+
40
+ | Tier | Meaning | How Assigned |
41
+ |------|---------|--------------|
42
+ | `unknown` | No manifest, or invalid signature | Default |
43
+ | `signed-known` | Valid signed manifest | Automatic |
44
+ | `evidenced` | Manifest + sufficient evidence history | Automatic |
45
+ | `privileged` | Operator-granted elevated trust | Manual override |
46
+
47
+ Policy example — require `signed-known` for destructive tools:
48
+
49
+ ```json
50
+ {
51
+ "default_tier": "unknown",
52
+ "tools": {
53
+ "delete_user": { "min_tier": "signed-known", "rate_limit": "5/hour" },
54
+ "read_file": { "require": "any" },
55
+ "*": { "rate_limit": "100/hour" }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ### Credential Vault
61
+
62
+ Agents never see raw API keys. Credentials are resolved from environment variables at call time and injected transparently:
63
+
64
+ ```json
65
+ {
66
+ "credentials": {
67
+ "stripe_api": {
68
+ "inject": "header",
69
+ "name": "Authorization",
70
+ "value_env": "STRIPE_SECRET_KEY"
71
+ },
72
+ "github_token": {
73
+ "inject": "header",
74
+ "name": "Authorization",
75
+ "value_env": "GITHUB_TOKEN"
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Decision logs reference credential labels (`stripe_api`), never values.
82
+
83
+ ### Signed Decision Receipts
84
+
85
+ Every tool call produces an Ed25519-signed v2 artifact — independently verifiable, no trust required:
86
+
87
+ ```
88
+ [PROTECT_MCP_RECEIPT] {"v":2,"type":"decision_receipt","algorithm":"ed25519","kid":"kPrK...","issuer":"sb:protect","issued_at":"2026-03-21T10:00:00Z","payload":{"decision":"allow","tool":"read_file","tier":"signed-known","policy_digest":"a1b2c3d4","scope":"default","mode":"shadow"},"signature":"a1b2c3..."}
89
+ ```
90
+
91
+ Verify with the CLI: `npx @veritasacta/verify receipt.json`
92
+ Verify in browser: [scopeblind.com/verify](https://scopeblind.com/verify)
93
+
94
+ ### Bring Your Own Policy Engine (BYOPE)
95
+
96
+ Plug in OPA, Cerbos, or any HTTP policy endpoint:
97
+
98
+ ```json
99
+ {
100
+ "policy_engine": "external",
101
+ "external": {
102
+ "endpoint": "http://localhost:8181/v1/data/mcp/allow",
103
+ "format": "opa",
104
+ "timeout_ms": 500,
105
+ "fallback": "deny"
106
+ }
107
+ }
108
+ ```
109
+
110
+ Supported formats: `opa`, `cerbos`, `generic`. ScopeBlind signs the receipt regardless of who made the decision.
111
+
112
+ ### Audit Bundle Export
32
113
 
33
- Create a `policy.json`:
114
+ Export self-contained audit bundles for offline verification or compliance:
34
115
 
35
116
  ```json
36
117
  {
118
+ "format": "scopeblind:audit-bundle",
119
+ "version": 1,
120
+ "tenant": "my-service",
121
+ "receipts": [ ...signed artifacts... ],
122
+ "verification": {
123
+ "algorithm": "ed25519",
124
+ "signing_keys": [ ...JWK keys... ]
125
+ }
126
+ }
127
+ ```
128
+
129
+ ## Policy File
130
+
131
+ ```json
132
+ {
133
+ "default_tier": "unknown",
37
134
  "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" }
135
+ "dangerous_tool": { "block": true },
136
+ "admin_tool": { "min_tier": "signed-known", "rate_limit": "5/hour" },
137
+ "read_tool": { "require": "any", "rate_limit": "100/hour" },
138
+ "*": { "rate_limit": "500/hour" }
139
+ },
140
+ "credentials": {
141
+ "api_key": { "inject": "header", "name": "Authorization", "value_env": "MY_API_KEY" }
142
+ },
143
+ "signing": {
144
+ "key_file": ".protect-mcp-key.json"
42
145
  }
43
146
  }
44
147
  ```
@@ -47,11 +150,10 @@ Create a `policy.json`:
47
150
 
48
151
  | Field | Values | Description |
49
152
  |-------|--------|-------------|
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
153
  | `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.
154
+ | `require` | `"any"`, `"none"` | Basic access requirement |
155
+ | `min_tier` | `"unknown"`, `"signed-known"`, `"evidenced"`, `"privileged"` | Minimum trust tier required |
156
+ | `rate_limit` | `"N/unit"` | Rate limit (e.g. `"5/hour"`, `"100/day"`) |
55
157
 
56
158
  Tool names match exactly, with `"*"` as a wildcard fallback.
57
159
 
@@ -67,7 +169,7 @@ Add to `claude_desktop_config.json`:
67
169
  "my-protected-server": {
68
170
  "command": "npx",
69
171
  "args": [
70
- "-y", "@scopeblind/protect-mcp",
172
+ "-y", "protect-mcp",
71
173
  "--policy", "/path/to/policy.json",
72
174
  "--enforce",
73
175
  "--", "node", "my-server.js"
@@ -81,39 +183,19 @@ Add to `claude_desktop_config.json`:
81
183
 
82
184
  Same pattern — replace the server command with protect-mcp wrapping it.
83
185
 
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
186
  ## CLI Options
109
187
 
110
188
  ```
111
189
  protect-mcp [options] -- <command> [args...]
190
+ protect-mcp init
191
+
192
+ Commands:
193
+ init Generate Ed25519 keypair + config template
112
194
 
113
195
  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)
196
+ --policy <path> Policy JSON file (default: shadow mode, allow-all)
197
+ --slug <slug> Service identifier for receipts
198
+ --enforce Enable enforcement mode (default: shadow)
117
199
  --verbose Enable debug logging
118
200
  --help Show help
119
201
  ```
@@ -121,21 +203,41 @@ Options:
121
203
  ## Programmatic API
122
204
 
123
205
  ```typescript
124
- import { ProtectGateway, loadPolicy } from '@scopeblind/protect-mcp';
206
+ import {
207
+ ProtectGateway,
208
+ loadPolicy,
209
+ evaluateTier,
210
+ meetsMinTier,
211
+ resolveCredential,
212
+ initSigning,
213
+ signDecision,
214
+ createAuditBundle,
215
+ } from 'protect-mcp';
216
+ ```
125
217
 
126
- const { policy, digest } = loadPolicy('./policy.json');
218
+ ## Decision Logs
127
219
 
128
- const gateway = new ProtectGateway({
129
- command: 'node',
130
- args: ['my-server.js'],
131
- policy,
132
- policyDigest: digest,
133
- enforce: true,
134
- });
220
+ Every tool call emits structured JSON to stderr:
135
221
 
136
- await gateway.start();
137
222
  ```
223
+ [PROTECT_MCP] {"v":2,"tool":"read_file","decision":"allow","tier":"signed-known","reason_code":"policy_allow","policy_digest":"a1b2c3d4","mode":"shadow","timestamp":1710000000}
224
+ ```
225
+
226
+ When signing is configured, a signed receipt follows:
227
+
228
+ ```
229
+ [PROTECT_MCP_RECEIPT] {"v":2,"type":"decision_receipt","algorithm":"ed25519",...,"signature":"..."}
230
+ ```
231
+
232
+ ## Philosophy
233
+
234
+ - **Shadow first.** See what's happening before you control anything.
235
+ - **Receipts, not logs.** Signed, independently verifiable. Not "trust us."
236
+ - **Credential isolation.** Agents call tools. They never see API keys.
237
+ - **Observe → Enforce → Audit.** Progressive adoption, not all-or-nothing.
138
238
 
139
239
  ## License
140
240
 
141
- FSL-1.1-MIT
241
+ FSL-1.1-MIT — free to use, converts to full MIT after 2 years.
242
+
243
+ [scopeblind.com](https://scopeblind.com) · [npm](https://www.npmjs.com/package/protect-mcp) · [GitHub](https://github.com/tomjwxf/scopeblind-gateway)
@@ -0,0 +1,252 @@
1
+ // node_modules/@noble/hashes/esm/cryptoNode.js
2
+ import * as nc from "crypto";
3
+ var crypto = nc && typeof nc === "object" && "webcrypto" in nc ? nc.webcrypto : nc && typeof nc === "object" && "randomBytes" in nc ? nc : void 0;
4
+
5
+ // node_modules/@noble/hashes/esm/utils.js
6
+ function isBytes(a) {
7
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
8
+ }
9
+ function anumber(n) {
10
+ if (!Number.isSafeInteger(n) || n < 0)
11
+ throw new Error("positive integer expected, got " + n);
12
+ }
13
+ function abytes(b, ...lengths) {
14
+ if (!isBytes(b))
15
+ throw new Error("Uint8Array expected");
16
+ if (lengths.length > 0 && !lengths.includes(b.length))
17
+ throw new Error("Uint8Array expected of length " + lengths + ", got length=" + b.length);
18
+ }
19
+ function ahash(h) {
20
+ if (typeof h !== "function" || typeof h.create !== "function")
21
+ throw new Error("Hash should be wrapped by utils.createHasher");
22
+ anumber(h.outputLen);
23
+ anumber(h.blockLen);
24
+ }
25
+ function aexists(instance, checkFinished = true) {
26
+ if (instance.destroyed)
27
+ throw new Error("Hash instance has been destroyed");
28
+ if (checkFinished && instance.finished)
29
+ throw new Error("Hash#digest() has already been called");
30
+ }
31
+ function aoutput(out, instance) {
32
+ abytes(out);
33
+ const min = instance.outputLen;
34
+ if (out.length < min) {
35
+ throw new Error("digestInto() expects output buffer of length at least " + min);
36
+ }
37
+ }
38
+ function u8(arr) {
39
+ return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
40
+ }
41
+ function u32(arr) {
42
+ return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
43
+ }
44
+ function clean(...arrays) {
45
+ for (let i = 0; i < arrays.length; i++) {
46
+ arrays[i].fill(0);
47
+ }
48
+ }
49
+ function createView(arr) {
50
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
51
+ }
52
+ function rotr(word, shift) {
53
+ return word << 32 - shift | word >>> shift;
54
+ }
55
+ function rotl(word, shift) {
56
+ return word << shift | word >>> 32 - shift >>> 0;
57
+ }
58
+ var isLE = /* @__PURE__ */ (() => new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68)();
59
+ function byteSwap(word) {
60
+ return word << 24 & 4278190080 | word << 8 & 16711680 | word >>> 8 & 65280 | word >>> 24 & 255;
61
+ }
62
+ var swap8IfBE = isLE ? (n) => n : (n) => byteSwap(n);
63
+ var byteSwapIfBE = swap8IfBE;
64
+ function byteSwap32(arr) {
65
+ for (let i = 0; i < arr.length; i++) {
66
+ arr[i] = byteSwap(arr[i]);
67
+ }
68
+ return arr;
69
+ }
70
+ var swap32IfBE = isLE ? (u) => u : byteSwap32;
71
+ var hasHexBuiltin = /* @__PURE__ */ (() => (
72
+ // @ts-ignore
73
+ typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function"
74
+ ))();
75
+ var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
76
+ function bytesToHex(bytes) {
77
+ abytes(bytes);
78
+ if (hasHexBuiltin)
79
+ return bytes.toHex();
80
+ let hex = "";
81
+ for (let i = 0; i < bytes.length; i++) {
82
+ hex += hexes[bytes[i]];
83
+ }
84
+ return hex;
85
+ }
86
+ var asciis = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 };
87
+ function asciiToBase16(ch) {
88
+ if (ch >= asciis._0 && ch <= asciis._9)
89
+ return ch - asciis._0;
90
+ if (ch >= asciis.A && ch <= asciis.F)
91
+ return ch - (asciis.A - 10);
92
+ if (ch >= asciis.a && ch <= asciis.f)
93
+ return ch - (asciis.a - 10);
94
+ return;
95
+ }
96
+ function hexToBytes(hex) {
97
+ if (typeof hex !== "string")
98
+ throw new Error("hex string expected, got " + typeof hex);
99
+ if (hasHexBuiltin)
100
+ return Uint8Array.fromHex(hex);
101
+ const hl = hex.length;
102
+ const al = hl / 2;
103
+ if (hl % 2)
104
+ throw new Error("hex string expected, got unpadded hex of length " + hl);
105
+ const array = new Uint8Array(al);
106
+ for (let ai = 0, hi = 0; ai < al; ai++, hi += 2) {
107
+ const n1 = asciiToBase16(hex.charCodeAt(hi));
108
+ const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
109
+ if (n1 === void 0 || n2 === void 0) {
110
+ const char = hex[hi] + hex[hi + 1];
111
+ throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
112
+ }
113
+ array[ai] = n1 * 16 + n2;
114
+ }
115
+ return array;
116
+ }
117
+ var nextTick = async () => {
118
+ };
119
+ async function asyncLoop(iters, tick, cb) {
120
+ let ts = Date.now();
121
+ for (let i = 0; i < iters; i++) {
122
+ cb(i);
123
+ const diff = Date.now() - ts;
124
+ if (diff >= 0 && diff < tick)
125
+ continue;
126
+ await nextTick();
127
+ ts += diff;
128
+ }
129
+ }
130
+ function utf8ToBytes(str) {
131
+ if (typeof str !== "string")
132
+ throw new Error("string expected");
133
+ return new Uint8Array(new TextEncoder().encode(str));
134
+ }
135
+ function bytesToUtf8(bytes) {
136
+ return new TextDecoder().decode(bytes);
137
+ }
138
+ function toBytes(data) {
139
+ if (typeof data === "string")
140
+ data = utf8ToBytes(data);
141
+ abytes(data);
142
+ return data;
143
+ }
144
+ function kdfInputToBytes(data) {
145
+ if (typeof data === "string")
146
+ data = utf8ToBytes(data);
147
+ abytes(data);
148
+ return data;
149
+ }
150
+ function concatBytes(...arrays) {
151
+ let sum = 0;
152
+ for (let i = 0; i < arrays.length; i++) {
153
+ const a = arrays[i];
154
+ abytes(a);
155
+ sum += a.length;
156
+ }
157
+ const res = new Uint8Array(sum);
158
+ for (let i = 0, pad = 0; i < arrays.length; i++) {
159
+ const a = arrays[i];
160
+ res.set(a, pad);
161
+ pad += a.length;
162
+ }
163
+ return res;
164
+ }
165
+ function checkOpts(defaults, opts) {
166
+ if (opts !== void 0 && {}.toString.call(opts) !== "[object Object]")
167
+ throw new Error("options should be object or undefined");
168
+ const merged = Object.assign(defaults, opts);
169
+ return merged;
170
+ }
171
+ var Hash = class {
172
+ };
173
+ function createHasher(hashCons) {
174
+ const hashC = (msg) => hashCons().update(toBytes(msg)).digest();
175
+ const tmp = hashCons();
176
+ hashC.outputLen = tmp.outputLen;
177
+ hashC.blockLen = tmp.blockLen;
178
+ hashC.create = () => hashCons();
179
+ return hashC;
180
+ }
181
+ function createOptHasher(hashCons) {
182
+ const hashC = (msg, opts) => hashCons(opts).update(toBytes(msg)).digest();
183
+ const tmp = hashCons({});
184
+ hashC.outputLen = tmp.outputLen;
185
+ hashC.blockLen = tmp.blockLen;
186
+ hashC.create = (opts) => hashCons(opts);
187
+ return hashC;
188
+ }
189
+ function createXOFer(hashCons) {
190
+ const hashC = (msg, opts) => hashCons(opts).update(toBytes(msg)).digest();
191
+ const tmp = hashCons({});
192
+ hashC.outputLen = tmp.outputLen;
193
+ hashC.blockLen = tmp.blockLen;
194
+ hashC.create = (opts) => hashCons(opts);
195
+ return hashC;
196
+ }
197
+ var wrapConstructor = createHasher;
198
+ var wrapConstructorWithOpts = createOptHasher;
199
+ var wrapXOFConstructorWithOpts = createXOFer;
200
+ function randomBytes(bytesLength = 32) {
201
+ if (crypto && typeof crypto.getRandomValues === "function") {
202
+ return crypto.getRandomValues(new Uint8Array(bytesLength));
203
+ }
204
+ if (crypto && typeof crypto.randomBytes === "function") {
205
+ return Uint8Array.from(crypto.randomBytes(bytesLength));
206
+ }
207
+ throw new Error("crypto.getRandomValues must be defined");
208
+ }
209
+
210
+ export {
211
+ isBytes,
212
+ anumber,
213
+ abytes,
214
+ ahash,
215
+ aexists,
216
+ aoutput,
217
+ u8,
218
+ u32,
219
+ clean,
220
+ createView,
221
+ rotr,
222
+ rotl,
223
+ isLE,
224
+ byteSwap,
225
+ swap8IfBE,
226
+ byteSwapIfBE,
227
+ byteSwap32,
228
+ swap32IfBE,
229
+ bytesToHex,
230
+ hexToBytes,
231
+ nextTick,
232
+ asyncLoop,
233
+ utf8ToBytes,
234
+ bytesToUtf8,
235
+ toBytes,
236
+ kdfInputToBytes,
237
+ concatBytes,
238
+ checkOpts,
239
+ Hash,
240
+ createHasher,
241
+ createOptHasher,
242
+ createXOFer,
243
+ wrapConstructor,
244
+ wrapConstructorWithOpts,
245
+ wrapXOFConstructorWithOpts,
246
+ randomBytes
247
+ };
248
+ /*! Bundled license information:
249
+
250
+ @noble/hashes/esm/utils.js:
251
+ (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
252
+ */