@tournesol-tag/mcp-bridge 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 +209 -0
- package/dist/cli.js +480 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +344 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# @tournesol-tag/mcp-bridge
|
|
2
|
+
|
|
3
|
+
CLI that proxies a **local MCP server** to **TAG** over a reverse WebSocket
|
|
4
|
+
relay, so a hosted TAG instance (or the TAG engine) can call tools that only
|
|
5
|
+
exist on your laptop or inside your VPN.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
hosted TAG ──HTTPS──► TAG MCP relay ──reverse WS──► tag-mcp-bridge ──stdio──► your MCP server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You run `tag-mcp-bridge` locally. It dials out to the relay (so no inbound
|
|
12
|
+
ports), keeps the MCP child process warm, and translates relay RPC envelopes
|
|
13
|
+
into MCP JSON-RPC requests (`tools/list`, `tools/call`).
|
|
14
|
+
|
|
15
|
+
See [ADR-033] for the full design.
|
|
16
|
+
|
|
17
|
+
[ADR-033]: https://github.com/jaimetournesol/TAG/blob/main/docs/adr/033-mcp-bridge.md
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start (easiest path)
|
|
22
|
+
|
|
23
|
+
The fastest way to wire this up to your local MCP server:
|
|
24
|
+
|
|
25
|
+
1. **Install the CLI** (see below): `npm install -g @tournesol-tag/mcp-bridge`
|
|
26
|
+
2. In hosted TAG, open **My MCP Bridge** (left nav). That page shows:
|
|
27
|
+
- your **relay URL** (the `--relay` value),
|
|
28
|
+
- a **Generate token** button (the `--token` value, valid 30 days),
|
|
29
|
+
- a ready-to-paste `tag-mcp-bridge …` command with both already filled in.
|
|
30
|
+
3. Copy that command, append `--mcp-cmd "<how you start your MCP server>"`,
|
|
31
|
+
and run it on the machine where your MCP server lives. Example:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
tag-mcp-bridge \
|
|
35
|
+
--relay https://tag-mcp-relay.<your-env>.azurecontainerapps.io \
|
|
36
|
+
--token "$TOKEN_FROM_UI" \
|
|
37
|
+
--mcp-cmd "python -m my_tools.server"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
4. Keep it running under `screen`, `pm2`, or `systemd` so it survives reboots.
|
|
41
|
+
Hosted workflows that reference your tools will get a 503 while it's offline.
|
|
42
|
+
|
|
43
|
+
That's it — no inbound ports, no DNS, no public hostname for your machine. The
|
|
44
|
+
bridge dials **out** to the relay. The rest of this README is reference detail.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
### npm (recommended)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g @tournesol-tag/mcp-bridge
|
|
54
|
+
# or run ad-hoc without installing
|
|
55
|
+
npx @tournesol-tag/mcp-bridge --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Docker
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
docker pull ghcr.io/jaimetournesol/mcp-bridge:latest
|
|
62
|
+
docker run --rm ghcr.io/jaimetournesol/mcp-bridge --help
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
tag-mcp-bridge — proxy a local MCP server to TAG via reverse-WS
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
tag-mcp-bridge --relay <url> --token <jwt> --mcp-cmd <cmd> [--dev-id <id>] [-- <mcp-args>]
|
|
74
|
+
|
|
75
|
+
Flags:
|
|
76
|
+
--relay <url> Relay base URL, e.g. https://tag-mcp-relay.example.com
|
|
77
|
+
Env: TAG_RELAY_URL
|
|
78
|
+
--token <jwt> Bridge JWT (sub=dev:<id>, scope=["bridge"])
|
|
79
|
+
Env: TAG_BRIDGE_TOKEN
|
|
80
|
+
--dev-id <id> Optional; derived from JWT subject if omitted.
|
|
81
|
+
--mcp-cmd <cmd> Shell-style command to spawn the MCP server.
|
|
82
|
+
Quoted form: --mcp-cmd "python -m tariff_tools.server"
|
|
83
|
+
Env: TAG_MCP_CMD
|
|
84
|
+
--mcp-env <k=v> Extra env var for the MCP child. Repeatable.
|
|
85
|
+
-- Everything after this is appended to the MCP child argv.
|
|
86
|
+
|
|
87
|
+
Reconnect: exponential backoff up to 30s. SIGINT to stop cleanly.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Example
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
export TAG_RELAY_URL="https://tag-mcp-relay.example.com" # shown on the My MCP Bridge page
|
|
94
|
+
export TAG_BRIDGE_TOKEN="eyJhbGciOiJSUzI1NiIs..." # Generate token on the My MCP Bridge page
|
|
95
|
+
|
|
96
|
+
tag-mcp-bridge \
|
|
97
|
+
--relay "$TAG_RELAY_URL" \
|
|
98
|
+
--token "$TAG_BRIDGE_TOKEN" \
|
|
99
|
+
--mcp-cmd "python -m tariff_tools.server" \
|
|
100
|
+
--mcp-env "NEO4J_URL=bolt://localhost:7687" \
|
|
101
|
+
--mcp-env "API_KEY=local-dev"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You should see:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
[tag-mcp-bridge] dev=alice relay=https://tag-mcp-relay.example.com mcp="python -m tariff_tools.server "
|
|
108
|
+
[tag-mcp-bridge] ws connected
|
|
109
|
+
[tag-mcp-bridge] mcp child ready (15 tools)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Then any hosted workflow whose `serviceUrls.<name>` resolves to your bridge
|
|
113
|
+
will route tool calls to your local MCP server.
|
|
114
|
+
|
|
115
|
+
### Docker example
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
docker run --rm -i \
|
|
119
|
+
-e TAG_RELAY_URL="https://tag-mcp-relay.example.com" \
|
|
120
|
+
-e TAG_BRIDGE_TOKEN="$TAG_BRIDGE_TOKEN" \
|
|
121
|
+
-e TAG_MCP_CMD="python -m tariff_tools.server" \
|
|
122
|
+
ghcr.io/jaimetournesol/mcp-bridge:latest \
|
|
123
|
+
--relay "$TAG_RELAY_URL" --token "$TAG_BRIDGE_TOKEN" --mcp-cmd "$TAG_MCP_CMD"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Note: the MCP child runs **inside** the container. If your tool needs Python
|
|
127
|
+
or other binaries, build a derived image with your runtime preinstalled and
|
|
128
|
+
keep the `tag-mcp-bridge` ENTRYPOINT.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## How tokens are issued
|
|
133
|
+
|
|
134
|
+
The simplest path is the **My MCP Bridge** page in hosted TAG — click
|
|
135
|
+
**Generate token**. Under the hood it calls the TAG API:
|
|
136
|
+
|
|
137
|
+
```http
|
|
138
|
+
POST /api/me/relay-bridge-token
|
|
139
|
+
Authorization: Bearer <your-tag-session>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The relay URL is likewise available from that page, or directly:
|
|
143
|
+
|
|
144
|
+
```http
|
|
145
|
+
GET /api/me/relay-bridge-info
|
|
146
|
+
Authorization: Bearer <your-tag-session>
|
|
147
|
+
→ { "relayUrl": "https://tag-mcp-relay.<env>.azurecontainerapps.io" }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The bridge JWT is valid 30 days, with `sub: "dev:<your-id>"` and
|
|
151
|
+
`scope: ["bridge"]`. The bridge decodes the `sub` claim to know which dev
|
|
152
|
+
slot to claim on the relay (no verification — the relay enforces it).
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Troubleshooting
|
|
157
|
+
|
|
158
|
+
**`ws connect failed: 401`**
|
|
159
|
+
Token expired or wrong relay. Re-issue from `POST /me/bridge/token` and
|
|
160
|
+
re-check `--relay`.
|
|
161
|
+
|
|
162
|
+
**`ws connect failed: ENOTFOUND`**
|
|
163
|
+
Relay hostname unreachable from this machine. Try `curl -I $TAG_RELAY_URL`.
|
|
164
|
+
|
|
165
|
+
**`mcp child exited: 127`**
|
|
166
|
+
`--mcp-cmd` resolves to a binary that isn't on `PATH` (or inside the
|
|
167
|
+
container image, if you're running in Docker). Run the command standalone to
|
|
168
|
+
isolate.
|
|
169
|
+
|
|
170
|
+
**Bridge keeps reconnecting in a loop**
|
|
171
|
+
Each disconnect backs off exponentially up to **30 seconds** and retries
|
|
172
|
+
forever. If the relay is up but the bridge keeps dropping, check:
|
|
173
|
+
- another bridge process is **already** holding your dev slot (last writer
|
|
174
|
+
wins — kill the old one with `pkill -f tag-mcp-bridge`),
|
|
175
|
+
- your laptop sleep/wake is breaking the TCP keepalive (the bridge will
|
|
176
|
+
recover on the next wake; this is by design).
|
|
177
|
+
|
|
178
|
+
**MCP session resets between calls**
|
|
179
|
+
The bridge keeps **one** MCP child for its entire lifetime, so tool state is
|
|
180
|
+
preserved across relay reconnects. If you see state loss, the MCP child
|
|
181
|
+
itself probably crashed and was respawned — check stderr.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
git clone https://github.com/jaimetournesol/TAG
|
|
189
|
+
cd TAG
|
|
190
|
+
pnpm install
|
|
191
|
+
pnpm --filter @tournesol-tag/mcp-bridge build
|
|
192
|
+
pnpm --filter @tournesol-tag/mcp-bridge test
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Source lives in `apps/mcp-bridge/src/`:
|
|
196
|
+
|
|
197
|
+
| file | purpose |
|
|
198
|
+
|----------------------|------------------------------------------------|
|
|
199
|
+
| `cli.ts` | argv parsing, env fallbacks, signal handling |
|
|
200
|
+
| `bridge.ts` | WS lifecycle, RPC envelope translation |
|
|
201
|
+
| `mcp-stdio.ts` | spawn MCP child, frame JSON-RPC over stdio |
|
|
202
|
+
| `protocol.ts` | relay envelope schema (Zod-free, hand-rolled) |
|
|
203
|
+
| `backoff.ts` | exponential backoff with jitter, capped at 30s |
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT — see [LICENSE](https://github.com/jaimetournesol/TAG/blob/main/LICENSE).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bridge.ts
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
|
|
6
|
+
// src/backoff.ts
|
|
7
|
+
var Backoff = class {
|
|
8
|
+
attempt = 0;
|
|
9
|
+
base;
|
|
10
|
+
cap;
|
|
11
|
+
random;
|
|
12
|
+
constructor(opts = {}) {
|
|
13
|
+
this.base = opts.base ?? 500;
|
|
14
|
+
this.cap = opts.cap ?? 3e4;
|
|
15
|
+
this.random = opts.random ?? Math.random;
|
|
16
|
+
}
|
|
17
|
+
reset() {
|
|
18
|
+
this.attempt = 0;
|
|
19
|
+
}
|
|
20
|
+
/** Return the next delay (ms) and advance attempt counter. */
|
|
21
|
+
next() {
|
|
22
|
+
const exp = Math.min(this.cap, this.base * 2 ** this.attempt);
|
|
23
|
+
this.attempt += 1;
|
|
24
|
+
return Math.floor(this.random() * exp);
|
|
25
|
+
}
|
|
26
|
+
/** Test introspection: current attempt count (0 after reset). */
|
|
27
|
+
get currentAttempt() {
|
|
28
|
+
return this.attempt;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/protocol.ts
|
|
33
|
+
function encode(frame) {
|
|
34
|
+
return JSON.stringify(frame);
|
|
35
|
+
}
|
|
36
|
+
function decode(raw) {
|
|
37
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
38
|
+
const parsed = JSON.parse(text);
|
|
39
|
+
if (!parsed || typeof parsed !== "object" || !("kind" in parsed)) {
|
|
40
|
+
throw new Error("invalid frame: missing kind");
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/mcp-stdio.ts
|
|
46
|
+
import { spawn } from "child_process";
|
|
47
|
+
import { EventEmitter } from "events";
|
|
48
|
+
var McpStdioClient = class extends EventEmitter {
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
super();
|
|
51
|
+
this.opts = opts;
|
|
52
|
+
}
|
|
53
|
+
opts;
|
|
54
|
+
proc = null;
|
|
55
|
+
framing = "unknown";
|
|
56
|
+
buf = Buffer.alloc(0);
|
|
57
|
+
nextId = 1;
|
|
58
|
+
pending = /* @__PURE__ */ new Map();
|
|
59
|
+
closed = false;
|
|
60
|
+
start() {
|
|
61
|
+
if (this.proc) throw new Error("already started");
|
|
62
|
+
const proc = spawn(this.opts.command, this.opts.args ?? [], {
|
|
63
|
+
env: { ...process.env, ...this.opts.env },
|
|
64
|
+
cwd: this.opts.cwd,
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
66
|
+
});
|
|
67
|
+
this.proc = proc;
|
|
68
|
+
proc.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
69
|
+
proc.stderr.on("data", (chunk) => {
|
|
70
|
+
this.emit("stderr", chunk.toString("utf-8"));
|
|
71
|
+
});
|
|
72
|
+
proc.on("exit", (code, signal) => {
|
|
73
|
+
this.closed = true;
|
|
74
|
+
this.emit("exit", { code, signal });
|
|
75
|
+
for (const [, resolve] of this.pending) {
|
|
76
|
+
resolve({
|
|
77
|
+
jsonrpc: "2.0",
|
|
78
|
+
error: { code: -32e3, message: `mcp child exited code=${code} signal=${signal}` }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
this.pending.clear();
|
|
82
|
+
});
|
|
83
|
+
proc.on("error", (err) => {
|
|
84
|
+
this.emit("error", err);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async stop(signal = "SIGTERM") {
|
|
88
|
+
if (!this.proc || this.closed) return;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
this.proc.once("exit", () => resolve());
|
|
91
|
+
try {
|
|
92
|
+
this.proc.kill(signal);
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
try {
|
|
97
|
+
this.proc?.kill("SIGKILL");
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}, 2e3).unref();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Send a JSON-RPC request to the MCP child and await its response.
|
|
105
|
+
* Auto-generates an id if the caller doesn't supply one.
|
|
106
|
+
*/
|
|
107
|
+
request(method, params) {
|
|
108
|
+
if (!this.proc) throw new Error("not started");
|
|
109
|
+
const id = this.nextId++;
|
|
110
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
this.pending.set(id, resolve);
|
|
113
|
+
this.write(msg);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Send an MCP notification (no response expected). */
|
|
117
|
+
notify(method, params) {
|
|
118
|
+
if (!this.proc) throw new Error("not started");
|
|
119
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
120
|
+
}
|
|
121
|
+
write(msg) {
|
|
122
|
+
const json = JSON.stringify(msg);
|
|
123
|
+
if (this.framing === "lsp") {
|
|
124
|
+
const body = Buffer.from(json, "utf-8");
|
|
125
|
+
this.proc.stdin.write(`Content-Length: ${body.length}\r
|
|
126
|
+
\r
|
|
127
|
+
`);
|
|
128
|
+
this.proc.stdin.write(body);
|
|
129
|
+
} else {
|
|
130
|
+
this.proc.stdin.write(json + "\n");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
onStdout(chunk) {
|
|
134
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
135
|
+
if (this.framing === "unknown") {
|
|
136
|
+
const peek = this.buf.subarray(0, Math.min(16, this.buf.length)).toString("utf-8");
|
|
137
|
+
if (peek.startsWith("Content-Length:")) this.framing = "lsp";
|
|
138
|
+
else if (this.buf.includes(10)) this.framing = "newline";
|
|
139
|
+
}
|
|
140
|
+
if (this.framing === "lsp") this.drainLsp();
|
|
141
|
+
else if (this.framing === "newline") this.drainNewline();
|
|
142
|
+
}
|
|
143
|
+
drainNewline() {
|
|
144
|
+
while (true) {
|
|
145
|
+
const nl = this.buf.indexOf(10);
|
|
146
|
+
if (nl < 0) return;
|
|
147
|
+
const line = this.buf.subarray(0, nl).toString("utf-8").trim();
|
|
148
|
+
this.buf = this.buf.subarray(nl + 1);
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
this.dispatchLine(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
drainLsp() {
|
|
154
|
+
while (true) {
|
|
155
|
+
const headerEnd = this.buf.indexOf("\r\n\r\n");
|
|
156
|
+
if (headerEnd < 0) return;
|
|
157
|
+
const headers = this.buf.subarray(0, headerEnd).toString("utf-8");
|
|
158
|
+
const m = /Content-Length:\s*(\d+)/i.exec(headers);
|
|
159
|
+
if (!m) {
|
|
160
|
+
this.buf = this.buf.subarray(headerEnd + 4);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const len = Number(m[1]);
|
|
164
|
+
const start = headerEnd + 4;
|
|
165
|
+
if (this.buf.length < start + len) return;
|
|
166
|
+
const body = this.buf.subarray(start, start + len).toString("utf-8");
|
|
167
|
+
this.buf = this.buf.subarray(start + len);
|
|
168
|
+
this.dispatchLine(body);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
dispatchLine(line) {
|
|
172
|
+
let msg;
|
|
173
|
+
try {
|
|
174
|
+
msg = JSON.parse(line);
|
|
175
|
+
} catch {
|
|
176
|
+
this.emit("parseError", line);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (msg.id !== void 0 && this.pending.has(msg.id)) {
|
|
180
|
+
const cb = this.pending.get(msg.id);
|
|
181
|
+
this.pending.delete(msg.id);
|
|
182
|
+
cb(msg);
|
|
183
|
+
} else {
|
|
184
|
+
this.emit("message", msg);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/bridge.ts
|
|
190
|
+
var Bridge = class {
|
|
191
|
+
constructor(opts) {
|
|
192
|
+
this.opts = opts;
|
|
193
|
+
this.backoff = new Backoff(opts.backoff);
|
|
194
|
+
this.log = opts.logger ?? {
|
|
195
|
+
info: (...a) => console.log("[bridge]", ...a),
|
|
196
|
+
warn: (...a) => console.warn("[bridge]", ...a),
|
|
197
|
+
error: (...a) => console.error("[bridge]", ...a)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
opts;
|
|
201
|
+
ws = null;
|
|
202
|
+
backoff;
|
|
203
|
+
mcp = null;
|
|
204
|
+
toolCatalog = null;
|
|
205
|
+
stopped = false;
|
|
206
|
+
reconnectTimer = null;
|
|
207
|
+
log;
|
|
208
|
+
/** Start the MCP child + connect to relay. Returns once initial connect resolves OR backoff begins. */
|
|
209
|
+
async start() {
|
|
210
|
+
if (this.mcp) throw new Error("already started");
|
|
211
|
+
this.mcp = new McpStdioClient({
|
|
212
|
+
command: this.opts.mcpCommand,
|
|
213
|
+
args: this.opts.mcpArgs,
|
|
214
|
+
env: this.opts.mcpEnv
|
|
215
|
+
});
|
|
216
|
+
this.mcp.on("stderr", (s) => this.log.warn("mcp child stderr:", s.trim()));
|
|
217
|
+
this.mcp.on("exit", (info) => this.log.error("mcp child exited", info));
|
|
218
|
+
this.mcp.on("parseError", (line) => this.log.warn("mcp parse error", line));
|
|
219
|
+
this.mcp.start();
|
|
220
|
+
try {
|
|
221
|
+
const resp = await this.mcp.request("tools/list");
|
|
222
|
+
if (resp.result && typeof resp.result === "object" && "tools" in resp.result) {
|
|
223
|
+
this.toolCatalog = resp.result.tools ?? [];
|
|
224
|
+
} else {
|
|
225
|
+
this.toolCatalog = [];
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.log.warn("initial tools/list failed; relay will use live RPC", err.message);
|
|
229
|
+
}
|
|
230
|
+
this.connect();
|
|
231
|
+
}
|
|
232
|
+
/** Stop the bridge: close WS, kill child, cancel reconnect. */
|
|
233
|
+
async stop() {
|
|
234
|
+
this.stopped = true;
|
|
235
|
+
if (this.reconnectTimer) {
|
|
236
|
+
clearTimeout(this.reconnectTimer);
|
|
237
|
+
this.reconnectTimer = null;
|
|
238
|
+
}
|
|
239
|
+
if (this.ws) {
|
|
240
|
+
try {
|
|
241
|
+
this.ws.close(1e3, "bridge-stop");
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
this.ws = null;
|
|
245
|
+
}
|
|
246
|
+
if (this.mcp) await this.mcp.stop();
|
|
247
|
+
}
|
|
248
|
+
wsUrl() {
|
|
249
|
+
const base = this.opts.relayUrl.replace(/\/$/, "").replace(/^http/, "ws");
|
|
250
|
+
return `${base}/dev/${this.opts.devId}/connect`;
|
|
251
|
+
}
|
|
252
|
+
connect() {
|
|
253
|
+
if (this.stopped) return;
|
|
254
|
+
const WS = this.opts.websocketCtor ?? WebSocket;
|
|
255
|
+
const ws = new WS(this.wsUrl(), {
|
|
256
|
+
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
257
|
+
});
|
|
258
|
+
this.ws = ws;
|
|
259
|
+
ws.on("open", () => {
|
|
260
|
+
this.backoff.reset();
|
|
261
|
+
this.log.info("connected to relay");
|
|
262
|
+
const helloTools = this.toolCatalog ?? [];
|
|
263
|
+
try {
|
|
264
|
+
ws.send(encode({ kind: "hello", devId: this.opts.devId, version: "0.1.0", tools: helloTools }));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
this.log.warn("failed to send hello", err.message);
|
|
267
|
+
}
|
|
268
|
+
this.opts.onConnect?.();
|
|
269
|
+
});
|
|
270
|
+
ws.on("message", (data) => {
|
|
271
|
+
let frame;
|
|
272
|
+
try {
|
|
273
|
+
frame = decode(data);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.log.warn("malformed frame from relay", err.message);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (frame.kind === "rpc.request") void this.handleRpc(ws, frame);
|
|
279
|
+
});
|
|
280
|
+
ws.on("close", (code, reason) => {
|
|
281
|
+
const r = reason?.toString() ?? "";
|
|
282
|
+
this.log.warn(`relay disconnected code=${code} reason=${r}`);
|
|
283
|
+
this.opts.onDisconnect?.({ code, reason: r });
|
|
284
|
+
this.ws = null;
|
|
285
|
+
if (code === 4001) {
|
|
286
|
+
this.log.error("kicked by newer connection \u2014 exiting reconnect loop");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.scheduleReconnect();
|
|
290
|
+
});
|
|
291
|
+
ws.on("error", (err) => {
|
|
292
|
+
this.opts.onError?.(err);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
scheduleReconnect() {
|
|
296
|
+
if (this.stopped) return;
|
|
297
|
+
const delay = this.backoff.next();
|
|
298
|
+
this.log.info(`reconnect in ${delay}ms (attempt ${this.backoff.currentAttempt})`);
|
|
299
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
300
|
+
}
|
|
301
|
+
async handleRpc(ws, frame) {
|
|
302
|
+
if (!this.mcp) {
|
|
303
|
+
this.respond(ws, frame.id, 502, { error: "mcp child not running" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
let mcpResp;
|
|
308
|
+
if (frame.method === "list") {
|
|
309
|
+
mcpResp = await this.mcp.request("tools/list");
|
|
310
|
+
} else if (frame.method === "call") {
|
|
311
|
+
mcpResp = await this.mcp.request("tools/call", {
|
|
312
|
+
name: frame.path,
|
|
313
|
+
arguments: frame.body ?? {}
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
this.respond(ws, frame.id, 400, { error: `unsupported method ${frame.method}` });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (mcpResp.error) {
|
|
320
|
+
this.respond(ws, frame.id, 500, { error: mcpResp.error.message, code: mcpResp.error.code, data: mcpResp.error.data });
|
|
321
|
+
} else {
|
|
322
|
+
this.respond(ws, frame.id, 200, mcpResp.result ?? null);
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
this.respond(ws, frame.id, 500, { error: err.message });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
respond(ws, id, status, body) {
|
|
329
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
330
|
+
try {
|
|
331
|
+
ws.send(encode({ kind: "rpc.response", id, status, body }));
|
|
332
|
+
} catch (err) {
|
|
333
|
+
this.log.warn("failed to send rpc.response", err.message);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// src/cli.ts
|
|
339
|
+
function usage() {
|
|
340
|
+
console.error(`tag-mcp-bridge \u2014 proxy a local MCP server to TAG via reverse-WS
|
|
341
|
+
|
|
342
|
+
Usage:
|
|
343
|
+
tag-mcp-bridge --relay <url> --token <jwt> --mcp-cmd <cmd> [--dev-id <id>] [-- <mcp-args>]
|
|
344
|
+
|
|
345
|
+
Flags:
|
|
346
|
+
--relay <url> Relay base URL, e.g. https://tag-mcp-relay.example.com
|
|
347
|
+
Env: TAG_RELAY_URL
|
|
348
|
+
--token <jwt> Bridge JWT (sub=dev:<id>, scope=["bridge"])
|
|
349
|
+
Env: TAG_BRIDGE_TOKEN
|
|
350
|
+
--dev-id <id> Optional; derived from JWT subject if omitted.
|
|
351
|
+
--mcp-cmd <cmd> Shell-style command to spawn the MCP server.
|
|
352
|
+
Quoted form: --mcp-cmd "python -m tariff_tools.server"
|
|
353
|
+
Env: TAG_MCP_CMD
|
|
354
|
+
--mcp-env <k=v> Extra env var for the MCP child. Repeatable.
|
|
355
|
+
-- Everything after this is appended to the MCP child argv.
|
|
356
|
+
|
|
357
|
+
Reconnect: exponential backoff up to 30s. SIGINT to stop cleanly.
|
|
358
|
+
`);
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
function parseArgs(argv) {
|
|
362
|
+
const out = {
|
|
363
|
+
mcpArgs: [],
|
|
364
|
+
mcpEnv: {}
|
|
365
|
+
};
|
|
366
|
+
let mcpCmdLine = "";
|
|
367
|
+
let sawSep = false;
|
|
368
|
+
for (let i = 0; i < argv.length; i++) {
|
|
369
|
+
const a = argv[i];
|
|
370
|
+
if (sawSep) {
|
|
371
|
+
out.mcpArgs.push(a);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (a === "--") {
|
|
375
|
+
sawSep = true;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (a === "--help" || a === "-h") usage();
|
|
379
|
+
if (!a.startsWith("--")) {
|
|
380
|
+
console.error(`unexpected positional arg: ${a}`);
|
|
381
|
+
usage();
|
|
382
|
+
}
|
|
383
|
+
const eq = a.indexOf("=");
|
|
384
|
+
let key, value;
|
|
385
|
+
if (eq >= 0) {
|
|
386
|
+
key = a.slice(2, eq);
|
|
387
|
+
value = a.slice(eq + 1);
|
|
388
|
+
} else {
|
|
389
|
+
key = a.slice(2);
|
|
390
|
+
value = argv[++i] ?? "";
|
|
391
|
+
}
|
|
392
|
+
switch (key) {
|
|
393
|
+
case "relay":
|
|
394
|
+
out.relayUrl = value;
|
|
395
|
+
break;
|
|
396
|
+
case "token":
|
|
397
|
+
out.token = value;
|
|
398
|
+
break;
|
|
399
|
+
case "dev-id":
|
|
400
|
+
out.devId = value;
|
|
401
|
+
break;
|
|
402
|
+
case "mcp-cmd":
|
|
403
|
+
mcpCmdLine = value;
|
|
404
|
+
break;
|
|
405
|
+
case "mcp-env": {
|
|
406
|
+
const [k, ...rest] = value.split("=");
|
|
407
|
+
if (k) out.mcpEnv[k] = rest.join("=");
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
default:
|
|
411
|
+
console.error(`unknown flag: --${key}`);
|
|
412
|
+
usage();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
out.relayUrl = out.relayUrl ?? process.env.TAG_RELAY_URL;
|
|
416
|
+
out.token = out.token ?? process.env.TAG_BRIDGE_TOKEN;
|
|
417
|
+
mcpCmdLine = mcpCmdLine || process.env.TAG_MCP_CMD || "";
|
|
418
|
+
if (!out.relayUrl || !out.token || !mcpCmdLine) {
|
|
419
|
+
console.error("missing required: --relay / --token / --mcp-cmd");
|
|
420
|
+
usage();
|
|
421
|
+
}
|
|
422
|
+
if (!out.devId) {
|
|
423
|
+
out.devId = decodeDevIdFromToken(out.token);
|
|
424
|
+
}
|
|
425
|
+
const parts = mcpCmdLine.trim().split(/\s+/);
|
|
426
|
+
const command = parts[0];
|
|
427
|
+
const args = parts.slice(1).concat(out.mcpArgs);
|
|
428
|
+
return {
|
|
429
|
+
relayUrl: out.relayUrl,
|
|
430
|
+
token: out.token,
|
|
431
|
+
devId: out.devId,
|
|
432
|
+
mcpCommand: command,
|
|
433
|
+
mcpArgs: args,
|
|
434
|
+
mcpEnv: out.mcpEnv
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
function decodeDevIdFromToken(token) {
|
|
438
|
+
const parts = token.split(".");
|
|
439
|
+
if (parts.length < 2) throw new Error("invalid token: cannot derive dev id");
|
|
440
|
+
try {
|
|
441
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
442
|
+
if (!payload.sub?.startsWith("dev:")) throw new Error("token sub missing dev: prefix");
|
|
443
|
+
return payload.sub.slice("dev:".length);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
throw new Error(`cannot derive dev id from token: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
async function main() {
|
|
449
|
+
const args = parseArgs(process.argv.slice(2));
|
|
450
|
+
if (!args.token) throw new Error("--token required (or set TAG_BRIDGE_TOKEN)");
|
|
451
|
+
if (!args.devId) throw new Error("--dev-id required (or derivable from --token)");
|
|
452
|
+
console.log(`[tag-mcp-bridge] dev=${args.devId} relay=${args.relayUrl} mcp="${args.mcpCommand} ${args.mcpArgs.join(" ")}"`);
|
|
453
|
+
const bridge = new Bridge({
|
|
454
|
+
relayUrl: args.relayUrl,
|
|
455
|
+
token: args.token,
|
|
456
|
+
devId: args.devId,
|
|
457
|
+
mcpCommand: args.mcpCommand,
|
|
458
|
+
mcpArgs: args.mcpArgs,
|
|
459
|
+
mcpEnv: args.mcpEnv
|
|
460
|
+
});
|
|
461
|
+
const shutdown = async (sig) => {
|
|
462
|
+
console.log(`[tag-mcp-bridge] received ${sig}, shutting down`);
|
|
463
|
+
await bridge.stop();
|
|
464
|
+
process.exit(0);
|
|
465
|
+
};
|
|
466
|
+
process.on("SIGINT", shutdown);
|
|
467
|
+
process.on("SIGTERM", shutdown);
|
|
468
|
+
await bridge.start();
|
|
469
|
+
}
|
|
470
|
+
var isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
471
|
+
if (isMain) {
|
|
472
|
+
main().catch((err) => {
|
|
473
|
+
console.error("[tag-mcp-bridge] fatal:", err);
|
|
474
|
+
process.exit(1);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
export {
|
|
478
|
+
parseArgs
|
|
479
|
+
};
|
|
480
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/bridge.ts","../src/backoff.ts","../src/protocol.ts","../src/mcp-stdio.ts","../src/cli.ts"],"sourcesContent":["// Bridge runtime: maintains a WebSocket to the relay, spawns the dev's\n// MCP child once, and translates relay RPC envelopes ↔ MCP JSON-RPC.\n//\n// Single-process design:\n// * one MCP child for the lifetime of the bridge (re-using its session\n// across relay reconnects — devs configure tools that may carry state).\n// * one WS connection to the relay at a time. On error/close we reconnect\n// with exponential backoff.\n// * each inbound rpc.request becomes one MCP request (`tools/list` or\n// `tools/call`). We synthesize a clean response envelope and forward.\n\nimport WebSocket from 'ws';\nimport { Backoff } from './backoff.js';\nimport { decode, encode, type Frame, type RpcRequestFrame } from './protocol.js';\nimport { McpStdioClient, type McpMessage } from './mcp-stdio.js';\n\nexport interface BridgeOptions {\n /** Relay base URL (without /dev/...). Trailing slash optional. */\n relayUrl: string;\n /** Bridge JWT — `dev:<id>` subject + scope=[\"bridge\"]. */\n token: string;\n /** Dev id; must match the JWT subject. */\n devId: string;\n /** Command to spawn for the MCP child. */\n mcpCommand: string;\n mcpArgs?: string[];\n mcpEnv?: Record<string, string>;\n /** Override for tests. */\n websocketCtor?: typeof WebSocket;\n /** Override backoff knobs. */\n backoff?: { base?: number; cap?: number; random?: () => number };\n /** Hook for tests + observability. */\n onConnect?: () => void;\n onDisconnect?: (info: { code: number; reason: string }) => void;\n onError?: (err: Error) => void;\n logger?: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void; error: (...a: unknown[]) => void };\n}\n\ninterface ToolDescriptor {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport class Bridge {\n private ws: WebSocket | null = null;\n private backoff: Backoff;\n private mcp: McpStdioClient | null = null;\n private toolCatalog: ToolDescriptor[] | null = null;\n private stopped = false;\n private reconnectTimer: NodeJS.Timeout | null = null;\n private log: NonNullable<BridgeOptions['logger']>;\n\n constructor(private readonly opts: BridgeOptions) {\n this.backoff = new Backoff(opts.backoff);\n this.log = opts.logger ?? {\n info: (...a) => console.log('[bridge]', ...a),\n warn: (...a) => console.warn('[bridge]', ...a),\n error: (...a) => console.error('[bridge]', ...a),\n };\n }\n\n /** Start the MCP child + connect to relay. Returns once initial connect resolves OR backoff begins. */\n async start(): Promise<void> {\n if (this.mcp) throw new Error('already started');\n this.mcp = new McpStdioClient({\n command: this.opts.mcpCommand,\n args: this.opts.mcpArgs,\n env: this.opts.mcpEnv,\n });\n this.mcp.on('stderr', (s) => this.log.warn('mcp child stderr:', s.trim()));\n this.mcp.on('exit', (info) => this.log.error('mcp child exited', info));\n this.mcp.on('parseError', (line) => this.log.warn('mcp parse error', line));\n this.mcp.start();\n\n // Snapshot the tool catalog once so we can publish it on the `hello` frame.\n try {\n const resp = await this.mcp.request('tools/list');\n if (resp.result && typeof resp.result === 'object' && 'tools' in (resp.result as Record<string, unknown>)) {\n this.toolCatalog = ((resp.result as { tools: ToolDescriptor[] }).tools) ?? [];\n } else {\n this.toolCatalog = [];\n }\n } catch (err) {\n // Non-fatal: relay will fall back to live /list calls on demand.\n this.log.warn('initial tools/list failed; relay will use live RPC', (err as Error).message);\n }\n\n this.connect();\n }\n\n /** Stop the bridge: close WS, kill child, cancel reconnect. */\n async stop(): Promise<void> {\n this.stopped = true;\n if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }\n if (this.ws) {\n try { this.ws.close(1000, 'bridge-stop'); } catch { /* ignore */ }\n this.ws = null;\n }\n if (this.mcp) await this.mcp.stop();\n }\n\n private wsUrl(): string {\n const base = this.opts.relayUrl.replace(/\\/$/, '').replace(/^http/, 'ws');\n return `${base}/dev/${this.opts.devId}/connect`;\n }\n\n private connect(): void {\n if (this.stopped) return;\n const WS = this.opts.websocketCtor ?? WebSocket;\n const ws = new WS(this.wsUrl(), {\n headers: { Authorization: `Bearer ${this.opts.token}` },\n });\n this.ws = ws;\n\n ws.on('open', () => {\n this.backoff.reset();\n this.log.info('connected to relay');\n const helloTools = this.toolCatalog ?? [];\n try {\n ws.send(encode({ kind: 'hello', devId: this.opts.devId, version: '0.1.0', tools: helloTools }));\n } catch (err) {\n this.log.warn('failed to send hello', (err as Error).message);\n }\n this.opts.onConnect?.();\n });\n\n ws.on('message', (data) => {\n let frame: Frame;\n try { frame = decode(data as Buffer); }\n catch (err) { this.log.warn('malformed frame from relay', (err as Error).message); return; }\n if (frame.kind === 'rpc.request') void this.handleRpc(ws, frame);\n });\n\n ws.on('close', (code, reason) => {\n const r = reason?.toString() ?? '';\n this.log.warn(`relay disconnected code=${code} reason=${r}`);\n this.opts.onDisconnect?.({ code, reason: r });\n this.ws = null;\n // Code 4001 = relay says we were kicked because a fresher bridge took\n // over our slot. In that case it's almost always wrong for us to\n // reconnect — we'd just kick the new one. Surface and stop.\n if (code === 4001) {\n this.log.error('kicked by newer connection — exiting reconnect loop');\n return;\n }\n this.scheduleReconnect();\n });\n\n ws.on('error', (err) => {\n this.opts.onError?.(err);\n // close event fires after error; reconnect handling lives there.\n });\n }\n\n private scheduleReconnect(): void {\n if (this.stopped) return;\n const delay = this.backoff.next();\n this.log.info(`reconnect in ${delay}ms (attempt ${this.backoff.currentAttempt})`);\n this.reconnectTimer = setTimeout(() => this.connect(), delay);\n }\n\n private async handleRpc(ws: WebSocket, frame: RpcRequestFrame): Promise<void> {\n if (!this.mcp) {\n this.respond(ws, frame.id, 502, { error: 'mcp child not running' });\n return;\n }\n try {\n let mcpResp: McpMessage;\n if (frame.method === 'list') {\n mcpResp = await this.mcp.request('tools/list');\n } else if (frame.method === 'call') {\n mcpResp = await this.mcp.request('tools/call', {\n name: frame.path,\n arguments: (frame.body as Record<string, unknown>) ?? {},\n });\n } else {\n this.respond(ws, frame.id, 400, { error: `unsupported method ${(frame as { method: string }).method}` });\n return;\n }\n if (mcpResp.error) {\n this.respond(ws, frame.id, 500, { error: mcpResp.error.message, code: mcpResp.error.code, data: mcpResp.error.data });\n } else {\n this.respond(ws, frame.id, 200, mcpResp.result ?? null);\n }\n } catch (err) {\n this.respond(ws, frame.id, 500, { error: (err as Error).message });\n }\n }\n\n private respond(ws: WebSocket, id: string, status: number, body: unknown): void {\n if (ws.readyState !== WebSocket.OPEN) return;\n try {\n ws.send(encode({ kind: 'rpc.response', id, status, body }));\n } catch (err) {\n this.log.warn('failed to send rpc.response', (err as Error).message);\n }\n }\n}\n","// Exponential backoff with full jitter, capped.\n//\n// Reconnect schedule for transient WS errors:\n// attempt 0 → 500ms\n// attempt 1 → 1000ms\n// attempt 2 → 2000ms\n// ...\n// capped at 30_000ms (per spec).\n//\n// Full jitter (sleep ∈ [0, exp_delay]) avoids reconnect storms when the\n// relay reboots and N bridges all wake at once.\n\nexport interface BackoffOpts {\n base?: number;\n cap?: number;\n /** Override RNG for deterministic tests. */\n random?: () => number;\n}\n\nexport class Backoff {\n private attempt = 0;\n private base: number;\n private cap: number;\n private random: () => number;\n\n constructor(opts: BackoffOpts = {}) {\n this.base = opts.base ?? 500;\n this.cap = opts.cap ?? 30_000;\n this.random = opts.random ?? Math.random;\n }\n\n reset(): void { this.attempt = 0; }\n\n /** Return the next delay (ms) and advance attempt counter. */\n next(): number {\n const exp = Math.min(this.cap, this.base * 2 ** this.attempt);\n this.attempt += 1;\n return Math.floor(this.random() * exp);\n }\n\n /** Test introspection: current attempt count (0 after reset). */\n get currentAttempt(): number { return this.attempt; }\n}\n","// Wire protocol shared with apps/mcp-relay. Duplicated locally (not imported\n// from the relay package) so this CLI can be `npm install -g`'d on a dev\n// laptop without pulling the relay's runtime deps. The schema is stable —\n// any change to the protocol must land in both files in the same PR.\n//\n// See apps/mcp-relay/src/protocol.ts for the canonical doc-comments.\n\nexport type RpcRequestFrame = {\n kind: 'rpc.request';\n id: string;\n method: 'list' | 'call';\n path: string;\n body?: unknown;\n};\n\nexport type RpcResponseFrame = {\n kind: 'rpc.response';\n id: string;\n status: number;\n body?: unknown;\n};\n\nexport type HelloFrame = {\n kind: 'hello';\n devId: string;\n version: string;\n tools?: unknown;\n};\n\nexport type Frame =\n | RpcRequestFrame\n | RpcResponseFrame\n | HelloFrame\n | { kind: 'ping' }\n | { kind: 'pong' };\n\nexport function encode(frame: Frame): string {\n return JSON.stringify(frame);\n}\n\nexport function decode(raw: string | Buffer): Frame {\n const text = typeof raw === 'string' ? raw : raw.toString('utf-8');\n const parsed = JSON.parse(text) as Frame;\n if (!parsed || typeof parsed !== 'object' || !('kind' in parsed)) {\n throw new Error('invalid frame: missing kind');\n }\n return parsed;\n}\n","// MCP-over-stdio client: spawns the dev's MCP server (e.g. `tariff-tools\n// serve` or `python -m tariff_tools.server`) and speaks line-delimited\n// JSON-RPC 2.0 to it.\n//\n// MCP's standard stdio transport uses LSP-style \"Content-Length\" framing.\n// We support BOTH framings:\n// * newline-delimited JSON (one JSON object per line) — common for\n// hand-rolled servers + simpler to test.\n// * Content-Length-prefixed JSON — what the MCP reference server emits.\n//\n// The bridge auto-detects which mode the child is using by sniffing the\n// first 16 bytes of output. If they start with \"Content-Length:\" we use\n// LSP framing; otherwise we use newline-delimited.\n\nimport { spawn, type ChildProcessWithoutNullStreams } from 'child_process';\nimport { EventEmitter } from 'events';\n\nexport type McpMessage = {\n jsonrpc: '2.0';\n id?: string | number;\n method?: string;\n params?: unknown;\n result?: unknown;\n error?: { code: number; message: string; data?: unknown };\n};\n\ntype Framing = 'newline' | 'lsp' | 'unknown';\n\nexport interface McpStdioOptions {\n command: string;\n args?: string[];\n env?: Record<string, string>;\n cwd?: string;\n}\n\nexport class McpStdioClient extends EventEmitter {\n private proc: ChildProcessWithoutNullStreams | null = null;\n private framing: Framing = 'unknown';\n private buf = Buffer.alloc(0);\n private nextId = 1;\n private pending = new Map<string | number, (msg: McpMessage) => void>();\n private closed = false;\n\n constructor(private readonly opts: McpStdioOptions) {\n super();\n }\n\n start(): void {\n if (this.proc) throw new Error('already started');\n const proc = spawn(this.opts.command, this.opts.args ?? [], {\n env: { ...process.env, ...this.opts.env },\n cwd: this.opts.cwd,\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n this.proc = proc;\n proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk));\n proc.stderr.on('data', (chunk: Buffer) => {\n this.emit('stderr', chunk.toString('utf-8'));\n });\n proc.on('exit', (code, signal) => {\n this.closed = true;\n this.emit('exit', { code, signal });\n // Surface a synthetic error to any pending requests.\n for (const [, resolve] of this.pending) {\n resolve({\n jsonrpc: '2.0',\n error: { code: -32000, message: `mcp child exited code=${code} signal=${signal}` },\n });\n }\n this.pending.clear();\n });\n proc.on('error', (err) => {\n this.emit('error', err);\n });\n }\n\n async stop(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {\n if (!this.proc || this.closed) return;\n return new Promise((resolve) => {\n this.proc!.once('exit', () => resolve());\n try { this.proc!.kill(signal); } catch { /* ignore */ }\n // hard-kill after 2s if it ignores SIGTERM\n setTimeout(() => { try { this.proc?.kill('SIGKILL'); } catch { /* ignore */ } }, 2_000).unref();\n });\n }\n\n /**\n * Send a JSON-RPC request to the MCP child and await its response.\n * Auto-generates an id if the caller doesn't supply one.\n */\n request(method: string, params?: unknown): Promise<McpMessage> {\n if (!this.proc) throw new Error('not started');\n const id = this.nextId++;\n const msg: McpMessage = { jsonrpc: '2.0', id, method, params };\n return new Promise((resolve) => {\n this.pending.set(id, resolve);\n this.write(msg);\n });\n }\n\n /** Send an MCP notification (no response expected). */\n notify(method: string, params?: unknown): void {\n if (!this.proc) throw new Error('not started');\n this.write({ jsonrpc: '2.0', method, params });\n }\n\n private write(msg: McpMessage): void {\n const json = JSON.stringify(msg);\n if (this.framing === 'lsp') {\n const body = Buffer.from(json, 'utf-8');\n this.proc!.stdin.write(`Content-Length: ${body.length}\\r\\n\\r\\n`);\n this.proc!.stdin.write(body);\n } else {\n // newline framing (default for unknown until we observe LSP from child)\n this.proc!.stdin.write(json + '\\n');\n }\n }\n\n private onStdout(chunk: Buffer): void {\n this.buf = Buffer.concat([this.buf, chunk]);\n\n if (this.framing === 'unknown') {\n // Sniff: LSP framing always starts with \"Content-Length:\" header.\n const peek = this.buf.subarray(0, Math.min(16, this.buf.length)).toString('utf-8');\n if (peek.startsWith('Content-Length:')) this.framing = 'lsp';\n else if (this.buf.includes(0x0a)) this.framing = 'newline';\n }\n\n if (this.framing === 'lsp') this.drainLsp();\n else if (this.framing === 'newline') this.drainNewline();\n }\n\n private drainNewline(): void {\n while (true) {\n const nl = this.buf.indexOf(0x0a);\n if (nl < 0) return;\n const line = this.buf.subarray(0, nl).toString('utf-8').trim();\n this.buf = this.buf.subarray(nl + 1);\n if (!line) continue;\n this.dispatchLine(line);\n }\n }\n\n private drainLsp(): void {\n while (true) {\n const headerEnd = this.buf.indexOf('\\r\\n\\r\\n');\n if (headerEnd < 0) return;\n const headers = this.buf.subarray(0, headerEnd).toString('utf-8');\n const m = /Content-Length:\\s*(\\d+)/i.exec(headers);\n if (!m) {\n // garbled headers — skip this chunk\n this.buf = this.buf.subarray(headerEnd + 4);\n continue;\n }\n const len = Number(m[1]);\n const start = headerEnd + 4;\n if (this.buf.length < start + len) return; // wait for more\n const body = this.buf.subarray(start, start + len).toString('utf-8');\n this.buf = this.buf.subarray(start + len);\n this.dispatchLine(body);\n }\n }\n\n private dispatchLine(line: string): void {\n let msg: McpMessage;\n try { msg = JSON.parse(line); }\n catch {\n this.emit('parseError', line);\n return;\n }\n if (msg.id !== undefined && this.pending.has(msg.id)) {\n const cb = this.pending.get(msg.id)!;\n this.pending.delete(msg.id);\n cb(msg);\n } else {\n // server-initiated notification or unmatched response\n this.emit('message', msg);\n }\n }\n}\n","// tag-mcp-bridge CLI entrypoint.\n//\n// Example:\n// tag-mcp-bridge \\\n// --relay https://tag-mcp-relay.example.com \\\n// --token \"$TAG_BRIDGE_JWT\" \\\n// --dev-id alice \\\n// --mcp-cmd \"python -m tariff_tools.server\"\n//\n// The dev-id is decoded from the JWT subject (`dev:<id>`) if not passed.\n\nimport { Bridge } from './bridge.js';\n\ninterface CliArgs {\n relayUrl: string;\n token: string;\n devId?: string;\n mcpCommand: string;\n mcpArgs: string[];\n mcpEnv: Record<string, string>;\n}\n\nfunction usage(): never {\n // eslint-disable-next-line no-console\n console.error(`tag-mcp-bridge — proxy a local MCP server to TAG via reverse-WS\n\nUsage:\n tag-mcp-bridge --relay <url> --token <jwt> --mcp-cmd <cmd> [--dev-id <id>] [-- <mcp-args>]\n\nFlags:\n --relay <url> Relay base URL, e.g. https://tag-mcp-relay.example.com\n Env: TAG_RELAY_URL\n --token <jwt> Bridge JWT (sub=dev:<id>, scope=[\"bridge\"])\n Env: TAG_BRIDGE_TOKEN\n --dev-id <id> Optional; derived from JWT subject if omitted.\n --mcp-cmd <cmd> Shell-style command to spawn the MCP server.\n Quoted form: --mcp-cmd \"python -m tariff_tools.server\"\n Env: TAG_MCP_CMD\n --mcp-env <k=v> Extra env var for the MCP child. Repeatable.\n -- Everything after this is appended to the MCP child argv.\n\nReconnect: exponential backoff up to 30s. SIGINT to stop cleanly.\n`);\n process.exit(1);\n}\n\nfunction parseArgs(argv: string[]): CliArgs {\n const out: Partial<CliArgs> & { mcpArgs: string[]; mcpEnv: Record<string, string> } = {\n mcpArgs: [],\n mcpEnv: {},\n };\n let mcpCmdLine = '';\n let sawSep = false;\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i]!;\n if (sawSep) { out.mcpArgs.push(a); continue; }\n if (a === '--') { sawSep = true; continue; }\n if (a === '--help' || a === '-h') usage();\n if (!a.startsWith('--')) {\n // eslint-disable-next-line no-console\n console.error(`unexpected positional arg: ${a}`);\n usage();\n }\n const eq = a.indexOf('=');\n let key: string, value: string;\n if (eq >= 0) {\n key = a.slice(2, eq);\n value = a.slice(eq + 1);\n } else {\n key = a.slice(2);\n value = argv[++i] ?? '';\n }\n switch (key) {\n case 'relay': out.relayUrl = value; break;\n case 'token': out.token = value; break;\n case 'dev-id': out.devId = value; break;\n case 'mcp-cmd': mcpCmdLine = value; break;\n case 'mcp-env': {\n const [k, ...rest] = value.split('=');\n if (k) out.mcpEnv[k] = rest.join('=');\n break;\n }\n default:\n // eslint-disable-next-line no-console\n console.error(`unknown flag: --${key}`);\n usage();\n }\n }\n\n out.relayUrl = out.relayUrl ?? process.env.TAG_RELAY_URL;\n out.token = out.token ?? process.env.TAG_BRIDGE_TOKEN;\n mcpCmdLine = mcpCmdLine || process.env.TAG_MCP_CMD || '';\n\n if (!out.relayUrl || !out.token || !mcpCmdLine) {\n // eslint-disable-next-line no-console\n console.error('missing required: --relay / --token / --mcp-cmd');\n usage();\n }\n\n if (!out.devId) {\n out.devId = decodeDevIdFromToken(out.token);\n }\n\n // Split mcp-cmd on whitespace (simple — no shell quoting). Anything\n // requiring quotes should use `-- arg arg` after the rest of the flags.\n const parts = mcpCmdLine.trim().split(/\\s+/);\n const command = parts[0]!;\n const args = parts.slice(1).concat(out.mcpArgs);\n\n return {\n relayUrl: out.relayUrl,\n token: out.token,\n devId: out.devId,\n mcpCommand: command,\n mcpArgs: args,\n mcpEnv: out.mcpEnv,\n };\n}\n\n/** Decode the `sub` claim of a JWT without verifying — we just want devId. */\nfunction decodeDevIdFromToken(token: string): string {\n const parts = token.split('.');\n if (parts.length < 2) throw new Error('invalid token: cannot derive dev id');\n try {\n const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf-8')) as { sub?: string };\n if (!payload.sub?.startsWith('dev:')) throw new Error('token sub missing dev: prefix');\n return payload.sub.slice('dev:'.length);\n } catch (err) {\n throw new Error(`cannot derive dev id from token: ${(err as Error).message}`);\n }\n}\n\nasync function main(): Promise<void> {\n const args = parseArgs(process.argv.slice(2));\n // parseArgs guarantees these are set (calls usage()→never otherwise),\n // but the inferred return type lets `token`/`devId` widen to\n // `string | undefined` because the underlying `out` is `Partial<CliArgs>`.\n // Guard explicitly here so the Bridge constructor's strict types hold.\n if (!args.token) throw new Error('--token required (or set TAG_BRIDGE_TOKEN)');\n if (!args.devId) throw new Error('--dev-id required (or derivable from --token)');\n // eslint-disable-next-line no-console\n console.log(`[tag-mcp-bridge] dev=${args.devId} relay=${args.relayUrl} mcp=\"${args.mcpCommand} ${args.mcpArgs.join(' ')}\"`);\n const bridge = new Bridge({\n relayUrl: args.relayUrl,\n token: args.token,\n devId: args.devId,\n mcpCommand: args.mcpCommand,\n mcpArgs: args.mcpArgs,\n mcpEnv: args.mcpEnv,\n });\n const shutdown = async (sig: NodeJS.Signals) => {\n // eslint-disable-next-line no-console\n console.log(`[tag-mcp-bridge] received ${sig}, shutting down`);\n await bridge.stop();\n process.exit(0);\n };\n process.on('SIGINT', shutdown);\n process.on('SIGTERM', shutdown);\n await bridge.start();\n}\n\n// Run only when invoked directly (not when imported by tests).\nconst isMain = import.meta.url === `file://${process.argv[1]}`;\nif (isMain) {\n main().catch((err) => {\n // eslint-disable-next-line no-console\n console.error('[tag-mcp-bridge] fatal:', err);\n process.exit(1);\n });\n}\n\nexport { parseArgs };\n"],"mappings":"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","names":[]}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bridge.ts
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
|
|
6
|
+
// src/backoff.ts
|
|
7
|
+
var Backoff = class {
|
|
8
|
+
attempt = 0;
|
|
9
|
+
base;
|
|
10
|
+
cap;
|
|
11
|
+
random;
|
|
12
|
+
constructor(opts = {}) {
|
|
13
|
+
this.base = opts.base ?? 500;
|
|
14
|
+
this.cap = opts.cap ?? 3e4;
|
|
15
|
+
this.random = opts.random ?? Math.random;
|
|
16
|
+
}
|
|
17
|
+
reset() {
|
|
18
|
+
this.attempt = 0;
|
|
19
|
+
}
|
|
20
|
+
/** Return the next delay (ms) and advance attempt counter. */
|
|
21
|
+
next() {
|
|
22
|
+
const exp = Math.min(this.cap, this.base * 2 ** this.attempt);
|
|
23
|
+
this.attempt += 1;
|
|
24
|
+
return Math.floor(this.random() * exp);
|
|
25
|
+
}
|
|
26
|
+
/** Test introspection: current attempt count (0 after reset). */
|
|
27
|
+
get currentAttempt() {
|
|
28
|
+
return this.attempt;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/protocol.ts
|
|
33
|
+
function encode(frame) {
|
|
34
|
+
return JSON.stringify(frame);
|
|
35
|
+
}
|
|
36
|
+
function decode(raw) {
|
|
37
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf-8");
|
|
38
|
+
const parsed = JSON.parse(text);
|
|
39
|
+
if (!parsed || typeof parsed !== "object" || !("kind" in parsed)) {
|
|
40
|
+
throw new Error("invalid frame: missing kind");
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/mcp-stdio.ts
|
|
46
|
+
import { spawn } from "child_process";
|
|
47
|
+
import { EventEmitter } from "events";
|
|
48
|
+
var McpStdioClient = class extends EventEmitter {
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
super();
|
|
51
|
+
this.opts = opts;
|
|
52
|
+
}
|
|
53
|
+
opts;
|
|
54
|
+
proc = null;
|
|
55
|
+
framing = "unknown";
|
|
56
|
+
buf = Buffer.alloc(0);
|
|
57
|
+
nextId = 1;
|
|
58
|
+
pending = /* @__PURE__ */ new Map();
|
|
59
|
+
closed = false;
|
|
60
|
+
start() {
|
|
61
|
+
if (this.proc) throw new Error("already started");
|
|
62
|
+
const proc = spawn(this.opts.command, this.opts.args ?? [], {
|
|
63
|
+
env: { ...process.env, ...this.opts.env },
|
|
64
|
+
cwd: this.opts.cwd,
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
66
|
+
});
|
|
67
|
+
this.proc = proc;
|
|
68
|
+
proc.stdout.on("data", (chunk) => this.onStdout(chunk));
|
|
69
|
+
proc.stderr.on("data", (chunk) => {
|
|
70
|
+
this.emit("stderr", chunk.toString("utf-8"));
|
|
71
|
+
});
|
|
72
|
+
proc.on("exit", (code, signal) => {
|
|
73
|
+
this.closed = true;
|
|
74
|
+
this.emit("exit", { code, signal });
|
|
75
|
+
for (const [, resolve] of this.pending) {
|
|
76
|
+
resolve({
|
|
77
|
+
jsonrpc: "2.0",
|
|
78
|
+
error: { code: -32e3, message: `mcp child exited code=${code} signal=${signal}` }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
this.pending.clear();
|
|
82
|
+
});
|
|
83
|
+
proc.on("error", (err) => {
|
|
84
|
+
this.emit("error", err);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async stop(signal = "SIGTERM") {
|
|
88
|
+
if (!this.proc || this.closed) return;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
this.proc.once("exit", () => resolve());
|
|
91
|
+
try {
|
|
92
|
+
this.proc.kill(signal);
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
try {
|
|
97
|
+
this.proc?.kill("SIGKILL");
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}, 2e3).unref();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Send a JSON-RPC request to the MCP child and await its response.
|
|
105
|
+
* Auto-generates an id if the caller doesn't supply one.
|
|
106
|
+
*/
|
|
107
|
+
request(method, params) {
|
|
108
|
+
if (!this.proc) throw new Error("not started");
|
|
109
|
+
const id = this.nextId++;
|
|
110
|
+
const msg = { jsonrpc: "2.0", id, method, params };
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
this.pending.set(id, resolve);
|
|
113
|
+
this.write(msg);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Send an MCP notification (no response expected). */
|
|
117
|
+
notify(method, params) {
|
|
118
|
+
if (!this.proc) throw new Error("not started");
|
|
119
|
+
this.write({ jsonrpc: "2.0", method, params });
|
|
120
|
+
}
|
|
121
|
+
write(msg) {
|
|
122
|
+
const json = JSON.stringify(msg);
|
|
123
|
+
if (this.framing === "lsp") {
|
|
124
|
+
const body = Buffer.from(json, "utf-8");
|
|
125
|
+
this.proc.stdin.write(`Content-Length: ${body.length}\r
|
|
126
|
+
\r
|
|
127
|
+
`);
|
|
128
|
+
this.proc.stdin.write(body);
|
|
129
|
+
} else {
|
|
130
|
+
this.proc.stdin.write(json + "\n");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
onStdout(chunk) {
|
|
134
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
135
|
+
if (this.framing === "unknown") {
|
|
136
|
+
const peek = this.buf.subarray(0, Math.min(16, this.buf.length)).toString("utf-8");
|
|
137
|
+
if (peek.startsWith("Content-Length:")) this.framing = "lsp";
|
|
138
|
+
else if (this.buf.includes(10)) this.framing = "newline";
|
|
139
|
+
}
|
|
140
|
+
if (this.framing === "lsp") this.drainLsp();
|
|
141
|
+
else if (this.framing === "newline") this.drainNewline();
|
|
142
|
+
}
|
|
143
|
+
drainNewline() {
|
|
144
|
+
while (true) {
|
|
145
|
+
const nl = this.buf.indexOf(10);
|
|
146
|
+
if (nl < 0) return;
|
|
147
|
+
const line = this.buf.subarray(0, nl).toString("utf-8").trim();
|
|
148
|
+
this.buf = this.buf.subarray(nl + 1);
|
|
149
|
+
if (!line) continue;
|
|
150
|
+
this.dispatchLine(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
drainLsp() {
|
|
154
|
+
while (true) {
|
|
155
|
+
const headerEnd = this.buf.indexOf("\r\n\r\n");
|
|
156
|
+
if (headerEnd < 0) return;
|
|
157
|
+
const headers = this.buf.subarray(0, headerEnd).toString("utf-8");
|
|
158
|
+
const m = /Content-Length:\s*(\d+)/i.exec(headers);
|
|
159
|
+
if (!m) {
|
|
160
|
+
this.buf = this.buf.subarray(headerEnd + 4);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const len = Number(m[1]);
|
|
164
|
+
const start = headerEnd + 4;
|
|
165
|
+
if (this.buf.length < start + len) return;
|
|
166
|
+
const body = this.buf.subarray(start, start + len).toString("utf-8");
|
|
167
|
+
this.buf = this.buf.subarray(start + len);
|
|
168
|
+
this.dispatchLine(body);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
dispatchLine(line) {
|
|
172
|
+
let msg;
|
|
173
|
+
try {
|
|
174
|
+
msg = JSON.parse(line);
|
|
175
|
+
} catch {
|
|
176
|
+
this.emit("parseError", line);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (msg.id !== void 0 && this.pending.has(msg.id)) {
|
|
180
|
+
const cb = this.pending.get(msg.id);
|
|
181
|
+
this.pending.delete(msg.id);
|
|
182
|
+
cb(msg);
|
|
183
|
+
} else {
|
|
184
|
+
this.emit("message", msg);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/bridge.ts
|
|
190
|
+
var Bridge = class {
|
|
191
|
+
constructor(opts) {
|
|
192
|
+
this.opts = opts;
|
|
193
|
+
this.backoff = new Backoff(opts.backoff);
|
|
194
|
+
this.log = opts.logger ?? {
|
|
195
|
+
info: (...a) => console.log("[bridge]", ...a),
|
|
196
|
+
warn: (...a) => console.warn("[bridge]", ...a),
|
|
197
|
+
error: (...a) => console.error("[bridge]", ...a)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
opts;
|
|
201
|
+
ws = null;
|
|
202
|
+
backoff;
|
|
203
|
+
mcp = null;
|
|
204
|
+
toolCatalog = null;
|
|
205
|
+
stopped = false;
|
|
206
|
+
reconnectTimer = null;
|
|
207
|
+
log;
|
|
208
|
+
/** Start the MCP child + connect to relay. Returns once initial connect resolves OR backoff begins. */
|
|
209
|
+
async start() {
|
|
210
|
+
if (this.mcp) throw new Error("already started");
|
|
211
|
+
this.mcp = new McpStdioClient({
|
|
212
|
+
command: this.opts.mcpCommand,
|
|
213
|
+
args: this.opts.mcpArgs,
|
|
214
|
+
env: this.opts.mcpEnv
|
|
215
|
+
});
|
|
216
|
+
this.mcp.on("stderr", (s) => this.log.warn("mcp child stderr:", s.trim()));
|
|
217
|
+
this.mcp.on("exit", (info) => this.log.error("mcp child exited", info));
|
|
218
|
+
this.mcp.on("parseError", (line) => this.log.warn("mcp parse error", line));
|
|
219
|
+
this.mcp.start();
|
|
220
|
+
try {
|
|
221
|
+
const resp = await this.mcp.request("tools/list");
|
|
222
|
+
if (resp.result && typeof resp.result === "object" && "tools" in resp.result) {
|
|
223
|
+
this.toolCatalog = resp.result.tools ?? [];
|
|
224
|
+
} else {
|
|
225
|
+
this.toolCatalog = [];
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
this.log.warn("initial tools/list failed; relay will use live RPC", err.message);
|
|
229
|
+
}
|
|
230
|
+
this.connect();
|
|
231
|
+
}
|
|
232
|
+
/** Stop the bridge: close WS, kill child, cancel reconnect. */
|
|
233
|
+
async stop() {
|
|
234
|
+
this.stopped = true;
|
|
235
|
+
if (this.reconnectTimer) {
|
|
236
|
+
clearTimeout(this.reconnectTimer);
|
|
237
|
+
this.reconnectTimer = null;
|
|
238
|
+
}
|
|
239
|
+
if (this.ws) {
|
|
240
|
+
try {
|
|
241
|
+
this.ws.close(1e3, "bridge-stop");
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
this.ws = null;
|
|
245
|
+
}
|
|
246
|
+
if (this.mcp) await this.mcp.stop();
|
|
247
|
+
}
|
|
248
|
+
wsUrl() {
|
|
249
|
+
const base = this.opts.relayUrl.replace(/\/$/, "").replace(/^http/, "ws");
|
|
250
|
+
return `${base}/dev/${this.opts.devId}/connect`;
|
|
251
|
+
}
|
|
252
|
+
connect() {
|
|
253
|
+
if (this.stopped) return;
|
|
254
|
+
const WS = this.opts.websocketCtor ?? WebSocket;
|
|
255
|
+
const ws = new WS(this.wsUrl(), {
|
|
256
|
+
headers: { Authorization: `Bearer ${this.opts.token}` }
|
|
257
|
+
});
|
|
258
|
+
this.ws = ws;
|
|
259
|
+
ws.on("open", () => {
|
|
260
|
+
this.backoff.reset();
|
|
261
|
+
this.log.info("connected to relay");
|
|
262
|
+
const helloTools = this.toolCatalog ?? [];
|
|
263
|
+
try {
|
|
264
|
+
ws.send(encode({ kind: "hello", devId: this.opts.devId, version: "0.1.0", tools: helloTools }));
|
|
265
|
+
} catch (err) {
|
|
266
|
+
this.log.warn("failed to send hello", err.message);
|
|
267
|
+
}
|
|
268
|
+
this.opts.onConnect?.();
|
|
269
|
+
});
|
|
270
|
+
ws.on("message", (data) => {
|
|
271
|
+
let frame;
|
|
272
|
+
try {
|
|
273
|
+
frame = decode(data);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.log.warn("malformed frame from relay", err.message);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (frame.kind === "rpc.request") void this.handleRpc(ws, frame);
|
|
279
|
+
});
|
|
280
|
+
ws.on("close", (code, reason) => {
|
|
281
|
+
const r = reason?.toString() ?? "";
|
|
282
|
+
this.log.warn(`relay disconnected code=${code} reason=${r}`);
|
|
283
|
+
this.opts.onDisconnect?.({ code, reason: r });
|
|
284
|
+
this.ws = null;
|
|
285
|
+
if (code === 4001) {
|
|
286
|
+
this.log.error("kicked by newer connection \u2014 exiting reconnect loop");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.scheduleReconnect();
|
|
290
|
+
});
|
|
291
|
+
ws.on("error", (err) => {
|
|
292
|
+
this.opts.onError?.(err);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
scheduleReconnect() {
|
|
296
|
+
if (this.stopped) return;
|
|
297
|
+
const delay = this.backoff.next();
|
|
298
|
+
this.log.info(`reconnect in ${delay}ms (attempt ${this.backoff.currentAttempt})`);
|
|
299
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
300
|
+
}
|
|
301
|
+
async handleRpc(ws, frame) {
|
|
302
|
+
if (!this.mcp) {
|
|
303
|
+
this.respond(ws, frame.id, 502, { error: "mcp child not running" });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
let mcpResp;
|
|
308
|
+
if (frame.method === "list") {
|
|
309
|
+
mcpResp = await this.mcp.request("tools/list");
|
|
310
|
+
} else if (frame.method === "call") {
|
|
311
|
+
mcpResp = await this.mcp.request("tools/call", {
|
|
312
|
+
name: frame.path,
|
|
313
|
+
arguments: frame.body ?? {}
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
this.respond(ws, frame.id, 400, { error: `unsupported method ${frame.method}` });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (mcpResp.error) {
|
|
320
|
+
this.respond(ws, frame.id, 500, { error: mcpResp.error.message, code: mcpResp.error.code, data: mcpResp.error.data });
|
|
321
|
+
} else {
|
|
322
|
+
this.respond(ws, frame.id, 200, mcpResp.result ?? null);
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
this.respond(ws, frame.id, 500, { error: err.message });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
respond(ws, id, status, body) {
|
|
329
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
330
|
+
try {
|
|
331
|
+
ws.send(encode({ kind: "rpc.response", id, status, body }));
|
|
332
|
+
} catch (err) {
|
|
333
|
+
this.log.warn("failed to send rpc.response", err.message);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
export {
|
|
338
|
+
Backoff,
|
|
339
|
+
Bridge,
|
|
340
|
+
McpStdioClient,
|
|
341
|
+
decode,
|
|
342
|
+
encode
|
|
343
|
+
};
|
|
344
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/bridge.ts","../src/backoff.ts","../src/protocol.ts","../src/mcp-stdio.ts"],"sourcesContent":["// Bridge runtime: maintains a WebSocket to the relay, spawns the dev's\n// MCP child once, and translates relay RPC envelopes ↔ MCP JSON-RPC.\n//\n// Single-process design:\n// * one MCP child for the lifetime of the bridge (re-using its session\n// across relay reconnects — devs configure tools that may carry state).\n// * one WS connection to the relay at a time. On error/close we reconnect\n// with exponential backoff.\n// * each inbound rpc.request becomes one MCP request (`tools/list` or\n// `tools/call`). We synthesize a clean response envelope and forward.\n\nimport WebSocket from 'ws';\nimport { Backoff } from './backoff.js';\nimport { decode, encode, type Frame, type RpcRequestFrame } from './protocol.js';\nimport { McpStdioClient, type McpMessage } from './mcp-stdio.js';\n\nexport interface BridgeOptions {\n /** Relay base URL (without /dev/...). Trailing slash optional. */\n relayUrl: string;\n /** Bridge JWT — `dev:<id>` subject + scope=[\"bridge\"]. */\n token: string;\n /** Dev id; must match the JWT subject. */\n devId: string;\n /** Command to spawn for the MCP child. */\n mcpCommand: string;\n mcpArgs?: string[];\n mcpEnv?: Record<string, string>;\n /** Override for tests. */\n websocketCtor?: typeof WebSocket;\n /** Override backoff knobs. */\n backoff?: { base?: number; cap?: number; random?: () => number };\n /** Hook for tests + observability. */\n onConnect?: () => void;\n onDisconnect?: (info: { code: number; reason: string }) => void;\n onError?: (err: Error) => void;\n logger?: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void; error: (...a: unknown[]) => void };\n}\n\ninterface ToolDescriptor {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport class Bridge {\n private ws: WebSocket | null = null;\n private backoff: Backoff;\n private mcp: McpStdioClient | null = null;\n private toolCatalog: ToolDescriptor[] | null = null;\n private stopped = false;\n private reconnectTimer: NodeJS.Timeout | null = null;\n private log: NonNullable<BridgeOptions['logger']>;\n\n constructor(private readonly opts: BridgeOptions) {\n this.backoff = new Backoff(opts.backoff);\n this.log = opts.logger ?? {\n info: (...a) => console.log('[bridge]', ...a),\n warn: (...a) => console.warn('[bridge]', ...a),\n error: (...a) => console.error('[bridge]', ...a),\n };\n }\n\n /** Start the MCP child + connect to relay. Returns once initial connect resolves OR backoff begins. */\n async start(): Promise<void> {\n if (this.mcp) throw new Error('already started');\n this.mcp = new McpStdioClient({\n command: this.opts.mcpCommand,\n args: this.opts.mcpArgs,\n env: this.opts.mcpEnv,\n });\n this.mcp.on('stderr', (s) => this.log.warn('mcp child stderr:', s.trim()));\n this.mcp.on('exit', (info) => this.log.error('mcp child exited', info));\n this.mcp.on('parseError', (line) => this.log.warn('mcp parse error', line));\n this.mcp.start();\n\n // Snapshot the tool catalog once so we can publish it on the `hello` frame.\n try {\n const resp = await this.mcp.request('tools/list');\n if (resp.result && typeof resp.result === 'object' && 'tools' in (resp.result as Record<string, unknown>)) {\n this.toolCatalog = ((resp.result as { tools: ToolDescriptor[] }).tools) ?? [];\n } else {\n this.toolCatalog = [];\n }\n } catch (err) {\n // Non-fatal: relay will fall back to live /list calls on demand.\n this.log.warn('initial tools/list failed; relay will use live RPC', (err as Error).message);\n }\n\n this.connect();\n }\n\n /** Stop the bridge: close WS, kill child, cancel reconnect. */\n async stop(): Promise<void> {\n this.stopped = true;\n if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }\n if (this.ws) {\n try { this.ws.close(1000, 'bridge-stop'); } catch { /* ignore */ }\n this.ws = null;\n }\n if (this.mcp) await this.mcp.stop();\n }\n\n private wsUrl(): string {\n const base = this.opts.relayUrl.replace(/\\/$/, '').replace(/^http/, 'ws');\n return `${base}/dev/${this.opts.devId}/connect`;\n }\n\n private connect(): void {\n if (this.stopped) return;\n const WS = this.opts.websocketCtor ?? WebSocket;\n const ws = new WS(this.wsUrl(), {\n headers: { Authorization: `Bearer ${this.opts.token}` },\n });\n this.ws = ws;\n\n ws.on('open', () => {\n this.backoff.reset();\n this.log.info('connected to relay');\n const helloTools = this.toolCatalog ?? [];\n try {\n ws.send(encode({ kind: 'hello', devId: this.opts.devId, version: '0.1.0', tools: helloTools }));\n } catch (err) {\n this.log.warn('failed to send hello', (err as Error).message);\n }\n this.opts.onConnect?.();\n });\n\n ws.on('message', (data) => {\n let frame: Frame;\n try { frame = decode(data as Buffer); }\n catch (err) { this.log.warn('malformed frame from relay', (err as Error).message); return; }\n if (frame.kind === 'rpc.request') void this.handleRpc(ws, frame);\n });\n\n ws.on('close', (code, reason) => {\n const r = reason?.toString() ?? '';\n this.log.warn(`relay disconnected code=${code} reason=${r}`);\n this.opts.onDisconnect?.({ code, reason: r });\n this.ws = null;\n // Code 4001 = relay says we were kicked because a fresher bridge took\n // over our slot. In that case it's almost always wrong for us to\n // reconnect — we'd just kick the new one. Surface and stop.\n if (code === 4001) {\n this.log.error('kicked by newer connection — exiting reconnect loop');\n return;\n }\n this.scheduleReconnect();\n });\n\n ws.on('error', (err) => {\n this.opts.onError?.(err);\n // close event fires after error; reconnect handling lives there.\n });\n }\n\n private scheduleReconnect(): void {\n if (this.stopped) return;\n const delay = this.backoff.next();\n this.log.info(`reconnect in ${delay}ms (attempt ${this.backoff.currentAttempt})`);\n this.reconnectTimer = setTimeout(() => this.connect(), delay);\n }\n\n private async handleRpc(ws: WebSocket, frame: RpcRequestFrame): Promise<void> {\n if (!this.mcp) {\n this.respond(ws, frame.id, 502, { error: 'mcp child not running' });\n return;\n }\n try {\n let mcpResp: McpMessage;\n if (frame.method === 'list') {\n mcpResp = await this.mcp.request('tools/list');\n } else if (frame.method === 'call') {\n mcpResp = await this.mcp.request('tools/call', {\n name: frame.path,\n arguments: (frame.body as Record<string, unknown>) ?? {},\n });\n } else {\n this.respond(ws, frame.id, 400, { error: `unsupported method ${(frame as { method: string }).method}` });\n return;\n }\n if (mcpResp.error) {\n this.respond(ws, frame.id, 500, { error: mcpResp.error.message, code: mcpResp.error.code, data: mcpResp.error.data });\n } else {\n this.respond(ws, frame.id, 200, mcpResp.result ?? null);\n }\n } catch (err) {\n this.respond(ws, frame.id, 500, { error: (err as Error).message });\n }\n }\n\n private respond(ws: WebSocket, id: string, status: number, body: unknown): void {\n if (ws.readyState !== WebSocket.OPEN) return;\n try {\n ws.send(encode({ kind: 'rpc.response', id, status, body }));\n } catch (err) {\n this.log.warn('failed to send rpc.response', (err as Error).message);\n }\n }\n}\n","// Exponential backoff with full jitter, capped.\n//\n// Reconnect schedule for transient WS errors:\n// attempt 0 → 500ms\n// attempt 1 → 1000ms\n// attempt 2 → 2000ms\n// ...\n// capped at 30_000ms (per spec).\n//\n// Full jitter (sleep ∈ [0, exp_delay]) avoids reconnect storms when the\n// relay reboots and N bridges all wake at once.\n\nexport interface BackoffOpts {\n base?: number;\n cap?: number;\n /** Override RNG for deterministic tests. */\n random?: () => number;\n}\n\nexport class Backoff {\n private attempt = 0;\n private base: number;\n private cap: number;\n private random: () => number;\n\n constructor(opts: BackoffOpts = {}) {\n this.base = opts.base ?? 500;\n this.cap = opts.cap ?? 30_000;\n this.random = opts.random ?? Math.random;\n }\n\n reset(): void { this.attempt = 0; }\n\n /** Return the next delay (ms) and advance attempt counter. */\n next(): number {\n const exp = Math.min(this.cap, this.base * 2 ** this.attempt);\n this.attempt += 1;\n return Math.floor(this.random() * exp);\n }\n\n /** Test introspection: current attempt count (0 after reset). */\n get currentAttempt(): number { return this.attempt; }\n}\n","// Wire protocol shared with apps/mcp-relay. Duplicated locally (not imported\n// from the relay package) so this CLI can be `npm install -g`'d on a dev\n// laptop without pulling the relay's runtime deps. The schema is stable —\n// any change to the protocol must land in both files in the same PR.\n//\n// See apps/mcp-relay/src/protocol.ts for the canonical doc-comments.\n\nexport type RpcRequestFrame = {\n kind: 'rpc.request';\n id: string;\n method: 'list' | 'call';\n path: string;\n body?: unknown;\n};\n\nexport type RpcResponseFrame = {\n kind: 'rpc.response';\n id: string;\n status: number;\n body?: unknown;\n};\n\nexport type HelloFrame = {\n kind: 'hello';\n devId: string;\n version: string;\n tools?: unknown;\n};\n\nexport type Frame =\n | RpcRequestFrame\n | RpcResponseFrame\n | HelloFrame\n | { kind: 'ping' }\n | { kind: 'pong' };\n\nexport function encode(frame: Frame): string {\n return JSON.stringify(frame);\n}\n\nexport function decode(raw: string | Buffer): Frame {\n const text = typeof raw === 'string' ? raw : raw.toString('utf-8');\n const parsed = JSON.parse(text) as Frame;\n if (!parsed || typeof parsed !== 'object' || !('kind' in parsed)) {\n throw new Error('invalid frame: missing kind');\n }\n return parsed;\n}\n","// MCP-over-stdio client: spawns the dev's MCP server (e.g. `tariff-tools\n// serve` or `python -m tariff_tools.server`) and speaks line-delimited\n// JSON-RPC 2.0 to it.\n//\n// MCP's standard stdio transport uses LSP-style \"Content-Length\" framing.\n// We support BOTH framings:\n// * newline-delimited JSON (one JSON object per line) — common for\n// hand-rolled servers + simpler to test.\n// * Content-Length-prefixed JSON — what the MCP reference server emits.\n//\n// The bridge auto-detects which mode the child is using by sniffing the\n// first 16 bytes of output. If they start with \"Content-Length:\" we use\n// LSP framing; otherwise we use newline-delimited.\n\nimport { spawn, type ChildProcessWithoutNullStreams } from 'child_process';\nimport { EventEmitter } from 'events';\n\nexport type McpMessage = {\n jsonrpc: '2.0';\n id?: string | number;\n method?: string;\n params?: unknown;\n result?: unknown;\n error?: { code: number; message: string; data?: unknown };\n};\n\ntype Framing = 'newline' | 'lsp' | 'unknown';\n\nexport interface McpStdioOptions {\n command: string;\n args?: string[];\n env?: Record<string, string>;\n cwd?: string;\n}\n\nexport class McpStdioClient extends EventEmitter {\n private proc: ChildProcessWithoutNullStreams | null = null;\n private framing: Framing = 'unknown';\n private buf = Buffer.alloc(0);\n private nextId = 1;\n private pending = new Map<string | number, (msg: McpMessage) => void>();\n private closed = false;\n\n constructor(private readonly opts: McpStdioOptions) {\n super();\n }\n\n start(): void {\n if (this.proc) throw new Error('already started');\n const proc = spawn(this.opts.command, this.opts.args ?? [], {\n env: { ...process.env, ...this.opts.env },\n cwd: this.opts.cwd,\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n this.proc = proc;\n proc.stdout.on('data', (chunk: Buffer) => this.onStdout(chunk));\n proc.stderr.on('data', (chunk: Buffer) => {\n this.emit('stderr', chunk.toString('utf-8'));\n });\n proc.on('exit', (code, signal) => {\n this.closed = true;\n this.emit('exit', { code, signal });\n // Surface a synthetic error to any pending requests.\n for (const [, resolve] of this.pending) {\n resolve({\n jsonrpc: '2.0',\n error: { code: -32000, message: `mcp child exited code=${code} signal=${signal}` },\n });\n }\n this.pending.clear();\n });\n proc.on('error', (err) => {\n this.emit('error', err);\n });\n }\n\n async stop(signal: NodeJS.Signals = 'SIGTERM'): Promise<void> {\n if (!this.proc || this.closed) return;\n return new Promise((resolve) => {\n this.proc!.once('exit', () => resolve());\n try { this.proc!.kill(signal); } catch { /* ignore */ }\n // hard-kill after 2s if it ignores SIGTERM\n setTimeout(() => { try { this.proc?.kill('SIGKILL'); } catch { /* ignore */ } }, 2_000).unref();\n });\n }\n\n /**\n * Send a JSON-RPC request to the MCP child and await its response.\n * Auto-generates an id if the caller doesn't supply one.\n */\n request(method: string, params?: unknown): Promise<McpMessage> {\n if (!this.proc) throw new Error('not started');\n const id = this.nextId++;\n const msg: McpMessage = { jsonrpc: '2.0', id, method, params };\n return new Promise((resolve) => {\n this.pending.set(id, resolve);\n this.write(msg);\n });\n }\n\n /** Send an MCP notification (no response expected). */\n notify(method: string, params?: unknown): void {\n if (!this.proc) throw new Error('not started');\n this.write({ jsonrpc: '2.0', method, params });\n }\n\n private write(msg: McpMessage): void {\n const json = JSON.stringify(msg);\n if (this.framing === 'lsp') {\n const body = Buffer.from(json, 'utf-8');\n this.proc!.stdin.write(`Content-Length: ${body.length}\\r\\n\\r\\n`);\n this.proc!.stdin.write(body);\n } else {\n // newline framing (default for unknown until we observe LSP from child)\n this.proc!.stdin.write(json + '\\n');\n }\n }\n\n private onStdout(chunk: Buffer): void {\n this.buf = Buffer.concat([this.buf, chunk]);\n\n if (this.framing === 'unknown') {\n // Sniff: LSP framing always starts with \"Content-Length:\" header.\n const peek = this.buf.subarray(0, Math.min(16, this.buf.length)).toString('utf-8');\n if (peek.startsWith('Content-Length:')) this.framing = 'lsp';\n else if (this.buf.includes(0x0a)) this.framing = 'newline';\n }\n\n if (this.framing === 'lsp') this.drainLsp();\n else if (this.framing === 'newline') this.drainNewline();\n }\n\n private drainNewline(): void {\n while (true) {\n const nl = this.buf.indexOf(0x0a);\n if (nl < 0) return;\n const line = this.buf.subarray(0, nl).toString('utf-8').trim();\n this.buf = this.buf.subarray(nl + 1);\n if (!line) continue;\n this.dispatchLine(line);\n }\n }\n\n private drainLsp(): void {\n while (true) {\n const headerEnd = this.buf.indexOf('\\r\\n\\r\\n');\n if (headerEnd < 0) return;\n const headers = this.buf.subarray(0, headerEnd).toString('utf-8');\n const m = /Content-Length:\\s*(\\d+)/i.exec(headers);\n if (!m) {\n // garbled headers — skip this chunk\n this.buf = this.buf.subarray(headerEnd + 4);\n continue;\n }\n const len = Number(m[1]);\n const start = headerEnd + 4;\n if (this.buf.length < start + len) return; // wait for more\n const body = this.buf.subarray(start, start + len).toString('utf-8');\n this.buf = this.buf.subarray(start + len);\n this.dispatchLine(body);\n }\n }\n\n private dispatchLine(line: string): void {\n let msg: McpMessage;\n try { msg = JSON.parse(line); }\n catch {\n this.emit('parseError', line);\n return;\n }\n if (msg.id !== undefined && this.pending.has(msg.id)) {\n const cb = this.pending.get(msg.id)!;\n this.pending.delete(msg.id);\n cb(msg);\n } else {\n // server-initiated notification or unmatched response\n this.emit('message', msg);\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tournesol-tag/mcp-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TAG MCP bridge — runs your local MCP server and proxies stdio ↔ TAG relay WebSocket so hosted TAG / engine can call your tools (J1, ADR-033).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tag",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"bridge",
|
|
10
|
+
"cli"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/jaimetournesol/TAG.git",
|
|
16
|
+
"directory": "apps/mcp-bridge"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/jaimetournesol/TAG/tree/main/apps/mcp-bridge#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/jaimetournesol/TAG/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"bin": {
|
|
25
|
+
"tag-mcp-bridge": "./dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/**/*",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"ws": "^8.18.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.11.5",
|
|
39
|
+
"@types/ws": "^8.5.10",
|
|
40
|
+
"tsup": "^8.0.1",
|
|
41
|
+
"tsx": "^4.7.0",
|
|
42
|
+
"typescript": "^5.3.3",
|
|
43
|
+
"vitest": "^1.2.0"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18.0.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"dev": "tsx watch src/cli.ts",
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"start": "node dist/cli.js",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
}
|
|
55
|
+
}
|