@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/CHANGELOG.md +93 -0
- package/COMMERCIAL.md +159 -0
- package/LICENSE +91 -0
- package/README.md +234 -0
- package/SECURITY.md +293 -0
- package/bin/strix-mcp-proxy.mjs +156 -0
- package/examples/notion-proxy-config.json +19 -0
- package/package.json +61 -0
- package/src/config.mjs +111 -0
- package/src/index.mjs +19 -0
- package/src/proxy.mjs +212 -0
- package/src/types.d.ts +52 -0
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>;
|