@mcpspend/proxy 0.1.4 → 0.2.1

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/README.md CHANGED
@@ -1,52 +1,70 @@
1
1
  # @mcpspend/proxy
2
2
 
3
- Transparent observability proxy for MCP (Model Context Protocol) servers.
3
+ One-command observability for [MCP](https://modelcontextprotocol.io) (Model Context Protocol) servers.
4
4
 
5
- Wraps any stdio MCP server, intercepts JSON-RPC `tools/call` traffic, and reports each call (tool, latency, success, approximate token sizes) to [MCPSpend](https://mcpspend.com) for cost attribution and analytics.
5
+ Detects every MCP client installed on your machine — Claude Desktop, Cursor, Windsurf, VS Code, Claude Code — and transparently wraps each configured server so MCPSpend can attribute tool calls, latency and cost. The proxy never blocks the MCP wire: if our API is unreachable, your agent keeps working.
6
6
 
7
- Fire-and-forget: the proxy never blocks the MCP wire if the MCPSpend API is unreachable, your agent keeps working.
7
+ ## Zero-friction setupno install needed
8
8
 
9
- ## Install
9
+ ```sh
10
+ npx @mcpspend/proxy init --key mcps_live_xxx
11
+ ```
12
+
13
+ That single command:
14
+
15
+ 1. Saves your API key to `~/.mcpspend/config.json` (mode `0600`).
16
+ 2. Discovers every supported MCP client config on your machine.
17
+ 3. Rewrites each `mcpServers` entry to be wrapped by `npx -y @mcpspend/proxy wrap --` (so the moment you restart your MCP client, it auto-fetches the proxy if missing).
18
+ 4. Leaves a `.mcpspend.bak` backup next to every file it touches.
19
+
20
+ > Prefer a global binary? `npm install -g @mcpspend/proxy` then use `mcpspend init` — everything below works the same way.
21
+
22
+ Restart your clients (Claude Desktop, Cursor, Windsurf, etc.) and head to [mcpspend.com](https://mcpspend.com) — calls start showing up within a minute.
23
+
24
+ Get an API key at [mcpspend.com/dashboard/keys](https://mcpspend.com/dashboard/keys).
25
+
26
+ ## Verify
10
27
 
11
28
  ```sh
12
- npm install -g @mcpspend/proxy
29
+ npx @mcpspend/proxy doctor
13
30
  ```
14
31
 
15
- ## Quick start
32
+ Reports API key status, endpoint reachability, and for every detected client: how many MCP servers are configured and how many are wrapped.
16
33
 
17
- 1. Sign in at [mcpspend.com](https://mcpspend.com) and create an API key.
18
- 2. Configure once:
19
- ```sh
20
- mcpspend config set apiKey mcps_live_xxx
21
- ```
22
- 3. Wrap any MCP server you already use:
23
- ```sh
24
- mcpspend wrap -- npx @modelcontextprotocol/server-filesystem /path
25
- ```
34
+ ## Undo
26
35
 
27
- ## Usage with Claude Desktop / Claude Code
36
+ ```sh
37
+ npx @mcpspend/proxy init --unwrap
38
+ ```
28
39
 
29
- Wherever you have an MCP server configured, prepend `mcpspend wrap --` to the command:
40
+ Restores every wrapped entry to its original `command` + `args`. The `.mcpspend.bak` files are left in place for paranoia.
30
41
 
31
- **Before:**
32
- ```json
33
- {
34
- "mcpServers": {
35
- "filesystem": {
36
- "command": "npx",
37
- "args": ["@modelcontextprotocol/server-filesystem", "/Users/me"]
38
- }
39
- }
40
- }
42
+ ## Supported clients
43
+
44
+ | Client | Config path (detected automatically) |
45
+ |---|---|
46
+ | Claude Desktop | `%APPDATA%\Claude\claude_desktop_config.json` · `~/Library/Application Support/Claude/claude_desktop_config.json` |
47
+ | Cursor | `~/.cursor/mcp.json` |
48
+ | Windsurf | `~/.codeium/windsurf/mcp_config.json` |
49
+ | VS Code (user) | `%APPDATA%\Code\User\mcp.json` · `~/.config/Code/User/mcp.json` |
50
+ | Claude Code | `~/.claude.json` |
51
+
52
+ ## Manual single-server wrap
53
+
54
+ If you want to wrap one specific invocation without touching client configs:
55
+
56
+ ```sh
57
+ npx @mcpspend/proxy wrap --key mcps_live_xxx -- npx @modelcontextprotocol/server-filesystem /data
41
58
  ```
42
59
 
43
- **After:**
60
+ Or paste this into a client config yourself:
61
+
44
62
  ```json
45
63
  {
46
64
  "mcpServers": {
47
65
  "filesystem": {
48
- "command": "mcpspend",
49
- "args": ["wrap", "--key", "mcps_live_xxx", "--", "npx", "@modelcontextprotocol/server-filesystem", "/Users/me"]
66
+ "command": "npx",
67
+ "args": ["-y", "@mcpspend/proxy", "wrap", "--", "npx", "@modelcontextprotocol/server-filesystem", "/Users/me"]
50
68
  }
51
69
  }
52
70
  }
@@ -64,9 +82,12 @@ CLI flags, environment variables, and `~/.mcpspend/config.json` are merged in th
64
82
  | Agent name | `--agent` | `MCPSPEND_AGENT_NAME` | `agentName` |
65
83
  | Disable tracking | `--disable` | `MCPSPEND_DISABLED=1` | `disabled: true` |
66
84
 
85
+ The API key is **never** written into client config files (which often land in dotfile repos). It lives only in `~/.mcpspend/config.json` (mode `0600`) or `MCPSPEND_API_KEY`.
86
+
67
87
  ## Privacy
68
88
 
69
89
  The proxy reports:
90
+
70
91
  - Tool name (e.g. `read_file`)
71
92
  - Server name (e.g. `filesystem`)
72
93
  - Latency, success, error codes
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js CHANGED
@@ -3,37 +3,62 @@
3
3
  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
- const VERSION = '0.1.0';
6
+ const init_js_1 = require("./init.js");
7
+ const VERSION = '0.2.1';
7
8
  const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
8
9
 
9
10
  USAGE
10
- mcpspend wrap [options] -- <command> [args...]
11
+ mcpspend init [options] Auto-detect MCP clients and wrap their servers
12
+ mcpspend doctor Diagnose setup (clients, API key, endpoint)
13
+ mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation
11
14
  mcpspend config set <key> <value>
12
15
  mcpspend config show
13
- mcpspend --version
14
- mcpspend --help
16
+ mcpspend --version | --help
17
+
18
+ INIT OPTIONS
19
+ --key <value> Save API key to ~/.mcpspend/config.json before patching
20
+ --project <id> Attribute calls to this project (baked into wrapped args)
21
+ --endpoint <url> Override API endpoint (default: https://api.mcpspend.com)
22
+ --agent <name> Agent name reported in dashboards
23
+ --client <id> Only patch a specific client (repeatable). IDs:
24
+ claude-desktop, cursor, windsurf, vscode, claude-code
25
+ --dry-run Show what would change without writing
26
+ --unwrap Restore original (non-wrapped) MCP server configs
15
27
 
16
28
  WRAP OPTIONS
17
29
  --key <value> API key (overrides config + MCPSPEND_API_KEY)
18
- --endpoint <url> API endpoint (default: https://api.mcpspend.com)
30
+ --endpoint <url> API endpoint
19
31
  --project <id> Attribute calls to this project
20
- --agent <name> Agent name reported in dashboards
32
+ --agent <name> Agent name
21
33
  --model <name> Model name for cost attribution (default: mcp-stdio)
22
34
  --disable Run in passthrough mode (no tracking)
23
35
 
24
36
  EXAMPLES
25
- # Wrap a stdio MCP server, attribute to a specific project
26
- mcpspend wrap --key mcps_live_xxx --project prj_abc -- npx @modelcontextprotocol/server-filesystem /data
37
+ # First-time setup: paste your API key, patch every installed MCP client
38
+ mcpspend init --key mcps_live_xxx
39
+
40
+ # See what init would change without writing
41
+ mcpspend init --dry-run
42
+
43
+ # Restore original configs (removes mcpspend wrapping)
44
+ mcpspend init --unwrap
45
+
46
+ # Only patch Cursor and Windsurf
47
+ mcpspend init --client cursor --client windsurf
27
48
 
28
- # Configure once, then run many wrapped servers without flags
29
- mcpspend config set apiKey mcps_live_xxx
30
- mcpspend wrap -- npx @modelcontextprotocol/server-github
49
+ # Manual single-server wrap (no client config touched)
50
+ mcpspend wrap --key mcps_live_xxx -- npx @modelcontextprotocol/server-filesystem /data
31
51
 
32
52
  Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1
33
53
  Config file: ~/.mcpspend/config.json
34
54
  `;
35
55
  function parseArgs(argv) {
36
- const result = { command: 'help', wrapOpts: {}, childArgs: [] };
56
+ const result = {
57
+ command: 'help',
58
+ wrapOpts: {},
59
+ initOpts: { clients: [], dryRun: false, unwrap: false },
60
+ childArgs: [],
61
+ };
37
62
  if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
38
63
  result.command = 'help';
39
64
  return result;
@@ -43,6 +68,52 @@ function parseArgs(argv) {
43
68
  return result;
44
69
  }
45
70
  const cmd = argv[0];
71
+ if (cmd === 'init') {
72
+ result.command = 'init';
73
+ let i = 1;
74
+ while (i < argv.length) {
75
+ const a = argv[i];
76
+ const next = argv[i + 1];
77
+ switch (a) {
78
+ case '--key':
79
+ result.initOpts.apiKey = next;
80
+ i += 2;
81
+ break;
82
+ case '--project':
83
+ result.initOpts.projectId = next;
84
+ i += 2;
85
+ break;
86
+ case '--endpoint':
87
+ result.initOpts.endpoint = next;
88
+ i += 2;
89
+ break;
90
+ case '--agent':
91
+ result.initOpts.agentName = next;
92
+ i += 2;
93
+ break;
94
+ case '--client':
95
+ result.initOpts.clients.push(next);
96
+ i += 2;
97
+ break;
98
+ case '--dry-run':
99
+ result.initOpts.dryRun = true;
100
+ i += 1;
101
+ break;
102
+ case '--unwrap':
103
+ result.initOpts.unwrap = true;
104
+ i += 1;
105
+ break;
106
+ default:
107
+ process.stderr.write(`mcpspend: unknown option ${a}\n`);
108
+ process.exit(2);
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+ if (cmd === 'doctor') {
114
+ result.command = 'doctor';
115
+ return result;
116
+ }
46
117
  if (cmd === 'wrap') {
47
118
  result.command = 'wrap';
48
119
  let i = 1;
@@ -124,6 +195,28 @@ async function main() {
124
195
  process.stdout.write(`${VERSION}\n`);
125
196
  return;
126
197
  }
198
+ if (parsed.command === 'init') {
199
+ const report = (0, init_js_1.runInit)({
200
+ apiKey: parsed.initOpts.apiKey,
201
+ projectId: parsed.initOpts.projectId,
202
+ endpoint: parsed.initOpts.endpoint,
203
+ agentName: parsed.initOpts.agentName,
204
+ clientFilter: parsed.initOpts.clients.length ? parsed.initOpts.clients : undefined,
205
+ dryRun: parsed.initOpts.dryRun,
206
+ unwrap: parsed.initOpts.unwrap,
207
+ });
208
+ process.stdout.write((0, init_js_1.formatReport)(report, parsed.initOpts.unwrap) + '\n');
209
+ if (report.clients.some((c) => c.status === 'error'))
210
+ process.exit(1);
211
+ return;
212
+ }
213
+ if (parsed.command === 'doctor') {
214
+ const report = await (0, init_js_1.runDoctor)(VERSION);
215
+ process.stdout.write((0, init_js_1.formatDoctor)(report) + '\n');
216
+ if (!report.apiKeyConfigured || !report.endpointReachable)
217
+ process.exit(1);
218
+ return;
219
+ }
127
220
  if (parsed.command === 'config') {
128
221
  if (parsed.configAction === 'show') {
129
222
  const cfg = (0, config_js_1.loadConfig)();
@@ -0,0 +1,61 @@
1
+ export interface McpServerEntry {
2
+ command: string;
3
+ args?: string[];
4
+ env?: Record<string, string>;
5
+ [key: string]: unknown;
6
+ }
7
+ export interface ClientConfig {
8
+ mcpServers?: Record<string, McpServerEntry>;
9
+ [key: string]: unknown;
10
+ }
11
+ export interface ClientDefinition {
12
+ id: 'claude-desktop' | 'cursor' | 'windsurf' | 'vscode' | 'vscode-workspace' | 'claude-code';
13
+ name: string;
14
+ configPaths: () => string[];
15
+ serversKey?: string;
16
+ }
17
+ export declare const CLIENTS: ClientDefinition[];
18
+ export interface DiscoveredClient {
19
+ client: ClientDefinition;
20
+ path: string;
21
+ }
22
+ export declare function discoverClients(): DiscoveredClient[];
23
+ export declare function readClientConfig(path: string): ClientConfig;
24
+ export declare function writeClientConfig(path: string, config: ClientConfig, backupSuffix?: string): string;
25
+ export interface WrapResult {
26
+ serverName: string;
27
+ status: 'wrapped' | 'already-wrapped' | 'skipped';
28
+ reason?: string;
29
+ }
30
+ export interface WrapOptions {
31
+ apiKey?: string;
32
+ projectId?: string;
33
+ endpoint?: string;
34
+ agentName?: string;
35
+ /**
36
+ * Wrap style:
37
+ * - 'npx' (default): emits `npx -y @mcpspend/proxy wrap -- <orig>`. Works without a global install.
38
+ * - 'bin': emits `mcpspend wrap -- <orig>`. Requires `npm i -g @mcpspend/proxy`.
39
+ */
40
+ style?: 'npx' | 'bin';
41
+ }
42
+ /**
43
+ * Wrap a single MCP server entry. Returns a NEW entry; does not mutate the original.
44
+ *
45
+ * - Keeps `env` untouched.
46
+ * - API key is NOT baked into args (would leak into git-tracked dotfiles). The proxy
47
+ * reads it from ~/.mcpspend/config.json (mode 0600) or MCPSPEND_API_KEY env.
48
+ */
49
+ export declare function wrapEntry(entry: McpServerEntry, opts?: WrapOptions): McpServerEntry;
50
+ export declare function unwrapEntry(entry: McpServerEntry): McpServerEntry | null;
51
+ /**
52
+ * Apply wrapping to all MCP servers in a config. Returns the modified config + per-server results.
53
+ */
54
+ export declare function wrapAllServers(config: ClientConfig, serversKey: string, opts?: WrapOptions): {
55
+ config: ClientConfig;
56
+ results: WrapResult[];
57
+ };
58
+ export declare function unwrapAllServers(config: ClientConfig, serversKey: string): {
59
+ config: ClientConfig;
60
+ results: WrapResult[];
61
+ };
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLIENTS = void 0;
4
+ exports.discoverClients = discoverClients;
5
+ exports.readClientConfig = readClientConfig;
6
+ exports.writeClientConfig = writeClientConfig;
7
+ exports.wrapEntry = wrapEntry;
8
+ exports.unwrapEntry = unwrapEntry;
9
+ exports.wrapAllServers = wrapAllServers;
10
+ exports.unwrapAllServers = unwrapAllServers;
11
+ const node_fs_1 = require("node:fs");
12
+ const node_os_1 = require("node:os");
13
+ const node_path_1 = require("node:path");
14
+ const home = (0, node_os_1.homedir)();
15
+ const isWin = (0, node_os_1.platform)() === 'win32';
16
+ const isMac = (0, node_os_1.platform)() === 'darwin';
17
+ function appData() {
18
+ if (isWin)
19
+ return process.env.APPDATA || (0, node_path_1.join)(home, 'AppData', 'Roaming');
20
+ if (isMac)
21
+ return (0, node_path_1.join)(home, 'Library', 'Application Support');
22
+ return process.env.XDG_CONFIG_HOME || (0, node_path_1.join)(home, '.config');
23
+ }
24
+ exports.CLIENTS = [
25
+ {
26
+ id: 'claude-desktop',
27
+ name: 'Claude Desktop',
28
+ configPaths: () => [(0, node_path_1.join)(appData(), 'Claude', 'claude_desktop_config.json')],
29
+ },
30
+ {
31
+ id: 'cursor',
32
+ name: 'Cursor',
33
+ configPaths: () => [(0, node_path_1.join)(home, '.cursor', 'mcp.json')],
34
+ },
35
+ {
36
+ id: 'windsurf',
37
+ name: 'Windsurf',
38
+ configPaths: () => [
39
+ (0, node_path_1.join)(home, '.codeium', 'windsurf', 'mcp_config.json'),
40
+ (0, node_path_1.join)(home, '.codeium', 'windsurf-next', 'mcp_config.json'),
41
+ ],
42
+ },
43
+ {
44
+ id: 'vscode',
45
+ name: 'VS Code (user)',
46
+ configPaths: () => {
47
+ const candidates = [];
48
+ if (isWin) {
49
+ candidates.push((0, node_path_1.join)(appData(), 'Code', 'User', 'mcp.json'));
50
+ }
51
+ else if (isMac) {
52
+ candidates.push((0, node_path_1.join)(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'));
53
+ }
54
+ else {
55
+ candidates.push((0, node_path_1.join)(home, '.config', 'Code', 'User', 'mcp.json'));
56
+ }
57
+ return candidates;
58
+ },
59
+ serversKey: 'servers',
60
+ },
61
+ {
62
+ id: 'claude-code',
63
+ name: 'Claude Code',
64
+ configPaths: () => [(0, node_path_1.join)(home, '.claude.json')],
65
+ },
66
+ ];
67
+ function discoverClients() {
68
+ const found = [];
69
+ for (const c of exports.CLIENTS) {
70
+ for (const p of c.configPaths()) {
71
+ if ((0, node_fs_1.existsSync)(p)) {
72
+ found.push({ client: c, path: p });
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ return found;
78
+ }
79
+ function readClientConfig(path) {
80
+ if (!(0, node_fs_1.existsSync)(path))
81
+ return {};
82
+ try {
83
+ const raw = (0, node_fs_1.readFileSync)(path, 'utf-8');
84
+ if (!raw.trim())
85
+ return {};
86
+ return JSON.parse(raw);
87
+ }
88
+ catch (err) {
89
+ throw new Error(`Failed to parse ${path}: ${err.message}`);
90
+ }
91
+ }
92
+ function writeClientConfig(path, config, backupSuffix = '.mcpspend.bak') {
93
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
94
+ let backupPath;
95
+ if ((0, node_fs_1.existsSync)(path)) {
96
+ backupPath = path + backupSuffix;
97
+ if (!(0, node_fs_1.existsSync)(backupPath)) {
98
+ (0, node_fs_1.copyFileSync)(path, backupPath);
99
+ }
100
+ }
101
+ (0, node_fs_1.writeFileSync)(path, JSON.stringify(config, null, 2) + '\n');
102
+ return backupPath ?? '';
103
+ }
104
+ // We support two wrap shapes:
105
+ // - npx-first (default, recommended): command='npx', args=['-y','@mcpspend/proxy','wrap',...]
106
+ // No global install required — works the moment Node is on PATH.
107
+ // - bin-direct (legacy / for users who installed -g @mcpspend/proxy):
108
+ // command='mcpspend', args=['wrap',...]
109
+ // `isAlreadyWrapped` accepts BOTH shapes so we don't re-wrap when migrating from
110
+ // the old layout, and so users who manually pinned the global binary aren't disturbed.
111
+ const MCPSPEND_BIN_NAMES = new Set(['mcpspend', 'mcpspend.cmd', 'mcpspend.exe']);
112
+ const NPX_BIN_NAMES = new Set(['npx', 'npx.cmd', 'npx.exe']);
113
+ const PROXY_PKG = '@mcpspend/proxy';
114
+ function isAlreadyWrapped(entry) {
115
+ const cmd = (entry.command || '').toLowerCase();
116
+ const base = cmd.split(/[\\/]/).pop() || cmd;
117
+ const args = entry.args || [];
118
+ // Shape A: `mcpspend wrap ...`
119
+ if (MCPSPEND_BIN_NAMES.has(base) && args[0] === 'wrap')
120
+ return true;
121
+ // Shape B: `npx [-y] @mcpspend/proxy wrap ...`
122
+ if (NPX_BIN_NAMES.has(base)) {
123
+ const skipFlag = args[0] === '-y' || args[0] === '--yes' ? 1 : 0;
124
+ if (args[skipFlag] === PROXY_PKG && args[skipFlag + 1] === 'wrap')
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ /**
130
+ * Find the position of the original command/args after the wrap prefix. Returns
131
+ * the index of the `--` separator, or -1 if the entry isn't wrapped.
132
+ */
133
+ function findOriginalCommandStart(entry) {
134
+ if (!isAlreadyWrapped(entry))
135
+ return -1;
136
+ const args = entry.args || [];
137
+ const sepIdx = args.indexOf('--');
138
+ return sepIdx === -1 ? -1 : sepIdx;
139
+ }
140
+ /**
141
+ * Wrap a single MCP server entry. Returns a NEW entry; does not mutate the original.
142
+ *
143
+ * - Keeps `env` untouched.
144
+ * - API key is NOT baked into args (would leak into git-tracked dotfiles). The proxy
145
+ * reads it from ~/.mcpspend/config.json (mode 0600) or MCPSPEND_API_KEY env.
146
+ */
147
+ function wrapEntry(entry, opts = {}) {
148
+ const style = opts.style || 'npx';
149
+ const wrapArgs = ['wrap'];
150
+ if (opts.endpoint)
151
+ wrapArgs.push('--endpoint', opts.endpoint);
152
+ if (opts.projectId)
153
+ wrapArgs.push('--project', opts.projectId);
154
+ if (opts.agentName)
155
+ wrapArgs.push('--agent', opts.agentName);
156
+ wrapArgs.push('--', entry.command, ...(entry.args || []));
157
+ if (style === 'bin') {
158
+ return { ...entry, command: 'mcpspend', args: wrapArgs };
159
+ }
160
+ // npx style — prefix wrapArgs with `-y @mcpspend/proxy`.
161
+ return { ...entry, command: 'npx', args: ['-y', PROXY_PKG, ...wrapArgs] };
162
+ }
163
+ function unwrapEntry(entry) {
164
+ const sepIdx = findOriginalCommandStart(entry);
165
+ if (sepIdx === -1)
166
+ return null;
167
+ const args = entry.args || [];
168
+ if (sepIdx + 1 >= args.length)
169
+ return null;
170
+ const restored = {
171
+ ...entry,
172
+ command: args[sepIdx + 1],
173
+ args: args.slice(sepIdx + 2),
174
+ };
175
+ return restored;
176
+ }
177
+ /**
178
+ * Apply wrapping to all MCP servers in a config. Returns the modified config + per-server results.
179
+ */
180
+ function wrapAllServers(config, serversKey, opts = {}) {
181
+ const results = [];
182
+ const servers = config[serversKey] || {};
183
+ const next = {};
184
+ for (const [name, entry] of Object.entries(servers)) {
185
+ if (!entry || typeof entry !== 'object' || !('command' in entry)) {
186
+ next[name] = entry;
187
+ results.push({ serverName: name, status: 'skipped', reason: 'no command field' });
188
+ continue;
189
+ }
190
+ if (isAlreadyWrapped(entry)) {
191
+ next[name] = entry;
192
+ results.push({ serverName: name, status: 'already-wrapped' });
193
+ continue;
194
+ }
195
+ next[name] = wrapEntry(entry, opts);
196
+ results.push({ serverName: name, status: 'wrapped' });
197
+ }
198
+ return { config: { ...config, [serversKey]: next }, results };
199
+ }
200
+ function unwrapAllServers(config, serversKey) {
201
+ const results = [];
202
+ const servers = config[serversKey] || {};
203
+ const next = {};
204
+ for (const [name, entry] of Object.entries(servers)) {
205
+ const restored = unwrapEntry(entry);
206
+ if (restored) {
207
+ next[name] = restored;
208
+ results.push({ serverName: name, status: 'wrapped', reason: 'restored to original' });
209
+ }
210
+ else {
211
+ next[name] = entry;
212
+ results.push({ serverName: name, status: 'skipped', reason: 'not wrapped by mcpspend' });
213
+ }
214
+ }
215
+ return { config: { ...config, [serversKey]: next }, results };
216
+ }
@@ -0,0 +1,9 @@
1
+ export interface Config {
2
+ apiKey?: string;
3
+ endpoint: string;
4
+ projectId?: string;
5
+ agentName?: string;
6
+ disabled?: boolean;
7
+ }
8
+ export declare function loadConfig(cliOverrides?: Partial<Config>): Config;
9
+ export declare function saveConfig(updates: Partial<Config>): string;
@@ -0,0 +1,8 @@
1
+ export { loadConfig, saveConfig } from './config.js';
2
+ export { runProxy } from './proxy.js';
3
+ export type { Config } from './config.js';
4
+ export type { ToolCallEvent } from './ingest.js';
5
+ export { CLIENTS, discoverClients, readClientConfig, writeClientConfig, wrapAllServers, unwrapAllServers, wrapEntry, unwrapEntry, } from './clients.js';
6
+ export type { ClientDefinition, ClientConfig, DiscoveredClient, McpServerEntry, WrapResult, WrapOptions, } from './clients.js';
7
+ export { runInit, runDoctor, formatReport, formatDoctor } from './init.js';
8
+ export type { InitOptions, InitReport, ClientReport, DoctorReport } from './init.js';
package/dist/index.js CHANGED
@@ -1,8 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runProxy = exports.saveConfig = exports.loadConfig = void 0;
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;
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; } });
7
7
  var proxy_js_1 = require("./proxy.js");
8
8
  Object.defineProperty(exports, "runProxy", { enumerable: true, get: function () { return proxy_js_1.runProxy; } });
9
+ // Programmatic API for embedders (VS Code extension, etc.)
10
+ var clients_js_1 = require("./clients.js");
11
+ Object.defineProperty(exports, "CLIENTS", { enumerable: true, get: function () { return clients_js_1.CLIENTS; } });
12
+ Object.defineProperty(exports, "discoverClients", { enumerable: true, get: function () { return clients_js_1.discoverClients; } });
13
+ Object.defineProperty(exports, "readClientConfig", { enumerable: true, get: function () { return clients_js_1.readClientConfig; } });
14
+ Object.defineProperty(exports, "writeClientConfig", { enumerable: true, get: function () { return clients_js_1.writeClientConfig; } });
15
+ Object.defineProperty(exports, "wrapAllServers", { enumerable: true, get: function () { return clients_js_1.wrapAllServers; } });
16
+ Object.defineProperty(exports, "unwrapAllServers", { enumerable: true, get: function () { return clients_js_1.unwrapAllServers; } });
17
+ Object.defineProperty(exports, "wrapEntry", { enumerable: true, get: function () { return clients_js_1.wrapEntry; } });
18
+ Object.defineProperty(exports, "unwrapEntry", { enumerable: true, get: function () { return clients_js_1.unwrapEntry; } });
19
+ var init_js_1 = require("./init.js");
20
+ Object.defineProperty(exports, "runInit", { enumerable: true, get: function () { return init_js_1.runInit; } });
21
+ Object.defineProperty(exports, "runDoctor", { enumerable: true, get: function () { return init_js_1.runDoctor; } });
22
+ Object.defineProperty(exports, "formatReport", { enumerable: true, get: function () { return init_js_1.formatReport; } });
23
+ Object.defineProperty(exports, "formatDoctor", { enumerable: true, get: function () { return init_js_1.formatDoctor; } });
@@ -0,0 +1,26 @@
1
+ import type { Config } from './config.js';
2
+ export interface ToolCallEvent {
3
+ serverName: string;
4
+ toolName: string;
5
+ model: string;
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ latencyMs: number;
9
+ success: boolean;
10
+ errorCode?: string;
11
+ calledAt: string;
12
+ projectId?: string;
13
+ sessionId?: string;
14
+ }
15
+ export declare class Ingest {
16
+ private cfg;
17
+ private queue;
18
+ private timer;
19
+ private inflight;
20
+ private dropped;
21
+ constructor(cfg: Config);
22
+ enqueue(event: ToolCallEvent): void;
23
+ private scheduleFlush;
24
+ flush(): Promise<void>;
25
+ shutdown(): Promise<void>;
26
+ }
package/dist/init.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { ClientDefinition, WrapResult } from './clients.js';
2
+ export interface InitOptions {
3
+ apiKey?: string;
4
+ projectId?: string;
5
+ endpoint?: string;
6
+ agentName?: string;
7
+ clientFilter?: string[];
8
+ dryRun?: boolean;
9
+ unwrap?: boolean;
10
+ }
11
+ export interface ClientReport {
12
+ client: ClientDefinition['id'];
13
+ name: string;
14
+ path: string;
15
+ status: 'patched' | 'no-changes' | 'error' | 'dry-run';
16
+ backupPath?: string;
17
+ servers: WrapResult[];
18
+ error?: string;
19
+ }
20
+ export interface InitReport {
21
+ apiKeyConfigured: boolean;
22
+ apiKeySource: 'flag' | 'env' | 'config' | 'none';
23
+ clientsFound: number;
24
+ clientsPatched: number;
25
+ clients: ClientReport[];
26
+ }
27
+ export declare function runInit(opts?: InitOptions): InitReport;
28
+ export declare function formatReport(report: InitReport, unwrap?: boolean): string;
29
+ export interface DoctorReport {
30
+ cliVersion: string;
31
+ apiKeyConfigured: boolean;
32
+ apiKeySource: 'env' | 'config' | 'none';
33
+ endpointReachable?: boolean;
34
+ endpointError?: string;
35
+ clients: Array<{
36
+ client: ClientDefinition['id'];
37
+ name: string;
38
+ path?: string;
39
+ detected: boolean;
40
+ serversCount: number;
41
+ wrappedCount: number;
42
+ }>;
43
+ }
44
+ export declare function runDoctor(cliVersion: string): Promise<DoctorReport>;
45
+ export declare function formatDoctor(report: DoctorReport): string;
package/dist/init.js ADDED
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runInit = runInit;
4
+ exports.formatReport = formatReport;
5
+ exports.runDoctor = runDoctor;
6
+ exports.formatDoctor = formatDoctor;
7
+ const clients_js_1 = require("./clients.js");
8
+ const config_js_1 = require("./config.js");
9
+ function runInit(opts = {}) {
10
+ if (opts.apiKey) {
11
+ (0, config_js_1.saveConfig)({ apiKey: opts.apiKey });
12
+ }
13
+ if (opts.endpoint) {
14
+ (0, config_js_1.saveConfig)({ endpoint: opts.endpoint });
15
+ }
16
+ const cfg = (0, config_js_1.loadConfig)();
17
+ const apiKeySource = opts.apiKey
18
+ ? 'flag'
19
+ : process.env.MCPSPEND_API_KEY
20
+ ? 'env'
21
+ : cfg.apiKey
22
+ ? 'config'
23
+ : 'none';
24
+ let discovered = (0, clients_js_1.discoverClients)();
25
+ if (opts.clientFilter && opts.clientFilter.length) {
26
+ const set = new Set(opts.clientFilter);
27
+ discovered = discovered.filter((d) => set.has(d.client.id));
28
+ }
29
+ const clientReports = [];
30
+ for (const { client, path } of discovered) {
31
+ const serversKey = client.serversKey || 'mcpServers';
32
+ try {
33
+ const current = (0, clients_js_1.readClientConfig)(path);
34
+ const { config: next, results } = opts.unwrap
35
+ ? (0, clients_js_1.unwrapAllServers)(current, serversKey)
36
+ : (0, clients_js_1.wrapAllServers)(current, serversKey, {
37
+ projectId: opts.projectId,
38
+ endpoint: opts.endpoint,
39
+ agentName: opts.agentName,
40
+ });
41
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
42
+ if (opts.dryRun) {
43
+ clientReports.push({
44
+ client: client.id,
45
+ name: client.name,
46
+ path,
47
+ status: 'dry-run',
48
+ servers: results,
49
+ });
50
+ continue;
51
+ }
52
+ if (!changed) {
53
+ clientReports.push({
54
+ client: client.id,
55
+ name: client.name,
56
+ path,
57
+ status: 'no-changes',
58
+ servers: results,
59
+ });
60
+ continue;
61
+ }
62
+ const backupPath = (0, clients_js_1.writeClientConfig)(path, next);
63
+ clientReports.push({
64
+ client: client.id,
65
+ name: client.name,
66
+ path,
67
+ status: 'patched',
68
+ backupPath: backupPath || undefined,
69
+ servers: results,
70
+ });
71
+ }
72
+ catch (err) {
73
+ clientReports.push({
74
+ client: client.id,
75
+ name: client.name,
76
+ path,
77
+ status: 'error',
78
+ servers: [],
79
+ error: err.message,
80
+ });
81
+ }
82
+ }
83
+ return {
84
+ apiKeyConfigured: !!cfg.apiKey || apiKeySource === 'env',
85
+ apiKeySource,
86
+ clientsFound: discovered.length,
87
+ clientsPatched: clientReports.filter((r) => r.status === 'patched').length,
88
+ clients: clientReports,
89
+ };
90
+ }
91
+ function formatReport(report, unwrap = false) {
92
+ const lines = [];
93
+ const action = unwrap ? 'Unwrap' : 'Wrap';
94
+ if (report.clientsFound === 0) {
95
+ lines.push('No supported MCP clients found on this machine.');
96
+ lines.push('');
97
+ lines.push('Looked for:');
98
+ for (const c of clients_js_1.CLIENTS) {
99
+ lines.push(` • ${c.name} — ${c.configPaths().join(', ')}`);
100
+ }
101
+ return lines.join('\n');
102
+ }
103
+ lines.push(`Found ${report.clientsFound} MCP client(s):`);
104
+ for (const r of report.clients) {
105
+ const tag = r.status === 'patched' ? '✓ patched'
106
+ : r.status === 'no-changes' ? '· no changes'
107
+ : r.status === 'dry-run' ? '∼ dry-run'
108
+ : `✗ error: ${r.error}`;
109
+ lines.push(` [${r.name}] ${tag}`);
110
+ lines.push(` ${r.path}`);
111
+ if (r.backupPath) {
112
+ lines.push(` backup: ${r.backupPath}`);
113
+ }
114
+ for (const s of r.servers) {
115
+ const sym = s.status === 'wrapped' ? '+'
116
+ : s.status === 'already-wrapped' ? '='
117
+ : '·';
118
+ const reason = s.reason ? ` (${s.reason})` : '';
119
+ lines.push(` ${sym} ${s.serverName}${reason}`);
120
+ }
121
+ }
122
+ lines.push('');
123
+ if (!report.apiKeyConfigured && !unwrap) {
124
+ lines.push('⚠ No API key configured. Tracking will be disabled until you set one:');
125
+ lines.push(' mcpspend config set apiKey mcps_live_xxx');
126
+ lines.push(' Get a key at https://mcpspend.com/dashboard/keys');
127
+ }
128
+ else if (!unwrap) {
129
+ lines.push(`API key source: ${report.apiKeySource}`);
130
+ }
131
+ if (action === 'Wrap' && report.clientsPatched > 0) {
132
+ lines.push('');
133
+ lines.push('Restart your MCP clients (Claude Desktop, Cursor, etc.) to pick up the new config.');
134
+ }
135
+ return lines.join('\n');
136
+ }
137
+ async function runDoctor(cliVersion) {
138
+ const cfg = (0, config_js_1.loadConfig)();
139
+ const apiKeySource = process.env.MCPSPEND_API_KEY
140
+ ? 'env'
141
+ : cfg.apiKey
142
+ ? 'config'
143
+ : 'none';
144
+ const discovered = (0, clients_js_1.discoverClients)();
145
+ const discoveredById = new Map(discovered.map((d) => [d.client.id, d]));
146
+ const clients = clients_js_1.CLIENTS.map((c) => {
147
+ const found = discoveredById.get(c.id);
148
+ if (!found) {
149
+ return { client: c.id, name: c.name, detected: false, serversCount: 0, wrappedCount: 0 };
150
+ }
151
+ let serversCount = 0;
152
+ let wrappedCount = 0;
153
+ try {
154
+ const cfg = (0, clients_js_1.readClientConfig)(found.path);
155
+ const serversKey = c.serversKey || 'mcpServers';
156
+ const servers = cfg[serversKey] || {};
157
+ serversCount = Object.keys(servers).length;
158
+ 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
+ }
164
+ }
165
+ }
166
+ catch { }
167
+ return { client: c.id, name: c.name, path: found.path, detected: true, serversCount, wrappedCount };
168
+ });
169
+ let endpointReachable;
170
+ let endpointError;
171
+ try {
172
+ const url = (cfg.endpoint || 'https://api.mcpspend.com') + '/health';
173
+ const ac = new AbortController();
174
+ const timer = setTimeout(() => ac.abort(), 5000);
175
+ const resp = await fetch(url, { signal: ac.signal });
176
+ clearTimeout(timer);
177
+ endpointReachable = resp.ok;
178
+ if (!resp.ok)
179
+ endpointError = `HTTP ${resp.status}`;
180
+ }
181
+ catch (err) {
182
+ endpointReachable = false;
183
+ endpointError = err.message;
184
+ }
185
+ return {
186
+ cliVersion,
187
+ apiKeyConfigured: apiKeySource !== 'none',
188
+ apiKeySource,
189
+ endpointReachable,
190
+ endpointError,
191
+ clients,
192
+ };
193
+ }
194
+ function formatDoctor(report) {
195
+ const lines = [];
196
+ lines.push(`mcpspend v${report.cliVersion}`);
197
+ lines.push('');
198
+ lines.push(`API key: ${report.apiKeyConfigured ? '✓' : '✗'} (${report.apiKeySource})`);
199
+ lines.push(`Endpoint: ${report.endpointReachable ? '✓ reachable' : `✗ ${report.endpointError || 'unreachable'}`}`);
200
+ lines.push('');
201
+ lines.push('Clients:');
202
+ for (const c of report.clients) {
203
+ if (!c.detected) {
204
+ lines.push(` · ${c.name} — not installed`);
205
+ continue;
206
+ }
207
+ const wrap = c.serversCount === 0 ? 'no servers configured' : `${c.wrappedCount}/${c.serversCount} wrapped`;
208
+ lines.push(` ✓ ${c.name} — ${wrap}`);
209
+ if (c.path)
210
+ lines.push(` ${c.path}`);
211
+ }
212
+ if (!report.apiKeyConfigured) {
213
+ lines.push('');
214
+ lines.push('Next: mcpspend config set apiKey mcps_live_xxx (get one at https://mcpspend.com/dashboard/keys)');
215
+ }
216
+ return lines.join('\n');
217
+ }
@@ -0,0 +1,7 @@
1
+ import type { Config } from './config.js';
2
+ export declare function runProxy(opts: {
3
+ command: string;
4
+ args: string[];
5
+ config: Config;
6
+ model: string;
7
+ }): Promise<number>;
package/dist/proxy.js CHANGED
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.runProxy = runProxy;
4
- const node_child_process_1 = require("node:child_process");
7
+ const cross_spawn_1 = __importDefault(require("cross-spawn"));
5
8
  const node_crypto_1 = require("node:crypto");
6
9
  const ingest_js_1 = require("./ingest.js");
7
10
  // Token estimation: ~4 chars per token for English/JSON content.
@@ -43,14 +46,12 @@ async function runProxy(opts) {
43
46
  if (!config.apiKey) {
44
47
  process.stderr.write('[mcpspend] no API key configured — running in passthrough mode (no tracking)\n');
45
48
  }
46
- // On Windows, Node's spawn() can't find `.cmd`/`.bat` shims (npx, etc.)
47
- // without going through cmd.exe. Use shell: true to let the OS resolve the
48
- // command same path most Node-based CLIs take.
49
- const isWindows = process.platform === 'win32';
50
- const child = (0, node_child_process_1.spawn)(command, args, {
49
+ // cross-spawn handles Windows .cmd/.bat resolution and argument quoting
50
+ // properly, avoiding both spawn ENOENT (no shell) and backslash mangling
51
+ // (shell: true). See https://github.com/moxystudio/node-cross-spawn.
52
+ const child = (0, cross_spawn_1.default)(command, args, {
51
53
  stdio: ['pipe', 'pipe', 'inherit'],
52
54
  env: process.env,
53
- shell: isWindows,
54
55
  });
55
56
  const pending = new Map();
56
57
  function handleClientMessage(line) {
@@ -123,6 +124,7 @@ async function runProxy(opts) {
123
124
  onLine(buf);
124
125
  });
125
126
  }
127
+ // stdio: ['pipe', 'pipe', 'inherit'] guarantees stdin/stdout exist
126
128
  pipeWithInspect(process.stdin, child.stdin, handleClientMessage);
127
129
  pipeWithInspect(child.stdout, process.stdout, handleServerMessage);
128
130
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspend/proxy",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
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",
@@ -22,6 +22,7 @@
22
22
  "mcpspend": "dist/cli.js"
23
23
  },
24
24
  "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
25
26
  "files": [
26
27
  "dist",
27
28
  "README.md"
@@ -29,6 +30,9 @@
29
30
  "engines": {
30
31
  "node": ">=18"
31
32
  },
33
+ "dependencies": {
34
+ "cross-spawn": "^7.0.6"
35
+ },
32
36
  "scripts": {
33
37
  "build": "tsc",
34
38
  "dev": "tsx src/cli.ts",
@@ -36,6 +40,7 @@
36
40
  "prepublishOnly": "npm run build"
37
41
  },
38
42
  "devDependencies": {
43
+ "@types/cross-spawn": "^6.0.6",
39
44
  "@types/node": "^22.10.10",
40
45
  "tsx": "^4.19.2",
41
46
  "typescript": "^5.7.3"