@chrrxs/robloxstudio-mcp 2.13.0 → 2.14.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),
@@ -2714,6 +2730,9 @@ function parseBridgeResponse(response) {
2714
2730
  }
2715
2731
  return JSON.stringify(response);
2716
2732
  }
2733
+ function sleep(ms) {
2734
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2735
+ }
2717
2736
  var SERVER_LOCAL_NAME, CLIENT_LOCAL_NAME, EVAL_WRAPPER_LINE_OFFSET, RobloxStudioTools;
2718
2737
  var init_tools = __esm({
2719
2738
  "../core/dist/tools/index.js"() {
@@ -2774,6 +2793,97 @@ var init_tools = __esm({
2774
2793
  const clientRoles = roles.filter((role) => role.startsWith("client")).sort();
2775
2794
  return { instanceId: resolvedId, clientRole: clientRoles[0] };
2776
2795
  }
2796
+ _resolveInstanceIdOnly(instance_id) {
2797
+ const instances = this.bridge.getInstances();
2798
+ const publicList = this.bridge.getPublicInstances();
2799
+ const errorData = { instances: publicList, count: publicList.length };
2800
+ if (instance_id !== void 0) {
2801
+ if (!instances.some((i) => i.instanceId === instance_id)) {
2802
+ throw new RoutingFailure({
2803
+ code: "unrecognized_instance_id",
2804
+ message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
2805
+ data: errorData
2806
+ });
2807
+ }
2808
+ return instance_id;
2809
+ }
2810
+ const distinct = Array.from(new Set(instances.map((i) => i.instanceId)));
2811
+ if (distinct.length === 0) {
2812
+ throw new RoutingFailure({
2813
+ code: "unrecognized_instance_id",
2814
+ message: "No Studio plugin is connected.",
2815
+ data: errorData
2816
+ });
2817
+ }
2818
+ if (distinct.length > 1) {
2819
+ throw new RoutingFailure({
2820
+ code: "multiple_instances_connected",
2821
+ message: "Multiple Studio places are connected. Pass instance_id to disambiguate.",
2822
+ data: errorData
2823
+ });
2824
+ }
2825
+ return distinct[0];
2826
+ }
2827
+ _resolveSingleTarget(target, instance_id) {
2828
+ const resolved = this.bridge.resolveTarget({ instance_id, target });
2829
+ if (!resolved.ok)
2830
+ throw new RoutingFailure(resolved.error);
2831
+ if (resolved.mode !== "single") {
2832
+ throw new RoutingFailure({
2833
+ code: "target_role_not_present_on_instance",
2834
+ message: "Pick a specific target role for this tool.",
2835
+ data: {
2836
+ instances: this.bridge.getPublicInstances(),
2837
+ count: this.bridge.getInstances().length
2838
+ }
2839
+ });
2840
+ }
2841
+ return { instanceId: resolved.targetInstanceId, role: resolved.targetRole };
2842
+ }
2843
+ _rolesForInstance(instanceId) {
2844
+ return this.bridge.getInstances().filter((i) => i.instanceId === instanceId).map((i) => i.role);
2845
+ }
2846
+ _clientRolesForInstance(instanceId) {
2847
+ return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
2848
+ }
2849
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
2850
+ const deadline = Date.now() + timeoutSec * 1e3;
2851
+ while (Date.now() < deadline) {
2852
+ const roles = this._rolesForInstance(instanceId);
2853
+ const hasServer = !opts.server || roles.includes("server");
2854
+ const hasClients = opts.clientCount === void 0 || this._clientRolesForInstance(instanceId).length >= opts.clientCount;
2855
+ const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
2856
+ const runtimeAbsent = !opts.noRuntime || !roles.some((role) => role === "server" || /^client-\d+$/.test(role));
2857
+ if (hasServer && hasClients && absent && runtimeAbsent) {
2858
+ return { ok: true, roles, timedOut: false };
2859
+ }
2860
+ await sleep(250);
2861
+ }
2862
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
2863
+ }
2864
+ async _waitForExactClientCount(instanceId, expectedClientCount, timeoutSec = 30, stableMs = 3e3) {
2865
+ const deadline = Date.now() + timeoutSec * 1e3;
2866
+ let exactSince;
2867
+ while (Date.now() < deadline) {
2868
+ const roles2 = this._rolesForInstance(instanceId);
2869
+ const clientCount2 = this._clientRolesForInstance(instanceId).length;
2870
+ if (clientCount2 > expectedClientCount) {
2871
+ return { ok: false, roles: roles2, timedOut: false, extraClients: true, clientCount: clientCount2 };
2872
+ }
2873
+ if (roles2.includes("server") && clientCount2 === expectedClientCount) {
2874
+ exactSince ??= Date.now();
2875
+ if (Date.now() - exactSince >= stableMs) {
2876
+ return { ok: true, roles: roles2, timedOut: false, extraClients: false, clientCount: clientCount2 };
2877
+ }
2878
+ } else {
2879
+ exactSince = void 0;
2880
+ }
2881
+ await sleep(250);
2882
+ }
2883
+ const roles = this._rolesForInstance(instanceId);
2884
+ const clientCount = this._clientRolesForInstance(instanceId).length;
2885
+ return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
2886
+ }
2777
2887
  async getFileTree(path2 = "", instance_id) {
2778
2888
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
2779
2889
  return {
@@ -3418,10 +3528,10 @@ ${code}`
3418
3528
  if (mode !== "play" && mode !== "run") {
3419
3529
  throw new Error('mode must be "play" or "run"');
3420
3530
  }
3421
- const data = { mode };
3422
3531
  if (numPlayers !== void 0) {
3423
- data.numPlayers = numPlayers;
3532
+ throw new Error("start_playtest is single-player only. Use multiplayer_test_start for multi-client StudioTestService sessions.");
3424
3533
  }
3534
+ const data = { mode };
3425
3535
  const response = await this._callSingle("/api/start-playtest", data, void 0, instance_id);
3426
3536
  return {
3427
3537
  content: [
@@ -3449,6 +3559,196 @@ ${code}`
3449
3559
  ]
3450
3560
  };
3451
3561
  }
3562
+ async _buildMultiplayerState(instanceId) {
3563
+ const peers = this.bridge.getPublicInstances().filter((i) => i.instanceId === instanceId).sort((a, b) => a.role.localeCompare(b.role));
3564
+ const body = {
3565
+ instanceId,
3566
+ peers,
3567
+ peerCount: peers.length
3568
+ };
3569
+ const edit = peers.find((p) => p.role === "edit");
3570
+ const server = peers.find((p) => p.role === "server");
3571
+ let editState;
3572
+ let serverState;
3573
+ if (edit) {
3574
+ try {
3575
+ editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3576
+ body.edit = editState;
3577
+ } catch (err) {
3578
+ body.edit = { error: err instanceof Error ? err.message : String(err) };
3579
+ }
3580
+ }
3581
+ if (server) {
3582
+ try {
3583
+ serverState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "server");
3584
+ body.server = serverState;
3585
+ } catch (err) {
3586
+ body.server = { error: err instanceof Error ? err.message : String(err) };
3587
+ }
3588
+ }
3589
+ const session = editState?.session;
3590
+ const rawPhase = typeof session?.phase === "string" ? session.phase : void 0;
3591
+ const hasRuntime = peers.some((p) => p.role === "server" || p.role.startsWith("client-"));
3592
+ body.phase = rawPhase === "starting" && hasRuntime ? "running" : rawPhase ?? (hasRuntime ? "running" : "idle");
3593
+ body.testId = session?.testId;
3594
+ body.numPlayers = session?.numPlayers;
3595
+ body.testArgs = session?.testArgs ?? serverState?.testArgs;
3596
+ body.result = session?.result;
3597
+ body.error = session?.error;
3598
+ body.players = serverState?.players ?? [];
3599
+ body.playerCount = serverState?.playerCount ?? 0;
3600
+ body.clientRoles = this._clientRolesForInstance(instanceId);
3601
+ return body;
3602
+ }
3603
+ async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
3604
+ const deadline = Date.now() + timeoutSec * 1e3;
3605
+ while (Date.now() < deadline) {
3606
+ if (!this._rolesForInstance(instanceId).includes("edit"))
3607
+ return false;
3608
+ try {
3609
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3610
+ const phase = editState?.session?.phase;
3611
+ if (phase === "completed" || phase === "failed")
3612
+ return true;
3613
+ } catch {
3614
+ }
3615
+ await sleep(250);
3616
+ }
3617
+ return false;
3618
+ }
3619
+ async _isMultiplayerTestRunning(instanceId) {
3620
+ if (!this._rolesForInstance(instanceId).includes("edit"))
3621
+ return false;
3622
+ try {
3623
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3624
+ const phase = editState?.session?.phase;
3625
+ return phase === "starting" || phase === "running";
3626
+ } catch {
3627
+ return false;
3628
+ }
3629
+ }
3630
+ async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30) {
3631
+ const deadline = Date.now() + timeoutSec * 1e3;
3632
+ while (Date.now() < deadline) {
3633
+ const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
3634
+ if (exact.ok || exact.extraClients) {
3635
+ return { ok: exact.ok, roles: exact.roles, timedOut: false, error: exact.extraClients ? `Expected ${clientCount} client(s), but Studio registered ${exact.clientCount}.` : void 0 };
3636
+ }
3637
+ try {
3638
+ const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
3639
+ const session = editState?.session;
3640
+ if (session?.phase === "failed" || session?.phase === "completed") {
3641
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: false, phase: session.phase, error: session.error };
3642
+ }
3643
+ } catch {
3644
+ }
3645
+ await sleep(250);
3646
+ }
3647
+ return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3648
+ }
3649
+ async multiplayerTestStart(numPlayers, testArgs, timeout, instance_id) {
3650
+ if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
3651
+ throw new Error("numPlayers must be an integer from 1 to 8");
3652
+ }
3653
+ const editTarget = this._resolveSingleTarget("edit", instance_id);
3654
+ const response = await this.client.request("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.instanceId, editTarget.role);
3655
+ if (response?.error) {
3656
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3657
+ }
3658
+ const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 30);
3659
+ const state = await this._buildMultiplayerState(editTarget.instanceId);
3660
+ return {
3661
+ content: [{
3662
+ type: "text",
3663
+ text: JSON.stringify({
3664
+ ...response,
3665
+ ready: wait.ok,
3666
+ timedOut: wait.timedOut,
3667
+ wait,
3668
+ roles: wait.roles,
3669
+ state
3670
+ })
3671
+ }]
3672
+ };
3673
+ }
3674
+ async multiplayerTestState(instance_id) {
3675
+ const instanceId = this._resolveInstanceIdOnly(instance_id);
3676
+ const state = await this._buildMultiplayerState(instanceId);
3677
+ return { content: [{ type: "text", text: JSON.stringify(state) }] };
3678
+ }
3679
+ async multiplayerTestAddPlayers(numPlayers, timeout, instance_id) {
3680
+ if (!Number.isInteger(numPlayers) || numPlayers < 1 || numPlayers > 8) {
3681
+ throw new Error("numPlayers must be an integer from 1 to 8");
3682
+ }
3683
+ const serverTarget = this._resolveSingleTarget("server", instance_id);
3684
+ const before = this._clientRolesForInstance(serverTarget.instanceId).length;
3685
+ const response = await this.client.request("/api/multiplayer-test-add-players", { numPlayers, timeout: timeout ?? 10 }, serverTarget.instanceId, serverTarget.role);
3686
+ if (response?.error) {
3687
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3688
+ }
3689
+ const wait = await this._waitForExactClientCount(serverTarget.instanceId, before + numPlayers, timeout ?? 30);
3690
+ const state = await this._buildMultiplayerState(serverTarget.instanceId);
3691
+ return {
3692
+ content: [{
3693
+ type: "text",
3694
+ text: JSON.stringify({
3695
+ ...response,
3696
+ ready: wait.ok,
3697
+ timedOut: wait.timedOut,
3698
+ wait,
3699
+ roles: wait.roles,
3700
+ state
3701
+ })
3702
+ }]
3703
+ };
3704
+ }
3705
+ async multiplayerTestLeaveClient(target = "client-1", timeout, instance_id) {
3706
+ if (!/^client-\d+$/.test(target)) {
3707
+ throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
3708
+ }
3709
+ const clientTarget = this._resolveSingleTarget(target, instance_id);
3710
+ const response = await this.client.request("/api/multiplayer-test-leave-client", {}, clientTarget.instanceId, clientTarget.role);
3711
+ if (response?.error) {
3712
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3713
+ }
3714
+ const wait = await this._waitForRuntimeRoles(clientTarget.instanceId, { absentRole: clientTarget.role }, timeout ?? 30);
3715
+ const state = await this._buildMultiplayerState(clientTarget.instanceId);
3716
+ return {
3717
+ content: [{
3718
+ type: "text",
3719
+ text: JSON.stringify({
3720
+ ...response,
3721
+ left: wait.ok,
3722
+ timedOut: wait.timedOut,
3723
+ roles: wait.roles,
3724
+ state
3725
+ })
3726
+ }]
3727
+ };
3728
+ }
3729
+ async multiplayerTestEnd(value, timeout, instance_id) {
3730
+ const serverTarget = this._resolveSingleTarget("server", instance_id);
3731
+ const response = await this.client.request("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.instanceId, serverTarget.role);
3732
+ if (response?.error) {
3733
+ return { content: [{ type: "text", text: JSON.stringify(response) }] };
3734
+ }
3735
+ const editDone = await this._waitForMultiplayerEditDone(serverTarget.instanceId, timeout ?? 30);
3736
+ const wait = await this._waitForRuntimeRoles(serverTarget.instanceId, { noRuntime: true }, timeout ?? 30);
3737
+ const state = await this._buildMultiplayerState(serverTarget.instanceId);
3738
+ return {
3739
+ content: [{
3740
+ type: "text",
3741
+ text: JSON.stringify({
3742
+ ...response,
3743
+ ended: wait.ok,
3744
+ editDone,
3745
+ timedOut: wait.timedOut,
3746
+ roles: wait.roles,
3747
+ state
3748
+ })
3749
+ }]
3750
+ };
3751
+ }
3452
3752
  async getConnectedInstances() {
3453
3753
  const instances = this.bridge.getPublicInstances();
3454
3754
  return {
@@ -3484,15 +3784,15 @@ ${code}`
3484
3784
  }
3485
3785
  static findProjectRoot(startDir) {
3486
3786
  let dir = path.resolve(startDir);
3487
- while (true) {
3787
+ let previous = "";
3788
+ while (dir !== previous) {
3488
3789
  if (fs.existsSync(path.join(dir, ".git")) || fs.existsSync(path.join(dir, "package.json"))) {
3489
3790
  return dir;
3490
3791
  }
3491
- const parent = path.dirname(dir);
3492
- if (parent === dir)
3493
- return null;
3494
- dir = parent;
3792
+ previous = dir;
3793
+ dir = path.dirname(dir);
3495
3794
  }
3795
+ return null;
3496
3796
  }
3497
3797
  static isDirectory(candidate) {
3498
3798
  if (!candidate)
@@ -4425,10 +4725,14 @@ ${code}`
4425
4725
  response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
4426
4726
  }
4427
4727
  if (response.error) {
4728
+ let text = response.error;
4729
+ if (clientRole && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
4730
+ 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}`;
4731
+ }
4428
4732
  return {
4429
4733
  content: [{
4430
4734
  type: "text",
4431
- text: response.error
4735
+ text
4432
4736
  }]
4433
4737
  };
4434
4738
  }
@@ -4440,7 +4744,9 @@ ${code}`
4440
4744
  const fmt = format === "png" ? "png" : "jpeg";
4441
4745
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
4442
4746
  const MAX_IMAGE_BYTES = 6e6;
4443
- let { buffer, mimeType } = encodeImageFromRgbaResponse(response, fmt, q);
4747
+ const encoded = encodeImageFromRgbaResponse(response, fmt, q);
4748
+ let { buffer } = encoded;
4749
+ const { mimeType } = encoded;
4444
4750
  let usedQ = q;
4445
4751
  let note = "";
4446
4752
  if (buffer.length > MAX_IMAGE_BYTES) {
@@ -5607,7 +5913,7 @@ var init_definitions = __esm({
5607
5913
  {
5608
5914
  name: "execute_luau",
5609
5915
  category: "write",
5610
- description: "Execute Luau code in plugin context. Use print()/warn() for output. Return value is captured.",
5916
+ 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
5917
  inputSchema: {
5612
5918
  type: "object",
5613
5919
  properties: {
@@ -5630,7 +5936,7 @@ var init_definitions = __esm({
5630
5936
  {
5631
5937
  name: "eval_server_runtime",
5632
5938
  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).",
5939
+ 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
5940
  inputSchema: {
5635
5941
  type: "object",
5636
5942
  properties: {
@@ -5649,7 +5955,7 @@ var init_definitions = __esm({
5649
5955
  {
5650
5956
  name: "eval_client_runtime",
5651
5957
  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).",
5958
+ 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
5959
  inputSchema: {
5654
5960
  type: "object",
5655
5961
  properties: {
@@ -5726,7 +6032,7 @@ var init_definitions = __esm({
5726
6032
  {
5727
6033
  name: "start_playtest",
5728
6034
  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).",
6035
+ description: "Start a simple single-player Studio playtest in play or run mode. 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
6036
  inputSchema: {
5731
6037
  type: "object",
5732
6038
  properties: {
@@ -5737,7 +6043,7 @@ var init_definitions = __esm({
5737
6043
  },
5738
6044
  numPlayers: {
5739
6045
  type: "number",
5740
- description: "Number of client players (1-8). Triggers server + clients mode via TestService."
6046
+ description: "Deprecated and rejected. Use multiplayer_test_start for multi-client testing."
5741
6047
  },
5742
6048
  instance_id: {
5743
6049
  type: "string",
@@ -5779,6 +6085,112 @@ var init_definitions = __esm({
5779
6085
  }
5780
6086
  }
5781
6087
  },
6088
+ {
6089
+ name: "multiplayer_test_start",
6090
+ category: "write",
6091
+ description: "Start a StudioTestService multiplayer test and wait for the server plus requested client peers to connect. Use this for multi-client runtime testing.",
6092
+ inputSchema: {
6093
+ type: "object",
6094
+ properties: {
6095
+ numPlayers: {
6096
+ type: "number",
6097
+ description: "Number of client players to start (1-8)."
6098
+ },
6099
+ testArgs: {
6100
+ description: "JSON-compatible table passed to StudioTestService:GetTestArgs() on server and clients."
6101
+ },
6102
+ timeout: {
6103
+ type: "number",
6104
+ description: "Max seconds to wait for server + clients to register (default 30)."
6105
+ },
6106
+ instance_id: {
6107
+ type: "string",
6108
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6109
+ }
6110
+ },
6111
+ required: ["numPlayers"]
6112
+ }
6113
+ },
6114
+ {
6115
+ name: "multiplayer_test_state",
6116
+ category: "read",
6117
+ description: "Get the active multiplayer StudioTestService state for a place: phase, peers, players, original testArgs, result/error, and connected client roles.",
6118
+ inputSchema: {
6119
+ type: "object",
6120
+ properties: {
6121
+ instance_id: {
6122
+ type: "string",
6123
+ description: "Which connected Studio place to inspect. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6124
+ }
6125
+ }
6126
+ }
6127
+ },
6128
+ {
6129
+ name: "multiplayer_test_add_players",
6130
+ category: "write",
6131
+ description: "Add client players to a running StudioTestService multiplayer test and wait for the new clients to connect.",
6132
+ inputSchema: {
6133
+ type: "object",
6134
+ properties: {
6135
+ numPlayers: {
6136
+ type: "number",
6137
+ description: "Number of additional client players to add (1-8)."
6138
+ },
6139
+ timeout: {
6140
+ type: "number",
6141
+ description: "Max seconds to wait for new clients to register (default 30)."
6142
+ },
6143
+ instance_id: {
6144
+ type: "string",
6145
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6146
+ }
6147
+ },
6148
+ required: ["numPlayers"]
6149
+ }
6150
+ },
6151
+ {
6152
+ name: "multiplayer_test_leave_client",
6153
+ category: "write",
6154
+ description: "Disconnect a specific client from a running StudioTestService multiplayer test, then wait for that client peer to leave.",
6155
+ inputSchema: {
6156
+ type: "object",
6157
+ properties: {
6158
+ target: {
6159
+ type: "string",
6160
+ description: 'Client target to leave: "client-1" (default), "client-2", etc.'
6161
+ },
6162
+ timeout: {
6163
+ type: "number",
6164
+ description: "Max seconds to wait for the client peer to disconnect (default 30)."
6165
+ },
6166
+ instance_id: {
6167
+ type: "string",
6168
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6169
+ }
6170
+ }
6171
+ }
6172
+ },
6173
+ {
6174
+ name: "multiplayer_test_end",
6175
+ category: "write",
6176
+ description: "End a running StudioTestService multiplayer test with an optional return value, then wait for all runtime peers to disconnect.",
6177
+ inputSchema: {
6178
+ type: "object",
6179
+ properties: {
6180
+ value: {
6181
+ description: "JSON-compatible value returned to the edit-side ExecuteMultiplayerTestAsync call."
6182
+ },
6183
+ timeout: {
6184
+ type: "number",
6185
+ description: "Max seconds to wait for runtime peers to disconnect (default 30)."
6186
+ },
6187
+ instance_id: {
6188
+ type: "string",
6189
+ description: "Which connected Studio place to target. Required when multiple places are connected; omit when one. Use get_connected_instances to list available IDs."
6190
+ }
6191
+ }
6192
+ }
6193
+ },
5782
6194
  {
5783
6195
  name: "get_runtime_logs",
5784
6196
  category: "read",
@@ -6323,7 +6735,7 @@ part(0,2,0,2,1,1,"b")`,
6323
6735
  {
6324
6736
  name: "capture_screenshot",
6325
6737
  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.',
6738
+ 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
6739
  inputSchema: {
6328
6740
  type: "object",
6329
6741
  properties: {
@@ -6781,7 +7193,7 @@ function handleVariantConflict({ pluginsFolder, otherAssetName, replace }) {
6781
7193
  }
6782
7194
  console.warn(`
6783
7195
  [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.
7196
+ 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
7197
  Re-run with --replace-variant to remove ${otherAssetName}, or delete it manually.
6786
7198
  `);
6787
7199
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "2.13.0",
4
- "description": "MCP Server for Roblox Studio Integration (fork of boshyxd/robloxstudio-mcp with per-peer execute_luau + stop_playtest fixes)",
3
+ "version": "2.14.0",
4
+ "description": "MCP server for testing, debugging, and controlling Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "bin": {
@@ -21,6 +21,7 @@ Complete your AI assistant integration with this easy-to-install Studio plugin.
21
21
  - **Windows**: Save to `%LOCALAPPDATA%/Roblox/Plugins/`
22
22
  - **macOS**: Save to `~/Documents/Roblox/Plugins/`
23
23
  - **Or use Studio**: Plugins tab > Plugins Folder > drop the file
24
+ - Keep only one MCP variant in this folder. Remove `MCPInspectorPlugin.rbxmx` if installing `MCPPlugin.rbxmx`, and remove `MCPPlugin.rbxmx` if installing the inspector variant.
24
25
 
25
26
  3. **Restart Roblox Studio** - Plugin appears automatically!
26
27