@mcpspend/proxy
Advanced tools
| 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; |
+119
| "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)); |
+1
-1
| { | ||
| "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", |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
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.
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.
79898
22.63%24
20%1807
23.43%20
11.11%3
50%