@strixgov/mcp-proxy 0.2.1 → 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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * End-to-end shadow discovery through startProxy (GSD-1 Phase 4).
3
+ *
4
+ * Same two-InMemoryTransport-pair harness as proxy-end-to-end.test.mjs:
5
+ *
6
+ * [Test Client] ─in-memory─▶ [Proxy as Server]
7
+ * [Proxy as Client] ─in-memory─▶ [Fake Upstream]
8
+ *
9
+ * The upstream advertises one tool the companion capability list has
10
+ * classified and one it has NOT (`mystery_tool`). The suite drives real
11
+ * MCP traffic and asserts shadow discovery reports the delta — without
12
+ * perturbing the governance pipeline (verdicts, receipts) it observes.
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+
21
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
22
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
23
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
24
+ import {
25
+ CallToolRequestSchema,
26
+ ListToolsRequestSchema,
27
+ } from "@modelcontextprotocol/sdk/types.js";
28
+
29
+ import { startProxy, SHADOW_MEASUREMENT_DISCLAIMER } from "../src/index.mjs";
30
+
31
+ const SAMPLE_CAPS = [
32
+ { id: "mcp.demo.read_thing", name: "read_thing", risk: "LOW", mode: "READ" },
33
+ { id: "mcp.demo.write_thing", name: "write_thing", risk: "MEDIUM", mode: "WRITE" },
34
+ ];
35
+
36
+ async function buildFakeUpstream() {
37
+ const upstreamServer = new Server(
38
+ { name: "fake-upstream", version: "0.0.1" },
39
+ { capabilities: { tools: {} } },
40
+ );
41
+ upstreamServer.setRequestHandler(ListToolsRequestSchema, async () => ({
42
+ tools: [
43
+ { name: "read_thing", description: "Read a thing", inputSchema: { type: "object" } },
44
+ { name: "write_thing", description: "Write a thing", inputSchema: { type: "object" } },
45
+ { name: "mystery_tool", description: "Unclassified surface", inputSchema: { type: "object" } },
46
+ ],
47
+ }));
48
+ upstreamServer.setRequestHandler(CallToolRequestSchema, async (request) => ({
49
+ content: [{ type: "text", text: `executed ${request.params.name}` }],
50
+ }));
51
+
52
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
53
+ await upstreamServer.connect(serverSide);
54
+ const upstreamClient = new Client(
55
+ { name: "test-upstream-client", version: "0.0.1" },
56
+ { capabilities: {} },
57
+ );
58
+ await upstreamClient.connect(clientSide);
59
+ return { upstreamServer, upstreamClient };
60
+ }
61
+
62
+ async function startTestProxy({ shadowDiscovery, storagePath } = {}) {
63
+ const auditLog = [];
64
+ const receipts = [];
65
+ const upstream = await buildFakeUpstream();
66
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
67
+ const consumerClient = new Client(
68
+ { name: "test-consumer", version: "0.0.1" },
69
+ { capabilities: {} },
70
+ );
71
+
72
+ const resolvedStoragePath =
73
+ storagePath === undefined
74
+ ? await fs.mkdtemp(path.join(os.tmpdir(), "strix-shadow-e2e-"))
75
+ : storagePath;
76
+
77
+ const handle = await startProxy({
78
+ serverId: "demo",
79
+ capabilities: SAMPLE_CAPS,
80
+ // ALLOW everything so the governed pipeline exercises real calls;
81
+ // mystery_tool has no capability row and rides the gateway's
82
+ // generic classification — exactly the case shadow discovery exists
83
+ // to surface.
84
+ policy: {
85
+ rules: {
86
+ "mcp.demo.read_thing": "ALLOW",
87
+ "mcp.demo.write_thing": "ALLOW",
88
+ "mcp.demo.mystery_tool": "ALLOW",
89
+ },
90
+ default: "ALLOW",
91
+ },
92
+ storagePath: resolvedStoragePath,
93
+ shadowDiscovery,
94
+ onReceipt: (r) => receipts.push(r),
95
+ onAudit: (e) => auditLog.push(e),
96
+ transport: {
97
+ kind: "test",
98
+ upstreamClient: upstream.upstreamClient,
99
+ serverTransport: serverSide,
100
+ },
101
+ });
102
+ await consumerClient.connect(clientSide);
103
+
104
+ return {
105
+ consumer: consumerClient,
106
+ proxy: handle,
107
+ auditLog,
108
+ receipts,
109
+ storagePath: resolvedStoragePath,
110
+ cleanup: async () => {
111
+ try { await consumerClient.close(); } catch { /* */ }
112
+ try { await handle.stop(); } catch { /* */ }
113
+ try { await upstream.upstreamServer.close(); } catch { /* */ }
114
+ if (resolvedStoragePath) {
115
+ try { await fs.rm(resolvedStoragePath, { recursive: true, force: true }); } catch { /* */ }
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ test("startup seed records the upstream's unclassified surface", async () => {
122
+ const t = await startTestProxy();
123
+ try {
124
+ const snap = t.proxy.shadowDiscovery.snapshot();
125
+ assert.equal(snap.advertisedToolCount, 3);
126
+ assert.deepEqual(snap.unclassifiedAdvertised, ["mystery_tool"]);
127
+ assert.equal(snap.measurement, SHADOW_MEASUREMENT_DISCLAIMER);
128
+ // The startup seed already audited the unclassified tool.
129
+ const seeded = t.auditLog.filter((e) => e.kind === "shadow.unclassified-tools");
130
+ assert.equal(seeded.length, 1);
131
+ assert.deepEqual(seeded[0].detail.tools, ["mystery_tool"]);
132
+ } finally {
133
+ await t.cleanup();
134
+ }
135
+ });
136
+
137
+ test("real MCP traffic through the proxy lands in the shadow report", async () => {
138
+ const t = await startTestProxy();
139
+ try {
140
+ await t.consumer.listTools();
141
+ await t.consumer.callTool({ name: "read_thing", arguments: {} });
142
+ await t.consumer.callTool({ name: "read_thing", arguments: {} });
143
+ await t.consumer.callTool({ name: "mystery_tool", arguments: {} });
144
+
145
+ const snap = t.proxy.shadowDiscovery.snapshot();
146
+ assert.deepEqual(snap.callCounts, { read_thing: 2, mystery_tool: 1 });
147
+ assert.deepEqual(snap.unclassifiedCallCounts, { mystery_tool: 1 });
148
+
149
+ // The shadow JSONL exists under the receipt store and every line is
150
+ // measurement-stamped.
151
+ const raw = await fs.readFile(t.proxy.shadowDiscoveryLogPath, "utf8");
152
+ const lines = raw.trim().split("\n").map((l) => JSON.parse(l));
153
+ assert.ok(lines.length >= 2);
154
+ for (const line of lines) {
155
+ assert.equal(line.measurement, SHADOW_MEASUREMENT_DISCLAIMER);
156
+ }
157
+ } finally {
158
+ await t.cleanup();
159
+ }
160
+ });
161
+
162
+ test("observation never perturbs governance: receipts still flow for governed calls", async () => {
163
+ const t = await startTestProxy();
164
+ try {
165
+ await t.consumer.callTool({ name: "write_thing", arguments: { v: 1 } });
166
+ assert.ok(t.receipts.length >= 1, "governed call should still emit a receipt");
167
+ // ...and the receipt pipeline's output is not shadow-shaped.
168
+ assert.equal("measurement" in t.receipts[0], false);
169
+ } finally {
170
+ await t.cleanup();
171
+ }
172
+ });
173
+
174
+ test("shadowDiscovery: false opts out cleanly", async () => {
175
+ const t = await startTestProxy({ shadowDiscovery: false });
176
+ try {
177
+ assert.equal(t.proxy.shadowDiscovery, null);
178
+ assert.equal(t.proxy.shadowDiscoveryLogPath, null);
179
+ // Traffic still flows.
180
+ const out = await t.consumer.callTool({ name: "read_thing", arguments: {} });
181
+ assert.ok(out);
182
+ } finally {
183
+ await t.cleanup();
184
+ }
185
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Unit tests for shadow discovery (GSD-1 Phase 4) — the runtime
3
+ * reconciliation layer over static governed-surface discovery.
4
+ *
5
+ * The two honesty rules under test:
6
+ * 1. Observation only — the recorder never throws into the call path.
7
+ * 2. Unsigned measurement, never proof — every snapshot and every log
8
+ * line carries the disclaimer and nothing receipt-shaped.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+
17
+ import {
18
+ createShadowDiscovery,
19
+ SHADOW_LOG_BASENAME,
20
+ SHADOW_MEASUREMENT_DISCLAIMER,
21
+ } from "../src/shadow-discovery.mjs";
22
+
23
+ const CAPS = [
24
+ { id: "mcp.demo.read_thing", name: "read_thing", risk: "LOW", mode: "READ" },
25
+ { id: "mcp.demo.write_thing", name: "write_thing", risk: "MEDIUM", mode: "WRITE" },
26
+ ];
27
+
28
+ test("advertised tools split into classified vs unclassified", () => {
29
+ const shadow = createShadowDiscovery({ serverId: "demo", capabilities: CAPS });
30
+ shadow.recordToolList([
31
+ { name: "read_thing", description: "classified" },
32
+ { name: "write_thing", description: "classified" },
33
+ { name: "mystery_tool", description: "nobody classified this" },
34
+ ]);
35
+ const snap = shadow.snapshot();
36
+ assert.equal(snap.advertisedToolCount, 3);
37
+ assert.equal(snap.classifiedCapabilityCount, 2);
38
+ assert.deepEqual(snap.unclassifiedAdvertised, ["mystery_tool"]);
39
+ });
40
+
41
+ test("calls are counted; unclassified calls surface separately", () => {
42
+ const shadow = createShadowDiscovery({ serverId: "demo", capabilities: CAPS });
43
+ shadow.recordCall("read_thing");
44
+ shadow.recordCall("read_thing");
45
+ shadow.recordCall("mystery_tool");
46
+ const snap = shadow.snapshot();
47
+ assert.deepEqual(snap.callCounts, { read_thing: 2, mystery_tool: 1 });
48
+ assert.deepEqual(snap.unclassifiedCallCounts, { mystery_tool: 1 });
49
+ });
50
+
51
+ test("audit events fire once per unclassified tool, not per call", () => {
52
+ const events = [];
53
+ const shadow = createShadowDiscovery({
54
+ serverId: "demo",
55
+ capabilities: CAPS,
56
+ onAudit: (e) => events.push(e),
57
+ });
58
+ shadow.recordCall("mystery_tool");
59
+ shadow.recordCall("mystery_tool");
60
+ shadow.recordCall("mystery_tool");
61
+ const unclassified = events.filter((e) => e.kind === "shadow.unclassified-call");
62
+ assert.equal(unclassified.length, 1);
63
+ assert.equal(unclassified[0].detail.tool, "mystery_tool");
64
+ });
65
+
66
+ test("every snapshot carries the measurement disclaimer and nothing receipt-shaped", () => {
67
+ const shadow = createShadowDiscovery({ serverId: "demo", capabilities: CAPS });
68
+ shadow.recordCall("mystery_tool");
69
+ const snap = shadow.snapshot();
70
+ assert.equal(snap.measurement, SHADOW_MEASUREMENT_DISCLAIMER);
71
+ for (const forbidden of ["signature", "signingKeyId", "kid", "canonical", "receiptId"]) {
72
+ assert.equal(forbidden in snap, false, `snapshot must not carry ${forbidden}`);
73
+ }
74
+ });
75
+
76
+ test("JSONL shadow log is written under storagePath with the disclaimer on every line", async () => {
77
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "strix-shadow-test-"));
78
+ const shadow = createShadowDiscovery({
79
+ serverId: "demo",
80
+ capabilities: CAPS,
81
+ storagePath: dir,
82
+ now: () => "2026-07-12T00:00:00.000Z",
83
+ });
84
+ shadow.recordToolList([{ name: "mystery_tool" }]);
85
+ shadow.recordCall("mystery_tool");
86
+
87
+ assert.equal(shadow.logPath, path.join(dir, SHADOW_LOG_BASENAME));
88
+ const lines = (await fs.readFile(shadow.logPath, "utf8")).trim().split("\n");
89
+ assert.equal(lines.length, 2);
90
+ const parsed = lines.map((l) => JSON.parse(l));
91
+ for (const entry of parsed) {
92
+ assert.equal(entry.measurement, SHADOW_MEASUREMENT_DISCLAIMER);
93
+ assert.equal(entry.serverId, "demo");
94
+ assert.equal(entry.at, "2026-07-12T00:00:00.000Z");
95
+ }
96
+ assert.equal(parsed[0].kind, "tool-list");
97
+ assert.deepEqual(parsed[0].unclassified, ["mystery_tool"]);
98
+ assert.equal(parsed[1].kind, "unclassified-call");
99
+ assert.equal(parsed[1].tool, "mystery_tool");
100
+
101
+ await fs.rm(dir, { recursive: true, force: true });
102
+ });
103
+
104
+ test("no storagePath → no log file, snapshot still works", () => {
105
+ const shadow = createShadowDiscovery({ serverId: "demo", capabilities: CAPS });
106
+ assert.equal(shadow.logPath, null);
107
+ shadow.recordCall("mystery_tool");
108
+ assert.equal(shadow.snapshot().unclassifiedCallCounts.mystery_tool, 1);
109
+ });
110
+
111
+ test("recorder is defensive: malformed input never throws", () => {
112
+ const shadow = createShadowDiscovery({ serverId: "demo" });
113
+ shadow.recordToolList(undefined);
114
+ shadow.recordToolList([{ notAName: true }, null, { name: "" }]);
115
+ shadow.recordCall(undefined);
116
+ shadow.recordCall("");
117
+ shadow.recordCall(42);
118
+ const snap = shadow.snapshot();
119
+ assert.equal(snap.advertisedToolCount, 0);
120
+ assert.deepEqual(snap.callCounts, {});
121
+ });
122
+
123
+ test("a throwing onAudit handler never propagates into the call path", () => {
124
+ const shadow = createShadowDiscovery({
125
+ serverId: "demo",
126
+ onAudit: () => {
127
+ throw new Error("audit handler bug");
128
+ },
129
+ });
130
+ assert.doesNotThrow(() => shadow.recordCall("mystery_tool"));
131
+ });
132
+
133
+ test("no capabilities configured → every tool reports unclassified", () => {
134
+ const shadow = createShadowDiscovery({ serverId: "demo" });
135
+ shadow.recordToolList([{ name: "a" }, { name: "b" }]);
136
+ assert.deepEqual(shadow.snapshot().unclassifiedAdvertised, ["a", "b"]);
137
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Webhook approval channel — the Slack-compatible notification adapter on
3
+ * top of the gateway's file approver.
4
+ *
5
+ * Pins the load-bearing failure semantics:
6
+ * - a delivered notification carries the Slack `text` + structured
7
+ * `strix` block, and the decision STILL flows through the response
8
+ * file (approve + deny);
9
+ * - an unreachable webhook endpoint never approves and never
10
+ * auto-denies — the file decision path keeps working;
11
+ * - bad config (missing/invalid webhookUrl) fails at construction,
12
+ * not at first call.
13
+ */
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import http from "node:http";
17
+ import fs from "node:fs/promises";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+
21
+ import { buildWebhookPayload, webhookApprover } from "../src/webhook-approver.mjs";
22
+
23
+ const CAP = { id: "notion.pages.create", name: "Create page", risk: "MEDIUM", mode: "APPROVAL_REQUIRED" };
24
+ const INV = { capabilityId: "notion.pages.create", action: "call", args: { title: "hi" }, actorId: "agent-1" };
25
+
26
+ async function tmpDir() {
27
+ return await fs.mkdtemp(path.join(os.tmpdir(), "strix-webhook-approver-"));
28
+ }
29
+
30
+ /** Start a capture server on 127.0.0.1; returns { url, bodies, close }. */
31
+ function captureServer() {
32
+ const bodies = [];
33
+ const server = http.createServer((req, res) => {
34
+ let raw = "";
35
+ req.on("data", (c) => (raw += c));
36
+ req.on("end", () => {
37
+ bodies.push(JSON.parse(raw));
38
+ res.writeHead(200).end("ok");
39
+ });
40
+ });
41
+ return new Promise((resolve) => {
42
+ server.listen(0, "127.0.0.1", () => {
43
+ const { port } = server.address();
44
+ resolve({
45
+ url: `http://127.0.0.1:${port}/hook`,
46
+ bodies,
47
+ close: () => new Promise((r) => server.close(r)),
48
+ });
49
+ });
50
+ });
51
+ }
52
+
53
+ /** Watch requestDir for the request file, then write the response file. */
54
+ async function respondWhenAsked(requestDir, response, { pollMs = 25, timeoutMs = 5_000 } = {}) {
55
+ const deadline = Date.now() + timeoutMs;
56
+ while (Date.now() < deadline) {
57
+ const entries = await fs.readdir(requestDir).catch(() => []);
58
+ const req = entries.find((e) => e.endsWith(".request.json"));
59
+ if (req) {
60
+ const requestId = req.replace(".request.json", "");
61
+ await fs.writeFile(
62
+ path.join(requestDir, `${requestId}.response.json`),
63
+ JSON.stringify(response),
64
+ );
65
+ return requestId;
66
+ }
67
+ await new Promise((r) => setTimeout(r, pollMs));
68
+ }
69
+ throw new Error("no request file appeared");
70
+ }
71
+
72
+ test("buildWebhookPayload is Slack-compatible and carries the structured strix block", () => {
73
+ const payload = buildWebhookPayload({
74
+ requestId: "abc123",
75
+ serverId: "notion",
76
+ capability: CAP,
77
+ invocation: INV,
78
+ });
79
+ assert.equal(typeof payload.text, "string"); // Slack incoming-webhook contract
80
+ assert.match(payload.text, /approval required/i);
81
+ assert.match(payload.text, /notion\.pages\.create/);
82
+ assert.match(payload.text, /guard approve abc123/);
83
+ assert.match(payload.text, /guard deny abc123/);
84
+ assert.deepEqual(payload.strix, {
85
+ kind: "approval.requested",
86
+ requestId: "abc123",
87
+ serverId: "notion",
88
+ capabilityId: "notion.pages.create",
89
+ risk: "MEDIUM",
90
+ action: "call",
91
+ });
92
+ });
93
+
94
+ test("notifies the webhook and approves via the response file", async () => {
95
+ const hook = await captureServer();
96
+ const requestDir = await tmpDir();
97
+ const events = [];
98
+ try {
99
+ const prompt = webhookApprover({
100
+ webhookUrl: hook.url,
101
+ serverId: "notion",
102
+ requestDir,
103
+ timeoutMs: 5_000,
104
+ pollIntervalMs: 25,
105
+ onAudit: (e) => events.push(e),
106
+ });
107
+
108
+ const responder = respondWhenAsked(requestDir, { approved: true, approvedBy: "alice" });
109
+ const result = await prompt(CAP, INV, {});
110
+ await responder;
111
+
112
+ assert.equal(result.approved, true);
113
+ assert.equal(result.approvedBy, "alice");
114
+
115
+ assert.equal(hook.bodies.length, 1);
116
+ assert.match(hook.bodies[0].text, /notion\.pages\.create/);
117
+ assert.equal(hook.bodies[0].strix.serverId, "notion");
118
+ assert.ok(events.some((e) => e.kind === "approval.webhook.notified"));
119
+ } finally {
120
+ await hook.close();
121
+ await fs.rm(requestDir, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ test("denies via the response file (webhook delivered)", async () => {
126
+ const hook = await captureServer();
127
+ const requestDir = await tmpDir();
128
+ try {
129
+ const prompt = webhookApprover({
130
+ webhookUrl: hook.url,
131
+ requestDir,
132
+ timeoutMs: 5_000,
133
+ pollIntervalMs: 25,
134
+ });
135
+ const responder = respondWhenAsked(requestDir, { approved: false });
136
+ const result = await prompt(CAP, INV, {});
137
+ await responder;
138
+ assert.equal(result.approved, false);
139
+ assert.equal(result.reason, "USER_DENIED");
140
+ } finally {
141
+ await hook.close();
142
+ await fs.rm(requestDir, { recursive: true, force: true });
143
+ }
144
+ });
145
+
146
+ test("unreachable webhook never approves and never auto-denies — file decision still works", async () => {
147
+ const requestDir = await tmpDir();
148
+ const events = [];
149
+ try {
150
+ const prompt = webhookApprover({
151
+ // Nothing listens here — connection refused on first packet.
152
+ webhookUrl: "http://127.0.0.1:1/hook",
153
+ requestDir,
154
+ timeoutMs: 5_000,
155
+ pollIntervalMs: 25,
156
+ onAudit: (e) => events.push(e),
157
+ });
158
+ const responder = respondWhenAsked(requestDir, { approved: true, approvedBy: "bob" });
159
+ const result = await prompt(CAP, INV, {});
160
+ await responder;
161
+ // The human decision carried the day despite the lost notification.
162
+ assert.equal(result.approved, true);
163
+ assert.ok(events.some((e) => e.kind === "approval.webhook.notify_failed"));
164
+ } finally {
165
+ await fs.rm(requestDir, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test("times out to DENY when nobody responds (decision semantics unchanged)", async () => {
170
+ const hook = await captureServer();
171
+ const requestDir = await tmpDir();
172
+ try {
173
+ const prompt = webhookApprover({
174
+ webhookUrl: hook.url,
175
+ requestDir,
176
+ timeoutMs: 300,
177
+ pollIntervalMs: 25,
178
+ });
179
+ const result = await prompt(CAP, INV, {});
180
+ assert.equal(result.approved, false);
181
+ } finally {
182
+ await hook.close();
183
+ await fs.rm(requestDir, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test("fails at construction on missing or non-http(s) webhookUrl", () => {
188
+ assert.throws(() => webhookApprover({ requestDir: "." }), /requires approval\.webhookUrl/);
189
+ assert.throws(
190
+ () => webhookApprover({ webhookUrl: "not a url", requestDir: "." }),
191
+ /not a valid URL/,
192
+ );
193
+ assert.throws(
194
+ () => webhookApprover({ webhookUrl: "ftp://example.com/x", requestDir: "." }),
195
+ /must be http\(s\)/,
196
+ );
197
+ });