@mcpspend/proxy 0.4.0 → 0.5.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 CHANGED
@@ -5,13 +5,16 @@ const config_js_1 = require("./config.js");
5
5
  const proxy_js_1 = require("./proxy.js");
6
6
  const init_js_1 = require("./init.js");
7
7
  const snippet_js_1 = require("./snippet.js");
8
- const VERSION = '0.4.0';
8
+ const http_bridge_js_1 = require("./http-bridge.js");
9
+ const VERSION = '0.5.0';
9
10
  const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
10
11
 
11
12
  USAGE
12
13
  mcpspend init [options] Auto-detect MCP clients and wrap their servers
13
14
  mcpspend doctor Diagnose setup (clients, API key, endpoint)
14
15
  mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation
16
+ mcpspend wrap-http [options] Wrap a REMOTE HTTP MCP server (Figma, etc.)
17
+ Speaks stdio to the client, HTTP to the server.
15
18
  mcpspend snippet [options] -- <cmd>...
16
19
  Print a paste-ready JSON snippet for a
17
20
  specific client (Windsurf protobuf, custom).
@@ -37,6 +40,15 @@ WRAP OPTIONS
37
40
  --model <name> Model name for cost attribution (default: mcp-stdio)
38
41
  --disable Run in passthrough mode (no tracking)
39
42
 
43
+ WRAP-HTTP OPTIONS
44
+ --url <url> Required. Remote MCP endpoint (POSTs JSON-RPC there).
45
+ --key <value> API key (overrides config + MCPSPEND_API_KEY)
46
+ --endpoint <url> MCPSpend API endpoint (NOT the remote MCP URL)
47
+ --project <id> Attribute calls to this project
48
+ --agent <name> Agent name reported in dashboards
49
+ --model <name> Model name for cost attribution (default: mcp-http)
50
+ --auth <header> Pass-through Authorization header to the remote MCP
51
+
40
52
  SNIPPET OPTIONS
41
53
  --client <id> One of: claude-desktop, cursor, windsurf, vscode,
42
54
  vscode-workspace, claude-code, generic. Default: generic.
@@ -65,6 +77,10 @@ EXAMPLES
65
77
  # Get a copy-paste snippet for Windsurf (which stores config as protobuf)
66
78
  mcpspend snippet --client windsurf --name playwright -- npx -y @playwright/mcp@latest
67
79
 
80
+ # Wrap a REMOTE HTTP MCP server (figma-remote etc.)
81
+ mcpspend wrap-http --url https://mcp.figma.com --key mcps_live_xxx \
82
+ --auth "Bearer FIGMA_TOKEN"
83
+
68
84
  Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1, MCPSPEND_NO_TELEMETRY=1
69
85
  Config file: ~/.mcpspend/config.json
70
86
  `;
@@ -74,6 +90,7 @@ function parseArgs(argv) {
74
90
  wrapOpts: {},
75
91
  initOpts: { clients: [], dryRun: false, unwrap: false },
76
92
  snippetOpts: { client: 'generic', style: 'npx' },
93
+ httpOpts: {},
77
94
  childArgs: [],
78
95
  };
79
96
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
@@ -131,6 +148,52 @@ function parseArgs(argv) {
131
148
  result.command = 'doctor';
132
149
  return result;
133
150
  }
151
+ if (cmd === 'wrap-http') {
152
+ result.command = 'wrap-http';
153
+ let i = 1;
154
+ while (i < argv.length) {
155
+ const a = argv[i];
156
+ const next = argv[i + 1];
157
+ switch (a) {
158
+ case '--url':
159
+ result.httpOpts.url = next;
160
+ i += 2;
161
+ break;
162
+ case '--key':
163
+ result.httpOpts.apiKey = next;
164
+ i += 2;
165
+ break;
166
+ case '--endpoint':
167
+ result.httpOpts.endpoint = next;
168
+ i += 2;
169
+ break;
170
+ case '--project':
171
+ result.httpOpts.projectId = next;
172
+ i += 2;
173
+ break;
174
+ case '--agent':
175
+ result.httpOpts.agentName = next;
176
+ i += 2;
177
+ break;
178
+ case '--model':
179
+ result.httpOpts.model = next;
180
+ i += 2;
181
+ break;
182
+ case '--auth':
183
+ result.httpOpts.auth = next;
184
+ i += 2;
185
+ break;
186
+ default:
187
+ process.stderr.write(`mcpspend: unknown option ${a}\n`);
188
+ process.exit(2);
189
+ }
190
+ }
191
+ if (!result.httpOpts.url) {
192
+ process.stderr.write('mcpspend: wrap-http requires --url <https://...>\n');
193
+ process.exit(2);
194
+ }
195
+ return result;
196
+ }
134
197
  if (cmd === 'snippet') {
135
198
  result.command = 'snippet';
136
199
  let i = 1;
@@ -276,6 +339,10 @@ async function main() {
276
339
  unwrap: parsed.initOpts.unwrap,
277
340
  });
278
341
  process.stdout.write((0, init_js_1.formatReport)(report, parsed.initOpts.unwrap) + '\n');
342
+ // Fire-and-forget anonymous compat report. Tells us when a client's schema
343
+ // changes so we can ship a fix BEFORE users notice. Opt out via
344
+ // MCPSPEND_NO_TELEMETRY=1. We swallow errors — telemetry is never blocking.
345
+ void (0, init_js_1.reportCompatFromInit)(VERSION, report);
279
346
  if (report.clients.some((c) => c.status === 'error'))
280
347
  process.exit(1);
281
348
  return;
@@ -334,6 +401,21 @@ async function main() {
334
401
  });
335
402
  process.exit(code);
336
403
  }
404
+ if (parsed.command === 'wrap-http') {
405
+ const cfg = (0, config_js_1.loadConfig)({
406
+ apiKey: parsed.httpOpts.apiKey,
407
+ endpoint: parsed.httpOpts.endpoint,
408
+ projectId: parsed.httpOpts.projectId,
409
+ agentName: parsed.httpOpts.agentName,
410
+ });
411
+ const code = await (0, http_bridge_js_1.runHttpBridge)({
412
+ url: parsed.httpOpts.url,
413
+ config: cfg,
414
+ model: parsed.httpOpts.model || process.env.MCPSPEND_MODEL || 'mcp-http',
415
+ remoteAuthHeader: parsed.httpOpts.auth,
416
+ });
417
+ process.exit(code);
418
+ }
337
419
  }
338
420
  // Very small heuristic — keeps snippet code self-contained without exporting
339
421
  // extractServerName from proxy.ts. Same trade-off rules apply: drop versions,
@@ -0,0 +1,8 @@
1
+ import type { Config } from './config.js';
2
+ export interface HttpWrapOptions {
3
+ url: string;
4
+ config: Config;
5
+ model: string;
6
+ remoteAuthHeader?: string;
7
+ }
8
+ export declare function runHttpBridge(opts: HttpWrapOptions): Promise<number>;
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ // Stdio→HTTP bridge for remote MCP servers.
3
+ //
4
+ // `mcpspend wrap-http --url https://figma.com/mcp --key mcps_live_xxx` exposes
5
+ // a local stdio MCP server that proxies every JSON-RPC message to the remote
6
+ // HTTP endpoint and records each tools/call to MCPSpend — same metadata pipeline
7
+ // as the stdio wrap (server name, latency, token estimate). The MCP client (any
8
+ // of them) sees a normal stdio server; the user never has to know it's HTTP
9
+ // behind the scenes.
10
+ //
11
+ // We deliberately do not implement SSE/Streamable response streaming yet — most
12
+ // remote MCP servers in 2026 accept simple POST + JSON response. When we hit
13
+ // a server that needs streaming we'll bolt it on here without changing the
14
+ // stdio surface.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.runHttpBridge = runHttpBridge;
17
+ const node_crypto_1 = require("node:crypto");
18
+ const ingest_js_1 = require("./ingest.js");
19
+ function estimateTokens(payload) {
20
+ if (payload === undefined || payload === null)
21
+ return 0;
22
+ const s = typeof payload === 'string' ? payload : JSON.stringify(payload);
23
+ return Math.max(1, Math.ceil(s.length / 4));
24
+ }
25
+ function inferServerNameFromUrl(url) {
26
+ try {
27
+ const u = new URL(url);
28
+ // figma.com → figma · api.notion.com → notion · mcp.foo.dev → foo
29
+ const host = u.hostname.replace(/^(api|mcp)\./, '');
30
+ const parts = host.split('.');
31
+ return parts.length >= 2 ? parts[parts.length - 2] : host;
32
+ }
33
+ catch {
34
+ return 'remote-mcp';
35
+ }
36
+ }
37
+ async function runHttpBridge(opts) {
38
+ const serverName = inferServerNameFromUrl(opts.url);
39
+ const sessionId = (0, node_crypto_1.randomUUID)();
40
+ const ingest = new ingest_js_1.Ingest(opts.config);
41
+ const pending = new Map();
42
+ if (!opts.config.apiKey) {
43
+ process.stderr.write('[mcpspend wrap-http] no API key configured — running in passthrough mode (no tracking)\n');
44
+ }
45
+ async function forward(msg) {
46
+ const headers = { 'Content-Type': 'application/json' };
47
+ if (opts.remoteAuthHeader)
48
+ headers['Authorization'] = opts.remoteAuthHeader;
49
+ const r = await fetch(opts.url, {
50
+ method: 'POST',
51
+ headers,
52
+ body: JSON.stringify(msg),
53
+ });
54
+ const text = await r.text();
55
+ if (!text.trim())
56
+ return null;
57
+ // The remote may return a single JSON-RPC envelope OR a batch (array).
58
+ // We pass through whatever we got — stdio readers handle batches fine.
59
+ try {
60
+ return JSON.parse(text);
61
+ }
62
+ catch {
63
+ process.stderr.write(`[mcpspend wrap-http] bad response from ${opts.url}: ${text.slice(0, 200)}\n`);
64
+ return null;
65
+ }
66
+ }
67
+ function onCallStart(msg) {
68
+ if (msg.method !== 'tools/call' || msg.id == null || !msg.params)
69
+ return;
70
+ const params = msg.params;
71
+ if (!params.name)
72
+ return;
73
+ pending.set(msg.id, {
74
+ toolName: params.name,
75
+ serverName,
76
+ startedAt: Date.now(),
77
+ inputTokens: estimateTokens(params.arguments),
78
+ });
79
+ }
80
+ function onCallEnd(msg) {
81
+ if (msg.id == null)
82
+ return;
83
+ const call = pending.get(msg.id);
84
+ if (!call)
85
+ return;
86
+ pending.delete(msg.id);
87
+ const latencyMs = Date.now() - call.startedAt;
88
+ const success = !msg.error;
89
+ const outputTokens = estimateTokens(success ? msg.result : msg.error);
90
+ void ingest.enqueue({
91
+ sessionId,
92
+ serverName: call.serverName,
93
+ toolName: call.toolName,
94
+ model: opts.model,
95
+ inputTokens: call.inputTokens,
96
+ outputTokens,
97
+ latencyMs,
98
+ success,
99
+ errorCode: msg.error?.code ? String(msg.error.code) : undefined,
100
+ calledAt: new Date(call.startedAt).toISOString(),
101
+ });
102
+ }
103
+ // Stdio loop: read line-delimited JSON from stdin, forward to HTTP, write
104
+ // response back to stdout.
105
+ let buf = '';
106
+ process.stdin.setEncoding('utf-8');
107
+ process.stdin.on('data', async (chunk) => {
108
+ buf += chunk;
109
+ let nl;
110
+ while ((nl = buf.indexOf('\n')) !== -1) {
111
+ const line = buf.slice(0, nl).trim();
112
+ buf = buf.slice(nl + 1);
113
+ if (!line)
114
+ continue;
115
+ try {
116
+ const msg = JSON.parse(line);
117
+ onCallStart(msg);
118
+ const resp = await forward(msg);
119
+ if (resp) {
120
+ onCallEnd(resp);
121
+ process.stdout.write(JSON.stringify(resp) + '\n');
122
+ }
123
+ }
124
+ catch (err) {
125
+ process.stderr.write(`[mcpspend wrap-http] error: ${err.message}\n`);
126
+ }
127
+ }
128
+ });
129
+ return new Promise((resolve) => {
130
+ process.stdin.on('end', async () => {
131
+ await ingest.flush();
132
+ resolve(0);
133
+ });
134
+ process.on('SIGTERM', () => { void ingest.flush().then(() => resolve(0)); });
135
+ process.on('SIGINT', () => { void ingest.flush().then(() => resolve(0)); });
136
+ });
137
+ }
package/dist/init.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface InitReport {
26
26
  clients: ClientReport[];
27
27
  }
28
28
  export declare function runInit(opts?: InitOptions): InitReport;
29
+ /**
30
+ * Fire a fire-and-forget anonymous compat report. Caller decides when to call —
31
+ * we don't run it inside runInit() to keep that function pure and testable.
32
+ * Opt out: MCPSPEND_NO_TELEMETRY=1.
33
+ */
34
+ export declare function reportCompatFromInit(cliVersion: string, init: InitReport): Promise<void>;
29
35
  export declare function formatReport(report: InitReport, unwrap?: boolean): string;
30
36
  export interface DoctorReport {
31
37
  cliVersion: string;
package/dist/init.js CHANGED
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runInit = runInit;
4
+ exports.reportCompatFromInit = reportCompatFromInit;
4
5
  exports.formatReport = formatReport;
5
6
  exports.runDoctor = runDoctor;
6
7
  exports.formatDoctor = formatDoctor;
8
+ const node_os_1 = require("node:os");
7
9
  const clients_js_1 = require("./clients.js");
8
10
  const config_js_1 = require("./config.js");
11
+ const telemetry_js_1 = require("./telemetry.js");
9
12
  function runInit(opts = {}) {
10
13
  if (opts.apiKey) {
11
14
  (0, config_js_1.saveConfig)({ apiKey: opts.apiKey });
@@ -108,6 +111,38 @@ function runInit(opts = {}) {
108
111
  clients: clientReports,
109
112
  };
110
113
  }
114
+ /**
115
+ * Fire a fire-and-forget anonymous compat report. Caller decides when to call —
116
+ * we don't run it inside runInit() to keep that function pure and testable.
117
+ * Opt out: MCPSPEND_NO_TELEMETRY=1.
118
+ */
119
+ async function reportCompatFromInit(cliVersion, init) {
120
+ const reports = init.clients.map((r) => {
121
+ let fp;
122
+ let format;
123
+ try {
124
+ const parsed = (0, clients_js_1.readClientConfig)(r.path);
125
+ fp = (0, telemetry_js_1.fingerprintConfig)(parsed);
126
+ format = 'json';
127
+ }
128
+ catch {
129
+ format = 'missing';
130
+ }
131
+ return {
132
+ id: r.client,
133
+ status: r.bootstrapped ? 'bootstrapped' : r.status,
134
+ configFormat: format,
135
+ topLevelKeysFingerprint: fp,
136
+ serverCount: r.servers.length,
137
+ wrappedCount: r.servers.filter((s) => s.status === 'wrapped' || s.status === 'already-wrapped').length,
138
+ };
139
+ });
140
+ await (0, telemetry_js_1.sendCompatReport)({
141
+ cliVersion,
142
+ platform: (0, node_os_1.platform)(),
143
+ reports,
144
+ });
145
+ }
111
146
  function formatReport(report, unwrap = false) {
112
147
  const lines = [];
113
148
  const action = unwrap ? 'Unwrap' : 'Wrap';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspend/proxy",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Transparent proxy CLI for MCP servers — tracks tool calls, latency, and cost via MCPSpend.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://mcpspend.com",