@prism-llm-labs/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/dist/cli.js ADDED
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/proxy.ts
5
+ var import_server = require("@modelcontextprotocol/sdk/server/index.js");
6
+ var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
7
+ var import_client = require("@modelcontextprotocol/sdk/client/index.js");
8
+ var import_stdio2 = require("@modelcontextprotocol/sdk/client/stdio.js");
9
+ var import_types = require("@modelcontextprotocol/sdk/types.js");
10
+ var import_mcp_sdk = require("@prism-llm-labs/mcp-sdk");
11
+ var DEFAULT_REDACT_KEYS = ["password", "token", "key", "secret", "api_key", "authorization"];
12
+ function redactObject(obj, keys) {
13
+ if (typeof obj !== "object" || obj === null) return obj;
14
+ if (Array.isArray(obj)) return obj.map((v) => redactObject(v, keys));
15
+ const out = {};
16
+ for (const [k, v] of Object.entries(obj)) {
17
+ out[k] = keys.some((r) => k.toLowerCase().includes(r.toLowerCase())) ? "[REDACTED]" : redactObject(v, keys);
18
+ }
19
+ return out;
20
+ }
21
+ function safeJson(val, redactKeys, maxLen) {
22
+ try {
23
+ const s = JSON.stringify(redactObject(val, redactKeys));
24
+ return s.length <= maxLen ? s : s.slice(0, maxLen) + "\u2026";
25
+ } catch {
26
+ return "[unserializable]";
27
+ }
28
+ }
29
+ function orgFromKey(key) {
30
+ const parts = key.split("_");
31
+ return parts.length >= 4 ? parts[2] ?? "" : "";
32
+ }
33
+ function ts() {
34
+ return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 23);
35
+ }
36
+ var PrismMcpProxy = class {
37
+ constructor(targetCommand, targetArgs, options = {}) {
38
+ this.targetCommand = targetCommand;
39
+ this.targetArgs = targetArgs;
40
+ const key = options.prismKey ?? process.env["PRISM_API_KEY"] ?? "";
41
+ if (!key) {
42
+ process.stderr.write("[prism-proxy] PRISM_API_KEY not set \u2014 telemetry disabled\n");
43
+ }
44
+ this.opts = {
45
+ prismKey: key,
46
+ serverName: options.serverName ?? targetCommand.split(/[\\/]/).pop() ?? "mcp-server",
47
+ project: options.project ?? process.env["PRISM_PROJECT"] ?? "",
48
+ team: options.team ?? process.env["PRISM_TEAM"] ?? "",
49
+ environment: options.environment ?? process.env["PRISM_ENVIRONMENT"] ?? "production",
50
+ sessionId: options.sessionId ?? crypto.randomUUID(),
51
+ sessionBudgetUsd: options.sessionBudgetUsd,
52
+ maxToolCallsPerSession: options.maxToolCallsPerSession,
53
+ captureInputs: options.captureInputs ?? false,
54
+ captureOutputs: options.captureOutputs ?? false,
55
+ costOverrides: options.costOverrides ?? {}
56
+ };
57
+ this.redactKeys = options.redactKeys ?? DEFAULT_REDACT_KEYS;
58
+ this.tracker = new import_mcp_sdk.McpEventTracker(key, this.opts.serverName, options.ingestUrl);
59
+ this.budget = new import_mcp_sdk.SessionBudgetChecker(orgFromKey(key));
60
+ }
61
+ /**
62
+ * Start the proxy. Blocks until the AI client disconnects.
63
+ * Spawn the target server, connect both transports, then wait.
64
+ */
65
+ async run() {
66
+ const targetTransport = new import_stdio2.StdioClientTransport({
67
+ command: this.targetCommand,
68
+ args: this.targetArgs,
69
+ stderr: "pipe"
70
+ // don't let target's stderr pollute our stdout
71
+ });
72
+ const targetClient = new import_client.Client(
73
+ { name: "prism-proxy-client", version: "1.0.0" },
74
+ { capabilities: {} }
75
+ );
76
+ await targetClient.connect(targetTransport);
77
+ const caps = targetClient.getServerCapabilities() ?? {};
78
+ const hasTools = !!caps.tools;
79
+ const hasResources = !!caps.resources;
80
+ const hasPrompts = !!caps.prompts;
81
+ const proxyCaps = {};
82
+ if (hasTools) proxyCaps.tools = {};
83
+ if (hasResources) proxyCaps.resources = { listChanged: false, subscribe: false };
84
+ if (hasPrompts) proxyCaps.prompts = { listChanged: false };
85
+ const proxyServer = new import_server.Server(
86
+ { name: this.opts.serverName, version: "1.0.0" },
87
+ { capabilities: proxyCaps }
88
+ );
89
+ if (hasTools) {
90
+ proxyServer.setRequestHandler(
91
+ import_types.ListToolsRequestSchema,
92
+ () => targetClient.listTools()
93
+ );
94
+ proxyServer.setRequestHandler(
95
+ import_types.CallToolRequestSchema,
96
+ (req) => this._handleToolCall(req.params.name, req.params.arguments ?? {}, targetClient)
97
+ );
98
+ }
99
+ if (hasResources) {
100
+ proxyServer.setRequestHandler(
101
+ import_types.ListResourcesRequestSchema,
102
+ () => targetClient.listResources()
103
+ );
104
+ proxyServer.setRequestHandler(
105
+ import_types.ListResourceTemplatesRequestSchema,
106
+ async () => {
107
+ try {
108
+ return await targetClient.listResourceTemplates();
109
+ } catch {
110
+ return { resourceTemplates: [] };
111
+ }
112
+ }
113
+ );
114
+ proxyServer.setRequestHandler(
115
+ import_types.ReadResourceRequestSchema,
116
+ (req) => this._handleResourceRead(req.params.uri, targetClient)
117
+ );
118
+ }
119
+ if (hasPrompts) {
120
+ proxyServer.setRequestHandler(
121
+ import_types.ListPromptsRequestSchema,
122
+ () => targetClient.listPrompts()
123
+ );
124
+ proxyServer.setRequestHandler(
125
+ import_types.GetPromptRequestSchema,
126
+ (req) => this._handlePromptGet(
127
+ req.params.name,
128
+ req.params.arguments,
129
+ targetClient
130
+ )
131
+ );
132
+ }
133
+ const proxyTransport = new import_stdio.StdioServerTransport();
134
+ await proxyServer.connect(proxyTransport);
135
+ await new Promise((resolve) => {
136
+ proxyTransport.onclose = resolve;
137
+ });
138
+ try {
139
+ await targetClient.close();
140
+ } catch {
141
+ }
142
+ }
143
+ // ── Private: tool call intercept ──────────────────────────────────────────
144
+ async _handleToolCall(name, args, target) {
145
+ await this._checkBudget();
146
+ const estimatedCost = (0, import_mcp_sdk.lookupToolCost)(name, this.opts.costOverrides);
147
+ const start = Date.now();
148
+ const eventTags = {};
149
+ if (this.opts.captureInputs) {
150
+ eventTags["tool_input"] = safeJson(args, this.redactKeys, 1e3);
151
+ }
152
+ let status = "ok";
153
+ let errorMsg = "";
154
+ let result;
155
+ try {
156
+ result = await target.callTool({ name, arguments: args });
157
+ } catch (err) {
158
+ status = "error";
159
+ errorMsg = err instanceof Error ? err.message : String(err);
160
+ this._ship("tool", name, Date.now() - start, estimatedCost, "estimated", status, errorMsg, eventTags);
161
+ throw err;
162
+ }
163
+ const latencyMs = Date.now() - start;
164
+ if (result.isError) {
165
+ status = "error";
166
+ const content = result.content;
167
+ const errBlock = Array.isArray(content) ? content.find((c) => c?.type === "text") : null;
168
+ errorMsg = errBlock?.text ?? "Tool returned error";
169
+ }
170
+ if (this.opts.captureOutputs) {
171
+ eventTags["tool_output"] = safeJson(result.content, this.redactKeys, 1e3);
172
+ }
173
+ this._ship("tool", name, latencyMs, estimatedCost, "estimated", status, errorMsg, eventTags);
174
+ return result;
175
+ }
176
+ // ── Private: resource read intercept ─────────────────────────────────────
177
+ async _handleResourceRead(uri, target) {
178
+ await this._checkBudget();
179
+ const start = Date.now();
180
+ let status = "ok";
181
+ let errorMsg = "";
182
+ let result;
183
+ try {
184
+ result = await target.readResource({ uri });
185
+ } catch (err) {
186
+ status = "error";
187
+ errorMsg = err instanceof Error ? err.message : String(err);
188
+ this._ship("resource", uri, Date.now() - start, 0, "estimated", status, errorMsg, {});
189
+ throw err;
190
+ }
191
+ this._ship("resource", uri, Date.now() - start, 0, "estimated", status, errorMsg, {});
192
+ return result;
193
+ }
194
+ // ── Private: prompt get intercept ────────────────────────────────────────
195
+ async _handlePromptGet(name, args, target) {
196
+ await this._checkBudget();
197
+ const start = Date.now();
198
+ let status = "ok";
199
+ let errorMsg = "";
200
+ let result;
201
+ try {
202
+ result = await target.getPrompt({ name, arguments: args });
203
+ } catch (err) {
204
+ status = "error";
205
+ errorMsg = err instanceof Error ? err.message : String(err);
206
+ this._ship("prompt", name, Date.now() - start, 0, "estimated", status, errorMsg, {});
207
+ throw err;
208
+ }
209
+ this._ship("prompt", name, Date.now() - start, 0, "estimated", status, errorMsg, {});
210
+ return result;
211
+ }
212
+ // ── Private: shared helpers ───────────────────────────────────────────────
213
+ async _checkBudget() {
214
+ await this.budget.checkOrThrow(
215
+ this.opts.sessionId,
216
+ this.opts.sessionBudgetUsd,
217
+ this.opts.maxToolCallsPerSession
218
+ );
219
+ }
220
+ _ship(primitiveType, toolName, latencyMs, costUsd, costStatus, status, errorMessage, tags) {
221
+ this.tracker.capture({
222
+ timestamp: ts(),
223
+ session_id: this.opts.sessionId,
224
+ project_id: this.opts.project,
225
+ team_id: this.opts.team,
226
+ user_id: "",
227
+ environment: this.opts.environment,
228
+ tool_name: toolName,
229
+ downstream_resource: "",
230
+ execution_latency_ms: latencyMs,
231
+ tool_cost_usd: costUsd,
232
+ cost_status: costStatus,
233
+ status,
234
+ error_message: errorMessage,
235
+ llm_request_id: "",
236
+ primitive_type: primitiveType,
237
+ tags
238
+ }).catch(() => {
239
+ });
240
+ }
241
+ };
242
+
243
+ // src/cli.ts
244
+ function usage() {
245
+ process.stderr.write(
246
+ "Usage: mcp-proxy [options] -- <command> [args...]\n\nOptions:\n --prism-key <key> Prism API key (default: $PRISM_API_KEY)\n --server-name <name> Name shown in dashboard\n --project <id> Project ID for attribution\n --team <id> Team attribution tag\n --environment <env> production|staging|development\n --session-id <id> Explicit session ID\n --session-budget <usd> Session budget in USD\n --max-tool-calls <n> Max calls per session\n --capture-inputs Log call arguments to tags\n --capture-outputs Log call results to tags\n --ingest-url <url> Override Prism ingest URL\n --cost <tool=usd,...> Per-tool cost overrides\n\nExample:\n mcp-proxy -- npx @modelcontextprotocol/server-filesystem /path/to/dir\n"
247
+ );
248
+ }
249
+ function parseArgs(argv) {
250
+ const args = argv.slice(2);
251
+ const opts = {};
252
+ let i = 0;
253
+ const dashDash = args.indexOf("--");
254
+ if (dashDash === -1) {
255
+ process.stderr.write("[mcp-proxy] Error: missing -- separator before target command\n");
256
+ return null;
257
+ }
258
+ const target = args.slice(dashDash + 1);
259
+ if (target.length === 0) {
260
+ process.stderr.write("[mcp-proxy] Error: no target command after --\n");
261
+ return null;
262
+ }
263
+ const optArgs = args.slice(0, dashDash);
264
+ while (i < optArgs.length) {
265
+ const flag = optArgs[i];
266
+ switch (flag) {
267
+ case "--prism-key":
268
+ opts.prismKey = optArgs[++i];
269
+ break;
270
+ case "--server-name":
271
+ opts.serverName = optArgs[++i];
272
+ break;
273
+ case "--project":
274
+ opts.project = optArgs[++i];
275
+ break;
276
+ case "--team":
277
+ opts.team = optArgs[++i];
278
+ break;
279
+ case "--environment":
280
+ opts.environment = optArgs[++i];
281
+ break;
282
+ case "--session-id":
283
+ opts.sessionId = optArgs[++i];
284
+ break;
285
+ case "--session-budget": {
286
+ const budget = parseFloat(optArgs[++i] ?? "");
287
+ if (!isNaN(budget)) opts.sessionBudgetUsd = budget;
288
+ break;
289
+ }
290
+ case "--max-tool-calls": {
291
+ const max = parseInt(optArgs[++i] ?? "", 10);
292
+ if (!isNaN(max)) opts.maxToolCallsPerSession = max;
293
+ break;
294
+ }
295
+ case "--capture-inputs":
296
+ opts.captureInputs = true;
297
+ break;
298
+ case "--capture-outputs":
299
+ opts.captureOutputs = true;
300
+ break;
301
+ case "--ingest-url":
302
+ opts.ingestUrl = optArgs[++i];
303
+ break;
304
+ case "--cost": {
305
+ const overrides = {};
306
+ const pairs = (optArgs[++i] ?? "").split(",");
307
+ for (const pair of pairs) {
308
+ const [tool, cost] = pair.split("=");
309
+ if (tool && cost) {
310
+ const usd = parseFloat(cost);
311
+ if (!isNaN(usd)) overrides[tool] = usd;
312
+ }
313
+ }
314
+ opts.costOverrides = overrides;
315
+ break;
316
+ }
317
+ case "--help":
318
+ case "-h":
319
+ usage();
320
+ process.exit(0);
321
+ break;
322
+ default:
323
+ process.stderr.write(`[mcp-proxy] Unknown flag: ${flag}
324
+ `);
325
+ return null;
326
+ }
327
+ i++;
328
+ }
329
+ return { target, opts };
330
+ }
331
+ async function main() {
332
+ const parsed = parseArgs(process.argv);
333
+ if (!parsed) {
334
+ usage();
335
+ process.exit(1);
336
+ }
337
+ const [targetCommand, ...targetArgs] = parsed.target;
338
+ const proxy = new PrismMcpProxy(targetCommand, targetArgs, parsed.opts);
339
+ for (const sig of ["SIGINT", "SIGTERM"]) {
340
+ process.on(sig, () => process.exit(0));
341
+ }
342
+ try {
343
+ await proxy.run();
344
+ } catch (err) {
345
+ process.stderr.write(`[mcp-proxy] Fatal: ${err instanceof Error ? err.message : String(err)}
346
+ `);
347
+ process.exit(1);
348
+ }
349
+ }
350
+ main();
@@ -0,0 +1,94 @@
1
+ interface ProxyOptions {
2
+ /** Prism API key — or set PRISM_API_KEY env var */
3
+ prismKey?: string;
4
+ /**
5
+ * Name shown in the Prism dashboard as the MCP server name.
6
+ * Defaults to the basename of the target command.
7
+ */
8
+ serverName?: string;
9
+ /** Project ID for cost attribution */
10
+ project?: string;
11
+ /** Team attribution tag */
12
+ team?: string;
13
+ /** "production" | "staging" | "development" (default: "production") */
14
+ environment?: string;
15
+ /**
16
+ * Explicit session ID. Auto-generated UUID if omitted.
17
+ * One proxy process = one session = one agent run.
18
+ */
19
+ sessionId?: string;
20
+ /**
21
+ * Session budget in USD. All tool/resource/prompt calls are blocked
22
+ * when combined session cost exceeds this value.
23
+ */
24
+ sessionBudgetUsd?: number;
25
+ /**
26
+ * Maximum MCP primitive calls per session.
27
+ * Blocks further calls when exceeded — loop detection guard.
28
+ */
29
+ maxToolCallsPerSession?: number;
30
+ /**
31
+ * Log call arguments into tags["tool_input"] (truncated to 1000 chars).
32
+ * Opt-in only — disabled by default for privacy.
33
+ */
34
+ captureInputs?: boolean;
35
+ /**
36
+ * Log call results into tags["tool_output"] (truncated to 1000 chars).
37
+ * Opt-in only — disabled by default for privacy.
38
+ */
39
+ captureOutputs?: boolean;
40
+ /**
41
+ * Keys to redact from captured inputs/outputs.
42
+ * Default: ["password", "token", "key", "secret", "api_key", "authorization"]
43
+ */
44
+ redactKeys?: string[];
45
+ /** Override ingest URL (for testing / self-hosted Prism) */
46
+ ingestUrl?: string;
47
+ /** Per-tool cost overrides in USD per call (tool_name → usd) */
48
+ costOverrides?: Record<string, number>;
49
+ }
50
+
51
+ /**
52
+ * PrismMcpProxy — transparent process-level MCP proxy.
53
+ *
54
+ * Architecture:
55
+ * AI Client (Claude Desktop, Cline, etc.)
56
+ * ↕ MCP / stdio
57
+ * PrismMcpProxy ← this package
58
+ * ↕ MCP / stdio
59
+ * Target MCP server (any server, unmodified)
60
+ *
61
+ * The proxy:
62
+ * 1. Spawns the target as a child process via StdioClientTransport
63
+ * 2. Discovers what capabilities the target declares (tools / resources / prompts)
64
+ * 3. Creates a proxy Server that re-advertises those same capabilities
65
+ * 4. Intercepts every tool call, resource read, and prompt get:
66
+ * – checks session budget + loop limits (pre-call)
67
+ * – forwards the request to the target
68
+ * – measures wall-clock latency
69
+ * – ships a fire-and-forget McpEvent to /api/mcp/ingest
70
+ * – returns the target's response unchanged
71
+ * 5. Connects the proxy Server to the caller via StdioServerTransport
72
+ */
73
+
74
+ declare class PrismMcpProxy {
75
+ private readonly targetCommand;
76
+ private readonly targetArgs;
77
+ private readonly opts;
78
+ private readonly tracker;
79
+ private readonly budget;
80
+ private readonly redactKeys;
81
+ constructor(targetCommand: string, targetArgs: string[], options?: ProxyOptions);
82
+ /**
83
+ * Start the proxy. Blocks until the AI client disconnects.
84
+ * Spawn the target server, connect both transports, then wait.
85
+ */
86
+ run(): Promise<void>;
87
+ private _handleToolCall;
88
+ private _handleResourceRead;
89
+ private _handlePromptGet;
90
+ private _checkBudget;
91
+ private _ship;
92
+ }
93
+
94
+ export { PrismMcpProxy, type ProxyOptions };
@@ -0,0 +1,94 @@
1
+ interface ProxyOptions {
2
+ /** Prism API key — or set PRISM_API_KEY env var */
3
+ prismKey?: string;
4
+ /**
5
+ * Name shown in the Prism dashboard as the MCP server name.
6
+ * Defaults to the basename of the target command.
7
+ */
8
+ serverName?: string;
9
+ /** Project ID for cost attribution */
10
+ project?: string;
11
+ /** Team attribution tag */
12
+ team?: string;
13
+ /** "production" | "staging" | "development" (default: "production") */
14
+ environment?: string;
15
+ /**
16
+ * Explicit session ID. Auto-generated UUID if omitted.
17
+ * One proxy process = one session = one agent run.
18
+ */
19
+ sessionId?: string;
20
+ /**
21
+ * Session budget in USD. All tool/resource/prompt calls are blocked
22
+ * when combined session cost exceeds this value.
23
+ */
24
+ sessionBudgetUsd?: number;
25
+ /**
26
+ * Maximum MCP primitive calls per session.
27
+ * Blocks further calls when exceeded — loop detection guard.
28
+ */
29
+ maxToolCallsPerSession?: number;
30
+ /**
31
+ * Log call arguments into tags["tool_input"] (truncated to 1000 chars).
32
+ * Opt-in only — disabled by default for privacy.
33
+ */
34
+ captureInputs?: boolean;
35
+ /**
36
+ * Log call results into tags["tool_output"] (truncated to 1000 chars).
37
+ * Opt-in only — disabled by default for privacy.
38
+ */
39
+ captureOutputs?: boolean;
40
+ /**
41
+ * Keys to redact from captured inputs/outputs.
42
+ * Default: ["password", "token", "key", "secret", "api_key", "authorization"]
43
+ */
44
+ redactKeys?: string[];
45
+ /** Override ingest URL (for testing / self-hosted Prism) */
46
+ ingestUrl?: string;
47
+ /** Per-tool cost overrides in USD per call (tool_name → usd) */
48
+ costOverrides?: Record<string, number>;
49
+ }
50
+
51
+ /**
52
+ * PrismMcpProxy — transparent process-level MCP proxy.
53
+ *
54
+ * Architecture:
55
+ * AI Client (Claude Desktop, Cline, etc.)
56
+ * ↕ MCP / stdio
57
+ * PrismMcpProxy ← this package
58
+ * ↕ MCP / stdio
59
+ * Target MCP server (any server, unmodified)
60
+ *
61
+ * The proxy:
62
+ * 1. Spawns the target as a child process via StdioClientTransport
63
+ * 2. Discovers what capabilities the target declares (tools / resources / prompts)
64
+ * 3. Creates a proxy Server that re-advertises those same capabilities
65
+ * 4. Intercepts every tool call, resource read, and prompt get:
66
+ * – checks session budget + loop limits (pre-call)
67
+ * – forwards the request to the target
68
+ * – measures wall-clock latency
69
+ * – ships a fire-and-forget McpEvent to /api/mcp/ingest
70
+ * – returns the target's response unchanged
71
+ * 5. Connects the proxy Server to the caller via StdioServerTransport
72
+ */
73
+
74
+ declare class PrismMcpProxy {
75
+ private readonly targetCommand;
76
+ private readonly targetArgs;
77
+ private readonly opts;
78
+ private readonly tracker;
79
+ private readonly budget;
80
+ private readonly redactKeys;
81
+ constructor(targetCommand: string, targetArgs: string[], options?: ProxyOptions);
82
+ /**
83
+ * Start the proxy. Blocks until the AI client disconnects.
84
+ * Spawn the target server, connect both transports, then wait.
85
+ */
86
+ run(): Promise<void>;
87
+ private _handleToolCall;
88
+ private _handleResourceRead;
89
+ private _handlePromptGet;
90
+ private _checkBudget;
91
+ private _ship;
92
+ }
93
+
94
+ export { PrismMcpProxy, type ProxyOptions };