dominus-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js ADDED
@@ -0,0 +1,907 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { WebSocket, WebSocketServer } from 'ws';
6
+ import { EventEmitter } from 'events';
7
+ import { nanoid } from 'nanoid';
8
+ import fs from 'fs';
9
+ import initSqlJs from 'sql.js';
10
+ import path from 'path';
11
+ import os from 'os';
12
+
13
+ var StudioMsg = {
14
+ CONNECTED: "studio:connected",
15
+ DISCONNECTED: "studio:disconnected",
16
+ EXPLORER: "studio:explorer",
17
+ OUTPUT: "studio:output",
18
+ ERROR: "studio:error",
19
+ SELECTION: "studio:selection",
20
+ SCRIPT_CONTENT: "studio:script:content",
21
+ SCRIPT_CHANGED: "studio:script:changed",
22
+ RESPONSE: "studio:response",
23
+ REFLECTION: "studio:reflection",
24
+ TEST_RESULTS: "studio:test:results"
25
+ };
26
+ var CliMsg = {
27
+ GET_EXPLORER: "cli:get:explorer",
28
+ GET_SCRIPT: "cli:get:script",
29
+ SET_SCRIPT: "cli:set:script",
30
+ RUN_CODE: "cli:run",
31
+ GET_PROPERTIES: "cli:get:properties",
32
+ SET_PROPERTIES: "cli:set:properties",
33
+ INSERT_INSTANCE: "cli:insert",
34
+ DELETE_INSTANCE: "cli:delete",
35
+ GET_SELECTION: "cli:get:selection",
36
+ SEARCH_SCRIPTS: "cli:search:scripts",
37
+ GET_OUTPUT: "cli:get:output",
38
+ RUN_TESTS: "cli:run:tests",
39
+ EXECUTE_RUN_TEST: "cli:test:run",
40
+ EXECUTE_PLAY_TEST: "cli:test:play",
41
+ BUILD_UI: "cli:build:ui",
42
+ GET_REFLECTION: "cli:get:reflection"};
43
+ function createMessage(type, payload) {
44
+ return { id: nanoid(), type, payload, ts: Date.now() };
45
+ }
46
+ function parseMessage(raw) {
47
+ try {
48
+ return JSON.parse(raw);
49
+ } catch {
50
+ throw new Error(`Failed to parse WebSocket message: ${raw.slice(0, 100)}`);
51
+ }
52
+ }
53
+ function serializeMessage(msg) {
54
+ return JSON.stringify(msg);
55
+ }
56
+
57
+ // src/transport/ws-server.ts
58
+ var DominusServer = class extends EventEmitter {
59
+ wss = null;
60
+ studioClient = null;
61
+ controllers = /* @__PURE__ */ new Set();
62
+ pendingRequests = /* @__PURE__ */ new Map();
63
+ relayRequests = /* @__PURE__ */ new Map();
64
+ port;
65
+ constructor(port = 18088) {
66
+ super();
67
+ this.port = port;
68
+ }
69
+ start(maxRetries = 0) {
70
+ return new Promise((resolve, reject) => {
71
+ const tryPort = (port, attempt) => {
72
+ const wss = new WebSocketServer({ port });
73
+ wss.on("listening", () => {
74
+ this.wss = wss;
75
+ this.port = port;
76
+ this.wss.on("connection", (ws) => this.handleConnection(ws));
77
+ this.emit("server:started", { port });
78
+ resolve();
79
+ });
80
+ wss.on("error", (err) => {
81
+ if (err.code === "EADDRINUSE" && attempt < maxRetries) {
82
+ wss.close();
83
+ tryPort(port + 1, attempt + 1);
84
+ } else {
85
+ this.emit("server:error", err);
86
+ reject(err);
87
+ }
88
+ });
89
+ };
90
+ tryPort(this.port, 0);
91
+ });
92
+ }
93
+ handleConnection(ws) {
94
+ let identified = false;
95
+ ws.on("message", (raw) => {
96
+ try {
97
+ const msg = parseMessage(raw.toString());
98
+ if (!identified) {
99
+ if (msg.type === StudioMsg.CONNECTED) {
100
+ identified = true;
101
+ this.setStudioClient(ws, msg);
102
+ return;
103
+ }
104
+ if (msg.type === "controller:connect") {
105
+ identified = true;
106
+ this.addController(ws);
107
+ ws.send(serializeMessage(createMessage("controller:welcome", {
108
+ studioConnected: this.isConnected(),
109
+ port: this.port
110
+ })));
111
+ return;
112
+ }
113
+ }
114
+ if (this.controllers.has(ws)) {
115
+ this.handleControllerMessage(ws, msg);
116
+ } else {
117
+ this.handleStudioMessage(msg);
118
+ }
119
+ } catch (err) {
120
+ this.emit("server:error", err);
121
+ }
122
+ });
123
+ ws.on("close", () => {
124
+ if (this.controllers.has(ws)) {
125
+ this.controllers.delete(ws);
126
+ for (const [id, controllerWs] of this.relayRequests) {
127
+ if (controllerWs === ws) this.relayRequests.delete(id);
128
+ }
129
+ } else if (this.studioClient?.ws === ws) {
130
+ this.studioClient = null;
131
+ this.emit("studio:disconnected");
132
+ this.broadcastToControllers("studio:disconnected", {});
133
+ for (const [id, pending] of this.pendingRequests) {
134
+ clearTimeout(pending.timer);
135
+ pending.reject(new Error("Studio disconnected"));
136
+ this.pendingRequests.delete(id);
137
+ }
138
+ }
139
+ });
140
+ ws.on("error", (err) => {
141
+ this.emit("server:error", err);
142
+ });
143
+ }
144
+ setStudioClient(ws, msg) {
145
+ if (this.studioClient) {
146
+ this.studioClient.ws.close();
147
+ }
148
+ const payload = msg.payload;
149
+ this.studioClient = {
150
+ ws,
151
+ connectedAt: Date.now(),
152
+ studioVersion: payload.studioVersion,
153
+ placeId: payload.placeId,
154
+ placeName: payload.placeName
155
+ };
156
+ this.emit("studio:connected", payload);
157
+ this.broadcastToControllers("studio:connected", payload);
158
+ }
159
+ addController(ws) {
160
+ this.controllers.add(ws);
161
+ this.emit("controller:connected");
162
+ }
163
+ broadcastToControllers(type, payload) {
164
+ const msg = serializeMessage(createMessage(type, payload));
165
+ for (const ws of this.controllers) {
166
+ if (ws.readyState === WebSocket.OPEN) {
167
+ ws.send(msg);
168
+ }
169
+ }
170
+ }
171
+ handleControllerMessage(controllerWs, msg) {
172
+ if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
173
+ controllerWs.send(serializeMessage({
174
+ id: msg.id,
175
+ type: StudioMsg.RESPONSE,
176
+ payload: { error: "Studio not connected" },
177
+ ts: Date.now()
178
+ }));
179
+ return;
180
+ }
181
+ this.relayRequests.set(msg.id, controllerWs);
182
+ this.studioClient.ws.send(serializeMessage(msg));
183
+ }
184
+ handleStudioMessage(msg) {
185
+ if (msg.type === StudioMsg.CONNECTED) {
186
+ const payload = msg.payload;
187
+ if (this.studioClient) {
188
+ this.studioClient.studioVersion = payload.studioVersion;
189
+ this.studioClient.placeId = payload.placeId;
190
+ this.studioClient.placeName = payload.placeName;
191
+ }
192
+ this.emit("studio:connected", payload);
193
+ this.broadcastToControllers("studio:connected", payload);
194
+ return;
195
+ }
196
+ if (msg.type === StudioMsg.RESPONSE) {
197
+ const controllerWs = this.relayRequests.get(msg.id);
198
+ if (controllerWs && controllerWs.readyState === WebSocket.OPEN) {
199
+ controllerWs.send(serializeMessage(msg));
200
+ this.relayRequests.delete(msg.id);
201
+ return;
202
+ }
203
+ const pending = this.pendingRequests.get(msg.id);
204
+ if (pending) {
205
+ clearTimeout(pending.timer);
206
+ pending.resolve(msg);
207
+ this.pendingRequests.delete(msg.id);
208
+ }
209
+ return;
210
+ }
211
+ this.emit(msg.type, msg.payload);
212
+ this.emit("message", msg);
213
+ this.broadcastToControllers(msg.type, msg.payload);
214
+ }
215
+ sendRequest(type, payload, timeoutMs = 1e4) {
216
+ return new Promise((resolve, reject) => {
217
+ if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
218
+ reject(new Error("No Studio connection"));
219
+ return;
220
+ }
221
+ const msg = createMessage(type, payload);
222
+ const timer = setTimeout(() => {
223
+ this.pendingRequests.delete(msg.id);
224
+ reject(new Error(`Request timed out: ${type}`));
225
+ }, timeoutMs);
226
+ this.pendingRequests.set(msg.id, {
227
+ resolve,
228
+ reject,
229
+ timer
230
+ });
231
+ this.studioClient.ws.send(serializeMessage(msg));
232
+ });
233
+ }
234
+ sendNotification(type, payload) {
235
+ if (!this.studioClient || this.studioClient.ws.readyState !== WebSocket.OPEN) {
236
+ return;
237
+ }
238
+ const msg = createMessage(type, payload);
239
+ this.studioClient.ws.send(serializeMessage(msg));
240
+ }
241
+ isConnected() {
242
+ return this.studioClient !== null && this.studioClient.ws.readyState === WebSocket.OPEN;
243
+ }
244
+ getConnectionInfo() {
245
+ return this.studioClient;
246
+ }
247
+ getPort() {
248
+ return this.port;
249
+ }
250
+ stop() {
251
+ return new Promise((resolve) => {
252
+ if (this.studioClient) {
253
+ this.studioClient.ws.close();
254
+ this.studioClient = null;
255
+ }
256
+ for (const ws of this.controllers) {
257
+ ws.close();
258
+ }
259
+ this.controllers.clear();
260
+ for (const [, pending] of this.pendingRequests) {
261
+ clearTimeout(pending.timer);
262
+ pending.reject(new Error("Server shutting down"));
263
+ }
264
+ this.pendingRequests.clear();
265
+ this.relayRequests.clear();
266
+ if (this.wss) {
267
+ this.wss.close(() => resolve());
268
+ } else {
269
+ resolve();
270
+ }
271
+ });
272
+ }
273
+ };
274
+ var DOMINUS_DIR = path.join(os.homedir(), ".dominus");
275
+ var CONFIG_PATH = path.join(DOMINUS_DIR, "config.json");
276
+ var DB_PATH = path.join(DOMINUS_DIR, "dominus.db");
277
+ path.join(DOMINUS_DIR, "rules.json");
278
+ var DEFAULTS = {
279
+ provider: "openai",
280
+ apiBaseUrl: "",
281
+ model: "",
282
+ port: 18088,
283
+ maxToolIterations: 25,
284
+ maxVerifyRetries: 3,
285
+ tokenBudget: 128e3,
286
+ autoIndex: true,
287
+ autoLearn: true,
288
+ userName: "Developer"
289
+ };
290
+ function ensureDir() {
291
+ if (!fs.existsSync(DOMINUS_DIR)) {
292
+ fs.mkdirSync(DOMINUS_DIR, { recursive: true });
293
+ }
294
+ }
295
+ function readJson(filePath, defaults) {
296
+ try {
297
+ if (fs.existsSync(filePath)) {
298
+ const raw = fs.readFileSync(filePath, "utf-8");
299
+ return { ...defaults, ...JSON.parse(raw) };
300
+ }
301
+ } catch {
302
+ }
303
+ return { ...defaults };
304
+ }
305
+ function loadConfig() {
306
+ ensureDir();
307
+ return readJson(CONFIG_PATH, DEFAULTS);
308
+ }
309
+ function getDbPath() {
310
+ ensureDir();
311
+ return DB_PATH;
312
+ }
313
+
314
+ // src/memory/store.ts
315
+ var db = null;
316
+ var dbPath;
317
+ var SCHEMA = `
318
+ CREATE TABLE IF NOT EXISTS facts (
319
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
320
+ project_id TEXT NOT NULL,
321
+ content TEXT NOT NULL,
322
+ category TEXT NOT NULL,
323
+ source TEXT,
324
+ relevance REAL DEFAULT 1.0,
325
+ created_at INTEGER NOT NULL,
326
+ accessed_at INTEGER NOT NULL
327
+ );
328
+
329
+ CREATE TABLE IF NOT EXISTS summaries (
330
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
331
+ project_id TEXT NOT NULL,
332
+ session_id TEXT NOT NULL,
333
+ summary TEXT NOT NULL,
334
+ created_at INTEGER NOT NULL
335
+ );
336
+
337
+ CREATE TABLE IF NOT EXISTS messages (
338
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
339
+ session_id TEXT NOT NULL,
340
+ role TEXT NOT NULL,
341
+ content TEXT NOT NULL,
342
+ tool_name TEXT,
343
+ created_at INTEGER NOT NULL
344
+ );
345
+
346
+ CREATE TABLE IF NOT EXISTS script_index (
347
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
348
+ project_id TEXT NOT NULL,
349
+ path TEXT NOT NULL,
350
+ class_name TEXT NOT NULL,
351
+ summary TEXT,
352
+ line_count INTEGER,
353
+ last_hash TEXT,
354
+ updated_at INTEGER NOT NULL
355
+ );
356
+
357
+ CREATE INDEX IF NOT EXISTS idx_facts_project ON facts(project_id);
358
+ CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(project_id, category);
359
+ CREATE INDEX IF NOT EXISTS idx_scripts_project ON script_index(project_id);
360
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
361
+ CREATE INDEX IF NOT EXISTS idx_summaries_project ON summaries(project_id);
362
+ `;
363
+ function persist() {
364
+ if (!db) return;
365
+ const data = db.export();
366
+ fs.writeFileSync(dbPath, Buffer.from(data));
367
+ }
368
+ async function initMemoryStore() {
369
+ if (db) return;
370
+ dbPath = getDbPath();
371
+ const SQL = await initSqlJs();
372
+ if (fs.existsSync(dbPath)) {
373
+ const buffer = fs.readFileSync(dbPath);
374
+ db = new SQL.Database(buffer);
375
+ } else {
376
+ db = new SQL.Database();
377
+ }
378
+ db.run(SCHEMA);
379
+ persist();
380
+ }
381
+ function getDb() {
382
+ if (!db) throw new Error("Memory store not initialized. Call initMemoryStore() first.");
383
+ return db;
384
+ }
385
+ function storeFact(fact) {
386
+ const d = getDb();
387
+ d.run(
388
+ `INSERT INTO facts (project_id, content, category, source, relevance, created_at, accessed_at)
389
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
390
+ [
391
+ fact.projectId,
392
+ fact.content,
393
+ fact.category,
394
+ fact.source ?? null,
395
+ fact.relevance,
396
+ fact.createdAt,
397
+ fact.accessedAt
398
+ ]
399
+ );
400
+ persist();
401
+ const stmt = d.prepare("SELECT last_insert_rowid()");
402
+ stmt.step();
403
+ const row = stmt.get();
404
+ stmt.free();
405
+ return row?.[0] ?? 0;
406
+ }
407
+ function recallFacts(projectId, query, limit = 10) {
408
+ const d = getDb();
409
+ const keywords = query.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
410
+ if (keywords.length === 0) {
411
+ const result2 = d.exec(
412
+ `SELECT * FROM facts WHERE project_id = ? ORDER BY relevance DESC, accessed_at DESC LIMIT ?`,
413
+ [projectId, limit]
414
+ );
415
+ return rowsToFacts(result2);
416
+ }
417
+ const likeClauses = keywords.map(() => `LOWER(content) LIKE ?`).join(" OR ");
418
+ const likeParams = keywords.map((k) => `%${k}%`);
419
+ const result = d.exec(
420
+ `SELECT * FROM facts WHERE project_id = ? AND (${likeClauses})
421
+ ORDER BY relevance DESC, accessed_at DESC LIMIT ?`,
422
+ [projectId, ...likeParams, limit]
423
+ );
424
+ const facts = rowsToFacts(result);
425
+ for (const fact of facts) {
426
+ if (fact.id) {
427
+ d.run(`UPDATE facts SET accessed_at = ? WHERE id = ?`, [Date.now(), fact.id]);
428
+ }
429
+ }
430
+ if (facts.length > 0) persist();
431
+ return facts;
432
+ }
433
+ function rowsToFacts(result) {
434
+ if (!result[0]) return [];
435
+ return result[0].values.map((row) => ({
436
+ id: row[0],
437
+ projectId: row[1],
438
+ content: row[2],
439
+ category: row[3],
440
+ source: row[4],
441
+ relevance: row[5],
442
+ createdAt: row[6],
443
+ accessedAt: row[7]
444
+ }));
445
+ }
446
+ function getScriptIndex(projectId) {
447
+ const d = getDb();
448
+ const result = d.exec(
449
+ `SELECT path, summary, class_name FROM script_index WHERE project_id = ? ORDER BY path`,
450
+ [projectId]
451
+ );
452
+ if (!result[0]) return [];
453
+ return result[0].values.map((row) => ({
454
+ path: row[0],
455
+ summary: row[1] || void 0,
456
+ className: row[2]
457
+ }));
458
+ }
459
+
460
+ // src/mcp/server.ts
461
+ var wsServer;
462
+ async function startMcpServer() {
463
+ const config = loadConfig();
464
+ await initMemoryStore();
465
+ wsServer = new DominusServer(config.port);
466
+ await wsServer.start();
467
+ const mcp = new McpServer({
468
+ name: "dominus",
469
+ version: "0.1.0"
470
+ });
471
+ mcp.tool(
472
+ "read_script",
473
+ "Read the source code of a script in Roblox Studio",
474
+ { path: z.string().describe('Full instance path, e.g. "ServerScriptService.GameManager"') },
475
+ async ({ path: path2 }) => {
476
+ assertConnected();
477
+ const res = await wsServer.sendRequest(CliMsg.GET_SCRIPT, { path: path2 });
478
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
479
+ }
480
+ );
481
+ mcp.tool(
482
+ "edit_script",
483
+ "Replace the entire source code of a script in Roblox Studio",
484
+ {
485
+ path: z.string().describe("Full instance path"),
486
+ source: z.string().describe("New complete source code")
487
+ },
488
+ async ({ path: path2, source }) => {
489
+ assertConnected();
490
+ const res = await wsServer.sendRequest(CliMsg.SET_SCRIPT, { path: path2, source });
491
+ return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
492
+ }
493
+ );
494
+ mcp.tool(
495
+ "run_code",
496
+ "Execute Luau code in Roblox Studio and return output",
497
+ { code: z.string().describe("Luau code to execute") },
498
+ async ({ code }) => {
499
+ assertConnected();
500
+ const res = await wsServer.sendRequest(CliMsg.RUN_CODE, { code });
501
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
502
+ }
503
+ );
504
+ mcp.tool(
505
+ "get_explorer",
506
+ "Get the DataModel explorer tree from Roblox Studio",
507
+ {
508
+ rootPath: z.string().optional().describe("Root path to start from. Omit for entire tree."),
509
+ maxDepth: z.number().optional().default(3).describe("Maximum tree depth")
510
+ },
511
+ async ({ rootPath, maxDepth }) => {
512
+ assertConnected();
513
+ const res = await wsServer.sendRequest(CliMsg.GET_EXPLORER, {
514
+ rootPath: rootPath ?? null,
515
+ maxDepth
516
+ });
517
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
518
+ }
519
+ );
520
+ mcp.tool(
521
+ "get_properties",
522
+ "Get all properties of an instance in Roblox Studio",
523
+ { path: z.string().describe("Full instance path") },
524
+ async ({ path: path2 }) => {
525
+ assertConnected();
526
+ const res = await wsServer.sendRequest(CliMsg.GET_PROPERTIES, { path: path2 });
527
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
528
+ }
529
+ );
530
+ mcp.tool(
531
+ "set_properties",
532
+ "Set properties on an instance in Roblox Studio",
533
+ {
534
+ path: z.string().describe("Full instance path"),
535
+ properties: z.record(z.unknown()).describe("Key-value pairs of properties to set")
536
+ },
537
+ async ({ path: path2, properties }) => {
538
+ assertConnected();
539
+ const res = await wsServer.sendRequest(CliMsg.SET_PROPERTIES, { path: path2, properties });
540
+ return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
541
+ }
542
+ );
543
+ mcp.tool(
544
+ "insert_instance",
545
+ "Create a new instance in the Roblox Studio DataModel",
546
+ {
547
+ className: z.string().describe('Class name (e.g. "Part", "Script")'),
548
+ parentPath: z.string().describe("Parent instance path"),
549
+ name: z.string().describe("Name for the new instance"),
550
+ properties: z.record(z.unknown()).optional().describe("Optional properties to set")
551
+ },
552
+ async ({ className, parentPath, name, properties }) => {
553
+ assertConnected();
554
+ const res = await wsServer.sendRequest(CliMsg.INSERT_INSTANCE, {
555
+ className,
556
+ parentPath,
557
+ name,
558
+ properties: properties ?? {}
559
+ });
560
+ return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
561
+ }
562
+ );
563
+ mcp.tool(
564
+ "delete_instance",
565
+ "Delete an instance from the Roblox Studio DataModel",
566
+ { path: z.string().describe("Full instance path to delete") },
567
+ async ({ path: path2 }) => {
568
+ assertConnected();
569
+ const res = await wsServer.sendRequest(CliMsg.DELETE_INSTANCE, { path: path2 });
570
+ return { content: [{ type: "text", text: JSON.stringify(res.payload) }] };
571
+ }
572
+ );
573
+ mcp.tool(
574
+ "get_selection",
575
+ "Get the currently selected instances in Roblox Studio",
576
+ {},
577
+ async () => {
578
+ assertConnected();
579
+ const res = await wsServer.sendRequest(CliMsg.GET_SELECTION, {});
580
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
581
+ }
582
+ );
583
+ mcp.tool(
584
+ "search_scripts",
585
+ "Search all scripts in Roblox Studio for a text pattern",
586
+ {
587
+ query: z.string().describe("Text to search for"),
588
+ maxResults: z.number().optional().default(20).describe("Max results")
589
+ },
590
+ async ({ query, maxResults }) => {
591
+ assertConnected();
592
+ const res = await wsServer.sendRequest(CliMsg.SEARCH_SCRIPTS, { query, maxResults });
593
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
594
+ }
595
+ );
596
+ mcp.tool(
597
+ "get_output",
598
+ "Get recent output log from Roblox Studio",
599
+ {
600
+ limit: z.number().optional().default(50).describe("Max entries"),
601
+ level: z.enum(["info", "warn", "error"]).optional().describe("Filter by level")
602
+ },
603
+ async ({ limit, level }) => {
604
+ assertConnected();
605
+ const res = await wsServer.sendRequest(CliMsg.GET_OUTPUT, {
606
+ limit,
607
+ level: level ?? null
608
+ });
609
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
610
+ }
611
+ );
612
+ mcp.tool(
613
+ "get_class_info",
614
+ "Look up the schema of any Roblox class using ReflectionService. Returns all properties (with types), methods (with parameters), and events. ALWAYS use this before creating or modifying GUI elements to know correct property names and types.",
615
+ { className: z.string().describe('Roblox class name, e.g. "Frame", "TextLabel", "Part", "MeshPart"') },
616
+ async ({ className }) => {
617
+ assertConnected();
618
+ const res = await wsServer.sendRequest(CliMsg.GET_REFLECTION, { className });
619
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
620
+ }
621
+ );
622
+ mcp.tool(
623
+ "create_ui",
624
+ '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.',
625
+ {
626
+ parent: z.string().optional().describe('Parent: "StarterGui", "PlayerGui", or path. Default: StarterGui'),
627
+ tree: z.record(z.unknown()).describe(
628
+ 'Root UI node: {ClassName, Name, properties..., Children: [{...}, ...]}. UDim2 as [xScale, xOffset, yScale, yOffset], Color3 as "#hex", Font as "GothamBold"'
629
+ )
630
+ },
631
+ async ({ parent, tree }) => {
632
+ assertConnected();
633
+ const res = await wsServer.sendRequest(
634
+ CliMsg.BUILD_UI,
635
+ { parent: parent ?? "StarterGui", tree }
636
+ );
637
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
638
+ }
639
+ );
640
+ mcp.tool(
641
+ "run_tests",
642
+ "Run tests by requiring ModuleScripts matching test naming conventions (*.test, *.spec, *Test, *Spec). Lightweight \u2014 does not enter Run/Play mode.",
643
+ { testName: z.string().optional().describe("Specific test name, or omit to run all") },
644
+ async ({ testName }) => {
645
+ assertConnected();
646
+ const res = await wsServer.sendRequest(CliMsg.RUN_TESTS, {
647
+ testName: testName ?? null
648
+ });
649
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
650
+ }
651
+ );
652
+ mcp.tool(
653
+ "execute_run_test",
654
+ "Start a Run mode test session using StudioTestService:ExecuteRunModeAsync(). This actually enters Run mode in Studio. Pass args that server scripts can retrieve via GetTestArgs(). Yields until EndTest() is called or the session stops. Returns the value from EndTest(). Use for headless automated tests \u2014 no player, just server scripts.",
655
+ { args: z.unknown().optional().describe("Arguments passed to the test session. Server scripts get this via StudioTestService:GetTestArgs()") },
656
+ async ({ args }) => {
657
+ assertConnected();
658
+ const res = await wsServer.sendRequest(CliMsg.EXECUTE_RUN_TEST, { args: args ?? null }, 12e4);
659
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
660
+ }
661
+ );
662
+ mcp.tool(
663
+ "execute_play_test",
664
+ "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.",
665
+ { args: z.unknown().optional().describe("Arguments passed to the test session. Server scripts get this via StudioTestService:GetTestArgs()") },
666
+ async ({ args }) => {
667
+ assertConnected();
668
+ const res = await wsServer.sendRequest(CliMsg.EXECUTE_PLAY_TEST, { args: args ?? null }, 12e4);
669
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
670
+ }
671
+ );
672
+ mcp.tool(
673
+ "create_part",
674
+ "Create a Part with position, size, color, material \u2014 all in one call. Faster than insert_instance + set_properties.",
675
+ {
676
+ name: z.string().describe("Name for the part"),
677
+ parent: z.string().optional().describe("Parent path (default: Workspace)"),
678
+ shape: z.enum(["Block", "Ball", "Cylinder", "Wedge", "CornerWedge"]).optional().describe("Part shape"),
679
+ position: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("World position"),
680
+ size: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("Size in studs"),
681
+ color: z.string().optional().describe('Hex "#00FF00", BrickColor name, or "rgb(0,255,0)"'),
682
+ material: z.string().optional().describe("Plastic, Wood, Grass, Neon, Metal, etc."),
683
+ transparency: z.number().optional().describe("0=opaque, 1=invisible"),
684
+ anchored: z.boolean().optional().describe("Anchored (default: true)"),
685
+ canCollide: z.boolean().optional().describe("Has collision")
686
+ },
687
+ async (params) => {
688
+ assertConnected();
689
+ const properties = {};
690
+ if (params.position) properties.Position = { X: params.position.x, Y: params.position.y, Z: params.position.z };
691
+ if (params.size) properties.Size = { X: params.size.x, Y: params.size.y, Z: params.size.z };
692
+ if (params.color) {
693
+ const c = params.color;
694
+ if (c.startsWith("#")) properties.Color = c;
695
+ else if (c.startsWith("rgb")) {
696
+ const m = c.match(/(\d+)/g);
697
+ if (m && m.length >= 3) properties.Color = { red: parseInt(m[0]), green: parseInt(m[1]), blue: parseInt(m[2]) };
698
+ } else properties.BrickColor = c;
699
+ }
700
+ if (params.material) properties.Material = params.material;
701
+ if (params.transparency !== void 0) properties.Transparency = params.transparency;
702
+ if (params.anchored !== void 0) properties.Anchored = params.anchored;
703
+ else properties.Anchored = true;
704
+ if (params.canCollide !== void 0) properties.CanCollide = params.canCollide;
705
+ if (params.shape && params.shape !== "Block") properties.Shape = params.shape;
706
+ const className = params.shape === "Wedge" ? "WedgePart" : params.shape === "CornerWedge" ? "CornerWedgePart" : "Part";
707
+ const res = await wsServer.sendRequest(CliMsg.INSERT_INSTANCE, {
708
+ className,
709
+ parentPath: params.parent ?? "Workspace",
710
+ name: params.name,
711
+ properties
712
+ });
713
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
714
+ }
715
+ );
716
+ mcp.tool(
717
+ "clone_instance",
718
+ "Clone an existing instance, optionally rename it, move it, or offset its position",
719
+ {
720
+ path: z.string().describe("Path of instance to clone"),
721
+ newName: z.string().optional().describe("Name for the clone"),
722
+ newParent: z.string().optional().describe("Parent path for the clone"),
723
+ offset: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe("Position offset from original")
724
+ },
725
+ async (params) => {
726
+ assertConnected();
727
+ const parts = params.path.split(".");
728
+ let resolve = "game";
729
+ for (const p of parts) resolve += `["${p}"]`;
730
+ const lines = [`local orig = ${resolve}`, `local clone = orig:Clone()`];
731
+ if (params.newName) lines.push(`clone.Name = "${params.newName}"`);
732
+ if (params.newParent) {
733
+ const pp = params.newParent.split(".");
734
+ let pr = "game";
735
+ for (const p of pp) pr += `["${p}"]`;
736
+ lines.push(`clone.Parent = ${pr}`);
737
+ } else {
738
+ lines.push(`clone.Parent = orig.Parent`);
739
+ }
740
+ if (params.offset) {
741
+ lines.push(`if clone:IsA("BasePart") then`);
742
+ lines.push(` clone.Position = clone.Position + Vector3.new(${params.offset.x}, ${params.offset.y}, ${params.offset.z})`);
743
+ lines.push(`end`);
744
+ }
745
+ lines.push(`print("Cloned: " .. clone:GetFullName())`);
746
+ const res = await wsServer.sendRequest(CliMsg.RUN_CODE, { code: lines.join("\n") });
747
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
748
+ }
749
+ );
750
+ mcp.tool(
751
+ "group_instances",
752
+ "Group multiple instances into a Model or Folder",
753
+ {
754
+ paths: z.array(z.string()).describe("Array of instance paths to group"),
755
+ groupName: z.string().describe("Name for the group"),
756
+ groupClass: z.enum(["Model", "Folder"]).optional().describe("Container class (default: Model)"),
757
+ parent: z.string().optional().describe("Parent path (default: Workspace)")
758
+ },
759
+ async (params) => {
760
+ assertConnected();
761
+ const gc = params.groupClass ?? "Model";
762
+ const parent = params.parent ?? "Workspace";
763
+ const parentParts = parent.split(".");
764
+ let parentResolve = "game";
765
+ for (const p of parentParts) parentResolve += `["${p}"]`;
766
+ const resolves = params.paths.map((path2) => {
767
+ const parts = path2.split(".");
768
+ let r = "game";
769
+ for (const part of parts) r += `["${part}"]`;
770
+ return r;
771
+ });
772
+ const code = [
773
+ `local group = Instance.new("${gc}")`,
774
+ `group.Name = "${params.groupName}"`,
775
+ `group.Parent = ${parentResolve}`,
776
+ ...resolves.map((r) => `${r}.Parent = group`),
777
+ `print("Grouped ${params.paths.length} instances into: " .. group:GetFullName())`
778
+ ].join("\n");
779
+ const res = await wsServer.sendRequest(CliMsg.RUN_CODE, { code });
780
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
781
+ }
782
+ );
783
+ mcp.tool(
784
+ "build_multiple",
785
+ "Create many parts/instances in one batch. Send an array of specs \u2014 all created in a single round-trip. Ideal for building trees, houses, vehicles.",
786
+ {
787
+ parts: z.array(z.object({
788
+ name: z.string(),
789
+ className: z.string().optional(),
790
+ parent: z.string().optional(),
791
+ position: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional(),
792
+ size: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional(),
793
+ color: z.string().optional().describe('Hex "#RRGGBB" or BrickColor name'),
794
+ material: z.string().optional(),
795
+ transparency: z.number().optional(),
796
+ anchored: z.boolean().optional(),
797
+ shape: z.enum(["Block", "Ball", "Cylinder", "Wedge"]).optional()
798
+ })).describe("Array of part specifications"),
799
+ groupName: z.string().optional().describe("Group all parts into a Model with this name"),
800
+ groupParent: z.string().optional().describe("Parent for the group (default: Workspace)")
801
+ },
802
+ async (params) => {
803
+ assertConnected();
804
+ const parts = params.parts;
805
+ const groupName = params.groupName;
806
+ const groupParent = params.groupParent ?? "Workspace";
807
+ function resolveP(path2) {
808
+ const segs = path2.split(".");
809
+ let r = "game";
810
+ for (const s of segs) r += `["${s}"]`;
811
+ return r;
812
+ }
813
+ const lines = [];
814
+ if (groupName) {
815
+ lines.push(`local _group = Instance.new("Model")`);
816
+ lines.push(`_group.Name = "${groupName}"`);
817
+ lines.push(`_group.Parent = ${resolveP(groupParent)}`);
818
+ }
819
+ for (let i = 0; i < parts.length; i++) {
820
+ const p = parts[i];
821
+ const v = `_p${i}`;
822
+ const cn = p.className ?? (p.shape === "Wedge" ? "WedgePart" : "Part");
823
+ lines.push(`local ${v} = Instance.new("${cn}")`);
824
+ lines.push(`${v}.Name = "${p.name}"`);
825
+ if (p.position) lines.push(`${v}.Position = Vector3.new(${p.position.x}, ${p.position.y}, ${p.position.z})`);
826
+ if (p.size) lines.push(`${v}.Size = Vector3.new(${p.size.x}, ${p.size.y}, ${p.size.z})`);
827
+ if (p.color) {
828
+ if (p.color.startsWith("#")) lines.push(`${v}.Color = Color3.fromHex("${p.color}")`);
829
+ else lines.push(`${v}.BrickColor = BrickColor.new("${p.color}")`);
830
+ }
831
+ if (p.material) lines.push(`${v}.Material = Enum.Material.${p.material}`);
832
+ if (p.transparency !== void 0) lines.push(`${v}.Transparency = ${p.transparency}`);
833
+ lines.push(`${v}.Anchored = ${p.anchored ?? true}`);
834
+ if (p.shape && p.shape !== "Block" && cn === "Part") lines.push(`${v}.Shape = Enum.PartType.${p.shape}`);
835
+ lines.push(`${v}.Parent = ${groupName ? "_group" : resolveP(p.parent ?? "Workspace")}`);
836
+ }
837
+ lines.push(`print("Built ${parts.length} parts${groupName ? ` in ${groupName}` : ""}")`);
838
+ const res = await wsServer.sendRequest(CliMsg.RUN_CODE, { code: lines.join("\n") });
839
+ return { content: [{ type: "text", text: JSON.stringify(res.payload, null, 2) }] };
840
+ }
841
+ );
842
+ mcp.tool(
843
+ "recall_memory",
844
+ "Search persistent memory for facts about the current Roblox project",
845
+ { query: z.string().describe("What to search for") },
846
+ async ({ query }) => {
847
+ const facts = recallFacts("current", query, 15);
848
+ const scripts = getScriptIndex("current");
849
+ return {
850
+ content: [{
851
+ type: "text",
852
+ text: JSON.stringify({ facts: facts.map((f) => f.content), scriptIndex: scripts }, null, 2)
853
+ }]
854
+ };
855
+ }
856
+ );
857
+ mcp.tool(
858
+ "remember",
859
+ "Store a fact about the project in persistent memory for future sessions",
860
+ {
861
+ fact: z.string().describe("The fact to remember"),
862
+ category: z.enum(["architecture", "pattern", "preference", "bug", "api", "general"]).describe("Category")
863
+ },
864
+ async ({ fact, category }) => {
865
+ const now = Date.now();
866
+ storeFact({ projectId: "current", content: fact, category, source: "mcp", relevance: 1, createdAt: now, accessedAt: now });
867
+ return { content: [{ type: "text", text: `Remembered: ${fact}` }] };
868
+ }
869
+ );
870
+ mcp.resource(
871
+ "studio://status",
872
+ "studio://status",
873
+ async () => {
874
+ const info = wsServer.getConnectionInfo();
875
+ return {
876
+ contents: [{
877
+ uri: "studio://status",
878
+ text: JSON.stringify({
879
+ connected: wsServer.isConnected(),
880
+ placeName: info?.placeName,
881
+ placeId: info?.placeId,
882
+ studioVersion: info?.studioVersion,
883
+ port: wsServer.getPort()
884
+ }, null, 2),
885
+ mimeType: "application/json"
886
+ }]
887
+ };
888
+ }
889
+ );
890
+ const transport = new StdioServerTransport();
891
+ await mcp.connect(transport);
892
+ }
893
+ function assertConnected() {
894
+ if (!wsServer.isConnected()) {
895
+ throw new Error(
896
+ "Roblox Studio is not connected. Open Studio and click the Dominus plugin Connect button."
897
+ );
898
+ }
899
+ }
900
+
901
+ // src/mcp/index.ts
902
+ startMcpServer().catch((err) => {
903
+ console.error("Failed to start Dominus MCP server:", err);
904
+ process.exit(1);
905
+ });
906
+ //# sourceMappingURL=mcp.js.map
907
+ //# sourceMappingURL=mcp.js.map