protect-mcp 0.1.1 → 0.2.1

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,79 @@
1
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. Shadow-mode logs by default, per-tool policies, optional local Ed25519 receipts, and verification-friendly audit output.
4
4
 
5
- **Observe by default.** Wraps any MCP server as a transparent proxy. Logs every tool call. Optionally enforces policies.
5
+ **Current CLI path:** wrap any stdio MCP server as a transparent proxy. In shadow mode it logs every `tools/call` request and allows everything through. Add a policy file to enforce per-tool rules. Run `protect-mcp init` to generate local signing keys and config so the gateway can also emit signed receipts.
6
6
 
7
7
  ## Quick Start
8
8
 
9
9
  ```bash
10
- # Observe mode — log all tool calls, allow everything through
10
+ # Shadow mode — log every tool call, enforce nothing
11
11
  npx protect-mcp -- node my-server.js
12
12
 
13
- # Enforce mode with a policy file
14
- npx protect-mcp --policy policy.json --enforce -- node my-server.js
13
+ # Generate keys + config template for local signing
14
+ npx protect-mcp init
15
+
16
+ # Shadow mode with local signing enabled
17
+ npx protect-mcp --policy protect-mcp.json -- node my-server.js
18
+
19
+ # Enforce mode
20
+ npx protect-mcp --policy protect-mcp.json --enforce -- node my-server.js
15
21
  ```
16
22
 
17
- ## How It Works
23
+ ## What It Does
18
24
 
19
25
  protect-mcp sits between your MCP client and server as a stdio proxy:
20
26
 
21
27
  ```
22
- MCP Client <--stdin/stdout--> protect-mcp <--stdin/stdout--> your MCP server
28
+ MCP Client stdin/stdout protect-mcp stdin/stdout your MCP server
23
29
  ```
24
30
 
25
31
  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
32
+ - **Shadow mode** (default): logs every tool call and allows everything through
33
+ - **Enforce mode**: applies per-tool policy rules such as `block`, `rate_limit`, and `min_tier`
34
+ - **Optional local signing**: when signing is configured, emits an Ed25519-signed receipt alongside the structured log
28
35
 
29
36
  All other MCP messages (`initialize`, `tools/list`, notifications) pass through transparently.
30
37
 
31
- ## Policy File
38
+ ## What Ships Today
39
+
40
+ - **Per-tool policies** — block destructive tools, rate-limit expensive ones, and attach minimum-tier requirements
41
+ - **Structured decision logs** — every decision is emitted to `stderr` with `[PROTECT_MCP]`
42
+ - **Optional local signed receipts** — generated when you run with a policy containing `signing.key_path`
43
+ - **Offline verification** — verify receipts or bundles with `npx @veritasacta/verify`
44
+ - **No account required** — local keys, local policy, local process
45
+
46
+ ## Current Capability Boundaries
32
47
 
33
- Create a `policy.json`:
48
+ These are important before you roll this out or talk to users:
49
+
50
+ - **Signing is not automatic on the bare `npx protect-mcp -- ...` path.** That path logs decisions in shadow mode. For local signing, run `npx protect-mcp init` and then start the gateway with the generated policy file.
51
+ - **Tier-aware policy checks are live, but manifest admission is not wired into the default CLI/stdio path.** The CLI defaults sessions to `unknown` unless a host integration calls the admission API programmatically.
52
+ - **Credential config currently validates env-backed credential references and records credential labels in logs/receipts.** Generic per-call injection into arbitrary stdio tools is adapter-specific and is not performed by the default proxy path.
53
+ - **External PDP adapters and audit bundle helpers exist as exported utilities.** They are not yet fully wired into the default CLI path.
54
+
55
+ ## Policy File
34
56
 
35
57
  ```json
36
58
  {
59
+ "default_tier": "unknown",
37
60
  "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" }
61
+ "dangerous_tool": { "block": true },
62
+ "admin_tool": { "min_tier": "signed-known", "rate_limit": "5/hour" },
63
+ "read_tool": { "require": "any", "rate_limit": "100/hour" },
64
+ "*": { "rate_limit": "500/hour" }
65
+ },
66
+ "signing": {
67
+ "key_path": "./keys/gateway.json",
68
+ "issuer": "protect-mcp",
69
+ "enabled": true
70
+ },
71
+ "credentials": {
72
+ "internal_api": {
73
+ "inject": "env",
74
+ "name": "INTERNAL_API_KEY",
75
+ "value_env": "INTERNAL_API_KEY"
76
+ }
42
77
  }
43
78
  }
44
79
  ```
@@ -47,11 +82,10 @@ Create a `policy.json`:
47
82
 
48
83
  | Field | Values | Description |
49
84
  |-------|--------|-------------|
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
85
  | `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.
86
+ | `require` | `"any"`, `"none"` | Basic access requirement |
87
+ | `min_tier` | `"unknown"`, `"signed-known"`, `"evidenced"`, `"privileged"` | Minimum tier required if your host sets admission state |
88
+ | `rate_limit` | `"N/unit"` | Rate limit (e.g. `"5/hour"`, `"100/day"`) |
55
89
 
56
90
  Tool names match exactly, with `"*"` as a wildcard fallback.
57
91
 
@@ -68,7 +102,7 @@ Add to `claude_desktop_config.json`:
68
102
  "command": "npx",
69
103
  "args": [
70
104
  "-y", "protect-mcp",
71
- "--policy", "/path/to/policy.json",
105
+ "--policy", "/path/to/protect-mcp.json",
72
106
  "--enforce",
73
107
  "--", "node", "my-server.js"
74
108
  ]
@@ -79,63 +113,95 @@ Add to `claude_desktop_config.json`:
79
113
 
80
114
  ### Cursor / VS Code
81
115
 
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).
116
+ Same pattern — replace the server command with `protect-mcp` wrapping it.
107
117
 
108
118
  ## CLI Options
109
119
 
110
120
  ```
111
121
  protect-mcp [options] -- <command> [args...]
122
+ protect-mcp init
123
+
124
+ Commands:
125
+ init Generate Ed25519 keypair + config template
112
126
 
113
127
  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)
128
+ --policy <path> Policy/config JSON file
129
+ --slug <slug> Service identifier for logs/receipts
130
+ --enforce Enable enforcement mode (default: shadow)
117
131
  --verbose Enable debug logging
118
132
  --help Show help
119
133
  ```
120
134
 
121
- ## Programmatic API
135
+ ## Programmatic Hooks
136
+
137
+ The library also exposes the primitives that are not yet wired into the default CLI path:
122
138
 
123
139
  ```typescript
124
- import { ProtectGateway, loadPolicy } from 'protect-mcp'; // npm: protect-mcp
140
+ import {
141
+ ProtectGateway,
142
+ loadPolicy,
143
+ evaluateTier,
144
+ meetsMinTier,
145
+ resolveCredential,
146
+ initSigning,
147
+ signDecision,
148
+ queryExternalPDP,
149
+ buildDecisionContext,
150
+ createAuditBundle,
151
+ } from 'protect-mcp';
152
+ ```
153
+
154
+ Use these if you want to add:
155
+ - manifest admission before a session starts
156
+ - an external PDP (OPA, Cerbos, or a generic HTTP webhook)
157
+ - custom credential-brokered integrations
158
+ - audit bundle export around your own receipt store
159
+
160
+ ## Decision Logs and Receipts
161
+
162
+ Every tool call emits structured JSON to `stderr`:
163
+
164
+ ```json
165
+ [PROTECT_MCP] {"v":2,"tool":"read_file","decision":"allow","reason_code":"observe_mode","policy_digest":"none","mode":"shadow","timestamp":1710000000}
166
+ ```
167
+
168
+ When signing is configured, a signed receipt follows:
169
+
170
+ ```json
171
+ [PROTECT_MCP_RECEIPT] {"v":2,"type":"decision_receipt","algorithm":"ed25519","kid":"...","issuer":"protect-mcp","issued_at":"2026-03-22T00:00:00Z","payload":{"tool":"read_file","decision":"allow","policy_digest":"...","mode":"shadow","request_id":"..."},"signature":"..."}
172
+ ```
173
+
174
+ Verify with the CLI: `npx @veritasacta/verify receipt.json`
175
+ Verify in browser: [scopeblind.com/verify](https://scopeblind.com/verify)
125
176
 
126
- const { policy, digest } = loadPolicy('./policy.json');
177
+ ## Audit Bundles
127
178
 
128
- const gateway = new ProtectGateway({
129
- command: 'node',
130
- args: ['my-server.js'],
131
- policy,
132
- policyDigest: digest,
133
- enforce: true,
134
- });
179
+ The package exports a helper for self-contained audit bundles:
135
180
 
136
- await gateway.start();
181
+ ```json
182
+ {
183
+ "format": "scopeblind:audit-bundle",
184
+ "version": 1,
185
+ "tenant": "my-service",
186
+ "receipts": ["..."],
187
+ "verification": {
188
+ "algorithm": "ed25519",
189
+ "signing_keys": ["..."]
190
+ }
191
+ }
137
192
  ```
138
193
 
194
+ Use `createAuditBundle()` around your own collected signed receipts.
195
+
196
+ ## Philosophy
197
+
198
+ - **Shadow first.** See what agents are doing before you enforce anything.
199
+ - **Receipts beat dashboard-only logs.** Signed artifacts should be independently verifiable.
200
+ - **Keep the claims tight.** The default CLI path does not yet do everything the long-term architecture will support.
201
+ - **Layer on top of existing auth.** Don't rip out your stack just to add control and evidence.
202
+
139
203
  ## License
140
204
 
141
- FSL-1.1-MIT
205
+ FSL-1.1-MIT — free to use, converts to full MIT after 2 years.
206
+
207
+ [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
+ */