@chrrxs/robloxstudio-mcp-inspector 3.1.2 → 3.1.4
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 +366 -57
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +548 -58
package/dist/index.js
CHANGED
|
@@ -1945,6 +1945,15 @@ import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
|
1945
1945
|
|
|
1946
1946
|
// ../core/dist/bridge-service.js
|
|
1947
1947
|
import { createHash, randomUUID } from "crypto";
|
|
1948
|
+
var MultiplayerGroupInUseError = class extends Error {
|
|
1949
|
+
groupId;
|
|
1950
|
+
code = "multiplayer_group_in_use";
|
|
1951
|
+
constructor(groupId) {
|
|
1952
|
+
super(`Multiplayer Group "${groupId}" still has connected runtime peers and cannot be removed.`);
|
|
1953
|
+
this.groupId = groupId;
|
|
1954
|
+
this.name = "MultiplayerGroupInUseError";
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1948
1957
|
function isExecutionOutcome(value) {
|
|
1949
1958
|
return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
|
|
1950
1959
|
}
|
|
@@ -2233,6 +2242,11 @@ var BridgeService = class {
|
|
|
2233
2242
|
const group = this.multiplayerGroupsById.get(groupId);
|
|
2234
2243
|
if (!group)
|
|
2235
2244
|
return void 0;
|
|
2245
|
+
for (const peer of this.peersById.values()) {
|
|
2246
|
+
if (peer.multiplayerGroupId === groupId && isRuntimeRole(peer.role)) {
|
|
2247
|
+
throw new MultiplayerGroupInUseError(groupId);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2236
2250
|
this.multiplayerGroupsById.delete(groupId);
|
|
2237
2251
|
for (const peer of this.peersById.values()) {
|
|
2238
2252
|
if (peer.multiplayerGroupId === groupId)
|
|
@@ -2483,6 +2497,12 @@ var BridgeService = class {
|
|
|
2483
2497
|
multiplayerGroups: this.getMultiplayerGroups()
|
|
2484
2498
|
};
|
|
2485
2499
|
}
|
|
2500
|
+
/** Local topology is authoritative; remote adapters must refresh before fanout. */
|
|
2501
|
+
refreshTopologyForRouting(signal) {
|
|
2502
|
+
if (signal?.aborted)
|
|
2503
|
+
throw new Error("Request aborted before topology resolution");
|
|
2504
|
+
return void 0;
|
|
2505
|
+
}
|
|
2486
2506
|
resolveConnectedInstanceId(instanceId) {
|
|
2487
2507
|
const exact = this.getInstances().find((instance) => instance.id === instanceId);
|
|
2488
2508
|
const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
|
|
@@ -5832,20 +5852,20 @@ var StudioInstanceManager = class {
|
|
|
5832
5852
|
};
|
|
5833
5853
|
|
|
5834
5854
|
// ../core/dist/mcp-runtime.js
|
|
5835
|
-
var
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
5855
|
+
var INTERNAL_ENVELOPE_KEYS = {
|
|
5856
|
+
bundleModifiedAt: true,
|
|
5857
|
+
bundlePath: true,
|
|
5858
|
+
bundleSha256: true,
|
|
5859
|
+
connectedAt: true,
|
|
5860
|
+
debug: true,
|
|
5861
|
+
diagnostics: true,
|
|
5862
|
+
internal: true,
|
|
5863
|
+
lastActivity: true,
|
|
5864
|
+
transportPeerId: true,
|
|
5865
|
+
pluginVariant: true,
|
|
5866
|
+
pluginVersion: true,
|
|
5867
|
+
serverVersion: true
|
|
5868
|
+
};
|
|
5849
5869
|
var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
|
|
5850
5870
|
var SIDE_EFFECTING_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
5851
5871
|
"capture_micro_profiler",
|
|
@@ -5894,18 +5914,15 @@ function compactPublicValue(value) {
|
|
|
5894
5914
|
return value.map(compactPublicValue);
|
|
5895
5915
|
if (!value || typeof value !== "object")
|
|
5896
5916
|
return value;
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
compact[key] = compactPublicValue(child);
|
|
5902
|
-
}
|
|
5903
|
-
return compact;
|
|
5917
|
+
return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== void 0).map(([key, child]) => [key, compactPublicValue(child)]));
|
|
5918
|
+
}
|
|
5919
|
+
function compactPublicEnvelope(value) {
|
|
5920
|
+
return Object.fromEntries(Object.entries(value).filter(([key, child]) => child !== void 0 && !Object.hasOwn(INTERNAL_ENVELOPE_KEYS, key)).map(([key, child]) => [key, compactPublicValue(child)]));
|
|
5904
5921
|
}
|
|
5905
5922
|
function asStructuredObject(value) {
|
|
5906
5923
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
5907
5924
|
return void 0;
|
|
5908
|
-
return
|
|
5925
|
+
return compactPublicEnvelope(value);
|
|
5909
5926
|
}
|
|
5910
5927
|
function parseJsonObject(text) {
|
|
5911
5928
|
try {
|
|
@@ -5930,6 +5947,7 @@ function normalizeToolResult(raw, era) {
|
|
|
5930
5947
|
...result.isError ? { isError: true } : {}
|
|
5931
5948
|
};
|
|
5932
5949
|
}
|
|
5950
|
+
const isError = result.isError || structured.success === false || typeof structured.error === "string" && structured.error.length > 0;
|
|
5933
5951
|
const content = era === "modern" ? originalContent.filter((_, index) => index !== jsonTextIndex) : [
|
|
5934
5952
|
{ type: "text", text: JSON.stringify(structured) },
|
|
5935
5953
|
...originalContent.filter((_, index) => index !== jsonTextIndex)
|
|
@@ -5937,7 +5955,7 @@ function normalizeToolResult(raw, era) {
|
|
|
5937
5955
|
return {
|
|
5938
5956
|
content,
|
|
5939
5957
|
structuredContent: structured,
|
|
5940
|
-
...
|
|
5958
|
+
...isError ? { isError: true } : {}
|
|
5941
5959
|
};
|
|
5942
5960
|
}
|
|
5943
5961
|
function publicRoutingError(error) {
|
|
@@ -5960,8 +5978,11 @@ function publicRoutingError(error) {
|
|
|
5960
5978
|
};
|
|
5961
5979
|
}
|
|
5962
5980
|
function publicToolErrorBody(name, error) {
|
|
5981
|
+
if (error instanceof MultiplayerGroupInUseError) {
|
|
5982
|
+
return { error: error.code, message: error.message, multiplayer_group_id: error.groupId };
|
|
5983
|
+
}
|
|
5963
5984
|
if (error instanceof StudioLaunchPreDispatchError) {
|
|
5964
|
-
return
|
|
5985
|
+
return compactPublicEnvelope(error.toResponseBody());
|
|
5965
5986
|
}
|
|
5966
5987
|
if (error instanceof RoutingFailure)
|
|
5967
5988
|
return publicRoutingError(error);
|
|
@@ -6435,6 +6456,47 @@ var WebSocketStudioTransport = class {
|
|
|
6435
6456
|
};
|
|
6436
6457
|
|
|
6437
6458
|
// ../core/dist/http-server.js
|
|
6459
|
+
var TOOL_PROXY_ENDPOINTS = {
|
|
6460
|
+
get_place_info: ["/api/place-info"],
|
|
6461
|
+
search_objects: ["/api/search-objects"],
|
|
6462
|
+
get_instance_properties: ["/api/instance-properties"],
|
|
6463
|
+
get_project_structure: ["/api/project-structure"],
|
|
6464
|
+
set_properties: ["/api/set-properties"],
|
|
6465
|
+
get_script_source: ["/api/get-script-source"],
|
|
6466
|
+
set_script_source: ["/api/set-script-source"],
|
|
6467
|
+
edit_script_lines: ["/api/edit-script-lines"],
|
|
6468
|
+
insert_script_lines: ["/api/insert-script-lines"],
|
|
6469
|
+
delete_script_lines: ["/api/delete-script-lines"],
|
|
6470
|
+
get_attributes: ["/api/get-attributes"],
|
|
6471
|
+
selection: ["/api/get-selection", "/api/set-selection", "/api/focus-viewport"],
|
|
6472
|
+
execute_luau: ["/api/execute-luau"],
|
|
6473
|
+
eval_server_runtime: ["/api/eval-runtime"],
|
|
6474
|
+
eval_client_runtime: ["/api/eval-runtime"],
|
|
6475
|
+
grep_scripts: ["/api/grep-scripts"],
|
|
6476
|
+
solo_playtest: ["/api/start-playtest", "/api/stop-playtest", "/api/multiplayer-test-state"],
|
|
6477
|
+
multiplayer_playtest: [
|
|
6478
|
+
"/api/multiplayer-test-start",
|
|
6479
|
+
"/api/multiplayer-test-add-players",
|
|
6480
|
+
"/api/multiplayer-test-leave-client",
|
|
6481
|
+
"/api/multiplayer-test-end",
|
|
6482
|
+
"/api/multiplayer-test-state"
|
|
6483
|
+
],
|
|
6484
|
+
get_runtime_logs: ["/api/get-runtime-logs"],
|
|
6485
|
+
capture_script_profiler: ["/api/capture-script-profiler"],
|
|
6486
|
+
capture_micro_profiler: ["/api/capture-micro-profiler"],
|
|
6487
|
+
breakpoints: ["/api/breakpoints"],
|
|
6488
|
+
insert_asset: ["/api/insert-asset"],
|
|
6489
|
+
generate_model: ["/api/generate-model"],
|
|
6490
|
+
preview_asset: ["/api/preview-asset"],
|
|
6491
|
+
capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read"],
|
|
6492
|
+
simulate_mouse_input: ["/api/simulate-mouse-input"],
|
|
6493
|
+
simulate_keyboard_input: ["/api/simulate-keyboard-input"],
|
|
6494
|
+
get_memory_breakdown: ["/api/get-memory-breakdown"],
|
|
6495
|
+
get_scene_analysis: ["/api/get-scene-analysis"],
|
|
6496
|
+
export_rbxm: ["/api/export-rbxm"],
|
|
6497
|
+
import_rbxm: ["/api/import-rbxm"],
|
|
6498
|
+
find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"]
|
|
6499
|
+
};
|
|
6438
6500
|
function toPassivePeer(peer) {
|
|
6439
6501
|
return {
|
|
6440
6502
|
instanceId: peer.instanceId,
|
|
@@ -6621,6 +6683,7 @@ ${body}`);
|
|
|
6621
6683
|
}
|
|
6622
6684
|
function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
6623
6685
|
const app = express();
|
|
6686
|
+
const allowedProxyEndpoints = allowedTools ? new Set(Object.entries(TOOL_PROXY_ENDPOINTS).filter(([toolName]) => allowedTools.has(toolName)).flatMap(([, endpoints]) => endpoints)) : void 0;
|
|
6624
6687
|
const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
|
|
6625
6688
|
const studioLifecycleCapabilities = studioLifecycleCallable ? tools.getStudioLifecycleCapabilities() : void 0;
|
|
6626
6689
|
let mcpServerActive = false;
|
|
@@ -7028,14 +7091,27 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
7028
7091
|
const group = await bridge.createMultiplayerGroupEverywhere(groupId, controllerInstanceId);
|
|
7029
7092
|
res.json({ success: true, group });
|
|
7030
7093
|
});
|
|
7031
|
-
app.post("/remove-multiplayer-group", async (req, res) => {
|
|
7094
|
+
app.post("/remove-multiplayer-group", async (req, res, next) => {
|
|
7032
7095
|
const { groupId } = req.body;
|
|
7033
7096
|
if (typeof groupId !== "string" || groupId.length === 0) {
|
|
7034
7097
|
res.status(400).json({ error: "groupId is required" });
|
|
7035
7098
|
return;
|
|
7036
7099
|
}
|
|
7037
|
-
|
|
7038
|
-
|
|
7100
|
+
try {
|
|
7101
|
+
const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
|
|
7102
|
+
res.json({ success: true, removed });
|
|
7103
|
+
} catch (error) {
|
|
7104
|
+
if (error instanceof MultiplayerGroupInUseError) {
|
|
7105
|
+
res.status(409).json({
|
|
7106
|
+
success: false,
|
|
7107
|
+
error: error.code,
|
|
7108
|
+
message: error.message,
|
|
7109
|
+
groupId: error.groupId
|
|
7110
|
+
});
|
|
7111
|
+
return;
|
|
7112
|
+
}
|
|
7113
|
+
next(error);
|
|
7114
|
+
}
|
|
7039
7115
|
});
|
|
7040
7116
|
app.get("/status", (req, res) => {
|
|
7041
7117
|
const peers = bridge.getPublicPeers().map(toPassivePeer);
|
|
@@ -7082,6 +7158,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
|
|
|
7082
7158
|
res.status(400).json({ error: "endpoint and targetPeerId are required" });
|
|
7083
7159
|
return;
|
|
7084
7160
|
}
|
|
7161
|
+
if (allowedProxyEndpoints && !allowedProxyEndpoints.has(endpoint)) {
|
|
7162
|
+
res.status(403).json({ error: "forbidden_endpoint" });
|
|
7163
|
+
return;
|
|
7164
|
+
}
|
|
7085
7165
|
if (timeoutMs !== void 0 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e5)) {
|
|
7086
7166
|
res.status(400).json({ error: "timeoutMs must be an integer between 1 and 300000" });
|
|
7087
7167
|
return;
|
|
@@ -10880,7 +10960,10 @@ var RobloxStudioTools = class {
|
|
|
10880
10960
|
_requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
|
|
10881
10961
|
return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
|
|
10882
10962
|
}
|
|
10883
|
-
_request(endpoint, data, instanceId, role, timeoutMs, signal) {
|
|
10963
|
+
async _request(endpoint, data, instanceId, role, timeoutMs, signal) {
|
|
10964
|
+
const refresh = this.bridge.refreshTopologyForRouting(signal);
|
|
10965
|
+
if (refresh)
|
|
10966
|
+
await refresh;
|
|
10884
10967
|
const peer = this._peerForRoleInScope(instanceId, role);
|
|
10885
10968
|
if (!peer) {
|
|
10886
10969
|
throw new RoutingFailure({
|
|
@@ -10893,6 +10976,9 @@ var RobloxStudioTools = class {
|
|
|
10893
10976
|
}
|
|
10894
10977
|
// Resolve an optional Studio process plus role to one exact Peer and dispatch.
|
|
10895
10978
|
async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
|
|
10979
|
+
const refresh = this.bridge.refreshTopologyForRouting(signal);
|
|
10980
|
+
if (refresh)
|
|
10981
|
+
await refresh;
|
|
10896
10982
|
const resolved = this.bridge.resolveTarget({ instance_id, target });
|
|
10897
10983
|
if (!resolved.ok)
|
|
10898
10984
|
throw new RoutingFailure(resolved.error);
|
|
@@ -10924,6 +11010,7 @@ var RobloxStudioTools = class {
|
|
|
10924
11010
|
clientRole: client?.role
|
|
10925
11011
|
};
|
|
10926
11012
|
}
|
|
11013
|
+
// Resolve synchronously after the caller has refreshed and captured its topology.
|
|
10927
11014
|
_resolveInstanceIdOnly(instance_id) {
|
|
10928
11015
|
if (instance_id !== void 0) {
|
|
10929
11016
|
const resolvedInstanceId = this.bridge.resolveConnectedInstanceId(instance_id);
|
|
@@ -11137,6 +11224,9 @@ var RobloxStudioTools = class {
|
|
|
11137
11224
|
async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
|
|
11138
11225
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
11139
11226
|
while (Date.now() < deadline) {
|
|
11227
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11228
|
+
if (refresh)
|
|
11229
|
+
await refresh;
|
|
11140
11230
|
const roles = this._rolesForScope(instanceId);
|
|
11141
11231
|
const clientRoles = this._clientRolesForScope(instanceId);
|
|
11142
11232
|
const hasServer = !opts.server || roles.includes("server");
|
|
@@ -11158,6 +11248,9 @@ var RobloxStudioTools = class {
|
|
|
11158
11248
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
11159
11249
|
let exactSince;
|
|
11160
11250
|
while (Date.now() < deadline) {
|
|
11251
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11252
|
+
if (refresh)
|
|
11253
|
+
await refresh;
|
|
11161
11254
|
const roles2 = this._rolesForScope(instanceId);
|
|
11162
11255
|
const clientCount2 = this._clientRolesForScope(instanceId).length;
|
|
11163
11256
|
if (clientCount2 > expectedClientCount) {
|
|
@@ -11180,6 +11273,9 @@ var RobloxStudioTools = class {
|
|
|
11180
11273
|
async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
|
|
11181
11274
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
11182
11275
|
while (Date.now() < deadline) {
|
|
11276
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11277
|
+
if (refresh)
|
|
11278
|
+
await refresh;
|
|
11183
11279
|
const peers = this.bridge.getPeersInScope(instanceId);
|
|
11184
11280
|
const roles = peers.map((peer) => peer.role);
|
|
11185
11281
|
const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
|
|
@@ -11477,6 +11573,9 @@ var RobloxStudioTools = class {
|
|
|
11477
11573
|
if (angleY !== void 0 && (angleY < -89 || angleY > 89)) {
|
|
11478
11574
|
throw new Error("selection angleY must be between -89 and 89");
|
|
11479
11575
|
}
|
|
11576
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11577
|
+
if (refresh)
|
|
11578
|
+
await refresh;
|
|
11480
11579
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
11481
11580
|
const response = await this._callSingle("/api/focus-viewport", {
|
|
11482
11581
|
path: instancePath,
|
|
@@ -11541,6 +11640,9 @@ var RobloxStudioTools = class {
|
|
|
11541
11640
|
}
|
|
11542
11641
|
async setNetworkProfile(profile, target, overrides, instance_id) {
|
|
11543
11642
|
const values = normalizeNetworkProfile(profile, overrides);
|
|
11643
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11644
|
+
if (refresh)
|
|
11645
|
+
await refresh;
|
|
11544
11646
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
11545
11647
|
const clientRoles = this._clientRolesForScope(instanceId);
|
|
11546
11648
|
const selectedTarget = target ?? "client-1";
|
|
@@ -11607,6 +11709,9 @@ var RobloxStudioTools = class {
|
|
|
11607
11709
|
const selectedInclude = this._normalizeSimulationInclude(include);
|
|
11608
11710
|
const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
|
|
11609
11711
|
const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
|
|
11712
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11713
|
+
if (refresh)
|
|
11714
|
+
await refresh;
|
|
11610
11715
|
const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
|
|
11611
11716
|
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
11612
11717
|
const state = {};
|
|
@@ -11653,6 +11758,9 @@ var RobloxStudioTools = class {
|
|
|
11653
11758
|
if (!resetNetwork && !resetDeviceSimulator) {
|
|
11654
11759
|
throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
|
|
11655
11760
|
}
|
|
11761
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11762
|
+
if (refresh)
|
|
11763
|
+
await refresh;
|
|
11656
11764
|
const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
|
|
11657
11765
|
const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
|
|
11658
11766
|
const result = {};
|
|
@@ -11711,6 +11819,9 @@ var RobloxStudioTools = class {
|
|
|
11711
11819
|
if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
|
|
11712
11820
|
throw new Error("deviceId must be a non-empty string when provided");
|
|
11713
11821
|
}
|
|
11822
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11823
|
+
if (refresh)
|
|
11824
|
+
await refresh;
|
|
11714
11825
|
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
|
|
11715
11826
|
const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
|
|
11716
11827
|
includeDeviceList: includeDeviceList !== false,
|
|
@@ -11735,6 +11846,9 @@ var RobloxStudioTools = class {
|
|
|
11735
11846
|
if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
|
|
11736
11847
|
throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
|
|
11737
11848
|
}
|
|
11849
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11850
|
+
if (refresh)
|
|
11851
|
+
await refresh;
|
|
11738
11852
|
const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
|
|
11739
11853
|
const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
|
|
11740
11854
|
const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
|
|
@@ -11793,6 +11907,9 @@ var RobloxStudioTools = class {
|
|
|
11793
11907
|
label: raw.label
|
|
11794
11908
|
};
|
|
11795
11909
|
});
|
|
11910
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
11911
|
+
if (refresh)
|
|
11912
|
+
await refresh;
|
|
11796
11913
|
const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
|
|
11797
11914
|
if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
|
|
11798
11915
|
throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
|
|
@@ -11908,6 +12025,9 @@ var RobloxStudioTools = class {
|
|
|
11908
12025
|
if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
|
|
11909
12026
|
throw new Error("get_runtime_logs tail must be a non-negative integer.");
|
|
11910
12027
|
}
|
|
12028
|
+
const refresh = this.bridge.refreshTopologyForRouting(signal);
|
|
12029
|
+
if (refresh)
|
|
12030
|
+
await refresh;
|
|
11911
12031
|
const instances = this.bridge.getInstances();
|
|
11912
12032
|
const groups = this.bridge.getMultiplayerGroups();
|
|
11913
12033
|
let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
|
|
@@ -12141,6 +12261,9 @@ var RobloxStudioTools = class {
|
|
|
12141
12261
|
if (outputPath) {
|
|
12142
12262
|
data.__mcp_include_raw_json = true;
|
|
12143
12263
|
}
|
|
12264
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12265
|
+
if (refresh)
|
|
12266
|
+
await refresh;
|
|
12144
12267
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
12145
12268
|
if (!resolved.ok)
|
|
12146
12269
|
throw new RoutingFailure(resolved.error);
|
|
@@ -12201,6 +12324,9 @@ var RobloxStudioTools = class {
|
|
|
12201
12324
|
if (summaryOutputPath || baselinePath || baseline || includeComparisonIndex) {
|
|
12202
12325
|
data.__mcp_include_comparison_index = true;
|
|
12203
12326
|
}
|
|
12327
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12328
|
+
if (refresh)
|
|
12329
|
+
await refresh;
|
|
12204
12330
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
12205
12331
|
if (!resolved.ok)
|
|
12206
12332
|
throw new RoutingFailure(resolved.error);
|
|
@@ -12255,6 +12381,9 @@ var RobloxStudioTools = class {
|
|
|
12255
12381
|
const data = { ...request, action };
|
|
12256
12382
|
delete data.target;
|
|
12257
12383
|
delete data.instance_id;
|
|
12384
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12385
|
+
if (refresh)
|
|
12386
|
+
await refresh;
|
|
12258
12387
|
const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
|
|
12259
12388
|
if (!resolved.ok)
|
|
12260
12389
|
throw new RoutingFailure(resolved.error);
|
|
@@ -12605,6 +12734,9 @@ var RobloxStudioTools = class {
|
|
|
12605
12734
|
throw new Error("solo_playtest requires action=start|stop|status");
|
|
12606
12735
|
}
|
|
12607
12736
|
if (action === "status") {
|
|
12737
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12738
|
+
if (refresh)
|
|
12739
|
+
await refresh;
|
|
12608
12740
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
12609
12741
|
const { roles, runtimeRoles } = this._briefRoles(instanceId);
|
|
12610
12742
|
return this._textResult({
|
|
@@ -12628,6 +12760,8 @@ var RobloxStudioTools = class {
|
|
|
12628
12760
|
});
|
|
12629
12761
|
}
|
|
12630
12762
|
return this._textResult({
|
|
12763
|
+
// Keep lifecycle diagnostics on failures; only successful responses are brief.
|
|
12764
|
+
...body2,
|
|
12631
12765
|
success: false,
|
|
12632
12766
|
action,
|
|
12633
12767
|
error: body2.error ?? "start_failed",
|
|
@@ -12644,6 +12778,7 @@ var RobloxStudioTools = class {
|
|
|
12644
12778
|
});
|
|
12645
12779
|
}
|
|
12646
12780
|
return this._textResult({
|
|
12781
|
+
...body,
|
|
12647
12782
|
success: false,
|
|
12648
12783
|
action,
|
|
12649
12784
|
error: body.error ?? "stop_failed",
|
|
@@ -12662,6 +12797,9 @@ var RobloxStudioTools = class {
|
|
|
12662
12797
|
}
|
|
12663
12798
|
const data = { mode };
|
|
12664
12799
|
const startedAt = Date.now();
|
|
12800
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12801
|
+
if (refresh)
|
|
12802
|
+
await refresh;
|
|
12665
12803
|
const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
|
|
12666
12804
|
if (!resolved.ok)
|
|
12667
12805
|
throw new RoutingFailure(resolved.error);
|
|
@@ -12712,6 +12850,9 @@ var RobloxStudioTools = class {
|
|
|
12712
12850
|
};
|
|
12713
12851
|
}
|
|
12714
12852
|
async stopPlaytest(instance_id, timeout = 15) {
|
|
12853
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12854
|
+
if (refresh)
|
|
12855
|
+
await refresh;
|
|
12715
12856
|
const { instanceId } = this._resolveSingleTarget("edit", instance_id);
|
|
12716
12857
|
let response;
|
|
12717
12858
|
let stopRequestError;
|
|
@@ -12732,7 +12873,7 @@ var RobloxStudioTools = class {
|
|
|
12732
12873
|
wait = {
|
|
12733
12874
|
ok: false,
|
|
12734
12875
|
roles: this._rolesForScope(instanceId),
|
|
12735
|
-
timedOut:
|
|
12876
|
+
timedOut: response.timedOut === true
|
|
12736
12877
|
};
|
|
12737
12878
|
}
|
|
12738
12879
|
const body = wait ? {
|
|
@@ -12747,8 +12888,8 @@ var RobloxStudioTools = class {
|
|
|
12747
12888
|
...body,
|
|
12748
12889
|
success: false,
|
|
12749
12890
|
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,
|
|
12891
|
+
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.",
|
|
12892
|
+
stopSignalAccepted: response?.success === true || response.stopSignalAccepted === true,
|
|
12752
12893
|
stopRequestError,
|
|
12753
12894
|
runtimeRoles,
|
|
12754
12895
|
possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
|
|
@@ -12762,6 +12903,9 @@ var RobloxStudioTools = class {
|
|
|
12762
12903
|
};
|
|
12763
12904
|
}
|
|
12764
12905
|
async _buildMultiplayerState(instanceId) {
|
|
12906
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12907
|
+
if (refresh)
|
|
12908
|
+
await refresh;
|
|
12765
12909
|
const peers = this.bridge.getPublicPeers().filter((peer) => this.bridge.getInstanceIdsInScope(instanceId).includes(peer.instanceId)).sort((a, b) => a.role.localeCompare(b.role));
|
|
12766
12910
|
const multiplayerGroup = this.bridge.getMultiplayerGroups().find((group) => group.instanceIds.includes(instanceId));
|
|
12767
12911
|
const body = {
|
|
@@ -12807,6 +12951,9 @@ var RobloxStudioTools = class {
|
|
|
12807
12951
|
async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
|
|
12808
12952
|
const deadline = Date.now() + timeoutSec * 1e3;
|
|
12809
12953
|
while (Date.now() < deadline) {
|
|
12954
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12955
|
+
if (refresh)
|
|
12956
|
+
await refresh;
|
|
12810
12957
|
if (!this._rolesForScope(instanceId).includes("edit"))
|
|
12811
12958
|
return false;
|
|
12812
12959
|
try {
|
|
@@ -12821,6 +12968,9 @@ var RobloxStudioTools = class {
|
|
|
12821
12968
|
return false;
|
|
12822
12969
|
}
|
|
12823
12970
|
async _isMultiplayerTestRunning(instanceId) {
|
|
12971
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
12972
|
+
if (refresh)
|
|
12973
|
+
await refresh;
|
|
12824
12974
|
return this.bridge.getMultiplayerGroups().some((group) => group.instanceIds.includes(instanceId));
|
|
12825
12975
|
}
|
|
12826
12976
|
async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
|
|
@@ -12861,6 +13011,9 @@ var RobloxStudioTools = class {
|
|
|
12861
13011
|
throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
|
|
12862
13012
|
}
|
|
12863
13013
|
const briefState = async (instanceId) => {
|
|
13014
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13015
|
+
if (refresh)
|
|
13016
|
+
await refresh;
|
|
12864
13017
|
const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
|
|
12865
13018
|
const roles = Array.isArray(state.peers) ? state.peers.flatMap((peer) => peer !== null && typeof peer === "object" && "role" in peer && typeof peer.role === "string" ? [peer.role] : []) : [];
|
|
12866
13019
|
return {
|
|
@@ -12958,6 +13111,9 @@ var RobloxStudioTools = class {
|
|
|
12958
13111
|
if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
|
|
12959
13112
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
12960
13113
|
}
|
|
13114
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13115
|
+
if (refresh)
|
|
13116
|
+
await refresh;
|
|
12961
13117
|
const editTarget = this._resolveSingleTarget("edit", instance_id);
|
|
12962
13118
|
const existingRuntime = this._runtimeTargetsForScope(editTarget.instanceId);
|
|
12963
13119
|
if (existingRuntime.length > 0) {
|
|
@@ -13014,6 +13170,9 @@ var RobloxStudioTools = class {
|
|
|
13014
13170
|
};
|
|
13015
13171
|
}
|
|
13016
13172
|
async multiplayerTestState(instance_id) {
|
|
13173
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13174
|
+
if (refresh)
|
|
13175
|
+
await refresh;
|
|
13017
13176
|
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
13018
13177
|
const state = await this._buildMultiplayerState(instanceId);
|
|
13019
13178
|
return { content: [{ type: "text", text: JSON.stringify(state) }] };
|
|
@@ -13022,6 +13181,9 @@ var RobloxStudioTools = class {
|
|
|
13022
13181
|
if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
|
|
13023
13182
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
13024
13183
|
}
|
|
13184
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13185
|
+
if (refresh)
|
|
13186
|
+
await refresh;
|
|
13025
13187
|
const serverTarget = this._resolveSingleTarget("server", instance_id);
|
|
13026
13188
|
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
|
|
13027
13189
|
const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
|
|
@@ -13050,6 +13212,9 @@ var RobloxStudioTools = class {
|
|
|
13050
13212
|
if (!/^client-\d+$/.test(target)) {
|
|
13051
13213
|
throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
|
|
13052
13214
|
}
|
|
13215
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13216
|
+
if (refresh)
|
|
13217
|
+
await refresh;
|
|
13053
13218
|
const clientTarget = this._resolveSingleTarget(target, instance_id);
|
|
13054
13219
|
const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
|
|
13055
13220
|
const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
|
|
@@ -13073,6 +13238,9 @@ var RobloxStudioTools = class {
|
|
|
13073
13238
|
};
|
|
13074
13239
|
}
|
|
13075
13240
|
async multiplayerTestEnd(value, timeout, instance_id) {
|
|
13241
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13242
|
+
if (refresh)
|
|
13243
|
+
await refresh;
|
|
13076
13244
|
let serverTarget;
|
|
13077
13245
|
try {
|
|
13078
13246
|
serverTarget = this._resolveSingleTarget("server", instance_id);
|
|
@@ -13121,6 +13289,9 @@ var RobloxStudioTools = class {
|
|
|
13121
13289
|
return result;
|
|
13122
13290
|
}
|
|
13123
13291
|
async getConnectedInstances() {
|
|
13292
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13293
|
+
if (refresh)
|
|
13294
|
+
await refresh;
|
|
13124
13295
|
return this._textResult({
|
|
13125
13296
|
instances: this.bridge.getConnectedInstances(),
|
|
13126
13297
|
multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
|
|
@@ -13662,6 +13833,9 @@ var RobloxStudioTools = class {
|
|
|
13662
13833
|
if (!action) {
|
|
13663
13834
|
throw new Error("action is required for simulate_mouse_input");
|
|
13664
13835
|
}
|
|
13836
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13837
|
+
if (refresh)
|
|
13838
|
+
await refresh;
|
|
13665
13839
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
13666
13840
|
const response = await this._callSingle("/api/simulate-mouse-input", {
|
|
13667
13841
|
action,
|
|
@@ -13680,6 +13854,9 @@ var RobloxStudioTools = class {
|
|
|
13680
13854
|
if (!keyCode && text === void 0) {
|
|
13681
13855
|
throw new Error("keyCode or text is required for simulate_keyboard_input");
|
|
13682
13856
|
}
|
|
13857
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13858
|
+
if (refresh)
|
|
13859
|
+
await refresh;
|
|
13683
13860
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
13684
13861
|
const response = await this._callSingle("/api/simulate-keyboard-input", {
|
|
13685
13862
|
keyCode,
|
|
@@ -13718,6 +13895,9 @@ var RobloxStudioTools = class {
|
|
|
13718
13895
|
const data = {};
|
|
13719
13896
|
if (tags !== void 0)
|
|
13720
13897
|
data.tags = tags;
|
|
13898
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13899
|
+
if (refresh)
|
|
13900
|
+
await refresh;
|
|
13721
13901
|
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
13722
13902
|
if (!resolved.ok)
|
|
13723
13903
|
throw new RoutingFailure(resolved.error);
|
|
@@ -13751,6 +13931,9 @@ var RobloxStudioTools = class {
|
|
|
13751
13931
|
data.topN = topN;
|
|
13752
13932
|
if (raw !== void 0)
|
|
13753
13933
|
data.raw = raw;
|
|
13934
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
13935
|
+
if (refresh)
|
|
13936
|
+
await refresh;
|
|
13754
13937
|
const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
|
|
13755
13938
|
if (!resolved.ok)
|
|
13756
13939
|
throw new RoutingFailure(resolved.error);
|
|
@@ -13883,18 +14066,23 @@ var RobloxStudioTools = class {
|
|
|
13883
14066
|
return { content: [{ type: "text", text: JSON.stringify(response) }] };
|
|
13884
14067
|
}
|
|
13885
14068
|
async _captureViewportImage(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
|
|
13886
|
-
|
|
13887
|
-
|
|
13888
|
-
|
|
13889
|
-
|
|
13890
|
-
|
|
13891
|
-
|
|
13892
|
-
|
|
13893
|
-
|
|
14069
|
+
const fmt = format === "png" ? "png" : "jpeg";
|
|
14070
|
+
const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
|
|
14071
|
+
let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
|
|
14072
|
+
const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
|
|
14073
|
+
if (studioFastPathMissing) {
|
|
14074
|
+
if (targetRole.startsWith("client-")) {
|
|
14075
|
+
const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
|
|
14076
|
+
if (begin.error) {
|
|
14077
|
+
return { success: false, error: begin.error };
|
|
14078
|
+
}
|
|
14079
|
+
if (!begin.contentId) {
|
|
14080
|
+
return { success: false, error: "Screenshot capture failed: no content id returned from client." };
|
|
14081
|
+
}
|
|
14082
|
+
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
14083
|
+
} else {
|
|
14084
|
+
response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
|
|
13894
14085
|
}
|
|
13895
|
-
response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
|
|
13896
|
-
} else {
|
|
13897
|
-
response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
|
|
13898
14086
|
}
|
|
13899
14087
|
if (response.error) {
|
|
13900
14088
|
let text = response.error;
|
|
@@ -13908,11 +14096,19 @@ var RobloxStudioTools = class {
|
|
|
13908
14096
|
if (w === void 0 || h === void 0) {
|
|
13909
14097
|
return { success: false, error: "Screenshot response missing dimensions." };
|
|
13910
14098
|
}
|
|
13911
|
-
|
|
13912
|
-
|
|
13913
|
-
|
|
13914
|
-
|
|
13915
|
-
|
|
14099
|
+
let buffer;
|
|
14100
|
+
let mimeType;
|
|
14101
|
+
if (response.encoding === "png") {
|
|
14102
|
+
if (!response.data) {
|
|
14103
|
+
return { success: false, error: "Screenshot response missing PNG data." };
|
|
14104
|
+
}
|
|
14105
|
+
buffer = Buffer.from(response.data, "base64");
|
|
14106
|
+
mimeType = "image/png";
|
|
14107
|
+
} else {
|
|
14108
|
+
const encoded = encodeImageFromRgbaResponse(response, fmt, q);
|
|
14109
|
+
buffer = encoded.buffer;
|
|
14110
|
+
mimeType = encoded.mimeType;
|
|
14111
|
+
}
|
|
13916
14112
|
let usedQ = q;
|
|
13917
14113
|
let note = "";
|
|
13918
14114
|
if (buffer.length > maxBytes) {
|
|
@@ -13951,6 +14147,9 @@ var RobloxStudioTools = class {
|
|
|
13951
14147
|
};
|
|
13952
14148
|
}
|
|
13953
14149
|
async captureScreenshot(instance_id, format, quality) {
|
|
14150
|
+
const refresh = this.bridge.refreshTopologyForRouting();
|
|
14151
|
+
if (refresh)
|
|
14152
|
+
await refresh;
|
|
13954
14153
|
const { instanceId, clientRole } = this._resolveRuntime(instance_id);
|
|
13955
14154
|
const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
|
|
13956
14155
|
if (!capture.success) {
|
|
@@ -14032,6 +14231,9 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14032
14231
|
initialRefresh;
|
|
14033
14232
|
refreshTimer;
|
|
14034
14233
|
static REFRESH_INTERVAL_MS = 1e3;
|
|
14234
|
+
static TOPOLOGY_TIMEOUT_MS = 2e3;
|
|
14235
|
+
topologyGeneration = 0;
|
|
14236
|
+
appliedTopologyGeneration = 0;
|
|
14035
14237
|
constructor(primaryBaseUrl, authToken) {
|
|
14036
14238
|
super();
|
|
14037
14239
|
this.primaryBaseUrl = primaryBaseUrl;
|
|
@@ -14049,17 +14251,33 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14049
14251
|
headers["X-MCP-Auth"] = this.authToken;
|
|
14050
14252
|
return headers;
|
|
14051
14253
|
}
|
|
14052
|
-
|
|
14254
|
+
refreshTopologyForRouting(signal) {
|
|
14255
|
+
return this.refreshTopology(signal, true);
|
|
14256
|
+
}
|
|
14257
|
+
async refreshTopology(signal, requireFresh = false) {
|
|
14258
|
+
const generation = ++this.topologyGeneration;
|
|
14259
|
+
const controller = new AbortController();
|
|
14260
|
+
const abort = () => controller.abort(signal?.reason);
|
|
14261
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
14262
|
+
if (signal?.aborted)
|
|
14263
|
+
abort();
|
|
14264
|
+
const timeout = setTimeout(() => controller.abort(new Error("Studio topology refresh timed out")), _ProxyBridgeService.TOPOLOGY_TIMEOUT_MS);
|
|
14053
14265
|
try {
|
|
14266
|
+
controller.signal.throwIfAborted();
|
|
14054
14267
|
const res = await fetch(`${this.primaryBaseUrl}/topology`, {
|
|
14055
|
-
headers: this.authHeaders()
|
|
14268
|
+
headers: this.authHeaders(),
|
|
14269
|
+
signal: controller.signal
|
|
14056
14270
|
});
|
|
14057
14271
|
if (!res.ok)
|
|
14058
|
-
|
|
14272
|
+
throw new Error(`Studio topology returned HTTP ${res.status}`);
|
|
14059
14273
|
const body = await res.json();
|
|
14060
|
-
|
|
14061
|
-
|
|
14274
|
+
controller.signal.throwIfAborted();
|
|
14275
|
+
if (!body || !Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
|
|
14276
|
+
throw new Error("Primary returned invalid Studio topology");
|
|
14062
14277
|
}
|
|
14278
|
+
if (generation < this.appliedTopologyGeneration)
|
|
14279
|
+
return;
|
|
14280
|
+
this.appliedTopologyGeneration = generation;
|
|
14063
14281
|
const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
|
|
14064
14282
|
this.cachedPeers = body.peers;
|
|
14065
14283
|
this.cachedInstances = body.instances;
|
|
@@ -14069,7 +14287,12 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14069
14287
|
this.notifyPeerRegistered(toPublicPeer(peer));
|
|
14070
14288
|
}
|
|
14071
14289
|
}
|
|
14072
|
-
} catch {
|
|
14290
|
+
} catch (error) {
|
|
14291
|
+
if (requireFresh)
|
|
14292
|
+
throw error;
|
|
14293
|
+
} finally {
|
|
14294
|
+
clearTimeout(timeout);
|
|
14295
|
+
signal?.removeEventListener("abort", abort);
|
|
14073
14296
|
}
|
|
14074
14297
|
}
|
|
14075
14298
|
getPeers() {
|
|
@@ -14124,6 +14347,13 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
|
|
|
14124
14347
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
14125
14348
|
body: JSON.stringify({ groupId })
|
|
14126
14349
|
});
|
|
14350
|
+
if (response.status === 409) {
|
|
14351
|
+
const refusal = await response.json().catch(() => void 0);
|
|
14352
|
+
if (refusal && typeof refusal === "object" && !Array.isArray(refusal) && "success" in refusal && refusal.success === false && "error" in refusal && refusal.error === "multiplayer_group_in_use" && "groupId" in refusal && refusal.groupId === groupId) {
|
|
14353
|
+
throw new MultiplayerGroupInUseError(groupId);
|
|
14354
|
+
}
|
|
14355
|
+
throw new Error("Proxy Multiplayer Group removal returned an invalid refusal.");
|
|
14356
|
+
}
|
|
14127
14357
|
if (!response.ok) {
|
|
14128
14358
|
const body = await response.text().catch(() => "");
|
|
14129
14359
|
throw new Error(`Proxy Multiplayer Group removal failed (${response.status}): ${body || response.statusText}`);
|
|
@@ -16459,7 +16689,7 @@ var getReadOnlyTools = () => TOOL_DEFINITIONS.filter((t) => t.category === "read
|
|
|
16459
16689
|
|
|
16460
16690
|
// ../core/dist/install-plugin-helpers.js
|
|
16461
16691
|
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";
|
|
16692
|
+
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
16693
|
import { execSync } from "child_process";
|
|
16464
16694
|
import { randomUUID as randomUUID5 } from "crypto";
|
|
16465
16695
|
import { basename as basename3, join as join5 } from "path";
|
|
@@ -16610,7 +16840,79 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
|
|
|
16610
16840
|
}
|
|
16611
16841
|
var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
|
|
16612
16842
|
function errorCode(error) {
|
|
16613
|
-
return error.code;
|
|
16843
|
+
return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
16844
|
+
}
|
|
16845
|
+
function pluginInstallLockError(lockPath, reason) {
|
|
16846
|
+
return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
|
|
16847
|
+
}
|
|
16848
|
+
function readPluginInstallLockOwner(lockPath) {
|
|
16849
|
+
const ownerPath = join5(lockPath, "owner.json");
|
|
16850
|
+
if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
|
|
16851
|
+
throw new Error("Lock and owner metadata must be a directory and regular file");
|
|
16852
|
+
}
|
|
16853
|
+
const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
|
|
16854
|
+
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) {
|
|
16855
|
+
throw new Error("Invalid lock owner metadata");
|
|
16856
|
+
}
|
|
16857
|
+
return { pid: owner.pid, token: owner.token, createdAt: owner.createdAt };
|
|
16858
|
+
}
|
|
16859
|
+
function assertPluginInstallOwnerExited(lockPath, owner) {
|
|
16860
|
+
if (owner.pid !== process.pid) {
|
|
16861
|
+
try {
|
|
16862
|
+
process.kill(owner.pid, 0);
|
|
16863
|
+
} catch (error) {
|
|
16864
|
+
if (errorCode(error) === "ESRCH")
|
|
16865
|
+
return;
|
|
16866
|
+
throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
|
|
16867
|
+
}
|
|
16868
|
+
}
|
|
16869
|
+
throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
|
|
16870
|
+
}
|
|
16871
|
+
function recoverPluginInstallLock(lockPath, warn) {
|
|
16872
|
+
let owner;
|
|
16873
|
+
try {
|
|
16874
|
+
owner = readPluginInstallLockOwner(lockPath);
|
|
16875
|
+
} catch (error) {
|
|
16876
|
+
throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
|
|
16877
|
+
}
|
|
16878
|
+
assertPluginInstallOwnerExited(lockPath, owner);
|
|
16879
|
+
const claimPath = join5(lockPath, `recovery-${owner.token}`);
|
|
16880
|
+
try {
|
|
16881
|
+
mkdirSync4(claimPath);
|
|
16882
|
+
} catch (error) {
|
|
16883
|
+
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})`);
|
|
16884
|
+
}
|
|
16885
|
+
const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
|
|
16886
|
+
let moved = false;
|
|
16887
|
+
try {
|
|
16888
|
+
let currentOwner;
|
|
16889
|
+
try {
|
|
16890
|
+
currentOwner = readPluginInstallLockOwner(lockPath);
|
|
16891
|
+
} catch (error) {
|
|
16892
|
+
throw pluginInstallLockError(lockPath, `Cannot safely recheck plugin installation lock owner (${error})`);
|
|
16893
|
+
}
|
|
16894
|
+
if (currentOwner.pid !== owner.pid || currentOwner.token !== owner.token || currentOwner.createdAt !== owner.createdAt) {
|
|
16895
|
+
throw pluginInstallLockError(lockPath, "Plugin installation lock owner changed; retry installation");
|
|
16896
|
+
}
|
|
16897
|
+
assertPluginInstallOwnerExited(lockPath, currentOwner);
|
|
16898
|
+
renameSync(lockPath, abandonedPath);
|
|
16899
|
+
moved = true;
|
|
16900
|
+
} finally {
|
|
16901
|
+
if (!moved) {
|
|
16902
|
+
try {
|
|
16903
|
+
rmdirSync(claimPath);
|
|
16904
|
+
} catch (error) {
|
|
16905
|
+
if (errorCode(error) !== "ENOENT") {
|
|
16906
|
+
warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
|
|
16907
|
+
}
|
|
16908
|
+
}
|
|
16909
|
+
}
|
|
16910
|
+
}
|
|
16911
|
+
try {
|
|
16912
|
+
rmSync2(abandonedPath, { recursive: true, force: true });
|
|
16913
|
+
} catch (error) {
|
|
16914
|
+
warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
|
|
16915
|
+
}
|
|
16614
16916
|
}
|
|
16615
16917
|
function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
16616
16918
|
const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
|
|
@@ -16625,7 +16927,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
16625
16927
|
} catch (error) {
|
|
16626
16928
|
if (errorCode(error) !== "EEXIST")
|
|
16627
16929
|
throw error;
|
|
16628
|
-
|
|
16930
|
+
recoverPluginInstallLock(lockPath, warn);
|
|
16931
|
+
try {
|
|
16932
|
+
mkdirSync4(lockPath);
|
|
16933
|
+
} catch (retryError) {
|
|
16934
|
+
if (errorCode(retryError) !== "EEXIST")
|
|
16935
|
+
throw retryError;
|
|
16936
|
+
throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
|
|
16937
|
+
}
|
|
16629
16938
|
}
|
|
16630
16939
|
try {
|
|
16631
16940
|
writeFileSync3(ownerPath, `${JSON.stringify(owner)}
|
|
@@ -16643,7 +16952,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
|
|
|
16643
16952
|
}
|
|
16644
16953
|
return () => {
|
|
16645
16954
|
try {
|
|
16646
|
-
const currentOwner =
|
|
16955
|
+
const currentOwner = readPluginInstallLockOwner(lockPath);
|
|
16647
16956
|
if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
|
|
16648
16957
|
rmSync2(lockPath, { recursive: true, force: true });
|
|
16649
16958
|
}
|