@oimdb/mcp 1.2.0 → 2.0.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/dist/index.d.ts CHANGED
@@ -1,37 +1,24 @@
1
- /**
2
- * The readable shape the MCP tools need from a registry — `OIMDevRegistry` from
3
- * `@oimdb/devtools` already satisfies it. Declared structurally (following the
4
- * existing `IOIMDev*Like` convention) so `@oimdb/mcp` need not depend on
5
- * `@oimdb/devtools`; it is not a separate registry implementation.
6
- */
7
- interface IOIMDevRegistryLike {
1
+ interface IOIMDevRegistry {
8
2
  inspect(): unknown;
9
3
  dumpString(): string;
10
4
  }
5
+
11
6
  type TOIMDevMcpOptions = {
12
- /** Port for the WebSocket bridge that browser tabs connect to. Default: 7432 */
7
+ /** Default 7432; unused when `registry` is given — no bridge is started. */
13
8
  wsPort?: number;
14
- /**
15
- * An in-process registry to read directly (your `OIMDevRegistry`). When
16
- * provided, the tools answer from it without a browser/WebSocket bridge
17
- * (no `wsPort` server started).
18
- */
19
- registry?: IOIMDevRegistryLike;
20
- /**
21
- * Path to a module that builds the model in this process for offline
22
- * introspection. The module must export a `registry` (or a default export,
23
- * or a zero-arg factory) exposing `inspect()` and `dumpString()`.
24
- * Also settable via the `OIMDB_MODEL_MODULE` env var for the CLI.
25
- */
9
+ registry?: IOIMDevRegistry;
10
+ /** Must export `oimDevRegistry`, a default, or a zero-arg factory. Env: `OIMDB_MODEL_MODULE`. */
26
11
  modelModulePath?: string;
12
+ /** LOOPBACK BY DEFAULT: a `WebSocketServer` built without a host binds everything. */
13
+ host?: string;
14
+ /** On top of the loopback origins. Exact, never `'*'`. */
15
+ allowedOrigins?: readonly string[];
27
16
  };
28
17
 
29
18
  declare class OIMDevMcpServer {
30
19
  private readonly mcpServer;
31
20
  private readonly wsPort;
32
- private wss;
33
- private latestBrowser;
34
- private readonly pending;
21
+ private readonly bridge;
35
22
  private inProcessRegistry;
36
23
  private readonly modelModulePath?;
37
24
  private modelModuleLoaded;
@@ -46,8 +33,7 @@ declare class OIMDevMcpServer {
46
33
  /** Reads the human-readable dump from the in-process model or the browser. */
47
34
  private readDump;
48
35
  private registerTools;
49
- private queryBrowser;
50
- private setupWebSocketServer;
36
+ private requestBrowser;
51
37
  start(): Promise<void>;
52
38
  }
53
39
 
@@ -56,18 +42,46 @@ type TOIMDevUIServerOptions = {
56
42
  uiPort?: number;
57
43
  uiDir: string;
58
44
  open?: boolean;
45
+ /**
46
+ * The interface both ports bind to. LOOPBACK BY DEFAULT: a tool that hands
47
+ * out the application's entire data model has no business being reachable
48
+ * from the café Wi-Fi, and `listen` without a host binds every interface.
49
+ *
50
+ * Set it — `'0.0.0.0'` — when the browser genuinely runs somewhere else: a
51
+ * container, a VM, a phone. That is an opt-in to everyone who can route
52
+ * there reading the model.
53
+ */
54
+ host?: string;
55
+ /**
56
+ * Page origins allowed onto the bridge and allowed to read `/api/inspect`,
57
+ * on top of the loopback origins allowed by default.
58
+ *
59
+ * Exact origins (`'http://app.test:3000'`). Never `'*'`: with that on every
60
+ * response, any page in any tab the developer has open can read every entity
61
+ * of every registered collection.
62
+ */
63
+ allowedOrigins?: readonly string[];
59
64
  };
65
+
60
66
  declare class OIMDevUIServer {
61
- private readonly wsPort;
62
67
  private readonly uiPort;
63
- private readonly uiDir;
64
68
  private readonly shouldOpen;
65
- private wss;
66
- private latestBrowser;
67
- private readonly pending;
69
+ private readonly host;
70
+ private readonly allowedOrigins;
71
+ private readonly devUIAssetCatalog;
72
+ private readonly bridge;
68
73
  constructor(options: TOIMDevUIServerOptions);
69
- private queryBrowser;
70
- private setupBridge;
74
+ /**
75
+ * While bound to loopback, answer only what was addressed to a loopback
76
+ * name.
77
+ *
78
+ * Without this, a page on any site can point a hostname it controls at
79
+ * 127.0.0.1 and become same-origin with this server. The `Host` header is
80
+ * the part of that a browser cannot forge. Binding elsewhere is an explicit
81
+ * decision, so the check stands down there.
82
+ */
83
+ private isRequestAddressedHere;
84
+ private requestBrowser;
71
85
  private setupHTTP;
72
86
  private openURL;
73
87
  start(): void;
@@ -76,4 +90,4 @@ declare class OIMDevUIServer {
76
90
  declare function createOIMDevMcpServer(options?: TOIMDevMcpOptions): OIMDevMcpServer;
77
91
  declare function createOIMDevUIServer(options: TOIMDevUIServerOptions): OIMDevUIServer;
78
92
 
79
- export { type IOIMDevRegistryLike, OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
93
+ export { type IOIMDevRegistry, OIMDevMcpServer, OIMDevUIServer, type TOIMDevMcpOptions, type TOIMDevUIServerOptions, createOIMDevMcpServer, createOIMDevUIServer };
package/dist/index.js CHANGED
@@ -1,31 +1,225 @@
1
1
  // src/core/OIMDevMcpServer.ts
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { WebSocketServer } from "ws";
5
4
  import { pathToFileURL } from "url";
6
5
  import { resolve as resolvePath } from "path";
6
+ import { z as z2 } from "zod";
7
+
8
+ // src/core/OIMDevBridge.ts
9
+ import { WebSocketServer } from "ws";
10
+
11
+ // src/core/OIMDevBridgeMessageReader.ts
7
12
  import { z } from "zod";
13
+ var _OIMDevBridgeMessageReader = class _OIMDevBridgeMessageReader {
14
+ read(raw) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ } catch {
19
+ return null;
20
+ }
21
+ const result = _OIMDevBridgeMessageReader.SCHEMA.safeParse(parsed);
22
+ if (!result.success) return null;
23
+ return result.data;
24
+ }
25
+ };
26
+ _OIMDevBridgeMessageReader.SCHEMA = z.discriminatedUnion("type", [
27
+ z.object({
28
+ type: z.literal("hello"),
29
+ url: z.string(),
30
+ title: z.string(),
31
+ timestamp: z.number()
32
+ }),
33
+ z.object({
34
+ type: z.literal("response"),
35
+ id: z.string(),
36
+ data: z.unknown(),
37
+ error: z.string().optional()
38
+ })
39
+ ]);
40
+ var OIMDevBridgeMessageReader = _OIMDevBridgeMessageReader;
41
+
42
+ // src/core/OIMDevLoopbackPolicy.ts
43
+ var _OIMDevLoopbackPolicy = class _OIMDevLoopbackPolicy {
44
+ static isLoopbackHost(host) {
45
+ return _OIMDevLoopbackPolicy.HOSTS.has(host) || host.endsWith(".localhost");
46
+ }
47
+ static isLoopbackAuthority(authority) {
48
+ const host = authority.startsWith("[") ? authority.slice(0, authority.indexOf("]") + 1) : authority.split(":", 1)[0];
49
+ return _OIMDevLoopbackPolicy.isLoopbackHost(host);
50
+ }
51
+ // The literal string `null` is NOT loopback: a sandboxed iframe on any site
52
+ // in the world sends exactly that.
53
+ static isLoopbackOrigin(origin) {
54
+ try {
55
+ return _OIMDevLoopbackPolicy.isLoopbackHost(new URL(origin).hostname);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+ };
61
+ _OIMDevLoopbackPolicy.HOSTS = /* @__PURE__ */ new Set([
62
+ "localhost",
63
+ "127.0.0.1",
64
+ "::1",
65
+ "[::1]"
66
+ ]);
67
+ var OIMDevLoopbackPolicy = _OIMDevLoopbackPolicy;
68
+
69
+ // src/core/OIMDevBridge.ts
70
+ var OIM_REQUEST_TIMEOUT_MS = 5e3;
71
+ var OIMDevBridge = class {
72
+ constructor(bridgeOptions) {
73
+ this.bridgeOptions = bridgeOptions;
74
+ this.messageReader = new OIMDevBridgeMessageReader();
75
+ this.pending = /* @__PURE__ */ new Map();
76
+ this.webSocketServer = null;
77
+ this.devConnectedBrowser = null;
78
+ }
79
+ get connectedBrowser() {
80
+ return this.devConnectedBrowser;
81
+ }
82
+ /**
83
+ * May a page use this bridge?
84
+ *
85
+ * From this machine, or named by whoever started the server. An ABSENT
86
+ * origin is a non-browser client — a CLI, a test — and is allowed; the
87
+ * literal `'null'` is not, because a sandboxed iframe on any site can send
88
+ * exactly that.
89
+ */
90
+ isOriginAllowed(origin) {
91
+ if (origin === void 0 || origin === "") return true;
92
+ if (this.bridgeOptions.allowedOrigins.has(origin)) return true;
93
+ return OIMDevLoopbackPolicy.isLoopbackOrigin(origin);
94
+ }
95
+ request(action) {
96
+ const browser = this.devConnectedBrowser;
97
+ if (browser === null) {
98
+ return Promise.reject(
99
+ new Error(this.bridgeOptions.requestTimeoutMessage)
100
+ );
101
+ }
102
+ return new Promise((resolve, reject) => {
103
+ const id = Math.random().toString(36).slice(2);
104
+ const timer = setTimeout(() => {
105
+ this.pending.delete(id);
106
+ reject(new Error(this.bridgeOptions.requestTimeoutMessage));
107
+ }, OIM_REQUEST_TIMEOUT_MS);
108
+ this.pending.set(id, { resolve, reject, timer });
109
+ browser.ws.send(JSON.stringify({ type: "request", id, action }));
110
+ });
111
+ }
112
+ start() {
113
+ this.webSocketServer = new WebSocketServer({
114
+ port: this.bridgeOptions.wsPort,
115
+ host: this.bridgeOptions.host,
116
+ verifyClient: (info) => this.isOriginAllowed(info.origin)
117
+ });
118
+ this.webSocketServer.on("error", (err) => {
119
+ if (err.code === "EADDRINUSE") {
120
+ this.write(
121
+ `Port ${this.bridgeOptions.wsPort} is already in use.
122
+ `
123
+ );
124
+ process.stderr.write(
125
+ `Set a different port: OIMDB_WS_PORT=7433 ${this.bridgeOptions.portInUseHint}
126
+ `
127
+ );
128
+ process.exit(1);
129
+ }
130
+ this.write(`Bridge error: ${err.message}
131
+ `);
132
+ process.exit(1);
133
+ });
134
+ this.webSocketServer.on("connection", (ws) => {
135
+ ws.on("error", (err) => {
136
+ this.write(`Bridge socket error: ${err.message}
137
+ `);
138
+ });
139
+ ws.on("message", (raw) => this.readFrame(ws, raw.toString()));
140
+ ws.on("close", () => {
141
+ if (this.devConnectedBrowser?.ws === ws) {
142
+ this.devConnectedBrowser = null;
143
+ this.write("Browser disconnected\n");
144
+ }
145
+ });
146
+ });
147
+ this.write(
148
+ `Bridge ready on ws://localhost:${this.bridgeOptions.wsPort} (bound to ${this.bridgeOptions.host})
149
+ `
150
+ );
151
+ }
152
+ readFrame(ws, raw) {
153
+ const devBridgeMessage = this.messageReader.read(raw);
154
+ if (devBridgeMessage === null) {
155
+ this.write("Dropping a client that sent a malformed frame\n");
156
+ ws.close(1007, "Malformed bridge message");
157
+ return;
158
+ }
159
+ if (devBridgeMessage.type === "hello") {
160
+ this.adoptBrowser(ws, devBridgeMessage.url, devBridgeMessage.title);
161
+ return;
162
+ }
163
+ if (this.devConnectedBrowser?.ws !== ws) return;
164
+ const devPendingRequest = this.pending.get(devBridgeMessage.id);
165
+ if (!devPendingRequest) return;
166
+ this.pending.delete(devBridgeMessage.id);
167
+ clearTimeout(devPendingRequest.timer);
168
+ if (devBridgeMessage.error) devPendingRequest.reject(new Error(devBridgeMessage.error));
169
+ else devPendingRequest.resolve(devBridgeMessage.data);
170
+ }
171
+ /**
172
+ * Newest tab wins — a reload is the ordinary reason for a second `hello` —
173
+ * but the tab it displaces is CLOSED and the swap is printed. Overwriting the
174
+ * field in place leaves the displaced socket open for ever and lets a second
175
+ * connection take the bridge over in silence.
176
+ */
177
+ adoptBrowser(ws, url, title) {
178
+ const previousDevConnectedBrowser = this.devConnectedBrowser;
179
+ this.devConnectedBrowser = { ws, url, title };
180
+ if (previousDevConnectedBrowser !== null && previousDevConnectedBrowser.ws !== ws) {
181
+ this.write(
182
+ `Bridge taken over by "${title}" (${url}); dropping "${previousDevConnectedBrowser.title}"
183
+ `
184
+ );
185
+ previousDevConnectedBrowser.ws.close(1e3, "Replaced by a newer OIMDB DevTools tab");
186
+ return;
187
+ }
188
+ this.write(`Browser connected: ${title} (${url})
189
+ `);
190
+ }
191
+ write(line) {
192
+ process.stderr.write(`${this.bridgeOptions.logPrefix} ${line}`);
193
+ }
194
+ };
195
+
196
+ // src/core/OIMDevMcpServer.ts
8
197
  async function loadRegistryFromModule(path) {
9
198
  const url = pathToFileURL(resolvePath(path)).href;
10
199
  const mod = await import(url);
11
- let candidate = mod.registry ?? mod.default ?? mod;
200
+ let candidate = mod.oimDevRegistry ?? mod.default ?? mod;
12
201
  if (typeof candidate === "function") {
13
202
  candidate = await candidate();
14
203
  }
15
204
  if (!candidate || typeof candidate.inspect !== "function" || typeof candidate.dumpString !== "function") {
16
205
  throw new Error(
17
- `[OIMDB MCP] Module "${path}" must export a registry exposing inspect() and dumpString() (as a "registry" or default export, or a zero-arg factory returning one).`
206
+ `[OIMDB MCP] Module "${path}" must export a registry exposing inspect() and dumpString() (as an "oimDevRegistry" or default export, or a zero-arg factory returning one).`
18
207
  );
19
208
  }
20
209
  return candidate;
21
210
  }
22
211
  var OIMDevMcpServer = class {
23
212
  constructor(options = {}) {
24
- this.wss = null;
25
- this.latestBrowser = null;
26
- this.pending = /* @__PURE__ */ new Map();
27
213
  this.modelModuleLoaded = false;
28
214
  this.wsPort = options.wsPort ?? 7432;
215
+ this.bridge = new OIMDevBridge({
216
+ wsPort: this.wsPort,
217
+ host: options.host ?? "127.0.0.1",
218
+ allowedOrigins: new Set(options.allowedOrigins ?? []),
219
+ logPrefix: "[OIMDB MCP]",
220
+ portInUseHint: "npx @oimdb/mcp",
221
+ requestTimeoutMessage: "Browser response timeout (5s). Is the page active?"
222
+ });
29
223
  this.inProcessRegistry = options.registry ?? null;
30
224
  this.modelModulePath = options.modelModulePath;
31
225
  this.mcpServer = new McpServer(
@@ -54,17 +248,17 @@ var OIMDevMcpServer = class {
54
248
  async readInspect() {
55
249
  const registry = await this.getInProcessRegistry();
56
250
  if (registry) return registry.inspect();
57
- return this.queryBrowser("inspect");
251
+ return this.requestBrowser("inspect");
58
252
  }
59
253
  /** Reads the human-readable dump from the in-process model or the browser. */
60
254
  async readDump() {
61
255
  const registry = await this.getInProcessRegistry();
62
256
  if (registry) return registry.dumpString();
63
- return await this.queryBrowser("dump");
257
+ return await this.requestBrowser("dump");
64
258
  }
65
259
  registerTools() {
66
260
  const server = this.mcpServer;
67
- const nameSchema = { name: z.string().describe("Registered name") };
261
+ const nameSchema = { name: z2.string().describe("Registered name") };
68
262
  server.registerTool("oimdb_inspect", {
69
263
  description: "Get full state of all registered OIMDB collections and computeds. Returns structured JSON with entity counts, field names, index keys, relations, and computed statuses. Source is the connected browser tab, or an in-process model when one is configured (offline/static \u2014 field names appear only if the model holds sample entities)."
70
264
  }, async () => {
@@ -72,7 +266,7 @@ var OIMDevMcpServer = class {
72
266
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
73
267
  });
74
268
  server.registerTool("oimdb_dump", {
75
- description: "Get a human-readable text summary of OIMDB state. Same output as registry.dump(). Reads the connected browser tab or an in-process model when configured."
269
+ description: "Get a human-readable text summary of OIMDB state. Same output as oimDevRegistry.dump(). Reads the connected browser tab or an in-process model when configured."
76
270
  }, async () => {
77
271
  const text = await this.readDump();
78
272
  return { content: [{ type: "text", text }] };
@@ -102,76 +296,22 @@ var OIMDevMcpServer = class {
102
296
  return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
103
297
  });
104
298
  }
105
- async queryBrowser(action) {
106
- if (!this.latestBrowser) {
299
+ requestBrowser(action) {
300
+ if (this.bridge.connectedBrowser === null) {
107
301
  throw new Error(
108
302
  `No browser tab connected to the OIMDB MCP bridge.
109
303
 
110
304
  Live mode \u2014 add to your debug entry point:
111
- import { registry } from '@oimdb/devtools';
112
- registry.connect(); // connects to ws://localhost:${this.wsPort}
305
+ import { oimDevRegistry } from '@oimdb/devtools';
306
+ oimDevRegistry.connect(); // connects to ws://localhost:${this.wsPort}
113
307
  Then reload the page.
114
308
 
115
309
  Offline mode \u2014 point the server at a model module instead:
116
310
  OIMDB_MODEL_MODULE=./path/to/model.js npx @oimdb/mcp
117
- (the module exports a "registry" with inspect()/dumpString()).`
311
+ (the module exports an "oimDevRegistry" with inspect()/dumpString()).`
118
312
  );
119
313
  }
120
- return new Promise((resolve, reject) => {
121
- const id = Math.random().toString(36).slice(2);
122
- const timer = setTimeout(() => {
123
- this.pending.delete(id);
124
- reject(new Error("Browser response timeout (5s). Is the page active?"));
125
- }, 5e3);
126
- this.pending.set(id, { resolve, reject, timer });
127
- this.latestBrowser.ws.send(JSON.stringify({ type: "request", id, action }));
128
- });
129
- }
130
- setupWebSocketServer() {
131
- this.wss = new WebSocketServer({ port: this.wsPort });
132
- this.wss.on("error", (err) => {
133
- if (err.code === "EADDRINUSE") {
134
- process.stderr.write(
135
- `[OIMDB MCP] Port ${this.wsPort} is already in use.
136
- Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp
137
- `
138
- );
139
- process.exit(1);
140
- }
141
- process.stderr.write(`[OIMDB MCP] WebSocket server error: ${err.message}
142
- `);
143
- process.exit(1);
144
- });
145
- this.wss.on("connection", (ws) => {
146
- ws.on("message", (raw) => {
147
- const msg = JSON.parse(raw.toString());
148
- if (msg.type === "hello") {
149
- this.latestBrowser = { ws, url: msg.url, title: msg.title };
150
- process.stderr.write(`[OIMDB MCP] Browser connected: ${msg.title} (${msg.url})
151
- `);
152
- return;
153
- }
154
- if (msg.type === "response") {
155
- const pending = this.pending.get(msg.id);
156
- if (!pending) return;
157
- this.pending.delete(msg.id);
158
- clearTimeout(pending.timer);
159
- if (msg.error) {
160
- pending.reject(new Error(msg.error));
161
- } else {
162
- pending.resolve(msg.data);
163
- }
164
- }
165
- });
166
- ws.on("close", () => {
167
- if (this.latestBrowser?.ws === ws) {
168
- this.latestBrowser = null;
169
- process.stderr.write("[OIMDB MCP] Browser disconnected\n");
170
- }
171
- });
172
- });
173
- process.stderr.write(`[OIMDB MCP] Bridge ready on ws://localhost:${this.wsPort}
174
- `);
314
+ return this.bridge.request(action);
175
315
  }
176
316
  async start() {
177
317
  const registry = await this.getInProcessRegistry();
@@ -180,7 +320,7 @@ Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp
180
320
  "[OIMDB MCP] In-process model loaded \u2014 serving offline (no browser bridge).\n"
181
321
  );
182
322
  } else {
183
- this.setupWebSocketServer();
323
+ this.bridge.start();
184
324
  }
185
325
  const transport = new StdioServerTransport();
186
326
  await this.mcpServer.connect(transport);
@@ -189,90 +329,88 @@ Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp
189
329
 
190
330
  // src/core/OIMDevUIServer.ts
191
331
  import { createServer } from "http";
192
- import { readFileSync } from "fs";
193
- import { join, extname } from "path";
194
332
  import { exec } from "child_process";
195
- import { WebSocketServer as WebSocketServer2 } from "ws";
196
- var MIME = {
197
- ".html": "text/html; charset=utf-8",
198
- ".js": "text/javascript; charset=utf-8",
199
- ".css": "text/css; charset=utf-8"
333
+
334
+ // src/core/OIMDevUIAssetCatalog.ts
335
+ import { readFileSync } from "fs";
336
+ import { join } from "path";
337
+ var _OIMDevUIAssetCatalog = class _OIMDevUIAssetCatalog {
338
+ constructor(uiDir) {
339
+ this.uiDir = uiDir;
340
+ }
341
+ resolve(requestUrl) {
342
+ const path = requestUrl.split("?", 1)[0].split("#", 1)[0];
343
+ return _OIMDevUIAssetCatalog.ASSETS.get(path) ?? null;
344
+ }
345
+ // Per request, so the UI stays editable; the constant file name is what
346
+ // makes that safe, not the timing.
347
+ read(asset) {
348
+ return readFileSync(join(this.uiDir, asset.fileName));
349
+ }
200
350
  };
351
+ _OIMDevUIAssetCatalog.ASSETS = /* @__PURE__ */ new Map([
352
+ ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
353
+ ["/index.html", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
354
+ ["/app.js", { fileName: "app.js", contentType: "text/javascript; charset=utf-8" }],
355
+ ["/app.css", { fileName: "app.css", contentType: "text/css; charset=utf-8" }]
356
+ ]);
357
+ var OIMDevUIAssetCatalog = _OIMDevUIAssetCatalog;
358
+
359
+ // src/core/OIMDevUIServer.ts
201
360
  var OIMDevUIServer = class {
202
361
  constructor(options) {
203
- this.wss = null;
204
- this.latestBrowser = null;
205
- this.pending = /* @__PURE__ */ new Map();
206
- this.wsPort = options.wsPort ?? 7432;
207
362
  this.uiPort = options.uiPort ?? 7433;
208
- this.uiDir = options.uiDir;
209
363
  this.shouldOpen = options.open ?? true;
210
- }
211
- async queryBrowser(action) {
212
- if (!this.latestBrowser) return null;
213
- return new Promise((resolve, reject) => {
214
- const id = Math.random().toString(36).slice(2);
215
- const timer = setTimeout(() => {
216
- this.pending.delete(id);
217
- reject(new Error("timeout"));
218
- }, 5e3);
219
- this.pending.set(id, { resolve, reject, timer });
220
- this.latestBrowser.ws.send(JSON.stringify({ type: "request", id, action }));
364
+ this.host = options.host ?? "127.0.0.1";
365
+ this.allowedOrigins = new Set(options.allowedOrigins ?? []);
366
+ this.devUIAssetCatalog = new OIMDevUIAssetCatalog(options.uiDir);
367
+ this.bridge = new OIMDevBridge({
368
+ wsPort: options.wsPort ?? 7432,
369
+ host: this.host,
370
+ allowedOrigins: this.allowedOrigins,
371
+ logPrefix: "[OIMDB DevTools]",
372
+ portInUseHint: "npx @oimdb/mcp devtools",
373
+ requestTimeoutMessage: "timeout"
221
374
  });
222
375
  }
223
- setupBridge() {
224
- this.wss = new WebSocketServer2({ port: this.wsPort });
225
- this.wss.on("error", (err) => {
226
- if (err.code === "EADDRINUSE") {
227
- process.stderr.write(
228
- `[OIMDB DevTools] Port ${this.wsPort} is already in use.
229
- Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp devtools
230
- `
231
- );
232
- process.exit(1);
233
- }
234
- process.stderr.write(`[OIMDB DevTools] Bridge error: ${err.message}
235
- `);
236
- process.exit(1);
237
- });
238
- this.wss.on("connection", (ws) => {
239
- ws.on("message", (raw) => {
240
- const msg = JSON.parse(raw.toString());
241
- if (msg.type === "hello") {
242
- this.latestBrowser = { ws, url: msg.url, title: msg.title };
243
- process.stderr.write(`[OIMDB DevTools] Browser connected: ${msg.title} (${msg.url})
244
- `);
245
- return;
246
- }
247
- if (msg.type === "response") {
248
- const req = this.pending.get(msg.id);
249
- if (!req) return;
250
- this.pending.delete(msg.id);
251
- clearTimeout(req.timer);
252
- if (msg.error) req.reject(new Error(msg.error));
253
- else req.resolve(msg.data);
254
- }
255
- });
256
- ws.on("close", () => {
257
- if (this.latestBrowser?.ws === ws) {
258
- this.latestBrowser = null;
259
- process.stderr.write("[OIMDB DevTools] Browser disconnected\n");
260
- }
261
- });
262
- });
263
- process.stderr.write(`[OIMDB DevTools] Bridge on ws://localhost:${this.wsPort}
264
- `);
376
+ /**
377
+ * While bound to loopback, answer only what was addressed to a loopback
378
+ * name.
379
+ *
380
+ * Without this, a page on any site can point a hostname it controls at
381
+ * 127.0.0.1 and become same-origin with this server. The `Host` header is
382
+ * the part of that a browser cannot forge. Binding elsewhere is an explicit
383
+ * decision, so the check stands down there.
384
+ */
385
+ isRequestAddressedHere(req) {
386
+ if (!OIMDevLoopbackPolicy.isLoopbackHost(this.host)) return true;
387
+ const host = req.headers.host;
388
+ return host !== void 0 && OIMDevLoopbackPolicy.isLoopbackAuthority(host);
389
+ }
390
+ requestBrowser(action) {
391
+ if (this.bridge.connectedBrowser === null) return Promise.resolve(null);
392
+ return this.bridge.request(action);
265
393
  }
266
394
  setupHTTP() {
267
395
  const server = createServer((req, res) => {
396
+ if (!this.isRequestAddressedHere(req)) {
397
+ res.writeHead(421);
398
+ res.end("Misdirected request");
399
+ return;
400
+ }
401
+ const origin = req.headers.origin;
402
+ if (origin !== void 0 && this.allowedOrigins.has(origin)) {
403
+ res.setHeader("Access-Control-Allow-Origin", origin);
404
+ res.setHeader("Vary", "Origin");
405
+ }
268
406
  const url = req.url ?? "/";
269
- res.setHeader("Access-Control-Allow-Origin", "*");
270
- if (url === "/api/inspect") {
271
- this.queryBrowser("inspect").then((data) => {
407
+ if (url.split("?", 1)[0] === "/api/inspect") {
408
+ this.requestBrowser("inspect").then((data) => {
272
409
  res.writeHead(200, { "Content-Type": "application/json" });
410
+ const connectedBrowser = this.bridge.connectedBrowser;
273
411
  res.end(JSON.stringify({
274
- connected: !!this.latestBrowser,
275
- tab: this.latestBrowser ? { url: this.latestBrowser.url, title: this.latestBrowser.title } : null,
412
+ connected: connectedBrowser !== null,
413
+ tab: connectedBrowser === null ? null : { url: connectedBrowser.url, title: connectedBrowser.title },
276
414
  data
277
415
  }));
278
416
  }).catch(() => {
@@ -281,11 +419,15 @@ Set a different port: OIMDB_WS_PORT=7433 npx @oimdb/mcp devtools
281
419
  });
282
420
  return;
283
421
  }
284
- const filePath = join(this.uiDir, url === "/" ? "index.html" : url);
422
+ const asset = this.devUIAssetCatalog.resolve(url);
423
+ if (asset === null) {
424
+ res.writeHead(404);
425
+ res.end("Not found");
426
+ return;
427
+ }
285
428
  try {
286
- const content = readFileSync(filePath);
287
- res.writeHead(200, { "Content-Type": MIME[extname(filePath)] ?? "text/plain" });
288
- res.end(content);
429
+ res.writeHead(200, { "Content-Type": asset.contentType });
430
+ res.end(this.devUIAssetCatalog.read(asset));
289
431
  } catch {
290
432
  res.writeHead(404);
291
433
  res.end("Not found");
@@ -301,9 +443,9 @@ Set a different port: OIMDB_UI_PORT=7434 npx @oimdb/mcp devtools
301
443
  process.exit(1);
302
444
  }
303
445
  });
304
- server.listen(this.uiPort, () => {
446
+ server.listen(this.uiPort, this.host, () => {
305
447
  const uiUrl = `http://localhost:${this.uiPort}`;
306
- process.stderr.write(`[OIMDB DevTools] UI ready \u2192 ${uiUrl}
448
+ process.stderr.write(`[OIMDB DevTools] UI ready \u2192 ${uiUrl} (bound to ${this.host})
307
449
  `);
308
450
  if (this.shouldOpen) this.openURL(uiUrl);
309
451
  });
@@ -316,10 +458,10 @@ Set a different port: OIMDB_UI_PORT=7434 npx @oimdb/mcp devtools
316
458
  });
317
459
  }
318
460
  start() {
319
- this.setupBridge();
461
+ this.bridge.start();
320
462
  this.setupHTTP();
321
463
  process.stderr.write(
322
- `[OIMDB DevTools] Add to debug.ts: registry.connect();
464
+ `[OIMDB DevTools] Add to debug.ts: oimDevRegistry.connect();
323
465
  `
324
466
  );
325
467
  }