Sign In

@mcpspend/proxy

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@mcpspend/proxy - npm Package Compare versions

Comparing version
0.4.0
to
0.5.0
+8
dist/http-bridge.d.ts
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

@@ -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 {

"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",