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