@chrrxs/robloxstudio-mcp-inspector 2.13.0 → 2.15.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,54 @@ 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 SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
106
+ local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
107
+ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
108
+ local LuauExec = TS.import(script, script.Parent, "LuauExec")
109
+ local StudioTestService = game:GetService("StudioTestService")
110
+ -- Mirror of Communication.computeInstanceId() — duplicated here because the
111
+ -- client broker runs in the play-server DM where it can't easily import from
112
+ -- the edit-side module, and the place identifier must match what the edit-DM
113
+ -- plugin reports. Both use the same algorithm against the shared DataModel.
114
+ local function computeInstanceId()
115
+ if game.PlaceId ~= 0 then
116
+ return `place:{tostring(game.PlaceId)}`
117
+ end
118
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
119
+ if type(existing) == "string" and existing ~= "" then
120
+ return `anon:{existing}`
121
+ end
122
+ local fresh = HttpService:GenerateGUID(false)
123
+ pcall(function()
124
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
125
+ end)
126
+ return `anon:{fresh}`
127
+ end
128
+ local cachedPlaceName
129
+ local function resolvePlaceName()
130
+ if cachedPlaceName ~= nil then
131
+ return cachedPlaceName
132
+ end
133
+ if game.PlaceId == 0 then
134
+ cachedPlaceName = game.Name
135
+ return cachedPlaceName
136
+ end
137
+ local MarketplaceService = game:GetService("MarketplaceService")
138
+ local ok, info = pcall(function()
139
+ return MarketplaceService:GetProductInfo(game.PlaceId)
140
+ end)
141
+ if ok and info ~= nil then
142
+ local name = info.Name
143
+ if type(name) == "string" and name ~= "" then
144
+ cachedPlaceName = name
145
+ return cachedPlaceName
146
+ end
147
+ end
148
+ return game.Name
149
+ end
99
150
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
100
151
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
101
152
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -109,6 +160,7 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
109
160
  -- signaling, which works regardless of MCP server state.)
110
161
  local MCP_URL = "http://localhost:58741"
111
162
  local BROKER_NAME = "__MCPClientBroker"
163
+ local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
112
164
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
113
165
  -- Each requires the client peer's plugin VM (because the buffer / require
114
166
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -116,6 +168,12 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
116
168
  ["/api/execute-luau"] = true,
117
169
  ["/api/get-runtime-logs"] = true,
118
170
  ["/api/get-memory-breakdown"] = true,
171
+ ["/api/get-scene-analysis"] = true,
172
+ ["/api/multiplayer-test-state"] = true,
173
+ ["/api/multiplayer-test-leave-client"] = true,
174
+ ["/api/capture-begin"] = true,
175
+ ["/api/simulate-mouse-input"] = true,
176
+ ["/api/simulate-keyboard-input"] = true,
119
177
  }
120
178
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
121
179
  -- polls doesn't cause a re-register stampede.
@@ -136,8 +194,13 @@ local function reRegisterProxy(proxyId, role)
136
194
  lastReadyByProxy[_proxyId_1] = now
137
195
  pcall(function()
138
196
  return postJson("/ready", {
139
- instanceId = proxyId,
197
+ pluginSessionId = proxyId,
198
+ instanceId = computeInstanceId(),
140
199
  role = role,
200
+ placeId = game.PlaceId,
201
+ placeName = resolvePlaceName(),
202
+ dataModelName = game.Name,
203
+ isRunning = RunService:IsRunning(),
141
204
  })
142
205
  end)
143
206
  end
@@ -170,34 +233,11 @@ local function handleExecuteLuau(data)
170
233
  error = "code is required",
171
234
  }
172
235
  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
- }
236
+ -- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
237
+ -- wrapper (so `print("hi")` with no return doesn't fail the
238
+ -- ModuleScript's "must return one value" rule) and JSON-encodes table
239
+ -- returns instead of yielding "table: 0xaddr".
240
+ return LuauExec.execute(code)
201
241
  end
202
242
  local function handleGetRuntimeLogs(data)
203
243
  local d = data or {}
@@ -212,10 +252,81 @@ local function handleGetRuntimeLogs(data)
212
252
  filter = filter,
213
253
  }, "client")
214
254
  end
255
+ local function handleMultiplayerTestState()
256
+ local argsOk, args = pcall(function()
257
+ return StudioTestService:GetTestArgs()
258
+ end)
259
+ local canLeaveOk, canLeave = pcall(function()
260
+ return StudioTestService:CanLeaveTest()
261
+ end)
262
+ local _exp = Players:GetPlayers()
263
+ -- ▼ ReadonlyArray.map ▼
264
+ local _newValue = table.create(#_exp)
265
+ local _callback = function(player)
266
+ return {
267
+ name = player.Name,
268
+ userId = player.UserId,
269
+ displayName = player.DisplayName,
270
+ }
271
+ end
272
+ for _k, _v in _exp do
273
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
274
+ end
275
+ -- ▲ ReadonlyArray.map ▲
276
+ local players = _newValue
277
+ table.sort(players, function(a, b)
278
+ return a.name < b.name
279
+ end)
280
+ return {
281
+ success = true,
282
+ peer = "client",
283
+ isRunning = RunService:IsRunning(),
284
+ isRunMode = RunService:IsRunMode(),
285
+ editModeActive = StudioTestService.EditModeActive,
286
+ testArgsOk = argsOk,
287
+ testArgs = if argsOk then args else nil,
288
+ testArgsError = if argsOk then nil else tostring(args),
289
+ players = players,
290
+ playerCount = #players,
291
+ localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
292
+ canLeaveOk = canLeaveOk,
293
+ canLeave = if canLeaveOk then canLeave else false,
294
+ canLeaveError = if canLeaveOk then nil else tostring(canLeave),
295
+ }
296
+ end
297
+ local function handleMultiplayerTestLeaveClient()
298
+ local canLeaveOk, canLeave = pcall(function()
299
+ return StudioTestService:CanLeaveTest()
300
+ end)
301
+ if not canLeaveOk then
302
+ return {
303
+ error = tostring(canLeave),
304
+ canLeaveOk = false,
305
+ }
306
+ end
307
+ if not canLeave then
308
+ return {
309
+ error = "This client cannot leave the current test session.",
310
+ canLeaveOk = true,
311
+ canLeave = false,
312
+ }
313
+ end
314
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
315
+ task.defer(function()
316
+ pcall(function()
317
+ return StudioTestService:LeaveTest()
318
+ end)
319
+ end)
320
+ return {
321
+ success = true,
322
+ message = "Client leave requested.",
323
+ localPlayer = localPlayer,
324
+ }
325
+ end
215
326
  local function setupClientBroker()
216
327
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
217
328
  if not rf or not rf:IsA("RemoteFunction") then
218
- warn(`[MCPFork] client: {BROKER_NAME} not found`)
329
+ warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
219
330
  return nil
220
331
  end
221
332
  rf.OnClientInvoke = function(payload)
@@ -231,6 +342,24 @@ local function setupClientBroker()
231
342
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
232
343
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
233
344
  end
345
+ if payload and payload.endpoint == "/api/get-scene-analysis" then
346
+ return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
347
+ end
348
+ if payload and payload.endpoint == "/api/multiplayer-test-state" then
349
+ return handleMultiplayerTestState()
350
+ end
351
+ if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
352
+ return handleMultiplayerTestLeaveClient()
353
+ end
354
+ if payload and payload.endpoint == "/api/capture-begin" then
355
+ return CaptureHandlers.captureBegin()
356
+ end
357
+ if payload and payload.endpoint == "/api/simulate-mouse-input" then
358
+ return InputHandlers.simulateMouseInput(payload.data or {})
359
+ end
360
+ if payload and payload.endpoint == "/api/simulate-keyboard-input" then
361
+ return InputHandlers.simulateKeyboardInput(payload.data or {})
362
+ end
234
363
  if payload and payload.endpoint == "/api/execute-luau" then
235
364
  return handleExecuteLuau(payload.data)
236
365
  end
@@ -239,6 +368,7 @@ local function setupClientBroker()
239
368
  end
240
369
  end
241
370
  local proxyByPlayer = {}
371
+ local serverBrokerStarted = false
242
372
  local function pollProxy(proxyId, player, rf)
243
373
  while true do
244
374
  local _condition = player.Parent ~= nil
@@ -251,7 +381,7 @@ local function pollProxy(proxyId, player, rf)
251
381
  end
252
382
  local ok, res = pcall(function()
253
383
  return HttpService:RequestAsync({
254
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
384
+ Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
255
385
  Method = "GET",
256
386
  Headers = {
257
387
  ["Content-Type"] = "application/json",
@@ -294,8 +424,12 @@ local function pollProxy(proxyId, player, rf)
294
424
  }
295
425
  end
296
426
  else
427
+ local allowed = {}
428
+ for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
429
+ table.insert(allowed, ep)
430
+ end
297
431
  response = {
298
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
432
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
299
433
  }
300
434
  end
301
435
  postJson("/response", {
@@ -315,11 +449,16 @@ local function registerProxy(player, rf)
315
449
  end
316
450
  local proxyId = HttpService:GenerateGUID(false)
317
451
  local ok, res = postJson("/ready", {
318
- instanceId = proxyId,
452
+ pluginSessionId = proxyId,
453
+ instanceId = computeInstanceId(),
319
454
  role = "client",
455
+ placeId = game.PlaceId,
456
+ placeName = resolvePlaceName(),
457
+ dataModelName = game.Name,
458
+ isRunning = RunService:IsRunning(),
320
459
  })
321
460
  if not ok or not res or not res.Success then
322
- warn(`[MCPFork] proxy register failed for {player.Name}`)
461
+ warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
323
462
  return nil
324
463
  end
325
464
  local body = HttpService:JSONDecode(res.Body)
@@ -330,7 +469,7 @@ local function registerProxy(player, rf)
330
469
  local assigned = _condition
331
470
  local _player_1 = player
332
471
  local _arg1 = {
333
- instanceId = proxyId,
472
+ pluginSessionId = proxyId,
334
473
  role = assigned,
335
474
  }
336
475
  proxyByPlayer[_player_1] = _arg1
@@ -341,12 +480,20 @@ end
341
480
  -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
342
481
  -- which doesn't depend on MCP server state or peer registration at all.)
343
482
  local function setupServerBroker()
483
+ if serverBrokerStarted then
484
+ return nil
485
+ end
344
486
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
345
487
  if not rf then
346
488
  rf = Instance.new("RemoteFunction")
347
489
  rf.Name = BROKER_NAME
348
490
  rf.Parent = ReplicatedStorage
349
491
  end
492
+ if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
493
+ return nil
494
+ end
495
+ rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
496
+ serverBrokerStarted = true
350
497
  local broker = rf
351
498
  Players.PlayerAdded:Connect(function(p)
352
499
  return registerProxy(p, broker)
@@ -361,14 +508,14 @@ local function setupServerBroker()
361
508
  local _p_1 = p
362
509
  proxyByPlayer[_p_1] = nil
363
510
  postJson("/disconnect", {
364
- instanceId = entry.instanceId,
511
+ pluginSessionId = entry.pluginSessionId,
365
512
  })
366
513
  end
367
514
  end)
368
515
  game:BindToClose(function()
369
516
  for _, entry in proxyByPlayer do
370
517
  postJson("/disconnect", {
371
- instanceId = entry.instanceId,
518
+ pluginSessionId = entry.pluginSessionId,
372
519
  })
373
520
  end
374
521
  table.clear(proxyByPlayer)
@@ -391,9 +538,11 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
391
538
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
392
539
  local HttpService = _services.HttpService
393
540
  local RunService = _services.RunService
541
+ local ServerStorage = _services.ServerStorage
394
542
  local State = TS.import(script, script.Parent, "State")
395
543
  local Utils = TS.import(script, script.Parent, "Utils")
396
544
  local UI = TS.import(script, script.Parent, "UI")
545
+ local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
397
546
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
398
547
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
399
548
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -407,8 +556,62 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
407
556
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
408
557
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
409
558
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
410
- local instanceId = HttpService:GenerateGUID(false)
559
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
560
+ -- Per-plugin-load random GUID. Used as the /poll URL param so the server
561
+ -- can tell our polls apart from any other plugin's polls. Not user-facing —
562
+ -- MCP tools and the LLM operate on instanceId (the place identifier).
563
+ local pluginSessionId = HttpService:GenerateGUID(false)
564
+ -- Place-level identifier shared by every plugin running in DataModels of
565
+ -- the same place file (edit DM + playtest server DM + playtest clients).
566
+ -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
567
+ -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
568
+ -- and travels with the .rbxl.
569
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
570
+ local function computeInstanceId()
571
+ if game.PlaceId ~= 0 then
572
+ return `place:{tostring(game.PlaceId)}`
573
+ end
574
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
575
+ if type(existing) == "string" and existing ~= "" then
576
+ return `anon:{existing}`
577
+ end
578
+ local fresh = HttpService:GenerateGUID(false)
579
+ pcall(function()
580
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
581
+ end)
582
+ return `anon:{fresh}`
583
+ end
584
+ local instanceId = computeInstanceId()
411
585
  local assignedRole
586
+ local duplicateInstanceRole = false
587
+ -- Cache the published place name from MarketplaceService:GetProductInfo so
588
+ -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
589
+ -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
590
+ -- once per plugin load; the published name doesn't change mid-session.
591
+ local cachedPlaceName
592
+ local function resolvePlaceName()
593
+ if cachedPlaceName ~= nil then
594
+ return cachedPlaceName
595
+ end
596
+ if game.PlaceId == 0 then
597
+ cachedPlaceName = game.Name
598
+ return cachedPlaceName
599
+ end
600
+ local MarketplaceService = game:GetService("MarketplaceService")
601
+ local ok, info = pcall(function()
602
+ return MarketplaceService:GetProductInfo(game.PlaceId)
603
+ end)
604
+ if ok and info ~= nil then
605
+ local name = info.Name
606
+ if type(name) == "string" and name ~= "" then
607
+ cachedPlaceName = name
608
+ return cachedPlaceName
609
+ end
610
+ end
611
+ -- Don't cache failures — could be transient (offline, rate-limited).
612
+ -- Next /ready will retry. Return game.Name as fallback.
613
+ return game.Name
614
+ end
412
615
  local function detectRole()
413
616
  if not RunService:IsRunning() then
414
617
  return "edit"
@@ -464,6 +667,11 @@ local routeMap = {
464
667
  ["/api/start-playtest"] = TestHandlers.startPlaytest,
465
668
  ["/api/stop-playtest"] = TestHandlers.stopPlaytest,
466
669
  ["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
670
+ ["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
671
+ ["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
672
+ ["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
673
+ ["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
674
+ ["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
467
675
  ["/api/character-navigation"] = TestHandlers.characterNavigation,
468
676
  ["/api/export-build"] = BuildHandlers.exportBuild,
469
677
  ["/api/import-build"] = BuildHandlers.importBuild,
@@ -472,6 +680,8 @@ local routeMap = {
472
680
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
473
681
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
474
682
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
683
+ ["/api/capture-begin"] = CaptureHandlers.captureBegin,
684
+ ["/api/capture-read"] = CaptureHandlers.captureRead,
475
685
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
476
686
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
477
687
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -479,6 +689,7 @@ local routeMap = {
479
689
  ["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
480
690
  ["/api/import-rbxm"] = SerializationHandlers.importRbxm,
481
691
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
692
+ ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
482
693
  }
483
694
  local function processRequest(request)
484
695
  local endpoint = request.endpoint
@@ -524,7 +735,33 @@ end
524
735
  -- Without this, every poll during the brief window where the server has just
525
736
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
526
737
  local lastReadyPostAt = 0
527
- local function sendReady(conn)
738
+ -- game.Name is sometimes "Place1" at plugin-load time and only settles to
739
+ -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
740
+ -- after Studio finishes wiring things up. Re-fire /ready when it changes so
741
+ -- get_connected_instances doesn't show a stale dataModelName forever. Set
742
+ -- up once per plugin load — the connection passed in is whichever was
743
+ -- active when activatePlugin was first called.
744
+ local nameChangeConn
745
+ local sendReady
746
+ local function ensureNameChangeWatcher(conn)
747
+ if nameChangeConn then
748
+ return nil
749
+ end
750
+ local okSig, signal = pcall(function()
751
+ return game:GetPropertyChangedSignal("Name")
752
+ end)
753
+ if not okSig or not signal then
754
+ return nil
755
+ end
756
+ nameChangeConn = signal:Connect(function()
757
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
758
+ sendReady(conn)
759
+ end)
760
+ end
761
+ function sendReady(conn)
762
+ if duplicateInstanceRole then
763
+ return nil
764
+ end
528
765
  local now = tick()
529
766
  if now - lastReadyPostAt < 2 then
530
767
  return nil
@@ -539,14 +776,36 @@ local function sendReady(conn)
539
776
  ["Content-Type"] = "application/json",
540
777
  },
541
778
  Body = HttpService:JSONEncode({
779
+ pluginSessionId = pluginSessionId,
542
780
  instanceId = instanceId,
543
781
  role = detectRole(),
782
+ placeId = game.PlaceId,
783
+ placeName = resolvePlaceName(),
784
+ dataModelName = game.Name,
785
+ isRunning = RunService:IsRunning(),
544
786
  pluginReady = true,
545
787
  timestamp = tick(),
546
788
  }),
547
789
  })
548
790
  end)
549
- if readyOk and readyResult.Success then
791
+ if not readyOk then
792
+ return nil
793
+ end
794
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
795
+ if readyResult.StatusCode == 409 then
796
+ duplicateInstanceRole = true
797
+ conn.isActive = false
798
+ local ui = UI.getElements()
799
+ if State.getActiveTabIndex() == 0 then
800
+ ui.statusLabel.Text = "Duplicate instance"
801
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
802
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
803
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
804
+ end
805
+ warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
806
+ return nil
807
+ end
808
+ if readyResult.Success then
550
809
  local parseOk, readyData = pcall(function()
551
810
  return HttpService:JSONDecode(readyResult.Body)
552
811
  end)
@@ -568,7 +827,7 @@ local function pollForRequests(connIndex)
568
827
  conn.isPolling = true
569
828
  local success, result = pcall(function()
570
829
  return HttpService:RequestAsync({
571
- Url = `{conn.serverUrl}/poll?instanceId={instanceId}`,
830
+ Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
572
831
  Method = "GET",
573
832
  Headers = {
574
833
  ["Content-Type"] = "application/json",
@@ -764,6 +1023,22 @@ local function activatePlugin(connIndex)
764
1023
  -- Initial /ready; pollForRequests will also re-fire ready if the server
765
1024
  -- later reports knownInstance=false (process restart, etc).
766
1025
  sendReady(conn)
1026
+ -- Keep the eval bridges present in the edit DM so that ANY playtest —
1027
+ -- including one the dev starts manually via the Studio Play button —
1028
+ -- clones them into the play DMs and eval_*_runtime works with no setup
1029
+ -- roundtrip. Only the edit DM installs; play DMs already have the cloned
1030
+ -- copies. Idempotent, so reconnects don't re-dirty the place.
1031
+ if not RunService:IsRunning() then
1032
+ task.spawn(function()
1033
+ local result = ensureBridgesInstalled()
1034
+ if not result.installed then
1035
+ warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
1036
+ end
1037
+ end)
1038
+ end
1039
+ -- Watch for game.Name updates so a stale "Place1" captured at first
1040
+ -- /ready gets refreshed once Studio settles on the real DM name.
1041
+ ensureNameChangeWatcher(conn)
767
1042
  end
768
1043
  local function deactivatePlugin(connIndex)
769
1044
  local _condition = connIndex
@@ -789,7 +1064,7 @@ local function deactivatePlugin(connIndex)
789
1064
  ["Content-Type"] = "application/json",
790
1065
  },
791
1066
  Body = HttpService:JSONEncode({
792
- instanceId = instanceId,
1067
+ pluginSessionId = pluginSessionId,
793
1068
  timestamp = tick(),
794
1069
  }),
795
1070
  })
@@ -882,11 +1157,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
882
1157
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
883
1158
  -- when LoadStringEnabled=false (the default in fresh places).
884
1159
  --
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).
1160
+ -- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
1161
+ -- installs them (ensureBridgesInstalled) when the plugin connects in edit,
1162
+ -- and TestHandlers.startPlaytest force-refreshes them right before
1163
+ -- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
1164
+ -- play DMs, so the scripts come along and run there. We keep them in the edit
1165
+ -- DM after a playtest ends (rather than cleaning up) so that a playtest the
1166
+ -- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
1167
+ -- tool — also gets the bridges cloned in. This is intentionally a little
1168
+ -- intrusive (two helper scripts visible in Explorer) in exchange for a
1169
+ -- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
890
1170
  --
891
1171
  -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
892
1172
  -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -918,9 +1198,9 @@ local BRIDGE_NAMES = {
918
1198
  -- Embedded Luau. The double `${...}` references our exported names so a
919
1199
  -- rename here propagates to both the script source and the tool wrappers.
920
1200
  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.\
1201
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
1202
+ -- tool (shared-require-cache eval on the server during playtests). Inert\
1203
+ -- outside Studio (no-ops in live games); safe to leave in place.\
924
1204
  \
925
1205
  local ServerScriptService = game:GetService("ServerScriptService")\
926
1206
  local RunService = game:GetService("RunService")\
@@ -944,9 +1224,9 @@ bf.OnInvoke = function(payload)\
944
1224
  end\
945
1225
  `
946
1226
  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.\
1227
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
1228
+ -- tool (shared-require-cache eval on the client during playtests). Inert\
1229
+ -- outside Studio (no-ops in live games); safe to leave in place.\
950
1230
  \
951
1231
  local ReplicatedStorage = game:GetService("ReplicatedStorage")\
952
1232
  local RunService = game:GetService("RunService")\
@@ -969,6 +1249,25 @@ bf.OnInvoke = function(payload)\
969
1249
  return pcall(require, payload)\
970
1250
  end\
971
1251
  `
1252
+ -- Stamp written onto each installed bridge Script so we can tell whether the
1253
+ -- bridge currently in the DM was produced by THIS plugin build. It's a djb2
1254
+ -- hash of the actual bridge source plus the plugin version, so ANY change to
1255
+ -- the source (or a version bump) yields a new stamp — which makes
1256
+ -- ensureBridgesInstalled() force a refresh on the next plugin load instead of
1257
+ -- keeping a stale bridge that happens to still be present (e.g. one saved into
1258
+ -- the .rbxl from an older build).
1259
+ local STAMP_ATTR = "__MCPBridgeStamp"
1260
+ local function computeBridgeStamp()
1261
+ local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
1262
+ local h = 5381
1263
+ for i = 1, #combined do
1264
+ h = (h * 33 + (string.byte(combined, i))) % 2147483647
1265
+ end
1266
+ -- "2.15.0" is replaced with the package version at package time
1267
+ -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1268
+ return `{tostring(h)}-2.15.0`
1269
+ end
1270
+ local BRIDGE_STAMP = computeBridgeStamp()
972
1271
  local function setSource(scriptInst, source)
973
1272
  -- ScriptEditorService is the cleaner API and integrates with Studio's
974
1273
  -- edit history; fall back to direct Source mutation (allowed in plugin
@@ -1004,7 +1303,31 @@ local function cleanupBridges()
1004
1303
  end)
1005
1304
  end
1006
1305
  end
1007
- local function installBridges()
1306
+ -- Idempotent variant: install only if the bridge scripts aren't already
1307
+ -- present in the edit DM. Used to keep the bridges always available (so a
1308
+ -- playtest the dev starts manually — not via the MCP start_playtest tool —
1309
+ -- still clones them into the play DMs). Cheap no-op when already installed,
1310
+ -- which avoids re-dirtying the place on every plugin reconnect.
1311
+ local installBridges
1312
+ local function ensureBridgesInstalled()
1313
+ local _binding = findBridges()
1314
+ local server = _binding.server
1315
+ local client = _binding.client
1316
+ if server and client then
1317
+ -- Both present — but only skip the reinstall if they were produced by
1318
+ -- THIS build. A mismatched/absent stamp means a stale bridge (older
1319
+ -- plugin, or one persisted in the saved place), so force a refresh.
1320
+ local sStamp = server:GetAttribute(STAMP_ATTR)
1321
+ local cStamp = client:GetAttribute(STAMP_ATTR)
1322
+ if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
1323
+ return {
1324
+ installed = true,
1325
+ }
1326
+ end
1327
+ end
1328
+ return installBridges()
1329
+ end
1330
+ function installBridges()
1008
1331
  -- Defensive: clear any stale bridges from a prior unclean exit before
1009
1332
  -- inserting fresh. The injected script also self-cleans its
1010
1333
  -- ReplicatedStorage/ServerScriptService children at startup, but the
@@ -1017,6 +1340,7 @@ local function installBridges()
1017
1340
  -- script. cleanupBridges() removes it from the edit DM when the
1018
1341
  -- playtest ends.
1019
1342
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1343
+ serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1020
1344
  serverScript.Parent = ServerScriptService
1021
1345
  local sps = getStarterPlayerScripts()
1022
1346
  if not sps then
@@ -1025,6 +1349,7 @@ local function installBridges()
1025
1349
  local clientScript = Instance.new("LocalScript")
1026
1350
  clientScript.Name = CLIENT_SCRIPT_NAME
1027
1351
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1352
+ clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1028
1353
  clientScript.Parent = sps
1029
1354
  end)
1030
1355
  if not ok then
@@ -1039,6 +1364,7 @@ local function installBridges()
1039
1364
  end
1040
1365
  return {
1041
1366
  cleanupBridges = cleanupBridges,
1367
+ ensureBridgesInstalled = ensureBridgesInstalled,
1042
1368
  installBridges = installBridges,
1043
1369
  BRIDGE_NAMES = BRIDGE_NAMES,
1044
1370
  }
@@ -1904,6 +2230,8 @@ return {
1904
2230
  <Properties>
1905
2231
  <string name="Name">CaptureHandlers</string>
1906
2232
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2233
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2234
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
1907
2235
  local CaptureService = game:GetService("CaptureService")
1908
2236
  local AssetService = game:GetService("AssetService")
1909
2237
  local MAX_TILE_SIZE = 1024
@@ -2013,7 +2341,20 @@ local function readPixelsTiled(img, w, h)
2013
2341
  end
2014
2342
  return fullBuf
2015
2343
  end
2016
- local function captureScreenshotData()
2344
+ -- Triggers CaptureService:CaptureScreenshot and waits for the temporary
2345
+ -- content id. Works in any DM, including the play CLIENT (where reading the
2346
+ -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
2347
+ -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
2348
+ -- privileged DM (the edit DM) — see captureRead.
2349
+ local function doCaptureScreenshot()
2350
+ -- Fast-fail with a clear reason if the window isn't rendering — otherwise
2351
+ -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
2352
+ local notRendering = RenderMonitor.notRenderingReason()
2353
+ if notRendering ~= nil then
2354
+ return {
2355
+ error = notRendering,
2356
+ }
2357
+ end
2017
2358
  local contentId
2018
2359
  CaptureService:CaptureScreenshot(function(id)
2019
2360
  contentId = id
@@ -2022,11 +2363,21 @@ local function captureScreenshotData()
2022
2363
  while contentId == nil do
2023
2364
  if tick() - startTime > 10 then
2024
2365
  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.",
2366
+ 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
2367
  }
2027
2368
  end
2028
2369
  task.wait(0.1)
2029
2370
  end
2371
+ return {
2372
+ contentId = contentId,
2373
+ }
2374
+ end
2375
+ -- Promotes a CaptureScreenshot content id into an EditableImage and reads its
2376
+ -- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
2377
+ -- the privilege to create an EditableImage from a temporary texture id (errors
2378
+ -- "cannot currently create editable image from temporary texture id"), while
2379
+ -- the edit DM can — even for an id captured in the play client DM.
2380
+ local function readContentToBase64(contentId)
2030
2381
  local editableOk, editableResult = pcall(function()
2031
2382
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2032
2383
  end)
@@ -2056,12 +2407,36 @@ local function captureScreenshotData()
2056
2407
  data = base64Data,
2057
2408
  }
2058
2409
  end
2410
+ -- Edit-mode single shot: capture and read back in the same (edit) context.
2411
+ local function captureScreenshotData()
2412
+ local cap = doCaptureScreenshot()
2413
+ if cap.error ~= nil then
2414
+ return cap
2415
+ end
2416
+ return readContentToBase64(cap.contentId)
2417
+ end
2059
2418
  local function captureScreenshot()
2060
2419
  return captureScreenshotData()
2061
2420
  end
2421
+ -- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
2422
+ local function captureBegin()
2423
+ return doCaptureScreenshot()
2424
+ end
2425
+ -- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
2426
+ local function captureRead(requestData)
2427
+ local contentId = requestData.contentId
2428
+ if not (contentId ~= "" and contentId) then
2429
+ return {
2430
+ error = "contentId is required",
2431
+ }
2432
+ end
2433
+ return readContentToBase64(contentId)
2434
+ end
2062
2435
  return {
2063
2436
  captureScreenshotData = captureScreenshotData,
2064
2437
  captureScreenshot = captureScreenshot,
2438
+ captureBegin = captureBegin,
2439
+ captureRead = captureRead,
2065
2440
  }
2066
2441
  ]]></string>
2067
2442
  </Properties>
@@ -2070,19 +2445,56 @@ return {
2070
2445
  <Properties>
2071
2446
  <string name="Name">InputHandlers</string>
2072
2447
  <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")
2448
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2449
+ -- Virtual input via UserInputService:CreateVirtualInput().
2450
+ --
2451
+ -- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
2452
+ -- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
2453
+ -- in every context a plugin can reach (edit DM, play server/client DMs), so
2454
+ -- they silently never worked. CreateVirtualInput() is callable without that
2455
+ -- capability and drives the REAL input pipeline: SendKey feeds
2456
+ -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
2457
+ -- character at full WalkSpeed with controls intact, no Humanoid hijack),
2458
+ -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
2459
+ -- CoreGui), and SendTextInput types into the focused TextBox.
2460
+ --
2461
+ -- Method set on the VirtualInput object (verified live):
2462
+ -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
2463
+ -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
2464
+ -- SendTextInput(text: string)
2465
+ -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
2466
+ -- "scroll" mouse actions are not supported.
2467
+ --
2468
+ -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
2469
+ -- what capture_screenshot returns (window space, origin at the top-left of the
2470
+ -- rendered viewport). Pass screenshot pixel coordinates straight through. Note
2471
+ -- that UserInputService reports input positions in GUI space, which is offset
2472
+ -- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
2473
+ -- callers who pick coordinates off a screenshot, which is why we do not
2474
+ -- translate here.
2475
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2476
+ local UserInputService = game:GetService("UserInputService")
2477
+ -- One VirtualInput per plugin VM, reused across calls so that a key held down
2478
+ -- in one call (action="press") and released in a later call (action="release")
2479
+ -- share the same input source.
2480
+ local cachedVI
2481
+ local function getVI()
2482
+ if cachedVI then
2483
+ return cachedVI
2484
+ end
2485
+ local ok, vi = pcall(function()
2486
+ return UserInputService:CreateVirtualInput()
2076
2487
  end)
2077
- if ok and result then
2078
- return result
2488
+ if ok and vi ~= nil then
2489
+ cachedVI = vi
2490
+ return cachedVI
2079
2491
  end
2080
2492
  return nil
2081
2493
  end
2082
- local BUTTON_MAP = {
2083
- Left = 0,
2084
- Right = 1,
2085
- Middle = 2,
2494
+ local MOUSE_TYPE_MAP = {
2495
+ Left = Enum.UserInputType.MouseButton1,
2496
+ Right = Enum.UserInputType.MouseButton2,
2497
+ Middle = Enum.UserInputType.MouseButton3,
2086
2498
  }
2087
2499
  local function simulateMouseInput(requestData)
2088
2500
  local action = requestData.action
@@ -2093,56 +2505,43 @@ local function simulateMouseInput(requestData)
2093
2505
  _condition = "Left"
2094
2506
  end
2095
2507
  local button = _condition
2096
- local scrollDirection = requestData.scrollDirection
2097
2508
  if not (action ~= "" and action) then
2098
2509
  return {
2099
2510
  error = "action is required",
2100
2511
  }
2101
2512
  end
2102
- local vim = getVIM()
2103
- if not vim then
2513
+ if x == nil or y == nil then
2104
2514
  return {
2105
- error = "VirtualInputManager is not available in this context",
2515
+ error = "x and y are required",
2106
2516
  }
2107
2517
  end
2108
- local _condition_1 = BUTTON_MAP[button]
2109
- if _condition_1 == nil then
2110
- _condition_1 = 0
2518
+ -- Input is silently dropped by the engine when the window isn't rendering
2519
+ -- (e.g. minimized). Surface that instead of returning a false success.
2520
+ local notRendering = RenderMonitor.notRenderingReason()
2521
+ if notRendering ~= nil then
2522
+ return {
2523
+ error = notRendering,
2524
+ }
2525
+ end
2526
+ local vi = getVI()
2527
+ if not vi then
2528
+ return {
2529
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2530
+ }
2111
2531
  end
2112
- local buttonNum = _condition_1
2532
+ local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
2533
+ local pos = Vector2.new(x, y)
2113
2534
  local success, err = pcall(function()
2114
2535
  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)
2536
+ vi:SendMouseButton(pos, inputType, true)
2119
2537
  task.wait(0.05)
2120
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2538
+ vi:SendMouseButton(pos, inputType, false)
2121
2539
  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)
2540
+ vi:SendMouseButton(pos, inputType, true)
2126
2541
  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")
2542
+ vi:SendMouseButton(pos, inputType, false)
2144
2543
  else
2145
- error(`Unknown action: {action}`)
2544
+ error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
2146
2545
  end
2147
2546
  end)
2148
2547
  if success then
@@ -2159,7 +2558,40 @@ local function simulateMouseInput(requestData)
2159
2558
  }
2160
2559
  end
2161
2560
  local function simulateKeyboardInput(requestData)
2561
+ local notRendering = RenderMonitor.notRenderingReason()
2562
+ if notRendering ~= nil then
2563
+ return {
2564
+ error = notRendering,
2565
+ }
2566
+ end
2567
+ local vi = getVI()
2568
+ if not vi then
2569
+ return {
2570
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2571
+ }
2572
+ end
2573
+ -- Text mode: type a string into the focused TextBox.
2574
+ local text = requestData.text
2575
+ if text ~= nil then
2576
+ local ok, err = pcall(function()
2577
+ return vi:SendTextInput(text)
2578
+ end)
2579
+ if ok then
2580
+ return {
2581
+ success = true,
2582
+ text = text,
2583
+ }
2584
+ end
2585
+ return {
2586
+ error = `Failed to send text input: {err}`,
2587
+ }
2588
+ end
2162
2589
  local keyCodeName = requestData.keyCode
2590
+ if not (keyCodeName ~= "" and keyCodeName) then
2591
+ return {
2592
+ error = "keyCode (or text) is required",
2593
+ }
2594
+ end
2163
2595
  local _condition = (requestData.action)
2164
2596
  if _condition == nil then
2165
2597
  _condition = "tap"
@@ -2170,17 +2602,6 @@ local function simulateKeyboardInput(requestData)
2170
2602
  _condition_1 = 0.1
2171
2603
  end
2172
2604
  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
2605
  local enumOk, keyCode = pcall(function()
2185
2606
  return (Enum.KeyCode)[keyCodeName]
2186
2607
  end)
@@ -2191,13 +2612,13 @@ local function simulateKeyboardInput(requestData)
2191
2612
  end
2192
2613
  local success, err = pcall(function()
2193
2614
  if action == "press" then
2194
- vim:SendKeyEvent(true, keyCode, false)
2615
+ vi:SendKey(true, keyCode)
2195
2616
  elseif action == "release" then
2196
- vim:SendKeyEvent(false, keyCode, false)
2617
+ vi:SendKey(false, keyCode)
2197
2618
  elseif action == "tap" then
2198
- vim:SendKeyEvent(true, keyCode, false)
2619
+ vi:SendKey(true, keyCode)
2199
2620
  task.wait(duration)
2200
- vim:SendKeyEvent(false, keyCode, false)
2621
+ vi:SendKey(false, keyCode)
2201
2622
  else
2202
2623
  error(`Unknown action: {action}`)
2203
2624
  end
@@ -2827,11 +3248,10 @@ return {
2827
3248
  <string name="Name">MetadataHandlers</string>
2828
3249
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
3250
  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
3251
+ local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2833
3252
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2834
3253
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
3254
+ local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
2835
3255
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
2836
3256
  local Selection = game:GetService("Selection")
2837
3257
  local _binding = Utils
@@ -3257,137 +3677,11 @@ local function executeLuau(requestData)
3257
3677
  error = "Code is required",
3258
3678
  }
3259
3679
  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
- }
3680
+ -- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
3681
+ -- of table returns, and parse-error recovery live in LuauExec so the
3682
+ -- edit/server (this handler) and the play-client (ClientBroker) take
3683
+ -- the same code path and produce identical output shapes.
3684
+ return LuauExec.execute(code)
3391
3685
  end
3392
3686
  local function undo(_requestData)
3393
3687
  local success, result = pcall(function()
@@ -4784,28 +5078,277 @@ return {
4784
5078
  </Item>
4785
5079
  <Item class="ModuleScript" referent="16">
4786
5080
  <Properties>
4787
- <string name="Name">ScriptHandlers</string>
5081
+ <string name="Name">SceneAnalysisHandlers</string>
4788
5082
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
4789
- local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
4790
- local Utils = TS.import(script, script.Parent.Parent, "Utils")
4791
- local Recording = TS.import(script, script.Parent.Parent, "Recording")
4792
- local ScriptEditorService = game:GetService("ScriptEditorService")
4793
- local _binding = Utils
4794
- local getInstancePath = _binding.getInstancePath
4795
- local getInstanceByPath = _binding.getInstanceByPath
4796
- local readScriptSource = _binding.readScriptSource
4797
- local splitLines = _binding.splitLines
4798
- local joinLines = _binding.joinLines
4799
- local _binding_1 = Recording
4800
- local beginRecording = _binding_1.beginRecording
4801
- local finishRecording = _binding_1.finishRecording
4802
- local function normalizeEscapes(s)
4803
- local result = s
4804
- result = (string.gsub(result, "\\\\", "\x01"))
4805
- result = (string.gsub(result, "\\n", "\n"))
4806
- result = (string.gsub(result, "\\t", "\t"))
4807
- result = (string.gsub(result, "\\r", "\r"))
4808
- result = (string.gsub(result, '\\"', '"'))
5083
+ local MODE_CONFIGS = {
5084
+ instance_composition = {
5085
+ method = "GetInstanceCompositionAsync",
5086
+ query = function(service)
5087
+ return service:GetInstanceCompositionAsync()
5088
+ end,
5089
+ },
5090
+ script_memory = {
5091
+ method = "GetScriptMemoryAsync",
5092
+ query = function(service)
5093
+ return service:GetScriptMemoryAsync()
5094
+ end,
5095
+ },
5096
+ unparented_instances = {
5097
+ method = "GetUnparentedInstancesAsync",
5098
+ query = function(service)
5099
+ return service:GetUnparentedInstancesAsync()
5100
+ end,
5101
+ },
5102
+ triangle_composition = {
5103
+ method = "GetTriangleCompositionAsync",
5104
+ query = function(service)
5105
+ return service:GetTriangleCompositionAsync()
5106
+ end,
5107
+ sortByTriangles = true,
5108
+ },
5109
+ animation_memory = {
5110
+ method = "GetAnimationMemoryAsync",
5111
+ query = function(service)
5112
+ return service:GetAnimationMemoryAsync()
5113
+ end,
5114
+ },
5115
+ audio_memory = {
5116
+ method = "GetAudioMemoryAsync",
5117
+ query = function(service)
5118
+ return service:GetAudioMemoryAsync()
5119
+ end,
5120
+ },
5121
+ }
5122
+ local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
5123
+ local function betaDisabledError()
5124
+ return {
5125
+ error = "scene_analysis_not_enabled",
5126
+ message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
5127
+ betaFeatureRequired = true,
5128
+ }
5129
+ end
5130
+ local function isBetaDisabledError(value)
5131
+ local _value = value
5132
+ local _condition = type(_value) == "string"
5133
+ if _condition then
5134
+ _condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
5135
+ end
5136
+ return _condition
5137
+ end
5138
+ local function getSceneAnalysisService()
5139
+ local provider = game
5140
+ local ok, service = pcall(function()
5141
+ return provider:GetService("SceneAnalysisService")
5142
+ end)
5143
+ if not ok or not service then
5144
+ return {
5145
+ error = "scene_analysis_unavailable",
5146
+ message = `SceneAnalysisService is unavailable: {tostring(service)}`,
5147
+ }
5148
+ end
5149
+ return service
5150
+ end
5151
+ local function normalizeMode(mode)
5152
+ if mode == nil or mode == "all" then
5153
+ return "all"
5154
+ end
5155
+ local _mode = mode
5156
+ local _condition = not (type(_mode) == "string")
5157
+ if not _condition then
5158
+ _condition = MODE_CONFIGS[mode] == nil
5159
+ end
5160
+ if _condition then
5161
+ return {
5162
+ error = "invalid_mode",
5163
+ message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
5164
+ }
5165
+ end
5166
+ return mode
5167
+ end
5168
+ local function normalizeTopN(topN)
5169
+ local _topN = topN
5170
+ if not (type(_topN) == "number") then
5171
+ return 10
5172
+ end
5173
+ return math.clamp(math.floor(topN), 1, 100)
5174
+ end
5175
+ local function countLeaves(node)
5176
+ local children = node.Children
5177
+ if children and #children > 0 then
5178
+ local total = 0
5179
+ for _, child in children do
5180
+ total += countLeaves(child)
5181
+ end
5182
+ return total
5183
+ end
5184
+ return 1
5185
+ end
5186
+ local function flattenLeaves(node, out)
5187
+ local children = node.Children
5188
+ if children and #children > 0 then
5189
+ for _, child in children do
5190
+ flattenLeaves(child, out)
5191
+ end
5192
+ return nil
5193
+ end
5194
+ local _out = out
5195
+ local _node = node
5196
+ table.insert(_out, _node)
5197
+ end
5198
+ local function compactEntry(node)
5199
+ local entry = {
5200
+ name = node.Name,
5201
+ }
5202
+ if node.Size ~= nil then
5203
+ entry.size = node.Size
5204
+ end
5205
+ if node.Sizes ~= nil then
5206
+ entry.sizes = node.Sizes
5207
+ end
5208
+ if node.AssetId ~= nil then
5209
+ entry.asset_id = node.AssetId
5210
+ end
5211
+ return entry
5212
+ end
5213
+ local function compactRoot(node, leafCount)
5214
+ local children = node.Children
5215
+ local root = {
5216
+ name = node.Name,
5217
+ child_count = if children then #children else 0,
5218
+ leaf_count = leafCount,
5219
+ }
5220
+ if node.Size ~= nil then
5221
+ root.size = node.Size
5222
+ end
5223
+ if node.Sizes ~= nil then
5224
+ root.sizes = node.Sizes
5225
+ end
5226
+ return root
5227
+ end
5228
+ local function metric(node, sortByTriangles)
5229
+ if sortByTriangles then
5230
+ local sizes = node.Sizes
5231
+ local triangles = if sizes then sizes.Triangles else nil
5232
+ local _condition = triangles
5233
+ if _condition == nil then
5234
+ _condition = 0
5235
+ end
5236
+ return _condition
5237
+ end
5238
+ local _condition = node.Size
5239
+ if _condition == nil then
5240
+ _condition = 0
5241
+ end
5242
+ return _condition
5243
+ end
5244
+ local function summarizeMode(mode, config, service, topN, raw)
5245
+ local started = os.clock()
5246
+ local ok, result = pcall(function()
5247
+ return config.query(service)
5248
+ end)
5249
+ local elapsedMs = math.floor((os.clock() - started) * 1000)
5250
+ if not ok then
5251
+ if isBetaDisabledError(result) then
5252
+ return betaDisabledError()
5253
+ end
5254
+ return {
5255
+ error = "scene_analysis_query_failed",
5256
+ mode = mode,
5257
+ method = config.method,
5258
+ message = tostring(result),
5259
+ }
5260
+ end
5261
+ local tree = result
5262
+ local leaves = {}
5263
+ flattenLeaves(tree, leaves)
5264
+ table.sort(leaves, function(a, b)
5265
+ return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
5266
+ end)
5267
+ local top = {}
5268
+ do
5269
+ local i = 0
5270
+ local _shouldIncrement = false
5271
+ while true do
5272
+ if _shouldIncrement then
5273
+ i += 1
5274
+ else
5275
+ _shouldIncrement = true
5276
+ end
5277
+ if not (i < math.min(topN, #leaves)) then
5278
+ break
5279
+ end
5280
+ local _arg0 = compactEntry(leaves[i + 1])
5281
+ table.insert(top, _arg0)
5282
+ end
5283
+ end
5284
+ local body = {
5285
+ mode = mode,
5286
+ method = config.method,
5287
+ elapsed_ms = elapsedMs,
5288
+ root = compactRoot(tree, #leaves),
5289
+ top = top,
5290
+ }
5291
+ if raw then
5292
+ body.tree = tree
5293
+ end
5294
+ return body
5295
+ end
5296
+ local function getSceneAnalysis(requestData)
5297
+ local mode = normalizeMode(requestData.mode)
5298
+ if not (type(mode) == "string") then
5299
+ return mode
5300
+ end
5301
+ local serviceOrError = getSceneAnalysisService()
5302
+ local _value = serviceOrError.IsA
5303
+ if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
5304
+ return serviceOrError
5305
+ end
5306
+ local service = serviceOrError
5307
+ local topN = normalizeTopN(requestData.topN)
5308
+ local raw = requestData.raw == true
5309
+ if mode ~= "all" then
5310
+ return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
5311
+ end
5312
+ local body = {}
5313
+ for _, m in ALL_MODES do
5314
+ local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
5315
+ if result.error == "scene_analysis_not_enabled" then
5316
+ return result
5317
+ end
5318
+ body[m] = result
5319
+ end
5320
+ return body
5321
+ end
5322
+ return {
5323
+ getSceneAnalysis = getSceneAnalysis,
5324
+ }
5325
+ ]]></string>
5326
+ </Properties>
5327
+ </Item>
5328
+ <Item class="ModuleScript" referent="17">
5329
+ <Properties>
5330
+ <string name="Name">ScriptHandlers</string>
5331
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5332
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5333
+ local Utils = TS.import(script, script.Parent.Parent, "Utils")
5334
+ local Recording = TS.import(script, script.Parent.Parent, "Recording")
5335
+ local ScriptEditorService = game:GetService("ScriptEditorService")
5336
+ local _binding = Utils
5337
+ local getInstancePath = _binding.getInstancePath
5338
+ local getInstanceByPath = _binding.getInstanceByPath
5339
+ local readScriptSource = _binding.readScriptSource
5340
+ local splitLines = _binding.splitLines
5341
+ local joinLines = _binding.joinLines
5342
+ local _binding_1 = Recording
5343
+ local beginRecording = _binding_1.beginRecording
5344
+ local finishRecording = _binding_1.finishRecording
5345
+ local function normalizeEscapes(s)
5346
+ local result = s
5347
+ result = (string.gsub(result, "\\\\", "\x01"))
5348
+ result = (string.gsub(result, "\\n", "\n"))
5349
+ result = (string.gsub(result, "\\t", "\t"))
5350
+ result = (string.gsub(result, "\\r", "\r"))
5351
+ result = (string.gsub(result, '\\"', '"'))
4809
5352
  result = (string.gsub(result, "\x01", "\\"))
4810
5353
  return result
4811
5354
  end
@@ -5478,7 +6021,7 @@ return {
5478
6021
  ]]></string>
5479
6022
  </Properties>
5480
6023
  </Item>
5481
- <Item class="ModuleScript" referent="17">
6024
+ <Item class="ModuleScript" referent="18">
5482
6025
  <Properties>
5483
6026
  <string name="Name">SerializationHandlers</string>
5484
6027
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5664,7 +6207,7 @@ return {
5664
6207
  ]]></string>
5665
6208
  </Properties>
5666
6209
  </Item>
5667
- <Item class="ModuleScript" referent="18">
6210
+ <Item class="ModuleScript" referent="19">
5668
6211
  <Properties>
5669
6212
  <string name="Name">TestHandlers</string>
5670
6213
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5672,10 +6215,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5672
6215
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5673
6216
  local HttpService = _services.HttpService
5674
6217
  local LogService = _services.LogService
6218
+ local Players = _services.Players
5675
6219
  local RunService = _services.RunService
5676
6220
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5677
6221
  local installBridges = _EvalBridges.installBridges
5678
- local cleanupBridges = _EvalBridges.cleanupBridges
6222
+ local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
5679
6223
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5680
6224
  local StudioTestService = game:GetService("StudioTestService")
5681
6225
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -5693,6 +6237,63 @@ local testResult
5693
6237
  local testError
5694
6238
  local stopListenerScript
5695
6239
  local navResultCallback
6240
+ local multiplayerState = {
6241
+ phase = "idle",
6242
+ }
6243
+ local function detectPeerRole()
6244
+ if not RunService:IsRunning() then
6245
+ return "edit"
6246
+ end
6247
+ if RunService:IsServer() then
6248
+ return "server"
6249
+ end
6250
+ return "client"
6251
+ end
6252
+ local function getPlayersSnapshot()
6253
+ local _exp = Players:GetPlayers()
6254
+ -- ▼ ReadonlyArray.map ▼
6255
+ local _newValue = table.create(#_exp)
6256
+ local _callback = function(player)
6257
+ return {
6258
+ name = player.Name,
6259
+ userId = player.UserId,
6260
+ displayName = player.DisplayName,
6261
+ }
6262
+ end
6263
+ for _k, _v in _exp do
6264
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
6265
+ end
6266
+ -- ▲ ReadonlyArray.map ▲
6267
+ local players = _newValue
6268
+ table.sort(players, function(a, b)
6269
+ return a.name < b.name
6270
+ end)
6271
+ return players
6272
+ end
6273
+ local function cloneMultiplayerState()
6274
+ return {
6275
+ phase = multiplayerState.phase,
6276
+ testId = multiplayerState.testId,
6277
+ numPlayers = multiplayerState.numPlayers,
6278
+ testArgs = multiplayerState.testArgs,
6279
+ startedAt = multiplayerState.startedAt,
6280
+ completedAt = multiplayerState.completedAt,
6281
+ ok = multiplayerState.ok,
6282
+ result = multiplayerState.result,
6283
+ error = multiplayerState.error,
6284
+ }
6285
+ end
6286
+ local function normalizeNumPlayers(value)
6287
+ local _value = value
6288
+ if not (type(_value) == "number") then
6289
+ return nil
6290
+ end
6291
+ local n = math.floor(value)
6292
+ if n ~= value or n < 1 or n > 8 then
6293
+ return nil
6294
+ end
6295
+ return n
6296
+ end
5696
6297
  local function buildCommandListenerSource()
5697
6298
  return `local LogService = game:GetService("LogService")\
5698
6299
  local PathfindingService = game:GetService("PathfindingService")\
@@ -5789,6 +6390,11 @@ local function startPlaytest(requestData)
5789
6390
  error = 'mode must be "play" or "run"',
5790
6391
  }
5791
6392
  end
6393
+ if numPlayers ~= nil then
6394
+ return {
6395
+ error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
6396
+ }
6397
+ end
5792
6398
  -- Self-heal: if testRunning is stuck true but Studio reports no active
5793
6399
  -- playtest, the previous start_playtest's task.spawn was orphaned
5794
6400
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -5800,7 +6406,9 @@ local function startPlaytest(requestData)
5800
6406
  logConnection = nil
5801
6407
  end
5802
6408
  cleanupStopListener()
5803
- cleanupBridges()
6409
+ -- Note: eval bridges are intentionally NOT cleaned up — they live
6410
+ -- permanently in the edit DM so manual playtests also get them. See
6411
+ -- EvalBridges.ts lifecycle comment.
5804
6412
  end
5805
6413
  if testRunning then
5806
6414
  return {
@@ -5843,17 +6451,14 @@ local function startPlaytest(requestData)
5843
6451
  if not injected then
5844
6452
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5845
6453
  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.
6454
+ -- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
6455
+ -- right before cloning so the play DMs get the current source. They also
6456
+ -- live permanently in the edit DM (installed on connect) so manually-started
6457
+ -- playtests get them too; here we just ensure they're fresh.
5849
6458
  local bridgeInstall = installBridges()
5850
6459
  if not bridgeInstall.installed then
5851
6460
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
5852
6461
  end
5853
- if numPlayers ~= nil and mode == "run" then
5854
- local TestService = game:GetService("TestService")
5855
- TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
5856
- end
5857
6462
  task.spawn(function()
5858
6463
  local ok, result = pcall(function()
5859
6464
  if mode == "play" then
@@ -5872,12 +6477,13 @@ local function startPlaytest(requestData)
5872
6477
  end
5873
6478
  testRunning = false
5874
6479
  cleanupStopListener()
5875
- cleanupBridges()
6480
+ -- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
6481
+ -- clean up here, so the next manual playtest still gets them.
6482
+ ensureBridgesInstalled()
5876
6483
  end)
5877
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5878
6484
  local response = {
5879
6485
  success = true,
5880
- message = msg,
6486
+ message = `Playtest started in {mode} mode.`,
5881
6487
  }
5882
6488
  -- Only mention eval bridges when they failed — when they're fine, the
5883
6489
  -- detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -5900,9 +6506,25 @@ local function stopPlaytest(_requestData)
5900
6506
  }
5901
6507
  end
5902
6508
  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.
6509
+ -- Two distinct failure modes collapse here, distinguished by whether
6510
+ -- THIS edit DM has a playtest tracked:
6511
+ --
6512
+ -- - testRunning=false: no playtest was running from this edit DM
6513
+ -- (true negative). Return "no active playtest" — fine to retry only
6514
+ -- after actually starting a playtest.
6515
+ -- - testRunning=true: a playtest IS running but the cross-DM signal
6516
+ -- didn't propagate within the consumption timeout (false negative
6517
+ -- from the caller's perspective — playtest may actually have ended).
6518
+ -- Tell the caller it's a timing issue and they can retry.
6519
+ --
6520
+ -- Either way clean up the pending flag so a future playtest's monitor
6521
+ -- doesn't fire EndTest on startup against a stale signal.
5905
6522
  StopPlayMonitor.clearPending()
6523
+ if testRunning then
6524
+ return {
6525
+ error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
6526
+ }
6527
+ end
5906
6528
  return {
5907
6529
  error = "No active playtest to stop.",
5908
6530
  }
@@ -5944,6 +6566,198 @@ local function getPlaytestOutput(_requestData)
5944
6566
  _object.testError = testError
5945
6567
  return _object
5946
6568
  end
6569
+ local function multiplayerTestStart(requestData)
6570
+ if RunService:IsRunning() then
6571
+ return {
6572
+ error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
6573
+ }
6574
+ end
6575
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6576
+ if numPlayers == nil then
6577
+ return {
6578
+ error = "numPlayers must be an integer from 1 to 8",
6579
+ }
6580
+ end
6581
+ if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
6582
+ return {
6583
+ error = "A multiplayer Studio test is already running",
6584
+ state = cloneMultiplayerState(),
6585
+ }
6586
+ end
6587
+ local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
6588
+ local testId = HttpService:GenerateGUID(false)
6589
+ local bridgeInstall = installBridges()
6590
+ if not bridgeInstall.installed then
6591
+ warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6592
+ end
6593
+ multiplayerState = {
6594
+ phase = "starting",
6595
+ testId = testId,
6596
+ numPlayers = numPlayers,
6597
+ testArgs = testArgs,
6598
+ startedAt = tick(),
6599
+ }
6600
+ task.spawn(function()
6601
+ multiplayerState.phase = "running"
6602
+ local ok, result = pcall(function()
6603
+ return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
6604
+ end)
6605
+ multiplayerState.completedAt = tick()
6606
+ multiplayerState.ok = ok
6607
+ if ok then
6608
+ multiplayerState.phase = "completed"
6609
+ multiplayerState.result = result
6610
+ multiplayerState.error = nil
6611
+ else
6612
+ multiplayerState.phase = "failed"
6613
+ multiplayerState.result = nil
6614
+ multiplayerState.error = tostring(result)
6615
+ end
6616
+ ensureBridgesInstalled()
6617
+ end)
6618
+ local response = {
6619
+ success = true,
6620
+ message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
6621
+ testId = testId,
6622
+ phase = multiplayerState.phase,
6623
+ numPlayers = numPlayers,
6624
+ testArgs = testArgs,
6625
+ }
6626
+ if not bridgeInstall.installed then
6627
+ response.evalBridgesError = bridgeInstall.error
6628
+ end
6629
+ return response
6630
+ end
6631
+ local function multiplayerTestState(_requestData)
6632
+ local peer = detectPeerRole()
6633
+ local response = {
6634
+ success = true,
6635
+ peer = peer,
6636
+ isRunning = RunService:IsRunning(),
6637
+ isRunMode = RunService:IsRunMode(),
6638
+ editModeActive = StudioTestService.EditModeActive,
6639
+ }
6640
+ if peer == "edit" then
6641
+ response.session = cloneMultiplayerState()
6642
+ return response
6643
+ end
6644
+ local argsOk, args = pcall(function()
6645
+ return StudioTestService:GetTestArgs()
6646
+ end)
6647
+ response.testArgsOk = argsOk
6648
+ response.testArgs = if argsOk then args else nil
6649
+ if not argsOk then
6650
+ response.testArgsError = tostring(args)
6651
+ end
6652
+ local players = getPlayersSnapshot()
6653
+ response.players = players
6654
+ response.playerCount = #players
6655
+ if peer == "client" then
6656
+ response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6657
+ local canLeaveOk, canLeave = pcall(function()
6658
+ return StudioTestService:CanLeaveTest()
6659
+ end)
6660
+ response.canLeaveOk = canLeaveOk
6661
+ response.canLeave = if canLeaveOk then canLeave else false
6662
+ if not canLeaveOk then
6663
+ response.canLeaveError = tostring(canLeave)
6664
+ end
6665
+ end
6666
+ return response
6667
+ end
6668
+ local function multiplayerTestAddPlayers(requestData)
6669
+ if not RunService:IsRunning() or not RunService:IsServer() then
6670
+ return {
6671
+ error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
6672
+ }
6673
+ end
6674
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6675
+ if numPlayers == nil then
6676
+ return {
6677
+ error = "numPlayers must be an integer from 1 to 8",
6678
+ }
6679
+ end
6680
+ local before = #Players:GetPlayers()
6681
+ local ok, result = pcall(function()
6682
+ return StudioTestService:AddPlayers(numPlayers)
6683
+ end)
6684
+ if not ok then
6685
+ return {
6686
+ error = tostring(result),
6687
+ }
6688
+ end
6689
+ local _exp = tick()
6690
+ local _condition = (requestData.timeout)
6691
+ if _condition == nil then
6692
+ _condition = 10
6693
+ end
6694
+ local deadline = _exp + _condition
6695
+ while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
6696
+ task.wait(0.1)
6697
+ end
6698
+ local players = getPlayersSnapshot()
6699
+ return {
6700
+ success = true,
6701
+ message = `Requested {numPlayers} additional player(s).`,
6702
+ playerCount = #players,
6703
+ players = players,
6704
+ }
6705
+ end
6706
+ local function multiplayerTestLeaveClient(_requestData)
6707
+ if not RunService:IsRunning() or RunService:IsServer() then
6708
+ return {
6709
+ error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
6710
+ }
6711
+ end
6712
+ local canLeaveOk, canLeave = pcall(function()
6713
+ return StudioTestService:CanLeaveTest()
6714
+ end)
6715
+ if not canLeaveOk then
6716
+ return {
6717
+ error = tostring(canLeave),
6718
+ canLeaveOk = false,
6719
+ }
6720
+ end
6721
+ if not canLeave then
6722
+ return {
6723
+ error = "This client cannot leave the current test session.",
6724
+ canLeaveOk = true,
6725
+ canLeave = false,
6726
+ }
6727
+ end
6728
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6729
+ task.defer(function()
6730
+ pcall(function()
6731
+ return StudioTestService:LeaveTest()
6732
+ end)
6733
+ end)
6734
+ return {
6735
+ success = true,
6736
+ message = "Client leave requested.",
6737
+ localPlayer = localPlayer,
6738
+ }
6739
+ end
6740
+ local function multiplayerTestEnd(requestData)
6741
+ if not RunService:IsRunning() or not RunService:IsServer() then
6742
+ return {
6743
+ error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
6744
+ }
6745
+ end
6746
+ local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
6747
+ local ok, result = pcall(function()
6748
+ return StudioTestService:EndTest(value)
6749
+ end)
6750
+ if not ok then
6751
+ return {
6752
+ error = tostring(result),
6753
+ }
6754
+ end
6755
+ return {
6756
+ success = true,
6757
+ message = "Multiplayer Studio test end requested.",
6758
+ value = value,
6759
+ }
6760
+ end
5947
6761
  local function characterNavigation(requestData)
5948
6762
  if not testRunning then
5949
6763
  return {
@@ -6015,13 +6829,336 @@ return {
6015
6829
  startPlaytest = startPlaytest,
6016
6830
  stopPlaytest = stopPlaytest,
6017
6831
  getPlaytestOutput = getPlaytestOutput,
6832
+ multiplayerTestStart = multiplayerTestStart,
6833
+ multiplayerTestState = multiplayerTestState,
6834
+ multiplayerTestAddPlayers = multiplayerTestAddPlayers,
6835
+ multiplayerTestLeaveClient = multiplayerTestLeaveClient,
6836
+ multiplayerTestEnd = multiplayerTestEnd,
6018
6837
  characterNavigation = characterNavigation,
6019
6838
  }
6020
6839
  ]]></string>
6021
6840
  </Properties>
6022
6841
  </Item>
6023
6842
  </Item>
6024
- <Item class="ModuleScript" referent="19">
6843
+ <Item class="ModuleScript" referent="20">
6844
+ <Properties>
6845
+ <string name="Name">LuauExec</string>
6846
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6847
+ -- eslint-disable
6848
+ -- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
6849
+ -- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
6850
+ -- module owns:
6851
+ --
6852
+ -- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
6853
+ -- and always returns { ok, value, output } so the ModuleScript itself
6854
+ -- always returns exactly one value (otherwise `print("hi")` with no
6855
+ -- return would fail with "Module code did not return exactly one value").
6856
+ --
6857
+ -- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
6858
+ -- recovery hack that pulls the real diagnostic from LogService.
6859
+ --
6860
+ -- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
6861
+ -- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
6862
+ -- pass through tostring. The encode is pcall'd so cycles or
6863
+ -- non-serializable values gracefully fall back to tostring.
6864
+ --
6865
+ -- Before this module existed, the client peer used a stripped-down
6866
+ -- require-only execution path that lacked both the wrapper and the JSON
6867
+ -- formatting, producing two well-known papercuts:
6868
+ -- - `print("hi")` (no return) failed with "Module code did not return..."
6869
+ -- - Returning a table yielded `table: 0xaddr` instead of structured data.
6870
+ local HttpService = game:GetService("HttpService")
6871
+ local LogService = game:GetService("LogService")
6872
+ local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
6873
+ local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
6874
+ -- Number of lines the wrapper emits BEFORE the first line of user code.
6875
+ -- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
6876
+ -- (remapPayloadLines, for compile errors recovered from LogService) so user
6877
+ -- code errors report user-relative line numbers instead of the inflated
6878
+ -- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
6879
+ -- prefix lines, update this constant — there's a self-check below.
6880
+ local WRAPPER_LINE_OFFSET = 23
6881
+ -- Count source lines so the wrapper can filter traceback frames that fall
6882
+ -- outside the user code range (the wrapper's own preamble/postamble lines).
6883
+ local function countLines(s)
6884
+ local n = 1
6885
+ local size = #s
6886
+ do
6887
+ local i = 1
6888
+ local _shouldIncrement = false
6889
+ while true do
6890
+ if _shouldIncrement then
6891
+ i += 1
6892
+ else
6893
+ _shouldIncrement = true
6894
+ end
6895
+ if not (i <= size) then
6896
+ break
6897
+ end
6898
+ if string.sub(s, i, i) == "\n" then
6899
+ n += 1
6900
+ end
6901
+ end
6902
+ end
6903
+ return n
6904
+ end
6905
+ local function buildWrapper(code)
6906
+ -- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
6907
+ -- match the number of lines emitted BEFORE the ${code} substitution.
6908
+ -- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
6909
+ -- used by remapPayloadLines on the TS side.
6910
+ local userLines = countLines(code)
6911
+ return `return ((function()\
6912
+ \tlocal __mcp_traceback\
6913
+ \tlocal __mcp_remap\
6914
+ \tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
6915
+ \tlocal __mcp_USER_LINES = {userLines}\
6916
+ \tlocal __mcp_output = \{\}\
6917
+ \tlocal __mcp_real_print = print\
6918
+ \tlocal __mcp_real_warn = warn\
6919
+ \tlocal print = function(...)\
6920
+ \t\t__mcp_real_print(...)\
6921
+ \t\tlocal args = \{...\}\
6922
+ \t\tlocal parts = table.create(#args)\
6923
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6924
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
6925
+ \tend\
6926
+ \tlocal warn = function(...)\
6927
+ \t\t__mcp_real_warn(...)\
6928
+ \t\tlocal args = \{...\}\
6929
+ \t\tlocal parts = table.create(#args)\
6930
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6931
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
6932
+ \tend\
6933
+ \tlocal function __mcp_run()\
6934
+ {code}\
6935
+ \tend\
6936
+ \t__mcp_remap = function(s)\
6937
+ \t\t-- Two chunk-name formats can reference our payload:\
6938
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
6939
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
6940
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
6941
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
6942
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload\
6943
+ \t\t-- line past user EOF. Without clamping the message says "user_code:49"\
6944
+ \t\t-- for one-line input, framing the wrapper as user code.\
6945
+ \t\tlocal function __mcp_user_line(payload_n)\
6946
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
6947
+ \t\t\tif user_n < 1 then return "1" end\
6948
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
6949
+ \t\t\treturn tostring(user_n)\
6950
+ \t\tend\
6951
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
6952
+ \t\t\tlocal n = tonumber(num)\
6953
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6954
+ \t\t\treturn "user_code:" .. num\
6955
+ \t\tend)\
6956
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
6957
+ \t\t\tlocal n = tonumber(num)\
6958
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6959
+ \t\t\treturn "user_code:" .. num\
6960
+ \t\tend)\
6961
+ \t\treturn s\
6962
+ \tend\
6963
+ \t__mcp_traceback = function(err)\
6964
+ \t\tlocal raw = debug.traceback(tostring(err), 2)\
6965
+ \t\tlocal kept = \{\}\
6966
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do\
6967
+ \t\t\t-- Extract referenced line number (either chunk-name format).\
6968
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
6969
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
6970
+ \t\t\tlocal n = num_str and tonumber(num_str)\
6971
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
6972
+ \t\t\t-- any filtering, because user-code frames carry that suffix —\
6973
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
6974
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
6975
+ \t\t\t-- get dropped. Strip first, then apply filters.\
6976
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
6977
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
6978
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)\
6979
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
6980
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
6981
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without\
6982
+ \t\t\t-- a payload-chunk line number (the traceback header / engine\
6983
+ \t\t\t-- C frames) are kept; remap is a no-op for them.\
6984
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
6985
+ \t\t\t\tskip = true\
6986
+ \t\t\tend\
6987
+ \t\t\tif not skip then\
6988
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))\
6989
+ \t\t\tend\
6990
+ \t\tend\
6991
+ \t\treturn table.concat(kept, "\\n")\
6992
+ \tend\
6993
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
6994
+ \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
6995
+ end)())`
6996
+ end
6997
+ -- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
6998
+ -- pulling the real compile-error diagnostic out of LogService — that error
6999
+ -- references the payload module's line number directly, and never passes
7000
+ -- through the IIFE's runtime wrapper.
7001
+ local function remapPayloadLines(s, userLines)
7002
+ -- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
7003
+ -- don't pass through the IIFE (compile errors recovered from
7004
+ -- LogService, the immediate loadstring compileError surface). Same
7005
+ -- two-format coverage plus the same clamp: unclosed user constructs
7006
+ -- let the parser consume wrapper postamble, so the raw payload line
7007
+ -- is sometimes well past user EOF — clamp to [1, userLines] and
7008
+ -- annotate so the error doesn't say "user_code:49" for one-line input.
7009
+ local userLine = function(payload)
7010
+ local u = payload - WRAPPER_LINE_OFFSET
7011
+ if u < 1 then
7012
+ return "1"
7013
+ end
7014
+ if u > userLines then
7015
+ return `{tostring(userLines)} (at end of input)`
7016
+ end
7017
+ return tostring(u)
7018
+ end
7019
+ local out = s
7020
+ local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
7021
+ local n = tonumber(num)
7022
+ if n ~= nil then
7023
+ return `user_code:{userLine(n)}`
7024
+ end
7025
+ return `user_code:{num}`
7026
+ end)
7027
+ out = a
7028
+ local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
7029
+ local n = tonumber(num)
7030
+ if n ~= nil then
7031
+ return `user_code:{userLine(n)}`
7032
+ end
7033
+ return `user_code:{num}`
7034
+ end)
7035
+ out = b
7036
+ return out
7037
+ end
7038
+ local function runViaModuleScript(wrapped, userLines)
7039
+ local m = Instance.new("ModuleScript")
7040
+ m.Name = PAYLOAD_INSTANCE_NAME
7041
+ local okSet, setErr = pcall(function()
7042
+ m.Source = wrapped
7043
+ end)
7044
+ if not okSet then
7045
+ m:Destroy()
7046
+ -- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
7047
+ -- prefix that error() would otherwise prepend, keeping the visible
7048
+ -- message focused on the user-actionable error rather than our path.
7049
+ error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
7050
+ end
7051
+ m.Parent = game:GetService("Workspace")
7052
+ local okReq, reqResult = pcall(function()
7053
+ return require(m)
7054
+ end)
7055
+ m:Destroy()
7056
+ if not okReq then
7057
+ local errMsg = tostring(reqResult)
7058
+ -- pcall(require, m) collapses parse/compile failures into the canned
7059
+ -- engine string. The real diagnostic was emitted to LogService on the
7060
+ -- next engine frame — give it ~50ms to land then scan backward.
7061
+ if errMsg == "Requested module experienced an error while loading" then
7062
+ task.wait(0.05)
7063
+ local hist = LogService:GetLogHistory()
7064
+ for i = #hist - 1, 0, -1 do
7065
+ local e = hist[i + 1]
7066
+ if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
7067
+ errMsg = e.message
7068
+ break
7069
+ end
7070
+ end
7071
+ end
7072
+ -- Compile errors reference the payload module's line number directly
7073
+ -- — remap + clamp to user-relative line numbers so `local x = 1 +`
7074
+ -- reports :1: instead of :23:, and reports the clamp annotation
7075
+ -- when the parser ran off the end of user code into wrapper code.
7076
+ error(remapPayloadLines(errMsg, userLines), 0)
7077
+ end
7078
+ return reqResult
7079
+ end
7080
+ local function isLoadstringUnavailable(err)
7081
+ local errStr = tostring(err)
7082
+ local matchStart = string.find(errStr, "not available", 1, true)
7083
+ return matchStart ~= nil
7084
+ end
7085
+ -- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
7086
+ -- the caller sees structured data instead of "table: 0xaddr". Anything that
7087
+ -- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
7088
+ local function formatReturnValue(value)
7089
+ if value == nil then
7090
+ return ""
7091
+ end
7092
+ local _value = value
7093
+ if type(_value) == "table" then
7094
+ local ok, encoded = pcall(function()
7095
+ return HttpService:JSONEncode(value)
7096
+ end)
7097
+ if ok then
7098
+ return encoded
7099
+ end
7100
+ end
7101
+ return tostring(value)
7102
+ end
7103
+ local function execute(code)
7104
+ if not (code ~= "" and code) or code == "" then
7105
+ return {
7106
+ success = false,
7107
+ error = "code is required",
7108
+ }
7109
+ end
7110
+ local wrapped = buildWrapper(code)
7111
+ local userLines = countLines(code)
7112
+ local success, result = pcall(function()
7113
+ local fn, compileError = loadstring(wrapped)
7114
+ if not fn then
7115
+ if isLoadstringUnavailable(compileError) then
7116
+ return runViaModuleScript(wrapped, userLines)
7117
+ end
7118
+ error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
7119
+ end
7120
+ return fn()
7121
+ end)
7122
+ -- loadstring can throw (not return nil) when ServerScriptService.
7123
+ -- LoadStringEnabled is false; treat that as a second-chance fallback.
7124
+ if not success and isLoadstringUnavailable(result) then
7125
+ success, result = pcall(function()
7126
+ return runViaModuleScript(wrapped, userLines)
7127
+ end)
7128
+ end
7129
+ if not success then
7130
+ return {
7131
+ success = false,
7132
+ error = tostring(result),
7133
+ output = {},
7134
+ message = "Code execution failed",
7135
+ }
7136
+ end
7137
+ local r = result
7138
+ local capturedOutput = r.output
7139
+ local output = if capturedOutput ~= nil then capturedOutput else ({})
7140
+ if r.ok == true then
7141
+ return {
7142
+ success = true,
7143
+ returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
7144
+ output = output,
7145
+ message = "Code executed successfully",
7146
+ }
7147
+ end
7148
+ return {
7149
+ success = false,
7150
+ error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
7151
+ output = output,
7152
+ message = "Code execution failed",
7153
+ }
7154
+ end
7155
+ return {
7156
+ execute = execute,
7157
+ }
7158
+ ]]></string>
7159
+ </Properties>
7160
+ </Item>
7161
+ <Item class="ModuleScript" referent="21">
6025
7162
  <Properties>
6026
7163
  <string name="Name">Recording</string>
6027
7164
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6051,7 +7188,75 @@ return {
6051
7188
  ]]></string>
6052
7189
  </Properties>
6053
7190
  </Item>
6054
- <Item class="ModuleScript" referent="20">
7191
+ <Item class="ModuleScript" referent="22">
7192
+ <Properties>
7193
+ <string name="Name">RenderMonitor</string>
7194
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7195
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7196
+ -- Detects whether the Studio window is actually rendering, so virtual input
7197
+ -- and screenshot tools can surface a clear reason instead of silently failing.
7198
+ --
7199
+ -- When a Studio window is MINIMIZED, the engine suspends the render loop AND
7200
+ -- input processing, but keeps running scripts (Heartbeat keeps firing). That's
7201
+ -- why simulate_*_input would return success while having zero effect, and
7202
+ -- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
7203
+ -- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
7204
+ -- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
7205
+ -- signal; Heartbeat is not.
7206
+ local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
7207
+ local lastFrame = 0
7208
+ local connected = false
7209
+ -- Above this many seconds since the last rendered frame, we treat the window
7210
+ -- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
7211
+ -- gap only happens when minimized/suspended, so 1s cleanly avoids false
7212
+ -- positives from ordinary frame hitches while still catching the real case.
7213
+ local STALE_THRESHOLD = 1.0
7214
+ local function start()
7215
+ if connected then
7216
+ return nil
7217
+ end
7218
+ -- RenderStepped can only be connected from a client/edit render loop; it
7219
+ -- throws in the play-server DM. pcall so a server-DM call is a safe no-op
7220
+ -- (connected stays false → notRenderingReason() returns undefined there).
7221
+ local ok = pcall(function()
7222
+ RunService.RenderStepped:Connect(function()
7223
+ lastFrame = tick()
7224
+ end)
7225
+ end)
7226
+ if ok then
7227
+ connected = true
7228
+ lastFrame = tick()
7229
+ end
7230
+ end
7231
+ local function secondsSinceFrame()
7232
+ if not connected then
7233
+ return 0
7234
+ end
7235
+ return tick() - lastFrame
7236
+ end
7237
+ -- Returns a human-readable reason if the window appears minimized / not
7238
+ -- rendering (so input + screenshots won't work), else undefined. Fail-open:
7239
+ -- when the monitor isn't active in this DM (server peer, or connect failed) it
7240
+ -- returns undefined so we never block on a false signal.
7241
+ local function notRenderingReason()
7242
+ if not connected then
7243
+ return nil
7244
+ end
7245
+ local gap = secondsSinceFrame()
7246
+ if gap > STALE_THRESHOLD then
7247
+ 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)
7248
+ end
7249
+ return nil
7250
+ end
7251
+ return {
7252
+ start = start,
7253
+ secondsSinceFrame = secondsSinceFrame,
7254
+ notRenderingReason = notRenderingReason,
7255
+ }
7256
+ ]]></string>
7257
+ </Properties>
7258
+ </Item>
7259
+ <Item class="ModuleScript" referent="23">
6055
7260
  <Properties>
6056
7261
  <string name="Name">RuntimeLogBuffer</string>
6057
7262
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6232,11 +7437,11 @@ return {
6232
7437
  ]]></string>
6233
7438
  </Properties>
6234
7439
  </Item>
6235
- <Item class="ModuleScript" referent="21">
7440
+ <Item class="ModuleScript" referent="24">
6236
7441
  <Properties>
6237
7442
  <string name="Name">State</string>
6238
7443
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6239
- local CURRENT_VERSION = "2.11.4"
7444
+ local CURRENT_VERSION = "2.15.0"
6240
7445
  local MAX_CONNECTIONS = 5
6241
7446
  local BASE_PORT = 58741
6242
7447
  local activeTabIndex = 0
@@ -6328,61 +7533,96 @@ return {
6328
7533
  ]]></string>
6329
7534
  </Properties>
6330
7535
  </Item>
6331
- <Item class="ModuleScript" referent="22">
7536
+ <Item class="ModuleScript" referent="25">
6332
7537
  <Properties>
6333
7538
  <string name="Name">StopPlayMonitor</string>
6334
7539
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6335
- -- Cross-DM stop_playtest signaling via plugin:SetSetting.
7540
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7541
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
7542
+ -- per-instance setting key so the same Studio process can host playtests
7543
+ -- for multiple places without one place's stop_playtest yanking another's.
6336
7544
  --
6337
7545
  -- `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":
6341
- --
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).
7546
+ -- shared across every DataModel the plugin runs in (edit DMs, play-server
7547
+ -- DMs, play-client DMs). For each connected place we use a dedicated key
7548
+ -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
6348
7549
  --
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).
7550
+ -- * The edit DM's stopPlaytest handler writes `true` into its own key
7551
+ -- (computed from its placeId / ServerStorage anon UUID).
7552
+ -- * Each play-server DM's monitor loop polls the key matching its own
7553
+ -- instanceId at 0.1Hz; on `true` it clears the key and calls
7554
+ -- StudioTestService:EndTest. Play-server DMs for other places never
7555
+ -- touch this key.
7556
+ -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
7557
+ -- matching play-server actually consumed the request.
6355
7558
  --
6356
- -- Pattern mirrors the official Roblox Studio MCP
6357
- -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
7559
+ -- Earlier versions used a single shared boolean flag, which let any
7560
+ -- play-server DM in the same Studio process consume any place's stop
7561
+ -- request — silently yanking teammates' playtests. The per-key scoping
7562
+ -- below is the fix.
7563
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7564
+ local HttpService = _services.HttpService
7565
+ local ServerStorage = _services.ServerStorage
6358
7566
  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
7567
+ local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
7568
+ -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
7569
+ -- lag tight so the consumption-confirmation window doesn't have to absorb
7570
+ -- polling jitter on top of EndTest's teardown time.
7571
+ local POLL_INTERVAL_SEC = 0.1
7572
+ -- Total time we wait for the matching play-server DM to consume the
7573
+ -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
7574
+ -- StudioTestService:EndTest teardown (several seconds on heavier places).
7575
+ -- 8s is comfortable; the tighter poll above keeps real cases well under.
7576
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
6362
7577
  local WAIT_POLL_SEC = 0.1
6363
7578
  local pluginRef
6364
7579
  local function init(p)
6365
7580
  pluginRef = p
6366
7581
  end
7582
+ -- Mirror of Communication.computeInstanceId(). Duplicated here because
7583
+ -- StopPlayMonitor runs in both edit and play-server DMs, and both must
7584
+ -- agree on the place identifier (published places: placeId; unpublished:
7585
+ -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
7586
+ -- into the play DM).
7587
+ local function computeInstanceId()
7588
+ if game.PlaceId ~= 0 then
7589
+ return `place:{tostring(game.PlaceId)}`
7590
+ end
7591
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7592
+ if type(existing) == "string" and existing ~= "" then
7593
+ return `anon:{existing}`
7594
+ end
7595
+ local fresh = HttpService:GenerateGUID(false)
7596
+ pcall(function()
7597
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7598
+ end)
7599
+ return `anon:{fresh}`
7600
+ end
7601
+ local function settingKey(instanceId)
7602
+ return SETTING_KEY_PREFIX .. instanceId
7603
+ end
6367
7604
  local function startMonitor()
6368
7605
  if not pluginRef then
6369
7606
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6370
7607
  return nil
6371
7608
  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.
7609
+ local myKey = settingKey(computeInstanceId())
7610
+ -- Clear any stale value left from a prior session. If a real stop
7611
+ -- request is in-flight when this runs, the requesting edit DM will
7612
+ -- write again within its consumption-confirmation window.
6375
7613
  pcall(function()
6376
- return pluginRef:SetSetting(SETTING_KEY, false)
7614
+ return pluginRef:SetSetting(myKey, false)
6377
7615
  end)
6378
7616
  task.spawn(function()
6379
7617
  while true do
6380
7618
  local okGet, val = pcall(function()
6381
- return pluginRef:GetSetting(SETTING_KEY)
7619
+ return pluginRef:GetSetting(myKey)
6382
7620
  end)
6383
7621
  if okGet and val == true then
7622
+ -- Consume the flag first so requestStop's
7623
+ -- waitForConsumption returns success, then end the test.
6384
7624
  pcall(function()
6385
- return pluginRef:SetSetting(SETTING_KEY, false)
7625
+ return pluginRef:SetSetting(myKey, false)
6386
7626
  end)
6387
7627
  pcall(function()
6388
7628
  return StudioTestService:EndTest("stopped_by_mcp")
@@ -6396,8 +7636,9 @@ local function requestStop()
6396
7636
  if not pluginRef then
6397
7637
  return false
6398
7638
  end
7639
+ local myKey = settingKey(computeInstanceId())
6399
7640
  local ok = pcall(function()
6400
- return pluginRef:SetSetting(SETTING_KEY, true)
7641
+ return pluginRef:SetSetting(myKey, true)
6401
7642
  end)
6402
7643
  return ok
6403
7644
  end
@@ -6405,10 +7646,11 @@ local function waitForConsumption()
6405
7646
  if not pluginRef then
6406
7647
  return false
6407
7648
  end
7649
+ local myKey = settingKey(computeInstanceId())
6408
7650
  local start = tick()
6409
7651
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6410
7652
  local okGet, val = pcall(function()
6411
- return pluginRef:GetSetting(SETTING_KEY)
7653
+ return pluginRef:GetSetting(myKey)
6412
7654
  end)
6413
7655
  if okGet and val ~= true then
6414
7656
  return true
@@ -6421,8 +7663,9 @@ local function clearPending()
6421
7663
  if not pluginRef then
6422
7664
  return nil
6423
7665
  end
7666
+ local myKey = settingKey(computeInstanceId())
6424
7667
  pcall(function()
6425
- return pluginRef:SetSetting(SETTING_KEY, false)
7668
+ return pluginRef:SetSetting(myKey, false)
6426
7669
  end)
6427
7670
  end
6428
7671
  return {
@@ -6435,7 +7678,7 @@ return {
6435
7678
  ]]></string>
6436
7679
  </Properties>
6437
7680
  </Item>
6438
- <Item class="ModuleScript" referent="23">
7681
+ <Item class="ModuleScript" referent="26">
6439
7682
  <Properties>
6440
7683
  <string name="Name">UI</string>
6441
7684
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7186,7 +8429,7 @@ return {
7186
8429
  ]]></string>
7187
8430
  </Properties>
7188
8431
  </Item>
7189
- <Item class="ModuleScript" referent="24">
8432
+ <Item class="ModuleScript" referent="27">
7190
8433
  <Properties>
7191
8434
  <string name="Name">Utils</string>
7192
8435
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7716,11 +8959,11 @@ return {
7716
8959
  </Properties>
7717
8960
  </Item>
7718
8961
  </Item>
7719
- <Item class="Folder" referent="28">
8962
+ <Item class="Folder" referent="31">
7720
8963
  <Properties>
7721
8964
  <string name="Name">include</string>
7722
8965
  </Properties>
7723
- <Item class="ModuleScript" referent="25">
8966
+ <Item class="ModuleScript" referent="28">
7724
8967
  <Properties>
7725
8968
  <string name="Name">Promise</string>
7726
8969
  <string name="Source"><![CDATA[--[[
@@ -9794,7 +11037,7 @@ return Promise
9794
11037
  ]]></string>
9795
11038
  </Properties>
9796
11039
  </Item>
9797
- <Item class="ModuleScript" referent="26">
11040
+ <Item class="ModuleScript" referent="29">
9798
11041
  <Properties>
9799
11042
  <string name="Name">RuntimeLib</string>
9800
11043
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10061,15 +11304,15 @@ return TS
10061
11304
  </Properties>
10062
11305
  </Item>
10063
11306
  </Item>
10064
- <Item class="Folder" referent="29">
11307
+ <Item class="Folder" referent="32">
10065
11308
  <Properties>
10066
11309
  <string name="Name">node_modules</string>
10067
11310
  </Properties>
10068
- <Item class="Folder" referent="30">
11311
+ <Item class="Folder" referent="33">
10069
11312
  <Properties>
10070
11313
  <string name="Name">@rbxts</string>
10071
11314
  </Properties>
10072
- <Item class="ModuleScript" referent="27">
11315
+ <Item class="ModuleScript" referent="30">
10073
11316
  <Properties>
10074
11317
  <string name="Name">services</string>
10075
11318
  <string name="Source"><![CDATA[return setmetatable({}, {