conduit-mcp 2.0.0 → 2.0.3

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.
@@ -10,7 +10,7 @@ import { WebSocketServer, WebSocket } from "ws";
10
10
  // src/protocol.ts
11
11
  import { randomUUID } from "crypto";
12
12
  function generateId() {
13
- return randomUUID().slice(0, 8);
13
+ return randomUUID().slice(0, 16);
14
14
  }
15
15
  function isHeartbeat(msg) {
16
16
  return typeof msg === "object" && msg !== null && "type" in msg && msg.type === "heartbeat";
@@ -41,8 +41,10 @@ var log = {
41
41
  var REQUEST_TIMEOUT_MS = 3e4;
42
42
  var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
43
43
  var HEARTBEAT_TIMEOUT_MS = 15e3;
44
+ var PING_INTERVAL_MS = 1e4;
44
45
  var LONG_POLL_TIMEOUT_MS = 25e3;
45
46
  var MAX_PORT_RETRIES = 10;
47
+ var REGISTRATION_TIMEOUT_MS = 1e4;
46
48
  var Bridge = class extends EventEmitter {
47
49
  constructor(port = 3200) {
48
50
  super();
@@ -56,6 +58,7 @@ var Bridge = class extends EventEmitter {
56
58
  pendingRequests = /* @__PURE__ */ new Map();
57
59
  lastHeartbeats = /* @__PURE__ */ new Map();
58
60
  heartbeatTimer = null;
61
+ pingTimer = null;
59
62
  actualPort = 0;
60
63
  // HTTP-only studios (no WebSocket connection)
61
64
  httpStudios = /* @__PURE__ */ new Map();
@@ -65,8 +68,10 @@ var Bridge = class extends EventEmitter {
65
68
  httpPendingCommands = /* @__PURE__ */ new Map();
66
69
  httpPollWaiters = /* @__PURE__ */ new Map();
67
70
  get isConnected() {
68
- const studio = this.getActiveStudio();
69
- return studio !== void 0 && studio.ws.readyState === WebSocket.OPEN;
71
+ for (const [, studio] of this.studios) {
72
+ if (studio.ws.readyState === WebSocket.OPEN) return true;
73
+ }
74
+ return this.httpStudios.size > 0;
70
75
  }
71
76
  get listeningPort() {
72
77
  return this.actualPort;
@@ -92,7 +97,35 @@ var Bridge = class extends EventEmitter {
92
97
  log.info(`Active studio set to: ${studioId}`);
93
98
  }
94
99
  // ── Server lifecycle ───────────────────────────────────────────
100
+ /**
101
+ * Try to shut down a stale Conduit instance on the target port.
102
+ * Returns true if the port was freed (or was already free).
103
+ */
104
+ async evictStaleInstance(port) {
105
+ return new Promise((resolve) => {
106
+ const req = http.request(
107
+ {
108
+ hostname: "127.0.0.1",
109
+ port,
110
+ path: "/shutdown",
111
+ method: "POST",
112
+ timeout: 2e3
113
+ },
114
+ (res) => {
115
+ res.resume();
116
+ setTimeout(() => resolve(true), 500);
117
+ }
118
+ );
119
+ req.on("error", () => resolve(false));
120
+ req.on("timeout", () => {
121
+ req.destroy();
122
+ resolve(false);
123
+ });
124
+ req.end();
125
+ });
126
+ }
95
127
  async start() {
128
+ await this.evictStaleInstance(this.port);
96
129
  return new Promise((resolve, reject) => {
97
130
  let attempts = 0;
98
131
  const tryPort = (port) => {
@@ -102,6 +135,9 @@ var Bridge = class extends EventEmitter {
102
135
  server.once("error", (err) => {
103
136
  if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
104
137
  attempts++;
138
+ log.warn(
139
+ `Port ${port} still in use after eviction \u2014 trying ${port + 1}`
140
+ );
105
141
  tryPort(port + 1);
106
142
  } else {
107
143
  reject(err);
@@ -139,6 +175,7 @@ var Bridge = class extends EventEmitter {
139
175
  clearInterval(this.heartbeatTimer);
140
176
  this.heartbeatTimer = null;
141
177
  }
178
+ this.stopPingInterval();
142
179
  for (const [, waiters] of this.httpPollWaiters) {
143
180
  for (const waiter of waiters) {
144
181
  clearTimeout(waiter.timer);
@@ -209,30 +246,33 @@ var Bridge = class extends EventEmitter {
209
246
  if (active && active.ws.readyState === WebSocket.OPEN) {
210
247
  return active;
211
248
  }
212
- if (this.studios.size === 1) {
213
- const [studioId, studio] = this.studios.entries().next().value;
249
+ for (const [studioId, studio] of this.studios) {
214
250
  if (studio.ws.readyState === WebSocket.OPEN) {
215
251
  this.activeStudioId = studioId;
252
+ log.info(`Auto-selected active studio: ${studioId}`);
216
253
  return studio;
217
254
  }
218
255
  }
219
- if (this.studios.size === 0) {
220
- return void 0;
221
- }
222
- throw new Error(
223
- `Multiple Studio instances connected but none is active. Use set_active_studio to choose one. Connected: ${Array.from(
224
- this.studios.values()
225
- ).map((s) => `${s.info.studioId} (${s.info.placeName ?? "unknown"})`).join(", ")}`
226
- );
256
+ return void 0;
227
257
  }
228
258
  // ── WebSocket handling ─────────────────────────────────────────
229
259
  handleWsConnection(ws) {
230
260
  log.info("New WebSocket connection \u2014 waiting for registration");
231
261
  this.pendingWs.add(ws);
262
+ const registrationTimer = setTimeout(() => {
263
+ if (this.pendingWs.has(ws)) {
264
+ log.warn(
265
+ "WebSocket failed to register within timeout \u2014 closing"
266
+ );
267
+ this.pendingWs.delete(ws);
268
+ ws.close(1008, "Registration timeout");
269
+ }
270
+ }, REGISTRATION_TIMEOUT_MS);
232
271
  const onFirstMessage = (data) => {
233
272
  try {
234
273
  const msg = JSON.parse(data.toString());
235
274
  if (isStudioRegistration(msg)) {
275
+ clearTimeout(registrationTimer);
236
276
  ws.removeListener("message", onFirstMessage);
237
277
  this.pendingWs.delete(ws);
238
278
  this.registerStudio(ws, {
@@ -243,9 +283,10 @@ var Bridge = class extends EventEmitter {
243
283
  });
244
284
  return;
245
285
  }
286
+ clearTimeout(registrationTimer);
246
287
  ws.removeListener("message", onFirstMessage);
247
288
  this.pendingWs.delete(ws);
248
- const syntheticId = "studio-1";
289
+ const syntheticId = `studio-legacy-${Date.now()}`;
249
290
  log.info(
250
291
  "Legacy plugin detected (no registration), assigning ID: " + syntheticId
251
292
  );
@@ -259,12 +300,17 @@ var Bridge = class extends EventEmitter {
259
300
  }
260
301
  };
261
302
  ws.on("message", onFirstMessage);
262
- ws.on("close", () => {
303
+ const onEarlyClose = () => {
304
+ clearTimeout(registrationTimer);
263
305
  this.pendingWs.delete(ws);
264
- });
265
- ws.on("error", (err) => {
306
+ };
307
+ const onEarlyError = (err) => {
266
308
  log.warn("WebSocket error:", err.message);
267
- });
309
+ };
310
+ ws.on("close", onEarlyClose);
311
+ ws.on("error", onEarlyError);
312
+ ws.__earlyClose = onEarlyClose;
313
+ ws.__earlyError = onEarlyError;
268
314
  }
269
315
  registerStudio(ws, info) {
270
316
  const { studioId } = info;
@@ -275,6 +321,10 @@ var Bridge = class extends EventEmitter {
275
321
  }
276
322
  this.studios.set(studioId, { ws, info });
277
323
  this.lastHeartbeats.set(studioId, Date.now());
324
+ ws.isAlive = true;
325
+ ws.on("pong", () => {
326
+ ws.isAlive = true;
327
+ });
278
328
  if (this.activeStudioId === null) {
279
329
  this.activeStudioId = studioId;
280
330
  }
@@ -283,11 +333,23 @@ var Bridge = class extends EventEmitter {
283
333
  `Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
284
334
  );
285
335
  this.startHeartbeatMonitor();
336
+ this.startPingInterval();
337
+ if (ws.__earlyClose) {
338
+ ws.removeListener("close", ws.__earlyClose);
339
+ delete ws.__earlyClose;
340
+ }
341
+ if (ws.__earlyError) {
342
+ ws.removeListener("error", ws.__earlyError);
343
+ delete ws.__earlyError;
344
+ }
286
345
  ws.on("message", (data) => {
287
346
  try {
288
347
  const msg = JSON.parse(data.toString());
289
348
  if (isHeartbeat(msg)) {
290
349
  this.lastHeartbeats.set(studioId, Date.now());
350
+ if (ws.readyState === WebSocket.OPEN) {
351
+ ws.send(JSON.stringify({ type: "heartbeat_ack" }));
352
+ }
291
353
  return;
292
354
  }
293
355
  this.handlePluginMessage(msg);
@@ -303,7 +365,7 @@ var Bridge = class extends EventEmitter {
303
365
  this.emit("studio-disconnected", info);
304
366
  log.info(`Studio disconnected: ${studioId}`);
305
367
  if (this.activeStudioId === studioId) {
306
- if (this.studios.size === 1) {
368
+ if (this.studios.size > 0) {
307
369
  this.activeStudioId = this.studios.keys().next().value;
308
370
  log.info(
309
371
  `Auto-switched active studio to: ${this.activeStudioId}`
@@ -314,26 +376,27 @@ var Bridge = class extends EventEmitter {
314
376
  }
315
377
  if (this.studios.size === 0) {
316
378
  this.stopHeartbeatMonitor();
379
+ this.stopPingInterval();
317
380
  }
318
381
  }
319
382
  });
320
383
  }
321
384
  handlePluginMessage(msg) {
322
- if (isBridgeResponse(msg)) {
385
+ if (isBridgeError(msg)) {
323
386
  const pending = this.pendingRequests.get(msg.id);
324
387
  if (pending) {
325
388
  clearTimeout(pending.timer);
326
389
  this.pendingRequests.delete(msg.id);
327
- pending.resolve(msg.result);
390
+ pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
328
391
  }
329
392
  return;
330
393
  }
331
- if (isBridgeError(msg)) {
394
+ if (isBridgeResponse(msg)) {
332
395
  const pending = this.pendingRequests.get(msg.id);
333
396
  if (pending) {
334
397
  clearTimeout(pending.timer);
335
398
  this.pendingRequests.delete(msg.id);
336
- pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
399
+ pending.resolve(msg.result);
337
400
  }
338
401
  return;
339
402
  }
@@ -350,7 +413,8 @@ var Bridge = class extends EventEmitter {
350
413
  log.warn(
351
414
  `Heartbeat timeout for studio "${studioId}" \u2014 disconnecting`
352
415
  );
353
- studio.ws.terminate();
416
+ studio.ws.close(1001, "Heartbeat timeout");
417
+ this.lastHeartbeats.delete(studioId);
354
418
  } else if (this.httpStudios.has(studioId)) {
355
419
  log.warn(
356
420
  `Heartbeat timeout for HTTP studio "${studioId}" \u2014 evicting`
@@ -374,9 +438,35 @@ var Bridge = class extends EventEmitter {
374
438
  this.heartbeatTimer = null;
375
439
  }
376
440
  }
441
+ startPingInterval() {
442
+ if (this.pingTimer) return;
443
+ this.pingTimer = setInterval(() => {
444
+ for (const [studioId, studio] of this.studios) {
445
+ const ws = studio.ws;
446
+ if (ws.isAlive === false) {
447
+ log.warn(
448
+ `Ping timeout for studio "${studioId}" \u2014 terminating connection`
449
+ );
450
+ ws.terminate();
451
+ return;
452
+ }
453
+ ws.isAlive = false;
454
+ ws.ping();
455
+ }
456
+ }, PING_INTERVAL_MS);
457
+ }
458
+ stopPingInterval() {
459
+ if (this.pingTimer) {
460
+ clearInterval(this.pingTimer);
461
+ this.pingTimer = null;
462
+ }
463
+ }
377
464
  // ── HTTP fallback handling ─────────────────────────────────────
378
465
  handleHttp(req, res) {
379
- res.setHeader("Access-Control-Allow-Origin", "*");
466
+ const origin = req.headers.origin;
467
+ if (origin && (origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1"))) {
468
+ res.setHeader("Access-Control-Allow-Origin", origin);
469
+ }
380
470
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
381
471
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
382
472
  if (req.method === "OPTIONS") {
@@ -386,6 +476,20 @@ var Bridge = class extends EventEmitter {
386
476
  }
387
477
  const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`);
388
478
  const pathname = parsedUrl.pathname;
479
+ if (req.method === "POST" && pathname === "/shutdown") {
480
+ if (req.headers.origin) {
481
+ res.writeHead(403);
482
+ res.end("Forbidden");
483
+ return;
484
+ }
485
+ log.info("Received shutdown request from new Conduit instance");
486
+ res.writeHead(200);
487
+ res.end("ok");
488
+ setImmediate(() => {
489
+ this.emit("shutdown");
490
+ });
491
+ return;
492
+ }
389
493
  if (req.method === "GET" && pathname === "/health") {
390
494
  res.writeHead(200, { "Content-Type": "application/json" });
391
495
  res.end(
@@ -393,7 +497,8 @@ var Bridge = class extends EventEmitter {
393
497
  status: "ok",
394
498
  connected: this.isConnected,
395
499
  port: this.actualPort,
396
- studios: this.getStudios()
500
+ studios: this.getStudios(),
501
+ activeStudioId: this.activeStudioId
397
502
  })
398
503
  );
399
504
  return;
@@ -404,6 +509,11 @@ var Bridge = class extends EventEmitter {
404
509
  return;
405
510
  }
406
511
  if (req.method === "POST" && pathname === "/result") {
512
+ if (req.headers.origin) {
513
+ res.writeHead(403);
514
+ res.end("Forbidden");
515
+ return;
516
+ }
407
517
  this.handleResult(req, res);
408
518
  return;
409
519
  }
@@ -418,8 +528,10 @@ var Bridge = class extends EventEmitter {
418
528
  connectedAt: Date.now()
419
529
  };
420
530
  this.httpStudios.set(studioId, info);
531
+ this.lastHeartbeats.set(studioId, Date.now());
421
532
  this.emit("studio-connected", info);
422
533
  log.info(`HTTP-only studio registered: ${studioId}`);
534
+ this.startHeartbeatMonitor();
423
535
  }
424
536
  this.lastHeartbeats.set(studioId, Date.now());
425
537
  if (this.activeStudioId === null) {
@@ -464,9 +576,22 @@ var Bridge = class extends EventEmitter {
464
576
  this.httpPollWaiters.set(studioId, waiters);
465
577
  }
466
578
  handleResult(req, res) {
467
- let body = "";
468
- req.on("data", (chunk) => body += chunk);
579
+ const MAX_BODY_SIZE = 10 * 1024 * 1024;
580
+ const chunks = [];
581
+ let totalSize = 0;
582
+ req.on("data", (chunk) => {
583
+ totalSize += chunk.length;
584
+ if (totalSize > MAX_BODY_SIZE) {
585
+ req.destroy();
586
+ res.writeHead(413);
587
+ res.end("Request body too large");
588
+ return;
589
+ }
590
+ chunks.push(chunk);
591
+ });
469
592
  req.on("end", () => {
593
+ if (totalSize > MAX_BODY_SIZE) return;
594
+ const body = Buffer.concat(chunks).toString("utf-8");
470
595
  try {
471
596
  const msg = JSON.parse(body);
472
597
  this.handlePluginMessage(msg);
@@ -520,7 +645,6 @@ function truncateToTokenBudget(text, budget) {
520
645
  }
521
646
 
522
647
  // src/utils/formatting.ts
523
- var DEFAULT_TOKEN_BUDGET = 4e3;
524
648
  function formatTree(data, depth = 0, indent = "") {
525
649
  let line = `${indent}- **${data.name}** \`${data.className}\``;
526
650
  if (data.properties && Object.keys(data.properties).length > 0) {
@@ -528,7 +652,7 @@ function formatTree(data, depth = 0, indent = "") {
528
652
  line += ` (${props})`;
529
653
  }
530
654
  let result = line + "\n";
531
- if (data.children && depth > 0) {
655
+ if (data.children && depth >= 0) {
532
656
  for (const child of data.children) {
533
657
  result += formatTree(child, depth - 1, indent + " ");
534
658
  }
@@ -552,8 +676,8 @@ ${source}
552
676
  \`\`\``;
553
677
  }
554
678
  function applyTokenBudget(text, maxTokens) {
555
- const budget = maxTokens ?? DEFAULT_TOKEN_BUDGET;
556
- const { text: result } = truncateToTokenBudget(text, budget);
679
+ if (maxTokens === void 0) return text;
680
+ const { text: result } = truncateToTokenBudget(text, maxTokens);
557
681
  return result;
558
682
  }
559
683
  function formatValue(v) {
@@ -883,6 +1007,9 @@ function registerWrite(server, bridge) {
883
1007
  sources: params.sources,
884
1008
  targetParent: params.targetParent
885
1009
  });
1010
+ if (result2.cloned.length === 0) {
1011
+ return { content: [{ type: "text", text: "No instances were cloned." }] };
1012
+ }
886
1013
  const text2 = result2.cloned.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
887
1014
  return { content: [{ type: "text", text: text2 }] };
888
1015
  }
@@ -892,6 +1019,9 @@ function registerWrite(server, bridge) {
892
1019
  const result = await bridge.send("create_instances", {
893
1020
  operations: params.operations
894
1021
  });
1022
+ if (result.created.length === 0) {
1023
+ return { content: [{ type: "text", text: "No instances were created." }] };
1024
+ }
895
1025
  const text = result.created.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
896
1026
  return { content: [{ type: "text", text }] };
897
1027
  }
@@ -956,6 +1086,9 @@ function registerWrite(server, bridge) {
956
1086
  const result = await bridge.send("modify_instances", {
957
1087
  operations: params.operations
958
1088
  });
1089
+ if (result.modified.length === 0) {
1090
+ return { content: [{ type: "text", text: "No instances were modified." }] };
1091
+ }
959
1092
  const text = result.modified.map((r) => `- \`${r.path}\` \u2014 modified: ${r.modified.join(", ")}`).join("\n");
960
1093
  return { content: [{ type: "text", text }] };
961
1094
  }
@@ -979,6 +1112,9 @@ function registerWrite(server, bridge) {
979
1112
  const result = await bridge.send("delete_instances", {
980
1113
  paths: params.paths
981
1114
  });
1115
+ if (result.deleted.length === 0) {
1116
+ return { content: [{ type: "text", text: "No instances were deleted." }] };
1117
+ }
982
1118
  const text = result.deleted.map((p) => `- \`${p}\` \u2014 deleted`).join("\n");
983
1119
  return { content: [{ type: "text", text }] };
984
1120
  }
@@ -1016,6 +1152,12 @@ function registerReadScript(server, bridge) {
1016
1152
  }
1017
1153
  },
1018
1154
  async (params) => {
1155
+ if (params.lineRange && params.lineRange.end < params.lineRange.start) {
1156
+ return {
1157
+ content: [{ type: "text", text: "lineRange.end must be >= lineRange.start." }],
1158
+ isError: true
1159
+ };
1160
+ }
1019
1161
  const result = await bridge.send("read_script", {
1020
1162
  path: params.path,
1021
1163
  lineRange: params.lineRange
@@ -1061,6 +1203,30 @@ function registerWriteTools(server, bridge) {
1061
1203
  }
1062
1204
  },
1063
1205
  async (params) => {
1206
+ if (params.mode === "range" && params.edits && params.edits.some((e) => e.endLine < e.startLine)) {
1207
+ return {
1208
+ content: [{ type: "text", text: "Range edit has endLine < startLine." }],
1209
+ isError: true
1210
+ };
1211
+ }
1212
+ if (params.mode === "full" && params.source === void 0) {
1213
+ return {
1214
+ content: [{ type: "text", text: "full mode requires a `source` parameter." }],
1215
+ isError: true
1216
+ };
1217
+ }
1218
+ if (params.mode === "range" && (!params.edits || params.edits.length === 0)) {
1219
+ return {
1220
+ content: [{ type: "text", text: "range mode requires a non-empty `edits` array." }],
1221
+ isError: true
1222
+ };
1223
+ }
1224
+ if (params.mode === "find_replace" && !params.find) {
1225
+ return {
1226
+ content: [{ type: "text", text: "find_replace mode requires a `find` parameter." }],
1227
+ isError: true
1228
+ };
1229
+ }
1064
1230
  if (params.mode === "multi_replace") {
1065
1231
  if (!params.scripts || params.scripts.length === 0) {
1066
1232
  return { content: [{ type: "text", text: "multi_replace mode requires a non-empty `scripts` array." }] };
@@ -1155,14 +1321,16 @@ ${outputText}
1155
1321
 
1156
1322
  // src/tools/playtest.ts
1157
1323
  import { z as z4 } from "zod";
1324
+ var playtestStartedAt = null;
1158
1325
  function register4(server, bridge) {
1159
1326
  server.registerTool(
1160
1327
  "playtest",
1161
1328
  {
1162
1329
  title: "Playtest Control & Virtual Input",
1163
- description: "Control Roblox Studio playtesting and simulate user input.\n\nActions:\n- `start`: Begin a playtest session.\n- `stop`: End the current playtest.\n- `execute`: Run Lua code in the running game context.\n- `get_output`: Get console/log output from Studio (works in edit mode and during playtest).\n- `inspect`: Evaluate a Luau expression and return the typed result (requires active playtest).\n- `navigate`: Walk the player character to a position using PathfindingService (requires client playtest).\n- `mouse_click`: Simulate a mouse click at screen coordinates.\n- `mouse_move`: Move the virtual mouse to screen coordinates.\n- `key_press`: Press and release a key.\n- `key_down`: Hold a key down.\n- `key_up`: Release a held key.",
1330
+ description: "Control Roblox Studio playtesting and simulate user input.\n\nActions:\n- `start`: Begin a playtest session. Defaults to Play mode (F5, full client with player character). Set mode='run' for Run mode (F8, server-only, no player).\n- `stop`: End the current playtest.\n- `execute`: Run Lua code in the running game context.\n- `get_output`: Get console/log output from Studio (works in edit mode and during playtest).\n- `inspect`: Evaluate a Luau expression and return the typed result (requires active playtest).\n- `navigate`: Walk the player character to a position using PathfindingService (requires client playtest).\n- `mouse_click`: Simulate a mouse click at screen coordinates.\n- `mouse_move`: Move the virtual mouse to screen coordinates.\n- `key_press`: Press and release a key.\n- `key_down`: Hold a key down.\n- `key_up`: Release a held key.",
1164
1331
  inputSchema: z4.object({
1165
1332
  action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up"]).describe("Playtest action"),
1333
+ mode: z4.enum(["play", "run"]).default("play").describe("Playtest mode: 'play' (F5, full client with player) or 'run' (F8, server-only). Default: play"),
1166
1334
  code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
1167
1335
  // get_output params
1168
1336
  messageTypes: z4.array(z4.string()).optional().describe("Filter by message types: 'MessageOutput', 'MessageWarning', 'MessageError', 'MessageInfo' (for 'get_output')"),
@@ -1193,9 +1361,10 @@ function register4(server, bridge) {
1193
1361
  },
1194
1362
  async (params) => {
1195
1363
  if (params.action === "get_output") {
1364
+ const since = params.since ?? playtestStartedAt ?? void 0;
1196
1365
  const result2 = await bridge.send("get_log_output", {
1197
1366
  messageTypes: params.messageTypes,
1198
- since: params.since,
1367
+ since,
1199
1368
  limit: params.limit
1200
1369
  });
1201
1370
  if (result2.logs.length === 0) {
@@ -1245,6 +1414,18 @@ ${result2.message}` : ""}`;
1245
1414
  return { content: [{ type: "text", text: text2 }] };
1246
1415
  }
1247
1416
  if (params.action === "mouse_click" || params.action === "mouse_move" || params.action === "key_press" || params.action === "key_down" || params.action === "key_up") {
1417
+ if ((params.action === "mouse_click" || params.action === "mouse_move") && (params.x === void 0 || params.y === void 0)) {
1418
+ return {
1419
+ content: [{ type: "text", text: `${params.action} requires \`x\` and \`y\` parameters.` }],
1420
+ isError: true
1421
+ };
1422
+ }
1423
+ if ((params.action === "key_press" || params.action === "key_down" || params.action === "key_up") && !params.key) {
1424
+ return {
1425
+ content: [{ type: "text", text: `${params.action} requires a \`key\` parameter.` }],
1426
+ isError: true
1427
+ };
1428
+ }
1248
1429
  const result2 = await bridge.send("virtual_input", {
1249
1430
  action: params.action,
1250
1431
  x: params.x,
@@ -1258,10 +1439,22 @@ ${result2.message}` : ""}`;
1258
1439
  ]
1259
1440
  };
1260
1441
  }
1442
+ if (params.action === "execute" && !params.code) {
1443
+ return {
1444
+ content: [{ type: "text", text: "execute action requires a `code` parameter." }],
1445
+ isError: true
1446
+ };
1447
+ }
1261
1448
  const result = await bridge.send("playtest", {
1262
1449
  action: params.action,
1263
- code: params.code
1450
+ code: params.code,
1451
+ mode: params.mode
1264
1452
  });
1453
+ if (params.action === "start" && result.status === "started") {
1454
+ playtestStartedAt = Date.now() / 1e3;
1455
+ } else if (params.action === "stop" && result.status === "stopped") {
1456
+ playtestStartedAt = null;
1457
+ }
1265
1458
  let text;
1266
1459
  if (params.action === "execute") {
1267
1460
  const lines = [];
@@ -1281,7 +1474,10 @@ ${outputText}
1281
1474
  text = lines.length > 0 ? lines.join("\n\n") : `Execution completed (${result.status})`;
1282
1475
  text = applyTokenBudget(text, void 0);
1283
1476
  } else {
1284
- text = `Playtest ${params.action}: ${result.status}`;
1477
+ const modeInfo = result.mode ? ` (${result.mode} mode)` : "";
1478
+ const extra = result.message ? `
1479
+ ${result.message}` : "";
1480
+ text = `Playtest ${params.action}${modeInfo}: ${result.status}${extra}`;
1285
1481
  }
1286
1482
  return { content: [{ type: "text", text }] };
1287
1483
  }
@@ -1343,6 +1539,14 @@ function register5(server, bridge) {
1343
1539
  const text2 = `Updated ${result2.modified?.length ?? 0} setting(s): ${result2.modified?.join(", ") ?? "none"}`;
1344
1540
  return { content: [{ type: "text", text: text2 }] };
1345
1541
  }
1542
+ if (params.action === "terrain_fill") {
1543
+ if (!params.region) {
1544
+ return { content: [{ type: "text", text: "terrain_fill requires a `region` parameter." }], isError: true };
1545
+ }
1546
+ if (!params.material) {
1547
+ return { content: [{ type: "text", text: "terrain_fill requires a `material` parameter." }], isError: true };
1548
+ }
1549
+ }
1346
1550
  const terrainAction = params.action.replace("terrain_", "");
1347
1551
  const result = await bridge.send("terrain", {
1348
1552
  action: terrainAction,
@@ -1666,6 +1870,10 @@ function formatSearchResults(results) {
1666
1870
 
1667
1871
  // src/tools/utility.ts
1668
1872
  function register7(server, bridge) {
1873
+ const transactionState = /* @__PURE__ */ new Map();
1874
+ bridge.on("studio-disconnected", (info) => {
1875
+ transactionState.delete(info.studioId);
1876
+ });
1669
1877
  server.registerTool(
1670
1878
  "undo_redo",
1671
1879
  {
@@ -1754,7 +1962,7 @@ function register7(server, bridge) {
1754
1962
  "transaction",
1755
1963
  {
1756
1964
  title: "Transaction Control",
1757
- description: "Group multiple mutating tool calls into a single Ctrl+Z undo point.\n\nActions:\n- `begin`: Start a transaction. All subsequent writes share one undo recording.\n- `commit`: Finish the transaction and commit all changes as one undo point.\n- `rollback`: Cancel the transaction and undo all changes made since begin.\n\nTransactions auto-rollback after 60 seconds if not committed.",
1965
+ description: "Group multiple mutating tool calls into a single Ctrl+Z undo point.\n\nUsage: ALWAYS call `begin` first, then make your changes, then call `commit` or `rollback`. Calling `commit` or `rollback` without a prior `begin` will error.\n\nActions:\n- `begin`: Start a transaction. All subsequent writes share one undo recording.\n- `commit`: Finish an open transaction and commit all changes as one undo point. Requires a prior `begin`.\n- `rollback`: Cancel an open transaction and undo all changes made since begin. Requires a prior `begin`.\n\nTransactions auto-rollback after 60 seconds if not committed.",
1758
1966
  inputSchema: z7.object({
1759
1967
  action: z7.enum(["begin", "commit", "rollback"]).describe("Transaction action"),
1760
1968
  name: z7.string().optional().describe("Transaction name for the undo history (for 'begin' action)")
@@ -1767,31 +1975,53 @@ function register7(server, bridge) {
1767
1975
  }
1768
1976
  },
1769
1977
  async (params) => {
1978
+ const studioId = bridge.getActiveStudioId() ?? "_default";
1770
1979
  if (params.action === "begin") {
1771
- const result2 = await bridge.send("begin_transaction", {
1772
- name: params.name
1773
- });
1980
+ try {
1981
+ const result = await bridge.send("begin_transaction", {
1982
+ name: params.name
1983
+ });
1984
+ transactionState.set(studioId, true);
1985
+ return {
1986
+ content: [
1987
+ { type: "text", text: `Transaction started: **${result.transactionId}**
1988
+ All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
1989
+ ]
1990
+ };
1991
+ } catch (err) {
1992
+ transactionState.set(studioId, false);
1993
+ throw err;
1994
+ }
1995
+ }
1996
+ if (!transactionState.get(studioId)) {
1774
1997
  return {
1775
1998
  content: [
1776
- { type: "text", text: `Transaction started: **${result2.transactionId}**
1777
- All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
1999
+ { type: "text", text: `No active transaction. Call \`begin\` first before calling \`${params.action}\`.` }
1778
2000
  ]
1779
2001
  };
1780
2002
  }
1781
2003
  if (params.action === "commit") {
1782
- const result2 = await bridge.send("commit_transaction", {});
2004
+ try {
2005
+ const result = await bridge.send("commit_transaction", {});
2006
+ return {
2007
+ content: [
2008
+ { type: "text", text: `Transaction ${result.status}. All changes are now a single undo point.` }
2009
+ ]
2010
+ };
2011
+ } finally {
2012
+ transactionState.set(studioId, false);
2013
+ }
2014
+ }
2015
+ try {
2016
+ const result = await bridge.send("rollback_transaction", {});
1783
2017
  return {
1784
2018
  content: [
1785
- { type: "text", text: `Transaction ${result2.status}. All changes are now a single undo point.` }
2019
+ { type: "text", text: `Transaction ${result.status}. All changes since begin have been reverted.` }
1786
2020
  ]
1787
2021
  };
2022
+ } finally {
2023
+ transactionState.set(studioId, false);
1788
2024
  }
1789
- const result = await bridge.send("rollback_transaction", {});
1790
- return {
1791
- content: [
1792
- { type: "text", text: `Transaction ${result.status}. All changes since begin have been reverted.` }
1793
- ]
1794
- };
1795
2025
  }
1796
2026
  );
1797
2027
  }
@@ -1853,7 +2083,15 @@ ${lines.join("\n")}`;
1853
2083
  }
1854
2084
  },
1855
2085
  async (params) => {
1856
- bridge.setActiveStudio(params.studioId);
2086
+ try {
2087
+ bridge.setActiveStudio(params.studioId);
2088
+ } catch (err) {
2089
+ const message = err instanceof Error ? err.message : String(err);
2090
+ return {
2091
+ content: [{ type: "text", text: message }],
2092
+ isError: true
2093
+ };
2094
+ }
1857
2095
  const studios = bridge.getStudios();
1858
2096
  const studio = studios.find((s) => s.studioId === params.studioId);
1859
2097
  const name = studio?.placeName ?? "unknown";
@@ -1883,7 +2121,13 @@ function ensureDir() {
1883
2121
  mkdirSync(BUILDS_DIR, { recursive: true });
1884
2122
  }
1885
2123
  }
2124
+ function validateBuildName(name) {
2125
+ if (!/^[\w-]+$/.test(name)) {
2126
+ throw new Error(`Invalid build name "${name}". Names must match /^[\\w-]+$/.`);
2127
+ }
2128
+ }
1886
2129
  function saveBuild(name, root, description) {
2130
+ validateBuildName(name);
1887
2131
  ensureDir();
1888
2132
  const rootObj = root;
1889
2133
  const data = {
@@ -1903,6 +2147,7 @@ function saveBuild(name, root, description) {
1903
2147
  };
1904
2148
  }
1905
2149
  function loadBuild(name) {
2150
+ validateBuildName(name);
1906
2151
  const filePath = join(BUILDS_DIR, `${name}.json`);
1907
2152
  if (!existsSync(filePath)) {
1908
2153
  throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
@@ -2027,7 +2272,7 @@ function register9(server, bridge) {
2027
2272
  }
2028
2273
 
2029
2274
  // src/tools/index.ts
2030
- function registerAllTools(server, bridge, options = {}) {
2275
+ async function registerAllTools(server, bridge, options = {}) {
2031
2276
  const mode = options.mode ?? "full";
2032
2277
  register8(server, bridge);
2033
2278
  register(server, bridge);
@@ -2044,10 +2289,20 @@ function registerAllTools(server, bridge, options = {}) {
2044
2289
  registerReadOnly2(server, bridge);
2045
2290
  }
2046
2291
  if (options.withCloud) {
2047
- import("./cloud-HQIBAMRL.js").then((mod) => mod.register(server));
2292
+ try {
2293
+ const mod = await import("./cloud-4AYJVND6.js");
2294
+ mod.register(server);
2295
+ } catch (err) {
2296
+ log.error("Failed to load cloud module:", err);
2297
+ }
2048
2298
  }
2049
2299
  if (options.withRojo) {
2050
- import("./rojo-TXV4K4PB.js").then((mod) => mod.register(server));
2300
+ try {
2301
+ const mod = await import("./rojo-2CMKPTPS.js");
2302
+ mod.register(server);
2303
+ } catch (err) {
2304
+ log.error("Failed to load rojo module:", err);
2305
+ }
2051
2306
  }
2052
2307
  }
2053
2308
 
@@ -2088,7 +2343,7 @@ async function startServer(port = 3200, options = {}) {
2088
2343
  withCloud: options.withCloud,
2089
2344
  withRojo: options.withRojo
2090
2345
  };
2091
- registerAllTools(server, bridge, toolOptions);
2346
+ await registerAllTools(server, bridge, toolOptions);
2092
2347
  bridge.on("studio-connected", (info) => {
2093
2348
  log.info(
2094
2349
  `Roblox Studio connected: ${info.studioId}` + (info.placeName ? ` (${info.placeName})` : "")
@@ -2104,12 +2359,16 @@ async function startServer(port = 3200, options = {}) {
2104
2359
  const transport = new StdioServerTransport();
2105
2360
  await server.connect(transport);
2106
2361
  log.info("MCP server connected via stdio");
2362
+ let shuttingDown = false;
2107
2363
  const shutdown = async () => {
2364
+ if (shuttingDown) return;
2365
+ shuttingDown = true;
2108
2366
  log.info("Shutting down...");
2109
2367
  await bridge.stop();
2110
2368
  await server.close();
2111
2369
  process.exit(0);
2112
2370
  };
2371
+ bridge.on("shutdown", shutdown);
2113
2372
  process.on("SIGINT", shutdown);
2114
2373
  process.on("SIGTERM", shutdown);
2115
2374
  }
@@ -2117,4 +2376,4 @@ async function startServer(port = 3200, options = {}) {
2117
2376
  export {
2118
2377
  startServer
2119
2378
  };
2120
- //# sourceMappingURL=chunk-JDWBW44K.js.map
2379
+ //# sourceMappingURL=chunk-HI6KFLGA.js.map