@chrrxs/robloxstudio-mcp 2.16.2 → 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.
package/dist/index.js CHANGED
@@ -3221,6 +3221,10 @@ var init_tools = __esm({
3221
3221
  _clientRolesForInstance(instanceId) {
3222
3222
  return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
3223
3223
  }
3224
+ _runtimeTargetsForEquivalentInstances(instanceId) {
3225
+ const instanceIds = new Set(this.bridge.getEquivalentInstanceIds(instanceId));
3226
+ return this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId) && (i.role === "server" || /^client-\d+$/.test(i.role))).map((i) => ({ instanceId: i.instanceId, role: i.role }));
3227
+ }
3224
3228
  _resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
3225
3229
  const selectedTarget = target ?? "edit";
3226
3230
  if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients" || selectedTarget === "edit-proxy") {
@@ -4487,10 +4491,27 @@ ${code}`
4487
4491
  }
4488
4492
  async stopPlaytest(instance_id) {
4489
4493
  const { instanceId } = this._resolveSingleTarget("edit", instance_id);
4490
- const response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4494
+ let response;
4495
+ let stopRequestError;
4496
+ try {
4497
+ response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4498
+ } catch (error) {
4499
+ stopRequestError = errorMessage(error);
4500
+ response = {
4501
+ success: false,
4502
+ error: "Edit stop request failed.",
4503
+ detail: stopRequestError
4504
+ };
4505
+ }
4491
4506
  let wait;
4492
4507
  if (response?.success === true) {
4493
4508
  wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15, true);
4509
+ } else if (this._runtimeTargetsForEquivalentInstances(instanceId).length > 0) {
4510
+ wait = {
4511
+ ok: false,
4512
+ roles: this._rolesForEquivalentInstances(instanceId),
4513
+ timedOut: false
4514
+ };
4494
4515
  }
4495
4516
  const body = wait ? {
4496
4517
  ...response,
@@ -4498,6 +4519,22 @@ ${code}`
4498
4519
  timedOut: wait.timedOut,
4499
4520
  roles: wait.roles
4500
4521
  } : response;
4522
+ if (wait && !wait.ok) {
4523
+ const runtimeRoles = wait.roles.filter((role) => role === "server" || /^client-\d+$/.test(role));
4524
+ const failureBody = {
4525
+ ...body,
4526
+ success: false,
4527
+ error: "Playtest teardown did not complete.",
4528
+ message: response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
4529
+ stopSignalAccepted: response?.success === true,
4530
+ stopRequestError,
4531
+ runtimeRoles,
4532
+ possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
4533
+ };
4534
+ return {
4535
+ content: [{ type: "text", text: JSON.stringify(failureBody) }]
4536
+ };
4537
+ }
4501
4538
  return {
4502
4539
  content: [{ type: "text", text: JSON.stringify(body) }]
4503
4540
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
4
4
  "description": "MCP server for testing, debugging, and controlling Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -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
@@ -708,6 +724,7 @@ local function detectRole()
708
724
  end
709
725
  return "client"
710
726
  end
727
+ local initialRole = detectRole()
711
728
  local routeMap = {
712
729
  ["/api/file-tree"] = QueryHandlers.getFileTree,
713
730
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -1125,6 +1142,7 @@ local function pollForRequests(connIndex)
1125
1142
  end
1126
1143
  end
1127
1144
  end
1145
+ local deactivatePlugin
1128
1146
  local function activatePlugin(connIndex)
1129
1147
  local _condition = connIndex
1130
1148
  if _condition == nil then
@@ -1157,6 +1175,11 @@ local function activatePlugin(connIndex)
1157
1175
  if not conn.heartbeatConnection then
1158
1176
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1159
1177
  local now = tick()
1178
+ if initialRole == "server" and not RunService:IsRunning() then
1179
+ ClientBroker.disconnectAllProxies()
1180
+ deactivatePlugin(idx)
1181
+ return nil
1182
+ end
1160
1183
  local currentInstanceId = computeInstanceId()
1161
1184
  if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1162
1185
  cachedPlaceName = nil
@@ -1181,7 +1204,7 @@ local function activatePlugin(connIndex)
1181
1204
  -- Watch identity fields so stale name or anon instance ids are refreshed.
1182
1205
  ensureIdentityWatcher(conn)
1183
1206
  end
1184
- local function deactivatePlugin(connIndex)
1207
+ function deactivatePlugin(connIndex)
1185
1208
  local _condition = connIndex
1186
1209
  if _condition == nil then
1187
1210
  _condition = State.getActiveTabIndex()
@@ -1388,9 +1411,9 @@ local function computeBridgeStamp()
1388
1411
  for i = 1, #combined do
1389
1412
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1390
1413
  end
1391
- -- "2.16.2" is replaced with the package version at package time
1414
+ -- "2.16.3" is replaced with the package version at package time
1392
1415
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1393
- return `{tostring(h)}-2.16.2`
1416
+ return `{tostring(h)}-2.16.3`
1394
1417
  end
1395
1418
  local BRIDGE_STAMP = computeBridgeStamp()
1396
1419
  local function setSource(scriptInst, source)
@@ -8018,7 +8041,7 @@ return {
8018
8041
  <Properties>
8019
8042
  <string name="Name">State</string>
8020
8043
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8021
- local CURRENT_VERSION = "2.16.2"
8044
+ local CURRENT_VERSION = "2.16.3"
8022
8045
  local PLUGIN_VARIANT = "inspector"
8023
8046
  local MAX_CONNECTIONS = 5
8024
8047
  local BASE_PORT = 58741
@@ -8297,7 +8320,7 @@ local function handleStopRequest(key, request)
8297
8320
  return nil
8298
8321
  end
8299
8322
  if endTestIssued then
8300
- 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.")
8301
8324
  return nil
8302
8325
  end
8303
8326
  if not RunService:IsRunning() or not RunService:IsServer() then
@@ -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
@@ -708,6 +724,7 @@ local function detectRole()
708
724
  end
709
725
  return "client"
710
726
  end
727
+ local initialRole = detectRole()
711
728
  local routeMap = {
712
729
  ["/api/file-tree"] = QueryHandlers.getFileTree,
713
730
  ["/api/search-files"] = QueryHandlers.searchFiles,
@@ -1125,6 +1142,7 @@ local function pollForRequests(connIndex)
1125
1142
  end
1126
1143
  end
1127
1144
  end
1145
+ local deactivatePlugin
1128
1146
  local function activatePlugin(connIndex)
1129
1147
  local _condition = connIndex
1130
1148
  if _condition == nil then
@@ -1157,6 +1175,11 @@ local function activatePlugin(connIndex)
1157
1175
  if not conn.heartbeatConnection then
1158
1176
  conn.heartbeatConnection = RunService.Heartbeat:Connect(function()
1159
1177
  local now = tick()
1178
+ if initialRole == "server" and not RunService:IsRunning() then
1179
+ ClientBroker.disconnectAllProxies()
1180
+ deactivatePlugin(idx)
1181
+ return nil
1182
+ end
1160
1183
  local currentInstanceId = computeInstanceId()
1161
1184
  if lastReadyInstanceId ~= nil and currentInstanceId ~= lastReadyInstanceId then
1162
1185
  cachedPlaceName = nil
@@ -1181,7 +1204,7 @@ local function activatePlugin(connIndex)
1181
1204
  -- Watch identity fields so stale name or anon instance ids are refreshed.
1182
1205
  ensureIdentityWatcher(conn)
1183
1206
  end
1184
- local function deactivatePlugin(connIndex)
1207
+ function deactivatePlugin(connIndex)
1185
1208
  local _condition = connIndex
1186
1209
  if _condition == nil then
1187
1210
  _condition = State.getActiveTabIndex()
@@ -1388,9 +1411,9 @@ local function computeBridgeStamp()
1388
1411
  for i = 1, #combined do
1389
1412
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1390
1413
  end
1391
- -- "2.16.2" is replaced with the package version at package time
1414
+ -- "2.16.3" is replaced with the package version at package time
1392
1415
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1393
- return `{tostring(h)}-2.16.2`
1416
+ return `{tostring(h)}-2.16.3`
1394
1417
  end
1395
1418
  local BRIDGE_STAMP = computeBridgeStamp()
1396
1419
  local function setSource(scriptInst, source)
@@ -8018,7 +8041,7 @@ return {
8018
8041
  <Properties>
8019
8042
  <string name="Name">State</string>
8020
8043
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
8021
- local CURRENT_VERSION = "2.16.2"
8044
+ local CURRENT_VERSION = "2.16.3"
8022
8045
  local PLUGIN_VARIANT = "main"
8023
8046
  local MAX_CONNECTIONS = 5
8024
8047
  local BASE_PORT = 58741
@@ -8297,7 +8320,7 @@ local function handleStopRequest(key, request)
8297
8320
  return nil
8298
8321
  end
8299
8322
  if endTestIssued then
8300
- 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.")
8301
8324
  return nil
8302
8325
  end
8303
8326
  if not RunService:IsRunning() or not RunService:IsServer() then
@@ -296,8 +296,28 @@ const proxyByPlayer = new Map<Player, ProxyEntry>();
296
296
  const proxyRegisterFailuresByPlayer = new Set<Player>();
297
297
  let serverBrokerStarted = false;
298
298
 
299
+ function unregisterProxy(player: Player, entry?: ProxyEntry): void {
300
+ const proxy = entry ?? proxyByPlayer.get(player);
301
+ if (!proxy) return;
302
+ proxyByPlayer.delete(player);
303
+ proxyRegisterFailuresByPlayer.delete(player);
304
+ postJson("/disconnect", { pluginSessionId: proxy.pluginSessionId });
305
+ }
306
+
307
+ function disconnectAllProxies(): void {
308
+ for (const [player, entry] of proxyByPlayer) {
309
+ unregisterProxy(player, entry);
310
+ }
311
+ proxyByPlayer.clear();
312
+ proxyRegisterFailuresByPlayer.clear();
313
+ }
314
+
299
315
  function pollProxy(proxyId: string, player: Player, rf: RemoteFunction) {
300
316
  while (player.Parent !== undefined && proxyByPlayer.has(player)) {
317
+ if (!RunService.IsRunning()) {
318
+ unregisterProxy(player);
319
+ break;
320
+ }
301
321
  const [ok, res] = pcall(() =>
302
322
  HttpService.RequestAsync({
303
323
  Url: `${mcpUrl}/poll?pluginSessionId=${proxyId}`,
@@ -396,18 +416,10 @@ function setupServerBroker() {
396
416
  task.spawn(registerProxy, p, broker);
397
417
  }
398
418
  Players.PlayerRemoving.Connect((p) => {
399
- const entry = proxyByPlayer.get(p);
400
- if (entry) {
401
- proxyByPlayer.delete(p);
402
- proxyRegisterFailuresByPlayer.delete(p);
403
- postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
404
- }
419
+ unregisterProxy(p);
405
420
  });
406
421
  game.BindToClose(() => {
407
- for (const [, entry] of proxyByPlayer) {
408
- postJson("/disconnect", { pluginSessionId: entry.pluginSessionId });
409
- }
410
- proxyByPlayer.clear();
422
+ disconnectAllProxies();
411
423
  });
412
424
  }
413
425
 
@@ -416,6 +428,7 @@ export = {
416
428
  DEFAULT_MCP_URL,
417
429
  getServerUrl,
418
430
  setServerUrl,
431
+ disconnectAllProxies,
419
432
  forkRole,
420
433
  setupClientBroker,
421
434
  setupServerBroker,
@@ -18,6 +18,7 @@ import SerializationHandlers from "./handlers/SerializationHandlers";
18
18
  import MemoryHandlers from "./handlers/MemoryHandlers";
19
19
  import SceneAnalysisHandlers from "./handlers/SceneAnalysisHandlers";
20
20
  import EvalRuntimeHandlers from "./handlers/EvalRuntimeHandlers";
21
+ import ClientBroker from "./ClientBroker";
21
22
  import ServerUrlSettings from "./ServerUrlSettings";
22
23
  import HttpDiagnostics from "./HttpDiagnostics";
23
24
  import { Connection, RequestPayload, PollResponse, ReadyResponse } from "../types";
@@ -89,6 +90,8 @@ function detectRole(): string {
89
90
  return "client";
90
91
  }
91
92
 
93
+ const initialRole = detectRole();
94
+
92
95
  type Handler = (data: Record<string, unknown>) => unknown;
93
96
 
94
97
  const routeMap: Record<string, Handler> = {
@@ -510,6 +513,11 @@ function activatePlugin(connIndex?: number) {
510
513
  if (!conn.heartbeatConnection) {
511
514
  conn.heartbeatConnection = RunService.Heartbeat.Connect(() => {
512
515
  const now = tick();
516
+ if (initialRole === "server" && !RunService.IsRunning()) {
517
+ ClientBroker.disconnectAllProxies();
518
+ deactivatePlugin(idx);
519
+ return;
520
+ }
513
521
  const currentInstanceId = computeInstanceId();
514
522
  if (lastReadyInstanceId !== undefined && currentInstanceId !== lastReadyInstanceId) {
515
523
  cachedPlaceName = undefined;
@@ -162,7 +162,12 @@ function handleStopRequest(key: string, request: StopPayload): void {
162
162
  }
163
163
 
164
164
  if (endTestIssued) {
165
- writeResult(key, request, true);
165
+ writeResult(
166
+ key,
167
+ request,
168
+ false,
169
+ "StudioTestService:EndTest was already issued for this play session, but the runtime DataModel is still alive.",
170
+ );
166
171
  return;
167
172
  }
168
173