satgate-proxy 0.1.0 → 0.3.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 +88 -15
- package/bin/satgate-proxy.js +52 -17
- package/package.json +4 -1
- package/src/bridge.js +39 -6
- package/src/config.js +56 -0
- package/src/index.js +34 -4
- package/src/local-bridge.js +147 -0
package/README.md
CHANGED
|
@@ -2,11 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
**Budget-enforced MCP proxy — hard-cap your AI agent tool spend.**
|
|
4
4
|
|
|
5
|
-
MCP servers are an open tap. Every `tools/call` costs money and there's no built-in spending limit. `satgate-proxy` sits between your MCP client (Claude Desktop, Cursor) and the server, enforcing a hard budget cap
|
|
5
|
+
MCP servers are an open tap. Every `tools/call` costs money and there's no built-in spending limit. `satgate-proxy` sits between your MCP client (Claude Desktop, Cursor) and the server, enforcing a hard budget cap.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Zero dependencies. Node.js built-ins only. `npx` and go.**
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Quick Start — Local Mode (New in v0.3.0)
|
|
10
|
+
|
|
11
|
+
No server, no API key, no account. Budget enforced in-process:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx satgate-proxy --local --budget 5.00 \
|
|
15
|
+
--server @modelcontextprotocol/server-google-search
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or with a config file:
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
# satgate.yaml
|
|
22
|
+
server: @modelcontextprotocol/server-google-search
|
|
23
|
+
budget: 5.00
|
|
24
|
+
|
|
25
|
+
mcp_pricing:
|
|
26
|
+
web_search: 5
|
|
27
|
+
dalle_generate: 50
|
|
28
|
+
'*': 1
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx satgate-proxy --local --config satgate.yaml
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Claude Desktop config (local mode):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"google-search": {
|
|
41
|
+
"command": "npx",
|
|
42
|
+
"args": [
|
|
43
|
+
"satgate-proxy",
|
|
44
|
+
"--local", "--budget", "5.00",
|
|
45
|
+
"--server", "@modelcontextprotocol/server-google-search"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
No env vars needed. No accounts. Just a budget cap.
|
|
53
|
+
|
|
54
|
+
## SaaS Mode (Teams & Enterprise)
|
|
55
|
+
|
|
56
|
+
For server-side enforcement with L402 macaroons:
|
|
10
57
|
|
|
11
58
|
```json
|
|
12
59
|
{
|
|
@@ -30,6 +77,20 @@ Get your API key at [cloud.satgate.io](https://cloud.satgate.io).
|
|
|
30
77
|
|
|
31
78
|
## How It Works
|
|
32
79
|
|
|
80
|
+
### Local Mode
|
|
81
|
+
```
|
|
82
|
+
┌──────────────┐ stdio ┌────────────────┐ stdio ┌──────────────┐
|
|
83
|
+
│ Claude │ ──── JSON-RPC ──▶ satgate-proxy │ ──── JSON-RPC ──▶ MCP Server │
|
|
84
|
+
│ Desktop │ ◀── JSON-RPC ── │ (budget gate) │ ◀── JSON-RPC ── │ (child proc) │
|
|
85
|
+
│ / Cursor │ └────────────────┘ └──────────────┘
|
|
86
|
+
└──────────────┘ ↑ intercepts tools/call
|
|
87
|
+
↑ deducts from budget
|
|
88
|
+
↑ blocks when exhausted
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The proxy spawns your MCP server as a child process, intercepts every `tools/call`, deducts from your budget based on per-tool pricing, and blocks calls when the budget is exhausted.
|
|
92
|
+
|
|
93
|
+
### SaaS Mode
|
|
33
94
|
```
|
|
34
95
|
┌──────────────┐ stdio ┌────────────────┐ SSE/HTTP ┌──────────────┐
|
|
35
96
|
│ Claude │ ──── JSON-RPC ──▶ satgate-proxy │ ──── JSON-RPC ──▶ SatGate │
|
|
@@ -44,21 +105,34 @@ Get your API key at [cloud.satgate.io](https://cloud.satgate.io).
|
|
|
44
105
|
└──────────────┘
|
|
45
106
|
```
|
|
46
107
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
108
|
+
## Config File (`satgate.yaml`)
|
|
109
|
+
|
|
110
|
+
Instead of CLI flags, you can use a config file:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
# satgate.yaml
|
|
114
|
+
server: @modelcontextprotocol/server-google-search
|
|
115
|
+
budget: 5.00
|
|
116
|
+
|
|
117
|
+
mcp_pricing:
|
|
118
|
+
web_search: 5 # 5 cents per search
|
|
119
|
+
dalle_generate: 50 # 50 cents per image
|
|
120
|
+
'*': 1 # 1 cent default for unlisted tools
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Pricing is in **cents**. The `'*'` wildcard sets the default cost for any tool not explicitly listed.
|
|
53
124
|
|
|
54
125
|
## CLI Flags
|
|
55
126
|
|
|
56
127
|
| Flag | Description | Default |
|
|
57
128
|
|------|-------------|---------|
|
|
58
129
|
| `--server <package>` | MCP server package to proxy (required) | — |
|
|
59
|
-
| `--
|
|
130
|
+
| `--local` | Run in local mode (no SatGate server needed) | off |
|
|
131
|
+
| `--budget <amount>` | Budget cap in USD (local mode) | unlimited |
|
|
132
|
+
| `--config <path>` | Path to `satgate.yaml` config file | — |
|
|
133
|
+
| `--cap <amount>` | Budget cap in USD (SaaS mode) | — |
|
|
60
134
|
| `--endpoint <url>` | SatGate proxy endpoint | `https://satgate-mcp-saas.fly.dev` |
|
|
61
|
-
| `--key <macaroon>` | API key (or
|
|
135
|
+
| `--key <macaroon>` | API key (or `SATGATE_API_KEY` env var) | — |
|
|
62
136
|
| `--verbose` | Debug logging to stderr | off |
|
|
63
137
|
| `-h, --help` | Show help | — |
|
|
64
138
|
|
|
@@ -67,14 +141,13 @@ Get your API key at [cloud.satgate.io](https://cloud.satgate.io).
|
|
|
67
141
|
MCP gives AI agents direct access to paid APIs — search, code execution, databases, you name it. There's no built-in spending limit. A runaway agent can burn through hundreds of dollars in minutes.
|
|
68
142
|
|
|
69
143
|
**satgate-proxy** adds a hard cap:
|
|
70
|
-
- Set `--
|
|
71
|
-
-
|
|
72
|
-
- Uses L402 macaroons for cryptographic budget enforcement
|
|
144
|
+
- **Local mode**: Set `--budget 5.00` → agent can spend at most $5, enforced in-process
|
|
145
|
+
- **SaaS mode**: Set `--cap 5.00` → enforced server-side with L402 macaroons
|
|
73
146
|
- Zero dependencies — `npx` runs it instantly
|
|
74
147
|
|
|
75
148
|
## Zero Dependencies
|
|
76
149
|
|
|
77
|
-
This package uses only Node.js built-ins. No `node_modules`, no install step. `npx satgate-proxy` just works.
|
|
150
|
+
This package uses only Node.js built-ins (`child_process`, `http`, `https`, `fs`). No `node_modules`, no install step. `npx satgate-proxy` just works.
|
|
78
151
|
|
|
79
152
|
## Links
|
|
80
153
|
|
package/bin/satgate-proxy.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const { parseArgs, printUsage } = require('../src/index.js');
|
|
5
5
|
const { Bridge } = require('../src/bridge.js');
|
|
6
|
+
const { LocalBridge } = require('../src/local-bridge.js');
|
|
6
7
|
|
|
7
8
|
const args = parseArgs(process.argv.slice(2));
|
|
8
9
|
|
|
@@ -11,27 +12,61 @@ if (args.help) {
|
|
|
11
12
|
process.exit(0);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
if
|
|
15
|
+
// Load config file if specified
|
|
16
|
+
let config = {};
|
|
17
|
+
if (args.config) {
|
|
18
|
+
const { parseConfig } = require('../src/config.js');
|
|
19
|
+
try {
|
|
20
|
+
config = parseConfig(args.config);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
process.stderr.write(`Error reading config file: ${err.message}\n`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Merge config values (CLI flags take precedence)
|
|
28
|
+
const server = args.server || config.server;
|
|
29
|
+
if (!server) {
|
|
15
30
|
process.stderr.write('Error: --server <package> is required\n\n');
|
|
16
31
|
printUsage();
|
|
17
32
|
process.exit(1);
|
|
18
33
|
}
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
35
|
+
if (args.local) {
|
|
36
|
+
// Local mode — budget enforced in-process
|
|
37
|
+
const budgetDollars = args.budget || config.budget;
|
|
38
|
+
const budgetCents = budgetDollars ? Number(budgetDollars) * 100 : Infinity;
|
|
39
|
+
const pricing = config.mcp_pricing || {};
|
|
25
40
|
|
|
26
|
-
const bridge = new
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
41
|
+
const bridge = new LocalBridge({
|
|
42
|
+
server,
|
|
43
|
+
budget: budgetCents,
|
|
44
|
+
pricing,
|
|
45
|
+
verbose: args.verbose,
|
|
46
|
+
});
|
|
33
47
|
|
|
34
|
-
bridge.start().catch((err) => {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
48
|
+
bridge.start().catch((err) => {
|
|
49
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
// SaaS mode
|
|
54
|
+
const apiKey = args.key || process.env.SATGATE_API_KEY;
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
process.stderr.write('Error: API key required via --key or SATGATE_API_KEY env var (use --local for local mode)\n');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const bridge = new Bridge({
|
|
61
|
+
server,
|
|
62
|
+
endpoint: args.endpoint,
|
|
63
|
+
apiKey,
|
|
64
|
+
cap: args.cap,
|
|
65
|
+
verbose: args.verbose,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
bridge.start().catch((err) => {
|
|
69
|
+
process.stderr.write(`Fatal: ${err.message}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "satgate-proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Budget-enforced MCP proxy — hard-cap your AI agent tool spend",
|
|
5
5
|
"bin": {
|
|
6
6
|
"satgate-proxy": "./bin/satgate-proxy.js"
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://satgate.io",
|
|
15
15
|
"author": "SatGate Inc.",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/"
|
|
18
|
+
},
|
|
16
19
|
"engines": {
|
|
17
20
|
"node": ">=18"
|
|
18
21
|
},
|
package/src/bridge.js
CHANGED
|
@@ -14,6 +14,12 @@ class Bridge {
|
|
|
14
14
|
this._buffer = '';
|
|
15
15
|
this._pendingRequests = new Map();
|
|
16
16
|
this._sseBuffer = '';
|
|
17
|
+
this._reconnectAttempts = 0;
|
|
18
|
+
this._maxReconnectAttempts = 10;
|
|
19
|
+
this._baseBackoff = 1000;
|
|
20
|
+
this._maxBackoff = 30000;
|
|
21
|
+
this._queue = [];
|
|
22
|
+
this._destroyed = false;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
log(msg) {
|
|
@@ -69,6 +75,25 @@ class Bridge {
|
|
|
69
75
|
return headers;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
_scheduleReconnect() {
|
|
79
|
+
if (this._destroyed) return;
|
|
80
|
+
this._reconnectAttempts++;
|
|
81
|
+
if (this._reconnectAttempts > this._maxReconnectAttempts) {
|
|
82
|
+
process.stderr.write(`[satgate-proxy] Max reconnection attempts (${this._maxReconnectAttempts}) exceeded, exiting\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const delay = Math.min(this._baseBackoff * Math.pow(2, this._reconnectAttempts - 1), this._maxBackoff);
|
|
88
|
+
process.stderr.write(`[satgate-proxy] Reconnecting (attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}) in ${delay}ms\n`);
|
|
89
|
+
|
|
90
|
+
// Re-queue in-flight: clear endpoint so new messages queue
|
|
91
|
+
this._postEndpoint = null;
|
|
92
|
+
this._sseBuffer = '';
|
|
93
|
+
|
|
94
|
+
this._reconnectTimer = setTimeout(() => this._connectSSE(), delay);
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
_connectSSE() {
|
|
73
98
|
const url = this._buildUrl('/sse');
|
|
74
99
|
this.log(`Connecting SSE: ${url}`);
|
|
@@ -85,29 +110,30 @@ class Bridge {
|
|
|
85
110
|
res.on('data', (d) => body += d);
|
|
86
111
|
res.on('end', () => {
|
|
87
112
|
if (body) process.stderr.write(`Response: ${body}\n`);
|
|
88
|
-
|
|
113
|
+
this._scheduleReconnect();
|
|
89
114
|
});
|
|
90
115
|
return;
|
|
91
116
|
}
|
|
92
117
|
|
|
93
118
|
this.log('SSE connected');
|
|
119
|
+
this._reconnectAttempts = 0; // Reset backoff on success
|
|
94
120
|
this._sseRes = res;
|
|
95
121
|
res.setEncoding('utf8');
|
|
96
122
|
|
|
97
123
|
res.on('data', (chunk) => this._onSSEData(chunk));
|
|
98
124
|
res.on('end', () => {
|
|
99
|
-
this.log('SSE connection closed');
|
|
100
|
-
|
|
125
|
+
this.log('SSE connection closed by server');
|
|
126
|
+
this._scheduleReconnect();
|
|
101
127
|
});
|
|
102
128
|
res.on('error', (err) => {
|
|
103
129
|
process.stderr.write(`SSE error: ${err.message}\n`);
|
|
104
|
-
|
|
130
|
+
this._scheduleReconnect();
|
|
105
131
|
});
|
|
106
132
|
});
|
|
107
133
|
|
|
108
134
|
req.on('error', (err) => {
|
|
109
135
|
process.stderr.write(`SSE connection error: ${err.message}\n`);
|
|
110
|
-
|
|
136
|
+
this._scheduleReconnect();
|
|
111
137
|
});
|
|
112
138
|
|
|
113
139
|
this._sseReq = req;
|
|
@@ -247,9 +273,16 @@ class Bridge {
|
|
|
247
273
|
req.end();
|
|
248
274
|
}
|
|
249
275
|
|
|
276
|
+
destroy() {
|
|
277
|
+
this._destroyed = true;
|
|
278
|
+
if (this._reconnectTimer) clearTimeout(this._reconnectTimer);
|
|
279
|
+
if (this._sseReq) this._sseReq.destroy();
|
|
280
|
+
this._sseReq = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
250
283
|
_cleanup() {
|
|
251
284
|
this.log('Shutting down');
|
|
252
|
-
|
|
285
|
+
this.destroy();
|
|
253
286
|
process.exit(0);
|
|
254
287
|
}
|
|
255
288
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a simple YAML-like config file.
|
|
7
|
+
* Supports top-level key: value and one level of nested maps (indented with spaces).
|
|
8
|
+
*/
|
|
9
|
+
function parseConfig(filePath) {
|
|
10
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
const lines = content.split('\n');
|
|
12
|
+
const config = {};
|
|
13
|
+
let currentMap = null;
|
|
14
|
+
let currentMapKey = null;
|
|
15
|
+
|
|
16
|
+
for (const rawLine of lines) {
|
|
17
|
+
// Strip comments
|
|
18
|
+
const line = rawLine.replace(/#.*$/, '');
|
|
19
|
+
if (line.trim() === '') continue;
|
|
20
|
+
|
|
21
|
+
const indented = line.startsWith(' ') || line.startsWith('\t');
|
|
22
|
+
|
|
23
|
+
if (indented && currentMapKey) {
|
|
24
|
+
// Nested key: value
|
|
25
|
+
const match = line.trim().match(/^['"]?([^'":\s]+)['"]?\s*:\s*(.+)$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
let val = match[2].trim().replace(/^['"]|['"]$/g, '');
|
|
28
|
+
currentMap[match[1]] = isNaN(Number(val)) ? val : Number(val);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// Top-level key: value OR top-level key: (start of map)
|
|
32
|
+
const match = line.match(/^([a-z_]+)\s*:\s*(.*)$/i);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
|
|
35
|
+
const key = match[1].trim();
|
|
36
|
+
const val = match[2].trim();
|
|
37
|
+
|
|
38
|
+
if (val === '' || val === '{}') {
|
|
39
|
+
// Start of nested map
|
|
40
|
+
currentMap = {};
|
|
41
|
+
currentMapKey = key;
|
|
42
|
+
config[key] = currentMap;
|
|
43
|
+
} else {
|
|
44
|
+
currentMap = null;
|
|
45
|
+
currentMapKey = null;
|
|
46
|
+
// Parse value
|
|
47
|
+
let parsed = val.replace(/^['"]|['"]$/g, '');
|
|
48
|
+
config[key] = isNaN(Number(parsed)) ? parsed : Number(parsed);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return config;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { parseConfig };
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,9 @@ function parseArgs(argv) {
|
|
|
10
10
|
key: null,
|
|
11
11
|
verbose: false,
|
|
12
12
|
help: false,
|
|
13
|
+
local: false,
|
|
14
|
+
budget: null,
|
|
15
|
+
config: null,
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -29,6 +32,15 @@ function parseArgs(argv) {
|
|
|
29
32
|
case '--verbose':
|
|
30
33
|
args.verbose = true;
|
|
31
34
|
break;
|
|
35
|
+
case '--local':
|
|
36
|
+
args.local = true;
|
|
37
|
+
break;
|
|
38
|
+
case '--budget':
|
|
39
|
+
args.budget = argv[++i];
|
|
40
|
+
break;
|
|
41
|
+
case '--config':
|
|
42
|
+
args.config = argv[++i];
|
|
43
|
+
break;
|
|
32
44
|
case '--help':
|
|
33
45
|
case '-h':
|
|
34
46
|
args.help = true;
|
|
@@ -43,15 +55,24 @@ function parseArgs(argv) {
|
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
function printUsage() {
|
|
46
|
-
process.stderr.write(`satgate-proxy v0.
|
|
58
|
+
process.stderr.write(`satgate-proxy v0.3.0 — Budget-enforced MCP proxy
|
|
47
59
|
|
|
48
60
|
USAGE:
|
|
49
|
-
|
|
61
|
+
|
|
62
|
+
Local mode (zero-config, solo devs):
|
|
63
|
+
npx satgate-proxy --local --budget 5.00 --server <package>
|
|
64
|
+
npx satgate-proxy --local --config satgate.yaml
|
|
65
|
+
|
|
66
|
+
SaaS mode (teams, enterprise):
|
|
67
|
+
npx satgate-proxy --server <package> --key <macaroon> [--cap 5.00]
|
|
50
68
|
|
|
51
69
|
OPTIONS:
|
|
52
70
|
--server <package> MCP server package to proxy (required)
|
|
53
71
|
e.g. @modelcontextprotocol/server-google-search
|
|
54
|
-
--
|
|
72
|
+
--local Run in local mode (no SatGate server needed)
|
|
73
|
+
--budget <amount> Budget cap in dollars for local mode (e.g. 5.00)
|
|
74
|
+
--config <path> Path to satgate.yaml config file
|
|
75
|
+
--cap <amount> Budget cap in dollars for SaaS mode (e.g. 5.00)
|
|
55
76
|
--endpoint <url> SatGate proxy endpoint
|
|
56
77
|
(default: ${DEFAULT_ENDPOINT})
|
|
57
78
|
--key <macaroon> API key / macaroon token
|
|
@@ -59,7 +80,16 @@ OPTIONS:
|
|
|
59
80
|
--verbose Enable debug logging to stderr
|
|
60
81
|
-h, --help Show this help
|
|
61
82
|
|
|
62
|
-
|
|
83
|
+
EXAMPLES:
|
|
84
|
+
|
|
85
|
+
# Local mode — budget enforced in-process, zero dependencies
|
|
86
|
+
npx satgate-proxy --local --budget 5.00 \\
|
|
87
|
+
--server @modelcontextprotocol/server-google-search
|
|
88
|
+
|
|
89
|
+
# Local mode with config file
|
|
90
|
+
npx satgate-proxy --local --config satgate.yaml
|
|
91
|
+
|
|
92
|
+
# SaaS mode (Claude Desktop config)
|
|
63
93
|
{
|
|
64
94
|
"mcpServers": {
|
|
65
95
|
"google-search": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
class LocalBridge {
|
|
6
|
+
constructor({ server, budget, pricing, verbose }) {
|
|
7
|
+
this.server = server;
|
|
8
|
+
this.budget = budget != null ? Number(budget) : Infinity;
|
|
9
|
+
this.spent = 0;
|
|
10
|
+
this.pricing = pricing || {}; // { tool_name: cost_in_cents, '*': default }
|
|
11
|
+
this.verbose = verbose;
|
|
12
|
+
this._buffer = '';
|
|
13
|
+
this._childBuffer = '';
|
|
14
|
+
this._destroyed = false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
log(msg) {
|
|
18
|
+
if (this.verbose) process.stderr.write(`[satgate-proxy:local] ${msg}\n`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_getToolCost(toolName) {
|
|
22
|
+
if (this.pricing[toolName] != null) return Number(this.pricing[toolName]);
|
|
23
|
+
if (this.pricing['*'] != null) return Number(this.pricing['*']);
|
|
24
|
+
return 1; // default 1 cent
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start() {
|
|
28
|
+
this.log(`Local mode — spawning: npx -y ${this.server}`);
|
|
29
|
+
this.log(`Budget: $${this.budget}`);
|
|
30
|
+
|
|
31
|
+
const child = spawn('npx', ['-y', this.server], {
|
|
32
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
this._child = child;
|
|
36
|
+
|
|
37
|
+
child.on('error', (err) => {
|
|
38
|
+
process.stderr.write(`[satgate-proxy:local] Failed to spawn server: ${err.message}\n`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.on('exit', (code) => {
|
|
43
|
+
this.log(`Server exited with code ${code}`);
|
|
44
|
+
if (!this._destroyed) process.exit(code || 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Read from child stdout → write to our stdout
|
|
48
|
+
child.stdout.setEncoding('utf8');
|
|
49
|
+
child.stdout.on('data', (chunk) => {
|
|
50
|
+
this._childBuffer += chunk;
|
|
51
|
+
const lines = this._childBuffer.split('\n');
|
|
52
|
+
this._childBuffer = lines.pop() || '';
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (line.trim()) {
|
|
55
|
+
this.log(`← ${line.substring(0, 200)}`);
|
|
56
|
+
process.stdout.write(line + '\n');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Read from our stdin → intercept tool calls → forward to child
|
|
62
|
+
process.stdin.setEncoding('utf8');
|
|
63
|
+
process.stdin.on('data', (chunk) => {
|
|
64
|
+
this._buffer += chunk;
|
|
65
|
+
const lines = this._buffer.split('\n');
|
|
66
|
+
this._buffer = lines.pop() || '';
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed) continue;
|
|
70
|
+
this._handleMessage(trimmed);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
process.stdin.on('end', () => {
|
|
75
|
+
this.log('stdin closed, shutting down');
|
|
76
|
+
this._cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
process.stdout.on('error', (err) => {
|
|
80
|
+
if (err.code === 'EPIPE') this._cleanup();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
process.on('SIGINT', () => this._cleanup());
|
|
84
|
+
process.on('SIGTERM', () => this._cleanup());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_handleMessage(jsonStr) {
|
|
88
|
+
this.log(`→ ${jsonStr.substring(0, 200)}`);
|
|
89
|
+
|
|
90
|
+
let msg;
|
|
91
|
+
try {
|
|
92
|
+
msg = JSON.parse(jsonStr);
|
|
93
|
+
} catch {
|
|
94
|
+
// Not valid JSON, forward anyway
|
|
95
|
+
this._child.stdin.write(jsonStr + '\n');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Intercept tools/call
|
|
100
|
+
if (msg.method === 'tools/call' && msg.params && msg.params.name) {
|
|
101
|
+
const toolName = msg.params.name;
|
|
102
|
+
const cost = this._getToolCost(toolName);
|
|
103
|
+
const remaining = this.budget - this.spent;
|
|
104
|
+
|
|
105
|
+
this.log(`Tool call: ${toolName}, cost: ${cost}¢, remaining: ${remaining.toFixed(2)}¢`);
|
|
106
|
+
|
|
107
|
+
if (cost > remaining) {
|
|
108
|
+
// Budget exhausted
|
|
109
|
+
this.log(`Budget exhausted! Need ${cost}¢ but only ${remaining.toFixed(2)}¢ left`);
|
|
110
|
+
if (msg.id != null) {
|
|
111
|
+
const errResp = JSON.stringify({
|
|
112
|
+
jsonrpc: '2.0',
|
|
113
|
+
id: msg.id,
|
|
114
|
+
error: {
|
|
115
|
+
code: -32000,
|
|
116
|
+
message: `Budget exceeded. Spent $${(this.spent / 100).toFixed(2)} of $${(this.budget / 100).toFixed(2)} cap. Tool '${toolName}' costs ${cost}¢.`,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
process.stdout.write(errResp + '\n');
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.spent += cost;
|
|
125
|
+
this.log(`Deducted ${cost}¢, total spent: ${this.spent}¢ / ${this.budget}¢`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Forward to child
|
|
129
|
+
this._child.stdin.write(jsonStr + '\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
destroy() {
|
|
133
|
+
this._destroyed = true;
|
|
134
|
+
if (this._child) {
|
|
135
|
+
this._child.kill();
|
|
136
|
+
this._child = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_cleanup() {
|
|
141
|
+
this.log('Shutting down');
|
|
142
|
+
this.destroy();
|
|
143
|
+
process.exit(0);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { LocalBridge };
|