@strixgov/mcp-proxy 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/CHANGELOG.md +93 -0
- package/COMMERCIAL.md +159 -0
- package/LICENSE +91 -0
- package/README.md +234 -0
- package/SECURITY.md +293 -0
- package/bin/strix-mcp-proxy.mjs +156 -0
- package/examples/notion-proxy-config.json +19 -0
- package/package.json +61 -0
- package/src/config.mjs +111 -0
- package/src/index.mjs +19 -0
- package/src/proxy.mjs +212 -0
- package/src/types.d.ts +52 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Security posture — `@strixgov/mcp-proxy`
|
|
2
|
+
|
|
3
|
+
This document is the security-team-facing surface for the package.
|
|
4
|
+
It answers the questions a security review will ask in the first
|
|
5
|
+
30 minutes — what does the package hold, where, for how long, and
|
|
6
|
+
which threats does it cover or not cover.
|
|
7
|
+
|
|
8
|
+
For the broader Strix threat model (the platform, the verifier,
|
|
9
|
+
the kernel-binding guarantees), see
|
|
10
|
+
[`docs/security/THREAT-MODEL.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/security/THREAT-MODEL.md)
|
|
11
|
+
in the source repository.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Scope
|
|
16
|
+
|
|
17
|
+
This document covers `@strixgov/mcp-proxy` — the standalone process
|
|
18
|
+
that wraps an upstream MCP server. It applies to v0.1.0 and any
|
|
19
|
+
patch release of that line.
|
|
20
|
+
|
|
21
|
+
Out of scope:
|
|
22
|
+
|
|
23
|
+
- The trust path of receipt verification — handled by
|
|
24
|
+
[`@strixgov/verifier`](https://www.npmjs.com/package/@strixgov/verifier)
|
|
25
|
+
(MIT, zero-dependency, separate trust root).
|
|
26
|
+
- The execution-authorization token format — handled by
|
|
27
|
+
[`@strixgov/mcp-token-validator`](https://www.npmjs.com/package/@strixgov/mcp-token-validator),
|
|
28
|
+
which lands as the Mode 3 enforcement primitive when wiring ships
|
|
29
|
+
in v0.2.0.
|
|
30
|
+
- The Strix Console + the hosted Connected Mode platform — separate
|
|
31
|
+
audit posture.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. Credential boundary — where do upstream credentials live?
|
|
36
|
+
|
|
37
|
+
**On disk: never, by design.** The proxy writes signed receipts
|
|
38
|
+
(`receipts.jsonl`) and nothing else. No `.env` file, no credential
|
|
39
|
+
cache, no token store. If you see secrets on disk, something in
|
|
40
|
+
your wrapper script or logging is putting them there — not the
|
|
41
|
+
proxy.
|
|
42
|
+
|
|
43
|
+
**In memory: depends on which architectural position you deploy.**
|
|
44
|
+
The proxy itself doesn't pin you to one of the three positions
|
|
45
|
+
below; the choice is an integration decision your operator makes.
|
|
46
|
+
|
|
47
|
+
### Position A — proxy holds the credential
|
|
48
|
+
|
|
49
|
+
The proxy fetches an upstream credential from a secrets manager
|
|
50
|
+
(or reads it from its own environment) and passes it downstream:
|
|
51
|
+
as a child-process env var (stdio MCP), as an `Authorization`
|
|
52
|
+
header on outbound HTTP, or as an SDK initialization parameter.
|
|
53
|
+
|
|
54
|
+
| | |
|
|
55
|
+
|---|---|
|
|
56
|
+
| **Credential lives in** | Proxy heap + downstream sink (child env, fetch headers, SDK client object) |
|
|
57
|
+
| **Lifetime** | Proxy process lifetime |
|
|
58
|
+
| **Who can read it** | Anyone with `ptrace` / `/proc/<pid>/mem` access to the proxy UID |
|
|
59
|
+
| **Used when** | Upstream is a binary you spawn (stdio MCP servers), OR an HTTP endpoint where the proxy is the OAuth client |
|
|
60
|
+
| **Audit posture** | Proxy is in scope for credential-handling audit. Treat it as a credential-bearing system. |
|
|
61
|
+
|
|
62
|
+
### Position B — proxy passes the credential through
|
|
63
|
+
|
|
64
|
+
Client (e.g. Claude Code / Claude Desktop) supplies the credential
|
|
65
|
+
per-request (header, bearer token). Proxy forwards it to upstream
|
|
66
|
+
unchanged. Proxy sees the bytes in flight but does not retain them
|
|
67
|
+
past the request.
|
|
68
|
+
|
|
69
|
+
| | |
|
|
70
|
+
|---|---|
|
|
71
|
+
| **Credential lives in** | Per-request heap, GC'd after the response |
|
|
72
|
+
| **Lifetime** | Single request |
|
|
73
|
+
| **Who can read it** | Same as Position A, but only during the request window |
|
|
74
|
+
| **Used when** | Upstream is hosted HTTP and supports OAuth; client is the OAuth client; transparent passthrough |
|
|
75
|
+
| **Audit posture** | Proxy is not a credential-bearing system in steady state. Receipts cannot cryptographically attest actor identity beyond what the client supplies. |
|
|
76
|
+
|
|
77
|
+
### Position C — proxy never sees the credential
|
|
78
|
+
|
|
79
|
+
Upstream identity is resolved out-of-band: Workload Identity on
|
|
80
|
+
cloud platforms, IAM role on the instance, OS keychain accessed
|
|
81
|
+
directly by the upstream process, or the upstream binary resolves
|
|
82
|
+
its own secrets from a secrets manager.
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| **Credential lives in** | Only the upstream's process / cloud metadata service |
|
|
87
|
+
| **Lifetime** | Upstream lifetime |
|
|
88
|
+
| **Who can read it** | Same UID as the upstream process |
|
|
89
|
+
| **Used when** | Cloud-native deployments; any upstream that supports native secret resolution |
|
|
90
|
+
| **Audit posture** | Cleanest. Proxy is not credential-bearing at all. The credential never crosses the proxy boundary. |
|
|
91
|
+
|
|
92
|
+
### The decision rule
|
|
93
|
+
|
|
94
|
+
If your auditor needs to confirm the proxy is **not** a
|
|
95
|
+
credential-bearing system, the answer depends entirely on whether
|
|
96
|
+
you deployed it as Position A, B, or C. The proxy itself does not
|
|
97
|
+
choose for you.
|
|
98
|
+
|
|
99
|
+
| If your deployment supports it | Use |
|
|
100
|
+
|---|---|
|
|
101
|
+
| Any major cloud / on-prem with Vault Agent or equivalent | **Position C** — cleanest audit story |
|
|
102
|
+
| Upstream is hosted HTTP + supports OAuth + client can be the OAuth client + you don't need cryptographically-verified actor identity in receipts | **Position B** — proxy stays credential-free |
|
|
103
|
+
| Upstream is a binary you spawn (stdio MCP) OR you need the proxy to be the identity broker | **Position A** — proxy is in scope for credential-handling audit, but the receipt's claimed actor identity can be cryptographically attested once AA-1 wiring ships |
|
|
104
|
+
|
|
105
|
+
Same primitive, three deployment shapes, materially different audit
|
|
106
|
+
and threat-model profiles.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 3. What the proxy stores regardless of position
|
|
111
|
+
|
|
112
|
+
The four artifacts the proxy holds in any deployment shape:
|
|
113
|
+
|
|
114
|
+
| Artifact | Contents | Sensitivity | Lifetime / Location |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| `receipts.jsonl` (or in-memory) | Signed canonical payloads: `capabilityId`, `action`, `decision`, `risk`, `mode`, `policyVersion`, `tenantId`, `environment`, **`invocationHash` (SHA-256 of args+actor)**, `evidenceHash`, `proofChainHash`, `timestamp`, `signature`, `signingKeyId`. Receipt also carries `actorId`, `actorRole`, and (if approved) `approvedBy` as un-signed display fields. | **Does NOT contain raw `args`** — only a hash. See §3.1 for what this means in practice. Never contains auth credentials. | On disk at `opts.storagePath` if set; in-memory otherwise (lost on process exit) |
|
|
117
|
+
| Gateway signing key | Ed25519 private key used to sign receipts | Generated per-install if not supplied; **distinct from upstream credentials** | Memory by default. Override via `opts.signingKey` to load from your secrets manager. Persistent storage is operator-managed. |
|
|
118
|
+
| Policy config | Capability rules, risk overrides, fail-closed defaults | **Not secret.** Check it into source control. | Read at startup from the config file or CLI flags |
|
|
119
|
+
| Approval gate state | Pending-approval queue (only if approvals enabled) | Holds pending-action metadata until decided | In-memory by default; lost on process exit unless you wire persistence |
|
|
120
|
+
|
|
121
|
+
### 3.1 What the receipt does and does not contain
|
|
122
|
+
|
|
123
|
+
This is the most common question. The honest answer:
|
|
124
|
+
|
|
125
|
+
| The receipt stores | The receipt does NOT store |
|
|
126
|
+
|---|---|
|
|
127
|
+
| The **capability id** invoked (e.g. `mcp.notion.create_comment`) | The raw tool arguments — only their SHA-256 hash (`invocationHash`) |
|
|
128
|
+
| The **decision** (`ALLOW` / `DENY`) | The agent's prompt or reasoning |
|
|
129
|
+
| The **actor id + role** (e.g. `agent-claude-1` / `agent`) | The tool's response content |
|
|
130
|
+
| The **approver identity** if approval gate fired | The upstream API response body |
|
|
131
|
+
| Timestamps + signing-key id + proof-chain linkage | Auth credentials of any kind |
|
|
132
|
+
|
|
133
|
+
**Practical implication for receipt sharing:** because raw args
|
|
134
|
+
are NOT in the signed bytes or the on-disk record, you can share
|
|
135
|
+
the receipt JSONL externally **without args-redaction concerns**.
|
|
136
|
+
The signature verifies against `invocationHash`, which is derived
|
|
137
|
+
deterministically from the args + actor fields the agent supplied.
|
|
138
|
+
An auditor can confirm the signed receipt corresponds to a given
|
|
139
|
+
args bundle by hashing their copy of the args and comparing to
|
|
140
|
+
`invocationHash`. If they don't have the args, they can still
|
|
141
|
+
confirm the receipt is authentic, just not what it was authorizing.
|
|
142
|
+
|
|
143
|
+
This is by design — the receipt is the audit artifact; the args
|
|
144
|
+
themselves are operationally-sensitive call data that the proxy
|
|
145
|
+
**never persists**.
|
|
146
|
+
|
|
147
|
+
### 3.2 In-memory args sensitivity
|
|
148
|
+
|
|
149
|
+
While a `callTool` is in flight, the proxy DOES see the raw args
|
|
150
|
+
in memory:
|
|
151
|
+
|
|
152
|
+
- Args come in from the MCP client via stdio
|
|
153
|
+
- Args flow through `governMCPServer` for policy evaluation +
|
|
154
|
+
hashing into `invocationHash`
|
|
155
|
+
- Args are forwarded to the upstream MCP server via the proxy's
|
|
156
|
+
internal client
|
|
157
|
+
|
|
158
|
+
During this window, an attacker with `ptrace` access to the proxy
|
|
159
|
+
UID can read the args. After the call returns, the args are eligible
|
|
160
|
+
for GC and the only persistent trace is `invocationHash`.
|
|
161
|
+
|
|
162
|
+
For deployments that need additional in-memory protection (e.g.
|
|
163
|
+
agent args contain regulated data even during a single call),
|
|
164
|
+
combine with a hardened deployment environment: dedicated UID,
|
|
165
|
+
seccomp/AppArmor profile that blocks `ptrace`, no co-tenant
|
|
166
|
+
processes under the same UID. Strix-side mitigations are not the
|
|
167
|
+
right layer.
|
|
168
|
+
|
|
169
|
+
### 3.3 Signing key lifecycle
|
|
170
|
+
|
|
171
|
+
The Ed25519 signing key the gateway uses to sign receipts is:
|
|
172
|
+
|
|
173
|
+
- **Auto-generated** if not supplied (per-process; lost on restart
|
|
174
|
+
unless `opts.storagePath` is set and the key is persisted by
|
|
175
|
+
the underlying `@strixgov/tool-gateway` storage). This is fine
|
|
176
|
+
for development and Mode 1 demonstrations.
|
|
177
|
+
- **Caller-supplied** when `opts.signingKey` is set, OR when an
|
|
178
|
+
operator runs the tool-gateway's key-management commands to
|
|
179
|
+
persist a key under `~/.strix/keys/`. Use this in production.
|
|
180
|
+
- **Rotated** by replacing the key + publishing the old public key
|
|
181
|
+
to the JWKS for the retention window (default 2 years for EU AI
|
|
182
|
+
Act Article 12 compliance). Historical receipts remain
|
|
183
|
+
verifiable.
|
|
184
|
+
|
|
185
|
+
The signing key is **not the same as the upstream credential**.
|
|
186
|
+
Rotating one does not rotate the other; they have different
|
|
187
|
+
lifecycles and different blast radii.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 4. Process model — what happens on failure
|
|
192
|
+
|
|
193
|
+
The proxy is a single Node.js process. Crash semantics:
|
|
194
|
+
|
|
195
|
+
| Failure mode | What happens | Recovery |
|
|
196
|
+
|---|---|---|
|
|
197
|
+
| Proxy crashes mid-tool-call | Upstream call may or may not have started (depends on timing). No receipt is written. The MCP client sees a transport error. | Restart proxy. The action did not execute in any auditable sense. Re-running the same agent input replays the call from scratch. |
|
|
198
|
+
| Upstream crashes during a call | Proxy receives the error from the upstream client. Receipt is written with `decision: "DENY"` (or `"ALLOW"` if policy already approved + upstream failed AFTER consume). Error propagated to client. | Standard MCP error handling on the client side. |
|
|
199
|
+
| Proxy crashes mid-approval | Pending approval is lost (in-memory queue). The held action does not execute. | Restart proxy. The action would need to be re-attempted; the previous approval is gone. |
|
|
200
|
+
| Disk fills up (with `storagePath` set) | New receipt writes fail. The proxy raises an error but does not fall back to silent ALLOW — fail-closed discipline. | Rotate / archive receipts.jsonl (see [`docs/runbooks/operate-mcp-proxy.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/runbooks/operate-mcp-proxy.md) §3) before restarting. |
|
|
201
|
+
| Signing key not loadable | Constructor throws at startup. **The proxy does not start.** | Fix the key configuration. There is no "governance degraded but tool still runs" state. |
|
|
202
|
+
|
|
203
|
+
### 4.1 Receipt-log rotation is operator-owned
|
|
204
|
+
|
|
205
|
+
`receipts.jsonl` has **no built-in rotation** in v0.1.0. A
|
|
206
|
+
high-volume deployment will fill disk. Mitigation is
|
|
207
|
+
operator-side:
|
|
208
|
+
|
|
209
|
+
- Standard `logrotate(8)` with copytruncate is the simplest path.
|
|
210
|
+
Configure it to rotate when the file exceeds your retention
|
|
211
|
+
comfort threshold.
|
|
212
|
+
- Connected Mode customers can configure the strixgov.com
|
|
213
|
+
platform to be the durable retention store; the local
|
|
214
|
+
`receipts.jsonl` then becomes a transient buffer that can be
|
|
215
|
+
rotated aggressively.
|
|
216
|
+
- Anyone running the proxy at non-trivial volume MUST decide on a
|
|
217
|
+
rotation policy before launching. See
|
|
218
|
+
[`docs/runbooks/operate-mcp-proxy.md`](https://github.com/Tarshann/strix-platform/tree/main/docs/runbooks/operate-mcp-proxy.md) §3.
|
|
219
|
+
|
|
220
|
+
This is intentionally not in the proxy itself — log rotation
|
|
221
|
+
strategy is a deployment decision (Node-level `logrotate-stream`?
|
|
222
|
+
journald? Connected Mode forwarding?) and baking one in would lock
|
|
223
|
+
operators out of their preferred tooling.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 5. Multi-tenant boundary
|
|
228
|
+
|
|
229
|
+
The proxy is **single-tenant by design** in v0.1.0.
|
|
230
|
+
|
|
231
|
+
- One proxy process serves one set of upstream credentials, one
|
|
232
|
+
signing key, one receipt log.
|
|
233
|
+
- If you run multiple agents through one proxy, they share:
|
|
234
|
+
- The same upstream credentials (Position A)
|
|
235
|
+
- The same signing key
|
|
236
|
+
- The same `receipts.jsonl`
|
|
237
|
+
- The same approval queue
|
|
238
|
+
|
|
239
|
+
This is not a bug — it matches how MCP clients (Claude Desktop,
|
|
240
|
+
mcp-cli, IDE integrations) configure MCP servers: one upstream
|
|
241
|
+
per client config entry, with the proxy spawning one process per
|
|
242
|
+
entry.
|
|
243
|
+
|
|
244
|
+
**If you need per-tenant isolation:** run one proxy process per
|
|
245
|
+
tenant. Each gets its own credentials, signing key, receipt log,
|
|
246
|
+
and approval queue. Resource cost: small (Node process + ~30MB
|
|
247
|
+
RSS). This is the recommended pattern.
|
|
248
|
+
|
|
249
|
+
Future v0.2.0+ may introduce a multi-tenant variant. Until then,
|
|
250
|
+
**do not share a single proxy process across tenants in
|
|
251
|
+
production** without an explicit threat model that accepts the
|
|
252
|
+
shared-credential + shared-signing-key implications.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## 6. What the proxy does NOT govern
|
|
257
|
+
|
|
258
|
+
For audit clarity — the proxy's surface is bounded:
|
|
259
|
+
|
|
260
|
+
| Surface | Governed? | Why not |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| MCP `callTool` requests routed through the proxy | **YES** | The whole point. |
|
|
263
|
+
| MCP `listTools` / `initialize` / other protocol messages | **NO** | These are protocol overhead, not state-changing. No receipts emitted. |
|
|
264
|
+
| Direct API calls the agent makes outside the proxy | **NO** | Out-of-wrapper traffic is invisible to the proxy. Mode 3 (capability enforcement) is the answer here; not shipped in v0.1.0. |
|
|
265
|
+
| The agent's own internal state, prompts, or reasoning | **NO** | The proxy is at the action boundary, not upstream of the model. |
|
|
266
|
+
| The upstream MCP server's internal behavior | **NO** | The proxy trusts the upstream to honor the `callTool` request it forwards. SQL injection, command injection inside the upstream's argument handling — out of scope for the proxy. Mitigation is the upstream's own argument validation. |
|
|
267
|
+
| Network egress from the agent's runtime | **NO** | Network policy is the deployment's responsibility. Mode 3 Posture B (HTTP egress gating) is the architectural answer; Strix Proxy v0.1.0 is the stdio-only variant. |
|
|
268
|
+
| The proxy's own logging / observability backend | **NO** | If you forward proxy logs to a SIEM, you're responsible for what ends up there. The proxy writes audit-relevant content to stderr (lifecycle events) and stdout-MCP-protocol (the protocol channel); receipts go to `receipts.jsonl` or in-memory. |
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## 7. Reporting security issues
|
|
273
|
+
|
|
274
|
+
Open a **GitHub Security Advisory** on the public mirror at
|
|
275
|
+
`github.com/Strixgov/strix` for any of:
|
|
276
|
+
|
|
277
|
+
- Cryptographic findings (signature verification bypass, key
|
|
278
|
+
recovery, canonicalization drift)
|
|
279
|
+
- Credential leakage paths in the proxy code
|
|
280
|
+
- Authentication / authorization gaps
|
|
281
|
+
- Receipt forgery vectors
|
|
282
|
+
|
|
283
|
+
For non-security operational issues, use the normal GitHub Issues
|
|
284
|
+
surface or the support channels documented in
|
|
285
|
+
[`COMMERCIAL.md`](./COMMERCIAL.md).
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 8. Version history of this document
|
|
290
|
+
|
|
291
|
+
| Version | Date | Change |
|
|
292
|
+
|---|---|---|
|
|
293
|
+
| 1.0 | 2026-05-23 | Initial publication alongside `@strixgov/mcp-proxy` v0.1.0 |
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* strix-mcp-proxy — CLI entry.
|
|
4
|
+
*
|
|
5
|
+
* Two invocation modes:
|
|
6
|
+
*
|
|
7
|
+
* strix-mcp-proxy --config ./proxy-config.json
|
|
8
|
+
*
|
|
9
|
+
* strix-mcp-proxy \
|
|
10
|
+
* --upstream-command npx \
|
|
11
|
+
* --upstream-arg "-y" --upstream-arg "@notionhq/notion-mcp-server" \
|
|
12
|
+
* --server-id notion \
|
|
13
|
+
* --capabilities @strixgov/capabilities-mcp-common/notion \
|
|
14
|
+
* --policy-default DENY \
|
|
15
|
+
* --risk-low ALLOW --risk-medium APPROVAL_REQUIRED
|
|
16
|
+
*
|
|
17
|
+
* The CLI is intentionally minimal — anything elaborate goes in the
|
|
18
|
+
* config file. Operators wiring this into Claude Desktop, mcp-cli, or
|
|
19
|
+
* an IDE integration just change their existing MCP command from
|
|
20
|
+
* `<upstream>` to `strix-mcp-proxy --config <path>`.
|
|
21
|
+
*
|
|
22
|
+
* Audit events (proxy lifecycle + denied calls) print to stderr so the
|
|
23
|
+
* MCP stdio channel on stdout stays clean for the protocol.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { startProxy, loadConfig, resolveCapabilities } from "../src/index.mjs";
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
30
|
+
printHelp();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
34
|
+
const { default: pkg } = await import("../package.json", { with: { type: "json" } });
|
|
35
|
+
console.log(`strix-mcp-proxy ${pkg.version}`);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const opts = await parseArgs(argv);
|
|
41
|
+
const handle = await startProxy({
|
|
42
|
+
...opts,
|
|
43
|
+
onAudit: (event) => {
|
|
44
|
+
// stderr: doesn't collide with the MCP stdio protocol on stdout.
|
|
45
|
+
console.error(`[strix-proxy] ${event.kind} ${JSON.stringify(event.detail)}`);
|
|
46
|
+
},
|
|
47
|
+
onReceipt: (r) => {
|
|
48
|
+
// One-line summary per receipt; full receipts persist via storagePath when set.
|
|
49
|
+
console.error(`[strix-proxy] receipt ${r.decision} ${r.capabilityId}`);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const shutdown = async (signal) => {
|
|
54
|
+
console.error(`[strix-proxy] shutting down (${signal})`);
|
|
55
|
+
try { await handle.stop(); } catch { /* best-effort */ }
|
|
56
|
+
process.exit(0);
|
|
57
|
+
};
|
|
58
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
59
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error(`[strix-proxy] startup failed: ${err.message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── arg parsing ────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async function parseArgs(argv) {
|
|
68
|
+
const flag = (name) => {
|
|
69
|
+
const i = argv.indexOf(name);
|
|
70
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
71
|
+
};
|
|
72
|
+
const flagAll = (name) => {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
if (argv[i] === name) out.push(argv[i + 1]);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const configPath = flag("--config");
|
|
81
|
+
if (configPath) {
|
|
82
|
+
const cfg = await loadConfig(configPath);
|
|
83
|
+
return {
|
|
84
|
+
serverId: cfg.serverId,
|
|
85
|
+
upstream: cfg.upstream,
|
|
86
|
+
capabilities: await resolveCapabilities(cfg.capabilities),
|
|
87
|
+
policy: cfg.policy,
|
|
88
|
+
approval: cfg.approval,
|
|
89
|
+
storagePath: cfg.storagePath,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const upstreamCommand = flag("--upstream-command");
|
|
94
|
+
if (!upstreamCommand) {
|
|
95
|
+
throw new Error("missing --config or --upstream-command (run with --help)");
|
|
96
|
+
}
|
|
97
|
+
const upstreamArgs = flagAll("--upstream-arg");
|
|
98
|
+
const serverId = flag("--server-id") ?? "proxy";
|
|
99
|
+
const capabilitiesPath = flag("--capabilities");
|
|
100
|
+
const policyDefault = flag("--policy-default") ?? "DENY";
|
|
101
|
+
const riskOverrides = {};
|
|
102
|
+
for (const level of ["LOW", "MEDIUM", "HIGH", "CRITICAL"]) {
|
|
103
|
+
const v = flag(`--risk-${level.toLowerCase()}`);
|
|
104
|
+
if (v) riskOverrides[level] = v;
|
|
105
|
+
}
|
|
106
|
+
const approvalEnabled = argv.includes("--approval-enabled");
|
|
107
|
+
const storagePath = flag("--storage-path");
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
serverId,
|
|
111
|
+
upstream: { command: upstreamCommand, args: upstreamArgs },
|
|
112
|
+
capabilities: capabilitiesPath ? await resolveCapabilities(capabilitiesPath) : undefined,
|
|
113
|
+
policy: {
|
|
114
|
+
riskOverrides: Object.keys(riskOverrides).length > 0 ? riskOverrides : undefined,
|
|
115
|
+
default: policyDefault,
|
|
116
|
+
},
|
|
117
|
+
approval: { enabled: approvalEnabled },
|
|
118
|
+
storagePath,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printHelp() {
|
|
123
|
+
console.log(`strix-mcp-proxy — governed MCP proxy
|
|
124
|
+
|
|
125
|
+
USAGE
|
|
126
|
+
strix-mcp-proxy --config <path>
|
|
127
|
+
strix-mcp-proxy --upstream-command <cmd> [--upstream-arg <a> ...] [other flags]
|
|
128
|
+
|
|
129
|
+
CONFIG-FILE MODE
|
|
130
|
+
--config <path> Path to a JSON config file (see README for shape)
|
|
131
|
+
|
|
132
|
+
FLAG MODE
|
|
133
|
+
--upstream-command <cmd> Command to spawn the upstream MCP server (e.g. "npx")
|
|
134
|
+
--upstream-arg <arg> One arg for the upstream command (repeatable)
|
|
135
|
+
--server-id <id> Namespace prefix for capability ids (default: "proxy")
|
|
136
|
+
--capabilities <pkg> Companion-pack import path
|
|
137
|
+
(e.g. @strixgov/capabilities-mcp-common/notion)
|
|
138
|
+
--policy-default <D> Default decision for unmatched calls: ALLOW or DENY
|
|
139
|
+
(default: DENY)
|
|
140
|
+
--risk-low <D> Override decision for LOW-risk calls
|
|
141
|
+
--risk-medium <D> Override decision for MEDIUM-risk calls
|
|
142
|
+
--risk-high <D> Override decision for HIGH-risk calls
|
|
143
|
+
--risk-critical <D> Override decision for CRITICAL-risk calls
|
|
144
|
+
--approval-enabled Enable the approval gate (requires custom prompt)
|
|
145
|
+
--storage-path <dir> Persist receipts to JSONL at this directory
|
|
146
|
+
(default: in-memory)
|
|
147
|
+
|
|
148
|
+
GENERAL
|
|
149
|
+
--help, -h Show this help
|
|
150
|
+
--version, -v Show proxy version
|
|
151
|
+
|
|
152
|
+
The proxy speaks MCP over stdio toward its consumer. Configure your
|
|
153
|
+
MCP-aware client (Claude Desktop, mcp-cli, etc.) to spawn this binary
|
|
154
|
+
in place of the upstream MCP server you previously used directly.
|
|
155
|
+
`);
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"serverId": "notion",
|
|
3
|
+
"upstream": {
|
|
4
|
+
"command": "npx",
|
|
5
|
+
"args": ["-y", "@notionhq/notion-mcp-server"]
|
|
6
|
+
},
|
|
7
|
+
"capabilities": "@strixgov/capabilities-mcp-common/notion",
|
|
8
|
+
"policy": {
|
|
9
|
+
"riskOverrides": {
|
|
10
|
+
"LOW": "ALLOW",
|
|
11
|
+
"MEDIUM": "APPROVAL_REQUIRED",
|
|
12
|
+
"HIGH": "APPROVAL_REQUIRED",
|
|
13
|
+
"CRITICAL": "DENY"
|
|
14
|
+
},
|
|
15
|
+
"default": "DENY"
|
|
16
|
+
},
|
|
17
|
+
"approval": { "enabled": false },
|
|
18
|
+
"storagePath": "~/.strix-mcp-proxy/notion"
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strixgov/mcp-proxy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Strix MCP Proxy — the distribution wedge for governed agent tool execution. Point your MCP client at the proxy instead of the underlying server; every callTool is intercepted, evaluated against policy, and emits a signed Ed25519 receipt. Designed so Mode 3 (capability-enforced) is the v0.2.0 upgrade path, not a rewrite.",
|
|
5
|
+
"license": "Elastic-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.mjs",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/types.d.ts",
|
|
11
|
+
"import": "./src/index.mjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"strix-mcp-proxy": "bin/strix-mcp-proxy.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@strixgov/mcp-adapter": "^0.1.0",
|
|
19
|
+
"@strixgov/tool-gateway": "^0.4.1",
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@strixgov/capabilities-mcp-common": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"strix",
|
|
27
|
+
"mcp",
|
|
28
|
+
"model-context-protocol",
|
|
29
|
+
"proxy",
|
|
30
|
+
"gateway",
|
|
31
|
+
"governance",
|
|
32
|
+
"ai-agents",
|
|
33
|
+
"execution-control",
|
|
34
|
+
"signed-receipts",
|
|
35
|
+
"capability-enforcement"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/Strixgov/strix",
|
|
40
|
+
"directory": "packages/strixgov-mcp-proxy"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"src/",
|
|
44
|
+
"bin/",
|
|
45
|
+
"examples/",
|
|
46
|
+
"README.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
48
|
+
"COMMERCIAL.md",
|
|
49
|
+
"SECURITY.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=18.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"test": "node --test test/*.test.mjs"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy config loader.
|
|
3
|
+
*
|
|
4
|
+
* Config file shape (JSON, no inline comments):
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* "serverId": "notion",
|
|
8
|
+
* "upstream": {
|
|
9
|
+
* "command": "npx",
|
|
10
|
+
* "args": ["-y", "@notionhq/notion-mcp-server"]
|
|
11
|
+
* },
|
|
12
|
+
* "capabilities": "@strixgov/capabilities-mcp-common/notion", // dynamic import path
|
|
13
|
+
* "policy": {
|
|
14
|
+
* "riskOverrides": { "LOW": "ALLOW", "MEDIUM": "APPROVAL_REQUIRED", "CRITICAL": "DENY" },
|
|
15
|
+
* "default": "DENY"
|
|
16
|
+
* },
|
|
17
|
+
* "approval": { "enabled": false },
|
|
18
|
+
* "storagePath": "~/.strix-mcp-proxy/receipts"
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* The `capabilities` field is either:
|
|
22
|
+
* - a bare import path (resolved via dynamic import; must export
|
|
23
|
+
* a named or default ReadonlyArray<McpCapability>), or
|
|
24
|
+
* - an inline array (already-classified caller-managed list)
|
|
25
|
+
*
|
|
26
|
+
* No env-var substitution in v0.1.0 — operators that need secrets
|
|
27
|
+
* inject them via `upstream.env` from the surrounding shell rather
|
|
28
|
+
* than embedding them in the config file.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs/promises";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} filePath
|
|
37
|
+
* @returns {Promise<object>}
|
|
38
|
+
*/
|
|
39
|
+
export async function loadConfig(filePath) {
|
|
40
|
+
const abs = path.isAbsolute(filePath)
|
|
41
|
+
? filePath
|
|
42
|
+
: path.resolve(process.cwd(), filePath);
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = await fs.readFile(abs, "utf-8");
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(`loadConfig: could not read '${abs}': ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
let cfg;
|
|
50
|
+
try {
|
|
51
|
+
cfg = JSON.parse(raw);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`loadConfig: '${abs}' is not valid JSON: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
validateConfig(cfg, abs);
|
|
56
|
+
if (typeof cfg.storagePath === "string" && cfg.storagePath.startsWith("~")) {
|
|
57
|
+
cfg.storagePath = cfg.storagePath.replace(/^~/, os.homedir());
|
|
58
|
+
}
|
|
59
|
+
return cfg;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the `capabilities` field of a config to an actual
|
|
64
|
+
* Array<McpCapability>. Accepts either a bare import path (resolved
|
|
65
|
+
* via dynamic import) or an inline array.
|
|
66
|
+
*
|
|
67
|
+
* @param {string | Array<object> | undefined} capabilities
|
|
68
|
+
* @returns {Promise<Array<object> | undefined>}
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveCapabilities(capabilities) {
|
|
71
|
+
if (capabilities === undefined || capabilities === null) return undefined;
|
|
72
|
+
if (Array.isArray(capabilities)) return capabilities;
|
|
73
|
+
if (typeof capabilities !== "string") {
|
|
74
|
+
throw new TypeError(
|
|
75
|
+
"resolveCapabilities: 'capabilities' must be a string import path or an array",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const mod = await import(capabilities);
|
|
79
|
+
// Look for the conventional name (foo → fooCapabilities, *Capabilities,
|
|
80
|
+
// default, or the first exported array). The companion packs use the
|
|
81
|
+
// `<server>Capabilities` convention, so check that first.
|
|
82
|
+
const exportedArrays = Object.entries(mod).filter(([, v]) => Array.isArray(v));
|
|
83
|
+
const conventional = exportedArrays.find(([k]) => k.endsWith("Capabilities"));
|
|
84
|
+
if (conventional) return conventional[1];
|
|
85
|
+
if (Array.isArray(mod.default)) return mod.default;
|
|
86
|
+
if (exportedArrays.length === 1) return exportedArrays[0][1];
|
|
87
|
+
throw new Error(
|
|
88
|
+
`resolveCapabilities: module '${capabilities}' does not export a recognisable capability array ` +
|
|
89
|
+
`(expected a *Capabilities export, a default export, or a single array export)`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── internal ─────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function validateConfig(cfg, abs) {
|
|
96
|
+
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
97
|
+
throw new Error(`loadConfig: '${abs}' must be a JSON object`);
|
|
98
|
+
}
|
|
99
|
+
if (!cfg.upstream || typeof cfg.upstream !== "object") {
|
|
100
|
+
throw new Error(`loadConfig: '${abs}' is missing 'upstream' object`);
|
|
101
|
+
}
|
|
102
|
+
if (typeof cfg.upstream.command !== "string" || !cfg.upstream.command) {
|
|
103
|
+
throw new Error(`loadConfig: '${abs}' is missing 'upstream.command' string`);
|
|
104
|
+
}
|
|
105
|
+
if (cfg.upstream.args !== undefined && !Array.isArray(cfg.upstream.args)) {
|
|
106
|
+
throw new Error(`loadConfig: '${abs}' 'upstream.args' must be an array of strings if present`);
|
|
107
|
+
}
|
|
108
|
+
if (cfg.policy !== undefined && (typeof cfg.policy !== "object" || cfg.policy === null)) {
|
|
109
|
+
throw new Error(`loadConfig: '${abs}' 'policy' must be an object if present`);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @strixgov/mcp-proxy
|
|
3
|
+
*
|
|
4
|
+
* Public API. Two entry points:
|
|
5
|
+
*
|
|
6
|
+
* - `startProxy(opts)` — programmatic. Used by the CLI; tests use it
|
|
7
|
+
* with `transport: { kind: "test", ... }`.
|
|
8
|
+
* - `loadConfig(path)` — read a proxy config JSON. CLI uses this;
|
|
9
|
+
* consumers can use it directly to validate a config before
|
|
10
|
+
* committing it to disk.
|
|
11
|
+
*
|
|
12
|
+
* Mode 3 (Capability-Enforced) is the v0.2.0 upgrade path. The seam
|
|
13
|
+
* is documented in src/proxy.mjs (`buildUpstreamIface`); the
|
|
14
|
+
* cryptographic primitive is already shipped at
|
|
15
|
+
* `@strixgov/mcp-token-validator`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { startProxy } from "./proxy.mjs";
|
|
19
|
+
export { loadConfig, resolveCapabilities } from "./config.mjs";
|