dominus-cli 0.6.0 → 2.1.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/mcp.js CHANGED
@@ -1,16 +1,58 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { z } from 'zod';
5
- import { WebSocket, WebSocketServer } from 'ws';
4
+ import fs3 from 'fs';
5
+ import path3 from 'path';
6
+ import os from 'os';
6
7
  import { EventEmitter } from 'events';
8
+ import { WebSocket, WebSocketServer } from 'ws';
7
9
  import { nanoid } from 'nanoid';
8
- import fs from 'fs';
9
- import initSqlJs from 'sql.js';
10
- import path from 'path';
11
- import os from 'os';
10
+ import { z } from 'zod';
11
+ import { randomUUID, randomBytes, timingSafeEqual, randomInt } from 'crypto';
12
12
 
13
+ var DOMINUS_DIR = path3.join(os.homedir(), ".dominus");
14
+ var CONFIG_PATH = path3.join(DOMINUS_DIR, "config.json");
15
+ path3.join(DOMINUS_DIR, "dominus.db");
16
+ path3.join(DOMINUS_DIR, "rules.json");
17
+ var DEFAULTS = {
18
+ provider: "openai",
19
+ apiBaseUrl: "",
20
+ model: "",
21
+ port: 18088,
22
+ maxToolIterations: 25,
23
+ maxVerifyRetries: 3,
24
+ tokenBudget: 128e3,
25
+ autoIndex: true,
26
+ autoLearn: true,
27
+ userName: "Developer"
28
+ };
29
+ function ensureDir() {
30
+ if (!fs3.existsSync(DOMINUS_DIR)) {
31
+ fs3.mkdirSync(DOMINUS_DIR, { recursive: true });
32
+ }
33
+ }
34
+ function readJson(filePath, defaults) {
35
+ try {
36
+ if (fs3.existsSync(filePath)) {
37
+ const raw = fs3.readFileSync(filePath, "utf-8");
38
+ return { ...defaults, ...JSON.parse(raw) };
39
+ }
40
+ } catch {
41
+ }
42
+ return { ...defaults };
43
+ }
44
+ function loadConfig() {
45
+ ensureDir();
46
+ return readJson(CONFIG_PATH, DEFAULTS);
47
+ }
48
+ function getDominusDir() {
49
+ ensureDir();
50
+ return DOMINUS_DIR;
51
+ }
52
+ var DOMINUS_PROTOCOL_VERSION = 2;
53
+ var MAX_MESSAGE_BYTES = 1024 * 1024;
13
54
  var StudioMsg = {
55
+ HELLO: "studio:hello",
14
56
  CONNECTED: "studio:connected",
15
57
  DISCONNECTED: "studio:disconnected",
16
58
  EXPLORER: "studio:explorer",
@@ -21,421 +63,206 @@ var StudioMsg = {
21
63
  SCRIPT_CHANGED: "studio:script:changed",
22
64
  RESPONSE: "studio:response",
23
65
  REFLECTION: "studio:reflection",
24
- TEST_RESULTS: "studio:test:results"
66
+ TEST_RESULTS: "studio:test:results",
67
+ PAIRING_REQUIRED: "server:pairing_required",
68
+ AUTHENTICATED: "server:authenticated",
69
+ AUTH_REJECTED: "server:auth_rejected"
70
+ };
71
+ var ControllerMsg = {
72
+ HELLO: "controller:hello",
73
+ WELCOME: "controller:welcome",
74
+ RELAY: "controller:relay",
75
+ TARGET_STATE: "controller:target_state",
76
+ SET_ACTIVE_CONNECTION: "controller:set_active_connection",
77
+ LIST_PENDING: "controller:list_pending",
78
+ APPROVE_CONNECTION: "controller:approve_connection"
25
79
  };
26
80
  var CliMsg = {
27
- GET_EXPLORER: "cli:get:explorer",
28
- GET_SCRIPT: "cli:get:script",
29
- SET_SCRIPT: "cli:set:script",
30
- RUN_CODE: "cli:run",
31
- GET_PROPERTIES: "cli:get:properties",
32
- GET_DESCENDANTS_PROPERTIES: "cli:get:descendants:properties",
33
- SET_PROPERTIES: "cli:set:properties",
34
- INSERT_INSTANCE: "cli:insert",
35
- DELETE_INSTANCE: "cli:delete",
36
- GET_SELECTION: "cli:get:selection",
37
- SELECT: "cli:select",
38
- SEARCH_SCRIPTS: "cli:search:scripts",
39
- GET_OUTPUT: "cli:get:output",
40
- RUN_TESTS: "cli:run:tests",
41
- EXECUTE_RUN_TEST: "cli:test:run",
42
- EXECUTE_PLAY_TEST: "cli:test:play",
43
- END_TEST: "cli:test:end",
44
- BUILD_UI: "cli:build:ui",
45
- GET_REFLECTION: "cli:get:reflection",
46
- UPLOAD_ASSET: "cli:upload:asset",
47
- SERIALIZE_UI: "cli:serialize:ui",
48
- MOVE_INSTANCE: "cli:move:instance",
49
- UNDO: "cli:undo",
50
- REDO: "cli:redo",
51
- BULK_SET_PROPERTIES: "cli:bulk:set:properties",
52
- FIND_REPLACE_SCRIPTS: "cli:find:replace:scripts"
81
+ V2_GET_TREE: "studio:v2:get_tree",
82
+ V2_INSPECT: "studio:v2:inspect",
83
+ V2_GET_SELECTION: "studio:v2:get_selection",
84
+ V2_READ_SCRIPT: "studio:v2:read_script",
85
+ V2_UPDATE_SCRIPT: "studio:v2:update_script",
86
+ V2_APPLY: "studio:v2:apply",
87
+ V2_DELETE: "studio:v2:delete",
88
+ V2_BUILD_UI: "studio:v2:build_ui",
89
+ V2_SNAPSHOT_UI: "studio:v2:snapshot_ui",
90
+ V2_RUN_TEST: "studio:v2:run_test",
91
+ V2_GET_OUTPUT: "studio:v2:get_output",
92
+ V2_GET_REFLECTION: "studio:v2:get_reflection"
53
93
  };
54
94
  function createMessage(type, payload) {
55
95
  return { id: nanoid(), type, payload, ts: Date.now() };
56
96
  }
57
97
  function parseMessage(raw) {
98
+ if (Buffer.byteLength(raw, "utf8") > MAX_MESSAGE_BYTES) {
99
+ throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
100
+ }
58
101
  try {
59
- return JSON.parse(raw);
102
+ const parsed = JSON.parse(raw);
103
+ return wsMessageSchema.parse(parsed);
60
104
  } catch {
61
105
  throw new Error(`Failed to parse WebSocket message: ${raw.slice(0, 100)}`);
62
106
  }
63
107
  }
64
108
  function serializeMessage(msg) {
65
- return JSON.stringify(msg);
66
- }
67
-
68
- // src/transport/ws-server.ts
69
- var DominusServer = class extends EventEmitter {
70
- wss = null;
71
- /** All connected Studio instances, keyed by placeId (0 for unknown) */
72
- studioClients = /* @__PURE__ */ new Map();
73
- /** Reverse lookup: ws → placeId */
74
- wsToPlaceId = /* @__PURE__ */ new Map();
75
- /** Which placeId to target. 0 = auto-select if only one is connected. */
76
- activePlaceId = 0;
77
- controllers = /* @__PURE__ */ new Set();
78
- pendingRequests = /* @__PURE__ */ new Map();
79
- relayRequests = /* @__PURE__ */ new Map();
80
- port;
81
- constructor(port = 18088) {
82
- super();
83
- this.port = port;
84
- }
85
- start(maxRetries = 0) {
86
- return new Promise((resolve, reject) => {
87
- const tryPort = (port, attempt) => {
88
- const wss = new WebSocketServer({ port });
89
- wss.on("listening", () => {
90
- this.wss = wss;
91
- this.port = port;
92
- this.wss.on("connection", (ws) => this.handleConnection(ws));
93
- this.emit("server:started", { port });
94
- resolve();
95
- });
96
- wss.on("error", (err) => {
97
- if (err.code === "EADDRINUSE" && attempt < maxRetries) {
98
- wss.close();
99
- tryPort(port + 1, attempt + 1);
100
- } else {
101
- this.emit("server:error", err);
102
- reject(err);
103
- }
104
- });
105
- };
106
- tryPort(this.port, 0);
107
- });
108
- }
109
- handleConnection(ws) {
110
- let identified = false;
111
- ws.on("message", (raw) => {
112
- try {
113
- const msg = parseMessage(raw.toString());
114
- if (!identified) {
115
- if (msg.type === StudioMsg.CONNECTED) {
116
- identified = true;
117
- this.setStudioClient(ws, msg);
118
- return;
119
- }
120
- if (msg.type === "controller:connect") {
121
- identified = true;
122
- this.addController(ws);
123
- ws.send(serializeMessage(createMessage("controller:welcome", {
124
- studioConnected: this.isConnected(),
125
- port: this.port,
126
- connections: this.listConnections(),
127
- activePlaceId: this.activePlaceId
128
- })));
129
- return;
130
- }
131
- }
132
- if (this.controllers.has(ws)) {
133
- this.handleControllerMessage(ws, msg);
134
- } else {
135
- this.handleStudioMessage(ws, msg);
136
- }
137
- } catch (err) {
138
- this.emit("server:error", err);
139
- }
140
- });
141
- ws.on("close", () => {
142
- if (this.controllers.has(ws)) {
143
- this.controllers.delete(ws);
144
- for (const [id, controllerWs] of this.relayRequests) {
145
- if (controllerWs === ws) this.relayRequests.delete(id);
146
- }
147
- } else {
148
- const placeId = this.wsToPlaceId.get(ws);
149
- if (placeId !== void 0) {
150
- const conn = this.studioClients.get(placeId);
151
- this.studioClients.delete(placeId);
152
- this.wsToPlaceId.delete(ws);
153
- this.emit("studio:disconnected", { placeId, placeName: conn?.placeName });
154
- this.broadcastToControllers("studio:disconnected", { placeId, placeName: conn?.placeName });
155
- for (const [id, pending] of this.pendingRequests) {
156
- clearTimeout(pending.timer);
157
- pending.reject(new Error(`Studio disconnected (place ${placeId})`));
158
- this.pendingRequests.delete(id);
159
- }
160
- if (this.activePlaceId === placeId) {
161
- this.activePlaceId = 0;
162
- }
163
- }
164
- }
165
- });
166
- ws.on("error", (err) => {
167
- this.emit("server:error", err);
168
- });
169
- }
170
- setStudioClient(ws, msg) {
171
- const payload = msg.payload;
172
- const placeId = payload.placeId ?? 0;
173
- const existing = this.studioClients.get(placeId);
174
- if (existing && existing.ws !== ws) {
175
- existing.ws.close();
176
- this.wsToPlaceId.delete(existing.ws);
177
- }
178
- const conn = {
179
- ws,
180
- connectedAt: Date.now(),
181
- studioVersion: payload.studioVersion,
182
- placeId,
183
- placeName: payload.placeName
184
- };
185
- this.studioClients.set(placeId, conn);
186
- this.wsToPlaceId.set(ws, placeId);
187
- this.emit("studio:connected", { ...payload, placeId });
188
- this.broadcastToControllers("studio:connected", { ...payload, placeId });
189
- }
190
- addController(ws) {
191
- this.controllers.add(ws);
192
- this.emit("controller:connected");
193
- }
194
- broadcastToControllers(type, payload) {
195
- const msg = serializeMessage(createMessage(type, payload));
196
- for (const ws of this.controllers) {
197
- if (ws.readyState === WebSocket.OPEN) {
198
- ws.send(msg);
199
- }
200
- }
201
- }
202
- handleControllerMessage(controllerWs, msg) {
203
- const target = this.resolveTarget();
204
- if (!target || target.ws.readyState !== WebSocket.OPEN) {
205
- controllerWs.send(serializeMessage({
206
- id: msg.id,
207
- type: StudioMsg.RESPONSE,
208
- payload: { error: this.getTargetError() },
209
- ts: Date.now()
210
- }));
211
- return;
212
- }
213
- this.relayRequests.set(msg.id, controllerWs);
214
- target.ws.send(serializeMessage(msg));
215
- }
216
- handleStudioMessage(ws, msg) {
217
- if (msg.type === StudioMsg.CONNECTED) {
218
- const payload = msg.payload;
219
- const placeId = this.wsToPlaceId.get(ws);
220
- if (placeId !== void 0) {
221
- const conn = this.studioClients.get(placeId);
222
- if (conn) {
223
- conn.studioVersion = payload.studioVersion;
224
- conn.placeId = payload.placeId;
225
- conn.placeName = payload.placeName;
226
- }
227
- }
228
- this.emit("studio:connected", payload);
229
- this.broadcastToControllers("studio:connected", payload);
230
- return;
231
- }
232
- if (msg.type === StudioMsg.RESPONSE) {
233
- const controllerWs = this.relayRequests.get(msg.id);
234
- if (controllerWs && controllerWs.readyState === WebSocket.OPEN) {
235
- controllerWs.send(serializeMessage(msg));
236
- this.relayRequests.delete(msg.id);
237
- return;
238
- }
239
- const pending = this.pendingRequests.get(msg.id);
240
- if (pending) {
241
- clearTimeout(pending.timer);
242
- pending.resolve(msg);
243
- this.pendingRequests.delete(msg.id);
244
- }
245
- return;
246
- }
247
- this.emit(msg.type, msg.payload);
248
- this.emit("message", msg);
249
- this.broadcastToControllers(msg.type, msg.payload);
250
- }
251
- /** Resolve which Studio connection to target */
252
- resolveTarget() {
253
- if (this.studioClients.size === 0) return null;
254
- if (this.activePlaceId !== 0) {
255
- const conn = this.studioClients.get(this.activePlaceId);
256
- if (conn && conn.ws.readyState === WebSocket.OPEN) return conn;
257
- return null;
258
- }
259
- if (this.studioClients.size === 1) {
260
- const conn = this.studioClients.values().next().value;
261
- if (conn.ws.readyState === WebSocket.OPEN) return conn;
262
- return null;
263
- }
264
- return null;
265
- }
266
- /** Generate an appropriate error message when no target found */
267
- getTargetError() {
268
- if (this.studioClients.size === 0) return "No Studio connection";
269
- if (this.activePlaceId !== 0) {
270
- return `Active place ${this.activePlaceId} is not connected. Use set_active_place or list_connections.`;
271
- }
272
- const places = [...this.studioClients.values()].map((c) => `${c.placeName ?? "Unknown"} (${c.placeId})`);
273
- return `Multiple Studio instances connected: ${places.join(", ")}. Use set_active_place to choose one.`;
274
- }
275
- sendRequest(type, payload, timeoutMs = 1e4) {
276
- return new Promise((resolve, reject) => {
277
- const target = this.resolveTarget();
278
- if (!target || target.ws.readyState !== WebSocket.OPEN) {
279
- reject(new Error(this.getTargetError()));
280
- return;
281
- }
282
- const msg = createMessage(type, payload);
283
- const timer = setTimeout(() => {
284
- this.pendingRequests.delete(msg.id);
285
- reject(new Error(`Request timed out: ${type}`));
286
- }, timeoutMs);
287
- this.pendingRequests.set(msg.id, {
288
- resolve,
289
- reject,
290
- timer
291
- });
292
- target.ws.send(serializeMessage(msg));
293
- });
294
- }
295
- sendNotification(type, payload) {
296
- const target = this.resolveTarget();
297
- if (!target || target.ws.readyState !== WebSocket.OPEN) return;
298
- const msg = createMessage(type, payload);
299
- target.ws.send(serializeMessage(msg));
300
- }
301
- isConnected() {
302
- return this.resolveTarget() !== null;
303
- }
304
- getConnectionInfo() {
305
- return this.resolveTarget();
306
- }
307
- listConnections() {
308
- return [...this.studioClients.values()].filter((c) => c.ws.readyState === WebSocket.OPEN).map((c) => ({
309
- placeId: c.placeId ?? 0,
310
- placeName: c.placeName,
311
- studioVersion: c.studioVersion,
312
- connectedAt: c.connectedAt
313
- }));
314
- }
315
- getActivePlaceId() {
316
- return this.activePlaceId;
317
- }
318
- setActivePlaceId(placeId) {
319
- this.activePlaceId = placeId;
109
+ const serialized = JSON.stringify(msg);
110
+ if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
111
+ throw new Error(`WebSocket message exceeds ${MAX_MESSAGE_BYTES} bytes`);
320
112
  }
321
- getPort() {
322
- return this.port;
113
+ return serialized;
114
+ }
115
+ var wsMessageSchema = z.object({
116
+ id: z.string().min(1).max(128),
117
+ type: z.string().min(1).max(128),
118
+ payload: z.unknown(),
119
+ ts: z.number().finite()
120
+ }).strict();
121
+ var TOKEN_FILE = "bridge-token";
122
+ function loadOrCreateBridgeToken() {
123
+ const tokenPath = path3.join(getDominusDir(), TOKEN_FILE);
124
+ try {
125
+ const existing = fs3.readFileSync(tokenPath, "utf8").trim();
126
+ if (existing.length >= 32) return existing;
127
+ } catch {
323
128
  }
324
- stop() {
325
- return new Promise((resolve) => {
326
- for (const conn of this.studioClients.values()) {
327
- conn.ws.close();
328
- }
329
- this.studioClients.clear();
330
- this.wsToPlaceId.clear();
331
- for (const ws of this.controllers) {
332
- ws.close();
333
- }
334
- this.controllers.clear();
335
- for (const [, pending] of this.pendingRequests) {
336
- clearTimeout(pending.timer);
337
- pending.reject(new Error("Server shutting down"));
338
- }
339
- this.pendingRequests.clear();
340
- this.relayRequests.clear();
341
- if (this.wss) {
342
- this.wss.close(() => resolve());
343
- } else {
344
- resolve();
345
- }
346
- });
129
+ const token = randomBytes(32).toString("base64url");
130
+ fs3.writeFileSync(tokenPath, `${token}
131
+ `, { encoding: "utf8", mode: 384 });
132
+ try {
133
+ fs3.chmodSync(tokenPath, 384);
134
+ } catch {
347
135
  }
348
- };
136
+ return token;
137
+ }
138
+ function tokensEqual(actual, expected) {
139
+ if (typeof actual !== "string") return false;
140
+ const actualBuffer = Buffer.from(actual);
141
+ const expectedBuffer = Buffer.from(expected);
142
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
143
+ }
144
+ function createPairingCode() {
145
+ return randomInt(1e5, 1e6).toString();
146
+ }
147
+
148
+ // src/transport/ws-client.ts
349
149
  var DominusClient = class extends EventEmitter {
350
150
  ws = null;
351
151
  port;
352
- studioConnected = false;
152
+ token;
153
+ connectTimeoutMs;
353
154
  connections = /* @__PURE__ */ new Map();
354
- activePlaceId = 0;
155
+ activeConnectionId = null;
355
156
  pendingRequests = /* @__PURE__ */ new Map();
356
- constructor(port = 18088) {
157
+ constructor(port = 18088, options = {}) {
357
158
  super();
358
159
  this.port = port;
160
+ this.token = options.token ?? loadOrCreateBridgeToken();
161
+ this.connectTimeoutMs = options.connectTimeoutMs ?? 8e3;
359
162
  }
360
163
  connect() {
361
164
  return new Promise((resolve, reject) => {
362
- const url = `ws://localhost:${this.port}`;
363
- const ws = new WebSocket(url);
165
+ const ws = new WebSocket(`ws://127.0.0.1:${this.port}`, {
166
+ perMessageDeflate: false,
167
+ maxPayload: 1024 * 1024
168
+ });
169
+ let settled = false;
170
+ const timer = setTimeout(() => {
171
+ if (!settled) {
172
+ settled = true;
173
+ ws.close(1008, "Dominus controller handshake timed out");
174
+ reject(new Error("Timed out authenticating with the Dominus bridge"));
175
+ }
176
+ }, this.connectTimeoutMs);
364
177
  ws.on("open", () => {
365
178
  this.ws = ws;
366
- ws.send(serializeMessage(createMessage("controller:connect", {})));
367
- this.emit("server:started", { port: this.port, relay: true });
368
- resolve();
179
+ ws.send(serializeMessage(createMessage(ControllerMsg.HELLO, {
180
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
181
+ token: this.token
182
+ })));
369
183
  });
370
184
  ws.on("message", (raw) => {
371
185
  try {
372
186
  const msg = parseMessage(raw.toString());
187
+ if (msg.type === ControllerMsg.WELCOME && !settled) {
188
+ settled = true;
189
+ clearTimeout(timer);
190
+ this.applyWelcome(msg.payload);
191
+ this.emit("server:started", { port: this.port, relay: true });
192
+ resolve();
193
+ return;
194
+ }
195
+ if (msg.type === StudioMsg.AUTH_REJECTED && !settled) {
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ const payload = msg.payload;
199
+ reject(new Error(payload.error ?? "Dominus controller authentication failed"));
200
+ ws.close(1008, "Controller authentication failed");
201
+ return;
202
+ }
373
203
  this.handleMessage(msg);
374
204
  } catch (err) {
375
205
  this.emit("server:error", err);
376
206
  }
377
207
  });
378
208
  ws.on("close", () => {
209
+ clearTimeout(timer);
379
210
  this.ws = null;
380
- this.studioConnected = false;
381
- this.emit("studio:disconnected");
382
- for (const [id, pending] of this.pendingRequests) {
383
- clearTimeout(pending.timer);
384
- pending.reject(new Error("Relay connection lost"));
385
- this.pendingRequests.delete(id);
211
+ this.connections.clear();
212
+ this.activeConnectionId = null;
213
+ if (!settled) {
214
+ settled = true;
215
+ reject(new Error("Dominus bridge closed before authentication completed"));
386
216
  }
217
+ this.rejectPending(new Error("Dominus relay connection lost"));
218
+ this.emit("studio:disconnected");
387
219
  });
388
220
  ws.on("error", (err) => {
389
- if (!this.ws) reject(err);
221
+ if (!settled) {
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ reject(err);
225
+ }
390
226
  this.emit("server:error", err);
391
227
  });
392
228
  });
393
229
  }
230
+ applyWelcome(payload) {
231
+ const welcome = payload;
232
+ this.replaceConnections(welcome.connections ?? []);
233
+ this.activeConnectionId = welcome.activeConnectionId ?? null;
234
+ if (!this.resolveTargetInfo() && this.connections.size === 1) {
235
+ this.activeConnectionId = this.connections.keys().next().value ?? null;
236
+ }
237
+ }
394
238
  handleMessage(msg) {
395
- if (msg.type === "controller:welcome") {
396
- const p = msg.payload;
397
- this.studioConnected = p.studioConnected;
398
- if (p.connections) {
399
- this.connections.clear();
400
- for (const c of p.connections) {
401
- this.connections.set(c.placeId, {
402
- connectedAt: c.connectedAt,
403
- studioVersion: c.studioVersion,
404
- placeId: c.placeId,
405
- placeName: c.placeName
406
- });
407
- }
408
- }
409
- if (p.activePlaceId !== void 0) {
410
- this.activePlaceId = p.activePlaceId;
239
+ if (msg.type === ControllerMsg.TARGET_STATE) {
240
+ const payload = msg.payload;
241
+ this.replaceConnections(payload.connections ?? []);
242
+ if (this.activeConnectionId && !this.connections.has(this.activeConnectionId)) {
243
+ this.activeConnectionId = null;
411
244
  }
412
- if (this.studioConnected) {
413
- this.emit("studio:connected", {});
245
+ if (!this.activeConnectionId && this.connections.size === 1) {
246
+ this.activeConnectionId = this.connections.keys().next().value ?? null;
414
247
  }
248
+ this.emit("controller:target_state", payload);
415
249
  return;
416
250
  }
417
- if (msg.type === "studio:connected") {
418
- this.studioConnected = true;
419
- const payload = msg.payload;
420
- const placeId = payload.placeId ?? 0;
421
- this.connections.set(placeId, {
422
- connectedAt: Date.now(),
423
- studioVersion: payload.studioVersion,
424
- placeId,
425
- placeName: payload.placeName
426
- });
427
- this.emit("studio:connected", payload);
251
+ if (msg.type === StudioMsg.CONNECTED) {
252
+ const connection = msg.payload;
253
+ this.connections.set(connection.connectionId, toConnectionInfo(connection));
254
+ if (!this.activeConnectionId && this.connections.size === 1) {
255
+ this.activeConnectionId = connection.connectionId;
256
+ }
257
+ this.emit("studio:connected", connection);
428
258
  return;
429
259
  }
430
- if (msg.type === "studio:disconnected") {
260
+ if (msg.type === StudioMsg.DISCONNECTED) {
431
261
  const payload = msg.payload;
432
- const placeId = payload.placeId ?? 0;
433
- this.connections.delete(placeId);
434
- if (this.connections.size === 0) {
435
- this.studioConnected = false;
436
- }
437
- if (this.activePlaceId === placeId) {
438
- this.activePlaceId = 0;
262
+ if (payload.connectionId) this.connections.delete(payload.connectionId);
263
+ if (this.activeConnectionId === payload.connectionId) this.activeConnectionId = null;
264
+ if (!this.activeConnectionId && this.connections.size === 1) {
265
+ this.activeConnectionId = this.connections.keys().next().value ?? null;
439
266
  }
440
267
  this.emit("studio:disconnected", payload);
441
268
  return;
@@ -444,90 +271,192 @@ var DominusClient = class extends EventEmitter {
444
271
  const pending = this.pendingRequests.get(msg.id);
445
272
  if (pending) {
446
273
  clearTimeout(pending.timer);
447
- pending.resolve(msg);
448
274
  this.pendingRequests.delete(msg.id);
275
+ pending.resolve(msg);
449
276
  }
450
277
  return;
451
278
  }
452
279
  this.emit(msg.type, msg.payload);
453
280
  this.emit("message", msg);
454
281
  }
455
- sendRequest(type, payload, timeoutMs = 1e4) {
282
+ replaceConnections(connections) {
283
+ this.connections.clear();
284
+ for (const connection of connections) {
285
+ this.connections.set(connection.connectionId, toConnectionInfo(connection));
286
+ }
287
+ }
288
+ sendMessage(message) {
289
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
290
+ throw new Error("Not connected to the Dominus bridge");
291
+ }
292
+ this.ws.send(serializeMessage(message));
293
+ }
294
+ waitForResponse(message, timeoutMs) {
456
295
  return new Promise((resolve, reject) => {
457
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
458
- reject(new Error("Not connected to Dominus server"));
459
- return;
460
- }
461
- if (!this.studioConnected) {
462
- reject(new Error("Studio not connected"));
463
- return;
296
+ const timer = setTimeout(() => {
297
+ this.pendingRequests.delete(message.id);
298
+ reject(new Error(`Dominus request timed out: ${message.type}`));
299
+ }, timeoutMs);
300
+ this.pendingRequests.set(message.id, {
301
+ resolve,
302
+ reject,
303
+ timer
304
+ });
305
+ try {
306
+ this.sendMessage(message);
307
+ } catch (err) {
308
+ clearTimeout(timer);
309
+ this.pendingRequests.delete(message.id);
310
+ reject(err);
464
311
  }
465
- const msg = createMessage(type, payload);
312
+ });
313
+ }
314
+ async sendControlRequest(type, payload, timeoutMs = 1e4) {
315
+ const response = await this.waitForResponse(createMessage(type, payload), timeoutMs);
316
+ const result = response.payload;
317
+ if (result.error || result.success === false) throw new Error(result.error ?? `${type} failed`);
318
+ return result;
319
+ }
320
+ sendRequest(type, payload, timeoutMs = 15e3) {
321
+ const target = this.resolveTargetInfo();
322
+ if (!target || !this.activeConnectionId) {
323
+ return Promise.reject(new Error(this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"));
324
+ }
325
+ const request = createMessage(type, payload);
326
+ const relay = createMessage(ControllerMsg.RELAY, {
327
+ connectionId: this.activeConnectionId,
328
+ request
329
+ });
330
+ return new Promise((resolve, reject) => {
466
331
  const timer = setTimeout(() => {
467
- this.pendingRequests.delete(msg.id);
468
- reject(new Error(`Request timed out: ${type}`));
332
+ this.pendingRequests.delete(request.id);
333
+ reject(new Error(`Studio request timed out: ${type}`));
469
334
  }, timeoutMs);
470
- this.pendingRequests.set(msg.id, {
335
+ this.pendingRequests.set(request.id, {
471
336
  resolve,
472
337
  reject,
473
338
  timer
474
339
  });
475
- this.ws.send(serializeMessage(msg));
340
+ try {
341
+ this.sendMessage(relay);
342
+ } catch (err) {
343
+ clearTimeout(timer);
344
+ this.pendingRequests.delete(request.id);
345
+ reject(err);
346
+ }
476
347
  });
477
348
  }
478
349
  sendNotification(type, payload) {
479
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
480
- const msg = createMessage(type, payload);
481
- this.ws.send(serializeMessage(msg));
350
+ if (!this.activeConnectionId) return;
351
+ const request = createMessage(type, payload);
352
+ this.sendMessage(createMessage(ControllerMsg.RELAY, {
353
+ connectionId: this.activeConnectionId,
354
+ request
355
+ }));
482
356
  }
483
357
  isConnected() {
484
- return this.studioConnected && this.connections.size > 0;
358
+ return this.resolveTargetInfo() !== null;
485
359
  }
486
360
  getConnectionInfo() {
487
- if (this.connections.size === 0) return null;
488
- const target = this.activePlaceId !== 0 ? this.connections.get(this.activePlaceId) : this.connections.values().next().value;
489
- if (!target) return null;
361
+ const target = this.resolveTargetInfo();
362
+ if (!target || !this.ws) return null;
490
363
  return { ...target, ws: this.ws };
491
364
  }
492
365
  listConnections() {
493
- return [...this.connections.values()].map((c) => ({
494
- placeId: c.placeId ?? 0,
495
- placeName: c.placeName,
496
- studioVersion: c.studioVersion,
497
- connectedAt: c.connectedAt
366
+ return [...this.connections.values()].map((connection) => ({
367
+ placeId: connection.placeId ?? 0,
368
+ connectionId: connection.connectionId,
369
+ clientId: connection.clientId,
370
+ placeName: connection.placeName,
371
+ studioVersion: connection.studioVersion,
372
+ connectedAt: connection.connectedAt,
373
+ active: connection.connectionId === this.activeConnectionId
498
374
  }));
499
375
  }
376
+ async listPendingConnections() {
377
+ const result = await this.sendControlRequest(
378
+ ControllerMsg.LIST_PENDING,
379
+ {}
380
+ );
381
+ return result.pending;
382
+ }
383
+ async approveConnection(connectionId, pairingCode) {
384
+ const result = await this.sendControlRequest(
385
+ ControllerMsg.APPROVE_CONNECTION,
386
+ { connectionId, pairingCode }
387
+ );
388
+ return result.connection;
389
+ }
390
+ getActiveConnectionId() {
391
+ return this.activeConnectionId;
392
+ }
393
+ async setActiveConnectionId(connectionId) {
394
+ if (connectionId && !this.connections.has(connectionId)) {
395
+ throw new Error(`Studio connection ${connectionId} is not connected`);
396
+ }
397
+ await this.sendControlRequest(ControllerMsg.SET_ACTIVE_CONNECTION, { connectionId });
398
+ this.activeConnectionId = connectionId;
399
+ }
500
400
  getActivePlaceId() {
501
- return this.activePlaceId;
401
+ return this.resolveTargetInfo()?.placeId ?? 0;
502
402
  }
503
403
  setActivePlaceId(placeId) {
504
- this.activePlaceId = placeId;
404
+ if (placeId === 0) {
405
+ void this.setActiveConnectionId(null);
406
+ return;
407
+ }
408
+ const matches = [...this.connections.values()].filter((connection) => connection.placeId === placeId);
409
+ if (matches.length !== 1) {
410
+ throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
411
+ }
412
+ void this.setActiveConnectionId(matches[0].connectionId);
413
+ }
414
+ resolveTargetInfo() {
415
+ if (this.activeConnectionId) return this.connections.get(this.activeConnectionId) ?? null;
416
+ if (this.connections.size === 1) return this.connections.values().next().value ?? null;
417
+ return null;
505
418
  }
506
419
  getPort() {
507
420
  return this.port;
508
421
  }
509
422
  stop() {
423
+ this.rejectPending(new Error("Dominus client is shutting down"));
510
424
  return new Promise((resolve) => {
511
- for (const [, pending] of this.pendingRequests) {
512
- clearTimeout(pending.timer);
513
- pending.reject(new Error("Client shutting down"));
425
+ if (!this.ws) {
426
+ resolve();
427
+ return;
514
428
  }
515
- this.pendingRequests.clear();
516
- if (this.ws) {
517
- this.ws.close();
518
- this.ws = null;
519
- }
520
- resolve();
429
+ const ws = this.ws;
430
+ this.ws = null;
431
+ ws.once("close", resolve);
432
+ ws.close(1e3, "Dominus client shutdown");
521
433
  });
522
434
  }
435
+ rejectPending(error) {
436
+ for (const pending of this.pendingRequests.values()) {
437
+ clearTimeout(pending.timer);
438
+ pending.reject(error);
439
+ }
440
+ this.pendingRequests.clear();
441
+ }
523
442
  };
443
+ function toConnectionInfo(connection) {
444
+ return {
445
+ connectedAt: connection.connectedAt,
446
+ connectionId: connection.connectionId,
447
+ clientId: connection.clientId,
448
+ studioVersion: connection.studioVersion,
449
+ placeId: connection.placeId,
450
+ placeName: connection.placeName
451
+ };
452
+ }
524
453
  async function isPortInUse(port) {
525
454
  return new Promise((resolve) => {
526
- const ws = new WebSocket(`ws://localhost:${port}`);
455
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`);
527
456
  const timer = setTimeout(() => {
528
457
  ws.close();
529
458
  resolve(false);
530
- }, 1e3);
459
+ }, 750);
531
460
  ws.on("open", () => {
532
461
  clearTimeout(timer);
533
462
  ws.close();
@@ -539,999 +468,1300 @@ async function isPortInUse(port) {
539
468
  });
540
469
  });
541
470
  }
542
- var DOMINUS_DIR = path.join(os.homedir(), ".dominus");
543
- var CONFIG_PATH = path.join(DOMINUS_DIR, "config.json");
544
- var DB_PATH = path.join(DOMINUS_DIR, "dominus.db");
545
- path.join(DOMINUS_DIR, "rules.json");
546
- var DEFAULTS = {
547
- provider: "openai",
548
- apiBaseUrl: "",
549
- model: "",
550
- port: 18088,
551
- maxToolIterations: 25,
552
- maxVerifyRetries: 3,
553
- tokenBudget: 128e3,
554
- autoIndex: true,
555
- autoLearn: true,
556
- userName: "Developer"
557
- };
558
- function ensureDir() {
559
- if (!fs.existsSync(DOMINUS_DIR)) {
560
- fs.mkdirSync(DOMINUS_DIR, { recursive: true });
471
+ var HANDSHAKE_TIMEOUT_MS = 8e3;
472
+ var PAIRING_TTL_MS = 5 * 6e4;
473
+ var RATE_WINDOW_MS = 1e4;
474
+ var RATE_LIMIT = 250;
475
+ var DominusServer = class extends EventEmitter {
476
+ wss = null;
477
+ studioClients = /* @__PURE__ */ new Map();
478
+ wsToConnectionId = /* @__PURE__ */ new Map();
479
+ pendingConnections = /* @__PURE__ */ new Map();
480
+ controllers = /* @__PURE__ */ new Set();
481
+ socketRoles = /* @__PURE__ */ new Map();
482
+ pendingRequests = /* @__PURE__ */ new Map();
483
+ relayRequests = /* @__PURE__ */ new Map();
484
+ rateState = /* @__PURE__ */ new Map();
485
+ token;
486
+ host;
487
+ maxPayloadBytes;
488
+ handshakeTimeoutMs;
489
+ activeConnectionId = null;
490
+ port;
491
+ constructor(port = 18088, options = {}) {
492
+ super();
493
+ this.port = port;
494
+ this.token = options.token ?? loadOrCreateBridgeToken();
495
+ this.host = options.host ?? "127.0.0.1";
496
+ this.maxPayloadBytes = options.maxPayloadBytes ?? MAX_MESSAGE_BYTES;
497
+ this.handshakeTimeoutMs = options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
561
498
  }
562
- }
563
- function readJson(filePath, defaults) {
564
- try {
565
- if (fs.existsSync(filePath)) {
566
- const raw = fs.readFileSync(filePath, "utf-8");
567
- return { ...defaults, ...JSON.parse(raw) };
499
+ start(maxRetries = 0) {
500
+ return new Promise((resolve, reject) => {
501
+ const tryPort = (port, attempt) => {
502
+ const wss = new WebSocketServer({
503
+ port,
504
+ host: this.host,
505
+ maxPayload: this.maxPayloadBytes,
506
+ perMessageDeflate: false
507
+ });
508
+ wss.on("listening", () => {
509
+ this.wss = wss;
510
+ this.port = port;
511
+ wss.on("connection", (ws) => this.handleConnection(ws));
512
+ this.emit("server:started", { port, host: this.host });
513
+ resolve();
514
+ });
515
+ wss.on("error", (err) => {
516
+ if (err.code === "EADDRINUSE" && attempt < maxRetries) {
517
+ wss.close();
518
+ tryPort(port + 1, attempt + 1);
519
+ return;
520
+ }
521
+ this.emit("server:error", err);
522
+ reject(err);
523
+ });
524
+ };
525
+ tryPort(this.port, 0);
526
+ });
527
+ }
528
+ handleConnection(ws) {
529
+ this.socketRoles.set(ws, "unknown");
530
+ this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
531
+ const handshakeTimer = setTimeout(() => {
532
+ if (this.socketRoles.get(ws) === "unknown") {
533
+ ws.close(1008, "Dominus handshake timed out");
534
+ }
535
+ }, this.handshakeTimeoutMs);
536
+ ws.on("message", (raw) => {
537
+ try {
538
+ if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
539
+ ws.close(1009, "Dominus message limit exceeded");
540
+ return;
541
+ }
542
+ const msg = parseMessage(raw.toString());
543
+ const role = this.socketRoles.get(ws) ?? "unknown";
544
+ if (role === "unknown") {
545
+ clearTimeout(handshakeTimer);
546
+ this.handleHandshake(ws, msg);
547
+ } else if (role === "controller") {
548
+ this.handleControllerMessage(ws, msg);
549
+ } else if (role === "studio") {
550
+ this.handleStudioMessage(ws, msg);
551
+ }
552
+ } catch (err) {
553
+ this.emit("server:error", err);
554
+ this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
555
+ error: err instanceof Error ? err.message : String(err)
556
+ }));
557
+ }
558
+ });
559
+ ws.on("close", () => {
560
+ clearTimeout(handshakeTimer);
561
+ this.cleanupSocket(ws);
562
+ });
563
+ ws.on("error", (err) => this.emit("server:error", err));
564
+ }
565
+ consumeRateBudget(ws) {
566
+ const now = Date.now();
567
+ const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
568
+ if (now - state.startedAt >= RATE_WINDOW_MS) {
569
+ state.startedAt = now;
570
+ state.count = 0;
568
571
  }
569
- } catch {
572
+ state.count += 1;
573
+ this.rateState.set(ws, state);
574
+ return state.count <= RATE_LIMIT;
570
575
  }
571
- return { ...defaults };
572
- }
573
- function loadConfig() {
574
- ensureDir();
575
- return readJson(CONFIG_PATH, DEFAULTS);
576
- }
577
- function getDbPath() {
578
- ensureDir();
579
- return DB_PATH;
580
- }
581
-
582
- // src/memory/store.ts
583
- var db = null;
584
- var dbPath;
585
- var SCHEMA = `
586
- CREATE TABLE IF NOT EXISTS facts (
587
- id INTEGER PRIMARY KEY AUTOINCREMENT,
588
- project_id TEXT NOT NULL,
589
- content TEXT NOT NULL,
590
- category TEXT NOT NULL,
591
- source TEXT,
592
- relevance REAL DEFAULT 1.0,
593
- created_at INTEGER NOT NULL,
594
- accessed_at INTEGER NOT NULL
595
- );
596
-
597
- CREATE TABLE IF NOT EXISTS summaries (
598
- id INTEGER PRIMARY KEY AUTOINCREMENT,
599
- project_id TEXT NOT NULL,
600
- session_id TEXT NOT NULL,
601
- summary TEXT NOT NULL,
602
- created_at INTEGER NOT NULL
603
- );
604
-
605
- CREATE TABLE IF NOT EXISTS messages (
606
- id INTEGER PRIMARY KEY AUTOINCREMENT,
607
- session_id TEXT NOT NULL,
608
- role TEXT NOT NULL,
609
- content TEXT NOT NULL,
610
- tool_name TEXT,
611
- created_at INTEGER NOT NULL
612
- );
613
-
614
- CREATE TABLE IF NOT EXISTS script_index (
615
- id INTEGER PRIMARY KEY AUTOINCREMENT,
616
- project_id TEXT NOT NULL,
617
- path TEXT NOT NULL,
618
- class_name TEXT NOT NULL,
619
- summary TEXT,
620
- line_count INTEGER,
621
- last_hash TEXT,
622
- updated_at INTEGER NOT NULL
623
- );
624
-
625
- CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project_id);
626
- CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(project_id, category);
627
- CREATE INDEX IF NOT EXISTS idx_scripts_project ON script_index(project_id);
628
- CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
629
- CREATE INDEX IF NOT EXISTS idx_summaries_project ON summaries(project_id);
630
- `;
631
- function persist() {
632
- if (!db) return;
633
- const data = db.export();
634
- fs.writeFileSync(dbPath, Buffer.from(data));
635
- }
636
- async function initMemoryStore() {
637
- if (db) return;
638
- dbPath = getDbPath();
639
- const SQL = await initSqlJs();
640
- if (fs.existsSync(dbPath)) {
641
- const buffer = fs.readFileSync(dbPath);
642
- db = new SQL.Database(buffer);
643
- } else {
644
- db = new SQL.Database();
645
- }
646
- db.run(SCHEMA);
647
- persist();
648
- }
649
- function getDb() {
650
- if (!db) throw new Error("Memory store not initialized. Call initMemoryStore() first.");
651
- return db;
652
- }
653
- function storeFact(fact) {
654
- const d = getDb();
655
- d.run(
656
- `INSERT INTO facts (project_id, content, category, source, relevance, created_at, accessed_at)
657
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
658
- [
659
- fact.projectId,
660
- fact.content,
661
- fact.category,
662
- fact.source ?? null,
663
- fact.relevance,
664
- fact.createdAt,
665
- fact.accessedAt
666
- ]
667
- );
668
- persist();
669
- const stmt = d.prepare("SELECT last_insert_rowid()");
670
- stmt.step();
671
- const row = stmt.get();
672
- stmt.free();
673
- return row?.[0] ?? 0;
674
- }
675
- function recallFacts(projectId, query, limit = 10) {
676
- const d = getDb();
677
- const keywords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
678
- if (keywords.length === 0) {
679
- const result2 = d.exec(
680
- `SELECT * FROM facts WHERE project_id = ? ORDER BY relevance DESC, accessed_at DESC LIMIT ?`,
681
- [projectId, limit]
682
- );
683
- return rowsToFacts(result2);
684
- }
685
- const likeClauses = keywords.map(() => `LOWER(content) LIKE ?`).join(" OR ");
686
- const likeParams = keywords.map((k) => `%${k}%`);
687
- const result = d.exec(
688
- `SELECT * FROM facts WHERE project_id = ? AND (${likeClauses})
689
- ORDER BY relevance DESC, accessed_at DESC LIMIT ?`,
690
- [projectId, ...likeParams, limit]
691
- );
692
- const facts = rowsToFacts(result);
693
- for (const fact of facts) {
694
- if (fact.id) {
695
- d.run(`UPDATE facts SET accessed_at = ? WHERE id = ?`, [Date.now(), fact.id]);
576
+ handleHandshake(ws, msg) {
577
+ if (msg.type === StudioMsg.HELLO) {
578
+ const hello = validateStudioHello(msg.payload);
579
+ if (tokensEqual(hello.token, this.token)) {
580
+ this.authorizeStudio(ws, hello, randomUUID());
581
+ } else {
582
+ this.createPendingStudio(ws, hello);
583
+ }
584
+ return;
696
585
  }
586
+ if (msg.type === ControllerMsg.HELLO) {
587
+ const payload = msg.payload;
588
+ if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
589
+ this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, { error: "Invalid Dominus controller credentials" }));
590
+ ws.close(1008, "Unauthorized controller");
591
+ return;
592
+ }
593
+ this.socketRoles.set(ws, "controller");
594
+ this.controllers.add(ws);
595
+ this.sendSafe(ws, createMessage(ControllerMsg.WELCOME, {
596
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
597
+ port: this.port,
598
+ connections: this.listConnections(),
599
+ activeConnectionId: this.activeConnectionId
600
+ }));
601
+ return;
602
+ }
603
+ this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
604
+ error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
605
+ }));
606
+ ws.close(1008, "Invalid Dominus handshake");
697
607
  }
698
- if (facts.length > 0) persist();
699
- return facts;
700
- }
701
- function rowsToFacts(result) {
702
- if (!result[0]) return [];
703
- return result[0].values.map((row) => ({
704
- id: row[0],
705
- projectId: row[1],
706
- content: row[2],
707
- category: row[3],
708
- source: row[4],
709
- relevance: row[5],
710
- createdAt: row[6],
711
- accessedAt: row[7]
712
- }));
713
- }
714
- function getScriptIndex(projectId) {
715
- const d = getDb();
716
- const result = d.exec(
717
- `SELECT path, summary, class_name FROM script_index WHERE project_id = ? ORDER BY path`,
718
- [projectId]
719
- );
720
- if (!result[0]) return [];
721
- return result[0].values.map((row) => ({
722
- path: row[0],
723
- summary: row[1] || void 0,
724
- className: row[2]
725
- }));
726
- }
727
-
728
- // src/agent/internal-memory.ts
729
- var INTERNAL_MEMORY = `## Internal Memory \u2014 Known Quirks & Lessons
730
-
731
- These are built-in lessons that ship with Dominus. Follow them exactly.
732
-
733
- ### create_ui \u2014 property format
734
- - Each node accepts properties in **either** of two shapes:
735
- - **Flattened (preferred)**: props as top-level keys next to \`ClassName\`, \`Name\`, \`Children\`.
736
- \`\`\`json
737
- { "ClassName": "TextButton", "Name": "Btn", "Text": "OK", "Size": [0, 100, 0, 40] }
738
- \`\`\`
739
- - **Nested bag**: props grouped under \`properties\` / \`Properties\` / \`props\` / \`Props\`.
740
- \`\`\`json
741
- { "ClassName": "TextButton", "Name": "Btn",
742
- "properties": { "Text": "OK", "Size": [0, 100, 0, 40] } }
743
- \`\`\`
744
- - Do NOT mix a nested \`properties\` bag with flattened props of the same name \u2014 the bag wins.
745
- - Reserved keys (never treated as instance properties): \`ClassName\`, \`className\`, \`Name\`, \`name\`, \`Children\`, \`children\`, \`properties\`, \`Properties\`, \`props\`, \`Props\`.
746
-
747
- ### build_multiple \u2014 schema is strict
748
- - Top-level keys only: \`name\`, \`className\`, \`position\`, \`size\`, \`color\`, \`material\`, \`anchored\`, \`shape\`, \`transparency\`, \`parent\`, \`canCollide\`.
749
- - Does NOT accept a nested \`properties\` bag. For anything outside those fields use \`insert_instance\` + \`set_properties\`.
750
- - \`shape\` values: \`Block\`, \`Ball\`, \`Cylinder\`, \`Wedge\`.
751
-
752
- ### Color formats (accepted everywhere)
753
- - Hex: \`"#RRGGBB"\`
754
- - RGB string: \`"rgb(255, 128, 0)"\`
755
- - BrickColor name: \`"Bright red"\`, \`"Institutional white"\`, \`"Really black"\`, etc.
756
- - Array: \`[r, g, b]\` (0\u20131 floats) or \`[255, 128, 0]\` (auto-detected if any value > 1)
757
-
758
- ### UDim2 / UDim / Vector2 / Color3 coercion (create_ui)
759
- - UDim2: \`[xScale, xOffset, yScale, yOffset]\` or \`{XScale, XOffset, YScale, YOffset}\`.
760
- - UDim: \`[scale, offset]\` or a plain number (treated as pixels).
761
- - Vector2 (e.g. \`AnchorPoint\`): \`[x, y]\` or \`{x, y}\`.
762
- - 2-element arrays are auto-disambiguated by property name (\`AnchorPoint\` \u2192 Vector2, \`CornerRadius\` \u2192 UDim).
763
-
764
- ### Common GUI property names
765
- - GUI uses \`Size\`/\`Position\` as **UDim2** \u2014 never Vector3.
766
- - \`BackgroundColor3\`, \`TextColor3\`, \`BorderColor3\`, \`ImageColor3\` \u2192 Color3.
767
- - \`Font\` accepts a string enum name like \`"GothamBold"\`, \`"SourceSans"\`.
768
- - Enum properties take the string name: \`"Center"\`, \`"Left"\`, \`"Fit"\`.
769
-
770
- ### Tool selection cheat-sheet
771
- - Single 3D part \u2192 \`create_part\`
772
- - Many 3D parts (tree, building, vehicle) \u2192 \`build_multiple\` with \`groupName\`
773
- - Full UI tree \u2192 \`create_ui\` (one call, never loop \`insert_instance\` for UI)
774
- - **Convert/export existing UI \u2192 \`serialize_ui\`** (NOT get_descendants_properties)
775
- - Adjusting an existing instance \u2192 \`set_properties\` (not \`run_code\`)
776
- - **Inspecting user's selection \u2192 \`get_selection\`, then \`get_properties\` or \`serialize_ui\`**
777
- - **Reading properties \u2192 \`get_properties\` (NEVER \`run_code\` with dump scripts)**
778
- - **Browsing the tree \u2192 \`get_explorer\`**
779
- - **Multiple Studios open \u2192 \`list_connections\` to see all, \`set_active_place\` to target one**
780
- - Anything exotic or procedural \u2192 \`run_code\` as last resort
781
-
782
- ### NEVER use run_code for:
783
- - Getting properties of an instance \u2192 use \`get_properties\` or \`serialize_ui\`
784
- - Dumping selection info \u2192 use \`inspect_selection\` (ONE call does it all)
785
- - Inspecting an instance + its children \u2192 use \`inspect_instance\`
786
- - Converting UI to Roact/React \u2192 use \`convert_ui_to_code\`
787
- - Creating instances \u2192 use \`create_part\`, \`create_ui\`, \`insert_instance\`, \`build_multiple\`
788
- - Modifying properties \u2192 use \`set_properties\` or \`bulk_set_properties\`
789
- - Reading scripts \u2192 use \`read_script\`
790
- - Browsing the explorer \u2192 use \`get_explorer\`
791
- \`run_code\` is ONLY for procedural logic, terrain generation, raycasts, physics queries, or custom algorithms with no tool equivalent.
792
-
793
- ### Composite skill tools (prefer these over chaining)
794
- - \`inspect_selection\` \u2014 Returns paths + compact properties for all selected instances in ONE call. Use when user says "my selection" or "what I selected".
795
- - \`inspect_instance\` \u2014 Returns compact properties + children tree for a single instance in ONE call. Combines get_properties + get_explorer.
796
- - \`convert_ui_to_code\` \u2014 Serializes a UI tree and generates a Roact/React component. Use when user says "turn this into a component" or "convert to React".
797
-
798
- ### Verification habits
799
- - After a build, call \`get_explorer\` or \`get_selection\` to confirm the tree looks right.
800
- - After editing a script, read it back.
801
- - After \`run_code\`, check \`get_output\` for errors.
802
-
803
- ### serialize_ui \u2014 the RIGHT tool for UI conversion
804
- - **Use \`serialize_ui\` when converting existing UI to Roact/React/Fusion components.** It returns a minimal JSON tree with only non-default properties, already in JSON-friendly formats.
805
- - **Do NOT use \`get_descendants_properties\` for UI conversion** \u2014 it returns a flat list with ~50+ properties per instance (including read-only, deprecated, nil, and default values), which bloats context and confuses conversion.
806
- - \`serialize_ui\` output maps 1:1 to \`create_ui\` input \u2014 you can round-trip UI through it.
807
- - The tree structure preserves parent-child hierarchy, so Roact/React children mapping is direct.
808
-
809
- ### UI \u2192 Roact/React conversion cheat-sheet
810
- - \`serialize_ui\` path \u2192 JSON tree \u2192 map each node to \`Roact.createElement(ClassName, props, childrenDict)\`
811
- - UDim2 arrays \`[xS, xO, yS, yO]\` \u2192 \`UDim2.new(xS, xO, yS, yO)\`
812
- - Color3 hex strings \u2192 \`Color3.fromHex("#RRGGBB")\`
813
- - Vector2 arrays \u2192 \`Vector2.new(x, y)\`
814
- - UDim arrays \u2192 \`UDim.new(s, o)\`
815
- - Enum strings like \`"Center"\` \u2192 \`Enum.TextXAlignment.Center\`
816
- - FontFace \u2192 \`Font.new(family, weight, style)\`
817
- - UI modifiers (UICorner, UIStroke, UIGradient, UIListLayout, UIPadding) are CHILDREN not props
818
- - Use instance Name as dictionary key in children table
819
- `;
820
-
821
- // src/mcp/server.ts
822
- var bridge;
823
- async function startMcpServer() {
824
- const config = loadConfig();
825
- await initMemoryStore();
826
- const portTaken = await isPortInUse(config.port);
827
- if (portTaken) {
828
- const client = new DominusClient(config.port);
829
- await client.connect();
830
- bridge = client;
831
- } else {
832
- const server = new DominusServer(config.port);
833
- await server.start();
834
- bridge = server;
608
+ createPendingStudio(ws, hello) {
609
+ const connectionId = randomUUID();
610
+ const requestedAt = Date.now();
611
+ const pending = {
612
+ ws,
613
+ pairingCode: createPairingCode(),
614
+ hello,
615
+ info: {
616
+ connectionId,
617
+ clientId: hello.clientId,
618
+ placeId: hello.placeId ?? 0,
619
+ placeName: hello.placeName,
620
+ studioVersion: hello.studioVersion,
621
+ requestedAt,
622
+ expiresAt: requestedAt + PAIRING_TTL_MS
623
+ }
624
+ };
625
+ this.pendingConnections.set(connectionId, pending);
626
+ this.socketRoles.set(ws, "pending");
627
+ this.sendSafe(ws, createMessage(StudioMsg.PAIRING_REQUIRED, {
628
+ connectionId,
629
+ pairingCode: pending.pairingCode,
630
+ expiresAt: pending.info.expiresAt
631
+ }));
632
+ this.emit("studio:pairing_required", pending.info);
835
633
  }
836
- const mcp = new McpServer({
837
- name: "dominus",
838
- version: "0.6.0"
839
- }, {
840
- instructions: INTERNAL_MEMORY
841
- });
842
- function luaStr(s) {
843
- return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
844
- }
845
- function resolvePath(dotPath) {
846
- const parts = dotPath.split(".");
847
- let r = "game";
848
- for (const p of parts) r += `[${luaStr(p)}]`;
849
- return r;
850
- }
851
- function cleanNum(n) {
852
- if (Number.isInteger(n)) return String(n);
853
- const rounded = Math.round(n * 1e3) / 1e3;
854
- return String(rounded);
855
- }
856
- function generateComponentCode(tree, componentName, framework) {
857
- const isReact = framework === "react";
858
- const lib = isReact ? "React" : "Roact";
859
- const lines = [];
860
- lines.push(`local ${lib} = require(game.ReplicatedStorage.Packages.${isReact ? "React" : "Roact"})`);
861
- if (isReact) {
862
- lines.push(`local e = React.createElement`);
863
- } else {
864
- lines.push(`local e = Roact.createElement`);
865
- }
866
- lines.push("");
867
- lines.push(`local function ${componentName}()`);
868
- lines.push(` return ${renderNode(tree, 1)}`);
869
- lines.push("end");
870
- lines.push("");
871
- lines.push(`return ${componentName}`);
872
- return lines.join("\n");
873
- }
874
- function renderNode(node, depth) {
875
- const indent = " ".repeat(depth);
876
- const indent2 = " ".repeat(depth + 1);
877
- const className = node.ClassName || "Frame";
878
- const children = node.Children || [];
879
- node.Name;
880
- const props = [];
881
- const skipKeys = /* @__PURE__ */ new Set(["ClassName", "Name", "Children", "className", "name", "children"]);
882
- for (const [key, val] of Object.entries(node)) {
883
- if (skipKeys.has(key)) continue;
884
- props.push(`${indent2}${key} = ${luaValue(val, depth + 1)},`);
634
+ authorizeStudio(ws, hello, connectionId) {
635
+ const connection = {
636
+ ws,
637
+ connectionId,
638
+ clientId: hello.clientId,
639
+ connectedAt: Date.now(),
640
+ studioVersion: hello.studioVersion,
641
+ placeId: hello.placeId ?? 0,
642
+ placeName: hello.placeName
643
+ };
644
+ this.pendingConnections.delete(connectionId);
645
+ this.studioClients.set(connectionId, connection);
646
+ this.wsToConnectionId.set(ws, connectionId);
647
+ this.socketRoles.set(ws, "studio");
648
+ if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
649
+ this.activeConnectionId = connectionId;
885
650
  }
886
- const childEntries = [];
887
- for (const child of children) {
888
- const childName = child.Name || child.ClassName || "Child";
889
- childEntries.push(`${indent2}${childName} = ${renderNode(child, depth + 1)},`);
651
+ this.sendSafe(ws, createMessage(StudioMsg.AUTHENTICATED, {
652
+ protocolVersion: DOMINUS_PROTOCOL_VERSION,
653
+ connectionId,
654
+ token: this.token
655
+ }));
656
+ const info = this.toPlaceInfo(connection);
657
+ this.emit("studio:connected", info);
658
+ this.broadcastToControllers(StudioMsg.CONNECTED, info);
659
+ this.broadcastTargetState();
660
+ return connection;
661
+ }
662
+ handleControllerMessage(controllerWs, msg) {
663
+ if (msg.type === ControllerMsg.RELAY) {
664
+ const payload = msg.payload;
665
+ const request = parseMessage(JSON.stringify(payload.request));
666
+ const target = this.resolveConnection(typeof payload.connectionId === "string" ? payload.connectionId : null);
667
+ if (!target) {
668
+ this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
669
+ return;
670
+ }
671
+ this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
672
+ this.sendSafe(target.ws, request);
673
+ return;
890
674
  }
891
- const hasProps = props.length > 0;
892
- const hasChildren = childEntries.length > 0;
893
- if (!hasProps && !hasChildren) {
894
- return `e("${className}", {})`;
675
+ if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
676
+ const payload = msg.payload;
677
+ const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
678
+ if (connectionId && !this.studioClients.has(connectionId)) {
679
+ this.sendResponse(controllerWs, msg.id, { success: false, error: `Studio connection ${connectionId} is not connected` });
680
+ return;
681
+ }
682
+ this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
683
+ return;
895
684
  }
896
- const parts = [];
897
- parts.push(`e("${className}", {`);
898
- if (hasProps) {
899
- parts.push(...props);
685
+ if (msg.type === ControllerMsg.LIST_PENDING) {
686
+ this.sendResponse(controllerWs, msg.id, { success: true, pending: this.getPendingInfos() });
687
+ return;
900
688
  }
901
- if (hasChildren) {
902
- parts.push(`${indent2}}, {`);
903
- parts.push(...childEntries);
904
- parts.push(`${indent}})`);
905
- } else {
906
- parts.push(`${indent}})`);
689
+ if (msg.type === ControllerMsg.APPROVE_CONNECTION) {
690
+ const payload = msg.payload;
691
+ this.approveConnection(String(payload.connectionId ?? ""), String(payload.pairingCode ?? "")).then((connection) => this.sendResponse(controllerWs, msg.id, { success: true, connection })).catch((err) => this.sendResponse(controllerWs, msg.id, {
692
+ success: false,
693
+ error: err instanceof Error ? err.message : String(err)
694
+ }));
695
+ return;
907
696
  }
908
- return parts.join("\n");
909
- }
910
- function luaValue(val, _depth) {
911
- if (val === null || val === void 0) return "nil";
912
- if (typeof val === "boolean") return String(val);
913
- if (typeof val === "number") return cleanNum(val);
914
- if (typeof val === "string") {
915
- if (val.startsWith("#") && val.length === 7) {
916
- return `Color3.fromHex("${val}")`;
697
+ this.sendResponse(controllerWs, msg.id, { error: `Unsupported controller message: ${msg.type}` });
698
+ }
699
+ handleStudioMessage(ws, msg) {
700
+ if (msg.type === StudioMsg.RESPONSE) {
701
+ const relay = this.relayRequests.get(msg.id);
702
+ if (relay) {
703
+ if (relay.targetWs !== ws) {
704
+ this.emit("server:error", new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`));
705
+ return;
706
+ }
707
+ this.sendSafe(relay.controllerWs, msg);
708
+ this.relayRequests.delete(msg.id);
709
+ return;
917
710
  }
918
- if (/^[A-Z][a-zA-Z]+$/.test(val)) {
919
- return `"${val}"`;
711
+ const pending = this.pendingRequests.get(msg.id);
712
+ if (pending) {
713
+ if (pending.targetWs !== ws) {
714
+ this.emit("server:error", new Error(`Ignored response ${msg.id} from the wrong Studio session`));
715
+ return;
716
+ }
717
+ clearTimeout(pending.timer);
718
+ this.pendingRequests.delete(msg.id);
719
+ pending.resolve(msg);
920
720
  }
921
- return `"${val.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
721
+ return;
922
722
  }
923
- if (Array.isArray(val)) {
924
- if (val.length === 4 && val.every((v) => typeof v === "number")) {
925
- return `UDim2.new(${val.map(cleanNum).join(", ")})`;
926
- }
927
- if (val.length === 2 && val.every((v) => typeof v === "number")) {
928
- return `Vector2.new(${val.map(cleanNum).join(", ")})`;
929
- }
930
- if (val.length === 3 && val.every((v) => typeof v === "number")) {
931
- const allSmall = val.every((v) => v <= 1);
932
- if (allSmall) return `Color3.new(${val.map(cleanNum).join(", ")})`;
933
- return `Color3.fromRGB(${val.map((v) => String(Math.round(v))).join(", ")})`;
723
+ this.emit(msg.type, msg.payload);
724
+ this.emit("message", msg);
725
+ }
726
+ cleanupSocket(ws) {
727
+ const role = this.socketRoles.get(ws);
728
+ this.socketRoles.delete(ws);
729
+ this.rateState.delete(ws);
730
+ if (role === "controller") {
731
+ this.controllers.delete(ws);
732
+ for (const [id, relay] of this.relayRequests) {
733
+ if (relay.controllerWs === ws) this.relayRequests.delete(id);
934
734
  }
935
- return `{${val.map((v) => luaValue(v, _depth)).join(", ")}}`;
936
- }
937
- if (typeof val === "object") {
938
- const entries = Object.entries(val);
939
- if (entries.length === 0) return "{}";
940
- return `{
941
- ${entries.map(([k, v]) => `${" ".repeat(_depth + 1)}${k} = ${luaValue(v, _depth + 1)}`).join(",\n")}
942
- ${" ".repeat(_depth)}}`;
943
- }
944
- return String(val);
945
- }
946
- mcp.tool(
947
- "read_script",
948
- "Read the source code of a script in Roblox Studio",
949
- { path: z.string().describe('Full instance path, e.g. "ServerScriptService.GameManager"') },
950
- async ({ path: path2 }) => {
951
- assertConnected();
952
- const res = await bridge.sendRequest(CliMsg.GET_SCRIPT, { path: path2 });
953
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
954
- }
955
- );
956
- mcp.tool(
957
- "edit_script",
958
- "Replace the entire source code of a script in Roblox Studio",
959
- {
960
- path: z.string().describe("Full instance path"),
961
- source: z.string().describe("New complete source code")
962
- },
963
- async ({ path: path2, source }) => {
964
- assertConnected();
965
- const res = await bridge.sendRequest(CliMsg.SET_SCRIPT, { path: path2, source });
966
- return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
967
- }
968
- );
969
- mcp.tool(
970
- "run_code",
971
- "Execute arbitrary Luau code in Roblox Studio. LAST RESORT \u2014 only use when no dedicated tool exists. Do NOT use for: reading properties (use get_properties/inspect_selection/serialize_ui), creating instances (use create_part/create_ui/insert_instance/build_multiple), modifying properties (use set_properties/bulk_set_properties), or inspecting the tree (use get_explorer). Valid uses: complex procedural generation, terrain algorithms, raycasting, physics queries, custom logic with no tool equivalent.",
972
- { code: z.string().describe("Luau code to execute") },
973
- async ({ code }) => {
974
- assertConnected();
975
- const res = await bridge.sendRequest(CliMsg.RUN_CODE, { code });
976
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
735
+ return;
977
736
  }
978
- );
979
- mcp.tool(
980
- "get_explorer",
981
- "Get the DataModel explorer tree from Roblox Studio",
982
- {
983
- rootPath: z.string().optional().describe("Root path to start from. Omit for entire tree."),
984
- maxDepth: z.number().optional().default(3).describe("Maximum tree depth")
985
- },
986
- async ({ rootPath, maxDepth }) => {
987
- assertConnected();
988
- const res = await bridge.sendRequest(CliMsg.GET_EXPLORER, {
989
- rootPath: rootPath ?? null,
990
- maxDepth
991
- });
992
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
737
+ if (role === "pending") {
738
+ for (const [id, pending] of this.pendingConnections) {
739
+ if (pending.ws === ws) this.pendingConnections.delete(id);
740
+ }
741
+ return;
993
742
  }
994
- );
995
- mcp.tool(
996
- "get_properties",
997
- "Get all properties of a single instance in Roblox Studio. Returns property name, value, type, and category. For inspecting UI trees, prefer serialize_ui (structured tree) or get_descendants_properties (flat list with compact filtering).",
998
- {
999
- path: z.string().describe('Full instance path (e.g. "Workspace.Part", "StarterGui.MyUI.Frame")'),
1000
- compact: z.boolean().optional().default(true).describe("Strip read-only, nil, deprecated, and default-valued properties (default true). Set false for full dump.")
1001
- },
1002
- async ({ path: path2, compact }) => {
1003
- assertConnected();
1004
- const res = await bridge.sendRequest(CliMsg.GET_PROPERTIES, { path: path2, compact });
1005
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
743
+ const connectionId = this.wsToConnectionId.get(ws);
744
+ if (!connectionId) return;
745
+ const connection = this.studioClients.get(connectionId);
746
+ this.wsToConnectionId.delete(ws);
747
+ this.studioClients.delete(connectionId);
748
+ for (const [id, pending] of this.pendingRequests) {
749
+ if (pending.targetWs === ws) {
750
+ clearTimeout(pending.timer);
751
+ pending.reject(new Error(`Studio disconnected (${connectionId})`));
752
+ this.pendingRequests.delete(id);
753
+ }
1006
754
  }
1007
- );
1008
- mcp.tool(
1009
- "get_descendants_properties",
1010
- "Easily get all descendants of a UI instance (or any instance) and all of their properties. By default uses compact mode which strips read-only, nil, deprecated, and default-valued properties to keep responses small.",
1011
- {
1012
- path: z.string().describe('Full instance path (e.g., "Workspace.Part", "StarterGui.ScreenGui")'),
1013
- compact: z.boolean().optional().default(true).describe("Strip noisy/default properties (default true). Set false for full dump."),
1014
- outputFile: z.string().optional().describe("Optional file path to write to prevent limit errors/timeouts")
1015
- },
1016
- async ({ path: path2, compact, outputFile }) => {
1017
- assertConnected();
1018
- const res = await bridge.sendRequest(CliMsg.GET_DESCENDANTS_PROPERTIES, { path: path2, compact, outputFile }, 1e3 * 60 * 5);
1019
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
755
+ for (const [id, relay] of this.relayRequests) {
756
+ if (relay.targetWs === ws) {
757
+ this.sendResponse(relay.controllerWs, id, { error: `Studio disconnected (${connectionId})` });
758
+ this.relayRequests.delete(id);
759
+ }
1020
760
  }
1021
- );
1022
- mcp.tool(
1023
- "set_properties",
1024
- "Set properties on an instance in Roblox Studio",
1025
- {
1026
- path: z.string().describe("Full instance path"),
1027
- properties: z.record(z.unknown()).describe("Key-value pairs of properties to set")
1028
- },
1029
- async ({ path: path2, properties }) => {
1030
- assertConnected();
1031
- const res = await bridge.sendRequest(CliMsg.SET_PROPERTIES, { path: path2, properties });
1032
- return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
761
+ if (this.activeConnectionId === connectionId) {
762
+ this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
1033
763
  }
1034
- );
1035
- mcp.tool(
1036
- "insert_instance",
1037
- "Create a new instance in the Roblox Studio DataModel",
1038
- {
1039
- className: z.string().describe('Class name (e.g. "Part", "Script")'),
1040
- parentPath: z.string().describe("Parent instance path"),
1041
- name: z.string().describe("Name for the new instance"),
1042
- properties: z.record(z.unknown()).optional().describe("Optional properties to set")
1043
- },
1044
- async ({ className, parentPath, name, properties }) => {
1045
- assertConnected();
1046
- const res = await bridge.sendRequest(CliMsg.INSERT_INSTANCE, {
1047
- className,
1048
- parentPath,
1049
- name,
1050
- properties: properties ?? {}
1051
- });
1052
- return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
764
+ const info = connection ? this.toPlaceInfo(connection) : { connectionId };
765
+ this.emit("studio:disconnected", info);
766
+ this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
767
+ this.broadcastTargetState();
768
+ }
769
+ resolveConnection(connectionId = this.activeConnectionId) {
770
+ if (connectionId) {
771
+ const connection = this.studioClients.get(connectionId);
772
+ return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
1053
773
  }
1054
- );
1055
- mcp.tool(
1056
- "delete_instance",
1057
- "Delete an instance from the Roblox Studio DataModel",
1058
- { path: z.string().describe("Full instance path to delete") },
1059
- async ({ path: path2 }) => {
1060
- assertConnected();
1061
- const res = await bridge.sendRequest(CliMsg.DELETE_INSTANCE, { path: path2 });
1062
- return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
774
+ if (this.studioClients.size === 1) {
775
+ const connection = this.studioClients.values().next().value;
776
+ return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
1063
777
  }
1064
- );
1065
- mcp.tool(
1066
- "get_selection",
1067
- 'Get the currently selected instances in Roblox Studio. Returns paths and class names of all selected objects. Use this FIRST when the user says "my selection" or "what I have selected". Then use get_properties or serialize_ui on the returned paths to inspect details.',
1068
- {},
1069
- async () => {
1070
- assertConnected();
1071
- const res = await bridge.sendRequest(CliMsg.GET_SELECTION, {});
1072
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
778
+ return null;
779
+ }
780
+ getTargetError() {
781
+ if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
782
+ if (this.activeConnectionId) return `Studio connection ${this.activeConnectionId} is unavailable`;
783
+ return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
784
+ }
785
+ getPendingInfos() {
786
+ const now = Date.now();
787
+ for (const [id, pending] of this.pendingConnections) {
788
+ if (pending.info.expiresAt <= now || pending.ws.readyState !== WebSocket.OPEN) {
789
+ this.pendingConnections.delete(id);
790
+ pending.ws.close(1008, "Pairing expired");
791
+ }
1073
792
  }
1074
- );
1075
- mcp.tool(
1076
- "search_scripts",
1077
- "Search all scripts in Roblox Studio for a text pattern",
1078
- {
1079
- query: z.string().describe("Text to search for"),
1080
- maxResults: z.number().optional().default(20).describe("Max results")
1081
- },
1082
- async ({ query, maxResults }) => {
1083
- assertConnected();
1084
- const res = await bridge.sendRequest(CliMsg.SEARCH_SCRIPTS, { query, maxResults });
1085
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
793
+ return [...this.pendingConnections.values()].map(({ info }) => ({ ...info }));
794
+ }
795
+ toPlaceInfo(connection) {
796
+ return {
797
+ placeId: connection.placeId ?? 0,
798
+ connectionId: connection.connectionId,
799
+ clientId: connection.clientId,
800
+ placeName: connection.placeName,
801
+ studioVersion: connection.studioVersion,
802
+ connectedAt: connection.connectedAt,
803
+ active: connection.connectionId === this.activeConnectionId
804
+ };
805
+ }
806
+ broadcastToControllers(type, payload) {
807
+ const message = createMessage(type, payload);
808
+ for (const controller of this.controllers) this.sendSafe(controller, message);
809
+ }
810
+ broadcastTargetState() {
811
+ this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
812
+ connections: this.listConnections(),
813
+ defaultActiveConnectionId: this.activeConnectionId
814
+ });
815
+ }
816
+ sendResponse(ws, id, payload) {
817
+ this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
818
+ }
819
+ sendSafe(ws, message) {
820
+ if (ws.readyState !== WebSocket.OPEN) return;
821
+ try {
822
+ ws.send(serializeMessage(message));
823
+ } catch (err) {
824
+ this.emit("server:error", err);
1086
825
  }
1087
- );
1088
- mcp.tool(
1089
- "get_output",
1090
- "Get recent output log from Roblox Studio",
1091
- {
1092
- limit: z.number().optional().default(50).describe("Max entries"),
1093
- level: z.enum(["info", "warn", "error"]).optional().describe("Filter by level")
1094
- },
1095
- async ({ limit, level }) => {
1096
- assertConnected();
1097
- const res = await bridge.sendRequest(CliMsg.GET_OUTPUT, {
1098
- limit,
1099
- level: level ?? null
826
+ }
827
+ sendRequest(type, payload, timeoutMs = 15e3) {
828
+ return new Promise((resolve, reject) => {
829
+ const target = this.resolveConnection();
830
+ if (!target) {
831
+ reject(new Error(this.getTargetError()));
832
+ return;
833
+ }
834
+ const message = createMessage(type, payload);
835
+ const timer = setTimeout(() => {
836
+ this.pendingRequests.delete(message.id);
837
+ reject(new Error(`Studio request timed out: ${type}`));
838
+ }, timeoutMs);
839
+ this.pendingRequests.set(message.id, {
840
+ resolve,
841
+ reject,
842
+ timer,
843
+ targetWs: target.ws
1100
844
  });
1101
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
1102
- }
1103
- );
1104
- mcp.tool(
1105
- "get_class_info",
1106
- "Look up the schema of any Roblox class using ReflectionService. Returns all properties (with types), methods (with parameters), and events. ALWAYS use this before creating or modifying GUI elements to know correct property names and types.",
1107
- { className: z.string().describe('Roblox class name, e.g. "Frame", "TextLabel", "Part", "MeshPart"') },
1108
- async ({ className }) => {
1109
- assertConnected();
1110
- const res = await bridge.sendRequest(CliMsg.GET_REFLECTION, { className });
1111
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
845
+ this.sendSafe(target.ws, message);
846
+ });
847
+ }
848
+ sendNotification(type, payload) {
849
+ const target = this.resolveConnection();
850
+ if (target) this.sendSafe(target.ws, createMessage(type, payload));
851
+ }
852
+ isConnected() {
853
+ return this.resolveConnection() !== null;
854
+ }
855
+ getConnectionInfo() {
856
+ return this.resolveConnection();
857
+ }
858
+ listConnections() {
859
+ return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
860
+ }
861
+ async listPendingConnections() {
862
+ return this.getPendingInfos();
863
+ }
864
+ async approveConnection(connectionId, pairingCode) {
865
+ const pending = this.pendingConnections.get(connectionId);
866
+ if (!pending || pending.info.expiresAt <= Date.now()) {
867
+ throw new Error("Pairing request not found or expired");
1112
868
  }
1113
- );
1114
- mcp.tool(
1115
- "create_ui",
1116
- 'Create an entire UI tree in one call. Pass a declarative JSON tree \u2014 ClassName, Name, properties, Children. All types auto-coerced in Luau (UDim2, Color3, Font, enums). 10x faster than run_code. UDim2: [xScale, xOffset, yScale, yOffset]. Color3: "#hex" or "rgb(r,g,b)". Font: "GothamBold". Enums: string names.',
1117
- {
1118
- parent: z.string().optional().describe('Parent: "StarterGui", "PlayerGui", or path. Default: StarterGui'),
1119
- tree: z.record(z.unknown()).describe(
1120
- 'Root UI node: {ClassName, Name, properties..., Children: [{...}, ...]}. UDim2 as [xScale, xOffset, yScale, yOffset], Color3 as "#hex", Font as "GothamBold"'
1121
- ),
1122
- animate: z.boolean().optional().describe("If true, task.wait() will be injected between creation of nodes so that the UI writes out dynamically.")
1123
- },
1124
- async ({ parent, tree, animate }) => {
1125
- assertConnected();
1126
- const res = await bridge.sendRequest(
1127
- CliMsg.BUILD_UI,
1128
- { parent: parent ?? "StarterGui", tree, animate }
1129
- );
1130
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
869
+ if (pending.pairingCode !== pairingCode) {
870
+ throw new Error("Pairing code is incorrect");
1131
871
  }
1132
- );
1133
- mcp.tool(
1134
- "run_tests",
1135
- "Run tests by requiring ModuleScripts matching test naming conventions (*.test, *.spec, *Test, *Spec). Lightweight \u2014 does not enter Run/Play mode.",
1136
- { testName: z.string().optional().describe("Specific test name, or omit to run all") },
1137
- async ({ testName }) => {
1138
- assertConnected();
1139
- const res = await bridge.sendRequest(CliMsg.RUN_TESTS, {
1140
- testName: testName ?? null
1141
- });
1142
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
872
+ const connection = this.authorizeStudio(pending.ws, pending.hello, connectionId);
873
+ return this.toPlaceInfo(connection);
874
+ }
875
+ getActiveConnectionId() {
876
+ return this.activeConnectionId;
877
+ }
878
+ async setActiveConnectionId(connectionId) {
879
+ if (connectionId && !this.studioClients.has(connectionId)) {
880
+ throw new Error(`Studio connection ${connectionId} is not connected`);
1143
881
  }
1144
- );
1145
- mcp.tool(
1146
- "execute_run_test",
1147
- "Start a Run mode test session using StudioTestService:ExecuteRunModeAsync(). This actually enters Run mode in Studio. Pass args that server scripts can retrieve via GetTestArgs(). Yields until EndTest() is called or the session stops. Returns the value from EndTest(). Use for headless automated tests \u2014 no player, just server scripts.",
1148
- { args: z.unknown().optional().describe("Arguments passed to the test session. Server scripts get this via StudioTestService:GetTestArgs()") },
1149
- async ({ args }) => {
1150
- assertConnected();
1151
- const res = await bridge.sendRequest(CliMsg.EXECUTE_RUN_TEST, { args: args ?? null }, 12e4);
1152
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
882
+ this.activeConnectionId = connectionId;
883
+ this.broadcastTargetState();
884
+ }
885
+ getActivePlaceId() {
886
+ return this.resolveConnection()?.placeId ?? 0;
887
+ }
888
+ setActivePlaceId(placeId) {
889
+ if (placeId === 0) {
890
+ this.activeConnectionId = null;
891
+ return;
1153
892
  }
1154
- );
1155
- mcp.tool(
1156
- "execute_play_test",
1157
- "Start a solo Play mode test session using StudioTestService:ExecutePlayModeAsync(). This enters Play mode with a player character. Pass args that server scripts can retrieve via GetTestArgs(). Yields until EndTest() is called or the session stops. Returns the value from EndTest(). Use for tests needing a player character and full client-server simulation.",
1158
- { args: z.unknown().optional().describe("Arguments passed to the test session. Server scripts get this via StudioTestService:GetTestArgs()") },
1159
- async ({ args }) => {
1160
- assertConnected();
1161
- const res = await bridge.sendRequest(CliMsg.EXECUTE_PLAY_TEST, { args: args ?? null }, 12e4);
1162
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
893
+ const matches = [...this.studioClients.values()].filter((connection) => connection.placeId === placeId);
894
+ if (matches.length !== 1) {
895
+ throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
1163
896
  }
1164
- );
1165
- mcp.tool(
1166
- "create_part",
1167
- "Create a Part with position, size, color, material \u2014 all in one call. Faster than insert_instance + set_properties.",
1168
- {
1169
- name: z.string().describe("Name for the part"),
1170
- parent: z.string().optional().describe("Parent path (default: Workspace)"),
1171
- shape: z.enum(["Block", "Ball", "Cylinder", "Wedge", "CornerWedge"]).optional().describe("Part shape"),
1172
- position: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("World position"),
1173
- size: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("Size in studs"),
1174
- color: z.string().optional().describe('Hex "#00FF00", BrickColor name, or "rgb(0,255,0)"'),
1175
- material: z.string().optional().describe("Plastic, Wood, Grass, Neon, Metal, etc."),
1176
- transparency: z.number().optional().describe("0=opaque, 1=invisible"),
1177
- anchored: z.boolean().optional().describe("Anchored (default: true)"),
1178
- canCollide: z.boolean().optional().describe("Has collision")
1179
- },
1180
- async (params) => {
1181
- assertConnected();
1182
- const properties = {};
1183
- if (params.position) properties.Position = { X: params.position.x, Y: params.position.y, Z: params.position.z };
1184
- if (params.size) properties.Size = { X: params.size.x, Y: params.size.y, Z: params.size.z };
1185
- if (params.color) {
1186
- const c = params.color;
1187
- if (c.startsWith("#")) properties.Color = c;
1188
- else if (c.startsWith("rgb")) {
1189
- const m = c.match(/(\d+)/g);
1190
- if (m && m.length >= 3) properties.Color = { red: parseInt(m[0]), green: parseInt(m[1]), blue: parseInt(m[2]) };
1191
- } else properties.BrickColor = c;
897
+ this.activeConnectionId = matches[0].connectionId;
898
+ }
899
+ getPort() {
900
+ return this.port;
901
+ }
902
+ stop() {
903
+ return new Promise((resolve) => {
904
+ for (const pending of this.pendingRequests.values()) {
905
+ clearTimeout(pending.timer);
906
+ pending.reject(new Error("Dominus bridge is shutting down"));
1192
907
  }
1193
- if (params.material) properties.Material = params.material;
1194
- if (params.transparency !== void 0) properties.Transparency = params.transparency;
1195
- if (params.anchored !== void 0) properties.Anchored = params.anchored;
1196
- else properties.Anchored = true;
1197
- if (params.canCollide !== void 0) properties.CanCollide = params.canCollide;
1198
- if (params.shape && params.shape !== "Block") properties.Shape = params.shape;
1199
- const className = params.shape === "Wedge" ? "WedgePart" : params.shape === "CornerWedge" ? "CornerWedgePart" : "Part";
1200
- const res = await bridge.sendRequest(CliMsg.INSERT_INSTANCE, {
1201
- className,
1202
- parentPath: params.parent ?? "Workspace",
1203
- name: params.name,
1204
- properties
908
+ this.pendingRequests.clear();
909
+ this.relayRequests.clear();
910
+ for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
911
+ this.studioClients.clear();
912
+ this.pendingConnections.clear();
913
+ this.controllers.clear();
914
+ this.socketRoles.clear();
915
+ this.wsToConnectionId.clear();
916
+ if (this.wss) this.wss.close(() => resolve());
917
+ else resolve();
918
+ });
919
+ }
920
+ };
921
+ function validateStudioHello(payload) {
922
+ if (!payload || typeof payload !== "object") throw new Error("Invalid Studio hello payload");
923
+ const hello = payload;
924
+ if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
925
+ throw new Error(`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`);
926
+ }
927
+ if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
928
+ throw new Error("Studio hello is missing a valid clientId");
929
+ }
930
+ if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
931
+ throw new Error("Studio hello is missing studioVersion");
932
+ }
933
+ if (hello.placeName && hello.placeName.length > 256) throw new Error("Studio placeName is too long");
934
+ return hello;
935
+ }
936
+ function rawByteLength(raw) {
937
+ if (typeof raw === "string") return Buffer.byteLength(raw);
938
+ if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
939
+ return raw.byteLength;
940
+ }
941
+
942
+ // src/mcp/instructions.ts
943
+ var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
944
+
945
+ Required workflow for Studio changes:
946
+ 1. Call dominus_status and select the intended Studio connection when more than one is open.
947
+ 2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
948
+ 3. Form a short plan with explicit acceptance checks.
949
+ 4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
950
+ 5. Apply the smallest coherent mutation. Script updates require the expectedRevision returned by studio_read_script.
951
+ 6. Read the affected instances back and compare exact values, hierarchy, and source revisions.
952
+ 7. Run an appropriate Studio test when behavior changed. Report verification evidence and any remaining uncertainty.
953
+
954
+ Safety rules:
955
+ - Never invent instance IDs or Roblox API members.
956
+ - Do not retry a failed write unchanged. Inspect the error and current state first.
957
+ - Deletion is a separate destructive tool and requires explicit user intent.
958
+ - studio_apply is transactional. Keep related edits together, but stay within its documented limits.
959
+ - UI fidelity work must snapshot and inspect the result after building; verify fonts, UDim2 values, colors, strokes, and hierarchy.
960
+ - Dominus intentionally has no arbitrary Luau execution tool.`;
961
+
962
+ // src/mcp/resources.ts
963
+ function registerDominusResources(server, bridge) {
964
+ server.registerResource("dominus-status", "dominus://status", {
965
+ title: "Dominus 2 Studio Status",
966
+ description: "Current authenticated Roblox Studio sessions and selected target.",
967
+ mimeType: "application/json"
968
+ }, async (uri) => ({
969
+ contents: [{
970
+ uri: uri.href,
971
+ mimeType: "application/json",
972
+ text: JSON.stringify({
973
+ protocolVersion: 2,
974
+ activeConnectionId: bridge.getActiveConnectionId(),
975
+ connections: bridge.listConnections(),
976
+ pendingConnections: await bridge.listPendingConnections()
977
+ }, null, 2)
978
+ }]
979
+ }));
980
+ server.registerPrompt("dominus-workflow", {
981
+ title: "Dominus 2 Engineering Workflow",
982
+ description: "Inspect, plan, apply, verify, and test a Roblox Studio change safely."
983
+ }, async () => ({
984
+ messages: [{
985
+ role: "user",
986
+ content: { type: "text", text: DOMINUS_MCP_INSTRUCTIONS }
987
+ }]
988
+ }));
989
+ }
990
+
991
+ // src/mcp/result.ts
992
+ function toolResult(output) {
993
+ return {
994
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
995
+ structuredContent: output,
996
+ isError: !output.success
997
+ };
998
+ }
999
+ function success(data = {}) {
1000
+ return toolResult({ success: true, data });
1001
+ }
1002
+ function failure(error, data) {
1003
+ return toolResult({
1004
+ success: false,
1005
+ error: error instanceof Error ? error.message : String(error),
1006
+ data
1007
+ });
1008
+ }
1009
+ var instanceRefSchema = z.object({
1010
+ instanceId: z.string().uuid().optional(),
1011
+ pathSegments: z.array(z.string().min(1).max(256)).min(1).max(100).optional()
1012
+ }).refine((value) => Boolean(value.instanceId || value.pathSegments), {
1013
+ message: "Provide instanceId or pathSegments"
1014
+ });
1015
+ var toolOutputSchema = {
1016
+ success: z.boolean(),
1017
+ data: z.record(z.unknown()).optional(),
1018
+ error: z.string().optional()
1019
+ };
1020
+ async function callStudio(bridge, command, payload, timeoutMs = 15e3) {
1021
+ try {
1022
+ const response = await bridge.sendRequest(command, payload, timeoutMs);
1023
+ const result = response.payload;
1024
+ if (!result || typeof result !== "object") return failure("Studio returned an invalid response");
1025
+ if (result.success === false || typeof result.error === "string") {
1026
+ return failure(result.error ?? "Studio command failed", result);
1027
+ }
1028
+ return success(result);
1029
+ } catch (err) {
1030
+ return failure(err);
1031
+ }
1032
+ }
1033
+ function ref(value) {
1034
+ return value;
1035
+ }
1036
+
1037
+ // src/mcp/tools/connections.ts
1038
+ function registerConnectionTools(server, bridge) {
1039
+ server.registerTool("dominus_status", {
1040
+ title: "Dominus Studio Status",
1041
+ description: "List authenticated and pending Roblox Studio sessions, the active target, and bridge health. Call this before Studio work.",
1042
+ inputSchema: {},
1043
+ outputSchema: toolOutputSchema,
1044
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1045
+ }, async () => {
1046
+ try {
1047
+ const pending = await bridge.listPendingConnections();
1048
+ return success({
1049
+ protocolVersion: 2,
1050
+ port: bridge.getPort(),
1051
+ activeConnectionId: bridge.getActiveConnectionId(),
1052
+ connections: bridge.listConnections(),
1053
+ pendingConnections: pending
1205
1054
  });
1206
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
1055
+ } catch (err) {
1056
+ return failure(err);
1207
1057
  }
1208
- );
1209
- mcp.tool(
1210
- "clone_instance",
1211
- "Clone an existing instance, optionally rename it, move it, or offset its position",
1212
- {
1213
- path: z.string().describe("Path of instance to clone"),
1214
- newName: z.string().optional().describe("Name for the clone"),
1215
- newParent: z.string().optional().describe("Parent path for the clone"),
1216
- offset: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("Position offset from original")
1058
+ });
1059
+ server.registerTool("dominus_pair_studio", {
1060
+ title: "Pair Roblox Studio",
1061
+ description: "Approve a first-time Studio plugin connection using the connection ID and six-digit code printed in Roblox Studio Output.",
1062
+ inputSchema: {
1063
+ connectionId: z.string().uuid(),
1064
+ pairingCode: z.string().regex(/^\d{6}$/)
1217
1065
  },
1218
- async (params) => {
1219
- assertConnected();
1220
- const lines = [`local orig = ${resolvePath(params.path)}`, `local clone = orig:Clone()`];
1221
- if (params.newName) lines.push(`clone.Name = ${luaStr(params.newName)}`);
1222
- if (params.newParent) {
1223
- lines.push(`clone.Parent = ${resolvePath(params.newParent)}`);
1224
- } else {
1225
- lines.push(`clone.Parent = orig.Parent`);
1226
- }
1227
- if (params.offset) {
1228
- lines.push(`if clone:IsA("BasePart") then`);
1229
- lines.push(` clone.Position = clone.Position + Vector3.new(${params.offset.x}, ${params.offset.y}, ${params.offset.z})`);
1230
- lines.push(`end`);
1231
- }
1232
- lines.push(`print("Cloned: " .. clone:GetFullName())`);
1233
- const res = await bridge.sendRequest(CliMsg.RUN_CODE, { code: lines.join("\n") });
1234
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
1066
+ outputSchema: toolOutputSchema,
1067
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
1068
+ }, async ({ connectionId, pairingCode }) => {
1069
+ try {
1070
+ const connection = await bridge.approveConnection(connectionId, pairingCode);
1071
+ return success({ connection });
1072
+ } catch (err) {
1073
+ return failure(err);
1235
1074
  }
1236
- );
1237
- mcp.tool(
1238
- "group_instances",
1239
- "Group multiple instances into a Model or Folder. All listed instances become children of the new group.",
1240
- {
1241
- paths: z.array(z.string()).describe("Array of instance paths to group"),
1242
- groupName: z.string().describe("Name for the group"),
1243
- groupClass: z.enum(["Model", "Folder"]).optional().describe("Container class (default: Model)"),
1244
- parent: z.string().optional().describe("Parent path (default: Workspace)")
1245
- },
1246
- async (params) => {
1247
- assertConnected();
1248
- const gc = params.groupClass ?? "Model";
1249
- const parent = params.parent ?? "Workspace";
1250
- const code = [
1251
- `local group = Instance.new(${luaStr(gc)})`,
1252
- `group.Name = ${luaStr(params.groupName)}`,
1253
- `group.Parent = ${resolvePath(parent)}`,
1254
- ...params.paths.map((p) => `${resolvePath(p)}.Parent = group`),
1255
- `print("Grouped ${params.paths.length} instances into: " .. group:GetFullName())`
1256
- ].join("\n");
1257
- const res = await bridge.sendRequest(CliMsg.RUN_CODE, { code });
1258
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
1075
+ });
1076
+ server.registerTool("dominus_select_studio", {
1077
+ title: "Select Studio Session",
1078
+ description: "Choose the exact Studio connection targeted by subsequent tools. Uses connectionId, not placeId, so duplicate windows are safe.",
1079
+ inputSchema: { connectionId: z.string().uuid().nullable() },
1080
+ outputSchema: toolOutputSchema,
1081
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1082
+ }, async ({ connectionId }) => {
1083
+ try {
1084
+ await bridge.setActiveConnectionId(connectionId);
1085
+ return success({ activeConnectionId: bridge.getActiveConnectionId() });
1086
+ } catch (err) {
1087
+ return failure(err);
1259
1088
  }
1260
- );
1261
- mcp.tool(
1262
- "build_multiple",
1263
- "Create many parts/instances in one batch. Send an array of specs \u2014 all created in a single round-trip. Ideal for building trees, houses, vehicles. Supports hex colors, rgb() colors, and BrickColor names.",
1264
- {
1265
- parts: z.array(z.object({
1266
- name: z.string(),
1267
- className: z.string().optional(),
1268
- parent: z.string().optional(),
1269
- position: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional(),
1270
- size: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional(),
1271
- color: z.string().optional().describe('Hex "#RRGGBB", "rgb(r,g,b)", or BrickColor name'),
1272
- material: z.string().optional().describe("Enum.Material name: Plastic, Wood, Neon, Glass, etc."),
1273
- transparency: z.number().optional(),
1274
- anchored: z.boolean().optional(),
1275
- canCollide: z.boolean().optional(),
1276
- shape: z.enum(["Block", "Ball", "Cylinder", "Wedge"]).optional()
1277
- })).describe("Array of part specifications"),
1278
- groupName: z.string().optional().describe("Group all parts into a Model with this name"),
1279
- groupParent: z.string().optional().describe("Parent for the group (default: Workspace)")
1280
- },
1281
- async (params) => {
1282
- assertConnected();
1283
- const parts = params.parts;
1284
- const groupName = params.groupName;
1285
- const groupParent = params.groupParent ?? "Workspace";
1286
- const lines = [];
1287
- if (groupName) {
1288
- lines.push(`local _group = Instance.new("Model")`);
1289
- lines.push(`_group.Name = ${luaStr(groupName)}`);
1290
- lines.push(`_group.Parent = ${resolvePath(groupParent)}`);
1291
- }
1292
- for (let i = 0; i < parts.length; i++) {
1293
- const p = parts[i];
1294
- const v = `_p${i}`;
1295
- const cn = p.className ?? (p.shape === "Wedge" ? "WedgePart" : "Part");
1296
- lines.push(`local ${v} = Instance.new(${luaStr(cn)})`);
1297
- lines.push(`${v}.Name = ${luaStr(p.name)}`);
1298
- if (p.position) lines.push(`${v}.Position = Vector3.new(${p.position.x}, ${p.position.y}, ${p.position.z})`);
1299
- if (p.size) lines.push(`${v}.Size = Vector3.new(${p.size.x}, ${p.size.y}, ${p.size.z})`);
1300
- if (p.color) {
1301
- if (p.color.startsWith("#")) {
1302
- lines.push(`${v}.Color = Color3.fromHex(${luaStr(p.color)})`);
1303
- } else if (p.color.startsWith("rgb(")) {
1304
- const m = p.color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
1305
- if (m) lines.push(`${v}.Color = Color3.fromRGB(${m[1]}, ${m[2]}, ${m[3]})`);
1306
- } else {
1307
- lines.push(`${v}.BrickColor = BrickColor.new(${luaStr(p.color)})`);
1308
- }
1309
- }
1310
- if (p.material) lines.push(`${v}.Material = Enum.Material.${p.material}`);
1311
- if (p.transparency !== void 0) lines.push(`${v}.Transparency = ${p.transparency}`);
1312
- lines.push(`${v}.Anchored = ${p.anchored ?? true}`);
1313
- if (p.canCollide !== void 0) lines.push(`${v}.CanCollide = ${p.canCollide}`);
1314
- if (p.shape && p.shape !== "Block" && cn === "Part") lines.push(`${v}.Shape = Enum.PartType.${p.shape}`);
1315
- lines.push(`${v}.Parent = ${groupName ? "_group" : resolvePath(p.parent ?? "Workspace")}`);
1316
- }
1317
- lines.push(`print("Built ${parts.length} parts${groupName ? ` in ${groupName}` : ""}")`);
1318
- const res = await bridge.sendRequest(CliMsg.RUN_CODE, { code: lines.join("\n") });
1319
- return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
1089
+ });
1090
+ }
1091
+ var GITHUB_RAW_BASE = "https://raw.githubusercontent.com/Roblox/creator-docs/main/content/en-us/reference/engine";
1092
+ var GITHUB_TREE_URL = "https://api.github.com/repos/Roblox/creator-docs/git/trees/main?recursive=1";
1093
+ var KIND_FOLDERS = {
1094
+ class: "classes",
1095
+ datatype: "datatypes",
1096
+ enum: "enums",
1097
+ global: "globals",
1098
+ library: "libraries"
1099
+ };
1100
+ var MEMBER_SECTIONS = [
1101
+ "properties",
1102
+ "methods",
1103
+ "events",
1104
+ "callbacks",
1105
+ "constructors",
1106
+ "functions",
1107
+ "items",
1108
+ "constants",
1109
+ "math_operations"
1110
+ ];
1111
+ var remoteTreeCache = null;
1112
+ async function getRobloxApiReference(name, opts = {}) {
1113
+ const normalizedName = normalizeLookupName(name);
1114
+ const kinds = opts.kind && opts.kind !== "any" ? [opts.kind] : Object.keys(KIND_FOLDERS);
1115
+ for (const kind of kinds) {
1116
+ const doc = await readReferenceYaml(normalizedName, kind, opts);
1117
+ if (!doc) continue;
1118
+ const reference = parseReferenceYaml(doc.content, {
1119
+ kind,
1120
+ sourceUrl: doc.sourceUrl
1121
+ });
1122
+ markDefinedOn(reference, reference.name);
1123
+ if (opts.includeInherited && reference.kind === "class") {
1124
+ const inherited = await collectInheritedMembers(reference, opts, /* @__PURE__ */ new Set([reference.name]));
1125
+ reference.inheritedClasses = inherited.classes;
1126
+ reference.inheritedMembers = inherited.members;
1320
1127
  }
1321
- );
1322
- mcp.tool(
1323
- "serialize_ui",
1324
- "Serialize an existing Roblox UI tree into a compact, create_ui-compatible JSON tree. This is the reverse of create_ui \u2014 reads any GUI instance and returns a declarative JSON tree with ClassName, Name, non-default properties (Color3 as hex, UDim2 as arrays, etc.), and Children. Perfect for: converting existing UI to Roact/React components, cloning/templating, understanding UI structure, or refactoring. Use this INSTEAD of get_descendants_properties when you need to recreate or convert UI.",
1325
- {
1326
- path: z.string().describe('Instance path to serialize (e.g. "StarterGui.MyScreenGui")'),
1327
- maxDepth: z.number().optional().default(50).describe("Maximum tree depth (default 50)")
1328
- },
1329
- async ({ path: path2, maxDepth }) => {
1330
- assertConnected();
1331
- const res = await bridge.sendRequest(
1332
- CliMsg.SERIALIZE_UI,
1333
- { path: path2, maxDepth },
1334
- 1e3 * 60 * 2
1335
- );
1336
- if (!res.payload.success) {
1337
- return { content: [{ type: "text", text: `Error: ${res.payload.error ?? "Serialization failed"}` }] };
1338
- }
1339
- return { content: [{ type: "text", text: JSON.stringify(res.payload.tree, null, 2) }] };
1128
+ return reference;
1129
+ }
1130
+ throw new Error(`Roblox API reference not found for "${name}"`);
1131
+ }
1132
+ async function collectInheritedMembers(reference, opts, visited) {
1133
+ const classes = [];
1134
+ const members = {};
1135
+ for (const parent of reference.inherits) {
1136
+ if (!parent || visited.has(parent)) continue;
1137
+ visited.add(parent);
1138
+ const doc = await readReferenceYaml(parent, "class", opts);
1139
+ if (!doc) continue;
1140
+ const parentReference = parseReferenceYaml(doc.content, {
1141
+ kind: "class",
1142
+ sourceUrl: doc.sourceUrl
1143
+ });
1144
+ markDefinedOn(parentReference, parentReference.name);
1145
+ classes.push(parentReference.name);
1146
+ mergeMembers(members, parentReference.members);
1147
+ const inherited = await collectInheritedMembers(parentReference, opts, visited);
1148
+ classes.push(...inherited.classes);
1149
+ mergeMembers(members, inherited.members);
1150
+ }
1151
+ return { classes, members };
1152
+ }
1153
+ function markDefinedOn(reference, className) {
1154
+ for (const section of Object.values(reference.members)) {
1155
+ for (const member of section) {
1156
+ member.definedOn = className;
1340
1157
  }
1341
- );
1342
- mcp.tool(
1343
- "inspect_selection",
1344
- `Inspect the user's current selection in Roblox Studio. Returns the selected instance paths AND their properties (compact) in a single call. Use this FIRST when the user says "my selection", "what I selected", or "the selected thing". Much better than calling get_selection + get_properties separately.`,
1345
- {},
1346
- async () => {
1347
- assertConnected();
1348
- const selRes = await bridge.sendRequest(CliMsg.GET_SELECTION, {});
1349
- const paths = selRes.payload?.paths ?? [];
1350
- if (paths.length === 0) {
1351
- return { content: [{ type: "text", text: "No instances selected in Studio." }] };
1352
- }
1353
- const results = {};
1354
- for (const p of paths.slice(0, 10)) {
1355
- try {
1356
- const propRes = await bridge.sendRequest(
1357
- CliMsg.GET_PROPERTIES,
1358
- { path: p, compact: true }
1359
- );
1360
- results[p] = propRes.payload.properties ?? propRes.payload;
1361
- } catch {
1362
- results[p] = { error: "Failed to read properties" };
1363
- }
1364
- }
1365
- if (paths.length > 10) {
1366
- results["_truncated"] = `${paths.length - 10} more instances not shown`;
1367
- }
1368
- return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
1158
+ }
1159
+ }
1160
+ function mergeMembers(target, source) {
1161
+ for (const [section, members] of Object.entries(source)) {
1162
+ target[section] ??= [];
1163
+ target[section].push(...members);
1164
+ }
1165
+ }
1166
+ async function searchRobloxApi(query, opts = {}) {
1167
+ const limit = Math.max(1, Math.min(opts.limit ?? 10, 50));
1168
+ const normalizedQuery = normalizeSearchText(query);
1169
+ const kinds = opts.kind && opts.kind !== "any" ? [opts.kind] : Object.keys(KIND_FOLDERS);
1170
+ const entries = await listReferenceEntries(opts);
1171
+ return entries.filter((entry) => kinds.includes(entry.kind)).map((entry) => ({
1172
+ ...entry,
1173
+ score: scoreSearchResult(normalizedQuery, entry.name, entry.kind)
1174
+ })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score || a.name.localeCompare(b.name)).slice(0, limit);
1175
+ }
1176
+ function parseReferenceYaml(raw, opts = {}) {
1177
+ const kind = opts.kind ?? normalizeKind(readTopScalar(raw, "type")) ?? "class";
1178
+ const members = {};
1179
+ for (const section of MEMBER_SECTIONS) {
1180
+ const parsed = parseMemberSection(raw, section);
1181
+ if (parsed.length > 0) {
1182
+ members[section] = parsed;
1369
1183
  }
1370
- );
1371
- mcp.tool(
1372
- "inspect_instance",
1373
- "Inspect a single instance: returns its compact properties AND its children tree in one call. Combines get_properties + get_explorer into a single round-trip. Use when you need to understand an instance and its subtree.",
1374
- {
1375
- path: z.string().describe('Full instance path (e.g. "Workspace.MyModel")'),
1376
- childDepth: z.number().optional().default(2).describe("How deep to show the children tree (default 2)")
1377
- },
1378
- async ({ path: path2, childDepth }) => {
1379
- assertConnected();
1380
- const [propRes, treeRes] = await Promise.all([
1381
- bridge.sendRequest(
1382
- CliMsg.GET_PROPERTIES,
1383
- { path: path2, compact: true }
1384
- ),
1385
- bridge.sendRequest(
1386
- CliMsg.GET_EXPLORER,
1387
- { rootPath: path2, maxDepth: childDepth ?? 2 }
1388
- )
1389
- ]);
1184
+ }
1185
+ return {
1186
+ name: readTopScalar(raw, "name") || "Unknown",
1187
+ kind,
1188
+ summary: cleanDocText(readTopScalar(raw, "summary")),
1189
+ description: cleanDocText(readTopScalar(raw, "description")),
1190
+ inherits: readTopList(raw, "inherits"),
1191
+ tags: readTopList(raw, "tags"),
1192
+ deprecationMessage: cleanDocText(readTopScalar(raw, "deprecation_message")),
1193
+ sourceUrl: opts.sourceUrl ?? "",
1194
+ members
1195
+ };
1196
+ }
1197
+ function normalizeKind(value) {
1198
+ if (value === "class" || value === "datatype" || value === "enum" || value === "global" || value === "library") {
1199
+ return value;
1200
+ }
1201
+ return void 0;
1202
+ }
1203
+ async function readReferenceYaml(name, kind, opts) {
1204
+ const localRoot = resolveDocsRoot(opts.docsRoot);
1205
+ const folder = KIND_FOLDERS[kind];
1206
+ const fileName = `${name}.yaml`;
1207
+ if (localRoot) {
1208
+ const localPath = path3.join(localRoot, folder, fileName);
1209
+ if (fs3.existsSync(localPath)) {
1390
1210
  return {
1391
- content: [{
1392
- type: "text",
1393
- text: JSON.stringify({
1394
- properties: propRes.payload.properties ?? propRes.payload,
1395
- children: treeRes.payload.tree ?? treeRes.payload
1396
- }, null, 2)
1397
- }]
1211
+ content: fs3.readFileSync(localPath, "utf-8"),
1212
+ sourceUrl: toCreatorDocsUrl(kind, name)
1398
1213
  };
1399
1214
  }
1400
- );
1401
- mcp.tool(
1402
- "convert_ui_to_code",
1403
- 'Convert an existing Roblox UI tree into Roact/React-lua component code. Serializes the UI at the given path, then generates a ready-to-use Luau component. Use when the user asks to "turn this into a component", "convert to React", or "make a Roact version".',
1404
- {
1405
- path: z.string().describe('Path to the UI instance to convert (e.g. "StarterGui.MyScreenGui.MainFrame")'),
1406
- framework: z.enum(["roact", "react"]).optional().default("react").describe('Target framework: "roact" (legacy) or "react" (react-lua, default)'),
1407
- componentName: z.string().optional().describe("Name for the generated component (defaults to instance Name)")
1408
- },
1409
- async ({ path: path2, framework, componentName }) => {
1410
- assertConnected();
1411
- const res = await bridge.sendRequest(
1412
- CliMsg.SERIALIZE_UI,
1413
- { path: path2, maxDepth: 50 },
1414
- 1e3 * 60 * 2
1415
- );
1416
- if (!res.payload.success || !res.payload.tree) {
1417
- return { content: [{ type: "text", text: `Error serializing UI: ${res.payload.error ?? "Unknown error"}` }] };
1215
+ }
1216
+ const sourceUrl = `${GITHUB_RAW_BASE}/${folder}/${encodeURIComponent(fileName)}`;
1217
+ try {
1218
+ return {
1219
+ content: await fetchText(sourceUrl),
1220
+ sourceUrl: toCreatorDocsUrl(kind, name)
1221
+ };
1222
+ } catch {
1223
+ return null;
1224
+ }
1225
+ }
1226
+ async function listReferenceEntries(opts) {
1227
+ const localRoot = resolveDocsRoot(opts.docsRoot);
1228
+ if (localRoot) {
1229
+ const entries = [];
1230
+ for (const [kind, folder] of Object.entries(KIND_FOLDERS)) {
1231
+ const dir = path3.join(localRoot, folder);
1232
+ if (!fs3.existsSync(dir)) continue;
1233
+ for (const file of fs3.readdirSync(dir)) {
1234
+ if (!file.endsWith(".yaml")) continue;
1235
+ const name = path3.basename(file, ".yaml");
1236
+ entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
1418
1237
  }
1419
- const tree = res.payload.tree;
1420
- const name = componentName || tree.Name || "GeneratedComponent";
1421
- const fw = framework || "react";
1422
- const code = generateComponentCode(tree, name, fw);
1423
- return { content: [{ type: "text", text: code }] };
1424
1238
  }
1425
- );
1426
- mcp.tool(
1427
- "list_connections",
1428
- "List all Roblox Studio instances currently connected to Dominus. Shows placeId, placeName, and connection time for each. Use this when working with multiple Studio windows.",
1429
- {},
1430
- async () => {
1431
- const connections = bridge.listConnections();
1432
- const activePlaceId = bridge.getActivePlaceId();
1433
- return {
1434
- content: [{
1435
- type: "text",
1436
- text: JSON.stringify({
1437
- activePlaceId: activePlaceId || "auto",
1438
- connections: connections.map((c) => ({
1439
- ...c,
1440
- active: activePlaceId === 0 && connections.length === 1 ? true : c.placeId === activePlaceId
1441
- }))
1442
- }, null, 2)
1443
- }]
1444
- };
1239
+ return entries;
1240
+ }
1241
+ const tree = await getRemoteTree();
1242
+ const prefix = "content/en-us/reference/engine/";
1243
+ return tree.filter((entry) => entry.type === "blob").map((entry) => entry.path).filter((entryPath) => entryPath.startsWith(prefix) && entryPath.endsWith(".yaml")).flatMap((entryPath) => {
1244
+ const rest = entryPath.slice(prefix.length);
1245
+ const [folder, file] = rest.split("/");
1246
+ const kind = folderToKind(folder);
1247
+ if (!kind || !file) return [];
1248
+ const name = path3.basename(file, ".yaml");
1249
+ return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
1250
+ });
1251
+ }
1252
+ async function getRemoteTree() {
1253
+ if (remoteTreeCache) return remoteTreeCache;
1254
+ const payload = JSON.parse(await fetchText(GITHUB_TREE_URL));
1255
+ remoteTreeCache = payload.tree ?? [];
1256
+ return remoteTreeCache;
1257
+ }
1258
+ function folderToKind(folder) {
1259
+ for (const [kind, value] of Object.entries(KIND_FOLDERS)) {
1260
+ if (value === folder) return kind;
1261
+ }
1262
+ return void 0;
1263
+ }
1264
+ function resolveDocsRoot(explicitRoot) {
1265
+ const candidates = [
1266
+ explicitRoot,
1267
+ process.env.ROBLOX_CREATOR_DOCS_PATH,
1268
+ path3.join(process.cwd(), ".tmp", "creator-docs")
1269
+ ].filter(Boolean);
1270
+ for (const candidate of candidates) {
1271
+ const normalized = normalizeDocsRoot(candidate);
1272
+ if (normalized && fs3.existsSync(normalized)) return normalized;
1273
+ }
1274
+ return null;
1275
+ }
1276
+ function normalizeDocsRoot(root) {
1277
+ const engineRoot = path3.join(root, "content", "en-us", "reference", "engine");
1278
+ if (fs3.existsSync(engineRoot)) return engineRoot;
1279
+ const directClasses = path3.join(root, "classes");
1280
+ if (fs3.existsSync(directClasses)) return root;
1281
+ return null;
1282
+ }
1283
+ function normalizeLookupName(name) {
1284
+ const trimmed = name.trim();
1285
+ const ownerName = trimmed.split(":")[0];
1286
+ const lastToken = ownerName.split(".").pop() ?? ownerName;
1287
+ return lastToken.replace(/[^A-Za-z0-9_]/g, "");
1288
+ }
1289
+ function normalizeSearchText(value) {
1290
+ return value.trim().toLowerCase().replace(/[^a-z0-9_]+/g, "");
1291
+ }
1292
+ function scoreSearchResult(query, name, kind) {
1293
+ if (!query) return 1;
1294
+ const normalizedName = normalizeSearchText(name);
1295
+ if (normalizedName === query) return 100;
1296
+ if (normalizedName.startsWith(query)) return 75;
1297
+ if (normalizedName.includes(query)) return 50;
1298
+ const kindScore = kind.includes(query) ? 10 : 0;
1299
+ return kindScore;
1300
+ }
1301
+ function toCreatorDocsUrl(kind, name) {
1302
+ return `https://create.roblox.com/docs/reference/engine/${KIND_FOLDERS[kind]}/${name}`;
1303
+ }
1304
+ async function fetchText(url) {
1305
+ const response = await fetch(url, {
1306
+ headers: { "User-Agent": "Dominus Roblox API docs lookup" }
1307
+ });
1308
+ if (!response.ok) {
1309
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
1310
+ }
1311
+ return response.text();
1312
+ }
1313
+ function readTopScalar(raw, key) {
1314
+ const lines = raw.split(/\r?\n/);
1315
+ for (let i = 0; i < lines.length; i++) {
1316
+ const match = lines[i].match(new RegExp(`^${escapeRegExp(key)}:\\s*(.*)$`));
1317
+ if (!match) continue;
1318
+ return readYamlValue(lines, i, 0, match[1]);
1319
+ }
1320
+ return "";
1321
+ }
1322
+ function readTopList(raw, key) {
1323
+ const lines = raw.split(/\r?\n/);
1324
+ for (let i = 0; i < lines.length; i++) {
1325
+ const match = lines[i].match(new RegExp(`^${escapeRegExp(key)}:\\s*(.*)$`));
1326
+ if (!match) continue;
1327
+ const inline = match[1].trim();
1328
+ if (inline === "[]" || inline === "") {
1329
+ if (inline === "[]") return [];
1330
+ return readIndentedList(lines, i + 1, 2);
1445
1331
  }
1446
- );
1447
- mcp.tool(
1448
- "set_active_place",
1449
- "Set which Roblox Studio instance to target when multiple are connected. Pass placeId=0 to reset to auto mode (works when only one is connected). Call list_connections first to see available placeIds.",
1450
- {
1451
- placeId: z.number().describe("The placeId to target. Use 0 to reset to auto-select.")
1452
- },
1453
- async ({ placeId }) => {
1454
- const connections = bridge.listConnections();
1455
- if (placeId !== 0 && !connections.some((c) => c.placeId === placeId)) {
1456
- const available = connections.map((c) => `${c.placeName ?? "Unknown"} (${c.placeId})`).join(", ");
1457
- return {
1458
- content: [{
1459
- type: "text",
1460
- text: `Place ${placeId} is not connected. Available: ${available || "none"}`
1461
- }]
1462
- };
1463
- }
1464
- bridge.setActivePlaceId(placeId);
1465
- if (placeId === 0) {
1466
- return { content: [{ type: "text", text: "Active place reset to auto-select." }] };
1467
- }
1468
- const target = connections.find((c) => c.placeId === placeId);
1469
- return {
1470
- content: [{
1471
- type: "text",
1472
- text: `Active place set to ${target?.placeName ?? "Unknown"} (${placeId})`
1473
- }]
1474
- };
1332
+ return [unquote(inline)];
1333
+ }
1334
+ return [];
1335
+ }
1336
+ function parseMemberSection(raw, section) {
1337
+ const sectionLines = extractTopSection(raw, section);
1338
+ if (sectionLines.length === 0) return [];
1339
+ const items = splitMemberItems(sectionLines);
1340
+ return items.map((item) => parseMemberItem(item, section)).filter((item) => Boolean(item));
1341
+ }
1342
+ function extractTopSection(raw, section) {
1343
+ const lines = raw.split(/\r?\n/);
1344
+ const start = lines.findIndex((line) => line === `${section}:`);
1345
+ if (start === -1) return [];
1346
+ const result = [];
1347
+ for (let i = start + 1; i < lines.length; i++) {
1348
+ if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(lines[i])) break;
1349
+ result.push(lines[i]);
1350
+ }
1351
+ return result;
1352
+ }
1353
+ function splitMemberItems(lines) {
1354
+ const items = [];
1355
+ let current = [];
1356
+ for (const line of lines) {
1357
+ if (/^ - /.test(line)) {
1358
+ if (current.length > 0) items.push(current);
1359
+ current = [line];
1360
+ continue;
1475
1361
  }
1476
- );
1477
- mcp.tool(
1478
- "recall_memory",
1479
- "Search persistent memory for facts about the current Roblox project",
1480
- { query: z.string().describe("What to search for") },
1481
- async ({ query }) => {
1482
- const facts = recallFacts("current", query, 15);
1483
- const scripts = getScriptIndex("current");
1484
- return {
1485
- content: [{
1486
- type: "text",
1487
- text: JSON.stringify({ facts: facts.map((f) => f.content), scriptIndex: scripts }, null, 2)
1488
- }]
1489
- };
1362
+ if (current.length > 0) current.push(line);
1363
+ }
1364
+ if (current.length > 0) items.push(current);
1365
+ return items;
1366
+ }
1367
+ function parseMemberItem(lines, section) {
1368
+ const name = readMemberScalar(lines, "name");
1369
+ if (!name) return null;
1370
+ const member = {
1371
+ name,
1372
+ memberType: section,
1373
+ type: readMemberScalar(lines, "type") || readMemberScalar(lines, "return_type"),
1374
+ summary: cleanDocText(readMemberScalar(lines, "summary")),
1375
+ description: cleanDocText(readMemberScalar(lines, "description")),
1376
+ parameters: readNestedObjects(lines, "parameters"),
1377
+ returns: readNestedObjects(lines, "returns"),
1378
+ tags: readMemberList(lines, "tags"),
1379
+ security: readSecurity(lines),
1380
+ threadSafety: readMemberScalar(lines, "thread_safety"),
1381
+ category: readMemberScalar(lines, "category"),
1382
+ deprecationMessage: cleanDocText(readMemberScalar(lines, "deprecation_message"))
1383
+ };
1384
+ const value = Number(readMemberScalar(lines, "value"));
1385
+ if (Number.isFinite(value)) member.value = value;
1386
+ return member;
1387
+ }
1388
+ function readMemberScalar(lines, key) {
1389
+ for (let i = 0; i < lines.length; i++) {
1390
+ const firstLinePattern = new RegExp(`^ - ${escapeRegExp(key)}:\\s*(.*)$`);
1391
+ const fieldPattern = new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*)$`);
1392
+ const firstLineMatch = lines[i].match(firstLinePattern);
1393
+ const fieldMatch = lines[i].match(fieldPattern);
1394
+ const match = firstLineMatch ?? fieldMatch;
1395
+ if (!match) continue;
1396
+ return readYamlValue(lines, i, firstLineMatch ? 2 : 4, match[1]);
1397
+ }
1398
+ return "";
1399
+ }
1400
+ function readMemberList(lines, key) {
1401
+ for (let i = 0; i < lines.length; i++) {
1402
+ const match = lines[i].match(new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*)$`));
1403
+ if (!match) continue;
1404
+ const inline = match[1].trim();
1405
+ if (inline === "[]") return [];
1406
+ if (inline) return [unquote(inline)];
1407
+ return readIndentedList(lines, i + 1, 6);
1408
+ }
1409
+ return [];
1410
+ }
1411
+ function readNestedObjects(lines, key) {
1412
+ const start = lines.findIndex((line) => line === ` ${key}:`);
1413
+ if (start === -1) return [];
1414
+ const nested = [];
1415
+ for (let i = start + 1; i < lines.length; i++) {
1416
+ if (/^ [A-Za-z_][A-Za-z0-9_]*:/.test(lines[i])) break;
1417
+ nested.push(lines[i]);
1418
+ }
1419
+ const items = [];
1420
+ let current = [];
1421
+ for (const line of nested) {
1422
+ if (/^ - /.test(line)) {
1423
+ if (current.length > 0) items.push(current);
1424
+ current = [line];
1425
+ continue;
1490
1426
  }
1491
- );
1492
- mcp.tool(
1493
- "remember",
1494
- "Store a fact about the project in persistent memory for future sessions",
1495
- {
1496
- fact: z.string().describe("The fact to remember"),
1497
- category: z.enum(["architecture", "pattern", "preference", "bug", "api", "general"]).describe("Category")
1427
+ if (current.length > 0) current.push(line);
1428
+ }
1429
+ if (current.length > 0) items.push(current);
1430
+ return items.map((item) => ({
1431
+ name: readNestedScalar(item, "name"),
1432
+ type: readNestedScalar(item, "type"),
1433
+ summary: cleanDocText(readNestedScalar(item, "summary")),
1434
+ default: readNestedScalar(item, "default")
1435
+ }));
1436
+ }
1437
+ function readNestedScalar(lines, key) {
1438
+ for (let i = 0; i < lines.length; i++) {
1439
+ const firstLinePattern = new RegExp(`^ - ${escapeRegExp(key)}:\\s*(.*)$`);
1440
+ const fieldPattern = new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*)$`);
1441
+ const firstLineMatch = lines[i].match(firstLinePattern);
1442
+ const fieldMatch = lines[i].match(fieldPattern);
1443
+ const match = firstLineMatch ?? fieldMatch;
1444
+ if (!match) continue;
1445
+ return readYamlValue(lines, i, firstLineMatch ? 6 : 8, match[1]);
1446
+ }
1447
+ return "";
1448
+ }
1449
+ function readSecurity(lines) {
1450
+ const start = lines.findIndex((line) => line === " security:");
1451
+ if (start === -1) return void 0;
1452
+ return {
1453
+ read: readIndentedScalar(lines, start + 1, "read", 6),
1454
+ write: readIndentedScalar(lines, start + 1, "write", 6)
1455
+ };
1456
+ }
1457
+ function readIndentedScalar(lines, start, key, indent) {
1458
+ const pattern = new RegExp(`^\\s{${indent}}${escapeRegExp(key)}:\\s*(.*)$`);
1459
+ for (let i = start; i < lines.length; i++) {
1460
+ if (indentOf(lines[i]) < indent) break;
1461
+ const match = lines[i].match(pattern);
1462
+ if (match) return unquote(match[1].trim());
1463
+ }
1464
+ return "";
1465
+ }
1466
+ function readYamlValue(lines, index, indent, rawValue) {
1467
+ const value = rawValue.trim();
1468
+ if (value === "|-" || value === "|") {
1469
+ return readBlock(lines, index + 1, indent + 2);
1470
+ }
1471
+ if (value === "''" || value === '""') return "";
1472
+ return unquote(value);
1473
+ }
1474
+ function readBlock(lines, start, minIndent) {
1475
+ const block = [];
1476
+ for (let i = start; i < lines.length; i++) {
1477
+ const line = lines[i];
1478
+ if (line.trim() && indentOf(line) < minIndent) break;
1479
+ block.push(line.slice(Math.min(minIndent, line.length)));
1480
+ }
1481
+ return block.join("\n").trim();
1482
+ }
1483
+ function readIndentedList(lines, start, indent) {
1484
+ const pattern = new RegExp(`^\\s{${indent}}-\\s*(.*)$`);
1485
+ const values = [];
1486
+ for (let i = start; i < lines.length; i++) {
1487
+ if (lines[i].trim() && indentOf(lines[i]) < indent) break;
1488
+ const match = lines[i].match(pattern);
1489
+ if (match) values.push(unquote(match[1].trim()));
1490
+ }
1491
+ return values;
1492
+ }
1493
+ function cleanDocText(value) {
1494
+ return value.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").trim();
1495
+ }
1496
+ function indentOf(line) {
1497
+ return line.match(/^ */)?.[0].length ?? 0;
1498
+ }
1499
+ function unquote(value) {
1500
+ if (value.startsWith("'") && value.endsWith("'") || value.startsWith('"') && value.endsWith('"')) {
1501
+ return value.slice(1, -1);
1502
+ }
1503
+ return value;
1504
+ }
1505
+ function escapeRegExp(value) {
1506
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1507
+ }
1508
+
1509
+ // src/mcp/tools/docs.ts
1510
+ var kindSchema = z.enum(["any", "class", "datatype", "enum", "global", "library"]);
1511
+ function registerDocsTools(server) {
1512
+ server.registerTool("roblox_search_api", {
1513
+ title: "Search Roblox API",
1514
+ description: "Search the official Roblox Creator Docs engine API mirror by exact or partial class, datatype, enum, global, or library name.",
1515
+ inputSchema: {
1516
+ query: z.string().min(1).max(100),
1517
+ kind: kindSchema.optional().default("any"),
1518
+ limit: z.number().int().min(1).max(25).optional().default(10)
1498
1519
  },
1499
- async ({ fact, category }) => {
1500
- const now = Date.now();
1501
- storeFact({ projectId: "current", content: fact, category, source: "mcp", relevance: 1, createdAt: now, accessedAt: now });
1502
- return { content: [{ type: "text", text: `Remembered: ${fact}` }] };
1520
+ outputSchema: toolOutputSchema,
1521
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1522
+ }, async ({ query, kind, limit }) => {
1523
+ try {
1524
+ return success({ results: await searchRobloxApi(query, { kind, limit }) });
1525
+ } catch (err) {
1526
+ return failure(err);
1503
1527
  }
1504
- );
1505
- mcp.resource(
1506
- "studio://status",
1507
- "studio://status",
1508
- async () => {
1509
- const info = bridge.getConnectionInfo();
1510
- return {
1511
- contents: [{
1512
- uri: "studio://status",
1513
- text: JSON.stringify({
1514
- connected: bridge.isConnected(),
1515
- placeName: info?.placeName,
1516
- placeId: info?.placeId,
1517
- studioVersion: info?.studioVersion,
1518
- port: bridge.getPort()
1519
- }, null, 2),
1520
- mimeType: "application/json"
1521
- }]
1522
- };
1528
+ });
1529
+ server.registerTool("roblox_get_api", {
1530
+ title: "Get Roblox API Reference",
1531
+ description: "Read an official Roblox engine API reference including members, types, security, thread safety, inheritance, and source URL.",
1532
+ inputSchema: {
1533
+ name: z.string().min(1).max(100),
1534
+ kind: kindSchema.optional().default("any"),
1535
+ includeInherited: z.boolean().optional().default(true)
1536
+ },
1537
+ outputSchema: toolOutputSchema,
1538
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
1539
+ }, async ({ name, kind, includeInherited }) => {
1540
+ try {
1541
+ const reference = await getRobloxApiReference(name, { kind, includeInherited });
1542
+ return success({ reference });
1543
+ } catch (err) {
1544
+ return failure(err);
1523
1545
  }
1524
- );
1525
- const transport = new StdioServerTransport();
1526
- await mcp.connect(transport);
1546
+ });
1527
1547
  }
1528
- function assertConnected() {
1529
- if (!bridge.isConnected()) {
1530
- throw new Error(
1531
- "Roblox Studio is not connected. Open Studio and click the Dominus plugin Connect button."
1532
- );
1548
+ var propertiesSchema = z.record(z.unknown()).refine(
1549
+ (properties) => Object.keys(properties).length <= 100,
1550
+ "At most 100 properties are allowed per operation"
1551
+ );
1552
+ var setPropertiesOperation = z.object({
1553
+ op: z.literal("setProperties"),
1554
+ target: instanceRefSchema,
1555
+ properties: propertiesSchema
1556
+ });
1557
+ var createOperation = z.object({
1558
+ op: z.literal("create"),
1559
+ parent: instanceRefSchema,
1560
+ className: z.string().min(1).max(100),
1561
+ name: z.string().min(1).max(256),
1562
+ properties: propertiesSchema.optional()
1563
+ });
1564
+ var moveOperation = z.object({
1565
+ op: z.literal("move"),
1566
+ target: instanceRefSchema,
1567
+ parent: instanceRefSchema
1568
+ });
1569
+ var mutationOperation = z.discriminatedUnion("op", [
1570
+ setPropertiesOperation,
1571
+ createOperation,
1572
+ moveOperation
1573
+ ]);
1574
+ var uiNodeSchema = z.lazy(() => z.object({
1575
+ ClassName: z.string().min(1).max(100),
1576
+ Name: z.string().min(1).max(256).optional(),
1577
+ properties: propertiesSchema.optional(),
1578
+ Children: z.array(uiNodeSchema).max(1e3).optional()
1579
+ }));
1580
+ function registerStudioTools(server, bridge) {
1581
+ server.registerTool("studio_get_tree", {
1582
+ title: "Inspect Studio Tree",
1583
+ description: "Read a deterministic Studio hierarchy and receive stable instance refs. Use refs from this result in later calls.",
1584
+ inputSchema: {
1585
+ root: instanceRefSchema.optional(),
1586
+ maxDepth: z.number().int().min(0).max(12).optional().default(3),
1587
+ maxNodes: z.number().int().min(1).max(5e3).optional().default(1e3)
1588
+ },
1589
+ outputSchema: toolOutputSchema,
1590
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1591
+ }, ({ root, maxDepth, maxNodes }) => callStudio(bridge, CliMsg.V2_GET_TREE, {
1592
+ root: root ? ref(root) : void 0,
1593
+ maxDepth,
1594
+ maxNodes
1595
+ }));
1596
+ server.registerTool("studio_inspect", {
1597
+ title: "Inspect Studio Instances",
1598
+ description: "Read typed property values and immediate children for up to 20 instance refs. Returns writable metadata from live ReflectionService.",
1599
+ inputSchema: {
1600
+ targets: z.array(instanceRefSchema).min(1).max(20),
1601
+ compact: z.boolean().optional().default(true)
1602
+ },
1603
+ outputSchema: toolOutputSchema,
1604
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1605
+ }, ({ targets, compact }) => callStudio(bridge, CliMsg.V2_INSPECT, { targets, compact }));
1606
+ server.registerTool("studio_get_selection", {
1607
+ title: "Get Studio Selection",
1608
+ description: "Return the user current Roblox Studio selection as stable instance refs.",
1609
+ inputSchema: {},
1610
+ outputSchema: toolOutputSchema,
1611
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1612
+ }, () => callStudio(bridge, CliMsg.V2_GET_SELECTION, {}));
1613
+ server.registerTool("studio_read_script", {
1614
+ title: "Read Studio Script",
1615
+ description: "Read a Script, LocalScript, or ModuleScript and receive its source plus a revision required for conflict-safe updates.",
1616
+ inputSchema: { target: instanceRefSchema },
1617
+ outputSchema: toolOutputSchema,
1618
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1619
+ }, ({ target }) => callStudio(bridge, CliMsg.V2_READ_SCRIPT, { target }));
1620
+ server.registerTool("studio_update_script", {
1621
+ title: "Update Studio Script",
1622
+ description: "Replace script source only if expectedRevision still matches the editor. Read the script again after updating to verify it.",
1623
+ inputSchema: {
1624
+ target: instanceRefSchema,
1625
+ source: z.string().max(75e4),
1626
+ expectedRevision: z.string().regex(/^\d+:[0-9a-f]{8}$/)
1627
+ },
1628
+ outputSchema: toolOutputSchema,
1629
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
1630
+ }, ({ target, source, expectedRevision }) => callStudio(
1631
+ bridge,
1632
+ CliMsg.V2_UPDATE_SCRIPT,
1633
+ { target, source, expectedRevision },
1634
+ 6e4
1635
+ ));
1636
+ server.registerTool("studio_apply", {
1637
+ title: "Apply Atomic Studio Mutations",
1638
+ description: "Atomically set properties, create instances, and move instances. Any failed operation cancels and undoes the entire batch.",
1639
+ inputSchema: { operations: z.array(mutationOperation).min(1).max(100) },
1640
+ outputSchema: toolOutputSchema,
1641
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
1642
+ }, ({ operations }) => callStudio(bridge, CliMsg.V2_APPLY, { operations }, 6e4));
1643
+ server.registerTool("studio_delete_instances", {
1644
+ title: "Delete Studio Instances",
1645
+ description: "Destructively delete up to 50 instances in one undoable recording. Only call when the user explicitly requested deletion or cleanup.",
1646
+ inputSchema: {
1647
+ targets: z.array(instanceRefSchema).min(1).max(50),
1648
+ confirm: z.literal(true)
1649
+ },
1650
+ outputSchema: toolOutputSchema,
1651
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
1652
+ }, ({ targets, confirm }) => callStudio(bridge, CliMsg.V2_DELETE, { targets, confirm }, 6e4));
1653
+ server.registerTool("studio_build_ui", {
1654
+ title: "Build Atomic Roblox UI",
1655
+ description: "Build and validate a declarative UI tree off-tree, then commit it in one undoable operation. Existing UI is replaced only when replaceExisting is true.",
1656
+ inputSchema: {
1657
+ parent: instanceRefSchema.optional(),
1658
+ tree: uiNodeSchema,
1659
+ replaceExisting: z.boolean().optional().default(false),
1660
+ maxDepth: z.number().int().min(1).max(50).optional().default(30),
1661
+ maxNodes: z.number().int().min(1).max(3e3).optional().default(1e3)
1662
+ },
1663
+ outputSchema: toolOutputSchema,
1664
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
1665
+ }, ({ parent, tree, replaceExisting, maxDepth, maxNodes }) => callStudio(
1666
+ bridge,
1667
+ CliMsg.V2_BUILD_UI,
1668
+ { parent, tree, replaceExisting, maxDepth, maxNodes },
1669
+ 12e4
1670
+ ));
1671
+ server.registerTool("studio_snapshot_ui", {
1672
+ title: "Snapshot Roblox UI",
1673
+ description: "Serialize a live UI subtree into typed, studio_build_ui-compatible data for fidelity checks and read-back verification.",
1674
+ inputSchema: {
1675
+ target: instanceRefSchema,
1676
+ maxDepth: z.number().int().min(0).max(50).optional().default(30)
1677
+ },
1678
+ outputSchema: toolOutputSchema,
1679
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1680
+ }, ({ target, maxDepth }) => callStudio(bridge, CliMsg.V2_SNAPSHOT_UI, { target, maxDepth }, 12e4));
1681
+ server.registerTool("studio_run_test", {
1682
+ title: "Run Roblox Studio Test",
1683
+ description: "Run a bounded StudioTestService run, play, or multiplayer scenario. The game test harness must call StudioTestService:EndTest with its result.",
1684
+ inputSchema: {
1685
+ mode: z.enum(["run", "play", "multiplayer"]).optional().default("run"),
1686
+ args: z.unknown().optional(),
1687
+ players: z.number().int().min(1).max(8).optional().default(1),
1688
+ timeoutMs: z.number().int().min(5e3).max(6e5).optional().default(12e4)
1689
+ },
1690
+ outputSchema: toolOutputSchema,
1691
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
1692
+ }, ({ mode, args, players, timeoutMs }) => callStudio(
1693
+ bridge,
1694
+ CliMsg.V2_RUN_TEST,
1695
+ { mode, args, players, timeoutMs },
1696
+ timeoutMs + 15e3
1697
+ ));
1698
+ server.registerTool("studio_get_output", {
1699
+ title: "Read Studio Output",
1700
+ description: "Read the bounded Studio output buffer, optionally filtered by severity. Dominus transport messages are excluded.",
1701
+ inputSchema: {
1702
+ limit: z.number().int().min(1).max(500).optional().default(100),
1703
+ level: z.enum(["info", "warn", "error"]).optional()
1704
+ },
1705
+ outputSchema: toolOutputSchema,
1706
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1707
+ }, ({ limit, level }) => callStudio(bridge, CliMsg.V2_GET_OUTPUT, { limit, level }));
1708
+ server.registerTool("studio_get_reflection", {
1709
+ title: "Read Live Roblox Reflection",
1710
+ description: "Read current ReflectionService fields for a class in the connected Studio, including Type, Display category, and permission metadata.",
1711
+ inputSchema: { className: z.string().min(1).max(100) },
1712
+ outputSchema: toolOutputSchema,
1713
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
1714
+ }, ({ className }) => callStudio(bridge, CliMsg.V2_GET_REFLECTION, { className }));
1715
+ }
1716
+
1717
+ // src/mcp/server.ts
1718
+ var VERSION = "2.1.0";
1719
+ function createDominusMcpServer(bridge) {
1720
+ const server = new McpServer({
1721
+ name: "dominus-2",
1722
+ version: VERSION
1723
+ }, {
1724
+ instructions: DOMINUS_MCP_INSTRUCTIONS
1725
+ });
1726
+ registerConnectionTools(server, bridge);
1727
+ registerStudioTools(server, bridge);
1728
+ registerDocsTools(server);
1729
+ registerDominusResources(server, bridge);
1730
+ return server;
1731
+ }
1732
+ async function createStudioBridge(port) {
1733
+ if (await isPortInUse(port)) {
1734
+ const client = new DominusClient(port);
1735
+ await client.connect();
1736
+ return client;
1737
+ }
1738
+ const server = new DominusServer(port);
1739
+ try {
1740
+ await server.start();
1741
+ return server;
1742
+ } catch (err) {
1743
+ const error = err;
1744
+ if (error.code !== "EADDRINUSE") throw err;
1745
+ const client = new DominusClient(port);
1746
+ await client.connect();
1747
+ return client;
1533
1748
  }
1534
1749
  }
1750
+ async function startMcpServer() {
1751
+ const config = loadConfig();
1752
+ const bridge = await createStudioBridge(config.port);
1753
+ const server = createDominusMcpServer(bridge);
1754
+ const transport = new StdioServerTransport();
1755
+ let stopped = false;
1756
+ const stop = async () => {
1757
+ if (stopped) return;
1758
+ stopped = true;
1759
+ await bridge.stop();
1760
+ };
1761
+ process.once("SIGINT", () => void stop().finally(() => process.exit(0)));
1762
+ process.once("SIGTERM", () => void stop().finally(() => process.exit(0)));
1763
+ await server.connect(transport);
1764
+ }
1535
1765
 
1536
1766
  // src/mcp/index.ts
1537
1767
  startMcpServer().catch((err) => {