@chrrxs/robloxstudio-mcp 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,11 @@ local Communication = TS.import(script, script, "modules", "Communication")
12
12
  local ClientBroker = TS.import(script, script, "modules", "ClientBroker")
13
13
  local RuntimeLogBuffer = TS.import(script, script, "modules", "RuntimeLogBuffer")
14
14
  local StopPlayMonitor = TS.import(script, script, "modules", "StopPlayMonitor")
15
+ local RenderMonitor = TS.import(script, script, "modules", "RenderMonitor")
16
+ -- Track render-loop liveness so input/screenshot tools can report "window
17
+ -- minimized / not rendering" instead of silently no-op'ing. No-op in the
18
+ -- server DM (RenderStepped can't connect there).
19
+ RenderMonitor.start()
15
20
  -- Attach the per-peer LogService.MessageOut listener as early as possible so
16
21
  -- boot-time prints from the user's place scripts are captured. Powers the
17
22
  -- get_runtime_logs MCP tool. Idempotent; safe to call before UI.init().
@@ -94,8 +99,53 @@ local HttpService = _services.HttpService
94
99
  local Players = _services.Players
95
100
  local ReplicatedStorage = _services.ReplicatedStorage
96
101
  local RunService = _services.RunService
102
+ local ServerStorage = _services.ServerStorage
97
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
98
104
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
105
+ local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
+ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
107
+ local LuauExec = TS.import(script, script.Parent, "LuauExec")
108
+ local StudioTestService = game:GetService("StudioTestService")
109
+ -- Mirror of Communication.computeInstanceId() — duplicated here because the
110
+ -- client broker runs in the play-server DM where it can't easily import from
111
+ -- the edit-side module, and the place identifier must match what the edit-DM
112
+ -- plugin reports. Both use the same algorithm against the shared DataModel.
113
+ local function computeInstanceId()
114
+ if game.PlaceId ~= 0 then
115
+ return `place:{tostring(game.PlaceId)}`
116
+ end
117
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
118
+ if type(existing) == "string" and existing ~= "" then
119
+ return `anon:{existing}`
120
+ end
121
+ local fresh = HttpService:GenerateGUID(false)
122
+ pcall(function()
123
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
124
+ end)
125
+ return `anon:{fresh}`
126
+ end
127
+ local cachedPlaceName
128
+ local function resolvePlaceName()
129
+ if cachedPlaceName ~= nil then
130
+ return cachedPlaceName
131
+ end
132
+ if game.PlaceId == 0 then
133
+ cachedPlaceName = game.Name
134
+ return cachedPlaceName
135
+ end
136
+ local MarketplaceService = game:GetService("MarketplaceService")
137
+ local ok, info = pcall(function()
138
+ return MarketplaceService:GetProductInfo(game.PlaceId)
139
+ end)
140
+ if ok and info ~= nil then
141
+ local name = info.Name
142
+ if type(name) == "string" and name ~= "" then
143
+ cachedPlaceName = name
144
+ return cachedPlaceName
145
+ end
146
+ end
147
+ return game.Name
148
+ end
99
149
  -- The client peer cannot reach the MCP HTTP server - Roblox forbids
100
150
  -- HttpService:RequestAsync from the client DM even under PluginSecurity, and
101
151
  -- HttpEnabled reads as false there regardless of identity. So the server peer
@@ -109,6 +159,7 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
109
159
  -- signaling, which works regardless of MCP server state.)
110
160
  local MCP_URL = "http://localhost:58741"
111
161
  local BROKER_NAME = "__MCPClientBroker"
162
+ local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
112
163
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
113
164
  -- Each requires the client peer's plugin VM (because the buffer / require
114
165
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -116,6 +167,11 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
116
167
  ["/api/execute-luau"] = true,
117
168
  ["/api/get-runtime-logs"] = true,
118
169
  ["/api/get-memory-breakdown"] = true,
170
+ ["/api/multiplayer-test-state"] = true,
171
+ ["/api/multiplayer-test-leave-client"] = true,
172
+ ["/api/capture-begin"] = true,
173
+ ["/api/simulate-mouse-input"] = true,
174
+ ["/api/simulate-keyboard-input"] = true,
119
175
  }
120
176
  -- Throttle re-ready calls per proxyId so a brief window of unknownInstance
121
177
  -- polls doesn't cause a re-register stampede.
@@ -136,8 +192,13 @@ local function reRegisterProxy(proxyId, role)
136
192
  lastReadyByProxy[_proxyId_1] = now
137
193
  pcall(function()
138
194
  return postJson("/ready", {
139
- instanceId = proxyId,
195
+ pluginSessionId = proxyId,
196
+ instanceId = computeInstanceId(),
140
197
  role = role,
198
+ placeId = game.PlaceId,
199
+ placeName = resolvePlaceName(),
200
+ dataModelName = game.Name,
201
+ isRunning = RunService:IsRunning(),
141
202
  })
142
203
  end)
143
204
  end
@@ -170,34 +231,11 @@ local function handleExecuteLuau(data)
170
231
  error = "code is required",
171
232
  }
172
233
  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
- }
234
+ -- Shared with edit/server (MetadataHandlers.executeLuau). Adds the IIFE
235
+ -- wrapper (so `print("hi")` with no return doesn't fail the
236
+ -- ModuleScript's "must return one value" rule) and JSON-encodes table
237
+ -- returns instead of yielding "table: 0xaddr".
238
+ return LuauExec.execute(code)
201
239
  end
202
240
  local function handleGetRuntimeLogs(data)
203
241
  local d = data or {}
@@ -212,6 +250,77 @@ local function handleGetRuntimeLogs(data)
212
250
  filter = filter,
213
251
  }, "client")
214
252
  end
253
+ local function handleMultiplayerTestState()
254
+ local argsOk, args = pcall(function()
255
+ return StudioTestService:GetTestArgs()
256
+ end)
257
+ local canLeaveOk, canLeave = pcall(function()
258
+ return StudioTestService:CanLeaveTest()
259
+ end)
260
+ local _exp = Players:GetPlayers()
261
+ -- ▼ ReadonlyArray.map ▼
262
+ local _newValue = table.create(#_exp)
263
+ local _callback = function(player)
264
+ return {
265
+ name = player.Name,
266
+ userId = player.UserId,
267
+ displayName = player.DisplayName,
268
+ }
269
+ end
270
+ for _k, _v in _exp do
271
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
272
+ end
273
+ -- ▲ ReadonlyArray.map ▲
274
+ local players = _newValue
275
+ table.sort(players, function(a, b)
276
+ return a.name < b.name
277
+ end)
278
+ return {
279
+ success = true,
280
+ peer = "client",
281
+ isRunning = RunService:IsRunning(),
282
+ isRunMode = RunService:IsRunMode(),
283
+ editModeActive = StudioTestService.EditModeActive,
284
+ testArgsOk = argsOk,
285
+ testArgs = if argsOk then args else nil,
286
+ testArgsError = if argsOk then nil else tostring(args),
287
+ players = players,
288
+ playerCount = #players,
289
+ localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
290
+ canLeaveOk = canLeaveOk,
291
+ canLeave = if canLeaveOk then canLeave else false,
292
+ canLeaveError = if canLeaveOk then nil else tostring(canLeave),
293
+ }
294
+ end
295
+ local function handleMultiplayerTestLeaveClient()
296
+ local canLeaveOk, canLeave = pcall(function()
297
+ return StudioTestService:CanLeaveTest()
298
+ end)
299
+ if not canLeaveOk then
300
+ return {
301
+ error = tostring(canLeave),
302
+ canLeaveOk = false,
303
+ }
304
+ end
305
+ if not canLeave then
306
+ return {
307
+ error = "This client cannot leave the current test session.",
308
+ canLeaveOk = true,
309
+ canLeave = false,
310
+ }
311
+ end
312
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
313
+ task.defer(function()
314
+ pcall(function()
315
+ return StudioTestService:LeaveTest()
316
+ end)
317
+ end)
318
+ return {
319
+ success = true,
320
+ message = "Client leave requested.",
321
+ localPlayer = localPlayer,
322
+ }
323
+ end
215
324
  local function setupClientBroker()
216
325
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
217
326
  if not rf or not rf:IsA("RemoteFunction") then
@@ -231,6 +340,21 @@ local function setupClientBroker()
231
340
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
232
341
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
233
342
  end
343
+ if payload and payload.endpoint == "/api/multiplayer-test-state" then
344
+ return handleMultiplayerTestState()
345
+ end
346
+ if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
347
+ return handleMultiplayerTestLeaveClient()
348
+ end
349
+ if payload and payload.endpoint == "/api/capture-begin" then
350
+ return CaptureHandlers.captureBegin()
351
+ end
352
+ if payload and payload.endpoint == "/api/simulate-mouse-input" then
353
+ return InputHandlers.simulateMouseInput(payload.data or {})
354
+ end
355
+ if payload and payload.endpoint == "/api/simulate-keyboard-input" then
356
+ return InputHandlers.simulateKeyboardInput(payload.data or {})
357
+ end
234
358
  if payload and payload.endpoint == "/api/execute-luau" then
235
359
  return handleExecuteLuau(payload.data)
236
360
  end
@@ -239,6 +363,7 @@ local function setupClientBroker()
239
363
  end
240
364
  end
241
365
  local proxyByPlayer = {}
366
+ local serverBrokerStarted = false
242
367
  local function pollProxy(proxyId, player, rf)
243
368
  while true do
244
369
  local _condition = player.Parent ~= nil
@@ -251,7 +376,7 @@ local function pollProxy(proxyId, player, rf)
251
376
  end
252
377
  local ok, res = pcall(function()
253
378
  return HttpService:RequestAsync({
254
- Url = `{MCP_URL}/poll?instanceId={proxyId}`,
379
+ Url = `{MCP_URL}/poll?pluginSessionId={proxyId}`,
255
380
  Method = "GET",
256
381
  Headers = {
257
382
  ["Content-Type"] = "application/json",
@@ -294,8 +419,12 @@ local function pollProxy(proxyId, player, rf)
294
419
  }
295
420
  end
296
421
  else
422
+ local allowed = {}
423
+ for ep in CLIENT_BROKER_ALLOWED_ENDPOINTS do
424
+ table.insert(allowed, ep)
425
+ end
297
426
  response = {
298
- error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: /api/execute-luau, /api/get-runtime-logs.`,
427
+ error = `Client-proxy does not forward {tostring(request.endpoint)}. ` .. `Allowed: {table.concat(allowed, ", ")}.`,
299
428
  }
300
429
  end
301
430
  postJson("/response", {
@@ -315,8 +444,13 @@ local function registerProxy(player, rf)
315
444
  end
316
445
  local proxyId = HttpService:GenerateGUID(false)
317
446
  local ok, res = postJson("/ready", {
318
- instanceId = proxyId,
447
+ pluginSessionId = proxyId,
448
+ instanceId = computeInstanceId(),
319
449
  role = "client",
450
+ placeId = game.PlaceId,
451
+ placeName = resolvePlaceName(),
452
+ dataModelName = game.Name,
453
+ isRunning = RunService:IsRunning(),
320
454
  })
321
455
  if not ok or not res or not res.Success then
322
456
  warn(`[MCPFork] proxy register failed for {player.Name}`)
@@ -330,7 +464,7 @@ local function registerProxy(player, rf)
330
464
  local assigned = _condition
331
465
  local _player_1 = player
332
466
  local _arg1 = {
333
- instanceId = proxyId,
467
+ pluginSessionId = proxyId,
334
468
  role = assigned,
335
469
  }
336
470
  proxyByPlayer[_player_1] = _arg1
@@ -341,12 +475,20 @@ end
341
475
  -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
342
476
  -- which doesn't depend on MCP server state or peer registration at all.)
343
477
  local function setupServerBroker()
478
+ if serverBrokerStarted then
479
+ return nil
480
+ end
344
481
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
345
482
  if not rf then
346
483
  rf = Instance.new("RemoteFunction")
347
484
  rf.Name = BROKER_NAME
348
485
  rf.Parent = ReplicatedStorage
349
486
  end
487
+ if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
488
+ return nil
489
+ end
490
+ rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
491
+ serverBrokerStarted = true
350
492
  local broker = rf
351
493
  Players.PlayerAdded:Connect(function(p)
352
494
  return registerProxy(p, broker)
@@ -361,14 +503,14 @@ local function setupServerBroker()
361
503
  local _p_1 = p
362
504
  proxyByPlayer[_p_1] = nil
363
505
  postJson("/disconnect", {
364
- instanceId = entry.instanceId,
506
+ pluginSessionId = entry.pluginSessionId,
365
507
  })
366
508
  end
367
509
  end)
368
510
  game:BindToClose(function()
369
511
  for _, entry in proxyByPlayer do
370
512
  postJson("/disconnect", {
371
- instanceId = entry.instanceId,
513
+ pluginSessionId = entry.pluginSessionId,
372
514
  })
373
515
  end
374
516
  table.clear(proxyByPlayer)
@@ -391,9 +533,11 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
391
533
  local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
392
534
  local HttpService = _services.HttpService
393
535
  local RunService = _services.RunService
536
+ local ServerStorage = _services.ServerStorage
394
537
  local State = TS.import(script, script.Parent, "State")
395
538
  local Utils = TS.import(script, script.Parent, "Utils")
396
539
  local UI = TS.import(script, script.Parent, "UI")
540
+ local ensureBridgesInstalled = TS.import(script, script.Parent, "EvalBridges").ensureBridgesInstalled
397
541
  local QueryHandlers = TS.import(script, script.Parent, "handlers", "QueryHandlers")
398
542
  local PropertyHandlers = TS.import(script, script.Parent, "handlers", "PropertyHandlers")
399
543
  local InstanceHandlers = TS.import(script, script.Parent, "handlers", "InstanceHandlers")
@@ -407,8 +551,61 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
407
551
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
408
552
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
409
553
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
410
- local instanceId = HttpService:GenerateGUID(false)
554
+ -- Per-plugin-load random GUID. Used as the /poll URL param so the server
555
+ -- can tell our polls apart from any other plugin's polls. Not user-facing —
556
+ -- MCP tools and the LLM operate on instanceId (the place identifier).
557
+ local pluginSessionId = HttpService:GenerateGUID(false)
558
+ -- Place-level identifier shared by every plugin running in DataModels of
559
+ -- the same place file (edit DM + playtest server DM + playtest clients).
560
+ -- Format: "place:<PlaceId>" when published, "anon:<UUID>" for unpublished
561
+ -- places where the UUID lives on ServerStorage's __MCPPlaceId attribute
562
+ -- and travels with the .rbxl.
563
+ local MCP_PLACE_ID_ATTRIBUTE = "__MCPPlaceId"
564
+ local function computeInstanceId()
565
+ if game.PlaceId ~= 0 then
566
+ return `place:{tostring(game.PlaceId)}`
567
+ end
568
+ local existing = ServerStorage:GetAttribute(MCP_PLACE_ID_ATTRIBUTE)
569
+ if type(existing) == "string" and existing ~= "" then
570
+ return `anon:{existing}`
571
+ end
572
+ local fresh = HttpService:GenerateGUID(false)
573
+ pcall(function()
574
+ return ServerStorage:SetAttribute(MCP_PLACE_ID_ATTRIBUTE, fresh)
575
+ end)
576
+ return `anon:{fresh}`
577
+ end
578
+ local instanceId = computeInstanceId()
411
579
  local assignedRole
580
+ local duplicateInstanceRole = false
581
+ -- Cache the published place name from MarketplaceService:GetProductInfo so
582
+ -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
583
+ -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
584
+ -- once per plugin load; the published name doesn't change mid-session.
585
+ local cachedPlaceName
586
+ local function resolvePlaceName()
587
+ if cachedPlaceName ~= nil then
588
+ return cachedPlaceName
589
+ end
590
+ if game.PlaceId == 0 then
591
+ cachedPlaceName = game.Name
592
+ return cachedPlaceName
593
+ end
594
+ local MarketplaceService = game:GetService("MarketplaceService")
595
+ local ok, info = pcall(function()
596
+ return MarketplaceService:GetProductInfo(game.PlaceId)
597
+ end)
598
+ if ok and info ~= nil then
599
+ local name = info.Name
600
+ if type(name) == "string" and name ~= "" then
601
+ cachedPlaceName = name
602
+ return cachedPlaceName
603
+ end
604
+ end
605
+ -- Don't cache failures — could be transient (offline, rate-limited).
606
+ -- Next /ready will retry. Return game.Name as fallback.
607
+ return game.Name
608
+ end
412
609
  local function detectRole()
413
610
  if not RunService:IsRunning() then
414
611
  return "edit"
@@ -464,6 +661,11 @@ local routeMap = {
464
661
  ["/api/start-playtest"] = TestHandlers.startPlaytest,
465
662
  ["/api/stop-playtest"] = TestHandlers.stopPlaytest,
466
663
  ["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
664
+ ["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
665
+ ["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
666
+ ["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
667
+ ["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
668
+ ["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
467
669
  ["/api/character-navigation"] = TestHandlers.characterNavigation,
468
670
  ["/api/export-build"] = BuildHandlers.exportBuild,
469
671
  ["/api/import-build"] = BuildHandlers.importBuild,
@@ -472,6 +674,8 @@ local routeMap = {
472
674
  ["/api/insert-asset"] = AssetHandlers.insertAsset,
473
675
  ["/api/preview-asset"] = AssetHandlers.previewAsset,
474
676
  ["/api/capture-screenshot"] = CaptureHandlers.captureScreenshot,
677
+ ["/api/capture-begin"] = CaptureHandlers.captureBegin,
678
+ ["/api/capture-read"] = CaptureHandlers.captureRead,
475
679
  ["/api/simulate-mouse-input"] = InputHandlers.simulateMouseInput,
476
680
  ["/api/simulate-keyboard-input"] = InputHandlers.simulateKeyboardInput,
477
681
  ["/api/find-and-replace-in-scripts"] = ScriptHandlers.findAndReplaceInScripts,
@@ -524,7 +728,33 @@ end
524
728
  -- Without this, every poll during the brief window where the server has just
525
729
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
526
730
  local lastReadyPostAt = 0
527
- local function sendReady(conn)
731
+ -- game.Name is sometimes "Place1" at plugin-load time and only settles to
732
+ -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
733
+ -- after Studio finishes wiring things up. Re-fire /ready when it changes so
734
+ -- get_connected_instances doesn't show a stale dataModelName forever. Set
735
+ -- up once per plugin load — the connection passed in is whichever was
736
+ -- active when activatePlugin was first called.
737
+ local nameChangeConn
738
+ local sendReady
739
+ local function ensureNameChangeWatcher(conn)
740
+ if nameChangeConn then
741
+ return nil
742
+ end
743
+ local okSig, signal = pcall(function()
744
+ return game:GetPropertyChangedSignal("Name")
745
+ end)
746
+ if not okSig or not signal then
747
+ return nil
748
+ end
749
+ nameChangeConn = signal:Connect(function()
750
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
751
+ sendReady(conn)
752
+ end)
753
+ end
754
+ function sendReady(conn)
755
+ if duplicateInstanceRole then
756
+ return nil
757
+ end
528
758
  local now = tick()
529
759
  if now - lastReadyPostAt < 2 then
530
760
  return nil
@@ -539,14 +769,36 @@ local function sendReady(conn)
539
769
  ["Content-Type"] = "application/json",
540
770
  },
541
771
  Body = HttpService:JSONEncode({
772
+ pluginSessionId = pluginSessionId,
542
773
  instanceId = instanceId,
543
774
  role = detectRole(),
775
+ placeId = game.PlaceId,
776
+ placeName = resolvePlaceName(),
777
+ dataModelName = game.Name,
778
+ isRunning = RunService:IsRunning(),
544
779
  pluginReady = true,
545
780
  timestamp = tick(),
546
781
  }),
547
782
  })
548
783
  end)
549
- if readyOk and readyResult.Success then
784
+ if not readyOk then
785
+ return nil
786
+ end
787
+ -- 409 = duplicate_instance_role. Surface in UI and stop polling.
788
+ if readyResult.StatusCode == 409 then
789
+ duplicateInstanceRole = true
790
+ conn.isActive = false
791
+ local ui = UI.getElements()
792
+ if State.getActiveTabIndex() == 0 then
793
+ ui.statusLabel.Text = "Duplicate instance"
794
+ ui.statusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
795
+ ui.detailStatusLabel.Text = "Another Studio is already connected as this place + role"
796
+ ui.detailStatusLabel.TextColor3 = Color3.fromRGB(239, 68, 68)
797
+ end
798
+ warn(`[MCPPlugin] Another Studio is already connected as ({instanceId}, {detectRole()}). Close the other Studio window or this one.`)
799
+ return nil
800
+ end
801
+ if readyResult.Success then
550
802
  local parseOk, readyData = pcall(function()
551
803
  return HttpService:JSONDecode(readyResult.Body)
552
804
  end)
@@ -568,7 +820,7 @@ local function pollForRequests(connIndex)
568
820
  conn.isPolling = true
569
821
  local success, result = pcall(function()
570
822
  return HttpService:RequestAsync({
571
- Url = `{conn.serverUrl}/poll?instanceId={instanceId}`,
823
+ Url = `{conn.serverUrl}/poll?pluginSessionId={pluginSessionId}`,
572
824
  Method = "GET",
573
825
  Headers = {
574
826
  ["Content-Type"] = "application/json",
@@ -764,6 +1016,22 @@ local function activatePlugin(connIndex)
764
1016
  -- Initial /ready; pollForRequests will also re-fire ready if the server
765
1017
  -- later reports knownInstance=false (process restart, etc).
766
1018
  sendReady(conn)
1019
+ -- Keep the eval bridges present in the edit DM so that ANY playtest —
1020
+ -- including one the dev starts manually via the Studio Play button —
1021
+ -- clones them into the play DMs and eval_*_runtime works with no setup
1022
+ -- roundtrip. Only the edit DM installs; play DMs already have the cloned
1023
+ -- copies. Idempotent, so reconnects don't re-dirty the place.
1024
+ if not RunService:IsRunning() then
1025
+ task.spawn(function()
1026
+ local result = ensureBridgesInstalled()
1027
+ if not result.installed then
1028
+ warn(`[MCPPlugin] Eval bridge install failed: {result.error}`)
1029
+ end
1030
+ end)
1031
+ end
1032
+ -- Watch for game.Name updates so a stale "Place1" captured at first
1033
+ -- /ready gets refreshed once Studio settles on the real DM name.
1034
+ ensureNameChangeWatcher(conn)
767
1035
  end
768
1036
  local function deactivatePlugin(connIndex)
769
1037
  local _condition = connIndex
@@ -789,7 +1057,7 @@ local function deactivatePlugin(connIndex)
789
1057
  ["Content-Type"] = "application/json",
790
1058
  },
791
1059
  Body = HttpService:JSONEncode({
792
- instanceId = instanceId,
1060
+ pluginSessionId = pluginSessionId,
793
1061
  timestamp = tick(),
794
1062
  }),
795
1063
  })
@@ -882,11 +1150,16 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
882
1150
  -- ServerScriptService.LoadStringEnabled, so eval_server_runtime works even
883
1151
  -- when LoadStringEnabled=false (the default in fresh places).
884
1152
  --
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).
1153
+ -- Lifecycle: the bridges live PERMANENTLY in the edit DM. Communication
1154
+ -- installs them (ensureBridgesInstalled) when the plugin connects in edit,
1155
+ -- and TestHandlers.startPlaytest force-refreshes them right before
1156
+ -- ExecutePlayModeAsync. ExecutePlayModeAsync clones the DataModel into the
1157
+ -- play DMs, so the scripts come along and run there. We keep them in the edit
1158
+ -- DM after a playtest ends (rather than cleaning up) so that a playtest the
1159
+ -- dev starts MANUALLY via the Studio Play button — not the MCP start_playtest
1160
+ -- tool — also gets the bridges cloned in. This is intentionally a little
1161
+ -- intrusive (two helper scripts visible in Explorer) in exchange for a
1162
+ -- zero-roundtrip eval_*_runtime experience for devs working 1:1 with an agent.
890
1163
  --
891
1164
  -- Archivable handling: ExecutePlayModeAsync's deep-clone SKIPS instances
892
1165
  -- with Archivable=false (verified empirically in v2.9.0 testing - bridges
@@ -918,9 +1191,9 @@ local BRIDGE_NAMES = {
918
1191
  -- Embedded Luau. The double `${...}` references our exported names so a
919
1192
  -- rename here propagates to both the script source and the tool wrappers.
920
1193
  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.\
1194
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_server_runtime MCP\
1195
+ -- tool (shared-require-cache eval on the server during playtests). Inert\
1196
+ -- outside Studio (no-ops in live games); safe to leave in place.\
924
1197
  \
925
1198
  local ServerScriptService = game:GetService("ServerScriptService")\
926
1199
  local RunService = game:GetService("RunService")\
@@ -944,9 +1217,9 @@ bf.OnInvoke = function(payload)\
944
1217
  end\
945
1218
  `
946
1219
  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.\
1220
+ -- Installed by @chrrxs/robloxstudio-mcp to power the eval_client_runtime MCP\
1221
+ -- tool (shared-require-cache eval on the client during playtests). Inert\
1222
+ -- outside Studio (no-ops in live games); safe to leave in place.\
950
1223
  \
951
1224
  local ReplicatedStorage = game:GetService("ReplicatedStorage")\
952
1225
  local RunService = game:GetService("RunService")\
@@ -969,6 +1242,25 @@ bf.OnInvoke = function(payload)\
969
1242
  return pcall(require, payload)\
970
1243
  end\
971
1244
  `
1245
+ -- Stamp written onto each installed bridge Script so we can tell whether the
1246
+ -- bridge currently in the DM was produced by THIS plugin build. It's a djb2
1247
+ -- hash of the actual bridge source plus the plugin version, so ANY change to
1248
+ -- the source (or a version bump) yields a new stamp — which makes
1249
+ -- ensureBridgesInstalled() force a refresh on the next plugin load instead of
1250
+ -- keeping a stale bridge that happens to still be present (e.g. one saved into
1251
+ -- the .rbxl from an older build).
1252
+ local STAMP_ATTR = "__MCPBridgeStamp"
1253
+ local function computeBridgeStamp()
1254
+ local combined = `{SERVER_BRIDGE_SOURCE}|{CLIENT_BRIDGE_SOURCE}`
1255
+ local h = 5381
1256
+ for i = 1, #combined do
1257
+ h = (h * 33 + (string.byte(combined, i))) % 2147483647
1258
+ end
1259
+ -- "2.14.0" is replaced with the package version at package time
1260
+ -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1261
+ return `{tostring(h)}-2.14.0`
1262
+ end
1263
+ local BRIDGE_STAMP = computeBridgeStamp()
972
1264
  local function setSource(scriptInst, source)
973
1265
  -- ScriptEditorService is the cleaner API and integrates with Studio's
974
1266
  -- edit history; fall back to direct Source mutation (allowed in plugin
@@ -1004,7 +1296,31 @@ local function cleanupBridges()
1004
1296
  end)
1005
1297
  end
1006
1298
  end
1007
- local function installBridges()
1299
+ -- Idempotent variant: install only if the bridge scripts aren't already
1300
+ -- present in the edit DM. Used to keep the bridges always available (so a
1301
+ -- playtest the dev starts manually — not via the MCP start_playtest tool —
1302
+ -- still clones them into the play DMs). Cheap no-op when already installed,
1303
+ -- which avoids re-dirtying the place on every plugin reconnect.
1304
+ local installBridges
1305
+ local function ensureBridgesInstalled()
1306
+ local _binding = findBridges()
1307
+ local server = _binding.server
1308
+ local client = _binding.client
1309
+ if server and client then
1310
+ -- Both present — but only skip the reinstall if they were produced by
1311
+ -- THIS build. A mismatched/absent stamp means a stale bridge (older
1312
+ -- plugin, or one persisted in the saved place), so force a refresh.
1313
+ local sStamp = server:GetAttribute(STAMP_ATTR)
1314
+ local cStamp = client:GetAttribute(STAMP_ATTR)
1315
+ if sStamp == BRIDGE_STAMP and cStamp == BRIDGE_STAMP then
1316
+ return {
1317
+ installed = true,
1318
+ }
1319
+ end
1320
+ end
1321
+ return installBridges()
1322
+ end
1323
+ function installBridges()
1008
1324
  -- Defensive: clear any stale bridges from a prior unclean exit before
1009
1325
  -- inserting fresh. The injected script also self-cleans its
1010
1326
  -- ReplicatedStorage/ServerScriptService children at startup, but the
@@ -1017,6 +1333,7 @@ local function installBridges()
1017
1333
  -- script. cleanupBridges() removes it from the edit DM when the
1018
1334
  -- playtest ends.
1019
1335
  setSource(serverScript, SERVER_BRIDGE_SOURCE)
1336
+ serverScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1020
1337
  serverScript.Parent = ServerScriptService
1021
1338
  local sps = getStarterPlayerScripts()
1022
1339
  if not sps then
@@ -1025,6 +1342,7 @@ local function installBridges()
1025
1342
  local clientScript = Instance.new("LocalScript")
1026
1343
  clientScript.Name = CLIENT_SCRIPT_NAME
1027
1344
  setSource(clientScript, CLIENT_BRIDGE_SOURCE)
1345
+ clientScript:SetAttribute(STAMP_ATTR, BRIDGE_STAMP)
1028
1346
  clientScript.Parent = sps
1029
1347
  end)
1030
1348
  if not ok then
@@ -1039,6 +1357,7 @@ local function installBridges()
1039
1357
  end
1040
1358
  return {
1041
1359
  cleanupBridges = cleanupBridges,
1360
+ ensureBridgesInstalled = ensureBridgesInstalled,
1042
1361
  installBridges = installBridges,
1043
1362
  BRIDGE_NAMES = BRIDGE_NAMES,
1044
1363
  }
@@ -1904,6 +2223,8 @@ return {
1904
2223
  <Properties>
1905
2224
  <string name="Name">CaptureHandlers</string>
1906
2225
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2226
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2227
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
1907
2228
  local CaptureService = game:GetService("CaptureService")
1908
2229
  local AssetService = game:GetService("AssetService")
1909
2230
  local MAX_TILE_SIZE = 1024
@@ -2013,7 +2334,20 @@ local function readPixelsTiled(img, w, h)
2013
2334
  end
2014
2335
  return fullBuf
2015
2336
  end
2016
- local function captureScreenshotData()
2337
+ -- Triggers CaptureService:CaptureScreenshot and waits for the temporary
2338
+ -- content id. Works in any DM, including the play CLIENT (where reading the
2339
+ -- pixels back is blocked, but capturing is not). The returned rbxtemp:// id is
2340
+ -- a process-scoped handle: it can be dereferenced from a DIFFERENT, more
2341
+ -- privileged DM (the edit DM) — see captureRead.
2342
+ local function doCaptureScreenshot()
2343
+ -- Fast-fail with a clear reason if the window isn't rendering — otherwise
2344
+ -- CaptureScreenshot's callback never fires and we'd block for the full 10s.
2345
+ local notRendering = RenderMonitor.notRenderingReason()
2346
+ if notRendering ~= nil then
2347
+ return {
2348
+ error = notRendering,
2349
+ }
2350
+ end
2017
2351
  local contentId
2018
2352
  CaptureService:CaptureScreenshot(function(id)
2019
2353
  contentId = id
@@ -2022,11 +2356,21 @@ local function captureScreenshotData()
2022
2356
  while contentId == nil do
2023
2357
  if tick() - startTime > 10 then
2024
2358
  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.",
2359
+ error = "Screenshot capture timed out (CaptureScreenshot callback never fired). The Studio window is likely minimized or occluded restore it so the viewport renders. (Known Roblox bug: capture can also fail if the viewport renders a solid color.)",
2026
2360
  }
2027
2361
  end
2028
2362
  task.wait(0.1)
2029
2363
  end
2364
+ return {
2365
+ contentId = contentId,
2366
+ }
2367
+ end
2368
+ -- Promotes a CaptureScreenshot content id into an EditableImage and reads its
2369
+ -- RGBA pixels. MUST run in the edit/plugin context: the running game VM lacks
2370
+ -- the privilege to create an EditableImage from a temporary texture id (errors
2371
+ -- "cannot currently create editable image from temporary texture id"), while
2372
+ -- the edit DM can — even for an id captured in the play client DM.
2373
+ local function readContentToBase64(contentId)
2030
2374
  local editableOk, editableResult = pcall(function()
2031
2375
  return AssetService:CreateEditableImageAsync(Content.fromUri(contentId))
2032
2376
  end)
@@ -2056,12 +2400,36 @@ local function captureScreenshotData()
2056
2400
  data = base64Data,
2057
2401
  }
2058
2402
  end
2403
+ -- Edit-mode single shot: capture and read back in the same (edit) context.
2404
+ local function captureScreenshotData()
2405
+ local cap = doCaptureScreenshot()
2406
+ if cap.error ~= nil then
2407
+ return cap
2408
+ end
2409
+ return readContentToBase64(cap.contentId)
2410
+ end
2059
2411
  local function captureScreenshot()
2060
2412
  return captureScreenshotData()
2061
2413
  end
2414
+ -- Play-mode step 1 (run on the CLIENT): capture only, return the temp id.
2415
+ local function captureBegin()
2416
+ return doCaptureScreenshot()
2417
+ end
2418
+ -- Play-mode step 2 (run on EDIT): read pixels from a temp id captured elsewhere.
2419
+ local function captureRead(requestData)
2420
+ local contentId = requestData.contentId
2421
+ if not (contentId ~= "" and contentId) then
2422
+ return {
2423
+ error = "contentId is required",
2424
+ }
2425
+ end
2426
+ return readContentToBase64(contentId)
2427
+ end
2062
2428
  return {
2063
2429
  captureScreenshotData = captureScreenshotData,
2064
2430
  captureScreenshot = captureScreenshot,
2431
+ captureBegin = captureBegin,
2432
+ captureRead = captureRead,
2065
2433
  }
2066
2434
  ]]></string>
2067
2435
  </Properties>
@@ -2070,19 +2438,56 @@ return {
2070
2438
  <Properties>
2071
2439
  <string name="Name">InputHandlers</string>
2072
2440
  <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")
2441
+ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
2442
+ -- Virtual input via UserInputService:CreateVirtualInput().
2443
+ --
2444
+ -- We deliberately do NOT use VirtualInputManager:Send*Event — those methods
2445
+ -- are gated behind RobloxScriptSecurity ("lacking capability RobloxScript")
2446
+ -- in every context a plugin can reach (edit DM, play server/client DMs), so
2447
+ -- they silently never worked. CreateVirtualInput() is callable without that
2448
+ -- capability and drives the REAL input pipeline: SendKey feeds
2449
+ -- UserInputService.InputBegan/Ended and the control modules (so WASD walks the
2450
+ -- character at full WalkSpeed with controls intact, no Humanoid hijack),
2451
+ -- SendMouseButton feeds UIS and activates GUI buttons (and hit-tests against
2452
+ -- CoreGui), and SendTextInput types into the focused TextBox.
2453
+ --
2454
+ -- Method set on the VirtualInput object (verified live):
2455
+ -- SendKey(isDown: boolean, keyCode: Enum.KeyCode)
2456
+ -- SendMouseButton(position: Vector2, inputType: Enum.UserInputType, isDown: boolean)
2457
+ -- SendTextInput(text: string)
2458
+ -- There is NO SendMouseMove / SendMouseWheel / SendKeyEvent — so "move" and
2459
+ -- "scroll" mouse actions are not supported.
2460
+ --
2461
+ -- Coordinate space: SendMouseButton coordinates are viewport pixels matching
2462
+ -- what capture_screenshot returns (window space, origin at the top-left of the
2463
+ -- rendered viewport). Pass screenshot pixel coordinates straight through. Note
2464
+ -- that UserInputService reports input positions in GUI space, which is offset
2465
+ -- from this by GuiService:GetGuiInset() (~58px on the Y axis) — irrelevant for
2466
+ -- callers who pick coordinates off a screenshot, which is why we do not
2467
+ -- translate here.
2468
+ local RenderMonitor = TS.import(script, script.Parent.Parent, "RenderMonitor")
2469
+ local UserInputService = game:GetService("UserInputService")
2470
+ -- One VirtualInput per plugin VM, reused across calls so that a key held down
2471
+ -- in one call (action="press") and released in a later call (action="release")
2472
+ -- share the same input source.
2473
+ local cachedVI
2474
+ local function getVI()
2475
+ if cachedVI then
2476
+ return cachedVI
2477
+ end
2478
+ local ok, vi = pcall(function()
2479
+ return UserInputService:CreateVirtualInput()
2076
2480
  end)
2077
- if ok and result then
2078
- return result
2481
+ if ok and vi ~= nil then
2482
+ cachedVI = vi
2483
+ return cachedVI
2079
2484
  end
2080
2485
  return nil
2081
2486
  end
2082
- local BUTTON_MAP = {
2083
- Left = 0,
2084
- Right = 1,
2085
- Middle = 2,
2487
+ local MOUSE_TYPE_MAP = {
2488
+ Left = Enum.UserInputType.MouseButton1,
2489
+ Right = Enum.UserInputType.MouseButton2,
2490
+ Middle = Enum.UserInputType.MouseButton3,
2086
2491
  }
2087
2492
  local function simulateMouseInput(requestData)
2088
2493
  local action = requestData.action
@@ -2093,56 +2498,43 @@ local function simulateMouseInput(requestData)
2093
2498
  _condition = "Left"
2094
2499
  end
2095
2500
  local button = _condition
2096
- local scrollDirection = requestData.scrollDirection
2097
2501
  if not (action ~= "" and action) then
2098
2502
  return {
2099
2503
  error = "action is required",
2100
2504
  }
2101
2505
  end
2102
- local vim = getVIM()
2103
- if not vim then
2506
+ if x == nil or y == nil then
2104
2507
  return {
2105
- error = "VirtualInputManager is not available in this context",
2508
+ error = "x and y are required",
2106
2509
  }
2107
2510
  end
2108
- local _condition_1 = BUTTON_MAP[button]
2109
- if _condition_1 == nil then
2110
- _condition_1 = 0
2511
+ -- Input is silently dropped by the engine when the window isn't rendering
2512
+ -- (e.g. minimized). Surface that instead of returning a false success.
2513
+ local notRendering = RenderMonitor.notRenderingReason()
2514
+ if notRendering ~= nil then
2515
+ return {
2516
+ error = notRendering,
2517
+ }
2111
2518
  end
2112
- local buttonNum = _condition_1
2519
+ local vi = getVI()
2520
+ if not vi then
2521
+ return {
2522
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2523
+ }
2524
+ end
2525
+ local inputType = MOUSE_TYPE_MAP[button] or Enum.UserInputType.MouseButton1
2526
+ local pos = Vector2.new(x, y)
2113
2527
  local success, err = pcall(function()
2114
2528
  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)
2529
+ vi:SendMouseButton(pos, inputType, true)
2119
2530
  task.wait(0.05)
2120
- vim:SendMouseButtonEvent(x, y, buttonNum, false)
2531
+ vi:SendMouseButton(pos, inputType, false)
2121
2532
  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)
2533
+ vi:SendMouseButton(pos, inputType, true)
2126
2534
  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")
2535
+ vi:SendMouseButton(pos, inputType, false)
2144
2536
  else
2145
- error(`Unknown action: {action}`)
2537
+ error(`Unsupported action "{action}". CreateVirtualInput supports click, mouseDown, mouseUp ` .. `(no move/scroll — those methods don't exist on VirtualInput).`)
2146
2538
  end
2147
2539
  end)
2148
2540
  if success then
@@ -2159,7 +2551,40 @@ local function simulateMouseInput(requestData)
2159
2551
  }
2160
2552
  end
2161
2553
  local function simulateKeyboardInput(requestData)
2554
+ local notRendering = RenderMonitor.notRenderingReason()
2555
+ if notRendering ~= nil then
2556
+ return {
2557
+ error = notRendering,
2558
+ }
2559
+ end
2560
+ local vi = getVI()
2561
+ if not vi then
2562
+ return {
2563
+ error = "UserInputService:CreateVirtualInput() is not available in this context",
2564
+ }
2565
+ end
2566
+ -- Text mode: type a string into the focused TextBox.
2567
+ local text = requestData.text
2568
+ if text ~= nil then
2569
+ local ok, err = pcall(function()
2570
+ return vi:SendTextInput(text)
2571
+ end)
2572
+ if ok then
2573
+ return {
2574
+ success = true,
2575
+ text = text,
2576
+ }
2577
+ end
2578
+ return {
2579
+ error = `Failed to send text input: {err}`,
2580
+ }
2581
+ end
2162
2582
  local keyCodeName = requestData.keyCode
2583
+ if not (keyCodeName ~= "" and keyCodeName) then
2584
+ return {
2585
+ error = "keyCode (or text) is required",
2586
+ }
2587
+ end
2163
2588
  local _condition = (requestData.action)
2164
2589
  if _condition == nil then
2165
2590
  _condition = "tap"
@@ -2170,17 +2595,6 @@ local function simulateKeyboardInput(requestData)
2170
2595
  _condition_1 = 0.1
2171
2596
  end
2172
2597
  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
2598
  local enumOk, keyCode = pcall(function()
2185
2599
  return (Enum.KeyCode)[keyCodeName]
2186
2600
  end)
@@ -2191,13 +2605,13 @@ local function simulateKeyboardInput(requestData)
2191
2605
  end
2192
2606
  local success, err = pcall(function()
2193
2607
  if action == "press" then
2194
- vim:SendKeyEvent(true, keyCode, false)
2608
+ vi:SendKey(true, keyCode)
2195
2609
  elseif action == "release" then
2196
- vim:SendKeyEvent(false, keyCode, false)
2610
+ vi:SendKey(false, keyCode)
2197
2611
  elseif action == "tap" then
2198
- vim:SendKeyEvent(true, keyCode, false)
2612
+ vi:SendKey(true, keyCode)
2199
2613
  task.wait(duration)
2200
- vim:SendKeyEvent(false, keyCode, false)
2614
+ vi:SendKey(false, keyCode)
2201
2615
  else
2202
2616
  error(`Unknown action: {action}`)
2203
2617
  end
@@ -2827,11 +3241,10 @@ return {
2827
3241
  <string name="Name">MetadataHandlers</string>
2828
3242
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
2829
3243
  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
3244
+ local CollectionService = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services").CollectionService
2833
3245
  local Utils = TS.import(script, script.Parent.Parent, "Utils")
2834
3246
  local Recording = TS.import(script, script.Parent.Parent, "Recording")
3247
+ local LuauExec = TS.import(script, script.Parent.Parent, "LuauExec")
2835
3248
  local ChangeHistoryService = game:GetService("ChangeHistoryService")
2836
3249
  local Selection = game:GetService("Selection")
2837
3250
  local _binding = Utils
@@ -3257,137 +3670,11 @@ local function executeLuau(requestData)
3257
3670
  error = "Code is required",
3258
3671
  }
3259
3672
  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
- }
3673
+ -- All wrapping, print/warn capture, loadstring fallback, JSON-encoding
3674
+ -- of table returns, and parse-error recovery live in LuauExec so the
3675
+ -- edit/server (this handler) and the play-client (ClientBroker) take
3676
+ -- the same code path and produce identical output shapes.
3677
+ return LuauExec.execute(code)
3391
3678
  end
3392
3679
  local function undo(_requestData)
3393
3680
  local success, result = pcall(function()
@@ -5672,10 +5959,11 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5672
5959
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5673
5960
  local HttpService = _services.HttpService
5674
5961
  local LogService = _services.LogService
5962
+ local Players = _services.Players
5675
5963
  local RunService = _services.RunService
5676
5964
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5677
5965
  local installBridges = _EvalBridges.installBridges
5678
- local cleanupBridges = _EvalBridges.cleanupBridges
5966
+ local ensureBridgesInstalled = _EvalBridges.ensureBridgesInstalled
5679
5967
  local StopPlayMonitor = TS.import(script, script.Parent.Parent, "StopPlayMonitor")
5680
5968
  local StudioTestService = game:GetService("StudioTestService")
5681
5969
  local ServerScriptService = game:GetService("ServerScriptService")
@@ -5693,27 +5981,84 @@ local testResult
5693
5981
  local testError
5694
5982
  local stopListenerScript
5695
5983
  local navResultCallback
5696
- local function buildCommandListenerSource()
5697
- return `local LogService = game:GetService("LogService")\
5698
- local PathfindingService = game:GetService("PathfindingService")\
5699
- local Players = game:GetService("Players")\
5700
- local HttpService = game:GetService("HttpService")\
5701
- local NAV_SIG = "{NAV_SIGNAL}"\
5702
- local NAV_RES = "{NAV_RESULT}"\
5703
- LogService.MessageOut:Connect(function(msg)\
5704
- if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
5705
- local json = string.sub(msg, #NAV_SIG + 2)\
5706
- task.spawn(function()\
5707
- local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
5708
- if not ok or not d then\
5709
- print(NAV_RES .. ':\{"success":false,"error":"parse_error"\}')\
5710
- return\
5711
- end\
5712
- local ps = Players:GetPlayers()\
5713
- if #ps == 0 then\
5714
- print(NAV_RES .. ':\{"success":false,"error":"no_players"\}')\
5715
- return\
5716
- end\
5984
+ local multiplayerState = {
5985
+ phase = "idle",
5986
+ }
5987
+ local function detectPeerRole()
5988
+ if not RunService:IsRunning() then
5989
+ return "edit"
5990
+ end
5991
+ if RunService:IsServer() then
5992
+ return "server"
5993
+ end
5994
+ return "client"
5995
+ end
5996
+ local function getPlayersSnapshot()
5997
+ local _exp = Players:GetPlayers()
5998
+ -- ▼ ReadonlyArray.map ▼
5999
+ local _newValue = table.create(#_exp)
6000
+ local _callback = function(player)
6001
+ return {
6002
+ name = player.Name,
6003
+ userId = player.UserId,
6004
+ displayName = player.DisplayName,
6005
+ }
6006
+ end
6007
+ for _k, _v in _exp do
6008
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
6009
+ end
6010
+ -- ▲ ReadonlyArray.map ▲
6011
+ local players = _newValue
6012
+ table.sort(players, function(a, b)
6013
+ return a.name < b.name
6014
+ end)
6015
+ return players
6016
+ end
6017
+ local function cloneMultiplayerState()
6018
+ return {
6019
+ phase = multiplayerState.phase,
6020
+ testId = multiplayerState.testId,
6021
+ numPlayers = multiplayerState.numPlayers,
6022
+ testArgs = multiplayerState.testArgs,
6023
+ startedAt = multiplayerState.startedAt,
6024
+ completedAt = multiplayerState.completedAt,
6025
+ ok = multiplayerState.ok,
6026
+ result = multiplayerState.result,
6027
+ error = multiplayerState.error,
6028
+ }
6029
+ end
6030
+ local function normalizeNumPlayers(value)
6031
+ local _value = value
6032
+ if not (type(_value) == "number") then
6033
+ return nil
6034
+ end
6035
+ local n = math.floor(value)
6036
+ if n ~= value or n < 1 or n > 8 then
6037
+ return nil
6038
+ end
6039
+ return n
6040
+ end
6041
+ local function buildCommandListenerSource()
6042
+ return `local LogService = game:GetService("LogService")\
6043
+ local PathfindingService = game:GetService("PathfindingService")\
6044
+ local Players = game:GetService("Players")\
6045
+ local HttpService = game:GetService("HttpService")\
6046
+ local NAV_SIG = "{NAV_SIGNAL}"\
6047
+ local NAV_RES = "{NAV_RESULT}"\
6048
+ LogService.MessageOut:Connect(function(msg)\
6049
+ if string.sub(msg, 1, #NAV_SIG + 1) == NAV_SIG .. ":" then\
6050
+ local json = string.sub(msg, #NAV_SIG + 2)\
6051
+ task.spawn(function()\
6052
+ local ok, d = pcall(function() return HttpService:JSONDecode(json) end)\
6053
+ if not ok or not d then\
6054
+ print(NAV_RES .. ':\{"success":false,"error":"parse_error"\}')\
6055
+ return\
6056
+ end\
6057
+ local ps = Players:GetPlayers()\
6058
+ if #ps == 0 then\
6059
+ print(NAV_RES .. ':\{"success":false,"error":"no_players"\}')\
6060
+ return\
6061
+ end\
5717
6062
  local char = ps[1].Character or ps[1].CharacterAdded:Wait()\
5718
6063
  local hum = char:FindFirstChildOfClass("Humanoid")\
5719
6064
  local root = char:FindFirstChild("HumanoidRootPart")\
@@ -5789,6 +6134,11 @@ local function startPlaytest(requestData)
5789
6134
  error = 'mode must be "play" or "run"',
5790
6135
  }
5791
6136
  end
6137
+ if numPlayers ~= nil then
6138
+ return {
6139
+ error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
6140
+ }
6141
+ end
5792
6142
  -- Self-heal: if testRunning is stuck true but Studio reports no active
5793
6143
  -- playtest, the previous start_playtest's task.spawn was orphaned
5794
6144
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -5800,7 +6150,9 @@ local function startPlaytest(requestData)
5800
6150
  logConnection = nil
5801
6151
  end
5802
6152
  cleanupStopListener()
5803
- cleanupBridges()
6153
+ -- Note: eval bridges are intentionally NOT cleaned up — they live
6154
+ -- permanently in the edit DM so manual playtests also get them. See
6155
+ -- EvalBridges.ts lifecycle comment.
5804
6156
  end
5805
6157
  if testRunning then
5806
6158
  return {
@@ -5843,17 +6195,14 @@ local function startPlaytest(requestData)
5843
6195
  if not injected then
5844
6196
  warn(`[MCP] Failed to inject stop listener: {injErr}`)
5845
6197
  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.
6198
+ -- Force-refresh the game-VM eval bridges (ServerEvalBridge + ClientEvalBridge)
6199
+ -- right before cloning so the play DMs get the current source. They also
6200
+ -- live permanently in the edit DM (installed on connect) so manually-started
6201
+ -- playtests get them too; here we just ensure they're fresh.
5849
6202
  local bridgeInstall = installBridges()
5850
6203
  if not bridgeInstall.installed then
5851
6204
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
5852
6205
  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
6206
  task.spawn(function()
5858
6207
  local ok, result = pcall(function()
5859
6208
  if mode == "play" then
@@ -5872,12 +6221,13 @@ local function startPlaytest(requestData)
5872
6221
  end
5873
6222
  testRunning = false
5874
6223
  cleanupStopListener()
5875
- cleanupBridges()
6224
+ -- Eval bridges persist in the edit DM (see EvalBridges.ts) — do not
6225
+ -- clean up here, so the next manual playtest still gets them.
6226
+ ensureBridgesInstalled()
5876
6227
  end)
5877
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
5878
6228
  local response = {
5879
6229
  success = true,
5880
- message = msg,
6230
+ message = `Playtest started in {mode} mode.`,
5881
6231
  }
5882
6232
  -- Only mention eval bridges when they failed — when they're fine, the
5883
6233
  -- detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -5900,9 +6250,25 @@ local function stopPlaytest(_requestData)
5900
6250
  }
5901
6251
  end
5902
6252
  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.
6253
+ -- Two distinct failure modes collapse here, distinguished by whether
6254
+ -- THIS edit DM has a playtest tracked:
6255
+ --
6256
+ -- - testRunning=false: no playtest was running from this edit DM
6257
+ -- (true negative). Return "no active playtest" — fine to retry only
6258
+ -- after actually starting a playtest.
6259
+ -- - testRunning=true: a playtest IS running but the cross-DM signal
6260
+ -- didn't propagate within the consumption timeout (false negative
6261
+ -- from the caller's perspective — playtest may actually have ended).
6262
+ -- Tell the caller it's a timing issue and they can retry.
6263
+ --
6264
+ -- Either way clean up the pending flag so a future playtest's monitor
6265
+ -- doesn't fire EndTest on startup against a stale signal.
5905
6266
  StopPlayMonitor.clearPending()
6267
+ if testRunning then
6268
+ return {
6269
+ error = "Playtest stop signal sent but consumption confirmation timed out. " .. "The playtest may have ended anyway; check get_connected_instances.",
6270
+ }
6271
+ end
5906
6272
  return {
5907
6273
  error = "No active playtest to stop.",
5908
6274
  }
@@ -5944,6 +6310,198 @@ local function getPlaytestOutput(_requestData)
5944
6310
  _object.testError = testError
5945
6311
  return _object
5946
6312
  end
6313
+ local function multiplayerTestStart(requestData)
6314
+ if RunService:IsRunning() then
6315
+ return {
6316
+ error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
6317
+ }
6318
+ end
6319
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6320
+ if numPlayers == nil then
6321
+ return {
6322
+ error = "numPlayers must be an integer from 1 to 8",
6323
+ }
6324
+ end
6325
+ if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
6326
+ return {
6327
+ error = "A multiplayer Studio test is already running",
6328
+ state = cloneMultiplayerState(),
6329
+ }
6330
+ end
6331
+ local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
6332
+ local testId = HttpService:GenerateGUID(false)
6333
+ local bridgeInstall = installBridges()
6334
+ if not bridgeInstall.installed then
6335
+ warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6336
+ end
6337
+ multiplayerState = {
6338
+ phase = "starting",
6339
+ testId = testId,
6340
+ numPlayers = numPlayers,
6341
+ testArgs = testArgs,
6342
+ startedAt = tick(),
6343
+ }
6344
+ task.spawn(function()
6345
+ multiplayerState.phase = "running"
6346
+ local ok, result = pcall(function()
6347
+ return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
6348
+ end)
6349
+ multiplayerState.completedAt = tick()
6350
+ multiplayerState.ok = ok
6351
+ if ok then
6352
+ multiplayerState.phase = "completed"
6353
+ multiplayerState.result = result
6354
+ multiplayerState.error = nil
6355
+ else
6356
+ multiplayerState.phase = "failed"
6357
+ multiplayerState.result = nil
6358
+ multiplayerState.error = tostring(result)
6359
+ end
6360
+ ensureBridgesInstalled()
6361
+ end)
6362
+ local response = {
6363
+ success = true,
6364
+ message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
6365
+ testId = testId,
6366
+ phase = multiplayerState.phase,
6367
+ numPlayers = numPlayers,
6368
+ testArgs = testArgs,
6369
+ }
6370
+ if not bridgeInstall.installed then
6371
+ response.evalBridgesError = bridgeInstall.error
6372
+ end
6373
+ return response
6374
+ end
6375
+ local function multiplayerTestState(_requestData)
6376
+ local peer = detectPeerRole()
6377
+ local response = {
6378
+ success = true,
6379
+ peer = peer,
6380
+ isRunning = RunService:IsRunning(),
6381
+ isRunMode = RunService:IsRunMode(),
6382
+ editModeActive = StudioTestService.EditModeActive,
6383
+ }
6384
+ if peer == "edit" then
6385
+ response.session = cloneMultiplayerState()
6386
+ return response
6387
+ end
6388
+ local argsOk, args = pcall(function()
6389
+ return StudioTestService:GetTestArgs()
6390
+ end)
6391
+ response.testArgsOk = argsOk
6392
+ response.testArgs = if argsOk then args else nil
6393
+ if not argsOk then
6394
+ response.testArgsError = tostring(args)
6395
+ end
6396
+ local players = getPlayersSnapshot()
6397
+ response.players = players
6398
+ response.playerCount = #players
6399
+ if peer == "client" then
6400
+ response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6401
+ local canLeaveOk, canLeave = pcall(function()
6402
+ return StudioTestService:CanLeaveTest()
6403
+ end)
6404
+ response.canLeaveOk = canLeaveOk
6405
+ response.canLeave = if canLeaveOk then canLeave else false
6406
+ if not canLeaveOk then
6407
+ response.canLeaveError = tostring(canLeave)
6408
+ end
6409
+ end
6410
+ return response
6411
+ end
6412
+ local function multiplayerTestAddPlayers(requestData)
6413
+ if not RunService:IsRunning() or not RunService:IsServer() then
6414
+ return {
6415
+ error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
6416
+ }
6417
+ end
6418
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6419
+ if numPlayers == nil then
6420
+ return {
6421
+ error = "numPlayers must be an integer from 1 to 8",
6422
+ }
6423
+ end
6424
+ local before = #Players:GetPlayers()
6425
+ local ok, result = pcall(function()
6426
+ return StudioTestService:AddPlayers(numPlayers)
6427
+ end)
6428
+ if not ok then
6429
+ return {
6430
+ error = tostring(result),
6431
+ }
6432
+ end
6433
+ local _exp = tick()
6434
+ local _condition = (requestData.timeout)
6435
+ if _condition == nil then
6436
+ _condition = 10
6437
+ end
6438
+ local deadline = _exp + _condition
6439
+ while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
6440
+ task.wait(0.1)
6441
+ end
6442
+ local players = getPlayersSnapshot()
6443
+ return {
6444
+ success = true,
6445
+ message = `Requested {numPlayers} additional player(s).`,
6446
+ playerCount = #players,
6447
+ players = players,
6448
+ }
6449
+ end
6450
+ local function multiplayerTestLeaveClient(_requestData)
6451
+ if not RunService:IsRunning() or RunService:IsServer() then
6452
+ return {
6453
+ error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
6454
+ }
6455
+ end
6456
+ local canLeaveOk, canLeave = pcall(function()
6457
+ return StudioTestService:CanLeaveTest()
6458
+ end)
6459
+ if not canLeaveOk then
6460
+ return {
6461
+ error = tostring(canLeave),
6462
+ canLeaveOk = false,
6463
+ }
6464
+ end
6465
+ if not canLeave then
6466
+ return {
6467
+ error = "This client cannot leave the current test session.",
6468
+ canLeaveOk = true,
6469
+ canLeave = false,
6470
+ }
6471
+ end
6472
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6473
+ task.defer(function()
6474
+ pcall(function()
6475
+ return StudioTestService:LeaveTest()
6476
+ end)
6477
+ end)
6478
+ return {
6479
+ success = true,
6480
+ message = "Client leave requested.",
6481
+ localPlayer = localPlayer,
6482
+ }
6483
+ end
6484
+ local function multiplayerTestEnd(requestData)
6485
+ if not RunService:IsRunning() or not RunService:IsServer() then
6486
+ return {
6487
+ error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
6488
+ }
6489
+ end
6490
+ local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
6491
+ local ok, result = pcall(function()
6492
+ return StudioTestService:EndTest(value)
6493
+ end)
6494
+ if not ok then
6495
+ return {
6496
+ error = tostring(result),
6497
+ }
6498
+ end
6499
+ return {
6500
+ success = true,
6501
+ message = "Multiplayer Studio test end requested.",
6502
+ value = value,
6503
+ }
6504
+ end
5947
6505
  local function characterNavigation(requestData)
5948
6506
  if not testRunning then
5949
6507
  return {
@@ -6015,6 +6573,11 @@ return {
6015
6573
  startPlaytest = startPlaytest,
6016
6574
  stopPlaytest = stopPlaytest,
6017
6575
  getPlaytestOutput = getPlaytestOutput,
6576
+ multiplayerTestStart = multiplayerTestStart,
6577
+ multiplayerTestState = multiplayerTestState,
6578
+ multiplayerTestAddPlayers = multiplayerTestAddPlayers,
6579
+ multiplayerTestLeaveClient = multiplayerTestLeaveClient,
6580
+ multiplayerTestEnd = multiplayerTestEnd,
6018
6581
  characterNavigation = characterNavigation,
6019
6582
  }
6020
6583
  ]]></string>
@@ -6022,6 +6585,324 @@ return {
6022
6585
  </Item>
6023
6586
  </Item>
6024
6587
  <Item class="ModuleScript" referent="19">
6588
+ <Properties>
6589
+ <string name="Name">LuauExec</string>
6590
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6591
+ -- eslint-disable
6592
+ -- Shared execute_luau machinery for edit/server (MetadataHandlers.executeLuau)
6593
+ -- and the play-client peer (ClientBroker.handleExecuteLuau). Three things this
6594
+ -- module owns:
6595
+ --
6596
+ -- 1. The IIFE wrapper that captures print/warn, runs user code in xpcall,
6597
+ -- and always returns { ok, value, output } so the ModuleScript itself
6598
+ -- always returns exactly one value (otherwise `print("hi")` with no
6599
+ -- return would fail with "Module code did not return exactly one value").
6600
+ --
6601
+ -- 2. The loadstring-then-ModuleScript-require fallback, with the parse-error
6602
+ -- recovery hack that pulls the real diagnostic from LogService.
6603
+ --
6604
+ -- 3. Return-value formatting: tables get HttpService:JSONEncode'd so the
6605
+ -- caller sees `{"x":1,"y":2}` instead of `table: 0xaddr`; primitives
6606
+ -- pass through tostring. The encode is pcall'd so cycles or
6607
+ -- non-serializable values gracefully fall back to tostring.
6608
+ --
6609
+ -- Before this module existed, the client peer used a stripped-down
6610
+ -- require-only execution path that lacked both the wrapper and the JSON
6611
+ -- formatting, producing two well-known papercuts:
6612
+ -- - `print("hi")` (no return) failed with "Module code did not return..."
6613
+ -- - Returning a table yielded `table: 0xaddr` instead of structured data.
6614
+ local HttpService = game:GetService("HttpService")
6615
+ local LogService = game:GetService("LogService")
6616
+ local PAYLOAD_INSTANCE_NAME = "__MCPExecLuauPayload"
6617
+ local PAYLOAD_PATH_PREFIX = `Workspace.{PAYLOAD_INSTANCE_NAME}:`
6618
+ -- Number of lines the wrapper emits BEFORE the first line of user code.
6619
+ -- Used both inside the wrapper (Luau __mcp_LINE_OFFSET) and on the TS side
6620
+ -- (remapPayloadLines, for compile errors recovered from LogService) so user
6621
+ -- code errors report user-relative line numbers instead of the inflated
6622
+ -- "line 23" the wrapper would otherwise expose. If you reorder buildWrapper's
6623
+ -- prefix lines, update this constant — there's a self-check below.
6624
+ local WRAPPER_LINE_OFFSET = 23
6625
+ -- Count source lines so the wrapper can filter traceback frames that fall
6626
+ -- outside the user code range (the wrapper's own preamble/postamble lines).
6627
+ local function countLines(s)
6628
+ local n = 1
6629
+ local size = #s
6630
+ do
6631
+ local i = 1
6632
+ local _shouldIncrement = false
6633
+ while true do
6634
+ if _shouldIncrement then
6635
+ i += 1
6636
+ else
6637
+ _shouldIncrement = true
6638
+ end
6639
+ if not (i <= size) then
6640
+ break
6641
+ end
6642
+ if string.sub(s, i, i) == "\n" then
6643
+ n += 1
6644
+ end
6645
+ end
6646
+ end
6647
+ return n
6648
+ end
6649
+ local function buildWrapper(code)
6650
+ -- If you reorder the prefix lines below, update WRAPPER_LINE_OFFSET to
6651
+ -- match the number of lines emitted BEFORE the ${code} substitution.
6652
+ -- The constant is mirrored inside the wrapper (__mcp_LINE_OFFSET) and
6653
+ -- used by remapPayloadLines on the TS side.
6654
+ local userLines = countLines(code)
6655
+ return `return ((function()\
6656
+ \tlocal __mcp_traceback\
6657
+ \tlocal __mcp_remap\
6658
+ \tlocal __mcp_LINE_OFFSET = {WRAPPER_LINE_OFFSET}\
6659
+ \tlocal __mcp_USER_LINES = {userLines}\
6660
+ \tlocal __mcp_output = \{\}\
6661
+ \tlocal __mcp_real_print = print\
6662
+ \tlocal __mcp_real_warn = warn\
6663
+ \tlocal print = function(...)\
6664
+ \t\t__mcp_real_print(...)\
6665
+ \t\tlocal args = \{...\}\
6666
+ \t\tlocal parts = table.create(#args)\
6667
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6668
+ \t\ttable.insert(__mcp_output, table.concat(parts, "\\t"))\
6669
+ \tend\
6670
+ \tlocal warn = function(...)\
6671
+ \t\t__mcp_real_warn(...)\
6672
+ \t\tlocal args = \{...\}\
6673
+ \t\tlocal parts = table.create(#args)\
6674
+ \t\tfor i, a in ipairs(args) do parts[i] = tostring(a) end\
6675
+ \t\ttable.insert(__mcp_output, "[warn] " .. table.concat(parts, "\\t"))\
6676
+ \tend\
6677
+ \tlocal function __mcp_run()\
6678
+ {code}\
6679
+ \tend\
6680
+ \t__mcp_remap = function(s)\
6681
+ \t\t-- Two chunk-name formats can reference our payload:\
6682
+ \t\t-- * "Workspace.__MCPExecLuauPayload:N" — ModuleScript:require fallback path\
6683
+ \t\t-- * "[string \\"return ((function()...\\"]:N" — loadstring() (default in plugin)\
6684
+ \t\t-- Subtract LINE_OFFSET to get the user-relative number, then clamp.\
6685
+ \t\t-- Clamping matters for unclosed constructs ("local x = (") where the\
6686
+ \t\t-- parser keeps reading into wrapper postamble and reports a payload\
6687
+ \t\t-- line past user EOF. Without clamping the message says "user_code:49"\
6688
+ \t\t-- for one-line input, framing the wrapper as user code.\
6689
+ \t\tlocal function __mcp_user_line(payload_n)\
6690
+ \t\t\tlocal user_n = payload_n - __mcp_LINE_OFFSET\
6691
+ \t\t\tif user_n < 1 then return "1" end\
6692
+ \t\t\tif user_n > __mcp_USER_LINES then return tostring(__mcp_USER_LINES) .. " (at end of input)" end\
6693
+ \t\t\treturn tostring(user_n)\
6694
+ \t\tend\
6695
+ \t\ts = string.gsub(s, "__MCPExecLuauPayload:(%d+)", function(num)\
6696
+ \t\t\tlocal n = tonumber(num)\
6697
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6698
+ \t\t\treturn "user_code:" .. num\
6699
+ \t\tend)\
6700
+ \t\ts = string.gsub(s, '%[string "[^"]+"%]:(%d+)', function(num)\
6701
+ \t\t\tlocal n = tonumber(num)\
6702
+ \t\t\tif n then return "user_code:" .. __mcp_user_line(n) end\
6703
+ \t\t\treturn "user_code:" .. num\
6704
+ \t\tend)\
6705
+ \t\treturn s\
6706
+ \tend\
6707
+ \t__mcp_traceback = function(err)\
6708
+ \t\tlocal raw = debug.traceback(tostring(err), 2)\
6709
+ \t\tlocal kept = \{\}\
6710
+ \t\tfor line in string.gmatch(raw, "[^\\n]+") do\
6711
+ \t\t\t-- Extract referenced line number (either chunk-name format).\
6712
+ \t\t\tlocal num_str = string.match(line, "__MCPExecLuauPayload:(%d+)")\
6713
+ \t\t\t\tor string.match(line, '%[string "[^"]+"%]:(%d+)')\
6714
+ \t\t\tlocal n = num_str and tonumber(num_str)\
6715
+ \t\t\t-- Strip the "in function '__mcp_run'" annotation before doing\
6716
+ \t\t\t-- any filtering, because user-code frames carry that suffix —\
6717
+ \t\t\t-- the entire user payload is hosted inside __mcp_run, so EVERY\
6718
+ \t\t\t-- user frame would otherwise match a naive "__mcp_" filter and\
6719
+ \t\t\t-- get dropped. Strip first, then apply filters.\
6720
+ \t\t\tline = (string.gsub(line, " in function '__mcp_run'", ""))\
6721
+ \t\t\tlocal skip = string.find(line, "MCPPlugin", 1, true)\
6722
+ \t\t\t\tor string.find(line, "__mcp_", 1, true)\
6723
+ \t\t\t\tor string.find(line, "in function 'xpcall'", 1, true)\
6724
+ \t\t\t-- Frame lines pointing at wrapper preamble/postamble (outside\
6725
+ \t\t\t-- user range) are wrapper internals — drop them. Lines without\
6726
+ \t\t\t-- a payload-chunk line number (the traceback header / engine\
6727
+ \t\t\t-- C frames) are kept; remap is a no-op for them.\
6728
+ \t\t\tif n and (n <= __mcp_LINE_OFFSET or n > __mcp_LINE_OFFSET + __mcp_USER_LINES) then\
6729
+ \t\t\t\tskip = true\
6730
+ \t\t\tend\
6731
+ \t\t\tif not skip then\
6732
+ \t\t\t\ttable.insert(kept, __mcp_remap(line))\
6733
+ \t\t\tend\
6734
+ \t\tend\
6735
+ \t\treturn table.concat(kept, "\\n")\
6736
+ \tend\
6737
+ \tlocal ok, errOrValue = xpcall(__mcp_run, __mcp_traceback)\
6738
+ \treturn \{ ok = ok, value = errOrValue, output = __mcp_output \}\
6739
+ end)())`
6740
+ end
6741
+ -- TS-side mirror of the Lua __mcp_remap. Used by runViaModuleScript when
6742
+ -- pulling the real compile-error diagnostic out of LogService — that error
6743
+ -- references the payload module's line number directly, and never passes
6744
+ -- through the IIFE's runtime wrapper.
6745
+ local function remapPayloadLines(s, userLines)
6746
+ -- Mirror of the Lua __mcp_remap inside the wrapper, for paths that
6747
+ -- don't pass through the IIFE (compile errors recovered from
6748
+ -- LogService, the immediate loadstring compileError surface). Same
6749
+ -- two-format coverage plus the same clamp: unclosed user constructs
6750
+ -- let the parser consume wrapper postamble, so the raw payload line
6751
+ -- is sometimes well past user EOF — clamp to [1, userLines] and
6752
+ -- annotate so the error doesn't say "user_code:49" for one-line input.
6753
+ local userLine = function(payload)
6754
+ local u = payload - WRAPPER_LINE_OFFSET
6755
+ if u < 1 then
6756
+ return "1"
6757
+ end
6758
+ if u > userLines then
6759
+ return `{tostring(userLines)} (at end of input)`
6760
+ end
6761
+ return tostring(u)
6762
+ end
6763
+ local out = s
6764
+ local a = string.gsub(out, "__MCPExecLuauPayload:(%d+)", function(num)
6765
+ local n = tonumber(num)
6766
+ if n ~= nil then
6767
+ return `user_code:{userLine(n)}`
6768
+ end
6769
+ return `user_code:{num}`
6770
+ end)
6771
+ out = a
6772
+ local b = string.gsub(out, '%[string "[^"]+"%]:(%d+)', function(num)
6773
+ local n = tonumber(num)
6774
+ if n ~= nil then
6775
+ return `user_code:{userLine(n)}`
6776
+ end
6777
+ return `user_code:{num}`
6778
+ end)
6779
+ out = b
6780
+ return out
6781
+ end
6782
+ local function runViaModuleScript(wrapped, userLines)
6783
+ local m = Instance.new("ModuleScript")
6784
+ m.Name = PAYLOAD_INSTANCE_NAME
6785
+ local okSet, setErr = pcall(function()
6786
+ m.Source = wrapped
6787
+ end)
6788
+ if not okSet then
6789
+ m:Destroy()
6790
+ -- error(..., 0) suppresses the "user_MCPPlugin.rbxmx.MCPPlugin.modules.LuauExec:N:"
6791
+ -- prefix that error() would otherwise prepend, keeping the visible
6792
+ -- message focused on the user-actionable error rather than our path.
6793
+ error(`ModuleScript Source set failed: {tostring(setErr)}`, 0)
6794
+ end
6795
+ m.Parent = game:GetService("Workspace")
6796
+ local okReq, reqResult = pcall(function()
6797
+ return require(m)
6798
+ end)
6799
+ m:Destroy()
6800
+ if not okReq then
6801
+ local errMsg = tostring(reqResult)
6802
+ -- pcall(require, m) collapses parse/compile failures into the canned
6803
+ -- engine string. The real diagnostic was emitted to LogService on the
6804
+ -- next engine frame — give it ~50ms to land then scan backward.
6805
+ if errMsg == "Requested module experienced an error while loading" then
6806
+ task.wait(0.05)
6807
+ local hist = LogService:GetLogHistory()
6808
+ for i = #hist - 1, 0, -1 do
6809
+ local e = hist[i + 1]
6810
+ if e.messageType == Enum.MessageType.MessageError and string.sub(e.message, 1, #PAYLOAD_PATH_PREFIX) == PAYLOAD_PATH_PREFIX then
6811
+ errMsg = e.message
6812
+ break
6813
+ end
6814
+ end
6815
+ end
6816
+ -- Compile errors reference the payload module's line number directly
6817
+ -- — remap + clamp to user-relative line numbers so `local x = 1 +`
6818
+ -- reports :1: instead of :23:, and reports the clamp annotation
6819
+ -- when the parser ran off the end of user code into wrapper code.
6820
+ error(remapPayloadLines(errMsg, userLines), 0)
6821
+ end
6822
+ return reqResult
6823
+ end
6824
+ local function isLoadstringUnavailable(err)
6825
+ local errStr = tostring(err)
6826
+ local matchStart = string.find(errStr, "not available", 1, true)
6827
+ return matchStart ~= nil
6828
+ end
6829
+ -- Returns a string suitable for `returnValue`. Tables get JSON-encoded so
6830
+ -- the caller sees structured data instead of "table: 0xaddr". Anything that
6831
+ -- JSONEncode chokes on (cycles, Roblox userdata) falls back to tostring.
6832
+ local function formatReturnValue(value)
6833
+ if value == nil then
6834
+ return ""
6835
+ end
6836
+ local _value = value
6837
+ if type(_value) == "table" then
6838
+ local ok, encoded = pcall(function()
6839
+ return HttpService:JSONEncode(value)
6840
+ end)
6841
+ if ok then
6842
+ return encoded
6843
+ end
6844
+ end
6845
+ return tostring(value)
6846
+ end
6847
+ local function execute(code)
6848
+ if not (code ~= "" and code) or code == "" then
6849
+ return {
6850
+ success = false,
6851
+ error = "code is required",
6852
+ }
6853
+ end
6854
+ local wrapped = buildWrapper(code)
6855
+ local userLines = countLines(code)
6856
+ local success, result = pcall(function()
6857
+ local fn, compileError = loadstring(wrapped)
6858
+ if not fn then
6859
+ if isLoadstringUnavailable(compileError) then
6860
+ return runViaModuleScript(wrapped, userLines)
6861
+ end
6862
+ error(`Compile error: {remapPayloadLines(tostring(compileError), userLines)}`, 0)
6863
+ end
6864
+ return fn()
6865
+ end)
6866
+ -- loadstring can throw (not return nil) when ServerScriptService.
6867
+ -- LoadStringEnabled is false; treat that as a second-chance fallback.
6868
+ if not success and isLoadstringUnavailable(result) then
6869
+ success, result = pcall(function()
6870
+ return runViaModuleScript(wrapped, userLines)
6871
+ end)
6872
+ end
6873
+ if not success then
6874
+ return {
6875
+ success = false,
6876
+ error = tostring(result),
6877
+ output = {},
6878
+ message = "Code execution failed",
6879
+ }
6880
+ end
6881
+ local r = result
6882
+ local capturedOutput = r.output
6883
+ local output = if capturedOutput ~= nil then capturedOutput else ({})
6884
+ if r.ok == true then
6885
+ return {
6886
+ success = true,
6887
+ returnValue = if r.value ~= nil then formatReturnValue(r.value) else nil,
6888
+ output = output,
6889
+ message = "Code executed successfully",
6890
+ }
6891
+ end
6892
+ return {
6893
+ success = false,
6894
+ error = if r.value ~= nil then tostring(r.value) else "(unknown error)",
6895
+ output = output,
6896
+ message = "Code execution failed",
6897
+ }
6898
+ end
6899
+ return {
6900
+ execute = execute,
6901
+ }
6902
+ ]]></string>
6903
+ </Properties>
6904
+ </Item>
6905
+ <Item class="ModuleScript" referent="20">
6025
6906
  <Properties>
6026
6907
  <string name="Name">Recording</string>
6027
6908
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6051,7 +6932,75 @@ return {
6051
6932
  ]]></string>
6052
6933
  </Properties>
6053
6934
  </Item>
6054
- <Item class="ModuleScript" referent="20">
6935
+ <Item class="ModuleScript" referent="21">
6936
+ <Properties>
6937
+ <string name="Name">RenderMonitor</string>
6938
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6939
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
6940
+ -- Detects whether the Studio window is actually rendering, so virtual input
6941
+ -- and screenshot tools can surface a clear reason instead of silently failing.
6942
+ --
6943
+ -- When a Studio window is MINIMIZED, the engine suspends the render loop AND
6944
+ -- input processing, but keeps running scripts (Heartbeat keeps firing). That's
6945
+ -- why simulate_*_input would return success while having zero effect, and
6946
+ -- CaptureService:CaptureScreenshot would time out. Validated live: during a 3s
6947
+ -- minimize, RenderStepped's max inter-frame gap was 5.08s while Heartbeat's was
6948
+ -- 0.10s. So RenderStepped freshness is the reliable "is this window rendering?"
6949
+ -- signal; Heartbeat is not.
6950
+ local RunService = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services").RunService
6951
+ local lastFrame = 0
6952
+ local connected = false
6953
+ -- Above this many seconds since the last rendered frame, we treat the window
6954
+ -- as not rendering. RenderStepped normally fires every ~16ms; a multi-second
6955
+ -- gap only happens when minimized/suspended, so 1s cleanly avoids false
6956
+ -- positives from ordinary frame hitches while still catching the real case.
6957
+ local STALE_THRESHOLD = 1.0
6958
+ local function start()
6959
+ if connected then
6960
+ return nil
6961
+ end
6962
+ -- RenderStepped can only be connected from a client/edit render loop; it
6963
+ -- throws in the play-server DM. pcall so a server-DM call is a safe no-op
6964
+ -- (connected stays false → notRenderingReason() returns undefined there).
6965
+ local ok = pcall(function()
6966
+ RunService.RenderStepped:Connect(function()
6967
+ lastFrame = tick()
6968
+ end)
6969
+ end)
6970
+ if ok then
6971
+ connected = true
6972
+ lastFrame = tick()
6973
+ end
6974
+ end
6975
+ local function secondsSinceFrame()
6976
+ if not connected then
6977
+ return 0
6978
+ end
6979
+ return tick() - lastFrame
6980
+ end
6981
+ -- Returns a human-readable reason if the window appears minimized / not
6982
+ -- rendering (so input + screenshots won't work), else undefined. Fail-open:
6983
+ -- when the monitor isn't active in this DM (server peer, or connect failed) it
6984
+ -- returns undefined so we never block on a false signal.
6985
+ local function notRenderingReason()
6986
+ if not connected then
6987
+ return nil
6988
+ end
6989
+ local gap = secondsSinceFrame()
6990
+ if gap > STALE_THRESHOLD then
6991
+ return string.format("Studio window appears minimized or not rendering (no frame in %.1fs). " .. "Virtual input and screenshots only work while the window is visible — " .. "restore/un-minimize the Studio window and retry.", gap)
6992
+ end
6993
+ return nil
6994
+ end
6995
+ return {
6996
+ start = start,
6997
+ secondsSinceFrame = secondsSinceFrame,
6998
+ notRenderingReason = notRenderingReason,
6999
+ }
7000
+ ]]></string>
7001
+ </Properties>
7002
+ </Item>
7003
+ <Item class="ModuleScript" referent="22">
6055
7004
  <Properties>
6056
7005
  <string name="Name">RuntimeLogBuffer</string>
6057
7006
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6232,11 +7181,11 @@ return {
6232
7181
  ]]></string>
6233
7182
  </Properties>
6234
7183
  </Item>
6235
- <Item class="ModuleScript" referent="21">
7184
+ <Item class="ModuleScript" referent="23">
6236
7185
  <Properties>
6237
7186
  <string name="Name">State</string>
6238
7187
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6239
- local CURRENT_VERSION = "2.11.4"
7188
+ local CURRENT_VERSION = "2.14.0"
6240
7189
  local MAX_CONNECTIONS = 5
6241
7190
  local BASE_PORT = 58741
6242
7191
  local activeTabIndex = 0
@@ -6328,61 +7277,96 @@ return {
6328
7277
  ]]></string>
6329
7278
  </Properties>
6330
7279
  </Item>
6331
- <Item class="ModuleScript" referent="22">
7280
+ <Item class="ModuleScript" referent="24">
6332
7281
  <Properties>
6333
7282
  <string name="Name">StopPlayMonitor</string>
6334
7283
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6335
- -- Cross-DM stop_playtest signaling via plugin:SetSetting.
7284
+ local TS = require(script.Parent.Parent.include.RuntimeLib)
7285
+ -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
7286
+ -- per-instance setting key so the same Studio process can host playtests
7287
+ -- for multiple places without one place's stop_playtest yanking another's.
6336
7288
  --
6337
7289
  -- `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).
7290
+ -- shared across every DataModel the plugin runs in (edit DMs, play-server
7291
+ -- DMs, play-client DMs). For each connected place we use a dedicated key
7292
+ -- "MCP_STOP_PLAY_<instanceId>" as a single-bit mailbox:
6348
7293
  --
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).
7294
+ -- * The edit DM's stopPlaytest handler writes `true` into its own key
7295
+ -- (computed from its placeId / ServerStorage anon UUID).
7296
+ -- * Each play-server DM's monitor loop polls the key matching its own
7297
+ -- instanceId at 0.1Hz; on `true` it clears the key and calls
7298
+ -- StudioTestService:EndTest. Play-server DMs for other places never
7299
+ -- touch this key.
7300
+ -- * The edit DM waits up to ~8s for its key to be cleared, confirming a
7301
+ -- matching play-server actually consumed the request.
6355
7302
  --
6356
- -- Pattern mirrors the official Roblox Studio MCP
6357
- -- (Roblox/studio-rust-mcp-server, plugin/src/Utils/GameStopUtil.luau).
7303
+ -- Earlier versions used a single shared boolean flag, which let any
7304
+ -- play-server DM in the same Studio process consume any place's stop
7305
+ -- request — silently yanking teammates' playtests. The per-key scoping
7306
+ -- below is the fix.
7307
+ local _services = TS.import(script, script.Parent.Parent, "node_modules", "@rbxts", "services")
7308
+ local HttpService = _services.HttpService
7309
+ local ServerStorage = _services.ServerStorage
6358
7310
  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
7311
+ local SETTING_KEY_PREFIX = "MCP_STOP_PLAY_"
7312
+ -- Monitor checks the key at this cadence. 0.1s keeps worst-case detection
7313
+ -- lag tight so the consumption-confirmation window doesn't have to absorb
7314
+ -- polling jitter on top of EndTest's teardown time.
7315
+ local POLL_INTERVAL_SEC = 0.1
7316
+ -- Total time we wait for the matching play-server DM to consume the
7317
+ -- signal. Must cover: monitor detection (<= POLL_INTERVAL_SEC) +
7318
+ -- StudioTestService:EndTest teardown (several seconds on heavier places).
7319
+ -- 8s is comfortable; the tighter poll above keeps real cases well under.
7320
+ local WAIT_FOR_CONSUMPTION_TIMEOUT_SEC = 8.0
6362
7321
  local WAIT_POLL_SEC = 0.1
6363
7322
  local pluginRef
6364
7323
  local function init(p)
6365
7324
  pluginRef = p
6366
7325
  end
7326
+ -- Mirror of Communication.computeInstanceId(). Duplicated here because
7327
+ -- StopPlayMonitor runs in both edit and play-server DMs, and both must
7328
+ -- agree on the place identifier (published places: placeId; unpublished:
7329
+ -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
7330
+ -- into the play DM).
7331
+ local function computeInstanceId()
7332
+ if game.PlaceId ~= 0 then
7333
+ return `place:{tostring(game.PlaceId)}`
7334
+ end
7335
+ local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7336
+ if type(existing) == "string" and existing ~= "" then
7337
+ return `anon:{existing}`
7338
+ end
7339
+ local fresh = HttpService:GenerateGUID(false)
7340
+ pcall(function()
7341
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7342
+ end)
7343
+ return `anon:{fresh}`
7344
+ end
7345
+ local function settingKey(instanceId)
7346
+ return SETTING_KEY_PREFIX .. instanceId
7347
+ end
6367
7348
  local function startMonitor()
6368
7349
  if not pluginRef then
6369
7350
  warn("[MCP] StopPlayMonitor.startMonitor called before init; skipping")
6370
7351
  return nil
6371
7352
  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.
7353
+ local myKey = settingKey(computeInstanceId())
7354
+ -- Clear any stale value left from a prior session. If a real stop
7355
+ -- request is in-flight when this runs, the requesting edit DM will
7356
+ -- write again within its consumption-confirmation window.
6375
7357
  pcall(function()
6376
- return pluginRef:SetSetting(SETTING_KEY, false)
7358
+ return pluginRef:SetSetting(myKey, false)
6377
7359
  end)
6378
7360
  task.spawn(function()
6379
7361
  while true do
6380
7362
  local okGet, val = pcall(function()
6381
- return pluginRef:GetSetting(SETTING_KEY)
7363
+ return pluginRef:GetSetting(myKey)
6382
7364
  end)
6383
7365
  if okGet and val == true then
7366
+ -- Consume the flag first so requestStop's
7367
+ -- waitForConsumption returns success, then end the test.
6384
7368
  pcall(function()
6385
- return pluginRef:SetSetting(SETTING_KEY, false)
7369
+ return pluginRef:SetSetting(myKey, false)
6386
7370
  end)
6387
7371
  pcall(function()
6388
7372
  return StudioTestService:EndTest("stopped_by_mcp")
@@ -6396,8 +7380,9 @@ local function requestStop()
6396
7380
  if not pluginRef then
6397
7381
  return false
6398
7382
  end
7383
+ local myKey = settingKey(computeInstanceId())
6399
7384
  local ok = pcall(function()
6400
- return pluginRef:SetSetting(SETTING_KEY, true)
7385
+ return pluginRef:SetSetting(myKey, true)
6401
7386
  end)
6402
7387
  return ok
6403
7388
  end
@@ -6405,10 +7390,11 @@ local function waitForConsumption()
6405
7390
  if not pluginRef then
6406
7391
  return false
6407
7392
  end
7393
+ local myKey = settingKey(computeInstanceId())
6408
7394
  local start = tick()
6409
7395
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
6410
7396
  local okGet, val = pcall(function()
6411
- return pluginRef:GetSetting(SETTING_KEY)
7397
+ return pluginRef:GetSetting(myKey)
6412
7398
  end)
6413
7399
  if okGet and val ~= true then
6414
7400
  return true
@@ -6421,8 +7407,9 @@ local function clearPending()
6421
7407
  if not pluginRef then
6422
7408
  return nil
6423
7409
  end
7410
+ local myKey = settingKey(computeInstanceId())
6424
7411
  pcall(function()
6425
- return pluginRef:SetSetting(SETTING_KEY, false)
7412
+ return pluginRef:SetSetting(myKey, false)
6426
7413
  end)
6427
7414
  end
6428
7415
  return {
@@ -6435,7 +7422,7 @@ return {
6435
7422
  ]]></string>
6436
7423
  </Properties>
6437
7424
  </Item>
6438
- <Item class="ModuleScript" referent="23">
7425
+ <Item class="ModuleScript" referent="25">
6439
7426
  <Properties>
6440
7427
  <string name="Name">UI</string>
6441
7428
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7186,7 +8173,7 @@ return {
7186
8173
  ]]></string>
7187
8174
  </Properties>
7188
8175
  </Item>
7189
- <Item class="ModuleScript" referent="24">
8176
+ <Item class="ModuleScript" referent="26">
7190
8177
  <Properties>
7191
8178
  <string name="Name">Utils</string>
7192
8179
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7716,11 +8703,11 @@ return {
7716
8703
  </Properties>
7717
8704
  </Item>
7718
8705
  </Item>
7719
- <Item class="Folder" referent="28">
8706
+ <Item class="Folder" referent="30">
7720
8707
  <Properties>
7721
8708
  <string name="Name">include</string>
7722
8709
  </Properties>
7723
- <Item class="ModuleScript" referent="25">
8710
+ <Item class="ModuleScript" referent="27">
7724
8711
  <Properties>
7725
8712
  <string name="Name">Promise</string>
7726
8713
  <string name="Source"><![CDATA[--[[
@@ -9794,7 +10781,7 @@ return Promise
9794
10781
  ]]></string>
9795
10782
  </Properties>
9796
10783
  </Item>
9797
- <Item class="ModuleScript" referent="26">
10784
+ <Item class="ModuleScript" referent="28">
9798
10785
  <Properties>
9799
10786
  <string name="Name">RuntimeLib</string>
9800
10787
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10061,15 +11048,15 @@ return TS
10061
11048
  </Properties>
10062
11049
  </Item>
10063
11050
  </Item>
10064
- <Item class="Folder" referent="29">
11051
+ <Item class="Folder" referent="31">
10065
11052
  <Properties>
10066
11053
  <string name="Name">node_modules</string>
10067
11054
  </Properties>
10068
- <Item class="Folder" referent="30">
11055
+ <Item class="Folder" referent="32">
10069
11056
  <Properties>
10070
11057
  <string name="Name">@rbxts</string>
10071
11058
  </Properties>
10072
- <Item class="ModuleScript" referent="27">
11059
+ <Item class="ModuleScript" referent="29">
10073
11060
  <Properties>
10074
11061
  <string name="Name">services</string>
10075
11062
  <string name="Source"><![CDATA[return setmetatable({}, {