@mcpspend/proxy 0.3.0 → 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 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.3.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/proxy.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Config } from './config.js';
2
+ export declare const __testExtractServerName: (command: string, args: string[]) => string;
2
3
  export declare function runProxy(opts: {
3
4
  command: string;
4
5
  args: string[];
package/dist/proxy.js CHANGED
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.__testExtractServerName = void 0;
6
7
  exports.runProxy = runProxy;
7
8
  const cross_spawn_1 = __importDefault(require("cross-spawn"));
8
9
  const node_crypto_1 = require("node:crypto");
@@ -14,29 +15,80 @@ function estimateTokens(payload) {
14
15
  const s = typeof payload === 'string' ? payload : JSON.stringify(payload);
15
16
  return Math.max(1, Math.ceil(s.length / 4));
16
17
  }
17
- function extractServerName(command, args) {
18
- // Best-effort: pick the most descriptive token from the command line.
19
- // Examples:
20
- // npx @modelcontextprotocol/server-filesystem /path → "filesystem"
21
- // node ./my-mcp-server.js "my-mcp-server"
22
- // /usr/bin/uvx mcp-server-fetch "mcp-server-fetch"
23
- const tokens = [command, ...args];
24
- for (const t of tokens) {
25
- const m = t.match(/(?:server-|mcp-server-)([a-z0-9-]+)/i);
26
- if (m)
27
- return m[1];
18
+ // Strip an npm package spec down to its identifying core. We do this in stages
19
+ // so each rule is auditable and reviewers can extend it without unwinding a
20
+ // monster regex.
21
+ //
22
+ // @playwright/mcp@latest playwright
23
+ // @modelcontextprotocol/server-fs fs
24
+ // @owner/foo-mcp → foo
25
+ // github-mcp-server → github
26
+ // mcp-server-fetch → fetch
27
+ // firecrawl-mcp → firecrawl
28
+ function stripMcpAffixes(s) {
29
+ let t = s;
30
+ t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, '');
31
+ t = t.replace(/^server-/i, '').replace(/-server$/i, '');
32
+ t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, '');
33
+ return t.toLowerCase().trim();
34
+ }
35
+ function isDegenerate(t) {
36
+ return !t || t === 'mcp' || t === 'server' || t === 'latest';
37
+ }
38
+ function normaliseServerToken(raw) {
39
+ let t = raw;
40
+ // Drop everything after the version separator, but only when it's a version,
41
+ // not a scope marker. `@playwright/mcp@latest` → `@playwright/mcp`.
42
+ const lastAt = t.lastIndexOf('@');
43
+ if (lastAt > 0)
44
+ t = t.slice(0, lastAt);
45
+ // If this is a scoped npm spec like `@playwright/mcp`, try the unscoped
46
+ // name first; if that strips down to something generic ("mcp", "server"),
47
+ // fall back to the scope itself. That gives "playwright" instead of "mcp"
48
+ // for `@playwright/mcp@latest`, while still preferring the specific name
49
+ // for `@modelcontextprotocol/server-filesystem` → "filesystem".
50
+ let scope = null;
51
+ if (t.startsWith('@')) {
52
+ const slash = t.indexOf('/');
53
+ if (slash > 0) {
54
+ scope = t.slice(1, slash).toLowerCase();
55
+ t = t.slice(slash + 1);
56
+ }
28
57
  }
29
- for (const t of tokens) {
30
- const m = t.match(/([a-z0-9-]+)-mcp-server/i);
31
- if (m)
32
- return m[1];
58
+ // Path basename when this is a script path.
59
+ t = t.split(/[\\/]/).pop() || t;
60
+ t = t.replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, '');
61
+ let name = stripMcpAffixes(t);
62
+ if (isDegenerate(name) && scope) {
63
+ name = stripMcpAffixes(scope);
33
64
  }
34
- // Fallback: last non-flag arg's basename without extension
35
- const lastArg = [...tokens].reverse().find((t) => !t.startsWith('-'));
36
- if (lastArg) {
37
- return lastArg.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
65
+ if (isDegenerate(name))
66
+ return null;
67
+ return name;
68
+ }
69
+ // Exported only for tests — see proxy.test.ts. Kept off the public surface to
70
+ // avoid implying it's a stable API.
71
+ const __testExtractServerName = (command, args) => extractServerName(command, args);
72
+ exports.__testExtractServerName = __testExtractServerName;
73
+ function extractServerName(command, args) {
74
+ // Skip well-known shims that never carry the server identity themselves.
75
+ const skipPrefix = new Set(['npx', 'npx.cmd', 'npx.exe', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']);
76
+ const tokens = [command, ...args].filter((t) => {
77
+ if (!t)
78
+ return false;
79
+ if (t.startsWith('-'))
80
+ return false; // flags
81
+ const base = t.split(/[\\/]/).pop()?.toLowerCase() || '';
82
+ return !skipPrefix.has(base) && !skipPrefix.has(t.toLowerCase());
83
+ });
84
+ for (const t of tokens) {
85
+ const name = normaliseServerToken(t);
86
+ if (name)
87
+ return name;
38
88
  }
39
- return 'mcp';
89
+ // Last resort — last raw token basename.
90
+ const lastArg = [...args].reverse().find((t) => !t.startsWith('-')) || command;
91
+ return (lastArg.split(/[\\/]/).pop() || lastArg).replace(/\.[^.]+$/, '') || 'mcp';
40
92
  }
41
93
  async function runProxy(opts) {
42
94
  const { command, args, config, model } = opts;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ // Tests for extractServerName. The function is not exported from proxy.ts so
3
+ // we re-import via require and access the internal binding. If the API ever
4
+ // grows we'll bring it out into its own module.
5
+ var __importDefault = (this && this.__importDefault) || function (mod) {
6
+ return (mod && mod.__esModule) ? mod : { "default": mod };
7
+ };
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const node_test_1 = require("node:test");
10
+ const strict_1 = __importDefault(require("node:assert/strict"));
11
+ // Internal binding access — proxy.ts doesn't export extractServerName because
12
+ // it's an implementation detail. We dynamic-require to expose it for tests
13
+ // without changing the public surface.
14
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
15
+ const proxy = require('./proxy.js');
16
+ // If the module doesn't expose a test hook, re-implement the same dispatcher
17
+ // inline. We'd rather duplicate three lines than couple production code to
18
+ // the test layout.
19
+ function extractName(command, args) {
20
+ if (proxy.__testExtractServerName)
21
+ return proxy.__testExtractServerName(command, args);
22
+ throw new Error('proxy.ts must expose __testExtractServerName for tests — see proxy.ts diff in this PR');
23
+ }
24
+ (0, node_test_1.describe)('extractServerName', () => {
25
+ (0, node_test_1.test)('playwright via @playwright/mcp@latest', () => {
26
+ strict_1.default.equal(extractName('npx', ['-y', '@playwright/mcp@latest']), 'playwright');
27
+ });
28
+ (0, node_test_1.test)('filesystem via @modelcontextprotocol/server-filesystem', () => {
29
+ strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-filesystem', '/data']), 'filesystem');
30
+ });
31
+ (0, node_test_1.test)('github via @modelcontextprotocol/server-github', () => {
32
+ strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-github']), 'github');
33
+ });
34
+ (0, node_test_1.test)('fetch via mcp-server-fetch (uvx)', () => {
35
+ strict_1.default.equal(extractName('uvx', ['mcp-server-fetch']), 'fetch');
36
+ });
37
+ (0, node_test_1.test)('firecrawl via firecrawl-mcp', () => {
38
+ strict_1.default.equal(extractName('npx', ['-y', 'firecrawl-mcp']), 'firecrawl');
39
+ });
40
+ (0, node_test_1.test)('github via github-mcp-server', () => {
41
+ strict_1.default.equal(extractName('npx', ['-y', 'github-mcp-server']), 'github');
42
+ });
43
+ (0, node_test_1.test)('node script with mcp-server suffix', () => {
44
+ strict_1.default.equal(extractName('node', ['./my-mcp-server.js']), 'my');
45
+ });
46
+ (0, node_test_1.test)('Windows absolute path node script', () => {
47
+ strict_1.default.equal(extractName('node', ['C:\\Users\\me\\servers\\notion-mcp.js']), 'notion');
48
+ });
49
+ (0, node_test_1.test)('skips npm shims, finds package after', () => {
50
+ strict_1.default.equal(extractName('npx', ['--yes', '@some-org/brave-search']), 'brave-search');
51
+ });
52
+ (0, node_test_1.test)('falls back to last token when nothing matches conventions', () => {
53
+ const out = extractName('python', ['./custom_runner.py']);
54
+ strict_1.default.ok(out.length > 0, 'should produce something');
55
+ strict_1.default.notEqual(out, 'python');
56
+ });
57
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcpspend/proxy",
3
- "version": "0.3.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,7 +36,7 @@
36
36
  "scripts": {
37
37
  "build": "tsc",
38
38
  "dev": "tsx src/cli.ts",
39
- "test": "node --test --import tsx src/clients.test.ts",
39
+ "test": "node --test --import tsx src/clients.test.ts src/proxy.test.ts",
40
40
  "typecheck": "tsc --noEmit",
41
41
  "prepublishOnly": "npm run build"
42
42
  },