@mcpspend/proxy 0.1.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/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @mcpspend/proxy
2
+
3
+ Transparent observability proxy for MCP (Model Context Protocol) servers.
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.
6
+
7
+ Fire-and-forget: the proxy never blocks the MCP wire — if the MCPSpend API is unreachable, your agent keeps working.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install -g @mcpspend/proxy
13
+ ```
14
+
15
+ ## Quick start
16
+
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
+ ```
26
+
27
+ ## Usage with Claude Desktop / Claude Code
28
+
29
+ Wherever you have an MCP server configured, prepend `mcpspend wrap --` to the command:
30
+
31
+ **Before:**
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "filesystem": {
36
+ "command": "npx",
37
+ "args": ["@modelcontextprotocol/server-filesystem", "/Users/me"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ **After:**
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "filesystem": {
48
+ "command": "mcpspend",
49
+ "args": ["wrap", "--key", "mcps_live_xxx", "--", "npx", "@modelcontextprotocol/server-filesystem", "/Users/me"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ ## Configuration
56
+
57
+ CLI flags, environment variables, and `~/.mcpspend/config.json` are merged in that order (CLI wins).
58
+
59
+ | Setting | Flag | Env var | Config key |
60
+ |---|---|---|---|
61
+ | API key | `--key` | `MCPSPEND_API_KEY` | `apiKey` |
62
+ | API endpoint | `--endpoint` | `MCPSPEND_ENDPOINT` | `endpoint` |
63
+ | Project ID | `--project` | `MCPSPEND_PROJECT_ID` | `projectId` |
64
+ | Agent name | `--agent` | `MCPSPEND_AGENT_NAME` | `agentName` |
65
+ | Disable tracking | `--disable` | `MCPSPEND_DISABLED=1` | `disabled: true` |
66
+
67
+ ## Privacy
68
+
69
+ The proxy reports:
70
+ - Tool name (e.g. `read_file`)
71
+ - Server name (e.g. `filesystem`)
72
+ - Latency, success, error codes
73
+ - Approximate input/output sizes (tokens, derived from JSON length)
74
+
75
+ It does **not** send the actual tool arguments or response bodies to MCPSpend.
76
+
77
+ ## Support
78
+
79
+ [support@mcpspend.com](mailto:support@mcpspend.com) · [mcpspend.com](https://mcpspend.com)
80
+
81
+ © NewRzs SRL · CUI RO48756557
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const config_js_1 = require("./config.js");
5
+ const proxy_js_1 = require("./proxy.js");
6
+ const VERSION = '0.1.0';
7
+ const HELP = `mcpspend — observability proxy for MCP servers (v${VERSION})
8
+
9
+ USAGE
10
+ mcpspend wrap [options] -- <command> [args...]
11
+ mcpspend config set <key> <value>
12
+ mcpspend config show
13
+ mcpspend --version
14
+ mcpspend --help
15
+
16
+ WRAP OPTIONS
17
+ --key <value> API key (overrides config + MCPSPEND_API_KEY)
18
+ --endpoint <url> API endpoint (default: https://api.mcpspend.com)
19
+ --project <id> Attribute calls to this project
20
+ --agent <name> Agent name reported in dashboards
21
+ --model <name> Model name for cost attribution (default: mcp-stdio)
22
+ --disable Run in passthrough mode (no tracking)
23
+
24
+ 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
27
+
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
31
+
32
+ Environment variables: MCPSPEND_API_KEY, MCPSPEND_ENDPOINT, MCPSPEND_PROJECT_ID, MCPSPEND_AGENT_NAME, MCPSPEND_DISABLED=1
33
+ Config file: ~/.mcpspend/config.json
34
+ `;
35
+ function parseArgs(argv) {
36
+ const result = { command: 'help', wrapOpts: {}, childArgs: [] };
37
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
38
+ result.command = 'help';
39
+ return result;
40
+ }
41
+ if (argv[0] === '-v' || argv[0] === '--version') {
42
+ result.command = 'version';
43
+ return result;
44
+ }
45
+ const cmd = argv[0];
46
+ if (cmd === 'wrap') {
47
+ result.command = 'wrap';
48
+ let i = 1;
49
+ while (i < argv.length) {
50
+ const a = argv[i];
51
+ if (a === '--') {
52
+ i++;
53
+ break;
54
+ }
55
+ const next = argv[i + 1];
56
+ switch (a) {
57
+ case '--key':
58
+ result.wrapOpts.apiKey = next;
59
+ i += 2;
60
+ break;
61
+ case '--endpoint':
62
+ result.wrapOpts.endpoint = next;
63
+ i += 2;
64
+ break;
65
+ case '--project':
66
+ result.wrapOpts.projectId = next;
67
+ i += 2;
68
+ break;
69
+ case '--agent':
70
+ result.wrapOpts.agentName = next;
71
+ i += 2;
72
+ break;
73
+ case '--model':
74
+ result.wrapOpts.model = next;
75
+ i += 2;
76
+ break;
77
+ case '--disable':
78
+ result.wrapOpts.disabled = true;
79
+ i += 1;
80
+ break;
81
+ default:
82
+ process.stderr.write(`mcpspend: unknown option ${a}\n`);
83
+ process.exit(2);
84
+ }
85
+ }
86
+ if (i >= argv.length) {
87
+ process.stderr.write('mcpspend: missing command to wrap. Use: mcpspend wrap [options] -- <command> [args...]\n');
88
+ process.exit(2);
89
+ }
90
+ result.childCommand = argv[i];
91
+ result.childArgs = argv.slice(i + 1);
92
+ return result;
93
+ }
94
+ if (cmd === 'config') {
95
+ result.command = 'config';
96
+ const action = argv[1];
97
+ if (action === 'show') {
98
+ result.configAction = 'show';
99
+ return result;
100
+ }
101
+ if (action === 'set') {
102
+ result.configAction = 'set';
103
+ result.configKey = argv[2];
104
+ result.configValue = argv[3];
105
+ if (!result.configKey || result.configValue === undefined) {
106
+ process.stderr.write('mcpspend: usage: mcpspend config set <key> <value>\n');
107
+ process.exit(2);
108
+ }
109
+ return result;
110
+ }
111
+ process.stderr.write(`mcpspend: unknown config action ${action}\n`);
112
+ process.exit(2);
113
+ }
114
+ process.stderr.write(`mcpspend: unknown command ${cmd}\n`);
115
+ process.exit(2);
116
+ }
117
+ async function main() {
118
+ const parsed = parseArgs(process.argv.slice(2));
119
+ if (parsed.command === 'help') {
120
+ process.stdout.write(HELP);
121
+ return;
122
+ }
123
+ if (parsed.command === 'version') {
124
+ process.stdout.write(`${VERSION}\n`);
125
+ return;
126
+ }
127
+ if (parsed.command === 'config') {
128
+ if (parsed.configAction === 'show') {
129
+ const cfg = (0, config_js_1.loadConfig)();
130
+ const masked = cfg.apiKey ? cfg.apiKey.slice(0, 12) + '…' : '(not set)';
131
+ process.stdout.write(JSON.stringify({ ...cfg, apiKey: masked }, null, 2) + '\n');
132
+ return;
133
+ }
134
+ if (parsed.configAction === 'set') {
135
+ const k = parsed.configKey;
136
+ const v = parsed.configValue;
137
+ const allowed = ['apiKey', 'endpoint', 'projectId', 'agentName', 'disabled'];
138
+ if (!allowed.includes(k)) {
139
+ process.stderr.write(`mcpspend: unknown config key ${k}. Allowed: ${allowed.join(', ')}\n`);
140
+ process.exit(2);
141
+ }
142
+ const updates = {};
143
+ updates[k] = k === 'disabled' ? (v === 'true' || v === '1') : v;
144
+ const path = (0, config_js_1.saveConfig)(updates);
145
+ process.stdout.write(`Saved ${k} → ${path}\n`);
146
+ return;
147
+ }
148
+ }
149
+ if (parsed.command === 'wrap') {
150
+ const cfg = (0, config_js_1.loadConfig)(parsed.wrapOpts);
151
+ const code = await (0, proxy_js_1.runProxy)({
152
+ command: parsed.childCommand,
153
+ args: parsed.childArgs,
154
+ config: cfg,
155
+ model: parsed.wrapOpts.model || process.env.MCPSPEND_MODEL || 'mcp-stdio',
156
+ });
157
+ process.exit(code);
158
+ }
159
+ }
160
+ main().catch((err) => {
161
+ process.stderr.write(`mcpspend: ${err instanceof Error ? err.message : String(err)}\n`);
162
+ process.exit(1);
163
+ });
package/dist/config.js ADDED
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadConfig = loadConfig;
4
+ exports.saveConfig = saveConfig;
5
+ const node_fs_1 = require("node:fs");
6
+ const node_os_1 = require("node:os");
7
+ const node_path_1 = require("node:path");
8
+ const DEFAULT_ENDPOINT = 'https://api.mcpspend.com';
9
+ function configPath() {
10
+ return process.env.MCPSPEND_CONFIG || (0, node_path_1.join)((0, node_os_1.homedir)(), '.mcpspend', 'config.json');
11
+ }
12
+ function fileConfig() {
13
+ const p = configPath();
14
+ if (!(0, node_fs_1.existsSync)(p))
15
+ return {};
16
+ try {
17
+ return JSON.parse((0, node_fs_1.readFileSync)(p, 'utf-8'));
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ function loadConfig(cliOverrides = {}) {
24
+ const file = fileConfig();
25
+ const apiKey = cliOverrides.apiKey ||
26
+ process.env.MCPSPEND_API_KEY ||
27
+ file.apiKey;
28
+ return {
29
+ apiKey,
30
+ endpoint: cliOverrides.endpoint || process.env.MCPSPEND_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT,
31
+ projectId: cliOverrides.projectId || process.env.MCPSPEND_PROJECT_ID || file.projectId,
32
+ agentName: cliOverrides.agentName || process.env.MCPSPEND_AGENT_NAME || file.agentName,
33
+ disabled: cliOverrides.disabled || process.env.MCPSPEND_DISABLED === '1' || file.disabled,
34
+ };
35
+ }
36
+ function saveConfig(updates) {
37
+ const p = configPath();
38
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
39
+ const current = fileConfig();
40
+ const next = { ...current, ...updates };
41
+ (0, node_fs_1.writeFileSync)(p, JSON.stringify(next, null, 2), { mode: 0o600 });
42
+ return p;
43
+ }
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runProxy = exports.saveConfig = exports.loadConfig = void 0;
4
+ var config_js_1 = require("./config.js");
5
+ Object.defineProperty(exports, "loadConfig", { enumerable: true, get: function () { return config_js_1.loadConfig; } });
6
+ Object.defineProperty(exports, "saveConfig", { enumerable: true, get: function () { return config_js_1.saveConfig; } });
7
+ var proxy_js_1 = require("./proxy.js");
8
+ Object.defineProperty(exports, "runProxy", { enumerable: true, get: function () { return proxy_js_1.runProxy; } });
package/dist/ingest.js ADDED
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Ingest = void 0;
4
+ const MAX_BATCH = 50;
5
+ const FLUSH_INTERVAL_MS = 1000;
6
+ const MAX_QUEUE = 1000; // drop events if we exceed this (protects memory if API is down)
7
+ class Ingest {
8
+ cfg;
9
+ queue = [];
10
+ timer = null;
11
+ inflight = false;
12
+ dropped = 0;
13
+ constructor(cfg) {
14
+ this.cfg = cfg;
15
+ }
16
+ enqueue(event) {
17
+ if (this.cfg.disabled || !this.cfg.apiKey)
18
+ return;
19
+ if (this.queue.length >= MAX_QUEUE) {
20
+ this.dropped++;
21
+ return;
22
+ }
23
+ this.queue.push({
24
+ ...event,
25
+ projectId: event.projectId || this.cfg.projectId,
26
+ });
27
+ this.scheduleFlush();
28
+ }
29
+ scheduleFlush() {
30
+ if (this.timer)
31
+ return;
32
+ if (this.queue.length >= MAX_BATCH) {
33
+ void this.flush();
34
+ return;
35
+ }
36
+ this.timer = setTimeout(() => {
37
+ this.timer = null;
38
+ void this.flush();
39
+ }, FLUSH_INTERVAL_MS);
40
+ }
41
+ async flush() {
42
+ if (this.inflight || this.queue.length === 0)
43
+ return;
44
+ if (this.timer) {
45
+ clearTimeout(this.timer);
46
+ this.timer = null;
47
+ }
48
+ const batch = this.queue.splice(0, MAX_BATCH);
49
+ this.inflight = true;
50
+ try {
51
+ const res = await fetch(`${this.cfg.endpoint}/api/ingest`, {
52
+ method: 'POST',
53
+ headers: {
54
+ 'Content-Type': 'application/json',
55
+ Authorization: `Bearer ${this.cfg.apiKey}`,
56
+ },
57
+ body: JSON.stringify(batch),
58
+ signal: AbortSignal.timeout(10_000),
59
+ });
60
+ if (!res.ok) {
61
+ // 4xx: don't requeue (bad data / unauthorized) — log and drop
62
+ // 5xx: requeue once at the front for retry
63
+ if (res.status >= 500 && this.queue.length + batch.length <= MAX_QUEUE) {
64
+ this.queue.unshift(...batch);
65
+ }
66
+ // Avoid logging high-volume errors to stdout (would pollute MCP protocol stream)
67
+ // Errors go to stderr; the parent process can choose to surface them.
68
+ process.stderr.write(`[mcpspend] ingest HTTP ${res.status}\n`);
69
+ }
70
+ }
71
+ catch (err) {
72
+ // Network error — requeue if there's room
73
+ if (this.queue.length + batch.length <= MAX_QUEUE) {
74
+ this.queue.unshift(...batch);
75
+ }
76
+ process.stderr.write(`[mcpspend] ingest failed: ${err instanceof Error ? err.message : 'unknown'}\n`);
77
+ }
78
+ finally {
79
+ this.inflight = false;
80
+ if (this.queue.length > 0)
81
+ this.scheduleFlush();
82
+ }
83
+ }
84
+ async shutdown() {
85
+ if (this.timer) {
86
+ clearTimeout(this.timer);
87
+ this.timer = null;
88
+ }
89
+ while (this.queue.length > 0 && !this.inflight) {
90
+ // try one final flush
91
+ await this.flush();
92
+ }
93
+ if (this.dropped > 0) {
94
+ process.stderr.write(`[mcpspend] dropped ${this.dropped} events (queue overflow)\n`);
95
+ }
96
+ }
97
+ }
98
+ exports.Ingest = Ingest;
package/dist/proxy.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runProxy = runProxy;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const node_crypto_1 = require("node:crypto");
6
+ const ingest_js_1 = require("./ingest.js");
7
+ // Token estimation: ~4 chars per token for English/JSON content.
8
+ function estimateTokens(payload) {
9
+ if (payload === undefined || payload === null)
10
+ return 0;
11
+ const s = typeof payload === 'string' ? payload : JSON.stringify(payload);
12
+ return Math.max(1, Math.ceil(s.length / 4));
13
+ }
14
+ function extractServerName(command, args) {
15
+ // Best-effort: pick the most descriptive token from the command line.
16
+ // Examples:
17
+ // npx @modelcontextprotocol/server-filesystem /path → "filesystem"
18
+ // node ./my-mcp-server.js → "my-mcp-server"
19
+ // /usr/bin/uvx mcp-server-fetch → "mcp-server-fetch"
20
+ const tokens = [command, ...args];
21
+ for (const t of tokens) {
22
+ const m = t.match(/(?:server-|mcp-server-)([a-z0-9-]+)/i);
23
+ if (m)
24
+ return m[1];
25
+ }
26
+ for (const t of tokens) {
27
+ const m = t.match(/([a-z0-9-]+)-mcp-server/i);
28
+ if (m)
29
+ return m[1];
30
+ }
31
+ // Fallback: last non-flag arg's basename without extension
32
+ const lastArg = [...tokens].reverse().find((t) => !t.startsWith('-'));
33
+ if (lastArg) {
34
+ return lastArg.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
35
+ }
36
+ return 'mcp';
37
+ }
38
+ async function runProxy(opts) {
39
+ const { command, args, config, model } = opts;
40
+ const serverName = extractServerName(command, args);
41
+ const sessionId = (0, node_crypto_1.randomUUID)();
42
+ const ingest = new ingest_js_1.Ingest(config);
43
+ if (!config.apiKey) {
44
+ process.stderr.write('[mcpspend] no API key configured — running in passthrough mode (no tracking)\n');
45
+ }
46
+ const child = (0, node_child_process_1.spawn)(command, args, {
47
+ stdio: ['pipe', 'pipe', 'inherit'],
48
+ env: process.env,
49
+ });
50
+ const pending = new Map();
51
+ function handleClientMessage(line) {
52
+ if (!line.trim())
53
+ return;
54
+ try {
55
+ const msg = JSON.parse(line);
56
+ if (msg.method === 'tools/call' && msg.id != null && msg.params && typeof msg.params === 'object') {
57
+ const params = msg.params;
58
+ if (params.name) {
59
+ pending.set(msg.id, {
60
+ toolName: params.name,
61
+ serverName,
62
+ startedAt: Date.now(),
63
+ inputTokens: estimateTokens(params.arguments),
64
+ });
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ // not JSON or partial — ignore, just forward
70
+ }
71
+ }
72
+ function handleServerMessage(line) {
73
+ if (!line.trim())
74
+ return;
75
+ try {
76
+ const msg = JSON.parse(line);
77
+ if (msg.id != null && (msg.result !== undefined || msg.error)) {
78
+ const call = pending.get(msg.id);
79
+ if (call) {
80
+ pending.delete(msg.id);
81
+ const latencyMs = Date.now() - call.startedAt;
82
+ const success = !msg.error;
83
+ ingest.enqueue({
84
+ serverName: call.serverName,
85
+ toolName: call.toolName,
86
+ model,
87
+ inputTokens: call.inputTokens,
88
+ outputTokens: estimateTokens(msg.result),
89
+ latencyMs,
90
+ success,
91
+ errorCode: msg.error ? String(msg.error.code) : undefined,
92
+ calledAt: new Date(call.startedAt).toISOString(),
93
+ sessionId,
94
+ });
95
+ }
96
+ }
97
+ }
98
+ catch {
99
+ // not JSON or partial — ignore
100
+ }
101
+ }
102
+ function pipeWithInspect(src, dst, onLine) {
103
+ let buf = '';
104
+ src.on('data', (chunk) => {
105
+ const text = chunk.toString('utf8');
106
+ // Pass through immediately — never block the MCP wire
107
+ dst.write(chunk);
108
+ buf += text;
109
+ let idx;
110
+ while ((idx = buf.indexOf('\n')) !== -1) {
111
+ const line = buf.slice(0, idx);
112
+ buf = buf.slice(idx + 1);
113
+ onLine(line);
114
+ }
115
+ });
116
+ src.on('end', () => {
117
+ if (buf)
118
+ onLine(buf);
119
+ });
120
+ }
121
+ pipeWithInspect(process.stdin, child.stdin, handleClientMessage);
122
+ pipeWithInspect(child.stdout, process.stdout, handleServerMessage);
123
+ return new Promise((resolve) => {
124
+ child.on('exit', async (code, signal) => {
125
+ // Final flush before exit
126
+ await ingest.shutdown();
127
+ resolve(code ?? (signal ? 128 : 0));
128
+ });
129
+ child.on('error', (err) => {
130
+ process.stderr.write(`[mcpspend] failed to spawn ${command}: ${err.message}\n`);
131
+ resolve(127);
132
+ });
133
+ // Propagate termination signals to the child so MCP servers shut down cleanly
134
+ const propagate = (sig) => {
135
+ if (!child.killed)
136
+ child.kill(sig);
137
+ };
138
+ process.on('SIGINT', () => propagate('SIGINT'));
139
+ process.on('SIGTERM', () => propagate('SIGTERM'));
140
+ });
141
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@mcpspend/proxy",
3
+ "version": "0.1.0",
4
+ "description": "Transparent proxy CLI for MCP servers — tracks tool calls, latency, and cost via MCPSpend.",
5
+ "license": "MIT",
6
+ "homepage": "https://mcpspend.com",
7
+ "bugs": "mailto:support@mcpspend.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/andreisirbu91-lab/MCPSpend.git",
11
+ "directory": "packages/proxy"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "observability",
17
+ "cost-tracking",
18
+ "ai",
19
+ "proxy"
20
+ ],
21
+ "bin": {
22
+ "mcpspend": "dist/cli.js"
23
+ },
24
+ "main": "dist/index.js",
25
+ "files": [
26
+ "dist",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc",
34
+ "dev": "tsx src/cli.ts",
35
+ "typecheck": "tsc --noEmit",
36
+ "prepublishOnly": "npm run build"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.10.10",
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.7.3"
42
+ }
43
+ }