@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.
Files changed (3) hide show
  1. package/README.md +9 -3
  2. package/bin/bifrost +204 -18
  3. 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. Auth is always enabled.
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
- ## Configuration
80
+ ## Options
81
81
 
82
- The daemon reads its port from the `PORT` environment variable (default: `3099`).
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 = 120_000;
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
- const ALLOWED_ORIGINS = new Set([
28
- "http://localhost",
29
- "http://127.0.0.1",
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
- console.error(`Secure MCP bridge running on ws://localhost:${PORT}`);
174
- console.error(`Auth token (use as header): ${AUTH_TOKEN}`);
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
- // 🔐 Origin check
179
- const origin = req.headers.origin;
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
- // 🔐 Header-based auth
186
- const token = req.headers["sec-websocket-protocol"];
187
- if (token !== AUTH_TOKEN) {
188
- ws.close();
189
- return;
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@businessmaps/bifrost",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Bridge any browser web app to Claude Code via MCP",
5
5
  "type": "module",
6
6
  "bin": {