@chrrxs/robloxstudio-mcp-inspector 2.11.4 → 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().
@@ -94,8 +99,52 @@ local HttpService = _services.HttpService
94
99
  local Players = _services.Players
95
100
  local ReplicatedStorage = _services.ReplicatedStorage
96
101
  local RunService = _services.RunService
102
+ local ServerStorage = _services.ServerStorage
97
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
98
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")
107
+ local LuauExec = TS.import(script, script.Parent, "LuauExec")
108
+ -- Mirror of Communication.computeInstanceId() — duplicated here because the
109
+ -- client broker runs in the play-server DM where it can't easily import from
110
+ -- the edit-side module, and the place identifier must match what the edit-DM
111
+ -- plugin reports. Both use the same algorithm against the shared DataModel.
112
+ local function computeInstanceId()
113
+ if game.PlaceId ~= 0 then
114
+ return `place:{tostring(game.PlaceId)}`
115
+ end
116
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
117
+ if type(existing) == "string" and existing ~= "" then
118
+ return `anon:{existing}`
119
+ end
120
+ local fresh = HttpService:GenerateGUID(false)
121
+ pcall(function()
122
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
123
+ end)
124
+ return `anon:{fresh}`
125
+ end
126
+ local cachedPlaceName
127
+ local function resolvePlaceName()
128
+ if cachedPlaceName ~= nil then
129
+ return cachedPlaceName
130
+ end
131
+ if game.PlaceId == 0 then
132
+ cachedPlaceName = game.Name
133
+ return cachedPlaceName
134
+ end
135
+ local MarketplaceService = game:GetService("MarketplaceService")
136
+ local ok, info = pcall(function()
137
+ return MarketplaceService:GetProductInfo(game.PlaceId)
138
+ end)
139
+ if ok and info ~= nil then
140
+ local name = info.Name
141
+ if type(name) == "string" and name ~= "" then
142
+ cachedPlaceName = name
143
+ return cachedPlaceName
144
+ end
145
+ end
146
+ return game.Name
147
+ end
99
148
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
100
149
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
101
150
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -116,6 +165,9 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
116
165
  ["/api/execute-luau"] = true,
117
166
  ["/api/get-runtime-logs"] = true,
118
167
  ["/api/get-memory-breakdown"] = true,
168
+ ["/api/capture-begin"] = true,
169
+ ["/api/simulate-mouse-input"] = true,
170
+ ["/api/simulate-keyboard-input"] = true,
119
171
  }
120
172
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
121
173
  -- polls doesn't cause a re-register stampede.
@@ -136,8 +188,13 @@ local function reRegisterProxy(proxyId, role)
136
188
  lastReadyByProxy[_proxyId_1] = now
137
189
  pcall(function()
138
190
  return postJson("/ready", {
139
- instanceId = proxyId,
191
+ pluginSessionId = proxyId,
192
+ instanceId = computeInstanceId(),
140
193
  role = role,
194
+ placeId = game.PlaceId,
195
+ placeName = resolvePlaceName(),
196
+ dataModelName = game.Name,
197
+ isRunning = RunService:IsRunning(),
141
198
  })
142
199
  end)
143
200
  end
@@ -170,34 +227,11 @@ local function handleExecuteLuau(data)
170
227
  error = "code is required",
171
228
  }
172
229
  end
173
- local m = Instance.new("ModuleScript")
174
- m.Name = "__MCPClientEval"
175
- local okSet, setErr = pcall(function()
176
- m.Source = code
177
- end)
178
- if not okSet then
179
- m:Destroy()
180
- return {
181
- success = false,
182
- error = `Source set failed: {tostring(setErr)}`,
183
- }
184
- end
185
- m.Parent = game.Workspace
186
- local okReq, result = pcall(function()
187
- return require(m)
188
- end)
189
- m:Destroy()
190
- if okReq then
191
- return {
192
- success = true,
193
- returnValue = if result ~= nil then tostring(result) else nil,
194
- message = "Code executed successfully",
195
- }
196
- end
197
- return {
198
- success = false,
199
- error = tostring(result),
200
- }
230
+ -- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
231
+ -- wrapper (so `print("hi")` with no return doesn't fail the
232
+ -- ModuleScript's "must return one value" rule) and JSON-encodes table
233
+ -- returns instead of yielding "table: 0xaddr".
234
+ return LuauExec.execute(code)
201
235
  end
202
236
  local function handleGetRuntimeLogs(data)
203
237
  local d = data or {}
@@ -231,6 +265,15 @@ local function setupClientBroker()
231
265
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
232
266
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
233
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
234
277
  if payload and payload.endpoint == "/api/execute-luau" then
235
278
  return handleExecuteLuau(payload.data)
236
279
  end
@@ -251,7 +294,7 @@ local function pollProxy(proxyId, player, rf)
251
294
  end
252
295
  local ok, res = pcall(function()
253
296
  return HttpService:RequestAsync({
254
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
297
+ Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
255
298
  Method = "GET",
256
299
  Headers = {
257
300
  ["Content-Type"] = "application/json",
@@ -294,8 +337,12 @@ local function pollProxy(proxyId, player, rf)
294
337
  }
295
338
  end
296
339
  else
340
+ local allowed = {}
341
+ for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
342
+ table.insert(allowed, ep)
343
+ end
297
344
  response = {
298
- 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, ", ")}.`,
299
346
  }
300
347
  end
301
348
  postJson("/response", {
@@ -315,8 +362,13 @@ local function registerProxy(player, rf)
315
362
  end
316
363
  local proxyId = HttpService:GenerateGUID(false)
317
364
  local ok, res = postJson("/ready", {
318
- instanceId = proxyId,
365
+ pluginSessionId = proxyId,
366
+ instanceId = computeInstanceId(),
319
367
  role = "client",
368
+ placeId = game.PlaceId,
369
+ placeName = resolvePlaceName(),
370
+ dataModelName = game.Name,
371
+ isRunning = RunService:IsRunning(),
320
372
  })
321
373
  if not ok or not res or not res.Success then
322
374
  warn(`[MCPFork] proxy register failed for {player.Name}`)
@@ -330,7 +382,7 @@ local function registerProxy(player, rf)
330
382
  local assigned = _condition
331
383
  local _player_1 = player
332
384
  local _arg1 = {
333
- instanceId = proxyId,
385
+ pluginSessionId = proxyId,
334
386
  role = assigned,
335
387
  }
336
388
  proxyByPlayer[_player_1] = _arg1
@@ -361,14 +413,14 @@ local function setupServerBroker()
361
413
  local _p_1 = p
362
414
  proxyByPlayer[_p_1] = nil
363
415
  postJson("/disconnect", {
364
- instanceId = entry.instanceId,
416
+ pluginSessionId = entry.pluginSessionId,
365
417
  })
366
418
  end
367
419
  end)
368
420
  game:BindToClose(function()
369
421
  for _, entry in proxyByPlayer do
370
422
  postJson("/disconnect", {
371
- instanceId = entry.instanceId,
423
+ pluginSessionId = entry.pluginSessionId,
372
424
  })
373
425
  end
374
426
  table.clear(proxyByPlayer)
@@ -391,9 +443,11 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
391
443
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
392
444
  local HttpService = _services.HttpService
393
445
  local RunService = _services.RunService
446
+ local ServerStorage = _services.ServerStorage
394
447
  local State = TS.import(script, script.Parent, "State")
395
448
  local Utils = TS.import(script, script.Parent, "Utils")
396
449
  local UI = TS.import(script, script.Parent, "UI")
450
+ local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
397
451
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
398
452
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
399
453
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -407,8 +461,61 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
407
461
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
408
462
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
409
463
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
410
- local instanceId = HttpService:GenerateGUID(false)
464
+ -- Per-plugin-load random GUID. Used as the /poll URL param so the server
465
+ -- can tell our polls apart from any other plugin's polls. Not user-facing —
466
+ -- MCP tools and the LLM operate on instanceId (the place identifier).
467
+ local pluginSessionId = HttpService:GenerateGUID(false)
468
+ -- Place-level identifier shared by every plugin running in DataModels of
469
+ -- the same place file (edit DM + playtest server DM + playtest clients).
470
+ -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
471
+ -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
472
+ -- and travels with the .rbxl.
473
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
474
+ local function computeInstanceId()
475
+ if game.PlaceId ~= 0 then
476
+ return `place:{tostring(game.PlaceId)}`
477
+ end
478
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
479
+ if type(existing) == "string" and existing ~= "" then
480
+ return `anon:{existing}`
481
+ end
482
+ local fresh = HttpService:GenerateGUID(false)
483
+ pcall(function()
484
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
485
+ end)
486
+ return `anon:{fresh}`
487
+ end
488
+ local instanceId = computeInstanceId()
411
489
  local assignedRole
490
+ local duplicateInstanceRole = false
491
+ -- Cache the published place name from MarketplaceService:GetProductInfo so
492
+ -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
493
+ -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
494
+ -- once per plugin load; the published name doesn't change mid-session.
495
+ local cachedPlaceName
496
+ local function resolvePlaceName()
497
+ if cachedPlaceName ~= nil then
498
+ return cachedPlaceName
499
+ end
500
+ if game.PlaceId == 0 then
501
+ cachedPlaceName = game.Name
502
+ return cachedPlaceName
503
+ end
504
+ local MarketplaceService = game:GetService("MarketplaceService")
505
+ local ok, info = pcall(function()
506
+ return MarketplaceService:GetProductInfo(game.PlaceId)
507
+ end)
508
+ if ok and info ~= nil then
509
+ local name = info.Name
510
+ if type(name) == "string" and name ~= "" then
511
+ cachedPlaceName = name
512
+ return cachedPlaceName
513
+ end
514
+ end
515
+ -- Don't cache failures — could be transient (offline, rate-limited).
516
+ -- Next /ready will retry. Return game.Name as fallback.
517
+ return game.Name
518
+ end
412
519
  local function detectRole()
413
520
  if not RunService:IsRunning() then
414
521
  return "edit"
@@ -472,6 +579,8 @@ local routeMap = {
472
579
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
473
580
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
474
581
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
582
+ ["/api/capture-begin"] = CaptureHandlers.captureBegin,
583
+ ["/api/capture-read"] = CaptureHandlers.captureRead,
475
584
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
476
585
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
477
586
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -524,7 +633,33 @@ end
524
633
  -- Without this, every poll during the brief window where the server has just
525
634
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
526
635
  local lastReadyPostAt = 0
527
- local function sendReady(conn)
636
+ -- game.Name is sometimes "Place1" at plugin-load time and only settles to
637
+ -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
638
+ -- after Studio finishes wiring things up. Re-fire /ready when it changes so
639
+ -- get_connected_instances doesn't show a stale dataModelName forever. Set
640
+ -- up once per plugin load — the connection passed in is whichever was
641
+ -- active when activatePlugin was first called.
642
+ local nameChangeConn
643
+ local sendReady
644
+ local function ensureNameChangeWatcher(conn)
645
+ if nameChangeConn then
646
+ return nil
647
+ end
648
+ local okSig, signal = pcall(function()
649
+ return game:GetPropertyChangedSignal("Name")
650
+ end)
651
+ if not okSig or not signal then
652
+ return nil
653
+ end
654
+ nameChangeConn = signal:Connect(function()
655
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
656
+ sendReady(conn)
657
+ end)
658
+ end
659
+ function sendReady(conn)
660
+ if duplicateInstanceRole then
661
+ return nil
662
+ end
528
663
  local now = tick()
529
664
  if now - lastReadyPostAt < 2 then
530
665
  return nil
@@ -539,14 +674,36 @@ local function sendReady(conn)
539
674
  ["Content-Type"] = "application/json",
540
675
  },
541
676
  Body = HttpService:JSONEncode({
677
+ pluginSessionId = pluginSessionId,
542
678
  instanceId = instanceId,
543
679
  role = detectRole(),
680
+ placeId = game.PlaceId,
681
+ placeName = resolvePlaceName(),
682
+ dataModelName = game.Name,
683
+ isRunning = RunService:IsRunning(),
544
684
  pluginReady = true,
545
685
  timestamp = tick(),
546
686
  }),
547
687
  })
548
688
  end)
549
- if readyOk and readyResult.Success then
689
+ if not readyOk then
690
+ return nil
691
+ end
692
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
693
+ if readyResult.StatusCode == 409 then
694
+ duplicateInstanceRole = true
695
+ conn.isActive = false
696
+ local ui = UI.getElements()
697
+ if State.getActiveTabIndex() == 0 then
698
+ ui.statusLabel.Text = "Duplicate instance"
699
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
700
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
701
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
702
+ end
703
+ warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
704
+ return nil
705
+ end
706
+ if readyResult.Success then
550
707
  local parseOk, readyData = pcall(function()
551
708
  return HttpService:JSONDecode(readyResult.Body)
552
709
  end)
@@ -568,7 +725,7 @@ local function pollForRequests(connIndex)
568
725
  conn.isPolling = true
569
726
  local success, result = pcall(function()
570
727
  return HttpService:RequestAsync({
571
- Url = `{conn.serverUrl}/poll?instanceId={instanceId}`,
728
+ Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
572
729
  Method = "GET",
573
730
  Headers = {
574
731
  ["Content-Type"] = "application/json",
@@ -764,6 +921,22 @@ local function activatePlugin(connIndex)
764
921
  -- Initial /ready; pollForRequests will also re-fire ready if the server
765
922
  -- later reports knownInstance=false (process restart, etc).
766
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
937
+ -- Watch for game.Name updates so a stale "Place1" captured at first
938
+ -- /ready gets refreshed once Studio settles on the real DM name.
939
+ ensureNameChangeWatcher(conn)
767
940
  end
768
941
  local function deactivatePlugin(connIndex)
769
942
  local _condition = connIndex
@@ -789,7 +962,7 @@ local function deactivatePlugin(connIndex)
789
962
  ["Content-Type"] = "application/json",
790
963
  },
791
964
  Body = HttpService:JSONEncode({
792
- instanceId = instanceId,
965
+ pluginSessionId = pluginSessionId,
793
966
  timestamp = tick(),
794
967
  }),
795
968
  })
@@ -882,11 +1055,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
882
1055
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
883
1056
  -- when LoadStringEnabled=false (the default in fresh places).
884
1057
  --
885
- -- Lifecycle: TestHandlers.startPlaytest inserts both scripts into the EDIT
886
- -- DM right before ExecutePlayModeAsync. ExecutePlayModeAsync clones the
887
- -- DataModel into the play DMs, so the scripts come along and run there.
888
- -- TestHandlers cleans them up from the edit DM when ExecutePlayModeAsync
889
- -- 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.
890
1068
  --
891
1069
  -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
892
1070
  -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -918,9 +1096,9 @@ local BRIDGE_NAMES = {
918
1096
  -- Embedded Luau. The double `${...}` references our exported names so a
919
1097
  -- rename here propagates to both the script source and the tool wrappers.
920
1098
  local SERVER_BRIDGE_SOURCE = `\
921
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at\
922
- -- stop_playtest. Provides shared-require-cache eval on the server peer for\
923
- -- 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.\
924
1102
  \
925
1103
  local ServerScriptService = game:GetService("ServerScriptService")\
926
1104
  local RunService = game:GetService("RunService")\
@@ -944,9 +1122,9 @@ bf.OnInvoke = function(payload)\
944
1122
  end\
945
1123
  `
946
1124
  local CLIENT_BRIDGE_SOURCE = `\
947
- -- Auto-installed by @chrrxs/robloxstudio-mcp at start_playtest, removed at\
948
- -- stop_playtest. Provides shared-require-cache eval on the client peer for\
949
- -- 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.\
950
1128
  \
951
1129
  local ReplicatedStorage = game:GetService("ReplicatedStorage")\
952
1130
  local RunService = game:GetService("RunService")\
@@ -969,6 +1147,25 @@ bf.OnInvoke = function(payload)\
969
1147
  return pcall(require, payload)\
970
1148
  end\
971
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()
972
1169
  local function setSource(scriptInst, source)
973
1170
  -- ScriptEditorService is the cleaner API and integrates with Studio's
974
1171
  -- edit history; fall back to direct Source mutation (allowed in plugin
@@ -1004,7 +1201,31 @@ local function cleanupBridges()
1004
1201
  end)
1005
1202
  end
1006
1203
  end
1007
- 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()
1008
1229
  -- Defensive: clear any stale bridges from a prior unclean exit before
1009
1230
  -- inserting fresh. The injected script also self-cleans its
1010
1231
  -- ReplicatedStorage/ServerScriptService children at startup, but the
@@ -1017,6 +1238,7 @@ local function installBridges()
1017
1238
  -- script. cleanupBridges() removes it from the edit DM when the
1018
1239
  -- playtest ends.
1019
1240
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1241
+ serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1020
1242
  serverScript.Parent = ServerScriptService
1021
1243
  local sps = getStarterPlayerScripts()
1022
1244
  if not sps then
@@ -1025,6 +1247,7 @@ local function installBridges()
1025
1247
  local clientScript = Instance.new("LocalScript")
1026
1248
  clientScript.Name = CLIENT_SCRIPT_NAME
1027
1249
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1250
+ clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1028
1251
  clientScript.Parent = sps
1029
1252
  end)
1030
1253
  if not ok then
@@ -1039,6 +1262,7 @@ local function installBridges()
1039
1262
  end
1040
1263
  return {
1041
1264
  cleanupBridges = cleanupBridges,
1265
+ ensureBridgesInstalled = ensureBridgesInstalled,
1042
1266
  installBridges = installBridges,
1043
1267
  BRIDGE_NAMES = BRIDGE_NAMES,
1044
1268
  }
@@ -1904,6 +2128,8 @@ return {
1904
2128
  <Properties>
1905
2129
  <string name="Name">CaptureHandlers</string>
1906
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")
1907
2133
  local CaptureService = game:GetService("CaptureService")
1908
2134
  local AssetService = game:GetService("AssetService")
1909
2135
  local MAX_TILE_SIZE = 1024
@@ -2013,7 +2239,20 @@ local function readPixelsTiled(img, w, h)
2013
2239
  end
2014
2240
  return fullBuf
2015
2241
  end
2016
- 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
2017
2256
  local contentId
2018
2257
  CaptureService:CaptureScreenshot(function(id)
2019
2258
  contentId = id
@@ -2022,11 +2261,21 @@ local function captureScreenshotData()
2022
2261
  while contentId == nil do
2023
2262
  if tick() - startTime > 10 then
2024
2263
  return {
2025
- 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.)",
2026
2265
  }
2027
2266
  end
2028
2267
  task.wait(0.1)
2029
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)
2030
2279
  local editableOk, editableResult = pcall(function()
2031
2280
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2032
2281
  end)
@@ -2056,12 +2305,36 @@ local function captureScreenshotData()
2056
2305
  data = base64Data,
2057
2306
  }
2058
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
2059
2316
  local function captureScreenshot()
2060
2317
  return captureScreenshotData()
2061
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
2062
2333
  return {
2063
2334
  captureScreenshotData = captureScreenshotData,
2064
2335
  captureScreenshot = captureScreenshot,
2336
+ captureBegin = captureBegin,
2337
+ captureRead = captureRead,
2065
2338
  }
2066
2339
  ]]></string>
2067
2340
  </Properties>
@@ -2070,19 +2343,56 @@ return {
2070
2343
  <Properties>
2071
2344
  <string name="Name">InputHandlers</string>
2072
2345
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2073
- local function getVIM()
2074
- local ok, result = pcall(function()
2075
- 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()
2076
2385
  end)
2077
- if ok and result then
2078
- return result
2386
+ if ok and vi ~= nil then
2387
+ cachedVI = vi
2388
+ return cachedVI
2079
2389
  end
2080
2390
  return nil
2081
2391
  end
2082
- local BUTTON_MAP = {
2083
- Left = 0,
2084
- Right = 1,
2085
- Middle = 2,
2392
+ local MOUSE_TYPE_MAP = {
2393
+ Left = Enum.UserInputType.MouseButton1,
2394
+ Right = Enum.UserInputType.MouseButton2,
2395
+ Middle = Enum.UserInputType.MouseButton3,
2086
2396
  }
2087
2397
  local function simulateMouseInput(requestData)
2088
2398
  local action = requestData.action
@@ -2093,56 +2403,43 @@ local function simulateMouseInput(requestData)
2093
2403
  _condition = "Left"
2094
2404
  end
2095
2405
  local button = _condition
2096
- local scrollDirection = requestData.scrollDirection
2097
2406
  if not (action ~= "" and action) then
2098
2407
  return {
2099
2408
  error = "action is required",
2100
2409
  }
2101
2410
  end
2102
- local vim = getVIM()
2103
- if not vim then
2411
+ if x == nil or y == nil then
2104
2412
  return {
2105
- error = "VirtualInputManager is not available in this context",
2413
+ error = "x and y are required",
2106
2414
  }
2107
2415
  end
2108
- local _condition_1 = BUTTON_MAP[button]
2109
- if _condition_1 == nil then
2110
- _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
+ }
2111
2429
  end
2112
- local buttonNum = _condition_1
2430
+ local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
2431
+ local pos = Vector2.new(x, y)
2113
2432
  local success, err = pcall(function()
2114
2433
  if action == "click" then
2115
- if x == nil or y == nil then
2116
- error("x and y are required for click")
2117
- end
2118
- vim:SendMouseButtonEvent(x, y, buttonNum, true)
2434
+ vi:SendMouseButton(pos, inputType, true)
2119
2435
  task.wait(0.05)
2120
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2436
+ vi:SendMouseButton(pos, inputType, false)
2121
2437
  elseif action == "mouseDown" then
2122
- if x == nil or y == nil then
2123
- error("x and y are required for mouseDown")
2124
- end
2125
- vim:SendMouseButtonEvent(x, y, buttonNum, true)
2438
+ vi:SendMouseButton(pos, inputType, true)
2126
2439
  elseif action == "mouseUp" then
2127
- if x == nil or y == nil then
2128
- error("x and y are required for mouseUp")
2129
- end
2130
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2131
- elseif action == "move" then
2132
- if x == nil or y == nil then
2133
- error("x and y are required for move")
2134
- end
2135
- vim:SendMouseMoveEvent(x, y)
2136
- elseif action == "scroll" then
2137
- if x == nil or y == nil then
2138
- error("x and y are required for scroll")
2139
- end
2140
- if not (scrollDirection ~= "" and scrollDirection) then
2141
- error("scrollDirection is required for scroll")
2142
- end
2143
- vim:SendMouseWheelEvent(x, y, scrollDirection == "up")
2440
+ vi:SendMouseButton(pos, inputType, false)
2144
2441
  else
2145
- error(`Unknown action: {action}`)
2442
+ error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
2146
2443
  end
2147
2444
  end)
2148
2445
  if success then
@@ -2159,7 +2456,40 @@ local function simulateMouseInput(requestData)
2159
2456
  }
2160
2457
  end
2161
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
2162
2487
  local keyCodeName = requestData.keyCode
2488
+ if not (keyCodeName ~= "" and keyCodeName) then
2489
+ return {
2490
+ error = "keyCode (or text) is required",
2491
+ }
2492
+ end
2163
2493
  local _condition = (requestData.action)
2164
2494
  if _condition == nil then
2165
2495
  _condition = "tap"
@@ -2170,17 +2500,6 @@ local function simulateKeyboardInput(requestData)
2170
2500
  _condition_1 = 0.1
2171
2501
  end
2172
2502
  local duration = _condition_1
2173
- if not (keyCodeName ~= "" and keyCodeName) then
2174
- return {
2175
- error = "keyCode is required",
2176
- }
2177
- end
2178
- local vim = getVIM()
2179
- if not vim then
2180
- return {
2181
- error = "VirtualInputManager is not available in this context",
2182
- }
2183
- end
2184
2503
  local enumOk, keyCode = pcall(function()
2185
2504
  return (Enum.KeyCode)[keyCodeName]
2186
2505
  end)
@@ -2191,13 +2510,13 @@ local function simulateKeyboardInput(requestData)
2191
2510
  end
2192
2511
  local success, err = pcall(function()
2193
2512
  if action == "press" then
2194
- vim:SendKeyEvent(true, keyCode, false)
2513
+ vi:SendKey(true, keyCode)
2195
2514
  elseif action == "release" then
2196
- vim:SendKeyEvent(false, keyCode, false)
2515
+ vi:SendKey(false, keyCode)
2197
2516
  elseif action == "tap" then
2198
- vim:SendKeyEvent(true, keyCode, false)
2517
+ vi:SendKey(true, keyCode)
2199
2518
  task.wait(duration)
2200
- vim:SendKeyEvent(false, keyCode, false)
2519
+ vi:SendKey(false, keyCode)
2201
2520
  else
2202
2521
  error(`Unknown action: {action}`)
2203
2522
  end
@@ -2827,11 +3146,10 @@ return {
2827
3146
  <string name="Name">MetadataHandlers</string>
2828
3147
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
3148
  local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2830
- local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
2831
- local CollectionService = _services.CollectionService
2832
- local LogService = _services.LogService
3149
+ local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2833
3150
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2834
3151
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
3152
+ local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
2835
3153
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
2836
3154
  local Selection = game:GetService("Selection")
2837
3155
  local _binding = Utils
@@ -3257,137 +3575,11 @@ local function executeLuau(requestData)
3257
3575
  error = "Code is required",
3258
3576
  }
3259
3577
  end
3260
- -- Both execution paths (loadstring + ModuleScript-require fallback) run
3261
- -- the SAME wrapped source so they return a uniform { ok, value, output }
3262
- -- shape. Two problems the wrapper solves at once:
3263
- --
3264
- -- 1. pcall(require, m) swallows the real error and returns Roblox's
3265
- -- generic "Requested module experienced an error while loading"
3266
- -- message. Wrapping user code in xpcall INSIDE the IIFE keeps the
3267
- -- ModuleScript itself returning successfully — the real error +
3268
- -- traceback live in the returned table.
3269
- --
3270
- -- 2. The ModuleScript path runs in its own environment, so a plugin-
3271
- -- side getfenv print/warn override never reached user prints. A
3272
- -- lexical local print/warn inside the IIFE captures user prints
3273
- -- regardless of which path executes. We also call the real global
3274
- -- print/warn so messages still flow to Studio's output and
3275
- -- LogService.MessageOut (which powers get_runtime_logs).
3276
- --
3277
- -- Prints from required sub-modules don't reach this capture (they have
3278
- -- their own env) — those go through the runtime log buffer.
3279
- local wrapped = `return ((function()\
3280
- \tlocal __mcp_output = \{\}\
3281
- \tlocal __mcp_real_print = print\
3282
- \tlocal __mcp_real_warn = warn\
3283
- \tlocal print = function(...)\
3284
- \t\t__mcp_real_print(...)\
3285
- \t\tlocal args = \{...\}\
3286
- \t\tlocal parts = table.create(#args)\
3287
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3288
- \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
3289
- \tend\
3290
- \tlocal warn = function(...)\
3291
- \t\t__mcp_real_warn(...)\
3292
- \t\tlocal args = \{...\}\
3293
- \t\tlocal parts = table.create(#args)\
3294
- \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
3295
- \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
3296
- \tend\
3297
- \tlocal function __mcp_run()\
3298
- {code}\
3299
- \tend\
3300
- \tlocal ok, errOrValue = xpcall(__mcp_run, debug.traceback)\
3301
- \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
3302
- end)())`
3303
- local runViaModuleScript = function()
3304
- local m = Instance.new("ModuleScript")
3305
- m.Name = "__MCPExecLuauPayload"
3306
- local okSet, setErr = pcall(function()
3307
- m.Source = wrapped
3308
- end)
3309
- if not okSet then
3310
- m:Destroy()
3311
- error(`ModuleScript Source set failed: {tostring(setErr)}`)
3312
- end
3313
- m.Parent = game:GetService("Workspace")
3314
- local okReq, reqResult = pcall(function()
3315
- return require(m)
3316
- end)
3317
- m:Destroy()
3318
- if not okReq then
3319
- local errMsg = tostring(reqResult)
3320
- -- pcall(require, m) collapses parse/compile failures into the
3321
- -- canned engine string below. Walk LogService backward for the
3322
- -- real diagnostic, which was emitted to MessageOut just before.
3323
- if errMsg == "Requested module experienced an error while loading" then
3324
- -- The parser diagnostic is emitted to LogService on the next
3325
- -- engine frame, not synchronously with pcall(require). task.wait(0)
3326
- -- yields too early; 50ms is enough to let the frame complete and
3327
- -- the message land in GetLogHistory.
3328
- task.wait(0.05)
3329
- local hist = LogService:GetLogHistory()
3330
- for i = #hist - 1, 0, -1 do
3331
- local e = hist[i + 1]
3332
- if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, 31) == "Workspace.__MCPExecLuauPayload:" then
3333
- errMsg = e.message
3334
- break
3335
- end
3336
- end
3337
- end
3338
- error(errMsg)
3339
- end
3340
- return reqResult
3341
- end
3342
- local isLoadstringUnavailable = function(err)
3343
- local errStr = tostring(err)
3344
- local matchStart = string.find(errStr, "not available", 1, true)
3345
- return matchStart ~= nil
3346
- end
3347
- local success, result = pcall(function()
3348
- local fn, compileError = loadstring(wrapped)
3349
- if not fn then
3350
- if isLoadstringUnavailable(compileError) then
3351
- return runViaModuleScript()
3352
- end
3353
- error(`Compile error: {compileError}`)
3354
- end
3355
- return fn()
3356
- end)
3357
- -- loadstring throws (not returns nil) in some plugin contexts when
3358
- -- LoadStringEnabled=false. Catch that as a second-chance fallback.
3359
- if not success and isLoadstringUnavailable(result) then
3360
- success, result = pcall(runViaModuleScript)
3361
- end
3362
- if not success then
3363
- -- Outer pcall failed - the wrapper itself didn't even run (e.g. compile
3364
- -- error in the user code, or ModuleScript setup error). 'result' is the
3365
- -- raw error string from pcall.
3366
- return {
3367
- success = false,
3368
- error = tostring(result),
3369
- output = {},
3370
- message = "Code execution failed",
3371
- }
3372
- end
3373
- -- Wrapper executed - unpack { ok, value, output }.
3374
- local r = result
3375
- local capturedOutput = r.output
3376
- local output = if capturedOutput ~= nil then capturedOutput else ({})
3377
- if r.ok == true then
3378
- return {
3379
- success = true,
3380
- returnValue = if r.value ~= nil then tostring(r.value) else nil,
3381
- output = output,
3382
- message = "Code executed successfully",
3383
- }
3384
- end
3385
- return {
3386
- success = false,
3387
- error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
3388
- output = output,
3389
- message = "Code execution failed",
3390
- }
3578
+ -- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
3579
+ -- of table returns, and parse-error recovery live in LuauExec so the
3580
+ -- edit/server (this handler) and the play-client (ClientBroker) take
3581
+ -- the same code path and produce identical output shapes.
3582
+ return LuauExec.execute(code)
3391
3583
  end
3392
3584
  local function undo(_requestData)
3393
3585
  local success, result = pcall(function()
@@ -5675,7 +5867,7 @@ local LogService = _services.LogService
5675
5867
  local RunService = _services.RunService
5676
5868
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5677
5869
  local installBridges = _EvalBridges.installBridges
5678
- local cleanupBridges = _EvalBridges.cleanupBridges
5870
+ local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
5679
5871
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5680
5872
  local StudioTestService = game:GetService("StudioTestService")
5681
5873
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -5800,7 +5992,9 @@ local function startPlaytest(requestData)
5800
5992
  logConnection = nil
5801
5993
  end
5802
5994
  cleanupStopListener()
5803
- 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.
5804
5998
  end
5805
5999
  if testRunning then
5806
6000
  return {
@@ -5843,9 +6037,10 @@ local function startPlaytest(requestData)
5843
6037
  if not injected then
5844
6038
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5845
6039
  end
5846
- -- Auto-install the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
5847
- -- so eval_server_runtime / eval_client_runtime work without manual setup.
5848
- -- 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.
5849
6044
  local bridgeInstall = installBridges()
5850
6045
  if not bridgeInstall.installed then
5851
6046
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
@@ -5872,7 +6067,9 @@ local function startPlaytest(requestData)
5872
6067
  end
5873
6068
  testRunning = false
5874
6069
  cleanupStopListener()
5875
- 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()
5876
6073
  end)
5877
6074
  local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5878
6075
  local response = {
@@ -5900,9 +6097,25 @@ local function stopPlaytest(_requestData)
5900
6097
  }
5901
6098
  end
5902
6099
  if not StopPlayMonitor.waitForConsumption() then
5903
- -- Clean up the pending flag so a future playtest's monitor doesn't fire
5904
- -- EndTest on its own startup against a stale signal.
6100
+ -- Two distinct failure modes collapse here, distinguished by whether
6101
+ -- THIS edit DM has a playtest tracked:
6102
+ --
6103
+ -- - testRunning=false: no playtest was running from this edit DM
6104
+ -- (true negative). Return "no active playtest" — fine to retry only
6105
+ -- after actually starting a playtest.
6106
+ -- - testRunning=true: a playtest IS running but the cross-DM signal
6107
+ -- didn't propagate within the consumption timeout (false negative
6108
+ -- from the caller's perspective — playtest may actually have ended).
6109
+ -- Tell the caller it's a timing issue and they can retry.
6110
+ --
6111
+ -- Either way clean up the pending flag so a future playtest's monitor
6112
+ -- doesn't fire EndTest on startup against a stale signal.
5905
6113
  StopPlayMonitor.clearPending()
6114
+ if testRunning then
6115
+ return {
6116
+ error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
6117
+ }
6118
+ end
5906
6119
  return {
5907
6120
  error = "No active playtest to stop.",
5908
6121
  }
@@ -6022,6 +6235,324 @@ return {
6022
6235
  </Item>
6023
6236
  </Item>
6024
6237
  <Item class="ModuleScript" referent="19">
6238
+ <Properties>
6239
+ <string name="Name">LuauExec</string>
6240
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6241
+ -- eslint-disable
6242
+ -- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
6243
+ -- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
6244
+ -- module owns:
6245
+ --
6246
+ -- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
6247
+ -- and always returns { ok, value, output } so the ModuleScript itself
6248
+ -- always returns exactly one value (otherwise `print("hi")` with no
6249
+ -- return would fail with "Module code did not return exactly one value").
6250
+ --
6251
+ -- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
6252
+ -- recovery hack that pulls the real diagnostic from LogService.
6253
+ --
6254
+ -- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
6255
+ -- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
6256
+ -- pass through tostring. The encode is pcall'd so cycles or
6257
+ -- non-serializable values gracefully fall back to tostring.
6258
+ --
6259
+ -- Before this module existed, the client peer used a stripped-down
6260
+ -- require-only execution path that lacked both the wrapper and the JSON
6261
+ -- formatting, producing two well-known papercuts:
6262
+ -- - `print("hi")` (no return) failed with "Module code did not return..."
6263
+ -- - Returning a table yielded `table: 0xaddr` instead of structured data.
6264
+ local HttpService = game:GetService("HttpService")
6265
+ local LogService = game:GetService("LogService")
6266
+ local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
6267
+ local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
6268
+ -- Number of lines the wrapper emits BEFORE the first line of user code.
6269
+ -- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
6270
+ -- (remapPayloadLines, for compile errors recovered from LogService) so user
6271
+ -- code errors report user-relative line numbers instead of the inflated
6272
+ -- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
6273
+ -- prefix lines, update this constant — there's a self-check below.
6274
+ local WRAPPER_LINE_OFFSET = 23
6275
+ -- Count source lines so the wrapper can filter traceback frames that fall
6276
+ -- outside the user code range (the wrapper's own preamble/postamble lines).
6277
+ local function countLines(s)
6278
+ local n = 1
6279
+ local size = #s
6280
+ do
6281
+ local i = 1
6282
+ local _shouldIncrement = false
6283
+ while true do
6284
+ if _shouldIncrement then
6285
+ i += 1
6286
+ else
6287
+ _shouldIncrement = true
6288
+ end
6289
+ if not (i <= size) then
6290
+ break
6291
+ end
6292
+ if string.sub(s, i, i) == "\n" then
6293
+ n += 1
6294
+ end
6295
+ end
6296
+ end
6297
+ return n
6298
+ end
6299
+ local function buildWrapper(code)
6300
+ -- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
6301
+ -- match the number of lines emitted BEFORE the ${code} substitution.
6302
+ -- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
6303
+ -- used by remapPayloadLines on the TS side.
6304
+ local userLines = countLines(code)
6305
+ return `return ((function()\
6306
+ \tlocal __mcp_traceback\
6307
+ \tlocal __mcp_remap\
6308
+ \tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
6309
+ \tlocal __mcp_USER_LINES = {userLines}\
6310
+ \tlocal __mcp_output = \{\}\
6311
+ \tlocal __mcp_real_print = print\
6312
+ \tlocal __mcp_real_warn = warn\
6313
+ \tlocal print = function(...)\
6314
+ \t\t__mcp_real_print(...)\
6315
+ \t\tlocal args = \{...\}\
6316
+ \t\tlocal parts = table.create(#args)\
6317
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6318
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
6319
+ \tend\
6320
+ \tlocal warn = function(...)\
6321
+ \t\t__mcp_real_warn(...)\
6322
+ \t\tlocal args = \{...\}\
6323
+ \t\tlocal parts = table.create(#args)\
6324
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6325
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
6326
+ \tend\
6327
+ \tlocal function __mcp_run()\
6328
+ {code}\
6329
+ \tend\
6330
+ \t__mcp_remap = function(s)\
6331
+ \t\t-- Two chunk-name formats can reference our payload:\
6332
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
6333
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
6334
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
6335
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
6336
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload\
6337
+ \t\t-- line past user EOF. Without clamping the message says "user_code:49"\
6338
+ \t\t-- for one-line input, framing the wrapper as user code.\
6339
+ \t\tlocal function __mcp_user_line(payload_n)\
6340
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
6341
+ \t\t\tif user_n < 1 then return "1" end\
6342
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
6343
+ \t\t\treturn tostring(user_n)\
6344
+ \t\tend\
6345
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
6346
+ \t\t\tlocal n = tonumber(num)\
6347
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6348
+ \t\t\treturn "user_code:" .. num\
6349
+ \t\tend)\
6350
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
6351
+ \t\t\tlocal n = tonumber(num)\
6352
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6353
+ \t\t\treturn "user_code:" .. num\
6354
+ \t\tend)\
6355
+ \t\treturn s\
6356
+ \tend\
6357
+ \t__mcp_traceback = function(err)\
6358
+ \t\tlocal raw = debug.traceback(tostring(err), 2)\
6359
+ \t\tlocal kept = \{\}\
6360
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do\
6361
+ \t\t\t-- Extract referenced line number (either chunk-name format).\
6362
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
6363
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
6364
+ \t\t\tlocal n = num_str and tonumber(num_str)\
6365
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
6366
+ \t\t\t-- any filtering, because user-code frames carry that suffix —\
6367
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
6368
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
6369
+ \t\t\t-- get dropped. Strip first, then apply filters.\
6370
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
6371
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
6372
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)\
6373
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
6374
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
6375
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without\
6376
+ \t\t\t-- a payload-chunk line number (the traceback header / engine\
6377
+ \t\t\t-- C frames) are kept; remap is a no-op for them.\
6378
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
6379
+ \t\t\t\tskip = true\
6380
+ \t\t\tend\
6381
+ \t\t\tif not skip then\
6382
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))\
6383
+ \t\t\tend\
6384
+ \t\tend\
6385
+ \t\treturn table.concat(kept, "\\n")\
6386
+ \tend\
6387
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
6388
+ \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
6389
+ end)())`
6390
+ end
6391
+ -- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
6392
+ -- pulling the real compile-error diagnostic out of LogService — that error
6393
+ -- references the payload module's line number directly, and never passes
6394
+ -- through the IIFE's runtime wrapper.
6395
+ local function remapPayloadLines(s, userLines)
6396
+ -- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
6397
+ -- don't pass through the IIFE (compile errors recovered from
6398
+ -- LogService, the immediate loadstring compileError surface). Same
6399
+ -- two-format coverage plus the same clamp: unclosed user constructs
6400
+ -- let the parser consume wrapper postamble, so the raw payload line
6401
+ -- is sometimes well past user EOF — clamp to [1, userLines] and
6402
+ -- annotate so the error doesn't say "user_code:49" for one-line input.
6403
+ local userLine = function(payload)
6404
+ local u = payload - WRAPPER_LINE_OFFSET
6405
+ if u < 1 then
6406
+ return "1"
6407
+ end
6408
+ if u > userLines then
6409
+ return `{tostring(userLines)} (at end of input)`
6410
+ end
6411
+ return tostring(u)
6412
+ end
6413
+ local out = s
6414
+ local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
6415
+ local n = tonumber(num)
6416
+ if n ~= nil then
6417
+ return `user_code:{userLine(n)}`
6418
+ end
6419
+ return `user_code:{num}`
6420
+ end)
6421
+ out = a
6422
+ local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
6423
+ local n = tonumber(num)
6424
+ if n ~= nil then
6425
+ return `user_code:{userLine(n)}`
6426
+ end
6427
+ return `user_code:{num}`
6428
+ end)
6429
+ out = b
6430
+ return out
6431
+ end
6432
+ local function runViaModuleScript(wrapped, userLines)
6433
+ local m = Instance.new("ModuleScript")
6434
+ m.Name = PAYLOAD_INSTANCE_NAME
6435
+ local okSet, setErr = pcall(function()
6436
+ m.Source = wrapped
6437
+ end)
6438
+ if not okSet then
6439
+ m:Destroy()
6440
+ -- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
6441
+ -- prefix that error() would otherwise prepend, keeping the visible
6442
+ -- message focused on the user-actionable error rather than our path.
6443
+ error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
6444
+ end
6445
+ m.Parent = game:GetService("Workspace")
6446
+ local okReq, reqResult = pcall(function()
6447
+ return require(m)
6448
+ end)
6449
+ m:Destroy()
6450
+ if not okReq then
6451
+ local errMsg = tostring(reqResult)
6452
+ -- pcall(require, m) collapses parse/compile failures into the canned
6453
+ -- engine string. The real diagnostic was emitted to LogService on the
6454
+ -- next engine frame — give it ~50ms to land then scan backward.
6455
+ if errMsg == "Requested module experienced an error while loading" then
6456
+ task.wait(0.05)
6457
+ local hist = LogService:GetLogHistory()
6458
+ for i = #hist - 1, 0, -1 do
6459
+ local e = hist[i + 1]
6460
+ if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
6461
+ errMsg = e.message
6462
+ break
6463
+ end
6464
+ end
6465
+ end
6466
+ -- Compile errors reference the payload module's line number directly
6467
+ -- — remap + clamp to user-relative line numbers so `local x = 1 +`
6468
+ -- reports :1: instead of :23:, and reports the clamp annotation
6469
+ -- when the parser ran off the end of user code into wrapper code.
6470
+ error(remapPayloadLines(errMsg, userLines), 0)
6471
+ end
6472
+ return reqResult
6473
+ end
6474
+ local function isLoadstringUnavailable(err)
6475
+ local errStr = tostring(err)
6476
+ local matchStart = string.find(errStr, "not available", 1, true)
6477
+ return matchStart ~= nil
6478
+ end
6479
+ -- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
6480
+ -- the caller sees structured data instead of "table: 0xaddr". Anything that
6481
+ -- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
6482
+ local function formatReturnValue(value)
6483
+ if value == nil then
6484
+ return ""
6485
+ end
6486
+ local _value = value
6487
+ if type(_value) == "table" then
6488
+ local ok, encoded = pcall(function()
6489
+ return HttpService:JSONEncode(value)
6490
+ end)
6491
+ if ok then
6492
+ return encoded
6493
+ end
6494
+ end
6495
+ return tostring(value)
6496
+ end
6497
+ local function execute(code)
6498
+ if not (code ~= "" and code) or code == "" then
6499
+ return {
6500
+ success = false,
6501
+ error = "code is required",
6502
+ }
6503
+ end
6504
+ local wrapped = buildWrapper(code)
6505
+ local userLines = countLines(code)
6506
+ local success, result = pcall(function()
6507
+ local fn, compileError = loadstring(wrapped)
6508
+ if not fn then
6509
+ if isLoadstringUnavailable(compileError) then
6510
+ return runViaModuleScript(wrapped, userLines)
6511
+ end
6512
+ error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
6513
+ end
6514
+ return fn()
6515
+ end)
6516
+ -- loadstring can throw (not return nil) when ServerScriptService.
6517
+ -- LoadStringEnabled is false; treat that as a second-chance fallback.
6518
+ if not success and isLoadstringUnavailable(result) then
6519
+ success, result = pcall(function()
6520
+ return runViaModuleScript(wrapped, userLines)
6521
+ end)
6522
+ end
6523
+ if not success then
6524
+ return {
6525
+ success = false,
6526
+ error = tostring(result),
6527
+ output = {},
6528
+ message = "Code execution failed",
6529
+ }
6530
+ end
6531
+ local r = result
6532
+ local capturedOutput = r.output
6533
+ local output = if capturedOutput ~= nil then capturedOutput else ({})
6534
+ if r.ok == true then
6535
+ return {
6536
+ success = true,
6537
+ returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
6538
+ output = output,
6539
+ message = "Code executed successfully",
6540
+ }
6541
+ end
6542
+ return {
6543
+ success = false,
6544
+ error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
6545
+ output = output,
6546
+ message = "Code execution failed",
6547
+ }
6548
+ end
6549
+ return {
6550
+ execute = execute,
6551
+ }
6552
+ ]]></string>
6553
+ </Properties>
6554
+ </Item>
6555
+ <Item class="ModuleScript" referent="20">
6025
6556
  <Properties>
6026
6557
  <string name="Name">Recording</string>
6027
6558
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6051,7 +6582,75 @@ return {
6051
6582
  ]]></string>
6052
6583
  </Properties>
6053
6584
  </Item>
6054
- <Item class="ModuleScript" referent="20">
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">
6055
6654
  <Properties>
6056
6655
  <string name="Name">RuntimeLogBuffer</string>
6057
6656
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6232,11 +6831,11 @@ return {
6232
6831
  ]]></string>
6233
6832
  </Properties>
6234
6833
  </Item>
6235
- <Item class="ModuleScript" referent="21">
6834
+ <Item class="ModuleScript" referent="23">
6236
6835
  <Properties>
6237
6836
  <string name="Name">State</string>
6238
6837
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6239
- local CURRENT_VERSION = "2.11.4"
6838
+ local CURRENT_VERSION = "2.13.0"
6240
6839
  local MAX_CONNECTIONS = 5
6241
6840
  local BASE_PORT = 58741
6242
6841
  local activeTabIndex = 0
@@ -6328,61 +6927,96 @@ return {
6328
6927
  ]]></string>
6329
6928
  </Properties>
6330
6929
  </Item>
6331
- <Item class="ModuleScript" referent="22">
6930
+ <Item class="ModuleScript" referent="24">
6332
6931
  <Properties>
6333
6932
  <string name="Name">StopPlayMonitor</string>
6334
6933
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6335
- -- Cross-DM stop_playtest signaling via plugin:SetSetting.
6934
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
6935
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
6936
+ -- per-instance setting key so the same Studio process can host playtests
6937
+ -- for multiple places without one place's stop_playtest yanking another's.
6336
6938
  --
6337
6939
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
6338
- -- that's shared across every DataModel the plugin runs in (edit, play-server,
6339
- -- play-clients). We use it as a one-bit flag for "please call EndTest in the
6340
- -- play-server DM":
6940
+ -- shared across every DataModel the plugin runs in (edit DMs, play-server
6941
+ -- DMs, play-client DMs). For each connected place we use a dedicated key
6942
+ -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
6341
6943
  --
6342
- -- * The edit DM's stopPlaytest handler writes the flag (requestStop).
6343
- -- * A monitor loop running inside the play-server DM polls the flag at 1Hz
6344
- -- and calls StudioTestService:EndTest when it flips true, then resets it.
6345
- -- * The edit DM then waits up to ~2.5s for the flag to be reset, which
6346
- -- tells us a play-server actually consumed the request (no false-positive
6347
- -- success when nothing was running).
6944
+ -- * The edit DM's stopPlaytest handler writes `true` into its own key
6945
+ -- (computed from its placeId / ServerStorage anon UUID).
6946
+ -- * Each play-server DM's monitor loop polls the key matching its own
6947
+ -- instanceId at 0.1Hz; on `true` it clears the key and calls
6948
+ -- StudioTestService:EndTest. Play-server DMs for other places never
6949
+ -- touch this key.
6950
+ -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
6951
+ -- matching play-server actually consumed the request.
6348
6952
  --
6349
- -- Why this is simpler than the previous edit-proxy registration:
6350
- -- * Doesn't depend on the MCP server tracking peer roles at all.
6351
- -- * Survives MCP server restarts: monitor loop is local to the play-server
6352
- -- plugin lifetime, not to any HTTP/registration state.
6353
- -- * No need for cross-DM LogService.MessageOut reflection (which we verified
6354
- -- does not work edit -> play-server anyway).
6355
- --
6356
- -- Pattern mirrors the official Roblox Studio MCP
6357
- -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
6953
+ -- Earlier versions used a single shared boolean flag, which let any
6954
+ -- play-server DM in the same Studio process consume any place's stop
6955
+ -- request silently yanking teammates' playtests. The per-key scoping
6956
+ -- below is the fix.
6957
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
6958
+ local HttpService = _services.HttpService
6959
+ local ServerStorage = _services.ServerStorage
6358
6960
  local StudioTestService = game:GetService("StudioTestService")
6359
- local SETTING_KEY = "MCP_STOP_PLAY_SIGNAL"
6360
- local POLL_INTERVAL_SEC = 1
6361
- local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 2.5
6961
+ local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
6962
+ -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
6963
+ -- lag tight so the consumption-confirmation window doesn't have to absorb
6964
+ -- polling jitter on top of EndTest's teardown time.
6965
+ local POLL_INTERVAL_SEC = 0.1
6966
+ -- Total time we wait for the matching play-server DM to consume the
6967
+ -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
6968
+ -- StudioTestService:EndTest teardown (several seconds on heavier places).
6969
+ -- 8s is comfortable; the tighter poll above keeps real cases well under.
6970
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
6362
6971
  local WAIT_POLL_SEC = 0.1
6363
6972
  local pluginRef
6364
6973
  local function init(p)
6365
6974
  pluginRef = p
6366
6975
  end
6976
+ -- Mirror of Communication.computeInstanceId(). Duplicated here because
6977
+ -- StopPlayMonitor runs in both edit and play-server DMs, and both must
6978
+ -- agree on the place identifier (published places: placeId; unpublished:
6979
+ -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
6980
+ -- into the play DM).
6981
+ local function computeInstanceId()
6982
+ if game.PlaceId ~= 0 then
6983
+ return `place:{tostring(game.PlaceId)}`
6984
+ end
6985
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
6986
+ if type(existing) == "string" and existing ~= "" then
6987
+ return `anon:{existing}`
6988
+ end
6989
+ local fresh = HttpService:GenerateGUID(false)
6990
+ pcall(function()
6991
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
6992
+ end)
6993
+ return `anon:{fresh}`
6994
+ end
6995
+ local function settingKey(instanceId)
6996
+ return SETTING_KEY_PREFIX .. instanceId
6997
+ end
6367
6998
  local function startMonitor()
6368
6999
  if not pluginRef then
6369
7000
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6370
7001
  return nil
6371
7002
  end
6372
- -- Clear any stale value left from a prior session. If a real stop request
6373
- -- is in-flight when this runs, the requesting edit DM will set it again
6374
- -- within its 2.5s wait window.
7003
+ local myKey = settingKey(computeInstanceId())
7004
+ -- Clear any stale value left from a prior session. If a real stop
7005
+ -- request is in-flight when this runs, the requesting edit DM will
7006
+ -- write again within its consumption-confirmation window.
6375
7007
  pcall(function()
6376
- return pluginRef:SetSetting(SETTING_KEY, false)
7008
+ return pluginRef:SetSetting(myKey, false)
6377
7009
  end)
6378
7010
  task.spawn(function()
6379
7011
  while true do
6380
7012
  local okGet, val = pcall(function()
6381
- return pluginRef:GetSetting(SETTING_KEY)
7013
+ return pluginRef:GetSetting(myKey)
6382
7014
  end)
6383
7015
  if okGet and val == true then
7016
+ -- Consume the flag first so requestStop's
7017
+ -- waitForConsumption returns success, then end the test.
6384
7018
  pcall(function()
6385
- return pluginRef:SetSetting(SETTING_KEY, false)
7019
+ return pluginRef:SetSetting(myKey, false)
6386
7020
  end)
6387
7021
  pcall(function()
6388
7022
  return StudioTestService:EndTest("stopped_by_mcp")
@@ -6396,8 +7030,9 @@ local function requestStop()
6396
7030
  if not pluginRef then
6397
7031
  return false
6398
7032
  end
7033
+ local myKey = settingKey(computeInstanceId())
6399
7034
  local ok = pcall(function()
6400
- return pluginRef:SetSetting(SETTING_KEY, true)
7035
+ return pluginRef:SetSetting(myKey, true)
6401
7036
  end)
6402
7037
  return ok
6403
7038
  end
@@ -6405,10 +7040,11 @@ local function waitForConsumption()
6405
7040
  if not pluginRef then
6406
7041
  return false
6407
7042
  end
7043
+ local myKey = settingKey(computeInstanceId())
6408
7044
  local start = tick()
6409
7045
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6410
7046
  local okGet, val = pcall(function()
6411
- return pluginRef:GetSetting(SETTING_KEY)
7047
+ return pluginRef:GetSetting(myKey)
6412
7048
  end)
6413
7049
  if okGet and val ~= true then
6414
7050
  return true
@@ -6421,8 +7057,9 @@ local function clearPending()
6421
7057
  if not pluginRef then
6422
7058
  return nil
6423
7059
  end
7060
+ local myKey = settingKey(computeInstanceId())
6424
7061
  pcall(function()
6425
- return pluginRef:SetSetting(SETTING_KEY, false)
7062
+ return pluginRef:SetSetting(myKey, false)
6426
7063
  end)
6427
7064
  end
6428
7065
  return {
@@ -6435,7 +7072,7 @@ return {
6435
7072
  ]]></string>
6436
7073
  </Properties>
6437
7074
  </Item>
6438
- <Item class="ModuleScript" referent="23">
7075
+ <Item class="ModuleScript" referent="25">
6439
7076
  <Properties>
6440
7077
  <string name="Name">UI</string>
6441
7078
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7186,7 +7823,7 @@ return {
7186
7823
  ]]></string>
7187
7824
  </Properties>
7188
7825
  </Item>
7189
- <Item class="ModuleScript" referent="24">
7826
+ <Item class="ModuleScript" referent="26">
7190
7827
  <Properties>
7191
7828
  <string name="Name">Utils</string>
7192
7829
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7716,11 +8353,11 @@ return {
7716
8353
  </Properties>
7717
8354
  </Item>
7718
8355
  </Item>
7719
- <Item class="Folder" referent="28">
8356
+ <Item class="Folder" referent="30">
7720
8357
  <Properties>
7721
8358
  <string name="Name">include</string>
7722
8359
  </Properties>
7723
- <Item class="ModuleScript" referent="25">
8360
+ <Item class="ModuleScript" referent="27">
7724
8361
  <Properties>
7725
8362
  <string name="Name">Promise</string>
7726
8363
  <string name="Source"><![CDATA[--[[
@@ -9794,7 +10431,7 @@ return Promise
9794
10431
  ]]></string>
9795
10432
  </Properties>
9796
10433
  </Item>
9797
- <Item class="ModuleScript" referent="26">
10434
+ <Item class="ModuleScript" referent="28">
9798
10435
  <Properties>
9799
10436
  <string name="Name">RuntimeLib</string>
9800
10437
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10061,15 +10698,15 @@ return TS
10061
10698
  </Properties>
10062
10699
  </Item>
10063
10700
  </Item>
10064
- <Item class="Folder" referent="29">
10701
+ <Item class="Folder" referent="31">
10065
10702
  <Properties>
10066
10703
  <string name="Name">node_modules</string>
10067
10704
  </Properties>
10068
- <Item class="Folder" referent="30">
10705
+ <Item class="Folder" referent="32">
10069
10706
  <Properties>
10070
10707
  <string name="Name">@rbxts</string>
10071
10708
  </Properties>
10072
- <Item class="ModuleScript" referent="27">
10709
+ <Item class="ModuleScript" referent="29">
10073
10710
  <Properties>
10074
10711
  <string name="Name">services</string>
10075
10712
  <string name="Source"><![CDATA[return setmetatable({}, {