@businessmaps/bifrost 0.0.1 → 0.0.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 +9 -3
- package/bin/bifrost +204 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,15 +71,21 @@ There's a working [live demo](https://business-maps.github.io/mcp/demo/) with fi
|
|
|
71
71
|
|
|
72
72
|
## Auth
|
|
73
73
|
|
|
74
|
-
The daemon generates a token on startup and prints it to stderr. Pass it to the client — the token is sent via the `Sec-WebSocket-Protocol` header during the WebSocket handshake.
|
|
74
|
+
The daemon generates a token on startup and prints it to stderr. Pass it to the client — the token is sent via the `Sec-WebSocket-Protocol` header during the WebSocket handshake. Skip with `--no-auth` during local dev.
|
|
75
75
|
|
|
76
76
|
## Multiple tabs
|
|
77
77
|
|
|
78
78
|
Each tab registers its own tools. Calls route to whoever owns the tool. Disconnecting a tab removes its tools.
|
|
79
79
|
|
|
80
|
-
##
|
|
80
|
+
## Options
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
```
|
|
83
|
+
--port <port> WebSocket port (default: 3099)
|
|
84
|
+
--timeout <secs> Tool call timeout (default: 120)
|
|
85
|
+
--no-auth Disable token auth (dev only)
|
|
86
|
+
--help, -h Show this message
|
|
87
|
+
--version, -v Show version
|
|
88
|
+
```
|
|
83
89
|
|
|
84
90
|
## Dev
|
|
85
91
|
|
package/bin/bifrost
CHANGED
|
@@ -4,9 +4,158 @@ import { createInterface } from "readline";
|
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { dirname, join } from "path";
|
|
7
|
+
import { readFileSync } from "fs";
|
|
7
8
|
|
|
8
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// ANSI helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const c = process.stderr.isTTY ? {
|
|
15
|
+
r: "\x1b[0m", // reset
|
|
16
|
+
red: "\x1b[31m",
|
|
17
|
+
RED: "\x1b[91m", // bright red
|
|
18
|
+
dim: "\x1b[2m",
|
|
19
|
+
bold: "\x1b[1m",
|
|
20
|
+
white: "\x1b[97m",
|
|
21
|
+
black: "\x1b[30m",
|
|
22
|
+
bgRed: "\x1b[41m",
|
|
23
|
+
gray: "\x1b[90m",
|
|
24
|
+
} : { r:"",red:"",RED:"",dim:"",bold:"",white:"",black:"",bgRed:"",gray:"" };
|
|
25
|
+
|
|
26
|
+
function log(msg) { process.stderr.write(msg + "\n"); }
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// ASCII globe — red sphere with black lat/long grid
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
function renderGlobe() {
|
|
32
|
+
const R = 9;
|
|
33
|
+
const lines = [];
|
|
34
|
+
for (let y = -R; y <= R; y++) {
|
|
35
|
+
let row = "";
|
|
36
|
+
const rSlice = Math.sqrt(R * R - y * y);
|
|
37
|
+
for (let x = -R * 2; x <= R * 2; x++) {
|
|
38
|
+
const nx = x / 2;
|
|
39
|
+
if (nx * nx + y * y > R * R) {
|
|
40
|
+
row += " ";
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// spherical coords for grid
|
|
44
|
+
const z = Math.sqrt(Math.max(0, R * R - nx * nx - y * y));
|
|
45
|
+
const theta = Math.atan2(y, nx);
|
|
46
|
+
const phi = Math.acos(z / R);
|
|
47
|
+
const lat = Math.abs(y);
|
|
48
|
+
const lonAngle = Math.atan2(nx, z);
|
|
49
|
+
|
|
50
|
+
// grid lines: latitudes every ~3 units, longitudes every ~30 degrees
|
|
51
|
+
const isLat = lat % 3 < 0.8;
|
|
52
|
+
const lonDeg = ((lonAngle * 180 / Math.PI) + 360) % 360;
|
|
53
|
+
const isLon = lonDeg % 30 < 6;
|
|
54
|
+
// equator
|
|
55
|
+
const isEquator = Math.abs(y) < 1;
|
|
56
|
+
|
|
57
|
+
if (isEquator || isLat || isLon) {
|
|
58
|
+
row += `${c.black}${c.bgRed}+${c.r}`;
|
|
59
|
+
} else {
|
|
60
|
+
row += `${c.RED}.${c.r}`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
lines.push(row);
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Startup banner
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
function printBanner(version) {
|
|
72
|
+
log("");
|
|
73
|
+
|
|
74
|
+
const globe = renderGlobe();
|
|
75
|
+
const bannerLines = [
|
|
76
|
+
"",
|
|
77
|
+
"",
|
|
78
|
+
"",
|
|
79
|
+
`${c.bold}${c.RED} ██████╗ ██╗███████╗██████╗ ██████╗ ███████╗████████╗${c.r}`,
|
|
80
|
+
`${c.bold}${c.RED} ██╔══██╗██║██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝${c.r}`,
|
|
81
|
+
`${c.bold}${c.RED} ██████╔╝██║█████╗ ██████╔╝██║ ██║███████╗ ██║ ${c.r}`,
|
|
82
|
+
`${c.bold}${c.RED} ██╔══██╗██║██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ${c.r}`,
|
|
83
|
+
`${c.bold}${c.RED} ██████╔╝██║██║ ██║ ██║╚██████╔╝███████║ ██║ ${c.r}`,
|
|
84
|
+
`${c.bold}${c.RED} ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ${c.r}`,
|
|
85
|
+
"",
|
|
86
|
+
`${c.dim}${c.red} ── SECURE BRIDGE PROTOCOL ──────────────── v${version} ──${c.r}`,
|
|
87
|
+
"",
|
|
88
|
+
"",
|
|
89
|
+
"",
|
|
90
|
+
"",
|
|
91
|
+
"",
|
|
92
|
+
"",
|
|
93
|
+
"",
|
|
94
|
+
"",
|
|
95
|
+
"",
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const globeW = globe.length;
|
|
99
|
+
const bannerW = bannerLines.length;
|
|
100
|
+
const rows = Math.max(globeW, bannerW);
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < rows; i++) {
|
|
103
|
+
const gLine = i < globe.length ? globe[i] : "";
|
|
104
|
+
const bLine = i < bannerLines.length ? bannerLines[i] : "";
|
|
105
|
+
log(` ${gLine} ${bLine}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Styled help
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
function printHelp() {
|
|
113
|
+
log("");
|
|
114
|
+
log(` ${c.bold}${c.RED}BIFROST${c.r} ${c.dim}${c.red}── SECURE BRIDGE PROTOCOL${c.r}`);
|
|
115
|
+
log("");
|
|
116
|
+
log(` ${c.dim}MCP browser bridge daemon${c.r}`);
|
|
117
|
+
log("");
|
|
118
|
+
log(` ${c.white}${c.bold}USAGE${c.r}`);
|
|
119
|
+
log(` ${c.gray}$${c.r} bifrost ${c.dim}[options]${c.r}`);
|
|
120
|
+
log("");
|
|
121
|
+
log(` ${c.white}${c.bold}OPTIONS${c.r}`);
|
|
122
|
+
log(` ${c.RED}--port${c.r} ${c.dim}<port>${c.r} WebSocket port ${c.gray}(default: 3099)${c.r}`);
|
|
123
|
+
log(` ${c.RED}--timeout${c.r} ${c.dim}<secs>${c.r} Tool call timeout ${c.gray}(default: 120)${c.r}`);
|
|
124
|
+
log(` ${c.RED}--no-auth${c.r} Disable token auth ${c.gray}(dev only)${c.r}`);
|
|
125
|
+
log(` ${c.RED}--help${c.r}, ${c.RED}-h${c.r} Show this message`);
|
|
126
|
+
log(` ${c.RED}--version${c.r}, ${c.RED}-v${c.r} Show version`);
|
|
127
|
+
log("");
|
|
128
|
+
log(` ${c.dim}${c.red}────────────────────────────────────────────────────${c.r}`);
|
|
129
|
+
log("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// CLI argument parsing
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
const args = process.argv.slice(2);
|
|
136
|
+
|
|
137
|
+
function flag(name) { return args.includes(name); }
|
|
138
|
+
function option(name, fallback) {
|
|
139
|
+
const idx = args.indexOf(name);
|
|
140
|
+
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : fallback;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let VERSION = "0.0.1";
|
|
144
|
+
try {
|
|
145
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
146
|
+
VERSION = pkg.version || VERSION;
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
if (flag("--help") || flag("-h")) {
|
|
150
|
+
printHelp();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (flag("--version") || flag("-v")) {
|
|
155
|
+
log(`${c.bold}${c.RED}bifrost${c.r} ${c.dim}${VERSION}${c.r}`);
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
10
159
|
const { MiniWebSocketServer } = await import(
|
|
11
160
|
new URL(`file://${join(__dirname, "..", "lib", "websocket.js")}`)
|
|
12
161
|
).catch(() =>
|
|
@@ -16,20 +165,26 @@ const { MiniWebSocketServer } = await import(
|
|
|
16
165
|
// ---------------------------------------------------------------------------
|
|
17
166
|
// Config
|
|
18
167
|
// ---------------------------------------------------------------------------
|
|
19
|
-
const PORT = parseInt(process.env.PORT || "3099", 10);
|
|
20
|
-
const TIMEOUT_MS =
|
|
168
|
+
const PORT = parseInt(option("--port", process.env.PORT || "3099"), 10);
|
|
169
|
+
const TIMEOUT_MS = parseInt(option("--timeout", "120"), 10) * 1000;
|
|
170
|
+
const NO_AUTH = flag("--no-auth");
|
|
21
171
|
const MAX_MESSAGE_SIZE = 1 * 1024 * 1024; // 1MB
|
|
22
172
|
const MAX_PENDING_CALLS = 100;
|
|
23
173
|
const MAX_CALLS_PER_MIN = 120;
|
|
24
174
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
25
175
|
const HEARTBEAT_TIMEOUT = 10_000;
|
|
26
176
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
177
|
+
function isLocalOrigin(origin) {
|
|
178
|
+
if (!origin) return true;
|
|
179
|
+
try {
|
|
180
|
+
const url = new URL(origin);
|
|
181
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
31
186
|
|
|
32
|
-
const AUTH_TOKEN = randomBytes(32).toString("base64url");
|
|
187
|
+
const AUTH_TOKEN = NO_AUTH ? null : randomBytes(32).toString("base64url");
|
|
33
188
|
|
|
34
189
|
// ---------------------------------------------------------------------------
|
|
35
190
|
// State
|
|
@@ -47,6 +202,10 @@ const rateMap = new Map();
|
|
|
47
202
|
// ---------------------------------------------------------------------------
|
|
48
203
|
function now() { return Date.now(); }
|
|
49
204
|
|
|
205
|
+
function timestamp() {
|
|
206
|
+
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
207
|
+
}
|
|
208
|
+
|
|
50
209
|
function rateLimit(key) {
|
|
51
210
|
const bucket = rateMap.get(key) || { count: 0, ts: now() };
|
|
52
211
|
if (now() - bucket.ts > 60_000) {
|
|
@@ -170,23 +329,46 @@ function handleMcp(msg) {
|
|
|
170
329
|
// WebSocket server
|
|
171
330
|
// ---------------------------------------------------------------------------
|
|
172
331
|
const wss = new MiniWebSocketServer({ port: PORT }, () => {
|
|
173
|
-
|
|
174
|
-
|
|
332
|
+
printBanner(VERSION);
|
|
333
|
+
|
|
334
|
+
log(` ${c.dim}${c.red}────────────────────────────────────────────────────${c.r}`);
|
|
335
|
+
log("");
|
|
336
|
+
log(` ${c.gray}PORT${c.r} ${c.white}${PORT}${c.r}`);
|
|
337
|
+
log(` ${c.gray}TIMEOUT${c.r} ${c.white}${TIMEOUT_MS / 1000}s${c.r}`);
|
|
338
|
+
log(` ${c.gray}AUTH${c.r} ${AUTH_TOKEN ? `${c.RED}ENABLED${c.r}` : `${c.dim}DISABLED${c.r}`}`);
|
|
339
|
+
log("");
|
|
340
|
+
|
|
341
|
+
// These lines must remain parseable by tests
|
|
342
|
+
log(` Secure MCP bridge running on ws://localhost:${PORT}`);
|
|
343
|
+
if (AUTH_TOKEN) {
|
|
344
|
+
// Token value stays raw (no ANSI) so test regex can capture it
|
|
345
|
+
log(` Auth token (use as header): ${AUTH_TOKEN}`);
|
|
346
|
+
} else {
|
|
347
|
+
log(` ${c.dim}Auth disabled (--no-auth)${c.r}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
log("");
|
|
351
|
+
log(` ${c.dim}${c.red}────────────────────────────────────────────────────${c.r}`);
|
|
352
|
+
log(` ${c.gray}Waiting for connections...${c.r}`);
|
|
353
|
+
log("");
|
|
175
354
|
});
|
|
176
355
|
|
|
177
356
|
wss.on("connection", (ws, req) => {
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
if (origin && !ALLOWED_ORIGINS.has(origin)) {
|
|
357
|
+
// Origin check — allow any localhost origin (any port)
|
|
358
|
+
if (!isLocalOrigin(req.headers.origin)) {
|
|
181
359
|
ws.close();
|
|
182
360
|
return;
|
|
183
361
|
}
|
|
184
362
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
363
|
+
// Auth check (unless --no-auth) — supports both query param and subprotocol header
|
|
364
|
+
if (AUTH_TOKEN) {
|
|
365
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
366
|
+
const queryToken = url.searchParams.get("token");
|
|
367
|
+
const headerToken = req.headers["sec-websocket-protocol"];
|
|
368
|
+
if (queryToken !== AUTH_TOKEN && headerToken !== AUTH_TOKEN) {
|
|
369
|
+
ws.close();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
190
372
|
}
|
|
191
373
|
|
|
192
374
|
const connId = `conn_${++connectionIdCounter}`;
|
|
@@ -198,6 +380,7 @@ wss.on("connection", (ws, req) => {
|
|
|
198
380
|
};
|
|
199
381
|
|
|
200
382
|
connections.set(connId, conn);
|
|
383
|
+
log(` ${c.gray}${timestamp()}${c.r} ${c.RED}+${c.r} ${c.white}${connId}${c.r} ${c.dim}connected${c.r}`);
|
|
201
384
|
|
|
202
385
|
// heartbeat
|
|
203
386
|
const interval = setInterval(() => {
|
|
@@ -227,6 +410,8 @@ wss.on("connection", (ws, req) => {
|
|
|
227
410
|
conn.tools.set(tool.name, tool);
|
|
228
411
|
}
|
|
229
412
|
|
|
413
|
+
log(` ${c.gray}${timestamp()}${c.r} ${c.RED}*${c.r} ${c.white}${connId}${c.r} ${c.dim}registered${c.r} ${c.RED}${conn.tools.size}${c.r} ${c.dim}tools${c.r}`);
|
|
414
|
+
|
|
230
415
|
sendMcp({
|
|
231
416
|
jsonrpc: "2.0",
|
|
232
417
|
method: "notifications/tools/list_changed",
|
|
@@ -267,10 +452,11 @@ wss.on("connection", (ws, req) => {
|
|
|
267
452
|
}
|
|
268
453
|
|
|
269
454
|
connections.delete(connId);
|
|
455
|
+
log(` ${c.gray}${timestamp()}${c.r} ${c.dim}-${c.r} ${c.dim}${connId} disconnected${c.r}`);
|
|
270
456
|
|
|
271
457
|
sendMcp({
|
|
272
458
|
jsonrpc: "2.0",
|
|
273
459
|
method: "notifications/tools/list_changed",
|
|
274
460
|
});
|
|
275
461
|
});
|
|
276
|
-
});
|
|
462
|
+
});
|