@mcpspend/proxy 0.3.1 → 0.5.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 +202 -2
- package/dist/clients.js +32 -11
- package/dist/http-bridge.d.ts +8 -0
- package/dist/http-bridge.js +137 -0
- package/dist/init.d.ts +6 -0
- package/dist/init.js +35 -0
- package/dist/snippet.d.ts +20 -0
- package/dist/snippet.js +119 -0
- package/dist/telemetry.d.ts +16 -0
- package/dist/telemetry.js +51 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,20 @@ 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
|
|
7
|
+
const snippet_js_1 = require("./snippet.js");
|
|
8
|
+
const http_bridge_js_1 = require("./http-bridge.js");
|
|
9
|
+
const VERSION = '0.5.0';
|
|
8
10
|
const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
|
|
9
11
|
|
|
10
12
|
USAGE
|
|
11
13
|
mcpspend init [options] Auto-detect MCP clients and wrap their servers
|
|
12
14
|
mcpspend doctor Diagnose setup (clients, API key, endpoint)
|
|
13
15
|
mcpspend wrap [options] -- <cmd>... Manually wrap a single MCP server invocation
|
|
16
|
+
mcpspend wrap-http [options] Wrap a REMOTE HTTP MCP server (Figma, etc.)
|
|
17
|
+
Speaks stdio to the client, HTTP to the server.
|
|
18
|
+
mcpspend snippet [options] -- <cmd>...
|
|
19
|
+
Print a paste-ready JSON snippet for a
|
|
20
|
+
specific client (Windsurf protobuf, custom).
|
|
14
21
|
mcpspend config set <key> <value>
|
|
15
22
|
mcpspend config show
|
|
16
23
|
mcpspend --version | --help
|
|
@@ -33,6 +40,24 @@ WRAP OPTIONS
|
|
|
33
40
|
--model <name> Model name for cost attribution (default: mcp-stdio)
|
|
34
41
|
--disable Run in passthrough mode (no tracking)
|
|
35
42
|
|
|
43
|
+
WRAP-HTTP OPTIONS
|
|
44
|
+
--url <url> Required. Remote MCP endpoint (POSTs JSON-RPC there).
|
|
45
|
+
--key <value> API key (overrides config + MCPSPEND_API_KEY)
|
|
46
|
+
--endpoint <url> MCPSpend API endpoint (NOT the remote MCP URL)
|
|
47
|
+
--project <id> Attribute calls to this project
|
|
48
|
+
--agent <name> Agent name reported in dashboards
|
|
49
|
+
--model <name> Model name for cost attribution (default: mcp-http)
|
|
50
|
+
--auth <header> Pass-through Authorization header to the remote MCP
|
|
51
|
+
|
|
52
|
+
SNIPPET OPTIONS
|
|
53
|
+
--client <id> One of: claude-desktop, cursor, windsurf, vscode,
|
|
54
|
+
vscode-workspace, claude-code, generic. Default: generic.
|
|
55
|
+
--name <name> Server name in the resulting JSON. Default: inferred from --
|
|
56
|
+
--project <id> Attribute calls to this project (baked into wrap args)
|
|
57
|
+
--endpoint <url> API endpoint
|
|
58
|
+
--agent <name> Agent name
|
|
59
|
+
--style <npx|bin> Wrap style. Default: npx (no global install required).
|
|
60
|
+
|
|
36
61
|
EXAMPLES
|
|
37
62
|
# First-time setup: paste your API key, patch every installed MCP client
|
|
38
63
|
mcpspend init --key mcps_live_xxx
|
|
@@ -49,7 +74,14 @@ EXAMPLES
|
|
|
49
74
|
# Manual single-server wrap (no client config touched)
|
|
50
75
|
mcpspend wrap --key mcps_live_xxx -- npx @modelcontextprotocol/server-filesystem /data
|
|
51
76
|
|
|
52
|
-
|
|
77
|
+
# Get a copy-paste snippet for Windsurf (which stores config as protobuf)
|
|
78
|
+
mcpspend snippet --client windsurf --name playwright -- npx -y @playwright/mcp@latest
|
|
79
|
+
|
|
80
|
+
# Wrap a REMOTE HTTP MCP server (figma-remote etc.)
|
|
81
|
+
mcpspend wrap-http --url https://mcp.figma.com --key mcps_live_xxx \
|
|
82
|
+
--auth "Bearer FIGMA_TOKEN"
|
|
83
|
+
|
|
84
|
+
Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1, MCPSPEND_NO_TELEMETRY=1
|
|
53
85
|
Config file: ~/.mcpspend/config.json
|
|
54
86
|
`;
|
|
55
87
|
function parseArgs(argv) {
|
|
@@ -57,6 +89,8 @@ function parseArgs(argv) {
|
|
|
57
89
|
command: 'help',
|
|
58
90
|
wrapOpts: {},
|
|
59
91
|
initOpts: { clients: [], dryRun: false, unwrap: false },
|
|
92
|
+
snippetOpts: { client: 'generic', style: 'npx' },
|
|
93
|
+
httpOpts: {},
|
|
60
94
|
childArgs: [],
|
|
61
95
|
};
|
|
62
96
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
@@ -114,6 +148,105 @@ function parseArgs(argv) {
|
|
|
114
148
|
result.command = 'doctor';
|
|
115
149
|
return result;
|
|
116
150
|
}
|
|
151
|
+
if (cmd === 'wrap-http') {
|
|
152
|
+
result.command = 'wrap-http';
|
|
153
|
+
let i = 1;
|
|
154
|
+
while (i < argv.length) {
|
|
155
|
+
const a = argv[i];
|
|
156
|
+
const next = argv[i + 1];
|
|
157
|
+
switch (a) {
|
|
158
|
+
case '--url':
|
|
159
|
+
result.httpOpts.url = next;
|
|
160
|
+
i += 2;
|
|
161
|
+
break;
|
|
162
|
+
case '--key':
|
|
163
|
+
result.httpOpts.apiKey = next;
|
|
164
|
+
i += 2;
|
|
165
|
+
break;
|
|
166
|
+
case '--endpoint':
|
|
167
|
+
result.httpOpts.endpoint = next;
|
|
168
|
+
i += 2;
|
|
169
|
+
break;
|
|
170
|
+
case '--project':
|
|
171
|
+
result.httpOpts.projectId = next;
|
|
172
|
+
i += 2;
|
|
173
|
+
break;
|
|
174
|
+
case '--agent':
|
|
175
|
+
result.httpOpts.agentName = next;
|
|
176
|
+
i += 2;
|
|
177
|
+
break;
|
|
178
|
+
case '--model':
|
|
179
|
+
result.httpOpts.model = next;
|
|
180
|
+
i += 2;
|
|
181
|
+
break;
|
|
182
|
+
case '--auth':
|
|
183
|
+
result.httpOpts.auth = next;
|
|
184
|
+
i += 2;
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
process.stderr.write(`mcpspend: unknown option ${a}\n`);
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!result.httpOpts.url) {
|
|
192
|
+
process.stderr.write('mcpspend: wrap-http requires --url <https://...>\n');
|
|
193
|
+
process.exit(2);
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
if (cmd === 'snippet') {
|
|
198
|
+
result.command = 'snippet';
|
|
199
|
+
let i = 1;
|
|
200
|
+
while (i < argv.length) {
|
|
201
|
+
const a = argv[i];
|
|
202
|
+
if (a === '--') {
|
|
203
|
+
i++;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
const next = argv[i + 1];
|
|
207
|
+
switch (a) {
|
|
208
|
+
case '--client':
|
|
209
|
+
result.snippetOpts.client = next;
|
|
210
|
+
i += 2;
|
|
211
|
+
break;
|
|
212
|
+
case '--name':
|
|
213
|
+
result.snippetOpts.name = next;
|
|
214
|
+
i += 2;
|
|
215
|
+
break;
|
|
216
|
+
case '--project':
|
|
217
|
+
result.snippetOpts.projectId = next;
|
|
218
|
+
i += 2;
|
|
219
|
+
break;
|
|
220
|
+
case '--endpoint':
|
|
221
|
+
result.snippetOpts.endpoint = next;
|
|
222
|
+
i += 2;
|
|
223
|
+
break;
|
|
224
|
+
case '--agent':
|
|
225
|
+
result.snippetOpts.agentName = next;
|
|
226
|
+
i += 2;
|
|
227
|
+
break;
|
|
228
|
+
case '--style':
|
|
229
|
+
if (next === 'npx' || next === 'bin')
|
|
230
|
+
result.snippetOpts.style = next;
|
|
231
|
+
else {
|
|
232
|
+
process.stderr.write('mcpspend: --style must be npx or bin\n');
|
|
233
|
+
process.exit(2);
|
|
234
|
+
}
|
|
235
|
+
i += 2;
|
|
236
|
+
break;
|
|
237
|
+
default:
|
|
238
|
+
process.stderr.write(`mcpspend: unknown option ${a}\n`);
|
|
239
|
+
process.exit(2);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (i >= argv.length) {
|
|
243
|
+
process.stderr.write('mcpspend: missing command for snippet. Use: mcpspend snippet [options] -- <command> [args...]\n');
|
|
244
|
+
process.exit(2);
|
|
245
|
+
}
|
|
246
|
+
result.childCommand = argv[i];
|
|
247
|
+
result.childArgs = argv.slice(i + 1);
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
117
250
|
if (cmd === 'wrap') {
|
|
118
251
|
result.command = 'wrap';
|
|
119
252
|
let i = 1;
|
|
@@ -206,6 +339,10 @@ async function main() {
|
|
|
206
339
|
unwrap: parsed.initOpts.unwrap,
|
|
207
340
|
});
|
|
208
341
|
process.stdout.write((0, init_js_1.formatReport)(report, parsed.initOpts.unwrap) + '\n');
|
|
342
|
+
// Fire-and-forget anonymous compat report. Tells us when a client's schema
|
|
343
|
+
// changes so we can ship a fix BEFORE users notice. Opt out via
|
|
344
|
+
// MCPSPEND_NO_TELEMETRY=1. We swallow errors — telemetry is never blocking.
|
|
345
|
+
void (0, init_js_1.reportCompatFromInit)(VERSION, report);
|
|
209
346
|
if (report.clients.some((c) => c.status === 'error'))
|
|
210
347
|
process.exit(1);
|
|
211
348
|
return;
|
|
@@ -217,6 +354,21 @@ async function main() {
|
|
|
217
354
|
process.exit(1);
|
|
218
355
|
return;
|
|
219
356
|
}
|
|
357
|
+
if (parsed.command === 'snippet') {
|
|
358
|
+
const inferredName = guessServerName(parsed.childCommand, parsed.childArgs);
|
|
359
|
+
const out = (0, snippet_js_1.buildSnippet)({
|
|
360
|
+
client: parsed.snippetOpts.client,
|
|
361
|
+
serverName: parsed.snippetOpts.name || inferredName,
|
|
362
|
+
command: parsed.childCommand,
|
|
363
|
+
args: parsed.childArgs,
|
|
364
|
+
projectId: parsed.snippetOpts.projectId,
|
|
365
|
+
endpoint: parsed.snippetOpts.endpoint,
|
|
366
|
+
agentName: parsed.snippetOpts.agentName,
|
|
367
|
+
style: parsed.snippetOpts.style,
|
|
368
|
+
});
|
|
369
|
+
process.stdout.write((0, snippet_js_1.formatSnippet)(out) + '\n');
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
220
372
|
if (parsed.command === 'config') {
|
|
221
373
|
if (parsed.configAction === 'show') {
|
|
222
374
|
const cfg = (0, config_js_1.loadConfig)();
|
|
@@ -249,6 +401,54 @@ async function main() {
|
|
|
249
401
|
});
|
|
250
402
|
process.exit(code);
|
|
251
403
|
}
|
|
404
|
+
if (parsed.command === 'wrap-http') {
|
|
405
|
+
const cfg = (0, config_js_1.loadConfig)({
|
|
406
|
+
apiKey: parsed.httpOpts.apiKey,
|
|
407
|
+
endpoint: parsed.httpOpts.endpoint,
|
|
408
|
+
projectId: parsed.httpOpts.projectId,
|
|
409
|
+
agentName: parsed.httpOpts.agentName,
|
|
410
|
+
});
|
|
411
|
+
const code = await (0, http_bridge_js_1.runHttpBridge)({
|
|
412
|
+
url: parsed.httpOpts.url,
|
|
413
|
+
config: cfg,
|
|
414
|
+
model: parsed.httpOpts.model || process.env.MCPSPEND_MODEL || 'mcp-http',
|
|
415
|
+
remoteAuthHeader: parsed.httpOpts.auth,
|
|
416
|
+
});
|
|
417
|
+
process.exit(code);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// Very small heuristic — keeps snippet code self-contained without exporting
|
|
421
|
+
// extractServerName from proxy.ts. Same trade-off rules apply: drop versions,
|
|
422
|
+
// drop scopes that reduce to "mcp", strip mcp-server- prefixes.
|
|
423
|
+
function guessServerName(command, args) {
|
|
424
|
+
const skip = new Set(['npx', 'npx.cmd', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']);
|
|
425
|
+
const tokens = [command, ...args].filter((t) => t && !t.startsWith('-') && !skip.has(t.toLowerCase()));
|
|
426
|
+
for (const raw of tokens) {
|
|
427
|
+
let t = raw;
|
|
428
|
+
const lastAt = t.lastIndexOf('@');
|
|
429
|
+
if (lastAt > 0)
|
|
430
|
+
t = t.slice(0, lastAt);
|
|
431
|
+
let scope = null;
|
|
432
|
+
if (t.startsWith('@')) {
|
|
433
|
+
const slash = t.indexOf('/');
|
|
434
|
+
if (slash > 0) {
|
|
435
|
+
scope = t.slice(1, slash);
|
|
436
|
+
t = t.slice(slash + 1);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
t = (t.split(/[\\/]/).pop() || t).replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, '');
|
|
440
|
+
t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, '');
|
|
441
|
+
t = t.replace(/^server-/i, '').replace(/-server$/i, '');
|
|
442
|
+
t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, '');
|
|
443
|
+
t = t.toLowerCase().trim();
|
|
444
|
+
if (!t || t === 'mcp' || t === 'server') {
|
|
445
|
+
if (scope)
|
|
446
|
+
return scope.toLowerCase();
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
return t;
|
|
450
|
+
}
|
|
451
|
+
return 'mcp-server';
|
|
252
452
|
}
|
|
253
453
|
main().catch((err) => {
|
|
254
454
|
process.stderr.write(`mcpspend: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/dist/clients.js
CHANGED
|
@@ -31,7 +31,13 @@ exports.CLIENTS = [
|
|
|
31
31
|
{
|
|
32
32
|
id: 'cursor',
|
|
33
33
|
name: 'Cursor',
|
|
34
|
-
configPaths: () => [
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
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) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Stdio→HTTP bridge for remote MCP servers.
|
|
3
|
+
//
|
|
4
|
+
// `mcpspend wrap-http --url https://figma.com/mcp --key mcps_live_xxx` exposes
|
|
5
|
+
// a local stdio MCP server that proxies every JSON-RPC message to the remote
|
|
6
|
+
// HTTP endpoint and records each tools/call to MCPSpend — same metadata pipeline
|
|
7
|
+
// as the stdio wrap (server name, latency, token estimate). The MCP client (any
|
|
8
|
+
// of them) sees a normal stdio server; the user never has to know it's HTTP
|
|
9
|
+
// behind the scenes.
|
|
10
|
+
//
|
|
11
|
+
// We deliberately do not implement SSE/Streamable response streaming yet — most
|
|
12
|
+
// remote MCP servers in 2026 accept simple POST + JSON response. When we hit
|
|
13
|
+
// a server that needs streaming we'll bolt it on here without changing the
|
|
14
|
+
// stdio surface.
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.runHttpBridge = runHttpBridge;
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
18
|
+
const ingest_js_1 = require("./ingest.js");
|
|
19
|
+
function estimateTokens(payload) {
|
|
20
|
+
if (payload === undefined || payload === null)
|
|
21
|
+
return 0;
|
|
22
|
+
const s = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
23
|
+
return Math.max(1, Math.ceil(s.length / 4));
|
|
24
|
+
}
|
|
25
|
+
function inferServerNameFromUrl(url) {
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(url);
|
|
28
|
+
// figma.com → figma · api.notion.com → notion · mcp.foo.dev → foo
|
|
29
|
+
const host = u.hostname.replace(/^(api|mcp)\./, '');
|
|
30
|
+
const parts = host.split('.');
|
|
31
|
+
return parts.length >= 2 ? parts[parts.length - 2] : host;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return 'remote-mcp';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function runHttpBridge(opts) {
|
|
38
|
+
const serverName = inferServerNameFromUrl(opts.url);
|
|
39
|
+
const sessionId = (0, node_crypto_1.randomUUID)();
|
|
40
|
+
const ingest = new ingest_js_1.Ingest(opts.config);
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
if (!opts.config.apiKey) {
|
|
43
|
+
process.stderr.write('[mcpspend wrap-http] no API key configured — running in passthrough mode (no tracking)\n');
|
|
44
|
+
}
|
|
45
|
+
async function forward(msg) {
|
|
46
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
47
|
+
if (opts.remoteAuthHeader)
|
|
48
|
+
headers['Authorization'] = opts.remoteAuthHeader;
|
|
49
|
+
const r = await fetch(opts.url, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify(msg),
|
|
53
|
+
});
|
|
54
|
+
const text = await r.text();
|
|
55
|
+
if (!text.trim())
|
|
56
|
+
return null;
|
|
57
|
+
// The remote may return a single JSON-RPC envelope OR a batch (array).
|
|
58
|
+
// We pass through whatever we got — stdio readers handle batches fine.
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
process.stderr.write(`[mcpspend wrap-http] bad response from ${opts.url}: ${text.slice(0, 200)}\n`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function onCallStart(msg) {
|
|
68
|
+
if (msg.method !== 'tools/call' || msg.id == null || !msg.params)
|
|
69
|
+
return;
|
|
70
|
+
const params = msg.params;
|
|
71
|
+
if (!params.name)
|
|
72
|
+
return;
|
|
73
|
+
pending.set(msg.id, {
|
|
74
|
+
toolName: params.name,
|
|
75
|
+
serverName,
|
|
76
|
+
startedAt: Date.now(),
|
|
77
|
+
inputTokens: estimateTokens(params.arguments),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function onCallEnd(msg) {
|
|
81
|
+
if (msg.id == null)
|
|
82
|
+
return;
|
|
83
|
+
const call = pending.get(msg.id);
|
|
84
|
+
if (!call)
|
|
85
|
+
return;
|
|
86
|
+
pending.delete(msg.id);
|
|
87
|
+
const latencyMs = Date.now() - call.startedAt;
|
|
88
|
+
const success = !msg.error;
|
|
89
|
+
const outputTokens = estimateTokens(success ? msg.result : msg.error);
|
|
90
|
+
void ingest.enqueue({
|
|
91
|
+
sessionId,
|
|
92
|
+
serverName: call.serverName,
|
|
93
|
+
toolName: call.toolName,
|
|
94
|
+
model: opts.model,
|
|
95
|
+
inputTokens: call.inputTokens,
|
|
96
|
+
outputTokens,
|
|
97
|
+
latencyMs,
|
|
98
|
+
success,
|
|
99
|
+
errorCode: msg.error?.code ? String(msg.error.code) : undefined,
|
|
100
|
+
calledAt: new Date(call.startedAt).toISOString(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// Stdio loop: read line-delimited JSON from stdin, forward to HTTP, write
|
|
104
|
+
// response back to stdout.
|
|
105
|
+
let buf = '';
|
|
106
|
+
process.stdin.setEncoding('utf-8');
|
|
107
|
+
process.stdin.on('data', async (chunk) => {
|
|
108
|
+
buf += chunk;
|
|
109
|
+
let nl;
|
|
110
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
111
|
+
const line = buf.slice(0, nl).trim();
|
|
112
|
+
buf = buf.slice(nl + 1);
|
|
113
|
+
if (!line)
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
const msg = JSON.parse(line);
|
|
117
|
+
onCallStart(msg);
|
|
118
|
+
const resp = await forward(msg);
|
|
119
|
+
if (resp) {
|
|
120
|
+
onCallEnd(resp);
|
|
121
|
+
process.stdout.write(JSON.stringify(resp) + '\n');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
process.stderr.write(`[mcpspend wrap-http] error: ${err.message}\n`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
process.stdin.on('end', async () => {
|
|
131
|
+
await ingest.flush();
|
|
132
|
+
resolve(0);
|
|
133
|
+
});
|
|
134
|
+
process.on('SIGTERM', () => { void ingest.flush().then(() => resolve(0)); });
|
|
135
|
+
process.on('SIGINT', () => { void ingest.flush().then(() => resolve(0)); });
|
|
136
|
+
});
|
|
137
|
+
}
|
package/dist/init.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface InitReport {
|
|
|
26
26
|
clients: ClientReport[];
|
|
27
27
|
}
|
|
28
28
|
export declare function runInit(opts?: InitOptions): InitReport;
|
|
29
|
+
/**
|
|
30
|
+
* Fire a fire-and-forget anonymous compat report. Caller decides when to call —
|
|
31
|
+
* we don't run it inside runInit() to keep that function pure and testable.
|
|
32
|
+
* Opt out: MCPSPEND_NO_TELEMETRY=1.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reportCompatFromInit(cliVersion: string, init: InitReport): Promise<void>;
|
|
29
35
|
export declare function formatReport(report: InitReport, unwrap?: boolean): string;
|
|
30
36
|
export interface DoctorReport {
|
|
31
37
|
cliVersion: string;
|
package/dist/init.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runInit = runInit;
|
|
4
|
+
exports.reportCompatFromInit = reportCompatFromInit;
|
|
4
5
|
exports.formatReport = formatReport;
|
|
5
6
|
exports.runDoctor = runDoctor;
|
|
6
7
|
exports.formatDoctor = formatDoctor;
|
|
8
|
+
const node_os_1 = require("node:os");
|
|
7
9
|
const clients_js_1 = require("./clients.js");
|
|
8
10
|
const config_js_1 = require("./config.js");
|
|
11
|
+
const telemetry_js_1 = require("./telemetry.js");
|
|
9
12
|
function runInit(opts = {}) {
|
|
10
13
|
if (opts.apiKey) {
|
|
11
14
|
(0, config_js_1.saveConfig)({ apiKey: opts.apiKey });
|
|
@@ -108,6 +111,38 @@ function runInit(opts = {}) {
|
|
|
108
111
|
clients: clientReports,
|
|
109
112
|
};
|
|
110
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Fire a fire-and-forget anonymous compat report. Caller decides when to call —
|
|
116
|
+
* we don't run it inside runInit() to keep that function pure and testable.
|
|
117
|
+
* Opt out: MCPSPEND_NO_TELEMETRY=1.
|
|
118
|
+
*/
|
|
119
|
+
async function reportCompatFromInit(cliVersion, init) {
|
|
120
|
+
const reports = init.clients.map((r) => {
|
|
121
|
+
let fp;
|
|
122
|
+
let format;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = (0, clients_js_1.readClientConfig)(r.path);
|
|
125
|
+
fp = (0, telemetry_js_1.fingerprintConfig)(parsed);
|
|
126
|
+
format = 'json';
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
format = 'missing';
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
id: r.client,
|
|
133
|
+
status: r.bootstrapped ? 'bootstrapped' : r.status,
|
|
134
|
+
configFormat: format,
|
|
135
|
+
topLevelKeysFingerprint: fp,
|
|
136
|
+
serverCount: r.servers.length,
|
|
137
|
+
wrappedCount: r.servers.filter((s) => s.status === 'wrapped' || s.status === 'already-wrapped').length,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
await (0, telemetry_js_1.sendCompatReport)({
|
|
141
|
+
cliVersion,
|
|
142
|
+
platform: (0, node_os_1.platform)(),
|
|
143
|
+
reports,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
111
146
|
function formatReport(report, unwrap = false) {
|
|
112
147
|
const lines = [];
|
|
113
148
|
const action = unwrap ? 'Unwrap' : 'Wrap';
|
|
@@ -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;
|
package/dist/snippet.js
ADDED
|
@@ -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