@strixgov/mcp-proxy 0.1.6 → 0.1.8

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
@@ -5,7 +5,48 @@ All notable changes to `@strixgov/mcp-proxy` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.1.6]
8
+ ## [0.1.8]
9
+
10
+ Republish of 0.1.7 with correct dependency specifiers — code is
11
+ byte-identical. `0.1.7` was published with `npm publish`, which ships the
12
+ monorepo's raw `workspace:^…` specifiers in the tarball's package.json;
13
+ consumer installs (`npx -y @strixgov/mcp-proxy`) fail with
14
+ `EUNSUPPORTEDPROTOCOL`. `pnpm publish` rewrites them to real semver at
15
+ pack time (how 0.1.5/0.1.6 shipped correctly). 0.1.7 is deprecated on
16
+ the registry.
17
+
18
+ ### Fixed
19
+ - Published dependency specifiers (`@strixgov/mcp-adapter`,
20
+ `@strixgov/tool-gateway`, `@strixgov/capabilities-mcp-common`) resolve
21
+ again for consumers.
22
+
23
+ ### Added
24
+ - `prepublishOnly` guard: publishing this package with npm now aborts
25
+ with instructions to use pnpm, so this defect class cannot ship again.
26
+
27
+ ## [0.1.7] — published 2026-07-06, DEPRECATED (broken consumer installs)
28
+
29
+ Adds the webhook approval channel. No change to the receipt format or the
30
+ signing flow. **Do not install this version** — see 0.1.8 above.
31
+
32
+ (Version note: this feature was originally staged under 0.1.6, but
33
+ `0.1.6` had already been published to npm on 2026-06-09 as the
34
+ documentation + license-fidelity patch below — so the webhook channel
35
+ ships as 0.1.7.)
36
+
37
+ ### Added
38
+ - **`approval.type: "webhook"`** — Slack-compatible approval notifications
39
+ layered on the existing file approver (`src/webhook-approver.mjs`). Each
40
+ APPROVAL_REQUIRED call still writes the request file and awaits the
41
+ response file (timeout → DENY, unchanged); the webhook POSTs a
42
+ `{ text, strix }` payload telling a human what is blocked and how to
43
+ decide (`npx @strixgov/guard approve <requestId>`). Failure semantics are
44
+ load-bearing and tested: a failed notification never approves and never
45
+ auto-denies; a missing/invalid `webhookUrl` fails at startup, not at first
46
+ call. New config keys: `approval.webhookUrl` (required for the type),
47
+ `approval.requestDir`, `approval.pollIntervalMs`.
48
+
49
+ ## [0.1.6] — published 2026-06-09
9
50
 
10
51
  Documentation + license-fidelity patch. No functional change to the proxy,
11
52
  the receipt format, the signing flow, or any runtime behavior.
package/README.md CHANGED
@@ -110,7 +110,19 @@ for a complete example.
110
110
  "requestDir": "~/.strix-mcp-proxy/notion/approvals",
111
111
  "timeoutMs": 300000 // 5 minutes
112
112
  },
113
- "storagePath": "~/.strix-mcp-proxy/notion" // optional (this IS the default for serverId="notion"); set to `null` for ephemeral
113
+ "storagePath": "~/.strix-mcp-proxy/notion", // optional (this IS the default for serverId="notion"); set to `null` for ephemeral
114
+
115
+ // Optional. Explicit directory for the persistent Ed25519 signing key,
116
+ // independent of storagePath. When set, the proxy generates or loads
117
+ // the key from this path on startup (PKCS8 PEM at 0600 / public JWK
118
+ // at 0644). Tilde is expanded via os.homedir().
119
+ //
120
+ // Useful when you want the signing key in a fixed, predictable location
121
+ // (e.g. a secrets volume) that is separate from the receipts store.
122
+ // Takes precedence over the storagePath-derived key location when both
123
+ // are set. If neither keyPath nor storagePath is set the key is generated
124
+ // in memory and not persisted.
125
+ "keyPath": "~/.strix-mcp-proxy/my-server/keys"
114
126
  }
115
127
  ```
116
128
 
@@ -87,6 +87,7 @@ async function parseArgs(argv) {
87
87
  policy: cfg.policy,
88
88
  approval: cfg.approval,
89
89
  storagePath: cfg.storagePath,
90
+ keyPath: cfg.keyPath,
90
91
  };
91
92
  }
92
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strixgov/mcp-proxy",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -15,10 +15,10 @@
15
15
  "strix-mcp-proxy": "bin/strix-mcp-proxy.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@strixgov/mcp-adapter": "^0.1.0",
19
- "@strixgov/tool-gateway": "^0.4.1",
18
+ "@modelcontextprotocol/sdk": "^1.0.0",
20
19
  "@strixgov/capabilities-mcp-common": "^0.1.0",
21
- "@modelcontextprotocol/sdk": "^1.0.0"
20
+ "@strixgov/mcp-adapter": "^0.1.0",
21
+ "@strixgov/tool-gateway": "^0.4.1"
22
22
  },
23
23
  "keywords": [
24
24
  "strix",
package/src/config.mjs CHANGED
@@ -15,9 +15,17 @@
15
15
  * "default": "DENY"
16
16
  * },
17
17
  * "approval": { "enabled": false },
18
- * "storagePath": "~/.strix-mcp-proxy/receipts"
18
+ * "storagePath": "~/.strix-mcp-proxy/receipts",
19
+ * "keyPath": "~/.strix-mcp-proxy/my-server/keys"
19
20
  * }
20
21
  *
22
+ * `keyPath` (optional): directory for the persistent Ed25519 signing key.
23
+ * When present, the proxy calls loadOrCreateSigningKey({ keyPath }) on startup:
24
+ * - If the key files don't exist they are generated + written there.
25
+ * - If they do exist they are loaded.
26
+ * Tilde is expanded the same way as storagePath.
27
+ * Takes precedence over the storagePath-derived key location when both are set.
28
+ *
21
29
  * The `capabilities` field is either:
22
30
  * - a bare import path (resolved via dynamic import; must export
23
31
  * a named or default ReadonlyArray<McpCapability>), or
@@ -56,6 +64,9 @@ export async function loadConfig(filePath) {
56
64
  if (typeof cfg.storagePath === "string" && cfg.storagePath.startsWith("~")) {
57
65
  cfg.storagePath = cfg.storagePath.replace(/^~/, os.homedir());
58
66
  }
67
+ if (typeof cfg.keyPath === "string" && cfg.keyPath.startsWith("~")) {
68
+ cfg.keyPath = cfg.keyPath.replace(/^~/, os.homedir());
69
+ }
59
70
  return cfg;
60
71
  }
61
72
 
@@ -133,4 +144,35 @@ function validateConfig(cfg, abs) {
133
144
  if (cfg.policy !== undefined && (typeof cfg.policy !== "object" || cfg.policy === null)) {
134
145
  throw new Error(`loadConfig: '${abs}' 'policy' must be an object if present`);
135
146
  }
147
+ if (cfg.keyPath !== undefined && typeof cfg.keyPath !== "string") {
148
+ throw new Error(`loadConfig: '${abs}' 'keyPath' must be a string if present`);
149
+ }
150
+ if (cfg.upstreamCredentials !== undefined) {
151
+ if (
152
+ typeof cfg.upstreamCredentials !== "object" ||
153
+ cfg.upstreamCredentials === null ||
154
+ Array.isArray(cfg.upstreamCredentials)
155
+ ) {
156
+ throw new Error(
157
+ `loadConfig: '${abs}' 'upstreamCredentials' must be an object if present`,
158
+ );
159
+ }
160
+ for (const [envKey, spec] of Object.entries(cfg.upstreamCredentials)) {
161
+ if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
162
+ throw new Error(
163
+ `loadConfig: '${abs}' 'upstreamCredentials["${envKey}"]' must be a spec object with a "from" field`,
164
+ );
165
+ }
166
+ if (spec.from !== "keychain" && spec.from !== "env") {
167
+ throw new Error(
168
+ `loadConfig: '${abs}' 'upstreamCredentials["${envKey}"].from' must be "keychain" or "env"`,
169
+ );
170
+ }
171
+ if (typeof spec.key !== "string" || !spec.key) {
172
+ throw new Error(
173
+ `loadConfig: '${abs}' 'upstreamCredentials["${envKey}"].key' must be a non-empty string`,
174
+ );
175
+ }
176
+ }
177
+ }
136
178
  }
package/src/proxy.mjs CHANGED
@@ -33,6 +33,7 @@ import path from "node:path";
33
33
  import os from "node:os";
34
34
  import { governMCPServer } from "@strixgov/mcp-adapter";
35
35
  import { loadOrCreateSigningKey, fileApprover } from "@strixgov/tool-gateway";
36
+ import { webhookApprover } from "./webhook-approver.mjs";
36
37
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
37
38
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
38
39
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -53,6 +54,7 @@ import {
53
54
  * policy?: object,
54
55
  * approval?: object,
55
56
  * storagePath?: string,
57
+ * keyPath?: string,
56
58
  * signingKey?: object,
57
59
  * connectedMode?: object,
58
60
  * onReceipt?: (receipt: object) => void,
@@ -75,6 +77,41 @@ export async function startProxy(opts) {
75
77
  }
76
78
  }
77
79
 
80
+ // ─── 0. Resolve upstream credentials ─────────────────────────────────────
81
+ // When `upstreamCredentials` is supplied (from a JSON config or programmatic
82
+ // caller), materialise each credential from its declared source — OS keychain
83
+ // or env var — and merge the result into `upstream.env` before the upstream
84
+ // process starts. This keeps secrets out of config files while still feeding
85
+ // them into the upstream server's environment at the last possible moment.
86
+ //
87
+ // Credentials are resolved before the storagePath default / signing-key logic
88
+ // so that any audit events emitted below see the final env shape.
89
+ //
90
+ // @strixgov/mcp-credentials is an optional peer: loaded dynamically so the
91
+ // proxy itself doesn't hard-depend on keytar. When the field is absent we
92
+ // skip this step entirely (no import, no overhead).
93
+ let resolvedUpstreamEnv = opts.upstream?.env;
94
+ if (opts.upstreamCredentials && transportKind === "stdio") {
95
+ let resolveUpstreamCredentials;
96
+ try {
97
+ const mod = await import("@strixgov/mcp-credentials");
98
+ resolveUpstreamCredentials = mod.resolveUpstreamCredentials;
99
+ } catch {
100
+ throw new Error(
101
+ "startProxy: 'upstreamCredentials' requires @strixgov/mcp-credentials. " +
102
+ "Install it alongside the proxy: npm install @strixgov/mcp-credentials",
103
+ );
104
+ }
105
+ const credEnv = await resolveUpstreamCredentials(opts.upstreamCredentials);
106
+ // Merge: explicit upstream.env takes precedence over resolved credentials
107
+ // so operators can still override individual values at the shell level.
108
+ resolvedUpstreamEnv = { ...credEnv, ...(opts.upstream?.env ?? {}) };
109
+ audit(opts, {
110
+ kind: "credentials.resolved",
111
+ detail: { envKeys: Object.keys(credEnv) },
112
+ });
113
+ }
114
+
78
115
  // v0.1.4: default storagePath to ~/.strix-mcp-proxy/<serverId> when unset.
79
116
  //
80
117
  // Pre-0.1.4 left opts.storagePath undefined, which caused governMCPServer
@@ -119,7 +156,7 @@ export async function startProxy(opts) {
119
156
  const upstreamTransport = new StdioClientTransport({
120
157
  command: opts.upstream.command,
121
158
  args: opts.upstream.args ?? [],
122
- env: opts.upstream.env ?? process.env,
159
+ env: resolvedUpstreamEnv ?? process.env,
123
160
  });
124
161
  await upstreamClient.connect(upstreamTransport);
125
162
  audit(opts, { kind: "upstream.connected", detail: { command: opts.upstream.command } });
@@ -142,6 +179,19 @@ export async function startProxy(opts) {
142
179
  // with PKCS8 PEM at 0600 / public JWK at 0644 (mirrors loadOrCreateSigningKey's
143
180
  // own discipline). Caller-supplied `opts.signingKey` still wins.
144
181
  let resolvedSigningKey = opts.signingKey;
182
+ // keyPath takes precedence over storagePath-derived key location when both
183
+ // are present (operators who want a stable key location independent of the
184
+ // receipt store can set keyPath without setting storagePath).
185
+ if (!resolvedSigningKey && opts.keyPath) {
186
+ resolvedSigningKey = await loadOrCreateSigningKey({
187
+ keyPath: opts.keyPath,
188
+ kid: `strix-${serverId}`,
189
+ });
190
+ audit(opts, {
191
+ kind: "signing.key.resolved",
192
+ detail: { source: "keyPath", kid: resolvedSigningKey.kid, keyPath: opts.keyPath },
193
+ });
194
+ }
145
195
  if (!resolvedSigningKey && resolvedStoragePath) {
146
196
  resolvedSigningKey = await loadOrCreateSigningKey({
147
197
  keyPath: path.join(resolvedStoragePath, "keys"),
@@ -179,6 +229,9 @@ export async function startProxy(opts) {
179
229
  // {enabled: true, type: "file",
180
230
  // requestDir: "~/.strix-gateway/approvals", ← request/response file polling (headless)
181
231
  // timeoutMs: 300000}
232
+ // {enabled: true, type: "webhook",
233
+ // webhookUrl: "https://hooks.slack.com/...", ← file polling + Slack-compatible
234
+ // requestDir: "...", timeoutMs: 300000} notification per pending request
182
235
  // {enabled: true, autoApprove: true} ← long form of {type: "auto"}
183
236
  // {enabled: true, prompt: <fn>} ← programmatic caller wins (always)
184
237
  const resolvedApproval = resolveApproval(opts.approval, {
@@ -363,8 +416,32 @@ function resolveApproval(approval, opts) {
363
416
  return approval;
364
417
  }
365
418
 
419
+ if (type === "webhook") {
420
+ // Slack-compatible notification layered on the file primitive: the
421
+ // request/response files stay the decision channel (timeout → DENY
422
+ // unchanged); the webhook only tells a human where to look. A failed
423
+ // notification never approves and never auto-denies — see
424
+ // webhook-approver.mjs for the full failure semantics.
425
+ const requestDirRaw =
426
+ approval.requestDir ??
427
+ (opts.storagePath ? path.join(opts.storagePath, "approvals") : "./.strix-approvals");
428
+ const requestDir = expandHomeTilde(requestDirRaw);
429
+ return {
430
+ ...approval,
431
+ prompt: webhookApprover({
432
+ webhookUrl: approval.webhookUrl,
433
+ serverId: opts.serverId,
434
+ requestDir,
435
+ timeoutMs: approval.timeoutMs,
436
+ pollIntervalMs: approval.pollIntervalMs,
437
+ onAudit: (event) => audit(opts, event),
438
+ fetchImpl: approval.fetchImpl, // programmatic/test seam; not JSON-configurable
439
+ }),
440
+ };
441
+ }
442
+
366
443
  throw new Error(
367
- `startProxy: unknown approval.type '${type}' — expected "terminal", "file", or "auto"`,
444
+ `startProxy: unknown approval.type '${type}' — expected "terminal", "file", "webhook", or "auto"`,
368
445
  );
369
446
  }
370
447
 
package/src/types.d.ts CHANGED
@@ -20,7 +20,15 @@ export interface ProxyOptions {
20
20
  approval?: {
21
21
  enabled?: boolean;
22
22
  prompt?: (cap: object, invocation: object, opts: object) => Promise<{ approved: boolean; approver?: string }>;
23
+ /** JSON-config approver selector: "terminal" | "file" | "webhook" | "auto". */
24
+ type?: "terminal" | "file" | "webhook" | "auto";
25
+ autoApprove?: boolean;
26
+ /** type "file" | "webhook": request/response directory (~ expanded). */
27
+ requestDir?: string;
28
+ /** type "webhook": Slack-compatible notification URL (http/https). Decision still flows via the response file. */
29
+ webhookUrl?: string;
23
30
  timeoutMs?: number;
31
+ pollIntervalMs?: number;
24
32
  };
25
33
  storagePath?: string;
26
34
  signingKey?: object;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Webhook approval channel — Slack-compatible notification on top of the
3
+ * gateway's file approver.
4
+ *
5
+ * Design (deliberate): the tool-gateway `fileApprover` is the headless
6
+ * approval PRIMITIVE — request file written, response file awaited,
7
+ * timeout → DENY. Its contract explicitly says network adapters belong
8
+ * ON TOP of that primitive, not inside it. This module is that adapter:
9
+ *
10
+ * 1. fileApprover writes `<requestId>.request.json` as always.
11
+ * 2. `onRequestWritten` fires a POST to `webhookUrl` with a
12
+ * Slack-incoming-webhook-compatible body (`{ text }`) plus a
13
+ * structured `strix` block for non-Slack consumers.
14
+ * 3. The DECISION still arrives as the response file — written by a
15
+ * human via `npx @strixgov/guard approve <requestId>`, a Slack
16
+ * workflow, a bot, or any out-of-band channel.
17
+ *
18
+ * Failure semantics (load-bearing):
19
+ * - Notification failure NEVER approves and NEVER auto-denies. The
20
+ * request file is already on disk; the approver keeps waiting and
21
+ * the existing timeout → DENY applies. A lost Slack message must
22
+ * not silently deny a legitimate call, and must never allow one.
23
+ * - Missing/invalid `webhookUrl` fails at STARTUP (config error),
24
+ * not at first call — an operator can't misconfigure silently
25
+ * into a notification-less loop.
26
+ *
27
+ * No new dependency: uses global fetch (Node >= 18).
28
+ */
29
+
30
+ import { fileApprover } from "@strixgov/tool-gateway";
31
+
32
+ /** Max characters of the args preview embedded in the notification. */
33
+ const ARGS_PREVIEW_MAX = 240;
34
+
35
+ /**
36
+ * Build the Slack-compatible notification body for a pending approval.
37
+ * Exported for tests (pure).
38
+ *
39
+ * @param {{ requestId: string, serverId?: string, capability: object, invocation: object }} req
40
+ * @returns {{ text: string, strix: object }}
41
+ */
42
+ export function buildWebhookPayload(req) {
43
+ const cap = req.capability ?? {};
44
+ const inv = req.invocation ?? {};
45
+ let argsPreview = "<none>";
46
+ if (inv.args !== undefined && inv.args !== null) {
47
+ try {
48
+ const s = JSON.stringify(inv.args);
49
+ argsPreview = s.length > ARGS_PREVIEW_MAX ? `${s.slice(0, ARGS_PREVIEW_MAX - 3)}...` : s;
50
+ } catch {
51
+ argsPreview = "<unserializable>";
52
+ }
53
+ }
54
+ const lines = [
55
+ ":shield: *Strix approval required*",
56
+ `*Server:* ${req.serverId ?? "unknown"}`,
57
+ `*Capability:* ${cap.id ?? inv.capabilityId ?? "unknown"} (risk: ${cap.risk ?? "unknown"})`,
58
+ `*Action:* ${inv.action ?? "unknown"}`,
59
+ `*Args:* \`${argsPreview}\``,
60
+ `*Request:* \`${req.requestId}\``,
61
+ "",
62
+ `Approve: \`npx @strixgov/guard approve ${req.requestId}\``,
63
+ `Deny: \`npx @strixgov/guard deny ${req.requestId}\``,
64
+ ];
65
+ return {
66
+ text: lines.join("\n"),
67
+ // Structured mirror for non-Slack webhook consumers (Slack ignores
68
+ // unknown top-level keys). Contains no more than the request file does.
69
+ strix: {
70
+ kind: "approval.requested",
71
+ requestId: req.requestId,
72
+ serverId: req.serverId,
73
+ capabilityId: cap.id ?? inv.capabilityId,
74
+ risk: cap.risk,
75
+ action: inv.action,
76
+ },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Construct the webhook approver prompt function.
82
+ *
83
+ * @param {{
84
+ * webhookUrl: string,
85
+ * serverId?: string,
86
+ * requestDir: string,
87
+ * timeoutMs?: number,
88
+ * pollIntervalMs?: number,
89
+ * onAudit?: (event: { kind: string, detail: object }) => void,
90
+ * fetchImpl?: typeof fetch, // test seam only
91
+ * }} opts
92
+ */
93
+ export function webhookApprover(opts) {
94
+ const { webhookUrl } = opts ?? {};
95
+ if (typeof webhookUrl !== "string" || webhookUrl.length === 0) {
96
+ throw new Error(
97
+ 'webhookApprover: approval.type "webhook" requires approval.webhookUrl',
98
+ );
99
+ }
100
+ let parsed;
101
+ try {
102
+ parsed = new URL(webhookUrl);
103
+ } catch {
104
+ throw new Error(`webhookApprover: approval.webhookUrl is not a valid URL: '${webhookUrl}'`);
105
+ }
106
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
107
+ throw new Error(
108
+ `webhookApprover: approval.webhookUrl must be http(s), got '${parsed.protocol}'`,
109
+ );
110
+ }
111
+ const doFetch = opts.fetchImpl ?? fetch;
112
+ const audit = (event) => {
113
+ if (typeof opts.onAudit === "function") {
114
+ try { opts.onAudit(event); } catch { /* never let audit break approval */ }
115
+ }
116
+ };
117
+
118
+ let pendingRequest = null;
119
+ const inner = fileApprover({
120
+ requestDir: opts.requestDir,
121
+ timeoutMs: opts.timeoutMs,
122
+ pollIntervalMs: opts.pollIntervalMs,
123
+ onRequestWritten: async (_requestPath, requestId) => {
124
+ const payload = buildWebhookPayload({ ...pendingRequest, requestId });
125
+ try {
126
+ const res = await doFetch(webhookUrl, {
127
+ method: "POST",
128
+ headers: { "content-type": "application/json" },
129
+ body: JSON.stringify(payload),
130
+ });
131
+ if (res.ok) {
132
+ audit({ kind: "approval.webhook.notified", detail: { requestId, status: res.status } });
133
+ } else {
134
+ audit({
135
+ kind: "approval.webhook.notify_failed",
136
+ detail: { requestId, status: res.status },
137
+ });
138
+ }
139
+ } catch (err) {
140
+ // Best-effort notification: the request file is the source of
141
+ // truth; the decision path (response file / timeout DENY) is
142
+ // unaffected by a lost notification.
143
+ audit({
144
+ kind: "approval.webhook.notify_failed",
145
+ detail: { requestId, error: err?.message ?? String(err) },
146
+ });
147
+ }
148
+ },
149
+ });
150
+
151
+ return async function webhookApproverPrompt(capability, invocation, callOpts) {
152
+ pendingRequest = { serverId: opts.serverId, capability, invocation };
153
+ try {
154
+ return await inner(capability, invocation, callOpts);
155
+ } finally {
156
+ pendingRequest = null;
157
+ }
158
+ };
159
+ }