@mcpspend/proxy 0.2.1 → 0.3.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,7 +4,7 @@ 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.2.1';
7
+ const VERSION = '0.3.0';
8
8
  const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
9
9
 
10
10
  USAGE
package/dist/clients.d.ts CHANGED
@@ -12,12 +12,14 @@ export interface ClientDefinition {
12
12
  id: 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode' | 'vscode-workspace' | 'claude-code';
13
13
  name: string;
14
14
  configPaths: () => string[];
15
+ installMarkers?: () => string[];
15
16
  serversKey?: string;
16
17
  }
17
18
  export declare const CLIENTS: ClientDefinition[];
18
19
  export interface DiscoveredClient {
19
20
  client: ClientDefinition;
20
21
  path: string;
22
+ bootstrapped?: boolean;
21
23
  }
22
24
  export declare function discoverClients(): DiscoveredClient[];
23
25
  export declare function readClientConfig(path: string): ClientConfig;
@@ -27,6 +29,7 @@ export interface WrapResult {
27
29
  status: 'wrapped' | 'already-wrapped' | 'skipped';
28
30
  reason?: string;
29
31
  }
32
+ export declare function isAlreadyWrapped(entry: McpServerEntry): boolean;
30
33
  export interface WrapOptions {
31
34
  apiKey?: string;
32
35
  projectId?: string;
package/dist/clients.js CHANGED
@@ -4,6 +4,7 @@ exports.CLIENTS = void 0;
4
4
  exports.discoverClients = discoverClients;
5
5
  exports.readClientConfig = readClientConfig;
6
6
  exports.writeClientConfig = writeClientConfig;
7
+ exports.isAlreadyWrapped = isAlreadyWrapped;
7
8
  exports.wrapEntry = wrapEntry;
8
9
  exports.unwrapEntry = unwrapEntry;
9
10
  exports.wrapAllServers = wrapAllServers;
@@ -39,6 +40,22 @@ exports.CLIENTS = [
39
40
  (0, node_path_1.join)(home, '.codeium', 'windsurf', 'mcp_config.json'),
40
41
  (0, node_path_1.join)(home, '.codeium', 'windsurf-next', 'mcp_config.json'),
41
42
  ],
43
+ // Windsurf only creates mcp_config.json after the user adds a server via
44
+ // the UI. The .codeium/windsurf dir exists from the moment Windsurf runs
45
+ // once, and the AppData install dir exists from the install itself.
46
+ installMarkers: () => {
47
+ const markers = [
48
+ (0, node_path_1.join)(home, '.codeium', 'windsurf'),
49
+ (0, node_path_1.join)(home, '.codeium', 'windsurf-next'),
50
+ ];
51
+ if (isWin) {
52
+ markers.push((0, node_path_1.join)(process.env.LOCALAPPDATA || (0, node_path_1.join)(home, 'AppData', 'Local'), 'Windsurf'));
53
+ }
54
+ else if (isMac) {
55
+ markers.push('/Applications/Windsurf.app');
56
+ }
57
+ return markers;
58
+ },
42
59
  },
43
60
  {
44
61
  id: 'vscode',
@@ -67,12 +84,29 @@ exports.CLIENTS = [
67
84
  function discoverClients() {
68
85
  const found = [];
69
86
  for (const c of exports.CLIENTS) {
87
+ let matched = false;
88
+ // First try existing config files (the common case).
70
89
  for (const p of c.configPaths()) {
71
90
  if ((0, node_fs_1.existsSync)(p)) {
72
91
  found.push({ client: c, path: p });
92
+ matched = true;
73
93
  break;
74
94
  }
75
95
  }
96
+ if (matched)
97
+ continue;
98
+ // Fall back to install markers. If the client is installed but has never
99
+ // had a config written, take the first configPath as the destination and
100
+ // mark the discovery as bootstrapped.
101
+ if (c.installMarkers) {
102
+ const installed = c.installMarkers().some(p => (0, node_fs_1.existsSync)(p));
103
+ if (installed) {
104
+ const paths = c.configPaths();
105
+ if (paths.length > 0) {
106
+ found.push({ client: c, path: paths[0], bootstrapped: true });
107
+ }
108
+ }
109
+ }
76
110
  }
77
111
  return found;
78
112
  }
@@ -111,6 +145,15 @@ function writeClientConfig(path, config, backupSuffix = '.mcpspend.bak') {
111
145
  const MCPSPEND_BIN_NAMES = new Set(['mcpspend', 'mcpspend.cmd', 'mcpspend.exe']);
112
146
  const NPX_BIN_NAMES = new Set(['npx', 'npx.cmd', 'npx.exe']);
113
147
  const PROXY_PKG = '@mcpspend/proxy';
148
+ // True if `pkgArg` is some form of @mcpspend/proxy: bare, @version, or @tag.
149
+ // '@mcpspend/proxy', '@mcpspend/proxy@0.2.1', '@mcpspend/proxy@latest' → true
150
+ function isProxyPackageArg(pkgArg) {
151
+ if (!pkgArg)
152
+ return false;
153
+ if (pkgArg === PROXY_PKG)
154
+ return true;
155
+ return pkgArg.startsWith(PROXY_PKG + '@');
156
+ }
114
157
  function isAlreadyWrapped(entry) {
115
158
  const cmd = (entry.command || '').toLowerCase();
116
159
  const base = cmd.split(/[\\/]/).pop() || cmd;
@@ -118,10 +161,10 @@ function isAlreadyWrapped(entry) {
118
161
  // Shape A: `mcpspend wrap ...`
119
162
  if (MCPSPEND_BIN_NAMES.has(base) && args[0] === 'wrap')
120
163
  return true;
121
- // Shape B: `npx [-y] @mcpspend/proxy wrap ...`
164
+ // Shape B: `npx [-y] @mcpspend/proxy[@version] wrap ...`
122
165
  if (NPX_BIN_NAMES.has(base)) {
123
166
  const skipFlag = args[0] === '-y' || args[0] === '--yes' ? 1 : 0;
124
- if (args[skipFlag] === PROXY_PKG && args[skipFlag + 1] === 'wrap')
167
+ if (isProxyPackageArg(args[skipFlag]) && args[skipFlag + 1] === 'wrap')
125
168
  return true;
126
169
  }
127
170
  return false;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ // Unit tests for the wrap/unwrap logic in clients.ts.
3
+ //
4
+ // History: we shipped 0.2.0 with isAlreadyWrapped() failing to recognise
5
+ // `npx -y @mcpspend/proxy@latest wrap …`, which caused init to double-wrap
6
+ // configs that users had previously set up manually. These tests pin down
7
+ // every wrap shape we ever generate or accept.
8
+ //
9
+ // Run with: node --test --import tsx src/clients.test.ts
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ const node_test_1 = require("node:test");
15
+ const strict_1 = __importDefault(require("node:assert/strict"));
16
+ const clients_js_1 = require("./clients.js");
17
+ (0, node_test_1.describe)('isAlreadyWrapped', () => {
18
+ (0, node_test_1.test)('recognises npx -y @mcpspend/proxy wrap', () => {
19
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
20
+ command: 'npx',
21
+ args: ['-y', '@mcpspend/proxy', 'wrap', '--', 'node', 'server.js'],
22
+ }), true);
23
+ });
24
+ (0, node_test_1.test)('recognises npx -y @mcpspend/proxy@latest wrap', () => {
25
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
26
+ command: 'npx',
27
+ args: ['-y', '@mcpspend/proxy@latest', 'wrap', '--', 'node', 'server.js'],
28
+ }), true);
29
+ });
30
+ (0, node_test_1.test)('recognises npx -y @mcpspend/proxy@0.2.2 wrap', () => {
31
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
32
+ command: 'npx',
33
+ args: ['-y', '@mcpspend/proxy@0.2.2', 'wrap', '--', 'node', 'server.js'],
34
+ }), true);
35
+ });
36
+ (0, node_test_1.test)('recognises npx without -y flag', () => {
37
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
38
+ command: 'npx',
39
+ args: ['@mcpspend/proxy', 'wrap', '--', 'node', 'server.js'],
40
+ }), true);
41
+ });
42
+ (0, node_test_1.test)('recognises bin-direct mcpspend wrap', () => {
43
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
44
+ command: 'mcpspend',
45
+ args: ['wrap', '--', 'node', 'server.js'],
46
+ }), true);
47
+ });
48
+ (0, node_test_1.test)('recognises absolute Windows path to mcpspend.cmd', () => {
49
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
50
+ command: 'C:\\Users\\me\\AppData\\Roaming\\npm\\mcpspend.cmd',
51
+ args: ['wrap', '--', 'node', 'server.js'],
52
+ }), true);
53
+ });
54
+ (0, node_test_1.test)('rejects unrelated npx invocations', () => {
55
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
56
+ command: 'npx',
57
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'],
58
+ }), false);
59
+ });
60
+ (0, node_test_1.test)('rejects raw mcpspend without wrap subcommand', () => {
61
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
62
+ command: 'mcpspend',
63
+ args: ['init', '--key', 'mcps_live_xxx'],
64
+ }), false);
65
+ });
66
+ (0, node_test_1.test)('rejects empty args', () => {
67
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({ command: 'npx', args: [] }), false);
68
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({ command: 'mcpspend', args: [] }), false);
69
+ });
70
+ (0, node_test_1.test)('rejects an npx invoking some other package called proxy', () => {
71
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)({
72
+ command: 'npx',
73
+ args: ['-y', '@otherorg/proxy', 'wrap', '--', 'node', 'server.js'],
74
+ }), false);
75
+ });
76
+ });
77
+ (0, node_test_1.describe)('wrap → unwrap roundtrip', () => {
78
+ (0, node_test_1.test)('npx-style preserves command + args + env', () => {
79
+ const original = {
80
+ command: 'npx',
81
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'],
82
+ env: { FOO: 'bar' },
83
+ };
84
+ const wrapped = (0, clients_js_1.wrapEntry)(original);
85
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)(wrapped), true, 'wrapped should be recognised');
86
+ const restored = (0, clients_js_1.unwrapEntry)(wrapped);
87
+ strict_1.default.deepEqual(restored, original);
88
+ });
89
+ (0, node_test_1.test)('bin-style preserves command + args + env', () => {
90
+ const original = {
91
+ command: 'node',
92
+ args: ['./my-server.js'],
93
+ env: { GITHUB_TOKEN: 'xxx' },
94
+ };
95
+ const wrapped = (0, clients_js_1.wrapEntry)(original, { style: 'bin' });
96
+ strict_1.default.equal(wrapped.command, 'mcpspend');
97
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)(wrapped), true);
98
+ const restored = (0, clients_js_1.unwrapEntry)(wrapped);
99
+ strict_1.default.deepEqual(restored, original);
100
+ });
101
+ (0, node_test_1.test)('does NOT bake the API key into wrapped args (prevents git leak)', () => {
102
+ const wrapped = (0, clients_js_1.wrapEntry)({ command: 'node', args: ['server.js'] }, { apiKey: 'mcps_live_SECRET' });
103
+ const flat = JSON.stringify(wrapped);
104
+ strict_1.default.equal(flat.includes('mcps_live_SECRET'), false);
105
+ strict_1.default.equal(flat.includes('--key'), false);
106
+ });
107
+ (0, node_test_1.test)('projectId and agentName are baked in (they are not secrets)', () => {
108
+ const wrapped = (0, clients_js_1.wrapEntry)({ command: 'node', args: ['server.js'] }, { projectId: 'prj_xyz', agentName: 'demo-agent' });
109
+ strict_1.default.ok(wrapped.args.includes('--project'));
110
+ strict_1.default.ok(wrapped.args.includes('prj_xyz'));
111
+ strict_1.default.ok(wrapped.args.includes('--agent'));
112
+ strict_1.default.ok(wrapped.args.includes('demo-agent'));
113
+ });
114
+ });
115
+ (0, node_test_1.describe)('unwrapEntry edge cases', () => {
116
+ (0, node_test_1.test)('returns null for non-wrapped entries (no-op safety)', () => {
117
+ strict_1.default.equal((0, clients_js_1.unwrapEntry)({ command: 'npx', args: ['some', 'thing'] }), null);
118
+ });
119
+ (0, node_test_1.test)('handles wrapped entry with no -- separator gracefully', () => {
120
+ strict_1.default.equal((0, clients_js_1.unwrapEntry)({ command: 'mcpspend', args: ['wrap'] }), null);
121
+ });
122
+ });
123
+ (0, node_test_1.describe)('idempotency', () => {
124
+ (0, node_test_1.test)('wrapping a wrapped entry should not double-wrap (guarded at wrapAllServers level)', () => {
125
+ // wrapEntry itself does not check — it's wrapAllServers that gates on
126
+ // isAlreadyWrapped. Confirm that the gate works as documented.
127
+ const original = { command: 'npx', args: ['@modelcontextprotocol/server-filesystem', '/data'] };
128
+ const once = (0, clients_js_1.wrapEntry)(original);
129
+ strict_1.default.equal((0, clients_js_1.isAlreadyWrapped)(once), true);
130
+ // If wrapAllServers correctly skips, the entry passed to wrapEntry would
131
+ // already be wrapped — but defensive callers should rely on isAlreadyWrapped.
132
+ });
133
+ });
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { loadConfig, saveConfig } from './config.js';
2
2
  export { runProxy } from './proxy.js';
3
3
  export type { Config } from './config.js';
4
4
  export type { ToolCallEvent } from './ingest.js';
5
- export { CLIENTS, discoverClients, readClientConfig, writeClientConfig, wrapAllServers, unwrapAllServers, wrapEntry, unwrapEntry, } from './clients.js';
5
+ export { CLIENTS, discoverClients, readClientConfig, writeClientConfig, wrapAllServers, unwrapAllServers, wrapEntry, unwrapEntry, isAlreadyWrapped, } from './clients.js';
6
6
  export type { ClientDefinition, ClientConfig, DiscoveredClient, McpServerEntry, WrapResult, WrapOptions, } from './clients.js';
7
7
  export { runInit, runDoctor, formatReport, formatDoctor } from './init.js';
8
8
  export type { InitOptions, InitReport, ClientReport, DoctorReport } from './init.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.formatDoctor = exports.formatReport = exports.runDoctor = exports.runInit = exports.unwrapEntry = exports.wrapEntry = exports.unwrapAllServers = exports.wrapAllServers = exports.writeClientConfig = exports.readClientConfig = exports.discoverClients = exports.CLIENTS = exports.runProxy = exports.saveConfig = exports.loadConfig = void 0;
3
+ exports.formatDoctor = exports.formatReport = exports.runDoctor = exports.runInit = exports.isAlreadyWrapped = exports.unwrapEntry = exports.wrapEntry = exports.unwrapAllServers = exports.wrapAllServers = exports.writeClientConfig = exports.readClientConfig = exports.discoverClients = exports.CLIENTS = exports.runProxy = exports.saveConfig = exports.loadConfig = void 0;
4
4
  var config_js_1 = require("./config.js");
5
5
  Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_js_1.loadConfig; } });
6
6
  Object.defineProperty(exports, "saveConfig", { enumerable: true, get: function () { return config_js_1.saveConfig; } });
@@ -16,6 +16,7 @@ Object.defineProperty(exports, "wrapAllServers", { enumerable: true, get: functi
16
16
  Object.defineProperty(exports, "unwrapAllServers", { enumerable: true, get: function () { return clients_js_1.unwrapAllServers; } });
17
17
  Object.defineProperty(exports, "wrapEntry", { enumerable: true, get: function () { return clients_js_1.wrapEntry; } });
18
18
  Object.defineProperty(exports, "unwrapEntry", { enumerable: true, get: function () { return clients_js_1.unwrapEntry; } });
19
+ Object.defineProperty(exports, "isAlreadyWrapped", { enumerable: true, get: function () { return clients_js_1.isAlreadyWrapped; } });
19
20
  var init_js_1 = require("./init.js");
20
21
  Object.defineProperty(exports, "runInit", { enumerable: true, get: function () { return init_js_1.runInit; } });
21
22
  Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return init_js_1.runDoctor; } });
package/dist/init.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface ClientReport {
16
16
  backupPath?: string;
17
17
  servers: WrapResult[];
18
18
  error?: string;
19
+ bootstrapped?: boolean;
19
20
  }
20
21
  export interface InitReport {
21
22
  apiKeyConfigured: boolean;
package/dist/init.js CHANGED
@@ -27,10 +27,24 @@ function runInit(opts = {}) {
27
27
  discovered = discovered.filter((d) => set.has(d.client.id));
28
28
  }
29
29
  const clientReports = [];
30
- for (const { client, path } of discovered) {
30
+ for (const { client, path, bootstrapped } of discovered) {
31
31
  const serversKey = client.serversKey || 'mcpServers';
32
32
  try {
33
33
  const current = (0, clients_js_1.readClientConfig)(path);
34
+ // Bootstrap-mode: client is installed but has no MCP config yet. We still
35
+ // want to create the file with an empty mcpServers object so the user
36
+ // can add their first server via the UI and have it auto-wrapped on the
37
+ // next `init` run. Unwrap is a no-op here.
38
+ if (bootstrapped && opts.unwrap) {
39
+ clientReports.push({
40
+ client: client.id,
41
+ name: client.name,
42
+ path,
43
+ status: 'no-changes',
44
+ servers: [],
45
+ });
46
+ continue;
47
+ }
34
48
  const { config: next, results } = opts.unwrap
35
49
  ? (0, clients_js_1.unwrapAllServers)(current, serversKey)
36
50
  : (0, clients_js_1.wrapAllServers)(current, serversKey, {
@@ -38,7 +52,12 @@ function runInit(opts = {}) {
38
52
  endpoint: opts.endpoint,
39
53
  agentName: opts.agentName,
40
54
  });
41
- const changed = JSON.stringify(current) !== JSON.stringify(next);
55
+ const changed = bootstrapped || JSON.stringify(current) !== JSON.stringify(next);
56
+ if (bootstrapped && !(next[serversKey] && Object.keys(next[serversKey]).length > 0)) {
57
+ // Ensure the key exists in the new file even when empty.
58
+ ;
59
+ next[serversKey] = {};
60
+ }
42
61
  if (opts.dryRun) {
43
62
  clientReports.push({
44
63
  client: client.id,
@@ -67,6 +86,7 @@ function runInit(opts = {}) {
67
86
  status: 'patched',
68
87
  backupPath: backupPath || undefined,
69
88
  servers: results,
89
+ bootstrapped,
70
90
  });
71
91
  }
72
92
  catch (err) {
@@ -102,7 +122,7 @@ function formatReport(report, unwrap = false) {
102
122
  }
103
123
  lines.push(`Found ${report.clientsFound} MCP client(s):`);
104
124
  for (const r of report.clients) {
105
- const tag = r.status === 'patched' ? '✓ patched'
125
+ const tag = r.status === 'patched' ? (r.bootstrapped ? '✓ bootstrapped (empty config created)' : '✓ patched')
106
126
  : r.status === 'no-changes' ? '· no changes'
107
127
  : r.status === 'dry-run' ? '∼ dry-run'
108
128
  : `✗ error: ${r.error}`;
@@ -111,6 +131,9 @@ function formatReport(report, unwrap = false) {
111
131
  if (r.backupPath) {
112
132
  lines.push(` backup: ${r.backupPath}`);
113
133
  }
134
+ if (r.bootstrapped && r.servers.length === 0) {
135
+ lines.push(` (add MCP servers in the client UI — they'll be auto-wrapped next time you run init)`);
136
+ }
114
137
  for (const s of r.servers) {
115
138
  const sym = s.status === 'wrapped' ? '+'
116
139
  : s.status === 'already-wrapped' ? '='
@@ -156,11 +179,8 @@ async function runDoctor(cliVersion) {
156
179
  const servers = cfg[serversKey] || {};
157
180
  serversCount = Object.keys(servers).length;
158
181
  for (const s of Object.values(servers)) {
159
- const cmdBase = (s.command || '').toLowerCase().split(/[\\/]/).pop() || '';
160
- if (cmdBase === 'mcpspend' || cmdBase === 'mcpspend.cmd' || cmdBase === 'mcpspend.exe') {
161
- if ((s.args || [])[0] === 'wrap')
162
- wrappedCount++;
163
- }
182
+ if (s && typeof s.command === 'string' && (0, clients_js_1.isAlreadyWrapped)(s))
183
+ wrappedCount++;
164
184
  }
165
185
  }
166
186
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspend/proxy",
3
- "version": "0.2.1",
3
+ "version": "0.3.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,6 +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
40
  "typecheck": "tsc --noEmit",
40
41
  "prepublishOnly": "npm run build"
41
42
  },