@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 +128 -13
- package/package.json +1 -1
- package/studio-plugin/MCPPlugin.rbxmx +27 -15
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:
|
|
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
|
-
|
|
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
|
-
|
|
14085
|
+
throw new Error(`Studio topology returned HTTP ${res.status}`);
|
|
14059
14086
|
const body = await res.json();
|
|
14060
|
-
|
|
14061
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
-
--
|
|
8068
|
-
--
|
|
8069
|
-
--
|
|
8070
|
-
--
|
|
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
|
-
|
|
8085
|
+
local editModeReady = StudioTestService.EditModeActive
|
|
8086
|
+
if testRunning or not editModeReady then
|
|
8080
8087
|
return {
|
|
8081
|
-
success =
|
|
8082
|
-
|
|
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.
|
|
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)
|