@chrrxs/robloxstudio-mcp 2.12.0 → 2.14.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.
@@ -12,6 +12,11 @@ local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
13
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
14
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
15
+ local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
16
+ -- Track render-loop liveness so input/screenshot tools can report "window
17
+ -- minimized / not rendering" instead of silently no-op'ing. No-op in the
18
+ -- server DM (RenderStepped can't connect there).
19
+ RenderMonitor.start()
15
20
  -- Attach the per-peer LogService.MessageOut listener as early as possible so
16
21
  -- boot-time prints from the user's place scripts are captured. Powers the
17
22
  -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
@@ -97,7 +102,10 @@ local RunService = _services.RunService
97
102
  local ServerStorage = _services.ServerStorage
98
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
99
104
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
105
+ local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
+ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
100
107
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
108
+ local StudioTestService = game:GetService("StudioTestService")
101
109
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
102
110
  -- client broker runs in the play-server DM where it can't easily import from
103
111
  -- the edit-side module, and the place identifier must match what the edit-DM
@@ -151,6 +159,7 @@ end
151
159
  -- signaling, which works regardless of MCP server state.)
152
160
  local MCP_URL = "http://localhost:58741"
153
161
  local BROKER_NAME = "__MCPClientBroker"
162
+ local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
154
163
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
155
164
  -- Each requires the client peer's plugin VM (because the buffer / require
156
165
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -158,6 +167,11 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
158
167
  ["/api/execute-luau"] = true,
159
168
  ["/api/get-runtime-logs"] = true,
160
169
  ["/api/get-memory-breakdown"] = true,
170
+ ["/api/multiplayer-test-state"] = true,
171
+ ["/api/multiplayer-test-leave-client"] = true,
172
+ ["/api/capture-begin"] = true,
173
+ ["/api/simulate-mouse-input"] = true,
174
+ ["/api/simulate-keyboard-input"] = true,
161
175
  }
162
176
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
163
177
  -- polls doesn't cause a re-register stampede.
@@ -236,6 +250,77 @@ local function handleGetRuntimeLogs(data)
236
250
  filter = filter,
237
251
  }, "client")
238
252
  end
253
+ local function handleMultiplayerTestState()
254
+ local argsOk, args = pcall(function()
255
+ return StudioTestService:GetTestArgs()
256
+ end)
257
+ local canLeaveOk, canLeave = pcall(function()
258
+ return StudioTestService:CanLeaveTest()
259
+ end)
260
+ local _exp = Players:GetPlayers()
261
+ -- ▼ ReadonlyArray.map ▼
262
+ local _newValue = table.create(#_exp)
263
+ local _callback = function(player)
264
+ return {
265
+ name = player.Name,
266
+ userId = player.UserId,
267
+ displayName = player.DisplayName,
268
+ }
269
+ end
270
+ for _k, _v in _exp do
271
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
272
+ end
273
+ -- ▲ ReadonlyArray.map ▲
274
+ local players = _newValue
275
+ table.sort(players, function(a, b)
276
+ return a.name < b.name
277
+ end)
278
+ return {
279
+ success = true,
280
+ peer = "client",
281
+ isRunning = RunService:IsRunning(),
282
+ isRunMode = RunService:IsRunMode(),
283
+ editModeActive = StudioTestService.EditModeActive,
284
+ testArgsOk = argsOk,
285
+ testArgs = if argsOk then args else nil,
286
+ testArgsError = if argsOk then nil else tostring(args),
287
+ players = players,
288
+ playerCount = #players,
289
+ localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
290
+ canLeaveOk = canLeaveOk,
291
+ canLeave = if canLeaveOk then canLeave else false,
292
+ canLeaveError = if canLeaveOk then nil else tostring(canLeave),
293
+ }
294
+ end
295
+ local function handleMultiplayerTestLeaveClient()
296
+ local canLeaveOk, canLeave = pcall(function()
297
+ return StudioTestService:CanLeaveTest()
298
+ end)
299
+ if not canLeaveOk then
300
+ return {
301
+ error = tostring(canLeave),
302
+ canLeaveOk = false,
303
+ }
304
+ end
305
+ if not canLeave then
306
+ return {
307
+ error = "This client cannot leave the current test session.",
308
+ canLeaveOk = true,
309
+ canLeave = false,
310
+ }
311
+ end
312
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
313
+ task.defer(function()
314
+ pcall(function()
315
+ return StudioTestService:LeaveTest()
316
+ end)
317
+ end)
318
+ return {
319
+ success = true,
320
+ message = "Client leave requested.",
321
+ localPlayer = localPlayer,
322
+ }
323
+ end
239
324
  local function setupClientBroker()
240
325
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
241
326
  if not rf or not rf:IsA("RemoteFunction") then
@@ -255,6 +340,21 @@ local function setupClientBroker()
255
340
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
256
341
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
257
342
  end
343
+ if payload and payload.endpoint == "/api/multiplayer-test-state" then
344
+ return handleMultiplayerTestState()
345
+ end
346
+ if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
347
+ return handleMultiplayerTestLeaveClient()
348
+ end
349
+ if payload and payload.endpoint == "/api/capture-begin" then
350
+ return CaptureHandlers.captureBegin()
351
+ end
352
+ if payload and payload.endpoint == "/api/simulate-mouse-input" then
353
+ return InputHandlers.simulateMouseInput(payload.data or {})
354
+ end
355
+ if payload and payload.endpoint == "/api/simulate-keyboard-input" then
356
+ return InputHandlers.simulateKeyboardInput(payload.data or {})
357
+ end
258
358
  if payload and payload.endpoint == "/api/execute-luau" then
259
359
  return handleExecuteLuau(payload.data)
260
360
  end
@@ -263,6 +363,7 @@ local function setupClientBroker()
263
363
  end
264
364
  end
265
365
  local proxyByPlayer = {}
366
+ local serverBrokerStarted = false
266
367
  local function pollProxy(proxyId, player, rf)
267
368
  while true do
268
369
  local _condition = player.Parent ~= nil
@@ -318,8 +419,12 @@ local function pollProxy(proxyId, player, rf)
318
419
  }
319
420
  end
320
421
  else
422
+ local allowed = {}
423
+ for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
424
+ table.insert(allowed, ep)
425
+ end
321
426
  response = {
322
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
427
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
323
428
  }
324
429
  end
325
430
  postJson("/response", {
@@ -370,12 +475,20 @@ end
370
475
  -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
371
476
  -- which doesn't depend on MCP server state or peer registration at all.)
372
477
  local function setupServerBroker()
478
+ if serverBrokerStarted then
479
+ return nil
480
+ end
373
481
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
374
482
  if not rf then
375
483
  rf = Instance.new("RemoteFunction")
376
484
  rf.Name = BROKER_NAME
377
485
  rf.Parent = ReplicatedStorage
378
486
  end
487
+ if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
488
+ return nil
489
+ end
490
+ rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
491
+ serverBrokerStarted = true
379
492
  local broker = rf
380
493
  Players.PlayerAdded:Connect(function(p)
381
494
  return registerProxy(p, broker)
@@ -424,6 +537,7 @@ local ServerStorage = _services.ServerStorage
424
537
  local State = TS.import(script, script.Parent, "State")
425
538
  local Utils = TS.import(script, script.Parent, "Utils")
426
539
  local UI = TS.import(script, script.Parent, "UI")
540
+ local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
427
541
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
428
542
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
429
543
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -547,6 +661,11 @@ local routeMap = {
547
661
  ["/api/start-playtest"] = TestHandlers.startPlaytest,
548
662
  ["/api/stop-playtest"] = TestHandlers.stopPlaytest,
549
663
  ["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
664
+ ["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
665
+ ["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
666
+ ["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
667
+ ["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
668
+ ["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
550
669
  ["/api/character-navigation"] = TestHandlers.characterNavigation,
551
670
  ["/api/export-build"] = BuildHandlers.exportBuild,
552
671
  ["/api/import-build"] = BuildHandlers.importBuild,
@@ -555,6 +674,8 @@ local routeMap = {
555
674
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
556
675
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
557
676
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
677
+ ["/api/capture-begin"] = CaptureHandlers.captureBegin,
678
+ ["/api/capture-read"] = CaptureHandlers.captureRead,
558
679
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
559
680
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
560
681
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -895,6 +1016,19 @@ local function activatePlugin(connIndex)
895
1016
  -- Initial /ready; pollForRequests will also re-fire ready if the server
896
1017
  -- later reports knownInstance=false (process restart, etc).
897
1018
  sendReady(conn)
1019
+ -- Keep the eval bridges present in the edit DM so that ANY playtest —
1020
+ -- including one the dev starts manually via the Studio Play button —
1021
+ -- clones them into the play DMs and eval_*_runtime works with no setup
1022
+ -- roundtrip. Only the edit DM installs; play DMs already have the cloned
1023
+ -- copies. Idempotent, so reconnects don't re-dirty the place.
1024
+ if not RunService:IsRunning() then
1025
+ task.spawn(function()
1026
+ local result = ensureBridgesInstalled()
1027
+ if not result.installed then
1028
+ warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
1029
+ end
1030
+ end)
1031
+ end
898
1032
  -- Watch for game.Name updates so a stale "Place1" captured at first
899
1033
  -- /ready gets refreshed once Studio settles on the real DM name.
900
1034
  ensureNameChangeWatcher(conn)
@@ -1016,11 +1150,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
1016
1150
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
1017
1151
  -- when LoadStringEnabled=false (the default in fresh places).
1018
1152
  --
1019
- -- Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
1020
- -- DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
1021
- -- DataModel into the play DMs, so the scripts come along and run there.
1022
- -- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
1023
- -- returns (test ended for any reason: stop_playtest, manual close, EndTest).
1153
+ -- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
1154
+ -- installs them (ensureBridgesInstalled) when the plugin connects in edit,
1155
+ -- and TestHandlers.startPlaytest force-refreshes them right before
1156
+ -- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
1157
+ -- play DMs, so the scripts come along and run there. We keep them in the edit
1158
+ -- DM after a playtest ends (rather than cleaning up) so that a playtest the
1159
+ -- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
1160
+ -- tool — also gets the bridges cloned in. This is intentionally a little
1161
+ -- intrusive (two helper scripts visible in Explorer) in exchange for a
1162
+ -- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
1024
1163
  --
1025
1164
  -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
1026
1165
  -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -1052,9 +1191,9 @@ local BRIDGE_NAMES = {
1052
1191
  -- Embedded Luau. The double `${...}` references our exported names so a
1053
1192
  -- rename here propagates to both the script source and the tool wrappers.
1054
1193
  local SERVER_BRIDGE_SOURCE = `\
1055
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at\
1056
- -- stop_playtest. Provides shared-require-cache eval on the server peer for\
1057
- -- the eval_server_runtime MCP tool.\
1194
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
1195
+ -- tool (shared-require-cache eval on the server during playtests). Inert\
1196
+ -- outside Studio (no-ops in live games); safe to leave in place.\
1058
1197
  \
1059
1198
  local ServerScriptService = game:GetService("ServerScriptService")\
1060
1199
  local RunService = game:GetService("RunService")\
@@ -1078,9 +1217,9 @@ bf.OnInvoke = function(payload)\
1078
1217
  end\
1079
1218
  `
1080
1219
  local CLIENT_BRIDGE_SOURCE = `\
1081
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at\
1082
- -- stop_playtest. Provides shared-require-cache eval on the client peer for\
1083
- -- the eval_client_runtime MCP tool.\
1220
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
1221
+ -- tool (shared-require-cache eval on the client during playtests). Inert\
1222
+ -- outside Studio (no-ops in live games); safe to leave in place.\
1084
1223
  \
1085
1224
  local ReplicatedStorage = game:GetService("ReplicatedStorage")\
1086
1225
  local RunService = game:GetService("RunService")\
@@ -1103,6 +1242,25 @@ bf.OnInvoke = function(payload)\
1103
1242
  return pcall(require, payload)\
1104
1243
  end\
1105
1244
  `
1245
+ -- Stamp written onto each installed bridge Script so we can tell whether the
1246
+ -- bridge currently in the DM was produced by THIS plugin build. It's a djb2
1247
+ -- hash of the actual bridge source plus the plugin version, so ANY change to
1248
+ -- the source (or a version bump) yields a new stamp — which makes
1249
+ -- ensureBridgesInstalled() force a refresh on the next plugin load instead of
1250
+ -- keeping a stale bridge that happens to still be present (e.g. one saved into
1251
+ -- the .rbxl from an older build).
1252
+ local STAMP_ATTR = "__MCPBridgeStamp"
1253
+ local function computeBridgeStamp()
1254
+ local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
1255
+ local h = 5381
1256
+ for i = 1, #combined do
1257
+ h = (h * 33 + (string.byte(combined, i))) % 2147483647
1258
+ end
1259
+ -- "2.14.0" is replaced with the package version at package time
1260
+ -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1261
+ return `{tostring(h)}-2.14.0`
1262
+ end
1263
+ local BRIDGE_STAMP = computeBridgeStamp()
1106
1264
  local function setSource(scriptInst, source)
1107
1265
  -- ScriptEditorService is the cleaner API and integrates with Studio's
1108
1266
  -- edit history; fall back to direct Source mutation (allowed in plugin
@@ -1138,7 +1296,31 @@ local function cleanupBridges()
1138
1296
  end)
1139
1297
  end
1140
1298
  end
1141
- local function installBridges()
1299
+ -- Idempotent variant: install only if the bridge scripts aren't already
1300
+ -- present in the edit DM. Used to keep the bridges always available (so a
1301
+ -- playtest the dev starts manually — not via the MCP start_playtest tool —
1302
+ -- still clones them into the play DMs). Cheap no-op when already installed,
1303
+ -- which avoids re-dirtying the place on every plugin reconnect.
1304
+ local installBridges
1305
+ local function ensureBridgesInstalled()
1306
+ local _binding = findBridges()
1307
+ local server = _binding.server
1308
+ local client = _binding.client
1309
+ if server and client then
1310
+ -- Both present — but only skip the reinstall if they were produced by
1311
+ -- THIS build. A mismatched/absent stamp means a stale bridge (older
1312
+ -- plugin, or one persisted in the saved place), so force a refresh.
1313
+ local sStamp = server:GetAttribute(STAMP_ATTR)
1314
+ local cStamp = client:GetAttribute(STAMP_ATTR)
1315
+ if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
1316
+ return {
1317
+ installed = true,
1318
+ }
1319
+ end
1320
+ end
1321
+ return installBridges()
1322
+ end
1323
+ function installBridges()
1142
1324
  -- Defensive: clear any stale bridges from a prior unclean exit before
1143
1325
  -- inserting fresh. The injected script also self-cleans its
1144
1326
  -- ReplicatedStorage/ServerScriptService children at startup, but the
@@ -1151,6 +1333,7 @@ local function installBridges()
1151
1333
  -- script. cleanupBridges() removes it from the edit DM when the
1152
1334
  -- playtest ends.
1153
1335
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1336
+ serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1154
1337
  serverScript.Parent = ServerScriptService
1155
1338
  local sps = getStarterPlayerScripts()
1156
1339
  if not sps then
@@ -1159,6 +1342,7 @@ local function installBridges()
1159
1342
  local clientScript = Instance.new("LocalScript")
1160
1343
  clientScript.Name = CLIENT_SCRIPT_NAME
1161
1344
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1345
+ clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1162
1346
  clientScript.Parent = sps
1163
1347
  end)
1164
1348
  if not ok then
@@ -1173,6 +1357,7 @@ local function installBridges()
1173
1357
  end
1174
1358
  return {
1175
1359
  cleanupBridges = cleanupBridges,
1360
+ ensureBridgesInstalled = ensureBridgesInstalled,
1176
1361
  installBridges = installBridges,
1177
1362
  BRIDGE_NAMES = BRIDGE_NAMES,
1178
1363
  }
@@ -2038,6 +2223,8 @@ return {
2038
2223
  <Properties>
2039
2224
  <string name="Name">CaptureHandlers</string>
2040
2225
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2226
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2227
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2041
2228
  local CaptureService = game:GetService("CaptureService")
2042
2229
  local AssetService = game:GetService("AssetService")
2043
2230
  local MAX_TILE_SIZE = 1024
@@ -2147,7 +2334,20 @@ local function readPixelsTiled(img, w, h)
2147
2334
  end
2148
2335
  return fullBuf
2149
2336
  end
2150
- local function captureScreenshotData()
2337
+ -- Triggers CaptureService:CaptureScreenshot and waits for the temporary
2338
+ -- content id. Works in any DM, including the play CLIENT (where reading the
2339
+ -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
2340
+ -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
2341
+ -- privileged DM (the edit DM) — see captureRead.
2342
+ local function doCaptureScreenshot()
2343
+ -- Fast-fail with a clear reason if the window isn't rendering — otherwise
2344
+ -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
2345
+ local notRendering = RenderMonitor.notRenderingReason()
2346
+ if notRendering ~= nil then
2347
+ return {
2348
+ error = notRendering,
2349
+ }
2350
+ end
2151
2351
  local contentId
2152
2352
  CaptureService:CaptureScreenshot(function(id)
2153
2353
  contentId = id
@@ -2156,11 +2356,21 @@ local function captureScreenshotData()
2156
2356
  while contentId == nil do
2157
2357
  if tick() - startTime > 10 then
2158
2358
  return {
2159
- error = "Screenshot capture timed out. Ensure the Studio viewport is visible and you are in Edit mode (not Play mode). Known Roblox bug: capture may fail if viewport renders a solid color.",
2359
+ error = "Screenshot capture timed out (CaptureScreenshot callback never fired). The Studio window is likely minimized or occluded restore it so the viewport renders. (Known Roblox bug: capture can also fail if the viewport renders a solid color.)",
2160
2360
  }
2161
2361
  end
2162
2362
  task.wait(0.1)
2163
2363
  end
2364
+ return {
2365
+ contentId = contentId,
2366
+ }
2367
+ end
2368
+ -- Promotes a CaptureScreenshot content id into an EditableImage and reads its
2369
+ -- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
2370
+ -- the privilege to create an EditableImage from a temporary texture id (errors
2371
+ -- "cannot currently create editable image from temporary texture id"), while
2372
+ -- the edit DM can — even for an id captured in the play client DM.
2373
+ local function readContentToBase64(contentId)
2164
2374
  local editableOk, editableResult = pcall(function()
2165
2375
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2166
2376
  end)
@@ -2190,12 +2400,36 @@ local function captureScreenshotData()
2190
2400
  data = base64Data,
2191
2401
  }
2192
2402
  end
2403
+ -- Edit-mode single shot: capture and read back in the same (edit) context.
2404
+ local function captureScreenshotData()
2405
+ local cap = doCaptureScreenshot()
2406
+ if cap.error ~= nil then
2407
+ return cap
2408
+ end
2409
+ return readContentToBase64(cap.contentId)
2410
+ end
2193
2411
  local function captureScreenshot()
2194
2412
  return captureScreenshotData()
2195
2413
  end
2414
+ -- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
2415
+ local function captureBegin()
2416
+ return doCaptureScreenshot()
2417
+ end
2418
+ -- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
2419
+ local function captureRead(requestData)
2420
+ local contentId = requestData.contentId
2421
+ if not (contentId ~= "" and contentId) then
2422
+ return {
2423
+ error = "contentId is required",
2424
+ }
2425
+ end
2426
+ return readContentToBase64(contentId)
2427
+ end
2196
2428
  return {
2197
2429
  captureScreenshotData = captureScreenshotData,
2198
2430
  captureScreenshot = captureScreenshot,
2431
+ captureBegin = captureBegin,
2432
+ captureRead = captureRead,
2199
2433
  }
2200
2434
  ]]></string>
2201
2435
  </Properties>
@@ -2204,19 +2438,56 @@ return {
2204
2438
  <Properties>
2205
2439
  <string name="Name">InputHandlers</string>
2206
2440
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2207
- local function getVIM()
2208
- local ok, result = pcall(function()
2209
- return game:GetService("VirtualInputManager")
2441
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2442
+ -- Virtual input via UserInputService:CreateVirtualInput().
2443
+ --
2444
+ -- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
2445
+ -- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
2446
+ -- in every context a plugin can reach (edit DM, play server/client DMs), so
2447
+ -- they silently never worked. CreateVirtualInput() is callable without that
2448
+ -- capability and drives the REAL input pipeline: SendKey feeds
2449
+ -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
2450
+ -- character at full WalkSpeed with controls intact, no Humanoid hijack),
2451
+ -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
2452
+ -- CoreGui), and SendTextInput types into the focused TextBox.
2453
+ --
2454
+ -- Method set on the VirtualInput object (verified live):
2455
+ -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
2456
+ -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
2457
+ -- SendTextInput(text: string)
2458
+ -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
2459
+ -- "scroll" mouse actions are not supported.
2460
+ --
2461
+ -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
2462
+ -- what capture_screenshot returns (window space, origin at the top-left of the
2463
+ -- rendered viewport). Pass screenshot pixel coordinates straight through. Note
2464
+ -- that UserInputService reports input positions in GUI space, which is offset
2465
+ -- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
2466
+ -- callers who pick coordinates off a screenshot, which is why we do not
2467
+ -- translate here.
2468
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2469
+ local UserInputService = game:GetService("UserInputService")
2470
+ -- One VirtualInput per plugin VM, reused across calls so that a key held down
2471
+ -- in one call (action="press") and released in a later call (action="release")
2472
+ -- share the same input source.
2473
+ local cachedVI
2474
+ local function getVI()
2475
+ if cachedVI then
2476
+ return cachedVI
2477
+ end
2478
+ local ok, vi = pcall(function()
2479
+ return UserInputService:CreateVirtualInput()
2210
2480
  end)
2211
- if ok and result then
2212
- return result
2481
+ if ok and vi ~= nil then
2482
+ cachedVI = vi
2483
+ return cachedVI
2213
2484
  end
2214
2485
  return nil
2215
2486
  end
2216
- local BUTTON_MAP = {
2217
- Left = 0,
2218
- Right = 1,
2219
- Middle = 2,
2487
+ local MOUSE_TYPE_MAP = {
2488
+ Left = Enum.UserInputType.MouseButton1,
2489
+ Right = Enum.UserInputType.MouseButton2,
2490
+ Middle = Enum.UserInputType.MouseButton3,
2220
2491
  }
2221
2492
  local function simulateMouseInput(requestData)
2222
2493
  local action = requestData.action
@@ -2227,56 +2498,43 @@ local function simulateMouseInput(requestData)
2227
2498
  _condition = "Left"
2228
2499
  end
2229
2500
  local button = _condition
2230
- local scrollDirection = requestData.scrollDirection
2231
2501
  if not (action ~= "" and action) then
2232
2502
  return {
2233
2503
  error = "action is required",
2234
2504
  }
2235
2505
  end
2236
- local vim = getVIM()
2237
- if not vim then
2506
+ if x == nil or y == nil then
2238
2507
  return {
2239
- error = "VirtualInputManager is not available in this context",
2508
+ error = "x and y are required",
2240
2509
  }
2241
2510
  end
2242
- local _condition_1 = BUTTON_MAP[button]
2243
- if _condition_1 == nil then
2244
- _condition_1 = 0
2511
+ -- Input is silently dropped by the engine when the window isn't rendering
2512
+ -- (e.g. minimized). Surface that instead of returning a false success.
2513
+ local notRendering = RenderMonitor.notRenderingReason()
2514
+ if notRendering ~= nil then
2515
+ return {
2516
+ error = notRendering,
2517
+ }
2518
+ end
2519
+ local vi = getVI()
2520
+ if not vi then
2521
+ return {
2522
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2523
+ }
2245
2524
  end
2246
- local buttonNum = _condition_1
2525
+ local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
2526
+ local pos = Vector2.new(x, y)
2247
2527
  local success, err = pcall(function()
2248
2528
  if action == "click" then
2249
- if x == nil or y == nil then
2250
- error("x and y are required for click")
2251
- end
2252
- vim:SendMouseButtonEvent(x, y, buttonNum, true)
2529
+ vi:SendMouseButton(pos, inputType, true)
2253
2530
  task.wait(0.05)
2254
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2531
+ vi:SendMouseButton(pos, inputType, false)
2255
2532
  elseif action == "mouseDown" then
2256
- if x == nil or y == nil then
2257
- error("x and y are required for mouseDown")
2258
- end
2259
- vim:SendMouseButtonEvent(x, y, buttonNum, true)
2533
+ vi:SendMouseButton(pos, inputType, true)
2260
2534
  elseif action == "mouseUp" then
2261
- if x == nil or y == nil then
2262
- error("x and y are required for mouseUp")
2263
- end
2264
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2265
- elseif action == "move" then
2266
- if x == nil or y == nil then
2267
- error("x and y are required for move")
2268
- end
2269
- vim:SendMouseMoveEvent(x, y)
2270
- elseif action == "scroll" then
2271
- if x == nil or y == nil then
2272
- error("x and y are required for scroll")
2273
- end
2274
- if not (scrollDirection ~= "" and scrollDirection) then
2275
- error("scrollDirection is required for scroll")
2276
- end
2277
- vim:SendMouseWheelEvent(x, y, scrollDirection == "up")
2535
+ vi:SendMouseButton(pos, inputType, false)
2278
2536
  else
2279
- error(`Unknown action: {action}`)
2537
+ error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
2280
2538
  end
2281
2539
  end)
2282
2540
  if success then
@@ -2293,7 +2551,40 @@ local function simulateMouseInput(requestData)
2293
2551
  }
2294
2552
  end
2295
2553
  local function simulateKeyboardInput(requestData)
2554
+ local notRendering = RenderMonitor.notRenderingReason()
2555
+ if notRendering ~= nil then
2556
+ return {
2557
+ error = notRendering,
2558
+ }
2559
+ end
2560
+ local vi = getVI()
2561
+ if not vi then
2562
+ return {
2563
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2564
+ }
2565
+ end
2566
+ -- Text mode: type a string into the focused TextBox.
2567
+ local text = requestData.text
2568
+ if text ~= nil then
2569
+ local ok, err = pcall(function()
2570
+ return vi:SendTextInput(text)
2571
+ end)
2572
+ if ok then
2573
+ return {
2574
+ success = true,
2575
+ text = text,
2576
+ }
2577
+ end
2578
+ return {
2579
+ error = `Failed to send text input: {err}`,
2580
+ }
2581
+ end
2296
2582
  local keyCodeName = requestData.keyCode
2583
+ if not (keyCodeName ~= "" and keyCodeName) then
2584
+ return {
2585
+ error = "keyCode (or text) is required",
2586
+ }
2587
+ end
2297
2588
  local _condition = (requestData.action)
2298
2589
  if _condition == nil then
2299
2590
  _condition = "tap"
@@ -2304,17 +2595,6 @@ local function simulateKeyboardInput(requestData)
2304
2595
  _condition_1 = 0.1
2305
2596
  end
2306
2597
  local duration = _condition_1
2307
- if not (keyCodeName ~= "" and keyCodeName) then
2308
- return {
2309
- error = "keyCode is required",
2310
- }
2311
- end
2312
- local vim = getVIM()
2313
- if not vim then
2314
- return {
2315
- error = "VirtualInputManager is not available in this context",
2316
- }
2317
- end
2318
2598
  local enumOk, keyCode = pcall(function()
2319
2599
  return (Enum.KeyCode)[keyCodeName]
2320
2600
  end)
@@ -2325,13 +2605,13 @@ local function simulateKeyboardInput(requestData)
2325
2605
  end
2326
2606
  local success, err = pcall(function()
2327
2607
  if action == "press" then
2328
- vim:SendKeyEvent(true, keyCode, false)
2608
+ vi:SendKey(true, keyCode)
2329
2609
  elseif action == "release" then
2330
- vim:SendKeyEvent(false, keyCode, false)
2610
+ vi:SendKey(false, keyCode)
2331
2611
  elseif action == "tap" then
2332
- vim:SendKeyEvent(true, keyCode, false)
2612
+ vi:SendKey(true, keyCode)
2333
2613
  task.wait(duration)
2334
- vim:SendKeyEvent(false, keyCode, false)
2614
+ vi:SendKey(false, keyCode)
2335
2615
  else
2336
2616
  error(`Unknown action: {action}`)
2337
2617
  end
@@ -5679,10 +5959,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5679
5959
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5680
5960
  local HttpService = _services.HttpService
5681
5961
  local LogService = _services.LogService
5962
+ local Players = _services.Players
5682
5963
  local RunService = _services.RunService
5683
5964
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5684
5965
  local installBridges = _EvalBridges.installBridges
5685
- local cleanupBridges = _EvalBridges.cleanupBridges
5966
+ local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
5686
5967
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5687
5968
  local StudioTestService = game:GetService("StudioTestService")
5688
5969
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -5700,6 +5981,63 @@ local testResult
5700
5981
  local testError
5701
5982
  local stopListenerScript
5702
5983
  local navResultCallback
5984
+ local multiplayerState = {
5985
+ phase = "idle",
5986
+ }
5987
+ local function detectPeerRole()
5988
+ if not RunService:IsRunning() then
5989
+ return "edit"
5990
+ end
5991
+ if RunService:IsServer() then
5992
+ return "server"
5993
+ end
5994
+ return "client"
5995
+ end
5996
+ local function getPlayersSnapshot()
5997
+ local _exp = Players:GetPlayers()
5998
+ -- ▼ ReadonlyArray.map ▼
5999
+ local _newValue = table.create(#_exp)
6000
+ local _callback = function(player)
6001
+ return {
6002
+ name = player.Name,
6003
+ userId = player.UserId,
6004
+ displayName = player.DisplayName,
6005
+ }
6006
+ end
6007
+ for _k, _v in _exp do
6008
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
6009
+ end
6010
+ -- ▲ ReadonlyArray.map ▲
6011
+ local players = _newValue
6012
+ table.sort(players, function(a, b)
6013
+ return a.name < b.name
6014
+ end)
6015
+ return players
6016
+ end
6017
+ local function cloneMultiplayerState()
6018
+ return {
6019
+ phase = multiplayerState.phase,
6020
+ testId = multiplayerState.testId,
6021
+ numPlayers = multiplayerState.numPlayers,
6022
+ testArgs = multiplayerState.testArgs,
6023
+ startedAt = multiplayerState.startedAt,
6024
+ completedAt = multiplayerState.completedAt,
6025
+ ok = multiplayerState.ok,
6026
+ result = multiplayerState.result,
6027
+ error = multiplayerState.error,
6028
+ }
6029
+ end
6030
+ local function normalizeNumPlayers(value)
6031
+ local _value = value
6032
+ if not (type(_value) == "number") then
6033
+ return nil
6034
+ end
6035
+ local n = math.floor(value)
6036
+ if n ~= value or n < 1 or n > 8 then
6037
+ return nil
6038
+ end
6039
+ return n
6040
+ end
5703
6041
  local function buildCommandListenerSource()
5704
6042
  return `local LogService = game:GetService("LogService")\
5705
6043
  local PathfindingService = game:GetService("PathfindingService")\
@@ -5796,6 +6134,11 @@ local function startPlaytest(requestData)
5796
6134
  error = 'mode must be "play" or "run"',
5797
6135
  }
5798
6136
  end
6137
+ if numPlayers ~= nil then
6138
+ return {
6139
+ error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
6140
+ }
6141
+ end
5799
6142
  -- Self-heal: if testRunning is stuck true but Studio reports no active
5800
6143
  -- playtest, the previous start_playtest's task.spawn was orphaned
5801
6144
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -5807,7 +6150,9 @@ local function startPlaytest(requestData)
5807
6150
  logConnection = nil
5808
6151
  end
5809
6152
  cleanupStopListener()
5810
- cleanupBridges()
6153
+ -- Note: eval bridges are intentionally NOT cleaned up — they live
6154
+ -- permanently in the edit DM so manual playtests also get them. See
6155
+ -- EvalBridges.ts lifecycle comment.
5811
6156
  end
5812
6157
  if testRunning then
5813
6158
  return {
@@ -5850,17 +6195,14 @@ local function startPlaytest(requestData)
5850
6195
  if not injected then
5851
6196
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5852
6197
  end
5853
- -- Auto-install the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
5854
- -- so eval_server_runtime / eval_client_runtime work without manual setup.
5855
- -- Bridges are cleaned up from the edit DM after the play DMs tear down.
6198
+ -- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
6199
+ -- right before cloning so the play DMs get the current source. They also
6200
+ -- live permanently in the edit DM (installed on connect) so manually-started
6201
+ -- playtests get them too; here we just ensure they're fresh.
5856
6202
  local bridgeInstall = installBridges()
5857
6203
  if not bridgeInstall.installed then
5858
6204
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
5859
6205
  end
5860
- if numPlayers ~= nil and mode == "run" then
5861
- local TestService = game:GetService("TestService")
5862
- TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
5863
- end
5864
6206
  task.spawn(function()
5865
6207
  local ok, result = pcall(function()
5866
6208
  if mode == "play" then
@@ -5879,12 +6221,13 @@ local function startPlaytest(requestData)
5879
6221
  end
5880
6222
  testRunning = false
5881
6223
  cleanupStopListener()
5882
- cleanupBridges()
6224
+ -- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
6225
+ -- clean up here, so the next manual playtest still gets them.
6226
+ ensureBridgesInstalled()
5883
6227
  end)
5884
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5885
6228
  local response = {
5886
6229
  success = true,
5887
- message = msg,
6230
+ message = `Playtest started in {mode} mode.`,
5888
6231
  }
5889
6232
  -- Only mention eval bridges when they failed — when they're fine, the
5890
6233
  -- detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -5967,6 +6310,198 @@ local function getPlaytestOutput(_requestData)
5967
6310
  _object.testError = testError
5968
6311
  return _object
5969
6312
  end
6313
+ local function multiplayerTestStart(requestData)
6314
+ if RunService:IsRunning() then
6315
+ return {
6316
+ error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
6317
+ }
6318
+ end
6319
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6320
+ if numPlayers == nil then
6321
+ return {
6322
+ error = "numPlayers must be an integer from 1 to 8",
6323
+ }
6324
+ end
6325
+ if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
6326
+ return {
6327
+ error = "A multiplayer Studio test is already running",
6328
+ state = cloneMultiplayerState(),
6329
+ }
6330
+ end
6331
+ local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
6332
+ local testId = HttpService:GenerateGUID(false)
6333
+ local bridgeInstall = installBridges()
6334
+ if not bridgeInstall.installed then
6335
+ warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6336
+ end
6337
+ multiplayerState = {
6338
+ phase = "starting",
6339
+ testId = testId,
6340
+ numPlayers = numPlayers,
6341
+ testArgs = testArgs,
6342
+ startedAt = tick(),
6343
+ }
6344
+ task.spawn(function()
6345
+ multiplayerState.phase = "running"
6346
+ local ok, result = pcall(function()
6347
+ return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
6348
+ end)
6349
+ multiplayerState.completedAt = tick()
6350
+ multiplayerState.ok = ok
6351
+ if ok then
6352
+ multiplayerState.phase = "completed"
6353
+ multiplayerState.result = result
6354
+ multiplayerState.error = nil
6355
+ else
6356
+ multiplayerState.phase = "failed"
6357
+ multiplayerState.result = nil
6358
+ multiplayerState.error = tostring(result)
6359
+ end
6360
+ ensureBridgesInstalled()
6361
+ end)
6362
+ local response = {
6363
+ success = true,
6364
+ message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
6365
+ testId = testId,
6366
+ phase = multiplayerState.phase,
6367
+ numPlayers = numPlayers,
6368
+ testArgs = testArgs,
6369
+ }
6370
+ if not bridgeInstall.installed then
6371
+ response.evalBridgesError = bridgeInstall.error
6372
+ end
6373
+ return response
6374
+ end
6375
+ local function multiplayerTestState(_requestData)
6376
+ local peer = detectPeerRole()
6377
+ local response = {
6378
+ success = true,
6379
+ peer = peer,
6380
+ isRunning = RunService:IsRunning(),
6381
+ isRunMode = RunService:IsRunMode(),
6382
+ editModeActive = StudioTestService.EditModeActive,
6383
+ }
6384
+ if peer == "edit" then
6385
+ response.session = cloneMultiplayerState()
6386
+ return response
6387
+ end
6388
+ local argsOk, args = pcall(function()
6389
+ return StudioTestService:GetTestArgs()
6390
+ end)
6391
+ response.testArgsOk = argsOk
6392
+ response.testArgs = if argsOk then args else nil
6393
+ if not argsOk then
6394
+ response.testArgsError = tostring(args)
6395
+ end
6396
+ local players = getPlayersSnapshot()
6397
+ response.players = players
6398
+ response.playerCount = #players
6399
+ if peer == "client" then
6400
+ response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6401
+ local canLeaveOk, canLeave = pcall(function()
6402
+ return StudioTestService:CanLeaveTest()
6403
+ end)
6404
+ response.canLeaveOk = canLeaveOk
6405
+ response.canLeave = if canLeaveOk then canLeave else false
6406
+ if not canLeaveOk then
6407
+ response.canLeaveError = tostring(canLeave)
6408
+ end
6409
+ end
6410
+ return response
6411
+ end
6412
+ local function multiplayerTestAddPlayers(requestData)
6413
+ if not RunService:IsRunning() or not RunService:IsServer() then
6414
+ return {
6415
+ error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
6416
+ }
6417
+ end
6418
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6419
+ if numPlayers == nil then
6420
+ return {
6421
+ error = "numPlayers must be an integer from 1 to 8",
6422
+ }
6423
+ end
6424
+ local before = #Players:GetPlayers()
6425
+ local ok, result = pcall(function()
6426
+ return StudioTestService:AddPlayers(numPlayers)
6427
+ end)
6428
+ if not ok then
6429
+ return {
6430
+ error = tostring(result),
6431
+ }
6432
+ end
6433
+ local _exp = tick()
6434
+ local _condition = (requestData.timeout)
6435
+ if _condition == nil then
6436
+ _condition = 10
6437
+ end
6438
+ local deadline = _exp + _condition
6439
+ while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
6440
+ task.wait(0.1)
6441
+ end
6442
+ local players = getPlayersSnapshot()
6443
+ return {
6444
+ success = true,
6445
+ message = `Requested {numPlayers} additional player(s).`,
6446
+ playerCount = #players,
6447
+ players = players,
6448
+ }
6449
+ end
6450
+ local function multiplayerTestLeaveClient(_requestData)
6451
+ if not RunService:IsRunning() or RunService:IsServer() then
6452
+ return {
6453
+ error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
6454
+ }
6455
+ end
6456
+ local canLeaveOk, canLeave = pcall(function()
6457
+ return StudioTestService:CanLeaveTest()
6458
+ end)
6459
+ if not canLeaveOk then
6460
+ return {
6461
+ error = tostring(canLeave),
6462
+ canLeaveOk = false,
6463
+ }
6464
+ end
6465
+ if not canLeave then
6466
+ return {
6467
+ error = "This client cannot leave the current test session.",
6468
+ canLeaveOk = true,
6469
+ canLeave = false,
6470
+ }
6471
+ end
6472
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6473
+ task.defer(function()
6474
+ pcall(function()
6475
+ return StudioTestService:LeaveTest()
6476
+ end)
6477
+ end)
6478
+ return {
6479
+ success = true,
6480
+ message = "Client leave requested.",
6481
+ localPlayer = localPlayer,
6482
+ }
6483
+ end
6484
+ local function multiplayerTestEnd(requestData)
6485
+ if not RunService:IsRunning() or not RunService:IsServer() then
6486
+ return {
6487
+ error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
6488
+ }
6489
+ end
6490
+ local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
6491
+ local ok, result = pcall(function()
6492
+ return StudioTestService:EndTest(value)
6493
+ end)
6494
+ if not ok then
6495
+ return {
6496
+ error = tostring(result),
6497
+ }
6498
+ end
6499
+ return {
6500
+ success = true,
6501
+ message = "Multiplayer Studio test end requested.",
6502
+ value = value,
6503
+ }
6504
+ end
5970
6505
  local function characterNavigation(requestData)
5971
6506
  if not testRunning then
5972
6507
  return {
@@ -6038,6 +6573,11 @@ return {
6038
6573
  startPlaytest = startPlaytest,
6039
6574
  stopPlaytest = stopPlaytest,
6040
6575
  getPlaytestOutput = getPlaytestOutput,
6576
+ multiplayerTestStart = multiplayerTestStart,
6577
+ multiplayerTestState = multiplayerTestState,
6578
+ multiplayerTestAddPlayers = multiplayerTestAddPlayers,
6579
+ multiplayerTestLeaveClient = multiplayerTestLeaveClient,
6580
+ multiplayerTestEnd = multiplayerTestEnd,
6041
6581
  characterNavigation = characterNavigation,
6042
6582
  }
6043
6583
  ]]></string>
@@ -6393,6 +6933,74 @@ return {
6393
6933
  </Properties>
6394
6934
  </Item>
6395
6935
  <Item class="ModuleScript" referent="21">
6936
+ <Properties>
6937
+ <string name="Name">RenderMonitor</string>
6938
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6939
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
6940
+ -- Detects whether the Studio window is actually rendering, so virtual input
6941
+ -- and screenshot tools can surface a clear reason instead of silently failing.
6942
+ --
6943
+ -- When a Studio window is MINIMIZED, the engine suspends the render loop AND
6944
+ -- input processing, but keeps running scripts (Heartbeat keeps firing). That's
6945
+ -- why simulate_*_input would return success while having zero effect, and
6946
+ -- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
6947
+ -- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
6948
+ -- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
6949
+ -- signal; Heartbeat is not.
6950
+ local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
6951
+ local lastFrame = 0
6952
+ local connected = false
6953
+ -- Above this many seconds since the last rendered frame, we treat the window
6954
+ -- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
6955
+ -- gap only happens when minimized/suspended, so 1s cleanly avoids false
6956
+ -- positives from ordinary frame hitches while still catching the real case.
6957
+ local STALE_THRESHOLD = 1.0
6958
+ local function start()
6959
+ if connected then
6960
+ return nil
6961
+ end
6962
+ -- RenderStepped can only be connected from a client/edit render loop; it
6963
+ -- throws in the play-server DM. pcall so a server-DM call is a safe no-op
6964
+ -- (connected stays false → notRenderingReason() returns undefined there).
6965
+ local ok = pcall(function()
6966
+ RunService.RenderStepped:Connect(function()
6967
+ lastFrame = tick()
6968
+ end)
6969
+ end)
6970
+ if ok then
6971
+ connected = true
6972
+ lastFrame = tick()
6973
+ end
6974
+ end
6975
+ local function secondsSinceFrame()
6976
+ if not connected then
6977
+ return 0
6978
+ end
6979
+ return tick() - lastFrame
6980
+ end
6981
+ -- Returns a human-readable reason if the window appears minimized / not
6982
+ -- rendering (so input + screenshots won't work), else undefined. Fail-open:
6983
+ -- when the monitor isn't active in this DM (server peer, or connect failed) it
6984
+ -- returns undefined so we never block on a false signal.
6985
+ local function notRenderingReason()
6986
+ if not connected then
6987
+ return nil
6988
+ end
6989
+ local gap = secondsSinceFrame()
6990
+ if gap > STALE_THRESHOLD then
6991
+ return string.format("Studio window appears minimized or not rendering (no frame in %.1fs). " .. "Virtual input and screenshots only work while the window is visible — " .. "restore/un-minimize the Studio window and retry.", gap)
6992
+ end
6993
+ return nil
6994
+ end
6995
+ return {
6996
+ start = start,
6997
+ secondsSinceFrame = secondsSinceFrame,
6998
+ notRenderingReason = notRenderingReason,
6999
+ }
7000
+ ]]></string>
7001
+ </Properties>
7002
+ </Item>
7003
+ <Item class="ModuleScript" referent="22">
6396
7004
  <Properties>
6397
7005
  <string name="Name">RuntimeLogBuffer</string>
6398
7006
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6573,11 +7181,11 @@ return {
6573
7181
  ]]></string>
6574
7182
  </Properties>
6575
7183
  </Item>
6576
- <Item class="ModuleScript" referent="22">
7184
+ <Item class="ModuleScript" referent="23">
6577
7185
  <Properties>
6578
7186
  <string name="Name">State</string>
6579
7187
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6580
- local CURRENT_VERSION = "2.12.0"
7188
+ local CURRENT_VERSION = "2.14.0"
6581
7189
  local MAX_CONNECTIONS = 5
6582
7190
  local BASE_PORT = 58741
6583
7191
  local activeTabIndex = 0
@@ -6669,7 +7277,7 @@ return {
6669
7277
  ]]></string>
6670
7278
  </Properties>
6671
7279
  </Item>
6672
- <Item class="ModuleScript" referent="23">
7280
+ <Item class="ModuleScript" referent="24">
6673
7281
  <Properties>
6674
7282
  <string name="Name">StopPlayMonitor</string>
6675
7283
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6814,7 +7422,7 @@ return {
6814
7422
  ]]></string>
6815
7423
  </Properties>
6816
7424
  </Item>
6817
- <Item class="ModuleScript" referent="24">
7425
+ <Item class="ModuleScript" referent="25">
6818
7426
  <Properties>
6819
7427
  <string name="Name">UI</string>
6820
7428
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7565,7 +8173,7 @@ return {
7565
8173
  ]]></string>
7566
8174
  </Properties>
7567
8175
  </Item>
7568
- <Item class="ModuleScript" referent="25">
8176
+ <Item class="ModuleScript" referent="26">
7569
8177
  <Properties>
7570
8178
  <string name="Name">Utils</string>
7571
8179
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8095,11 +8703,11 @@ return {
8095
8703
  </Properties>
8096
8704
  </Item>
8097
8705
  </Item>
8098
- <Item class="Folder" referent="29">
8706
+ <Item class="Folder" referent="30">
8099
8707
  <Properties>
8100
8708
  <string name="Name">include</string>
8101
8709
  </Properties>
8102
- <Item class="ModuleScript" referent="26">
8710
+ <Item class="ModuleScript" referent="27">
8103
8711
  <Properties>
8104
8712
  <string name="Name">Promise</string>
8105
8713
  <string name="Source"><![CDATA[--[[
@@ -10173,7 +10781,7 @@ return Promise
10173
10781
  ]]></string>
10174
10782
  </Properties>
10175
10783
  </Item>
10176
- <Item class="ModuleScript" referent="27">
10784
+ <Item class="ModuleScript" referent="28">
10177
10785
  <Properties>
10178
10786
  <string name="Name">RuntimeLib</string>
10179
10787
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10440,15 +11048,15 @@ return TS
10440
11048
  </Properties>
10441
11049
  </Item>
10442
11050
  </Item>
10443
- <Item class="Folder" referent="30">
11051
+ <Item class="Folder" referent="31">
10444
11052
  <Properties>
10445
11053
  <string name="Name">node_modules</string>
10446
11054
  </Properties>
10447
- <Item class="Folder" referent="31">
11055
+ <Item class="Folder" referent="32">
10448
11056
  <Properties>
10449
11057
  <string name="Name">@rbxts</string>
10450
11058
  </Properties>
10451
- <Item class="ModuleScript" referent="28">
11059
+ <Item class="ModuleScript" referent="29">
10452
11060
  <Properties>
10453
11061
  <string name="Name">services</string>
10454
11062
  <string name="Source"><![CDATA[return setmetatable({}, {