conduit-mcp 2.0.0 → 2.0.1

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";
@@ -40,9 +40,10 @@ var log = {
40
40
  // src/bridge.ts
41
41
  var REQUEST_TIMEOUT_MS = 3e4;
42
42
  var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
43
- var HEARTBEAT_TIMEOUT_MS = 15e3;
43
+ var HEARTBEAT_TIMEOUT_MS = 35e3;
44
44
  var LONG_POLL_TIMEOUT_MS = 25e3;
45
45
  var MAX_PORT_RETRIES = 10;
46
+ var REGISTRATION_TIMEOUT_MS = 1e4;
46
47
  var Bridge = class extends EventEmitter {
47
48
  constructor(port = 3200) {
48
49
  super();
@@ -65,8 +66,10 @@ var Bridge = class extends EventEmitter {
65
66
  httpPendingCommands = /* @__PURE__ */ new Map();
66
67
  httpPollWaiters = /* @__PURE__ */ new Map();
67
68
  get isConnected() {
68
- const studio = this.getActiveStudio();
69
- return studio !== void 0 && studio.ws.readyState === WebSocket.OPEN;
69
+ for (const [, studio] of this.studios) {
70
+ if (studio.ws.readyState === WebSocket.OPEN) return true;
71
+ }
72
+ return this.httpStudios.size > 0;
70
73
  }
71
74
  get listeningPort() {
72
75
  return this.actualPort;
@@ -92,7 +95,35 @@ var Bridge = class extends EventEmitter {
92
95
  log.info(`Active studio set to: ${studioId}`);
93
96
  }
94
97
  // ── Server lifecycle ───────────────────────────────────────────
98
+ /**
99
+ * Try to shut down a stale Conduit instance on the target port.
100
+ * Returns true if the port was freed (or was already free).
101
+ */
102
+ async evictStaleInstance(port) {
103
+ return new Promise((resolve) => {
104
+ const req = http.request(
105
+ {
106
+ hostname: "127.0.0.1",
107
+ port,
108
+ path: "/shutdown",
109
+ method: "POST",
110
+ timeout: 2e3
111
+ },
112
+ (res) => {
113
+ res.resume();
114
+ setTimeout(() => resolve(true), 500);
115
+ }
116
+ );
117
+ req.on("error", () => resolve(false));
118
+ req.on("timeout", () => {
119
+ req.destroy();
120
+ resolve(false);
121
+ });
122
+ req.end();
123
+ });
124
+ }
95
125
  async start() {
126
+ await this.evictStaleInstance(this.port);
96
127
  return new Promise((resolve, reject) => {
97
128
  let attempts = 0;
98
129
  const tryPort = (port) => {
@@ -102,6 +133,9 @@ var Bridge = class extends EventEmitter {
102
133
  server.once("error", (err) => {
103
134
  if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
104
135
  attempts++;
136
+ log.warn(
137
+ `Port ${port} still in use after eviction \u2014 trying ${port + 1}`
138
+ );
105
139
  tryPort(port + 1);
106
140
  } else {
107
141
  reject(err);
@@ -209,30 +243,33 @@ var Bridge = class extends EventEmitter {
209
243
  if (active && active.ws.readyState === WebSocket.OPEN) {
210
244
  return active;
211
245
  }
212
- if (this.studios.size === 1) {
213
- const [studioId, studio] = this.studios.entries().next().value;
246
+ for (const [studioId, studio] of this.studios) {
214
247
  if (studio.ws.readyState === WebSocket.OPEN) {
215
248
  this.activeStudioId = studioId;
249
+ log.info(`Auto-selected active studio: ${studioId}`);
216
250
  return studio;
217
251
  }
218
252
  }
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
- );
253
+ return void 0;
227
254
  }
228
255
  // ── WebSocket handling ─────────────────────────────────────────
229
256
  handleWsConnection(ws) {
230
257
  log.info("New WebSocket connection \u2014 waiting for registration");
231
258
  this.pendingWs.add(ws);
259
+ const registrationTimer = setTimeout(() => {
260
+ if (this.pendingWs.has(ws)) {
261
+ log.warn(
262
+ "WebSocket failed to register within timeout \u2014 closing"
263
+ );
264
+ this.pendingWs.delete(ws);
265
+ ws.close(1008, "Registration timeout");
266
+ }
267
+ }, REGISTRATION_TIMEOUT_MS);
232
268
  const onFirstMessage = (data) => {
233
269
  try {
234
270
  const msg = JSON.parse(data.toString());
235
271
  if (isStudioRegistration(msg)) {
272
+ clearTimeout(registrationTimer);
236
273
  ws.removeListener("message", onFirstMessage);
237
274
  this.pendingWs.delete(ws);
238
275
  this.registerStudio(ws, {
@@ -243,9 +280,10 @@ var Bridge = class extends EventEmitter {
243
280
  });
244
281
  return;
245
282
  }
283
+ clearTimeout(registrationTimer);
246
284
  ws.removeListener("message", onFirstMessage);
247
285
  this.pendingWs.delete(ws);
248
- const syntheticId = "studio-1";
286
+ const syntheticId = `studio-legacy-${Date.now()}`;
249
287
  log.info(
250
288
  "Legacy plugin detected (no registration), assigning ID: " + syntheticId
251
289
  );
@@ -259,12 +297,17 @@ var Bridge = class extends EventEmitter {
259
297
  }
260
298
  };
261
299
  ws.on("message", onFirstMessage);
262
- ws.on("close", () => {
300
+ const onEarlyClose = () => {
301
+ clearTimeout(registrationTimer);
263
302
  this.pendingWs.delete(ws);
264
- });
265
- ws.on("error", (err) => {
303
+ };
304
+ const onEarlyError = (err) => {
266
305
  log.warn("WebSocket error:", err.message);
267
- });
306
+ };
307
+ ws.on("close", onEarlyClose);
308
+ ws.on("error", onEarlyError);
309
+ ws.__earlyClose = onEarlyClose;
310
+ ws.__earlyError = onEarlyError;
268
311
  }
269
312
  registerStudio(ws, info) {
270
313
  const { studioId } = info;
@@ -283,6 +326,14 @@ var Bridge = class extends EventEmitter {
283
326
  `Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
284
327
  );
285
328
  this.startHeartbeatMonitor();
329
+ if (ws.__earlyClose) {
330
+ ws.removeListener("close", ws.__earlyClose);
331
+ delete ws.__earlyClose;
332
+ }
333
+ if (ws.__earlyError) {
334
+ ws.removeListener("error", ws.__earlyError);
335
+ delete ws.__earlyError;
336
+ }
286
337
  ws.on("message", (data) => {
287
338
  try {
288
339
  const msg = JSON.parse(data.toString());
@@ -303,7 +354,7 @@ var Bridge = class extends EventEmitter {
303
354
  this.emit("studio-disconnected", info);
304
355
  log.info(`Studio disconnected: ${studioId}`);
305
356
  if (this.activeStudioId === studioId) {
306
- if (this.studios.size === 1) {
357
+ if (this.studios.size > 0) {
307
358
  this.activeStudioId = this.studios.keys().next().value;
308
359
  log.info(
309
360
  `Auto-switched active studio to: ${this.activeStudioId}`
@@ -319,21 +370,21 @@ var Bridge = class extends EventEmitter {
319
370
  });
320
371
  }
321
372
  handlePluginMessage(msg) {
322
- if (isBridgeResponse(msg)) {
373
+ if (isBridgeError(msg)) {
323
374
  const pending = this.pendingRequests.get(msg.id);
324
375
  if (pending) {
325
376
  clearTimeout(pending.timer);
326
377
  this.pendingRequests.delete(msg.id);
327
- pending.resolve(msg.result);
378
+ pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
328
379
  }
329
380
  return;
330
381
  }
331
- if (isBridgeError(msg)) {
382
+ if (isBridgeResponse(msg)) {
332
383
  const pending = this.pendingRequests.get(msg.id);
333
384
  if (pending) {
334
385
  clearTimeout(pending.timer);
335
386
  this.pendingRequests.delete(msg.id);
336
- pending.reject(new Error(`${msg.error.code}: ${msg.error.message}`));
387
+ pending.resolve(msg.result);
337
388
  }
338
389
  return;
339
390
  }
@@ -350,7 +401,8 @@ var Bridge = class extends EventEmitter {
350
401
  log.warn(
351
402
  `Heartbeat timeout for studio "${studioId}" \u2014 disconnecting`
352
403
  );
353
- studio.ws.terminate();
404
+ studio.ws.close(1001, "Heartbeat timeout");
405
+ this.lastHeartbeats.delete(studioId);
354
406
  } else if (this.httpStudios.has(studioId)) {
355
407
  log.warn(
356
408
  `Heartbeat timeout for HTTP studio "${studioId}" \u2014 evicting`
@@ -376,7 +428,10 @@ var Bridge = class extends EventEmitter {
376
428
  }
377
429
  // ── HTTP fallback handling ─────────────────────────────────────
378
430
  handleHttp(req, res) {
379
- res.setHeader("Access-Control-Allow-Origin", "*");
431
+ const origin = req.headers.origin;
432
+ if (origin && (origin.startsWith("http://localhost") || origin.startsWith("http://127.0.0.1"))) {
433
+ res.setHeader("Access-Control-Allow-Origin", origin);
434
+ }
380
435
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
381
436
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
382
437
  if (req.method === "OPTIONS") {
@@ -386,6 +441,20 @@ var Bridge = class extends EventEmitter {
386
441
  }
387
442
  const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`);
388
443
  const pathname = parsedUrl.pathname;
444
+ if (req.method === "POST" && pathname === "/shutdown") {
445
+ if (req.headers.origin) {
446
+ res.writeHead(403);
447
+ res.end("Forbidden");
448
+ return;
449
+ }
450
+ log.info("Received shutdown request from new Conduit instance");
451
+ res.writeHead(200);
452
+ res.end("ok");
453
+ setImmediate(() => {
454
+ this.emit("shutdown");
455
+ });
456
+ return;
457
+ }
389
458
  if (req.method === "GET" && pathname === "/health") {
390
459
  res.writeHead(200, { "Content-Type": "application/json" });
391
460
  res.end(
@@ -393,7 +462,8 @@ var Bridge = class extends EventEmitter {
393
462
  status: "ok",
394
463
  connected: this.isConnected,
395
464
  port: this.actualPort,
396
- studios: this.getStudios()
465
+ studios: this.getStudios(),
466
+ activeStudioId: this.activeStudioId
397
467
  })
398
468
  );
399
469
  return;
@@ -404,6 +474,11 @@ var Bridge = class extends EventEmitter {
404
474
  return;
405
475
  }
406
476
  if (req.method === "POST" && pathname === "/result") {
477
+ if (req.headers.origin) {
478
+ res.writeHead(403);
479
+ res.end("Forbidden");
480
+ return;
481
+ }
407
482
  this.handleResult(req, res);
408
483
  return;
409
484
  }
@@ -418,8 +493,10 @@ var Bridge = class extends EventEmitter {
418
493
  connectedAt: Date.now()
419
494
  };
420
495
  this.httpStudios.set(studioId, info);
496
+ this.lastHeartbeats.set(studioId, Date.now());
421
497
  this.emit("studio-connected", info);
422
498
  log.info(`HTTP-only studio registered: ${studioId}`);
499
+ this.startHeartbeatMonitor();
423
500
  }
424
501
  this.lastHeartbeats.set(studioId, Date.now());
425
502
  if (this.activeStudioId === null) {
@@ -464,9 +541,22 @@ var Bridge = class extends EventEmitter {
464
541
  this.httpPollWaiters.set(studioId, waiters);
465
542
  }
466
543
  handleResult(req, res) {
467
- let body = "";
468
- req.on("data", (chunk) => body += chunk);
544
+ const MAX_BODY_SIZE = 10 * 1024 * 1024;
545
+ const chunks = [];
546
+ let totalSize = 0;
547
+ req.on("data", (chunk) => {
548
+ totalSize += chunk.length;
549
+ if (totalSize > MAX_BODY_SIZE) {
550
+ req.destroy();
551
+ res.writeHead(413);
552
+ res.end("Request body too large");
553
+ return;
554
+ }
555
+ chunks.push(chunk);
556
+ });
469
557
  req.on("end", () => {
558
+ if (totalSize > MAX_BODY_SIZE) return;
559
+ const body = Buffer.concat(chunks).toString("utf-8");
470
560
  try {
471
561
  const msg = JSON.parse(body);
472
562
  this.handlePluginMessage(msg);
@@ -520,7 +610,6 @@ function truncateToTokenBudget(text, budget) {
520
610
  }
521
611
 
522
612
  // src/utils/formatting.ts
523
- var DEFAULT_TOKEN_BUDGET = 4e3;
524
613
  function formatTree(data, depth = 0, indent = "") {
525
614
  let line = `${indent}- **${data.name}** \`${data.className}\``;
526
615
  if (data.properties && Object.keys(data.properties).length > 0) {
@@ -528,7 +617,7 @@ function formatTree(data, depth = 0, indent = "") {
528
617
  line += ` (${props})`;
529
618
  }
530
619
  let result = line + "\n";
531
- if (data.children && depth > 0) {
620
+ if (data.children && depth >= 0) {
532
621
  for (const child of data.children) {
533
622
  result += formatTree(child, depth - 1, indent + " ");
534
623
  }
@@ -552,8 +641,8 @@ ${source}
552
641
  \`\`\``;
553
642
  }
554
643
  function applyTokenBudget(text, maxTokens) {
555
- const budget = maxTokens ?? DEFAULT_TOKEN_BUDGET;
556
- const { text: result } = truncateToTokenBudget(text, budget);
644
+ if (maxTokens === void 0) return text;
645
+ const { text: result } = truncateToTokenBudget(text, maxTokens);
557
646
  return result;
558
647
  }
559
648
  function formatValue(v) {
@@ -883,6 +972,9 @@ function registerWrite(server, bridge) {
883
972
  sources: params.sources,
884
973
  targetParent: params.targetParent
885
974
  });
975
+ if (result2.cloned.length === 0) {
976
+ return { content: [{ type: "text", text: "No instances were cloned." }] };
977
+ }
886
978
  const text2 = result2.cloned.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
887
979
  return { content: [{ type: "text", text: text2 }] };
888
980
  }
@@ -892,6 +984,9 @@ function registerWrite(server, bridge) {
892
984
  const result = await bridge.send("create_instances", {
893
985
  operations: params.operations
894
986
  });
987
+ if (result.created.length === 0) {
988
+ return { content: [{ type: "text", text: "No instances were created." }] };
989
+ }
895
990
  const text = result.created.map((r) => `- \`${r.path}\` (${r.className})`).join("\n");
896
991
  return { content: [{ type: "text", text }] };
897
992
  }
@@ -956,6 +1051,9 @@ function registerWrite(server, bridge) {
956
1051
  const result = await bridge.send("modify_instances", {
957
1052
  operations: params.operations
958
1053
  });
1054
+ if (result.modified.length === 0) {
1055
+ return { content: [{ type: "text", text: "No instances were modified." }] };
1056
+ }
959
1057
  const text = result.modified.map((r) => `- \`${r.path}\` \u2014 modified: ${r.modified.join(", ")}`).join("\n");
960
1058
  return { content: [{ type: "text", text }] };
961
1059
  }
@@ -979,6 +1077,9 @@ function registerWrite(server, bridge) {
979
1077
  const result = await bridge.send("delete_instances", {
980
1078
  paths: params.paths
981
1079
  });
1080
+ if (result.deleted.length === 0) {
1081
+ return { content: [{ type: "text", text: "No instances were deleted." }] };
1082
+ }
982
1083
  const text = result.deleted.map((p) => `- \`${p}\` \u2014 deleted`).join("\n");
983
1084
  return { content: [{ type: "text", text }] };
984
1085
  }
@@ -1016,6 +1117,12 @@ function registerReadScript(server, bridge) {
1016
1117
  }
1017
1118
  },
1018
1119
  async (params) => {
1120
+ if (params.lineRange && params.lineRange.end < params.lineRange.start) {
1121
+ return {
1122
+ content: [{ type: "text", text: "lineRange.end must be >= lineRange.start." }],
1123
+ isError: true
1124
+ };
1125
+ }
1019
1126
  const result = await bridge.send("read_script", {
1020
1127
  path: params.path,
1021
1128
  lineRange: params.lineRange
@@ -1061,6 +1168,30 @@ function registerWriteTools(server, bridge) {
1061
1168
  }
1062
1169
  },
1063
1170
  async (params) => {
1171
+ if (params.mode === "range" && params.edits && params.edits.some((e) => e.endLine < e.startLine)) {
1172
+ return {
1173
+ content: [{ type: "text", text: "Range edit has endLine < startLine." }],
1174
+ isError: true
1175
+ };
1176
+ }
1177
+ if (params.mode === "full" && params.source === void 0) {
1178
+ return {
1179
+ content: [{ type: "text", text: "full mode requires a `source` parameter." }],
1180
+ isError: true
1181
+ };
1182
+ }
1183
+ if (params.mode === "range" && (!params.edits || params.edits.length === 0)) {
1184
+ return {
1185
+ content: [{ type: "text", text: "range mode requires a non-empty `edits` array." }],
1186
+ isError: true
1187
+ };
1188
+ }
1189
+ if (params.mode === "find_replace" && !params.find) {
1190
+ return {
1191
+ content: [{ type: "text", text: "find_replace mode requires a `find` parameter." }],
1192
+ isError: true
1193
+ };
1194
+ }
1064
1195
  if (params.mode === "multi_replace") {
1065
1196
  if (!params.scripts || params.scripts.length === 0) {
1066
1197
  return { content: [{ type: "text", text: "multi_replace mode requires a non-empty `scripts` array." }] };
@@ -1160,9 +1291,10 @@ function register4(server, bridge) {
1160
1291
  "playtest",
1161
1292
  {
1162
1293
  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.",
1294
+ 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
1295
  inputSchema: z4.object({
1165
1296
  action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up"]).describe("Playtest action"),
1297
+ mode: z4.enum(["play", "run"]).default("play").describe("Playtest mode: 'play' (F5, full client with player) or 'run' (F8, server-only). Default: play"),
1166
1298
  code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
1167
1299
  // get_output params
1168
1300
  messageTypes: z4.array(z4.string()).optional().describe("Filter by message types: 'MessageOutput', 'MessageWarning', 'MessageError', 'MessageInfo' (for 'get_output')"),
@@ -1245,6 +1377,18 @@ ${result2.message}` : ""}`;
1245
1377
  return { content: [{ type: "text", text: text2 }] };
1246
1378
  }
1247
1379
  if (params.action === "mouse_click" || params.action === "mouse_move" || params.action === "key_press" || params.action === "key_down" || params.action === "key_up") {
1380
+ if ((params.action === "mouse_click" || params.action === "mouse_move") && (params.x === void 0 || params.y === void 0)) {
1381
+ return {
1382
+ content: [{ type: "text", text: `${params.action} requires \`x\` and \`y\` parameters.` }],
1383
+ isError: true
1384
+ };
1385
+ }
1386
+ if ((params.action === "key_press" || params.action === "key_down" || params.action === "key_up") && !params.key) {
1387
+ return {
1388
+ content: [{ type: "text", text: `${params.action} requires a \`key\` parameter.` }],
1389
+ isError: true
1390
+ };
1391
+ }
1248
1392
  const result2 = await bridge.send("virtual_input", {
1249
1393
  action: params.action,
1250
1394
  x: params.x,
@@ -1258,9 +1402,16 @@ ${result2.message}` : ""}`;
1258
1402
  ]
1259
1403
  };
1260
1404
  }
1405
+ if (params.action === "execute" && !params.code) {
1406
+ return {
1407
+ content: [{ type: "text", text: "execute action requires a `code` parameter." }],
1408
+ isError: true
1409
+ };
1410
+ }
1261
1411
  const result = await bridge.send("playtest", {
1262
1412
  action: params.action,
1263
- code: params.code
1413
+ code: params.code,
1414
+ mode: params.mode
1264
1415
  });
1265
1416
  let text;
1266
1417
  if (params.action === "execute") {
@@ -1281,7 +1432,10 @@ ${outputText}
1281
1432
  text = lines.length > 0 ? lines.join("\n\n") : `Execution completed (${result.status})`;
1282
1433
  text = applyTokenBudget(text, void 0);
1283
1434
  } else {
1284
- text = `Playtest ${params.action}: ${result.status}`;
1435
+ const modeInfo = result.mode ? ` (${result.mode} mode)` : "";
1436
+ const extra = result.message ? `
1437
+ ${result.message}` : "";
1438
+ text = `Playtest ${params.action}${modeInfo}: ${result.status}${extra}`;
1285
1439
  }
1286
1440
  return { content: [{ type: "text", text }] };
1287
1441
  }
@@ -1343,6 +1497,14 @@ function register5(server, bridge) {
1343
1497
  const text2 = `Updated ${result2.modified?.length ?? 0} setting(s): ${result2.modified?.join(", ") ?? "none"}`;
1344
1498
  return { content: [{ type: "text", text: text2 }] };
1345
1499
  }
1500
+ if (params.action === "terrain_fill") {
1501
+ if (!params.region) {
1502
+ return { content: [{ type: "text", text: "terrain_fill requires a `region` parameter." }], isError: true };
1503
+ }
1504
+ if (!params.material) {
1505
+ return { content: [{ type: "text", text: "terrain_fill requires a `material` parameter." }], isError: true };
1506
+ }
1507
+ }
1346
1508
  const terrainAction = params.action.replace("terrain_", "");
1347
1509
  const result = await bridge.send("terrain", {
1348
1510
  action: terrainAction,
@@ -1666,6 +1828,10 @@ function formatSearchResults(results) {
1666
1828
 
1667
1829
  // src/tools/utility.ts
1668
1830
  function register7(server, bridge) {
1831
+ const transactionState = /* @__PURE__ */ new Map();
1832
+ bridge.on("studio-disconnected", (info) => {
1833
+ transactionState.delete(info.studioId);
1834
+ });
1669
1835
  server.registerTool(
1670
1836
  "undo_redo",
1671
1837
  {
@@ -1754,7 +1920,7 @@ function register7(server, bridge) {
1754
1920
  "transaction",
1755
1921
  {
1756
1922
  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.",
1923
+ 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
1924
  inputSchema: z7.object({
1759
1925
  action: z7.enum(["begin", "commit", "rollback"]).describe("Transaction action"),
1760
1926
  name: z7.string().optional().describe("Transaction name for the undo history (for 'begin' action)")
@@ -1767,31 +1933,53 @@ function register7(server, bridge) {
1767
1933
  }
1768
1934
  },
1769
1935
  async (params) => {
1936
+ const studioId = bridge.getActiveStudioId() ?? "_default";
1770
1937
  if (params.action === "begin") {
1771
- const result2 = await bridge.send("begin_transaction", {
1772
- name: params.name
1773
- });
1938
+ try {
1939
+ const result = await bridge.send("begin_transaction", {
1940
+ name: params.name
1941
+ });
1942
+ transactionState.set(studioId, true);
1943
+ return {
1944
+ content: [
1945
+ { type: "text", text: `Transaction started: **${result.transactionId}**
1946
+ All subsequent writes will be grouped into one undo point. Call commit or rollback to finish.` }
1947
+ ]
1948
+ };
1949
+ } catch (err) {
1950
+ transactionState.set(studioId, false);
1951
+ throw err;
1952
+ }
1953
+ }
1954
+ if (!transactionState.get(studioId)) {
1774
1955
  return {
1775
1956
  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.` }
1957
+ { type: "text", text: `No active transaction. Call \`begin\` first before calling \`${params.action}\`.` }
1778
1958
  ]
1779
1959
  };
1780
1960
  }
1781
1961
  if (params.action === "commit") {
1782
- const result2 = await bridge.send("commit_transaction", {});
1962
+ try {
1963
+ const result = await bridge.send("commit_transaction", {});
1964
+ return {
1965
+ content: [
1966
+ { type: "text", text: `Transaction ${result.status}. All changes are now a single undo point.` }
1967
+ ]
1968
+ };
1969
+ } finally {
1970
+ transactionState.set(studioId, false);
1971
+ }
1972
+ }
1973
+ try {
1974
+ const result = await bridge.send("rollback_transaction", {});
1783
1975
  return {
1784
1976
  content: [
1785
- { type: "text", text: `Transaction ${result2.status}. All changes are now a single undo point.` }
1977
+ { type: "text", text: `Transaction ${result.status}. All changes since begin have been reverted.` }
1786
1978
  ]
1787
1979
  };
1980
+ } finally {
1981
+ transactionState.set(studioId, false);
1788
1982
  }
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
1983
  }
1796
1984
  );
1797
1985
  }
@@ -1853,7 +2041,15 @@ ${lines.join("\n")}`;
1853
2041
  }
1854
2042
  },
1855
2043
  async (params) => {
1856
- bridge.setActiveStudio(params.studioId);
2044
+ try {
2045
+ bridge.setActiveStudio(params.studioId);
2046
+ } catch (err) {
2047
+ const message = err instanceof Error ? err.message : String(err);
2048
+ return {
2049
+ content: [{ type: "text", text: message }],
2050
+ isError: true
2051
+ };
2052
+ }
1857
2053
  const studios = bridge.getStudios();
1858
2054
  const studio = studios.find((s) => s.studioId === params.studioId);
1859
2055
  const name = studio?.placeName ?? "unknown";
@@ -1883,7 +2079,13 @@ function ensureDir() {
1883
2079
  mkdirSync(BUILDS_DIR, { recursive: true });
1884
2080
  }
1885
2081
  }
2082
+ function validateBuildName(name) {
2083
+ if (!/^[\w-]+$/.test(name)) {
2084
+ throw new Error(`Invalid build name "${name}". Names must match /^[\\w-]+$/.`);
2085
+ }
2086
+ }
1886
2087
  function saveBuild(name, root, description) {
2088
+ validateBuildName(name);
1887
2089
  ensureDir();
1888
2090
  const rootObj = root;
1889
2091
  const data = {
@@ -1903,6 +2105,7 @@ function saveBuild(name, root, description) {
1903
2105
  };
1904
2106
  }
1905
2107
  function loadBuild(name) {
2108
+ validateBuildName(name);
1906
2109
  const filePath = join(BUILDS_DIR, `${name}.json`);
1907
2110
  if (!existsSync(filePath)) {
1908
2111
  throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
@@ -2027,7 +2230,7 @@ function register9(server, bridge) {
2027
2230
  }
2028
2231
 
2029
2232
  // src/tools/index.ts
2030
- function registerAllTools(server, bridge, options = {}) {
2233
+ async function registerAllTools(server, bridge, options = {}) {
2031
2234
  const mode = options.mode ?? "full";
2032
2235
  register8(server, bridge);
2033
2236
  register(server, bridge);
@@ -2044,10 +2247,20 @@ function registerAllTools(server, bridge, options = {}) {
2044
2247
  registerReadOnly2(server, bridge);
2045
2248
  }
2046
2249
  if (options.withCloud) {
2047
- import("./cloud-HQIBAMRL.js").then((mod) => mod.register(server));
2250
+ try {
2251
+ const mod = await import("./cloud-4AYJVND6.js");
2252
+ mod.register(server);
2253
+ } catch (err) {
2254
+ log.error("Failed to load cloud module:", err);
2255
+ }
2048
2256
  }
2049
2257
  if (options.withRojo) {
2050
- import("./rojo-TXV4K4PB.js").then((mod) => mod.register(server));
2258
+ try {
2259
+ const mod = await import("./rojo-2CMKPTPS.js");
2260
+ mod.register(server);
2261
+ } catch (err) {
2262
+ log.error("Failed to load rojo module:", err);
2263
+ }
2051
2264
  }
2052
2265
  }
2053
2266
 
@@ -2088,7 +2301,7 @@ async function startServer(port = 3200, options = {}) {
2088
2301
  withCloud: options.withCloud,
2089
2302
  withRojo: options.withRojo
2090
2303
  };
2091
- registerAllTools(server, bridge, toolOptions);
2304
+ await registerAllTools(server, bridge, toolOptions);
2092
2305
  bridge.on("studio-connected", (info) => {
2093
2306
  log.info(
2094
2307
  `Roblox Studio connected: ${info.studioId}` + (info.placeName ? ` (${info.placeName})` : "")
@@ -2104,12 +2317,16 @@ async function startServer(port = 3200, options = {}) {
2104
2317
  const transport = new StdioServerTransport();
2105
2318
  await server.connect(transport);
2106
2319
  log.info("MCP server connected via stdio");
2320
+ let shuttingDown = false;
2107
2321
  const shutdown = async () => {
2322
+ if (shuttingDown) return;
2323
+ shuttingDown = true;
2108
2324
  log.info("Shutting down...");
2109
2325
  await bridge.stop();
2110
2326
  await server.close();
2111
2327
  process.exit(0);
2112
2328
  };
2329
+ bridge.on("shutdown", shutdown);
2113
2330
  process.on("SIGINT", shutdown);
2114
2331
  process.on("SIGTERM", shutdown);
2115
2332
  }
@@ -2117,4 +2334,4 @@ async function startServer(port = 3200, options = {}) {
2117
2334
  export {
2118
2335
  startServer
2119
2336
  };
2120
- //# sourceMappingURL=chunk-JDWBW44K.js.map
2337
+ //# sourceMappingURL=chunk-U4GZFDM3.js.map