conduit-mcp 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -224,7 +224,21 @@ var Bridge = class extends EventEmitter {
224
224
  }, REQUEST_TIMEOUT_MS);
225
225
  this.pendingRequests.set(id, { resolve, reject, timer });
226
226
  if (studio) {
227
- studio.ws.send(json);
227
+ studio.ws.send(json, (err) => {
228
+ if (err) {
229
+ clearTimeout(timer);
230
+ this.pendingRequests.delete(id);
231
+ log.warn(
232
+ `WebSocket send failed for studio "${this.activeStudioId}": ${err.message}`
233
+ );
234
+ this.evictStaleStudio(this.activeStudioId);
235
+ reject(
236
+ new Error(
237
+ `Failed to send to Roblox Studio: ${err.message}. The plugin connection may have dropped \u2014 it should auto-reconnect shortly. Please retry.`
238
+ )
239
+ );
240
+ }
241
+ });
228
242
  } else {
229
243
  const studioId = this.activeStudioId ?? "_default";
230
244
  const queue = this.httpPendingCommands.get(studioId) ?? [];
@@ -241,13 +255,51 @@ var Bridge = class extends EventEmitter {
241
255
  }
242
256
  return void 0;
243
257
  }
258
+ isStudioAlive(studioId, studio) {
259
+ if (studio.ws.readyState !== WebSocket.OPEN) return false;
260
+ const lastBeat = this.lastHeartbeats.get(studioId);
261
+ if (lastBeat !== void 0 && Date.now() - lastBeat > HEARTBEAT_TIMEOUT_MS) {
262
+ log.warn(
263
+ `Studio "${studioId}" has stale heartbeat (${Math.round((Date.now() - lastBeat) / 1e3)}s ago) \u2014 evicting`
264
+ );
265
+ this.evictStaleStudio(studioId);
266
+ return false;
267
+ }
268
+ return true;
269
+ }
270
+ evictStaleStudio(studioId) {
271
+ const studio = this.studios.get(studioId);
272
+ if (studio) {
273
+ studio.ws.terminate();
274
+ this.studios.delete(studioId);
275
+ this.lastHeartbeats.delete(studioId);
276
+ this.emit("studio-disconnected", studio.info);
277
+ log.info(`Evicted stale studio: ${studioId}`);
278
+ }
279
+ if (this.activeStudioId === studioId) {
280
+ for (const [id, s] of this.studios) {
281
+ if (s.ws.readyState === WebSocket.OPEN) {
282
+ this.activeStudioId = id;
283
+ log.info(`Auto-switched active studio to: ${id}`);
284
+ return;
285
+ }
286
+ }
287
+ this.activeStudioId = null;
288
+ }
289
+ if (this.studios.size === 0) {
290
+ this.stopHeartbeatMonitor();
291
+ this.stopPingInterval();
292
+ }
293
+ }
244
294
  resolveTargetStudio() {
245
- const active = this.getActiveStudio();
246
- if (active && active.ws.readyState === WebSocket.OPEN) {
247
- return active;
295
+ if (this.activeStudioId) {
296
+ const active = this.studios.get(this.activeStudioId);
297
+ if (active && this.isStudioAlive(this.activeStudioId, active)) {
298
+ return active;
299
+ }
248
300
  }
249
301
  for (const [studioId, studio] of this.studios) {
250
- if (studio.ws.readyState === WebSocket.OPEN) {
302
+ if (this.isStudioAlive(studioId, studio)) {
251
303
  this.activeStudioId = studioId;
252
304
  log.info(`Auto-selected active studio: ${studioId}`);
253
305
  return studio;
@@ -325,7 +377,7 @@ var Bridge = class extends EventEmitter {
325
377
  ws.on("pong", () => {
326
378
  ws.isAlive = true;
327
379
  });
328
- if (this.activeStudioId === null) {
380
+ if (this.activeStudioId === null || this.activeStudioId === studioId) {
329
381
  this.activeStudioId = studioId;
330
382
  }
331
383
  this.emit("studio-connected", info);
@@ -792,10 +844,13 @@ function register(server, bridge) {
792
844
  "get_info",
793
845
  {
794
846
  title: "Get Instance Info",
795
- description: "Get detailed information about a single instance: class, parent, children count, properties, attributes, tags, and optionally a typed property list \u2014 all in one call.",
847
+ description: "Get detailed information about a single instance: class, parent, children count, properties, attributes, tags, and optionally a typed property list \u2014 all in one call.\n\nUse this to inspect runtime UI layout (Size, Position, Transparency, Visible, Text, etc.), part properties, lighting, sounds, and more. Reads ~60 common properties automatically. Use `propertyNames` to request only specific properties for a leaner response.\n\nFor UI debugging: check BackgroundTransparency, Visible, Size, Position, AnchorPoint, ZIndex, LayoutOrder on GuiObjects.",
796
848
  inputSchema: z.object({
797
849
  path: z.string().describe("Path to the instance, e.g. 'game.Workspace.Part'"),
798
- includeProperties: z.boolean().default(true).describe("Include property values"),
850
+ includeProperties: z.boolean().default(true).describe("Include property values (reads ~60 common properties)"),
851
+ propertyNames: z.array(z.string()).optional().describe(
852
+ "Request only these specific properties by name, e.g. ['Size', 'Position', 'Transparency']. When set, only these properties are returned (much leaner than the full property dump)."
853
+ ),
799
854
  includeAttributes: z.boolean().default(true).describe("Include custom attributes"),
800
855
  includeTags: z.boolean().default(true).describe("Include CollectionService tags"),
801
856
  includePropertyList: z.boolean().default(false).describe("Include a typed list of all discoverable properties with types and values"),
@@ -813,6 +868,7 @@ function register(server, bridge) {
813
868
  const result = await bridge.send("get_info", {
814
869
  path: params.path,
815
870
  includeProperties: params.includeProperties,
871
+ propertyNames: params.propertyNames,
816
872
  includeAttributes: params.includeAttributes,
817
873
  includeTags: params.includeTags,
818
874
  includePropertyList: params.includePropertyList,
@@ -1135,13 +1191,16 @@ function registerReadScript(server, bridge) {
1135
1191
  "read_script",
1136
1192
  {
1137
1193
  title: "Read Script Source",
1138
- description: "Read the Lua source code of a script instance. Optionally restrict to a line range.",
1194
+ description: "Read the Lua source code of a script instance. Optionally restrict to a line range. Use outline=true to get just function signatures and top-level declarations with line numbers \u2014 much cheaper than reading the full source for large scripts.",
1139
1195
  inputSchema: z3.object({
1140
1196
  path: z3.string().describe("Path to the script instance"),
1197
+ outline: z3.boolean().default(false).describe(
1198
+ "If true, return only a structural outline (function signatures, top-level locals, types, return) with line numbers instead of full source. Ideal for navigating large scripts."
1199
+ ),
1141
1200
  lineRange: z3.object({
1142
1201
  start: z3.number().int().min(1).describe("Start line (1-based)"),
1143
1202
  end: z3.number().int().min(1).describe("End line (1-based, inclusive)")
1144
- }).optional().describe("Optional line range to read"),
1203
+ }).optional().describe("Optional line range to read (ignored when outline=true)"),
1145
1204
  maxTokens: z3.number().optional().describe("Maximum token budget for the response")
1146
1205
  }),
1147
1206
  annotations: {
@@ -1152,6 +1211,28 @@ function registerReadScript(server, bridge) {
1152
1211
  }
1153
1212
  },
1154
1213
  async (params) => {
1214
+ if (params.outline) {
1215
+ const result2 = await bridge.send("outline_script", {
1216
+ path: params.path
1217
+ });
1218
+ const lines = [
1219
+ `### Outline: \`${result2.path}\` (${result2.totalLines} lines)`,
1220
+ ""
1221
+ ];
1222
+ for (const entry of result2.outline) {
1223
+ const pad = " ".repeat(entry.indent);
1224
+ lines.push(`${entry.line}: ${pad}${entry.text}`);
1225
+ }
1226
+ if (result2.outline.length === 0) {
1227
+ lines.push("*No functions, type definitions, or top-level declarations found.*");
1228
+ }
1229
+ const text2 = lines.join("\n");
1230
+ return {
1231
+ content: [
1232
+ { type: "text", text: applyTokenBudget(text2, params.maxTokens) }
1233
+ ]
1234
+ };
1235
+ }
1155
1236
  if (params.lineRange && params.lineRange.end < params.lineRange.start) {
1156
1237
  return {
1157
1238
  content: [{ type: "text", text: "lineRange.end must be >= lineRange.start." }],
@@ -1176,10 +1257,10 @@ function registerWriteTools(server, bridge) {
1176
1257
  "edit_script",
1177
1258
  {
1178
1259
  title: "Edit Script Source",
1179
- description: "Edit the Lua source code of a script. Supports four modes: 'full' replaces the entire source, 'range' replaces specific line/column ranges, 'find_replace' does text find-and-replace on one script, and 'multi_replace' does find-and-replace across multiple scripts in one undoable operation.",
1260
+ description: "Edit the Lua source code of a script. Supports five modes:\n- 'full': Replace entire source.\n- 'range': Replace specific line/column ranges.\n- 'find_replace': Text find-and-replace on one script.\n- 'multi_replace': Same find-and-replace across multiple scripts.\n- 'batch': Different find-and-replace edits across different scripts in one atomic operation. Use this when you need different changes in different scripts.\n\nTip: Wrap multiple edits in a transaction (begin/commit) to group them into a single Ctrl+Z undo point.",
1180
1261
  inputSchema: z3.object({
1181
- path: z3.string().describe("Path to the script instance"),
1182
- mode: z3.enum(["full", "range", "find_replace", "multi_replace"]).describe("Edit mode: full, range, find_replace, or multi_replace"),
1262
+ path: z3.string().optional().describe("Path to the script instance (not needed for 'multi_replace' or 'batch' modes)"),
1263
+ mode: z3.enum(["full", "range", "find_replace", "multi_replace", "batch"]).describe("Edit mode"),
1183
1264
  source: z3.string().optional().describe("Complete new source (for 'full' mode)"),
1184
1265
  edits: z3.array(
1185
1266
  z3.object({
@@ -1193,7 +1274,17 @@ function registerWriteTools(server, bridge) {
1193
1274
  find: z3.string().optional().describe("Text or pattern to find (for 'find_replace' mode)"),
1194
1275
  replace: z3.string().optional().describe("Replacement text (for 'find_replace' mode)"),
1195
1276
  regex: z3.boolean().optional().describe("Treat 'find' as a Lua pattern (for 'find_replace' and 'multi_replace' modes)"),
1196
- scripts: z3.array(z3.string()).optional().describe("Array of script paths to apply find/replace across (for 'multi_replace' mode)")
1277
+ scripts: z3.array(z3.string()).optional().describe("Array of script paths to apply find/replace across (for 'multi_replace' mode)"),
1278
+ batch: z3.array(
1279
+ z3.object({
1280
+ path: z3.string().describe("Script path"),
1281
+ find: z3.string().describe("Text or pattern to find"),
1282
+ replace: z3.string().default("").describe("Replacement text"),
1283
+ regex: z3.boolean().default(false).describe("Treat find as a Lua pattern")
1284
+ })
1285
+ ).optional().describe(
1286
+ "Array of per-script find/replace operations (for 'batch' mode). Each entry targets a different script with its own find/replace pair."
1287
+ )
1197
1288
  }),
1198
1289
  annotations: {
1199
1290
  readOnlyHint: false,
@@ -1255,6 +1346,33 @@ function registerWriteTools(server, bridge) {
1255
1346
  }
1256
1347
  return { content: [{ type: "text", text: lines.join("\n") }] };
1257
1348
  }
1349
+ if (params.mode === "batch") {
1350
+ if (!params.batch || params.batch.length === 0) {
1351
+ return {
1352
+ content: [{ type: "text", text: "batch mode requires a non-empty `batch` array." }],
1353
+ isError: true
1354
+ };
1355
+ }
1356
+ const result2 = await bridge.send("batch_edit_scripts", {
1357
+ edits: params.batch
1358
+ });
1359
+ const lines = [
1360
+ `**Batch edit** \u2014 ${result2.scriptsModified} script(s) modified`,
1361
+ ""
1362
+ ];
1363
+ for (const r of result2.results) {
1364
+ if (r.success) {
1365
+ lines.push(`- \`${r.path}\`: ${r.replacements ?? 0} replacement(s)`);
1366
+ }
1367
+ }
1368
+ if (result2.errors && result2.errors.length > 0) {
1369
+ lines.push("", "**Errors:**");
1370
+ for (const e of result2.errors) {
1371
+ lines.push(`- \`${e.path}\`: ${e.error}`);
1372
+ }
1373
+ }
1374
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1375
+ }
1258
1376
  const result = await bridge.send("edit_script", {
1259
1377
  path: params.path,
1260
1378
  mode: params.mode,
@@ -1327,9 +1445,9 @@ function register4(server, bridge) {
1327
1445
  "playtest",
1328
1446
  {
1329
1447
  title: "Playtest Control & Virtual Input",
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.",
1448
+ 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.\n- `screenshot`: Capture the viewport during playtest. Useful for seeing the game state visually.",
1331
1449
  inputSchema: z4.object({
1332
- action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up"]).describe("Playtest action"),
1450
+ action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up", "screenshot"]).describe("Playtest action"),
1333
1451
  mode: z4.enum(["play", "run"]).default("play").describe("Playtest mode: 'play' (F5, full client with player) or 'run' (F8, server-only). Default: play"),
1334
1452
  code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
1335
1453
  // get_output params
@@ -1413,6 +1531,25 @@ ${logText}
1413
1531
  ${result2.message}` : ""}`;
1414
1532
  return { content: [{ type: "text", text: text2 }] };
1415
1533
  }
1534
+ if (params.action === "screenshot") {
1535
+ const result2 = await bridge.send("screenshot", {});
1536
+ if (result2.imageBase64 && result2.mimeType) {
1537
+ return {
1538
+ content: [
1539
+ {
1540
+ type: "image",
1541
+ data: result2.imageBase64,
1542
+ mimeType: result2.mimeType
1543
+ }
1544
+ ]
1545
+ };
1546
+ }
1547
+ return {
1548
+ content: [
1549
+ { type: "text", text: `Playtest screenshot: ${result2.message ?? result2.status}` }
1550
+ ]
1551
+ };
1552
+ }
1416
1553
  if (params.action === "mouse_click" || params.action === "mouse_move" || params.action === "key_press" || params.action === "key_down" || params.action === "key_up") {
1417
1554
  if ((params.action === "mouse_click" || params.action === "mouse_move") && (params.x === void 0 || params.y === void 0)) {
1418
1555
  return {
@@ -1903,7 +2040,7 @@ function register7(server, bridge) {
1903
2040
  "screenshot",
1904
2041
  {
1905
2042
  title: "Take Screenshot",
1906
- description: "Capture a screenshot of the current Roblox Studio viewport. Returns base64 image data when available for vision model consumption.",
2043
+ description: "Capture a screenshot of the current Roblox Studio viewport. Works in both edit mode and during playtest.\n\nThe screenshot is saved to the user's Roblox screenshots folder. Base64 image data is returned when the EditableImage API is available (Roblox platform limitation).\n\nTip: During playtest, use this to capture the game viewport. Combine with `playtest start` + a short delay via `playtest execute` (e.g. `task.wait(2)`) to capture after the game loads.",
1907
2044
  inputSchema: z7.object({}),
1908
2045
  annotations: {
1909
2046
  readOnlyHint: true,
@@ -1962,7 +2099,7 @@ function register7(server, bridge) {
1962
2099
  "transaction",
1963
2100
  {
1964
2101
  title: "Transaction Control",
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.",
2102
+ description: "Group multiple tool calls into a single Ctrl+Z undo point \u2014 essential when making several related edits that the user should be able to revert together.\n\n**When to use:** Before starting a multi-edit session (e.g. refactoring across scripts, creating multiple instances, UI changes). Call `begin`, make all your changes, then `commit`. The user can undo everything in one Ctrl+Z.\n\nActions:\n- `begin`: Start a transaction. All subsequent writes share one undo recording.\n- `commit`: Finish and commit all changes as one undo point.\n- `rollback`: Cancel and revert all changes since begin.\n\nTransactions auto-rollback after 60 seconds if not committed.",
1966
2103
  inputSchema: z7.object({
1967
2104
  action: z7.enum(["begin", "commit", "rollback"]).describe("Transaction action"),
1968
2105
  name: z7.string().optional().describe("Transaction name for the undo history (for 'begin' action)")
@@ -2376,4 +2513,4 @@ async function startServer(port = 3200, options = {}) {
2376
2513
  export {
2377
2514
  startServer
2378
2515
  };
2379
- //# sourceMappingURL=chunk-HI6KFLGA.js.map
2516
+ //# sourceMappingURL=chunk-VTWABOF4.js.map