satgate-proxy 0.2.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 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 via [SatGate](https://satgate.io).
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
- ## Quick Start
7
+ **Zero dependencies. Node.js built-ins only. `npx` and go.**
8
8
 
9
- Add to your Claude Desktop config (`claude_desktop_config.json`):
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
- 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
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
- | `--cap <amount>` | Budget cap in USD | |
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 use `SATGATE_API_KEY` env var) | — |
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 `--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
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
 
@@ -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 (!args.server) {
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
- 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
- }
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 Bridge({
27
- server: args.server,
28
- endpoint: args.endpoint,
29
- apiKey,
30
- cap: args.cap,
31
- verbose: args.verbose,
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
- process.stderr.write(`Fatal: ${err.message}\n`);
36
- process.exit(1);
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.2.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"
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.2.0 — Budget-enforced MCP proxy
58
+ process.stderr.write(`satgate-proxy v0.3.0 — Budget-enforced MCP proxy
47
59
 
48
60
  USAGE:
49
- npx satgate-proxy --server <package> [options]
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
- --cap <amount> Budget cap in dollars (e.g. 5.00)
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
- EXAMPLE (Claude Desktop config):
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 };