@businessmaps/bifrost 0.0.2 → 0.1.2
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 +8 -9
- package/bin/bifrost +5 -5
- package/lib/websocket.js +39 -281
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
MCP server that lets AI tools call functions running in your browser.
|
|
7
7
|
|
|
8
|
-
The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC over stdio on one side and WebSocket on the other. Your browser app connects, registers tools, and the AI can call them.
|
|
8
|
+
The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC over stdio on one side and WebSocket on the other. Your browser app connects, registers tools, and the AI can call them.
|
|
9
|
+
|
|
10
|
+
**Requirements:** Node.js 18+ · [`ws`](https://github.com/websockets/ws) (installed automatically)
|
|
9
11
|
|
|
10
12
|
## Install
|
|
11
13
|
|
|
@@ -13,18 +15,14 @@ The daemon sits between any MCP client and your browser tab. It speaks JSON-RPC
|
|
|
13
15
|
npm i -g @businessmaps/bifrost
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
or without npm
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
curl -fsSL https://raw.githubusercontent.com/Business-Maps/mcp/main/install.sh | sh
|
|
20
|
-
```
|
|
21
|
-
|
|
22
18
|
Then register it with your MCP client. For example:
|
|
23
19
|
|
|
24
20
|
```bash
|
|
25
|
-
claude mcp add --transport stdio bifrost -- bifrost
|
|
21
|
+
claude mcp add --transport stdio bifrost -- bifrost --no-auth
|
|
26
22
|
```
|
|
27
23
|
|
|
24
|
+
> **Note:** The `--no-auth` flag is currently recommended because there is an unresolved issue with token authentication when MCP clients launch the daemon. Without it the client has no way to read the token printed to stderr.
|
|
25
|
+
|
|
28
26
|
The MCP client starts the daemon automatically.
|
|
29
27
|
|
|
30
28
|
## Usage
|
|
@@ -91,7 +89,8 @@ Each tab registers its own tools. Calls route to whoever owns the tool. Disconne
|
|
|
91
89
|
|
|
92
90
|
```bash
|
|
93
91
|
git clone https://github.com/Business-Maps/mcp.git && cd mcp
|
|
94
|
-
npm
|
|
92
|
+
npm install
|
|
93
|
+
npm test
|
|
95
94
|
```
|
|
96
95
|
|
|
97
96
|
[Architecture](docs/architecture.md) · [Client API](docs/client-api.md) · [Contributing](CONTRIBUTING.md)
|
package/bin/bifrost
CHANGED
|
@@ -83,7 +83,7 @@ function printBanner(version) {
|
|
|
83
83
|
`${c.bold}${c.RED} ██████╔╝██║██║ ██║ ██║╚██████╔╝███████║ ██║ ${c.r}`,
|
|
84
84
|
`${c.bold}${c.RED} ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ${c.r}`,
|
|
85
85
|
"",
|
|
86
|
-
`${c.dim}${c.red}
|
|
86
|
+
`${c.dim}${c.red} ---------------------------------------- v${version} --${c.r}`,
|
|
87
87
|
"",
|
|
88
88
|
"",
|
|
89
89
|
"",
|
|
@@ -111,7 +111,7 @@ function printBanner(version) {
|
|
|
111
111
|
// ---------------------------------------------------------------------------
|
|
112
112
|
function printHelp() {
|
|
113
113
|
log("");
|
|
114
|
-
log(` ${c.bold}${c.RED}BIFROST${c.r} ${c.dim}${c.red}
|
|
114
|
+
log(` ${c.bold}${c.RED}BIFROST${c.r} ${c.dim}${c.red}----------------------${c.r}`);
|
|
115
115
|
log("");
|
|
116
116
|
log(` ${c.dim}MCP browser bridge daemon${c.r}`);
|
|
117
117
|
log("");
|
|
@@ -125,7 +125,7 @@ function printHelp() {
|
|
|
125
125
|
log(` ${c.RED}--help${c.r}, ${c.RED}-h${c.r} Show this message`);
|
|
126
126
|
log(` ${c.RED}--version${c.r}, ${c.RED}-v${c.r} Show version`);
|
|
127
127
|
log("");
|
|
128
|
-
log(` ${c.dim}${c.red}
|
|
128
|
+
log(` ${c.dim}${c.red}----------------------------------------------------${c.r}`);
|
|
129
129
|
log("");
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -356,7 +356,7 @@ const wss = new MiniWebSocketServer({ port: PORT }, () => {
|
|
|
356
356
|
wss.on("connection", (ws, req) => {
|
|
357
357
|
// Origin check — allow any localhost origin (any port)
|
|
358
358
|
if (!isLocalOrigin(req.headers.origin)) {
|
|
359
|
-
ws.
|
|
359
|
+
ws.terminate();
|
|
360
360
|
return;
|
|
361
361
|
}
|
|
362
362
|
|
|
@@ -366,7 +366,7 @@ wss.on("connection", (ws, req) => {
|
|
|
366
366
|
const queryToken = url.searchParams.get("token");
|
|
367
367
|
const headerToken = req.headers["sec-websocket-protocol"];
|
|
368
368
|
if (queryToken !== AUTH_TOKEN && headerToken !== AUTH_TOKEN) {
|
|
369
|
-
ws.
|
|
369
|
+
ws.terminate();
|
|
370
370
|
return;
|
|
371
371
|
}
|
|
372
372
|
}
|
package/lib/websocket.js
CHANGED
|
@@ -1,274 +1,63 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
//
|
|
3
|
-
// Zero dependencies
|
|
2
|
+
// WebSocket server — thin wrapper around 'ws' for API compatibility
|
|
4
3
|
// ============================================================================
|
|
5
4
|
|
|
6
5
|
import { createServer } from "http";
|
|
7
|
-
import { createHash } from "crypto";
|
|
8
6
|
import { EventEmitter } from "events";
|
|
9
|
-
|
|
10
|
-
const GUID = "258EAFA5-E914-47DA-95CA-5AB5DC65C97B";
|
|
11
|
-
|
|
12
|
-
const MAX_PAYLOAD_SIZE = 100 * 1024 * 1024; // 100MB
|
|
13
|
-
const MAX_BUFFER_SIZE = 200 * 1024 * 1024; // 200MB total buffered
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
14
8
|
|
|
15
9
|
// ---------------------------------------------------------------------------
|
|
16
|
-
// MiniWebSocket
|
|
10
|
+
// MiniWebSocket — wraps a ws.WebSocket with the same API the daemon expects
|
|
17
11
|
// ---------------------------------------------------------------------------
|
|
18
12
|
export class MiniWebSocket extends EventEmitter {
|
|
19
|
-
constructor(
|
|
13
|
+
constructor(ws) {
|
|
20
14
|
super();
|
|
21
|
-
this.
|
|
15
|
+
this._ws = ws;
|
|
22
16
|
this.readyState = 1;
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
this._fragType = null;
|
|
28
|
-
this._fragBuffer = [];
|
|
29
|
-
|
|
30
|
-
socket.on("data", (c) => this._onData(c));
|
|
31
|
-
socket.on("close", () => this._handleClose());
|
|
32
|
-
socket.on("end", () => this._handleClose());
|
|
33
|
-
socket.on("error", (err) => {
|
|
34
|
-
this._handleClose();
|
|
35
|
-
this.emit("error", err);
|
|
18
|
+
ws.on("message", (data) => {
|
|
19
|
+
const text = typeof data === "string" ? data : data.toString();
|
|
20
|
+
this.emit("message", text);
|
|
36
21
|
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// -------------------------------------------------------------------------
|
|
40
|
-
// Public API
|
|
41
|
-
// -------------------------------------------------------------------------
|
|
42
|
-
send(data) {
|
|
43
|
-
if (this.readyState !== 1) return;
|
|
44
|
-
|
|
45
|
-
const payload = Buffer.isBuffer(data)
|
|
46
|
-
? data
|
|
47
|
-
: Buffer.from(String(data));
|
|
48
|
-
|
|
49
|
-
this._writeFrame(0x1, payload);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
close(code = 1000) {
|
|
53
|
-
if (this.readyState !== 1) return;
|
|
54
|
-
this.readyState = 2;
|
|
55
|
-
|
|
56
|
-
const buf = Buffer.alloc(2);
|
|
57
|
-
buf.writeUInt16BE(code, 0);
|
|
58
|
-
|
|
59
|
-
this._writeFrame(0x8, buf);
|
|
60
|
-
this.socket.end();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// -------------------------------------------------------------------------
|
|
64
|
-
// Core frame processing
|
|
65
|
-
// -------------------------------------------------------------------------
|
|
66
|
-
_onData(chunk) {
|
|
67
|
-
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
68
|
-
|
|
69
|
-
if (this._buffer.length > MAX_BUFFER_SIZE) {
|
|
70
|
-
this._fail(1009, "buffer overflow");
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
while (true) {
|
|
75
|
-
const frame = this._decodeFrame(this._buffer);
|
|
76
|
-
if (!frame) break;
|
|
77
|
-
|
|
78
|
-
if (frame.error) {
|
|
79
|
-
this._fail(frame.code || 1002, frame.error);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
this._buffer = this._buffer.subarray(frame.totalLength);
|
|
84
|
-
this._handleFrame(frame);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
_handleFrame(f) {
|
|
89
|
-
const { opcode, payload, fin } = f;
|
|
90
|
-
|
|
91
|
-
// control frames
|
|
92
|
-
if (opcode === 0x8) {
|
|
93
|
-
// close
|
|
94
|
-
this.close();
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (opcode === 0x9) {
|
|
99
|
-
// ping → pong
|
|
100
|
-
this._writeFrame(0xA, payload);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (opcode === 0xA) {
|
|
105
|
-
this.emit("pong");
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
22
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (fin) {
|
|
116
|
-
const full = Buffer.concat(this._fragBuffer);
|
|
117
|
-
this._emitMessage(this._fragType, full);
|
|
118
|
-
this._fragType = null;
|
|
119
|
-
this._fragBuffer = [];
|
|
120
|
-
}
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (opcode === 0x1 || opcode === 0x2) {
|
|
125
|
-
if (this._fragType) return this._fail(1002, "nested fragments");
|
|
23
|
+
ws.on("close", () => {
|
|
24
|
+
if (this.readyState === 3) return;
|
|
25
|
+
this.readyState = 3;
|
|
26
|
+
this.emit("close");
|
|
27
|
+
});
|
|
126
28
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
this.
|
|
130
|
-
|
|
29
|
+
ws.on("error", (err) => {
|
|
30
|
+
if (this.readyState !== 3) {
|
|
31
|
+
this.readyState = 3;
|
|
32
|
+
this.emit("close");
|
|
131
33
|
}
|
|
34
|
+
this.emit("error", err);
|
|
35
|
+
});
|
|
132
36
|
|
|
133
|
-
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
this._fail(1002, "invalid opcode");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
_emitMessage(opcode, payload) {
|
|
141
|
-
if (opcode === 0x1) {
|
|
142
|
-
// text
|
|
143
|
-
try {
|
|
144
|
-
const text = new TextDecoder("utf-8", { fatal: true }).decode(payload);
|
|
145
|
-
this.emit("message", text);
|
|
146
|
-
} catch {
|
|
147
|
-
this._fail(1007, "invalid utf8");
|
|
148
|
-
}
|
|
149
|
-
} else {
|
|
150
|
-
// binary
|
|
151
|
-
this.emit("message", payload);
|
|
152
|
-
}
|
|
37
|
+
ws.on("pong", () => this.emit("pong"));
|
|
153
38
|
}
|
|
154
39
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// -------------------------------------------------------------------------
|
|
158
|
-
_decodeFrame(buf) {
|
|
159
|
-
if (buf.length < 2) return null;
|
|
160
|
-
|
|
161
|
-
const b0 = buf[0];
|
|
162
|
-
const b1 = buf[1];
|
|
163
|
-
|
|
164
|
-
const fin = (b0 & 0x80) !== 0;
|
|
165
|
-
const opcode = b0 & 0x0f;
|
|
166
|
-
|
|
167
|
-
const masked = (b1 & 0x80) !== 0;
|
|
168
|
-
let payloadLen = b1 & 0x7f;
|
|
169
|
-
|
|
170
|
-
// MUST be masked (client → server)
|
|
171
|
-
if (!masked) return { error: "unmasked frame", code: 1002 };
|
|
172
|
-
|
|
173
|
-
// opcode validation
|
|
174
|
-
const valid = [0x0, 0x1, 0x2, 0x8, 0x9, 0xA];
|
|
175
|
-
if (!valid.includes(opcode)) {
|
|
176
|
-
return { error: "invalid opcode", code: 1002 };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
let offset = 2;
|
|
180
|
-
|
|
181
|
-
if (payloadLen === 126) {
|
|
182
|
-
if (buf.length < 4) return null;
|
|
183
|
-
payloadLen = buf.readUInt16BE(2);
|
|
184
|
-
offset = 4;
|
|
185
|
-
} else if (payloadLen === 127) {
|
|
186
|
-
if (buf.length < 10) return null;
|
|
187
|
-
const big = buf.readBigUInt64BE(2);
|
|
188
|
-
if (big > BigInt(MAX_PAYLOAD_SIZE)) {
|
|
189
|
-
return { error: "too large", code: 1009 };
|
|
190
|
-
}
|
|
191
|
-
payloadLen = Number(big);
|
|
192
|
-
offset = 10;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (payloadLen > MAX_PAYLOAD_SIZE) {
|
|
196
|
-
return { error: "too large", code: 1009 };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// control frames rules
|
|
200
|
-
if (opcode >= 0x8) {
|
|
201
|
-
if (!fin) return { error: "fragmented control", code: 1002 };
|
|
202
|
-
if (payloadLen > 125) return { error: "control too large", code: 1002 };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const total = offset + 4 + payloadLen;
|
|
206
|
-
if (buf.length < total) return null;
|
|
207
|
-
|
|
208
|
-
const mask = buf.subarray(offset, offset + 4);
|
|
209
|
-
const payload = Buffer.alloc(payloadLen);
|
|
210
|
-
|
|
211
|
-
for (let i = 0; i < payloadLen; i++) {
|
|
212
|
-
payload[i] = buf[offset + 4 + i] ^ mask[i % 4];
|
|
213
|
-
}
|
|
40
|
+
get socket() { return this._ws._socket || null; }
|
|
41
|
+
get bufferedAmount() { return this._ws.bufferedAmount; }
|
|
214
42
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
payload,
|
|
219
|
-
totalLength: total,
|
|
220
|
-
};
|
|
43
|
+
send(data) {
|
|
44
|
+
if (this._ws.readyState !== WebSocket.OPEN) return;
|
|
45
|
+
this._ws.send(typeof data === "string" ? data : String(data));
|
|
221
46
|
}
|
|
222
47
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
_writeFrame(opcode, payload) {
|
|
227
|
-
if (this.readyState !== 1) return;
|
|
228
|
-
|
|
229
|
-
const len = payload.length;
|
|
230
|
-
let header;
|
|
231
|
-
|
|
232
|
-
if (len < 126) {
|
|
233
|
-
header = Buffer.alloc(2);
|
|
234
|
-
header[1] = len;
|
|
235
|
-
} else if (len < 65536) {
|
|
236
|
-
header = Buffer.alloc(4);
|
|
237
|
-
header[1] = 126;
|
|
238
|
-
header.writeUInt16BE(len, 2);
|
|
239
|
-
} else {
|
|
240
|
-
header = Buffer.alloc(10);
|
|
241
|
-
header[1] = 127;
|
|
242
|
-
header.writeBigUInt64BE(BigInt(len), 2);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
header[0] = 0x80 | opcode;
|
|
246
|
-
|
|
247
|
-
const frame = Buffer.concat([header, payload]);
|
|
248
|
-
|
|
249
|
-
if (!this.socket.write(frame)) {
|
|
250
|
-
this.socket.once("drain", () => {});
|
|
48
|
+
close(code = 1000) {
|
|
49
|
+
if (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING) {
|
|
50
|
+
this._ws.close(code);
|
|
251
51
|
}
|
|
252
52
|
}
|
|
253
53
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const buf = Buffer.alloc(2);
|
|
257
|
-
buf.writeUInt16BE(code, 0);
|
|
258
|
-
this._writeFrame(0x8, buf);
|
|
259
|
-
} catch {}
|
|
260
|
-
this.socket.destroy();
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
_handleClose() {
|
|
264
|
-
if (this.readyState === 3) return;
|
|
265
|
-
this.readyState = 3;
|
|
266
|
-
this.emit("close");
|
|
54
|
+
terminate() {
|
|
55
|
+
this._ws.terminate();
|
|
267
56
|
}
|
|
268
57
|
}
|
|
269
58
|
|
|
270
59
|
// ---------------------------------------------------------------------------
|
|
271
|
-
//
|
|
60
|
+
// MiniWebSocketServer — wraps ws.WebSocketServer, emits MiniWebSocket
|
|
272
61
|
// ---------------------------------------------------------------------------
|
|
273
62
|
export class MiniWebSocketServer extends EventEmitter {
|
|
274
63
|
constructor({ port }, cb) {
|
|
@@ -279,51 +68,20 @@ export class MiniWebSocketServer extends EventEmitter {
|
|
|
279
68
|
res.end("Upgrade required");
|
|
280
69
|
});
|
|
281
70
|
|
|
282
|
-
this.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
!req.headers["connection"]?.toLowerCase().includes("upgrade") ||
|
|
287
|
-
req.headers["sec-websocket-version"] !== "13"
|
|
288
|
-
) {
|
|
289
|
-
socket.destroy();
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const key = req.headers["sec-websocket-key"];
|
|
294
|
-
if (!key) {
|
|
295
|
-
socket.destroy();
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const accept = createHash("sha1")
|
|
300
|
-
.update(key + GUID)
|
|
301
|
-
.digest("base64");
|
|
302
|
-
|
|
303
|
-
// subprotocol (used for auth in your daemon)
|
|
304
|
-
const protocol = req.headers["sec-websocket-protocol"];
|
|
305
|
-
|
|
306
|
-
socket.setNoDelay(true);
|
|
307
|
-
socket.setTimeout(0);
|
|
308
|
-
|
|
309
|
-
socket.write(
|
|
310
|
-
"HTTP/1.1 101 Switching Protocols\r\n" +
|
|
311
|
-
"Upgrade: websocket\r\n" +
|
|
312
|
-
"Connection: Upgrade\r\n" +
|
|
313
|
-
`Sec-WebSocket-Accept: ${accept}\r\n` +
|
|
314
|
-
(protocol ? `Sec-WebSocket-Protocol: ${protocol}\r\n` : "") +
|
|
315
|
-
"\r\n"
|
|
316
|
-
);
|
|
317
|
-
|
|
318
|
-
if (head?.length) socket.unshift(head);
|
|
71
|
+
this._wss = new WebSocketServer({
|
|
72
|
+
server: this.server,
|
|
73
|
+
perMessageDeflate: false,
|
|
74
|
+
});
|
|
319
75
|
|
|
320
|
-
|
|
76
|
+
this._wss.on("connection", (ws, req) => {
|
|
77
|
+
this.emit("connection", new MiniWebSocket(ws), req);
|
|
321
78
|
});
|
|
322
79
|
|
|
323
80
|
this.server.listen(port, "127.0.0.1", cb);
|
|
324
81
|
}
|
|
325
82
|
|
|
326
83
|
close(cb) {
|
|
84
|
+
this._wss.close();
|
|
327
85
|
this.server.close(cb);
|
|
328
86
|
}
|
|
329
|
-
}
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@businessmaps/bifrost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Bridge any browser web app to Claude Code via MCP",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,5 +35,8 @@
|
|
|
35
35
|
"lib/",
|
|
36
36
|
"LICENSE",
|
|
37
37
|
"README.md"
|
|
38
|
-
]
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"ws": "^8.20.0"
|
|
41
|
+
}
|
|
39
42
|
}
|