@chrrxs/robloxstudio-mcp 3.2.0 → 3.2.1

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
@@ -3296,7 +3296,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
3296
3296
  ## Connection and paths
3297
3297
 
3298
3298
  - Use canonical DataModel paths returned by the tools. Paths usually start with game.
3299
- - Call get_connected_instances when more than one Studio process may be connected. Pass either a top-level Instance ID or a multiplayer group's role-suffixed Instance ID as instance_id on later calls.
3299
+ - Call get_connected_instances when more than one Studio process may be connected. Pass either a top-level Instance ID or a multiplayer group's role-suffixed Instance ID as instance_id on later calls. A role-suffixed ID selects that server or client as the default target; a conflicting single-peer target is rejected.
3300
3300
  - Use get_place_info for the active place identity and settings.
3301
3301
 
3302
3302
  ## Discovery and edit work
@@ -3879,6 +3879,9 @@ var ManagedInstanceRegistry = class {
3879
3879
  if (!options.observeProcess || !(parsed.nativeProcessId || parsed.spawnPid))
3880
3880
  continue;
3881
3881
  const observation = await options.observeProcess(parsed);
3882
+ const previousObservationAt = parsed.lastProcessObservationAt;
3883
+ if (previousObservationAt !== void 0 && observation.observedAt < previousObservationAt)
3884
+ continue;
3882
3885
  if (observation.status === "unknown") {
3883
3886
  parsed.processObservationStatus = "unknown";
3884
3887
  parsed.lastProcessObservationAt = observation.observedAt;
@@ -3888,7 +3891,6 @@ var ManagedInstanceRegistry = class {
3888
3891
  await this.writeRecordUnlocked(parsed);
3889
3892
  continue;
3890
3893
  }
3891
- const previousObservationAt = parsed.lastProcessObservationAt;
3892
3894
  parsed.lastSuccessfulProcessObservationAt = observation.observedAt;
3893
3895
  parsed.lastProcessObservationAt = observation.observedAt;
3894
3896
  parsed.lastProcessObservationError = void 0;
@@ -5507,10 +5509,10 @@ var WINDOWS_STUDIO_PROCESS_QUERY = [
5507
5509
  function parseWindowsStudioProcesses(output) {
5508
5510
  const parsed = JSON.parse(output);
5509
5511
  const processes = Array.isArray(parsed) ? parsed : [parsed];
5510
- if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && typeof value.Path === "string" && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
5512
+ if (!processes.every((value) => value !== null && typeof value === "object" && "Id" in value && typeof value.Id === "number" && Number.isSafeInteger(value.Id) && value.Id > 0 && "Name" in value && typeof value.Name === "string" && "Path" in value && (typeof value.Path === "string" || value.Path === null) && "MainWindowTitle" in value && typeof value.MainWindowTitle === "string" && "StartTimeUtcFileTime" in value && typeof value.StartTimeUtcFileTime === "string" && /^[1-9]\d*$/u.test(value.StartTimeUtcFileTime))) {
5511
5513
  throw new Error("Malformed Roblox Studio process enumeration result.");
5512
5514
  }
5513
- return processes;
5515
+ return processes.map(({ Path, ...processInfo }) => Path === null ? processInfo : { ...processInfo, Path });
5514
5516
  }
5515
5517
  async function observeStudioProcesses(timeoutMs = 15e3) {
5516
5518
  const observedAt = Date.now();
@@ -6521,6 +6523,8 @@ var StudioInstanceManager = class {
6521
6523
  if (record.closedAt !== void 0)
6522
6524
  return;
6523
6525
  const previousObservationAt = record.lastProcessObservationAt;
6526
+ if (previousObservationAt !== void 0 && observation.observedAt < previousObservationAt)
6527
+ return;
6524
6528
  record.lastProcessObservationAt = observation.observedAt;
6525
6529
  if (observation.status === "unknown") {
6526
6530
  record.processObservationStatus = "unknown";
@@ -6854,7 +6858,7 @@ function serverInstructions(definitions) {
6854
6858
  const has = (...toolNames) => toolNames.every((name) => names.has(name));
6855
6859
  const instructions = ["Use canonical DataModel paths returned by tools."];
6856
6860
  if (has("get_connected_instances")) {
6857
- instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id.");
6861
+ instructions.push("When more than one Studio process scope is connected, call get_connected_instances and pass either a top-level instance id or a multiplayer group's role-suffixed instance id as instance_id. A role-suffixed id selects that server or client as the default target; a conflicting single-peer target is rejected.");
6858
6862
  }
6859
6863
  if (has("get_request_status", "execute_luau", "set_properties")) {
6860
6864
  instructions.push("Supply a unique operation_id to execute_luau or set_properties when retry safety matters. After a timeout, query get_request_status with that ID before retrying. Identical arguments reuse a retained outcome; changed arguments are rejected. Recovery and deduplication are bounded to the current server session and five-minute retention window; result payloads may be evicted earlier. Unknown status does not mean unexecuted, and cancellation cannot roll back mutations.", "Request stages are queued, dispatched, executing (plugin handler entered), and response_delivery (handler returned or admission rejected). executionOutcome is separate from waiter state and delivery outcome; handler observations do not prove user Luau instructions ran. A waiter timeout is not an execution deadline or rollback. Neither missing progress nor connection loss proves completion.");
@@ -6995,6 +6999,7 @@ var HTTP_BODY_LIMIT_BYTES = 50 * 1024 * 1024;
6995
6999
 
6996
7000
  // ../core/dist/studio-transport.js
6997
7001
  var HEARTBEAT_INTERVAL_MS = 1e4;
7002
+ var PONG_TIMEOUT_MS = 3e4;
6998
7003
  var MAX_PENDING_ACKS = 128;
6999
7004
  var STUDIO_PROTOCOL_VERSION = 1;
7000
7005
  var MAX_ACTIVE_STUDIO_SOCKETS = 64;
@@ -7042,13 +7047,20 @@ var WebSocketStudioTransport = class {
7042
7047
  settling: false,
7043
7048
  statusPending: true,
7044
7049
  heartbeatPending: false,
7050
+ lastSeenAt: Date.now(),
7045
7051
  acknowledgements: /* @__PURE__ */ new Map(),
7046
7052
  onClose: () => {
7047
7053
  this.closeSocket(connection);
7048
7054
  this.finishClose(connection);
7049
7055
  },
7050
7056
  onError: () => this.closeSocket(connection, 1011, "socket_error"),
7051
- onMessage: (data, isBinary) => this.receive(connection, data, isBinary)
7057
+ onMessage: (data, isBinary) => this.receive(connection, data, isBinary),
7058
+ onPong: () => {
7059
+ if (!this.isCurrent(connection))
7060
+ return;
7061
+ connection.lastSeenAt = Date.now();
7062
+ this.queue.updatePeerActivity(transportPeerId);
7063
+ }
7052
7064
  };
7053
7065
  this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
7054
7066
  const replaced = this.sockets.get(transportPeerId);
@@ -7059,10 +7071,20 @@ var WebSocketStudioTransport = class {
7059
7071
  socket.on("close", connection.onClose);
7060
7072
  socket.on("error", connection.onError);
7061
7073
  socket.on("message", connection.onMessage);
7074
+ socket.on("pong", connection.onPong);
7062
7075
  connection.heartbeatTimer = setInterval(() => {
7063
7076
  if (!this.isCurrent(connection))
7064
7077
  return;
7065
- this.queue.updatePeerActivity(transportPeerId);
7078
+ if (Date.now() - connection.lastSeenAt >= PONG_TIMEOUT_MS) {
7079
+ this.closeSocket(connection, 1001, "pong_timeout");
7080
+ return;
7081
+ }
7082
+ try {
7083
+ connection.socket.ping();
7084
+ } catch {
7085
+ this.closeSocket(connection, 1011, "ping_failed");
7086
+ return;
7087
+ }
7066
7088
  connection.statusPending = true;
7067
7089
  connection.heartbeatPending = true;
7068
7090
  this.pump(connection);
@@ -7120,6 +7142,11 @@ var WebSocketStudioTransport = class {
7120
7142
  this.closeSocket(connection, 1007, "invalid_json");
7121
7143
  return;
7122
7144
  }
7145
+ if (message !== null && typeof message === "object" && !Array.isArray(message) && "kind" in message && message.kind === "disconnect") {
7146
+ this.queue.unregisterPeer(connection.transportPeerId);
7147
+ this.closeSocket(connection, 1e3, "peer_unregistered");
7148
+ return;
7149
+ }
7123
7150
  if (message === null || typeof message !== "object" || Array.isArray(message) || !("kind" in message) || message.kind !== "response" && message.kind !== "progress" || !("requestId" in message) || typeof message.requestId !== "string" || message.requestId.length === 0 || message.requestId.length > 1024) {
7124
7151
  this.closeSocket(connection, 1008, "invalid_response");
7125
7152
  return;
@@ -7260,6 +7287,7 @@ var WebSocketStudioTransport = class {
7260
7287
  connection.closed = true;
7261
7288
  clearInterval(connection.heartbeatTimer);
7262
7289
  connection.socket.removeListener("message", connection.onMessage);
7290
+ connection.socket.removeListener("pong", connection.onPong);
7263
7291
  connection.acknowledgements.clear();
7264
7292
  if (this.sockets.get(connection.transportPeerId) === connection)
7265
7293
  this.sockets.delete(connection.transportPeerId);
@@ -7822,9 +7850,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7822
7850
  });
7823
7851
  return;
7824
7852
  }
7825
- const isClientRole = role === "client" || /^client-[1-9]\d*$/.test(role);
7853
+ const isClientRole2 = role === "client" || /^client-[1-9]\d*$/.test(role);
7826
7854
  const isProxiedPeer = transportPeerId !== peerId;
7827
- if (isProxiedPeer && !isClientRole || !isProxiedPeer && isClientRole || !isClientRole && role !== "edit" && role !== "server") {
7855
+ if (isProxiedPeer && !isClientRole2 || !isProxiedPeer && isClientRole2 || !isClientRole2 && role !== "edit" && role !== "server") {
7828
7856
  res.status(400).json({
7829
7857
  success: false,
7830
7858
  error: "invalid_peer_topology",
@@ -12884,6 +12912,10 @@ end
12884
12912
  error("Unsupported device simulator operation: " .. tostring(opts.operation), 0)
12885
12913
  `.trim();
12886
12914
  }
12915
+ var isClientRole = (role) => /^client-\d+$/.test(role);
12916
+ var isRuntimeRole2 = (role) => role === "server" || isClientRole(role);
12917
+ var anyRole = () => true;
12918
+ var SCOPE_WIDE_TARGETS = /* @__PURE__ */ new Set(["all", "all-clients", "edit-and-clients"]);
12887
12919
  var RobloxStudioTools = class {
12888
12920
  client;
12889
12921
  bridge;
@@ -13027,7 +13059,8 @@ var RobloxStudioTools = class {
13027
13059
  const refresh = this.bridge.refreshTopologyForRouting(signal);
13028
13060
  if (refresh)
13029
13061
  await refresh;
13030
- const resolved = this.bridge.resolveTarget({ instance_id, target });
13062
+ const role = typeof target === "object" ? this._targetForInstanceId(instance_id, target) : target;
13063
+ const resolved = this.bridge.resolveTarget({ instance_id, target: role });
13031
13064
  if (!resolved.ok)
13032
13065
  throw new RoutingFailure(resolved.error);
13033
13066
  if (resolved.mode !== "single") {
@@ -13039,26 +13072,66 @@ var RobloxStudioTools = class {
13039
13072
  }
13040
13073
  return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
13041
13074
  }
13075
+ // A grouped runtime process ID, or its role-suffixed Instance ID, names exactly
13076
+ // one Peer. Solo playtest Peers share one Instance ID, so it names none of them.
13077
+ _peerSelectedByInstanceId(instance_id) {
13078
+ if (instance_id === void 0)
13079
+ return void 0;
13080
+ const selected = this.bridge.getPeersInScope(instance_id).filter((peer) => isRuntimeRole2(peer.role) && (instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`));
13081
+ return selected.length === 1 ? selected[0] : void 0;
13082
+ }
13083
+ // Apply a Peer-naming instance_id to a tool's target after topology refresh: it
13084
+ // replaces the tool default, and a different single-Peer target is rejected
13085
+ // instead of silently acting on another Peer.
13086
+ _targetForInstanceId(instance_id, choice) {
13087
+ const requested = choice.requested || void 0;
13088
+ const selected = this._peerSelectedByInstanceId(instance_id);
13089
+ if (!selected || !choice.accepts(selected.role))
13090
+ return requested ?? choice.fallback;
13091
+ if (requested === void 0)
13092
+ return selected.role;
13093
+ if (requested !== selected.role && !SCOPE_WIDE_TARGETS.has(requested)) {
13094
+ throw new RoutingFailure({
13095
+ code: "ambiguous_target",
13096
+ message: `instance_id "${instance_id}" selects ${selected.role}, but target=${requested}. Omit target, pass target=${selected.role}, or pass the controller Instance ID with target=${requested}.`,
13097
+ data: this._routingErrorData()
13098
+ });
13099
+ }
13100
+ return requested;
13101
+ }
13042
13102
  // Honor an explicitly selected client for viewport/input operations; otherwise
13043
13103
  // prefer the first client in the scope, then retain the default Peer's Instance.
13044
13104
  _resolveRuntime(instance_id) {
13045
13105
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
13046
- if (!resolved.ok)
13106
+ let scopePeers;
13107
+ let defaultInstanceId;
13108
+ if (resolved.ok) {
13109
+ if (resolved.mode !== "single") {
13110
+ throw new RoutingFailure({
13111
+ code: "target_role_not_present_on_instance",
13112
+ message: "A single runtime target is required.",
13113
+ data: this._routingErrorData()
13114
+ });
13115
+ }
13116
+ scopePeers = this.bridge.getPeersInScope(resolved.targetInstanceId);
13117
+ defaultInstanceId = resolved.targetInstanceId;
13118
+ } else if (resolved.error.code === "target_role_required") {
13119
+ scopePeers = instance_id !== void 0 ? this.bridge.getPeersInScope(instance_id) : this.bridge.getPeers();
13120
+ } else {
13047
13121
  throw new RoutingFailure(resolved.error);
13048
- if (resolved.mode !== "single") {
13122
+ }
13123
+ const named = this._peerSelectedByInstanceId(instance_id);
13124
+ const client = (named && isClientRole(named.role) ? named : void 0) ?? scopePeers.filter((peer) => isClientRole(peer.role)).sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
13125
+ if (client)
13126
+ return { instanceId: client.instanceId, clientRole: client.role };
13127
+ if (defaultInstanceId === void 0) {
13049
13128
  throw new RoutingFailure({
13050
13129
  code: "target_role_not_present_on_instance",
13051
- message: "A single runtime target is required.",
13130
+ message: `No edit or client viewport is connected in this scope. Connected roles: ${scopePeers.map((peer) => peer.role).join(", ")}.`,
13052
13131
  data: this._routingErrorData()
13053
13132
  });
13054
13133
  }
13055
- const clients = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role));
13056
- const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
13057
- const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
13058
- return {
13059
- instanceId: client?.instanceId ?? resolved.targetInstanceId,
13060
- clientRole: client?.role
13061
- };
13134
+ return { instanceId: defaultInstanceId };
13062
13135
  }
13063
13136
  // Resolve synchronously after the caller has refreshed and captured its topology.
13064
13137
  _resolveInstanceIdOnly(instance_id) {
@@ -13126,7 +13199,7 @@ var RobloxStudioTools = class {
13126
13199
  return compact;
13127
13200
  }
13128
13201
  _resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
13129
- const selectedTarget = target ?? "edit";
13202
+ const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isClientRole });
13130
13203
  if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients") {
13131
13204
  throw new Error(`${toolName} target must be "edit" or "client-N" (got: ${selectedTarget})`);
13132
13205
  }
@@ -13137,7 +13210,7 @@ var RobloxStudioTools = class {
13137
13210
  return { ...resolved, selectedTarget };
13138
13211
  }
13139
13212
  _resolveDeviceSimulatorSetTargets(target, instance_id) {
13140
- const selectedTarget = target ?? "edit";
13213
+ const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isClientRole });
13141
13214
  if (selectedTarget === "all-clients") {
13142
13215
  const instanceId = this._resolveInstanceIdOnly(instance_id);
13143
13216
  const roles = this._clientRolesForScope(instanceId);
@@ -13161,7 +13234,7 @@ var RobloxStudioTools = class {
13161
13234
  return selectedInclude;
13162
13235
  }
13163
13236
  _resolveSimulationTargets(target, instance_id, toolName) {
13164
- const selectedTarget = target ?? "edit-and-clients";
13237
+ const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit-and-clients", accepts: isClientRole });
13165
13238
  if (selectedTarget === "server" || selectedTarget === "all") {
13166
13239
  throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
13167
13240
  }
@@ -13668,7 +13741,7 @@ var RobloxStudioTools = class {
13668
13741
  if (!code) {
13669
13742
  throw new Error("Code is required for execute_luau");
13670
13743
  }
13671
- const response = await this._callSingle("/api/execute-luau", { code }, target || "edit", instance_id, void 0, void 0, operation_id);
13744
+ const response = await this._callSingle("/api/execute-luau", { code }, { requested: target, fallback: "edit", accepts: anyRole }, instance_id, void 0, void 0, operation_id);
13672
13745
  return {
13673
13746
  content: [
13674
13747
  {
@@ -13696,11 +13769,10 @@ var RobloxStudioTools = class {
13696
13769
  if (!code) {
13697
13770
  throw new Error("Code is required for eval_client_runtime");
13698
13771
  }
13699
- const clientTarget = target || "client-1";
13700
- if (!clientTarget.startsWith("client-")) {
13701
- throw new Error(`eval_client_runtime requires target=client-N (got: ${clientTarget})`);
13772
+ if (target && !target.startsWith("client-")) {
13773
+ throw new Error(`eval_client_runtime requires target=client-N (got: ${target})`);
13702
13774
  }
13703
- const response = await this._callSingle("/api/eval-runtime", { code }, clientTarget, instance_id);
13775
+ const response = await this._callSingle("/api/eval-runtime", { code }, { requested: target, fallback: "client-1", accepts: isClientRole }, instance_id);
13704
13776
  return {
13705
13777
  content: [
13706
13778
  {
@@ -13717,7 +13789,7 @@ var RobloxStudioTools = class {
13717
13789
  await refresh;
13718
13790
  const instanceId = this._resolveInstanceIdOnly(instance_id);
13719
13791
  const clientRoles = this._clientRolesForScope(instanceId);
13720
- const selectedTarget = target ?? "client-1";
13792
+ const selectedTarget = this._targetForInstanceId(instance_id, { requested: target, fallback: "client-1", accepts: isClientRole });
13721
13793
  let targetRoles;
13722
13794
  if (selectedTarget === "all-clients") {
13723
13795
  targetRoles = clientRoles;
@@ -14323,7 +14395,6 @@ var RobloxStudioTools = class {
14323
14395
  return this._textResult(result);
14324
14396
  }
14325
14397
  async captureScriptProfiler(target, request = {}, instance_id) {
14326
- const targetRole = target ?? "server";
14327
14398
  const data = { ...request };
14328
14399
  const outputPath = data.output_path;
14329
14400
  delete data.output_path;
@@ -14336,6 +14407,7 @@ var RobloxStudioTools = class {
14336
14407
  const refresh = this.bridge.refreshTopologyForRouting();
14337
14408
  if (refresh)
14338
14409
  await refresh;
14410
+ const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "server", accepts: isRuntimeRole2 });
14339
14411
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
14340
14412
  if (!resolved.ok)
14341
14413
  throw new RoutingFailure(resolved.error);
@@ -14366,7 +14438,6 @@ var RobloxStudioTools = class {
14366
14438
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
14367
14439
  }
14368
14440
  async captureMicroProfiler(target, request = {}, instance_id) {
14369
- const targetRole = target ?? "server";
14370
14441
  const data = { ...request };
14371
14442
  const outputPath = data.output_path;
14372
14443
  const summaryOutputPath = data.summary_output_path;
@@ -14399,6 +14470,7 @@ var RobloxStudioTools = class {
14399
14470
  const refresh = this.bridge.refreshTopologyForRouting();
14400
14471
  if (refresh)
14401
14472
  await refresh;
14473
+ const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "server", accepts: isRuntimeRole2 });
14402
14474
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
14403
14475
  if (!resolved.ok)
14404
14476
  throw new RoutingFailure(resolved.error);
@@ -14449,13 +14521,13 @@ var RobloxStudioTools = class {
14449
14521
  if (!action || typeof action !== "string") {
14450
14522
  throw new Error("breakpoints requires action=set|remove|clear|list");
14451
14523
  }
14452
- const targetRole = target ?? "edit";
14453
14524
  const data = { ...request, action };
14454
14525
  delete data.target;
14455
14526
  delete data.instance_id;
14456
14527
  const refresh = this.bridge.refreshTopologyForRouting();
14457
14528
  if (refresh)
14458
14529
  await refresh;
14530
+ const targetRole = this._targetForInstanceId(instance_id, { requested: target, fallback: "edit", accepts: isRuntimeRole2 });
14459
14531
  const resolved = this.bridge.resolveTarget({ instance_id, target: targetRole });
14460
14532
  if (!resolved.ok)
14461
14533
  throw new RoutingFailure(resolved.error);
@@ -15147,7 +15219,7 @@ var RobloxStudioTools = class {
15147
15219
  });
15148
15220
  }
15149
15221
  if (action === "leave_client") {
15150
- const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target ?? "client-1", timeout, instance_id));
15222
+ const body2 = this._parseTextResult(await this.multiplayerTestLeaveClient(target, timeout, instance_id));
15151
15223
  return this._textResult(body2.success === true && body2.left === true ? {
15152
15224
  success: true,
15153
15225
  action,
@@ -15280,14 +15352,15 @@ var RobloxStudioTools = class {
15280
15352
  }]
15281
15353
  };
15282
15354
  }
15283
- async multiplayerTestLeaveClient(target = "client-1", timeout, instance_id) {
15284
- if (!/^client-\d+$/.test(target)) {
15355
+ async multiplayerTestLeaveClient(target, timeout, instance_id) {
15356
+ if (target && !/^client-\d+$/.test(target)) {
15285
15357
  throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
15286
15358
  }
15287
15359
  const refresh = this.bridge.refreshTopologyForRouting();
15288
15360
  if (refresh)
15289
15361
  await refresh;
15290
- const clientTarget = this._resolveSingleTarget(target, instance_id);
15362
+ const role = this._targetForInstanceId(instance_id, { requested: target, fallback: "client-1", accepts: isClientRole });
15363
+ const clientTarget = this._resolveSingleTarget(role, instance_id);
15291
15364
  const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
15292
15365
  const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
15293
15366
  const response = await this._requestPeer("/api/multiplayer-test-leave-client", {}, clientTarget.targetPeerId);
@@ -15909,12 +15982,13 @@ var RobloxStudioTools = class {
15909
15982
  if (refresh)
15910
15983
  await refresh;
15911
15984
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
15985
+ const role = this._targetForInstanceId(instance_id, { requested: target, fallback: clientRole ?? "edit", accepts: isClientRole });
15912
15986
  const response = await this._callSingle("/api/simulate-mouse-input", {
15913
15987
  action,
15914
15988
  x,
15915
15989
  y,
15916
15990
  button
15917
- }, target || clientRole || "edit", instanceId);
15991
+ }, role, instanceId);
15918
15992
  return {
15919
15993
  content: [{
15920
15994
  type: "text",
@@ -15930,12 +16004,13 @@ var RobloxStudioTools = class {
15930
16004
  if (refresh)
15931
16005
  await refresh;
15932
16006
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
16007
+ const role = this._targetForInstanceId(instance_id, { requested: target, fallback: clientRole ?? "edit", accepts: isClientRole });
15933
16008
  const response = await this._callSingle("/api/simulate-keyboard-input", {
15934
16009
  keyCode,
15935
16010
  action,
15936
16011
  duration,
15937
16012
  text
15938
- }, target || clientRole || "edit", instanceId);
16013
+ }, role, instanceId);
15939
16014
  return {
15940
16015
  content: [{
15941
16016
  type: "text",
@@ -15963,13 +16038,13 @@ var RobloxStudioTools = class {
15963
16038
  };
15964
16039
  }
15965
16040
  async getMemoryBreakdown(target, tags, instance_id) {
15966
- const tgt = target ?? "all";
15967
16041
  const data = {};
15968
16042
  if (tags !== void 0)
15969
16043
  data.tags = tags;
15970
16044
  const refresh = this.bridge.refreshTopologyForRouting();
15971
16045
  if (refresh)
15972
16046
  await refresh;
16047
+ const tgt = this._targetForInstanceId(instance_id, { requested: target, fallback: "all", accepts: anyRole });
15973
16048
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
15974
16049
  if (!resolved.ok)
15975
16050
  throw new RoutingFailure(resolved.error);
@@ -15995,7 +16070,6 @@ var RobloxStudioTools = class {
15995
16070
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
15996
16071
  }
15997
16072
  async getSceneAnalysis(mode, target, topN, raw, instance_id) {
15998
- const tgt = target ?? "all";
15999
16073
  const data = {};
16000
16074
  if (mode !== void 0)
16001
16075
  data.mode = mode;
@@ -16006,6 +16080,7 @@ var RobloxStudioTools = class {
16006
16080
  const refresh = this.bridge.refreshTopologyForRouting();
16007
16081
  if (refresh)
16008
16082
  await refresh;
16083
+ const tgt = this._targetForInstanceId(instance_id, { requested: target, fallback: "all", accepts: anyRole });
16009
16084
  const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
16010
16085
  if (!resolved.ok)
16011
16086
  throw new RoutingFailure(resolved.error);
@@ -16037,11 +16112,10 @@ var RobloxStudioTools = class {
16037
16112
  if (!outputPath || typeof outputPath !== "string") {
16038
16113
  throw new Error("output_path is required for export_rbxm");
16039
16114
  }
16040
- const tgt = target || "edit";
16041
- if (tgt !== "edit" && tgt !== "server") {
16042
- throw new Error(`export_rbxm target must be "edit" or "server" (got: ${tgt})`);
16115
+ if (target && target !== "edit" && target !== "server") {
16116
+ throw new Error(`export_rbxm target must be "edit" or "server" (got: ${target})`);
16043
16117
  }
16044
- const response = await this._callSingle("/api/export-rbxm", { instance_paths: instancePaths }, tgt, instance_id);
16118
+ const response = await this._callSingle("/api/export-rbxm", { instance_paths: instancePaths }, { requested: target, fallback: "edit", accepts: (role) => role === "server" }, instance_id);
16045
16119
  if (response.error) {
16046
16120
  return { content: [{ type: "text", text: JSON.stringify({ error: response.error }) }] };
16047
16121
  }
@@ -16074,9 +16148,8 @@ var RobloxStudioTools = class {
16074
16148
  if (!parentPath || typeof parentPath !== "string") {
16075
16149
  throw new Error("parent_path is required for import_rbxm");
16076
16150
  }
16077
- const tgt = target || "edit";
16078
- if (tgt !== "edit" && tgt !== "server") {
16079
- throw new Error(`import_rbxm target must be "edit" or "server" (got: ${tgt})`);
16151
+ if (target && target !== "edit" && target !== "server") {
16152
+ throw new Error(`import_rbxm target must be "edit" or "server" (got: ${target})`);
16080
16153
  }
16081
16154
  const modes = ["path", "url", "base64"].filter((k) => source[k] !== void 0);
16082
16155
  if (modes.length !== 1) {
@@ -16093,7 +16166,7 @@ var RobloxStudioTools = class {
16093
16166
  base64: bytes.toString("base64"),
16094
16167
  parent_path: parentPath,
16095
16168
  source_label: sourceLabel
16096
- }, tgt, instance_id, void 0, signal);
16169
+ }, { requested: target, fallback: "edit", accepts: (role) => role === "server" }, instance_id, void 0, signal);
16097
16170
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
16098
16171
  }
16099
16172
  // Even native capture must wait while another call has fitted the simulator
@@ -17538,7 +17611,7 @@ var TOOL_DEFINITIONS = [
17538
17611
  },
17539
17612
  target: {
17540
17613
  type: "string",
17541
- description: "Client peer; defaults to client-1."
17614
+ description: "Client peer; defaults to the client named by instance_id, else client-1."
17542
17615
  },
17543
17616
  instance_id: {
17544
17617
  type: "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
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",
@@ -64,8 +64,8 @@ applyRememberedServerUrl()
64
64
  -- down their VM. Close the persistent WebStreamClient while BindToClose still
65
65
  -- gives this server peer a live execution context; otherwise each play cycle
66
66
  -- can retain one native WebSocket until Studio itself is restarted. The
67
- -- transport releases that native socket before yielding to unregister the
68
- -- logical server peer.
67
+ -- transport unregisters the server peer on that socket and waits at most two
68
+ -- seconds for the server to close it, so no HTTP request can hold up shutdown.
69
69
  if startupRole == "server" then
70
70
  game:BindToClose(StudioWebSocket.suspendForShutdown)
71
71
  end
@@ -104,8 +104,20 @@ plugin.Unloading:Connect(function()
104
104
  Communication.deactivateAll()
105
105
  end)
106
106
  UI.updateUIState()
107
- Communication.checkForUpdates()
107
+ -- Every DataModel shares Studio's five plugin HTTP request slots, and a
108
+ -- request has no time limit. Check for updates once, from the edit DataModel,
109
+ -- instead of again in every play server and client.
110
+ if startupRole == "edit" then
111
+ Communication.checkForUpdates()
112
+ end
108
113
  task.delay(TOOLBAR_REGISTRATION_DELAY_SECONDS, registerToolbarButton)
114
+ --* Runs one startup step so that its failure cannot skip the steps after it.
115
+ local function runStartupStep(description, step)
116
+ local ok, err = pcall(step)
117
+ if not ok then
118
+ warn(`[robloxstudio-mcp] {description} failed: {err}`)
119
+ end
120
+ end
109
121
  -- Auto-activate per Peer. Runtime plugin VMs can load before their first
110
122
  -- Heartbeat; task.delay() would then wait behind the very multiplayer startup
111
123
  -- that needs this Peer to register. Start runtime initialization immediately,
@@ -123,10 +135,13 @@ local function autoActivatePeer()
123
135
  if role == "server" then
124
136
  -- Yields until this playtest server knows which edit session (or
125
137
  -- multiplayer group) it belongs to, so it registers under that identity.
126
- PluginSession.resolveRuntimeTopology()
138
+ -- On failure the server still registers, under its own identity.
139
+ runStartupStep("Playtest topology resolution", function()
140
+ return PluginSession.resolveRuntimeTopology()
141
+ end)
127
142
  end
128
143
  if role == "edit" or role == "server" then
129
- local activationOk, activationError = pcall(function()
144
+ runStartupStep(`Automatic {role} Peer activation`, function()
130
145
  local conn = State.getActiveConnection()
131
146
  if not conn.isActive then
132
147
  if role == "server" then
@@ -155,19 +170,20 @@ local function autoActivatePeer()
155
170
  Communication.activatePlugin()
156
171
  end
157
172
  end)
158
- if not activationOk then
159
- warn(`[robloxstudio-mcp] Automatic {role} Peer activation failed: {activationError}`)
160
- end
161
173
  end
162
174
  if role == "server" then
163
- ClientBroker.setupServerBroker()
175
+ runStartupStep("Client broker setup", function()
176
+ return ClientBroker.setupServerBroker()
177
+ end)
164
178
  -- The play-server DM is the only one where StudioTestService:EndTest is
165
179
  -- legal, so the stop-play monitor lives here. It consumes tokenized
166
180
  -- stop requests from plugin settings and acknowledges EndTest results.
167
- StopPlayMonitor.startMonitor({
168
- beforeEndTest = StudioWebSocket.suspendForShutdown,
169
- afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
170
- })
181
+ runStartupStep("Stop-play monitor startup", function()
182
+ return StopPlayMonitor.startMonitor({
183
+ beforeEndTest = StudioWebSocket.suspendForShutdown,
184
+ afterEndTestFailure = StudioWebSocket.resumeAfterShutdownFailure,
185
+ })
186
+ end)
171
187
  elseif role == "client" then
172
188
  ClientBroker.setupClientBroker()
173
189
  end
@@ -1959,9 +1975,9 @@ local function computeBridgeStamp()
1959
1975
  for i = 1, #combined do
1960
1976
  h = (h * 33 + (string.byte(combined, i))) % 2147483647
1961
1977
  end
1962
- -- "3.2.0" is replaced with the package version at package time
1978
+ -- "3.2.1" is replaced with the package version at package time
1963
1979
  -- (scripts/build-plugin.mjs injectVersion), so a release bump also restamps.
1964
- return `{tostring(h)}-3.2.0`
1980
+ return `{tostring(h)}-3.2.1`
1965
1981
  end
1966
1982
  local BRIDGE_STAMP = computeBridgeStamp()
1967
1983
  local function setSource(scriptInst, source)
@@ -10092,10 +10108,12 @@ local TOPOLOGY_TOKEN_ATTRIBUTE = "__MCPTopologyToken"
10092
10108
  -- saved or published DataModel, so edit sessions remove them on load.
10093
10109
  local LEGACY_TOPOLOGY_ATTRIBUTES = { TOPOLOGY_MODE_ATTRIBUTE, TOPOLOGY_INSTANCE_ID_ATTRIBUTE, TOPOLOGY_GROUP_ID_ATTRIBUTE, TOPOLOGY_TOKEN_ATTRIBUTE }
10094
10110
  local SESSION_IDENTITY_NAME = "__MCPSessionIdentity"
10095
- -- Attributes on the CoreGui identity value: CoreGui survives plugin reloads but
10096
- -- is never saved with the place or copied into playtest DataModels.
10097
- local PLAY_RECORD_ATTRIBUTE = "PlayRecord"
10098
- local RUNTIME_TOPOLOGY_ATTRIBUTE = "RuntimeTopology"
10111
+ -- JSON session state lives in StringValue children of the CoreGui identity
10112
+ -- value: CoreGui survives plugin reloads but is never saved with the place or
10113
+ -- copied into playtest DataModels. Not attributes: with NextGenerationReplication,
10114
+ -- playtest DataModels reject attribute strings longer than 50 characters.
10115
+ local PLAY_RECORD_NAME = "PlayRecord"
10116
+ local RUNTIME_TOPOLOGY_NAME = "RuntimeTopology"
10099
10117
  -- MCP-started solo and Run playtests carry their topology in StudioTestService
10100
10118
  -- test args, which never touch the DataModel.
10101
10119
  local TEST_ARGS_TOPOLOGY_KEY = "__mcpTopology"
@@ -10216,18 +10234,54 @@ local function optionalString(value)
10216
10234
  end
10217
10235
  return if _condition then value else nil
10218
10236
  end
10219
- function readPlayRecord()
10220
- local raw = sessionIdentity:GetAttribute(PLAY_RECORD_ATTRIBUTE)
10221
- if not (type(raw) == "string") then
10237
+ local function readSessionState(name)
10238
+ local holder = sessionIdentity:FindFirstChild(name)
10239
+ if holder == nil or not holder:IsA("StringValue") then
10222
10240
  return nil
10223
10241
  end
10224
10242
  local ok, decoded = pcall(function()
10225
- return HttpService:JSONDecode(raw)
10243
+ return HttpService:JSONDecode(holder.Value)
10226
10244
  end)
10227
10245
  if not ok or not (type(decoded) == "table") then
10228
10246
  return nil
10229
10247
  end
10230
- local data = decoded
10248
+ return decoded
10249
+ end
10250
+ --* Best effort: saved session state only lets a plugin reload resume where it left off.
10251
+ local function writeSessionState(name, state)
10252
+ local ok, err = pcall(function()
10253
+ local existing = sessionIdentity:FindFirstChild(name)
10254
+ if state == nil then
10255
+ local _result = existing
10256
+ if _result ~= nil then
10257
+ _result:Destroy()
10258
+ end
10259
+ return nil
10260
+ end
10261
+ local encoded = HttpService:JSONEncode(state)
10262
+ if existing ~= nil and existing:IsA("StringValue") then
10263
+ existing.Value = encoded
10264
+ return nil
10265
+ end
10266
+ local _result = existing
10267
+ if _result ~= nil then
10268
+ _result:Destroy()
10269
+ end
10270
+ local holder = Instance.new("StringValue")
10271
+ holder.Name = name
10272
+ holder.Value = encoded
10273
+ holder.Archivable = false
10274
+ holder.Parent = sessionIdentity
10275
+ end)
10276
+ if not ok then
10277
+ warn(`[robloxstudio-mcp] Could not save {name} session state: {err}`)
10278
+ end
10279
+ end
10280
+ function readPlayRecord()
10281
+ local data = readSessionState(PLAY_RECORD_NAME)
10282
+ if data == nil then
10283
+ return nil
10284
+ end
10231
10285
  local phase = if data.phase == "armed" or data.phase == "active" then data.phase else nil
10232
10286
  if phase == nil then
10233
10287
  return nil
@@ -10243,16 +10297,7 @@ function readPlayRecord()
10243
10297
  }
10244
10298
  end
10245
10299
  local function writePlayRecord(record)
10246
- if record == nil then
10247
- sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, nil)
10248
- return nil
10249
- end
10250
- local ok, encoded = pcall(function()
10251
- return HttpService:JSONEncode(record)
10252
- end)
10253
- if ok and type(encoded) == "string" then
10254
- sessionIdentity:SetAttribute(PLAY_RECORD_ATTRIBUTE, encoded)
10255
- end
10300
+ writeSessionState(PLAY_RECORD_NAME, record)
10256
10301
  end
10257
10302
  local function createTicket(instanceId, groupId)
10258
10303
  local now = PlaytestTickets.nowMs()
@@ -10544,17 +10589,10 @@ local function topologyFromTestArgs()
10544
10589
  }
10545
10590
  end
10546
10591
  local function readRememberedRuntimeTopology()
10547
- local raw = sessionIdentity:GetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE)
10548
- if not (type(raw) == "string") then
10592
+ local data = readSessionState(RUNTIME_TOPOLOGY_NAME)
10593
+ if data == nil then
10549
10594
  return nil
10550
10595
  end
10551
- local ok, decoded = pcall(function()
10552
- return HttpService:JSONDecode(raw)
10553
- end)
10554
- if not ok or not (type(decoded) == "table") then
10555
- return nil
10556
- end
10557
- local data = decoded
10558
10596
  return {
10559
10597
  instanceId = optionalString(data.instanceId),
10560
10598
  groupId = optionalString(data.groupId),
@@ -10564,12 +10602,7 @@ local function readRememberedRuntimeTopology()
10564
10602
  end
10565
10603
  local function adoptRuntimeTopology(topology)
10566
10604
  runtimeTopology = topology
10567
- local ok, encoded = pcall(function()
10568
- return HttpService:JSONEncode(topology)
10569
- end)
10570
- if ok and type(encoded) == "string" then
10571
- sessionIdentity:SetAttribute(RUNTIME_TOPOLOGY_ATTRIBUTE, encoded)
10572
- end
10605
+ writeSessionState(RUNTIME_TOPOLOGY_NAME, topology)
10573
10606
  end
10574
10607
  --[[
10575
10608
  *
@@ -11496,7 +11529,7 @@ return {
11496
11529
  <Properties>
11497
11530
  <string name="Name">State</string>
11498
11531
  <string name="Source"><![CDATA[-- Compiled with roblox-ts v3.0.0
11499
- local CURRENT_VERSION = "3.2.0"
11532
+ local CURRENT_VERSION = "3.2.1"
11500
11533
  local PLUGIN_VARIANT = "main"
11501
11534
  local BASE_PORT = 58741
11502
11535
  local SENSITIVE_DIAGNOSTIC_TERMS = { "token", "authorization", "bearer", "password", "cookie", "secret" }
@@ -11943,6 +11976,10 @@ local MAX_PENDING_RESPONSE_BYTES = 64 * 1024 * 1024
11943
11976
  local RESERVED_ERROR_BYTES = 4096
11944
11977
  -- The bridge admits 128 UTF-16 code units; each can require three UTF-8 bytes.
11945
11978
  local MAX_REQUEST_ID_BYTES = 128 * 3
11979
+ -- Upper bound on BindToClose waiting for the server to close the socket after
11980
+ -- the unregister frame; it normally closes within milliseconds.
11981
+ local SHUTDOWN_UNREGISTER_WAIT_SECONDS = 2
11982
+ local SHUTDOWN_UNREGISTER_POLL_SECONDS = 0.05
11946
11983
  local options
11947
11984
  local active = false
11948
11985
  local shutdownSuspended = false
@@ -12985,8 +13022,13 @@ local function refresh()
12985
13022
  refreshRequested = true
12986
13023
  flushRefresh()
12987
13024
  end
12988
- -- EndTest may tear down this VM without an Unloading callback. Release the
12989
- -- native socket before yielding to unregister; a failed EndTest can resume.
13025
+ -- EndTest may tear down this VM without an Unloading callback, and BindToClose
13026
+ -- waits for this function. It unregisters on the socket itself: Studio runs at
13027
+ -- most five plugin HTTP requests at a time across the whole process, with no
13028
+ -- time limit, so a /disconnect queued behind five that never finish held
13029
+ -- shutdown until Studio's 30 s deadline. Closing right after Send can drop the
13030
+ -- frame, so it waits briefly for the server to close the socket first. A
13031
+ -- failed EndTest can resume and registers again.
12990
13032
  local function suspendForShutdown()
12991
13033
  local currentOptions = options
12992
13034
  if not active or currentOptions == nil then
@@ -12998,9 +13040,34 @@ local function suspendForShutdown()
12998
13040
  cancelTransportWork()
12999
13041
  refreshRequested = false
13000
13042
  reconnectAttempt = 0
13043
+ local client = socketClient
13044
+ local unregistered = false
13045
+ if client ~= nil and socketOpen then
13046
+ local serverClosed = false
13047
+ local closedConnection = client.Closed:Connect(function()
13048
+ serverClosed = true
13049
+ end)
13050
+ local sent = pcall(function()
13051
+ return client:Send(HttpService:JSONEncode({
13052
+ kind = "disconnect",
13053
+ }))
13054
+ end)
13055
+ unregistered = sent
13056
+ local deadline = os.clock() + SHUTDOWN_UNREGISTER_WAIT_SECONDS
13057
+ while sent and not serverClosed and os.clock() < deadline do
13058
+ task.wait(SHUTDOWN_UNREGISTER_POLL_SECONDS)
13059
+ end
13060
+ closedConnection:Disconnect()
13061
+ end
13001
13062
  closeCurrentSocket()
13002
13063
  cachedReady = nil
13003
- disconnectSession(currentOptions)
13064
+ -- Without an open socket, unregister over HTTP on another thread rather
13065
+ -- than wait; the server also drops a peer whose socket stays closed.
13066
+ if not unregistered then
13067
+ task.spawn(function()
13068
+ return disconnectSession(currentOptions)
13069
+ end)
13070
+ end
13004
13071
  end
13005
13072
  local function resumeAfterShutdownFailure()
13006
13073
  if not shutdownSuspended or options == nil then