satgate-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,88 @@
1
+ # satgate-proxy
2
+
3
+ **Budget-enforced MCP proxy — hard-cap your AI agent tool spend.**
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 via [SatGate](https://satgate.io).
6
+
7
+ ## Quick Start
8
+
9
+ Add to your Claude Desktop config (`claude_desktop_config.json`):
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "google-search": {
15
+ "command": "npx",
16
+ "args": [
17
+ "satgate-proxy",
18
+ "--cap", "5.00",
19
+ "--server", "@modelcontextprotocol/server-google-search"
20
+ ],
21
+ "env": {
22
+ "SATGATE_API_KEY": "your_macaroon_here"
23
+ }
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ Get your API key at [cloud.satgate.io](https://cloud.satgate.io).
30
+
31
+ ## How It Works
32
+
33
+ ```
34
+ ┌──────────────┐ stdio ┌────────────────┐ SSE/HTTP ┌──────────────┐
35
+ │ Claude │ ──── JSON-RPC ──▶ satgate-proxy │ ──── JSON-RPC ──▶ SatGate │
36
+ │ Desktop │ ◀── JSON-RPC ── │ (this package) │ ◀── JSON-RPC ── │ MCP Proxy │
37
+ │ / Cursor │ └────────────────┘ │ + Budget │
38
+ └──────────────┘ │ Enforcement │
39
+ └──────┬───────┘
40
+
41
+ ┌──────▼───────┐
42
+ │ MCP Server │
43
+ │ (hosted) │
44
+ └──────────────┘
45
+ ```
46
+
47
+ 1. Claude/Cursor launches `satgate-proxy` as a stdio MCP server
48
+ 2. The proxy connects to SatGate's SSE endpoint
49
+ 3. JSON-RPC messages from stdin are forwarded to SatGate via HTTP POST
50
+ 4. SatGate enforces your budget cap and proxies to the real MCP server
51
+ 5. Responses stream back via SSE and are written to stdout
52
+ 6. If budget is exceeded → 402 → clean error back to the client
53
+
54
+ ## CLI Flags
55
+
56
+ | Flag | Description | Default |
57
+ |------|-------------|---------|
58
+ | `--server <package>` | MCP server package to proxy (required) | — |
59
+ | `--cap <amount>` | Budget cap in USD | — |
60
+ | `--endpoint <url>` | SatGate proxy endpoint | `https://satgate-mcp-saas.fly.dev` |
61
+ | `--key <macaroon>` | API key (or use `SATGATE_API_KEY` env var) | — |
62
+ | `--verbose` | Debug logging to stderr | off |
63
+ | `-h, --help` | Show help | — |
64
+
65
+ ## Why?
66
+
67
+ 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
+
69
+ **satgate-proxy** adds a hard cap:
70
+ - Set `--cap 5.00` → agent can spend at most $5
71
+ - Enforced server-side by SatGate (can't be bypassed by the client)
72
+ - Uses L402 macaroons for cryptographic budget enforcement
73
+ - Zero dependencies — `npx` runs it instantly
74
+
75
+ ## Zero Dependencies
76
+
77
+ This package uses only Node.js built-ins. No `node_modules`, no install step. `npx satgate-proxy` just works.
78
+
79
+ ## Links
80
+
81
+ - 🌐 [satgate.io](https://satgate.io) — Homepage
82
+ - ☁️ [cloud.satgate.io](https://cloud.satgate.io) — Dashboard & API keys
83
+ - 📦 [GitHub](https://github.com/SatGate-io/satgate-proxy) — Source code
84
+ - 📋 [MCP Specification](https://modelcontextprotocol.io) — Model Context Protocol
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { parseArgs, printUsage } = require('../src/index.js');
5
+ const { Bridge } = require('../src/bridge.js');
6
+
7
+ const args = parseArgs(process.argv.slice(2));
8
+
9
+ if (args.help) {
10
+ printUsage();
11
+ process.exit(0);
12
+ }
13
+
14
+ if (!args.server) {
15
+ process.stderr.write('Error: --server <package> is required\n\n');
16
+ printUsage();
17
+ process.exit(1);
18
+ }
19
+
20
+ const apiKey = args.key || process.env.SATGATE_API_KEY;
21
+ if (!apiKey) {
22
+ process.stderr.write('Error: API key required via --key or SATGATE_API_KEY env var\n');
23
+ process.exit(1);
24
+ }
25
+
26
+ const bridge = new Bridge({
27
+ server: args.server,
28
+ endpoint: args.endpoint,
29
+ apiKey,
30
+ cap: args.cap,
31
+ verbose: args.verbose,
32
+ });
33
+
34
+ bridge.start().catch((err) => {
35
+ process.stderr.write(`Fatal: ${err.message}\n`);
36
+ process.exit(1);
37
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "satgate-proxy",
3
+ "version": "0.1.0",
4
+ "description": "Budget-enforced MCP proxy — hard-cap your AI agent tool spend",
5
+ "bin": {
6
+ "satgate-proxy": "./bin/satgate-proxy.js"
7
+ },
8
+ "keywords": ["mcp", "ai", "agent", "budget", "l402", "satgate", "proxy", "claude", "cursor"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/SatGate-io/satgate-proxy"
13
+ },
14
+ "homepage": "https://satgate.io",
15
+ "author": "SatGate Inc.",
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "files": ["bin/", "src/", "README.md"]
20
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+ const { URL } = require('url');
6
+
7
+ class Bridge {
8
+ constructor({ server, endpoint, apiKey, cap, verbose }) {
9
+ this.server = server;
10
+ this.endpoint = endpoint;
11
+ this.apiKey = apiKey;
12
+ this.cap = cap;
13
+ this.verbose = verbose;
14
+ this._buffer = '';
15
+ this._pendingRequests = new Map();
16
+ this._sseBuffer = '';
17
+ }
18
+
19
+ log(msg) {
20
+ if (this.verbose) process.stderr.write(`[satgate-proxy] ${msg}\n`);
21
+ }
22
+
23
+ async start() {
24
+ this.log(`Bridging stdio ↔ ${this.endpoint}`);
25
+ this.log(`Server: ${this.server}`);
26
+ if (this.cap) this.log(`Budget cap: $${this.cap}`);
27
+
28
+ // Connect SSE session to SatGate
29
+ this._connectSSE();
30
+
31
+ // Read JSON-RPC from stdin and forward via HTTP POST
32
+ process.stdin.setEncoding('utf8');
33
+ process.stdin.on('data', (chunk) => this._onStdinData(chunk));
34
+ process.stdin.on('end', () => {
35
+ this.log('stdin closed, shutting down');
36
+ this._cleanup();
37
+ });
38
+
39
+ // Handle stdout errors (EPIPE when reader disconnects)
40
+ process.stdout.on('error', (err) => {
41
+ if (err.code === 'EPIPE') {
42
+ this.log('stdout pipe closed, shutting down');
43
+ this._cleanup();
44
+ }
45
+ });
46
+
47
+ // Graceful shutdown
48
+ process.on('SIGINT', () => this._cleanup());
49
+ process.on('SIGTERM', () => this._cleanup());
50
+ process.on('unhandledRejection', (err) => {
51
+ this.log(`Unhandled rejection: ${err.message}`);
52
+ });
53
+ }
54
+
55
+ _buildUrl(path) {
56
+ const base = this.endpoint.replace(/\/$/, '');
57
+ return `${base}${path}`;
58
+ }
59
+
60
+ _buildHeaders() {
61
+ const headers = {
62
+ 'Content-Type': 'application/json',
63
+ 'Authorization': `Bearer ${this.apiKey}`,
64
+ };
65
+ if (this.cap) {
66
+ headers['X-SatGate-Cap'] = this.cap;
67
+ }
68
+ headers['X-SatGate-Server'] = this.server;
69
+ return headers;
70
+ }
71
+
72
+ _connectSSE() {
73
+ const url = this._buildUrl('/sse');
74
+ this.log(`Connecting SSE: ${url}`);
75
+
76
+ const parsedUrl = new URL(url);
77
+ const mod = parsedUrl.protocol === 'https:' ? https : http;
78
+ const headers = this._buildHeaders();
79
+ headers['Accept'] = 'text/event-stream';
80
+
81
+ const req = mod.get(url, { headers }, (res) => {
82
+ if (res.statusCode !== 200) {
83
+ process.stderr.write(`SSE connection failed: HTTP ${res.statusCode}\n`);
84
+ let body = '';
85
+ res.on('data', (d) => body += d);
86
+ res.on('end', () => {
87
+ if (body) process.stderr.write(`Response: ${body}\n`);
88
+ process.exit(1);
89
+ });
90
+ return;
91
+ }
92
+
93
+ this.log('SSE connected');
94
+ this._sseRes = res;
95
+ res.setEncoding('utf8');
96
+
97
+ res.on('data', (chunk) => this._onSSEData(chunk));
98
+ res.on('end', () => {
99
+ this.log('SSE connection closed');
100
+ process.exit(0);
101
+ });
102
+ res.on('error', (err) => {
103
+ process.stderr.write(`SSE error: ${err.message}\n`);
104
+ process.exit(1);
105
+ });
106
+ });
107
+
108
+ req.on('error', (err) => {
109
+ process.stderr.write(`SSE connection error: ${err.message}\n`);
110
+ process.exit(1);
111
+ });
112
+
113
+ this._sseReq = req;
114
+ }
115
+
116
+ _onSSEData(chunk) {
117
+ this._sseBuffer += chunk;
118
+ const lines = this._sseBuffer.split('\n');
119
+ // Keep incomplete last line in buffer
120
+ this._sseBuffer = lines.pop() || '';
121
+
122
+ let eventType = 'message';
123
+ let dataLines = [];
124
+
125
+ for (const line of lines) {
126
+ if (line.startsWith('event:')) {
127
+ eventType = line.slice(6).trim();
128
+ } else if (line.startsWith('data:')) {
129
+ dataLines.push(line.slice(5).trim());
130
+ } else if (line === '') {
131
+ // End of event
132
+ if (dataLines.length > 0) {
133
+ const data = dataLines.join('\n');
134
+ this._handleSSEEvent(eventType, data);
135
+ }
136
+ eventType = 'message';
137
+ dataLines = [];
138
+ }
139
+ }
140
+ }
141
+
142
+ _handleSSEEvent(eventType, data) {
143
+ this.log(`SSE event: ${eventType}`);
144
+
145
+ if (eventType === 'endpoint') {
146
+ // SatGate sends the POST endpoint URL via SSE
147
+ this._postEndpoint = data.startsWith('http') ? data : this._buildUrl(data);
148
+ this.log(`POST endpoint: ${this._postEndpoint}`);
149
+ // Flush any queued messages
150
+ this._flushQueue();
151
+ return;
152
+ }
153
+
154
+ if (eventType === 'message') {
155
+ this.log(`← ${data.substring(0, 200)}`);
156
+ // Write JSON-RPC response to stdout
157
+ process.stdout.write(data + '\n');
158
+ }
159
+ }
160
+
161
+ _onStdinData(chunk) {
162
+ this._buffer += chunk;
163
+ // Split on newlines — MCP uses line-delimited JSON
164
+ const lines = this._buffer.split('\n');
165
+ this._buffer = lines.pop() || '';
166
+
167
+ for (const line of lines) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed) continue;
170
+ this._sendMessage(trimmed);
171
+ }
172
+ }
173
+
174
+ _sendMessage(jsonStr) {
175
+ this.log(`→ ${jsonStr.substring(0, 200)}`);
176
+
177
+ if (!this._postEndpoint) {
178
+ this.log('POST endpoint not yet known, queuing');
179
+ if (!this._queue) this._queue = [];
180
+ this._queue.push(jsonStr);
181
+ return;
182
+ }
183
+
184
+ this._postToEndpoint(jsonStr);
185
+ }
186
+
187
+ _flushQueue() {
188
+ if (!this._queue || this._queue.length === 0) return;
189
+ this.log(`Flushing ${this._queue.length} queued messages`);
190
+ for (const msg of this._queue) {
191
+ this._postToEndpoint(msg);
192
+ }
193
+ this._queue = [];
194
+ }
195
+
196
+ _postToEndpoint(jsonStr) {
197
+ const url = new URL(this._postEndpoint);
198
+ const mod = url.protocol === 'https:' ? https : http;
199
+
200
+ const body = Buffer.from(jsonStr, 'utf8');
201
+ const headers = this._buildHeaders();
202
+ headers['Content-Length'] = body.length;
203
+
204
+ const opts = {
205
+ hostname: url.hostname,
206
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
207
+ path: url.pathname + url.search,
208
+ method: 'POST',
209
+ headers,
210
+ };
211
+
212
+ const req = mod.request(opts, (res) => {
213
+ let respBody = '';
214
+ res.setEncoding('utf8');
215
+ res.on('data', (d) => respBody += d);
216
+ res.on('end', () => {
217
+ if (res.statusCode === 402) {
218
+ // Budget exceeded — synthesize JSON-RPC error
219
+ this.log('Budget exceeded (402)');
220
+ try {
221
+ const parsed = JSON.parse(jsonStr);
222
+ if (parsed.id != null) {
223
+ const errResp = JSON.stringify({
224
+ jsonrpc: '2.0',
225
+ id: parsed.id,
226
+ error: {
227
+ code: -32000,
228
+ message: 'Budget exceeded. Your SatGate spending cap has been reached.',
229
+ },
230
+ });
231
+ process.stdout.write(errResp + '\n');
232
+ }
233
+ } catch (_) {}
234
+ } else if (res.statusCode >= 400) {
235
+ this.log(`POST error ${res.statusCode}: ${respBody}`);
236
+ } else {
237
+ this.log(`POST OK ${res.statusCode}`);
238
+ }
239
+ });
240
+ });
241
+
242
+ req.on('error', (err) => {
243
+ process.stderr.write(`POST error: ${err.message}\n`);
244
+ });
245
+
246
+ req.write(body);
247
+ req.end();
248
+ }
249
+
250
+ _cleanup() {
251
+ this.log('Shutting down');
252
+ if (this._sseReq) this._sseReq.destroy();
253
+ process.exit(0);
254
+ }
255
+ }
256
+
257
+ module.exports = { Bridge };
package/src/index.js ADDED
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_ENDPOINT = 'https://satgate-mcp-saas.fly.dev';
4
+
5
+ function parseArgs(argv) {
6
+ const args = {
7
+ server: null,
8
+ cap: null,
9
+ endpoint: DEFAULT_ENDPOINT,
10
+ key: null,
11
+ verbose: false,
12
+ help: false,
13
+ };
14
+
15
+ for (let i = 0; i < argv.length; i++) {
16
+ switch (argv[i]) {
17
+ case '--server':
18
+ args.server = argv[++i];
19
+ break;
20
+ case '--cap':
21
+ args.cap = argv[++i];
22
+ break;
23
+ case '--endpoint':
24
+ args.endpoint = argv[++i];
25
+ break;
26
+ case '--key':
27
+ args.key = argv[++i];
28
+ break;
29
+ case '--verbose':
30
+ args.verbose = true;
31
+ break;
32
+ case '--help':
33
+ case '-h':
34
+ args.help = true;
35
+ break;
36
+ default:
37
+ process.stderr.write(`Unknown flag: ${argv[i]}\n`);
38
+ args.help = true;
39
+ }
40
+ }
41
+
42
+ return args;
43
+ }
44
+
45
+ function printUsage() {
46
+ process.stderr.write(`satgate-proxy v0.1.0 — Budget-enforced MCP proxy
47
+
48
+ USAGE:
49
+ npx satgate-proxy --server <package> [options]
50
+
51
+ OPTIONS:
52
+ --server <package> MCP server package to proxy (required)
53
+ e.g. @modelcontextprotocol/server-google-search
54
+ --cap <amount> Budget cap in dollars (e.g. 5.00)
55
+ --endpoint <url> SatGate proxy endpoint
56
+ (default: ${DEFAULT_ENDPOINT})
57
+ --key <macaroon> API key / macaroon token
58
+ (also reads SATGATE_API_KEY env var)
59
+ --verbose Enable debug logging to stderr
60
+ -h, --help Show this help
61
+
62
+ EXAMPLE (Claude Desktop config):
63
+ {
64
+ "mcpServers": {
65
+ "google-search": {
66
+ "command": "npx",
67
+ "args": ["satgate-proxy", "--cap", "5.00",
68
+ "--server", "@modelcontextprotocol/server-google-search"],
69
+ "env": { "SATGATE_API_KEY": "your_macaroon_here" }
70
+ }
71
+ }
72
+ }
73
+
74
+ More info: https://satgate.io
75
+ `);
76
+ }
77
+
78
+ module.exports = { parseArgs, printUsage, DEFAULT_ENDPOINT };