@mcpspend/proxy
Advanced tools
| import type { Config } from './config.js'; | ||
| export interface HttpWrapOptions { | ||
| url: string; | ||
| config: Config; | ||
| model: string; | ||
| remoteAuthHeader?: string; | ||
| } | ||
| export declare function runHttpBridge(opts: HttpWrapOptions): Promise<number>; |
| "use strict"; | ||
| // Stdio→HTTP bridge for remote MCP servers. | ||
| // | ||
| // `mcpspend wrap-http --url https://figma.com/mcp --key mcps_live_xxx` exposes | ||
| // a local stdio MCP server that proxies every JSON-RPC message to the remote | ||
| // HTTP endpoint and records each tools/call to MCPSpend — same metadata pipeline | ||
| // as the stdio wrap (server name, latency, token estimate). The MCP client (any | ||
| // of them) sees a normal stdio server; the user never has to know it's HTTP | ||
| // behind the scenes. | ||
| // | ||
| // We deliberately do not implement SSE/Streamable response streaming yet — most | ||
| // remote MCP servers in 2026 accept simple POST + JSON response. When we hit | ||
| // a server that needs streaming we'll bolt it on here without changing the | ||
| // stdio surface. | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.runHttpBridge = runHttpBridge; | ||
| const node_crypto_1 = require("node:crypto"); | ||
| const ingest_js_1 = require("./ingest.js"); | ||
| function estimateTokens(payload) { | ||
| if (payload === undefined || payload === null) | ||
| return 0; | ||
| const s = typeof payload === 'string' ? payload : JSON.stringify(payload); | ||
| return Math.max(1, Math.ceil(s.length / 4)); | ||
| } | ||
| function inferServerNameFromUrl(url) { | ||
| try { | ||
| const u = new URL(url); | ||
| // figma.com → figma · api.notion.com → notion · mcp.foo.dev → foo | ||
| const host = u.hostname.replace(/^(api|mcp)\./, ''); | ||
| const parts = host.split('.'); | ||
| return parts.length >= 2 ? parts[parts.length - 2] : host; | ||
| } | ||
| catch { | ||
| return 'remote-mcp'; | ||
| } | ||
| } | ||
| async function runHttpBridge(opts) { | ||
| const serverName = inferServerNameFromUrl(opts.url); | ||
| const sessionId = (0, node_crypto_1.randomUUID)(); | ||
| const ingest = new ingest_js_1.Ingest(opts.config); | ||
| const pending = new Map(); | ||
| if (!opts.config.apiKey) { | ||
| process.stderr.write('[mcpspend wrap-http] no API key configured — running in passthrough mode (no tracking)\n'); | ||
| } | ||
| async function forward(msg) { | ||
| const headers = { 'Content-Type': 'application/json' }; | ||
| if (opts.remoteAuthHeader) | ||
| headers['Authorization'] = opts.remoteAuthHeader; | ||
| const r = await fetch(opts.url, { | ||
| method: 'POST', | ||
| headers, | ||
| body: JSON.stringify(msg), | ||
| }); | ||
| const text = await r.text(); | ||
| if (!text.trim()) | ||
| return null; | ||
| // The remote may return a single JSON-RPC envelope OR a batch (array). | ||
| // We pass through whatever we got — stdio readers handle batches fine. | ||
| try { | ||
| return JSON.parse(text); | ||
| } | ||
| catch { | ||
| process.stderr.write(`[mcpspend wrap-http] bad response from ${opts.url}: ${text.slice(0, 200)}\n`); | ||
| return null; | ||
| } | ||
| } | ||
| function onCallStart(msg) { | ||
| if (msg.method !== 'tools/call' || msg.id == null || !msg.params) | ||
| return; | ||
| const params = msg.params; | ||
| if (!params.name) | ||
| return; | ||
| pending.set(msg.id, { | ||
| toolName: params.name, | ||
| serverName, | ||
| startedAt: Date.now(), | ||
| inputTokens: estimateTokens(params.arguments), | ||
| }); | ||
| } | ||
| function onCallEnd(msg) { | ||
| if (msg.id == null) | ||
| return; | ||
| const call = pending.get(msg.id); | ||
| if (!call) | ||
| return; | ||
| pending.delete(msg.id); | ||
| const latencyMs = Date.now() - call.startedAt; | ||
| const success = !msg.error; | ||
| const outputTokens = estimateTokens(success ? msg.result : msg.error); | ||
| void ingest.enqueue({ | ||
| sessionId, | ||
| serverName: call.serverName, | ||
| toolName: call.toolName, | ||
| model: opts.model, | ||
| inputTokens: call.inputTokens, | ||
| outputTokens, | ||
| latencyMs, | ||
| success, | ||
| errorCode: msg.error?.code ? String(msg.error.code) : undefined, | ||
| calledAt: new Date(call.startedAt).toISOString(), | ||
| }); | ||
| } | ||
| // Stdio loop: read line-delimited JSON from stdin, forward to HTTP, write | ||
| // response back to stdout. | ||
| let buf = ''; | ||
| process.stdin.setEncoding('utf-8'); | ||
| process.stdin.on('data', async (chunk) => { | ||
| buf += chunk; | ||
| let nl; | ||
| while ((nl = buf.indexOf('\n')) !== -1) { | ||
| const line = buf.slice(0, nl).trim(); | ||
| buf = buf.slice(nl + 1); | ||
| if (!line) | ||
| continue; | ||
| try { | ||
| const msg = JSON.parse(line); | ||
| onCallStart(msg); | ||
| const resp = await forward(msg); | ||
| if (resp) { | ||
| onCallEnd(resp); | ||
| process.stdout.write(JSON.stringify(resp) + '\n'); | ||
| } | ||
| } | ||
| catch (err) { | ||
| process.stderr.write(`[mcpspend wrap-http] error: ${err.message}\n`); | ||
| } | ||
| } | ||
| }); | ||
| return new Promise((resolve) => { | ||
| process.stdin.on('end', async () => { | ||
| await ingest.flush(); | ||
| resolve(0); | ||
| }); | ||
| process.on('SIGTERM', () => { void ingest.flush().then(() => resolve(0)); }); | ||
| process.on('SIGINT', () => { void ingest.flush().then(() => resolve(0)); }); | ||
| }); | ||
| } |
+83
-1
@@ -8,3 +8,4 @@ #!/usr/bin/env node | ||
| const snippet_js_1 = require("./snippet.js"); | ||
| const VERSION = '0.4.0'; | ||
| const http_bridge_js_1 = require("./http-bridge.js"); | ||
| const VERSION = '0.5.0'; | ||
| const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION}) | ||
@@ -16,2 +17,4 @@ | ||
| mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation | ||
| mcpspend wrap-http [options] Wrap a REMOTE HTTP MCP server (Figma, etc.) | ||
| Speaks stdio to the client, HTTP to the server. | ||
| mcpspend snippet [options] -- <cmd>... | ||
@@ -42,2 +45,11 @@ Print a paste-ready JSON snippet for a | ||
| WRAP-HTTP OPTIONS | ||
| --url <url> Required. Remote MCP endpoint (POSTs JSON-RPC there). | ||
| --key <value> API key (overrides config + MCPSPEND_API_KEY) | ||
| --endpoint <url> MCPSpend API endpoint (NOT the remote MCP URL) | ||
| --project <id> Attribute calls to this project | ||
| --agent <name> Agent name reported in dashboards | ||
| --model <name> Model name for cost attribution (default: mcp-http) | ||
| --auth <header> Pass-through Authorization header to the remote MCP | ||
| SNIPPET OPTIONS | ||
@@ -71,2 +83,6 @@ --client <id> One of: claude-desktop, cursor, windsurf, vscode, | ||
| # Wrap a REMOTE HTTP MCP server (figma-remote etc.) | ||
| mcpspend wrap-http --url https://mcp.figma.com --key mcps_live_xxx \ | ||
| --auth "Bearer FIGMA_TOKEN" | ||
| Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1, MCPSPEND_NO_TELEMETRY=1 | ||
@@ -81,2 +97,3 @@ Config file: ~/.mcpspend/config.json | ||
| snippetOpts: { client: 'generic', style: 'npx' }, | ||
| httpOpts: {}, | ||
| childArgs: [], | ||
@@ -139,2 +156,48 @@ }; | ||
| } | ||
| if (cmd === 'wrap-http') { | ||
| result.command = 'wrap-http'; | ||
| let i = 1; | ||
| while (i < argv.length) { | ||
| const a = argv[i]; | ||
| const next = argv[i + 1]; | ||
| switch (a) { | ||
| case '--url': | ||
| result.httpOpts.url = next; | ||
| i += 2; | ||
| break; | ||
| case '--key': | ||
| result.httpOpts.apiKey = next; | ||
| i += 2; | ||
| break; | ||
| case '--endpoint': | ||
| result.httpOpts.endpoint = next; | ||
| i += 2; | ||
| break; | ||
| case '--project': | ||
| result.httpOpts.projectId = next; | ||
| i += 2; | ||
| break; | ||
| case '--agent': | ||
| result.httpOpts.agentName = next; | ||
| i += 2; | ||
| break; | ||
| case '--model': | ||
| result.httpOpts.model = next; | ||
| i += 2; | ||
| break; | ||
| case '--auth': | ||
| result.httpOpts.auth = next; | ||
| i += 2; | ||
| break; | ||
| default: | ||
| process.stderr.write(`mcpspend: unknown option ${a}\n`); | ||
| process.exit(2); | ||
| } | ||
| } | ||
| if (!result.httpOpts.url) { | ||
| process.stderr.write('mcpspend: wrap-http requires --url <https://...>\n'); | ||
| process.exit(2); | ||
| } | ||
| return result; | ||
| } | ||
| if (cmd === 'snippet') { | ||
@@ -285,2 +348,6 @@ result.command = 'snippet'; | ||
| process.stdout.write((0, init_js_1.formatReport)(report, parsed.initOpts.unwrap) + '\n'); | ||
| // Fire-and-forget anonymous compat report. Tells us when a client's schema | ||
| // changes so we can ship a fix BEFORE users notice. Opt out via | ||
| // MCPSPEND_NO_TELEMETRY=1. We swallow errors — telemetry is never blocking. | ||
| void (0, init_js_1.reportCompatFromInit)(VERSION, report); | ||
| if (report.clients.some((c) => c.status === 'error')) | ||
@@ -344,2 +411,17 @@ process.exit(1); | ||
| } | ||
| if (parsed.command === 'wrap-http') { | ||
| const cfg = (0, config_js_1.loadConfig)({ | ||
| apiKey: parsed.httpOpts.apiKey, | ||
| endpoint: parsed.httpOpts.endpoint, | ||
| projectId: parsed.httpOpts.projectId, | ||
| agentName: parsed.httpOpts.agentName, | ||
| }); | ||
| const code = await (0, http_bridge_js_1.runHttpBridge)({ | ||
| url: parsed.httpOpts.url, | ||
| config: cfg, | ||
| model: parsed.httpOpts.model || process.env.MCPSPEND_MODEL || 'mcp-http', | ||
| remoteAuthHeader: parsed.httpOpts.auth, | ||
| }); | ||
| process.exit(code); | ||
| } | ||
| } | ||
@@ -346,0 +428,0 @@ // Very small heuristic — keeps snippet code self-contained without exporting |
+6
-0
@@ -29,2 +29,8 @@ import { ClientDefinition, WrapResult } from './clients.js'; | ||
| export declare function runInit(opts?: InitOptions): InitReport; | ||
| /** | ||
| * Fire a fire-and-forget anonymous compat report. Caller decides when to call — | ||
| * we don't run it inside runInit() to keep that function pure and testable. | ||
| * Opt out: MCPSPEND_NO_TELEMETRY=1. | ||
| */ | ||
| export declare function reportCompatFromInit(cliVersion: string, init: InitReport): Promise<void>; | ||
| export declare function formatReport(report: InitReport, unwrap?: boolean): string; | ||
@@ -31,0 +37,0 @@ export interface DoctorReport { |
+35
-0
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.runInit = runInit; | ||
| exports.reportCompatFromInit = reportCompatFromInit; | ||
| exports.formatReport = formatReport; | ||
| exports.runDoctor = runDoctor; | ||
| exports.formatDoctor = formatDoctor; | ||
| const node_os_1 = require("node:os"); | ||
| const clients_js_1 = require("./clients.js"); | ||
| const config_js_1 = require("./config.js"); | ||
| const telemetry_js_1 = require("./telemetry.js"); | ||
| function runInit(opts = {}) { | ||
@@ -111,2 +114,34 @@ if (opts.apiKey) { | ||
| } | ||
| /** | ||
| * Fire a fire-and-forget anonymous compat report. Caller decides when to call — | ||
| * we don't run it inside runInit() to keep that function pure and testable. | ||
| * Opt out: MCPSPEND_NO_TELEMETRY=1. | ||
| */ | ||
| async function reportCompatFromInit(cliVersion, init) { | ||
| const reports = init.clients.map((r) => { | ||
| let fp; | ||
| let format; | ||
| try { | ||
| const parsed = (0, clients_js_1.readClientConfig)(r.path); | ||
| fp = (0, telemetry_js_1.fingerprintConfig)(parsed); | ||
| format = 'json'; | ||
| } | ||
| catch { | ||
| format = 'missing'; | ||
| } | ||
| return { | ||
| id: r.client, | ||
| status: r.bootstrapped ? 'bootstrapped' : r.status, | ||
| configFormat: format, | ||
| topLevelKeysFingerprint: fp, | ||
| serverCount: r.servers.length, | ||
| wrappedCount: r.servers.filter((s) => s.status === 'wrapped' || s.status === 'already-wrapped').length, | ||
| }; | ||
| }); | ||
| await (0, telemetry_js_1.sendCompatReport)({ | ||
| cliVersion, | ||
| platform: (0, node_os_1.platform)(), | ||
| reports, | ||
| }); | ||
| } | ||
| function formatReport(report, unwrap = false) { | ||
@@ -113,0 +148,0 @@ const lines = []; |
+1
-1
| { | ||
| "name": "@mcpspend/proxy", | ||
| "version": "0.4.0", | ||
| "version": "0.5.0", | ||
| "description": "Transparent proxy CLI for MCP servers — tracks tool calls, latency, and cost via MCPSpend.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
90275
12.99%26
8.33%2073
14.72%22
10%4
33.33%