@chrrxs/robloxstudio-mcp 2.17.1 → 2.18.0
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 +931 -162
- package/package.json +1 -1
- package/studio-plugin/MCPInspectorPlugin.rbxmx +3 -3
- package/studio-plugin/MCPPlugin.rbxmx +3 -3
package/dist/index.js
CHANGED
|
@@ -188,6 +188,14 @@ var init_bridge_service = __esm({
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
|
+
unregisterInstanceId(instanceId) {
|
|
192
|
+
const matching = this.matchingInstancesForInstanceId(instanceId);
|
|
193
|
+
const removed = matching.map(toPublic);
|
|
194
|
+
for (const inst of matching) {
|
|
195
|
+
this.unregisterInstance(inst.pluginSessionId);
|
|
196
|
+
}
|
|
197
|
+
return removed;
|
|
198
|
+
}
|
|
191
199
|
getInstances() {
|
|
192
200
|
return Array.from(this.instances.values());
|
|
193
201
|
}
|
|
@@ -889,8 +897,11 @@ var init_http_server = __esm({
|
|
|
889
897
|
get_device_simulator_state: (tools, body) => tools.getDeviceSimulatorState(body.target, body.deviceId, body.includeDeviceList, body.instance_id),
|
|
890
898
|
set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
|
|
891
899
|
capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
|
|
900
|
+
manage_instance: (tools, body) => tools.manageInstance(body),
|
|
901
|
+
solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
|
|
892
902
|
start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
|
|
893
903
|
stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
|
|
904
|
+
multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
|
|
894
905
|
multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id),
|
|
895
906
|
multiplayer_test_state: (tools, body) => tools.multiplayerTestState(body.instance_id),
|
|
896
907
|
multiplayer_test_add_players: (tools, body) => tools.multiplayerTestAddPlayers(body.numPlayers, body.timeout, body.instance_id),
|
|
@@ -1469,6 +1480,14 @@ var init_opencloud_client = __esm({
|
|
|
1469
1480
|
async getAssetDetails(assetId) {
|
|
1470
1481
|
return this.request(`/toolbox-service/v2/assets/${assetId}`);
|
|
1471
1482
|
}
|
|
1483
|
+
async listAssetVersions(assetId, maxPageSize = 10, pageToken) {
|
|
1484
|
+
return this.request(`/assets/v1/assets/${assetId}/versions`, {
|
|
1485
|
+
params: {
|
|
1486
|
+
maxPageSize,
|
|
1487
|
+
pageToken
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1472
1491
|
async getAssetThumbnail(assetId, size = "420x420") {
|
|
1473
1492
|
const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
|
|
1474
1493
|
try {
|
|
@@ -1697,6 +1716,254 @@ var init_roblox_cookie_client = __esm({
|
|
|
1697
1716
|
}
|
|
1698
1717
|
});
|
|
1699
1718
|
|
|
1719
|
+
// ../core/dist/studio-instance-manager.js
|
|
1720
|
+
import { execFileSync, spawn } from "child_process";
|
|
1721
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
1722
|
+
import * as os from "os";
|
|
1723
|
+
import * as path from "path";
|
|
1724
|
+
function run(command, args, options = {}) {
|
|
1725
|
+
return execFileSync(command, args, {
|
|
1726
|
+
encoding: "utf8",
|
|
1727
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1728
|
+
...options
|
|
1729
|
+
}).trim();
|
|
1730
|
+
}
|
|
1731
|
+
function isWsl() {
|
|
1732
|
+
if (process.platform !== "linux")
|
|
1733
|
+
return false;
|
|
1734
|
+
try {
|
|
1735
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
1736
|
+
} catch {
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
function powershell(script) {
|
|
1741
|
+
return run("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
1742
|
+
cwd: isWsl() && existsSync("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
|
|
1743
|
+
});
|
|
1744
|
+
}
|
|
1745
|
+
function windowsLocalAppData() {
|
|
1746
|
+
if (process.platform === "win32")
|
|
1747
|
+
return process.env.LOCALAPPDATA;
|
|
1748
|
+
if (!isWsl())
|
|
1749
|
+
return void 0;
|
|
1750
|
+
try {
|
|
1751
|
+
return run("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], {
|
|
1752
|
+
cwd: existsSync("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
|
|
1753
|
+
});
|
|
1754
|
+
} catch {
|
|
1755
|
+
return void 0;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
function toWslPath(windowsPath) {
|
|
1759
|
+
if (!isWsl())
|
|
1760
|
+
return windowsPath;
|
|
1761
|
+
return run("wslpath", ["-u", windowsPath]);
|
|
1762
|
+
}
|
|
1763
|
+
function toStudioLaunchArg(arg) {
|
|
1764
|
+
if (!isWsl() || !path.isAbsolute(arg) || !existsSync(arg))
|
|
1765
|
+
return arg;
|
|
1766
|
+
return run("wslpath", ["-w", arg]);
|
|
1767
|
+
}
|
|
1768
|
+
function resolveStudioExe() {
|
|
1769
|
+
if (process.env.ROBLOX_STUDIO_EXE)
|
|
1770
|
+
return process.env.ROBLOX_STUDIO_EXE;
|
|
1771
|
+
if (process.platform === "darwin") {
|
|
1772
|
+
return "/Applications/RobloxStudio.app/Contents/MacOS/RobloxStudio";
|
|
1773
|
+
}
|
|
1774
|
+
if (process.platform !== "win32" && !isWsl()) {
|
|
1775
|
+
throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
|
|
1776
|
+
}
|
|
1777
|
+
const localAppData = windowsLocalAppData();
|
|
1778
|
+
const root = localAppData ? path.join(toWslPath(localAppData), "Roblox", "Versions") : path.join(os.homedir(), "AppData", "Local", "Roblox", "Versions");
|
|
1779
|
+
if (!existsSync(root)) {
|
|
1780
|
+
throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
1781
|
+
}
|
|
1782
|
+
const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
1783
|
+
if (candidates.length === 0) {
|
|
1784
|
+
throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
|
|
1785
|
+
}
|
|
1786
|
+
return candidates[0];
|
|
1787
|
+
}
|
|
1788
|
+
function listStudioProcesses() {
|
|
1789
|
+
if (process.platform === "darwin") {
|
|
1790
|
+
let out2 = "";
|
|
1791
|
+
try {
|
|
1792
|
+
out2 = run("pgrep", ["-fl", "RobloxStudio"]);
|
|
1793
|
+
} catch {
|
|
1794
|
+
return [];
|
|
1795
|
+
}
|
|
1796
|
+
return out2.split("\n").filter(Boolean).map((line) => {
|
|
1797
|
+
const [pid, ...rest] = line.trim().split(/\s+/);
|
|
1798
|
+
return { Id: Number(pid), Path: rest.join(" "), MainWindowTitle: "" };
|
|
1799
|
+
}).filter((proc) => Number.isFinite(proc.Id));
|
|
1800
|
+
}
|
|
1801
|
+
if (process.platform !== "win32" && !isWsl())
|
|
1802
|
+
return [];
|
|
1803
|
+
let out = "";
|
|
1804
|
+
try {
|
|
1805
|
+
out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | Select-Object Id,Path,MainWindowTitle | ConvertTo-Json -Compress");
|
|
1806
|
+
} catch {
|
|
1807
|
+
return [];
|
|
1808
|
+
}
|
|
1809
|
+
if (!out)
|
|
1810
|
+
return [];
|
|
1811
|
+
const parsed = JSON.parse(out);
|
|
1812
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
1813
|
+
}
|
|
1814
|
+
function buildStudioLaunchArgs(options) {
|
|
1815
|
+
switch (options.source) {
|
|
1816
|
+
case "baseplate":
|
|
1817
|
+
return [];
|
|
1818
|
+
case "local_file":
|
|
1819
|
+
if (!options.localPlaceFile)
|
|
1820
|
+
throw new Error('local_place_file is required when source="local_file".');
|
|
1821
|
+
return ["--task", "EditFile", "--localPlaceFile", options.localPlaceFile];
|
|
1822
|
+
case "published_place":
|
|
1823
|
+
if (!options.placeId)
|
|
1824
|
+
throw new Error('place_id is required when source="published_place".');
|
|
1825
|
+
if (!options.universeId)
|
|
1826
|
+
throw new Error('universe_id is required when source="published_place".');
|
|
1827
|
+
return ["--task", "EditPlace", "--placeId", String(options.placeId), "--universeId", String(options.universeId)];
|
|
1828
|
+
case "place_revision":
|
|
1829
|
+
if (!options.placeId)
|
|
1830
|
+
throw new Error('place_id is required when source="place_revision".');
|
|
1831
|
+
if (!options.universeId)
|
|
1832
|
+
throw new Error('universe_id is required when source="place_revision".');
|
|
1833
|
+
if (!options.placeVersion)
|
|
1834
|
+
throw new Error('place_version is required when launching source="place_revision".');
|
|
1835
|
+
return [
|
|
1836
|
+
"--task",
|
|
1837
|
+
"EditPlaceRevision",
|
|
1838
|
+
"--placeId",
|
|
1839
|
+
String(options.placeId),
|
|
1840
|
+
"--universeId",
|
|
1841
|
+
String(options.universeId),
|
|
1842
|
+
"--placeVersion",
|
|
1843
|
+
String(options.placeVersion)
|
|
1844
|
+
];
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
function delay(ms) {
|
|
1848
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1849
|
+
}
|
|
1850
|
+
var StudioInstanceManager;
|
|
1851
|
+
var init_studio_instance_manager = __esm({
|
|
1852
|
+
"../core/dist/studio-instance-manager.js"() {
|
|
1853
|
+
"use strict";
|
|
1854
|
+
StudioInstanceManager = class {
|
|
1855
|
+
managedByInstanceId = /* @__PURE__ */ new Map();
|
|
1856
|
+
pending = /* @__PURE__ */ new Set();
|
|
1857
|
+
list() {
|
|
1858
|
+
return [...this.managedByInstanceId.values(), ...this.pending].filter((instance, index, all) => all.indexOf(instance) === index);
|
|
1859
|
+
}
|
|
1860
|
+
get(instanceId) {
|
|
1861
|
+
return this.managedByInstanceId.get(instanceId);
|
|
1862
|
+
}
|
|
1863
|
+
attachInstanceId(record, instanceId) {
|
|
1864
|
+
record.instanceId = instanceId;
|
|
1865
|
+
this.pending.delete(record);
|
|
1866
|
+
this.managedByInstanceId.set(instanceId, record);
|
|
1867
|
+
}
|
|
1868
|
+
async launch(options) {
|
|
1869
|
+
const before = new Set(listStudioProcesses().map((proc2) => proc2.Id));
|
|
1870
|
+
const exe = resolveStudioExe();
|
|
1871
|
+
const args = buildStudioLaunchArgs(options).map(toStudioLaunchArg);
|
|
1872
|
+
const proc = spawn(exe, args, {
|
|
1873
|
+
cwd: isWsl() && existsSync("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
|
|
1874
|
+
detached: true,
|
|
1875
|
+
stdio: "ignore"
|
|
1876
|
+
});
|
|
1877
|
+
proc.unref();
|
|
1878
|
+
const record = {
|
|
1879
|
+
source: options.source,
|
|
1880
|
+
spawnPid: proc.pid,
|
|
1881
|
+
exe,
|
|
1882
|
+
args,
|
|
1883
|
+
placeId: options.placeId,
|
|
1884
|
+
universeId: options.universeId,
|
|
1885
|
+
placeVersion: options.placeVersion,
|
|
1886
|
+
localPlaceFile: options.localPlaceFile,
|
|
1887
|
+
launchedAt: Date.now()
|
|
1888
|
+
};
|
|
1889
|
+
this.pending.add(record);
|
|
1890
|
+
const deadline = Date.now() + 5e3;
|
|
1891
|
+
while (Date.now() < deadline && record.nativeProcessId === void 0) {
|
|
1892
|
+
const created = listStudioProcesses().find((candidate) => !before.has(candidate.Id));
|
|
1893
|
+
if (created) {
|
|
1894
|
+
record.nativeProcessId = created.Id;
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
await delay(250);
|
|
1898
|
+
}
|
|
1899
|
+
if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
|
|
1900
|
+
record.nativeProcessId = proc.pid;
|
|
1901
|
+
}
|
|
1902
|
+
return record;
|
|
1903
|
+
}
|
|
1904
|
+
close(record) {
|
|
1905
|
+
const processId = record.nativeProcessId ?? record.spawnPid;
|
|
1906
|
+
if (!processId) {
|
|
1907
|
+
throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
|
|
1908
|
+
}
|
|
1909
|
+
if (process.platform === "win32" || isWsl()) {
|
|
1910
|
+
powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
|
|
1911
|
+
} else {
|
|
1912
|
+
try {
|
|
1913
|
+
process.kill(processId, "SIGTERM");
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
if (error.code !== "ESRCH")
|
|
1916
|
+
throw error;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
record.closedAt = Date.now();
|
|
1920
|
+
if (record.instanceId)
|
|
1921
|
+
this.managedByInstanceId.delete(record.instanceId);
|
|
1922
|
+
this.pending.delete(record);
|
|
1923
|
+
}
|
|
1924
|
+
closeConnectedInstance(instance) {
|
|
1925
|
+
const process2 = this.findProcessForConnectedInstance(instance);
|
|
1926
|
+
if (!process2) {
|
|
1927
|
+
throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
|
|
1928
|
+
}
|
|
1929
|
+
this.closeProcess(process2.Id);
|
|
1930
|
+
}
|
|
1931
|
+
closeProcess(processId) {
|
|
1932
|
+
if (process.platform === "win32" || isWsl()) {
|
|
1933
|
+
powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
|
|
1934
|
+
} else {
|
|
1935
|
+
try {
|
|
1936
|
+
process.kill(processId, "SIGTERM");
|
|
1937
|
+
} catch (error) {
|
|
1938
|
+
if (error.code !== "ESRCH")
|
|
1939
|
+
throw error;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
findProcessForConnectedInstance(instance) {
|
|
1944
|
+
const processes = listStudioProcesses();
|
|
1945
|
+
if (processes.length === 0)
|
|
1946
|
+
return void 0;
|
|
1947
|
+
if (processes.length === 1)
|
|
1948
|
+
return processes[0];
|
|
1949
|
+
const names = [instance.dataModelName, instance.placeName].map((name) => name.trim()).filter((name, index, all) => name.length > 0 && all.indexOf(name) === index);
|
|
1950
|
+
const candidates = processes.filter((proc) => {
|
|
1951
|
+
const title = (proc.MainWindowTitle ?? "").trim();
|
|
1952
|
+
if (!title)
|
|
1953
|
+
return false;
|
|
1954
|
+
return names.some((name) => title === `${name} - Roblox Studio` || title.startsWith(`${name} - `) || title.startsWith(`${name} (`));
|
|
1955
|
+
});
|
|
1956
|
+
if (candidates.length === 1)
|
|
1957
|
+
return candidates[0];
|
|
1958
|
+
if (candidates.length > 1) {
|
|
1959
|
+
throw new Error(`Multiple Studio processes matched connected instance "${instance.instanceId}".`);
|
|
1960
|
+
}
|
|
1961
|
+
return void 0;
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1700
1967
|
// ../core/dist/jpeg-encoder.js
|
|
1701
1968
|
function rgbaToJpeg(rgba, width, height, quality = 80) {
|
|
1702
1969
|
if (width <= 0 || height <= 0)
|
|
@@ -2715,8 +2982,8 @@ var init_png_encoder = __esm({
|
|
|
2715
2982
|
|
|
2716
2983
|
// ../core/dist/tools/index.js
|
|
2717
2984
|
import * as fs from "fs";
|
|
2718
|
-
import * as
|
|
2719
|
-
import * as
|
|
2985
|
+
import * as os2 from "os";
|
|
2986
|
+
import * as path2 from "path";
|
|
2720
2987
|
function encodeImageFromRgbaResponse(response, format, quality) {
|
|
2721
2988
|
if (!response.data || response.width === void 0 || response.height === void 0) {
|
|
2722
2989
|
throw new Error("Render response missing data, width, or height");
|
|
@@ -3072,6 +3339,7 @@ var init_tools = __esm({
|
|
|
3072
3339
|
init_build_executor();
|
|
3073
3340
|
init_opencloud_client();
|
|
3074
3341
|
init_roblox_cookie_client();
|
|
3342
|
+
init_studio_instance_manager();
|
|
3075
3343
|
init_jpeg_encoder();
|
|
3076
3344
|
init_png_encoder();
|
|
3077
3345
|
MAX_INLINE_IMAGE_BYTES = 6e6;
|
|
@@ -3129,11 +3397,34 @@ var init_tools = __esm({
|
|
|
3129
3397
|
bridge;
|
|
3130
3398
|
openCloudClient;
|
|
3131
3399
|
cookieClient;
|
|
3400
|
+
instanceManager;
|
|
3132
3401
|
constructor(bridge) {
|
|
3133
3402
|
this.client = new StudioHttpClient(bridge);
|
|
3134
3403
|
this.bridge = bridge;
|
|
3135
3404
|
this.openCloudClient = new OpenCloudClient();
|
|
3136
3405
|
this.cookieClient = new RobloxCookieClient();
|
|
3406
|
+
this.instanceManager = new StudioInstanceManager();
|
|
3407
|
+
}
|
|
3408
|
+
_textResult(body) {
|
|
3409
|
+
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
3410
|
+
}
|
|
3411
|
+
_parseTextResult(result) {
|
|
3412
|
+
const text = result?.content?.[0]?.text;
|
|
3413
|
+
if (typeof text !== "string")
|
|
3414
|
+
return {};
|
|
3415
|
+
try {
|
|
3416
|
+
const parsed = JSON.parse(text);
|
|
3417
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3418
|
+
} catch {
|
|
3419
|
+
return {};
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
_briefRoles(instanceId, equivalentInstances = false) {
|
|
3423
|
+
const roles = equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId);
|
|
3424
|
+
return {
|
|
3425
|
+
roles,
|
|
3426
|
+
runtimeRoles: roles.filter((role) => role === "server" || /^client-\d+$/.test(role))
|
|
3427
|
+
};
|
|
3137
3428
|
}
|
|
3138
3429
|
// Resolve (instance_id, target-role) → concrete (instanceId, role) and
|
|
3139
3430
|
// dispatch a single request. Throws RoutingFailure if the resolution is
|
|
@@ -3458,8 +3749,8 @@ var init_tools = __esm({
|
|
|
3458
3749
|
timedOut: true
|
|
3459
3750
|
};
|
|
3460
3751
|
}
|
|
3461
|
-
async getFileTree(
|
|
3462
|
-
const response = await this._callSingle("/api/file-tree", { path:
|
|
3752
|
+
async getFileTree(path3 = "", instance_id) {
|
|
3753
|
+
const response = await this._callSingle("/api/file-tree", { path: path3 }, void 0, instance_id);
|
|
3463
3754
|
return {
|
|
3464
3755
|
content: [
|
|
3465
3756
|
{
|
|
@@ -3576,9 +3867,9 @@ var init_tools = __esm({
|
|
|
3576
3867
|
]
|
|
3577
3868
|
};
|
|
3578
3869
|
}
|
|
3579
|
-
async getProjectStructure(
|
|
3870
|
+
async getProjectStructure(path3, maxDepth, scriptsOnly, instance_id) {
|
|
3580
3871
|
const response = await this._callSingle("/api/project-structure", {
|
|
3581
|
-
path:
|
|
3872
|
+
path: path3,
|
|
3582
3873
|
maxDepth,
|
|
3583
3874
|
scriptsOnly
|
|
3584
3875
|
}, void 0, instance_id);
|
|
@@ -4499,8 +4790,8 @@ ${code}`
|
|
|
4499
4790
|
const rawJson = mutable.raw_json;
|
|
4500
4791
|
if (typeof rawJson === "string") {
|
|
4501
4792
|
if (typeof outputPath === "string" && outputPath !== "") {
|
|
4502
|
-
const resolvedOutputPath =
|
|
4503
|
-
fs.mkdirSync(
|
|
4793
|
+
const resolvedOutputPath = path2.resolve(outputPath);
|
|
4794
|
+
fs.mkdirSync(path2.dirname(resolvedOutputPath), { recursive: true });
|
|
4504
4795
|
fs.writeFileSync(resolvedOutputPath, rawJson, "utf8");
|
|
4505
4796
|
mutable.output_path = resolvedOutputPath;
|
|
4506
4797
|
}
|
|
@@ -4536,12 +4827,272 @@ ${code}`
|
|
|
4536
4827
|
const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
|
|
4537
4828
|
return { content: [{ type: "text", text: JSON.stringify(body) }] };
|
|
4538
4829
|
}
|
|
4539
|
-
|
|
4830
|
+
_positiveInteger(value, name) {
|
|
4831
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
4832
|
+
throw new Error(`${name} must be a positive number.`);
|
|
4833
|
+
}
|
|
4834
|
+
return Math.trunc(value);
|
|
4835
|
+
}
|
|
4836
|
+
_optionalPositiveInteger(value, name) {
|
|
4837
|
+
if (value === void 0 || value === null)
|
|
4838
|
+
return void 0;
|
|
4839
|
+
return this._positiveInteger(value, name);
|
|
4840
|
+
}
|
|
4841
|
+
_publicInstanceKey(instance) {
|
|
4842
|
+
return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
|
|
4843
|
+
}
|
|
4844
|
+
_isLatestPublishedPlaceOpen(placeId) {
|
|
4845
|
+
const publishedInstanceId2 = `place:${placeId}`;
|
|
4846
|
+
return this.bridge.getPublicInstances().some((instance) => instance.placeId === placeId || instance.instanceId === publishedInstanceId2) || this.instanceManager.list().some((record) => record.closedAt === void 0 && record.source === "published_place" && record.placeId === placeId);
|
|
4847
|
+
}
|
|
4848
|
+
async _deriveUniverseId(placeId) {
|
|
4849
|
+
const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
|
|
4850
|
+
if (!response.ok) {
|
|
4851
|
+
const body = await response.text().catch(() => "");
|
|
4852
|
+
throw new Error(`Could not derive universe_id for place ${placeId} (${response.status}): ${body}`);
|
|
4853
|
+
}
|
|
4854
|
+
const data = await response.json();
|
|
4855
|
+
if (typeof data.universeId !== "number" || !Number.isFinite(data.universeId)) {
|
|
4856
|
+
throw new Error(`Could not derive universe_id for place ${placeId}.`);
|
|
4857
|
+
}
|
|
4858
|
+
return Math.trunc(data.universeId);
|
|
4859
|
+
}
|
|
4860
|
+
async _waitForManagedEditConnection(record, beforeKeys, timeoutMs) {
|
|
4861
|
+
const deadline = Date.now() + timeoutMs;
|
|
4862
|
+
while (Date.now() < deadline) {
|
|
4863
|
+
const candidates = this.bridge.getPublicInstances().filter((instance) => instance.role === "edit").filter((instance) => !beforeKeys.has(this._publicInstanceKey(instance))).filter((instance) => instance.connectedAt >= record.launchedAt - 1e3).filter((instance) => {
|
|
4864
|
+
if (record.source === "published_place") {
|
|
4865
|
+
return record.placeId !== void 0 && instance.placeId === record.placeId;
|
|
4866
|
+
}
|
|
4867
|
+
return true;
|
|
4868
|
+
}).sort((a, b) => b.connectedAt - a.connectedAt);
|
|
4869
|
+
if (candidates[0])
|
|
4870
|
+
return candidates[0];
|
|
4871
|
+
await sleep(500);
|
|
4872
|
+
}
|
|
4873
|
+
return void 0;
|
|
4874
|
+
}
|
|
4875
|
+
_managedStatus(record) {
|
|
4876
|
+
const connected = record.instanceId ? this.bridge.getPublicInstances().filter((instance) => instance.instanceId === record.instanceId) : [];
|
|
4877
|
+
return {
|
|
4878
|
+
instance_id: record.instanceId,
|
|
4879
|
+
source: record.source,
|
|
4880
|
+
place_id: record.placeId,
|
|
4881
|
+
place_version: record.placeVersion,
|
|
4882
|
+
connected: connected.length > 0,
|
|
4883
|
+
roles: connected.map((instance) => instance.role).sort()
|
|
4884
|
+
};
|
|
4885
|
+
}
|
|
4886
|
+
_versionNumberFromPath(pathValue) {
|
|
4887
|
+
const match = pathValue.match(/\/versions\/(\d+)$/);
|
|
4888
|
+
return match ? Number(match[1]) : void 0;
|
|
4889
|
+
}
|
|
4890
|
+
async manageInstance(request) {
|
|
4891
|
+
const action = request.action;
|
|
4892
|
+
const instance_id = typeof request.instance_id === "string" ? request.instance_id : void 0;
|
|
4893
|
+
if (action !== "launch" && action !== "close" && action !== "status" && action !== "list_place_versions") {
|
|
4894
|
+
throw new Error("manage_instance requires action=launch|close|status|list_place_versions");
|
|
4895
|
+
}
|
|
4896
|
+
if (action === "list_place_versions") {
|
|
4897
|
+
if (!this.openCloudClient.hasApiKey()) {
|
|
4898
|
+
return this._textResult({
|
|
4899
|
+
error: "ROBLOX_OPEN_CLOUD_API_KEY is required to list place versions."
|
|
4900
|
+
});
|
|
4901
|
+
}
|
|
4902
|
+
const placeId2 = this._positiveInteger(request.place_id, "place_id");
|
|
4903
|
+
const rawMaxPageSize = this._optionalPositiveInteger(request.max_page_size, "max_page_size") ?? 10;
|
|
4904
|
+
const maxPageSize = Math.max(1, Math.min(50, rawMaxPageSize));
|
|
4905
|
+
const pageToken = typeof request.page_token === "string" ? request.page_token : void 0;
|
|
4906
|
+
const response = await this.openCloudClient.listAssetVersions(placeId2, maxPageSize, pageToken);
|
|
4907
|
+
const body = {
|
|
4908
|
+
versions: (response.assetVersions ?? []).map((version) => ({
|
|
4909
|
+
version: this._versionNumberFromPath(version.path),
|
|
4910
|
+
created_at: version.createTime,
|
|
4911
|
+
path: version.path,
|
|
4912
|
+
moderation_state: version.moderationResult?.moderationState
|
|
4913
|
+
}))
|
|
4914
|
+
};
|
|
4915
|
+
if (response.nextPageToken)
|
|
4916
|
+
body.next_page_token = response.nextPageToken;
|
|
4917
|
+
return this._textResult(body);
|
|
4918
|
+
}
|
|
4919
|
+
if (action === "status") {
|
|
4920
|
+
if (instance_id) {
|
|
4921
|
+
const record2 = this.instanceManager.get(instance_id);
|
|
4922
|
+
const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
|
|
4923
|
+
if (!record2 && connected2.length === 0) {
|
|
4924
|
+
return this._textResult({ error: "Instance is not connected or managed.", instance_id });
|
|
4925
|
+
}
|
|
4926
|
+
return this._textResult({
|
|
4927
|
+
instance_id,
|
|
4928
|
+
managed: !!record2,
|
|
4929
|
+
source: record2?.source,
|
|
4930
|
+
place_id: record2?.placeId ?? connected2[0]?.placeId,
|
|
4931
|
+
place_version: record2?.placeVersion,
|
|
4932
|
+
roles: connected2.map((instance) => instance.role).sort()
|
|
4933
|
+
});
|
|
4934
|
+
}
|
|
4935
|
+
return this._textResult({
|
|
4936
|
+
managed: this.instanceManager.list().filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
|
|
4937
|
+
connected: this.bridge.getPublicInstances().map((instance) => ({
|
|
4938
|
+
instance_id: instance.instanceId,
|
|
4939
|
+
role: instance.role,
|
|
4940
|
+
place_id: instance.placeId,
|
|
4941
|
+
place_name: instance.placeName
|
|
4942
|
+
}))
|
|
4943
|
+
});
|
|
4944
|
+
}
|
|
4945
|
+
if (action === "close") {
|
|
4946
|
+
let record2;
|
|
4947
|
+
if (instance_id) {
|
|
4948
|
+
record2 = this.instanceManager.get(instance_id);
|
|
4949
|
+
if (!record2) {
|
|
4950
|
+
const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
|
|
4951
|
+
const edit = connected2.find((instance) => instance.role === "edit");
|
|
4952
|
+
if (!edit) {
|
|
4953
|
+
return this._textResult({
|
|
4954
|
+
error: "Instance is not connected or managed.",
|
|
4955
|
+
instance_id
|
|
4956
|
+
});
|
|
4957
|
+
}
|
|
4958
|
+
try {
|
|
4959
|
+
this.instanceManager.closeConnectedInstance(edit);
|
|
4960
|
+
} catch (error) {
|
|
4961
|
+
return this._textResult({
|
|
4962
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4963
|
+
instance_id
|
|
4964
|
+
});
|
|
4965
|
+
}
|
|
4966
|
+
this.bridge.unregisterInstanceId(instance_id);
|
|
4967
|
+
return this._textResult({
|
|
4968
|
+
instance_id,
|
|
4969
|
+
message: "Studio instance closed."
|
|
4970
|
+
});
|
|
4971
|
+
}
|
|
4972
|
+
} else {
|
|
4973
|
+
const active = this.instanceManager.list().filter((entry) => entry.closedAt === void 0);
|
|
4974
|
+
if (active.length === 0) {
|
|
4975
|
+
return this._textResult({ message: "No managed Studio instances are active." });
|
|
4976
|
+
}
|
|
4977
|
+
if (active.length > 1) {
|
|
4978
|
+
return this._textResult({
|
|
4979
|
+
error: "instance_id is required because multiple managed Studio instances are active.",
|
|
4980
|
+
managed: active.map((entry) => this._managedStatus(entry))
|
|
4981
|
+
});
|
|
4982
|
+
}
|
|
4983
|
+
record2 = active[0];
|
|
4984
|
+
}
|
|
4985
|
+
this.instanceManager.close(record2);
|
|
4986
|
+
if (record2.instanceId)
|
|
4987
|
+
this.bridge.unregisterInstanceId(record2.instanceId);
|
|
4988
|
+
return this._textResult({
|
|
4989
|
+
instance_id: record2.instanceId,
|
|
4990
|
+
message: "Studio instance closed."
|
|
4991
|
+
});
|
|
4992
|
+
}
|
|
4993
|
+
const source = request.source;
|
|
4994
|
+
if (source !== "baseplate" && source !== "local_file" && source !== "published_place" && source !== "place_revision") {
|
|
4995
|
+
throw new Error("manage_instance action=launch requires source=baseplate|local_file|published_place|place_revision");
|
|
4996
|
+
}
|
|
4997
|
+
const launchSource = source;
|
|
4998
|
+
const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : this._optionalPositiveInteger(request.place_id, "place_id");
|
|
4999
|
+
const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : this._optionalPositiveInteger(request.place_version, "place_version");
|
|
5000
|
+
const localPlaceFile = typeof request.local_place_file === "string" ? request.local_place_file : void 0;
|
|
5001
|
+
if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
|
|
5002
|
+
return this._textResult({
|
|
5003
|
+
error: "Place is already open.",
|
|
5004
|
+
message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
|
|
5005
|
+
});
|
|
5006
|
+
}
|
|
5007
|
+
const universeId = launchSource === "published_place" || launchSource === "place_revision" ? this._optionalPositiveInteger(request.universe_id, "universe_id") ?? await this._deriveUniverseId(placeId) : this._optionalPositiveInteger(request.universe_id, "universe_id");
|
|
5008
|
+
const waitForConnection = request.wait_for_connection !== false;
|
|
5009
|
+
const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
|
|
5010
|
+
const beforeKeys = new Set(this.bridge.getPublicInstances().map((instance) => this._publicInstanceKey(instance)));
|
|
5011
|
+
const record = await this.instanceManager.launch({
|
|
5012
|
+
source: launchSource,
|
|
5013
|
+
localPlaceFile,
|
|
5014
|
+
placeId,
|
|
5015
|
+
universeId,
|
|
5016
|
+
placeVersion
|
|
5017
|
+
});
|
|
5018
|
+
if (!waitForConnection) {
|
|
5019
|
+
return this._textResult({ message: "Studio launch requested." });
|
|
5020
|
+
}
|
|
5021
|
+
const connected = await this._waitForManagedEditConnection(record, beforeKeys, timeoutMs);
|
|
5022
|
+
if (!connected) {
|
|
5023
|
+
try {
|
|
5024
|
+
this.instanceManager.close(record);
|
|
5025
|
+
} catch {
|
|
5026
|
+
}
|
|
5027
|
+
return this._textResult({
|
|
5028
|
+
error: "Studio launched, but the MCP plugin did not connect before timeout."
|
|
5029
|
+
});
|
|
5030
|
+
}
|
|
5031
|
+
this.instanceManager.attachInstanceId(record, connected.instanceId);
|
|
5032
|
+
return this._textResult({
|
|
5033
|
+
instance_id: connected.instanceId,
|
|
5034
|
+
message: launchSource === "place_revision" ? `Studio opened place revision ${placeVersion}.` : "Studio opened."
|
|
5035
|
+
});
|
|
5036
|
+
}
|
|
5037
|
+
async soloPlaytest(action, mode, timeout, instance_id) {
|
|
5038
|
+
if (action !== "start" && action !== "stop" && action !== "status") {
|
|
5039
|
+
throw new Error("solo_playtest requires action=start|stop|status");
|
|
5040
|
+
}
|
|
5041
|
+
if (action === "status") {
|
|
5042
|
+
const instanceId = this._resolveInstanceIdOnly(instance_id);
|
|
5043
|
+
const { roles, runtimeRoles } = this._briefRoles(instanceId, true);
|
|
5044
|
+
return this._textResult({
|
|
5045
|
+
success: true,
|
|
5046
|
+
action,
|
|
5047
|
+
running: runtimeRoles.length > 0,
|
|
5048
|
+
roles
|
|
5049
|
+
});
|
|
5050
|
+
}
|
|
5051
|
+
if (action === "start") {
|
|
5052
|
+
if (mode !== "play" && mode !== "run") {
|
|
5053
|
+
throw new Error("solo_playtest action=start requires mode=play|run");
|
|
5054
|
+
}
|
|
5055
|
+
const body2 = this._parseTextResult(await this.startPlaytest(mode, void 0, instance_id, timeout));
|
|
5056
|
+
if (body2.success === true && body2.runtimeReady !== false) {
|
|
5057
|
+
return this._textResult({
|
|
5058
|
+
success: true,
|
|
5059
|
+
action,
|
|
5060
|
+
message: "Playtest started.",
|
|
5061
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5062
|
+
});
|
|
5063
|
+
}
|
|
5064
|
+
return this._textResult({
|
|
5065
|
+
success: false,
|
|
5066
|
+
action,
|
|
5067
|
+
error: body2.error ?? "start_failed",
|
|
5068
|
+
message: body2.success === true ? "Playtest did not become ready before timeout." : body2.message ?? "Playtest did not start.",
|
|
5069
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5070
|
+
});
|
|
5071
|
+
}
|
|
5072
|
+
const body = this._parseTextResult(await this.stopPlaytest(instance_id, timeout));
|
|
5073
|
+
if (body.success === true && body.runtimeStopped !== false) {
|
|
5074
|
+
return this._textResult({
|
|
5075
|
+
success: true,
|
|
5076
|
+
action,
|
|
5077
|
+
message: "Playtest stopped."
|
|
5078
|
+
});
|
|
5079
|
+
}
|
|
5080
|
+
return this._textResult({
|
|
5081
|
+
success: false,
|
|
5082
|
+
action,
|
|
5083
|
+
error: body.error ?? "stop_failed",
|
|
5084
|
+
message: body.message ?? "Playtest did not stop.",
|
|
5085
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
5086
|
+
requiresBuiltInMcp: body.requiresBuiltInMcp === true ? true : void 0,
|
|
5087
|
+
recoveryHint: typeof body.recoveryHint === "string" ? body.recoveryHint : void 0
|
|
5088
|
+
});
|
|
5089
|
+
}
|
|
5090
|
+
async startPlaytest(mode, numPlayers, instance_id, timeout = 60) {
|
|
4540
5091
|
if (mode !== "play" && mode !== "run") {
|
|
4541
5092
|
throw new Error('mode must be "play" or "run"');
|
|
4542
5093
|
}
|
|
4543
5094
|
if (numPlayers !== void 0) {
|
|
4544
|
-
throw new Error(
|
|
5095
|
+
throw new Error('start_playtest is single-player only. Use multiplayer_playtest action="start" for multi-client StudioTestService sessions.');
|
|
4545
5096
|
}
|
|
4546
5097
|
const data = { mode };
|
|
4547
5098
|
const startedAt = Date.now();
|
|
@@ -4580,7 +5131,7 @@ ${code}`
|
|
|
4580
5131
|
let wait;
|
|
4581
5132
|
if (response?.success === true) {
|
|
4582
5133
|
const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
|
|
4583
|
-
wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles,
|
|
5134
|
+
wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout, true);
|
|
4584
5135
|
}
|
|
4585
5136
|
const body = wait ? {
|
|
4586
5137
|
...response,
|
|
@@ -4597,7 +5148,7 @@ ${code}`
|
|
|
4597
5148
|
]
|
|
4598
5149
|
};
|
|
4599
5150
|
}
|
|
4600
|
-
async stopPlaytest(instance_id) {
|
|
5151
|
+
async stopPlaytest(instance_id, timeout = 15) {
|
|
4601
5152
|
const { instanceId } = this._resolveSingleTarget("edit", instance_id);
|
|
4602
5153
|
let response;
|
|
4603
5154
|
let stopRequestError;
|
|
@@ -4613,7 +5164,7 @@ ${code}`
|
|
|
4613
5164
|
}
|
|
4614
5165
|
let wait;
|
|
4615
5166
|
if (response?.success === true) {
|
|
4616
|
-
wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true },
|
|
5167
|
+
wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout, true);
|
|
4617
5168
|
} else if (this._runtimeTargetsForEquivalentInstances(instanceId).length > 0) {
|
|
4618
5169
|
wait = {
|
|
4619
5170
|
ok: false,
|
|
@@ -4738,6 +5289,94 @@ ${code}`
|
|
|
4738
5289
|
}
|
|
4739
5290
|
return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
|
|
4740
5291
|
}
|
|
5292
|
+
async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id) {
|
|
5293
|
+
if (action !== "start" && action !== "status" && action !== "add_players" && action !== "leave_client" && action !== "end") {
|
|
5294
|
+
throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
|
|
5295
|
+
}
|
|
5296
|
+
const briefState = async (instanceId) => {
|
|
5297
|
+
const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
|
|
5298
|
+
return {
|
|
5299
|
+
phase: state.phase,
|
|
5300
|
+
roles: Array.isArray(state.peers) ? state.peers.map((peer) => peer.role).filter((role) => typeof role === "string") : [],
|
|
5301
|
+
clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : [],
|
|
5302
|
+
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0,
|
|
5303
|
+
error: typeof state.error === "string" ? state.error : void 0
|
|
5304
|
+
};
|
|
5305
|
+
};
|
|
5306
|
+
if (action === "status") {
|
|
5307
|
+
return this._textResult({
|
|
5308
|
+
success: true,
|
|
5309
|
+
action,
|
|
5310
|
+
...await briefState(instance_id)
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
if (action === "start") {
|
|
5314
|
+
const body2 = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id));
|
|
5315
|
+
const state = body2.state && typeof body2.state === "object" ? body2.state : {};
|
|
5316
|
+
const success = body2.success === true && body2.ready === true;
|
|
5317
|
+
return this._textResult(success ? {
|
|
5318
|
+
success: true,
|
|
5319
|
+
action,
|
|
5320
|
+
message: "Multiplayer playtest started.",
|
|
5321
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0,
|
|
5322
|
+
clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
|
|
5323
|
+
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
|
|
5324
|
+
} : {
|
|
5325
|
+
success: false,
|
|
5326
|
+
action,
|
|
5327
|
+
error: body2.error ?? body2.wait?.error ?? "start_failed",
|
|
5328
|
+
message: body2.success === true ? "Multiplayer playtest did not become ready before timeout." : body2.message ?? "Multiplayer playtest did not start.",
|
|
5329
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5330
|
+
});
|
|
5331
|
+
}
|
|
5332
|
+
if (action === "add_players") {
|
|
5333
|
+
const body2 = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
|
|
5334
|
+
const state = body2.state && typeof body2.state === "object" ? body2.state : {};
|
|
5335
|
+
const success = body2.success === true && body2.ready === true;
|
|
5336
|
+
return this._textResult(success ? {
|
|
5337
|
+
success: true,
|
|
5338
|
+
action,
|
|
5339
|
+
message: "Players added.",
|
|
5340
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0,
|
|
5341
|
+
clientRoles: Array.isArray(state.clientRoles) ? state.clientRoles : void 0,
|
|
5342
|
+
playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
|
|
5343
|
+
} : {
|
|
5344
|
+
success: false,
|
|
5345
|
+
action,
|
|
5346
|
+
error: body2.error ?? "add_players_failed",
|
|
5347
|
+
message: body2.success === true ? "Players did not finish joining before timeout." : body2.message ?? "Players were not added.",
|
|
5348
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5349
|
+
});
|
|
5350
|
+
}
|
|
5351
|
+
if (action === "leave_client") {
|
|
5352
|
+
const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target ?? "client-1", timeout, instance_id));
|
|
5353
|
+
return this._textResult(body2.success === true && body2.left === true ? {
|
|
5354
|
+
success: true,
|
|
5355
|
+
action,
|
|
5356
|
+
message: "Client left.",
|
|
5357
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5358
|
+
} : {
|
|
5359
|
+
success: false,
|
|
5360
|
+
action,
|
|
5361
|
+
error: body2.error ?? "leave_client_failed",
|
|
5362
|
+
message: body2.message ?? "Client did not leave.",
|
|
5363
|
+
roles: Array.isArray(body2.roles) ? body2.roles : void 0
|
|
5364
|
+
});
|
|
5365
|
+
}
|
|
5366
|
+
const body = this._parseTextResult(await this.multiplayerTestEnd(value, timeout, instance_id));
|
|
5367
|
+
return this._textResult(body.success === true && body.ended === true ? {
|
|
5368
|
+
success: true,
|
|
5369
|
+
action,
|
|
5370
|
+
message: "Multiplayer playtest ended."
|
|
5371
|
+
} : {
|
|
5372
|
+
success: false,
|
|
5373
|
+
action,
|
|
5374
|
+
error: body.error ?? "end_failed",
|
|
5375
|
+
message: body.message ?? "Multiplayer playtest did not end.",
|
|
5376
|
+
roles: Array.isArray(body.roles) ? body.roles : void 0,
|
|
5377
|
+
editDone: body.editDone === false ? false : void 0
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
4741
5380
|
async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
|
|
4742
5381
|
if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
|
|
4743
5382
|
throw new Error("numPlayers must be an integer from 1 to 8");
|
|
@@ -4875,14 +5514,14 @@ ${code}`
|
|
|
4875
5514
|
};
|
|
4876
5515
|
}
|
|
4877
5516
|
static findProjectRoot(startDir) {
|
|
4878
|
-
let dir =
|
|
5517
|
+
let dir = path2.resolve(startDir);
|
|
4879
5518
|
let previous = "";
|
|
4880
5519
|
while (dir !== previous) {
|
|
4881
|
-
if (fs.existsSync(
|
|
5520
|
+
if (fs.existsSync(path2.join(dir, ".git")) || fs.existsSync(path2.join(dir, "package.json"))) {
|
|
4882
5521
|
return dir;
|
|
4883
5522
|
}
|
|
4884
5523
|
previous = dir;
|
|
4885
|
-
dir =
|
|
5524
|
+
dir = path2.dirname(dir);
|
|
4886
5525
|
}
|
|
4887
5526
|
return null;
|
|
4888
5527
|
}
|
|
@@ -4896,7 +5535,7 @@ ${code}`
|
|
|
4896
5535
|
}
|
|
4897
5536
|
}
|
|
4898
5537
|
static ensureWritableDirectory(candidate, label) {
|
|
4899
|
-
const resolved =
|
|
5538
|
+
const resolved = path2.resolve(candidate);
|
|
4900
5539
|
try {
|
|
4901
5540
|
fs.mkdirSync(resolved, { recursive: true });
|
|
4902
5541
|
} catch (error) {
|
|
@@ -4917,11 +5556,11 @@ ${code}`
|
|
|
4917
5556
|
if (_RobloxStudioTools._cachedLibraryPath)
|
|
4918
5557
|
return _RobloxStudioTools._cachedLibraryPath;
|
|
4919
5558
|
const overridePath = process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
|
|
4920
|
-
const cwd =
|
|
5559
|
+
const cwd = path2.resolve(process.cwd());
|
|
4921
5560
|
const projectRoot = _RobloxStudioTools.findProjectRoot(cwd);
|
|
4922
|
-
const homeLibraryPath =
|
|
4923
|
-
const projectLibraryPath = projectRoot ?
|
|
4924
|
-
const cwdLibraryPath =
|
|
5561
|
+
const homeLibraryPath = path2.join(os2.homedir(), ".robloxstudio-mcp", "build-library");
|
|
5562
|
+
const projectLibraryPath = projectRoot ? path2.join(projectRoot, "build-library") : null;
|
|
5563
|
+
const cwdLibraryPath = path2.join(cwd, "build-library");
|
|
4925
5564
|
let result;
|
|
4926
5565
|
if (overridePath) {
|
|
4927
5566
|
result = _RobloxStudioTools.ensureWritableDirectory(overridePath, "override");
|
|
@@ -4935,7 +5574,7 @@ ${code}`
|
|
|
4935
5574
|
}
|
|
4936
5575
|
})());
|
|
4937
5576
|
if (existing) {
|
|
4938
|
-
result =
|
|
5577
|
+
result = path2.resolve(existing);
|
|
4939
5578
|
} else if (projectLibraryPath) {
|
|
4940
5579
|
try {
|
|
4941
5580
|
result = _RobloxStudioTools.ensureWritableDirectory(projectLibraryPath, "project-root");
|
|
@@ -4962,8 +5601,8 @@ ${code}`
|
|
|
4962
5601
|
if (response && response.success && response.buildData) {
|
|
4963
5602
|
const buildData = response.buildData;
|
|
4964
5603
|
const buildId = buildData.id || `${style}/exported`;
|
|
4965
|
-
const filePath =
|
|
4966
|
-
const dirPath =
|
|
5604
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
|
|
5605
|
+
const dirPath = path2.dirname(filePath);
|
|
4967
5606
|
if (!fs.existsSync(dirPath)) {
|
|
4968
5607
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
4969
5608
|
}
|
|
@@ -5064,8 +5703,8 @@ ${code}`
|
|
|
5064
5703
|
const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
|
|
5065
5704
|
const computedBounds = bounds || this.computeBounds(normalizedParts);
|
|
5066
5705
|
const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
|
|
5067
|
-
const filePath =
|
|
5068
|
-
const dirPath =
|
|
5706
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
5707
|
+
const dirPath = path2.dirname(filePath);
|
|
5069
5708
|
if (!fs.existsSync(dirPath)) {
|
|
5070
5709
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
5071
5710
|
}
|
|
@@ -5123,8 +5762,8 @@ ${code}`
|
|
|
5123
5762
|
};
|
|
5124
5763
|
if (seed !== void 0)
|
|
5125
5764
|
buildData.generatorSeed = seed;
|
|
5126
|
-
const filePath =
|
|
5127
|
-
const dirPath =
|
|
5765
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
5766
|
+
const dirPath = path2.dirname(filePath);
|
|
5128
5767
|
if (!fs.existsSync(dirPath)) {
|
|
5129
5768
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
5130
5769
|
}
|
|
@@ -5152,13 +5791,13 @@ ${code}`
|
|
|
5152
5791
|
}
|
|
5153
5792
|
let resolved;
|
|
5154
5793
|
if (typeof buildData === "string") {
|
|
5155
|
-
const filePath =
|
|
5794
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
|
|
5156
5795
|
if (!fs.existsSync(filePath)) {
|
|
5157
5796
|
throw new Error(`Build not found in library: ${buildData}`);
|
|
5158
5797
|
}
|
|
5159
5798
|
resolved = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
5160
5799
|
} else if (buildData.id && !buildData.parts) {
|
|
5161
|
-
const filePath =
|
|
5800
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
|
|
5162
5801
|
if (!fs.existsSync(filePath)) {
|
|
5163
5802
|
throw new Error(`Build not found in library: ${buildData.id}`);
|
|
5164
5803
|
}
|
|
@@ -5185,13 +5824,13 @@ ${code}`
|
|
|
5185
5824
|
const styles = style ? [style] : ["medieval", "modern", "nature", "scifi", "misc"];
|
|
5186
5825
|
const builds = [];
|
|
5187
5826
|
for (const s of styles) {
|
|
5188
|
-
const dirPath =
|
|
5827
|
+
const dirPath = path2.join(libraryPath, s);
|
|
5189
5828
|
if (!fs.existsSync(dirPath))
|
|
5190
5829
|
continue;
|
|
5191
5830
|
const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
|
|
5192
5831
|
for (const file of files) {
|
|
5193
5832
|
try {
|
|
5194
|
-
const content = fs.readFileSync(
|
|
5833
|
+
const content = fs.readFileSync(path2.join(dirPath, file), "utf-8");
|
|
5195
5834
|
const data = JSON.parse(content);
|
|
5196
5835
|
builds.push({
|
|
5197
5836
|
id: data.id || `${s}/${file.replace(".json", "")}`,
|
|
@@ -5230,7 +5869,7 @@ ${code}`
|
|
|
5230
5869
|
if (!id) {
|
|
5231
5870
|
throw new Error("Build ID is required for get_build");
|
|
5232
5871
|
}
|
|
5233
|
-
const filePath =
|
|
5872
|
+
const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
|
|
5234
5873
|
if (!fs.existsSync(filePath)) {
|
|
5235
5874
|
throw new Error(`Build not found in library: ${id}`);
|
|
5236
5875
|
}
|
|
@@ -5317,7 +5956,7 @@ ${code}`
|
|
|
5317
5956
|
if (!buildId) {
|
|
5318
5957
|
throw new Error(`Invalid ${validatedKeyPath}: model key "${modelKey}" is not defined in sceneData.models`);
|
|
5319
5958
|
}
|
|
5320
|
-
const filePath =
|
|
5959
|
+
const filePath = path2.join(libraryPath, `${buildId}.json`);
|
|
5321
5960
|
if (!fs.existsSync(filePath)) {
|
|
5322
5961
|
throw new Error(`Build not found in library: ${buildId}`);
|
|
5323
5962
|
}
|
|
@@ -5500,7 +6139,7 @@ ${code}`
|
|
|
5500
6139
|
throw new Error(`File not found: ${filePath}`);
|
|
5501
6140
|
}
|
|
5502
6141
|
const fileContent = fs.readFileSync(filePath);
|
|
5503
|
-
const fileName =
|
|
6142
|
+
const fileName = path2.basename(filePath);
|
|
5504
6143
|
if (assetType === "Decal" && this.cookieClient.hasCookie()) {
|
|
5505
6144
|
const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
|
|
5506
6145
|
return {
|
|
@@ -5725,9 +6364,9 @@ ${code}`
|
|
|
5725
6364
|
return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
|
|
5726
6365
|
}
|
|
5727
6366
|
const bytes = Buffer.from(response.base64, "base64");
|
|
5728
|
-
const resolved =
|
|
6367
|
+
const resolved = path2.resolve(outputPath);
|
|
5729
6368
|
try {
|
|
5730
|
-
fs.mkdirSync(
|
|
6369
|
+
fs.mkdirSync(path2.dirname(resolved), { recursive: true });
|
|
5731
6370
|
fs.writeFileSync(resolved, bytes);
|
|
5732
6371
|
} catch (err) {
|
|
5733
6372
|
return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
|
|
@@ -5761,7 +6400,7 @@ ${code}`
|
|
|
5761
6400
|
let bytes;
|
|
5762
6401
|
let sourceLabel;
|
|
5763
6402
|
if (source.path !== void 0) {
|
|
5764
|
-
const resolved =
|
|
6403
|
+
const resolved = path2.resolve(source.path);
|
|
5765
6404
|
try {
|
|
5766
6405
|
bytes = fs.readFileSync(resolved);
|
|
5767
6406
|
} catch (err) {
|
|
@@ -5830,7 +6469,7 @@ ${code}`
|
|
|
5830
6469
|
if (response.error) {
|
|
5831
6470
|
let text = response.error;
|
|
5832
6471
|
if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
|
|
5833
|
-
text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular
|
|
6472
|
+
text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular solo_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
|
|
5834
6473
|
}
|
|
5835
6474
|
return { success: false, error: text };
|
|
5836
6475
|
}
|
|
@@ -6003,7 +6642,7 @@ var init_server = __esm({
|
|
|
6003
6642
|
config;
|
|
6004
6643
|
constructor(config) {
|
|
6005
6644
|
this.config = config;
|
|
6006
|
-
this.allowedToolNames = new Set(config.tools.map((t) => t.name));
|
|
6645
|
+
this.allowedToolNames = new Set((config.callableTools ?? config.tools).map((t) => t.name));
|
|
6007
6646
|
this.server = new Server2({
|
|
6008
6647
|
name: config.name,
|
|
6009
6648
|
version: config.version
|
|
@@ -6176,7 +6815,7 @@ var init_server = __esm({
|
|
|
6176
6815
|
});
|
|
6177
6816
|
|
|
6178
6817
|
// ../core/dist/tools/definitions.js
|
|
6179
|
-
var TOOL_DEFINITIONS, getAllTools;
|
|
6818
|
+
var TOOL_DEFINITIONS, DEPRECATED_TOOL_DEFINITIONS, getAllTools, getAllCallableTools;
|
|
6180
6819
|
var init_definitions = __esm({
|
|
6181
6820
|
"../core/dist/tools/definitions.js"() {
|
|
6182
6821
|
"use strict";
|
|
@@ -7142,43 +7781,92 @@ var init_definitions = __esm({
|
|
|
7142
7781
|
required: ["pattern"]
|
|
7143
7782
|
}
|
|
7144
7783
|
},
|
|
7145
|
-
// ===
|
|
7784
|
+
// === Studio Instance Management ===
|
|
7146
7785
|
{
|
|
7147
|
-
name: "
|
|
7786
|
+
name: "manage_instance",
|
|
7148
7787
|
category: "write",
|
|
7149
|
-
description:
|
|
7788
|
+
description: 'Launch, close, inspect, and find revisions for Studio instances. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision" and place_version to open an older revision. action="close" can close an MCP-managed instance or an explicitly connected edit instance by instance_id. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
|
|
7150
7789
|
inputSchema: {
|
|
7151
7790
|
type: "object",
|
|
7152
7791
|
properties: {
|
|
7153
|
-
|
|
7792
|
+
action: {
|
|
7154
7793
|
type: "string",
|
|
7155
|
-
enum: ["
|
|
7156
|
-
description: "
|
|
7794
|
+
enum: ["launch", "close", "status", "list_place_versions"],
|
|
7795
|
+
description: "Instance management action."
|
|
7157
7796
|
},
|
|
7158
|
-
|
|
7797
|
+
source: {
|
|
7798
|
+
type: "string",
|
|
7799
|
+
enum: ["baseplate", "local_file", "published_place", "place_revision"],
|
|
7800
|
+
description: 'Required for action="launch". published_place opens the latest place; place_revision opens a specific older version as an anonymous local copy.'
|
|
7801
|
+
},
|
|
7802
|
+
local_place_file: {
|
|
7803
|
+
type: "string",
|
|
7804
|
+
description: 'Required for source="local_file". Path to a .rbxl/.rbxlx place file.'
|
|
7805
|
+
},
|
|
7806
|
+
place_id: {
|
|
7807
|
+
type: "number",
|
|
7808
|
+
description: 'Required for source="published_place", source="place_revision", and action="list_place_versions".'
|
|
7809
|
+
},
|
|
7810
|
+
universe_id: {
|
|
7811
|
+
type: "number",
|
|
7812
|
+
description: "Optional for published_place/place_revision launches; derived from place_id when omitted."
|
|
7813
|
+
},
|
|
7814
|
+
place_version: {
|
|
7815
|
+
type: "number",
|
|
7816
|
+
description: 'Required for source="place_revision". Use action="list_place_versions" to discover available version numbers.'
|
|
7817
|
+
},
|
|
7818
|
+
wait_for_connection: {
|
|
7819
|
+
type: "boolean",
|
|
7820
|
+
description: 'For action="launch": wait until the MCP plugin connects and return instance_id (default true).'
|
|
7821
|
+
},
|
|
7822
|
+
timeout_ms: {
|
|
7159
7823
|
type: "number",
|
|
7160
|
-
description: "
|
|
7824
|
+
description: 'For action="launch": max milliseconds to wait for plugin connection (default 120000).'
|
|
7825
|
+
},
|
|
7826
|
+
max_page_size: {
|
|
7827
|
+
type: "number",
|
|
7828
|
+
description: 'For action="list_place_versions": number of versions to return, clamped to 1-50 (default 10).'
|
|
7829
|
+
},
|
|
7830
|
+
page_token: {
|
|
7831
|
+
type: "string",
|
|
7832
|
+
description: 'For action="list_place_versions": pagination token returned by a prior call.'
|
|
7161
7833
|
},
|
|
7162
7834
|
instance_id: {
|
|
7163
7835
|
type: "string",
|
|
7164
|
-
description: "
|
|
7836
|
+
description: 'For action="close" or action="status": Studio instance to inspect or close. close accepts MCP-managed instances and explicitly connected edit instances.'
|
|
7165
7837
|
}
|
|
7166
7838
|
},
|
|
7167
|
-
required: ["
|
|
7839
|
+
required: ["action"]
|
|
7168
7840
|
}
|
|
7169
7841
|
},
|
|
7842
|
+
// === Playtest ===
|
|
7170
7843
|
{
|
|
7171
|
-
name: "
|
|
7844
|
+
name: "solo_playtest",
|
|
7172
7845
|
category: "write",
|
|
7173
|
-
description: "
|
|
7846
|
+
description: 'Start, stop, or inspect a single-player Studio playtest. Use action="start" with mode="play" or "run", action="stop" to end the playtest, and action="status" to inspect active runtime roles. Returns brief lifecycle status only; read script output with get_runtime_logs. Ordinary start/eval/stop workflows do not need reset_simulation_state; use simulation reset only for network or device-simulator tests. For multi-client testing use multiplayer_playtest.',
|
|
7174
7847
|
inputSchema: {
|
|
7175
7848
|
type: "object",
|
|
7176
7849
|
properties: {
|
|
7850
|
+
action: {
|
|
7851
|
+
type: "string",
|
|
7852
|
+
enum: ["start", "stop", "status"],
|
|
7853
|
+
description: "Lifecycle action to run."
|
|
7854
|
+
},
|
|
7855
|
+
mode: {
|
|
7856
|
+
type: "string",
|
|
7857
|
+
enum: ["play", "run"],
|
|
7858
|
+
description: 'Required for action="start".'
|
|
7859
|
+
},
|
|
7860
|
+
timeout: {
|
|
7861
|
+
type: "number",
|
|
7862
|
+
description: "Max seconds to wait for start readiness or stop teardown. Defaults: start 60, stop 15."
|
|
7863
|
+
},
|
|
7177
7864
|
instance_id: {
|
|
7178
7865
|
type: "string",
|
|
7179
7866
|
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7180
7867
|
}
|
|
7181
|
-
}
|
|
7868
|
+
},
|
|
7869
|
+
required: ["action"]
|
|
7182
7870
|
}
|
|
7183
7871
|
},
|
|
7184
7872
|
{
|
|
@@ -7247,7 +7935,7 @@ var init_definitions = __esm({
|
|
|
7247
7935
|
{
|
|
7248
7936
|
name: "get_simulation_state",
|
|
7249
7937
|
category: "read",
|
|
7250
|
-
description: 'Inspect current NetworkSettings and/or StudioDeviceSimulatorService state for edit and connected
|
|
7938
|
+
description: 'Inspect current NetworkSettings and/or StudioDeviceSimulatorService state for edit and connected clients only. Defaults to include="both" and target="edit-and-clients"; server peers are skipped. Use when a task explicitly involves simulated network/device behavior or when you suspect stale simulator state. This is not part of ordinary playtest lifecycle.',
|
|
7251
7939
|
inputSchema: {
|
|
7252
7940
|
type: "object",
|
|
7253
7941
|
properties: {
|
|
@@ -7270,7 +7958,7 @@ var init_definitions = __esm({
|
|
|
7270
7958
|
{
|
|
7271
7959
|
name: "reset_simulation_state",
|
|
7272
7960
|
category: "write",
|
|
7273
|
-
description: 'Reset reachable
|
|
7961
|
+
description: 'Reset reachable NetworkSettings and/or StudioDeviceSimulatorService state for deterministic network/device tests. Defaults to target="edit-and-clients" and resets both network and device simulator state. Network reset sets all six simulated NetworkSettings fields to 0; device reset calls StopSimulationAsync(). Do not call as routine Studio lifecycle hygiene. Use it after intentionally changing simulation settings, when get_simulation_state shows dirty state, or when a task explicitly requires a clean network/device baseline.',
|
|
7274
7962
|
inputSchema: {
|
|
7275
7963
|
type: "object",
|
|
7276
7964
|
properties: {
|
|
@@ -7456,109 +8144,41 @@ var init_definitions = __esm({
|
|
|
7456
8144
|
}
|
|
7457
8145
|
},
|
|
7458
8146
|
{
|
|
7459
|
-
name: "
|
|
8147
|
+
name: "multiplayer_playtest",
|
|
7460
8148
|
category: "write",
|
|
7461
|
-
description:
|
|
7462
|
-
inputSchema: {
|
|
7463
|
-
type: "object",
|
|
7464
|
-
properties: {
|
|
7465
|
-
numPlayers: {
|
|
7466
|
-
type: "number",
|
|
7467
|
-
description: "Number of client players to start (1-8)."
|
|
7468
|
-
},
|
|
7469
|
-
testArgs: {
|
|
7470
|
-
description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
|
|
7471
|
-
},
|
|
7472
|
-
timeout: {
|
|
7473
|
-
type: "number",
|
|
7474
|
-
description: "Max seconds to wait for server + clients to register (default 30)."
|
|
7475
|
-
},
|
|
7476
|
-
instance_id: {
|
|
7477
|
-
type: "string",
|
|
7478
|
-
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7479
|
-
}
|
|
7480
|
-
},
|
|
7481
|
-
required: ["numPlayers"]
|
|
7482
|
-
}
|
|
7483
|
-
},
|
|
7484
|
-
{
|
|
7485
|
-
name: "multiplayer_test_state",
|
|
7486
|
-
category: "read",
|
|
7487
|
-
description: "Get the active multiplayer StudioTestService state for a place: phase, peers, players, original testArgs, result/error, and connected client roles.",
|
|
8149
|
+
description: 'Start, inspect, add players to, remove a client from, or end a StudioTestService multiplayer playtest. Use action="start" with numPlayers, action="status", action="add_players" with numPlayers, action="leave_client" with target="client-N", or action="end". Returns brief lifecycle status only; read script output with get_runtime_logs.',
|
|
7488
8150
|
inputSchema: {
|
|
7489
8151
|
type: "object",
|
|
7490
8152
|
properties: {
|
|
7491
|
-
|
|
8153
|
+
action: {
|
|
7492
8154
|
type: "string",
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
}
|
|
7497
|
-
},
|
|
7498
|
-
{
|
|
7499
|
-
name: "multiplayer_test_add_players",
|
|
7500
|
-
category: "write",
|
|
7501
|
-
description: "Add client players to a running StudioTestService multiplayer test and wait for the new clients to connect.",
|
|
7502
|
-
inputSchema: {
|
|
7503
|
-
type: "object",
|
|
7504
|
-
properties: {
|
|
8155
|
+
enum: ["start", "status", "add_players", "leave_client", "end"],
|
|
8156
|
+
description: "Lifecycle action to run."
|
|
8157
|
+
},
|
|
7505
8158
|
numPlayers: {
|
|
7506
8159
|
type: "number",
|
|
7507
|
-
description: "Number of
|
|
8160
|
+
description: 'Required for action="start" and action="add_players". Number of client players (1-8).'
|
|
7508
8161
|
},
|
|
7509
|
-
timeout: {
|
|
7510
|
-
type: "number",
|
|
7511
|
-
description: "Max seconds to wait for new clients to register (default 30)."
|
|
7512
|
-
},
|
|
7513
|
-
instance_id: {
|
|
7514
|
-
type: "string",
|
|
7515
|
-
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7516
|
-
}
|
|
7517
|
-
},
|
|
7518
|
-
required: ["numPlayers"]
|
|
7519
|
-
}
|
|
7520
|
-
},
|
|
7521
|
-
{
|
|
7522
|
-
name: "multiplayer_test_leave_client",
|
|
7523
|
-
category: "write",
|
|
7524
|
-
description: "Disconnect a specific client from a running StudioTestService multiplayer test, then wait for that client peer to leave.",
|
|
7525
|
-
inputSchema: {
|
|
7526
|
-
type: "object",
|
|
7527
|
-
properties: {
|
|
7528
8162
|
target: {
|
|
7529
8163
|
type: "string",
|
|
7530
|
-
description: 'Client target
|
|
8164
|
+
description: 'Client target for action="leave_client", such as "client-1". Defaults to "client-1".'
|
|
7531
8165
|
},
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
description: "Max seconds to wait for the client peer to disconnect (default 30)."
|
|
8166
|
+
testArgs: {
|
|
8167
|
+
description: 'For action="start": JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients.'
|
|
7535
8168
|
},
|
|
7536
|
-
instance_id: {
|
|
7537
|
-
type: "string",
|
|
7538
|
-
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7539
|
-
}
|
|
7540
|
-
}
|
|
7541
|
-
}
|
|
7542
|
-
},
|
|
7543
|
-
{
|
|
7544
|
-
name: "multiplayer_test_end",
|
|
7545
|
-
category: "write",
|
|
7546
|
-
description: "End a running StudioTestService multiplayer test with an optional return value, then wait for all runtime peers to disconnect.",
|
|
7547
|
-
inputSchema: {
|
|
7548
|
-
type: "object",
|
|
7549
|
-
properties: {
|
|
7550
8169
|
value: {
|
|
7551
|
-
description: "JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call.
|
|
8170
|
+
description: 'For action="end": JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call.'
|
|
7552
8171
|
},
|
|
7553
8172
|
timeout: {
|
|
7554
8173
|
type: "number",
|
|
7555
|
-
description: "Max seconds to wait for
|
|
8174
|
+
description: "Max seconds to wait for action completion. Defaults to 30."
|
|
7556
8175
|
},
|
|
7557
8176
|
instance_id: {
|
|
7558
8177
|
type: "string",
|
|
7559
8178
|
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
7560
8179
|
}
|
|
7561
|
-
}
|
|
8180
|
+
},
|
|
8181
|
+
required: ["action"]
|
|
7562
8182
|
}
|
|
7563
8183
|
},
|
|
7564
8184
|
{
|
|
@@ -8586,20 +9206,168 @@ part(0,2,0,2,1,1,"b")`,
|
|
|
8586
9206
|
}
|
|
8587
9207
|
}
|
|
8588
9208
|
];
|
|
9209
|
+
DEPRECATED_TOOL_DEFINITIONS = [
|
|
9210
|
+
// === Deprecated Playtest API ===
|
|
9211
|
+
{
|
|
9212
|
+
name: "start_playtest",
|
|
9213
|
+
category: "write",
|
|
9214
|
+
description: 'Deprecated. Use solo_playtest with action="start" instead. Starts a simple single-player Studio playtest in play or run mode.',
|
|
9215
|
+
inputSchema: {
|
|
9216
|
+
type: "object",
|
|
9217
|
+
properties: {
|
|
9218
|
+
mode: {
|
|
9219
|
+
type: "string",
|
|
9220
|
+
enum: ["play", "run"],
|
|
9221
|
+
description: "Play mode"
|
|
9222
|
+
},
|
|
9223
|
+
numPlayers: {
|
|
9224
|
+
type: "number",
|
|
9225
|
+
description: 'Deprecated and rejected. Use multiplayer_playtest action="start" for multi-client testing.'
|
|
9226
|
+
},
|
|
9227
|
+
instance_id: {
|
|
9228
|
+
type: "string",
|
|
9229
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9230
|
+
}
|
|
9231
|
+
},
|
|
9232
|
+
required: ["mode"]
|
|
9233
|
+
}
|
|
9234
|
+
},
|
|
9235
|
+
{
|
|
9236
|
+
name: "stop_playtest",
|
|
9237
|
+
category: "write",
|
|
9238
|
+
description: 'Deprecated. Use solo_playtest with action="stop" instead. Stops a single-player Studio playtest and waits for runtime peers to disconnect.',
|
|
9239
|
+
inputSchema: {
|
|
9240
|
+
type: "object",
|
|
9241
|
+
properties: {
|
|
9242
|
+
instance_id: {
|
|
9243
|
+
type: "string",
|
|
9244
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9245
|
+
}
|
|
9246
|
+
}
|
|
9247
|
+
}
|
|
9248
|
+
},
|
|
9249
|
+
{
|
|
9250
|
+
name: "multiplayer_test_start",
|
|
9251
|
+
category: "write",
|
|
9252
|
+
description: 'Deprecated. Use multiplayer_playtest with action="start" instead. Starts a StudioTestService multiplayer test.',
|
|
9253
|
+
inputSchema: {
|
|
9254
|
+
type: "object",
|
|
9255
|
+
properties: {
|
|
9256
|
+
numPlayers: {
|
|
9257
|
+
type: "number",
|
|
9258
|
+
description: "Number of client players to start (1-8)."
|
|
9259
|
+
},
|
|
9260
|
+
testArgs: {
|
|
9261
|
+
description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
|
|
9262
|
+
},
|
|
9263
|
+
timeout: {
|
|
9264
|
+
type: "number",
|
|
9265
|
+
description: "Max seconds to wait for server + clients to register (default 30)."
|
|
9266
|
+
},
|
|
9267
|
+
instance_id: {
|
|
9268
|
+
type: "string",
|
|
9269
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9270
|
+
}
|
|
9271
|
+
},
|
|
9272
|
+
required: ["numPlayers"]
|
|
9273
|
+
}
|
|
9274
|
+
},
|
|
9275
|
+
{
|
|
9276
|
+
name: "multiplayer_test_state",
|
|
9277
|
+
category: "read",
|
|
9278
|
+
description: 'Deprecated. Use multiplayer_playtest with action="status" instead. Gets the active multiplayer StudioTestService state.',
|
|
9279
|
+
inputSchema: {
|
|
9280
|
+
type: "object",
|
|
9281
|
+
properties: {
|
|
9282
|
+
instance_id: {
|
|
9283
|
+
type: "string",
|
|
9284
|
+
description: "Which connected Studio place to inspect. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9285
|
+
}
|
|
9286
|
+
}
|
|
9287
|
+
}
|
|
9288
|
+
},
|
|
9289
|
+
{
|
|
9290
|
+
name: "multiplayer_test_add_players",
|
|
9291
|
+
category: "write",
|
|
9292
|
+
description: 'Deprecated. Use multiplayer_playtest with action="add_players" instead. Adds client players to a running StudioTestService multiplayer test.',
|
|
9293
|
+
inputSchema: {
|
|
9294
|
+
type: "object",
|
|
9295
|
+
properties: {
|
|
9296
|
+
numPlayers: {
|
|
9297
|
+
type: "number",
|
|
9298
|
+
description: "Number of additional client players to add (1-8)."
|
|
9299
|
+
},
|
|
9300
|
+
timeout: {
|
|
9301
|
+
type: "number",
|
|
9302
|
+
description: "Max seconds to wait for new clients to register (default 30)."
|
|
9303
|
+
},
|
|
9304
|
+
instance_id: {
|
|
9305
|
+
type: "string",
|
|
9306
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9307
|
+
}
|
|
9308
|
+
},
|
|
9309
|
+
required: ["numPlayers"]
|
|
9310
|
+
}
|
|
9311
|
+
},
|
|
9312
|
+
{
|
|
9313
|
+
name: "multiplayer_test_leave_client",
|
|
9314
|
+
category: "write",
|
|
9315
|
+
description: 'Deprecated. Use multiplayer_playtest with action="leave_client" instead. Disconnects a specific client from a running StudioTestService multiplayer test.',
|
|
9316
|
+
inputSchema: {
|
|
9317
|
+
type: "object",
|
|
9318
|
+
properties: {
|
|
9319
|
+
target: {
|
|
9320
|
+
type: "string",
|
|
9321
|
+
description: 'Client target to leave: "client-1" (default), "client-2", etc.'
|
|
9322
|
+
},
|
|
9323
|
+
timeout: {
|
|
9324
|
+
type: "number",
|
|
9325
|
+
description: "Max seconds to wait for the client peer to disconnect (default 30)."
|
|
9326
|
+
},
|
|
9327
|
+
instance_id: {
|
|
9328
|
+
type: "string",
|
|
9329
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9330
|
+
}
|
|
9331
|
+
}
|
|
9332
|
+
}
|
|
9333
|
+
},
|
|
9334
|
+
{
|
|
9335
|
+
name: "multiplayer_test_end",
|
|
9336
|
+
category: "write",
|
|
9337
|
+
description: 'Deprecated. Use multiplayer_playtest with action="end" instead. Ends a running StudioTestService multiplayer test.',
|
|
9338
|
+
inputSchema: {
|
|
9339
|
+
type: "object",
|
|
9340
|
+
properties: {
|
|
9341
|
+
value: {
|
|
9342
|
+
description: "JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call."
|
|
9343
|
+
},
|
|
9344
|
+
timeout: {
|
|
9345
|
+
type: "number",
|
|
9346
|
+
description: "Max seconds to wait for runtime peers to disconnect (default 30)."
|
|
9347
|
+
},
|
|
9348
|
+
instance_id: {
|
|
9349
|
+
type: "string",
|
|
9350
|
+
description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
|
|
9351
|
+
}
|
|
9352
|
+
}
|
|
9353
|
+
}
|
|
9354
|
+
}
|
|
9355
|
+
];
|
|
8589
9356
|
getAllTools = () => [...TOOL_DEFINITIONS];
|
|
9357
|
+
getAllCallableTools = () => [...TOOL_DEFINITIONS, ...DEPRECATED_TOOL_DEFINITIONS];
|
|
8590
9358
|
}
|
|
8591
9359
|
});
|
|
8592
9360
|
|
|
8593
9361
|
// ../core/dist/install-plugin-helpers.js
|
|
8594
|
-
import { existsSync as
|
|
9362
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, unlinkSync } from "fs";
|
|
8595
9363
|
import { execSync } from "child_process";
|
|
8596
|
-
import { join as
|
|
8597
|
-
import { homedir as
|
|
9364
|
+
import { join as join3 } from "path";
|
|
9365
|
+
import { homedir as homedir3 } from "os";
|
|
8598
9366
|
function isWSL() {
|
|
8599
9367
|
if (process.platform !== "linux")
|
|
8600
9368
|
return false;
|
|
8601
9369
|
try {
|
|
8602
|
-
const v =
|
|
9370
|
+
const v = readFileSync3("/proc/version", "utf8");
|
|
8603
9371
|
return /microsoft|wsl/i.test(v);
|
|
8604
9372
|
} catch {
|
|
8605
9373
|
return false;
|
|
@@ -8617,7 +9385,7 @@ function getWindowsUserPluginsDir() {
|
|
|
8617
9385
|
}).toString().trim();
|
|
8618
9386
|
if (!linuxPath)
|
|
8619
9387
|
return null;
|
|
8620
|
-
return
|
|
9388
|
+
return join3(linuxPath, "Roblox", "Plugins");
|
|
8621
9389
|
} catch {
|
|
8622
9390
|
return null;
|
|
8623
9391
|
}
|
|
@@ -8626,7 +9394,7 @@ function getPluginsFolder() {
|
|
|
8626
9394
|
if (process.env.MCP_PLUGINS_DIR)
|
|
8627
9395
|
return process.env.MCP_PLUGINS_DIR;
|
|
8628
9396
|
if (process.platform === "win32") {
|
|
8629
|
-
return
|
|
9397
|
+
return join3(process.env.LOCALAPPDATA || join3(homedir3(), "AppData", "Local"), "Roblox", "Plugins");
|
|
8630
9398
|
}
|
|
8631
9399
|
if (isWSL()) {
|
|
8632
9400
|
const win = getWindowsUserPluginsDir();
|
|
@@ -8634,11 +9402,11 @@ function getPluginsFolder() {
|
|
|
8634
9402
|
return win;
|
|
8635
9403
|
console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
|
|
8636
9404
|
}
|
|
8637
|
-
return
|
|
9405
|
+
return join3(homedir3(), "Documents", "Roblox", "Plugins");
|
|
8638
9406
|
}
|
|
8639
9407
|
function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
|
|
8640
|
-
const otherDest =
|
|
8641
|
-
if (!
|
|
9408
|
+
const otherDest = join3(pluginsFolder, otherAssetName);
|
|
9409
|
+
if (!existsSync3(otherDest))
|
|
8642
9410
|
return;
|
|
8643
9411
|
if (replace) {
|
|
8644
9412
|
try {
|
|
@@ -8683,8 +9451,8 @@ __export(install_plugin_exports, {
|
|
|
8683
9451
|
installBundledPlugin: () => installBundledPlugin,
|
|
8684
9452
|
installPlugin: () => installPlugin
|
|
8685
9453
|
});
|
|
8686
|
-
import { copyFileSync, createWriteStream, existsSync as
|
|
8687
|
-
import { dirname as dirname2, join as
|
|
9454
|
+
import { copyFileSync, createWriteStream, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
|
|
9455
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
8688
9456
|
import { fileURLToPath } from "url";
|
|
8689
9457
|
import { get } from "https";
|
|
8690
9458
|
function httpsGet(url) {
|
|
@@ -8754,7 +9522,7 @@ function prepareInstall({
|
|
|
8754
9522
|
warn
|
|
8755
9523
|
}) {
|
|
8756
9524
|
const pluginsFolder = getPluginsFolder();
|
|
8757
|
-
if (!
|
|
9525
|
+
if (!existsSync4(pluginsFolder)) {
|
|
8758
9526
|
mkdirSync2(pluginsFolder, { recursive: true });
|
|
8759
9527
|
}
|
|
8760
9528
|
handleVariantConflict({
|
|
@@ -8769,21 +9537,21 @@ function prepareInstall({
|
|
|
8769
9537
|
function bundledAssetPath() {
|
|
8770
9538
|
const currentDir = dirname2(fileURLToPath(import.meta.url));
|
|
8771
9539
|
const candidates = [
|
|
8772
|
-
|
|
8773
|
-
|
|
9540
|
+
join4(currentDir, "..", "studio-plugin", ASSET_NAME),
|
|
9541
|
+
join4(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
|
|
8774
9542
|
];
|
|
8775
|
-
return candidates.find((candidate) =>
|
|
9543
|
+
return candidates.find((candidate) => existsSync4(candidate)) ?? null;
|
|
8776
9544
|
}
|
|
8777
9545
|
function packageVersion() {
|
|
8778
9546
|
const currentDir = dirname2(fileURLToPath(import.meta.url));
|
|
8779
|
-
const pkg = JSON.parse(
|
|
9547
|
+
const pkg = JSON.parse(readFileSync4(join4(currentDir, "..", "package.json"), "utf8"));
|
|
8780
9548
|
if (!pkg.version) {
|
|
8781
9549
|
throw new Error("Package version not found");
|
|
8782
9550
|
}
|
|
8783
9551
|
return pkg.version;
|
|
8784
9552
|
}
|
|
8785
9553
|
function bundledPluginVersion(source) {
|
|
8786
|
-
const match =
|
|
9554
|
+
const match = readFileSync4(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
|
|
8787
9555
|
return match ? match[1] : null;
|
|
8788
9556
|
}
|
|
8789
9557
|
function assertBundledPluginVersion(source) {
|
|
@@ -8796,9 +9564,9 @@ function assertBundledPluginVersion(source) {
|
|
|
8796
9564
|
}
|
|
8797
9565
|
}
|
|
8798
9566
|
function filesMatch(a, b) {
|
|
8799
|
-
if (!
|
|
8800
|
-
const aBytes =
|
|
8801
|
-
const bBytes =
|
|
9567
|
+
if (!existsSync4(b)) return false;
|
|
9568
|
+
const aBytes = readFileSync4(a);
|
|
9569
|
+
const bBytes = readFileSync4(b);
|
|
8802
9570
|
return aBytes.length === bBytes.length && aBytes.equals(bBytes);
|
|
8803
9571
|
}
|
|
8804
9572
|
async function installBundledPlugin(options = {}) {
|
|
@@ -8811,7 +9579,7 @@ async function installBundledPlugin(options = {}) {
|
|
|
8811
9579
|
}
|
|
8812
9580
|
assertBundledPluginVersion(source);
|
|
8813
9581
|
const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
|
|
8814
|
-
const dest =
|
|
9582
|
+
const dest = join4(pluginsFolder, ASSET_NAME);
|
|
8815
9583
|
if (filesMatch(source, dest)) return;
|
|
8816
9584
|
copyFileSync(source, dest);
|
|
8817
9585
|
log(`Installed ${ASSET_NAME} to ${dest}`);
|
|
@@ -8825,7 +9593,7 @@ async function installPlugin(options = {}) {
|
|
|
8825
9593
|
const bundled = bundledAssetPath();
|
|
8826
9594
|
if (bundled) {
|
|
8827
9595
|
assertBundledPluginVersion(bundled);
|
|
8828
|
-
const dest2 =
|
|
9596
|
+
const dest2 = join4(pluginsFolder, ASSET_NAME);
|
|
8829
9597
|
if (filesMatch(bundled, dest2)) {
|
|
8830
9598
|
log(`${ASSET_NAME} already installed.`);
|
|
8831
9599
|
return;
|
|
@@ -8840,7 +9608,7 @@ async function installPlugin(options = {}) {
|
|
|
8840
9608
|
if (!asset) {
|
|
8841
9609
|
throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
|
|
8842
9610
|
}
|
|
8843
|
-
const dest =
|
|
9611
|
+
const dest = join4(pluginsFolder, ASSET_NAME);
|
|
8844
9612
|
log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
|
|
8845
9613
|
await download(asset.browser_download_url, dest);
|
|
8846
9614
|
log(`Installed to ${dest}`);
|
|
@@ -8894,7 +9662,8 @@ if (process.argv.includes("--install-plugin")) {
|
|
|
8894
9662
|
const server = new RobloxStudioMCPServer({
|
|
8895
9663
|
name: "robloxstudio-mcp",
|
|
8896
9664
|
version: VERSION,
|
|
8897
|
-
tools: getAllTools()
|
|
9665
|
+
tools: getAllTools(),
|
|
9666
|
+
callableTools: getAllCallableTools()
|
|
8898
9667
|
});
|
|
8899
9668
|
server.run().catch((error) => {
|
|
8900
9669
|
console.error("Server failed to start:", error);
|