@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 +81 -0
- package/dist/cli.js +163 -0
- package/dist/config.js +43 -0
- package/dist/index.js +8 -0
- package/dist/ingest.js +98 -0
- package/dist/proxy.js +141 -0
- package/package.json +43 -0
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
|
+
}
|