@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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2]
11
+
12
+ ### Fixed
13
+ - The published package now ships its test suite (`test/`, except the monorepo-only
14
+ `installed-tarball.test.mjs`) and `scripts/preflight.mjs`, so `npm test` runs in an installed
15
+ copy. `scripts/run-tests.mjs` exits non-zero if no test files are found, if the runner reports
16
+ zero or skipped-only tests, or if fewer than 60 tests run (Node counts an empty test file as one
17
+ passing test).
18
+
10
19
  ## [0.2.1]
11
20
 
12
21
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strixgov/mcp-proxy",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
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
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -43,7 +43,9 @@
43
43
  "files": [
44
44
  "src/",
45
45
  "bin/",
46
- "scripts/run-tests.mjs",
46
+ "scripts/",
47
+ "test/",
48
+ "!test/installed-tarball.test.mjs",
47
49
  "examples/",
48
50
  "README.md",
49
51
  "CHANGELOG.md",
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Fresh-clone guard for @strixgov/mcp-proxy's test suite.
4
+ *
5
+ * Sibling of packages/strixgov-mcp-adapter/scripts/preflight.mjs. The two are
6
+ * deliberately duplicated rather than shared: these packages publish
7
+ * independently, and importing a guard across the workspace would either add a
8
+ * dependency edge that does not exist at runtime or require shipping the guard
9
+ * in the tarball. The REQUIRED list below is this package's own, not a copy.
10
+ *
11
+ * This package resolves its Strix dependencies through pnpm workspace links
12
+ * (`workspace:^` entries resolved in pnpm-lock.yaml as `link:../<pkg>`). In a
13
+ * checkout that has not been installed, those links do not exist, and
14
+ * `node --test` fails with a wall of ERR_MODULE_NOT_FOUND traces that name the
15
+ * missing specifiers but never say what to do about it. Read cold, that looks
16
+ * like a broken package rather than an uninstalled tree.
17
+ *
18
+ * This guard prints the one command that fixes it and exits non-zero instead.
19
+ * It is silent on success, so CI — which runs `pnpm install --frozen-lockfile`
20
+ * first — never sees extra output.
21
+ *
22
+ * Why dynamic import() and not require.resolve():
23
+ * tool-gateway's package.json "exports" map declares only an "import"
24
+ * condition and does not export "./package.json", so BOTH
25
+ * require.resolve("@strixgov/tool-gateway") and
26
+ * require.resolve("@strixgov/tool-gateway/package.json") throw even when the
27
+ * package is correctly installed. import() is also the exact operation src/
28
+ * and the tests perform, which makes a pass here a faithful predicate for
29
+ * "the suite can load" rather than an approximation of it.
30
+ */
31
+
32
+ /**
33
+ * Every specifier the suite needs in order to run.
34
+ *
35
+ * - @strixgov/tool-gateway and @strixgov/mcp-adapter are imported by
36
+ * src/proxy.mjs at module scope.
37
+ * - @modelcontextprotocol/sdk is a registry dependency, not a workspace link,
38
+ * but it fails the same way in an uninstalled tree and deserves the same
39
+ * message. The subpath is checked rather than the bare name because the SDK
40
+ * ships an "exports" map with no root export.
41
+ * - @strixgov/capabilities-mcp-common is dynamically imported by
42
+ * resolveCapabilities() in src/config.mjs and exercised by
43
+ * test/config.test.mjs, so its absence is a real test failure.
44
+ *
45
+ * Deliberately NOT listed: @strixgov/mcp-proxy itself. The only import of it
46
+ * lives inside a generated temp project in test/installed-tarball.test.mjs,
47
+ * which resolves it from that project's own install, not from this workspace.
48
+ */
49
+ const REQUIRED = [
50
+ "@strixgov/tool-gateway",
51
+ "@strixgov/mcp-adapter",
52
+ "@modelcontextprotocol/sdk/client/index.js",
53
+ "@strixgov/capabilities-mcp-common/notion",
54
+ ];
55
+
56
+ /**
57
+ * @param {string} specifier
58
+ * @returns {Promise<boolean>} true if the specifier loads.
59
+ */
60
+ async function resolves(specifier) {
61
+ try {
62
+ await import(specifier);
63
+ return true;
64
+ } catch (err) {
65
+ if (err?.code === "ERR_MODULE_NOT_FOUND") return false;
66
+ // The package is present but threw while loading (a syntax error inside
67
+ // it, a broken transitive import). That is a real failure, but it is not
68
+ // the one this guard exists to explain — let the test run surface it
69
+ // verbatim rather than mislabelling it as "not installed".
70
+ return true;
71
+ }
72
+ }
73
+
74
+ /** @type {string[]} */
75
+ const missing = [];
76
+ for (const specifier of REQUIRED) {
77
+ if (!(await resolves(specifier))) missing.push(specifier);
78
+ }
79
+
80
+ if (missing.length > 0) {
81
+ // Deduplicate to package names: reporting several subpaths of one missing
82
+ // package is noise for what is a single missing install.
83
+ const packages = [
84
+ ...new Set(missing.map((s) => s.split("/").slice(0, 2).join("/"))),
85
+ ];
86
+
87
+ process.stderr.write(
88
+ [
89
+ "",
90
+ " @strixgov/mcp-proxy: dependencies are not installed.",
91
+ "",
92
+ " Cannot resolve:",
93
+ ...packages.map((p) => ` - ${p}`),
94
+ "",
95
+ " This package resolves its Strix dependencies through the pnpm",
96
+ " workspace (and the rest from the registry), so the repository has",
97
+ " to be installed once before its tests can run:",
98
+ "",
99
+ " pnpm install # from the repo root",
100
+ " pnpm --dir packages/strixgov-mcp-proxy test",
101
+ "",
102
+ " Nothing is wrong with the published package: installing it from npm",
103
+ " pulls these in automatically as regular dependencies. This only",
104
+ " affects running the suite from a source checkout.",
105
+ "",
106
+ ].join("\n") + "\n",
107
+ );
108
+ process.exit(1);
109
+ }
@@ -2,13 +2,14 @@
2
2
  /**
3
3
  * `npm test` entry point for @strixgov/mcp-proxy.
4
4
  *
5
- * The suite needs a source checkout (devDependencies and pnpm workspace links),
6
- * so the npm tarball does not ship test/ or scripts/preflight.mjs. Previously
7
- * `test` called preflight.mjs directly, so `npm test` in an installed copy died
8
- * with "Cannot find module scripts/preflight.mjs". This wrapper fails closed
9
- * with an explanation instead: it never exits 0 having run zero tests.
5
+ * Works both in a source checkout and in an installed copy of the published
6
+ * tarball (the suite ships in the package; only test/installed-tarball.test.mjs,
7
+ * which needs sibling monorepo packages, is excluded from the tarball).
8
+ *
9
+ * Fails closed: a run that executes ZERO tests, finds no test files, or cannot
10
+ * read a test count exits non-zero. "No tests" must never read as a pass.
10
11
  */
11
- import { existsSync } from "node:fs";
12
+ import { existsSync, readdirSync } from "node:fs";
12
13
  import { spawnSync } from "node:child_process";
13
14
  import { fileURLToPath } from "node:url";
14
15
 
@@ -16,26 +17,40 @@ const root = fileURLToPath(new URL("..", import.meta.url));
16
17
  const testDir = fileURLToPath(new URL("../test", import.meta.url));
17
18
  const preflight = fileURLToPath(new URL("./preflight.mjs", import.meta.url));
18
19
 
19
- if (!existsSync(testDir) || !existsSync(preflight)) {
20
- process.stderr.write(
21
- [
22
- "",
23
- " @strixgov/mcp-proxy: no tests to run here.",
24
- "",
25
- " The test suite is not included in the published npm package.",
26
- " Run it from a source checkout of https://github.com/Strixgov/strix:",
27
- "",
28
- " pnpm install",
29
- " pnpm --dir packages/strixgov-mcp-proxy test",
30
- "",
31
- " Exiting non-zero on purpose: zero tests run must not read as a pass.",
32
- "",
33
- ].join("\n"),
34
- );
20
+ // Node counts an empty test file as one passing test, so "> 0" is not enough:
21
+ // an emptied suite would still pass. Require a floor close to the real size (66 at 0.2.2).
22
+ const MIN_TESTS = 60;
23
+
24
+ const fail = (msg) => {
25
+ process.stderr.write(`\n @strixgov/mcp-proxy: ${msg}\n\n`);
35
26
  process.exit(1);
36
- }
27
+ };
37
28
 
38
- for (const args of [[preflight], ["--test"]]) {
39
- const r = spawnSync(process.execPath, args, { cwd: root, stdio: "inherit" });
40
- if (r.status !== 0) process.exit(r.status ?? 1);
29
+ const files = existsSync(testDir) ? readdirSync(testDir).filter((f) => f.endsWith(".test.mjs")) : [];
30
+ if (files.length === 0 || !existsSync(preflight)) {
31
+ fail("no tests to run (test/*.test.mjs or scripts/preflight.mjs missing). Exiting non-zero: zero tests run must not read as a pass.");
41
32
  }
33
+
34
+ const pre = spawnSync(process.execPath, [preflight], { cwd: root, stdio: "inherit" });
35
+ if (pre.status !== 0) process.exit(pre.status ?? 1);
36
+
37
+ // Run via the node test runner and read its own summary so we can refuse a zero count.
38
+ const run = spawnSync(process.execPath, ["--test", ...files.map((f) => `test/${f}`)], {
39
+ cwd: root,
40
+ encoding: "utf8",
41
+ maxBuffer: 64 * 1024 * 1024,
42
+ });
43
+ process.stdout.write(run.stdout ?? "");
44
+ process.stderr.write(run.stderr ?? "");
45
+ if (run.status !== 0) process.exit(run.status ?? 1);
46
+
47
+ const text = `${run.stdout}\n${run.stderr}`;
48
+ const count = (label) => {
49
+ const m = text.match(new RegExp(`(?:ℹ|#)\\s+${label}\\s+(\\d+)`));
50
+ return m ? Number(m[1]) : null;
51
+ };
52
+ const tests = count("tests");
53
+ const passed = count("pass");
54
+ if (tests === null || passed === null) fail("could not read the test runner's summary; refusing to report success.");
55
+ if (tests === 0 || passed === 0) fail(`test runner executed ${tests} tests (${passed} passed). Exiting non-zero: zero tests run must not read as a pass.`);
56
+ if (tests < MIN_TESTS) fail(`test runner executed only ${tests} tests; expected at least ${MIN_TESTS}. A truncated or emptied suite must not read as a pass.`);
@@ -0,0 +1,46 @@
1
+ /**
2
+ * CLI sanity tests.
3
+ *
4
+ * Doesn't run the full MCP loop (covered by proxy-end-to-end), just
5
+ * confirms the CLI parses args correctly, surfaces --help / --version,
6
+ * and refuses to start without enough configuration.
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { spawnSync } from "node:child_process";
12
+ import path from "node:path";
13
+ import url from "node:url";
14
+
15
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
16
+ const BIN = path.resolve(__dirname, "..", "bin", "strix-mcp-proxy.mjs");
17
+
18
+ function runCli(args, opts = {}) {
19
+ return spawnSync("node", [BIN, ...args], { encoding: "utf8", timeout: 10_000, ...opts });
20
+ }
21
+
22
+ test("--help prints usage and exits 0", () => {
23
+ const out = runCli(["--help"]);
24
+ assert.equal(out.status, 0);
25
+ assert.match(out.stdout, /strix-mcp-proxy/);
26
+ assert.match(out.stdout, /CONFIG-FILE MODE/);
27
+ assert.match(out.stdout, /FLAG MODE/);
28
+ });
29
+
30
+ test("--version prints the package version and exits 0", () => {
31
+ const out = runCli(["--version"]);
32
+ assert.equal(out.status, 0);
33
+ assert.match(out.stdout, /^strix-mcp-proxy \d+\.\d+\.\d+/);
34
+ });
35
+
36
+ test("no args: refuses to start with a clear error", () => {
37
+ const out = runCli([], { input: "" });
38
+ assert.notEqual(out.status, 0, "must exit non-zero without enough config");
39
+ assert.match(out.stderr, /missing --config or --upstream-command/);
40
+ });
41
+
42
+ test("missing --config file: clear error", () => {
43
+ const out = runCli(["--config", "/nonexistent/strix-proxy-cli-test.json"], { input: "" });
44
+ assert.notEqual(out.status, 0);
45
+ assert.match(out.stderr, /could not read/);
46
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Config loader tests — the CLI's only durable input is the config
3
+ * file, so the loader's validation rules are the proxy's public
4
+ * contract for operator-managed deployments.
5
+ */
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { tmpdir } from "node:os";
12
+
13
+ import { loadConfig, resolveCapabilities } from "../src/config.mjs";
14
+
15
+ async function withTempConfig(contents, fn) {
16
+ const dir = mkdtempSync(path.join(tmpdir(), "strix-proxy-config-"));
17
+ const file = path.join(dir, "proxy.json");
18
+ writeFileSync(file, typeof contents === "string" ? contents : JSON.stringify(contents));
19
+ try { return await fn(file); } finally { rmSync(dir, { recursive: true, force: true }); }
20
+ }
21
+
22
+ test("loadConfig: rejects missing file", async () => {
23
+ await assert.rejects(loadConfig("/this/path/does/not/exist.json"), /could not read/);
24
+ });
25
+
26
+ test("loadConfig: rejects malformed JSON", async () => {
27
+ await withTempConfig("{ not: json }", async (file) => {
28
+ await assert.rejects(loadConfig(file), /not valid JSON/);
29
+ });
30
+ });
31
+
32
+ test("loadConfig: rejects non-object root", async () => {
33
+ await withTempConfig("[]", async (file) => {
34
+ await assert.rejects(loadConfig(file), /must be a JSON object/);
35
+ });
36
+ });
37
+
38
+ test("loadConfig: requires upstream.command", async () => {
39
+ await withTempConfig({ upstream: {} }, async (file) => {
40
+ await assert.rejects(loadConfig(file), /upstream\.command/);
41
+ });
42
+ });
43
+
44
+ test("loadConfig: rejects non-array upstream.args", async () => {
45
+ await withTempConfig({ upstream: { command: "npx", args: "oops" } }, async (file) => {
46
+ await assert.rejects(loadConfig(file), /upstream\.args/);
47
+ });
48
+ });
49
+
50
+ test("loadConfig: rejects non-object policy", async () => {
51
+ await withTempConfig({ upstream: { command: "x" }, policy: "deny-all" }, async (file) => {
52
+ await assert.rejects(loadConfig(file), /'policy' must be an object/);
53
+ });
54
+ });
55
+
56
+ test("loadConfig: expands a leading ~ in storagePath", async () => {
57
+ await withTempConfig(
58
+ { upstream: { command: "npx" }, storagePath: "~/strix-receipts" },
59
+ async (file) => {
60
+ const cfg = await loadConfig(file);
61
+ assert.ok(!cfg.storagePath.startsWith("~"), `expected ~ to expand; got ${cfg.storagePath}`);
62
+ },
63
+ );
64
+ });
65
+
66
+ test("loadConfig: returns the parsed config when valid", async () => {
67
+ await withTempConfig(
68
+ {
69
+ serverId: "notion",
70
+ upstream: { command: "npx", args: ["-y", "@notionhq/notion-mcp-server"] },
71
+ policy: { default: "DENY" },
72
+ },
73
+ async (file) => {
74
+ const cfg = await loadConfig(file);
75
+ assert.equal(cfg.serverId, "notion");
76
+ assert.equal(cfg.upstream.command, "npx");
77
+ assert.deepEqual(cfg.upstream.args, ["-y", "@notionhq/notion-mcp-server"]);
78
+ },
79
+ );
80
+ });
81
+
82
+ test("loadConfig: missing keyPath is fine (undefined passes through)", async () => {
83
+ await withTempConfig({ upstream: { command: "npx" } }, async (file) => {
84
+ const cfg = await loadConfig(file);
85
+ assert.equal(cfg.keyPath, undefined);
86
+ });
87
+ });
88
+
89
+ test("loadConfig: rejects non-string keyPath", async () => {
90
+ await withTempConfig({ upstream: { command: "npx" }, keyPath: 42 }, async (file) => {
91
+ await assert.rejects(loadConfig(file), /'keyPath' must be a string/);
92
+ });
93
+ });
94
+
95
+ test("loadConfig: expands a leading ~ in keyPath", async () => {
96
+ await withTempConfig(
97
+ { upstream: { command: "npx" }, keyPath: "~/.strix-mcp-proxy/my-server/keys" },
98
+ async (file) => {
99
+ const cfg = await loadConfig(file);
100
+ assert.ok(!cfg.keyPath.startsWith("~"), `expected ~ to expand; got ${cfg.keyPath}`);
101
+ assert.ok(cfg.keyPath.includes(".strix-mcp-proxy/my-server/keys"));
102
+ },
103
+ );
104
+ });
105
+
106
+ test("loadConfig: passes a valid string keyPath through unchanged", async () => {
107
+ await withTempConfig(
108
+ { upstream: { command: "npx" }, keyPath: "/opt/strix/keys" },
109
+ async (file) => {
110
+ const cfg = await loadConfig(file);
111
+ assert.equal(cfg.keyPath, "/opt/strix/keys");
112
+ },
113
+ );
114
+ });
115
+
116
+ test("resolveCapabilities: passes an inline array through unchanged", async () => {
117
+ const arr = [{ id: "x", name: "x", risk: "LOW", mode: "READ", description: "x" }];
118
+ const out = await resolveCapabilities(arr);
119
+ assert.equal(out, arr);
120
+ });
121
+
122
+ test("resolveCapabilities: returns undefined for null/undefined input", async () => {
123
+ assert.equal(await resolveCapabilities(undefined), undefined);
124
+ assert.equal(await resolveCapabilities(null), undefined);
125
+ });
126
+
127
+ test("resolveCapabilities: rejects non-string non-array input", async () => {
128
+ await assert.rejects(resolveCapabilities(42), /must be a string import path or an array/);
129
+ });
130
+
131
+ test("resolveCapabilities: resolves a companion-pack module by import path", async () => {
132
+ // Uses the workspace-symlinked package — exercises the real
133
+ // dynamic-import flow the CLI would use in production.
134
+ const caps = await resolveCapabilities("@strixgov/capabilities-mcp-common/notion");
135
+ assert.ok(Array.isArray(caps));
136
+ assert.ok(caps.length > 0);
137
+ assert.ok(caps.every((c) => c.id?.startsWith("mcp.notion.")));
138
+ });
@@ -0,0 +1,69 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const exampleDir = path.resolve(here, "../examples/nvidia-nooa");
9
+
10
+ test("NOOA .mcp.json launches the Strix proxy over stdio with trusted identity", async () => {
11
+ const config = JSON.parse(
12
+ await fs.readFile(path.join(exampleDir, ".mcp.json"), "utf8"),
13
+ );
14
+ const server = config.mcpServers.strix_secure_coding;
15
+ assert.equal(server.transport, "stdio");
16
+ assert.match(server.args.join(" "), /strix-mcp-proxy\.mjs/);
17
+ assert.equal(server.env.STRIX_REQUIRE_TRUSTED_IDENTITY, "true");
18
+ assert.match(server.env.STRIX_TRUSTED_ACTOR_ID, /^spiffe:\/\//);
19
+ assert.equal(server.env.STRIX_TENANT_ID, "nvidia-nooa-demo");
20
+ assert.equal(server.env.STRIX_ENVIRONMENT, "openshell");
21
+ });
22
+
23
+ test("NOOA proxy policy is fail-closed and protects consequential actions", async () => {
24
+ const config = JSON.parse(
25
+ await fs.readFile(path.join(exampleDir, "proxy-config.json"), "utf8"),
26
+ );
27
+ assert.equal(config.policy.default, "DENY");
28
+ assert.equal(
29
+ config.policy.rules["mcp.nooa-secure-coding.inspect_repository"],
30
+ "ALLOW",
31
+ );
32
+ assert.equal(
33
+ config.policy.rules["mcp.nooa-secure-coding.propose_patch"],
34
+ "ALLOW",
35
+ );
36
+ assert.equal(
37
+ config.policy.rules["mcp.nooa-secure-coding.merge_production"],
38
+ "APPROVAL_REQUIRED",
39
+ );
40
+ assert.equal(
41
+ config.policy.rules["mcp.nooa-secure-coding.rotate_credentials"],
42
+ "DENY",
43
+ );
44
+ assert.equal(config.approval.type, "file");
45
+ assert.ok(config.storagePath);
46
+ assert.ok(config.keyPath);
47
+ });
48
+
49
+ test("OpenShell template leaves Python without ordinary downstream egress", async () => {
50
+ const policy = await fs.readFile(
51
+ path.join(exampleDir, "openshell-policy.yaml"),
52
+ "utf8",
53
+ );
54
+ assert.match(policy, /^version: 1/m);
55
+ assert.match(policy, /run_as_user: sandbox/);
56
+ assert.match(policy, /api\.github\.com/);
57
+ assert.match(policy, /protocol: rest/);
58
+ assert.match(policy, /tls: terminate/);
59
+ assert.match(policy, /enforcement: enforce/);
60
+ assert.match(policy, /access: read-only/);
61
+ assert.doesNotMatch(policy, /path: \/usr\/bin\/python/);
62
+ assert.doesNotMatch(policy, /path: \/usr\/local\/bin\/python/);
63
+ });
64
+
65
+ test("NOOA agent uses MCPManager.create_from_server", async () => {
66
+ const source = await fs.readFile(path.join(exampleDir, "nooa_agent.py"), "utf8");
67
+ assert.match(source, /MCPManager\.create_from_server/);
68
+ assert.match(source, /strix_secure_coding/);
69
+ });
@@ -0,0 +1,20 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
9
+
10
+ const covered = (rel) =>
11
+ pkg.files.some((f) => rel === f || (f.endsWith("/") && rel.startsWith(f)));
12
+
13
+ test("every script file the `test` script runs ships in the tarball", () => {
14
+ const refs = [...pkg.scripts.test.matchAll(/\b(scripts\/[\w./-]+\.mjs)\b/g)].map((m) => m[1]);
15
+ assert.ok(refs.length > 0, "test script should reference a script file");
16
+ for (const rel of refs) {
17
+ assert.ok(existsSync(path.join(root, rel)), `${rel} is missing from the repo`);
18
+ assert.ok(covered(rel), `${rel} is used by \`npm test\` but excluded by package.json "files"`);
19
+ }
20
+ });