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/CHANGELOG.md +175 -0
- package/README.md +164 -248
- package/dist/install-plugin.js +30 -0
- package/dist/install-plugin.js.map +1 -0
- package/dist/mcp.js +1576 -1346
- package/dist/mcp.js.map +1 -1
- package/docs/DOMINUS_2_PLAN.md +75 -0
- package/package.json +24 -25
- package/plugin/Dominus.rbxm +0 -0
- package/plugin/src/CommandRouter.lua +94 -0
- package/plugin/src/Explorer.lua +530 -373
- package/plugin/src/InstanceRegistry.lua +122 -0
- package/plugin/src/Mutation.lua +135 -0
- package/plugin/src/Properties.lua +859 -725
- package/plugin/src/Protocol.lua +30 -30
- package/plugin/src/Reflection.lua +91 -59
- package/plugin/src/TestRunner.lua +69 -141
- package/plugin/src/UIBuilder.lua +857 -727
- package/plugin/src/ValueCodec.lua +230 -0
- package/plugin/src/Watcher.lua +85 -85
- package/plugin/src/WsClient.lua +153 -81
- package/plugin/src/init.server.lua +175 -362
- package/dist/index.js +0 -5796
- package/dist/index.js.map +0 -1
- package/plugin/src/Executor.lua +0 -117
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
|
|
5
|
-
import
|
|
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
|
|
9
|
-
import
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
152
|
+
token;
|
|
153
|
+
connectTimeoutMs;
|
|
353
154
|
connections = /* @__PURE__ */ new Map();
|
|
354
|
-
|
|
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
|
|
363
|
-
|
|
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(
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
381
|
-
this.
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 (!
|
|
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 ===
|
|
396
|
-
const
|
|
397
|
-
this.
|
|
398
|
-
if (
|
|
399
|
-
this.
|
|
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.
|
|
413
|
-
this.
|
|
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 ===
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 ===
|
|
260
|
+
if (msg.type === StudioMsg.DISCONNECTED) {
|
|
431
261
|
const payload = msg.payload;
|
|
432
|
-
|
|
433
|
-
this.
|
|
434
|
-
if (this.connections.size ===
|
|
435
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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(
|
|
468
|
-
reject(new Error(`
|
|
332
|
+
this.pendingRequests.delete(request.id);
|
|
333
|
+
reject(new Error(`Studio request timed out: ${type}`));
|
|
469
334
|
}, timeoutMs);
|
|
470
|
-
this.pendingRequests.set(
|
|
335
|
+
this.pendingRequests.set(request.id, {
|
|
471
336
|
resolve,
|
|
472
337
|
reject,
|
|
473
338
|
timer
|
|
474
339
|
});
|
|
475
|
-
|
|
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.
|
|
480
|
-
const
|
|
481
|
-
this.
|
|
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.
|
|
358
|
+
return this.resolveTargetInfo() !== null;
|
|
485
359
|
}
|
|
486
360
|
getConnectionInfo() {
|
|
487
|
-
|
|
488
|
-
|
|
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((
|
|
494
|
-
placeId:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
401
|
+
return this.resolveTargetInfo()?.placeId ?? 0;
|
|
502
402
|
}
|
|
503
403
|
setActivePlaceId(placeId) {
|
|
504
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
425
|
+
if (!this.ws) {
|
|
426
|
+
resolve();
|
|
427
|
+
return;
|
|
514
428
|
}
|
|
515
|
-
this.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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://
|
|
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
|
-
},
|
|
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
|
|
543
|
-
var
|
|
544
|
-
var
|
|
545
|
-
|
|
546
|
-
var
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
572
|
+
state.count += 1;
|
|
573
|
+
this.rateState.set(ws, state);
|
|
574
|
+
return state.count <= RATE_LIMIT;
|
|
570
575
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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 (
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
|
721
|
+
return;
|
|
922
722
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
|
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
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
{}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
return failure(err);
|
|
1207
1057
|
}
|
|
1208
|
-
);
|
|
1209
|
-
|
|
1210
|
-
"
|
|
1211
|
-
"
|
|
1212
|
-
{
|
|
1213
|
-
|
|
1214
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1238
|
-
"
|
|
1239
|
-
"
|
|
1240
|
-
{
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
1372
|
-
"
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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
|
-
|
|
1506
|
-
"
|
|
1507
|
-
"
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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) => {
|