@mcpspend/proxy
Advanced tools
| export {}; |
| "use strict"; | ||
| // Tests for extractServerName. The function is not exported from proxy.ts so | ||
| // we re-import via require and access the internal binding. If the API ever | ||
| // grows we'll bring it out into its own module. | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| const node_test_1 = require("node:test"); | ||
| const strict_1 = __importDefault(require("node:assert/strict")); | ||
| // Internal binding access — proxy.ts doesn't export extractServerName because | ||
| // it's an implementation detail. We dynamic-require to expose it for tests | ||
| // without changing the public surface. | ||
| // eslint-disable-next-line @typescript-eslint/no-require-imports | ||
| const proxy = require('./proxy.js'); | ||
| // If the module doesn't expose a test hook, re-implement the same dispatcher | ||
| // inline. We'd rather duplicate three lines than couple production code to | ||
| // the test layout. | ||
| function extractName(command, args) { | ||
| if (proxy.__testExtractServerName) | ||
| return proxy.__testExtractServerName(command, args); | ||
| throw new Error('proxy.ts must expose __testExtractServerName for tests — see proxy.ts diff in this PR'); | ||
| } | ||
| (0, node_test_1.describe)('extractServerName', () => { | ||
| (0, node_test_1.test)('playwright via @playwright/mcp@latest', () => { | ||
| strict_1.default.equal(extractName('npx', ['-y', '@playwright/mcp@latest']), 'playwright'); | ||
| }); | ||
| (0, node_test_1.test)('filesystem via @modelcontextprotocol/server-filesystem', () => { | ||
| strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-filesystem', '/data']), 'filesystem'); | ||
| }); | ||
| (0, node_test_1.test)('github via @modelcontextprotocol/server-github', () => { | ||
| strict_1.default.equal(extractName('npx', ['-y', '@modelcontextprotocol/server-github']), 'github'); | ||
| }); | ||
| (0, node_test_1.test)('fetch via mcp-server-fetch (uvx)', () => { | ||
| strict_1.default.equal(extractName('uvx', ['mcp-server-fetch']), 'fetch'); | ||
| }); | ||
| (0, node_test_1.test)('firecrawl via firecrawl-mcp', () => { | ||
| strict_1.default.equal(extractName('npx', ['-y', 'firecrawl-mcp']), 'firecrawl'); | ||
| }); | ||
| (0, node_test_1.test)('github via github-mcp-server', () => { | ||
| strict_1.default.equal(extractName('npx', ['-y', 'github-mcp-server']), 'github'); | ||
| }); | ||
| (0, node_test_1.test)('node script with mcp-server suffix', () => { | ||
| strict_1.default.equal(extractName('node', ['./my-mcp-server.js']), 'my'); | ||
| }); | ||
| (0, node_test_1.test)('Windows absolute path node script', () => { | ||
| strict_1.default.equal(extractName('node', ['C:\\Users\\me\\servers\\notion-mcp.js']), 'notion'); | ||
| }); | ||
| (0, node_test_1.test)('skips npm shims, finds package after', () => { | ||
| strict_1.default.equal(extractName('npx', ['--yes', '@some-org/brave-search']), 'brave-search'); | ||
| }); | ||
| (0, node_test_1.test)('falls back to last token when nothing matches conventions', () => { | ||
| const out = extractName('python', ['./custom_runner.py']); | ||
| strict_1.default.ok(out.length > 0, 'should produce something'); | ||
| strict_1.default.notEqual(out, 'python'); | ||
| }); | ||
| }); |
+1
-1
@@ -7,3 +7,3 @@ #!/usr/bin/env node | ||
| const init_js_1 = require("./init.js"); | ||
| const VERSION = '0.3.0'; | ||
| const VERSION = '0.3.1'; | ||
| const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION}) | ||
@@ -10,0 +10,0 @@ |
+1
-0
| import type { Config } from './config.js'; | ||
| export declare const __testExtractServerName: (command: string, args: string[]) => string; | ||
| export declare function runProxy(opts: { | ||
@@ -3,0 +4,0 @@ command: string; |
+72
-20
@@ -6,2 +6,3 @@ "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.__testExtractServerName = void 0; | ||
| exports.runProxy = runProxy; | ||
@@ -18,25 +19,76 @@ const cross_spawn_1 = __importDefault(require("cross-spawn")); | ||
| } | ||
| // Strip an npm package spec down to its identifying core. We do this in stages | ||
| // so each rule is auditable and reviewers can extend it without unwinding a | ||
| // monster regex. | ||
| // | ||
| // @playwright/mcp@latest → playwright | ||
| // @modelcontextprotocol/server-fs → fs | ||
| // @owner/foo-mcp → foo | ||
| // github-mcp-server → github | ||
| // mcp-server-fetch → fetch | ||
| // firecrawl-mcp → firecrawl | ||
| function stripMcpAffixes(s) { | ||
| let t = s; | ||
| t = t.replace(/^mcp-server-/i, '').replace(/-mcp-server$/i, ''); | ||
| t = t.replace(/^server-/i, '').replace(/-server$/i, ''); | ||
| t = t.replace(/^mcp-/i, '').replace(/-mcp$/i, ''); | ||
| return t.toLowerCase().trim(); | ||
| } | ||
| function isDegenerate(t) { | ||
| return !t || t === 'mcp' || t === 'server' || t === 'latest'; | ||
| } | ||
| function normaliseServerToken(raw) { | ||
| let t = raw; | ||
| // Drop everything after the version separator, but only when it's a version, | ||
| // not a scope marker. `@playwright/mcp@latest` → `@playwright/mcp`. | ||
| const lastAt = t.lastIndexOf('@'); | ||
| if (lastAt > 0) | ||
| t = t.slice(0, lastAt); | ||
| // If this is a scoped npm spec like `@playwright/mcp`, try the unscoped | ||
| // name first; if that strips down to something generic ("mcp", "server"), | ||
| // fall back to the scope itself. That gives "playwright" instead of "mcp" | ||
| // for `@playwright/mcp@latest`, while still preferring the specific name | ||
| // for `@modelcontextprotocol/server-filesystem` → "filesystem". | ||
| let scope = null; | ||
| if (t.startsWith('@')) { | ||
| const slash = t.indexOf('/'); | ||
| if (slash > 0) { | ||
| scope = t.slice(1, slash).toLowerCase(); | ||
| t = t.slice(slash + 1); | ||
| } | ||
| } | ||
| // Path basename when this is a script path. | ||
| t = t.split(/[\\/]/).pop() || t; | ||
| t = t.replace(/\.(js|cjs|mjs|ts|tsx|py)$/i, ''); | ||
| let name = stripMcpAffixes(t); | ||
| if (isDegenerate(name) && scope) { | ||
| name = stripMcpAffixes(scope); | ||
| } | ||
| if (isDegenerate(name)) | ||
| return null; | ||
| return name; | ||
| } | ||
| // Exported only for tests — see proxy.test.ts. Kept off the public surface to | ||
| // avoid implying it's a stable API. | ||
| const __testExtractServerName = (command, args) => extractServerName(command, args); | ||
| exports.__testExtractServerName = __testExtractServerName; | ||
| function extractServerName(command, args) { | ||
| // Best-effort: pick the most descriptive token from the command line. | ||
| // Examples: | ||
| // npx @modelcontextprotocol/server-filesystem /path → "filesystem" | ||
| // node ./my-mcp-server.js → "my-mcp-server" | ||
| // /usr/bin/uvx mcp-server-fetch → "mcp-server-fetch" | ||
| const tokens = [command, ...args]; | ||
| // Skip well-known shims that never carry the server identity themselves. | ||
| const skipPrefix = new Set(['npx', 'npx.cmd', 'npx.exe', 'uvx', 'pnpx', 'bunx', 'pipx', 'node', 'bun', 'deno', 'python', 'python3']); | ||
| const tokens = [command, ...args].filter((t) => { | ||
| if (!t) | ||
| return false; | ||
| if (t.startsWith('-')) | ||
| return false; // flags | ||
| const base = t.split(/[\\/]/).pop()?.toLowerCase() || ''; | ||
| return !skipPrefix.has(base) && !skipPrefix.has(t.toLowerCase()); | ||
| }); | ||
| for (const t of tokens) { | ||
| const m = t.match(/(?:server-|mcp-server-)([a-z0-9-]+)/i); | ||
| if (m) | ||
| return m[1]; | ||
| const name = normaliseServerToken(t); | ||
| if (name) | ||
| return name; | ||
| } | ||
| for (const t of tokens) { | ||
| const m = t.match(/([a-z0-9-]+)-mcp-server/i); | ||
| if (m) | ||
| return m[1]; | ||
| } | ||
| // Fallback: last non-flag arg's basename without extension | ||
| const lastArg = [...tokens].reverse().find((t) => !t.startsWith('-')); | ||
| if (lastArg) { | ||
| return lastArg.split(/[\\/]/).pop().replace(/\.[^.]+$/, ''); | ||
| } | ||
| return 'mcp'; | ||
| // Last resort — last raw token basename. | ||
| const lastArg = [...args].reverse().find((t) => !t.startsWith('-')) || command; | ||
| return (lastArg.split(/[\\/]/).pop() || lastArg).replace(/\.[^.]+$/, '') || 'mcp'; | ||
| } | ||
@@ -43,0 +95,0 @@ async function runProxy(opts) { |
+2
-2
| { | ||
| "name": "@mcpspend/proxy", | ||
| "version": "0.3.0", | ||
| "version": "0.3.1", | ||
| "description": "Transparent proxy CLI for MCP servers — tracks tool calls, latency, and cost via MCPSpend.", | ||
@@ -39,3 +39,3 @@ "license": "MIT", | ||
| "dev": "tsx src/cli.ts", | ||
| "test": "node --test --import tsx src/clients.test.ts", | ||
| "test": "node --test --import tsx src/clients.test.ts src/proxy.test.ts", | ||
| "typecheck": "tsc --noEmit", | ||
@@ -42,0 +42,0 @@ "prepublishOnly": "npm run build" |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
65155
9.6%20
11.11%1464
8.2%18
-5.26%