🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
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.3.1
to
0.4.0
+20
dist/snippet.d.ts
import { WrapOptions } from './clients.js';
export type SnippetClient = 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode' | 'vscode-workspace' | 'claude-code' | 'generic';
export interface SnippetOptions extends WrapOptions {
client: SnippetClient;
serverName: string;
command: string;
args: string[];
env?: Record<string, string>;
}
export interface SnippetOutput {
client: SnippetClient;
destination: string;
serversKey: string;
/** Stringified JSON ready to paste, plus the entry's name as the object key. */
json: string;
/** Plain instructions tailored to the client. */
instructions: string[];
}
export declare function buildSnippet(opts: SnippetOptions): SnippetOutput;
export declare function formatSnippet(out: SnippetOutput): string;
"use strict";
// Generates ready-to-paste snippets for clients we cannot auto-patch
// (Windsurf in protobuf mode, custom JSON files, etc.).
//
// The user runs:
// mcpspend snippet --client windsurf -- npx -y @playwright/mcp@latest
//
// We print:
// 1. The destination path / UI navigation for the chosen client
// 2. The wrapped JSON they should paste
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSnippet = buildSnippet;
exports.formatSnippet = formatSnippet;
const clients_js_1 = require("./clients.js");
const TEMPLATES = {
'claude-desktop': {
destination: '%APPDATA%\\Claude\\claude_desktop_config.json (Windows) · ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)',
serversKey: 'mcpServers',
instructions: (s) => [
'Open the file above (create it if missing).',
`Add the snippet under "mcpServers" → "${s}".`,
'Quit Claude Desktop fully and reopen.',
],
},
cursor: {
destination: '~/.cursor/mcp.json (or .cursor/mcp.json in this project for workspace-scoped)',
serversKey: 'mcpServers',
instructions: (s) => [
'Open the file above (create it if missing).',
`Add the snippet under "mcpServers" → "${s}".`,
'Reload Cursor (Cmd/Ctrl + Shift + P → "Reload Window").',
],
},
windsurf: {
destination: 'Settings → Cascade → MCP Servers → Add server (UI only — Windsurf no longer reads JSON)',
serversKey: 'mcpServers',
instructions: (s) => [
'Recent Windsurf versions store MCP config in a protobuf binary; you have to add the server through the UI.',
'Open Windsurf → Settings → Cascade → MCP Servers → "Add server".',
`Use this server name: ${s}`,
'Paste the command + args from the JSON snippet below into the corresponding fields.',
'Start a NEW Cascade conversation — the existing one won\'t see the new server until then.',
],
},
vscode: {
destination: '%APPDATA%\\Code\\User\\mcp.json (Windows) · ~/.config/Code/User/mcp.json (Linux) · ~/Library/Application Support/Code/User/mcp.json (macOS)',
serversKey: 'servers',
instructions: (s) => [
'Open the file above (create it if missing).',
`Add the snippet under "servers" → "${s}".`,
'Reload VS Code.',
],
},
'vscode-workspace': {
destination: '.vscode/mcp.json (in your project root)',
serversKey: 'servers',
instructions: (s) => [
'Create .vscode/mcp.json in your project root (if missing).',
`Add the snippet under "servers" → "${s}".`,
'Reload VS Code.',
],
},
'claude-code': {
destination: '.mcp.json (project root) or ~/.claude.json (user-level)',
serversKey: 'mcpServers',
instructions: (s) => [
'For per-project: create .mcp.json in the project root.',
'For user-global: edit ~/.claude.json (add an "mcpServers" key if missing).',
`Place the snippet under "mcpServers" → "${s}".`,
'Restart the Claude Code panel / re-open the conversation.',
],
},
generic: {
destination: 'Wherever your MCP client reads its config.',
serversKey: 'mcpServers',
instructions: (s) => [
`Add the snippet below as the value for "${s}" inside your client's mcpServers (or equivalent) object.`,
'Restart the client.',
],
},
};
function buildSnippet(opts) {
const tmpl = TEMPLATES[opts.client];
const baseEntry = { command: opts.command, args: opts.args };
if (opts.env)
baseEntry.env = opts.env;
const wrapped = (0, clients_js_1.wrapEntry)(baseEntry, {
projectId: opts.projectId,
endpoint: opts.endpoint,
agentName: opts.agentName,
style: opts.style ?? 'npx',
});
const obj = { [opts.serverName]: wrapped };
const json = JSON.stringify(obj, null, 2);
return {
client: opts.client,
destination: tmpl.destination,
serversKey: tmpl.serversKey,
json,
instructions: tmpl.instructions(opts.serverName),
};
}
function formatSnippet(out) {
const lines = [];
lines.push('');
lines.push(`▾ ${out.client.toUpperCase()} — paste-ready snippet`);
lines.push('');
lines.push('Destination:');
lines.push(` ${out.destination}`);
lines.push('');
lines.push('Steps:');
out.instructions.forEach((s, i) => lines.push(` ${i + 1}. ${s}`));
lines.push('');
lines.push(`JSON (under "${out.serversKey}"):`);
lines.push('');
out.json.split('\n').forEach((l) => lines.push(' ' + l));
lines.push('');
return lines.join('\n');
}
export interface CompatClientReport {
id: string;
status: 'patched' | 'no-changes' | 'error' | 'dry-run' | 'bootstrapped' | 'not-detected';
configFormat?: 'json' | 'protobuf' | 'binary-unknown' | 'missing';
topLevelKeysFingerprint?: string;
serverCount?: number;
wrappedCount?: number;
}
export interface CompatPayload {
cliVersion: string;
platform: string;
reports: CompatClientReport[];
errorSummary?: string;
}
export declare function fingerprintConfig(parsed: unknown): string;
export declare function sendCompatReport(payload: CompatPayload, endpoint?: string): Promise<void>;
"use strict";
// Anonymous compatibility telemetry.
//
// Why: clients like Windsurf or Cursor occasionally change where or how they
// store MCP config. The moment they do, our `init` silently stops auto-
// patching for that client. We won't hear about it until a user files an
// issue — by then the bad version has shipped to thousands.
//
// Solution: when init/doctor runs, POST a small payload to
// /api/internal/compat-report
// describing what we found per client. The backend aggregates schema
// fingerprints + version hashes. When a new fingerprint appears for an
// existing client we get an alert and ship a fix.
//
// Payload is anonymous: no user identity, no config contents — only:
// - cli version (so we know which mcpspend rolled into the wall)
// - per-client: { id, configFormat, fingerprint, status }
// where fingerprint = sha256(sorted top-level keys) — leaks zero PII but
// lets us spot "this file used to have mcpServers, now has serverGroups".
//
// Opt out: MCPSPEND_NO_TELEMETRY=1
Object.defineProperty(exports, "__esModule", { value: true });
exports.fingerprintConfig = fingerprintConfig;
exports.sendCompatReport = sendCompatReport;
const node_crypto_1 = require("node:crypto");
const ENDPOINT_DEFAULT = 'https://api.mcpspend.com/api/internal/compat-report';
function fingerprintConfig(parsed) {
if (!parsed || typeof parsed !== 'object')
return 'NA';
const keys = Object.keys(parsed).sort();
return (0, node_crypto_1.createHash)('sha256').update(keys.join(',')).digest('hex').slice(0, 16);
}
async function sendCompatReport(payload, endpoint) {
if (process.env.MCPSPEND_NO_TELEMETRY === '1')
return;
const url = endpoint || process.env.MCPSPEND_COMPAT_ENDPOINT || ENDPOINT_DEFAULT;
try {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 4000);
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: ac.signal,
}).catch(() => undefined);
clearTimeout(timer);
}
catch {
// Never propagate telemetry failures.
}
}
+120
-2

@@ -7,3 +7,4 @@ #!/usr/bin/env node

const init_js_1 = require("./init.js");
const VERSION = '0.3.1';
const snippet_js_1 = require("./snippet.js");
const VERSION = '0.4.0';
const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})

@@ -15,2 +16,5 @@

mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation
mcpspend snippet [options] -- <cmd>...
Print a paste-ready JSON snippet for a
specific client (Windsurf protobuf, custom).
mcpspend config set <key> <value>

@@ -38,2 +42,11 @@ mcpspend config show

SNIPPET OPTIONS
--client <id> One of: claude-desktop, cursor, windsurf, vscode,
vscode-workspace, claude-code, generic. Default: generic.
--name <name> Server name in the resulting JSON. Default: inferred from --
--project <id> Attribute calls to this project (baked into wrap args)
--endpoint <url> API endpoint
--agent <name> Agent name
--style <npx|bin> Wrap style. Default: npx (no global install required).
EXAMPLES

@@ -55,3 +68,6 @@ # First-time setup: paste your API key, patch every installed MCP client

Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1
# Get a copy-paste snippet for Windsurf (which stores config as protobuf)
mcpspend snippet --client windsurf --name playwright -- npx -y @playwright/mcp@latest
Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1, MCPSPEND_NO_TELEMETRY=1
Config file: ~/.mcpspend/config.json

@@ -64,2 +80,3 @@ `;

initOpts: { clients: [], dryRun: false, unwrap: false },
snippetOpts: { client: 'generic', style: 'npx' },
childArgs: [],

@@ -122,2 +139,55 @@ };

}
if (cmd === 'snippet') {
result.command = 'snippet';
let i = 1;
while (i < argv.length) {
const a = argv[i];
if (a === '--') {
i++;
break;
}
const next = argv[i + 1];
switch (a) {
case '--client':
result.snippetOpts.client = next;
i += 2;
break;
case '--name':
result.snippetOpts.name = next;
i += 2;
break;
case '--project':
result.snippetOpts.projectId = next;
i += 2;
break;
case '--endpoint':
result.snippetOpts.endpoint = next;
i += 2;
break;
case '--agent':
result.snippetOpts.agentName = next;
i += 2;
break;
case '--style':
if (next === 'npx' || next === 'bin')
result.snippetOpts.style = next;
else {
process.stderr.write('mcpspend: --style must be npx or bin\n');
process.exit(2);
}
i += 2;
break;
default:
process.stderr.write(`mcpspend: unknown option ${a}\n`);
process.exit(2);
}
}
if (i >= argv.length) {
process.stderr.write('mcpspend: missing command for snippet. Use: mcpspend snippet [options] -- <command> [args...]\n');
process.exit(2);
}
result.childCommand = argv[i];
result.childArgs = argv.slice(i + 1);
return result;
}
if (cmd === 'wrap') {

@@ -226,2 +296,17 @@ result.command = 'wrap';

}
if (parsed.command === 'snippet') {
const inferredName = guessServerName(parsed.childCommand, parsed.childArgs);
const out = (0, snippet_js_1.buildSnippet)({
client: parsed.snippetOpts.client,
serverName: parsed.snippetOpts.name || inferredName,
command: parsed.childCommand,
args: parsed.childArgs,
projectId: parsed.snippetOpts.projectId,
endpoint: parsed.snippetOpts.endpoint,
agentName: parsed.snippetOpts.agentName,
style: parsed.snippetOpts.style,
});
process.stdout.write((0, snippet_js_1.formatSnippet)(out) + '\n');
return;
}
if (parsed.command === 'config') {

@@ -260,2 +345,35 @@ if (parsed.configAction === 'show') {

}
// Very small heuristic — keeps snippet code self-contained without exporting
// extractServerName from proxy.ts. Same trade-off rules apply: drop versions,
// drop scopes that reduce to "mcp", strip mcp-server- prefixes.
function guessServerName(command, args) {
const skip = new Set(['npx', 'npx.cmd', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']);
const tokens = [command, ...args].filter((t) => t && !t.startsWith('-') && !skip.has(t.toLowerCase()));
for (const raw of tokens) {
let t = raw;
const lastAt = t.lastIndexOf('@');
if (lastAt > 0)
t = t.slice(0, lastAt);
let scope = null;
if (t.startsWith('@')) {
const slash = t.indexOf('/');
if (slash > 0) {
scope = t.slice(1, slash);
t = t.slice(slash + 1);
}
}
t = (t.split(/[\\/]/).pop() || t).replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, '');
t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, '');
t = t.replace(/^server-/i, '').replace(/-server$/i, '');
t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, '');
t = t.toLowerCase().trim();
if (!t || t === 'mcp' || t === 'server') {
if (scope)
return scope.toLowerCase();
continue;
}
return t;
}
return 'mcp-server';
}
main().catch((err) => {

@@ -262,0 +380,0 @@ process.stderr.write(`mcpspend: ${err instanceof Error ? err.message : String(err)}\n`);

+32
-11

@@ -34,3 +34,9 @@ "use strict";

name: 'Cursor',
configPaths: () => [(0, node_path_1.join)(home, '.cursor', 'mcp.json')],
configPaths: () => [
(0, node_path_1.join)(home, '.cursor', 'mcp.json'),
// Cursor also supports a workspace-scoped config at .cursor/mcp.json
// in the project. We discover it from process.cwd() so init knows about
// the project the user is currently in.
(0, node_path_1.join)(process.cwd(), '.cursor', 'mcp.json'),
],
},

@@ -80,5 +86,21 @@ {

{
id: 'vscode-workspace',
name: 'VS Code (workspace)',
// Workspace-scoped MCP config that lives inside the project. Cursor also
// reads this file because Cursor is a VS Code fork, so wrapping it here
// covers both. We resolve from process.cwd() — `init` should be run from
// the project root.
configPaths: () => [(0, node_path_1.join)(process.cwd(), '.vscode', 'mcp.json')],
serversKey: 'servers',
},
{
id: 'claude-code',
name: 'Claude Code',
configPaths: () => [(0, node_path_1.join)(home, '.claude.json')],
// User-level config at ~/.claude.json AND project-level `.mcp.json` in
// the current working directory. The project-level file is the standard
// way to ship MCP server lists with a repository.
configPaths: () => [
(0, node_path_1.join)(home, '.claude.json'),
(0, node_path_1.join)(process.cwd(), '.mcp.json'),
],
},

@@ -89,16 +111,15 @@ ];

for (const c of exports.CLIENTS) {
let matched = false;
// First try existing config files (the common case).
for (const p of c.configPaths()) {
if ((0, node_fs_1.existsSync)(p)) {
// Collect EVERY existing config file for this client, not just the first.
// Claude Code in particular has both a user-level (~/.claude.json) and
// project-level (./.mcp.json) location and they're independent.
const existing = c.configPaths().filter(p => (0, node_fs_1.existsSync)(p));
if (existing.length > 0) {
for (const p of existing) {
found.push({ client: c, path: p });
matched = true;
break;
}
continue;
}
if (matched)
continue;
// Fall back to install markers. If the client is installed but has never
// had a config written, take the first configPath as the destination and
// mark the discovery as bootstrapped.
// mark the discovery as bootstrapped so init creates the file.
if (c.installMarkers) {

@@ -105,0 +126,0 @@ const installed = c.installMarkers().some(p => (0, node_fs_1.existsSync)(p));

{
"name": "@mcpspend/proxy",
"version": "0.3.1",
"version": "0.4.0",
"description": "Transparent proxy CLI for MCP servers — tracks tool calls, latency, and cost via MCPSpend.",

@@ -5,0 +5,0 @@ "license": "MIT",