@strixgov/mcp-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/proxy.mjs ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Strix MCP Proxy — core.
3
+ *
4
+ * Architectural shape:
5
+ *
6
+ * MCP Client ──stdio──▶ [Strix Proxy: server] ──governMCPServer──▶ [client] ──stdio──▶ Upstream MCP Server
7
+ *
8
+ * The proxy IS an MCP server (toward the consumer's client) AND an MCP
9
+ * client (toward the upstream tool). Every callTool entering the proxy
10
+ * is routed through `governMCPServer` from `@strixgov/mcp-adapter`,
11
+ * which applies policy, fires the approval gate when needed, executes
12
+ * via the upstream client, and emits a signed Ed25519 receipt.
13
+ *
14
+ * Today (v0.1.0) — Mode 1 (Observed) and Mode 2 (Approval-Gated).
15
+ *
16
+ * Designed for Mode 3 upgrade. The downstream-call seam at
17
+ * `buildUpstreamIface()` is the single place where Mode 3 enforcement
18
+ * will attach in v0.2.0: a future option (`mode: "enforced"`) wraps
19
+ * every outbound call with the `execution_authorization_v1` token
20
+ * mint + transport step from
21
+ * `docs/architecture/mcp-mode-3-enforcement-v1.md` §6, without
22
+ * touching the policy / approval / receipt pipeline above it.
23
+ *
24
+ * Why a proxy at all? Because changing a `mcp-server-command` config
25
+ * value is something every MCP-aware client already supports
26
+ * (Claude Desktop, mcp-cli, IDE integrations); changing the
27
+ * application's authorization model is not. The proxy is the
28
+ * "change your MCP endpoint" wedge — minimal developer friction,
29
+ * maximum coverage of the actual MCP-call surface.
30
+ */
31
+
32
+ import { governMCPServer } from "@strixgov/mcp-adapter";
33
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
34
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
35
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
36
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
37
+ import {
38
+ CallToolRequestSchema,
39
+ ListToolsRequestSchema,
40
+ } from "@modelcontextprotocol/sdk/types.js";
41
+
42
+ /**
43
+ * Start the proxy. Returns a handle with a `stop()` method so callers
44
+ * (CLI, tests) can shut it down cleanly.
45
+ *
46
+ * @param {{
47
+ * upstream: { command: string, args?: string[], env?: Record<string, string> },
48
+ * serverId?: string,
49
+ * capabilities?: Array<object>,
50
+ * policy?: object,
51
+ * approval?: object,
52
+ * storagePath?: string,
53
+ * signingKey?: object,
54
+ * connectedMode?: object,
55
+ * onReceipt?: (receipt: object) => void,
56
+ * onAudit?: (event: { kind: string, detail: object }) => void,
57
+ * transport?: { kind: "stdio" } | { kind: "test", server: object, client: object },
58
+ * }} opts
59
+ * @returns {Promise<{ stop: () => Promise<void>, gateway: object, signingKey: object }>}
60
+ */
61
+ export async function startProxy(opts) {
62
+ if (!opts) {
63
+ throw new TypeError("startProxy: opts is required");
64
+ }
65
+ const serverId = opts.serverId ?? "proxy";
66
+ const transportKind = opts.transport?.kind ?? "stdio";
67
+ // In real (stdio) mode opts.upstream.command is required; in test mode the
68
+ // caller supplies a pre-connected upstream client instead.
69
+ if (transportKind === "stdio") {
70
+ if (!opts.upstream || typeof opts.upstream.command !== "string") {
71
+ throw new TypeError("startProxy: opts.upstream.command is required for stdio mode");
72
+ }
73
+ }
74
+
75
+ // ─── 1. Connect to the upstream MCP server ─────────────────────────────────
76
+ // Test mode lets callers pre-build the upstream client (e.g. with an
77
+ // InMemoryTransport pair) so the proxy can be exercised end-to-end
78
+ // without spawning a real subprocess.
79
+ let upstreamClient;
80
+ if (transportKind === "test" && opts.transport.upstreamClient) {
81
+ upstreamClient = opts.transport.upstreamClient;
82
+ audit(opts, { kind: "upstream.connected", detail: { command: "(test mode)" } });
83
+ } else {
84
+ upstreamClient = new Client(
85
+ { name: `strix-proxy:${serverId}`, version: "0.1.0" },
86
+ { capabilities: {} },
87
+ );
88
+ const upstreamTransport = new StdioClientTransport({
89
+ command: opts.upstream.command,
90
+ args: opts.upstream.args ?? [],
91
+ env: opts.upstream.env ?? process.env,
92
+ });
93
+ await upstreamClient.connect(upstreamTransport);
94
+ audit(opts, { kind: "upstream.connected", detail: { command: opts.upstream.command } });
95
+ }
96
+
97
+ // ─── 2. Build the iface mcp-adapter expects ───────────────────────────────
98
+ const upstreamIface = buildUpstreamIface(upstreamClient, serverId);
99
+
100
+ // ─── 3. Wrap with governMCPServer (Mode 1/2) ───────────────────────────────
101
+ const governed = governMCPServer(upstreamIface, {
102
+ serverId,
103
+ capabilities: opts.capabilities,
104
+ policy: opts.policy ?? { default: "DENY" },
105
+ approval: opts.approval ?? { enabled: false },
106
+ storagePath: opts.storagePath,
107
+ signingKey: opts.signingKey,
108
+ connectedMode: opts.connectedMode,
109
+ });
110
+
111
+ if (typeof opts.onReceipt === "function") {
112
+ governed.gateway.on("receipt", opts.onReceipt);
113
+ }
114
+
115
+ // ─── 4. Expose the proxy as an MCP server toward the consumer ──────────────
116
+ const proxyServer = new Server(
117
+ { name: `strix-proxy:${serverId}`, version: "0.1.0" },
118
+ { capabilities: { tools: {} } },
119
+ );
120
+
121
+ proxyServer.setRequestHandler(ListToolsRequestSchema, async () => {
122
+ const tools = await governed.listTools();
123
+ // governed.listTools returns [{ name, description? }]; the MCP wire
124
+ // spec expects { tools: [{ name, description, inputSchema }] }.
125
+ // Re-fetch the full descriptors from the upstream so callers see
126
+ // the original argument shapes — governance doesn't reshape tools.
127
+ const upstream = await upstreamClient.listTools();
128
+ const upstreamByName = new Map(upstream.tools.map((t) => [t.name, t]));
129
+ return {
130
+ tools: tools.map((t) => upstreamByName.get(t.name) ?? { name: t.name }),
131
+ };
132
+ });
133
+
134
+ proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => {
135
+ const { name, arguments: args } = request.params;
136
+ // Forward actor context if the client put it in _meta — this is how
137
+ // mcp-aware clients attribute calls to a specific agent identity.
138
+ const ctx = {
139
+ actorId: request.params?._meta?.strix_actor_id,
140
+ actorRole: request.params?._meta?.strix_actor_role,
141
+ };
142
+ try {
143
+ const result = await governed.callTool(name, args, ctx);
144
+ return result;
145
+ } catch (err) {
146
+ audit(opts, { kind: "call.denied", detail: { name, code: err?.code, message: err?.message } });
147
+ // Re-throw as an MCP-shaped error so the consumer's client sees
148
+ // a structured failure rather than a generic exception.
149
+ throw err;
150
+ }
151
+ });
152
+
153
+ // ─── 5. Connect the proxy server to its outbound transport ─────────────────
154
+ let proxyTransport;
155
+ if (transportKind === "stdio") {
156
+ proxyTransport = new StdioServerTransport();
157
+ await proxyServer.connect(proxyTransport);
158
+ audit(opts, { kind: "proxy.listening", detail: { serverId, transport: "stdio" } });
159
+ } else if (transportKind === "test") {
160
+ // Test-mode: caller supplies the proxy-server-side Transport
161
+ // (typically an InMemoryTransport linked to a paired transport
162
+ // they hand to a test Client). Lets the test suite drive the
163
+ // proxy end-to-end without spawning child processes.
164
+ if (!opts.transport.serverTransport) {
165
+ throw new TypeError("startProxy: transport.kind='test' requires transport.serverTransport");
166
+ }
167
+ proxyTransport = opts.transport.serverTransport;
168
+ await proxyServer.connect(proxyTransport);
169
+ audit(opts, { kind: "proxy.listening", detail: { serverId, transport: "test" } });
170
+ } else {
171
+ throw new TypeError(`startProxy: unknown transport kind '${transportKind}'`);
172
+ }
173
+
174
+ return {
175
+ gateway: governed.gateway,
176
+ signingKey: governed.signingKey,
177
+ async stop() {
178
+ try { await proxyServer.close(); } catch { /* best-effort */ }
179
+ try { await upstreamClient.close(); } catch { /* best-effort */ }
180
+ audit(opts, { kind: "proxy.stopped", detail: { serverId } });
181
+ },
182
+ };
183
+ }
184
+
185
+ // ─── internal ─────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * The single seam where Mode 3 enforcement will attach in v0.2.0.
189
+ * Today this just forwards to the upstream client. In v0.2.0 a
190
+ * `mode: "enforced"` option will mint an execution_authorization_v1
191
+ * token at this layer and attach it via the chosen transport
192
+ * (header for HTTP upstream, _meta for stdio first-party MCP) per
193
+ * docs/architecture/mcp-mode-3-enforcement-v1.md §6.
194
+ */
195
+ function buildUpstreamIface(client, serverId) {
196
+ return {
197
+ serverId,
198
+ listTools: async () => {
199
+ const out = await client.listTools();
200
+ return out.tools.map((t) => ({ name: t.name, description: t.description }));
201
+ },
202
+ handler: async (name, args) => {
203
+ return await client.callTool({ name, arguments: args ?? {} });
204
+ },
205
+ };
206
+ }
207
+
208
+ function audit(opts, event) {
209
+ if (typeof opts.onAudit === "function") {
210
+ try { opts.onAudit(event); } catch { /* swallow audit-handler errors */ }
211
+ }
212
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * TypeScript surface for @strixgov/mcp-proxy.
3
+ */
4
+
5
+ export interface UpstreamConfig {
6
+ command: string;
7
+ args?: string[];
8
+ env?: Record<string, string>;
9
+ }
10
+
11
+ export interface ProxyOptions {
12
+ upstream: UpstreamConfig;
13
+ serverId?: string;
14
+ capabilities?: Array<{ id: string; name: string; risk: string; mode: string; description?: string }>;
15
+ policy?: {
16
+ rules?: Record<string, "ALLOW" | "DENY" | "APPROVAL_REQUIRED">;
17
+ riskOverrides?: Record<string, "ALLOW" | "DENY" | "APPROVAL_REQUIRED">;
18
+ default?: "ALLOW" | "DENY";
19
+ };
20
+ approval?: {
21
+ enabled?: boolean;
22
+ prompt?: (cap: object, invocation: object, opts: object) => Promise<{ approved: boolean; approver?: string }>;
23
+ timeoutMs?: number;
24
+ };
25
+ storagePath?: string;
26
+ signingKey?: object;
27
+ connectedMode?: { kernelUrl: string; apiKey: string; tenantId: string };
28
+ onReceipt?: (receipt: object) => void;
29
+ onAudit?: (event: { kind: string; detail: object }) => void;
30
+ transport?: { kind: "stdio" } | { kind: "test"; [k: string]: unknown };
31
+ }
32
+
33
+ export interface ProxyHandle {
34
+ stop(): Promise<void>;
35
+ gateway: object;
36
+ signingKey: object;
37
+ }
38
+
39
+ export interface ProxyConfigFile {
40
+ serverId?: string;
41
+ upstream: UpstreamConfig;
42
+ capabilities?: string | Array<object>;
43
+ policy?: ProxyOptions["policy"];
44
+ approval?: ProxyOptions["approval"];
45
+ storagePath?: string;
46
+ }
47
+
48
+ export declare function startProxy(opts: ProxyOptions): Promise<ProxyHandle>;
49
+ export declare function loadConfig(filePath: string): Promise<ProxyConfigFile>;
50
+ export declare function resolveCapabilities(
51
+ capabilities: string | Array<object> | undefined,
52
+ ): Promise<Array<object> | undefined>;