dominus-cli 2.2.0 → 2.3.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 +48 -0
- package/README.md +34 -12
- package/dist/bridge-daemon.js +581 -0
- package/dist/bridge-daemon.js.map +1 -0
- package/dist/install-plugin.js +497 -27
- package/dist/install-plugin.js.map +1 -1
- package/dist/mcp.js +417 -479
- package/dist/mcp.js.map +1 -1
- package/package.json +5 -5
- package/plugin/Dominus.rbxmx +507 -15
- package/plugin/default.project.json +4 -1
- package/plugin/src/Building.lua +427 -0
- package/plugin/src/CommandRouter.lua +25 -0
- package/plugin/src/Mutation.lua +30 -0
- package/plugin/src/init.server.lua +5 -1
package/dist/mcp.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
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 fs4 from 'fs';
|
|
5
|
+
import path4 from 'path';
|
|
6
6
|
import os from 'os';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
7
9
|
import { EventEmitter } from 'events';
|
|
8
|
-
import { WebSocket
|
|
10
|
+
import { WebSocket } from 'ws';
|
|
9
11
|
import { nanoid } from 'nanoid';
|
|
10
12
|
import { z } from 'zod';
|
|
11
13
|
import * as crypto from 'crypto';
|
|
12
|
-
import { randomUUID } from 'crypto';
|
|
13
14
|
|
|
14
|
-
var DOMINUS_DIR =
|
|
15
|
-
var CONFIG_PATH =
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
var DOMINUS_DIR = path4.join(os.homedir(), ".dominus");
|
|
16
|
+
var CONFIG_PATH = path4.join(DOMINUS_DIR, "config.json");
|
|
17
|
+
path4.join(DOMINUS_DIR, "dominus.db");
|
|
18
|
+
path4.join(DOMINUS_DIR, "rules.json");
|
|
18
19
|
var DEFAULTS = {
|
|
19
20
|
provider: "openai",
|
|
20
21
|
apiBaseUrl: "",
|
|
@@ -28,14 +29,14 @@ var DEFAULTS = {
|
|
|
28
29
|
userName: "Developer"
|
|
29
30
|
};
|
|
30
31
|
function ensureDir() {
|
|
31
|
-
if (!
|
|
32
|
-
|
|
32
|
+
if (!fs4.existsSync(DOMINUS_DIR)) {
|
|
33
|
+
fs4.mkdirSync(DOMINUS_DIR, { recursive: true });
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
function readJson(filePath, defaults) {
|
|
36
37
|
try {
|
|
37
|
-
if (
|
|
38
|
-
const raw =
|
|
38
|
+
if (fs4.existsSync(filePath)) {
|
|
39
|
+
const raw = fs4.readFileSync(filePath, "utf-8");
|
|
39
40
|
return { ...defaults, ...JSON.parse(raw) };
|
|
40
41
|
}
|
|
41
42
|
} catch {
|
|
@@ -108,6 +109,14 @@ var CliMsg = {
|
|
|
108
109
|
V2_READ_SCRIPT: "studio:v2:read_script",
|
|
109
110
|
V2_UPDATE_SCRIPT: "studio:v2:update_script",
|
|
110
111
|
V2_APPLY: "studio:v2:apply",
|
|
112
|
+
V2_CREATE_PARTS: "studio:v2:create_parts",
|
|
113
|
+
V2_CLONE_INSTANCES: "studio:v2:clone_instances",
|
|
114
|
+
V2_GROUP_INSTANCES: "studio:v2:group_instances",
|
|
115
|
+
V2_UNGROUP_INSTANCES: "studio:v2:ungroup_instances",
|
|
116
|
+
V2_TRANSFORM_INSTANCES: "studio:v2:transform_instances",
|
|
117
|
+
V2_CREATE_WELDS: "studio:v2:create_welds",
|
|
118
|
+
V2_QUERY_PARTS: "studio:v2:query_parts",
|
|
119
|
+
V2_SET_SELECTION: "studio:v2:set_selection",
|
|
111
120
|
V2_DELETE: "studio:v2:delete",
|
|
112
121
|
V2_BUILD_UI: "studio:v2:build_ui",
|
|
113
122
|
V2_SNAPSHOT_UI: "studio:v2:snapshot_ui",
|
|
@@ -144,27 +153,21 @@ var wsMessageSchema = z.object({
|
|
|
144
153
|
}).strict();
|
|
145
154
|
var TOKEN_FILE = "bridge-token";
|
|
146
155
|
function loadOrCreateBridgeToken() {
|
|
147
|
-
const tokenPath =
|
|
156
|
+
const tokenPath = path4.join(getDominusDir(), TOKEN_FILE);
|
|
148
157
|
try {
|
|
149
|
-
const existing =
|
|
158
|
+
const existing = fs4.readFileSync(tokenPath, "utf8").trim();
|
|
150
159
|
if (existing.length >= 32) return existing;
|
|
151
160
|
} catch {
|
|
152
161
|
}
|
|
153
162
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
154
|
-
|
|
163
|
+
fs4.writeFileSync(tokenPath, `${token}
|
|
155
164
|
`, { encoding: "utf8", mode: 384 });
|
|
156
165
|
try {
|
|
157
|
-
|
|
166
|
+
fs4.chmodSync(tokenPath, 384);
|
|
158
167
|
} catch {
|
|
159
168
|
}
|
|
160
169
|
return token;
|
|
161
170
|
}
|
|
162
|
-
function tokensEqual(actual, expected) {
|
|
163
|
-
if (typeof actual !== "string") return false;
|
|
164
|
-
const actualBuffer = Buffer.from(actual);
|
|
165
|
-
const expectedBuffer = Buffer.from(expected);
|
|
166
|
-
return actualBuffer.length === expectedBuffer.length && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
167
|
-
}
|
|
168
171
|
|
|
169
172
|
// src/transport/ws-client.ts
|
|
170
173
|
var DominusClient = class extends EventEmitter {
|
|
@@ -197,10 +200,14 @@ var DominusClient = class extends EventEmitter {
|
|
|
197
200
|
}, this.connectTimeoutMs);
|
|
198
201
|
ws.on("open", () => {
|
|
199
202
|
this.ws = ws;
|
|
200
|
-
ws.send(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
203
|
+
ws.send(
|
|
204
|
+
serializeMessage(
|
|
205
|
+
createMessage(ControllerMsg.HELLO, {
|
|
206
|
+
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
207
|
+
token: this.token
|
|
208
|
+
})
|
|
209
|
+
)
|
|
210
|
+
);
|
|
204
211
|
});
|
|
205
212
|
ws.on("message", (raw) => {
|
|
206
213
|
try {
|
|
@@ -341,7 +348,11 @@ var DominusClient = class extends EventEmitter {
|
|
|
341
348
|
sendRequest(type, payload, timeoutMs = 15e3) {
|
|
342
349
|
const target = this.resolveTargetInfo();
|
|
343
350
|
if (!target || !this.activeConnectionId) {
|
|
344
|
-
return Promise.reject(
|
|
351
|
+
return Promise.reject(
|
|
352
|
+
new Error(
|
|
353
|
+
this.connections.size > 1 ? "Multiple Studio sessions are connected. Call dominus_select_studio first." : "No authenticated Roblox Studio connection"
|
|
354
|
+
)
|
|
355
|
+
);
|
|
345
356
|
}
|
|
346
357
|
const request = createMessage(type, payload);
|
|
347
358
|
const relay = createMessage(ControllerMsg.RELAY, {
|
|
@@ -370,10 +381,12 @@ var DominusClient = class extends EventEmitter {
|
|
|
370
381
|
sendNotification(type, payload) {
|
|
371
382
|
if (!this.activeConnectionId) return;
|
|
372
383
|
const request = createMessage(type, payload);
|
|
373
|
-
this.sendMessage(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
384
|
+
this.sendMessage(
|
|
385
|
+
createMessage(ControllerMsg.RELAY, {
|
|
386
|
+
connectionId: this.activeConnectionId,
|
|
387
|
+
request
|
|
388
|
+
})
|
|
389
|
+
);
|
|
377
390
|
}
|
|
378
391
|
isConnected() {
|
|
379
392
|
return this.resolveTargetInfo() !== null;
|
|
@@ -412,9 +425,13 @@ var DominusClient = class extends EventEmitter {
|
|
|
412
425
|
void this.setActiveConnectionId(null);
|
|
413
426
|
return;
|
|
414
427
|
}
|
|
415
|
-
const matches = [...this.connections.values()].filter(
|
|
428
|
+
const matches = [...this.connections.values()].filter(
|
|
429
|
+
(connection) => connection.placeId === placeId
|
|
430
|
+
);
|
|
416
431
|
if (matches.length !== 1) {
|
|
417
|
-
throw new Error(
|
|
432
|
+
throw new Error(
|
|
433
|
+
matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`
|
|
434
|
+
);
|
|
418
435
|
}
|
|
419
436
|
void this.setActiveConnectionId(matches[0].connectionId);
|
|
420
437
|
}
|
|
@@ -475,413 +492,74 @@ async function isPortInUse(port) {
|
|
|
475
492
|
});
|
|
476
493
|
});
|
|
477
494
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const wss = new WebSocketServer({
|
|
508
|
-
port,
|
|
509
|
-
host: this.host,
|
|
510
|
-
maxPayload: this.maxPayloadBytes,
|
|
511
|
-
perMessageDeflate: false
|
|
512
|
-
});
|
|
513
|
-
wss.on("listening", () => {
|
|
514
|
-
this.wss = wss;
|
|
515
|
-
this.port = port;
|
|
516
|
-
wss.on("connection", (ws) => this.handleConnection(ws));
|
|
517
|
-
this.emit("server:started", { port, host: this.host });
|
|
518
|
-
resolve();
|
|
519
|
-
});
|
|
520
|
-
wss.on("error", (err) => {
|
|
521
|
-
if (err.code === "EADDRINUSE" && attempt < maxRetries) {
|
|
522
|
-
wss.close();
|
|
523
|
-
tryPort(port + 1, attempt + 1);
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
this.emit("server:error", err);
|
|
527
|
-
reject(err);
|
|
528
|
-
});
|
|
529
|
-
};
|
|
530
|
-
tryPort(this.port, 0);
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
handleConnection(ws) {
|
|
534
|
-
this.socketRoles.set(ws, "unknown");
|
|
535
|
-
this.rateState.set(ws, { startedAt: Date.now(), count: 0 });
|
|
536
|
-
const handshakeTimer = setTimeout(() => {
|
|
537
|
-
if (this.socketRoles.get(ws) === "unknown") {
|
|
538
|
-
ws.close(1008, "Dominus handshake timed out");
|
|
539
|
-
}
|
|
540
|
-
}, this.handshakeTimeoutMs);
|
|
541
|
-
ws.on("message", (raw) => {
|
|
542
|
-
try {
|
|
543
|
-
if (!this.consumeRateBudget(ws) || rawByteLength(raw) > this.maxPayloadBytes) {
|
|
544
|
-
ws.close(1009, "Dominus message limit exceeded");
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
const msg = parseMessage(raw.toString());
|
|
548
|
-
const role = this.socketRoles.get(ws) ?? "unknown";
|
|
549
|
-
if (role === "unknown") {
|
|
550
|
-
clearTimeout(handshakeTimer);
|
|
551
|
-
this.handleHandshake(ws, msg);
|
|
552
|
-
} else if (role === "controller") {
|
|
553
|
-
this.handleControllerMessage(ws, msg);
|
|
554
|
-
} else if (role === "studio") {
|
|
555
|
-
this.handleStudioMessage(ws, msg);
|
|
556
|
-
}
|
|
557
|
-
} catch (err) {
|
|
558
|
-
this.emit("server:error", err);
|
|
559
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
560
|
-
error: err instanceof Error ? err.message : String(err)
|
|
561
|
-
}));
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
ws.on("close", () => {
|
|
565
|
-
clearTimeout(handshakeTimer);
|
|
566
|
-
this.cleanupSocket(ws);
|
|
567
|
-
});
|
|
568
|
-
ws.on("error", (err) => this.emit("server:error", err));
|
|
569
|
-
}
|
|
570
|
-
consumeRateBudget(ws) {
|
|
571
|
-
const now = Date.now();
|
|
572
|
-
const state = this.rateState.get(ws) ?? { startedAt: now, count: 0 };
|
|
573
|
-
if (now - state.startedAt >= RATE_WINDOW_MS) {
|
|
574
|
-
state.startedAt = now;
|
|
575
|
-
state.count = 0;
|
|
576
|
-
}
|
|
577
|
-
state.count += 1;
|
|
578
|
-
this.rateState.set(ws, state);
|
|
579
|
-
return state.count <= RATE_LIMIT;
|
|
580
|
-
}
|
|
581
|
-
handleHandshake(ws, msg) {
|
|
582
|
-
if (msg.type === StudioMsg.HELLO) {
|
|
583
|
-
const hello = validateStudioHello(msg.payload);
|
|
584
|
-
if (tokensEqual(hello.token, this.token)) {
|
|
585
|
-
this.authorizeStudio(ws, hello, randomUUID());
|
|
586
|
-
} else {
|
|
587
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
588
|
-
error: "Studio plugin credentials are missing or stale. Run dominus-install-plugin and restart Studio."
|
|
589
|
-
}));
|
|
590
|
-
ws.close(1008, "Unauthorized Studio plugin");
|
|
591
|
-
}
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
if (msg.type === ControllerMsg.HELLO) {
|
|
595
|
-
const payload = msg.payload;
|
|
596
|
-
if (payload.protocolVersion !== DOMINUS_PROTOCOL_VERSION || !tokensEqual(payload.token, this.token)) {
|
|
597
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, { error: "Invalid Dominus controller credentials" }));
|
|
598
|
-
ws.close(1008, "Unauthorized controller");
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
this.socketRoles.set(ws, "controller");
|
|
602
|
-
this.controllers.add(ws);
|
|
603
|
-
this.sendSafe(ws, createMessage(ControllerMsg.WELCOME, {
|
|
604
|
-
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
605
|
-
port: this.port,
|
|
606
|
-
connections: this.listConnections(),
|
|
607
|
-
activeConnectionId: this.activeConnectionId
|
|
608
|
-
}));
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTH_REJECTED, {
|
|
612
|
-
error: `Expected ${StudioMsg.HELLO} or ${ControllerMsg.HELLO}`
|
|
613
|
-
}));
|
|
614
|
-
ws.close(1008, "Invalid Dominus handshake");
|
|
615
|
-
}
|
|
616
|
-
authorizeStudio(ws, hello, connectionId) {
|
|
617
|
-
const connection = {
|
|
618
|
-
ws,
|
|
619
|
-
connectionId,
|
|
620
|
-
clientId: hello.clientId,
|
|
621
|
-
connectedAt: Date.now(),
|
|
622
|
-
studioVersion: hello.studioVersion,
|
|
623
|
-
placeId: hello.placeId ?? 0,
|
|
624
|
-
placeName: hello.placeName
|
|
625
|
-
};
|
|
626
|
-
this.studioClients.set(connectionId, connection);
|
|
627
|
-
this.wsToConnectionId.set(ws, connectionId);
|
|
628
|
-
this.socketRoles.set(ws, "studio");
|
|
629
|
-
if (!this.activeConnectionId || !this.studioClients.has(this.activeConnectionId)) {
|
|
630
|
-
this.activeConnectionId = connectionId;
|
|
631
|
-
}
|
|
632
|
-
this.sendSafe(ws, createMessage(StudioMsg.AUTHENTICATED, {
|
|
633
|
-
protocolVersion: DOMINUS_PROTOCOL_VERSION,
|
|
634
|
-
connectionId,
|
|
635
|
-
token: this.token
|
|
636
|
-
}));
|
|
637
|
-
const info = this.toPlaceInfo(connection);
|
|
638
|
-
this.emit("studio:connected", info);
|
|
639
|
-
this.broadcastToControllers(StudioMsg.CONNECTED, info);
|
|
640
|
-
this.broadcastTargetState();
|
|
641
|
-
return connection;
|
|
642
|
-
}
|
|
643
|
-
handleControllerMessage(controllerWs, msg) {
|
|
644
|
-
if (msg.type === ControllerMsg.RELAY) {
|
|
645
|
-
const payload = msg.payload;
|
|
646
|
-
const request = parseMessage(JSON.stringify(payload.request));
|
|
647
|
-
const target = this.resolveConnection(typeof payload.connectionId === "string" ? payload.connectionId : null);
|
|
648
|
-
if (!target) {
|
|
649
|
-
this.sendResponse(controllerWs, request.id, { error: this.getTargetError() });
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
this.relayRequests.set(request.id, { controllerWs, targetWs: target.ws });
|
|
653
|
-
this.sendSafe(target.ws, request);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
if (msg.type === ControllerMsg.SET_ACTIVE_CONNECTION) {
|
|
657
|
-
const payload = msg.payload;
|
|
658
|
-
const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : null;
|
|
659
|
-
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
660
|
-
this.sendResponse(controllerWs, msg.id, { success: false, error: `Studio connection ${connectionId} is not connected` });
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
this.sendResponse(controllerWs, msg.id, { success: true, connectionId });
|
|
664
|
-
return;
|
|
665
|
-
}
|
|
666
|
-
this.sendResponse(controllerWs, msg.id, { error: `Unsupported controller message: ${msg.type}` });
|
|
667
|
-
}
|
|
668
|
-
handleStudioMessage(ws, msg) {
|
|
669
|
-
if (msg.type === StudioMsg.RESPONSE) {
|
|
670
|
-
const relay = this.relayRequests.get(msg.id);
|
|
671
|
-
if (relay) {
|
|
672
|
-
if (relay.targetWs !== ws) {
|
|
673
|
-
this.emit("server:error", new Error(`Ignored relayed response ${msg.id} from the wrong Studio session`));
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
this.sendSafe(relay.controllerWs, msg);
|
|
677
|
-
this.relayRequests.delete(msg.id);
|
|
678
|
-
return;
|
|
679
|
-
}
|
|
680
|
-
const pending = this.pendingRequests.get(msg.id);
|
|
681
|
-
if (pending) {
|
|
682
|
-
if (pending.targetWs !== ws) {
|
|
683
|
-
this.emit("server:error", new Error(`Ignored response ${msg.id} from the wrong Studio session`));
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
clearTimeout(pending.timer);
|
|
687
|
-
this.pendingRequests.delete(msg.id);
|
|
688
|
-
pending.resolve(msg);
|
|
689
|
-
}
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
this.emit(msg.type, msg.payload);
|
|
693
|
-
this.emit("message", msg);
|
|
495
|
+
|
|
496
|
+
// src/transport/daemon-launcher.ts
|
|
497
|
+
function resolveDaemonPath() {
|
|
498
|
+
const besideEntry = fileURLToPath(new URL("./bridge-daemon.js", import.meta.url));
|
|
499
|
+
if (fs4.existsSync(besideEntry)) return besideEntry;
|
|
500
|
+
const projectBuild = path4.resolve(
|
|
501
|
+
path4.dirname(fileURLToPath(import.meta.url)),
|
|
502
|
+
"../../dist/bridge-daemon.js"
|
|
503
|
+
);
|
|
504
|
+
if (fs4.existsSync(projectBuild)) return projectBuild;
|
|
505
|
+
throw new Error("Dominus bridge daemon is missing. Reinstall or rebuild dominus-cli.");
|
|
506
|
+
}
|
|
507
|
+
function launchBridgeDaemon(port, daemonPath = resolveDaemonPath()) {
|
|
508
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
509
|
+
detached: true,
|
|
510
|
+
env: { ...process.env, DOMINUS_BRIDGE_PORT: String(port) },
|
|
511
|
+
stdio: "ignore",
|
|
512
|
+
windowsHide: true
|
|
513
|
+
});
|
|
514
|
+
child.unref();
|
|
515
|
+
}
|
|
516
|
+
async function connectController(port, timeoutMs, token) {
|
|
517
|
+
const client = new DominusClient(port, { connectTimeoutMs: timeoutMs, token });
|
|
518
|
+
try {
|
|
519
|
+
await client.connect();
|
|
520
|
+
return client;
|
|
521
|
+
} catch (error) {
|
|
522
|
+
await client.stop().catch(() => void 0);
|
|
523
|
+
throw error;
|
|
694
524
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
if (!connectionId) return;
|
|
708
|
-
const connection = this.studioClients.get(connectionId);
|
|
709
|
-
this.wsToConnectionId.delete(ws);
|
|
710
|
-
this.studioClients.delete(connectionId);
|
|
711
|
-
for (const [id, pending] of this.pendingRequests) {
|
|
712
|
-
if (pending.targetWs === ws) {
|
|
713
|
-
clearTimeout(pending.timer);
|
|
714
|
-
pending.reject(new Error(`Studio disconnected (${connectionId})`));
|
|
715
|
-
this.pendingRequests.delete(id);
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
for (const [id, relay] of this.relayRequests) {
|
|
719
|
-
if (relay.targetWs === ws) {
|
|
720
|
-
this.sendResponse(relay.controllerWs, id, { error: `Studio disconnected (${connectionId})` });
|
|
721
|
-
this.relayRequests.delete(id);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
if (this.activeConnectionId === connectionId) {
|
|
725
|
-
this.activeConnectionId = this.studioClients.size === 1 ? this.studioClients.keys().next().value ?? null : null;
|
|
726
|
-
}
|
|
727
|
-
const info = connection ? this.toPlaceInfo(connection) : { connectionId };
|
|
728
|
-
this.emit("studio:disconnected", info);
|
|
729
|
-
this.broadcastToControllers(StudioMsg.DISCONNECTED, info);
|
|
730
|
-
this.broadcastTargetState();
|
|
731
|
-
}
|
|
732
|
-
resolveConnection(connectionId = this.activeConnectionId) {
|
|
733
|
-
if (connectionId) {
|
|
734
|
-
const connection = this.studioClients.get(connectionId);
|
|
735
|
-
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
736
|
-
}
|
|
737
|
-
if (this.studioClients.size === 1) {
|
|
738
|
-
const connection = this.studioClients.values().next().value;
|
|
739
|
-
return connection?.ws.readyState === WebSocket.OPEN ? connection : null;
|
|
525
|
+
}
|
|
526
|
+
async function ensureBridgeDaemon(port, options = {}) {
|
|
527
|
+
const attempts = options.attempts ?? 30;
|
|
528
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? 750;
|
|
529
|
+
const retryDelayMs = options.retryDelayMs ?? 100;
|
|
530
|
+
try {
|
|
531
|
+
return await connectController(port, connectTimeoutMs, options.token);
|
|
532
|
+
} catch (initialError) {
|
|
533
|
+
if (await isPortInUse(port)) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Port ${port} is occupied by a process that is not this Dominus bridge: ${formatError(initialError)}`
|
|
536
|
+
);
|
|
740
537
|
}
|
|
741
|
-
return null;
|
|
742
|
-
}
|
|
743
|
-
getTargetError() {
|
|
744
|
-
if (this.studioClients.size === 0) return "No authenticated Roblox Studio connection";
|
|
745
|
-
if (this.activeConnectionId) return `Studio connection ${this.activeConnectionId} is unavailable`;
|
|
746
|
-
return "Multiple Studio sessions are connected. Call dominus_select_studio with a connectionId.";
|
|
747
|
-
}
|
|
748
|
-
toPlaceInfo(connection) {
|
|
749
|
-
return {
|
|
750
|
-
placeId: connection.placeId ?? 0,
|
|
751
|
-
connectionId: connection.connectionId,
|
|
752
|
-
clientId: connection.clientId,
|
|
753
|
-
placeName: connection.placeName,
|
|
754
|
-
studioVersion: connection.studioVersion,
|
|
755
|
-
connectedAt: connection.connectedAt,
|
|
756
|
-
active: connection.connectionId === this.activeConnectionId
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
broadcastToControllers(type, payload) {
|
|
760
|
-
const message = createMessage(type, payload);
|
|
761
|
-
for (const controller of this.controllers) this.sendSafe(controller, message);
|
|
762
|
-
}
|
|
763
|
-
broadcastTargetState() {
|
|
764
|
-
this.broadcastToControllers(ControllerMsg.TARGET_STATE, {
|
|
765
|
-
connections: this.listConnections(),
|
|
766
|
-
defaultActiveConnectionId: this.activeConnectionId
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
sendResponse(ws, id, payload) {
|
|
770
|
-
this.sendSafe(ws, { id, type: StudioMsg.RESPONSE, payload, ts: Date.now() });
|
|
771
538
|
}
|
|
772
|
-
|
|
773
|
-
|
|
539
|
+
const launch = options.launch ?? ((targetPort) => launchBridgeDaemon(targetPort, options.daemonPath));
|
|
540
|
+
launch(port);
|
|
541
|
+
let lastError;
|
|
542
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
543
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
774
544
|
try {
|
|
775
|
-
|
|
776
|
-
} catch (
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
sendRequest(type, payload, timeoutMs = 15e3) {
|
|
781
|
-
return new Promise((resolve, reject) => {
|
|
782
|
-
const target = this.resolveConnection();
|
|
783
|
-
if (!target) {
|
|
784
|
-
reject(new Error(this.getTargetError()));
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
const message = createMessage(type, payload);
|
|
788
|
-
const timer = setTimeout(() => {
|
|
789
|
-
this.pendingRequests.delete(message.id);
|
|
790
|
-
reject(new Error(`Studio request timed out: ${type}`));
|
|
791
|
-
}, timeoutMs);
|
|
792
|
-
this.pendingRequests.set(message.id, {
|
|
793
|
-
resolve,
|
|
794
|
-
reject,
|
|
795
|
-
timer,
|
|
796
|
-
targetWs: target.ws
|
|
797
|
-
});
|
|
798
|
-
this.sendSafe(target.ws, message);
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
sendNotification(type, payload) {
|
|
802
|
-
const target = this.resolveConnection();
|
|
803
|
-
if (target) this.sendSafe(target.ws, createMessage(type, payload));
|
|
804
|
-
}
|
|
805
|
-
isConnected() {
|
|
806
|
-
return this.resolveConnection() !== null;
|
|
807
|
-
}
|
|
808
|
-
getConnectionInfo() {
|
|
809
|
-
return this.resolveConnection();
|
|
810
|
-
}
|
|
811
|
-
listConnections() {
|
|
812
|
-
return [...this.studioClients.values()].filter(({ ws }) => ws.readyState === WebSocket.OPEN).map((connection) => this.toPlaceInfo(connection));
|
|
813
|
-
}
|
|
814
|
-
getActiveConnectionId() {
|
|
815
|
-
return this.activeConnectionId;
|
|
816
|
-
}
|
|
817
|
-
async setActiveConnectionId(connectionId) {
|
|
818
|
-
if (connectionId && !this.studioClients.has(connectionId)) {
|
|
819
|
-
throw new Error(`Studio connection ${connectionId} is not connected`);
|
|
820
|
-
}
|
|
821
|
-
this.activeConnectionId = connectionId;
|
|
822
|
-
this.broadcastTargetState();
|
|
823
|
-
}
|
|
824
|
-
getActivePlaceId() {
|
|
825
|
-
return this.resolveConnection()?.placeId ?? 0;
|
|
826
|
-
}
|
|
827
|
-
setActivePlaceId(placeId) {
|
|
828
|
-
if (placeId === 0) {
|
|
829
|
-
this.activeConnectionId = null;
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
const matches = [...this.studioClients.values()].filter((connection) => connection.placeId === placeId);
|
|
833
|
-
if (matches.length !== 1) {
|
|
834
|
-
throw new Error(matches.length === 0 ? `Place ${placeId} is not connected` : `Place ${placeId} has multiple Studio sessions; select a connectionId instead`);
|
|
545
|
+
return await connectController(port, connectTimeoutMs, options.token);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
lastError = error;
|
|
835
548
|
}
|
|
836
|
-
this.activeConnectionId = matches[0].connectionId;
|
|
837
|
-
}
|
|
838
|
-
getPort() {
|
|
839
|
-
return this.port;
|
|
840
|
-
}
|
|
841
|
-
stop() {
|
|
842
|
-
return new Promise((resolve) => {
|
|
843
|
-
for (const pending of this.pendingRequests.values()) {
|
|
844
|
-
clearTimeout(pending.timer);
|
|
845
|
-
pending.reject(new Error("Dominus bridge is shutting down"));
|
|
846
|
-
}
|
|
847
|
-
this.pendingRequests.clear();
|
|
848
|
-
this.relayRequests.clear();
|
|
849
|
-
for (const ws of this.socketRoles.keys()) ws.close(1001, "Dominus bridge shutdown");
|
|
850
|
-
this.studioClients.clear();
|
|
851
|
-
this.controllers.clear();
|
|
852
|
-
this.socketRoles.clear();
|
|
853
|
-
this.wsToConnectionId.clear();
|
|
854
|
-
if (this.wss) this.wss.close(() => resolve());
|
|
855
|
-
else resolve();
|
|
856
|
-
});
|
|
857
549
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const hello = payload;
|
|
862
|
-
if (hello.protocolVersion !== DOMINUS_PROTOCOL_VERSION) {
|
|
863
|
-
throw new Error(`Studio plugin protocol ${hello.protocolVersion ?? "unknown"} is incompatible; expected ${DOMINUS_PROTOCOL_VERSION}`);
|
|
864
|
-
}
|
|
865
|
-
if (typeof hello.clientId !== "string" || hello.clientId.length < 8 || hello.clientId.length > 128) {
|
|
866
|
-
throw new Error("Studio hello is missing a valid clientId");
|
|
867
|
-
}
|
|
868
|
-
if (typeof hello.studioVersion !== "string" || hello.studioVersion.length > 128) {
|
|
869
|
-
throw new Error("Studio hello is missing studioVersion");
|
|
870
|
-
}
|
|
871
|
-
if (hello.placeName && hello.placeName.length > 256) throw new Error("Studio placeName is too long");
|
|
872
|
-
return hello;
|
|
550
|
+
throw new Error(
|
|
551
|
+
`Dominus could not start its local Studio bridge on port ${port}: ${formatError(lastError)}`
|
|
552
|
+
);
|
|
873
553
|
}
|
|
874
|
-
function
|
|
875
|
-
|
|
876
|
-
if (Array.isArray(raw)) return raw.reduce((total, item) => total + item.byteLength, 0);
|
|
877
|
-
return raw.byteLength;
|
|
554
|
+
function formatError(error) {
|
|
555
|
+
return error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
878
556
|
}
|
|
879
557
|
|
|
880
558
|
// src/mcp/instructions.ts
|
|
881
559
|
var DOMINUS_MCP_INSTRUCTIONS = `Dominus 2 is a Roblox Studio engineering MCP server.
|
|
882
560
|
|
|
883
561
|
Required workflow for Studio changes:
|
|
884
|
-
1. Call dominus_status
|
|
562
|
+
1. Call dominus_status. If Studio is disconnected, ask the user to run dominus-install-plugin only when the Studio Output reports an authentication failure. Select the intended authenticated Studio connection when more than one window is open.
|
|
885
563
|
2. Inspect the relevant tree, selection, instances, scripts, and Roblox API references before editing.
|
|
886
564
|
3. Form a short plan with explicit acceptance checks.
|
|
887
565
|
4. Use stable instance refs returned by Dominus. Prefer instanceId; retain pathSegments as fallback context.
|
|
@@ -891,11 +569,18 @@ Required workflow for Studio changes:
|
|
|
891
569
|
|
|
892
570
|
For broad tasks with independent Studio branches, prefer run_parallel_task when the MCP client supports Sampling. Dominus partitions immediate child scopes, runs proposal-only workers concurrently, rejects unknown refs and overlapping writes, applies only the coordinator-approved atomic plan, and reads the result back. Use the normal workflow when Sampling is unavailable or the task is a simple edit.
|
|
893
571
|
|
|
572
|
+
Building workflow:
|
|
573
|
+
- Use studio_create_parts for multi-part structures instead of many generic create operations.
|
|
574
|
+
- Use studio_clone_instances when an existing instance or model is the intended template. Clone rather than manually recreating descendants and properties.
|
|
575
|
+
- Use studio_transform_instances for Models and BaseParts so model layouts move through PVInstance pivots.
|
|
576
|
+
- Use studio_group_instances and studio_ungroup_instances to organize hierarchy, studio_create_welds for rigid assemblies, and studio_query_parts to inspect crowded 3D regions before editing.
|
|
577
|
+
- Use studio_set_selection when surfacing completed objects to the Studio user.
|
|
578
|
+
|
|
894
579
|
Safety rules:
|
|
895
580
|
- Never invent instance IDs or Roblox API members.
|
|
896
581
|
- Do not retry a failed write unchanged. Inspect the error and current state first.
|
|
897
582
|
- Deletion is a separate destructive tool and requires explicit user intent.
|
|
898
|
-
- studio_apply is transactional. Keep related edits together, but stay within its documented limits.
|
|
583
|
+
- studio_apply is transactional and supports setProperties, create, clone, and move operations. Keep related edits together, but stay within its documented limits.
|
|
899
584
|
- UI fidelity work must snapshot and inspect the result after building; verify fonts, UDim2 values, colors, strokes, and hierarchy.
|
|
900
585
|
- Dominus intentionally has no arbitrary Luau execution tool.`;
|
|
901
586
|
|
|
@@ -962,6 +647,8 @@ function failure(error, data) {
|
|
|
962
647
|
data
|
|
963
648
|
});
|
|
964
649
|
}
|
|
650
|
+
|
|
651
|
+
// src/mcp/tools/shared.ts
|
|
965
652
|
var instanceRefSchema = z.object({
|
|
966
653
|
instanceId: z.string().uuid().optional(),
|
|
967
654
|
pathSegments: z.array(z.string().min(1).max(256)).min(1).max(100).optional()
|
|
@@ -994,7 +681,245 @@ function ref(value) {
|
|
|
994
681
|
return value;
|
|
995
682
|
}
|
|
996
683
|
|
|
997
|
-
// src/mcp/tools/
|
|
684
|
+
// src/mcp/tools/building.ts
|
|
685
|
+
var finiteNumber = z.number().finite();
|
|
686
|
+
var vector3Schema = z.object({ x: finiteNumber, y: finiteNumber, z: finiteNumber }).strict();
|
|
687
|
+
var propertiesSchema = z.record(z.unknown()).refine(
|
|
688
|
+
(properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
|
|
689
|
+
"At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
|
|
690
|
+
);
|
|
691
|
+
var partSchema = z.object({
|
|
692
|
+
name: z.string().min(1).max(100),
|
|
693
|
+
className: z.enum([
|
|
694
|
+
"Part",
|
|
695
|
+
"WedgePart",
|
|
696
|
+
"CornerWedgePart",
|
|
697
|
+
"TrussPart",
|
|
698
|
+
"MeshPart",
|
|
699
|
+
"SpawnLocation",
|
|
700
|
+
"Seat",
|
|
701
|
+
"VehicleSeat"
|
|
702
|
+
]).optional().default("Part"),
|
|
703
|
+
shape: z.enum(["Block", "Ball", "Cylinder", "Wedge", "CornerWedge"]).optional(),
|
|
704
|
+
position: vector3Schema.optional(),
|
|
705
|
+
orientation: vector3Schema.optional(),
|
|
706
|
+
size: vector3Schema.optional(),
|
|
707
|
+
color: z.string().min(1).max(100).optional(),
|
|
708
|
+
material: z.string().min(1).max(100).optional(),
|
|
709
|
+
transparency: z.number().min(0).max(1).optional(),
|
|
710
|
+
anchored: z.boolean().optional().default(true),
|
|
711
|
+
canCollide: z.boolean().optional(),
|
|
712
|
+
properties: propertiesSchema.optional()
|
|
713
|
+
});
|
|
714
|
+
var cloneRequestSchema = z.object({
|
|
715
|
+
target: instanceRefSchema,
|
|
716
|
+
parent: instanceRefSchema.optional(),
|
|
717
|
+
name: z.string().min(1).max(100).optional(),
|
|
718
|
+
count: z.number().int().min(1).max(50).optional().default(1),
|
|
719
|
+
offset: vector3Schema.optional(),
|
|
720
|
+
stepOffset: vector3Schema.optional(),
|
|
721
|
+
properties: propertiesSchema.optional()
|
|
722
|
+
});
|
|
723
|
+
var cframeSchema = z.array(finiteNumber).refine((value) => value.length === 3 || value.length === 12, "CFrame requires 3 or 12 numbers");
|
|
724
|
+
var transformOperationSchema = z.object({
|
|
725
|
+
target: instanceRefSchema,
|
|
726
|
+
mode: z.enum(["absolute", "relative"]).optional().default("absolute"),
|
|
727
|
+
cframe: cframeSchema.optional(),
|
|
728
|
+
position: vector3Schema.optional(),
|
|
729
|
+
orientation: vector3Schema.optional(),
|
|
730
|
+
offset: vector3Schema.optional(),
|
|
731
|
+
rotationOffset: vector3Schema.optional(),
|
|
732
|
+
space: z.enum(["world", "local"]).optional().default("world")
|
|
733
|
+
}).refine(
|
|
734
|
+
(value) => value.mode === "relative" ? Boolean(value.offset || value.rotationOffset) : Boolean(value.cframe || value.position || value.orientation),
|
|
735
|
+
"Absolute transforms require cframe, position, or orientation; relative transforms require offset or rotationOffset"
|
|
736
|
+
);
|
|
737
|
+
function registerBuildingTools(server, bridge) {
|
|
738
|
+
server.registerTool(
|
|
739
|
+
"studio_create_parts",
|
|
740
|
+
{
|
|
741
|
+
title: "Build Roblox Parts",
|
|
742
|
+
description: "Create up to 200 typed BaseParts in one undoable batch, optionally grouped under a new Model or Folder. Supports positions, orientations, sizes, colors, materials, shapes, and additional writable properties.",
|
|
743
|
+
inputSchema: {
|
|
744
|
+
parent: instanceRefSchema.optional(),
|
|
745
|
+
groupName: z.string().min(1).max(100).optional(),
|
|
746
|
+
groupClass: z.enum(["Model", "Folder"]).optional().default("Model"),
|
|
747
|
+
parts: z.array(partSchema).min(1).max(200)
|
|
748
|
+
},
|
|
749
|
+
outputSchema: toolOutputSchema,
|
|
750
|
+
annotations: {
|
|
751
|
+
readOnlyHint: false,
|
|
752
|
+
destructiveHint: false,
|
|
753
|
+
idempotentHint: false,
|
|
754
|
+
openWorldHint: false
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
({ parent, groupName, groupClass, parts }) => callStudio(bridge, CliMsg.V2_CREATE_PARTS, { parent, groupName, groupClass, parts }, 12e4)
|
|
758
|
+
);
|
|
759
|
+
server.registerTool(
|
|
760
|
+
"studio_clone_instances",
|
|
761
|
+
{
|
|
762
|
+
title: "Clone Roblox Instances",
|
|
763
|
+
description: "Deep-clone instances and descendants with Instance:Clone(), optionally rename, reparent, override root properties, and create repeated spatial copies using offsets. The entire request is undoable and rolls back on failure.",
|
|
764
|
+
inputSchema: { requests: z.array(cloneRequestSchema).min(1).max(100) },
|
|
765
|
+
outputSchema: toolOutputSchema,
|
|
766
|
+
annotations: {
|
|
767
|
+
readOnlyHint: false,
|
|
768
|
+
destructiveHint: false,
|
|
769
|
+
idempotentHint: false,
|
|
770
|
+
openWorldHint: false
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
({ requests }) => callStudio(bridge, CliMsg.V2_CLONE_INSTANCES, { requests }, 12e4)
|
|
774
|
+
);
|
|
775
|
+
server.registerTool(
|
|
776
|
+
"studio_group_instances",
|
|
777
|
+
{
|
|
778
|
+
title: "Group Roblox Instances",
|
|
779
|
+
description: "Create a Model or Folder and move up to 100 independent instances into it in one undoable operation.",
|
|
780
|
+
inputSchema: {
|
|
781
|
+
targets: z.array(instanceRefSchema).min(1).max(100),
|
|
782
|
+
name: z.string().min(1).max(100),
|
|
783
|
+
className: z.enum(["Model", "Folder"]).optional().default("Model"),
|
|
784
|
+
parent: instanceRefSchema.optional()
|
|
785
|
+
},
|
|
786
|
+
outputSchema: toolOutputSchema,
|
|
787
|
+
annotations: {
|
|
788
|
+
readOnlyHint: false,
|
|
789
|
+
destructiveHint: false,
|
|
790
|
+
idempotentHint: false,
|
|
791
|
+
openWorldHint: false
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
({ targets, name, className, parent }) => callStudio(bridge, CliMsg.V2_GROUP_INSTANCES, { targets, name, className, parent }, 6e4)
|
|
795
|
+
);
|
|
796
|
+
server.registerTool(
|
|
797
|
+
"studio_ungroup_instances",
|
|
798
|
+
{
|
|
799
|
+
title: "Ungroup Roblox Instances",
|
|
800
|
+
description: "Move every child out of up to 20 Models or Folders, then remove the empty containers in one undoable operation.",
|
|
801
|
+
inputSchema: {
|
|
802
|
+
targets: z.array(instanceRefSchema).min(1).max(20),
|
|
803
|
+
parent: instanceRefSchema.optional(),
|
|
804
|
+
confirm: z.literal(true)
|
|
805
|
+
},
|
|
806
|
+
outputSchema: toolOutputSchema,
|
|
807
|
+
annotations: {
|
|
808
|
+
readOnlyHint: false,
|
|
809
|
+
destructiveHint: true,
|
|
810
|
+
idempotentHint: false,
|
|
811
|
+
openWorldHint: false
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
({ targets, parent, confirm }) => callStudio(bridge, CliMsg.V2_UNGROUP_INSTANCES, { targets, parent, confirm }, 6e4)
|
|
815
|
+
);
|
|
816
|
+
server.registerTool(
|
|
817
|
+
"studio_transform_instances",
|
|
818
|
+
{
|
|
819
|
+
title: "Transform Roblox Models and Parts",
|
|
820
|
+
description: "Move or rotate up to 100 Models and BaseParts through PVInstance:PivotTo. Supports exact absolute CFrames and world/local relative offsets without breaking model layouts.",
|
|
821
|
+
inputSchema: { operations: z.array(transformOperationSchema).min(1).max(100) },
|
|
822
|
+
outputSchema: toolOutputSchema,
|
|
823
|
+
annotations: {
|
|
824
|
+
readOnlyHint: false,
|
|
825
|
+
destructiveHint: false,
|
|
826
|
+
idempotentHint: false,
|
|
827
|
+
openWorldHint: false
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
({ operations }) => callStudio(bridge, CliMsg.V2_TRANSFORM_INSTANCES, { operations }, 6e4)
|
|
831
|
+
);
|
|
832
|
+
server.registerTool(
|
|
833
|
+
"studio_create_welds",
|
|
834
|
+
{
|
|
835
|
+
title: "Create Roblox Welds",
|
|
836
|
+
description: "Create up to 200 WeldConstraints between explicit BasePart refs in one undoable operation.",
|
|
837
|
+
inputSchema: {
|
|
838
|
+
welds: z.array(
|
|
839
|
+
z.object({
|
|
840
|
+
part0: instanceRefSchema,
|
|
841
|
+
part1: instanceRefSchema,
|
|
842
|
+
name: z.string().min(1).max(100).optional(),
|
|
843
|
+
parent: instanceRefSchema.optional()
|
|
844
|
+
})
|
|
845
|
+
).min(1).max(200)
|
|
846
|
+
},
|
|
847
|
+
outputSchema: toolOutputSchema,
|
|
848
|
+
annotations: {
|
|
849
|
+
readOnlyHint: false,
|
|
850
|
+
destructiveHint: false,
|
|
851
|
+
idempotentHint: false,
|
|
852
|
+
openWorldHint: false
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
({ welds }) => callStudio(bridge, CliMsg.V2_CREATE_WELDS, { welds }, 6e4)
|
|
856
|
+
);
|
|
857
|
+
server.registerTool(
|
|
858
|
+
"studio_query_parts",
|
|
859
|
+
{
|
|
860
|
+
title: "Query Parts in 3D Space",
|
|
861
|
+
description: "Find BaseParts intersecting an oriented box or radius using WorldRoot spatial queries. Returns stable refs, positions, and sizes for inspection or later mutations.",
|
|
862
|
+
inputSchema: {
|
|
863
|
+
root: instanceRefSchema.optional(),
|
|
864
|
+
mode: z.enum(["box", "radius"]).optional().default("box"),
|
|
865
|
+
position: vector3Schema,
|
|
866
|
+
orientation: vector3Schema.optional(),
|
|
867
|
+
size: vector3Schema.optional(),
|
|
868
|
+
radius: z.number().finite().positive().optional(),
|
|
869
|
+
maxParts: z.number().int().min(1).max(500).optional().default(200),
|
|
870
|
+
filter: z.array(instanceRefSchema).max(100).optional(),
|
|
871
|
+
filterType: z.enum(["Include", "Exclude"]).optional().default("Exclude"),
|
|
872
|
+
respectCanCollide: z.boolean().optional().default(false)
|
|
873
|
+
},
|
|
874
|
+
outputSchema: toolOutputSchema,
|
|
875
|
+
annotations: {
|
|
876
|
+
readOnlyHint: true,
|
|
877
|
+
destructiveHint: false,
|
|
878
|
+
idempotentHint: true,
|
|
879
|
+
openWorldHint: false
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
({
|
|
883
|
+
root,
|
|
884
|
+
mode,
|
|
885
|
+
position,
|
|
886
|
+
orientation,
|
|
887
|
+
size,
|
|
888
|
+
radius,
|
|
889
|
+
maxParts,
|
|
890
|
+
filter,
|
|
891
|
+
filterType,
|
|
892
|
+
respectCanCollide
|
|
893
|
+
}) => callStudio(bridge, CliMsg.V2_QUERY_PARTS, {
|
|
894
|
+
root,
|
|
895
|
+
mode,
|
|
896
|
+
position,
|
|
897
|
+
orientation,
|
|
898
|
+
size,
|
|
899
|
+
radius,
|
|
900
|
+
maxParts,
|
|
901
|
+
filter,
|
|
902
|
+
filterType,
|
|
903
|
+
respectCanCollide
|
|
904
|
+
})
|
|
905
|
+
);
|
|
906
|
+
server.registerTool(
|
|
907
|
+
"studio_set_selection",
|
|
908
|
+
{
|
|
909
|
+
title: "Set Roblox Studio Selection",
|
|
910
|
+
description: "Replace the current Studio selection with up to 200 stable instance refs. Pass an empty array to clear selection.",
|
|
911
|
+
inputSchema: { targets: z.array(instanceRefSchema).max(200) },
|
|
912
|
+
outputSchema: toolOutputSchema,
|
|
913
|
+
annotations: {
|
|
914
|
+
readOnlyHint: false,
|
|
915
|
+
destructiveHint: false,
|
|
916
|
+
idempotentHint: true,
|
|
917
|
+
openWorldHint: false
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
({ targets }) => callStudio(bridge, CliMsg.V2_SET_SELECTION, { targets })
|
|
921
|
+
);
|
|
922
|
+
}
|
|
998
923
|
function registerConnectionTools(server, bridge) {
|
|
999
924
|
server.registerTool(
|
|
1000
925
|
"dominus_status",
|
|
@@ -1164,10 +1089,10 @@ async function readReferenceYaml(name, kind, opts) {
|
|
|
1164
1089
|
const folder = KIND_FOLDERS[kind];
|
|
1165
1090
|
const fileName = `${name}.yaml`;
|
|
1166
1091
|
if (localRoot) {
|
|
1167
|
-
const localPath =
|
|
1168
|
-
if (
|
|
1092
|
+
const localPath = path4.join(localRoot, folder, fileName);
|
|
1093
|
+
if (fs4.existsSync(localPath)) {
|
|
1169
1094
|
return {
|
|
1170
|
-
content:
|
|
1095
|
+
content: fs4.readFileSync(localPath, "utf-8"),
|
|
1171
1096
|
sourceUrl: toCreatorDocsUrl(kind, name)
|
|
1172
1097
|
};
|
|
1173
1098
|
}
|
|
@@ -1187,11 +1112,11 @@ async function listReferenceEntries(opts) {
|
|
|
1187
1112
|
if (localRoot) {
|
|
1188
1113
|
const entries = [];
|
|
1189
1114
|
for (const [kind, folder] of Object.entries(KIND_FOLDERS)) {
|
|
1190
|
-
const dir =
|
|
1191
|
-
if (!
|
|
1192
|
-
for (const file of
|
|
1115
|
+
const dir = path4.join(localRoot, folder);
|
|
1116
|
+
if (!fs4.existsSync(dir)) continue;
|
|
1117
|
+
for (const file of fs4.readdirSync(dir)) {
|
|
1193
1118
|
if (!file.endsWith(".yaml")) continue;
|
|
1194
|
-
const name =
|
|
1119
|
+
const name = path4.basename(file, ".yaml");
|
|
1195
1120
|
entries.push({ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) });
|
|
1196
1121
|
}
|
|
1197
1122
|
}
|
|
@@ -1204,7 +1129,7 @@ async function listReferenceEntries(opts) {
|
|
|
1204
1129
|
const [folder, file] = rest.split("/");
|
|
1205
1130
|
const kind = folderToKind(folder);
|
|
1206
1131
|
if (!kind || !file) return [];
|
|
1207
|
-
const name =
|
|
1132
|
+
const name = path4.basename(file, ".yaml");
|
|
1208
1133
|
return [{ name, kind, score: 0, sourceUrl: toCreatorDocsUrl(kind, name) }];
|
|
1209
1134
|
});
|
|
1210
1135
|
}
|
|
@@ -1224,19 +1149,19 @@ function resolveDocsRoot(explicitRoot) {
|
|
|
1224
1149
|
const candidates = [
|
|
1225
1150
|
explicitRoot,
|
|
1226
1151
|
process.env.ROBLOX_CREATOR_DOCS_PATH,
|
|
1227
|
-
|
|
1152
|
+
path4.join(process.cwd(), ".tmp", "creator-docs")
|
|
1228
1153
|
].filter(Boolean);
|
|
1229
1154
|
for (const candidate of candidates) {
|
|
1230
1155
|
const normalized = normalizeDocsRoot(candidate);
|
|
1231
|
-
if (normalized &&
|
|
1156
|
+
if (normalized && fs4.existsSync(normalized)) return normalized;
|
|
1232
1157
|
}
|
|
1233
1158
|
return null;
|
|
1234
1159
|
}
|
|
1235
1160
|
function normalizeDocsRoot(root) {
|
|
1236
|
-
const engineRoot =
|
|
1237
|
-
if (
|
|
1238
|
-
const directClasses =
|
|
1239
|
-
if (
|
|
1161
|
+
const engineRoot = path4.join(root, "content", "en-us", "reference", "engine");
|
|
1162
|
+
if (fs4.existsSync(engineRoot)) return engineRoot;
|
|
1163
|
+
const directClasses = path4.join(root, "classes");
|
|
1164
|
+
if (fs4.existsSync(directClasses)) return root;
|
|
1240
1165
|
return null;
|
|
1241
1166
|
}
|
|
1242
1167
|
function normalizeLookupName(name) {
|
|
@@ -1522,7 +1447,7 @@ function registerDocsTools(server) {
|
|
|
1522
1447
|
}
|
|
1523
1448
|
);
|
|
1524
1449
|
}
|
|
1525
|
-
var
|
|
1450
|
+
var propertiesSchema2 = z.record(z.unknown()).refine(
|
|
1526
1451
|
(properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
|
|
1527
1452
|
"At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
|
|
1528
1453
|
);
|
|
@@ -1530,19 +1455,27 @@ var mutationOperationSchema = z.discriminatedUnion("op", [
|
|
|
1530
1455
|
z.object({
|
|
1531
1456
|
op: z.literal("setProperties"),
|
|
1532
1457
|
target: instanceRefSchema,
|
|
1533
|
-
properties:
|
|
1458
|
+
properties: propertiesSchema2
|
|
1534
1459
|
}),
|
|
1535
1460
|
z.object({
|
|
1536
1461
|
op: z.literal("create"),
|
|
1537
1462
|
parent: instanceRefSchema,
|
|
1538
1463
|
className: z.string().min(1).max(100),
|
|
1539
1464
|
name: z.string().min(1).max(256),
|
|
1540
|
-
properties:
|
|
1465
|
+
properties: propertiesSchema2.optional()
|
|
1541
1466
|
}),
|
|
1542
1467
|
z.object({
|
|
1543
1468
|
op: z.literal("move"),
|
|
1544
1469
|
target: instanceRefSchema,
|
|
1545
1470
|
parent: instanceRefSchema
|
|
1471
|
+
}),
|
|
1472
|
+
z.object({
|
|
1473
|
+
op: z.literal("clone"),
|
|
1474
|
+
target: instanceRefSchema,
|
|
1475
|
+
parent: instanceRefSchema,
|
|
1476
|
+
name: z.string().min(1).max(100),
|
|
1477
|
+
offset: z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).optional(),
|
|
1478
|
+
properties: propertiesSchema2.optional()
|
|
1546
1479
|
})
|
|
1547
1480
|
]);
|
|
1548
1481
|
var workerProposalSchema = z.object({
|
|
@@ -1771,7 +1704,7 @@ async function runWorker(server, bridge, goal, assignment, constraints, taskType
|
|
|
1771
1704
|
systemPrompt: [
|
|
1772
1705
|
"You are a read-only Dominus worker. Inspect only the Studio evidence in the request.",
|
|
1773
1706
|
"You cannot write to Studio. Return one strict JSON WorkerProposal and no Markdown.",
|
|
1774
|
-
"Only propose setProperties, create, or move operations using exact instance refs copied from evidence.",
|
|
1707
|
+
"Only propose setProperties, create, clone, or move operations using exact instance refs copied from evidence.",
|
|
1775
1708
|
"Never invent instanceId values. Never propose delete, script edits, or arbitrary execution."
|
|
1776
1709
|
].join(" "),
|
|
1777
1710
|
messages: [
|
|
@@ -1802,6 +1735,13 @@ async function runWorker(server, bridge, goal, assignment, constraints, taskType
|
|
|
1802
1735
|
op: "move",
|
|
1803
1736
|
target: { instanceId: "uuid", pathSegments: ["..."] },
|
|
1804
1737
|
parent: { instanceId: "uuid", pathSegments: ["..."] }
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
op: "clone",
|
|
1741
|
+
target: { instanceId: "uuid", pathSegments: ["..."] },
|
|
1742
|
+
parent: { instanceId: "uuid", pathSegments: ["..."] },
|
|
1743
|
+
name: "CloneName",
|
|
1744
|
+
offset: { x: 0, y: 0, z: 0 }
|
|
1805
1745
|
}
|
|
1806
1746
|
],
|
|
1807
1747
|
risks: ["string"],
|
|
@@ -1886,7 +1826,7 @@ function validateWorkerResults(results) {
|
|
|
1886
1826
|
function validateOperations(assignment, operations) {
|
|
1887
1827
|
const errors = [];
|
|
1888
1828
|
for (const [index, operation] of operations.entries()) {
|
|
1889
|
-
const refs = operation.op === "create" ? [operation.parent] : operation.op === "move" ? [operation.target, operation.parent] : [operation.target];
|
|
1829
|
+
const refs = operation.op === "create" ? [operation.parent] : operation.op === "move" || operation.op === "clone" ? [operation.target, operation.parent] : [operation.target];
|
|
1890
1830
|
for (const ref2 of refs) {
|
|
1891
1831
|
if (!isKnownParallelRef(ref2, assignment.knownRefKeys)) {
|
|
1892
1832
|
errors.push(`Operation ${index} references an instance absent from worker evidence`);
|
|
@@ -1905,7 +1845,7 @@ function rejectProposalConflicts(accepted) {
|
|
|
1905
1845
|
const targets = item.proposal.operations.map(operationTarget);
|
|
1906
1846
|
const conflict = claims.find(
|
|
1907
1847
|
(claim) => targets.some(
|
|
1908
|
-
(target) => target.path && claim.paths.some((
|
|
1848
|
+
(target) => target.path && claim.paths.some((path5) => parallelPathsOverlap(target.path, path5)) || claim.refKeys.includes(target.key)
|
|
1909
1849
|
)
|
|
1910
1850
|
);
|
|
1911
1851
|
if (conflict) {
|
|
@@ -2032,12 +1972,14 @@ async function verifyPlan(bridge, rootRef, originalRoot, operations) {
|
|
|
2032
1972
|
);
|
|
2033
1973
|
const knownPaths = collectInstancePaths(originalRoot);
|
|
2034
1974
|
const expectedCreates = operations.flatMap((operation) => {
|
|
2035
|
-
if (operation.op !== "create") return [];
|
|
1975
|
+
if (operation.op !== "create" && operation.op !== "clone") return [];
|
|
2036
1976
|
const parentPath = resolveRefPath(operation.parent, knownPaths);
|
|
2037
1977
|
return parentPath ? [pathKey([...parentPath, operation.name])] : [];
|
|
2038
1978
|
});
|
|
2039
|
-
const missingCreates = expectedCreates.filter((
|
|
2040
|
-
const inspectOperations = operations.filter(
|
|
1979
|
+
const missingCreates = expectedCreates.filter((path5) => !paths.has(path5));
|
|
1980
|
+
const inspectOperations = operations.filter(
|
|
1981
|
+
(operation) => operation.op !== "create" && operation.op !== "clone"
|
|
1982
|
+
);
|
|
2041
1983
|
const inspectFailures = [];
|
|
2042
1984
|
const propertyMismatches = [];
|
|
2043
1985
|
const moveMismatches = [];
|
|
@@ -2107,6 +2049,7 @@ function findRepairOperations(operations, verification, originalRoot) {
|
|
|
2107
2049
|
const parentPath = resolveRefPath(operation.parent, knownPaths);
|
|
2108
2050
|
return Boolean(parentPath && missingCreates.has(pathKey([...parentPath, operation.name])));
|
|
2109
2051
|
}
|
|
2052
|
+
if (operation.op === "clone") return false;
|
|
2110
2053
|
if (operation.op === "setProperties") return mismatchedTargets.has(refLabel(operation.target));
|
|
2111
2054
|
return mismatchedMoves.has(refLabel(operation.target));
|
|
2112
2055
|
});
|
|
@@ -2176,7 +2119,7 @@ function isParallelRefWithinScopes(ref2, scopes) {
|
|
|
2176
2119
|
return scopes.some((scope) => Boolean(ref2.instanceId && scope.instanceId === ref2.instanceId));
|
|
2177
2120
|
}
|
|
2178
2121
|
function operationTarget(operation) {
|
|
2179
|
-
if (operation.op === "create") {
|
|
2122
|
+
if (operation.op === "create" || operation.op === "clone") {
|
|
2180
2123
|
const parentPath = operation.parent.pathSegments;
|
|
2181
2124
|
return {
|
|
2182
2125
|
key: `${refKey(operation.parent)}/create:${operation.name}`,
|
|
@@ -2204,8 +2147,8 @@ function resolveRefPath(ref2, known) {
|
|
|
2204
2147
|
if (ref2.pathSegments) return ref2.pathSegments;
|
|
2205
2148
|
return ref2.instanceId ? known.get(`id:${ref2.instanceId}`) : void 0;
|
|
2206
2149
|
}
|
|
2207
|
-
function pathKey(
|
|
2208
|
-
return
|
|
2150
|
+
function pathKey(path5) {
|
|
2151
|
+
return path5.join("\0");
|
|
2209
2152
|
}
|
|
2210
2153
|
function parallelPathIsWithin(target, scope) {
|
|
2211
2154
|
return target.length >= scope.length && scope.every((segment, index) => target[index] === segment);
|
|
@@ -2218,8 +2161,8 @@ function samePath(left, right) {
|
|
|
2218
2161
|
left && right && left.length === right.length && left.every((part, index) => part === right[index])
|
|
2219
2162
|
);
|
|
2220
2163
|
}
|
|
2221
|
-
function parallelRootPathToSegments(
|
|
2222
|
-
const trimmed =
|
|
2164
|
+
function parallelRootPathToSegments(path5) {
|
|
2165
|
+
const trimmed = path5.trim();
|
|
2223
2166
|
const bracketSegments = [...trimmed.matchAll(/\["((?:\\.|[^"])*)"\]/g)].map(
|
|
2224
2167
|
(match) => JSON.parse(`"${match[1]}"`)
|
|
2225
2168
|
);
|
|
@@ -2281,37 +2224,46 @@ function emit(server, type, data) {
|
|
|
2281
2224
|
function toErrorMessage(error) {
|
|
2282
2225
|
return error instanceof Error ? error.message : String(error);
|
|
2283
2226
|
}
|
|
2284
|
-
var
|
|
2227
|
+
var propertiesSchema3 = z.record(z.unknown()).refine(
|
|
2285
2228
|
(properties) => Object.keys(properties).length <= 100 && !Object.keys(properties).some((name) => ["Parent", "ClassName", "Source"].includes(name)),
|
|
2286
2229
|
"At most 100 properties are allowed; Parent, ClassName, and Source require dedicated operations"
|
|
2287
2230
|
);
|
|
2288
2231
|
var setPropertiesOperation = z.object({
|
|
2289
2232
|
op: z.literal("setProperties"),
|
|
2290
2233
|
target: instanceRefSchema,
|
|
2291
|
-
properties:
|
|
2234
|
+
properties: propertiesSchema3
|
|
2292
2235
|
});
|
|
2293
2236
|
var createOperation = z.object({
|
|
2294
2237
|
op: z.literal("create"),
|
|
2295
2238
|
parent: instanceRefSchema,
|
|
2296
2239
|
className: z.string().min(1).max(100),
|
|
2297
2240
|
name: z.string().min(1).max(256),
|
|
2298
|
-
properties:
|
|
2241
|
+
properties: propertiesSchema3.optional()
|
|
2299
2242
|
});
|
|
2300
2243
|
var moveOperation = z.object({
|
|
2301
2244
|
op: z.literal("move"),
|
|
2302
2245
|
target: instanceRefSchema,
|
|
2303
2246
|
parent: instanceRefSchema
|
|
2304
2247
|
});
|
|
2248
|
+
var cloneOperation = z.object({
|
|
2249
|
+
op: z.literal("clone"),
|
|
2250
|
+
target: instanceRefSchema,
|
|
2251
|
+
parent: instanceRefSchema.optional(),
|
|
2252
|
+
name: z.string().min(1).max(100).optional(),
|
|
2253
|
+
offset: z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).optional(),
|
|
2254
|
+
properties: propertiesSchema3.optional()
|
|
2255
|
+
});
|
|
2305
2256
|
var mutationOperation = z.discriminatedUnion("op", [
|
|
2306
2257
|
setPropertiesOperation,
|
|
2307
2258
|
createOperation,
|
|
2259
|
+
cloneOperation,
|
|
2308
2260
|
moveOperation
|
|
2309
2261
|
]);
|
|
2310
2262
|
var uiNodeSchema = z.lazy(
|
|
2311
2263
|
() => z.object({
|
|
2312
2264
|
ClassName: z.string().min(1).max(100),
|
|
2313
2265
|
Name: z.string().min(1).max(256).optional(),
|
|
2314
|
-
properties:
|
|
2266
|
+
properties: propertiesSchema3.optional(),
|
|
2315
2267
|
Children: z.array(uiNodeSchema).max(1e3).optional()
|
|
2316
2268
|
})
|
|
2317
2269
|
);
|
|
@@ -2415,7 +2367,7 @@ function registerStudioTools(server, bridge) {
|
|
|
2415
2367
|
"studio_apply",
|
|
2416
2368
|
{
|
|
2417
2369
|
title: "Apply Atomic Studio Mutations",
|
|
2418
|
-
description: "Atomically set properties, create instances, and move instances. Any failed operation cancels and undoes the entire batch.",
|
|
2370
|
+
description: "Atomically set properties, create instances, deep-clone instances, and move instances. Any failed operation cancels and undoes the entire batch.",
|
|
2419
2371
|
inputSchema: { operations: z.array(mutationOperation).min(1).max(100) },
|
|
2420
2372
|
outputSchema: toolOutputSchema,
|
|
2421
2373
|
annotations: {
|
|
@@ -2556,7 +2508,7 @@ function registerStudioTools(server, bridge) {
|
|
|
2556
2508
|
}
|
|
2557
2509
|
|
|
2558
2510
|
// src/mcp/server.ts
|
|
2559
|
-
var VERSION = "2.
|
|
2511
|
+
var VERSION = "2.3.0";
|
|
2560
2512
|
function createDominusMcpServer(bridge) {
|
|
2561
2513
|
const server = new McpServer(
|
|
2562
2514
|
{
|
|
@@ -2569,28 +2521,14 @@ function createDominusMcpServer(bridge) {
|
|
|
2569
2521
|
);
|
|
2570
2522
|
registerConnectionTools(server, bridge);
|
|
2571
2523
|
registerStudioTools(server, bridge);
|
|
2524
|
+
registerBuildingTools(server, bridge);
|
|
2572
2525
|
registerParallelTaskTool(server, bridge);
|
|
2573
2526
|
registerDocsTools(server);
|
|
2574
2527
|
registerDominusResources(server, bridge);
|
|
2575
2528
|
return server;
|
|
2576
2529
|
}
|
|
2577
2530
|
async function createStudioBridge(port) {
|
|
2578
|
-
|
|
2579
|
-
const client = new DominusClient(port);
|
|
2580
|
-
await client.connect();
|
|
2581
|
-
return client;
|
|
2582
|
-
}
|
|
2583
|
-
const server = new DominusServer(port);
|
|
2584
|
-
try {
|
|
2585
|
-
await server.start();
|
|
2586
|
-
return server;
|
|
2587
|
-
} catch (err) {
|
|
2588
|
-
const error = err;
|
|
2589
|
-
if (error.code !== "EADDRINUSE") throw err;
|
|
2590
|
-
const client = new DominusClient(port);
|
|
2591
|
-
await client.connect();
|
|
2592
|
-
return client;
|
|
2593
|
-
}
|
|
2531
|
+
return ensureBridgeDaemon(port);
|
|
2594
2532
|
}
|
|
2595
2533
|
async function startMcpServer() {
|
|
2596
2534
|
const config = loadConfig();
|