@chrrxs/robloxstudio-mcp-inspector 3.1.3 → 3.1.5

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
@@ -1945,6 +1945,15 @@ import { toNodeHandler } from "@modelcontextprotocol/node";
1945
1945
 
1946
1946
  // ../core/dist/bridge-service.js
1947
1947
  import { createHash, randomUUID } from "crypto";
1948
+ var MultiplayerGroupInUseError = class extends Error {
1949
+ groupId;
1950
+ code = "multiplayer_group_in_use";
1951
+ constructor(groupId) {
1952
+ super(`Multiplayer Group "${groupId}" still has connected runtime peers and cannot be removed.`);
1953
+ this.groupId = groupId;
1954
+ this.name = "MultiplayerGroupInUseError";
1955
+ }
1956
+ };
1948
1957
  function isExecutionOutcome(value) {
1949
1958
  return value === "success" || value === "error" || value === "not_executed" || value === "unknown";
1950
1959
  }
@@ -2233,6 +2242,11 @@ var BridgeService = class {
2233
2242
  const group = this.multiplayerGroupsById.get(groupId);
2234
2243
  if (!group)
2235
2244
  return void 0;
2245
+ for (const peer of this.peersById.values()) {
2246
+ if (peer.multiplayerGroupId === groupId && isRuntimeRole(peer.role)) {
2247
+ throw new MultiplayerGroupInUseError(groupId);
2248
+ }
2249
+ }
2236
2250
  this.multiplayerGroupsById.delete(groupId);
2237
2251
  for (const peer of this.peersById.values()) {
2238
2252
  if (peer.multiplayerGroupId === groupId)
@@ -2855,7 +2869,7 @@ var BridgeService = class {
2855
2869
  operation.status.executionStartedAt = now;
2856
2870
  else {
2857
2871
  operation.status.executionOutcome = outcome ?? "unknown";
2858
- if (outcome !== "not_executed")
2872
+ if (outcome === "success" || outcome === "error")
2859
2873
  operation.status.executionCompletedAt = now;
2860
2874
  }
2861
2875
  operation.updatedAt = now;
@@ -2871,7 +2885,7 @@ var BridgeService = class {
2871
2885
  requestId,
2872
2886
  targetPeerId: operation.status.targetPeerId,
2873
2887
  ...observations(operation.status),
2874
- executionCompletedAt: executionOutcome === "not_executed" ? void 0 : operation.status.executionCompletedAt ?? Date.now(),
2888
+ executionCompletedAt: executionOutcome === "success" || executionOutcome === "error" ? operation.status.executionCompletedAt ?? Date.now() : void 0,
2875
2889
  stage: "response_delivery",
2876
2890
  outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
2877
2891
  executionOutcome
@@ -2899,13 +2913,15 @@ var BridgeService = class {
2899
2913
  operation.status.state = "settled";
2900
2914
  const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
2901
2915
  const responseOutcome = handlerOutcome(response);
2902
- const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2916
+ const completedOutcome = executionOutcome === "unknown" || executionOutcome === "not_executed" ? executionOutcome : !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2903
2917
  operation.status.executionOutcome = completedOutcome;
2904
- operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2918
+ operation.status.outcome = hasError || responseOutcome === "error" || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2905
2919
  if (!localRejection) {
2906
2920
  operation.status.stage = "response_delivery";
2907
- if (completedOutcome !== "not_executed")
2921
+ if (completedOutcome === "success" || completedOutcome === "error")
2908
2922
  operation.status.executionCompletedAt ??= now;
2923
+ else
2924
+ delete operation.status.executionCompletedAt;
2909
2925
  }
2910
2926
  operation.status.settledAt = now;
2911
2927
  operation.updatedAt = now;
@@ -3308,6 +3324,10 @@ execute_luau runs through the Studio plugin. eval_server_runtime and eval_client
3308
3324
 
3309
3325
  Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance, or nextCursorByInstance as cursor_by_instance for a Multiplayer Group, instead of requesting the full process streams again.
3310
3326
 
3327
+ - Each Peer VM retains approximately 64 KiB of message text, rather than a fixed message count. Reads do not consume or clear the buffer; incoming messages evict old entries.
3328
+ - totalDropped sums lifetime eviction counts from successfully read Peers. It counts messages evicted, not messages missed by this caller, and may decrease when Peers fail, disconnect, or reload.
3329
+ - For successfully read Peers, nextCursor advances past all captured entries, including those omitted by filter or tail. It is a continuation watermark, not pagination through omitted results; continuing from it will not return those entries.
3330
+
3311
3331
  ## Simulation and input
3312
3332
 
3313
3333
  - Inspect current settings with get_simulation_state before changing them.
@@ -3936,6 +3956,40 @@ var StudioLaunchPreDispatchError = class extends Error {
3936
3956
  };
3937
3957
  }
3938
3958
  };
3959
+ var StudioCloseVerificationError = class extends Error {
3960
+ observation;
3961
+ constructor(message, observation) {
3962
+ super(message);
3963
+ this.observation = observation;
3964
+ }
3965
+ };
3966
+ async function beforeCloseDeadline(operation, deadline, processId) {
3967
+ const { promise, reject } = Promise.withResolvers();
3968
+ const timeoutError = () => new StudioCloseVerificationError(`Timed out verifying Studio process ${processId} termination; process state is unknown.`, { status: "unknown", observedAt: Date.now(), error: "Close operation deadline exceeded." });
3969
+ if (Date.now() >= deadline)
3970
+ throw timeoutError();
3971
+ const timer = setTimeout(() => reject(timeoutError()), deadline - Date.now());
3972
+ try {
3973
+ return await Promise.race([operation(), promise]);
3974
+ } finally {
3975
+ clearTimeout(timer);
3976
+ }
3977
+ }
3978
+ function observePosixProcess(processId) {
3979
+ try {
3980
+ process.kill(processId, 0);
3981
+ return { status: "running", observedAt: Date.now() };
3982
+ } catch (error) {
3983
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
3984
+ return { status: "not_running", observedAt: Date.now(), reason: "missing" };
3985
+ }
3986
+ return {
3987
+ status: "unknown",
3988
+ observedAt: Date.now(),
3989
+ error: error instanceof Error ? error.message : String(error)
3990
+ };
3991
+ }
3992
+ }
3939
3993
  function run(command, args, options = {}) {
3940
3994
  return execFileSync2(command, args, {
3941
3995
  encoding: "utf8",
@@ -3957,9 +4011,10 @@ async function runAsync(command, args, options = {}) {
3957
4011
  function isWsl() {
3958
4012
  return getStudioPlatformCapabilities().isWsl;
3959
4013
  }
3960
- async function powershellAsync(script) {
4014
+ async function powershellAsync(script, timeoutMs = 15e3) {
3961
4015
  return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
3962
- cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
4016
+ cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4017
+ timeout: timeoutMs
3963
4018
  });
3964
4019
  }
3965
4020
  function windowsLocalAppData() {
@@ -4094,8 +4149,15 @@ function quoteWindowsCommandLineArg(value) {
4094
4149
  }
4095
4150
  return `${quoted}${"\\".repeat(backslashes * 2)}"`;
4096
4151
  }
4097
- function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory) {
4152
+ function parseStudioTestWorkerJobName(name) {
4153
+ if (name !== void 0 && !/^Local\\RsmcpStudioWorker-[a-f0-9]{32}$/u.test(name)) {
4154
+ throw new Error("Invalid Studio test worker job name.");
4155
+ }
4156
+ return name;
4157
+ }
4158
+ function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB) {
4098
4159
  const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
4160
+ parseStudioTestWorkerJobName(testWorkerJobName);
4099
4161
  const commandLine = [windowsExe, ...args].map(quoteWindowsCommandLineArg).join(" ");
4100
4162
  return [
4101
4163
  "$ErrorActionPreference = 'Stop'",
@@ -4208,6 +4270,15 @@ public sealed class McpSuspendedStudio : IDisposable
4208
4270
  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
4209
4271
  private static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string name);
4210
4272
 
4273
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
4274
+ private static extern IntPtr OpenJobObjectW(uint access, bool inheritHandle, string name);
4275
+
4276
+ [DllImport("kernel32.dll", SetLastError = true)]
4277
+ private static extern bool GetHandleInformation(IntPtr handle, out uint flags);
4278
+
4279
+ [DllImport("kernel32.dll", SetLastError = true)]
4280
+ private static extern bool QueryInformationJobObject(IntPtr job, int informationClass, IntPtr information, uint informationLength, out uint returned);
4281
+
4211
4282
  [DllImport("kernel32.dll", SetLastError = true)]
4212
4283
  private static extern bool SetInformationJobObject(
4213
4284
  IntPtr job,
@@ -4295,20 +4366,60 @@ public sealed class McpSuspendedStudio : IDisposable
4295
4366
  throw new InvalidOperationException("Unexpected Studio process wait result: " + wait);
4296
4367
  }
4297
4368
 
4298
- public static McpSuspendedStudio Start(string application, string commandLine, string currentDirectory)
4369
+ private static Win32Exception TestWorkerError(string operation)
4370
+ {
4371
+ int error = Marshal.GetLastWin32Error();
4372
+ return new Win32Exception(error, operation + " (Win32 " + error + "): " + new Win32Exception(error).Message);
4373
+ }
4374
+
4375
+ private static IntPtr OpenTestWorkerJob(string name)
4376
+ {
4377
+ if (name == null) return IntPtr.Zero;
4378
+ if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"\\ALocal\\\\RsmcpStudioWorker-[a-f0-9]{32}\\z"))
4379
+ throw new ArgumentException("Invalid Studio test worker job name");
4380
+ IntPtr handle = OpenJobObjectW(5, false, name);
4381
+ if (handle == IntPtr.Zero)
4382
+ throw TestWorkerError("Open test worker job failed");
4383
+ try
4384
+ {
4385
+ uint flags;
4386
+ if (!GetHandleInformation(handle, out flags))
4387
+ throw TestWorkerError("Get test worker job handle flags failed");
4388
+ if ((flags & 1) != 0)
4389
+ throw new InvalidOperationException("Test worker job handle must not be inherited");
4390
+ int length = Marshal.SizeOf(typeof(ExtendedLimitInformation));
4391
+ IntPtr buffer = Marshal.AllocHGlobal(length);
4392
+ try
4393
+ {
4394
+ uint returned;
4395
+ if (!QueryInformationJobObject(handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, buffer, (uint)length, out returned))
4396
+ throw TestWorkerError("Query test worker job limits failed");
4397
+ var information = (ExtendedLimitInformation)Marshal.PtrToStructure(buffer, typeof(ExtendedLimitInformation));
4398
+ if (returned != length || information.BasicLimitInformation.LimitFlags != JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
4399
+ throw new InvalidOperationException("Test worker job must retain kill-on-close without breakaway");
4400
+ }
4401
+ finally { Marshal.FreeHGlobal(buffer); }
4402
+ return handle;
4403
+ }
4404
+ catch { CloseHandle(handle); throw; }
4405
+ }
4406
+
4407
+ public static McpSuspendedStudio Start(string application, string commandLine, string currentDirectory, string testWorkerJobName)
4299
4408
  {
4300
4409
  if (String.IsNullOrEmpty(currentDirectory))
4301
4410
  currentDirectory = null;
4302
4411
  IntPtr jobHandle = CreateJobObjectW(IntPtr.Zero, null);
4303
4412
  if (jobHandle == IntPtr.Zero)
4304
4413
  throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObjectW failed");
4414
+ IntPtr workerJob = IntPtr.Zero;
4305
4415
  try
4306
4416
  {
4307
4417
  ConfigureKillOnClose(jobHandle, true);
4418
+ workerJob = OpenTestWorkerJob(testWorkerJobName);
4308
4419
  var startup = new StartupInfo();
4309
4420
  startup.cb = Marshal.SizeOf(startup);
4310
4421
  ProcessInformation created;
4311
- uint flags = CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB;
4422
+ uint flags = CREATE_SUSPENDED | (workerJob == IntPtr.Zero ? CREATE_BREAKAWAY_FROM_JOB : 0);
4312
4423
  bool started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, flags, IntPtr.Zero, currentDirectory, ref startup, out created);
4313
4424
  if (!started && Marshal.GetLastWin32Error() == 5)
4314
4425
  started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, CREATE_SUSPENDED, IntPtr.Zero, currentDirectory, ref startup, out created);
@@ -4316,6 +4427,10 @@ public sealed class McpSuspendedStudio : IDisposable
4316
4427
  throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW failed");
4317
4428
  try
4318
4429
  {
4430
+ // Worker lifetime encloses the private launch-authorization job.
4431
+ // COMPLETE may release authorization, never test-worker ownership.
4432
+ if (workerJob != IntPtr.Zero && !AssignProcessToJobObject(workerJob, created.hProcess))
4433
+ throw TestWorkerError("Assign Studio to test worker job failed");
4319
4434
  if (!AssignProcessToJobObject(jobHandle, created.hProcess))
4320
4435
  throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
4321
4436
  return new McpSuspendedStudio(created, jobHandle);
@@ -4339,6 +4454,10 @@ public sealed class McpSuspendedStudio : IDisposable
4339
4454
  CloseHandle(jobHandle);
4340
4455
  throw;
4341
4456
  }
4457
+ finally
4458
+ {
4459
+ if (workerJob != IntPtr.Zero) CloseHandle(workerJob);
4460
+ }
4342
4461
  }
4343
4462
 
4344
4463
  public void Resume()
@@ -4388,7 +4507,7 @@ public sealed class McpSuspendedStudio : IDisposable
4388
4507
  }
4389
4508
  }
4390
4509
  '@`,
4391
- `$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)})`,
4510
+ `$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)}, ${testWorkerJobName === void 0 ? "$null" : powershellStringLiteral(testWorkerJobName)})`,
4392
4511
  "try {",
4393
4512
  "[Console]::Out.WriteLine((ConvertTo-Json @{ pid = $launch.ProcessId; started = [string]$launch.StartedAtFileTime } -Compress)); [Console]::Out.Flush()",
4394
4513
  "$accepted = $false",
@@ -4427,13 +4546,14 @@ function buildWindowsStudioStopScript(processId, startedAt) {
4427
4546
  "} finally { if ($null -ne $studio) { $studio.Dispose() } }"
4428
4547
  ].join("; ");
4429
4548
  }
4430
- async function stopWindowsStudio(processId, startedAt) {
4431
- await powershellAsync(buildWindowsStudioStopScript(processId, startedAt));
4549
+ async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
4550
+ await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
4432
4551
  }
4433
4552
  async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDirectory) {
4434
4553
  const windowsExe = await toStudioLaunchArgAsync(exe);
4435
4554
  const windowsWorkingDirectory = studioWorkingDirectory ? await toStudioLaunchArgAsync(studioWorkingDirectory) : void 0;
4436
- const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory);
4555
+ const testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB;
4556
+ const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName);
4437
4557
  const launcher = spawn("powershell.exe", ["-NoProfile", "-Command", script], {
4438
4558
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4439
4559
  stdio: ["pipe", "pipe", "pipe"],
@@ -4740,6 +4860,24 @@ function prepareStudioLaunchOptions(options) {
4740
4860
  localPlaceFile: createBaseplatePlaceFile()
4741
4861
  };
4742
4862
  }
4863
+ function resolveStudioExeFromVersions(root) {
4864
+ if (!existsSync2(root)) {
4865
+ throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4866
+ }
4867
+ const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
4868
+ if (candidates.length === 0) {
4869
+ throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4870
+ }
4871
+ const exe = candidates[0];
4872
+ const installationDir = path2.dirname(exe);
4873
+ const hasIncompleteDownload = readdirSync(installationDir).some((name) => /\.crdownload$/i.test(name));
4874
+ const settings = statSync(path2.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
4875
+ if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
4876
+ const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
4877
+ throw new Error(`Roblox Studio installation/update is incomplete at ${installationDir}: ${reason}. Complete the installation/update or reinstall Roblox Studio under the owning Windows account before launching.`);
4878
+ }
4879
+ return exe;
4880
+ }
4743
4881
  function resolveStudioExe() {
4744
4882
  if (process.env.ROBLOX_STUDIO_EXE)
4745
4883
  return process.env.ROBLOX_STUDIO_EXE;
@@ -4751,14 +4889,7 @@ function resolveStudioExe() {
4751
4889
  }
4752
4890
  const localAppData = windowsLocalAppData();
4753
4891
  const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4754
- if (!existsSync2(root)) {
4755
- throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4756
- }
4757
- const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
4758
- if (candidates.length === 0) {
4759
- throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4760
- }
4761
- return candidates[0];
4892
+ return resolveStudioExeFromVersions(root);
4762
4893
  }
4763
4894
  async function resolveStudioExeAsync() {
4764
4895
  if (process.env.ROBLOX_STUDIO_EXE)
@@ -4771,14 +4902,7 @@ async function resolveStudioExeAsync() {
4771
4902
  }
4772
4903
  const localAppData = await windowsLocalAppDataAsync();
4773
4904
  const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4774
- if (!existsSync2(root)) {
4775
- throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4776
- }
4777
- const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
4778
- if (candidates.length === 0) {
4779
- throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4780
- }
4781
- return candidates[0];
4905
+ return resolveStudioExeFromVersions(root);
4782
4906
  }
4783
4907
  var WINDOWS_STUDIO_PROCESS_QUERY = [
4784
4908
  "$ErrorActionPreference = 'Stop'",
@@ -4795,13 +4919,13 @@ function parseWindowsStudioProcesses(output) {
4795
4919
  }
4796
4920
  return processes;
4797
4921
  }
4798
- async function observeStudioProcesses() {
4922
+ async function observeStudioProcesses(timeoutMs = 15e3) {
4799
4923
  const observedAt = Date.now();
4800
4924
  try {
4801
4925
  if (process.platform === "darwin") {
4802
4926
  let out2 = "";
4803
4927
  try {
4804
- out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"]);
4928
+ out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"], { timeout: timeoutMs });
4805
4929
  } catch (error) {
4806
4930
  const code = error.code;
4807
4931
  if (Number(code) === 1)
@@ -4817,7 +4941,7 @@ async function observeStudioProcesses() {
4817
4941
  if (process.platform !== "win32" && !isWsl()) {
4818
4942
  return { status: "ok", observedAt, processes: [] };
4819
4943
  }
4820
- const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
4944
+ const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY, timeoutMs);
4821
4945
  return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
4822
4946
  } catch (error) {
4823
4947
  return {
@@ -4901,6 +5025,7 @@ var StudioInstanceManager = class {
4901
5025
  confirmedExitGraceMs;
4902
5026
  snapshotCacheMs;
4903
5027
  launchCompletionTimeoutMs;
5028
+ closeTimeoutMs;
4904
5029
  coordinatorTimer;
4905
5030
  coordinatorRefresh;
4906
5031
  cachedSnapshot;
@@ -4915,6 +5040,7 @@ var StudioInstanceManager = class {
4915
5040
  this.confirmedExitGraceMs = options.confirmedExitGraceMs ?? 5e3;
4916
5041
  this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
4917
5042
  this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
5043
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
4918
5044
  }
4919
5045
  getLifecycleCapabilities() {
4920
5046
  const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
@@ -5312,58 +5438,57 @@ var StudioInstanceManager = class {
5312
5438
  instanceId: record.instanceId
5313
5439
  };
5314
5440
  }
5441
+ const deadline = Date.now() + this.closeTimeoutMs;
5315
5442
  const processId = record.nativeProcessId ?? record.spawnPid;
5316
5443
  if (!processId) {
5317
5444
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
5318
5445
  }
5319
5446
  const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
5320
- if (record.processAuthorizationState !== "released" && control) {
5321
- await control.abort();
5322
- const closedAt2 = Date.now();
5323
- record.closedAt = closedAt2;
5324
- record.exitedAt = closedAt2;
5325
- if (record.state !== "failed")
5326
- record.state = "exited";
5327
- record.processObservationStatus = "not_running";
5328
- record.lastProcessObservationAt = closedAt2;
5329
- record.lastSuccessfulProcessObservationAt = closedAt2;
5330
- record.lastProcessObservationError = void 0;
5331
- this.cleanupManagedRecord(record);
5332
- this.markClosedInMemory(record);
5333
- await this.persist(record);
5334
- return {
5335
- status: "closed",
5336
- launchId: record.recordId,
5337
- instanceId: record.instanceId
5338
- };
5339
- }
5340
- const snapshot = await this.getProcessSnapshot(true);
5341
- const observation = this.observeRecord(record, snapshot);
5342
- if (observation.status === "unknown") {
5343
- await this.applyProcessObservation(record, observation);
5344
- throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
5345
- }
5346
- if (observation.status === "not_running") {
5347
- await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
5348
- await this.registry.logEvent({
5349
- event: "registry_close_already_stopped",
5350
- recordId: record.recordId,
5351
- instanceId: record.instanceId,
5352
- source: record.source,
5353
- reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
5354
- action: "marked_closed_and_cleaned_baseplate"
5355
- });
5356
- return {
5357
- status: "already_closed",
5358
- launchId: record.recordId,
5359
- instanceId: record.instanceId
5360
- };
5447
+ const abort = record.processAuthorizationState !== "released" && control ? () => control.abort() : void 0;
5448
+ if (!abort) {
5449
+ let snapshot;
5450
+ try {
5451
+ snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
5452
+ } catch (error) {
5453
+ if (error instanceof StudioCloseVerificationError) {
5454
+ await this.applyProcessObservation(record, error.observation);
5455
+ }
5456
+ throw error;
5457
+ }
5458
+ const observation = this.observeRecord(record, snapshot);
5459
+ if (observation.status === "unknown") {
5460
+ await this.applyProcessObservation(record, observation);
5461
+ throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
5462
+ }
5463
+ if (observation.status === "not_running") {
5464
+ await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
5465
+ await this.registry.logEvent({
5466
+ event: "registry_close_already_stopped",
5467
+ recordId: record.recordId,
5468
+ instanceId: record.instanceId,
5469
+ source: record.source,
5470
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
5471
+ action: "marked_closed_and_cleaned_baseplate"
5472
+ });
5473
+ return {
5474
+ status: "already_closed",
5475
+ launchId: record.recordId,
5476
+ instanceId: record.instanceId
5477
+ };
5478
+ }
5361
5479
  }
5362
5480
  try {
5363
- await this.closeProcess(processId, record.nativeProcessStartedAt);
5481
+ await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
5364
5482
  } catch (error) {
5365
- const retry = await this.getProcessSnapshot(true);
5483
+ if (error instanceof StudioCloseVerificationError) {
5484
+ await this.applyProcessObservation(record, error.observation);
5485
+ throw error;
5486
+ }
5487
+ if (abort)
5488
+ throw error;
5489
+ const retry = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
5366
5490
  const retryObservation = this.observeRecord(record, retry);
5491
+ await this.applyProcessObservation(record, retryObservation);
5367
5492
  if (retryObservation.status === "running" || retryObservation.status === "unknown")
5368
5493
  throw error;
5369
5494
  await this.registry.logEvent({
@@ -5400,7 +5525,8 @@ var StudioInstanceManager = class {
5400
5525
  };
5401
5526
  }
5402
5527
  async closeConnectedInstance(instance) {
5403
- const snapshot = await this.getProcessSnapshot(true);
5528
+ const deadline = Date.now() + this.closeTimeoutMs;
5529
+ const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
5404
5530
  if (snapshot.status === "error") {
5405
5531
  throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
5406
5532
  }
@@ -5408,26 +5534,64 @@ var StudioInstanceManager = class {
5408
5534
  if (!process2) {
5409
5535
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
5410
5536
  }
5411
- await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime);
5537
+ await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
5412
5538
  }
5413
- async closeProcess(processId, startedAt) {
5414
- if (this.processAdapter.stopProcess) {
5415
- await this.processAdapter.stopProcess(processId, startedAt);
5416
- return;
5417
- }
5418
- if (process.platform === "win32" || isWsl()) {
5419
- if (startedAt === void 0) {
5420
- throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
5539
+ async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
5540
+ this.cachedSnapshot = void 0;
5541
+ await beforeCloseDeadline(async () => {
5542
+ if (stop) {
5543
+ await stop();
5544
+ } else if (this.processAdapter.stopProcess) {
5545
+ await this.processAdapter.stopProcess(processId, startedAt);
5546
+ } else if (process.platform === "win32" || isWsl()) {
5547
+ if (startedAt === void 0) {
5548
+ throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
5549
+ }
5550
+ await stopWindowsStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
5551
+ } else {
5552
+ try {
5553
+ process.kill(processId, "SIGTERM");
5554
+ } catch (error) {
5555
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH")
5556
+ throw error;
5557
+ }
5421
5558
  }
5422
- await stopWindowsStudio(processId, startedAt);
5423
- } else {
5424
- try {
5425
- process.kill(processId, "SIGTERM");
5426
- } catch (error) {
5427
- if (error.code !== "ESRCH")
5428
- throw error;
5559
+ }, deadline, processId);
5560
+ while (true) {
5561
+ const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
5562
+ if (observation.status === "not_running")
5563
+ return;
5564
+ if (observation.status === "unknown") {
5565
+ throw new StudioCloseVerificationError(`Could not verify Studio process ${processId} termination: ${observation.error}`, observation);
5566
+ }
5567
+ const remaining = deadline - Date.now();
5568
+ if (remaining <= 0) {
5569
+ throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5570
+ }
5571
+ await delay2(Math.min(100, remaining));
5572
+ if (Date.now() >= deadline) {
5573
+ throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5574
+ }
5575
+ }
5576
+ }
5577
+ async observeClosingProcess(processId, startedAt, deadline) {
5578
+ if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
5579
+ return observePosixProcess(processId);
5580
+ }
5581
+ const snapshot = await this.readProcessSnapshot(Math.max(1, deadline - Date.now()));
5582
+ if (snapshot.status === "error") {
5583
+ return { status: "unknown", observedAt: snapshot.observedAt, error: snapshot.error };
5584
+ }
5585
+ const target = snapshot.processes.find((candidate) => candidate.Id === processId);
5586
+ if (!target)
5587
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
5588
+ if (startedAt !== void 0 && target.StartTimeUtcFileTime !== startedAt) {
5589
+ if (target.StartTimeUtcFileTime === void 0) {
5590
+ return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
5429
5591
  }
5592
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
5430
5593
  }
5594
+ return { status: "running", observedAt: snapshot.observedAt };
5431
5595
  }
5432
5596
  findProcessForConnectedInstance(instance, processes) {
5433
5597
  if (processes.length === 0)
@@ -5457,24 +5621,7 @@ var StudioInstanceManager = class {
5457
5621
  return this.snapshotInFlight;
5458
5622
  this.snapshotInFlight = (async () => {
5459
5623
  try {
5460
- let snapshot;
5461
- if (this.processAdapter.observeStudioProcesses) {
5462
- snapshot = await this.processAdapter.observeStudioProcesses();
5463
- } else if (this.processAdapter.listStudioProcesses) {
5464
- const observedAt = Date.now();
5465
- const processes = await this.processAdapter.listStudioProcesses();
5466
- snapshot = { status: "ok", observedAt, processes };
5467
- } else {
5468
- snapshot = await observeStudioProcesses();
5469
- }
5470
- this.cachedSnapshot = snapshot;
5471
- return snapshot;
5472
- } catch (error) {
5473
- const snapshot = {
5474
- status: "error",
5475
- observedAt: Date.now(),
5476
- error: error instanceof Error ? error.message : String(error)
5477
- };
5624
+ const snapshot = await this.readProcessSnapshot();
5478
5625
  this.cachedSnapshot = snapshot;
5479
5626
  return snapshot;
5480
5627
  } finally {
@@ -5483,6 +5630,25 @@ var StudioInstanceManager = class {
5483
5630
  })();
5484
5631
  return this.snapshotInFlight;
5485
5632
  }
5633
+ async readProcessSnapshot(timeoutMs = 15e3) {
5634
+ try {
5635
+ if (this.processAdapter.observeStudioProcesses) {
5636
+ return await this.processAdapter.observeStudioProcesses();
5637
+ }
5638
+ if (this.processAdapter.listStudioProcesses) {
5639
+ const observedAt = Date.now();
5640
+ const processes = await this.processAdapter.listStudioProcesses();
5641
+ return { status: "ok", observedAt, processes };
5642
+ }
5643
+ return await observeStudioProcesses(Math.max(1, timeoutMs));
5644
+ } catch (error) {
5645
+ return {
5646
+ status: "error",
5647
+ observedAt: Date.now(),
5648
+ error: error instanceof Error ? error.message : String(error)
5649
+ };
5650
+ }
5651
+ }
5486
5652
  async getCurrentBootId() {
5487
5653
  return this.processAdapter.currentBootId ? await this.processAdapter.currentBootId() : currentBootIdAsync();
5488
5654
  }
@@ -5548,8 +5714,16 @@ var StudioInstanceManager = class {
5548
5714
  if (!processId)
5549
5715
  return { status: "running", observedAt: snapshot.observedAt };
5550
5716
  const studioProcess = snapshot.processes.find((candidate) => candidate.Id === processId);
5551
- if (!studioProcess)
5717
+ if (!studioProcess) {
5718
+ if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
5719
+ const observation = observePosixProcess(processId);
5720
+ return observation.status === "running" ? { status: "unknown", observedAt: observation.observedAt, error: `Studio PID ${processId} is still alive but missing from process enumeration.` } : observation;
5721
+ }
5552
5722
  return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
5723
+ }
5724
+ if (record.nativeProcessStartedAt !== void 0 && studioProcess.StartTimeUtcFileTime === void 0) {
5725
+ return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
5726
+ }
5553
5727
  return this.verifyProcessForRecord(record, studioProcess) ? { status: "running", observedAt: snapshot.observedAt } : { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
5554
5728
  }
5555
5729
  verifyProcessForRecord(record, studioProcess) {
@@ -5838,20 +6012,20 @@ var StudioInstanceManager = class {
5838
6012
  };
5839
6013
 
5840
6014
  // ../core/dist/mcp-runtime.js
5841
- var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
5842
- "bundleModifiedAt",
5843
- "bundlePath",
5844
- "bundleSha256",
5845
- "connectedAt",
5846
- "debug",
5847
- "diagnostics",
5848
- "internal",
5849
- "lastActivity",
5850
- "transportPeerId",
5851
- "pluginVariant",
5852
- "pluginVersion",
5853
- "serverVersion"
5854
- ]);
6015
+ var INTERNAL_ENVELOPE_KEYS = {
6016
+ bundleModifiedAt: true,
6017
+ bundlePath: true,
6018
+ bundleSha256: true,
6019
+ connectedAt: true,
6020
+ debug: true,
6021
+ diagnostics: true,
6022
+ internal: true,
6023
+ lastActivity: true,
6024
+ transportPeerId: true,
6025
+ pluginVariant: true,
6026
+ pluginVersion: true,
6027
+ serverVersion: true
6028
+ };
5855
6029
  var TEXT_RESULT_TOOLS = /* @__PURE__ */ new Set(["get_roblox_docs"]);
5856
6030
  var SIDE_EFFECTING_READ_TOOLS = /* @__PURE__ */ new Set([
5857
6031
  "capture_micro_profiler",
@@ -5900,18 +6074,15 @@ function compactPublicValue(value) {
5900
6074
  return value.map(compactPublicValue);
5901
6075
  if (!value || typeof value !== "object")
5902
6076
  return value;
5903
- const compact = {};
5904
- for (const [key, child] of Object.entries(value)) {
5905
- if (child === void 0 || INTERNAL_RESULT_KEYS.has(key))
5906
- continue;
5907
- compact[key] = compactPublicValue(child);
5908
- }
5909
- return compact;
6077
+ return Object.fromEntries(Object.entries(value).filter(([, child]) => child !== void 0).map(([key, child]) => [key, compactPublicValue(child)]));
6078
+ }
6079
+ function compactPublicEnvelope(value) {
6080
+ return Object.fromEntries(Object.entries(value).filter(([key, child]) => child !== void 0 && !Object.hasOwn(INTERNAL_ENVELOPE_KEYS, key)).map(([key, child]) => [key, compactPublicValue(child)]));
5910
6081
  }
5911
6082
  function asStructuredObject(value) {
5912
6083
  if (!value || typeof value !== "object" || Array.isArray(value))
5913
6084
  return void 0;
5914
- return compactPublicValue(value);
6085
+ return compactPublicEnvelope(value);
5915
6086
  }
5916
6087
  function parseJsonObject(text) {
5917
6088
  try {
@@ -5936,6 +6107,7 @@ function normalizeToolResult(raw, era) {
5936
6107
  ...result.isError ? { isError: true } : {}
5937
6108
  };
5938
6109
  }
6110
+ const isError = result.isError || structured.success === false || typeof structured.error === "string" && structured.error.length > 0;
5939
6111
  const content = era === "modern" ? originalContent.filter((_, index) => index !== jsonTextIndex) : [
5940
6112
  { type: "text", text: JSON.stringify(structured) },
5941
6113
  ...originalContent.filter((_, index) => index !== jsonTextIndex)
@@ -5943,7 +6115,7 @@ function normalizeToolResult(raw, era) {
5943
6115
  return {
5944
6116
  content,
5945
6117
  structuredContent: structured,
5946
- ...result.isError ? { isError: true } : {}
6118
+ ...isError ? { isError: true } : {}
5947
6119
  };
5948
6120
  }
5949
6121
  function publicRoutingError(error) {
@@ -5966,8 +6138,11 @@ function publicRoutingError(error) {
5966
6138
  };
5967
6139
  }
5968
6140
  function publicToolErrorBody(name, error) {
6141
+ if (error instanceof MultiplayerGroupInUseError) {
6142
+ return { error: error.code, message: error.message, multiplayer_group_id: error.groupId };
6143
+ }
5969
6144
  if (error instanceof StudioLaunchPreDispatchError) {
5970
- return compactPublicValue(error.toResponseBody());
6145
+ return compactPublicEnvelope(error.toResponseBody());
5971
6146
  }
5972
6147
  if (error instanceof RoutingFailure)
5973
6148
  return publicRoutingError(error);
@@ -6441,6 +6616,47 @@ var WebSocketStudioTransport = class {
6441
6616
  };
6442
6617
 
6443
6618
  // ../core/dist/http-server.js
6619
+ var TOOL_PROXY_ENDPOINTS = {
6620
+ get_place_info: ["/api/place-info"],
6621
+ search_objects: ["/api/search-objects"],
6622
+ get_instance_properties: ["/api/instance-properties"],
6623
+ get_project_structure: ["/api/project-structure"],
6624
+ set_properties: ["/api/set-properties"],
6625
+ get_script_source: ["/api/get-script-source"],
6626
+ set_script_source: ["/api/set-script-source"],
6627
+ edit_script_lines: ["/api/edit-script-lines"],
6628
+ insert_script_lines: ["/api/insert-script-lines"],
6629
+ delete_script_lines: ["/api/delete-script-lines"],
6630
+ get_attributes: ["/api/get-attributes"],
6631
+ selection: ["/api/get-selection", "/api/set-selection", "/api/focus-viewport"],
6632
+ execute_luau: ["/api/execute-luau"],
6633
+ eval_server_runtime: ["/api/eval-runtime"],
6634
+ eval_client_runtime: ["/api/eval-runtime"],
6635
+ grep_scripts: ["/api/grep-scripts"],
6636
+ solo_playtest: ["/api/start-playtest", "/api/stop-playtest", "/api/multiplayer-test-state"],
6637
+ multiplayer_playtest: [
6638
+ "/api/multiplayer-test-start",
6639
+ "/api/multiplayer-test-add-players",
6640
+ "/api/multiplayer-test-leave-client",
6641
+ "/api/multiplayer-test-end",
6642
+ "/api/multiplayer-test-state"
6643
+ ],
6644
+ get_runtime_logs: ["/api/get-runtime-logs"],
6645
+ capture_script_profiler: ["/api/capture-script-profiler"],
6646
+ capture_micro_profiler: ["/api/capture-micro-profiler"],
6647
+ breakpoints: ["/api/breakpoints"],
6648
+ insert_asset: ["/api/insert-asset"],
6649
+ generate_model: ["/api/generate-model"],
6650
+ preview_asset: ["/api/preview-asset"],
6651
+ capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read", "/api/capture-markers"],
6652
+ simulate_mouse_input: ["/api/simulate-mouse-input"],
6653
+ simulate_keyboard_input: ["/api/simulate-keyboard-input"],
6654
+ get_memory_breakdown: ["/api/get-memory-breakdown"],
6655
+ get_scene_analysis: ["/api/get-scene-analysis"],
6656
+ export_rbxm: ["/api/export-rbxm"],
6657
+ import_rbxm: ["/api/import-rbxm"],
6658
+ find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"]
6659
+ };
6444
6660
  function toPassivePeer(peer) {
6445
6661
  return {
6446
6662
  instanceId: peer.instanceId,
@@ -6627,6 +6843,7 @@ ${body}`);
6627
6843
  }
6628
6844
  function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6629
6845
  const app = express();
6846
+ const allowedProxyEndpoints = allowedTools ? new Set(Object.entries(TOOL_PROXY_ENDPOINTS).filter(([toolName]) => allowedTools.has(toolName)).flatMap(([, endpoints]) => endpoints)) : void 0;
6630
6847
  const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
6631
6848
  const studioLifecycleCapabilities = studioLifecycleCallable ? tools.getStudioLifecycleCapabilities() : void 0;
6632
6849
  let mcpServerActive = false;
@@ -6773,8 +6990,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6773
6990
  });
6774
6991
  const authToken = security?.authToken;
6775
6992
  const authRequired = (requestPath) => {
6776
- const path6 = requestPath.toLowerCase().replace(/\/+$/, "");
6777
- return path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/request-status" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6993
+ const path7 = requestPath.toLowerCase().replace(/\/+$/, "");
6994
+ return path7 === "/mcp" || path7.startsWith("/mcp/") || path7 === "/proxy" || path7 === "/topology" || path7 === "/request-status" || path7 === "/unregister-instance-id" || path7 === "/create-multiplayer-group" || path7 === "/remove-multiplayer-group";
6778
6995
  };
6779
6996
  app.use((req, res, next) => {
6780
6997
  if (!authToken || !authRequired(req.path)) {
@@ -7034,14 +7251,27 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7034
7251
  const group = await bridge.createMultiplayerGroupEverywhere(groupId, controllerInstanceId);
7035
7252
  res.json({ success: true, group });
7036
7253
  });
7037
- app.post("/remove-multiplayer-group", async (req, res) => {
7254
+ app.post("/remove-multiplayer-group", async (req, res, next) => {
7038
7255
  const { groupId } = req.body;
7039
7256
  if (typeof groupId !== "string" || groupId.length === 0) {
7040
7257
  res.status(400).json({ error: "groupId is required" });
7041
7258
  return;
7042
7259
  }
7043
- const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
7044
- res.json({ success: true, removed });
7260
+ try {
7261
+ const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
7262
+ res.json({ success: true, removed });
7263
+ } catch (error) {
7264
+ if (error instanceof MultiplayerGroupInUseError) {
7265
+ res.status(409).json({
7266
+ success: false,
7267
+ error: error.code,
7268
+ message: error.message,
7269
+ groupId: error.groupId
7270
+ });
7271
+ return;
7272
+ }
7273
+ next(error);
7274
+ }
7045
7275
  });
7046
7276
  app.get("/status", (req, res) => {
7047
7277
  const peers = bridge.getPublicPeers().map(toPassivePeer);
@@ -7088,6 +7318,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7088
7318
  res.status(400).json({ error: "endpoint and targetPeerId are required" });
7089
7319
  return;
7090
7320
  }
7321
+ if (allowedProxyEndpoints && !allowedProxyEndpoints.has(endpoint)) {
7322
+ res.status(403).json({ error: "forbidden_endpoint" });
7323
+ return;
7324
+ }
7091
7325
  if (timeoutMs !== void 0 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e5)) {
7092
7326
  res.status(400).json({ error: "timeoutMs must be an integer between 1 and 300000" });
7093
7327
  return;
@@ -7750,12 +7984,10 @@ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7750
7984
  var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
7751
7985
  var MAX_PNG_DIMENSION = 16 * 1024;
7752
7986
  var MAX_PNG_PIXELS = 8 * 1024 * 1024;
7987
+ var MAX_SCREENSHOT_PNG_PIXELS = 32 * 1024 * 1024;
7753
7988
  var MAX_PNG_INPUT_BYTES = 32 * 1024 * 1024;
7754
7989
  var MAX_PNG_BASE64_CHARACTERS = 4 * Math.ceil(MAX_PNG_INPUT_BYTES / 3);
7755
- var MAX_PNG_CHUNK_BYTES = 32 * 1024 * 1024;
7756
- var MAX_PNG_COMPRESSED_BYTES = 32 * 1024 * 1024;
7757
7990
  var MAX_PNG_CHUNKS = 65536;
7758
- var MAX_PNG_INFLATED_BYTES = MAX_PNG_PIXELS * 4 + MAX_PNG_DIMENSION;
7759
7991
  function paethPredictor(a, b, c) {
7760
7992
  const p = a + b - c;
7761
7993
  const pa = Math.abs(p - a);
@@ -7778,12 +8010,12 @@ function bytesPerPixel(colorType) {
7778
8010
  return 2;
7779
8011
  throw new Error(`Unsupported PNG color type ${colorType}. Supported color types: 0, 2, 4, 6.`);
7780
8012
  }
7781
- function validatePngInputSize(byteLength) {
8013
+ function validatePngInputSize(byteLength, maxInputBytes = MAX_PNG_INPUT_BYTES) {
7782
8014
  if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
7783
8015
  throw new Error(`Invalid PNG input length ${byteLength}.`);
7784
8016
  }
7785
- if (byteLength > MAX_PNG_INPUT_BYTES) {
7786
- throw new Error(`PNG input length ${byteLength} exceeds the ${MAX_PNG_INPUT_BYTES}-byte limit.`);
8017
+ if (byteLength > maxInputBytes) {
8018
+ throw new Error(`PNG input length ${byteLength} exceeds the ${maxInputBytes}-byte limit.`);
7787
8019
  }
7788
8020
  }
7789
8021
  function getBase64DecodedLength(encoded) {
@@ -7845,7 +8077,7 @@ function readPngFileWithinLimit(resolved, imagePath) {
7845
8077
  fs2.closeSync(descriptor);
7846
8078
  }
7847
8079
  }
7848
- function validateSourceDimensions(width, height) {
8080
+ function validateSourceDimensions(width, height, maxPixels) {
7849
8081
  if (width <= 0 || height <= 0) {
7850
8082
  throw new Error("PNG IHDR dimensions must be positive.");
7851
8083
  }
@@ -7853,78 +8085,65 @@ function validateSourceDimensions(width, height) {
7853
8085
  throw new Error(`PNG dimensions ${width}x${height} exceed the ${MAX_PNG_DIMENSION}-pixel dimension limit.`);
7854
8086
  }
7855
8087
  const pixelCount = width * height;
7856
- if (!Number.isSafeInteger(pixelCount) || pixelCount > MAX_PNG_PIXELS) {
7857
- throw new Error(`PNG pixel count ${pixelCount} exceeds the ${MAX_PNG_PIXELS}-pixel limit.`);
8088
+ if (!Number.isSafeInteger(pixelCount) || pixelCount > maxPixels) {
8089
+ throw new Error(`PNG pixel count ${pixelCount} exceeds the ${maxPixels}-pixel limit.`);
7858
8090
  }
7859
8091
  }
7860
- function getExpectedScanlineLength(width, height, bpp) {
8092
+ function getExpectedScanlineLength(width, height, bpp, maxPixels) {
8093
+ const maxInflatedBytes = maxPixels * 4 + MAX_PNG_DIMENSION;
7861
8094
  const stride = width * bpp;
7862
8095
  const expected = height * (stride + 1);
7863
- if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected > MAX_PNG_INFLATED_BYTES) {
7864
- throw new Error(`PNG scanline data length ${expected} exceeds the ${MAX_PNG_INFLATED_BYTES}-byte limit.`);
8096
+ if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected > maxInflatedBytes) {
8097
+ throw new Error(`PNG scanline data length ${expected} exceeds the ${maxInflatedBytes}-byte limit.`);
7865
8098
  }
7866
8099
  return expected;
7867
8100
  }
7868
- function convertScanlinesToRgba(raw, width, height, colorType, bpp, expectedLength) {
7869
- const stride = width * bpp;
7870
- if (raw.length !== expectedLength) {
7871
- if (raw.length > expectedLength) {
7872
- throw new Error(`Inflated PNG data exceeds the expected scanline length of ${expectedLength} bytes.`);
7873
- }
7874
- throw new Error(`PNG data ended early. Expected ${expectedLength} bytes after inflate, got ${raw.length}.`);
7875
- }
7876
- const unfiltered = Buffer.alloc(width * height * bpp);
7877
- let srcOffset = 0;
7878
- for (let y = 0; y < height; y++) {
7879
- const filter = raw[srcOffset++];
7880
- const rowOffset = y * stride;
7881
- const prevRowOffset = (y - 1) * stride;
7882
- for (let x = 0; x < stride; x++) {
7883
- const value = raw[srcOffset++];
7884
- const left = x >= bpp ? unfiltered[rowOffset + x - bpp] : 0;
7885
- const up = y > 0 ? unfiltered[prevRowOffset + x] : 0;
7886
- const upLeft = y > 0 && x >= bpp ? unfiltered[prevRowOffset + x - bpp] : 0;
7887
- let decoded;
7888
- if (filter === 0) {
7889
- decoded = value;
7890
- } else if (filter === 1) {
7891
- decoded = value + left;
7892
- } else if (filter === 2) {
7893
- decoded = value + up;
7894
- } else if (filter === 3) {
7895
- decoded = value + Math.floor((left + up) / 2);
7896
- } else if (filter === 4) {
7897
- decoded = value + paethPredictor(left, up, upLeft);
7898
- } else {
7899
- throw new Error(`Unsupported PNG filter type ${filter}.`);
7900
- }
7901
- unfiltered[rowOffset + x] = decoded & 255;
8101
+ function unfilterPngRow(raw, rowOffset, stride, bpp) {
8102
+ const filter = raw[rowOffset - 1];
8103
+ if (filter === 0)
8104
+ return;
8105
+ const prevRowOffset = rowOffset - stride - 1;
8106
+ for (let x = 0; x < stride; x++) {
8107
+ const left = x >= bpp ? raw[rowOffset + x - bpp] : 0;
8108
+ const up = prevRowOffset > 0 ? raw[prevRowOffset + x] : 0;
8109
+ const upLeft = prevRowOffset > 0 && x >= bpp ? raw[prevRowOffset + x - bpp] : 0;
8110
+ let predictor;
8111
+ if (filter === 1) {
8112
+ predictor = left;
8113
+ } else if (filter === 2) {
8114
+ predictor = up;
8115
+ } else if (filter === 3) {
8116
+ predictor = Math.floor((left + up) / 2);
8117
+ } else {
8118
+ predictor = paethPredictor(left, up, upLeft);
7902
8119
  }
8120
+ raw[rowOffset + x] = raw[rowOffset + x] + predictor & 255;
7903
8121
  }
7904
- if (colorType === 6)
7905
- return unfiltered;
8122
+ }
8123
+ function convertScanlinesToRgba({ raw, width, height, colorType, bpp }) {
8124
+ const stride = width * bpp;
7906
8125
  const rgba = Buffer.alloc(width * height * 4);
7907
- let si = 0;
7908
8126
  let di = 0;
7909
- for (let i = 0; i < width * height; i++) {
7910
- if (colorType === 2) {
7911
- rgba[di++] = unfiltered[si++];
7912
- rgba[di++] = unfiltered[si++];
7913
- rgba[di++] = unfiltered[si++];
7914
- rgba[di++] = 255;
7915
- } else if (colorType === 0) {
7916
- const gray = unfiltered[si++];
7917
- rgba[di++] = gray;
7918
- rgba[di++] = gray;
7919
- rgba[di++] = gray;
7920
- rgba[di++] = 255;
7921
- } else {
7922
- const gray = unfiltered[si++];
7923
- const alpha = unfiltered[si++];
7924
- rgba[di++] = gray;
7925
- rgba[di++] = gray;
7926
- rgba[di++] = gray;
7927
- rgba[di++] = alpha;
8127
+ for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
8128
+ unfilterPngRow(raw, rowOffset, stride, bpp);
8129
+ if (colorType === 6) {
8130
+ raw.copy(rgba, di, rowOffset, rowOffset + stride);
8131
+ di += stride;
8132
+ continue;
8133
+ }
8134
+ for (let si = rowOffset; si < rowOffset + stride; si += bpp) {
8135
+ if (colorType === 2) {
8136
+ rgba[di++] = raw[si];
8137
+ rgba[di++] = raw[si + 1];
8138
+ rgba[di++] = raw[si + 2];
8139
+ rgba[di++] = 255;
8140
+ } else {
8141
+ const gray = raw[si];
8142
+ rgba[di++] = gray;
8143
+ rgba[di++] = gray;
8144
+ rgba[di++] = gray;
8145
+ rgba[di++] = colorType === 4 ? raw[si + 1] : 255;
8146
+ }
7928
8147
  }
7929
8148
  }
7930
8149
  return rgba;
@@ -7951,8 +8170,9 @@ function resizeRgbaNearest(image) {
7951
8170
  }
7952
8171
  return { width, height, rgba };
7953
8172
  }
7954
- function decodePngToRgba(data) {
7955
- validatePngInputSize(data.length);
8173
+ function inflatePng(data, maxPixels) {
8174
+ const maxInputBytes = maxPixels * 4;
8175
+ validatePngInputSize(data.length, maxInputBytes);
7956
8176
  if (data.length < PNG_SIGNATURE.length || !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
7957
8177
  throw new Error("Unsupported image format. generate_model currently supports PNG images.");
7958
8178
  }
@@ -7973,8 +8193,8 @@ function decodePngToRgba(data) {
7973
8193
  }
7974
8194
  const length = data.readUInt32BE(offset);
7975
8195
  const type = data.toString("ascii", offset + 4, offset + 8);
7976
- if (length > MAX_PNG_CHUNK_BYTES) {
7977
- throw new Error(`PNG chunk ${type} length ${length} exceeds the ${MAX_PNG_CHUNK_BYTES}-byte chunk limit.`);
8196
+ if (length > maxInputBytes) {
8197
+ throw new Error(`PNG chunk ${type} length ${length} exceeds the ${maxInputBytes}-byte chunk limit.`);
7978
8198
  }
7979
8199
  if (++chunkCount > MAX_PNG_CHUNKS) {
7980
8200
  throw new Error(`PNG contains more than the ${MAX_PNG_CHUNKS}-chunk limit.`);
@@ -8010,12 +8230,12 @@ function decodePngToRgba(data) {
8010
8230
  if (interlace !== 0)
8011
8231
  throw new Error("Interlaced PNG images are not supported.");
8012
8232
  bpp = bytesPerPixel(colorType);
8013
- validateSourceDimensions(width, height);
8014
- expectedScanlineLength = getExpectedScanlineLength(width, height, bpp);
8233
+ validateSourceDimensions(width, height, maxPixels);
8234
+ expectedScanlineLength = getExpectedScanlineLength(width, height, bpp, maxPixels);
8015
8235
  sawIhdr = true;
8016
8236
  } else if (type === "IDAT") {
8017
- if (length > MAX_PNG_COMPRESSED_BYTES - idatLength) {
8018
- throw new Error(`PNG image data exceeds the ${MAX_PNG_COMPRESSED_BYTES}-byte compressed-data limit.`);
8237
+ if (length > maxInputBytes - idatLength) {
8238
+ throw new Error(`PNG image data exceeds the ${maxInputBytes}-byte compressed-data limit.`);
8019
8239
  }
8020
8240
  idatLength += length;
8021
8241
  idatChunks.push(chunk);
@@ -8043,8 +8263,43 @@ function decodePngToRgba(data) {
8043
8263
  const detail = error instanceof Error ? error.message : String(error);
8044
8264
  throw new Error(`Invalid PNG compressed data: ${detail}`);
8045
8265
  }
8046
- const rgba = convertScanlinesToRgba(raw, width, height, colorType, bpp, expectedScanlineLength);
8047
- return resizeRgbaNearest({ width, height, rgba });
8266
+ if (raw.length !== expectedScanlineLength) {
8267
+ throw new Error(`PNG data ended early. Expected ${expectedScanlineLength} bytes after inflate, got ${raw.length}.`);
8268
+ }
8269
+ const rowLength = width * bpp + 1;
8270
+ for (let rowOffset = 0; rowOffset < raw.length; rowOffset += rowLength) {
8271
+ if (raw[rowOffset] > 4) {
8272
+ throw new Error(`Unsupported PNG filter type ${raw[rowOffset]}.`);
8273
+ }
8274
+ }
8275
+ return { width, height, colorType, bpp, raw };
8276
+ }
8277
+ function isUniformPng(data) {
8278
+ const { raw, width, colorType, bpp } = inflatePng(data, MAX_SCREENSHOT_PNG_PIXELS);
8279
+ const stride = width * bpp;
8280
+ unfilterPngRow(raw, 1, stride, bpp);
8281
+ const red = raw[1];
8282
+ const grayscale = colorType === 0 || colorType === 4;
8283
+ const green = grayscale ? red : raw[2];
8284
+ const blue = grayscale ? red : raw[3];
8285
+ for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
8286
+ if (rowOffset !== 1)
8287
+ unfilterPngRow(raw, rowOffset, stride, bpp);
8288
+ for (let offset = rowOffset; offset < rowOffset + stride; offset += bpp) {
8289
+ if (raw[offset] !== red || !grayscale && (raw[offset + 1] !== green || raw[offset + 2] !== blue)) {
8290
+ return false;
8291
+ }
8292
+ }
8293
+ }
8294
+ return true;
8295
+ }
8296
+ function decodePngToRgba(data) {
8297
+ const png = inflatePng(data, MAX_PNG_PIXELS);
8298
+ return resizeRgbaNearest({
8299
+ width: png.width,
8300
+ height: png.height,
8301
+ rgba: convertScanlinesToRgba(png)
8302
+ });
8048
8303
  }
8049
8304
  function decodePngBase64ToRgba(encoded) {
8050
8305
  const expectedLength = getBase64DecodedLength(encoded);
@@ -10028,9 +10283,308 @@ function rgbaToPng(rgba, width, height) {
10028
10283
  ]);
10029
10284
  }
10030
10285
 
10031
- // ../core/dist/tools/index.js
10286
+ // ../core/dist/host-capture.js
10287
+ import { spawn as spawn2 } from "child_process";
10032
10288
  import * as fs3 from "fs";
10289
+ import * as os3 from "os";
10033
10290
  import * as path5 from "path";
10291
+ var HOST_CAPTURE_TIMEOUT_MS = 2e4;
10292
+ var MARKER_MIN_RB = 200;
10293
+ var MARKER_MAX_G = 60;
10294
+ var MARKER_SCALE_TOLERANCE = 0.03;
10295
+ function isHostCaptureDisabled(env = process.env) {
10296
+ const value = (env.ROBLOX_STUDIO_HOST_CAPTURE ?? "").trim().toLowerCase();
10297
+ return value === "0" || value === "false" || value === "off";
10298
+ }
10299
+ function isHostCaptureSupported(platform = process.platform) {
10300
+ return platform === "win32";
10301
+ }
10302
+ function hostCaptureUnsupportedReason(platform = process.platform) {
10303
+ return `host window capture is only implemented on Windows (this is ${platform})`;
10304
+ }
10305
+ function isUniformFrame(rgba, width, height) {
10306
+ const pixels = width * height;
10307
+ if (pixels <= 0 || rgba.length < pixels * 4)
10308
+ return false;
10309
+ const r = rgba[0];
10310
+ const g = rgba[1];
10311
+ const b = rgba[2];
10312
+ for (let i = 0; i < pixels; i++) {
10313
+ const o = i * 4;
10314
+ if (rgba[o] !== r || rgba[o + 1] !== g || rgba[o + 2] !== b)
10315
+ return false;
10316
+ }
10317
+ return true;
10318
+ }
10319
+ function isMarkerPixel(rgba, offset) {
10320
+ return rgba[offset] >= MARKER_MIN_RB && rgba[offset + 1] <= MARKER_MAX_G && rgba[offset + 2] >= MARKER_MIN_RB;
10321
+ }
10322
+ function findViewportRect(rgba, width, height, hint) {
10323
+ let minX = width;
10324
+ let minY = height;
10325
+ let maxX = -1;
10326
+ let maxY = -1;
10327
+ for (let y = 0; y < height; y++) {
10328
+ const row = y * width * 4;
10329
+ for (let x = 0; x < width; x++) {
10330
+ if (!isMarkerPixel(rgba, row + x * 4))
10331
+ continue;
10332
+ if (x < minX)
10333
+ minX = x;
10334
+ if (x > maxX)
10335
+ maxX = x;
10336
+ if (y < minY)
10337
+ minY = y;
10338
+ if (y > maxY)
10339
+ maxY = y;
10340
+ }
10341
+ }
10342
+ if (maxX < 0) {
10343
+ return { error: "no viewport markers were visible in the Studio window capture" };
10344
+ }
10345
+ const rect = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
10346
+ const inset = Math.max(1, Math.floor(hint.markerSize / 4));
10347
+ const corners = [
10348
+ [minX + inset, minY + inset],
10349
+ [maxX - inset, minY + inset],
10350
+ [minX + inset, maxY - inset],
10351
+ [maxX - inset, maxY - inset]
10352
+ ];
10353
+ for (const [x, y] of corners) {
10354
+ if (!isMarkerPixel(rgba, (y * width + x) * 4)) {
10355
+ return { error: `viewport markers were found but do not form a rectangle (box ${rect.width}x${rect.height} at ${rect.x},${rect.y})` };
10356
+ }
10357
+ }
10358
+ const scaleX = rect.width / hint.viewportWidth;
10359
+ const scaleY = rect.height / hint.viewportHeight;
10360
+ if (Math.abs(scaleX - scaleY) > MARKER_SCALE_TOLERANCE * Math.max(scaleX, scaleY)) {
10361
+ return {
10362
+ error: `viewport marker box ${rect.width}x${rect.height} does not match the reported ${hint.viewportWidth}x${hint.viewportHeight} viewport aspect`
10363
+ };
10364
+ }
10365
+ return { rect };
10366
+ }
10367
+ function cropToViewport(rgba, width, height, rect, targetWidth, targetHeight) {
10368
+ const x0 = Math.max(0, Math.min(width - 1, rect.x));
10369
+ const y0 = Math.max(0, Math.min(height - 1, rect.y));
10370
+ const srcW = Math.max(1, Math.min(rect.width, width - x0));
10371
+ const srcH = Math.max(1, Math.min(rect.height, height - y0));
10372
+ const exact = srcW === targetWidth && srcH === targetHeight;
10373
+ const outW = targetWidth;
10374
+ const outH = targetHeight;
10375
+ const out = Buffer.alloc(outW * outH * 4);
10376
+ if (exact) {
10377
+ for (let y = 0; y < outH; y++) {
10378
+ rgba.copy(out, y * outW * 4, ((y0 + y) * width + x0) * 4, ((y0 + y) * width + x0 + outW) * 4);
10379
+ }
10380
+ return { width: outW, height: outH, rgba: out };
10381
+ }
10382
+ const sx = srcW / outW;
10383
+ const sy = srcH / outH;
10384
+ for (let y = 0; y < outH; y++) {
10385
+ const fy = Math.min(srcH - 1, (y + 0.5) * sy - 0.5);
10386
+ const iy = Math.max(0, Math.floor(fy));
10387
+ const ny = Math.min(srcH - 1, iy + 1);
10388
+ const wy = Math.max(0, fy - iy);
10389
+ for (let x = 0; x < outW; x++) {
10390
+ const fx = Math.min(srcW - 1, (x + 0.5) * sx - 0.5);
10391
+ const ix = Math.max(0, Math.floor(fx));
10392
+ const nx = Math.min(srcW - 1, ix + 1);
10393
+ const wx = Math.max(0, fx - ix);
10394
+ const o00 = ((y0 + iy) * width + x0 + ix) * 4;
10395
+ const o10 = ((y0 + iy) * width + x0 + nx) * 4;
10396
+ const o01 = ((y0 + ny) * width + x0 + ix) * 4;
10397
+ const o11 = ((y0 + ny) * width + x0 + nx) * 4;
10398
+ const dst = (y * outW + x) * 4;
10399
+ for (let c = 0; c < 4; c++) {
10400
+ const top = rgba[o00 + c] * (1 - wx) + rgba[o10 + c] * wx;
10401
+ const bottom = rgba[o01 + c] * (1 - wx) + rgba[o11 + c] * wx;
10402
+ out[dst + c] = Math.round(top * (1 - wy) + bottom * wy);
10403
+ }
10404
+ }
10405
+ }
10406
+ return { width: outW, height: outH, rgba: out };
10407
+ }
10408
+ var WINDOWS_CAPTURE_SCRIPT = String.raw`
10409
+ $ErrorActionPreference = 'Stop'
10410
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
10411
+ Add-Type -AssemblyName System.Drawing
10412
+ Add-Type -TypeDefinition @"
10413
+ using System;
10414
+ using System.Collections.Generic;
10415
+ using System.Runtime.InteropServices;
10416
+ using System.Text;
10417
+ public static class McpStudioCapture {
10418
+ [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
10419
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
10420
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
10421
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
10422
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextW(IntPtr hWnd, StringBuilder text, int count);
10423
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextLengthW(IntPtr hWnd);
10424
+ [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT rect);
10425
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdc, uint flags);
10426
+ delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
10427
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
10428
+ public class Candidate { public IntPtr Handle; public uint Pid; public string Title; }
10429
+ public static List<Candidate> Find(string processName) {
10430
+ var found = new List<Candidate>();
10431
+ EnumWindows((hWnd, lParam) => {
10432
+ if (!IsWindowVisible(hWnd)) return true;
10433
+ int length = GetWindowTextLengthW(hWnd);
10434
+ if (length <= 0) return true;
10435
+ var sb = new StringBuilder(length + 1);
10436
+ GetWindowTextW(hWnd, sb, sb.Capacity);
10437
+ string title = sb.ToString();
10438
+ if (!title.EndsWith("Roblox Studio", StringComparison.Ordinal)) return true;
10439
+ uint pid;
10440
+ GetWindowThreadProcessId(hWnd, out pid);
10441
+ try {
10442
+ var proc = System.Diagnostics.Process.GetProcessById((int)pid);
10443
+ if (!string.Equals(proc.ProcessName, processName, StringComparison.OrdinalIgnoreCase)) return true;
10444
+ } catch { return true; }
10445
+ found.Add(new Candidate { Handle = hWnd, Pid = pid, Title = title });
10446
+ return true;
10447
+ }, IntPtr.Zero);
10448
+ return found;
10449
+ }
10450
+ }
10451
+ "@
10452
+ function Emit($obj) { Write-Output ($obj | ConvertTo-Json -Compress) }
10453
+ $hint = $env:MCP_CAPTURE_TITLE_HINT
10454
+ $outFile = $env:MCP_CAPTURE_OUT
10455
+ $candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
10456
+ if ($candidates.Count -eq 0) { Emit @{ ok = $false; error = 'no visible Roblox Studio window was found' }; exit 0 }
10457
+ $pick = $null
10458
+ if ($hint) { $pick = $candidates | Where-Object { $_.Title.StartsWith($hint) } | Select-Object -First 1 }
10459
+ if ($pick -eq $null -and $candidates.Count -eq 1) { $pick = $candidates[0] }
10460
+ if ($pick -eq $null) {
10461
+ $titles = ($candidates | ForEach-Object { $_.Title }) -join ' | '
10462
+ Emit @{ ok = $false; error = "could not pick a Studio window for '$hint' among: $titles" }; exit 0
10463
+ }
10464
+ if ([McpStudioCapture]::IsIconic($pick.Handle)) {
10465
+ Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
10466
+ }
10467
+ $rect = New-Object McpStudioCapture+RECT
10468
+ [void][McpStudioCapture]::GetClientRect($pick.Handle, [ref]$rect)
10469
+ $w = $rect.Right - $rect.Left; $h = $rect.Bottom - $rect.Top
10470
+ if ($w -le 0 -or $h -le 0) { Emit @{ ok = $false; error = "the Studio window client area is empty ($w x $h)" }; exit 0 }
10471
+ $bmp = New-Object System.Drawing.Bitmap $w, $h, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
10472
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
10473
+ $hdc = $g.GetHdc()
10474
+ # PW_CLIENTONLY (1) | PW_RENDERFULLCONTENT (2): DWM-composited client area, occluded windows included.
10475
+ $printed = [McpStudioCapture]::PrintWindow($pick.Handle, $hdc, 3)
10476
+ $g.ReleaseHdc($hdc); $g.Dispose()
10477
+ if (-not $printed) { $bmp.Dispose(); Emit @{ ok = $false; error = 'PrintWindow failed for the Studio window' }; exit 0 }
10478
+ $bounds = New-Object System.Drawing.Rectangle 0, 0, $w, $h
10479
+ $data = $bmp.LockBits($bounds, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
10480
+ $bytes = New-Object byte[] ($data.Stride * $h)
10481
+ [System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $bytes.Length)
10482
+ $stride = $data.Stride
10483
+ $bmp.UnlockBits($data); $bmp.Dispose()
10484
+ [System.IO.File]::WriteAllBytes($outFile, $bytes)
10485
+ Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title }
10486
+ `;
10487
+ function powershellPath() {
10488
+ const systemRoot = process.env.SystemRoot ?? process.env.windir;
10489
+ if (systemRoot) {
10490
+ const candidate = path5.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
10491
+ if (fs3.existsSync(candidate))
10492
+ return candidate;
10493
+ }
10494
+ return "powershell.exe";
10495
+ }
10496
+ function runWindowsCapture(titleHint, outFile) {
10497
+ return new Promise((resolve5, reject) => {
10498
+ const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
10499
+ const child = spawn2(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
10500
+ env: { ...process.env, MCP_CAPTURE_TITLE_HINT: titleHint ?? "", MCP_CAPTURE_OUT: outFile },
10501
+ stdio: ["ignore", "pipe", "pipe"],
10502
+ windowsHide: true
10503
+ });
10504
+ let stdout = "";
10505
+ let stderr = "";
10506
+ let settled = false;
10507
+ const timer = setTimeout(() => {
10508
+ if (settled)
10509
+ return;
10510
+ settled = true;
10511
+ child.kill();
10512
+ reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
10513
+ }, HOST_CAPTURE_TIMEOUT_MS);
10514
+ child.stdout.setEncoding("utf8");
10515
+ child.stderr.setEncoding("utf8");
10516
+ child.stdout.on("data", (chunk) => {
10517
+ stdout += chunk;
10518
+ });
10519
+ child.stderr.on("data", (chunk) => {
10520
+ stderr += chunk;
10521
+ });
10522
+ child.on("error", (error) => {
10523
+ if (settled)
10524
+ return;
10525
+ settled = true;
10526
+ clearTimeout(timer);
10527
+ reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
10528
+ });
10529
+ child.on("close", (code) => {
10530
+ if (settled)
10531
+ return;
10532
+ settled = true;
10533
+ clearTimeout(timer);
10534
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
10535
+ const last = lines[lines.length - 1];
10536
+ if (last && last.startsWith("{")) {
10537
+ try {
10538
+ resolve5(JSON.parse(last));
10539
+ return;
10540
+ } catch {
10541
+ }
10542
+ }
10543
+ const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
10544
+ reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
10545
+ });
10546
+ });
10547
+ }
10548
+ async function captureStudioWindow(titleHint) {
10549
+ if (isHostCaptureDisabled()) {
10550
+ return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
10551
+ }
10552
+ if (!isHostCaptureSupported()) {
10553
+ return { ok: false, error: hostCaptureUnsupportedReason() };
10554
+ }
10555
+ const outFile = path5.join(os3.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
10556
+ try {
10557
+ const report = await runWindowsCapture(titleHint, outFile);
10558
+ if (!report.ok || !report.width || !report.height || !report.stride) {
10559
+ return { ok: false, error: report.error ?? "host window capture returned no image" };
10560
+ }
10561
+ const raw = fs3.readFileSync(outFile);
10562
+ const { width, height, stride } = report;
10563
+ const rgba = Buffer.alloc(width * height * 4);
10564
+ for (let y = 0; y < height; y++) {
10565
+ const src = y * stride;
10566
+ const dst = y * width * 4;
10567
+ for (let x = 0; x < width; x++) {
10568
+ const s = src + x * 4;
10569
+ const d = dst + x * 4;
10570
+ rgba[d] = raw[s + 2];
10571
+ rgba[d + 1] = raw[s + 1];
10572
+ rgba[d + 2] = raw[s];
10573
+ rgba[d + 3] = 255;
10574
+ }
10575
+ }
10576
+ return { ok: true, capture: { width, height, rgba, title: report.title ?? "" } };
10577
+ } catch (error) {
10578
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
10579
+ } finally {
10580
+ fs3.rmSync(outFile, { force: true });
10581
+ }
10582
+ }
10583
+
10584
+ // ../core/dist/tools/index.js
10585
+ import * as fs4 from "fs";
10586
+ import * as path6 from "path";
10587
+ var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
10034
10588
  var MAX_INLINE_IMAGE_BYTES = 6e6;
10035
10589
  var MAX_MATRIX_IMAGE_BYTES = 8e6;
10036
10590
  var DEFAULT_ASSET_AUDIO_PREVIEWS = 3;
@@ -10209,9 +10763,9 @@ function compactSoundReference(sound) {
10209
10763
  name: stringField(sound, "name"),
10210
10764
  className: stringField(sound, "className")
10211
10765
  };
10212
- const path6 = stringField(sound, "path");
10213
- if (path6)
10214
- compact.path = path6;
10766
+ const path7 = stringField(sound, "path");
10767
+ if (path7)
10768
+ compact.path = path7;
10215
10769
  const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
10216
10770
  if (assetId !== void 0)
10217
10771
  compact.assetId = assetId;
@@ -10274,8 +10828,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
10274
10828
  if (typeof sourcePath !== "string" || sourcePath === "") {
10275
10829
  throw new Error("baseline_path must be a non-empty string when provided");
10276
10830
  }
10277
- const resolved = path5.resolve(sourcePath);
10278
- const parsed = JSON.parse(fs3.readFileSync(resolved, "utf8"));
10831
+ const resolved = path6.resolve(sourcePath);
10832
+ const parsed = JSON.parse(fs4.readFileSync(resolved, "utf8"));
10279
10833
  const record = asRecord(parsed);
10280
10834
  if (!record)
10281
10835
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -10777,6 +11331,9 @@ var RobloxStudioTools = class {
10777
11331
  cookieClient;
10778
11332
  instanceManager;
10779
11333
  managedConnectionAssociations = Promise.resolve();
11334
+ hostWindowCapture = captureStudioWindow;
11335
+ hostViewportRects = /* @__PURE__ */ new Map();
11336
+ viewportCaptureQueues = /* @__PURE__ */ new Map();
10780
11337
  constructor(bridge) {
10781
11338
  this.client = new StudioHttpClient(bridge);
10782
11339
  this.bridge = bridge;
@@ -10886,7 +11443,10 @@ var RobloxStudioTools = class {
10886
11443
  _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal, operationId) {
10887
11444
  return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal, operationId);
10888
11445
  }
10889
- _request(endpoint, data, instanceId, role, timeoutMs, signal) {
11446
+ async _request(endpoint, data, instanceId, role, timeoutMs, signal) {
11447
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
11448
+ if (refresh)
11449
+ await refresh;
10890
11450
  const peer = this._peerForRoleInScope(instanceId, role);
10891
11451
  if (!peer) {
10892
11452
  throw new RoutingFailure({
@@ -10899,6 +11459,9 @@ var RobloxStudioTools = class {
10899
11459
  }
10900
11460
  // Resolve an optional Studio process plus role to one exact Peer and dispatch.
10901
11461
  async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal, operationId) {
11462
+ const refresh = this.bridge.refreshTopologyForRouting(signal);
11463
+ if (refresh)
11464
+ await refresh;
10902
11465
  const resolved = this.bridge.resolveTarget({ instance_id, target });
10903
11466
  if (!resolved.ok)
10904
11467
  throw new RoutingFailure(resolved.error);
@@ -10930,6 +11493,7 @@ var RobloxStudioTools = class {
10930
11493
  clientRole: client?.role
10931
11494
  };
10932
11495
  }
11496
+ // Resolve synchronously after the caller has refreshed and captured its topology.
10933
11497
  _resolveInstanceIdOnly(instance_id) {
10934
11498
  if (instance_id !== void 0) {
10935
11499
  const resolvedInstanceId = this.bridge.resolveConnectedInstanceId(instance_id);
@@ -11143,6 +11707,9 @@ var RobloxStudioTools = class {
11143
11707
  async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
11144
11708
  const deadline = Date.now() + timeoutSec * 1e3;
11145
11709
  while (Date.now() < deadline) {
11710
+ const refresh = this.bridge.refreshTopologyForRouting();
11711
+ if (refresh)
11712
+ await refresh;
11146
11713
  const roles = this._rolesForScope(instanceId);
11147
11714
  const clientRoles = this._clientRolesForScope(instanceId);
11148
11715
  const hasServer = !opts.server || roles.includes("server");
@@ -11164,6 +11731,9 @@ var RobloxStudioTools = class {
11164
11731
  const deadline = Date.now() + timeoutSec * 1e3;
11165
11732
  let exactSince;
11166
11733
  while (Date.now() < deadline) {
11734
+ const refresh = this.bridge.refreshTopologyForRouting();
11735
+ if (refresh)
11736
+ await refresh;
11167
11737
  const roles2 = this._rolesForScope(instanceId);
11168
11738
  const clientCount2 = this._clientRolesForScope(instanceId).length;
11169
11739
  if (clientCount2 > expectedClientCount) {
@@ -11186,6 +11756,9 @@ var RobloxStudioTools = class {
11186
11756
  async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
11187
11757
  const deadline = Date.now() + timeoutSec * 1e3;
11188
11758
  while (Date.now() < deadline) {
11759
+ const refresh = this.bridge.refreshTopologyForRouting();
11760
+ if (refresh)
11761
+ await refresh;
11189
11762
  const peers = this.bridge.getPeersInScope(instanceId);
11190
11763
  const roles = peers.map((peer) => peer.role);
11191
11764
  const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
@@ -11200,8 +11773,8 @@ var RobloxStudioTools = class {
11200
11773
  timedOut: true
11201
11774
  };
11202
11775
  }
11203
- async getFileTree(path6 = "", instance_id) {
11204
- const response = await this._callSingle("/api/file-tree", { path: path6 }, void 0, instance_id);
11776
+ async getFileTree(path7 = "", instance_id) {
11777
+ const response = await this._callSingle("/api/file-tree", { path: path7 }, void 0, instance_id);
11205
11778
  return {
11206
11779
  content: [
11207
11780
  {
@@ -11293,9 +11866,9 @@ var RobloxStudioTools = class {
11293
11866
  ]
11294
11867
  };
11295
11868
  }
11296
- async getProjectStructure(path6, maxDepth, scriptsOnly, instance_id) {
11869
+ async getProjectStructure(path7, maxDepth, scriptsOnly, instance_id) {
11297
11870
  const response = await this._callSingle("/api/project-structure", {
11298
- path: path6,
11871
+ path: path7,
11299
11872
  maxDepth,
11300
11873
  scriptsOnly
11301
11874
  }, void 0, instance_id);
@@ -11456,7 +12029,7 @@ var RobloxStudioTools = class {
11456
12029
  };
11457
12030
  }
11458
12031
  async setSelection(paths, mode, instance_id) {
11459
- if (!Array.isArray(paths) || paths.some((path6) => typeof path6 !== "string" || path6.length === 0)) {
12032
+ if (!Array.isArray(paths) || paths.some((path7) => typeof path7 !== "string" || path7.length === 0)) {
11460
12033
  throw new Error("selection paths must contain only non-empty instance paths");
11461
12034
  }
11462
12035
  const selectionMode = mode ?? "set";
@@ -11483,6 +12056,9 @@ var RobloxStudioTools = class {
11483
12056
  if (angleY !== void 0 && (angleY < -89 || angleY > 89)) {
11484
12057
  throw new Error("selection angleY must be between -89 and 89");
11485
12058
  }
12059
+ const refresh = this.bridge.refreshTopologyForRouting();
12060
+ if (refresh)
12061
+ await refresh;
11486
12062
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
11487
12063
  const response = await this._callSingle("/api/focus-viewport", {
11488
12064
  path: instancePath,
@@ -11547,6 +12123,9 @@ var RobloxStudioTools = class {
11547
12123
  }
11548
12124
  async setNetworkProfile(profile, target, overrides, instance_id) {
11549
12125
  const values = normalizeNetworkProfile(profile, overrides);
12126
+ const refresh = this.bridge.refreshTopologyForRouting();
12127
+ if (refresh)
12128
+ await refresh;
11550
12129
  const instanceId = this._resolveInstanceIdOnly(instance_id);
11551
12130
  const clientRoles = this._clientRolesForScope(instanceId);
11552
12131
  const selectedTarget = target ?? "client-1";
@@ -11613,6 +12192,9 @@ var RobloxStudioTools = class {
11613
12192
  const selectedInclude = this._normalizeSimulationInclude(include);
11614
12193
  const includeNetwork = selectedInclude === "network" || selectedInclude === "both";
11615
12194
  const includeDeviceSimulator = selectedInclude === "deviceSimulator" || selectedInclude === "both";
12195
+ const refresh = this.bridge.refreshTopologyForRouting();
12196
+ if (refresh)
12197
+ await refresh;
11616
12198
  const resolved = this._resolveSimulationTargets(target, instance_id, "get_simulation_state");
11617
12199
  const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
11618
12200
  const state = {};
@@ -11659,6 +12241,9 @@ var RobloxStudioTools = class {
11659
12241
  if (!resetNetwork && !resetDeviceSimulator) {
11660
12242
  throw new Error("reset_simulation_state requires network=true and/or deviceSimulator=true; both default to true");
11661
12243
  }
12244
+ const refresh = this.bridge.refreshTopologyForRouting();
12245
+ if (refresh)
12246
+ await refresh;
11662
12247
  const resolved = this._resolveSimulationTargets(target, instance_id, "reset_simulation_state");
11663
12248
  const roleEntries = await Promise.all(resolved.roles.map(async (role) => {
11664
12249
  const result = {};
@@ -11717,6 +12302,9 @@ var RobloxStudioTools = class {
11717
12302
  if (deviceId !== void 0 && (typeof deviceId !== "string" || deviceId.trim() === "")) {
11718
12303
  throw new Error("deviceId must be a non-empty string when provided");
11719
12304
  }
12305
+ const refresh = this.bridge.refreshTopologyForRouting();
12306
+ if (refresh)
12307
+ await refresh;
11720
12308
  const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "get_device_simulator_state");
11721
12309
  const state = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "get", {
11722
12310
  includeDeviceList: includeDeviceList !== false,
@@ -11741,6 +12329,9 @@ var RobloxStudioTools = class {
11741
12329
  if (stopSimulation !== true && !hasDeviceSimulatorSettings(settings)) {
11742
12330
  throw new Error("set_device_simulator requires stopSimulation=true or at least one simulator setting");
11743
12331
  }
12332
+ const refresh = this.bridge.refreshTopologyForRouting();
12333
+ if (refresh)
12334
+ await refresh;
11744
12335
  const resolved = this._resolveDeviceSimulatorSetTargets(target, instance_id);
11745
12336
  const responses = await Promise.allSettled(resolved.roles.map(async (role) => {
11746
12337
  const result = await this._executeDeviceSimulatorOperation(resolved.instanceId, role, "set", stopSimulation === true ? { stopSimulation: true } : { settings });
@@ -11799,6 +12390,9 @@ var RobloxStudioTools = class {
11799
12390
  label: raw.label
11800
12391
  };
11801
12392
  });
12393
+ const refresh = this.bridge.refreshTopologyForRouting();
12394
+ if (refresh)
12395
+ await refresh;
11802
12396
  const resolved = this._resolveDeviceSimulatorSingleTarget(target, instance_id, "capture_device_matrix");
11803
12397
  if (resolved.role.startsWith("client-") && await this._isMultiplayerTestRunning(resolved.instanceId)) {
11804
12398
  throw new Error("capture_device_matrix does not support StudioTestService multiplayer client targets because Roblox scopes temporary screenshot textures per client process");
@@ -12150,6 +12744,9 @@ var RobloxStudioTools = class {
12150
12744
  if (outputPath) {
12151
12745
  data.__mcp_include_raw_json = true;
12152
12746
  }
12747
+ const refresh = this.bridge.refreshTopologyForRouting();
12748
+ if (refresh)
12749
+ await refresh;
12153
12750
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12154
12751
  if (!resolved.ok)
12155
12752
  throw new RoutingFailure(resolved.error);
@@ -12169,9 +12766,9 @@ var RobloxStudioTools = class {
12169
12766
  const rawJson = mutable.raw_json;
12170
12767
  if (typeof rawJson === "string") {
12171
12768
  if (typeof outputPath === "string" && outputPath !== "") {
12172
- const resolvedOutputPath = path5.resolve(outputPath);
12173
- fs3.mkdirSync(path5.dirname(resolvedOutputPath), { recursive: true });
12174
- fs3.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12769
+ const resolvedOutputPath = path6.resolve(outputPath);
12770
+ fs4.mkdirSync(path6.dirname(resolvedOutputPath), { recursive: true });
12771
+ fs4.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12175
12772
  mutable.output_path = resolvedOutputPath;
12176
12773
  }
12177
12774
  delete mutable.raw_json;
@@ -12210,6 +12807,9 @@ var RobloxStudioTools = class {
12210
12807
  if (summaryOutputPath || baselinePath || baseline || includeComparisonIndex) {
12211
12808
  data.__mcp_include_comparison_index = true;
12212
12809
  }
12810
+ const refresh = this.bridge.refreshTopologyForRouting();
12811
+ if (refresh)
12812
+ await refresh;
12213
12813
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12214
12814
  if (!resolved.ok)
12215
12815
  throw new RoutingFailure(resolved.error);
@@ -12229,9 +12829,9 @@ var RobloxStudioTools = class {
12229
12829
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
12230
12830
  if (typeof rawSnapshotBase64 === "string") {
12231
12831
  if (typeof outputPath === "string" && outputPath !== "") {
12232
- const resolvedOutputPath = path5.resolve(outputPath);
12233
- fs3.mkdirSync(path5.dirname(resolvedOutputPath), { recursive: true });
12234
- fs3.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
12832
+ const resolvedOutputPath = path6.resolve(outputPath);
12833
+ fs4.mkdirSync(path6.dirname(resolvedOutputPath), { recursive: true });
12834
+ fs4.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
12235
12835
  mutable.output_path = resolvedOutputPath;
12236
12836
  }
12237
12837
  delete mutable.raw_snapshot_base64;
@@ -12245,9 +12845,9 @@ var RobloxStudioTools = class {
12245
12845
  });
12246
12846
  }
12247
12847
  if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
12248
- const resolvedSummaryPath = path5.resolve(summaryOutputPath);
12249
- fs3.mkdirSync(path5.dirname(resolvedSummaryPath), { recursive: true });
12250
- fs3.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
12848
+ const resolvedSummaryPath = path6.resolve(summaryOutputPath);
12849
+ fs4.mkdirSync(path6.dirname(resolvedSummaryPath), { recursive: true });
12850
+ fs4.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
12251
12851
  mutable.summary_output_path = resolvedSummaryPath;
12252
12852
  }
12253
12853
  if (!includeComparisonIndex) {
@@ -12264,6 +12864,9 @@ var RobloxStudioTools = class {
12264
12864
  const data = { ...request, action };
12265
12865
  delete data.target;
12266
12866
  delete data.instance_id;
12867
+ const refresh = this.bridge.refreshTopologyForRouting();
12868
+ if (refresh)
12869
+ await refresh;
12267
12870
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
12268
12871
  if (!resolved.ok)
12269
12872
  throw new RoutingFailure(resolved.error);
@@ -12306,7 +12909,7 @@ var RobloxStudioTools = class {
12306
12909
  return record.placeId !== void 0 && instance.placeId === record.placeId;
12307
12910
  }
12308
12911
  if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
12309
- const expectedName = path5.basename(record.localPlaceFile);
12912
+ const expectedName = path6.basename(record.localPlaceFile);
12310
12913
  return instance.placeName === expectedName || instance.dataModelName === expectedName;
12311
12914
  }
12312
12915
  return true;
@@ -12453,6 +13056,7 @@ var RobloxStudioTools = class {
12453
13056
  });
12454
13057
  }
12455
13058
  return this._textResult({
13059
+ test_worker_job_name: parseStudioTestWorkerJobName(process.env.RSMCP_STUDIO_TEST_WORKER_JOB),
12456
13060
  managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
12457
13061
  connected: this.bridge.getPublicInstances().map((instance) => ({
12458
13062
  instance_id: instance.id,
@@ -12531,10 +13135,9 @@ var RobloxStudioTools = class {
12531
13135
  }
12532
13136
  record2 = active[0];
12533
13137
  }
12534
- if (record2.instanceId)
12535
- await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12536
13138
  const closeResult = await this.instanceManager.close(record2);
12537
13139
  if (record2.instanceId) {
13140
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12538
13141
  await sleep(500);
12539
13142
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12540
13143
  }
@@ -12614,6 +13217,9 @@ var RobloxStudioTools = class {
12614
13217
  throw new Error("solo_playtest requires action=start|stop|status");
12615
13218
  }
12616
13219
  if (action === "status") {
13220
+ const refresh = this.bridge.refreshTopologyForRouting();
13221
+ if (refresh)
13222
+ await refresh;
12617
13223
  const instanceId = this._resolveInstanceIdOnly(instance_id);
12618
13224
  const { roles, runtimeRoles } = this._briefRoles(instanceId);
12619
13225
  return this._textResult({
@@ -12674,6 +13280,9 @@ var RobloxStudioTools = class {
12674
13280
  }
12675
13281
  const data = { mode };
12676
13282
  const startedAt = Date.now();
13283
+ const refresh = this.bridge.refreshTopologyForRouting();
13284
+ if (refresh)
13285
+ await refresh;
12677
13286
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
12678
13287
  if (!resolved.ok)
12679
13288
  throw new RoutingFailure(resolved.error);
@@ -12724,6 +13333,9 @@ var RobloxStudioTools = class {
12724
13333
  };
12725
13334
  }
12726
13335
  async stopPlaytest(instance_id, timeout = 15) {
13336
+ const refresh = this.bridge.refreshTopologyForRouting();
13337
+ if (refresh)
13338
+ await refresh;
12727
13339
  const { instanceId } = this._resolveSingleTarget("edit", instance_id);
12728
13340
  let response;
12729
13341
  let stopRequestError;
@@ -12774,6 +13386,9 @@ var RobloxStudioTools = class {
12774
13386
  };
12775
13387
  }
12776
13388
  async _buildMultiplayerState(instanceId) {
13389
+ const refresh = this.bridge.refreshTopologyForRouting();
13390
+ if (refresh)
13391
+ await refresh;
12777
13392
  const peers = this.bridge.getPublicPeers().filter((peer) => this.bridge.getInstanceIdsInScope(instanceId).includes(peer.instanceId)).sort((a, b) => a.role.localeCompare(b.role));
12778
13393
  const multiplayerGroup = this.bridge.getMultiplayerGroups().find((group) => group.instanceIds.includes(instanceId));
12779
13394
  const body = {
@@ -12819,6 +13434,9 @@ var RobloxStudioTools = class {
12819
13434
  async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
12820
13435
  const deadline = Date.now() + timeoutSec * 1e3;
12821
13436
  while (Date.now() < deadline) {
13437
+ const refresh = this.bridge.refreshTopologyForRouting();
13438
+ if (refresh)
13439
+ await refresh;
12822
13440
  if (!this._rolesForScope(instanceId).includes("edit"))
12823
13441
  return false;
12824
13442
  try {
@@ -12833,6 +13451,9 @@ var RobloxStudioTools = class {
12833
13451
  return false;
12834
13452
  }
12835
13453
  async _isMultiplayerTestRunning(instanceId) {
13454
+ const refresh = this.bridge.refreshTopologyForRouting();
13455
+ if (refresh)
13456
+ await refresh;
12836
13457
  return this.bridge.getMultiplayerGroups().some((group) => group.instanceIds.includes(instanceId));
12837
13458
  }
12838
13459
  async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
@@ -12873,6 +13494,9 @@ var RobloxStudioTools = class {
12873
13494
  throw new Error("multiplayer_playtest requires action=start|status|add_players|leave_client|end");
12874
13495
  }
12875
13496
  const briefState = async (instanceId) => {
13497
+ const refresh = this.bridge.refreshTopologyForRouting();
13498
+ if (refresh)
13499
+ await refresh;
12876
13500
  const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
12877
13501
  const roles = Array.isArray(state.peers) ? state.peers.flatMap((peer) => peer !== null && typeof peer === "object" && "role" in peer && typeof peer.role === "string" ? [peer.role] : []) : [];
12878
13502
  return {
@@ -12970,6 +13594,9 @@ var RobloxStudioTools = class {
12970
13594
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
12971
13595
  throw new Error("numPlayers must be an integer from 1 to 8");
12972
13596
  }
13597
+ const refresh = this.bridge.refreshTopologyForRouting();
13598
+ if (refresh)
13599
+ await refresh;
12973
13600
  const editTarget = this._resolveSingleTarget("edit", instance_id);
12974
13601
  const existingRuntime = this._runtimeTargetsForScope(editTarget.instanceId);
12975
13602
  if (existingRuntime.length > 0) {
@@ -13026,6 +13653,9 @@ var RobloxStudioTools = class {
13026
13653
  };
13027
13654
  }
13028
13655
  async multiplayerTestState(instance_id) {
13656
+ const refresh = this.bridge.refreshTopologyForRouting();
13657
+ if (refresh)
13658
+ await refresh;
13029
13659
  const instanceId = this._resolveInstanceIdOnly(instance_id);
13030
13660
  const state = await this._buildMultiplayerState(instanceId);
13031
13661
  return { content: [{ type: "text", text: JSON.stringify(state) }] };
@@ -13034,6 +13664,9 @@ var RobloxStudioTools = class {
13034
13664
  if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
13035
13665
  throw new Error("numPlayers must be an integer from 1 to 8");
13036
13666
  }
13667
+ const refresh = this.bridge.refreshTopologyForRouting();
13668
+ if (refresh)
13669
+ await refresh;
13037
13670
  const serverTarget = this._resolveSingleTarget("server", instance_id);
13038
13671
  const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
13039
13672
  const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
@@ -13062,6 +13695,9 @@ var RobloxStudioTools = class {
13062
13695
  if (!/^client-\d+$/.test(target)) {
13063
13696
  throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
13064
13697
  }
13698
+ const refresh = this.bridge.refreshTopologyForRouting();
13699
+ if (refresh)
13700
+ await refresh;
13065
13701
  const clientTarget = this._resolveSingleTarget(target, instance_id);
13066
13702
  const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
13067
13703
  const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
@@ -13085,6 +13721,9 @@ var RobloxStudioTools = class {
13085
13721
  };
13086
13722
  }
13087
13723
  async multiplayerTestEnd(value, timeout, instance_id) {
13724
+ const refresh = this.bridge.refreshTopologyForRouting();
13725
+ if (refresh)
13726
+ await refresh;
13088
13727
  let serverTarget;
13089
13728
  try {
13090
13729
  serverTarget = this._resolveSingleTarget("server", instance_id);
@@ -13133,6 +13772,9 @@ var RobloxStudioTools = class {
13133
13772
  return result;
13134
13773
  }
13135
13774
  async getConnectedInstances() {
13775
+ const refresh = this.bridge.refreshTopologyForRouting();
13776
+ if (refresh)
13777
+ await refresh;
13136
13778
  return this._textResult({
13137
13779
  instances: this.bridge.getConnectedInstances(),
13138
13780
  multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
@@ -13598,11 +14240,11 @@ var RobloxStudioTools = class {
13598
14240
  return this.resolveUploadedReferenceImageId(decalId, instance_id);
13599
14241
  }
13600
14242
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
13601
- if (!fs3.existsSync(filePath)) {
14243
+ if (!fs4.existsSync(filePath)) {
13602
14244
  throw new Error(`File not found: ${filePath}`);
13603
14245
  }
13604
- const fileContent = fs3.readFileSync(filePath);
13605
- const fileName = path5.basename(filePath);
14246
+ const fileContent = fs4.readFileSync(filePath);
14247
+ const fileName = path6.basename(filePath);
13606
14248
  const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
13607
14249
  const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
13608
14250
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
@@ -13674,6 +14316,9 @@ var RobloxStudioTools = class {
13674
14316
  if (!action) {
13675
14317
  throw new Error("action is required for simulate_mouse_input");
13676
14318
  }
14319
+ const refresh = this.bridge.refreshTopologyForRouting();
14320
+ if (refresh)
14321
+ await refresh;
13677
14322
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13678
14323
  const response = await this._callSingle("/api/simulate-mouse-input", {
13679
14324
  action,
@@ -13692,6 +14337,9 @@ var RobloxStudioTools = class {
13692
14337
  if (!keyCode && text === void 0) {
13693
14338
  throw new Error("keyCode or text is required for simulate_keyboard_input");
13694
14339
  }
14340
+ const refresh = this.bridge.refreshTopologyForRouting();
14341
+ if (refresh)
14342
+ await refresh;
13695
14343
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13696
14344
  const response = await this._callSingle("/api/simulate-keyboard-input", {
13697
14345
  keyCode,
@@ -13730,6 +14378,9 @@ var RobloxStudioTools = class {
13730
14378
  const data = {};
13731
14379
  if (tags !== void 0)
13732
14380
  data.tags = tags;
14381
+ const refresh = this.bridge.refreshTopologyForRouting();
14382
+ if (refresh)
14383
+ await refresh;
13733
14384
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
13734
14385
  if (!resolved.ok)
13735
14386
  throw new RoutingFailure(resolved.error);
@@ -13763,6 +14414,9 @@ var RobloxStudioTools = class {
13763
14414
  data.topN = topN;
13764
14415
  if (raw !== void 0)
13765
14416
  data.raw = raw;
14417
+ const refresh = this.bridge.refreshTopologyForRouting();
14418
+ if (refresh)
14419
+ await refresh;
13766
14420
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
13767
14421
  if (!resolved.ok)
13768
14422
  throw new RoutingFailure(resolved.error);
@@ -13806,10 +14460,10 @@ var RobloxStudioTools = class {
13806
14460
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
13807
14461
  }
13808
14462
  const bytes = Buffer.from(response.base64, "base64");
13809
- const resolved = path5.resolve(outputPath);
14463
+ const resolved = path6.resolve(outputPath);
13810
14464
  try {
13811
- fs3.mkdirSync(path5.dirname(resolved), { recursive: true });
13812
- fs3.writeFileSync(resolved, bytes);
14465
+ fs4.mkdirSync(path6.dirname(resolved), { recursive: true });
14466
+ fs4.writeFileSync(resolved, bytes);
13813
14467
  } catch (err2) {
13814
14468
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
13815
14469
  }
@@ -13842,9 +14496,9 @@ var RobloxStudioTools = class {
13842
14496
  let bytes;
13843
14497
  let sourceLabel;
13844
14498
  if (source.path !== void 0) {
13845
- const resolved = path5.resolve(source.path);
14499
+ const resolved = path6.resolve(source.path);
13846
14500
  try {
13847
- bytes = fs3.readFileSync(resolved);
14501
+ bytes = fs4.readFileSync(resolved);
13848
14502
  } catch (err2) {
13849
14503
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err2.message}` }) }] };
13850
14504
  }
@@ -13894,19 +14548,57 @@ var RobloxStudioTools = class {
13894
14548
  }, tgt, instance_id);
13895
14549
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
13896
14550
  }
14551
+ // Even native capture must wait while another call has fitted the simulator
14552
+ // or drawn markers. Otherwise it could observe temporary UI or dimensions.
13897
14553
  async _captureViewportImage(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
13898
- let response;
13899
- if (targetRole.startsWith("client-")) {
13900
- const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
13901
- if (begin.error) {
13902
- return { success: false, error: begin.error };
14554
+ const previous = this.viewportCaptureQueues.get(instanceId) ?? Promise.resolve();
14555
+ const capture = previous.then(() => this._captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes));
14556
+ const settled = capture.then(() => void 0, () => void 0);
14557
+ this.viewportCaptureQueues.set(instanceId, settled);
14558
+ try {
14559
+ return await capture;
14560
+ } finally {
14561
+ if (this.viewportCaptureQueues.get(instanceId) === settled)
14562
+ this.viewportCaptureQueues.delete(instanceId);
14563
+ }
14564
+ }
14565
+ async _captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
14566
+ const fmt = format === "png" ? "png" : "jpeg";
14567
+ const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
14568
+ let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
14569
+ const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
14570
+ if (studioFastPathMissing) {
14571
+ if (targetRole.startsWith("client-")) {
14572
+ const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
14573
+ if (begin.error) {
14574
+ return { success: false, error: begin.error };
14575
+ }
14576
+ if (!begin.contentId) {
14577
+ return { success: false, error: "Screenshot capture failed: no content id returned from client." };
14578
+ }
14579
+ response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14580
+ } else {
14581
+ response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
13903
14582
  }
13904
- if (!begin.contentId) {
13905
- return { success: false, error: "Screenshot capture failed: no content id returned from client." };
14583
+ }
14584
+ let hostNote = "";
14585
+ let hostReason;
14586
+ try {
14587
+ hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ? "Studio's CaptureService returned a blank (single-colour) frame" : void 0;
14588
+ } catch (error) {
14589
+ response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
14590
+ hostReason = `Studio's capture failed (${response.error})`;
14591
+ }
14592
+ if (hostReason !== void 0) {
14593
+ const host = await this._captureViewportFromHostWindow(instanceId, targetRole);
14594
+ if (host.success) {
14595
+ response = host.response;
14596
+ hostNote = ` Captured from the Studio window through the host OS because ${hostReason}.`;
14597
+ } else if (response.error) {
14598
+ response = { ...response, error: `${response.error} Host window capture also failed: ${host.error}.` };
14599
+ } else {
14600
+ hostNote = ` Warning: ${hostReason} and host window capture also failed (${host.error}), so this image may be blank.`;
13906
14601
  }
13907
- response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
13908
- } else {
13909
- response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
13910
14602
  }
13911
14603
  if (response.error) {
13912
14604
  let text = response.error;
@@ -13920,11 +14612,19 @@ var RobloxStudioTools = class {
13920
14612
  if (w === void 0 || h === void 0) {
13921
14613
  return { success: false, error: "Screenshot response missing dimensions." };
13922
14614
  }
13923
- const fmt = format === "png" ? "png" : "jpeg";
13924
- const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
13925
- const encoded = encodeImageFromRgbaResponse(response, fmt, q);
13926
- let { buffer } = encoded;
13927
- const { mimeType } = encoded;
14615
+ let buffer;
14616
+ let mimeType;
14617
+ if (response.encoding === "png") {
14618
+ if (!response.data) {
14619
+ return { success: false, error: "Screenshot response missing PNG data." };
14620
+ }
14621
+ buffer = Buffer.from(response.data, "base64");
14622
+ mimeType = "image/png";
14623
+ } else {
14624
+ const encoded = encodeImageFromRgbaResponse(response, fmt, q);
14625
+ buffer = encoded.buffer;
14626
+ mimeType = encoded.mimeType;
14627
+ }
13928
14628
  let usedQ = q;
13929
14629
  let note = "";
13930
14630
  if (buffer.length > maxBytes) {
@@ -13949,7 +14649,7 @@ var RobloxStudioTools = class {
13949
14649
  }
13950
14650
  const nativeW = response.nativeWidth ?? w;
13951
14651
  const nativeH = response.nativeHeight ?? h;
13952
- const message = nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`;
14652
+ const message = (nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`) + hostNote;
13953
14653
  return {
13954
14654
  success: true,
13955
14655
  width: w,
@@ -13962,7 +14662,153 @@ var RobloxStudioTools = class {
13962
14662
  message
13963
14663
  };
13964
14664
  }
14665
+ _isUniformCaptureResponse(response) {
14666
+ if (!response.data || !response.width || !response.height)
14667
+ return false;
14668
+ const data = Buffer.from(response.data, "base64");
14669
+ if (response.encoding === "png")
14670
+ return isUniformPng(data);
14671
+ return isUniformFrame(data, response.width, response.height);
14672
+ }
14673
+ _hostCaptureTitleHint(instanceId) {
14674
+ const peers = this.bridge.getPeers().filter((peer) => peer.instanceId === instanceId);
14675
+ const edit = peers.find((peer) => peer.role === "edit") ?? peers[0];
14676
+ const name = edit?.dataModelName || edit?.placeName;
14677
+ return name ? name : void 0;
14678
+ }
14679
+ async _callViewportMarkers(action, targetRole, instanceId, captureId) {
14680
+ return await this._callSingle("/api/capture-markers", { action, ...captureId ? { captureId } : {} }, targetRole, instanceId);
14681
+ }
14682
+ // Always restore the simulator, including failures and cached window grabs.
14683
+ async _captureViewportFromHostWindow(instanceId, targetRole) {
14684
+ let prepared;
14685
+ let result;
14686
+ try {
14687
+ prepared = await this._callViewportMarkers("prepare", targetRole, instanceId);
14688
+ if (prepared.error || !prepared.captureId || !prepared.viewportWidth || !prepared.viewportHeight) {
14689
+ result = { success: false, error: prepared.error ?? "the Studio plugin could not prepare host capture (update the plugin)" };
14690
+ } else {
14691
+ if (prepared.viewportChanged)
14692
+ this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
14693
+ result = await this._capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared);
14694
+ }
14695
+ } catch (error) {
14696
+ result = { success: false, error: `host capture failed: ${error instanceof Error ? error.message : String(error)}` };
14697
+ } finally {
14698
+ if (prepared?.captureId) {
14699
+ try {
14700
+ const restored = await this._callViewportMarkers("finish", targetRole, instanceId, prepared.captureId);
14701
+ if (restored.error)
14702
+ result = { success: false, error: `could not restore the Studio viewport: ${restored.error}` };
14703
+ else if (restored.stale || !restored.success)
14704
+ result = { success: false, error: "the host capture transaction expired or lost ownership; retry the capture" };
14705
+ } catch (error) {
14706
+ result = { success: false, error: `could not restore the Studio viewport: ${error instanceof Error ? error.message : String(error)}` };
14707
+ }
14708
+ }
14709
+ }
14710
+ if (!result.success)
14711
+ this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
14712
+ return result;
14713
+ }
14714
+ // Grabs the Studio window through the host OS and crops it to the viewport
14715
+ // of `targetRole`. The plugin pins magenta markers to the viewport corners
14716
+ // so the crop is exact regardless of Studio's dock layout or DPI scale; the
14717
+ // located rect is cached briefly so repeated captures cost one window grab.
14718
+ async _capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared) {
14719
+ const titleHint = this._hostCaptureTitleHint(instanceId);
14720
+ const cacheKey = `${instanceId}|${targetRole}`;
14721
+ const cached = this.hostViewportRects.get(cacheKey);
14722
+ let sizeInfo;
14723
+ try {
14724
+ sizeInfo = await this._callViewportMarkers("query", targetRole, instanceId, prepared.captureId);
14725
+ } catch (error) {
14726
+ return { success: false, error: `viewport markers unavailable: ${error instanceof Error ? error.message : String(error)}` };
14727
+ }
14728
+ if (sizeInfo.error || !sizeInfo.viewportWidth || !sizeInfo.viewportHeight) {
14729
+ return {
14730
+ success: false,
14731
+ error: sizeInfo.error ?? "the Studio plugin did not report a viewport size (update the plugin to a build with /api/capture-markers)"
14732
+ };
14733
+ }
14734
+ const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
14735
+ const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
14736
+ if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
14737
+ const grab = await this.hostWindowCapture(titleHint);
14738
+ if (!grab.ok)
14739
+ return { success: false, error: grab.error };
14740
+ if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
14741
+ return {
14742
+ success: true,
14743
+ response: this._hostResponseFromCrop(grab.capture.rgba, grab.capture.width, grab.capture.height, cached.rect, viewportWidth, viewportHeight)
14744
+ };
14745
+ }
14746
+ this.hostViewportRects.delete(cacheKey);
14747
+ }
14748
+ let shown;
14749
+ try {
14750
+ shown = await this._callViewportMarkers("show", targetRole, instanceId, prepared.captureId);
14751
+ } catch (error) {
14752
+ return { success: false, error: `could not draw viewport markers: ${error instanceof Error ? error.message : String(error)}` };
14753
+ }
14754
+ if (shown.error)
14755
+ return { success: false, error: `could not draw viewport markers: ${shown.error}` };
14756
+ const markerSize = shown.markerSize ?? 12;
14757
+ let markerGrab;
14758
+ let hidden;
14759
+ try {
14760
+ markerGrab = await this.hostWindowCapture(titleHint);
14761
+ } finally {
14762
+ hidden = await this._callViewportMarkers("hide", targetRole, instanceId, prepared.captureId);
14763
+ }
14764
+ if (hidden.error)
14765
+ return { success: false, error: `could not hide viewport markers: ${hidden.error}` };
14766
+ if (!markerGrab.ok)
14767
+ return { success: false, error: markerGrab.error };
14768
+ const located = findViewportRect(markerGrab.capture.rgba, markerGrab.capture.width, markerGrab.capture.height, {
14769
+ viewportWidth: shown.viewportWidth ?? viewportWidth,
14770
+ viewportHeight: shown.viewportHeight ?? viewportHeight,
14771
+ markerSize
14772
+ });
14773
+ if ("error" in located)
14774
+ return { success: false, error: located.error };
14775
+ this.hostViewportRects.set(cacheKey, {
14776
+ rect: located.rect,
14777
+ windowWidth: markerGrab.capture.width,
14778
+ windowHeight: markerGrab.capture.height,
14779
+ viewportWidth,
14780
+ viewportHeight,
14781
+ cachedAt: Date.now()
14782
+ });
14783
+ const cleanGrab = await this.hostWindowCapture(titleHint);
14784
+ if (!cleanGrab.ok)
14785
+ return { success: false, error: cleanGrab.error };
14786
+ if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
14787
+ this.hostViewportRects.delete(cacheKey);
14788
+ return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
14789
+ }
14790
+ return {
14791
+ success: true,
14792
+ response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
14793
+ };
14794
+ }
14795
+ _hostResponseFromCrop(rgba, width, height, rect, viewportWidth, viewportHeight) {
14796
+ const cropped = cropToViewport(rgba, width, height, rect, viewportWidth, viewportHeight);
14797
+ return {
14798
+ success: true,
14799
+ encoding: "rgba8",
14800
+ source: "host-window",
14801
+ width: cropped.width,
14802
+ height: cropped.height,
14803
+ nativeWidth: viewportWidth,
14804
+ nativeHeight: viewportHeight,
14805
+ data: cropped.rgba.toString("base64")
14806
+ };
14807
+ }
13965
14808
  async captureScreenshot(instance_id, format, quality) {
14809
+ const refresh = this.bridge.refreshTopologyForRouting();
14810
+ if (refresh)
14811
+ await refresh;
13966
14812
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
13967
14813
  const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
13968
14814
  if (!capture.success) {
@@ -14160,6 +15006,13 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14160
15006
  headers: this.authHeaders({ "Content-Type": "application/json" }),
14161
15007
  body: JSON.stringify({ groupId })
14162
15008
  });
15009
+ if (response.status === 409) {
15010
+ const refusal = await response.json().catch(() => void 0);
15011
+ if (refusal && typeof refusal === "object" && !Array.isArray(refusal) && "success" in refusal && refusal.success === false && "error" in refusal && refusal.error === "multiplayer_group_in_use" && "groupId" in refusal && refusal.groupId === groupId) {
15012
+ throw new MultiplayerGroupInUseError(groupId);
15013
+ }
15014
+ throw new Error("Proxy Multiplayer Group removal returned an invalid refusal.");
15015
+ }
14163
15016
  if (!response.ok) {
14164
15017
  const body = await response.text().catch(() => "");
14165
15018
  throw new Error(`Proxy Multiplayer Group removal failed (${response.status}): ${body || response.statusText}`);
@@ -16495,10 +17348,10 @@ var getReadOnlyTools = () => TOOL_DEFINITIONS.filter((t) => t.category === "read
16495
17348
 
16496
17349
  // ../core/dist/install-plugin-helpers.js
16497
17350
  var import_saxes = __toESM(require_saxes(), 1);
16498
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
17351
+ import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync, rmSync as rmSync3, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
16499
17352
  import { execSync } from "child_process";
16500
17353
  import { randomUUID as randomUUID5 } from "crypto";
16501
- import { basename as basename3, join as join5 } from "path";
17354
+ import { basename as basename3, join as join6 } from "path";
16502
17355
  import { homedir as homedir4 } from "os";
16503
17356
  import { isUtf8 } from "buffer";
16504
17357
  var DEFAULT_PLUGIN_PORT = 58741;
@@ -16550,15 +17403,15 @@ function getPluginsFolder() {
16550
17403
  if (process.env.MCP_PLUGINS_DIR)
16551
17404
  return process.env.MCP_PLUGINS_DIR;
16552
17405
  if (process.platform === "win32") {
16553
- return join5(process.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
17406
+ return join6(process.env.LOCALAPPDATA || join6(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
16554
17407
  }
16555
17408
  if (isWSL()) {
16556
17409
  const localAppData = getWindowsUserLocalAppData();
16557
17410
  if (localAppData)
16558
- return join5(localAppData.linuxPath, "Roblox", "Plugins");
17411
+ return join6(localAppData.linuxPath, "Roblox", "Plugins");
16559
17412
  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.");
16560
17413
  }
16561
- return join5(homedir4(), "Documents", "Roblox", "Plugins");
17414
+ return join6(homedir4(), "Documents", "Roblox", "Plugins");
16562
17415
  }
16563
17416
  var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
16564
17417
  function pluginDirectorySettingPattern() {
@@ -16568,12 +17421,12 @@ function escapeXmlText(value) {
16568
17421
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
16569
17422
  }
16570
17423
  function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDirectory }) {
16571
- if (!existsSync6(settingsPath))
17424
+ if (!existsSync7(settingsPath))
16572
17425
  return false;
16573
17426
  if (studioPluginsDirectory === "") {
16574
17427
  throw new Error("studioPluginsDirectory must be a non-empty path.");
16575
17428
  }
16576
- const contents = readFileSync6(settingsPath, "utf8");
17429
+ const contents = readFileSync7(settingsPath, "utf8");
16577
17430
  const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
16578
17431
  if (matches.length !== 1) {
16579
17432
  throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
@@ -16590,7 +17443,7 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
16590
17443
  });
16591
17444
  renameSync(temporaryPath, settingsPath);
16592
17445
  } finally {
16593
- rmSync2(temporaryPath, { force: true });
17446
+ rmSync3(temporaryPath, { force: true });
16594
17447
  }
16595
17448
  return true;
16596
17449
  }
@@ -16601,16 +17454,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
16601
17454
  let defaultPluginsFolder;
16602
17455
  let studioPluginsDirectory;
16603
17456
  if (process.platform === "win32") {
16604
- const localAppData = process.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local");
16605
- defaultPluginsFolder = join5(localAppData, "Roblox", "Plugins");
16606
- settingsPath = join5(localAppData, "Roblox", "GlobalSettings_13.xml");
17457
+ const localAppData = process.env.LOCALAPPDATA || join6(homedir4(), "AppData", "Local");
17458
+ defaultPluginsFolder = join6(localAppData, "Roblox", "Plugins");
17459
+ settingsPath = join6(localAppData, "Roblox", "GlobalSettings_13.xml");
16607
17460
  studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
16608
17461
  } else if (isWSL()) {
16609
17462
  const localAppData = getWindowsUserLocalAppData();
16610
17463
  if (!localAppData)
16611
17464
  return;
16612
- defaultPluginsFolder = join5(localAppData.linuxPath, "Roblox", "Plugins");
16613
- settingsPath = join5(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
17465
+ defaultPluginsFolder = join6(localAppData.linuxPath, "Roblox", "Plugins");
17466
+ settingsPath = join6(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
16614
17467
  studioPluginsDirectory = [
16615
17468
  localAppData.windowsPath.replace(/[\\/]+$/u, ""),
16616
17469
  "Roblox",
@@ -16626,8 +17479,8 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
16626
17479
  }
16627
17480
  }
16628
17481
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
16629
- const otherDest = join5(pluginsFolder, otherAssetName);
16630
- if (!existsSync6(otherDest))
17482
+ const otherDest = join6(pluginsFolder, otherAssetName);
17483
+ if (!existsSync7(otherDest))
16631
17484
  return;
16632
17485
  if (replace) {
16633
17486
  try {
@@ -16652,11 +17505,11 @@ function pluginInstallLockError(lockPath, reason) {
16652
17505
  return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
16653
17506
  }
16654
17507
  function readPluginInstallLockOwner(lockPath) {
16655
- const ownerPath = join5(lockPath, "owner.json");
17508
+ const ownerPath = join6(lockPath, "owner.json");
16656
17509
  if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
16657
17510
  throw new Error("Lock and owner metadata must be a directory and regular file");
16658
17511
  }
16659
- const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
17512
+ const owner = JSON.parse(readFileSync7(ownerPath, "utf8"));
16660
17513
  if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
16661
17514
  throw new Error("Invalid lock owner metadata");
16662
17515
  }
@@ -16682,7 +17535,7 @@ function recoverPluginInstallLock(lockPath, warn) {
16682
17535
  throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
16683
17536
  }
16684
17537
  assertPluginInstallOwnerExited(lockPath, owner);
16685
- const claimPath = join5(lockPath, `recovery-${owner.token}`);
17538
+ const claimPath = join6(lockPath, `recovery-${owner.token}`);
16686
17539
  try {
16687
17540
  mkdirSync4(claimPath);
16688
17541
  } catch (error) {
@@ -16715,14 +17568,14 @@ function recoverPluginInstallLock(lockPath, warn) {
16715
17568
  }
16716
17569
  }
16717
17570
  try {
16718
- rmSync2(abandonedPath, { recursive: true, force: true });
17571
+ rmSync3(abandonedPath, { recursive: true, force: true });
16719
17572
  } catch (error) {
16720
17573
  warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
16721
17574
  }
16722
17575
  }
16723
17576
  function acquirePluginInstallLock(pluginsFolder, warn) {
16724
- const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
16725
- const ownerPath = join5(lockPath, "owner.json");
17577
+ const lockPath = join6(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
17578
+ const ownerPath = join6(lockPath, "owner.json");
16726
17579
  const owner = {
16727
17580
  pid: process.pid,
16728
17581
  token: randomUUID5(),
@@ -16750,7 +17603,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16750
17603
  });
16751
17604
  } catch (error) {
16752
17605
  try {
16753
- rmSync2(lockPath, { recursive: true, force: true });
17606
+ rmSync3(lockPath, { recursive: true, force: true });
16754
17607
  } catch (cleanupError) {
16755
17608
  warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
16756
17609
  }
@@ -16760,7 +17613,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16760
17613
  try {
16761
17614
  const currentOwner = readPluginInstallLockOwner(lockPath);
16762
17615
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
16763
- rmSync2(lockPath, { recursive: true, force: true });
17616
+ rmSync3(lockPath, { recursive: true, force: true });
16764
17617
  }
16765
17618
  } catch (error) {
16766
17619
  if (errorCode(error) !== "ENOENT") {
@@ -16852,11 +17705,11 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
16852
17705
  }
16853
17706
  }
16854
17707
  function installedFileMatches(source, destination) {
16855
- if (!existsSync6(destination))
17708
+ if (!existsSync7(destination))
16856
17709
  return false;
16857
17710
  if (!lstatSync(destination).isFile())
16858
17711
  return false;
16859
- const installed = readFileSync6(destination);
17712
+ const installed = readFileSync7(destination);
16860
17713
  return installed.length === source.length && installed.equals(source);
16861
17714
  }
16862
17715
  function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
@@ -16868,12 +17721,12 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
16868
17721
  const configured = configurePluginAssetForPort(source, rawPort);
16869
17722
  assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
16870
17723
  mkdirSync4(pluginsFolder, { recursive: true });
16871
- const destination = join5(pluginsFolder, assetName);
17724
+ const destination = join6(pluginsFolder, assetName);
16872
17725
  const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
16873
17726
  try {
16874
17727
  let installed = false;
16875
17728
  if (!installedFileMatches(configured, destination)) {
16876
- const staged = join5(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
17729
+ const staged = join6(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
16877
17730
  try {
16878
17731
  writeFileSync3(staged, configured, { flag: "wx" });
16879
17732
  renameSync(staged, destination);
@@ -16906,8 +17759,8 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
16906
17759
  import { createRequire } from "module";
16907
17760
 
16908
17761
  // src/install-plugin.ts
16909
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
16910
- import { dirname as dirname5, join as join6 } from "path";
17762
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
17763
+ import { dirname as dirname5, join as join7 } from "path";
16911
17764
  import { fileURLToPath } from "url";
16912
17765
  import { get } from "https";
16913
17766
  var REPO = "chrrxs/robloxstudio-mcp";
@@ -16967,18 +17820,18 @@ async function fetchJson(url) {
16967
17820
  function bundledAssetPath() {
16968
17821
  const currentDir = dirname5(fileURLToPath(import.meta.url));
16969
17822
  const candidates = [
16970
- join6(currentDir, "..", "studio-plugin", ASSET_NAME),
16971
- join6(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
17823
+ join7(currentDir, "..", "studio-plugin", ASSET_NAME),
17824
+ join7(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
16972
17825
  ];
16973
- return candidates.find((candidate) => existsSync7(candidate)) ?? null;
17826
+ return candidates.find((candidate) => existsSync8(candidate)) ?? null;
16974
17827
  }
16975
17828
  function resolvePluginAssetPath(sourcePath) {
16976
17829
  if (sourcePath === void 0) return bundledAssetPath();
16977
- return existsSync7(sourcePath) ? sourcePath : null;
17830
+ return existsSync8(sourcePath) ? sourcePath : null;
16978
17831
  }
16979
17832
  function packageVersion() {
16980
17833
  const currentDir = dirname5(fileURLToPath(import.meta.url));
16981
- const pkg = JSON.parse(readFileSync7(join6(currentDir, "..", "package.json"), "utf8"));
17834
+ const pkg = JSON.parse(readFileSync8(join7(currentDir, "..", "package.json"), "utf8"));
16982
17835
  if (!pkg.version) {
16983
17836
  throw new Error("Package version not found");
16984
17837
  }
@@ -16998,7 +17851,7 @@ async function installBundledPlugin(options = {}) {
16998
17851
  pluginsFolder: getPluginsFolder(),
16999
17852
  assetName: ASSET_NAME,
17000
17853
  otherAssetName: OTHER_VARIANT,
17001
- source: readFileSync7(sourcePath),
17854
+ source: readFileSync8(sourcePath),
17002
17855
  expectedVersion: packageVersion(),
17003
17856
  expectedVariant: "inspector",
17004
17857
  replaceVariant,
@@ -17022,7 +17875,7 @@ async function installPlugin(options = {}) {
17022
17875
  pluginsFolder: getPluginsFolder(),
17023
17876
  assetName: ASSET_NAME,
17024
17877
  otherAssetName: OTHER_VARIANT,
17025
- source: readFileSync7(bundled),
17878
+ source: readFileSync8(bundled),
17026
17879
  expectedVersion: packageVersion(),
17027
17880
  expectedVariant: "inspector",
17028
17881
  replaceVariant,