@chrrxs/robloxstudio-mcp-inspector 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,9 +102,11 @@ local RunService = _services.RunService
102
102
  local ServerStorage = _services.ServerStorage
103
103
  local RuntimeLogBuffer = TS.import(script, script.Parent, "RuntimeLogBuffer")
104
104
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
105
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
105
106
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
107
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
107
108
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
109
+ local StudioTestService = game:GetService("StudioTestService")
108
110
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
109
111
  -- client broker runs in the play-server DM where it can't easily import from
110
112
  -- the edit-side module, and the place identifier must match what the edit-DM
@@ -158,6 +160,7 @@ end
158
160
  -- signaling, which works regardless of MCP server state.)
159
161
  local MCP_URL = "http://localhost:58741"
160
162
  local BROKER_NAME = "__MCPClientBroker"
163
+ local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
161
164
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
162
165
  -- Each requires the client peer's plugin VM (because the buffer / require
163
166
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -165,6 +168,9 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
165
168
  ["/api/execute-luau"] = true,
166
169
  ["/api/get-runtime-logs"] = true,
167
170
  ["/api/get-memory-breakdown"] = true,
171
+ ["/api/get-scene-analysis"] = true,
172
+ ["/api/multiplayer-test-state"] = true,
173
+ ["/api/multiplayer-test-leave-client"] = true,
168
174
  ["/api/capture-begin"] = true,
169
175
  ["/api/simulate-mouse-input"] = true,
170
176
  ["/api/simulate-keyboard-input"] = true,
@@ -246,10 +252,81 @@ local function handleGetRuntimeLogs(data)
246
252
  filter = filter,
247
253
  }, "client")
248
254
  end
255
+ local function handleMultiplayerTestState()
256
+ local argsOk, args = pcall(function()
257
+ return StudioTestService:GetTestArgs()
258
+ end)
259
+ local canLeaveOk, canLeave = pcall(function()
260
+ return StudioTestService:CanLeaveTest()
261
+ end)
262
+ local _exp = Players:GetPlayers()
263
+ -- ▼ ReadonlyArray.map ▼
264
+ local _newValue = table.create(#_exp)
265
+ local _callback = function(player)
266
+ return {
267
+ name = player.Name,
268
+ userId = player.UserId,
269
+ displayName = player.DisplayName,
270
+ }
271
+ end
272
+ for _k, _v in _exp do
273
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
274
+ end
275
+ -- ▲ ReadonlyArray.map ▲
276
+ local players = _newValue
277
+ table.sort(players, function(a, b)
278
+ return a.name < b.name
279
+ end)
280
+ return {
281
+ success = true,
282
+ peer = "client",
283
+ isRunning = RunService:IsRunning(),
284
+ isRunMode = RunService:IsRunMode(),
285
+ editModeActive = StudioTestService.EditModeActive,
286
+ testArgsOk = argsOk,
287
+ testArgs = if argsOk then args else nil,
288
+ testArgsError = if argsOk then nil else tostring(args),
289
+ players = players,
290
+ playerCount = #players,
291
+ localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil,
292
+ canLeaveOk = canLeaveOk,
293
+ canLeave = if canLeaveOk then canLeave else false,
294
+ canLeaveError = if canLeaveOk then nil else tostring(canLeave),
295
+ }
296
+ end
297
+ local function handleMultiplayerTestLeaveClient()
298
+ local canLeaveOk, canLeave = pcall(function()
299
+ return StudioTestService:CanLeaveTest()
300
+ end)
301
+ if not canLeaveOk then
302
+ return {
303
+ error = tostring(canLeave),
304
+ canLeaveOk = false,
305
+ }
306
+ end
307
+ if not canLeave then
308
+ return {
309
+ error = "This client cannot leave the current test session.",
310
+ canLeaveOk = true,
311
+ canLeave = false,
312
+ }
313
+ end
314
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
315
+ task.defer(function()
316
+ pcall(function()
317
+ return StudioTestService:LeaveTest()
318
+ end)
319
+ end)
320
+ return {
321
+ success = true,
322
+ message = "Client leave requested.",
323
+ localPlayer = localPlayer,
324
+ }
325
+ end
249
326
  local function setupClientBroker()
250
327
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
251
328
  if not rf or not rf:IsA("RemoteFunction") then
252
- warn(`[MCPFork] client: {BROKER_NAME} not found`)
329
+ warn(`[robloxstudio-mcp] client: {BROKER_NAME} not found`)
253
330
  return nil
254
331
  end
255
332
  rf.OnClientInvoke = function(payload)
@@ -265,6 +342,15 @@ local function setupClientBroker()
265
342
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
266
343
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
267
344
  end
345
+ if payload and payload.endpoint == "/api/get-scene-analysis" then
346
+ return SceneAnalysisHandlers.getSceneAnalysis(payload.data or {})
347
+ end
348
+ if payload and payload.endpoint == "/api/multiplayer-test-state" then
349
+ return handleMultiplayerTestState()
350
+ end
351
+ if payload and payload.endpoint == "/api/multiplayer-test-leave-client" then
352
+ return handleMultiplayerTestLeaveClient()
353
+ end
268
354
  if payload and payload.endpoint == "/api/capture-begin" then
269
355
  return CaptureHandlers.captureBegin()
270
356
  end
@@ -282,6 +368,7 @@ local function setupClientBroker()
282
368
  end
283
369
  end
284
370
  local proxyByPlayer = {}
371
+ local serverBrokerStarted = false
285
372
  local function pollProxy(proxyId, player, rf)
286
373
  while true do
287
374
  local _condition = player.Parent ~= nil
@@ -371,7 +458,7 @@ local function registerProxy(player, rf)
371
458
  isRunning = RunService:IsRunning(),
372
459
  })
373
460
  if not ok or not res or not res.Success then
374
- warn(`[MCPFork] proxy register failed for {player.Name}`)
461
+ warn(`[robloxstudio-mcp] proxy register failed for {player.Name}`)
375
462
  return nil
376
463
  end
377
464
  local body = HttpService:JSONDecode(res.Body)
@@ -393,12 +480,20 @@ end
393
480
  -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
394
481
  -- which doesn't depend on MCP server state or peer registration at all.)
395
482
  local function setupServerBroker()
483
+ if serverBrokerStarted then
484
+ return nil
485
+ end
396
486
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
397
487
  if not rf then
398
488
  rf = Instance.new("RemoteFunction")
399
489
  rf.Name = BROKER_NAME
400
490
  rf.Parent = ReplicatedStorage
401
491
  end
492
+ if rf:GetAttribute(BROKER_OWNER_ATTRIBUTE) ~= nil then
493
+ return nil
494
+ end
495
+ rf:SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService:GenerateGUID(false))
496
+ serverBrokerStarted = true
402
497
  local broker = rf
403
498
  Players.PlayerAdded:Connect(function(p)
404
499
  return registerProxy(p, broker)
@@ -461,6 +556,7 @@ local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandler
461
556
  local LogHandlers = TS.import(script, script.Parent, "handlers", "LogHandlers")
462
557
  local SerializationHandlers = TS.import(script, script.Parent, "handlers", "SerializationHandlers")
463
558
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
559
+ local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
464
560
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
465
561
  -- can tell our polls apart from any other plugin's polls. Not user-facing —
466
562
  -- MCP tools and the LLM operate on instanceId (the place identifier).
@@ -571,6 +667,11 @@ local routeMap = {
571
667
  ["/api/start-playtest"] = TestHandlers.startPlaytest,
572
668
  ["/api/stop-playtest"] = TestHandlers.stopPlaytest,
573
669
  ["/api/get-playtest-output"] = TestHandlers.getPlaytestOutput,
670
+ ["/api/multiplayer-test-start"] = TestHandlers.multiplayerTestStart,
671
+ ["/api/multiplayer-test-state"] = TestHandlers.multiplayerTestState,
672
+ ["/api/multiplayer-test-add-players"] = TestHandlers.multiplayerTestAddPlayers,
673
+ ["/api/multiplayer-test-leave-client"] = TestHandlers.multiplayerTestLeaveClient,
674
+ ["/api/multiplayer-test-end"] = TestHandlers.multiplayerTestEnd,
574
675
  ["/api/character-navigation"] = TestHandlers.characterNavigation,
575
676
  ["/api/export-build"] = BuildHandlers.exportBuild,
576
677
  ["/api/import-build"] = BuildHandlers.importBuild,
@@ -588,6 +689,7 @@ local routeMap = {
588
689
  ["/api/export-rbxm"] = SerializationHandlers.exportRbxm,
589
690
  ["/api/import-rbxm"] = SerializationHandlers.importRbxm,
590
691
  ["/api/get-memory-breakdown"] = MemoryHandlers.getMemoryBreakdown,
692
+ ["/api/get-scene-analysis"] = SceneAnalysisHandlers.getSceneAnalysis,
591
693
  }
592
694
  local function processRequest(request)
593
695
  local endpoint = request.endpoint
@@ -1161,9 +1263,9 @@ local function computeBridgeStamp()
1161
1263
  for i = 1, #combined do
1162
1264
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1163
1265
  end
1164
- -- "2.13.0" is replaced with the package version at package time
1266
+ -- "2.15.0" is replaced with the package version at package time
1165
1267
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1166
- return `{tostring(h)}-2.13.0`
1268
+ return `{tostring(h)}-2.15.0`
1167
1269
  end
1168
1270
  local BRIDGE_STAMP = computeBridgeStamp()
1169
1271
  local function setSource(scriptInst, source)
@@ -4975,6 +5077,255 @@ return {
4975
5077
  </Properties>
4976
5078
  </Item>
4977
5079
  <Item class="ModuleScript" referent="16">
5080
+ <Properties>
5081
+ <string name="Name">SceneAnalysisHandlers</string>
5082
+ <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
5083
+ local MODE_CONFIGS = {
5084
+ instance_composition = {
5085
+ method = "GetInstanceCompositionAsync",
5086
+ query = function(service)
5087
+ return service:GetInstanceCompositionAsync()
5088
+ end,
5089
+ },
5090
+ script_memory = {
5091
+ method = "GetScriptMemoryAsync",
5092
+ query = function(service)
5093
+ return service:GetScriptMemoryAsync()
5094
+ end,
5095
+ },
5096
+ unparented_instances = {
5097
+ method = "GetUnparentedInstancesAsync",
5098
+ query = function(service)
5099
+ return service:GetUnparentedInstancesAsync()
5100
+ end,
5101
+ },
5102
+ triangle_composition = {
5103
+ method = "GetTriangleCompositionAsync",
5104
+ query = function(service)
5105
+ return service:GetTriangleCompositionAsync()
5106
+ end,
5107
+ sortByTriangles = true,
5108
+ },
5109
+ animation_memory = {
5110
+ method = "GetAnimationMemoryAsync",
5111
+ query = function(service)
5112
+ return service:GetAnimationMemoryAsync()
5113
+ end,
5114
+ },
5115
+ audio_memory = {
5116
+ method = "GetAudioMemoryAsync",
5117
+ query = function(service)
5118
+ return service:GetAudioMemoryAsync()
5119
+ end,
5120
+ },
5121
+ }
5122
+ local ALL_MODES = { "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory" }
5123
+ local function betaDisabledError()
5124
+ return {
5125
+ error = "scene_analysis_not_enabled",
5126
+ message = "SceneAnalysisService is not enabled. Enable Scene Analysis in Studio Beta Features and restart Studio.",
5127
+ betaFeatureRequired = true,
5128
+ }
5129
+ end
5130
+ local function isBetaDisabledError(value)
5131
+ local _value = value
5132
+ local _condition = type(_value) == "string"
5133
+ if _condition then
5134
+ _condition = (string.find(value, "SceneAnalysisService is not enabled", 1, true)) ~= nil
5135
+ end
5136
+ return _condition
5137
+ end
5138
+ local function getSceneAnalysisService()
5139
+ local provider = game
5140
+ local ok, service = pcall(function()
5141
+ return provider:GetService("SceneAnalysisService")
5142
+ end)
5143
+ if not ok or not service then
5144
+ return {
5145
+ error = "scene_analysis_unavailable",
5146
+ message = `SceneAnalysisService is unavailable: {tostring(service)}`,
5147
+ }
5148
+ end
5149
+ return service
5150
+ end
5151
+ local function normalizeMode(mode)
5152
+ if mode == nil or mode == "all" then
5153
+ return "all"
5154
+ end
5155
+ local _mode = mode
5156
+ local _condition = not (type(_mode) == "string")
5157
+ if not _condition then
5158
+ _condition = MODE_CONFIGS[mode] == nil
5159
+ end
5160
+ if _condition then
5161
+ return {
5162
+ error = "invalid_mode",
5163
+ message = `mode must be one of: all, {table.concat(ALL_MODES, ", ")}`,
5164
+ }
5165
+ end
5166
+ return mode
5167
+ end
5168
+ local function normalizeTopN(topN)
5169
+ local _topN = topN
5170
+ if not (type(_topN) == "number") then
5171
+ return 10
5172
+ end
5173
+ return math.clamp(math.floor(topN), 1, 100)
5174
+ end
5175
+ local function countLeaves(node)
5176
+ local children = node.Children
5177
+ if children and #children > 0 then
5178
+ local total = 0
5179
+ for _, child in children do
5180
+ total += countLeaves(child)
5181
+ end
5182
+ return total
5183
+ end
5184
+ return 1
5185
+ end
5186
+ local function flattenLeaves(node, out)
5187
+ local children = node.Children
5188
+ if children and #children > 0 then
5189
+ for _, child in children do
5190
+ flattenLeaves(child, out)
5191
+ end
5192
+ return nil
5193
+ end
5194
+ local _out = out
5195
+ local _node = node
5196
+ table.insert(_out, _node)
5197
+ end
5198
+ local function compactEntry(node)
5199
+ local entry = {
5200
+ name = node.Name,
5201
+ }
5202
+ if node.Size ~= nil then
5203
+ entry.size = node.Size
5204
+ end
5205
+ if node.Sizes ~= nil then
5206
+ entry.sizes = node.Sizes
5207
+ end
5208
+ if node.AssetId ~= nil then
5209
+ entry.asset_id = node.AssetId
5210
+ end
5211
+ return entry
5212
+ end
5213
+ local function compactRoot(node, leafCount)
5214
+ local children = node.Children
5215
+ local root = {
5216
+ name = node.Name,
5217
+ child_count = if children then #children else 0,
5218
+ leaf_count = leafCount,
5219
+ }
5220
+ if node.Size ~= nil then
5221
+ root.size = node.Size
5222
+ end
5223
+ if node.Sizes ~= nil then
5224
+ root.sizes = node.Sizes
5225
+ end
5226
+ return root
5227
+ end
5228
+ local function metric(node, sortByTriangles)
5229
+ if sortByTriangles then
5230
+ local sizes = node.Sizes
5231
+ local triangles = if sizes then sizes.Triangles else nil
5232
+ local _condition = triangles
5233
+ if _condition == nil then
5234
+ _condition = 0
5235
+ end
5236
+ return _condition
5237
+ end
5238
+ local _condition = node.Size
5239
+ if _condition == nil then
5240
+ _condition = 0
5241
+ end
5242
+ return _condition
5243
+ end
5244
+ local function summarizeMode(mode, config, service, topN, raw)
5245
+ local started = os.clock()
5246
+ local ok, result = pcall(function()
5247
+ return config.query(service)
5248
+ end)
5249
+ local elapsedMs = math.floor((os.clock() - started) * 1000)
5250
+ if not ok then
5251
+ if isBetaDisabledError(result) then
5252
+ return betaDisabledError()
5253
+ end
5254
+ return {
5255
+ error = "scene_analysis_query_failed",
5256
+ mode = mode,
5257
+ method = config.method,
5258
+ message = tostring(result),
5259
+ }
5260
+ end
5261
+ local tree = result
5262
+ local leaves = {}
5263
+ flattenLeaves(tree, leaves)
5264
+ table.sort(leaves, function(a, b)
5265
+ return metric(a, config.sortByTriangles == true) > metric(b, config.sortByTriangles == true)
5266
+ end)
5267
+ local top = {}
5268
+ do
5269
+ local i = 0
5270
+ local _shouldIncrement = false
5271
+ while true do
5272
+ if _shouldIncrement then
5273
+ i += 1
5274
+ else
5275
+ _shouldIncrement = true
5276
+ end
5277
+ if not (i < math.min(topN, #leaves)) then
5278
+ break
5279
+ end
5280
+ local _arg0 = compactEntry(leaves[i + 1])
5281
+ table.insert(top, _arg0)
5282
+ end
5283
+ end
5284
+ local body = {
5285
+ mode = mode,
5286
+ method = config.method,
5287
+ elapsed_ms = elapsedMs,
5288
+ root = compactRoot(tree, #leaves),
5289
+ top = top,
5290
+ }
5291
+ if raw then
5292
+ body.tree = tree
5293
+ end
5294
+ return body
5295
+ end
5296
+ local function getSceneAnalysis(requestData)
5297
+ local mode = normalizeMode(requestData.mode)
5298
+ if not (type(mode) == "string") then
5299
+ return mode
5300
+ end
5301
+ local serviceOrError = getSceneAnalysisService()
5302
+ local _value = serviceOrError.IsA
5303
+ if not (_value ~= 0 and _value == _value and _value ~= "" and _value) then
5304
+ return serviceOrError
5305
+ end
5306
+ local service = serviceOrError
5307
+ local topN = normalizeTopN(requestData.topN)
5308
+ local raw = requestData.raw == true
5309
+ if mode ~= "all" then
5310
+ return summarizeMode(mode, MODE_CONFIGS[mode], service, topN, raw)
5311
+ end
5312
+ local body = {}
5313
+ for _, m in ALL_MODES do
5314
+ local result = summarizeMode(m, MODE_CONFIGS[m], service, topN, raw)
5315
+ if result.error == "scene_analysis_not_enabled" then
5316
+ return result
5317
+ end
5318
+ body[m] = result
5319
+ end
5320
+ return body
5321
+ end
5322
+ return {
5323
+ getSceneAnalysis = getSceneAnalysis,
5324
+ }
5325
+ ]]></string>
5326
+ </Properties>
5327
+ </Item>
5328
+ <Item class="ModuleScript" referent="17">
4978
5329
  <Properties>
4979
5330
  <string name="Name">ScriptHandlers</string>
4980
5331
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5670,7 +6021,7 @@ return {
5670
6021
  ]]></string>
5671
6022
  </Properties>
5672
6023
  </Item>
5673
- <Item class="ModuleScript" referent="17">
6024
+ <Item class="ModuleScript" referent="18">
5674
6025
  <Properties>
5675
6026
  <string name="Name">SerializationHandlers</string>
5676
6027
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5856,7 +6207,7 @@ return {
5856
6207
  ]]></string>
5857
6208
  </Properties>
5858
6209
  </Item>
5859
- <Item class="ModuleScript" referent="18">
6210
+ <Item class="ModuleScript" referent="19">
5860
6211
  <Properties>
5861
6212
  <string name="Name">TestHandlers</string>
5862
6213
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -5864,6 +6215,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5864
6215
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5865
6216
  local HttpService = _services.HttpService
5866
6217
  local LogService = _services.LogService
6218
+ local Players = _services.Players
5867
6219
  local RunService = _services.RunService
5868
6220
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5869
6221
  local installBridges = _EvalBridges.installBridges
@@ -5885,6 +6237,63 @@ local testResult
5885
6237
  local testError
5886
6238
  local stopListenerScript
5887
6239
  local navResultCallback
6240
+ local multiplayerState = {
6241
+ phase = "idle",
6242
+ }
6243
+ local function detectPeerRole()
6244
+ if not RunService:IsRunning() then
6245
+ return "edit"
6246
+ end
6247
+ if RunService:IsServer() then
6248
+ return "server"
6249
+ end
6250
+ return "client"
6251
+ end
6252
+ local function getPlayersSnapshot()
6253
+ local _exp = Players:GetPlayers()
6254
+ -- ▼ ReadonlyArray.map ▼
6255
+ local _newValue = table.create(#_exp)
6256
+ local _callback = function(player)
6257
+ return {
6258
+ name = player.Name,
6259
+ userId = player.UserId,
6260
+ displayName = player.DisplayName,
6261
+ }
6262
+ end
6263
+ for _k, _v in _exp do
6264
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
6265
+ end
6266
+ -- ▲ ReadonlyArray.map ▲
6267
+ local players = _newValue
6268
+ table.sort(players, function(a, b)
6269
+ return a.name < b.name
6270
+ end)
6271
+ return players
6272
+ end
6273
+ local function cloneMultiplayerState()
6274
+ return {
6275
+ phase = multiplayerState.phase,
6276
+ testId = multiplayerState.testId,
6277
+ numPlayers = multiplayerState.numPlayers,
6278
+ testArgs = multiplayerState.testArgs,
6279
+ startedAt = multiplayerState.startedAt,
6280
+ completedAt = multiplayerState.completedAt,
6281
+ ok = multiplayerState.ok,
6282
+ result = multiplayerState.result,
6283
+ error = multiplayerState.error,
6284
+ }
6285
+ end
6286
+ local function normalizeNumPlayers(value)
6287
+ local _value = value
6288
+ if not (type(_value) == "number") then
6289
+ return nil
6290
+ end
6291
+ local n = math.floor(value)
6292
+ if n ~= value or n < 1 or n > 8 then
6293
+ return nil
6294
+ end
6295
+ return n
6296
+ end
5888
6297
  local function buildCommandListenerSource()
5889
6298
  return `local LogService = game:GetService("LogService")\
5890
6299
  local PathfindingService = game:GetService("PathfindingService")\
@@ -5981,6 +6390,11 @@ local function startPlaytest(requestData)
5981
6390
  error = 'mode must be "play" or "run"',
5982
6391
  }
5983
6392
  end
6393
+ if numPlayers ~= nil then
6394
+ return {
6395
+ error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
6396
+ }
6397
+ end
5984
6398
  -- Self-heal: if testRunning is stuck true but Studio reports no active
5985
6399
  -- playtest, the previous start_playtest's task.spawn was orphaned
5986
6400
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -6045,10 +6459,6 @@ local function startPlaytest(requestData)
6045
6459
  if not bridgeInstall.installed then
6046
6460
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6047
6461
  end
6048
- if numPlayers ~= nil and mode == "run" then
6049
- local TestService = game:GetService("TestService")
6050
- TestService.NumberOfPlayers = math.clamp(numPlayers, 1, 8)
6051
- end
6052
6462
  task.spawn(function()
6053
6463
  local ok, result = pcall(function()
6054
6464
  if mode == "play" then
@@ -6071,10 +6481,9 @@ local function startPlaytest(requestData)
6071
6481
  -- clean up here, so the next manual playtest still gets them.
6072
6482
  ensureBridgesInstalled()
6073
6483
  end)
6074
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
6075
6484
  local response = {
6076
6485
  success = true,
6077
- message = msg,
6486
+ message = `Playtest started in {mode} mode.`,
6078
6487
  }
6079
6488
  -- Only mention eval bridges when they failed — when they're fine, the
6080
6489
  -- detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -6157,6 +6566,198 @@ local function getPlaytestOutput(_requestData)
6157
6566
  _object.testError = testError
6158
6567
  return _object
6159
6568
  end
6569
+ local function multiplayerTestStart(requestData)
6570
+ if RunService:IsRunning() then
6571
+ return {
6572
+ error = "multiplayer_test_start must be called on the edit DataModel. Route with target=edit.",
6573
+ }
6574
+ end
6575
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6576
+ if numPlayers == nil then
6577
+ return {
6578
+ error = "numPlayers must be an integer from 1 to 8",
6579
+ }
6580
+ end
6581
+ if multiplayerState.phase == "starting" or multiplayerState.phase == "running" then
6582
+ return {
6583
+ error = "A multiplayer Studio test is already running",
6584
+ state = cloneMultiplayerState(),
6585
+ }
6586
+ end
6587
+ local testArgs = if requestData.testArgs ~= nil then requestData.testArgs else {}
6588
+ local testId = HttpService:GenerateGUID(false)
6589
+ local bridgeInstall = installBridges()
6590
+ if not bridgeInstall.installed then
6591
+ warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6592
+ end
6593
+ multiplayerState = {
6594
+ phase = "starting",
6595
+ testId = testId,
6596
+ numPlayers = numPlayers,
6597
+ testArgs = testArgs,
6598
+ startedAt = tick(),
6599
+ }
6600
+ task.spawn(function()
6601
+ multiplayerState.phase = "running"
6602
+ local ok, result = pcall(function()
6603
+ return StudioTestService:ExecuteMultiplayerTestAsync(numPlayers, testArgs)
6604
+ end)
6605
+ multiplayerState.completedAt = tick()
6606
+ multiplayerState.ok = ok
6607
+ if ok then
6608
+ multiplayerState.phase = "completed"
6609
+ multiplayerState.result = result
6610
+ multiplayerState.error = nil
6611
+ else
6612
+ multiplayerState.phase = "failed"
6613
+ multiplayerState.result = nil
6614
+ multiplayerState.error = tostring(result)
6615
+ end
6616
+ ensureBridgesInstalled()
6617
+ end)
6618
+ local response = {
6619
+ success = true,
6620
+ message = `Multiplayer Studio test starting with {numPlayers} player(s).`,
6621
+ testId = testId,
6622
+ phase = multiplayerState.phase,
6623
+ numPlayers = numPlayers,
6624
+ testArgs = testArgs,
6625
+ }
6626
+ if not bridgeInstall.installed then
6627
+ response.evalBridgesError = bridgeInstall.error
6628
+ end
6629
+ return response
6630
+ end
6631
+ local function multiplayerTestState(_requestData)
6632
+ local peer = detectPeerRole()
6633
+ local response = {
6634
+ success = true,
6635
+ peer = peer,
6636
+ isRunning = RunService:IsRunning(),
6637
+ isRunMode = RunService:IsRunMode(),
6638
+ editModeActive = StudioTestService.EditModeActive,
6639
+ }
6640
+ if peer == "edit" then
6641
+ response.session = cloneMultiplayerState()
6642
+ return response
6643
+ end
6644
+ local argsOk, args = pcall(function()
6645
+ return StudioTestService:GetTestArgs()
6646
+ end)
6647
+ response.testArgsOk = argsOk
6648
+ response.testArgs = if argsOk then args else nil
6649
+ if not argsOk then
6650
+ response.testArgsError = tostring(args)
6651
+ end
6652
+ local players = getPlayersSnapshot()
6653
+ response.players = players
6654
+ response.playerCount = #players
6655
+ if peer == "client" then
6656
+ response.localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6657
+ local canLeaveOk, canLeave = pcall(function()
6658
+ return StudioTestService:CanLeaveTest()
6659
+ end)
6660
+ response.canLeaveOk = canLeaveOk
6661
+ response.canLeave = if canLeaveOk then canLeave else false
6662
+ if not canLeaveOk then
6663
+ response.canLeaveError = tostring(canLeave)
6664
+ end
6665
+ end
6666
+ return response
6667
+ end
6668
+ local function multiplayerTestAddPlayers(requestData)
6669
+ if not RunService:IsRunning() or not RunService:IsServer() then
6670
+ return {
6671
+ error = "multiplayer_test_add_players must be called on the running server peer. Route with target=server.",
6672
+ }
6673
+ end
6674
+ local numPlayers = normalizeNumPlayers(requestData.numPlayers)
6675
+ if numPlayers == nil then
6676
+ return {
6677
+ error = "numPlayers must be an integer from 1 to 8",
6678
+ }
6679
+ end
6680
+ local before = #Players:GetPlayers()
6681
+ local ok, result = pcall(function()
6682
+ return StudioTestService:AddPlayers(numPlayers)
6683
+ end)
6684
+ if not ok then
6685
+ return {
6686
+ error = tostring(result),
6687
+ }
6688
+ end
6689
+ local _exp = tick()
6690
+ local _condition = (requestData.timeout)
6691
+ if _condition == nil then
6692
+ _condition = 10
6693
+ end
6694
+ local deadline = _exp + _condition
6695
+ while #Players:GetPlayers() < before + numPlayers and tick() < deadline do
6696
+ task.wait(0.1)
6697
+ end
6698
+ local players = getPlayersSnapshot()
6699
+ return {
6700
+ success = true,
6701
+ message = `Requested {numPlayers} additional player(s).`,
6702
+ playerCount = #players,
6703
+ players = players,
6704
+ }
6705
+ end
6706
+ local function multiplayerTestLeaveClient(_requestData)
6707
+ if not RunService:IsRunning() or RunService:IsServer() then
6708
+ return {
6709
+ error = "multiplayer_test_leave_client must be called on a running client peer. Route with target=client-N.",
6710
+ }
6711
+ end
6712
+ local canLeaveOk, canLeave = pcall(function()
6713
+ return StudioTestService:CanLeaveTest()
6714
+ end)
6715
+ if not canLeaveOk then
6716
+ return {
6717
+ error = tostring(canLeave),
6718
+ canLeaveOk = false,
6719
+ }
6720
+ end
6721
+ if not canLeave then
6722
+ return {
6723
+ error = "This client cannot leave the current test session.",
6724
+ canLeaveOk = true,
6725
+ canLeave = false,
6726
+ }
6727
+ end
6728
+ local localPlayer = if Players.LocalPlayer then Players.LocalPlayer.Name else nil
6729
+ task.defer(function()
6730
+ pcall(function()
6731
+ return StudioTestService:LeaveTest()
6732
+ end)
6733
+ end)
6734
+ return {
6735
+ success = true,
6736
+ message = "Client leave requested.",
6737
+ localPlayer = localPlayer,
6738
+ }
6739
+ end
6740
+ local function multiplayerTestEnd(requestData)
6741
+ if not RunService:IsRunning() or not RunService:IsServer() then
6742
+ return {
6743
+ error = "multiplayer_test_end must be called on the running server peer. Route with target=server.",
6744
+ }
6745
+ end
6746
+ local value = if requestData.value ~= nil then requestData.value else "ended_by_mcp"
6747
+ local ok, result = pcall(function()
6748
+ return StudioTestService:EndTest(value)
6749
+ end)
6750
+ if not ok then
6751
+ return {
6752
+ error = tostring(result),
6753
+ }
6754
+ end
6755
+ return {
6756
+ success = true,
6757
+ message = "Multiplayer Studio test end requested.",
6758
+ value = value,
6759
+ }
6760
+ end
6160
6761
  local function characterNavigation(requestData)
6161
6762
  if not testRunning then
6162
6763
  return {
@@ -6228,13 +6829,18 @@ return {
6228
6829
  startPlaytest = startPlaytest,
6229
6830
  stopPlaytest = stopPlaytest,
6230
6831
  getPlaytestOutput = getPlaytestOutput,
6832
+ multiplayerTestStart = multiplayerTestStart,
6833
+ multiplayerTestState = multiplayerTestState,
6834
+ multiplayerTestAddPlayers = multiplayerTestAddPlayers,
6835
+ multiplayerTestLeaveClient = multiplayerTestLeaveClient,
6836
+ multiplayerTestEnd = multiplayerTestEnd,
6231
6837
  characterNavigation = characterNavigation,
6232
6838
  }
6233
6839
  ]]></string>
6234
6840
  </Properties>
6235
6841
  </Item>
6236
6842
  </Item>
6237
- <Item class="ModuleScript" referent="19">
6843
+ <Item class="ModuleScript" referent="20">
6238
6844
  <Properties>
6239
6845
  <string name="Name">LuauExec</string>
6240
6846
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6552,7 +7158,7 @@ return {
6552
7158
  ]]></string>
6553
7159
  </Properties>
6554
7160
  </Item>
6555
- <Item class="ModuleScript" referent="20">
7161
+ <Item class="ModuleScript" referent="21">
6556
7162
  <Properties>
6557
7163
  <string name="Name">Recording</string>
6558
7164
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6582,7 +7188,7 @@ return {
6582
7188
  ]]></string>
6583
7189
  </Properties>
6584
7190
  </Item>
6585
- <Item class="ModuleScript" referent="21">
7191
+ <Item class="ModuleScript" referent="22">
6586
7192
  <Properties>
6587
7193
  <string name="Name">RenderMonitor</string>
6588
7194
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6650,7 +7256,7 @@ return {
6650
7256
  ]]></string>
6651
7257
  </Properties>
6652
7258
  </Item>
6653
- <Item class="ModuleScript" referent="22">
7259
+ <Item class="ModuleScript" referent="23">
6654
7260
  <Properties>
6655
7261
  <string name="Name">RuntimeLogBuffer</string>
6656
7262
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -6831,11 +7437,11 @@ return {
6831
7437
  ]]></string>
6832
7438
  </Properties>
6833
7439
  </Item>
6834
- <Item class="ModuleScript" referent="23">
7440
+ <Item class="ModuleScript" referent="24">
6835
7441
  <Properties>
6836
7442
  <string name="Name">State</string>
6837
7443
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6838
- local CURRENT_VERSION = "2.13.0"
7444
+ local CURRENT_VERSION = "2.15.0"
6839
7445
  local MAX_CONNECTIONS = 5
6840
7446
  local BASE_PORT = 58741
6841
7447
  local activeTabIndex = 0
@@ -6927,7 +7533,7 @@ return {
6927
7533
  ]]></string>
6928
7534
  </Properties>
6929
7535
  </Item>
6930
- <Item class="ModuleScript" referent="24">
7536
+ <Item class="ModuleScript" referent="25">
6931
7537
  <Properties>
6932
7538
  <string name="Name">StopPlayMonitor</string>
6933
7539
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7072,7 +7678,7 @@ return {
7072
7678
  ]]></string>
7073
7679
  </Properties>
7074
7680
  </Item>
7075
- <Item class="ModuleScript" referent="25">
7681
+ <Item class="ModuleScript" referent="26">
7076
7682
  <Properties>
7077
7683
  <string name="Name">UI</string>
7078
7684
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -7823,7 +8429,7 @@ return {
7823
8429
  ]]></string>
7824
8430
  </Properties>
7825
8431
  </Item>
7826
- <Item class="ModuleScript" referent="26">
8432
+ <Item class="ModuleScript" referent="27">
7827
8433
  <Properties>
7828
8434
  <string name="Name">Utils</string>
7829
8435
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
@@ -8353,11 +8959,11 @@ return {
8353
8959
  </Properties>
8354
8960
  </Item>
8355
8961
  </Item>
8356
- <Item class="Folder" referent="30">
8962
+ <Item class="Folder" referent="31">
8357
8963
  <Properties>
8358
8964
  <string name="Name">include</string>
8359
8965
  </Properties>
8360
- <Item class="ModuleScript" referent="27">
8966
+ <Item class="ModuleScript" referent="28">
8361
8967
  <Properties>
8362
8968
  <string name="Name">Promise</string>
8363
8969
  <string name="Source"><![CDATA[--[[
@@ -10431,7 +11037,7 @@ return Promise
10431
11037
  ]]></string>
10432
11038
  </Properties>
10433
11039
  </Item>
10434
- <Item class="ModuleScript" referent="28">
11040
+ <Item class="ModuleScript" referent="29">
10435
11041
  <Properties>
10436
11042
  <string name="Name">RuntimeLib</string>
10437
11043
  <string name="Source"><![CDATA[local Promise = require(script.Parent.Promise)
@@ -10698,15 +11304,15 @@ return TS
10698
11304
  </Properties>
10699
11305
  </Item>
10700
11306
  </Item>
10701
- <Item class="Folder" referent="31">
11307
+ <Item class="Folder" referent="32">
10702
11308
  <Properties>
10703
11309
  <string name="Name">node_modules</string>
10704
11310
  </Properties>
10705
- <Item class="Folder" referent="32">
11311
+ <Item class="Folder" referent="33">
10706
11312
  <Properties>
10707
11313
  <string name="Name">@rbxts</string>
10708
11314
  </Properties>
10709
- <Item class="ModuleScript" referent="29">
11315
+ <Item class="ModuleScript" referent="30">
10710
11316
  <Properties>
10711
11317
  <string name="Name">services</string>
10712
11318
  <string name="Source"><![CDATA[return setmetatable({}, {