@chrrxs/robloxstudio-mcp-inspector 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.
@@ -105,6 +105,7 @@ local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandl
105
105
  local CaptureHandlers = TS.import(script, script.Parent, "handlers", "CaptureHandlers")
106
106
  local InputHandlers = TS.import(script, script.Parent, "handlers", "InputHandlers")
107
107
  local LuauExec = TS.import(script, script.Parent, "LuauExec")
108
+ local StudioTestService = game:GetService("StudioTestService")
108
109
  -- Mirror of Communication.computeInstanceId() — duplicated here because the
109
110
  -- client broker runs in the play-server DM where it can't easily import from
110
111
  -- the edit-side module, and the place identifier must match what the edit-DM
@@ -158,6 +159,7 @@ end
158
159
  -- signaling, which works regardless of MCP server state.)
159
160
  local MCP_URL = "http://localhost:58741"
160
161
  local BROKER_NAME = "__MCPClientBroker"
162
+ local BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner"
161
163
  -- Endpoints the server-peer broker is allowed to forward to the client peer.
162
164
  -- Each requires the client peer's plugin VM (because the buffer / require
163
165
  -- cache / etc. lives there) so the server peer alone can't satisfy them.
@@ -165,6 +167,8 @@ local CLIENT_BROKER_ALLOWED_ENDPOINTS = {
165
167
  ["/api/execute-luau"] = true,
166
168
  ["/api/get-runtime-logs"] = true,
167
169
  ["/api/get-memory-breakdown"] = true,
170
+ ["/api/multiplayer-test-state"] = true,
171
+ ["/api/multiplayer-test-leave-client"] = true,
168
172
  ["/api/capture-begin"] = true,
169
173
  ["/api/simulate-mouse-input"] = true,
170
174
  ["/api/simulate-keyboard-input"] = true,
@@ -246,6 +250,77 @@ local function handleGetRuntimeLogs(data)
246
250
  filter = filter,
247
251
  }, "client")
248
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
249
324
  local function setupClientBroker()
250
325
  local rf = ReplicatedStorage:WaitForChild(BROKER_NAME, 10)
251
326
  if not rf or not rf:IsA("RemoteFunction") then
@@ -265,6 +340,12 @@ local function setupClientBroker()
265
340
  if payload and payload.endpoint == "/api/get-memory-breakdown" then
266
341
  return MemoryHandlers.getMemoryBreakdown(payload.data or {})
267
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
268
349
  if payload and payload.endpoint == "/api/capture-begin" then
269
350
  return CaptureHandlers.captureBegin()
270
351
  end
@@ -282,6 +363,7 @@ local function setupClientBroker()
282
363
  end
283
364
  end
284
365
  local proxyByPlayer = {}
366
+ local serverBrokerStarted = false
285
367
  local function pollProxy(proxyId, player, rf)
286
368
  while true do
287
369
  local _condition = player.Parent ~= nil
@@ -393,12 +475,20 @@ end
393
475
  -- plugin:SetSetting flag consumed by StopPlayMonitor in the play-server DM,
394
476
  -- which doesn't depend on MCP server state or peer registration at all.)
395
477
  local function setupServerBroker()
478
+ if serverBrokerStarted then
479
+ return nil
480
+ end
396
481
  local rf = ReplicatedStorage:FindFirstChild(BROKER_NAME)
397
482
  if not rf then
398
483
  rf = Instance.new("RemoteFunction")
399
484
  rf.Name = BROKER_NAME
400
485
  rf.Parent = ReplicatedStorage
401
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
402
492
  local broker = rf
403
493
  Players.PlayerAdded:Connect(function(p)
404
494
  return registerProxy(p, broker)
@@ -571,6 +661,11 @@ local routeMap = {
571
661
  ["/api/start-playtest"] = TestHandlers.startPlaytest,
572
662
  ["/api/stop-playtest"] = TestHandlers.stopPlaytest,
573
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,
574
669
  ["/api/character-navigation"] = TestHandlers.characterNavigation,
575
670
  ["/api/export-build"] = BuildHandlers.exportBuild,
576
671
  ["/api/import-build"] = BuildHandlers.importBuild,
@@ -1161,9 +1256,9 @@ local function computeBridgeStamp()
1161
1256
  for i = 1, #combined do
1162
1257
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1163
1258
  end
1164
- -- "2.13.0" is replaced with the package version at package time
1259
+ -- "2.14.0" is replaced with the package version at package time
1165
1260
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1166
- return `{tostring(h)}-2.13.0`
1261
+ return `{tostring(h)}-2.14.0`
1167
1262
  end
1168
1263
  local BRIDGE_STAMP = computeBridgeStamp()
1169
1264
  local function setSource(scriptInst, source)
@@ -5864,6 +5959,7 @@ local TS = require(script.Parent.Parent.Parent.include.RuntimeLib)
5864
5959
  local _services = TS.import(script, script.Parent.Parent.Parent, "node_modules", "@rbxts", "services")
5865
5960
  local HttpService = _services.HttpService
5866
5961
  local LogService = _services.LogService
5962
+ local Players = _services.Players
5867
5963
  local RunService = _services.RunService
5868
5964
  local _EvalBridges = TS.import(script, script.Parent.Parent, "EvalBridges")
5869
5965
  local installBridges = _EvalBridges.installBridges
@@ -5885,6 +5981,63 @@ local testResult
5885
5981
  local testError
5886
5982
  local stopListenerScript
5887
5983
  local navResultCallback
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
5888
6041
  local function buildCommandListenerSource()
5889
6042
  return `local LogService = game:GetService("LogService")\
5890
6043
  local PathfindingService = game:GetService("PathfindingService")\
@@ -5981,6 +6134,11 @@ local function startPlaytest(requestData)
5981
6134
  error = 'mode must be "play" or "run"',
5982
6135
  }
5983
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
5984
6142
  -- Self-heal: if testRunning is stuck true but Studio reports no active
5985
6143
  -- playtest, the previous start_playtest's task.spawn was orphaned
5986
6144
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -6045,10 +6203,6 @@ local function startPlaytest(requestData)
6045
6203
  if not bridgeInstall.installed then
6046
6204
  warn(`[MCP] Eval bridge install failed: {bridgeInstall.error}`)
6047
6205
  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
6206
  task.spawn(function()
6053
6207
  local ok, result = pcall(function()
6054
6208
  if mode == "play" then
@@ -6071,10 +6225,9 @@ local function startPlaytest(requestData)
6071
6225
  -- clean up here, so the next manual playtest still gets them.
6072
6226
  ensureBridgesInstalled()
6073
6227
  end)
6074
- local msg = if numPlayers ~= nil then `Playtest started in {mode} mode with {numPlayers} player(s).` else `Playtest started in {mode} mode.`
6075
6228
  local response = {
6076
6229
  success = true,
6077
- message = msg,
6230
+ message = `Playtest started in {mode} mode.`,
6078
6231
  }
6079
6232
  -- Only mention eval bridges when they failed — when they're fine, the
6080
6233
  -- detail is noise. eval_server_runtime / eval_client_runtime will surface
@@ -6157,6 +6310,198 @@ local function getPlaytestOutput(_requestData)
6157
6310
  _object.testError = testError
6158
6311
  return _object
6159
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
6160
6505
  local function characterNavigation(requestData)
6161
6506
  if not testRunning then
6162
6507
  return {
@@ -6228,6 +6573,11 @@ return {
6228
6573
  startPlaytest = startPlaytest,
6229
6574
  stopPlaytest = stopPlaytest,
6230
6575
  getPlaytestOutput = getPlaytestOutput,
6576
+ multiplayerTestStart = multiplayerTestStart,
6577
+ multiplayerTestState = multiplayerTestState,
6578
+ multiplayerTestAddPlayers = multiplayerTestAddPlayers,
6579
+ multiplayerTestLeaveClient = multiplayerTestLeaveClient,
6580
+ multiplayerTestEnd = multiplayerTestEnd,
6231
6581
  characterNavigation = characterNavigation,
6232
6582
  }
6233
6583
  ]]></string>
@@ -6835,7 +7185,7 @@ return {
6835
7185
  <Properties>
6836
7186
  <string name="Name">State</string>
6837
7187
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
6838
- local CURRENT_VERSION = "2.13.0"
7188
+ local CURRENT_VERSION = "2.14.0"
6839
7189
  local MAX_CONNECTIONS = 5
6840
7190
  local BASE_PORT = 58741
6841
7191
  local activeTabIndex = 0
@@ -5,6 +5,14 @@ import CaptureHandlers from "./handlers/CaptureHandlers";
5
5
  import InputHandlers from "./handlers/InputHandlers";
6
6
  import LuauExec from "./LuauExec";
7
7
 
8
+ interface StudioTestServiceMultiplayer extends StudioTestService {
9
+ CanLeaveTest(): boolean;
10
+ LeaveTest(): void;
11
+ EditModeActive: boolean;
12
+ }
13
+
14
+ const StudioTestService = game.GetService("StudioTestService") as StudioTestServiceMultiplayer;
15
+
8
16
  // Mirror of Communication.computeInstanceId() — duplicated here because the
9
17
  // client broker runs in the play-server DM where it can't easily import from
10
18
  // the edit-side module, and the place identifier must match what the edit-DM
@@ -55,6 +63,7 @@ function resolvePlaceName(): string {
55
63
 
56
64
  const MCP_URL = "http://localhost:58741";
57
65
  const BROKER_NAME = "__MCPClientBroker";
66
+ const BROKER_OWNER_ATTRIBUTE = "__MCPBrokerOwner";
58
67
 
59
68
  interface ProxyEntry {
60
69
  pluginSessionId: string;
@@ -78,6 +87,8 @@ const CLIENT_BROKER_ALLOWED_ENDPOINTS = new Set<string>([
78
87
  "/api/execute-luau",
79
88
  "/api/get-runtime-logs",
80
89
  "/api/get-memory-breakdown",
90
+ "/api/multiplayer-test-state",
91
+ "/api/multiplayer-test-leave-client",
81
92
  // Screenshot capture must run in the client peer (CaptureService captures
82
93
  // the play viewport there); the edit DM reads the temp id back separately.
83
94
  "/api/capture-begin",
@@ -164,6 +175,52 @@ function handleGetRuntimeLogs(data: Record<string, unknown> | undefined): unknow
164
175
  return RuntimeLogBuffer.query({ since, tail, filter }, "client");
165
176
  }
166
177
 
178
+ function handleMultiplayerTestState(): unknown {
179
+ const [argsOk, args] = pcall(() => StudioTestService.GetTestArgs());
180
+ const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
181
+ const players = Players.GetPlayers().map((player) => ({
182
+ name: player.Name,
183
+ userId: player.UserId,
184
+ displayName: player.DisplayName,
185
+ }));
186
+ players.sort((a, b) => a.name < b.name);
187
+ return {
188
+ success: true,
189
+ peer: "client",
190
+ isRunning: RunService.IsRunning(),
191
+ isRunMode: RunService.IsRunMode(),
192
+ editModeActive: StudioTestService.EditModeActive,
193
+ testArgsOk: argsOk,
194
+ testArgs: argsOk ? args : undefined,
195
+ testArgsError: argsOk ? undefined : tostring(args),
196
+ players,
197
+ playerCount: players.size(),
198
+ localPlayer: Players.LocalPlayer ? Players.LocalPlayer.Name : undefined,
199
+ canLeaveOk,
200
+ canLeave: canLeaveOk ? canLeave : false,
201
+ canLeaveError: canLeaveOk ? undefined : tostring(canLeave),
202
+ };
203
+ }
204
+
205
+ function handleMultiplayerTestLeaveClient(): unknown {
206
+ const [canLeaveOk, canLeave] = pcall(() => StudioTestService.CanLeaveTest());
207
+ if (!canLeaveOk) {
208
+ return { error: tostring(canLeave), canLeaveOk: false };
209
+ }
210
+ if (!canLeave) {
211
+ return { error: "This client cannot leave the current test session.", canLeaveOk: true, canLeave: false };
212
+ }
213
+ const localPlayer = Players.LocalPlayer ? Players.LocalPlayer.Name : undefined;
214
+ task.defer(() => {
215
+ pcall(() => StudioTestService.LeaveTest());
216
+ });
217
+ return {
218
+ success: true,
219
+ message: "Client leave requested.",
220
+ localPlayer,
221
+ };
222
+ }
223
+
167
224
  function setupClientBroker() {
168
225
  const rf = ReplicatedStorage.WaitForChild(BROKER_NAME, 10);
169
226
  if (!rf || !rf.IsA("RemoteFunction")) {
@@ -183,6 +240,12 @@ function setupClientBroker() {
183
240
  if (payload && payload.endpoint === "/api/get-memory-breakdown") {
184
241
  return MemoryHandlers.getMemoryBreakdown(payload.data ?? {});
185
242
  }
243
+ if (payload && payload.endpoint === "/api/multiplayer-test-state") {
244
+ return handleMultiplayerTestState();
245
+ }
246
+ if (payload && payload.endpoint === "/api/multiplayer-test-leave-client") {
247
+ return handleMultiplayerTestLeaveClient();
248
+ }
186
249
  if (payload && payload.endpoint === "/api/capture-begin") {
187
250
  return CaptureHandlers.captureBegin();
188
251
  }
@@ -201,6 +264,7 @@ function setupClientBroker() {
201
264
  }
202
265
 
203
266
  const proxyByPlayer = new Map<Player, ProxyEntry>();
267
+ let serverBrokerStarted = false;
204
268
 
205
269
  function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
206
270
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
@@ -277,12 +341,18 @@ function registerProxy(player: Player, rf: RemoteFunction) {
277
341
  // which doesn't depend on MCP server state or peer registration at all.)
278
342
 
279
343
  function setupServerBroker() {
344
+ if (serverBrokerStarted) return;
280
345
  let rf = ReplicatedStorage.FindFirstChild(BROKER_NAME) as RemoteFunction | undefined;
281
346
  if (!rf) {
282
347
  rf = new Instance("RemoteFunction");
283
348
  rf.Name = BROKER_NAME;
284
349
  rf.Parent = ReplicatedStorage;
285
350
  }
351
+ if (rf.GetAttribute(BROKER_OWNER_ATTRIBUTE) !== undefined) {
352
+ return;
353
+ }
354
+ rf.SetAttribute(BROKER_OWNER_ATTRIBUTE, HttpService.GenerateGUID(false));
355
+ serverBrokerStarted = true;
286
356
  const broker = rf;
287
357
  Players.PlayerAdded.Connect((p) => registerProxy(p, broker));
288
358
  for (const p of Players.GetPlayers()) {
@@ -133,6 +133,11 @@ const routeMap: Record<string, Handler> = {
133
133
  "/api/start-playtest": TestHandlers.startPlaytest,
134
134
  "/api/stop-playtest": TestHandlers.stopPlaytest,
135
135
  "/api/get-playtest-output": TestHandlers.getPlaytestOutput,
136
+ "/api/multiplayer-test-start": TestHandlers.multiplayerTestStart,
137
+ "/api/multiplayer-test-state": TestHandlers.multiplayerTestState,
138
+ "/api/multiplayer-test-add-players": TestHandlers.multiplayerTestAddPlayers,
139
+ "/api/multiplayer-test-leave-client": TestHandlers.multiplayerTestLeaveClient,
140
+ "/api/multiplayer-test-end": TestHandlers.multiplayerTestEnd,
136
141
  "/api/character-navigation": TestHandlers.characterNavigation,
137
142
 
138
143
  "/api/export-build": BuildHandlers.exportBuild,