@mcpspend/proxy 0.2.2 → 0.3.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/dist/cli.js +1 -1
- package/dist/clients.d.ts +2 -0
- package/dist/clients.js +33 -0
- package/dist/clients.test.d.ts +1 -0
- package/dist/clients.test.js +133 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +26 -3
- package/dist/proxy.d.ts +1 -0
- package/dist/proxy.js +72 -20
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +57 -0
- package/package.json +2 -1
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.
|
|
7
|
+
const VERSION = '0.3.1';
|
|
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;
|
package/dist/clients.js
CHANGED
|
@@ -40,6 +40,22 @@ exports.CLIENTS = [
|
|
|
40
40
|
(0, node_path_1.join)(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
41
41
|
(0, node_path_1.join)(home, '.codeium', 'windsurf-next', 'mcp_config.json'),
|
|
42
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
|
+
},
|
|
43
59
|
},
|
|
44
60
|
{
|
|
45
61
|
id: 'vscode',
|
|
@@ -68,12 +84,29 @@ exports.CLIENTS = [
|
|
|
68
84
|
function discoverClients() {
|
|
69
85
|
const found = [];
|
|
70
86
|
for (const c of exports.CLIENTS) {
|
|
87
|
+
let matched = false;
|
|
88
|
+
// First try existing config files (the common case).
|
|
71
89
|
for (const p of c.configPaths()) {
|
|
72
90
|
if ((0, node_fs_1.existsSync)(p)) {
|
|
73
91
|
found.push({ client: c, path: p });
|
|
92
|
+
matched = true;
|
|
74
93
|
break;
|
|
75
94
|
}
|
|
76
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
|
+
}
|
|
77
110
|
}
|
|
78
111
|
return found;
|
|
79
112
|
}
|
|
@@ -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/init.d.ts
CHANGED
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' ? '='
|
package/dist/proxy.d.ts
CHANGED
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcpspend/proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -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 src/proxy.test.ts",
|
|
39
40
|
"typecheck": "tsc --noEmit",
|
|
40
41
|
"prepublishOnly": "npm run build"
|
|
41
42
|
},
|