@strixgov/mcp-proxy 0.2.0 → 0.2.2
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 +18 -0
- package/examples/youspot/README.md +58 -0
- package/examples/youspot-proxy-config.json +236 -0
- package/package.json +8 -2
- package/scripts/preflight.mjs +109 -0
- package/scripts/run-tests.mjs +56 -0
- package/test/cli.test.mjs +46 -0
- package/test/config.test.mjs +138 -0
- package/test/nvidia-nooa-profile.test.mjs +69 -0
- package/test/package-contract.test.mjs +20 -0
- package/test/proxy-end-to-end.test.mjs +715 -0
- package/test/shadow-discovery-e2e.test.mjs +185 -0
- package/test/shadow-discovery.test.mjs +137 -0
- package/test/webhook-approver.test.mjs +197 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end proxy test using two InMemoryTransport pairs:
|
|
3
|
+
*
|
|
4
|
+
* [Test Client] ─in-memory─▶ [Proxy as Server]
|
|
5
|
+
* [Proxy as Client] ─in-memory─▶ [Fake Upstream MCP Server]
|
|
6
|
+
*
|
|
7
|
+
* Validates the full pipeline — incoming MCP request → governance
|
|
8
|
+
* evaluation → upstream call → response — without spawning real
|
|
9
|
+
* subprocesses. The same code paths run in production; only the
|
|
10
|
+
* transports are swapped.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import fs from "node:fs/promises";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
|
|
19
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
20
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
21
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
22
|
+
import {
|
|
23
|
+
CallToolRequestSchema,
|
|
24
|
+
ListToolsRequestSchema,
|
|
25
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
26
|
+
|
|
27
|
+
import { startProxy } from "../src/index.mjs";
|
|
28
|
+
|
|
29
|
+
const SAMPLE_CAPS = [
|
|
30
|
+
{ id: "mcp.demo.read_thing", name: "read_thing", risk: "LOW", mode: "READ" },
|
|
31
|
+
{ id: "mcp.demo.write_thing", name: "write_thing", risk: "MEDIUM", mode: "WRITE" },
|
|
32
|
+
{ id: "mcp.demo.delete_thing", name: "delete_thing", risk: "CRITICAL", mode: "WRITE" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a fake upstream MCP server + a Client pre-connected to it via
|
|
37
|
+
* an InMemoryTransport pair. The proxy treats this client as if it
|
|
38
|
+
* were a real subprocess-spawned upstream.
|
|
39
|
+
*/
|
|
40
|
+
async function buildFakeUpstream(toolCallLog) {
|
|
41
|
+
const upstreamServer = new Server(
|
|
42
|
+
{ name: "fake-upstream", version: "0.0.1" },
|
|
43
|
+
{ capabilities: { tools: {} } },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
upstreamServer.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
47
|
+
tools: [
|
|
48
|
+
{ name: "read_thing", description: "Read a thing", inputSchema: { type: "object" } },
|
|
49
|
+
{ name: "write_thing", description: "Write a thing", inputSchema: { type: "object" } },
|
|
50
|
+
{ name: "delete_thing", description: "Delete a thing", inputSchema: { type: "object" } },
|
|
51
|
+
],
|
|
52
|
+
}));
|
|
53
|
+
upstreamServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
54
|
+
toolCallLog.push({ name: request.params.name, args: request.params.arguments });
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: `executed ${request.params.name}` }],
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
61
|
+
await upstreamServer.connect(serverSide);
|
|
62
|
+
|
|
63
|
+
const upstreamClient = new Client(
|
|
64
|
+
{ name: "test-upstream-client", version: "0.0.1" },
|
|
65
|
+
{ capabilities: {} },
|
|
66
|
+
);
|
|
67
|
+
await upstreamClient.connect(clientSide);
|
|
68
|
+
|
|
69
|
+
return { upstreamServer, upstreamClient };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build a test MCP client paired to the proxy's server-side transport. */
|
|
73
|
+
async function buildConsumerClient() {
|
|
74
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
75
|
+
const consumerClient = new Client(
|
|
76
|
+
{ name: "test-consumer", version: "0.0.1" },
|
|
77
|
+
{ capabilities: {} },
|
|
78
|
+
);
|
|
79
|
+
return { consumerClient, clientSide, serverSide };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function startTestProxy({
|
|
83
|
+
allowList,
|
|
84
|
+
capabilities = SAMPLE_CAPS,
|
|
85
|
+
riskOverrides,
|
|
86
|
+
storagePath,
|
|
87
|
+
} = {}) {
|
|
88
|
+
const upstreamCallLog = [];
|
|
89
|
+
const auditLog = [];
|
|
90
|
+
const receipts = [];
|
|
91
|
+
const upstream = await buildFakeUpstream(upstreamCallLog);
|
|
92
|
+
const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
|
|
93
|
+
|
|
94
|
+
const rules = {};
|
|
95
|
+
if (allowList) for (const id of allowList) rules[id] = "ALLOW";
|
|
96
|
+
|
|
97
|
+
// Default to a per-test tmpdir so we never pollute the dev's home dir with
|
|
98
|
+
// ~/.strix-mcp-proxy/demo/. Callers can override (incl. passing `null` to
|
|
99
|
+
// exercise the explicit ephemeral opt-out path).
|
|
100
|
+
const resolvedStoragePath =
|
|
101
|
+
storagePath === undefined
|
|
102
|
+
? await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-test-"))
|
|
103
|
+
: storagePath;
|
|
104
|
+
|
|
105
|
+
const handle = await startProxy({
|
|
106
|
+
serverId: "demo",
|
|
107
|
+
capabilities,
|
|
108
|
+
policy: { rules, riskOverrides, default: "DENY" },
|
|
109
|
+
storagePath: resolvedStoragePath,
|
|
110
|
+
onReceipt: (r) => receipts.push(r),
|
|
111
|
+
onAudit: (e) => auditLog.push(e),
|
|
112
|
+
transport: {
|
|
113
|
+
kind: "test",
|
|
114
|
+
upstreamClient: upstream.upstreamClient,
|
|
115
|
+
serverTransport: serverSide,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
await consumerClient.connect(clientSide);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
consumer: consumerClient,
|
|
122
|
+
proxy: handle,
|
|
123
|
+
upstream,
|
|
124
|
+
upstreamCallLog,
|
|
125
|
+
receipts,
|
|
126
|
+
auditLog,
|
|
127
|
+
storagePath: resolvedStoragePath,
|
|
128
|
+
cleanup: async () => {
|
|
129
|
+
try { await consumerClient.close(); } catch { /* */ }
|
|
130
|
+
try { await handle.stop(); } catch { /* */ }
|
|
131
|
+
try { await upstream.upstreamClient.close(); } catch { /* */ }
|
|
132
|
+
try { await upstream.upstreamServer.close(); } catch { /* */ }
|
|
133
|
+
if (resolvedStoragePath && resolvedStoragePath.startsWith(os.tmpdir())) {
|
|
134
|
+
try { await fs.rm(resolvedStoragePath, { recursive: true, force: true }); } catch { /* */ }
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test("listTools surfaces the upstream's tool descriptors verbatim (governance does not reshape tools)", async () => {
|
|
141
|
+
const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
|
|
142
|
+
try {
|
|
143
|
+
const result = await ctx.consumer.listTools();
|
|
144
|
+
const names = result.tools.map((t) => t.name).sort();
|
|
145
|
+
assert.deepEqual(names, ["delete_thing", "read_thing", "write_thing"]);
|
|
146
|
+
// Description preserved end-to-end.
|
|
147
|
+
const readTool = result.tools.find((t) => t.name === "read_thing");
|
|
148
|
+
assert.equal(readTool.description, "Read a thing");
|
|
149
|
+
} finally {
|
|
150
|
+
await ctx.cleanup();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("ALLOW: callTool reaches the upstream and the response round-trips", async () => {
|
|
155
|
+
const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
|
|
156
|
+
try {
|
|
157
|
+
const result = await ctx.consumer.callTool({
|
|
158
|
+
name: "read_thing",
|
|
159
|
+
arguments: { id: "x" },
|
|
160
|
+
});
|
|
161
|
+
assert.equal(result.content[0].text, "executed read_thing");
|
|
162
|
+
assert.equal(ctx.upstreamCallLog.length, 1);
|
|
163
|
+
assert.deepEqual(ctx.upstreamCallLog[0], { name: "read_thing", args: { id: "x" } });
|
|
164
|
+
} finally {
|
|
165
|
+
await ctx.cleanup();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("DENY: callTool fails AND the upstream is never invoked AND a signed receipt is still emitted", async () => {
|
|
170
|
+
const ctx = await startTestProxy({}); // no allowlist → DENY default
|
|
171
|
+
try {
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
() => ctx.consumer.callTool({ name: "delete_thing", arguments: { id: "x" } }),
|
|
174
|
+
/denied/i,
|
|
175
|
+
);
|
|
176
|
+
assert.equal(ctx.upstreamCallLog.length, 0, "upstream must not be invoked on DENY");
|
|
177
|
+
assert.equal(ctx.receipts.length, 1);
|
|
178
|
+
assert.equal(ctx.receipts[0].decision, "DENY");
|
|
179
|
+
assert.ok(ctx.receipts[0].signature, "denied calls still produce a signed receipt");
|
|
180
|
+
} finally {
|
|
181
|
+
await ctx.cleanup();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("companion-pack canonical id is used in the receipt (not heuristic-derived)", async () => {
|
|
186
|
+
const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
|
|
187
|
+
try {
|
|
188
|
+
await ctx.consumer.callTool({ name: "read_thing", arguments: {} });
|
|
189
|
+
assert.equal(ctx.receipts[0].capabilityId, "mcp.demo.read_thing");
|
|
190
|
+
assert.equal(ctx.receipts[0].decision, "ALLOW");
|
|
191
|
+
} finally {
|
|
192
|
+
await ctx.cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("actor context from _meta is forwarded to the receipt", async () => {
|
|
197
|
+
const ctx = await startTestProxy({ allowList: ["mcp.demo.read_thing"] });
|
|
198
|
+
try {
|
|
199
|
+
await ctx.consumer.callTool({
|
|
200
|
+
name: "read_thing",
|
|
201
|
+
arguments: { id: "x" },
|
|
202
|
+
_meta: { strix_actor_id: "agent-demo-1" },
|
|
203
|
+
});
|
|
204
|
+
assert.equal(ctx.receipts[0].actorId, "agent-demo-1");
|
|
205
|
+
} finally {
|
|
206
|
+
await ctx.cleanup();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("audit events fire for lifecycle and for denied calls", async () => {
|
|
211
|
+
const ctx = await startTestProxy({});
|
|
212
|
+
try {
|
|
213
|
+
await assert.rejects(() => ctx.consumer.callTool({ name: "delete_thing", arguments: {} }));
|
|
214
|
+
const kinds = ctx.auditLog.map((e) => e.kind);
|
|
215
|
+
assert.ok(kinds.includes("upstream.connected"));
|
|
216
|
+
assert.ok(kinds.includes("proxy.listening"));
|
|
217
|
+
assert.ok(kinds.includes("call.denied"));
|
|
218
|
+
} finally {
|
|
219
|
+
await ctx.cleanup();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("riskOverrides gate the whole CRITICAL surface in one rule", async () => {
|
|
224
|
+
const ctx = await startTestProxy({
|
|
225
|
+
riskOverrides: { LOW: "ALLOW", MEDIUM: "DENY", CRITICAL: "DENY" },
|
|
226
|
+
});
|
|
227
|
+
try {
|
|
228
|
+
await ctx.consumer.callTool({ name: "read_thing", arguments: {} }); // LOW → ALLOW
|
|
229
|
+
await assert.rejects(() => ctx.consumer.callTool({ name: "write_thing", arguments: {} })); // MEDIUM → DENY
|
|
230
|
+
await assert.rejects(() => ctx.consumer.callTool({ name: "delete_thing", arguments: {} })); // CRITICAL → DENY
|
|
231
|
+
const decisions = ctx.receipts.map((r) => r.decision);
|
|
232
|
+
assert.deepEqual(decisions, ["ALLOW", "DENY", "DENY"]);
|
|
233
|
+
} finally {
|
|
234
|
+
await ctx.cleanup();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("startProxy validates required opts.upstream.command in stdio mode", async () => {
|
|
239
|
+
await assert.rejects(
|
|
240
|
+
() => startProxy({}),
|
|
241
|
+
/opts\.upstream\.command is required/,
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("startProxy validates transport.serverTransport in test mode", async () => {
|
|
246
|
+
const upstream = await buildFakeUpstream([]);
|
|
247
|
+
try {
|
|
248
|
+
await assert.rejects(
|
|
249
|
+
() => startProxy({
|
|
250
|
+
serverId: "demo",
|
|
251
|
+
transport: { kind: "test", upstreamClient: upstream.upstreamClient },
|
|
252
|
+
policy: { default: "DENY" },
|
|
253
|
+
// storagePath: null so this purely-validation test doesn't write a
|
|
254
|
+
// signing key to the dev's home directory before throwing.
|
|
255
|
+
storagePath: null,
|
|
256
|
+
}),
|
|
257
|
+
/serverTransport/,
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
await upstream.upstreamClient.close();
|
|
261
|
+
await upstream.upstreamServer.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── Persistent signing key (when storagePath is set) ─────────────────
|
|
266
|
+
//
|
|
267
|
+
// Pre-0.1.2 startProxy used an in-memory ephemeral signing key for
|
|
268
|
+
// every run — meaning receipts written to JSONL on disk could never
|
|
269
|
+
// be verified after the proxy exited, because the key that signed them
|
|
270
|
+
// was discarded with the process. These tests pin the persistent-key
|
|
271
|
+
// path: when storagePath is set, the keypair is written to
|
|
272
|
+
// `<storagePath>/keys/` so the SAME kid keeps signing receipts across
|
|
273
|
+
// restarts, and an external verifier can be pointed at
|
|
274
|
+
// `<storagePath>/keys/public-jwk.json`.
|
|
275
|
+
|
|
276
|
+
async function tempDir(label) {
|
|
277
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), `strix-proxy-${label}-`));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function startProxyWithStorage(storagePath) {
|
|
281
|
+
const upstreamCallLog = [];
|
|
282
|
+
const upstream = await buildFakeUpstream(upstreamCallLog);
|
|
283
|
+
const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
|
|
284
|
+
|
|
285
|
+
const handle = await startProxy({
|
|
286
|
+
serverId: "demo",
|
|
287
|
+
capabilities: SAMPLE_CAPS,
|
|
288
|
+
policy: { rules: { "mcp.demo.read_thing": "ALLOW" }, default: "DENY" },
|
|
289
|
+
storagePath,
|
|
290
|
+
transport: {
|
|
291
|
+
kind: "test",
|
|
292
|
+
upstreamClient: upstream.upstreamClient,
|
|
293
|
+
serverTransport: serverSide,
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
await consumerClient.connect(clientSide);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
consumer: consumerClient,
|
|
300
|
+
proxy: handle,
|
|
301
|
+
upstream,
|
|
302
|
+
cleanup: async () => {
|
|
303
|
+
try { await consumerClient.close(); } catch { /* */ }
|
|
304
|
+
try { await handle.stop(); } catch { /* */ }
|
|
305
|
+
try { await upstream.upstreamClient.close(); } catch { /* */ }
|
|
306
|
+
try { await upstream.upstreamServer.close(); } catch { /* */ }
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
test("storagePath causes signing key + public JWK to be persisted to disk", async () => {
|
|
312
|
+
const dir = await tempDir("persist");
|
|
313
|
+
const ctx = await startProxyWithStorage(dir);
|
|
314
|
+
try {
|
|
315
|
+
const keyDir = path.join(dir, "keys");
|
|
316
|
+
const pemPath = path.join(keyDir, "signing-key.pem");
|
|
317
|
+
const jwkPath = path.join(keyDir, "public-jwk.json");
|
|
318
|
+
|
|
319
|
+
// Both files must exist after startProxy returns.
|
|
320
|
+
await fs.access(pemPath);
|
|
321
|
+
await fs.access(jwkPath);
|
|
322
|
+
|
|
323
|
+
// Public JWK is well-formed and uses the expected kid convention.
|
|
324
|
+
const jwk = JSON.parse(await fs.readFile(jwkPath, "utf8"));
|
|
325
|
+
assert.equal(jwk.kty, "OKP");
|
|
326
|
+
assert.equal(jwk.crv, "Ed25519");
|
|
327
|
+
assert.equal(jwk.kid, "strix-demo");
|
|
328
|
+
assert.equal(typeof jwk.x, "string");
|
|
329
|
+
assert.ok(jwk.x.length > 0);
|
|
330
|
+
|
|
331
|
+
// Private key file content is PKCS8 PEM (cheap shape check — full
|
|
332
|
+
// crypto round-trip is exercised by tool-gateway's own tests).
|
|
333
|
+
const pem = await fs.readFile(pemPath, "utf8");
|
|
334
|
+
assert.match(pem, /^-----BEGIN PRIVATE KEY-----/);
|
|
335
|
+
} finally {
|
|
336
|
+
await ctx.cleanup();
|
|
337
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("persistent signing kid survives a proxy restart (the demo-story invariant)", async () => {
|
|
342
|
+
const dir = await tempDir("restart");
|
|
343
|
+
try {
|
|
344
|
+
// First start — writes the keypair.
|
|
345
|
+
const ctx1 = await startProxyWithStorage(dir);
|
|
346
|
+
const kid1 = ctx1.proxy.signingKey.kid;
|
|
347
|
+
const pubX1 = ctx1.proxy.signingKey.publicKeyJwk.x;
|
|
348
|
+
await ctx1.cleanup();
|
|
349
|
+
|
|
350
|
+
// Second start in the same storagePath — must reuse, not regenerate.
|
|
351
|
+
const ctx2 = await startProxyWithStorage(dir);
|
|
352
|
+
const kid2 = ctx2.proxy.signingKey.kid;
|
|
353
|
+
const pubX2 = ctx2.proxy.signingKey.publicKeyJwk.x;
|
|
354
|
+
await ctx2.cleanup();
|
|
355
|
+
|
|
356
|
+
assert.equal(kid1, "strix-demo");
|
|
357
|
+
assert.equal(kid2, "strix-demo");
|
|
358
|
+
// Same public key bytes ⇒ same private key on disk was reused.
|
|
359
|
+
assert.equal(pubX1, pubX2, "second start must reuse the persisted key");
|
|
360
|
+
} finally {
|
|
361
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("caller-supplied opts.signingKey wins over storagePath persistence", async () => {
|
|
366
|
+
// Caller-supplied keys bypass disk persistence entirely. This pins the
|
|
367
|
+
// ordering documented in proxy.mjs §3a — operators integrating with a
|
|
368
|
+
// KMS or secrets manager shouldn't be surprised by a key file showing
|
|
369
|
+
// up on disk because storagePath happens to be set.
|
|
370
|
+
const { generateSigningKey } = await import("@strixgov/tool-gateway");
|
|
371
|
+
const explicitKey = generateSigningKey("custom-kid");
|
|
372
|
+
const dir = await tempDir("explicit-wins");
|
|
373
|
+
const upstream = await buildFakeUpstream([]);
|
|
374
|
+
const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
|
|
375
|
+
try {
|
|
376
|
+
const handle = await startProxy({
|
|
377
|
+
serverId: "demo",
|
|
378
|
+
capabilities: SAMPLE_CAPS,
|
|
379
|
+
policy: { default: "DENY" },
|
|
380
|
+
storagePath: dir,
|
|
381
|
+
signingKey: explicitKey,
|
|
382
|
+
transport: {
|
|
383
|
+
kind: "test",
|
|
384
|
+
upstreamClient: upstream.upstreamClient,
|
|
385
|
+
serverTransport: serverSide,
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
await consumerClient.connect(clientSide);
|
|
389
|
+
try {
|
|
390
|
+
assert.equal(handle.signingKey.kid, "custom-kid");
|
|
391
|
+
// No keys/ subdirectory should have been created — disk persistence
|
|
392
|
+
// is skipped when the caller supplied a key explicitly.
|
|
393
|
+
await assert.rejects(
|
|
394
|
+
() => fs.access(path.join(dir, "keys", "public-jwk.json")),
|
|
395
|
+
/ENOENT/,
|
|
396
|
+
);
|
|
397
|
+
} finally {
|
|
398
|
+
await consumerClient.close();
|
|
399
|
+
await handle.stop();
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
await upstream.upstreamClient.close();
|
|
403
|
+
await upstream.upstreamServer.close();
|
|
404
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("storagePath unset ⇒ defaults to ~/.strix-mcp-proxy/<serverId> with persistent key", async () => {
|
|
409
|
+
// v0.1.4: leaving storagePath undefined no longer falls back to ephemeral.
|
|
410
|
+
// The proxy now defaults storagePath to a per-serverId persistent path so
|
|
411
|
+
// receipts on disk are always verifiable across restarts. Use a tmpdir
|
|
412
|
+
// override here instead of HOME so the test stays hermetic.
|
|
413
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-default-"));
|
|
414
|
+
try {
|
|
415
|
+
const ctx = await startTestProxy({
|
|
416
|
+
allowList: ["mcp.demo.read_thing"],
|
|
417
|
+
storagePath: dir,
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
assert.ok(ctx.proxy.signingKey, "signingKey must be present by default");
|
|
421
|
+
assert.equal(ctx.proxy.signingKey.kid, "strix-demo");
|
|
422
|
+
// The key file must exist on disk where the verifier expects it.
|
|
423
|
+
const jwkPath = path.join(dir, "keys", "public-jwk.json");
|
|
424
|
+
const jwk = JSON.parse(await fs.readFile(jwkPath, "utf8"));
|
|
425
|
+
assert.equal(jwk.kid, "strix-demo");
|
|
426
|
+
assert.equal(jwk.kty, "OKP");
|
|
427
|
+
} finally {
|
|
428
|
+
await ctx.cleanup();
|
|
429
|
+
}
|
|
430
|
+
} finally {
|
|
431
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("storagePath: null ⇒ explicit ephemeral opt-out (no disk writes)", async () => {
|
|
436
|
+
// Documented escape hatch for callers who really do want an in-memory
|
|
437
|
+
// ephemeral key (CI smoke runs, demos). Receipts in this mode are chain-
|
|
438
|
+
// coherent within the session but unverifiable across restarts; that's
|
|
439
|
+
// the trade-off the caller is opting into.
|
|
440
|
+
const ctx = await startTestProxy({
|
|
441
|
+
allowList: ["mcp.demo.read_thing"],
|
|
442
|
+
storagePath: null,
|
|
443
|
+
});
|
|
444
|
+
try {
|
|
445
|
+
assert.ok(ctx.proxy.signingKey, "signingKey must still be present (ephemeral)");
|
|
446
|
+
assert.equal(ctx.proxy.signingKey.kid, "strix-demo");
|
|
447
|
+
} finally {
|
|
448
|
+
await ctx.cleanup();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// ─── Approval resolution (JSON-config friendly approver types) ────────
|
|
453
|
+
//
|
|
454
|
+
// Pre-0.1.3 startProxy only accepted `approval.prompt` as a function —
|
|
455
|
+
// which made the JSON-config path useless for headless approval, since
|
|
456
|
+
// JSON can't carry a function. 0.1.3 adds string-typed approvers
|
|
457
|
+
// (`type: "file" | "auto" | "terminal"`) that the proxy translates to
|
|
458
|
+
// the appropriate @strixgov/tool-gateway primitive. These tests pin the
|
|
459
|
+
// resolution rules.
|
|
460
|
+
|
|
461
|
+
async function startProxyWithApproval(approval, extraOpts = {}) {
|
|
462
|
+
const upstreamCallLog = [];
|
|
463
|
+
const upstream = await buildFakeUpstream(upstreamCallLog);
|
|
464
|
+
const { consumerClient, clientSide, serverSide } = await buildConsumerClient();
|
|
465
|
+
|
|
466
|
+
// v0.1.4: storagePath now defaults to ~/.strix-mcp-proxy/<serverId>, so
|
|
467
|
+
// approval-config tests would pollute the dev's home dir unless we route
|
|
468
|
+
// to a tmpdir. Approval-resolution tests don't care about the path —
|
|
469
|
+
// they only check that the approver function is wired correctly — so a
|
|
470
|
+
// tmpdir override is the right boundary here. Callers can still pass
|
|
471
|
+
// their own `storagePath` via extraOpts.
|
|
472
|
+
const tmpStoragePath =
|
|
473
|
+
extraOpts.storagePath === undefined
|
|
474
|
+
? await fs.mkdtemp(path.join(os.tmpdir(), "strix-proxy-approval-"))
|
|
475
|
+
: extraOpts.storagePath;
|
|
476
|
+
|
|
477
|
+
const handle = await startProxy({
|
|
478
|
+
serverId: "demo",
|
|
479
|
+
capabilities: SAMPLE_CAPS,
|
|
480
|
+
// write_thing is MEDIUM. With `MEDIUM → APPROVAL_REQUIRED`, every
|
|
481
|
+
// write_thing call should hit the approval path so these tests can
|
|
482
|
+
// observe whether the approver was wired correctly.
|
|
483
|
+
policy: {
|
|
484
|
+
rules: { "mcp.demo.read_thing": "ALLOW" },
|
|
485
|
+
riskOverrides: { MEDIUM: "APPROVAL_REQUIRED" },
|
|
486
|
+
default: "DENY",
|
|
487
|
+
},
|
|
488
|
+
approval,
|
|
489
|
+
transport: {
|
|
490
|
+
kind: "test",
|
|
491
|
+
upstreamClient: upstream.upstreamClient,
|
|
492
|
+
serverTransport: serverSide,
|
|
493
|
+
},
|
|
494
|
+
...extraOpts,
|
|
495
|
+
storagePath: tmpStoragePath,
|
|
496
|
+
});
|
|
497
|
+
await consumerClient.connect(clientSide);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
consumer: consumerClient,
|
|
501
|
+
proxy: handle,
|
|
502
|
+
upstream,
|
|
503
|
+
upstreamCallLog,
|
|
504
|
+
storagePath: tmpStoragePath,
|
|
505
|
+
cleanup: async () => {
|
|
506
|
+
try { await consumerClient.close(); } catch { /* */ }
|
|
507
|
+
try { await handle.stop(); } catch { /* */ }
|
|
508
|
+
try { await upstream.upstreamClient.close(); } catch { /* */ }
|
|
509
|
+
try { await upstream.upstreamServer.close(); } catch { /* */ }
|
|
510
|
+
if (tmpStoragePath && tmpStoragePath.startsWith(os.tmpdir())) {
|
|
511
|
+
try { await fs.rm(tmpStoragePath, { recursive: true, force: true }); } catch { /* */ }
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
test("approval.type: 'auto' auto-approves MEDIUM calls and they reach the upstream", async () => {
|
|
518
|
+
const ctx = await startProxyWithApproval({ enabled: true, type: "auto" });
|
|
519
|
+
try {
|
|
520
|
+
const result = await ctx.consumer.callTool({
|
|
521
|
+
name: "write_thing",
|
|
522
|
+
arguments: { id: "x" },
|
|
523
|
+
});
|
|
524
|
+
assert.equal(result.content[0].text, "executed write_thing");
|
|
525
|
+
assert.equal(ctx.upstreamCallLog.length, 1, "auto-approved write must reach the upstream");
|
|
526
|
+
} finally {
|
|
527
|
+
await ctx.cleanup();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("approval.autoApprove: true (long form) behaves like type: 'auto'", async () => {
|
|
532
|
+
// Pin that the long form continues to work — operators who already
|
|
533
|
+
// wrote `autoApprove: true` shouldn't get broken by the new resolver.
|
|
534
|
+
const ctx = await startProxyWithApproval({ enabled: true, autoApprove: true });
|
|
535
|
+
try {
|
|
536
|
+
await ctx.consumer.callTool({ name: "write_thing", arguments: {} });
|
|
537
|
+
assert.equal(ctx.upstreamCallLog.length, 1);
|
|
538
|
+
} finally {
|
|
539
|
+
await ctx.cleanup();
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("approval.type: 'file' wires up a fileApprover and writes a request file to disk", async () => {
|
|
544
|
+
const requestDir = await tempDir("file-approver");
|
|
545
|
+
const ctx = await startProxyWithApproval(
|
|
546
|
+
{
|
|
547
|
+
enabled: true,
|
|
548
|
+
type: "file",
|
|
549
|
+
requestDir,
|
|
550
|
+
timeoutMs: 250, // short timeout so the test doesn't hang
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
try {
|
|
554
|
+
// No out-of-band approval response is written → expect timeout → DENY.
|
|
555
|
+
// What we're verifying here is structural: the proxy did the request-file
|
|
556
|
+
// write, which means the fileApprover was constructed and called.
|
|
557
|
+
await assert.rejects(
|
|
558
|
+
() => ctx.consumer.callTool({ name: "write_thing", arguments: { id: "x" } }),
|
|
559
|
+
/denied|timeout/i,
|
|
560
|
+
);
|
|
561
|
+
assert.equal(ctx.upstreamCallLog.length, 0, "denied call must not reach upstream");
|
|
562
|
+
} finally {
|
|
563
|
+
await ctx.cleanup();
|
|
564
|
+
await fs.rm(requestDir, { recursive: true, force: true });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("approval.type: 'file' with no requestDir defaults to <storagePath>/approvals", async () => {
|
|
569
|
+
const storagePath = await tempDir("file-approver-default");
|
|
570
|
+
const ctx = await startProxyWithApproval(
|
|
571
|
+
{ enabled: true, type: "file", timeoutMs: 250 },
|
|
572
|
+
{ storagePath },
|
|
573
|
+
);
|
|
574
|
+
try {
|
|
575
|
+
await assert.rejects(
|
|
576
|
+
() => ctx.consumer.callTool({ name: "write_thing", arguments: {} }),
|
|
577
|
+
/denied|timeout/i,
|
|
578
|
+
);
|
|
579
|
+
// fileApprover cleans up request/response files on each call, so we
|
|
580
|
+
// can't assert their persistence. But the approvals directory itself
|
|
581
|
+
// is created on first request — that's enough structural evidence.
|
|
582
|
+
const dirExists = await fs
|
|
583
|
+
.access(path.join(storagePath, "approvals"))
|
|
584
|
+
.then(() => true)
|
|
585
|
+
.catch(() => false);
|
|
586
|
+
assert.ok(dirExists, "<storagePath>/approvals directory must be created");
|
|
587
|
+
} finally {
|
|
588
|
+
await ctx.cleanup();
|
|
589
|
+
await fs.rm(storagePath, { recursive: true, force: true });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("approval.type: 'file' with ~/ requestDir expands to os.homedir()", async () => {
|
|
594
|
+
// We don't want to actually write to the developer's home directory in
|
|
595
|
+
// a test, so use a custom HOME via os.tmpdir trick: temporarily replace
|
|
596
|
+
// os.homedir's return value via env. node's os.homedir respects $HOME
|
|
597
|
+
// on Unix and $USERPROFILE on Windows; setting both keeps the test
|
|
598
|
+
// platform-agnostic.
|
|
599
|
+
const fakeHome = await tempDir("fake-home");
|
|
600
|
+
const origHome = process.env.HOME;
|
|
601
|
+
const origUserProfile = process.env.USERPROFILE;
|
|
602
|
+
process.env.HOME = fakeHome;
|
|
603
|
+
process.env.USERPROFILE = fakeHome;
|
|
604
|
+
try {
|
|
605
|
+
const ctx = await startProxyWithApproval({
|
|
606
|
+
enabled: true,
|
|
607
|
+
type: "file",
|
|
608
|
+
requestDir: "~/strix-approvals-test",
|
|
609
|
+
timeoutMs: 250,
|
|
610
|
+
});
|
|
611
|
+
try {
|
|
612
|
+
await assert.rejects(() => ctx.consumer.callTool({ name: "write_thing", arguments: {} }));
|
|
613
|
+
const exists = await fs
|
|
614
|
+
.access(path.join(fakeHome, "strix-approvals-test"))
|
|
615
|
+
.then(() => true)
|
|
616
|
+
.catch(() => false);
|
|
617
|
+
assert.ok(exists, "~/ in approval.requestDir must expand to homedir");
|
|
618
|
+
} finally {
|
|
619
|
+
await ctx.cleanup();
|
|
620
|
+
}
|
|
621
|
+
} finally {
|
|
622
|
+
process.env.HOME = origHome;
|
|
623
|
+
process.env.USERPROFILE = origUserProfile;
|
|
624
|
+
await fs.rm(fakeHome, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("approval.type: 'terminal' is the explicit legacy default (passes through; denies in non-TTY)", async () => {
|
|
629
|
+
// Under `node --test` neither stdio end is a TTY, so terminalApprove's
|
|
630
|
+
// "non-TTY → PROMPT_FAILED" guard fires immediately and nothing is written
|
|
631
|
+
// to stdout. That guard used to be unreachable (it compared `isTTY ===
|
|
632
|
+
// false`, but Node leaves `isTTY` undefined on a pipe rather than setting
|
|
633
|
+
// it false), so this test instead fell through to a real prompt banner
|
|
634
|
+
// written into the runner's own serialized stdout channel — which is what
|
|
635
|
+
// made this FILE intermittently fail with "Unable to deserialize cloned
|
|
636
|
+
// data", ~1% of runs and much more often under CPU load. See
|
|
637
|
+
// tool-gateway's approval.test.mjs for the guard's own regression pins.
|
|
638
|
+
//
|
|
639
|
+
// We're pinning "type: 'terminal' is passed through to the gateway and
|
|
640
|
+
// denies headlessly", not the specific deny reason — hence the loose regex.
|
|
641
|
+
// timeoutMs stays low so a future regression that DOES reach the prompt
|
|
642
|
+
// path fails fast instead of sitting for 60s.
|
|
643
|
+
const ctx = await startProxyWithApproval({
|
|
644
|
+
enabled: true,
|
|
645
|
+
type: "terminal",
|
|
646
|
+
timeoutMs: 250,
|
|
647
|
+
});
|
|
648
|
+
try {
|
|
649
|
+
await assert.rejects(
|
|
650
|
+
() => ctx.consumer.callTool({ name: "write_thing", arguments: {} }),
|
|
651
|
+
/denied|timeout/i,
|
|
652
|
+
);
|
|
653
|
+
assert.equal(ctx.upstreamCallLog.length, 0);
|
|
654
|
+
} finally {
|
|
655
|
+
await ctx.cleanup();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("approval.type: unknown ⇒ startProxy throws at startup (fail-fast, not silently fail-closed)", async () => {
|
|
660
|
+
// The whole point of typed config is to surface misconfiguration BEFORE
|
|
661
|
+
// the proxy starts denying calls in production. Operators who type
|
|
662
|
+
// `"type": "filee"` should see an immediate startup error, not a silent
|
|
663
|
+
// deny loop.
|
|
664
|
+
const upstream = await buildFakeUpstream([]);
|
|
665
|
+
const { serverSide } = await buildConsumerClient();
|
|
666
|
+
try {
|
|
667
|
+
await assert.rejects(
|
|
668
|
+
() =>
|
|
669
|
+
startProxy({
|
|
670
|
+
serverId: "demo",
|
|
671
|
+
capabilities: SAMPLE_CAPS,
|
|
672
|
+
policy: { default: "DENY" },
|
|
673
|
+
approval: { enabled: true, type: "websocket-pony" },
|
|
674
|
+
transport: {
|
|
675
|
+
kind: "test",
|
|
676
|
+
upstreamClient: upstream.upstreamClient,
|
|
677
|
+
serverTransport: serverSide,
|
|
678
|
+
},
|
|
679
|
+
// storagePath: null so this fail-fast validation test doesn't
|
|
680
|
+
// write a signing key to the dev's home directory before the
|
|
681
|
+
// approval.type error throws.
|
|
682
|
+
storagePath: null,
|
|
683
|
+
}),
|
|
684
|
+
/unknown approval\.type/,
|
|
685
|
+
);
|
|
686
|
+
} finally {
|
|
687
|
+
await upstream.upstreamClient.close();
|
|
688
|
+
await upstream.upstreamServer.close();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
test("approval.prompt (function) wins over approval.type (programmatic caller priority)", async () => {
|
|
693
|
+
// If a programmatic caller (e.g. embedded SDK use) passes a prompt
|
|
694
|
+
// function directly, the proxy's string-to-function translator must
|
|
695
|
+
// NOT overwrite it. Verifies the resolution-order docstring's claim
|
|
696
|
+
// that prompt-fn is rule #1.
|
|
697
|
+
let promptCallCount = 0;
|
|
698
|
+
const customPrompt = async () => {
|
|
699
|
+
promptCallCount++;
|
|
700
|
+
return { approved: true, reason: "USER_APPROVED", approvedBy: "custom" };
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const ctx = await startProxyWithApproval({
|
|
704
|
+
enabled: true,
|
|
705
|
+
type: "auto", // would normally autoApprove without ever calling prompt
|
|
706
|
+
prompt: customPrompt, // but this should win
|
|
707
|
+
});
|
|
708
|
+
try {
|
|
709
|
+
await ctx.consumer.callTool({ name: "write_thing", arguments: {} });
|
|
710
|
+
assert.equal(promptCallCount, 1, "custom prompt must be called, not autoApprove path");
|
|
711
|
+
assert.equal(ctx.upstreamCallLog.length, 1);
|
|
712
|
+
} finally {
|
|
713
|
+
await ctx.cleanup();
|
|
714
|
+
}
|
|
715
|
+
});
|