@chrrxs/robloxstudio-mcp-inspector 2.12.0 → 2.13.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,6 +102,8 @@ 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")
101
108
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
102
109
  -- client broker runs in the play-server DM where it can't easily import from
@@ -158,6 +165,9 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
158
165
  ["/api/execute-luau"] = true,
159
166
  ["/api/get-runtime-logs"] = true,
160
167
  ["/api/get-memory-breakdown"] = true,
168
+ ["/api/capture-begin"] = true,
169
+ ["/api/simulate-mouse-input"] = true,
170
+ ["/api/simulate-keyboard-input"] = true,
161
171
  }
162
172
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
163
173
  -- polls doesn't cause a re-register stampede.
@@ -255,6 +265,15 @@ local function setupClientBroker()
255
265
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
256
266
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
257
267
  end
268
+ if payload and payload.endpoint == "/api/capture-begin" then
269
+ return CaptureHandlers.captureBegin()
270
+ end
271
+ if payload and payload.endpoint == "/api/simulate-mouse-input" then
272
+ return InputHandlers.simulateMouseInput(payload.data or {})
273
+ end
274
+ if payload and payload.endpoint == "/api/simulate-keyboard-input" then
275
+ return InputHandlers.simulateKeyboardInput(payload.data or {})
276
+ end
258
277
  if payload and payload.endpoint == "/api/execute-luau" then
259
278
  return handleExecuteLuau(payload.data)
260
279
  end
@@ -318,8 +337,12 @@ local function pollProxy(proxyId, player, rf)
318
337
  }
319
338
  end
320
339
  else
340
+ local allowed = {}
341
+ for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
342
+ table.insert(allowed, ep)
343
+ end
321
344
  response = {
322
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
345
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
323
346
  }
324
347
  end
325
348
  postJson("/response", {
@@ -424,6 +447,7 @@ local ServerStorage = _services.ServerStorage
424
447
  local State = TS.import(script, script.Parent, "State")
425
448
  local Utils = TS.import(script, script.Parent, "Utils")
426
449
  local UI = TS.import(script, script.Parent, "UI")
450
+ local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
427
451
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
428
452
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
429
453
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -555,6 +579,8 @@ local routeMap = {
555
579
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
556
580
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
557
581
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
582
+ ["/api/capture-begin"] = CaptureHandlers.captureBegin,
583
+ ["/api/capture-read"] = CaptureHandlers.captureRead,
558
584
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
559
585
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
560
586
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -895,6 +921,19 @@ local function activatePlugin(connIndex)
895
921
  -- Initial /ready; pollForRequests will also re-fire ready if the server
896
922
  -- later reports knownInstance=false (process restart, etc).
897
923
  sendReady(conn)
924
+ -- Keep the eval bridges present in the edit DM so that ANY playtest —
925
+ -- including one the dev starts manually via the Studio Play button —
926
+ -- clones them into the play DMs and eval_*_runtime works with no setup
927
+ -- roundtrip. Only the edit DM installs; play DMs already have the cloned
928
+ -- copies. Idempotent, so reconnects don't re-dirty the place.
929
+ if not RunService:IsRunning() then
930
+ task.spawn(function()
931
+ local result = ensureBridgesInstalled()
932
+ if not result.installed then
933
+ warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
934
+ end
935
+ end)
936
+ end
898
937
  -- Watch for game.Name updates so a stale "Place1" captured at first
899
938
  -- /ready gets refreshed once Studio settles on the real DM name.
900
939
  ensureNameChangeWatcher(conn)
@@ -1016,11 +1055,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
1016
1055
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
1017
1056
  -- when LoadStringEnabled=false (the default in fresh places).
1018
1057
  --
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).
1058
+ -- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
1059
+ -- installs them (ensureBridgesInstalled) when the plugin connects in edit,
1060
+ -- and TestHandlers.startPlaytest force-refreshes them right before
1061
+ -- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
1062
+ -- play DMs, so the scripts come along and run there. We keep them in the edit
1063
+ -- DM after a playtest ends (rather than cleaning up) so that a playtest the
1064
+ -- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
1065
+ -- tool — also gets the bridges cloned in. This is intentionally a little
1066
+ -- intrusive (two helper scripts visible in Explorer) in exchange for a
1067
+ -- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
1024
1068
  --
1025
1069
  -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
1026
1070
  -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -1052,9 +1096,9 @@ local BRIDGE_NAMES = {
1052
1096
  -- Embedded Luau. The double `${...}` references our exported names so a
1053
1097
  -- rename here propagates to both the script source and the tool wrappers.
1054
1098
  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.\
1099
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
1100
+ -- tool (shared-require-cache eval on the server during playtests). Inert\
1101
+ -- outside Studio (no-ops in live games); safe to leave in place.\
1058
1102
  \
1059
1103
  local ServerScriptService = game:GetService("ServerScriptService")\
1060
1104
  local RunService = game:GetService("RunService")\
@@ -1078,9 +1122,9 @@ bf.OnInvoke = function(payload)\
1078
1122
  end\
1079
1123
  `
1080
1124
  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.\
1125
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
1126
+ -- tool (shared-require-cache eval on the client during playtests). Inert\
1127
+ -- outside Studio (no-ops in live games); safe to leave in place.\
1084
1128
  \
1085
1129
  local ReplicatedStorage = game:GetService("ReplicatedStorage")\
1086
1130
  local RunService = game:GetService("RunService")\
@@ -1103,6 +1147,25 @@ bf.OnInvoke = function(payload)\
1103
1147
  return pcall(require, payload)\
1104
1148
  end\
1105
1149
  `
1150
+ -- Stamp written onto each installed bridge Script so we can tell whether the
1151
+ -- bridge currently in the DM was produced by THIS plugin build. It's a djb2
1152
+ -- hash of the actual bridge source plus the plugin version, so ANY change to
1153
+ -- the source (or a version bump) yields a new stamp — which makes
1154
+ -- ensureBridgesInstalled() force a refresh on the next plugin load instead of
1155
+ -- keeping a stale bridge that happens to still be present (e.g. one saved into
1156
+ -- the .rbxl from an older build).
1157
+ local STAMP_ATTR = "__MCPBridgeStamp"
1158
+ local function computeBridgeStamp()
1159
+ local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
1160
+ local h = 5381
1161
+ for i = 1, #combined do
1162
+ h = (h * 33 + (string.byte(combined, i))) % 2147483647
1163
+ end
1164
+ -- "2.13.0" is replaced with the package version at package time
1165
+ -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1166
+ return `{tostring(h)}-2.13.0`
1167
+ end
1168
+ local BRIDGE_STAMP = computeBridgeStamp()
1106
1169
  local function setSource(scriptInst, source)
1107
1170
  -- ScriptEditorService is the cleaner API and integrates with Studio's
1108
1171
  -- edit history; fall back to direct Source mutation (allowed in plugin
@@ -1138,7 +1201,31 @@ local function cleanupBridges()
1138
1201
  end)
1139
1202
  end
1140
1203
  end
1141
- local function installBridges()
1204
+ -- Idempotent variant: install only if the bridge scripts aren't already
1205
+ -- present in the edit DM. Used to keep the bridges always available (so a
1206
+ -- playtest the dev starts manually — not via the MCP start_playtest tool —
1207
+ -- still clones them into the play DMs). Cheap no-op when already installed,
1208
+ -- which avoids re-dirtying the place on every plugin reconnect.
1209
+ local installBridges
1210
+ local function ensureBridgesInstalled()
1211
+ local _binding = findBridges()
1212
+ local server = _binding.server
1213
+ local client = _binding.client
1214
+ if server and client then
1215
+ -- Both present — but only skip the reinstall if they were produced by
1216
+ -- THIS build. A mismatched/absent stamp means a stale bridge (older
1217
+ -- plugin, or one persisted in the saved place), so force a refresh.
1218
+ local sStamp = server:GetAttribute(STAMP_ATTR)
1219
+ local cStamp = client:GetAttribute(STAMP_ATTR)
1220
+ if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
1221
+ return {
1222
+ installed = true,
1223
+ }
1224
+ end
1225
+ end
1226
+ return installBridges()
1227
+ end
1228
+ function installBridges()
1142
1229
  -- Defensive: clear any stale bridges from a prior unclean exit before
1143
1230
  -- inserting fresh. The injected script also self-cleans its
1144
1231
  -- ReplicatedStorage/ServerScriptService children at startup, but the
@@ -1151,6 +1238,7 @@ local function installBridges()
1151
1238
  -- script. cleanupBridges() removes it from the edit DM when the
1152
1239
  -- playtest ends.
1153
1240
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1241
+ serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1154
1242
  serverScript.Parent = ServerScriptService
1155
1243
  local sps = getStarterPlayerScripts()
1156
1244
  if not sps then
@@ -1159,6 +1247,7 @@ local function installBridges()
1159
1247
  local clientScript = Instance.new("LocalScript")
1160
1248
  clientScript.Name = CLIENT_SCRIPT_NAME
1161
1249
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1250
+ clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1162
1251
  clientScript.Parent = sps
1163
1252
  end)
1164
1253
  if not ok then
@@ -1173,6 +1262,7 @@ local function installBridges()
1173
1262
  end
1174
1263
  return {
1175
1264
  cleanupBridges = cleanupBridges,
1265
+ ensureBridgesInstalled = ensureBridgesInstalled,
1176
1266
  installBridges = installBridges,
1177
1267
  BRIDGE_NAMES = BRIDGE_NAMES,
1178
1268
  }
@@ -2038,6 +2128,8 @@ return {
2038
2128
  <Properties>
2039
2129
  <string name="Name">CaptureHandlers</string>
2040
2130
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2131
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2132
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2041
2133
  local CaptureService = game:GetService("CaptureService")
2042
2134
  local AssetService = game:GetService("AssetService")
2043
2135
  local MAX_TILE_SIZE = 1024
@@ -2147,7 +2239,20 @@ local function readPixelsTiled(img, w, h)
2147
2239
  end
2148
2240
  return fullBuf
2149
2241
  end
2150
- local function captureScreenshotData()
2242
+ -- Triggers CaptureService:CaptureScreenshot and waits for the temporary
2243
+ -- content id. Works in any DM, including the play CLIENT (where reading the
2244
+ -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
2245
+ -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
2246
+ -- privileged DM (the edit DM) — see captureRead.
2247
+ local function doCaptureScreenshot()
2248
+ -- Fast-fail with a clear reason if the window isn't rendering — otherwise
2249
+ -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
2250
+ local notRendering = RenderMonitor.notRenderingReason()
2251
+ if notRendering ~= nil then
2252
+ return {
2253
+ error = notRendering,
2254
+ }
2255
+ end
2151
2256
  local contentId
2152
2257
  CaptureService:CaptureScreenshot(function(id)
2153
2258
  contentId = id
@@ -2156,11 +2261,21 @@ local function captureScreenshotData()
2156
2261
  while contentId == nil do
2157
2262
  if tick() - startTime > 10 then
2158
2263
  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.",
2264
+ 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
2265
  }
2161
2266
  end
2162
2267
  task.wait(0.1)
2163
2268
  end
2269
+ return {
2270
+ contentId = contentId,
2271
+ }
2272
+ end
2273
+ -- Promotes a CaptureScreenshot content id into an EditableImage and reads its
2274
+ -- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
2275
+ -- the privilege to create an EditableImage from a temporary texture id (errors
2276
+ -- "cannot currently create editable image from temporary texture id"), while
2277
+ -- the edit DM can — even for an id captured in the play client DM.
2278
+ local function readContentToBase64(contentId)
2164
2279
  local editableOk, editableResult = pcall(function()
2165
2280
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2166
2281
  end)
@@ -2190,12 +2305,36 @@ local function captureScreenshotData()
2190
2305
  data = base64Data,
2191
2306
  }
2192
2307
  end
2308
+ -- Edit-mode single shot: capture and read back in the same (edit) context.
2309
+ local function captureScreenshotData()
2310
+ local cap = doCaptureScreenshot()
2311
+ if cap.error ~= nil then
2312
+ return cap
2313
+ end
2314
+ return readContentToBase64(cap.contentId)
2315
+ end
2193
2316
  local function captureScreenshot()
2194
2317
  return captureScreenshotData()
2195
2318
  end
2319
+ -- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
2320
+ local function captureBegin()
2321
+ return doCaptureScreenshot()
2322
+ end
2323
+ -- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
2324
+ local function captureRead(requestData)
2325
+ local contentId = requestData.contentId
2326
+ if not (contentId ~= "" and contentId) then
2327
+ return {
2328
+ error = "contentId is required",
2329
+ }
2330
+ end
2331
+ return readContentToBase64(contentId)
2332
+ end
2196
2333
  return {
2197
2334
  captureScreenshotData = captureScreenshotData,
2198
2335
  captureScreenshot = captureScreenshot,
2336
+ captureBegin = captureBegin,
2337
+ captureRead = captureRead,
2199
2338
  }
2200
2339
  ]]></string>
2201
2340
  </Properties>
@@ -2204,19 +2343,56 @@ return {
2204
2343
  <Properties>
2205
2344
  <string name="Name">InputHandlers</string>
2206
2345
  <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")
2346
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2347
+ -- Virtual input via UserInputService:CreateVirtualInput().
2348
+ --
2349
+ -- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
2350
+ -- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
2351
+ -- in every context a plugin can reach (edit DM, play server/client DMs), so
2352
+ -- they silently never worked. CreateVirtualInput() is callable without that
2353
+ -- capability and drives the REAL input pipeline: SendKey feeds
2354
+ -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
2355
+ -- character at full WalkSpeed with controls intact, no Humanoid hijack),
2356
+ -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
2357
+ -- CoreGui), and SendTextInput types into the focused TextBox.
2358
+ --
2359
+ -- Method set on the VirtualInput object (verified live):
2360
+ -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
2361
+ -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
2362
+ -- SendTextInput(text: string)
2363
+ -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
2364
+ -- "scroll" mouse actions are not supported.
2365
+ --
2366
+ -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
2367
+ -- what capture_screenshot returns (window space, origin at the top-left of the
2368
+ -- rendered viewport). Pass screenshot pixel coordinates straight through. Note
2369
+ -- that UserInputService reports input positions in GUI space, which is offset
2370
+ -- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
2371
+ -- callers who pick coordinates off a screenshot, which is why we do not
2372
+ -- translate here.
2373
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2374
+ local UserInputService = game:GetService("UserInputService")
2375
+ -- One VirtualInput per plugin VM, reused across calls so that a key held down
2376
+ -- in one call (action="press") and released in a later call (action="release")
2377
+ -- share the same input source.
2378
+ local cachedVI
2379
+ local function getVI()
2380
+ if cachedVI then
2381
+ return cachedVI
2382
+ end
2383
+ local ok, vi = pcall(function()
2384
+ return UserInputService:CreateVirtualInput()
2210
2385
  end)
2211
- if ok and result then
2212
- return result
2386
+ if ok and vi ~= nil then
2387
+ cachedVI = vi
2388
+ return cachedVI
2213
2389
  end
2214
2390
  return nil
2215
2391
  end
2216
- local BUTTON_MAP = {
2217
- Left = 0,
2218
- Right = 1,
2219
- Middle = 2,
2392
+ local MOUSE_TYPE_MAP = {
2393
+ Left = Enum.UserInputType.MouseButton1,
2394
+ Right = Enum.UserInputType.MouseButton2,
2395
+ Middle = Enum.UserInputType.MouseButton3,
2220
2396
  }
2221
2397
  local function simulateMouseInput(requestData)
2222
2398
  local action = requestData.action
@@ -2227,56 +2403,43 @@ local function simulateMouseInput(requestData)
2227
2403
  _condition = "Left"
2228
2404
  end
2229
2405
  local button = _condition
2230
- local scrollDirection = requestData.scrollDirection
2231
2406
  if not (action ~= "" and action) then
2232
2407
  return {
2233
2408
  error = "action is required",
2234
2409
  }
2235
2410
  end
2236
- local vim = getVIM()
2237
- if not vim then
2411
+ if x == nil or y == nil then
2238
2412
  return {
2239
- error = "VirtualInputManager is not available in this context",
2413
+ error = "x and y are required",
2240
2414
  }
2241
2415
  end
2242
- local _condition_1 = BUTTON_MAP[button]
2243
- if _condition_1 == nil then
2244
- _condition_1 = 0
2416
+ -- Input is silently dropped by the engine when the window isn't rendering
2417
+ -- (e.g. minimized). Surface that instead of returning a false success.
2418
+ local notRendering = RenderMonitor.notRenderingReason()
2419
+ if notRendering ~= nil then
2420
+ return {
2421
+ error = notRendering,
2422
+ }
2423
+ end
2424
+ local vi = getVI()
2425
+ if not vi then
2426
+ return {
2427
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2428
+ }
2245
2429
  end
2246
- local buttonNum = _condition_1
2430
+ local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
2431
+ local pos = Vector2.new(x, y)
2247
2432
  local success, err = pcall(function()
2248
2433
  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)
2434
+ vi:SendMouseButton(pos, inputType, true)
2253
2435
  task.wait(0.05)
2254
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2436
+ vi:SendMouseButton(pos, inputType, false)
2255
2437
  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)
2438
+ vi:SendMouseButton(pos, inputType, true)
2260
2439
  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")
2440
+ vi:SendMouseButton(pos, inputType, false)
2278
2441
  else
2279
- error(`Unknown action: {action}`)
2442
+ error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
2280
2443
  end
2281
2444
  end)
2282
2445
  if success then
@@ -2293,7 +2456,40 @@ local function simulateMouseInput(requestData)
2293
2456
  }
2294
2457
  end
2295
2458
  local function simulateKeyboardInput(requestData)
2459
+ local notRendering = RenderMonitor.notRenderingReason()
2460
+ if notRendering ~= nil then
2461
+ return {
2462
+ error = notRendering,
2463
+ }
2464
+ end
2465
+ local vi = getVI()
2466
+ if not vi then
2467
+ return {
2468
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2469
+ }
2470
+ end
2471
+ -- Text mode: type a string into the focused TextBox.
2472
+ local text = requestData.text
2473
+ if text ~= nil then
2474
+ local ok, err = pcall(function()
2475
+ return vi:SendTextInput(text)
2476
+ end)
2477
+ if ok then
2478
+ return {
2479
+ success = true,
2480
+ text = text,
2481
+ }
2482
+ end
2483
+ return {
2484
+ error = `Failed to send text input: {err}`,
2485
+ }
2486
+ end
2296
2487
  local keyCodeName = requestData.keyCode
2488
+ if not (keyCodeName ~= "" and keyCodeName) then
2489
+ return {
2490
+ error = "keyCode (or text) is required",
2491
+ }
2492
+ end
2297
2493
  local _condition = (requestData.action)
2298
2494
  if _condition == nil then
2299
2495
  _condition = "tap"
@@ -2304,17 +2500,6 @@ local function simulateKeyboardInput(requestData)
2304
2500
  _condition_1 = 0.1
2305
2501
  end
2306
2502
  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
2503
  local enumOk, keyCode = pcall(function()
2319
2504
  return (Enum.KeyCode)[keyCodeName]
2320
2505
  end)
@@ -2325,13 +2510,13 @@ local function simulateKeyboardInput(requestData)
2325
2510
  end
2326
2511
  local success, err = pcall(function()
2327
2512
  if action == "press" then
2328
- vim:SendKeyEvent(true, keyCode, false)
2513
+ vi:SendKey(true, keyCode)
2329
2514
  elseif action == "release" then
2330
- vim:SendKeyEvent(false, keyCode, false)
2515
+ vi:SendKey(false, keyCode)
2331
2516
  elseif action == "tap" then
2332
- vim:SendKeyEvent(true, keyCode, false)
2517
+ vi:SendKey(true, keyCode)
2333
2518
  task.wait(duration)
2334
- vim:SendKeyEvent(false, keyCode, false)
2519
+ vi:SendKey(false, keyCode)
2335
2520
  else
2336
2521
  error(`Unknown action: {action}`)
2337
2522
  end
@@ -5682,7 +5867,7 @@ local LogService = _services.LogService
5682
5867
  local RunService = _services.RunService
5683
5868
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5684
5869
  local installBridges = _EvalBridges.installBridges
5685
- local cleanupBridges = _EvalBridges.cleanupBridges
5870
+ local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
5686
5871
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5687
5872
  local StudioTestService = game:GetService("StudioTestService")
5688
5873
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -5807,7 +5992,9 @@ local function startPlaytest(requestData)
5807
5992
  logConnection = nil
5808
5993
  end
5809
5994
  cleanupStopListener()
5810
- cleanupBridges()
5995
+ -- Note: eval bridges are intentionally NOT cleaned up — they live
5996
+ -- permanently in the edit DM so manual playtests also get them. See
5997
+ -- EvalBridges.ts lifecycle comment.
5811
5998
  end
5812
5999
  if testRunning then
5813
6000
  return {
@@ -5850,9 +6037,10 @@ local function startPlaytest(requestData)
5850
6037
  if not injected then
5851
6038
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5852
6039
  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.
6040
+ -- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
6041
+ -- right before cloning so the play DMs get the current source. They also
6042
+ -- live permanently in the edit DM (installed on connect) so manually-started
6043
+ -- playtests get them too; here we just ensure they're fresh.
5856
6044
  local bridgeInstall = installBridges()
5857
6045
  if not bridgeInstall.installed then
5858
6046
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
@@ -5879,7 +6067,9 @@ local function startPlaytest(requestData)
5879
6067
  end
5880
6068
  testRunning = false
5881
6069
  cleanupStopListener()
5882
- cleanupBridges()
6070
+ -- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
6071
+ -- clean up here, so the next manual playtest still gets them.
6072
+ ensureBridgesInstalled()
5883
6073
  end)
5884
6074
  local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5885
6075
  local response = {
@@ -6393,6 +6583,74 @@ return {
6393
6583
  </Properties>
6394
6584
  </Item>
6395
6585
  <Item class="ModuleScript" referent="21">
6586
+ <Properties>
6587
+ <string name="Name">RenderMonitor</string>
6588
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6589
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
6590
+ -- Detects whether the Studio window is actually rendering, so virtual input
6591
+ -- and screenshot tools can surface a clear reason instead of silently failing.
6592
+ --
6593
+ -- When a Studio window is MINIMIZED, the engine suspends the render loop AND
6594
+ -- input processing, but keeps running scripts (Heartbeat keeps firing). That's
6595
+ -- why simulate_*_input would return success while having zero effect, and
6596
+ -- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
6597
+ -- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
6598
+ -- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
6599
+ -- signal; Heartbeat is not.
6600
+ local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
6601
+ local lastFrame = 0
6602
+ local connected = false
6603
+ -- Above this many seconds since the last rendered frame, we treat the window
6604
+ -- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
6605
+ -- gap only happens when minimized/suspended, so 1s cleanly avoids false
6606
+ -- positives from ordinary frame hitches while still catching the real case.
6607
+ local STALE_THRESHOLD = 1.0
6608
+ local function start()
6609
+ if connected then
6610
+ return nil
6611
+ end
6612
+ -- RenderStepped can only be connected from a client/edit render loop; it
6613
+ -- throws in the play-server DM. pcall so a server-DM call is a safe no-op
6614
+ -- (connected stays false → notRenderingReason() returns undefined there).
6615
+ local ok = pcall(function()
6616
+ RunService.RenderStepped:Connect(function()
6617
+ lastFrame = tick()
6618
+ end)
6619
+ end)
6620
+ if ok then
6621
+ connected = true
6622
+ lastFrame = tick()
6623
+ end
6624
+ end
6625
+ local function secondsSinceFrame()
6626
+ if not connected then
6627
+ return 0
6628
+ end
6629
+ return tick() - lastFrame
6630
+ end
6631
+ -- Returns a human-readable reason if the window appears minimized / not
6632
+ -- rendering (so input + screenshots won't work), else undefined. Fail-open:
6633
+ -- when the monitor isn't active in this DM (server peer, or connect failed) it
6634
+ -- returns undefined so we never block on a false signal.
6635
+ local function notRenderingReason()
6636
+ if not connected then
6637
+ return nil
6638
+ end
6639
+ local gap = secondsSinceFrame()
6640
+ if gap > STALE_THRESHOLD then
6641
+ 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)
6642
+ end
6643
+ return nil
6644
+ end
6645
+ return {
6646
+ start = start,
6647
+ secondsSinceFrame = secondsSinceFrame,
6648
+ notRenderingReason = notRenderingReason,
6649
+ }
6650
+ ]]></string>
6651
+ </Properties>
6652
+ </Item>
6653
+ <Item class="ModuleScript" referent="22">
6396
6654
  <Properties>
6397
6655
  <string name="Name">RuntimeLogBuffer</string>
6398
6656
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6573,11 +6831,11 @@ return {
6573
6831
  ]]></string>
6574
6832
  </Properties>
6575
6833
  </Item>
6576
- <Item class="ModuleScript" referent="22">
6834
+ <Item class="ModuleScript" referent="23">
6577
6835
  <Properties>
6578
6836
  <string name="Name">State</string>
6579
6837
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6580
- local CURRENT_VERSION = "2.12.0"
6838
+ local CURRENT_VERSION = "2.13.0"
6581
6839
  local MAX_CONNECTIONS = 5
6582
6840
  local BASE_PORT = 58741
6583
6841
  local activeTabIndex = 0
@@ -6669,7 +6927,7 @@ return {
6669
6927
  ]]></string>
6670
6928
  </Properties>
6671
6929
  </Item>
6672
- <Item class="ModuleScript" referent="23">
6930
+ <Item class="ModuleScript" referent="24">
6673
6931
  <Properties>
6674
6932
  <string name="Name">StopPlayMonitor</string>
6675
6933
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6814,7 +7072,7 @@ return {
6814
7072
  ]]></string>
6815
7073
  </Properties>
6816
7074
  </Item>
6817
- <Item class="ModuleScript" referent="24">
7075
+ <Item class="ModuleScript" referent="25">
6818
7076
  <Properties>
6819
7077
  <string name="Name">UI</string>
6820
7078
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7565,7 +7823,7 @@ return {
7565
7823
  ]]></string>
7566
7824
  </Properties>
7567
7825
  </Item>
7568
- <Item class="ModuleScript" referent="25">
7826
+ <Item class="ModuleScript" referent="26">
7569
7827
  <Properties>
7570
7828
  <string name="Name">Utils</string>
7571
7829
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8095,11 +8353,11 @@ return {
8095
8353
  </Properties>
8096
8354
  </Item>
8097
8355
  </Item>
8098
- <Item class="Folder" referent="29">
8356
+ <Item class="Folder" referent="30">
8099
8357
  <Properties>
8100
8358
  <string name="Name">include</string>
8101
8359
  </Properties>
8102
- <Item class="ModuleScript" referent="26">
8360
+ <Item class="ModuleScript" referent="27">
8103
8361
  <Properties>
8104
8362
  <string name="Name">Promise</string>
8105
8363
  <string name="Source"><![CDATA[--[[
@@ -10173,7 +10431,7 @@ return Promise
10173
10431
  ]]></string>
10174
10432
  </Properties>
10175
10433
  </Item>
10176
- <Item class="ModuleScript" referent="27">
10434
+ <Item class="ModuleScript" referent="28">
10177
10435
  <Properties>
10178
10436
  <string name="Name">RuntimeLib</string>
10179
10437
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10440,15 +10698,15 @@ return TS
10440
10698
  </Properties>
10441
10699
  </Item>
10442
10700
  </Item>
10443
- <Item class="Folder" referent="30">
10701
+ <Item class="Folder" referent="31">
10444
10702
  <Properties>
10445
10703
  <string name="Name">node_modules</string>
10446
10704
  </Properties>
10447
- <Item class="Folder" referent="31">
10705
+ <Item class="Folder" referent="32">
10448
10706
  <Properties>
10449
10707
  <string name="Name">@rbxts</string>
10450
10708
  </Properties>
10451
- <Item class="ModuleScript" referent="28">
10709
+ <Item class="ModuleScript" referent="29">
10452
10710
  <Properties>
10453
10711
  <string name="Name">services</string>
10454
10712
  <string name="Source"><![CDATA[return setmetatable({}, {