@chrrxs/robloxstudio-mcp 2.17.0 → 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 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),
@@ -932,7 +943,6 @@ var init_http_server = __esm({
932
943
  capture_screenshot: (tools, body) => tools.captureScreenshot(body.instance_id, body.format, body.quality),
933
944
  simulate_mouse_input: (tools, body) => tools.simulateMouseInput(body.action, body.x, body.y, body.button, body.scrollDirection, body.target, body.instance_id),
934
945
  simulate_keyboard_input: (tools, body) => tools.simulateKeyboardInput(body.keyCode, body.action, body.duration, body.text, body.target, body.instance_id),
935
- character_navigation: (tools, body) => tools.characterNavigation(body.position, body.instancePath, body.waitForCompletion, body.timeout, body.target, body.instance_id),
936
946
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
937
947
  get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
938
948
  export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
@@ -1470,6 +1480,14 @@ var init_opencloud_client = __esm({
1470
1480
  async getAssetDetails(assetId) {
1471
1481
  return this.request(`/toolbox-service/v2/assets/${assetId}`);
1472
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
+ }
1473
1491
  async getAssetThumbnail(assetId, size = "420x420") {
1474
1492
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
1475
1493
  try {
@@ -1698,6 +1716,254 @@ var init_roblox_cookie_client = __esm({
1698
1716
  }
1699
1717
  });
1700
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
+
1701
1967
  // ../core/dist/jpeg-encoder.js
1702
1968
  function rgbaToJpeg(rgba, width, height, quality = 80) {
1703
1969
  if (width <= 0 || height <= 0)
@@ -2716,8 +2982,8 @@ var init_png_encoder = __esm({
2716
2982
 
2717
2983
  // ../core/dist/tools/index.js
2718
2984
  import * as fs from "fs";
2719
- import * as os from "os";
2720
- import * as path from "path";
2985
+ import * as os2 from "os";
2986
+ import * as path2 from "path";
2721
2987
  function encodeImageFromRgbaResponse(response, format, quality) {
2722
2988
  if (!response.data || response.width === void 0 || response.height === void 0) {
2723
2989
  throw new Error("Render response missing data, width, or height");
@@ -3073,6 +3339,7 @@ var init_tools = __esm({
3073
3339
  init_build_executor();
3074
3340
  init_opencloud_client();
3075
3341
  init_roblox_cookie_client();
3342
+ init_studio_instance_manager();
3076
3343
  init_jpeg_encoder();
3077
3344
  init_png_encoder();
3078
3345
  MAX_INLINE_IMAGE_BYTES = 6e6;
@@ -3130,11 +3397,34 @@ var init_tools = __esm({
3130
3397
  bridge;
3131
3398
  openCloudClient;
3132
3399
  cookieClient;
3400
+ instanceManager;
3133
3401
  constructor(bridge) {
3134
3402
  this.client = new StudioHttpClient(bridge);
3135
3403
  this.bridge = bridge;
3136
3404
  this.openCloudClient = new OpenCloudClient();
3137
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
+ };
3138
3428
  }
3139
3429
  // Resolve (instance_id, target-role) → concrete (instanceId, role) and
3140
3430
  // dispatch a single request. Throws RoutingFailure if the resolution is
@@ -3459,8 +3749,8 @@ var init_tools = __esm({
3459
3749
  timedOut: true
3460
3750
  };
3461
3751
  }
3462
- async getFileTree(path2 = "", instance_id) {
3463
- const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
3752
+ async getFileTree(path3 = "", instance_id) {
3753
+ const response = await this._callSingle("/api/file-tree", { path: path3 }, void 0, instance_id);
3464
3754
  return {
3465
3755
  content: [
3466
3756
  {
@@ -3577,9 +3867,9 @@ var init_tools = __esm({
3577
3867
  ]
3578
3868
  };
3579
3869
  }
3580
- async getProjectStructure(path2, maxDepth, scriptsOnly, instance_id) {
3870
+ async getProjectStructure(path3, maxDepth, scriptsOnly, instance_id) {
3581
3871
  const response = await this._callSingle("/api/project-structure", {
3582
- path: path2,
3872
+ path: path3,
3583
3873
  maxDepth,
3584
3874
  scriptsOnly
3585
3875
  }, void 0, instance_id);
@@ -4500,8 +4790,8 @@ ${code}`
4500
4790
  const rawJson = mutable.raw_json;
4501
4791
  if (typeof rawJson === "string") {
4502
4792
  if (typeof outputPath === "string" && outputPath !== "") {
4503
- const resolvedOutputPath = path.resolve(outputPath);
4504
- fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true });
4793
+ const resolvedOutputPath = path2.resolve(outputPath);
4794
+ fs.mkdirSync(path2.dirname(resolvedOutputPath), { recursive: true });
4505
4795
  fs.writeFileSync(resolvedOutputPath, rawJson, "utf8");
4506
4796
  mutable.output_path = resolvedOutputPath;
4507
4797
  }
@@ -4537,12 +4827,272 @@ ${code}`
4537
4827
  const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
4538
4828
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
4539
4829
  }
4540
- async startPlaytest(mode, numPlayers, instance_id) {
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) {
4541
5091
  if (mode !== "play" && mode !== "run") {
4542
5092
  throw new Error('mode must be "play" or "run"');
4543
5093
  }
4544
5094
  if (numPlayers !== void 0) {
4545
- throw new Error("start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.");
5095
+ throw new Error('start_playtest is single-player only. Use multiplayer_playtest action="start" for multi-client StudioTestService sessions.');
4546
5096
  }
4547
5097
  const data = { mode };
4548
5098
  const startedAt = Date.now();
@@ -4581,7 +5131,7 @@ ${code}`
4581
5131
  let wait;
4582
5132
  if (response?.success === true) {
4583
5133
  const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
4584
- wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, 60, true);
5134
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout, true);
4585
5135
  }
4586
5136
  const body = wait ? {
4587
5137
  ...response,
@@ -4598,7 +5148,7 @@ ${code}`
4598
5148
  ]
4599
5149
  };
4600
5150
  }
4601
- async stopPlaytest(instance_id) {
5151
+ async stopPlaytest(instance_id, timeout = 15) {
4602
5152
  const { instanceId } = this._resolveSingleTarget("edit", instance_id);
4603
5153
  let response;
4604
5154
  let stopRequestError;
@@ -4614,7 +5164,7 @@ ${code}`
4614
5164
  }
4615
5165
  let wait;
4616
5166
  if (response?.success === true) {
4617
- wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15, true);
5167
+ wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout, true);
4618
5168
  } else if (this._runtimeTargetsForEquivalentInstances(instanceId).length > 0) {
4619
5169
  wait = {
4620
5170
  ok: false,
@@ -4739,6 +5289,94 @@ ${code}`
4739
5289
  }
4740
5290
  return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
4741
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
+ }
4742
5380
  async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
4743
5381
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
4744
5382
  throw new Error("numPlayers must be an integer from 1 to 8");
@@ -4876,14 +5514,14 @@ ${code}`
4876
5514
  };
4877
5515
  }
4878
5516
  static findProjectRoot(startDir) {
4879
- let dir = path.resolve(startDir);
5517
+ let dir = path2.resolve(startDir);
4880
5518
  let previous = "";
4881
5519
  while (dir !== previous) {
4882
- if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
5520
+ if (fs.existsSync(path2.join(dir, ".git")) || fs.existsSync(path2.join(dir, "package.json"))) {
4883
5521
  return dir;
4884
5522
  }
4885
5523
  previous = dir;
4886
- dir = path.dirname(dir);
5524
+ dir = path2.dirname(dir);
4887
5525
  }
4888
5526
  return null;
4889
5527
  }
@@ -4897,7 +5535,7 @@ ${code}`
4897
5535
  }
4898
5536
  }
4899
5537
  static ensureWritableDirectory(candidate, label) {
4900
- const resolved = path.resolve(candidate);
5538
+ const resolved = path2.resolve(candidate);
4901
5539
  try {
4902
5540
  fs.mkdirSync(resolved, { recursive: true });
4903
5541
  } catch (error) {
@@ -4918,11 +5556,11 @@ ${code}`
4918
5556
  if (_RobloxStudioTools._cachedLibraryPath)
4919
5557
  return _RobloxStudioTools._cachedLibraryPath;
4920
5558
  const overridePath = process.env.ROBLOXSTUDIO_MCP_BUILD_LIBRARY || process.env.BUILD_LIBRARY_PATH;
4921
- const cwd = path.resolve(process.cwd());
5559
+ const cwd = path2.resolve(process.cwd());
4922
5560
  const projectRoot = _RobloxStudioTools.findProjectRoot(cwd);
4923
- const homeLibraryPath = path.join(os.homedir(), ".robloxstudio-mcp", "build-library");
4924
- const projectLibraryPath = projectRoot ? path.join(projectRoot, "build-library") : null;
4925
- const cwdLibraryPath = path.join(cwd, "build-library");
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");
4926
5564
  let result;
4927
5565
  if (overridePath) {
4928
5566
  result = _RobloxStudioTools.ensureWritableDirectory(overridePath, "override");
@@ -4936,7 +5574,7 @@ ${code}`
4936
5574
  }
4937
5575
  })());
4938
5576
  if (existing) {
4939
- result = path.resolve(existing);
5577
+ result = path2.resolve(existing);
4940
5578
  } else if (projectLibraryPath) {
4941
5579
  try {
4942
5580
  result = _RobloxStudioTools.ensureWritableDirectory(projectLibraryPath, "project-root");
@@ -4963,8 +5601,8 @@ ${code}`
4963
5601
  if (response && response.success && response.buildData) {
4964
5602
  const buildData = response.buildData;
4965
5603
  const buildId = buildData.id || `${style}/exported`;
4966
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
4967
- const dirPath = path.dirname(filePath);
5604
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildId}.json`);
5605
+ const dirPath = path2.dirname(filePath);
4968
5606
  if (!fs.existsSync(dirPath)) {
4969
5607
  fs.mkdirSync(dirPath, { recursive: true });
4970
5608
  }
@@ -5065,8 +5703,8 @@ ${code}`
5065
5703
  const normalizedParts = this.normalizeBuildParts(parts, new Set(Object.keys(normalizedPalette)));
5066
5704
  const computedBounds = bounds || this.computeBounds(normalizedParts);
5067
5705
  const buildData = { id, style, bounds: computedBounds, palette: normalizedPalette, parts: normalizedParts };
5068
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5069
- const dirPath = path.dirname(filePath);
5706
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5707
+ const dirPath = path2.dirname(filePath);
5070
5708
  if (!fs.existsSync(dirPath)) {
5071
5709
  fs.mkdirSync(dirPath, { recursive: true });
5072
5710
  }
@@ -5124,8 +5762,8 @@ ${code}`
5124
5762
  };
5125
5763
  if (seed !== void 0)
5126
5764
  buildData.generatorSeed = seed;
5127
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5128
- const dirPath = path.dirname(filePath);
5765
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5766
+ const dirPath = path2.dirname(filePath);
5129
5767
  if (!fs.existsSync(dirPath)) {
5130
5768
  fs.mkdirSync(dirPath, { recursive: true });
5131
5769
  }
@@ -5153,13 +5791,13 @@ ${code}`
5153
5791
  }
5154
5792
  let resolved;
5155
5793
  if (typeof buildData === "string") {
5156
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
5794
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildData}.json`);
5157
5795
  if (!fs.existsSync(filePath)) {
5158
5796
  throw new Error(`Build not found in library: ${buildData}`);
5159
5797
  }
5160
5798
  resolved = JSON.parse(fs.readFileSync(filePath, "utf-8"));
5161
5799
  } else if (buildData.id && !buildData.parts) {
5162
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
5800
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${buildData.id}.json`);
5163
5801
  if (!fs.existsSync(filePath)) {
5164
5802
  throw new Error(`Build not found in library: ${buildData.id}`);
5165
5803
  }
@@ -5186,13 +5824,13 @@ ${code}`
5186
5824
  const styles = style ? [style] : ["medieval", "modern", "nature", "scifi", "misc"];
5187
5825
  const builds = [];
5188
5826
  for (const s of styles) {
5189
- const dirPath = path.join(libraryPath, s);
5827
+ const dirPath = path2.join(libraryPath, s);
5190
5828
  if (!fs.existsSync(dirPath))
5191
5829
  continue;
5192
5830
  const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".json"));
5193
5831
  for (const file of files) {
5194
5832
  try {
5195
- const content = fs.readFileSync(path.join(dirPath, file), "utf-8");
5833
+ const content = fs.readFileSync(path2.join(dirPath, file), "utf-8");
5196
5834
  const data = JSON.parse(content);
5197
5835
  builds.push({
5198
5836
  id: data.id || `${s}/${file.replace(".json", "")}`,
@@ -5231,7 +5869,7 @@ ${code}`
5231
5869
  if (!id) {
5232
5870
  throw new Error("Build ID is required for get_build");
5233
5871
  }
5234
- const filePath = path.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5872
+ const filePath = path2.join(_RobloxStudioTools.findLibraryPath(), `${id}.json`);
5235
5873
  if (!fs.existsSync(filePath)) {
5236
5874
  throw new Error(`Build not found in library: ${id}`);
5237
5875
  }
@@ -5318,7 +5956,7 @@ ${code}`
5318
5956
  if (!buildId) {
5319
5957
  throw new Error(`Invalid ${validatedKeyPath}: model key "${modelKey}" is not defined in sceneData.models`);
5320
5958
  }
5321
- const filePath = path.join(libraryPath, `${buildId}.json`);
5959
+ const filePath = path2.join(libraryPath, `${buildId}.json`);
5322
5960
  if (!fs.existsSync(filePath)) {
5323
5961
  throw new Error(`Build not found in library: ${buildId}`);
5324
5962
  }
@@ -5501,7 +6139,7 @@ ${code}`
5501
6139
  throw new Error(`File not found: ${filePath}`);
5502
6140
  }
5503
6141
  const fileContent = fs.readFileSync(filePath);
5504
- const fileName = path.basename(filePath);
6142
+ const fileName = path2.basename(filePath);
5505
6143
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
5506
6144
  const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
5507
6145
  return {
@@ -5598,23 +6236,6 @@ ${code}`
5598
6236
  }]
5599
6237
  };
5600
6238
  }
5601
- async characterNavigation(position, instancePath, waitForCompletion, timeout, target, instance_id) {
5602
- if (!position && !instancePath) {
5603
- throw new Error("Either position or instancePath is required for character_navigation");
5604
- }
5605
- const response = await this._callSingle("/api/character-navigation", {
5606
- position,
5607
- instancePath,
5608
- waitForCompletion,
5609
- timeout
5610
- }, target || "edit", instance_id);
5611
- return {
5612
- content: [{
5613
- type: "text",
5614
- text: JSON.stringify(response)
5615
- }]
5616
- };
5617
- }
5618
6239
  async cloneObject(instancePath, targetParentPath, instance_id) {
5619
6240
  if (!instancePath || !targetParentPath) {
5620
6241
  throw new Error("instancePath and targetParentPath are required for clone_object");
@@ -5743,9 +6364,9 @@ ${code}`
5743
6364
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
5744
6365
  }
5745
6366
  const bytes = Buffer.from(response.base64, "base64");
5746
- const resolved = path.resolve(outputPath);
6367
+ const resolved = path2.resolve(outputPath);
5747
6368
  try {
5748
- fs.mkdirSync(path.dirname(resolved), { recursive: true });
6369
+ fs.mkdirSync(path2.dirname(resolved), { recursive: true });
5749
6370
  fs.writeFileSync(resolved, bytes);
5750
6371
  } catch (err) {
5751
6372
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err.message}` }) }] };
@@ -5779,7 +6400,7 @@ ${code}`
5779
6400
  let bytes;
5780
6401
  let sourceLabel;
5781
6402
  if (source.path !== void 0) {
5782
- const resolved = path.resolve(source.path);
6403
+ const resolved = path2.resolve(source.path);
5783
6404
  try {
5784
6405
  bytes = fs.readFileSync(resolved);
5785
6406
  } catch (err) {
@@ -5848,7 +6469,7 @@ ${code}`
5848
6469
  if (response.error) {
5849
6470
  let text = response.error;
5850
6471
  if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
5851
- 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 start_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}`;
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}`;
5852
6473
  }
5853
6474
  return { success: false, error: text };
5854
6475
  }
@@ -6021,7 +6642,7 @@ var init_server = __esm({
6021
6642
  config;
6022
6643
  constructor(config) {
6023
6644
  this.config = config;
6024
- this.allowedToolNames = new Set(config.tools.map((t) => t.name));
6645
+ this.allowedToolNames = new Set((config.callableTools ?? config.tools).map((t) => t.name));
6025
6646
  this.server = new Server2({
6026
6647
  name: config.name,
6027
6648
  version: config.version
@@ -6194,7 +6815,7 @@ var init_server = __esm({
6194
6815
  });
6195
6816
 
6196
6817
  // ../core/dist/tools/definitions.js
6197
- var TOOL_DEFINITIONS, getAllTools;
6818
+ var TOOL_DEFINITIONS, DEPRECATED_TOOL_DEFINITIONS, getAllTools, getAllCallableTools;
6198
6819
  var init_definitions = __esm({
6199
6820
  "../core/dist/tools/definitions.js"() {
6200
6821
  "use strict";
@@ -6209,7 +6830,7 @@ var init_definitions = __esm({
6209
6830
  properties: {
6210
6831
  path: {
6211
6832
  type: "string",
6212
- description: "Root path (default: game root)"
6833
+ description: 'Canonical DataModel path (default: game root), such as game.Workspace or game.ServerScriptService[".dir"]'
6213
6834
  },
6214
6835
  instance_id: {
6215
6836
  type: "string",
@@ -6313,7 +6934,7 @@ var init_definitions = __esm({
6313
6934
  properties: {
6314
6935
  instancePath: {
6315
6936
  type: "string",
6316
- description: "Instance path (dot notation)"
6937
+ description: 'Canonical DataModel path, such as game.Workspace.Part or game.ServerScriptService[".dir"].Main'
6317
6938
  },
6318
6939
  excludeSource: {
6319
6940
  type: "boolean",
@@ -6336,7 +6957,7 @@ var init_definitions = __esm({
6336
6957
  properties: {
6337
6958
  instancePath: {
6338
6959
  type: "string",
6339
- description: "Instance path (dot notation)"
6960
+ description: 'Canonical DataModel path, such as game.Workspace.Part or game.ServerScriptService[".dir"].Main'
6340
6961
  },
6341
6962
  instance_id: {
6342
6963
  type: "string",
@@ -6398,7 +7019,7 @@ var init_definitions = __esm({
6398
7019
  properties: {
6399
7020
  path: {
6400
7021
  type: "string",
6401
- description: "Root path (default: workspace root)"
7022
+ description: "Canonical DataModel path (default: workspace root)"
6402
7023
  },
6403
7024
  maxDepth: {
6404
7025
  type: "number",
@@ -6425,7 +7046,7 @@ var init_definitions = __esm({
6425
7046
  properties: {
6426
7047
  instancePath: {
6427
7048
  type: "string",
6428
- description: "Instance path (dot notation)"
7049
+ description: "Canonical DataModel path"
6429
7050
  },
6430
7051
  propertyName: {
6431
7052
  type: "string",
@@ -6452,7 +7073,7 @@ var init_definitions = __esm({
6452
7073
  paths: {
6453
7074
  type: "array",
6454
7075
  items: { type: "string" },
6455
- description: "Instance paths"
7076
+ description: "Canonical DataModel paths"
6456
7077
  },
6457
7078
  propertyName: {
6458
7079
  type: "string",
@@ -6479,7 +7100,7 @@ var init_definitions = __esm({
6479
7100
  paths: {
6480
7101
  type: "array",
6481
7102
  items: { type: "string" },
6482
- description: "Instance paths"
7103
+ description: "Canonical DataModel paths"
6483
7104
  },
6484
7105
  propertyName: {
6485
7106
  type: "string",
@@ -6502,7 +7123,7 @@ var init_definitions = __esm({
6502
7123
  properties: {
6503
7124
  instancePath: {
6504
7125
  type: "string",
6505
- description: "Instance path"
7126
+ description: "Canonical DataModel path"
6506
7127
  },
6507
7128
  properties: {
6508
7129
  type: "object",
@@ -6530,7 +7151,7 @@ var init_definitions = __esm({
6530
7151
  },
6531
7152
  parent: {
6532
7153
  type: "string",
6533
- description: "Parent instance path"
7154
+ description: "Canonical parent DataModel path"
6534
7155
  },
6535
7156
  name: {
6536
7157
  type: "string",
@@ -6566,7 +7187,7 @@ var init_definitions = __esm({
6566
7187
  },
6567
7188
  parent: {
6568
7189
  type: "string",
6569
- description: "Parent instance path"
7190
+ description: "Canonical parent DataModel path"
6570
7191
  },
6571
7192
  name: {
6572
7193
  type: "string",
@@ -6598,7 +7219,7 @@ var init_definitions = __esm({
6598
7219
  properties: {
6599
7220
  instancePath: {
6600
7221
  type: "string",
6601
- description: "Instance path (dot notation)"
7222
+ description: "Canonical DataModel path"
6602
7223
  },
6603
7224
  instance_id: {
6604
7225
  type: "string",
@@ -6618,7 +7239,7 @@ var init_definitions = __esm({
6618
7239
  properties: {
6619
7240
  instancePath: {
6620
7241
  type: "string",
6621
- description: "Instance path (dot notation)"
7242
+ description: "Canonical DataModel path"
6622
7243
  },
6623
7244
  count: {
6624
7245
  type: "number",
@@ -6679,7 +7300,7 @@ var init_definitions = __esm({
6679
7300
  properties: {
6680
7301
  instancePath: {
6681
7302
  type: "string",
6682
- description: "Instance path (dot notation)"
7303
+ description: "Canonical DataModel path"
6683
7304
  },
6684
7305
  count: {
6685
7306
  type: "number",
@@ -6742,7 +7363,7 @@ var init_definitions = __esm({
6742
7363
  properties: {
6743
7364
  instancePath: {
6744
7365
  type: "string",
6745
- description: "Script instance path"
7366
+ description: "Canonical path to a LuaSourceContainer"
6746
7367
  },
6747
7368
  startLine: {
6748
7369
  type: "number",
@@ -6769,7 +7390,7 @@ var init_definitions = __esm({
6769
7390
  properties: {
6770
7391
  instancePath: {
6771
7392
  type: "string",
6772
- description: "Script instance path"
7393
+ description: "Canonical path to a LuaSourceContainer"
6773
7394
  },
6774
7395
  source: {
6775
7396
  type: "string",
@@ -6792,7 +7413,7 @@ var init_definitions = __esm({
6792
7413
  properties: {
6793
7414
  instancePath: {
6794
7415
  type: "string",
6795
- description: "Script instance path"
7416
+ description: "Canonical path to a LuaSourceContainer"
6796
7417
  },
6797
7418
  old_string: {
6798
7419
  type: "string",
@@ -6823,7 +7444,7 @@ var init_definitions = __esm({
6823
7444
  properties: {
6824
7445
  instancePath: {
6825
7446
  type: "string",
6826
- description: "Script instance path"
7447
+ description: "Canonical path to a LuaSourceContainer"
6827
7448
  },
6828
7449
  afterLine: {
6829
7450
  type: "number",
@@ -6850,7 +7471,7 @@ var init_definitions = __esm({
6850
7471
  properties: {
6851
7472
  instancePath: {
6852
7473
  type: "string",
6853
- description: "Script instance path"
7474
+ description: "Canonical path to a LuaSourceContainer"
6854
7475
  },
6855
7476
  startLine: {
6856
7477
  type: "number",
@@ -6878,7 +7499,7 @@ var init_definitions = __esm({
6878
7499
  properties: {
6879
7500
  instancePath: {
6880
7501
  type: "string",
6881
- description: "Instance path (dot notation)"
7502
+ description: "Canonical DataModel path"
6882
7503
  },
6883
7504
  attributeName: {
6884
7505
  type: "string",
@@ -6908,7 +7529,7 @@ var init_definitions = __esm({
6908
7529
  properties: {
6909
7530
  instancePath: {
6910
7531
  type: "string",
6911
- description: "Instance path (dot notation)"
7532
+ description: "Canonical DataModel path"
6912
7533
  },
6913
7534
  instance_id: {
6914
7535
  type: "string",
@@ -6927,7 +7548,7 @@ var init_definitions = __esm({
6927
7548
  properties: {
6928
7549
  instancePath: {
6929
7550
  type: "string",
6930
- description: "Instance path (dot notation)"
7551
+ description: "Canonical DataModel path"
6931
7552
  },
6932
7553
  attributeName: {
6933
7554
  type: "string",
@@ -6951,7 +7572,7 @@ var init_definitions = __esm({
6951
7572
  properties: {
6952
7573
  instancePath: {
6953
7574
  type: "string",
6954
- description: "Instance path (dot notation)"
7575
+ description: "Canonical DataModel path"
6955
7576
  },
6956
7577
  instance_id: {
6957
7578
  type: "string",
@@ -6970,7 +7591,7 @@ var init_definitions = __esm({
6970
7591
  properties: {
6971
7592
  instancePath: {
6972
7593
  type: "string",
6973
- description: "Instance path (dot notation)"
7594
+ description: "Canonical DataModel path"
6974
7595
  },
6975
7596
  tagName: {
6976
7597
  type: "string",
@@ -6993,7 +7614,7 @@ var init_definitions = __esm({
6993
7614
  properties: {
6994
7615
  instancePath: {
6995
7616
  type: "string",
6996
- description: "Instance path (dot notation)"
7617
+ description: "Canonical DataModel path"
6997
7618
  },
6998
7619
  tagName: {
6999
7620
  type: "string",
@@ -7160,43 +7781,92 @@ var init_definitions = __esm({
7160
7781
  required: ["pattern"]
7161
7782
  }
7162
7783
  },
7163
- // === Playtest ===
7784
+ // === Studio Instance Management ===
7164
7785
  {
7165
- name: "start_playtest",
7786
+ name: "manage_instance",
7166
7787
  category: "write",
7167
- description: "Start a simple single-player Studio playtest in play or run mode, waiting until a runtime peer registers with MCP. Read print/warn/error output with get_runtime_logs, then end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
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.',
7168
7789
  inputSchema: {
7169
7790
  type: "object",
7170
7791
  properties: {
7171
- mode: {
7792
+ action: {
7172
7793
  type: "string",
7173
- enum: ["play", "run"],
7174
- description: "Play mode"
7794
+ enum: ["launch", "close", "status", "list_place_versions"],
7795
+ description: "Instance management action."
7175
7796
  },
7176
- numPlayers: {
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: {
7177
7811
  type: "number",
7178
- description: "Deprecated and rejected. Use multiplayer_test_start for multi-client testing."
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: {
7823
+ type: "number",
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.'
7179
7833
  },
7180
7834
  instance_id: {
7181
7835
  type: "string",
7182
- description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7836
+ description: 'For action="close" or action="status": Studio instance to inspect or close. close accepts MCP-managed instances and explicitly connected edit instances.'
7183
7837
  }
7184
7838
  },
7185
- required: ["mode"]
7839
+ required: ["action"]
7186
7840
  }
7187
7841
  },
7842
+ // === Playtest ===
7188
7843
  {
7189
- name: "stop_playtest",
7844
+ name: "solo_playtest",
7190
7845
  category: "write",
7191
- description: "Stop playtest and wait for runtime peers to disconnect.",
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.',
7192
7847
  inputSchema: {
7193
7848
  type: "object",
7194
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
+ },
7195
7864
  instance_id: {
7196
7865
  type: "string",
7197
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."
7198
7867
  }
7199
- }
7868
+ },
7869
+ required: ["action"]
7200
7870
  }
7201
7871
  },
7202
7872
  {
@@ -7265,7 +7935,7 @@ var init_definitions = __esm({
7265
7935
  {
7266
7936
  name: "get_simulation_state",
7267
7937
  category: "read",
7268
- description: 'Inspect current NetworkSettings and/or StudioDeviceSimulatorService state for edit and connected playtest clients only. Defaults to include="both" and target="edit-and-clients"; server peers are skipped. Use before diagnosing network or device-sensitive tests, especially because normal Play can write client simulator changes back to edit and StudioTestService clients can inherit stale device simulator state.',
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.',
7269
7939
  inputSchema: {
7270
7940
  type: "object",
7271
7941
  properties: {
@@ -7288,7 +7958,7 @@ var init_definitions = __esm({
7288
7958
  {
7289
7959
  name: "reset_simulation_state",
7290
7960
  category: "write",
7291
- description: 'Reset reachable simulation state to a clean baseline for deterministic 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(). Call before tests, after starting Play or multiplayer, before stopping, and again on edit after stopping.',
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.',
7292
7962
  inputSchema: {
7293
7963
  type: "object",
7294
7964
  properties: {
@@ -7474,109 +8144,41 @@ var init_definitions = __esm({
7474
8144
  }
7475
8145
  },
7476
8146
  {
7477
- name: "multiplayer_test_start",
8147
+ name: "multiplayer_playtest",
7478
8148
  category: "write",
7479
- description: "Start a StudioTestService multiplayer test and wait for the server plus requested client peers to connect. Use this for multi-client runtime testing.",
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.',
7480
8150
  inputSchema: {
7481
8151
  type: "object",
7482
8152
  properties: {
7483
- numPlayers: {
7484
- type: "number",
7485
- description: "Number of client players to start (1-8)."
7486
- },
7487
- testArgs: {
7488
- description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
7489
- },
7490
- timeout: {
7491
- type: "number",
7492
- description: "Max seconds to wait for server + clients to register (default 30)."
7493
- },
7494
- instance_id: {
7495
- type: "string",
7496
- description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7497
- }
7498
- },
7499
- required: ["numPlayers"]
7500
- }
7501
- },
7502
- {
7503
- name: "multiplayer_test_state",
7504
- category: "read",
7505
- description: "Get the active multiplayer StudioTestService state for a place: phase, peers, players, original testArgs, result/error, and connected client roles.",
7506
- inputSchema: {
7507
- type: "object",
7508
- properties: {
7509
- instance_id: {
8153
+ action: {
7510
8154
  type: "string",
7511
- description: "Which connected Studio place to inspect. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7512
- }
7513
- }
7514
- }
7515
- },
7516
- {
7517
- name: "multiplayer_test_add_players",
7518
- category: "write",
7519
- description: "Add client players to a running StudioTestService multiplayer test and wait for the new clients to connect.",
7520
- inputSchema: {
7521
- type: "object",
7522
- properties: {
7523
- numPlayers: {
7524
- type: "number",
7525
- description: "Number of additional client players to add (1-8)."
8155
+ enum: ["start", "status", "add_players", "leave_client", "end"],
8156
+ description: "Lifecycle action to run."
7526
8157
  },
7527
- timeout: {
8158
+ numPlayers: {
7528
8159
  type: "number",
7529
- description: "Max seconds to wait for new clients to register (default 30)."
8160
+ description: 'Required for action="start" and action="add_players". Number of client players (1-8).'
7530
8161
  },
7531
- instance_id: {
7532
- type: "string",
7533
- description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7534
- }
7535
- },
7536
- required: ["numPlayers"]
7537
- }
7538
- },
7539
- {
7540
- name: "multiplayer_test_leave_client",
7541
- category: "write",
7542
- description: "Disconnect a specific client from a running StudioTestService multiplayer test, then wait for that client peer to leave.",
7543
- inputSchema: {
7544
- type: "object",
7545
- properties: {
7546
8162
  target: {
7547
8163
  type: "string",
7548
- description: 'Client target to leave: "client-1" (default), "client-2", etc.'
8164
+ description: 'Client target for action="leave_client", such as "client-1". Defaults to "client-1".'
7549
8165
  },
7550
- timeout: {
7551
- type: "number",
7552
- 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.'
7553
8168
  },
7554
- instance_id: {
7555
- type: "string",
7556
- description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7557
- }
7558
- }
7559
- }
7560
- },
7561
- {
7562
- name: "multiplayer_test_end",
7563
- category: "write",
7564
- description: "End a running StudioTestService multiplayer test with an optional return value, then wait for all runtime peers to disconnect.",
7565
- inputSchema: {
7566
- type: "object",
7567
- properties: {
7568
8169
  value: {
7569
- 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.'
7570
8171
  },
7571
8172
  timeout: {
7572
8173
  type: "number",
7573
- description: "Max seconds to wait for runtime peers to disconnect (default 30)."
8174
+ description: "Max seconds to wait for action completion. Defaults to 30."
7574
8175
  },
7575
8176
  instance_id: {
7576
8177
  type: "string",
7577
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."
7578
8179
  }
7579
- }
8180
+ },
8181
+ required: ["action"]
7580
8182
  }
7581
8183
  },
7582
8184
  {
@@ -7689,7 +8291,7 @@ var init_definitions = __esm({
7689
8291
  },
7690
8292
  script_path: {
7691
8293
  type: "string",
7692
- description: "Path to a LuaSourceContainer, for example game.ServerScriptService.Main. Required for set/remove."
8294
+ description: 'Canonical path to a LuaSourceContainer, for example game.ServerScriptService.Main or game.ServerScriptService[".dir"].ReproScript. Required for set/remove.'
7693
8295
  },
7694
8296
  line: {
7695
8297
  type: "number",
@@ -7772,7 +8374,7 @@ var init_definitions = __esm({
7772
8374
  properties: {
7773
8375
  instancePath: {
7774
8376
  type: "string",
7775
- description: "Path to the Model or Folder to export (dot notation)"
8377
+ description: "Canonical path to the Model or Folder to export"
7776
8378
  },
7777
8379
  outputId: {
7778
8380
  type: "string",
@@ -7929,7 +8531,7 @@ part(0,2,0,2,1,1,"b")`,
7929
8531
  },
7930
8532
  targetPath: {
7931
8533
  type: "string",
7932
- description: "Parent instance path where the model will be created"
8534
+ description: "Canonical parent DataModel path where the model will be created"
7933
8535
  },
7934
8536
  position: {
7935
8537
  type: "array",
@@ -8060,7 +8662,7 @@ part(0,2,0,2,1,1,"b")`,
8060
8662
  },
8061
8663
  targetPath: {
8062
8664
  type: "string",
8063
- description: "Parent instance path for the scene (default: game.Workspace)"
8665
+ description: "Canonical parent DataModel path for the scene (default: game.Workspace)"
8064
8666
  },
8065
8667
  instance_id: {
8066
8668
  type: "string",
@@ -8152,7 +8754,7 @@ part(0,2,0,2,1,1,"b")`,
8152
8754
  },
8153
8755
  parentPath: {
8154
8756
  type: "string",
8155
- description: "Parent instance path (default: game.Workspace)"
8757
+ description: "Canonical parent DataModel path (default: game.Workspace)"
8156
8758
  },
8157
8759
  position: {
8158
8760
  type: "object",
@@ -8330,42 +8932,6 @@ part(0,2,0,2,1,1,"b")`,
8330
8932
  }
8331
8933
  }
8332
8934
  },
8333
- // === Character Navigation ===
8334
- {
8335
- name: "character_navigation",
8336
- category: "write",
8337
- description: 'Move the player character to a target position or instance during playtest. Uses PathfindingService for automatic navigation around obstacles, falling back to direct movement. Requires an active playtest in "play" mode. Does NOT simulate player input - moves the character directly.',
8338
- inputSchema: {
8339
- type: "object",
8340
- properties: {
8341
- position: {
8342
- type: "array",
8343
- items: { type: "number" },
8344
- description: "Target world position [x, y, z]. Either this or instancePath is required."
8345
- },
8346
- instancePath: {
8347
- type: "string",
8348
- description: "Instance to navigate to (dot notation). The character walks to its Position. Either this or position is required."
8349
- },
8350
- waitForCompletion: {
8351
- type: "boolean",
8352
- description: "Wait for the character to arrive before returning (default: true)"
8353
- },
8354
- timeout: {
8355
- type: "number",
8356
- description: "Max seconds to wait for navigation to complete (default: 25)"
8357
- },
8358
- target: {
8359
- type: "string",
8360
- description: 'Instance target: "edit" (default), "server", "client-1", "client-2", etc.'
8361
- },
8362
- instance_id: {
8363
- type: "string",
8364
- description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
8365
- }
8366
- }
8367
- }
8368
- },
8369
8935
  // === Instance Operations ===
8370
8936
  {
8371
8937
  name: "clone_object",
@@ -8376,11 +8942,11 @@ part(0,2,0,2,1,1,"b")`,
8376
8942
  properties: {
8377
8943
  instancePath: {
8378
8944
  type: "string",
8379
- description: "Path of the instance to clone"
8945
+ description: "Canonical path of the instance to clone"
8380
8946
  },
8381
8947
  targetParentPath: {
8382
8948
  type: "string",
8383
- description: "Path of the parent to place the clone under"
8949
+ description: "Canonical path of the parent to place the clone under"
8384
8950
  },
8385
8951
  instance_id: {
8386
8952
  type: "string",
@@ -8400,7 +8966,7 @@ part(0,2,0,2,1,1,"b")`,
8400
8966
  properties: {
8401
8967
  instancePath: {
8402
8968
  type: "string",
8403
- description: "Root instance path"
8969
+ description: "Canonical root DataModel path"
8404
8970
  },
8405
8971
  maxDepth: {
8406
8972
  type: "number",
@@ -8427,11 +8993,11 @@ part(0,2,0,2,1,1,"b")`,
8427
8993
  properties: {
8428
8994
  instancePathA: {
8429
8995
  type: "string",
8430
- description: "First instance path"
8996
+ description: "First canonical DataModel path"
8431
8997
  },
8432
8998
  instancePathB: {
8433
8999
  type: "string",
8434
- description: "Second instance path"
9000
+ description: "Second canonical DataModel path"
8435
9001
  },
8436
9002
  instance_id: {
8437
9003
  type: "string",
@@ -8451,7 +9017,7 @@ part(0,2,0,2,1,1,"b")`,
8451
9017
  properties: {
8452
9018
  instancePath: {
8453
9019
  type: "string",
8454
- description: "Instance path"
9020
+ description: "Canonical DataModel path"
8455
9021
  },
8456
9022
  attributes: {
8457
9023
  type: "object",
@@ -8533,7 +9099,7 @@ part(0,2,0,2,1,1,"b")`,
8533
9099
  instance_paths: {
8534
9100
  type: "array",
8535
9101
  items: { type: "string" },
8536
- description: 'DataModel paths to serialize (e.g. ["Workspace.TestRig", "ServerStorage.Templates.NPC"])'
9102
+ description: 'Canonical DataModel paths to serialize (e.g. ["game.Workspace.TestRig", "game.ServerStorage.Templates.NPC"])'
8537
9103
  },
8538
9104
  output_path: {
8539
9105
  type: "string",
@@ -8575,7 +9141,7 @@ part(0,2,0,2,1,1,"b")`,
8575
9141
  },
8576
9142
  parent_path: {
8577
9143
  type: "string",
8578
- description: 'DataModel path of the Instance to parent imported instances under (e.g. "ServerStorage.Imported")'
9144
+ description: 'Canonical DataModel path of the Instance to parent imported instances under (e.g. "game.ServerStorage.Imported")'
8579
9145
  },
8580
9146
  target: {
8581
9147
  type: "string",
@@ -8640,20 +9206,168 @@ part(0,2,0,2,1,1,"b")`,
8640
9206
  }
8641
9207
  }
8642
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
+ ];
8643
9356
  getAllTools = () => [...TOOL_DEFINITIONS];
9357
+ getAllCallableTools = () => [...TOOL_DEFINITIONS, ...DEPRECATED_TOOL_DEFINITIONS];
8644
9358
  }
8645
9359
  });
8646
9360
 
8647
9361
  // ../core/dist/install-plugin-helpers.js
8648
- import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync } from "fs";
9362
+ import { existsSync as existsSync3, readFileSync as readFileSync3, unlinkSync } from "fs";
8649
9363
  import { execSync } from "child_process";
8650
- import { join as join2 } from "path";
8651
- import { homedir as homedir2 } from "os";
9364
+ import { join as join3 } from "path";
9365
+ import { homedir as homedir3 } from "os";
8652
9366
  function isWSL() {
8653
9367
  if (process.platform !== "linux")
8654
9368
  return false;
8655
9369
  try {
8656
- const v = readFileSync2("/proc/version", "utf8");
9370
+ const v = readFileSync3("/proc/version", "utf8");
8657
9371
  return /microsoft|wsl/i.test(v);
8658
9372
  } catch {
8659
9373
  return false;
@@ -8671,7 +9385,7 @@ function getWindowsUserPluginsDir() {
8671
9385
  }).toString().trim();
8672
9386
  if (!linuxPath)
8673
9387
  return null;
8674
- return join2(linuxPath, "Roblox", "Plugins");
9388
+ return join3(linuxPath, "Roblox", "Plugins");
8675
9389
  } catch {
8676
9390
  return null;
8677
9391
  }
@@ -8680,7 +9394,7 @@ function getPluginsFolder() {
8680
9394
  if (process.env.MCP_PLUGINS_DIR)
8681
9395
  return process.env.MCP_PLUGINS_DIR;
8682
9396
  if (process.platform === "win32") {
8683
- return join2(process.env.LOCALAPPDATA || join2(homedir2(), "AppData", "Local"), "Roblox", "Plugins");
9397
+ return join3(process.env.LOCALAPPDATA || join3(homedir3(), "AppData", "Local"), "Roblox", "Plugins");
8684
9398
  }
8685
9399
  if (isWSL()) {
8686
9400
  const win = getWindowsUserPluginsDir();
@@ -8688,11 +9402,11 @@ function getPluginsFolder() {
8688
9402
  return win;
8689
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.");
8690
9404
  }
8691
- return join2(homedir2(), "Documents", "Roblox", "Plugins");
9405
+ return join3(homedir3(), "Documents", "Roblox", "Plugins");
8692
9406
  }
8693
9407
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
8694
- const otherDest = join2(pluginsFolder, otherAssetName);
8695
- if (!existsSync2(otherDest))
9408
+ const otherDest = join3(pluginsFolder, otherAssetName);
9409
+ if (!existsSync3(otherDest))
8696
9410
  return;
8697
9411
  if (replace) {
8698
9412
  try {
@@ -8737,8 +9451,8 @@ __export(install_plugin_exports, {
8737
9451
  installBundledPlugin: () => installBundledPlugin,
8738
9452
  installPlugin: () => installPlugin
8739
9453
  });
8740
- import { copyFileSync, createWriteStream, existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, unlinkSync as unlinkSync2 } from "fs";
8741
- import { dirname as dirname2, join as join3 } from "path";
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";
8742
9456
  import { fileURLToPath } from "url";
8743
9457
  import { get } from "https";
8744
9458
  function httpsGet(url) {
@@ -8808,7 +9522,7 @@ function prepareInstall({
8808
9522
  warn
8809
9523
  }) {
8810
9524
  const pluginsFolder = getPluginsFolder();
8811
- if (!existsSync3(pluginsFolder)) {
9525
+ if (!existsSync4(pluginsFolder)) {
8812
9526
  mkdirSync2(pluginsFolder, { recursive: true });
8813
9527
  }
8814
9528
  handleVariantConflict({
@@ -8823,21 +9537,21 @@ function prepareInstall({
8823
9537
  function bundledAssetPath() {
8824
9538
  const currentDir = dirname2(fileURLToPath(import.meta.url));
8825
9539
  const candidates = [
8826
- join3(currentDir, "..", "studio-plugin", ASSET_NAME),
8827
- join3(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
9540
+ join4(currentDir, "..", "studio-plugin", ASSET_NAME),
9541
+ join4(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
8828
9542
  ];
8829
- return candidates.find((candidate) => existsSync3(candidate)) ?? null;
9543
+ return candidates.find((candidate) => existsSync4(candidate)) ?? null;
8830
9544
  }
8831
9545
  function packageVersion() {
8832
9546
  const currentDir = dirname2(fileURLToPath(import.meta.url));
8833
- const pkg = JSON.parse(readFileSync3(join3(currentDir, "..", "package.json"), "utf8"));
9547
+ const pkg = JSON.parse(readFileSync4(join4(currentDir, "..", "package.json"), "utf8"));
8834
9548
  if (!pkg.version) {
8835
9549
  throw new Error("Package version not found");
8836
9550
  }
8837
9551
  return pkg.version;
8838
9552
  }
8839
9553
  function bundledPluginVersion(source) {
8840
- const match = readFileSync3(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
9554
+ const match = readFileSync4(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
8841
9555
  return match ? match[1] : null;
8842
9556
  }
8843
9557
  function assertBundledPluginVersion(source) {
@@ -8850,9 +9564,9 @@ function assertBundledPluginVersion(source) {
8850
9564
  }
8851
9565
  }
8852
9566
  function filesMatch(a, b) {
8853
- if (!existsSync3(b)) return false;
8854
- const aBytes = readFileSync3(a);
8855
- const bBytes = readFileSync3(b);
9567
+ if (!existsSync4(b)) return false;
9568
+ const aBytes = readFileSync4(a);
9569
+ const bBytes = readFileSync4(b);
8856
9570
  return aBytes.length === bBytes.length && aBytes.equals(bBytes);
8857
9571
  }
8858
9572
  async function installBundledPlugin(options = {}) {
@@ -8865,7 +9579,7 @@ async function installBundledPlugin(options = {}) {
8865
9579
  }
8866
9580
  assertBundledPluginVersion(source);
8867
9581
  const pluginsFolder = prepareInstall({ replaceVariant, log, warn });
8868
- const dest = join3(pluginsFolder, ASSET_NAME);
9582
+ const dest = join4(pluginsFolder, ASSET_NAME);
8869
9583
  if (filesMatch(source, dest)) return;
8870
9584
  copyFileSync(source, dest);
8871
9585
  log(`Installed ${ASSET_NAME} to ${dest}`);
@@ -8879,7 +9593,7 @@ async function installPlugin(options = {}) {
8879
9593
  const bundled = bundledAssetPath();
8880
9594
  if (bundled) {
8881
9595
  assertBundledPluginVersion(bundled);
8882
- const dest2 = join3(pluginsFolder, ASSET_NAME);
9596
+ const dest2 = join4(pluginsFolder, ASSET_NAME);
8883
9597
  if (filesMatch(bundled, dest2)) {
8884
9598
  log(`${ASSET_NAME} already installed.`);
8885
9599
  return;
@@ -8894,7 +9608,7 @@ async function installPlugin(options = {}) {
8894
9608
  if (!asset) {
8895
9609
  throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
8896
9610
  }
8897
- const dest = join3(pluginsFolder, ASSET_NAME);
9611
+ const dest = join4(pluginsFolder, ASSET_NAME);
8898
9612
  log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
8899
9613
  await download(asset.browser_download_url, dest);
8900
9614
  log(`Installed to ${dest}`);
@@ -8948,7 +9662,8 @@ if (process.argv.includes("--install-plugin")) {
8948
9662
  const server = new RobloxStudioMCPServer({
8949
9663
  name: "robloxstudio-mcp",
8950
9664
  version: VERSION,
8951
- tools: getAllTools()
9665
+ tools: getAllTools(),
9666
+ callableTools: getAllCallableTools()
8952
9667
  });
8953
9668
  server.run().catch((error) => {
8954
9669
  console.error("Server failed to start:", error);