@chrrxs/robloxstudio-mcp-inspector 2.16.1 → 2.16.3

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.
@@ -432,6 +432,31 @@ end
432
432
  local proxyByPlayer = {}
433
433
  local proxyRegisterFailuresByPlayer = {}
434
434
  local serverBrokerStarted = false
435
+ local function unregisterProxy(player, entry)
436
+ local _condition = entry
437
+ if _condition == nil then
438
+ local _player = player
439
+ _condition = proxyByPlayer[_player]
440
+ end
441
+ local proxy = _condition
442
+ if not proxy then
443
+ return nil
444
+ end
445
+ local _player = player
446
+ proxyByPlayer[_player] = nil
447
+ local _player_1 = player
448
+ proxyRegisterFailuresByPlayer[_player_1] = nil
449
+ postJson("/disconnect", {
450
+ pluginSessionId = proxy.pluginSessionId,
451
+ })
452
+ end
453
+ local function disconnectAllProxies()
454
+ for player, entry in proxyByPlayer do
455
+ unregisterProxy(player, entry)
456
+ end
457
+ table.clear(proxyByPlayer)
458
+ table.clear(proxyRegisterFailuresByPlayer)
459
+ end
435
460
  local function pollProxy(proxyId, player, rf)
436
461
  while true do
437
462
  local _condition = player.Parent ~= nil
@@ -442,6 +467,10 @@ local function pollProxy(proxyId, player, rf)
442
467
  if not _condition then
443
468
  break
444
469
  end
470
+ if not RunService:IsRunning() then
471
+ unregisterProxy(player)
472
+ break
473
+ end
445
474
  local ok, res = pcall(function()
446
475
  return HttpService:RequestAsync({
447
476
  Url = `{mcpUrl}/poll?pluginSessionId={proxyId}`,
@@ -575,25 +604,10 @@ local function setupServerBroker()
575
604
  task.spawn(registerProxy, p, broker)
576
605
  end
577
606
  Players.PlayerRemoving:Connect(function(p)
578
- local _p = p
579
- local entry = proxyByPlayer[_p]
580
- if entry then
581
- local _p_1 = p
582
- proxyByPlayer[_p_1] = nil
583
- local _p_2 = p
584
- proxyRegisterFailuresByPlayer[_p_2] = nil
585
- postJson("/disconnect", {
586
- pluginSessionId = entry.pluginSessionId,
587
- })
588
- end
607
+ unregisterProxy(p)
589
608
  end)
590
609
  game:BindToClose(function()
591
- for _, entry in proxyByPlayer do
592
- postJson("/disconnect", {
593
- pluginSessionId = entry.pluginSessionId,
594
- })
595
- end
596
- table.clear(proxyByPlayer)
610
+ disconnectAllProxies()
597
611
  end)
598
612
  end
599
613
  return {
@@ -601,6 +615,7 @@ return {
601
615
  DEFAULT_MCP_URL = DEFAULT_MCP_URL,
602
616
  getServerUrl = getServerUrl,
603
617
  setServerUrl = setServerUrl,
618
+ disconnectAllProxies = disconnectAllProxies,
604
619
  forkRole = forkRole,
605
620
  setupClientBroker = setupClientBroker,
606
621
  setupServerBroker = setupServerBroker,
@@ -636,6 +651,7 @@ local SerializationHandlers = TS.import(script, script.Parent, "handlers", "Seri
636
651
  local MemoryHandlers = TS.import(script, script.Parent, "handlers", "MemoryHandlers")
637
652
  local SceneAnalysisHandlers = TS.import(script, script.Parent, "handlers", "SceneAnalysisHandlers")
638
653
  local EvalRuntimeHandlers = TS.import(script, script.Parent, "handlers", "EvalRuntimeHandlers")
654
+ local ClientBroker = TS.import(script, script.Parent, "ClientBroker")
639
655
  local ServerUrlSettings = TS.import(script, script.Parent, "ServerUrlSettings")
640
656
  local HttpDiagnostics = TS.import(script, script.Parent, "HttpDiagnostics")
641
657
  -- Per-plugin-load random GUID. Used as the /poll URL param so the server
@@ -662,21 +678,24 @@ local function computeInstanceId()
662
678
  end)
663
679
  return `anon:{fresh}`
664
680
  end
665
- local instanceId = computeInstanceId()
666
681
  local assignedRole
667
682
  local duplicateInstanceRole = false
668
683
  local hasVersionMismatch = false
669
684
  local lastVersionMismatchWarningKey
685
+ local lastReadyInstanceId
670
686
  local readyFailureLogKeys = {}
671
687
  -- Cache the published place name from MarketplaceService:GetProductInfo so
672
688
  -- /ready can carry a friendly identifier (e.g. "Natural Disasters") distinct
673
689
  -- from game.Name (the DataModel name, often "Place1" in edit). We only fetch
674
690
  -- once per plugin load; the published name doesn't change mid-session.
675
691
  local cachedPlaceName
692
+ local cachedPlaceNamePlaceId
676
693
  local function resolvePlaceName()
677
- if cachedPlaceName ~= nil then
694
+ if cachedPlaceName ~= nil and cachedPlaceNamePlaceId == game.PlaceId then
678
695
  return cachedPlaceName
679
696
  end
697
+ cachedPlaceName = nil
698
+ cachedPlaceNamePlaceId = game.PlaceId
680
699
  if game.PlaceId == 0 then
681
700
  cachedPlaceName = game.Name
682
701
  return cachedPlaceName
@@ -705,6 +724,7 @@ local function detectRole()
705
724
  end
706
725
  return "client"
707
726
  end
727
+ local initialRole = detectRole()
708
728
  local routeMap = {
709
729
  ["/api/file-tree"] = QueryHandlers.getFileTree,
710
730
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -820,28 +840,36 @@ end
820
840
  -- Without this, every poll during the brief window where the server has just
821
841
  -- restarted but hasn't seen our re-ready yet would fire a duplicate /ready.
822
842
  local lastReadyPostAt = 0
823
- -- game.Name is sometimes "Place1" at plugin-load time and only settles to
824
- -- the real DataModel name (e.g. "Game" once playtest spawns the play DM)
825
- -- after Studio finishes wiring things up. Re-fire /ready when it changes so
826
- -- get_connected_instances doesn't show a stale dataModelName forever. Set
827
- -- up once per plugin load — the connection passed in is whichever was
828
- -- active when activatePlugin was first called.
843
+ -- game.Name and game.PlaceId can both settle after plugin load. PlaceId also
844
+ -- changes when an unpublished file is published while MCP is already active.
845
+ -- Re-fire /ready so the bridge can migrate anon:<uuid> to place:<PlaceId>.
829
846
  local nameChangeConn
847
+ local placeIdChangeConn
830
848
  local sendReady
831
- local function ensureNameChangeWatcher(conn)
832
- if nameChangeConn then
833
- return nil
849
+ local function ensureIdentityWatcher(conn)
850
+ if not nameChangeConn then
851
+ local okSig, signal = pcall(function()
852
+ return game:GetPropertyChangedSignal("Name")
853
+ end)
854
+ if okSig and signal then
855
+ nameChangeConn = signal:Connect(function()
856
+ -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
857
+ sendReady(conn)
858
+ end)
859
+ end
834
860
  end
835
- local okSig, signal = pcall(function()
836
- return game:GetPropertyChangedSignal("Name")
837
- end)
838
- if not okSig or not signal then
839
- return nil
861
+ if not placeIdChangeConn then
862
+ local okSig, signal = pcall(function()
863
+ return game:GetPropertyChangedSignal("PlaceId")
864
+ end)
865
+ if okSig and signal then
866
+ placeIdChangeConn = signal:Connect(function()
867
+ cachedPlaceName = nil
868
+ cachedPlaceNamePlaceId = nil
869
+ sendReady(conn)
870
+ end)
871
+ end
840
872
  end
841
- nameChangeConn = signal:Connect(function()
842
- -- sendReady has its own 2s throttle, so rapid burst changes coalesce.
843
- sendReady(conn)
844
- end)
845
873
  end
846
874
  function sendReady(conn)
847
875
  if duplicateInstanceRole then
@@ -852,6 +880,7 @@ function sendReady(conn)
852
880
  return nil
853
881
  end
854
882
  lastReadyPostAt = now
883
+ local instanceId = computeInstanceId()
855
884
  task.spawn(function()
856
885
  local readyOk, readyResult = pcall(function()
857
886
  return HttpService:RequestAsync({
@@ -910,11 +939,20 @@ function sendReady(conn)
910
939
  if _value ~= "" and _value then
911
940
  assignedRole = readyData.assignedRole
912
941
  end
913
- local _condition = assignedRole
914
- if _condition == nil then
915
- _condition = detectRole()
942
+ local _condition = parseOk
943
+ if _condition then
944
+ local _instanceId = readyData.instanceId
945
+ _condition = type(_instanceId) == "string"
946
+ if _condition then
947
+ _condition = readyData.instanceId ~= ""
948
+ end
916
949
  end
917
- local connectedRole = _condition
950
+ lastReadyInstanceId = if _condition then readyData.instanceId else instanceId
951
+ local _condition_1 = assignedRole
952
+ if _condition_1 == nil then
953
+ _condition_1 = detectRole()
954
+ end
955
+ local connectedRole = _condition_1
918
956
  if readyFailureLogKeys[readyLogKey] ~= nil then
919
957
  readyFailureLogKeys[readyLogKey] = nil
920
958
  print(`[robloxstudio-mcp] /ready connected for {instanceId}/{connectedRole} via {conn.serverUrl}`)
@@ -1104,6 +1142,7 @@ local function pollForRequests(connIndex)
1104
1142
  end
1105
1143
  end
1106
1144
  end
1145
+ local deactivatePlugin
1107
1146
  local function activatePlugin(connIndex)
1108
1147
  local _condition = connIndex
1109
1148
  if _condition == nil then
@@ -1136,6 +1175,17 @@ local function activatePlugin(connIndex)
1136
1175
  if not conn.heartbeatConnection then
1137
1176
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1138
1177
  local now = tick()
1178
+ if initialRole == "server" and not RunService:IsRunning() then
1179
+ ClientBroker.disconnectAllProxies()
1180
+ deactivatePlugin(idx)
1181
+ return nil
1182
+ end
1183
+ local currentInstanceId = computeInstanceId()
1184
+ if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1185
+ cachedPlaceName = nil
1186
+ cachedPlaceNamePlaceId = nil
1187
+ sendReady(conn)
1188
+ end
1139
1189
  local currentInterval = if conn.consecutiveFailures > 5 then conn.currentRetryDelay else conn.pollInterval
1140
1190
  if now - conn.lastPoll > currentInterval then
1141
1191
  conn.lastPoll = now
@@ -1151,11 +1201,10 @@ local function activatePlugin(connIndex)
1151
1201
  if not RunService:IsRunning() then
1152
1202
  task.spawn(cleanupLegacyEditBridges)
1153
1203
  end
1154
- -- Watch for game.Name updates so a stale "Place1" captured at first
1155
- -- /ready gets refreshed once Studio settles on the real DM name.
1156
- ensureNameChangeWatcher(conn)
1204
+ -- Watch identity fields so stale name or anon instance ids are refreshed.
1205
+ ensureIdentityWatcher(conn)
1157
1206
  end
1158
- local function deactivatePlugin(connIndex)
1207
+ function deactivatePlugin(connIndex)
1159
1208
  local _condition = connIndex
1160
1209
  if _condition == nil then
1161
1210
  _condition = State.getActiveTabIndex()
@@ -1362,9 +1411,9 @@ local function computeBridgeStamp()
1362
1411
  for i = 1, #combined do
1363
1412
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1364
1413
  end
1365
- -- "2.16.1" is replaced with the package version at package time
1414
+ -- "2.16.3" is replaced with the package version at package time
1366
1415
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1367
- return `{tostring(h)}-2.16.1`
1416
+ return `{tostring(h)}-2.16.3`
1368
1417
  end
1369
1418
  local BRIDGE_STAMP = computeBridgeStamp()
1370
1419
  local function setSource(scriptInst, source)
@@ -7925,19 +7974,31 @@ local pluginRef
7925
7974
  local function init(p)
7926
7975
  pluginRef = p
7927
7976
  end
7928
- local function computeInstanceId()
7977
+ local function addUnique(values, value)
7978
+ local _values = values
7979
+ local _value = value
7980
+ if not (table.find(_values, _value) ~= nil) then
7981
+ local _values_1 = values
7982
+ local _value_1 = value
7983
+ table.insert(_values_1, _value_1)
7984
+ end
7985
+ end
7986
+ local function computeInstanceIds()
7987
+ local ids = {}
7929
7988
  if game.PlaceId ~= 0 then
7930
- return `place:{tostring(game.PlaceId)}`
7989
+ addUnique(ids, `place:{tostring(game.PlaceId)}`)
7931
7990
  end
7932
7991
  local existing = ServerStorage:GetAttribute("__MCPPlaceId")
7933
7992
  if type(existing) == "string" and existing ~= "" then
7934
- return `anon:{existing}`
7993
+ addUnique(ids, `anon:{existing}`)
7994
+ elseif game.PlaceId == 0 then
7995
+ local fresh = HttpService:GenerateGUID(false)
7996
+ pcall(function()
7997
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7998
+ end)
7999
+ addUnique(ids, `anon:{fresh}`)
7935
8000
  end
7936
- local fresh = HttpService:GenerateGUID(false)
7937
- pcall(function()
7938
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
7939
- end)
7940
- return `anon:{fresh}`
8001
+ return ids
7941
8002
  end
7942
8003
  local function settingKey(instanceId)
7943
8004
  return SETTING_KEY_PREFIX .. instanceId
@@ -7946,21 +8007,25 @@ local function rememberServerUrl(serverUrl)
7946
8007
  if not pluginRef or serverUrl == "" then
7947
8008
  return nil
7948
8009
  end
7949
- local key = settingKey(computeInstanceId())
7950
- pcall(function()
7951
- return pluginRef:SetSetting(key, serverUrl)
7952
- end)
8010
+ for _, instanceId in computeInstanceIds() do
8011
+ local key = settingKey(instanceId)
8012
+ pcall(function()
8013
+ return pluginRef:SetSetting(key, serverUrl)
8014
+ end)
8015
+ end
7953
8016
  end
7954
8017
  local function readServerUrl()
7955
8018
  if not pluginRef then
7956
8019
  return nil
7957
8020
  end
7958
- local key = settingKey(computeInstanceId())
7959
- local ok, value = pcall(function()
7960
- return pluginRef:GetSetting(key)
7961
- end)
7962
- if ok and type(value) == "string" and value ~= "" then
7963
- return value
8021
+ for _, instanceId in computeInstanceIds() do
8022
+ local key = settingKey(instanceId)
8023
+ local ok, value = pcall(function()
8024
+ return pluginRef:GetSetting(key)
8025
+ end)
8026
+ if ok and type(value) == "string" and value ~= "" then
8027
+ return value
8028
+ end
7964
8029
  end
7965
8030
  return nil
7966
8031
  end
@@ -7976,7 +8041,7 @@ return {
7976
8041
  <Properties>
7977
8042
  <string name="Name">State</string>
7978
8043
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
7979
- local CURRENT_VERSION = "2.16.1"
8044
+ local CURRENT_VERSION = "2.16.3"
7980
8045
  local PLUGIN_VARIANT = "inspector"
7981
8046
  local MAX_CONNECTIONS = 5
7982
8047
  local BASE_PORT = 58741
@@ -8078,6 +8143,9 @@ local TS = require(script.Parent.Parent.include.RuntimeLib)
8078
8143
  -- Cross-DM stop_playtest signaling via plugin:SetSetting, scoped by
8079
8144
  -- per-instance setting key so the same Studio process can host playtests
8080
8145
  -- for multiple places without one place's stop_playtest yanking another's.
8146
+ -- During publish-after-connect, both "anon:<uuid>" and "place:<PlaceId>"
8147
+ -- can refer to the same Studio place, so stop requests are mirrored across
8148
+ -- both keys while the monitor waits for a matching result on either key.
8081
8149
  --
8082
8150
  -- `plugin:SetSetting` / `plugin:GetSetting` is a per-plugin persistent store
8083
8151
  -- shared across every DataModel the plugin runs in (edit DMs, play-server
@@ -8125,23 +8193,48 @@ end
8125
8193
  -- agree on the place identifier (published places: placeId; unpublished:
8126
8194
  -- UUID on ServerStorage's __MCPPlaceId attribute, travels with the .rbxl
8127
8195
  -- into the play DM).
8128
- local function computeInstanceId()
8196
+ local function addUnique(values, value)
8197
+ local _values = values
8198
+ local _value = value
8199
+ if not (table.find(_values, _value) ~= nil) then
8200
+ local _values_1 = values
8201
+ local _value_1 = value
8202
+ table.insert(_values_1, _value_1)
8203
+ end
8204
+ end
8205
+ local function computeInstanceIds()
8206
+ local ids = {}
8129
8207
  if game.PlaceId ~= 0 then
8130
- return `place:{tostring(game.PlaceId)}`
8208
+ addUnique(ids, `place:{tostring(game.PlaceId)}`)
8131
8209
  end
8132
8210
  local existing = ServerStorage:GetAttribute("__MCPPlaceId")
8133
8211
  if type(existing) == "string" and existing ~= "" then
8134
- return `anon:{existing}`
8212
+ addUnique(ids, `anon:{existing}`)
8213
+ elseif game.PlaceId == 0 then
8214
+ local fresh = HttpService:GenerateGUID(false)
8215
+ pcall(function()
8216
+ return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
8217
+ end)
8218
+ addUnique(ids, `anon:{fresh}`)
8135
8219
  end
8136
- local fresh = HttpService:GenerateGUID(false)
8137
- pcall(function()
8138
- return ServerStorage:SetAttribute("__MCPPlaceId", fresh)
8139
- end)
8140
- return `anon:{fresh}`
8220
+ return ids
8141
8221
  end
8142
8222
  local function settingKey(instanceId)
8143
8223
  return SETTING_KEY_PREFIX .. instanceId
8144
8224
  end
8225
+ local function settingKeys()
8226
+ local _exp = computeInstanceIds()
8227
+ -- ▼ ReadonlyArray.map ▼
8228
+ local _newValue = table.create(#_exp)
8229
+ local _callback = function(instanceId)
8230
+ return settingKey(instanceId)
8231
+ end
8232
+ for _k, _v in _exp do
8233
+ _newValue[_k] = _callback(_v, _k - 1, _exp)
8234
+ end
8235
+ -- ▲ ReadonlyArray.map ▲
8236
+ return _newValue
8237
+ end
8145
8238
  local function readSetting(key)
8146
8239
  if not pluginRef then
8147
8240
  return nil
@@ -8227,7 +8320,7 @@ local function handleStopRequest(key, request)
8227
8320
  return nil
8228
8321
  end
8229
8322
  if endTestIssued then
8230
- writeResult(key, request, true)
8323
+ writeResult(key, request, false, "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.")
8231
8324
  return nil
8232
8325
  end
8233
8326
  if not RunService:IsRunning() or not RunService:IsServer() then
@@ -8248,18 +8341,19 @@ local function startMonitor()
8248
8341
  warn("[robloxstudio-mcp] StopPlayMonitor.startMonitor called before init; skipping")
8249
8342
  return nil
8250
8343
  end
8251
- local myKey = settingKey(computeInstanceId())
8252
8344
  task.spawn(function()
8253
8345
  while true do
8254
- local value = readSetting(myKey)
8255
- if value == true then
8256
- -- Legacy boolean requests are ambiguous and may be stale from
8257
- -- a prior crashed session. New stop requests use token payloads.
8258
- writeSetting(myKey, false)
8259
- else
8260
- local payload = decodePayload(value)
8261
- if payload then
8262
- handleStopRequest(myKey, payload)
8346
+ for _, myKey in settingKeys() do
8347
+ local value = readSetting(myKey)
8348
+ if value == true then
8349
+ -- Legacy boolean requests are ambiguous and may be stale from
8350
+ -- a prior crashed session. New stop requests use token payloads.
8351
+ writeSetting(myKey, false)
8352
+ else
8353
+ local payload = decodePayload(value)
8354
+ if payload then
8355
+ handleStopRequest(myKey, payload)
8356
+ end
8263
8357
  end
8264
8358
  end
8265
8359
  task.wait(POLL_INTERVAL_SEC)
@@ -8272,13 +8366,16 @@ local function requestStop()
8272
8366
  ok = false,
8273
8367
  }
8274
8368
  end
8275
- local myKey = settingKey(computeInstanceId())
8276
8369
  local requestId = HttpService:GenerateGUID(false)
8277
- local ok = writePayload(myKey, {
8370
+ local payload = {
8278
8371
  kind = "request",
8279
8372
  id = requestId,
8280
8373
  requestedAt = tick(),
8281
- })
8374
+ }
8375
+ local ok = false
8376
+ for _, myKey in settingKeys() do
8377
+ ok = writePayload(myKey, payload) or ok
8378
+ end
8282
8379
  return {
8283
8380
  ok = ok,
8284
8381
  requestId = if ok then requestId else nil,
@@ -8292,16 +8389,17 @@ local function waitForConsumption(requestId)
8292
8389
  error = "Plugin reference is not initialized.",
8293
8390
  }
8294
8391
  end
8295
- local myKey = settingKey(computeInstanceId())
8296
8392
  local start = tick()
8297
8393
  while tick() - start < WAIT_FOR_CONSUMPTION_TIMEOUT_SEC do
8298
- local payload = decodePayload(readSetting(myKey))
8299
- if payload and payload.kind == "result" and payload.id == requestId then
8300
- return {
8301
- ok = payload.ok == true,
8302
- consumed = true,
8303
- error = payload.error,
8304
- }
8394
+ for _, myKey in settingKeys() do
8395
+ local payload = decodePayload(readSetting(myKey))
8396
+ if payload and payload.kind == "result" and payload.id == requestId then
8397
+ return {
8398
+ ok = payload.ok == true,
8399
+ consumed = true,
8400
+ error = payload.error,
8401
+ }
8402
+ end
8305
8403
  end
8306
8404
  task.wait(WAIT_POLL_SEC)
8307
8405
  end
@@ -8315,14 +8413,15 @@ local function clearPending(requestId)
8315
8413
  if not pluginRef then
8316
8414
  return nil
8317
8415
  end
8318
- local myKey = settingKey(computeInstanceId())
8319
- if requestId ~= nil then
8320
- local payload = decodePayload(readSetting(myKey))
8321
- if payload and payload.id ~= requestId then
8322
- return nil
8416
+ for _, myKey in settingKeys() do
8417
+ if requestId ~= nil then
8418
+ local payload = decodePayload(readSetting(myKey))
8419
+ if payload and payload.id ~= requestId then
8420
+ continue
8421
+ end
8323
8422
  end
8423
+ writeSetting(myKey, false)
8324
8424
  end
8325
- writeSetting(myKey, false)
8326
8425
  end
8327
8426
  return {
8328
8427
  init = init,