@chrrxs/robloxstudio-mcp 3.1.2 → 3.1.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
@@ -2483,6 +2483,12 @@ var BridgeService = class {
2483
2483
  multiplayerGroups: this.getMultiplayerGroups()
2484
2484
  };
2485
2485
  }
2486
+ /** Local topology is authoritative; remote adapters must refresh before fanout. */
2487
+ refreshTopologyForRouting(signal) {
2488
+ if (signal?.aborted)
2489
+ throw new Error("Request aborted before topology resolution");
2490
+ return void 0;
2491
+ }
2486
2492
  resolveConnectedInstanceId(instanceId) {
2487
2493
  const exact = this.getInstances().find((instance) => instance.id === instanceId);
2488
2494
  const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
@@ -11908,6 +11914,9 @@ var RobloxStudioTools = class {
11908
11914
  if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
11909
11915
  throw new Error("get_runtime_logs tail must be a non-negative integer.");
11910
11916
  }
11917
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
11918
+ if (refresh)
11919
+ await refresh;
11911
11920
  const instances = this.bridge.getInstances();
11912
11921
  const groups = this.bridge.getMultiplayerGroups();
11913
11922
  let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
@@ -12628,6 +12637,8 @@ var RobloxStudioTools = class {
12628
12637
  });
12629
12638
  }
12630
12639
  return this._textResult({
12640
+ // Keep lifecycle diagnostics on failures; only successful responses are brief.
12641
+ ...body2,
12631
12642
  success: false,
12632
12643
  action,
12633
12644
  error: body2.error ?? "start_failed",
@@ -12644,6 +12655,7 @@ var RobloxStudioTools = class {
12644
12655
  });
12645
12656
  }
12646
12657
  return this._textResult({
12658
+ ...body,
12647
12659
  success: false,
12648
12660
  action,
12649
12661
  error: body.error ?? "stop_failed",
@@ -12732,7 +12744,7 @@ var RobloxStudioTools = class {
12732
12744
  wait = {
12733
12745
  ok: false,
12734
12746
  roles: this._rolesForScope(instanceId),
12735
- timedOut: false
12747
+ timedOut: response.timedOut === true
12736
12748
  };
12737
12749
  }
12738
12750
  const body = wait ? {
@@ -12747,8 +12759,8 @@ var RobloxStudioTools = class {
12747
12759
  ...body,
12748
12760
  success: false,
12749
12761
  error: "Playtest teardown did not complete.",
12750
- 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.",
12751
- stopSignalAccepted: response?.success === true,
12762
+ message: response.stopSignalAccepted === true && typeof response.message === "string" ? response.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.",
12763
+ stopSignalAccepted: response?.success === true || response.stopSignalAccepted === true,
12752
12764
  stopRequestError,
12753
12765
  runtimeRoles,
12754
12766
  possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
@@ -14032,6 +14044,9 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14032
14044
  initialRefresh;
14033
14045
  refreshTimer;
14034
14046
  static REFRESH_INTERVAL_MS = 1e3;
14047
+ static TOPOLOGY_TIMEOUT_MS = 2e3;
14048
+ topologyGeneration = 0;
14049
+ appliedTopologyGeneration = 0;
14035
14050
  constructor(primaryBaseUrl, authToken) {
14036
14051
  super();
14037
14052
  this.primaryBaseUrl = primaryBaseUrl;
@@ -14049,17 +14064,33 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14049
14064
  headers["X-MCP-Auth"] = this.authToken;
14050
14065
  return headers;
14051
14066
  }
14052
- async refreshTopology() {
14067
+ refreshTopologyForRouting(signal) {
14068
+ return this.refreshTopology(signal, true);
14069
+ }
14070
+ async refreshTopology(signal, requireFresh = false) {
14071
+ const generation = ++this.topologyGeneration;
14072
+ const controller = new AbortController();
14073
+ const abort = () => controller.abort(signal?.reason);
14074
+ signal?.addEventListener("abort", abort, { once: true });
14075
+ if (signal?.aborted)
14076
+ abort();
14077
+ const timeout = setTimeout(() => controller.abort(new Error("Studio topology refresh timed out")), _ProxyBridgeService.TOPOLOGY_TIMEOUT_MS);
14053
14078
  try {
14079
+ controller.signal.throwIfAborted();
14054
14080
  const res = await fetch(`${this.primaryBaseUrl}/topology`, {
14055
- headers: this.authHeaders()
14081
+ headers: this.authHeaders(),
14082
+ signal: controller.signal
14056
14083
  });
14057
14084
  if (!res.ok)
14058
- return;
14085
+ throw new Error(`Studio topology returned HTTP ${res.status}`);
14059
14086
  const body = await res.json();
14060
- if (!Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
14061
- return;
14087
+ controller.signal.throwIfAborted();
14088
+ if (!body || !Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
14089
+ throw new Error("Primary returned invalid Studio topology");
14062
14090
  }
14091
+ if (generation < this.appliedTopologyGeneration)
14092
+ return;
14093
+ this.appliedTopologyGeneration = generation;
14063
14094
  const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
14064
14095
  this.cachedPeers = body.peers;
14065
14096
  this.cachedInstances = body.instances;
@@ -14069,7 +14100,12 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14069
14100
  this.notifyPeerRegistered(toPublicPeer(peer));
14070
14101
  }
14071
14102
  }
14072
- } catch {
14103
+ } catch (error) {
14104
+ if (requireFresh)
14105
+ throw error;
14106
+ } finally {
14107
+ clearTimeout(timeout);
14108
+ signal?.removeEventListener("abort", abort);
14073
14109
  }
14074
14110
  }
14075
14111
  getPeers() {
@@ -16459,7 +16495,7 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
16459
16495
 
16460
16496
  // ../core/dist/install-plugin-helpers.js
16461
16497
  var import_saxes = __toESM(require_saxes(), 1);
16462
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16498
+ import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16463
16499
  import { execSync } from "child_process";
16464
16500
  import { randomUUID as randomUUID5 } from "crypto";
16465
16501
  import { basename as basename3, join as join5 } from "path";
@@ -16610,7 +16646,79 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
16610
16646
  }
16611
16647
  var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
16612
16648
  function errorCode(error) {
16613
- return error.code;
16649
+ return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
16650
+ }
16651
+ function pluginInstallLockError(lockPath, reason) {
16652
+ return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
16653
+ }
16654
+ function readPluginInstallLockOwner(lockPath) {
16655
+ const ownerPath = join5(lockPath, "owner.json");
16656
+ if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
16657
+ throw new Error("Lock and owner metadata must be a directory and regular file");
16658
+ }
16659
+ const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16660
+ if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
16661
+ throw new Error("Invalid lock owner metadata");
16662
+ }
16663
+ return { pid: owner.pid, token: owner.token, createdAt: owner.createdAt };
16664
+ }
16665
+ function assertPluginInstallOwnerExited(lockPath, owner) {
16666
+ if (owner.pid !== process.pid) {
16667
+ try {
16668
+ process.kill(owner.pid, 0);
16669
+ } catch (error) {
16670
+ if (errorCode(error) === "ESRCH")
16671
+ return;
16672
+ throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
16673
+ }
16674
+ }
16675
+ throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
16676
+ }
16677
+ function recoverPluginInstallLock(lockPath, warn) {
16678
+ let owner;
16679
+ try {
16680
+ owner = readPluginInstallLockOwner(lockPath);
16681
+ } catch (error) {
16682
+ throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
16683
+ }
16684
+ assertPluginInstallOwnerExited(lockPath, owner);
16685
+ const claimPath = join5(lockPath, `recovery-${owner.token}`);
16686
+ try {
16687
+ mkdirSync4(claimPath);
16688
+ } catch (error) {
16689
+ throw pluginInstallLockError(lockPath, errorCode(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
16690
+ }
16691
+ const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
16692
+ let moved = false;
16693
+ try {
16694
+ let currentOwner;
16695
+ try {
16696
+ currentOwner = readPluginInstallLockOwner(lockPath);
16697
+ } catch (error) {
16698
+ throw pluginInstallLockError(lockPath, `Cannot safely recheck plugin installation lock owner (${error})`);
16699
+ }
16700
+ if (currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || currentOwner.createdAt !== owner.createdAt) {
16701
+ throw pluginInstallLockError(lockPath, "Plugin installation lock owner changed; retry installation");
16702
+ }
16703
+ assertPluginInstallOwnerExited(lockPath, currentOwner);
16704
+ renameSync(lockPath, abandonedPath);
16705
+ moved = true;
16706
+ } finally {
16707
+ if (!moved) {
16708
+ try {
16709
+ rmdirSync(claimPath);
16710
+ } catch (error) {
16711
+ if (errorCode(error) !== "ENOENT") {
16712
+ warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
16713
+ }
16714
+ }
16715
+ }
16716
+ }
16717
+ try {
16718
+ rmSync2(abandonedPath, { recursive: true, force: true });
16719
+ } catch (error) {
16720
+ warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
16721
+ }
16614
16722
  }
16615
16723
  function acquirePluginInstallLock(pluginsFolder, warn) {
16616
16724
  const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
@@ -16625,7 +16733,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16625
16733
  } catch (error) {
16626
16734
  if (errorCode(error) !== "EEXIST")
16627
16735
  throw error;
16628
- throw new Error(`Another Studio plugin installation is already in progress: ${lockPath}. If no installer is running, remove that lock directory and retry.`);
16736
+ recoverPluginInstallLock(lockPath, warn);
16737
+ try {
16738
+ mkdirSync4(lockPath);
16739
+ } catch (retryError) {
16740
+ if (errorCode(retryError) !== "EEXIST")
16741
+ throw retryError;
16742
+ throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
16743
+ }
16629
16744
  }
16630
16745
  try {
16631
16746
  writeFileSync3(ownerPath, `${JSON.stringify(owner)}
@@ -16643,7 +16758,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16643
16758
  }
16644
16759
  return () => {
16645
16760
  try {
16646
- const currentOwner = JSON.parse(readFileSync6(ownerPath, "utf8"));
16761
+ const currentOwner = readPluginInstallLockOwner(lockPath);
16647
16762
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
16648
16763
  rmSync2(lockPath, { recursive: true, force: true });
16649
16764
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "3.1.2",
3
+ "version": "3.1.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",
@@ -1472,9 +1472,9 @@ local function computeBridgeStamp()
1472
1472
  for i = 1, #combined do
1473
1473
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1474
1474
  end
1475
- -- "3.1.2" is replaced with the package version at package time
1475
+ -- "3.1.3" is replaced with the package version at package time
1476
1476
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1477
- return `{tostring(h)}-3.1.2`
1477
+ return `{tostring(h)}-3.1.3`
1478
1478
  end
1479
1479
  local BRIDGE_STAMP = computeBridgeStamp()
1480
1480
  local function setSource(scriptInst, source)
@@ -7985,6 +7985,16 @@ local function startPlaytest(requestData)
7985
7985
  error = "start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.",
7986
7986
  }
7987
7987
  end
7988
+ -- Peer teardown and ExecutePlayModeAsync completion can precede native edit
7989
+ -- readiness. Do not acknowledge a new start that the engine cannot execute.
7990
+ if not StudioTestService.EditModeActive then
7991
+ return {
7992
+ success = false,
7993
+ error = "Studio is not ready to start a playtest.",
7994
+ message = "Wait for Studio to finish its current playtest transition before starting another.",
7995
+ editModeReady = false,
7996
+ }
7997
+ end
7988
7998
  -- Self-heal: if testRunning is stuck true but Studio reports no active
7989
7999
  -- playtest, the previous start_playtest's task.spawn was orphaned
7990
8000
  -- (plugin reload mid-test, Studio entered some inconsistent state, etc).
@@ -8064,22 +8074,24 @@ local function stopPlaytest(_requestData)
8064
8074
  }
8065
8075
  end
8066
8076
  StopPlayMonitor.clearPending(stopRequest.requestId)
8067
- -- Request was consumed (EndTest called). ExecutePlayModeAsync in our
8068
- -- startPlaytest task.spawn is still unwinding though testRunning stays
8069
- -- true until that yield completes and the post-block runs. Wait so
8070
- -- back-to-back stop -> start sequences don't race against the prior
8071
- -- teardown and get "A test is already running". 10s covers play-DM
8072
- -- teardown on heavier places; if it still hasn't cleared we return
8073
- -- anyway so users aren't stuck — but note that in the response so the
8074
- -- caller knows a subsequent start may need a moment.
8077
+ -- EndTest consumption, task completion, and native edit readiness are distinct
8078
+ -- milestones. In particular, testRunning can clear before Studio accepts another
8079
+ -- ExecutePlayModeAsync call (and is never set for manually started playtests).
8080
+ -- Only report completion after both tracked execution and native teardown settle.
8075
8081
  local deadline = tick() + 10
8076
- while testRunning and tick() < deadline do
8082
+ while (testRunning or not StudioTestService.EditModeActive) and tick() < deadline do
8077
8083
  task.wait(0.1)
8078
8084
  end
8079
- if testRunning then
8085
+ local editModeReady = StudioTestService.EditModeActive
8086
+ if testRunning or not editModeReady then
8080
8087
  return {
8081
- success = true,
8082
- message = "Playtest stop signal sent; teardown still in progress.",
8088
+ success = false,
8089
+ error = "Playtest teardown did not complete.",
8090
+ message = "Stop signal was accepted, but Studio teardown did not settle before timeout.",
8091
+ stopSignalAccepted = true,
8092
+ editModeReady = editModeReady,
8093
+ playtestTaskPending = testRunning,
8094
+ timedOut = true,
8083
8095
  }
8084
8096
  end
8085
8097
  return {
@@ -9815,7 +9827,7 @@ return {
9815
9827
  <Properties>
9816
9828
  <string name="Name">State</string>
9817
9829
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
9818
- local CURRENT_VERSION = "3.1.2"
9830
+ local CURRENT_VERSION = "3.1.3"
9819
9831
  local PLUGIN_VARIANT = "main"
9820
9832
  local BASE_PORT = 58741
9821
9833
  local function createConnection(port)