@chrrxs/robloxstudio-mcp 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -43,18 +43,25 @@ var init_bridge_service = __esm({
43
43
  requestTimeout = 3e4;
44
44
  registerInstance(input) {
45
45
  const { pluginSessionId, instanceId, role } = input;
46
+ const prior = this.instances.get(pluginSessionId);
46
47
  let assignedRole = role;
47
48
  if (role === "client") {
48
- const used = /* @__PURE__ */ new Set();
49
- for (const inst of this.instances.values()) {
50
- const match = inst.role.match(/^client-(\d+)$/);
51
- if (match)
52
- used.add(Number(match[1]));
49
+ if (prior && prior.instanceId === instanceId && prior.role.match(/^client-\d+$/)) {
50
+ assignedRole = prior.role;
51
+ } else {
52
+ const used = /* @__PURE__ */ new Set();
53
+ for (const inst of this.instances.values()) {
54
+ if (inst.instanceId !== instanceId || inst.pluginSessionId === pluginSessionId)
55
+ continue;
56
+ const match = inst.role.match(/^client-(\d+)$/);
57
+ if (match)
58
+ used.add(Number(match[1]));
59
+ }
60
+ let idx = 1;
61
+ while (used.has(idx))
62
+ idx++;
63
+ assignedRole = `client-${idx}`;
53
64
  }
54
- let idx = 1;
55
- while (used.has(idx))
56
- idx++;
57
- assignedRole = `client-${idx}`;
58
65
  }
59
66
  const existing = Array.from(this.instances.values()).find((i) => i.instanceId === instanceId && i.role === assignedRole && i.pluginSessionId !== pluginSessionId);
60
67
  if (existing) {
@@ -76,7 +83,7 @@ var init_bridge_service = __esm({
76
83
  dataModelName: input.dataModelName ?? "",
77
84
  isRunning: input.isRunning ?? false,
78
85
  lastActivity: Date.now(),
79
- connectedAt: Date.now()
86
+ connectedAt: prior?.connectedAt ?? Date.now()
80
87
  });
81
88
  return { ok: true, assignedRole, instanceId };
82
89
  }
@@ -214,7 +221,7 @@ var init_bridge_service = __esm({
214
221
  }
215
222
  if (distinctInstanceIds.size > 1) {
216
223
  const errorCode = role ? "ambiguous_target" : "multiple_instances_connected";
217
- const msg = role ? `target=${role} is ambiguous: multiple places have this role. Pass instance_id.` : "Multiple Studio places are connected. Pass instance_id to disambiguate.";
224
+ const msg = role ? `target=${role} is ambiguous because multiple Studio places are connected. Pass instance_id to choose a place.` : "Multiple Studio places are connected. Pass instance_id to disambiguate.";
218
225
  return { ok: false, error: { code: errorCode, message: msg, data: errorData } };
219
226
  }
220
227
  const onlyInstanceId = instances[0].instanceId;
@@ -236,6 +243,7 @@ var init_bridge_service = __esm({
236
243
  targetInstanceId,
237
244
  targetRole,
238
245
  timestamp: Date.now(),
246
+ inFlight: false,
239
247
  resolve: resolve2,
240
248
  reject,
241
249
  timeoutId
@@ -250,11 +258,14 @@ var init_bridge_service = __esm({
250
258
  continue;
251
259
  if (request.targetRole !== callerRole)
252
260
  continue;
261
+ if (request.inFlight)
262
+ continue;
253
263
  if (!oldestRequest || request.timestamp < oldestRequest.timestamp) {
254
264
  oldestRequest = request;
255
265
  }
256
266
  }
257
267
  if (oldestRequest) {
268
+ oldestRequest.inFlight = true;
258
269
  return {
259
270
  requestId: oldestRequest.id,
260
271
  request: {
@@ -677,6 +688,11 @@ var init_http_server = __esm({
677
688
  start_playtest: (tools, body) => tools.startPlaytest(body.mode, body.numPlayers, body.instance_id),
678
689
  stop_playtest: (tools, body) => tools.stopPlaytest(body.instance_id),
679
690
  get_playtest_output: (tools, body) => tools.getPlaytestOutput(body.target, body.instance_id),
691
+ multiplayer_test_start: (tools, body) => tools.multiplayerTestStart(body.numPlayers, body.testArgs, body.timeout, body.instance_id),
692
+ multiplayer_test_state: (tools, body) => tools.multiplayerTestState(body.instance_id),
693
+ multiplayer_test_add_players: (tools, body) => tools.multiplayerTestAddPlayers(body.numPlayers, body.timeout, body.instance_id),
694
+ multiplayer_test_leave_client: (tools, body) => tools.multiplayerTestLeaveClient(body.target, body.timeout, body.instance_id),
695
+ multiplayer_test_end: (tools, body) => tools.multiplayerTestEnd(body.value, body.timeout, body.instance_id),
680
696
  get_runtime_logs: (tools, body) => tools.getRuntimeLogs(body.target, body.since, body.tail, body.filter, body.instance_id),
681
697
  get_connected_instances: (tools) => tools.getConnectedInstances(),
682
698
  export_build: (tools, body) => tools.exportBuild(body.instancePath, body.outputId, body.style, body.instance_id),
@@ -705,6 +721,7 @@ var init_http_server = __esm({
705
721
  simulate_keyboard_input: (tools, body) => tools.simulateKeyboardInput(body.keyCode, body.action, body.duration, body.text, body.target, body.instance_id),
706
722
  character_navigation: (tools, body) => tools.characterNavigation(body.position, body.instancePath, body.waitForCompletion, body.timeout, body.target, body.instance_id),
707
723
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
724
+ get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
708
725
  export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
709
726
  import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
710
727
  find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
@@ -2714,6 +2731,9 @@ function parseBridgeResponse(response) {
2714
2731
  }
2715
2732
  return JSON.stringify(response);
2716
2733
  }
2734
+ function sleep(ms) {
2735
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2736
+ }
2717
2737
  var SERVER_LOCAL_NAME, CLIENT_LOCAL_NAME, EVAL_WRAPPER_LINE_OFFSET, RobloxStudioTools;
2718
2738
  var init_tools = __esm({
2719
2739
  "../core/dist/tools/index.js"() {
@@ -2774,6 +2794,110 @@ var init_tools = __esm({
2774
2794
  const clientRoles = roles.filter((role) => role.startsWith("client")).sort();
2775
2795
  return { instanceId: resolvedId, clientRole: clientRoles[0] };
2776
2796
  }
2797
+ _resolveInstanceIdOnly(instance_id) {
2798
+ const instances = this.bridge.getInstances();
2799
+ const publicList = this.bridge.getPublicInstances();
2800
+ const errorData = { instances: publicList, count: publicList.length };
2801
+ if (instance_id !== void 0) {
2802
+ if (!instances.some((i) => i.instanceId === instance_id)) {
2803
+ throw new RoutingFailure({
2804
+ code: "unrecognized_instance_id",
2805
+ message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
2806
+ data: errorData
2807
+ });
2808
+ }
2809
+ return instance_id;
2810
+ }
2811
+ const distinct = Array.from(new Set(instances.map((i) => i.instanceId)));
2812
+ if (distinct.length === 0) {
2813
+ throw new RoutingFailure({
2814
+ code: "unrecognized_instance_id",
2815
+ message: "No Studio plugin is connected.",
2816
+ data: errorData
2817
+ });
2818
+ }
2819
+ if (distinct.length > 1) {
2820
+ throw new RoutingFailure({
2821
+ code: "multiple_instances_connected",
2822
+ message: "Multiple Studio places are connected. Pass instance_id to disambiguate.",
2823
+ data: errorData
2824
+ });
2825
+ }
2826
+ return distinct[0];
2827
+ }
2828
+ _resolveSingleTarget(target, instance_id) {
2829
+ const resolved = this.bridge.resolveTarget({ instance_id, target });
2830
+ if (!resolved.ok)
2831
+ throw new RoutingFailure(resolved.error);
2832
+ if (resolved.mode !== "single") {
2833
+ throw new RoutingFailure({
2834
+ code: "target_role_not_present_on_instance",
2835
+ message: "Pick a specific target role for this tool.",
2836
+ data: {
2837
+ instances: this.bridge.getPublicInstances(),
2838
+ count: this.bridge.getInstances().length
2839
+ }
2840
+ });
2841
+ }
2842
+ return { instanceId: resolved.targetInstanceId, role: resolved.targetRole };
2843
+ }
2844
+ _rolesForInstance(instanceId) {
2845
+ return this.bridge.getInstances().filter((i) => i.instanceId === instanceId).map((i) => i.role);
2846
+ }
2847
+ _clientRolesForInstance(instanceId) {
2848
+ return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
2849
+ }
2850
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
2851
+ const deadline = Date.now() + timeoutSec * 1e3;
2852
+ while (Date.now() < deadline) {
2853
+ const roles = this._rolesForInstance(instanceId);
2854
+ const hasServer = !opts.server || roles.includes("server");
2855
+ const hasClients = opts.clientCount === void 0 || this._clientRolesForInstance(instanceId).length >= opts.clientCount;
2856
+ const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
2857
+ const runtimeAbsent = !opts.noRuntime || !roles.some((role) => role === "server" || /^client-\d+$/.test(role));
2858
+ if (hasServer && hasClients && absent && runtimeAbsent) {
2859
+ return { ok: true, roles, timedOut: false };
2860
+ }
2861
+ await sleep(250);
2862
+ }
2863
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
2864
+ }
2865
+ async _waitForExactClientCount(instanceId, expectedClientCount, timeoutSec = 30, stableMs = 3e3) {
2866
+ const deadline = Date.now() + timeoutSec * 1e3;
2867
+ let exactSince;
2868
+ while (Date.now() < deadline) {
2869
+ const roles2 = this._rolesForInstance(instanceId);
2870
+ const clientCount2 = this._clientRolesForInstance(instanceId).length;
2871
+ if (clientCount2 > expectedClientCount) {
2872
+ return { ok: false, roles: roles2, timedOut: false, extraClients: true, clientCount: clientCount2 };
2873
+ }
2874
+ if (roles2.includes("server") && clientCount2 === expectedClientCount) {
2875
+ exactSince ??= Date.now();
2876
+ if (Date.now() - exactSince >= stableMs) {
2877
+ return { ok: true, roles: roles2, timedOut: false, extraClients: false, clientCount: clientCount2 };
2878
+ }
2879
+ } else {
2880
+ exactSince = void 0;
2881
+ }
2882
+ await sleep(250);
2883
+ }
2884
+ const roles = this._rolesForInstance(instanceId);
2885
+ const clientCount = this._clientRolesForInstance(instanceId).length;
2886
+ return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
2887
+ }
2888
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
2889
+ const deadline = Date.now() + timeoutSec * 1e3;
2890
+ while (Date.now() < deadline) {
2891
+ const instances = this.bridge.getInstances().filter((i) => i.instanceId === instanceId);
2892
+ const roles = instances.map((i) => i.role);
2893
+ const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
2894
+ if (requiredRoles.every((role) => freshRoles.has(role))) {
2895
+ return { ok: true, roles, timedOut: false };
2896
+ }
2897
+ await sleep(250);
2898
+ }
2899
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
2900
+ }
2777
2901
  async getFileTree(path2 = "", instance_id) {
2778
2902
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
2779
2903
  return {
@@ -3418,16 +3542,41 @@ ${code}`
3418
3542
  if (mode !== "play" && mode !== "run") {
3419
3543
  throw new Error('mode must be "play" or "run"');
3420
3544
  }
3421
- const data = { mode };
3422
3545
  if (numPlayers !== void 0) {
3423
- data.numPlayers = numPlayers;
3546
+ throw new Error("start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.");
3547
+ }
3548
+ const data = { mode };
3549
+ const startedAt = Date.now();
3550
+ const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
3551
+ if (!resolved.ok)
3552
+ throw new RoutingFailure(resolved.error);
3553
+ if (resolved.mode !== "single") {
3554
+ throw new RoutingFailure({
3555
+ code: "target_role_not_present_on_instance",
3556
+ message: "This tool does not support target=all. Pick a specific role or omit target.",
3557
+ data: {
3558
+ instances: this.bridge.getPublicInstances(),
3559
+ count: this.bridge.getInstances().length
3560
+ }
3561
+ });
3424
3562
  }
3425
- const response = await this._callSingle("/api/start-playtest", data, void 0, instance_id);
3563
+ const response = await this.client.request("/api/start-playtest", data, resolved.targetInstanceId, resolved.targetRole);
3564
+ let wait;
3565
+ if (response?.success === true) {
3566
+ const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
3567
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
3568
+ }
3569
+ const body = wait ? {
3570
+ ...response,
3571
+ runtimeReady: wait.ok,
3572
+ timedOut: wait.timedOut,
3573
+ roles: wait.roles
3574
+ } : response;
3426
3575
  return {
3427
3576
  content: [
3428
3577
  {
3429
3578
  type: "text",
3430
- text: JSON.stringify(response)
3579
+ text: JSON.stringify(body)
3431
3580
  }
3432
3581
  ]
3433
3582
  };
@@ -3449,6 +3598,196 @@ ${code}`
3449
3598
  ]
3450
3599
  };
3451
3600
  }
3601
+ async _buildMultiplayerState(instanceId) {
3602
+ const peers = this.bridge.getPublicInstances().filter((i) => i.instanceId === instanceId).sort((a, b) => a.role.localeCompare(b.role));
3603
+ const body = {
3604
+ instanceId,
3605
+ peers,
3606
+ peerCount: peers.length
3607
+ };
3608
+ const edit = peers.find((p) => p.role === "edit");
3609
+ const server = peers.find((p) => p.role === "server");
3610
+ let editState;
3611
+ let serverState;
3612
+ if (edit) {
3613
+ try {
3614
+ editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3615
+ body.edit = editState;
3616
+ } catch (err) {
3617
+ body.edit = { error: err instanceof Error ? err.message : String(err) };
3618
+ }
3619
+ }
3620
+ if (server) {
3621
+ try {
3622
+ serverState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "server");
3623
+ body.server = serverState;
3624
+ } catch (err) {
3625
+ body.server = { error: err instanceof Error ? err.message : String(err) };
3626
+ }
3627
+ }
3628
+ const session = editState?.session;
3629
+ const rawPhase = typeof session?.phase === "string" ? session.phase : void 0;
3630
+ const hasRuntime = peers.some((p) => p.role === "server" || p.role.startsWith("client-"));
3631
+ body.phase = rawPhase === "starting" && hasRuntime ? "running" : rawPhase ?? (hasRuntime ? "running" : "idle");
3632
+ body.testId = session?.testId;
3633
+ body.numPlayers = session?.numPlayers;
3634
+ body.testArgs = session?.testArgs ?? serverState?.testArgs;
3635
+ body.result = session?.result;
3636
+ body.error = session?.error;
3637
+ body.players = serverState?.players ?? [];
3638
+ body.playerCount = serverState?.playerCount ?? 0;
3639
+ body.clientRoles = this._clientRolesForInstance(instanceId);
3640
+ return body;
3641
+ }
3642
+ async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
3643
+ const deadline = Date.now() + timeoutSec * 1e3;
3644
+ while (Date.now() < deadline) {
3645
+ if (!this._rolesForInstance(instanceId).includes("edit"))
3646
+ return false;
3647
+ try {
3648
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3649
+ const phase = editState?.session?.phase;
3650
+ if (phase === "completed" || phase === "failed")
3651
+ return true;
3652
+ } catch {
3653
+ }
3654
+ await sleep(250);
3655
+ }
3656
+ return false;
3657
+ }
3658
+ async _isMultiplayerTestRunning(instanceId) {
3659
+ if (!this._rolesForInstance(instanceId).includes("edit"))
3660
+ return false;
3661
+ try {
3662
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3663
+ const phase = editState?.session?.phase;
3664
+ return phase === "starting" || phase === "running";
3665
+ } catch {
3666
+ return false;
3667
+ }
3668
+ }
3669
+ async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
3670
+ const deadline = Date.now() + timeoutSec * 1e3;
3671
+ while (Date.now() < deadline) {
3672
+ const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
3673
+ if (exact.ok || exact.extraClients) {
3674
+ return { ok: exact.ok, roles: exact.roles, timedOut: false, error: exact.extraClients ? `Expected ${clientCount} client(s), but Studio registered ${exact.clientCount}.` : void 0 };
3675
+ }
3676
+ try {
3677
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3678
+ const session = editState?.session;
3679
+ if (session?.phase === "failed" || session?.phase === "completed") {
3680
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: false, phase: session.phase, error: session.error };
3681
+ }
3682
+ } catch {
3683
+ }
3684
+ await sleep(250);
3685
+ }
3686
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3687
+ }
3688
+ async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
3689
+ if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
3690
+ throw new Error("numPlayers must be an integer from 1 to 8");
3691
+ }
3692
+ const editTarget = this._resolveSingleTarget("edit", instance_id);
3693
+ const response = await this.client.request("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.instanceId, editTarget.role);
3694
+ if (response?.error) {
3695
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3696
+ }
3697
+ const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 30);
3698
+ const state = await this._buildMultiplayerState(editTarget.instanceId);
3699
+ return {
3700
+ content: [{
3701
+ type: "text",
3702
+ text: JSON.stringify({
3703
+ ...response,
3704
+ ready: wait.ok,
3705
+ timedOut: wait.timedOut,
3706
+ wait,
3707
+ roles: wait.roles,
3708
+ state
3709
+ })
3710
+ }]
3711
+ };
3712
+ }
3713
+ async multiplayerTestState(instance_id) {
3714
+ const instanceId = this._resolveInstanceIdOnly(instance_id);
3715
+ const state = await this._buildMultiplayerState(instanceId);
3716
+ return { content: [{ type: "text", text: JSON.stringify(state) }] };
3717
+ }
3718
+ async multiplayerTestAddPlayers(numPlayers, timeout, instance_id) {
3719
+ if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
3720
+ throw new Error("numPlayers must be an integer from 1 to 8");
3721
+ }
3722
+ const serverTarget = this._resolveSingleTarget("server", instance_id);
3723
+ const before = this._clientRolesForInstance(serverTarget.instanceId).length;
3724
+ const response = await this.client.request("/api/multiplayer-test-add-players", { numPlayers, timeout: timeout ?? 10 }, serverTarget.instanceId, serverTarget.role);
3725
+ if (response?.error) {
3726
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3727
+ }
3728
+ const wait = await this._waitForExactClientCount(serverTarget.instanceId, before + numPlayers, timeout ?? 30);
3729
+ const state = await this._buildMultiplayerState(serverTarget.instanceId);
3730
+ return {
3731
+ content: [{
3732
+ type: "text",
3733
+ text: JSON.stringify({
3734
+ ...response,
3735
+ ready: wait.ok,
3736
+ timedOut: wait.timedOut,
3737
+ wait,
3738
+ roles: wait.roles,
3739
+ state
3740
+ })
3741
+ }]
3742
+ };
3743
+ }
3744
+ async multiplayerTestLeaveClient(target = "client-1", timeout, instance_id) {
3745
+ if (!/^client-\d+$/.test(target)) {
3746
+ throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
3747
+ }
3748
+ const clientTarget = this._resolveSingleTarget(target, instance_id);
3749
+ const response = await this.client.request("/api/multiplayer-test-leave-client", {}, clientTarget.instanceId, clientTarget.role);
3750
+ if (response?.error) {
3751
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3752
+ }
3753
+ const wait = await this._waitForRuntimeRoles(clientTarget.instanceId, { absentRole: clientTarget.role }, timeout ?? 30);
3754
+ const state = await this._buildMultiplayerState(clientTarget.instanceId);
3755
+ return {
3756
+ content: [{
3757
+ type: "text",
3758
+ text: JSON.stringify({
3759
+ ...response,
3760
+ left: wait.ok,
3761
+ timedOut: wait.timedOut,
3762
+ roles: wait.roles,
3763
+ state
3764
+ })
3765
+ }]
3766
+ };
3767
+ }
3768
+ async multiplayerTestEnd(value, timeout, instance_id) {
3769
+ const serverTarget = this._resolveSingleTarget("server", instance_id);
3770
+ const response = await this.client.request("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.instanceId, serverTarget.role);
3771
+ if (response?.error) {
3772
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3773
+ }
3774
+ const editDone = await this._waitForMultiplayerEditDone(serverTarget.instanceId, timeout ?? 30);
3775
+ const wait = await this._waitForRuntimeRoles(serverTarget.instanceId, { noRuntime: true }, timeout ?? 30);
3776
+ const state = await this._buildMultiplayerState(serverTarget.instanceId);
3777
+ return {
3778
+ content: [{
3779
+ type: "text",
3780
+ text: JSON.stringify({
3781
+ ...response,
3782
+ ended: wait.ok,
3783
+ editDone,
3784
+ timedOut: wait.timedOut,
3785
+ roles: wait.roles,
3786
+ state
3787
+ })
3788
+ }]
3789
+ };
3790
+ }
3452
3791
  async getConnectedInstances() {
3453
3792
  const instances = this.bridge.getPublicInstances();
3454
3793
  return {
@@ -3484,15 +3823,15 @@ ${code}`
3484
3823
  }
3485
3824
  static findProjectRoot(startDir) {
3486
3825
  let dir = path.resolve(startDir);
3487
- while (true) {
3826
+ let previous = "";
3827
+ while (dir !== previous) {
3488
3828
  if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
3489
3829
  return dir;
3490
3830
  }
3491
- const parent = path.dirname(dir);
3492
- if (parent === dir)
3493
- return null;
3494
- dir = parent;
3831
+ previous = dir;
3832
+ dir = path.dirname(dir);
3495
3833
  }
3834
+ return null;
3496
3835
  }
3497
3836
  static isDirectory(candidate) {
3498
3837
  if (!candidate)
@@ -4302,6 +4641,39 @@ ${code}`
4302
4641
  }
4303
4642
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
4304
4643
  }
4644
+ async getSceneAnalysis(mode, target, topN, raw, instance_id) {
4645
+ const tgt = target ?? "all";
4646
+ const data = {};
4647
+ if (mode !== void 0)
4648
+ data.mode = mode;
4649
+ if (topN !== void 0)
4650
+ data.topN = topN;
4651
+ if (raw !== void 0)
4652
+ data.raw = raw;
4653
+ const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
4654
+ if (!resolved.ok)
4655
+ throw new RoutingFailure(resolved.error);
4656
+ if (resolved.mode === "single") {
4657
+ const response = await this.client.request("/api/get-scene-analysis", data, resolved.targetInstanceId, resolved.targetRole);
4658
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
4659
+ }
4660
+ const targets = resolved.targets.filter((t) => t.targetRole !== "edit-proxy");
4661
+ const responses = await Promise.allSettled(targets.map(async (t) => ({
4662
+ peer: t.targetRole,
4663
+ result: await this.client.request("/api/get-scene-analysis", data, t.targetInstanceId, t.targetRole)
4664
+ })));
4665
+ const body = {};
4666
+ for (let i = 0; i < responses.length; i++) {
4667
+ const r = responses[i];
4668
+ const peer = targets[i].targetRole;
4669
+ if (r.status === "fulfilled") {
4670
+ body[peer] = r.value.result;
4671
+ } else {
4672
+ body[peer] = { error: "disconnected" };
4673
+ }
4674
+ }
4675
+ return { content: [{ type: "text", text: JSON.stringify(body) }] };
4676
+ }
4305
4677
  async exportRbxm(instancePaths, outputPath, target, instance_id) {
4306
4678
  if (!Array.isArray(instancePaths) || instancePaths.length === 0) {
4307
4679
  throw new Error("instance_paths must be a non-empty array for export_rbxm");
@@ -4425,10 +4797,14 @@ ${code}`
4425
4797
  response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
4426
4798
  }
4427
4799
  if (response.error) {
4800
+ let text = response.error;
4801
+ if (clientRole && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
4802
+ text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular start_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
4803
+ }
4428
4804
  return {
4429
4805
  content: [{
4430
4806
  type: "text",
4431
- text: response.error
4807
+ text
4432
4808
  }]
4433
4809
  };
4434
4810
  }
@@ -4440,7 +4816,9 @@ ${code}`
4440
4816
  const fmt = format === "png" ? "png" : "jpeg";
4441
4817
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
4442
4818
  const MAX_IMAGE_BYTES = 6e6;
4443
- let { buffer, mimeType } = encodeImageFromRgbaResponse(response, fmt, q);
4819
+ const encoded = encodeImageFromRgbaResponse(response, fmt, q);
4820
+ let { buffer } = encoded;
4821
+ const { mimeType } = encoded;
4444
4822
  let usedQ = q;
4445
4823
  let note = "";
4446
4824
  if (buffer.length > MAX_IMAGE_BYTES) {
@@ -5607,7 +5985,7 @@ var init_definitions = __esm({
5607
5985
  {
5608
5986
  name: "execute_luau",
5609
5987
  category: "write",
5610
- description: "Execute Luau code in plugin context. Use print()/warn() for output. Return value is captured.",
5988
+ description: 'Execute Luau code in plugin context. target="server" and target="client-N" run against live runtime DataModels with PluginSecurity permissions; use eval_*_runtime instead when you need the game Script/LocalScript VM require cache. Use print()/warn() for output. Return value is captured.',
5611
5989
  inputSchema: {
5612
5990
  type: "object",
5613
5991
  properties: {
@@ -5630,7 +6008,7 @@ var init_definitions = __esm({
5630
6008
  {
5631
6009
  name: "eval_server_runtime",
5632
6010
  category: "write",
5633
- description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts). Use this instead of execute_luau target=server when you need to see runtime-mutated module state. Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
6011
+ description: "Execute Luau on the server peer in the running game's Script VM (shares require cache with user game scripts, unlike execute_luau target=server which runs in plugin context). Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
5634
6012
  inputSchema: {
5635
6013
  type: "object",
5636
6014
  properties: {
@@ -5649,7 +6027,7 @@ var init_definitions = __esm({
5649
6027
  {
5650
6028
  name: "eval_client_runtime",
5651
6029
  category: "write",
5652
- description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts). Use this instead of execute_luau target=client-N when you need to see runtime-mutated module state. Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
6030
+ description: "Execute Luau on a client peer in the running game's LocalScript VM (shares require cache with user game scripts, unlike execute_luau target=client-N which runs in plugin context). Requires a running playtest; the bridge is installed automatically (including for playtests started manually via the Studio Play button).",
5653
6031
  inputSchema: {
5654
6032
  type: "object",
5655
6033
  properties: {
@@ -5726,7 +6104,7 @@ var init_definitions = __esm({
5726
6104
  {
5727
6105
  name: "start_playtest",
5728
6106
  category: "write",
5729
- description: "Start playtest. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. Use numPlayers for multi-client testing (server + N clients).",
6107
+ description: "Start a simple single-player Studio playtest in play or run mode, waiting until a runtime peer registers with MCP. Captures print/warn/error via LogService. Poll with get_playtest_output, end with stop_playtest. For multi-client testing use multiplayer_test_start instead.",
5730
6108
  inputSchema: {
5731
6109
  type: "object",
5732
6110
  properties: {
@@ -5737,7 +6115,7 @@ var init_definitions = __esm({
5737
6115
  },
5738
6116
  numPlayers: {
5739
6117
  type: "number",
5740
- description: "Number of client players (1-8). Triggers server + clients mode via TestService."
6118
+ description: "Deprecated and rejected. Use multiplayer_test_start for multi-client testing."
5741
6119
  },
5742
6120
  instance_id: {
5743
6121
  type: "string",
@@ -5779,6 +6157,112 @@ var init_definitions = __esm({
5779
6157
  }
5780
6158
  }
5781
6159
  },
6160
+ {
6161
+ name: "multiplayer_test_start",
6162
+ category: "write",
6163
+ description: "Start a StudioTestService multiplayer test and wait for the server plus requested client peers to connect. Use this for multi-client runtime testing.",
6164
+ inputSchema: {
6165
+ type: "object",
6166
+ properties: {
6167
+ numPlayers: {
6168
+ type: "number",
6169
+ description: "Number of client players to start (1-8)."
6170
+ },
6171
+ testArgs: {
6172
+ description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
6173
+ },
6174
+ timeout: {
6175
+ type: "number",
6176
+ description: "Max seconds to wait for server + clients to register (default 30)."
6177
+ },
6178
+ instance_id: {
6179
+ type: "string",
6180
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6181
+ }
6182
+ },
6183
+ required: ["numPlayers"]
6184
+ }
6185
+ },
6186
+ {
6187
+ name: "multiplayer_test_state",
6188
+ category: "read",
6189
+ description: "Get the active multiplayer StudioTestService state for a place: phase, peers, players, original testArgs, result/error, and connected client roles.",
6190
+ inputSchema: {
6191
+ type: "object",
6192
+ properties: {
6193
+ instance_id: {
6194
+ type: "string",
6195
+ description: "Which connected Studio place to inspect. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6196
+ }
6197
+ }
6198
+ }
6199
+ },
6200
+ {
6201
+ name: "multiplayer_test_add_players",
6202
+ category: "write",
6203
+ description: "Add client players to a running StudioTestService multiplayer test and wait for the new clients to connect.",
6204
+ inputSchema: {
6205
+ type: "object",
6206
+ properties: {
6207
+ numPlayers: {
6208
+ type: "number",
6209
+ description: "Number of additional client players to add (1-8)."
6210
+ },
6211
+ timeout: {
6212
+ type: "number",
6213
+ description: "Max seconds to wait for new clients to register (default 30)."
6214
+ },
6215
+ instance_id: {
6216
+ type: "string",
6217
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6218
+ }
6219
+ },
6220
+ required: ["numPlayers"]
6221
+ }
6222
+ },
6223
+ {
6224
+ name: "multiplayer_test_leave_client",
6225
+ category: "write",
6226
+ description: "Disconnect a specific client from a running StudioTestService multiplayer test, then wait for that client peer to leave.",
6227
+ inputSchema: {
6228
+ type: "object",
6229
+ properties: {
6230
+ target: {
6231
+ type: "string",
6232
+ description: 'Client target to leave: "client-1" (default), "client-2", etc.'
6233
+ },
6234
+ timeout: {
6235
+ type: "number",
6236
+ description: "Max seconds to wait for the client peer to disconnect (default 30)."
6237
+ },
6238
+ instance_id: {
6239
+ type: "string",
6240
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6241
+ }
6242
+ }
6243
+ }
6244
+ },
6245
+ {
6246
+ name: "multiplayer_test_end",
6247
+ category: "write",
6248
+ description: "End a running StudioTestService multiplayer test with an optional return value, then wait for all runtime peers to disconnect.",
6249
+ inputSchema: {
6250
+ type: "object",
6251
+ properties: {
6252
+ value: {
6253
+ description: "JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call."
6254
+ },
6255
+ timeout: {
6256
+ type: "number",
6257
+ description: "Max seconds to wait for runtime peers to disconnect (default 30)."
6258
+ },
6259
+ instance_id: {
6260
+ type: "string",
6261
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6262
+ }
6263
+ }
6264
+ }
6265
+ },
5782
6266
  {
5783
6267
  name: "get_runtime_logs",
5784
6268
  category: "read",
@@ -6323,7 +6807,7 @@ part(0,2,0,2,1,1,"b")`,
6323
6807
  {
6324
6808
  name: "capture_screenshot",
6325
6809
  category: "read",
6326
- description: 'Capture the Roblox Studio viewport at native resolution and return it as an image, plus a text line stating the exact pixel dimensions. Works in Edit mode and during a playtest (auto-detects a running client and captures the live play viewport). The returned image is never downscaled, so its pixel grid is exactly the coordinate space simulate_mouse_input uses \u2014 read click positions straight off this image. For reading fine text/UI, use format="png" (lossless) or a higher quality; enlarging the Studio window raises resolution. Requires EditableImage API enabled (Game Settings > Security > "Allow Mesh / Image APIs") and the window to be visible.',
6810
+ description: 'Capture the Roblox Studio viewport at native resolution and return it as an image, plus a text line stating the exact pixel dimensions. Works in Edit mode and regular playtests (auto-detects a running client and captures the live play viewport). StudioTestService multiplayer client screenshots are currently blocked by Roblox temporary-texture process scoping; the tool returns a clear error in that case. The returned image is never downscaled, so its pixel grid is exactly the coordinate space simulate_mouse_input uses \u2014 read click positions straight off this image. For reading fine text/UI, use format="png" (lossless) or a higher quality; enlarging the Studio window raises resolution. Requires EditableImage API enabled (Game Settings > Security > "Allow Mesh / Image APIs") and the window to be visible.',
6327
6811
  inputSchema: {
6328
6812
  type: "object",
6329
6813
  properties: {
@@ -6598,6 +7082,39 @@ part(0,2,0,2,1,1,"b")`,
6598
7082
  }
6599
7083
  }
6600
7084
  },
7085
+ {
7086
+ name: "get_scene_analysis",
7087
+ category: "read",
7088
+ description: 'Read Roblox SceneAnalysisService data for attribution-focused performance analysis. Complements get_memory_breakdown: returns compact top-N entries for instance composition, script memory, unparented instances, triangle composition, animation memory, and audio memory. Requires the Studio Scene Analysis beta feature; if disabled, returns scene_analysis_not_enabled with betaFeatureRequired=true. target="all" (default) returns per-peer data; single-peer targets return that peer directly. raw=true includes the full nested Scene Analysis tree.',
7089
+ inputSchema: {
7090
+ type: "object",
7091
+ properties: {
7092
+ mode: {
7093
+ type: "string",
7094
+ enum: ["all", "instance_composition", "script_memory", "unparented_instances", "triangle_composition", "animation_memory", "audio_memory"],
7095
+ description: 'Scene analysis mode to read. Defaults to "all".'
7096
+ },
7097
+ target: {
7098
+ type: "string",
7099
+ description: 'Peer to read from: "edit", "server", "client-N", or "all" (default).'
7100
+ },
7101
+ topN: {
7102
+ type: "number",
7103
+ minimum: 1,
7104
+ maximum: 100,
7105
+ description: "Number of flattened top entries to include per mode. Defaults to 10; plugin clamps to 1-100."
7106
+ },
7107
+ raw: {
7108
+ type: "boolean",
7109
+ description: "Include the full nested SceneAnalysisService tree in each mode result. Defaults to false."
7110
+ },
7111
+ instance_id: {
7112
+ type: "string",
7113
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
7114
+ }
7115
+ }
7116
+ }
7117
+ },
6601
7118
  // === SerializationService round-trip ===
6602
7119
  {
6603
7120
  name: "export_rbxm",
@@ -6781,7 +7298,7 @@ function handleVariantConflict({ pluginsFolder, otherAssetName, replace }) {
6781
7298
  }
6782
7299
  console.warn(`
6783
7300
  [install-plugin] WARNING: ${otherAssetName} is already present in ${pluginsFolder}.
6784
- Both plugins will register with MCP at Studio launch, causing duplicate role registrations and unpredictable routing for stop_playtest and per-peer execute_luau.
7301
+ Only one MCP plugin variant should be present. If both variants are in the Studio Plugins folder, Studio loads both and runtime routing can become unpredictable.
6785
7302
  Re-run with --replace-variant to remove ${otherAssetName}, or delete it manually.
6786
7303
  `);
6787
7304
  }