@mcpspend/proxy 0.3.0 → 0.4.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
@@ -4,13 +4,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  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
- const VERSION = '0.3.0';
7
+ const snippet_js_1 = require("./snippet.js");
8
+ const VERSION = '0.4.0';
8
9
  const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
9
10
 
10
11
  USAGE
11
12
  mcpspend init [options] Auto-detect MCP clients and wrap their servers
12
13
  mcpspend doctor Diagnose setup (clients, API key, endpoint)
13
14
  mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation
15
+ mcpspend snippet [options] -- <cmd>...
16
+ Print a paste-ready JSON snippet for a
17
+ specific client (Windsurf protobuf, custom).
14
18
  mcpspend config set <key> <value>
15
19
  mcpspend config show
16
20
  mcpspend --version | --help
@@ -33,6 +37,15 @@ WRAP OPTIONS
33
37
  --model <name> Model name for cost attribution (default: mcp-stdio)
34
38
  --disable Run in passthrough mode (no tracking)
35
39
 
40
+ SNIPPET OPTIONS
41
+ --client <id> One of: claude-desktop, cursor, windsurf, vscode,
42
+ vscode-workspace, claude-code, generic. Default: generic.
43
+ --name <name> Server name in the resulting JSON. Default: inferred from --
44
+ --project <id> Attribute calls to this project (baked into wrap args)
45
+ --endpoint <url> API endpoint
46
+ --agent <name> Agent name
47
+ --style <npx|bin> Wrap style. Default: npx (no global install required).
48
+
36
49
  EXAMPLES
37
50
  # First-time setup: paste your API key, patch every installed MCP client
38
51
  mcpspend init --key mcps_live_xxx
@@ -49,7 +62,10 @@ EXAMPLES
49
62
  # Manual single-server wrap (no client config touched)
50
63
  mcpspend wrap --key mcps_live_xxx -- npx @modelcontextprotocol/server-filesystem /data
51
64
 
52
- Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1
65
+ # Get a copy-paste snippet for Windsurf (which stores config as protobuf)
66
+ mcpspend snippet --client windsurf --name playwright -- npx -y @playwright/mcp@latest
67
+
68
+ Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1, MCPSPEND_NO_TELEMETRY=1
53
69
  Config file: ~/.mcpspend/config.json
54
70
  `;
55
71
  function parseArgs(argv) {
@@ -57,6 +73,7 @@ function parseArgs(argv) {
57
73
  command: 'help',
58
74
  wrapOpts: {},
59
75
  initOpts: { clients: [], dryRun: false, unwrap: false },
76
+ snippetOpts: { client: 'generic', style: 'npx' },
60
77
  childArgs: [],
61
78
  };
62
79
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
@@ -114,6 +131,59 @@ function parseArgs(argv) {
114
131
  result.command = 'doctor';
115
132
  return result;
116
133
  }
134
+ if (cmd === 'snippet') {
135
+ result.command = 'snippet';
136
+ let i = 1;
137
+ while (i < argv.length) {
138
+ const a = argv[i];
139
+ if (a === '--') {
140
+ i++;
141
+ break;
142
+ }
143
+ const next = argv[i + 1];
144
+ switch (a) {
145
+ case '--client':
146
+ result.snippetOpts.client = next;
147
+ i += 2;
148
+ break;
149
+ case '--name':
150
+ result.snippetOpts.name = next;
151
+ i += 2;
152
+ break;
153
+ case '--project':
154
+ result.snippetOpts.projectId = next;
155
+ i += 2;
156
+ break;
157
+ case '--endpoint':
158
+ result.snippetOpts.endpoint = next;
159
+ i += 2;
160
+ break;
161
+ case '--agent':
162
+ result.snippetOpts.agentName = next;
163
+ i += 2;
164
+ break;
165
+ case '--style':
166
+ if (next === 'npx' || next === 'bin')
167
+ result.snippetOpts.style = next;
168
+ else {
169
+ process.stderr.write('mcpspend: --style must be npx or bin\n');
170
+ process.exit(2);
171
+ }
172
+ i += 2;
173
+ break;
174
+ default:
175
+ process.stderr.write(`mcpspend: unknown option ${a}\n`);
176
+ process.exit(2);
177
+ }
178
+ }
179
+ if (i >= argv.length) {
180
+ process.stderr.write('mcpspend: missing command for snippet. Use: mcpspend snippet [options] -- <command> [args...]\n');
181
+ process.exit(2);
182
+ }
183
+ result.childCommand = argv[i];
184
+ result.childArgs = argv.slice(i + 1);
185
+ return result;
186
+ }
117
187
  if (cmd === 'wrap') {
118
188
  result.command = 'wrap';
119
189
  let i = 1;
@@ -217,6 +287,21 @@ async function main() {
217
287
  process.exit(1);
218
288
  return;
219
289
  }
290
+ if (parsed.command === 'snippet') {
291
+ const inferredName = guessServerName(parsed.childCommand, parsed.childArgs);
292
+ const out = (0, snippet_js_1.buildSnippet)({
293
+ client: parsed.snippetOpts.client,
294
+ serverName: parsed.snippetOpts.name || inferredName,
295
+ command: parsed.childCommand,
296
+ args: parsed.childArgs,
297
+ projectId: parsed.snippetOpts.projectId,
298
+ endpoint: parsed.snippetOpts.endpoint,
299
+ agentName: parsed.snippetOpts.agentName,
300
+ style: parsed.snippetOpts.style,
301
+ });
302
+ process.stdout.write((0, snippet_js_1.formatSnippet)(out) + '\n');
303
+ return;
304
+ }
220
305
  if (parsed.command === 'config') {
221
306
  if (parsed.configAction === 'show') {
222
307
  const cfg = (0, config_js_1.loadConfig)();
@@ -250,6 +335,39 @@ async function main() {
250
335
  process.exit(code);
251
336
  }
252
337
  }
338
+ // Very small heuristic — keeps snippet code self-contained without exporting
339
+ // extractServerName from proxy.ts. Same trade-off rules apply: drop versions,
340
+ // drop scopes that reduce to "mcp", strip mcp-server- prefixes.
341
+ function guessServerName(command, args) {
342
+ const skip = new Set(['npx', 'npx.cmd', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']);
343
+ const tokens = [command, ...args].filter((t) => t && !t.startsWith('-') && !skip.has(t.toLowerCase()));
344
+ for (const raw of tokens) {
345
+ let t = raw;
346
+ const lastAt = t.lastIndexOf('@');
347
+ if (lastAt > 0)
348
+ t = t.slice(0, lastAt);
349
+ let scope = null;
350
+ if (t.startsWith('@')) {
351
+ const slash = t.indexOf('/');
352
+ if (slash > 0) {
353
+ scope = t.slice(1, slash);
354
+ t = t.slice(slash + 1);
355
+ }
356
+ }
357
+ t = (t.split(/[\\/]/).pop() || t).replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, '');
358
+ t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, '');
359
+ t = t.replace(/^server-/i, '').replace(/-server$/i, '');
360
+ t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, '');
361
+ t = t.toLowerCase().trim();
362
+ if (!t || t === 'mcp' || t === 'server') {
363
+ if (scope)
364
+ return scope.toLowerCase();
365
+ continue;
366
+ }
367
+ return t;
368
+ }
369
+ return 'mcp-server';
370
+ }
253
371
  main().catch((err) => {
254
372
  process.stderr.write(`mcpspend: ${err instanceof Error ? err.message : String(err)}\n`);
255
373
  process.exit(1);
package/dist/clients.js CHANGED
@@ -31,7 +31,13 @@ exports.CLIENTS = [
31
31
  {
32
32
  id: 'cursor',
33
33
  name: 'Cursor',
34
- configPaths: () => [(0, node_path_1.join)(home, '.cursor', 'mcp.json')],
34
+ configPaths: () => [
35
+ (0, node_path_1.join)(home, '.cursor', 'mcp.json'),
36
+ // Cursor also supports a workspace-scoped config at .cursor/mcp.json
37
+ // in the project. We discover it from process.cwd() so init knows about
38
+ // the project the user is currently in.
39
+ (0, node_path_1.join)(process.cwd(), '.cursor', 'mcp.json'),
40
+ ],
35
41
  },
36
42
  {
37
43
  id: 'windsurf',
@@ -75,29 +81,44 @@ exports.CLIENTS = [
75
81
  },
76
82
  serversKey: 'servers',
77
83
  },
84
+ {
85
+ id: 'vscode-workspace',
86
+ name: 'VS Code (workspace)',
87
+ // Workspace-scoped MCP config that lives inside the project. Cursor also
88
+ // reads this file because Cursor is a VS Code fork, so wrapping it here
89
+ // covers both. We resolve from process.cwd() — `init` should be run from
90
+ // the project root.
91
+ configPaths: () => [(0, node_path_1.join)(process.cwd(), '.vscode', 'mcp.json')],
92
+ serversKey: 'servers',
93
+ },
78
94
  {
79
95
  id: 'claude-code',
80
96
  name: 'Claude Code',
81
- configPaths: () => [(0, node_path_1.join)(home, '.claude.json')],
97
+ // User-level config at ~/.claude.json AND project-level `.mcp.json` in
98
+ // the current working directory. The project-level file is the standard
99
+ // way to ship MCP server lists with a repository.
100
+ configPaths: () => [
101
+ (0, node_path_1.join)(home, '.claude.json'),
102
+ (0, node_path_1.join)(process.cwd(), '.mcp.json'),
103
+ ],
82
104
  },
83
105
  ];
84
106
  function discoverClients() {
85
107
  const found = [];
86
108
  for (const c of exports.CLIENTS) {
87
- let matched = false;
88
- // First try existing config files (the common case).
89
- for (const p of c.configPaths()) {
90
- if ((0, node_fs_1.existsSync)(p)) {
109
+ // Collect EVERY existing config file for this client, not just the first.
110
+ // Claude Code in particular has both a user-level (~/.claude.json) and
111
+ // project-level (./.mcp.json) location and they're independent.
112
+ const existing = c.configPaths().filter(p => (0, node_fs_1.existsSync)(p));
113
+ if (existing.length > 0) {
114
+ for (const p of existing) {
91
115
  found.push({ client: c, path: p });
92
- matched = true;
93
- break;
94
116
  }
95
- }
96
- if (matched)
97
117
  continue;
118
+ }
98
119
  // Fall back to install markers. If the client is installed but has never
99
120
  // had a config written, take the first configPath as the destination and
100
- // mark the discovery as bootstrapped.
121
+ // mark the discovery as bootstrapped so init creates the file.
101
122
  if (c.installMarkers) {
102
123
  const installed = c.installMarkers().some(p => (0, node_fs_1.existsSync)(p));
103
124
  if (installed) {
package/dist/proxy.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Config } from './config.js';
2
+ export declare const __testExtractServerName: (command: string, args: string[]) => string;
2
3
  export declare function runProxy(opts: {
3
4
  command: string;
4
5
  args: string[];
package/dist/proxy.js CHANGED
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.__testExtractServerName = void 0;
6
7
  exports.runProxy = runProxy;
7
8
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
8
9
  const node_crypto_1 = require("node:crypto");
@@ -14,29 +15,80 @@ function estimateTokens(payload) {
14
15
  const s = typeof payload === 'string' ? payload : JSON.stringify(payload);
15
16
  return Math.max(1, Math.ceil(s.length / 4));
16
17
  }
17
- function extractServerName(command, args) {
18
- // Best-effort: pick the most descriptive token from the command line.
19
- // Examples:
20
- // npx @modelcontextprotocol/server-filesystem /path → "filesystem"
21
- // node ./my-mcp-server.js "my-mcp-server"
22
- // /usr/bin/uvx mcp-server-fetch "mcp-server-fetch"
23
- const tokens = [command, ...args];
24
- for (const t of tokens) {
25
- const m = t.match(/(?:server-|mcp-server-)([a-z0-9-]+)/i);
26
- if (m)
27
- return m[1];
18
+ // Strip an npm package spec down to its identifying core. We do this in stages
19
+ // so each rule is auditable and reviewers can extend it without unwinding a
20
+ // monster regex.
21
+ //
22
+ // @playwright/mcp@latest playwright
23
+ // @modelcontextprotocol/server-fs fs
24
+ // @owner/foo-mcp → foo
25
+ // github-mcp-server → github
26
+ // mcp-server-fetch → fetch
27
+ // firecrawl-mcp → firecrawl
28
+ function stripMcpAffixes(s) {
29
+ let t = s;
30
+ t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, '');
31
+ t = t.replace(/^server-/i, '').replace(/-server$/i, '');
32
+ t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, '');
33
+ return t.toLowerCase().trim();
34
+ }
35
+ function isDegenerate(t) {
36
+ return !t || t === 'mcp' || t === 'server' || t === 'latest';
37
+ }
38
+ function normaliseServerToken(raw) {
39
+ let t = raw;
40
+ // Drop everything after the version separator, but only when it's a version,
41
+ // not a scope marker. `@playwright/mcp@latest` → `@playwright/mcp`.
42
+ const lastAt = t.lastIndexOf('@');
43
+ if (lastAt > 0)
44
+ t = t.slice(0, lastAt);
45
+ // If this is a scoped npm spec like `@playwright/mcp`, try the unscoped
46
+ // name first; if that strips down to something generic ("mcp", "server"),
47
+ // fall back to the scope itself. That gives "playwright" instead of "mcp"
48
+ // for `@playwright/mcp@latest`, while still preferring the specific name
49
+ // for `@modelcontextprotocol/server-filesystem` → "filesystem".
50
+ let scope = null;
51
+ if (t.startsWith('@')) {
52
+ const slash = t.indexOf('/');
53
+ if (slash > 0) {
54
+ scope = t.slice(1, slash).toLowerCase();
55
+ t = t.slice(slash + 1);
56
+ }
28
57
  }
29
- for (const t of tokens) {
30
- const m = t.match(/([a-z0-9-]+)-mcp-server/i);
31
- if (m)
32
- return m[1];
58
+ // Path basename when this is a script path.
59
+ t = t.split(/[\\/]/).pop() || t;
60
+ t = t.replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, '');
61
+ let name = stripMcpAffixes(t);
62
+ if (isDegenerate(name) && scope) {
63
+ name = stripMcpAffixes(scope);
33
64
  }
34
- // Fallback: last non-flag arg's basename without extension
35
- const lastArg = [...tokens].reverse().find((t) => !t.startsWith('-'));
36
- if (lastArg) {
37
- return lastArg.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
65
+ if (isDegenerate(name))
66
+ return null;
67
+ return name;
68
+ }
69
+ // Exported only for tests — see proxy.test.ts. Kept off the public surface to
70
+ // avoid implying it's a stable API.
71
+ const __testExtractServerName = (command, args) => extractServerName(command, args);
72
+ exports.__testExtractServerName = __testExtractServerName;
73
+ function extractServerName(command, args) {
74
+ // Skip well-known shims that never carry the server identity themselves.
75
+ const skipPrefix = new Set(['npx', 'npx.cmd', 'npx.exe', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']);
76
+ const tokens = [command, ...args].filter((t) => {
77
+ if (!t)
78
+ return false;
79
+ if (t.startsWith('-'))
80
+ return false; // flags
81
+ const base = t.split(/[\\/]/).pop()?.toLowerCase() || '';
82
+ return !skipPrefix.has(base) && !skipPrefix.has(t.toLowerCase());
83
+ });
84
+ for (const t of tokens) {
85
+ const name = normaliseServerToken(t);
86
+ if (name)
87
+ return name;
38
88
  }
39
- return 'mcp';
89
+ // Last resort — last raw token basename.
90
+ const lastArg = [...args].reverse().find((t) => !t.startsWith('-')) || command;
91
+ return (lastArg.split(/[\\/]/).pop() || lastArg).replace(/\.[^.]+$/, '') || 'mcp';
40
92
  }
41
93
  async function runProxy(opts) {
42
94
  const { command, args, config, model } = opts;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ // Tests for extractServerName. The function is not exported from proxy.ts so
3
+ // we re-import via require and access the internal binding. If the API ever
4
+ // grows we'll bring it out into its own module.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const node_test_1 = require("node:test");
10
+ const strict_1 = __importDefault(require("node:assert/strict"));
11
+ // Internal binding access — proxy.ts doesn't export extractServerName because
12
+ // it's an implementation detail. We dynamic-require to expose it for tests
13
+ // without changing the public surface.
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ const proxy = require('./proxy.js');
16
+ // If the module doesn't expose a test hook, re-implement the same dispatcher
17
+ // inline. We'd rather duplicate three lines than couple production code to
18
+ // the test layout.
19
+ function extractName(command, args) {
20
+ if (proxy.__testExtractServerName)
21
+ return proxy.__testExtractServerName(command, args);
22
+ throw new Error('proxy.ts must expose __testExtractServerName for tests — see proxy.ts diff in this PR');
23
+ }
24
+ (0, node_test_1.describe)('extractServerName', () => {
25
+ (0, node_test_1.test)('playwright via @playwright/mcp@latest', () => {
26
+ strict_1.default.equal(extractName('npx', ['-y', '@playwright/mcp@latest']), 'playwright');
27
+ });
28
+ (0, node_test_1.test)('filesystem via @modelcontextprotocol/server-filesystem', () => {
29
+ strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-filesystem', '/data']), 'filesystem');
30
+ });
31
+ (0, node_test_1.test)('github via @modelcontextprotocol/server-github', () => {
32
+ strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-github']), 'github');
33
+ });
34
+ (0, node_test_1.test)('fetch via mcp-server-fetch (uvx)', () => {
35
+ strict_1.default.equal(extractName('uvx', ['mcp-server-fetch']), 'fetch');
36
+ });
37
+ (0, node_test_1.test)('firecrawl via firecrawl-mcp', () => {
38
+ strict_1.default.equal(extractName('npx', ['-y', 'firecrawl-mcp']), 'firecrawl');
39
+ });
40
+ (0, node_test_1.test)('github via github-mcp-server', () => {
41
+ strict_1.default.equal(extractName('npx', ['-y', 'github-mcp-server']), 'github');
42
+ });
43
+ (0, node_test_1.test)('node script with mcp-server suffix', () => {
44
+ strict_1.default.equal(extractName('node', ['./my-mcp-server.js']), 'my');
45
+ });
46
+ (0, node_test_1.test)('Windows absolute path node script', () => {
47
+ strict_1.default.equal(extractName('node', ['C:\\Users\\me\\servers\\notion-mcp.js']), 'notion');
48
+ });
49
+ (0, node_test_1.test)('skips npm shims, finds package after', () => {
50
+ strict_1.default.equal(extractName('npx', ['--yes', '@some-org/brave-search']), 'brave-search');
51
+ });
52
+ (0, node_test_1.test)('falls back to last token when nothing matches conventions', () => {
53
+ const out = extractName('python', ['./custom_runner.py']);
54
+ strict_1.default.ok(out.length > 0, 'should produce something');
55
+ strict_1.default.notEqual(out, 'python');
56
+ });
57
+ });
@@ -0,0 +1,20 @@
1
+ import { WrapOptions } from './clients.js';
2
+ export type SnippetClient = 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode' | 'vscode-workspace' | 'claude-code' | 'generic';
3
+ export interface SnippetOptions extends WrapOptions {
4
+ client: SnippetClient;
5
+ serverName: string;
6
+ command: string;
7
+ args: string[];
8
+ env?: Record<string, string>;
9
+ }
10
+ export interface SnippetOutput {
11
+ client: SnippetClient;
12
+ destination: string;
13
+ serversKey: string;
14
+ /** Stringified JSON ready to paste, plus the entry's name as the object key. */
15
+ json: string;
16
+ /** Plain instructions tailored to the client. */
17
+ instructions: string[];
18
+ }
19
+ export declare function buildSnippet(opts: SnippetOptions): SnippetOutput;
20
+ export declare function formatSnippet(out: SnippetOutput): string;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ // Generates ready-to-paste snippets for clients we cannot auto-patch
3
+ // (Windsurf in protobuf mode, custom JSON files, etc.).
4
+ //
5
+ // The user runs:
6
+ // mcpspend snippet --client windsurf -- npx -y @playwright/mcp@latest
7
+ //
8
+ // We print:
9
+ // 1. The destination path / UI navigation for the chosen client
10
+ // 2. The wrapped JSON they should paste
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.buildSnippet = buildSnippet;
13
+ exports.formatSnippet = formatSnippet;
14
+ const clients_js_1 = require("./clients.js");
15
+ const TEMPLATES = {
16
+ 'claude-desktop': {
17
+ destination: '%APPDATA%\\Claude\\claude_desktop_config.json (Windows) · ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)',
18
+ serversKey: 'mcpServers',
19
+ instructions: (s) => [
20
+ 'Open the file above (create it if missing).',
21
+ `Add the snippet under "mcpServers" → "${s}".`,
22
+ 'Quit Claude Desktop fully and reopen.',
23
+ ],
24
+ },
25
+ cursor: {
26
+ destination: '~/.cursor/mcp.json (or .cursor/mcp.json in this project for workspace-scoped)',
27
+ serversKey: 'mcpServers',
28
+ instructions: (s) => [
29
+ 'Open the file above (create it if missing).',
30
+ `Add the snippet under "mcpServers" → "${s}".`,
31
+ 'Reload Cursor (Cmd/Ctrl + Shift + P → "Reload Window").',
32
+ ],
33
+ },
34
+ windsurf: {
35
+ destination: 'Settings → Cascade → MCP Servers → Add server (UI only — Windsurf no longer reads JSON)',
36
+ serversKey: 'mcpServers',
37
+ instructions: (s) => [
38
+ 'Recent Windsurf versions store MCP config in a protobuf binary; you have to add the server through the UI.',
39
+ 'Open Windsurf → Settings → Cascade → MCP Servers → "Add server".',
40
+ `Use this server name: ${s}`,
41
+ 'Paste the command + args from the JSON snippet below into the corresponding fields.',
42
+ 'Start a NEW Cascade conversation — the existing one won\'t see the new server until then.',
43
+ ],
44
+ },
45
+ vscode: {
46
+ destination: '%APPDATA%\\Code\\User\\mcp.json (Windows) · ~/.config/Code/User/mcp.json (Linux) · ~/Library/Application Support/Code/User/mcp.json (macOS)',
47
+ serversKey: 'servers',
48
+ instructions: (s) => [
49
+ 'Open the file above (create it if missing).',
50
+ `Add the snippet under "servers" → "${s}".`,
51
+ 'Reload VS Code.',
52
+ ],
53
+ },
54
+ 'vscode-workspace': {
55
+ destination: '.vscode/mcp.json (in your project root)',
56
+ serversKey: 'servers',
57
+ instructions: (s) => [
58
+ 'Create .vscode/mcp.json in your project root (if missing).',
59
+ `Add the snippet under "servers" → "${s}".`,
60
+ 'Reload VS Code.',
61
+ ],
62
+ },
63
+ 'claude-code': {
64
+ destination: '.mcp.json (project root) or ~/.claude.json (user-level)',
65
+ serversKey: 'mcpServers',
66
+ instructions: (s) => [
67
+ 'For per-project: create .mcp.json in the project root.',
68
+ 'For user-global: edit ~/.claude.json (add an "mcpServers" key if missing).',
69
+ `Place the snippet under "mcpServers" → "${s}".`,
70
+ 'Restart the Claude Code panel / re-open the conversation.',
71
+ ],
72
+ },
73
+ generic: {
74
+ destination: 'Wherever your MCP client reads its config.',
75
+ serversKey: 'mcpServers',
76
+ instructions: (s) => [
77
+ `Add the snippet below as the value for "${s}" inside your client's mcpServers (or equivalent) object.`,
78
+ 'Restart the client.',
79
+ ],
80
+ },
81
+ };
82
+ function buildSnippet(opts) {
83
+ const tmpl = TEMPLATES[opts.client];
84
+ const baseEntry = { command: opts.command, args: opts.args };
85
+ if (opts.env)
86
+ baseEntry.env = opts.env;
87
+ const wrapped = (0, clients_js_1.wrapEntry)(baseEntry, {
88
+ projectId: opts.projectId,
89
+ endpoint: opts.endpoint,
90
+ agentName: opts.agentName,
91
+ style: opts.style ?? 'npx',
92
+ });
93
+ const obj = { [opts.serverName]: wrapped };
94
+ const json = JSON.stringify(obj, null, 2);
95
+ return {
96
+ client: opts.client,
97
+ destination: tmpl.destination,
98
+ serversKey: tmpl.serversKey,
99
+ json,
100
+ instructions: tmpl.instructions(opts.serverName),
101
+ };
102
+ }
103
+ function formatSnippet(out) {
104
+ const lines = [];
105
+ lines.push('');
106
+ lines.push(`▾ ${out.client.toUpperCase()} — paste-ready snippet`);
107
+ lines.push('');
108
+ lines.push('Destination:');
109
+ lines.push(` ${out.destination}`);
110
+ lines.push('');
111
+ lines.push('Steps:');
112
+ out.instructions.forEach((s, i) => lines.push(` ${i + 1}. ${s}`));
113
+ lines.push('');
114
+ lines.push(`JSON (under "${out.serversKey}"):`);
115
+ lines.push('');
116
+ out.json.split('\n').forEach((l) => lines.push(' ' + l));
117
+ lines.push('');
118
+ return lines.join('\n');
119
+ }
@@ -0,0 +1,16 @@
1
+ export interface CompatClientReport {
2
+ id: string;
3
+ status: 'patched' | 'no-changes' | 'error' | 'dry-run' | 'bootstrapped' | 'not-detected';
4
+ configFormat?: 'json' | 'protobuf' | 'binary-unknown' | 'missing';
5
+ topLevelKeysFingerprint?: string;
6
+ serverCount?: number;
7
+ wrappedCount?: number;
8
+ }
9
+ export interface CompatPayload {
10
+ cliVersion: string;
11
+ platform: string;
12
+ reports: CompatClientReport[];
13
+ errorSummary?: string;
14
+ }
15
+ export declare function fingerprintConfig(parsed: unknown): string;
16
+ export declare function sendCompatReport(payload: CompatPayload, endpoint?: string): Promise<void>;
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ // Anonymous compatibility telemetry.
3
+ //
4
+ // Why: clients like Windsurf or Cursor occasionally change where or how they
5
+ // store MCP config. The moment they do, our `init` silently stops auto-
6
+ // patching for that client. We won't hear about it until a user files an
7
+ // issue — by then the bad version has shipped to thousands.
8
+ //
9
+ // Solution: when init/doctor runs, POST a small payload to
10
+ // /api/internal/compat-report
11
+ // describing what we found per client. The backend aggregates schema
12
+ // fingerprints + version hashes. When a new fingerprint appears for an
13
+ // existing client we get an alert and ship a fix.
14
+ //
15
+ // Payload is anonymous: no user identity, no config contents — only:
16
+ // - cli version (so we know which mcpspend rolled into the wall)
17
+ // - per-client: { id, configFormat, fingerprint, status }
18
+ // where fingerprint = sha256(sorted top-level keys) — leaks zero PII but
19
+ // lets us spot "this file used to have mcpServers, now has serverGroups".
20
+ //
21
+ // Opt out: MCPSPEND_NO_TELEMETRY=1
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.fingerprintConfig = fingerprintConfig;
24
+ exports.sendCompatReport = sendCompatReport;
25
+ const node_crypto_1 = require("node:crypto");
26
+ const ENDPOINT_DEFAULT = 'https://api.mcpspend.com/api/internal/compat-report';
27
+ function fingerprintConfig(parsed) {
28
+ if (!parsed || typeof parsed !== 'object')
29
+ return 'NA';
30
+ const keys = Object.keys(parsed).sort();
31
+ return (0, node_crypto_1.createHash)('sha256').update(keys.join(',')).digest('hex').slice(0, 16);
32
+ }
33
+ async function sendCompatReport(payload, endpoint) {
34
+ if (process.env.MCPSPEND_NO_TELEMETRY === '1')
35
+ return;
36
+ const url = endpoint || process.env.MCPSPEND_COMPAT_ENDPOINT || ENDPOINT_DEFAULT;
37
+ try {
38
+ const ac = new AbortController();
39
+ const timer = setTimeout(() => ac.abort(), 4000);
40
+ await fetch(url, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(payload),
44
+ signal: ac.signal,
45
+ }).catch(() => undefined);
46
+ clearTimeout(timer);
47
+ }
48
+ catch {
49
+ // Never propagate telemetry failures.
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspend/proxy",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -36,7 +36,7 @@
36
36
  "scripts": {
37
37
  "build": "tsc",
38
38
  "dev": "tsx src/cli.ts",
39
- "test": "node --test --import tsx src/clients.test.ts",
39
+ "test": "node --test --import tsx src/clients.test.ts src/proxy.test.ts",
40
40
  "typecheck": "tsc --noEmit",
41
41
  "prepublishOnly": "npm run build"
42
42
  },