@chrrxs/robloxstudio-mcp 3.0.4 → 3.1.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
@@ -1951,59 +1951,83 @@ var RoutingFailure = class extends Error {
1951
1951
  this.routingError = routingError;
1952
1952
  }
1953
1953
  };
1954
- function toPublic(inst) {
1954
+ function toPublicPeer(peer) {
1955
1955
  return {
1956
- instanceId: inst.instanceId,
1957
- role: inst.role,
1958
- placeId: inst.placeId,
1959
- placeName: inst.placeName,
1960
- dataModelName: inst.dataModelName,
1961
- isRunning: inst.isRunning,
1962
- pluginVersion: inst.pluginVersion,
1963
- pluginVariant: inst.pluginVariant,
1964
- serverVersion: inst.serverVersion,
1965
- lastActivity: inst.lastActivity,
1966
- connectedAt: inst.connectedAt
1956
+ peerId: peer.peerId,
1957
+ instanceId: peer.instanceId,
1958
+ multiplayerGroupId: peer.multiplayerGroupId,
1959
+ role: peer.role,
1960
+ placeId: peer.placeId,
1961
+ placeName: peer.placeName,
1962
+ placeKey: peer.placeKey,
1963
+ dataModelName: peer.dataModelName,
1964
+ isRunning: peer.isRunning,
1965
+ pluginVersion: peer.pluginVersion,
1966
+ pluginVariant: peer.pluginVariant,
1967
+ serverVersion: peer.serverVersion,
1968
+ lastActivity: peer.lastActivity,
1969
+ connectedAt: peer.connectedAt
1967
1970
  };
1968
1971
  }
1969
- var STALE_INSTANCE_MS = 3e4;
1970
- var DUPLICATE_TAKEOVER_MS = 3e3;
1971
- var INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
1972
+ var STALE_PEER_MS = 3e4;
1972
1973
  var ACCEPTED_REQUEST_TOMBSTONE_TTL_MS = 6e4;
1973
1974
  var MAX_ACCEPTED_REQUEST_TOMBSTONES = 4096;
1974
1975
  var CANCELLATION_TOMBSTONE_TTL_MS = 6e4;
1975
1976
  var MAX_CANCELLATION_TOMBSTONES = 4096;
1976
- function publishedInstanceId(placeId) {
1977
- if (placeId === void 0 || !Number.isFinite(placeId) || placeId <= 0)
1978
- return void 0;
1979
- return `place:${Math.trunc(placeId)}`;
1977
+ var MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER = 4;
1978
+ function roleOrder(role) {
1979
+ if (role === "edit")
1980
+ return 0;
1981
+ if (role === "server")
1982
+ return 1;
1983
+ const client = /^client-(\d+)$/.exec(role);
1984
+ return client ? 2 + Number(client[1]) : Number.MAX_SAFE_INTEGER;
1985
+ }
1986
+ function isRuntimeRole(role) {
1987
+ return role === "server" || /^client-\d+$/.test(role);
1988
+ }
1989
+ function connectedRuntimeInstanceId(peer) {
1990
+ return `${peer.instanceId}-${peer.role}`;
1991
+ }
1992
+ function peerIdsByRole(peers) {
1993
+ return Object.fromEntries([...peers].sort((left, right) => roleOrder(left.role) - roleOrder(right.role) || left.peerId.localeCompare(right.peerId)).map((peer) => [peer.role, peer.peerId]));
1994
+ }
1995
+ function preferredPeer(peers) {
1996
+ return peers.reduce((preferred, candidate) => {
1997
+ const difference = roleOrder(candidate.role) - roleOrder(preferred.role);
1998
+ if (difference !== 0)
1999
+ return difference < 0 ? candidate : preferred;
2000
+ return candidate.connectedAt < preferred.connectedAt ? candidate : preferred;
2001
+ });
2002
+ }
2003
+ function copyGroup(group) {
2004
+ return { ...group, instanceIds: [...group.instanceIds] };
1980
2005
  }
1981
2006
  var BridgeService = class {
1982
2007
  pendingRequests = /* @__PURE__ */ new Map();
1983
2008
  acceptedRequestIds = /* @__PURE__ */ new Map();
1984
2009
  pendingCancellations = /* @__PURE__ */ new Map();
1985
- // Keyed by pluginSessionId (the per-plugin GUID).
1986
- instances = /* @__PURE__ */ new Map();
1987
- instanceAliases = /* @__PURE__ */ new Map();
1988
- instanceRegisteredListeners = /* @__PURE__ */ new Set();
2010
+ peersById = /* @__PURE__ */ new Map();
2011
+ multiplayerGroupsById = /* @__PURE__ */ new Map();
2012
+ peerRegisteredListeners = /* @__PURE__ */ new Set();
1989
2013
  requestAvailableListeners = /* @__PURE__ */ new Set();
1990
- sessionClosedListeners = /* @__PURE__ */ new Set();
1991
- deliveryOwnersByPhysicalSession = /* @__PURE__ */ new Map();
2014
+ peerClosedListeners = /* @__PURE__ */ new Set();
2015
+ deliveryOwnersByTransportPeer = /* @__PURE__ */ new Map();
1992
2016
  requestTimeout = 3e4;
1993
- onInstanceRegistered(listener) {
1994
- this.instanceRegisteredListeners.add(listener);
1995
- for (const instance of this.getPublicInstances()) {
2017
+ onPeerRegistered(listener) {
2018
+ this.peerRegisteredListeners.add(listener);
2019
+ for (const peer of this.getPublicPeers()) {
1996
2020
  try {
1997
- listener(instance);
2021
+ listener(peer);
1998
2022
  } catch {
1999
2023
  }
2000
2024
  }
2001
- return () => this.instanceRegisteredListeners.delete(listener);
2025
+ return () => this.peerRegisteredListeners.delete(listener);
2002
2026
  }
2003
- notifyInstanceRegistered(instance) {
2004
- for (const listener of this.instanceRegisteredListeners) {
2027
+ notifyPeerRegistered(peer) {
2028
+ for (const listener of this.peerRegisteredListeners) {
2005
2029
  try {
2006
- listener(instance);
2030
+ listener(peer);
2007
2031
  } catch {
2008
2032
  }
2009
2033
  }
@@ -2012,16 +2036,16 @@ var BridgeService = class {
2012
2036
  this.requestAvailableListeners.add(listener);
2013
2037
  return () => this.requestAvailableListeners.delete(listener);
2014
2038
  }
2015
- onSessionClosed(listener) {
2016
- this.sessionClosedListeners.add(listener);
2017
- return () => this.sessionClosedListeners.delete(listener);
2039
+ onPeerClosed(listener) {
2040
+ this.peerClosedListeners.add(listener);
2041
+ return () => this.peerClosedListeners.delete(listener);
2018
2042
  }
2019
- setDeliveryActive(physicalSessionId, owner, active) {
2020
- let owners = this.deliveryOwnersByPhysicalSession.get(physicalSessionId);
2043
+ setDeliveryActive(transportPeerId, owner, active) {
2044
+ let owners = this.deliveryOwnersByTransportPeer.get(transportPeerId);
2021
2045
  if (active) {
2022
2046
  if (!owners) {
2023
2047
  owners = /* @__PURE__ */ new Set();
2024
- this.deliveryOwnersByPhysicalSession.set(physicalSessionId, owners);
2048
+ this.deliveryOwnersByTransportPeer.set(transportPeerId, owners);
2025
2049
  }
2026
2050
  owners.add(owner);
2027
2051
  return;
@@ -2030,422 +2054,534 @@ var BridgeService = class {
2030
2054
  return;
2031
2055
  owners.delete(owner);
2032
2056
  if (owners.size === 0)
2033
- this.deliveryOwnersByPhysicalSession.delete(physicalSessionId);
2057
+ this.deliveryOwnersByTransportPeer.delete(transportPeerId);
2034
2058
  }
2035
- notifyRequestAvailable(physicalSessionId) {
2059
+ notifyRequestAvailable(transportPeerId) {
2036
2060
  for (const listener of this.requestAvailableListeners) {
2037
2061
  try {
2038
- listener(physicalSessionId);
2062
+ listener(transportPeerId);
2039
2063
  } catch {
2040
2064
  }
2041
2065
  }
2042
2066
  }
2043
2067
  notifyRequestCancelled(request, reason) {
2044
- const physicalSessionId = request.lastDeliveryPhysicalSessionId;
2045
- if (!physicalSessionId || this.pendingCancellations.has(request.id))
2068
+ const transportPeerId = request.lastDeliveryTransportPeerId;
2069
+ if (!transportPeerId || this.pendingCancellations.has(request.id))
2046
2070
  return;
2047
2071
  const now = Date.now();
2048
2072
  this.prunePendingCancellations(now);
2049
2073
  this.pendingCancellations.set(request.id, {
2050
2074
  requestId: request.id,
2051
2075
  reason,
2052
- physicalSessionId,
2076
+ transportPeerId,
2053
2077
  createdAt: now
2054
2078
  });
2055
2079
  this.prunePendingCancellations(now);
2056
- this.notifyRequestAvailable(physicalSessionId);
2080
+ this.notifyRequestAvailable(transportPeerId);
2057
2081
  }
2058
- physicalSessionsForTarget(targetInstanceId, targetRole) {
2059
- const physicalSessionIds = /* @__PURE__ */ new Set();
2060
- for (const instance of this.instances.values()) {
2061
- if (instance.instanceId === targetInstanceId && instance.role === targetRole) {
2062
- physicalSessionIds.add(instance.physicalSessionId);
2063
- }
2082
+ groupIdForInstance(instanceId) {
2083
+ for (const group of this.getMultiplayerGroups()) {
2084
+ if (group.instanceIds.includes(instanceId))
2085
+ return group.id;
2064
2086
  }
2065
- return Array.from(physicalSessionIds);
2087
+ return void 0;
2066
2088
  }
2067
- canonicalInstanceId(instanceId, placeId) {
2068
- return publishedInstanceId(placeId) ?? instanceId;
2089
+ peerScopeKey(peer) {
2090
+ return peer.multiplayerGroupId ? `group:${peer.multiplayerGroupId}` : `instance:${peer.instanceId}`;
2069
2091
  }
2070
- rememberInstanceAlias(aliasInstanceId, targetInstanceId) {
2071
- if (aliasInstanceId === targetInstanceId)
2072
- return;
2073
- this.instanceAliases.set(aliasInstanceId, {
2074
- targetInstanceId,
2075
- lastSeen: Date.now()
2076
- });
2092
+ registrationScopePeers(instanceId, multiplayerGroupId) {
2093
+ if (multiplayerGroupId) {
2094
+ return this.getPeers().filter((peer) => peer.multiplayerGroupId === multiplayerGroupId);
2095
+ }
2096
+ return this.getPeers().filter((peer) => peer.instanceId === instanceId && peer.multiplayerGroupId === void 0);
2077
2097
  }
2078
- resolveInstanceAlias(instanceId) {
2079
- const alias = this.instanceAliases.get(instanceId);
2080
- if (!alias)
2081
- return instanceId;
2082
- alias.lastSeen = Date.now();
2083
- return alias.targetInstanceId;
2098
+ detachInstanceFromOtherGroups(instanceId, retainedGroupId) {
2099
+ for (const group of this.multiplayerGroupsById.values()) {
2100
+ if (group.id === retainedGroupId || !group.instanceIds.includes(instanceId))
2101
+ continue;
2102
+ group.instanceIds = group.instanceIds.filter((id) => id !== instanceId);
2103
+ if (group.controllerInstanceId === instanceId)
2104
+ group.controllerInstanceId = void 0;
2105
+ if (group.instanceIds.length === 0)
2106
+ this.multiplayerGroupsById.delete(group.id);
2107
+ }
2108
+ }
2109
+ groupAttachmentConflict(instanceId, groupId) {
2110
+ const incomingRoles = new Set(this.getPeers().filter((peer) => peer.instanceId === instanceId).map((peer) => peer.role));
2111
+ return this.getPeers().find((peer) => peer.instanceId !== instanceId && peer.multiplayerGroupId === groupId && incomingRoles.has(peer.role));
2112
+ }
2113
+ attachInstanceToGroup(instanceId, groupId) {
2114
+ const conflict = this.groupAttachmentConflict(instanceId, groupId);
2115
+ if (conflict) {
2116
+ throw new Error(`Cannot attach Instance "${instanceId}" to Multiplayer Group "${groupId}": role "${conflict.role}" is already owned by Peer "${conflict.peerId}".`);
2117
+ }
2118
+ this.detachInstanceFromOtherGroups(instanceId, groupId);
2119
+ let group = this.multiplayerGroupsById.get(groupId);
2120
+ if (!group) {
2121
+ group = { id: groupId, instanceIds: [], createdAt: Date.now() };
2122
+ this.multiplayerGroupsById.set(groupId, group);
2123
+ }
2124
+ if (!group.instanceIds.includes(instanceId))
2125
+ group.instanceIds.push(instanceId);
2126
+ for (const peer of this.peersById.values()) {
2127
+ if (peer.instanceId === instanceId)
2128
+ peer.multiplayerGroupId = groupId;
2129
+ }
2130
+ return group;
2131
+ }
2132
+ createMultiplayerGroup(groupId, controllerInstanceId) {
2133
+ const group = this.attachInstanceToGroup(controllerInstanceId, groupId);
2134
+ group.controllerInstanceId = controllerInstanceId;
2135
+ return copyGroup(group);
2136
+ }
2137
+ async createMultiplayerGroupEverywhere(groupId, controllerInstanceId) {
2138
+ return this.createMultiplayerGroup(groupId, controllerInstanceId);
2139
+ }
2140
+ removeMultiplayerGroup(groupId) {
2141
+ const group = this.multiplayerGroupsById.get(groupId);
2142
+ if (!group)
2143
+ return void 0;
2144
+ this.multiplayerGroupsById.delete(groupId);
2145
+ for (const peer of this.peersById.values()) {
2146
+ if (peer.multiplayerGroupId === groupId)
2147
+ peer.multiplayerGroupId = void 0;
2148
+ }
2149
+ return copyGroup(group);
2150
+ }
2151
+ async removeMultiplayerGroupEverywhere(groupId) {
2152
+ return this.removeMultiplayerGroup(groupId);
2153
+ }
2154
+ connectedInstanceIdCollision(peerId, instanceId, role, multiplayerGroupId) {
2155
+ const peers = this.getPeers().filter((peer) => peer.peerId !== peerId);
2156
+ const canonicalCollision = peers.find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
2157
+ if (canonicalCollision)
2158
+ return canonicalCollision;
2159
+ if (multiplayerGroupId === void 0 || !isRuntimeRole(role))
2160
+ return void 0;
2161
+ const runtimeAlias = `${instanceId}-${role}`;
2162
+ return peers.find((peer) => peer.instanceId === runtimeAlias);
2084
2163
  }
2085
- migratePendingRequests(fromInstanceId, toInstanceId) {
2086
- if (fromInstanceId === toInstanceId)
2087
- return;
2088
- for (const request of this.pendingRequests.values()) {
2089
- if (request.targetInstanceId === fromInstanceId) {
2090
- request.targetInstanceId = toInstanceId;
2091
- }
2164
+ registerPeer(input) {
2165
+ const prior = this.peersById.get(input.peerId);
2166
+ if (prior && (prior.instanceId !== input.instanceId || prior.transportPeerId !== input.transportPeerId)) {
2167
+ return {
2168
+ ok: false,
2169
+ error: {
2170
+ code: "peer_identity_mismatch",
2171
+ message: `Peer "${input.peerId}" is already registered to Instance "${prior.instanceId}" through transport Peer "${prior.transportPeerId}".`,
2172
+ existing: toPublicPeer(prior)
2173
+ }
2174
+ };
2092
2175
  }
2093
- }
2094
- cleanupStaleAliases(now = Date.now()) {
2095
- for (const [alias, entry] of this.instanceAliases.entries()) {
2096
- const targetIsLive = this.getInstances().some((inst) => inst.instanceId === entry.targetInstanceId);
2097
- if (!targetIsLive && now - entry.lastSeen > INSTANCE_ALIAS_TTL_MS) {
2098
- this.instanceAliases.delete(alias);
2099
- }
2176
+ const multiplayerGroupId = input.multiplayerGroupId ?? this.groupIdForInstance(input.instanceId) ?? prior?.multiplayerGroupId;
2177
+ const attachmentConflict = multiplayerGroupId ? this.groupAttachmentConflict(input.instanceId, multiplayerGroupId) : void 0;
2178
+ if (attachmentConflict) {
2179
+ return {
2180
+ ok: false,
2181
+ error: {
2182
+ code: "duplicate_scope_role",
2183
+ message: `Multiplayer Group "${multiplayerGroupId}" already has a Peer registered as "${attachmentConflict.role}".`,
2184
+ existing: toPublicPeer(attachmentConflict)
2185
+ }
2186
+ };
2100
2187
  }
2101
- }
2102
- routingKeyForInstance(inst) {
2103
- return publishedInstanceId(inst.placeId) ?? this.resolveInstanceAlias(inst.instanceId);
2104
- }
2105
- matchingInstancesForInstanceId(instanceId) {
2106
- const resolvedInstanceId = this.resolveInstanceAlias(instanceId);
2107
- const ids = /* @__PURE__ */ new Set([instanceId, resolvedInstanceId]);
2108
- const placeIds = /* @__PURE__ */ new Set();
2109
- const addPlaceId = (placeId) => {
2110
- const published = publishedInstanceId(placeId);
2111
- if (!published || placeId === void 0)
2112
- return;
2113
- ids.add(published);
2114
- placeIds.add(Math.trunc(placeId));
2115
- };
2116
- const placeMatch = resolvedInstanceId.match(/^place:(\d+)$/) ?? instanceId.match(/^place:(\d+)$/);
2117
- if (placeMatch)
2118
- addPlaceId(Number(placeMatch[1]));
2119
- for (const inst of this.getInstances()) {
2120
- if (ids.has(inst.instanceId))
2121
- addPlaceId(inst.placeId);
2122
- }
2123
- return this.getInstances().filter((inst) => ids.has(inst.instanceId) || inst.placeId > 0 && placeIds.has(Math.trunc(inst.placeId)));
2124
- }
2125
- resolveInstanceId(instanceId) {
2126
- return this.resolveInstanceAlias(instanceId);
2127
- }
2128
- registerInstance(input) {
2129
- const { pluginSessionId, role } = input;
2130
- const rawInstanceId = input.instanceId;
2131
- const instanceId = this.canonicalInstanceId(rawInstanceId, input.placeId);
2132
- const prior = this.instances.get(pluginSessionId);
2133
- let assignedRole = role;
2134
- const pluginVersion = input.pluginVersion ?? "";
2135
- const pluginVariant = input.pluginVariant ?? "unknown";
2136
- const serverVersion = input.serverVersion ?? "";
2137
- this.rememberInstanceAlias(rawInstanceId, instanceId);
2138
- if (prior && prior.instanceId !== instanceId) {
2139
- this.rememberInstanceAlias(prior.instanceId, instanceId);
2140
- this.migratePendingRequests(prior.instanceId, instanceId);
2141
- }
2142
- if (role === "client") {
2143
- if (prior && prior.role.match(/^client-\d+$/)) {
2188
+ const scopePeers = this.registrationScopePeers(input.instanceId, multiplayerGroupId).filter((peer) => peer.peerId !== input.peerId);
2189
+ let assignedRole = input.role;
2190
+ if (input.role === "client") {
2191
+ const used = /* @__PURE__ */ new Set();
2192
+ for (const peer of scopePeers) {
2193
+ const match = /^client-(\d+)$/.exec(peer.role);
2194
+ if (match)
2195
+ used.add(Number(match[1]));
2196
+ }
2197
+ const priorOrdinal = prior ? /^client-(\d+)$/.exec(prior.role) : null;
2198
+ if (prior && priorOrdinal && !used.has(Number(priorOrdinal[1]))) {
2144
2199
  assignedRole = prior.role;
2145
2200
  } else {
2146
- const used = /* @__PURE__ */ new Set();
2147
- for (const inst of this.instances.values()) {
2148
- if (inst.instanceId !== instanceId || inst.pluginSessionId === pluginSessionId)
2149
- continue;
2150
- const match = inst.role.match(/^client-(\d+)$/);
2151
- if (match)
2152
- used.add(Number(match[1]));
2153
- }
2154
- let idx = 1;
2155
- while (used.has(idx))
2156
- idx++;
2157
- assignedRole = `client-${idx}`;
2201
+ let ordinal = 1;
2202
+ while (used.has(ordinal))
2203
+ ordinal += 1;
2204
+ assignedRole = `client-${ordinal}`;
2158
2205
  }
2159
2206
  }
2160
- const existing = Array.from(this.instances.values()).find((i) => i.instanceId === instanceId && i.role === assignedRole && i.pluginSessionId !== pluginSessionId);
2207
+ const instanceIdCollision = this.connectedInstanceIdCollision(input.peerId, input.instanceId, assignedRole, multiplayerGroupId);
2208
+ if (instanceIdCollision) {
2209
+ return {
2210
+ ok: false,
2211
+ error: {
2212
+ code: "instance_id_alias_collision",
2213
+ message: `Instance "${input.instanceId}" would make a grouped runtime Instance ID ambiguous.`,
2214
+ existing: toPublicPeer(instanceIdCollision)
2215
+ }
2216
+ };
2217
+ }
2218
+ const existing = scopePeers.find((peer) => peer.role === assignedRole);
2161
2219
  if (existing) {
2162
- const existingDeliveryActive = (this.deliveryOwnersByPhysicalSession.get(existing.physicalSessionId)?.size ?? 0) > 0;
2163
- if (!existingDeliveryActive && Date.now() - existing.lastActivity > DUPLICATE_TAKEOVER_MS) {
2164
- this.unregisterInstance(existing.pluginSessionId);
2165
- } else {
2166
- return {
2167
- ok: false,
2168
- error: {
2169
- code: "duplicate_instance_role",
2170
- message: `Another plugin is already registered as (${instanceId}, ${assignedRole}).`,
2171
- existing: toPublic(existing)
2172
- }
2173
- };
2174
- }
2220
+ const scopeDescription = multiplayerGroupId ? `Multiplayer Group "${multiplayerGroupId}"` : `Instance "${input.instanceId}"`;
2221
+ return {
2222
+ ok: false,
2223
+ error: {
2224
+ code: "duplicate_scope_role",
2225
+ message: `${scopeDescription} already has a Peer registered as "${assignedRole}".`,
2226
+ existing: toPublicPeer(existing)
2227
+ }
2228
+ };
2175
2229
  }
2230
+ const now = Date.now();
2176
2231
  const registered = {
2177
- pluginSessionId,
2178
- physicalSessionId: input.physicalSessionId,
2179
- instanceId,
2232
+ peerId: input.peerId,
2233
+ transportPeerId: input.transportPeerId,
2234
+ instanceId: input.instanceId,
2235
+ multiplayerGroupId,
2180
2236
  role: assignedRole,
2181
2237
  placeId: input.placeId ?? 0,
2182
2238
  placeName: input.placeName ?? "",
2239
+ placeKey: input.placeKey,
2183
2240
  dataModelName: input.dataModelName ?? "",
2184
2241
  isRunning: input.isRunning ?? false,
2185
- pluginVersion,
2186
- pluginVariant,
2187
- serverVersion,
2188
- lastActivity: Date.now(),
2189
- connectedAt: prior?.connectedAt ?? Date.now()
2242
+ pluginVersion: input.pluginVersion ?? "",
2243
+ pluginVariant: input.pluginVariant ?? "unknown",
2244
+ serverVersion: input.serverVersion ?? "",
2245
+ lastActivity: now,
2246
+ connectedAt: prior?.connectedAt ?? now
2247
+ };
2248
+ this.peersById.set(input.peerId, registered);
2249
+ if (multiplayerGroupId)
2250
+ this.attachInstanceToGroup(input.instanceId, multiplayerGroupId);
2251
+ this.notifyPeerRegistered(toPublicPeer(registered));
2252
+ this.notifyRequestAvailable(registered.transportPeerId);
2253
+ return {
2254
+ ok: true,
2255
+ assignedRole,
2256
+ peerId: registered.peerId,
2257
+ instanceId: registered.instanceId,
2258
+ multiplayerGroupId: registered.multiplayerGroupId
2190
2259
  };
2191
- this.instances.set(pluginSessionId, registered);
2192
- this.notifyInstanceRegistered(toPublic(registered));
2193
- this.notifyRequestAvailable(registered.physicalSessionId);
2194
- return { ok: true, assignedRole, instanceId };
2195
2260
  }
2196
- unregisterInstance(pluginSessionId) {
2197
- const removed = this.instances.get(pluginSessionId);
2261
+ unregisterPeer(peerId) {
2262
+ this.unregisterPeerInternal(peerId, /* @__PURE__ */ new Set());
2263
+ }
2264
+ unregisterPeerInternal(peerId, visited, removedPeers) {
2265
+ if (visited.has(peerId))
2266
+ return;
2267
+ visited.add(peerId);
2268
+ const removed = this.peersById.get(peerId);
2198
2269
  if (!removed)
2199
2270
  return;
2200
- const logicalChildren = removed.physicalSessionId === pluginSessionId ? Array.from(this.instances.values()).filter((instance) => instance.pluginSessionId !== pluginSessionId && instance.physicalSessionId === pluginSessionId) : [];
2201
- this.instances.delete(pluginSessionId);
2202
- const session = {
2203
- logicalSessionId: pluginSessionId,
2204
- physicalSessionId: removed.physicalSessionId
2205
- };
2206
- for (const listener of this.sessionClosedListeners) {
2271
+ removedPeers?.push(removed);
2272
+ const dependentPeerIds = removed.transportPeerId === peerId ? this.getPeers().filter((peer) => peer.peerId !== peerId && peer.transportPeerId === peerId).map((peer) => peer.peerId) : [];
2273
+ this.peersById.delete(peerId);
2274
+ const session = { peerId, transportPeerId: removed.transportPeerId };
2275
+ for (const listener of this.peerClosedListeners) {
2207
2276
  try {
2208
2277
  listener(session);
2209
2278
  } catch {
2210
2279
  }
2211
2280
  }
2212
- if (removed.physicalSessionId === pluginSessionId) {
2213
- this.deliveryOwnersByPhysicalSession.delete(pluginSessionId);
2281
+ if (removed.transportPeerId === peerId) {
2282
+ this.deliveryOwnersByTransportPeer.delete(peerId);
2214
2283
  }
2215
- for (const child of logicalChildren)
2216
- this.unregisterInstance(child.pluginSessionId);
2217
- for (const req of Array.from(this.pendingRequests.values())) {
2218
- const stillHasHandler = Array.from(this.instances.values()).some((i) => i.instanceId === req.targetInstanceId && i.role === req.targetRole);
2219
- if (!stillHasHandler) {
2220
- this.removePendingRequest(req);
2221
- req.reject(new Error(`Target (${req.targetInstanceId}, ${req.targetRole}) disconnected`));
2222
- }
2284
+ for (const request of Array.from(this.pendingRequests.values())) {
2285
+ if (request.targetPeerId !== peerId)
2286
+ continue;
2287
+ const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2288
+ this.removePendingRequest(request);
2289
+ request.reject(new Error(`Target Peer "${peerId}" disconnected`));
2290
+ if (deliveryTransportPeerId)
2291
+ this.notifyRequestAvailable(deliveryTransportPeerId);
2292
+ }
2293
+ for (const dependentPeerId of dependentPeerIds) {
2294
+ this.unregisterPeerInternal(dependentPeerId, visited, removedPeers);
2223
2295
  }
2296
+ this.removeInstanceFromGroupsWhenDisconnected(removed.instanceId);
2297
+ }
2298
+ removeInstanceFromGroupsWhenDisconnected(instanceId) {
2299
+ if (this.getPeers().some((peer) => peer.instanceId === instanceId))
2300
+ return;
2301
+ this.detachInstanceFromOtherGroups(instanceId);
2224
2302
  }
2225
2303
  unregisterInstanceId(instanceId) {
2226
- const matching = this.matchingInstancesForInstanceId(instanceId);
2227
- const removed = matching.map(toPublic);
2228
- for (const inst of matching) {
2229
- this.unregisterInstance(inst.pluginSessionId);
2304
+ const matching = this.getPeers().filter((peer) => peer.instanceId === instanceId);
2305
+ const departingRuntimeGroupIds = new Set(matching.flatMap((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) ? [peer.multiplayerGroupId] : []));
2306
+ const removedPeers = [];
2307
+ const visited = /* @__PURE__ */ new Set();
2308
+ for (const peer of matching) {
2309
+ this.unregisterPeerInternal(peer.peerId, visited, removedPeers);
2230
2310
  }
2231
- return removed;
2311
+ this.detachInstanceFromOtherGroups(instanceId);
2312
+ for (const groupId of departingRuntimeGroupIds) {
2313
+ const hasRuntimePeer = this.getPeers().some((peer) => peer.multiplayerGroupId === groupId && isRuntimeRole(peer.role));
2314
+ if (!hasRuntimePeer)
2315
+ this.removeMultiplayerGroup(groupId);
2316
+ }
2317
+ return removedPeers.map(toPublicPeer);
2232
2318
  }
2233
2319
  async unregisterInstanceIdEverywhere(instanceId) {
2234
2320
  return this.unregisterInstanceId(instanceId);
2235
2321
  }
2322
+ getPeers() {
2323
+ return Array.from(this.peersById.values());
2324
+ }
2325
+ getPublicPeers() {
2326
+ return this.getPeers().map(toPublicPeer);
2327
+ }
2328
+ getPeerById(peerId) {
2329
+ return this.getPeers().find((peer) => peer.peerId === peerId);
2330
+ }
2236
2331
  getInstances() {
2237
- return Array.from(this.instances.values());
2332
+ const peersByInstance = /* @__PURE__ */ new Map();
2333
+ for (const peer of this.getPeers()) {
2334
+ const peers = peersByInstance.get(peer.instanceId);
2335
+ if (peers)
2336
+ peers.push(peer);
2337
+ else
2338
+ peersByInstance.set(peer.instanceId, [peer]);
2339
+ }
2340
+ return Array.from(peersByInstance, ([id, peers]) => {
2341
+ const preferred = preferredPeer(peers);
2342
+ return {
2343
+ id,
2344
+ multiplayerGroupId: this.groupIdForInstance(id) ?? preferred.multiplayerGroupId,
2345
+ placeId: preferred.placeId,
2346
+ placeName: preferred.placeName,
2347
+ peers
2348
+ };
2349
+ });
2238
2350
  }
2239
2351
  getPublicInstances() {
2240
- return this.getInstances().map(toPublic);
2352
+ return this.getInstances().map((instance) => ({
2353
+ id: instance.id,
2354
+ multiplayerGroupId: instance.multiplayerGroupId,
2355
+ placeId: instance.placeId,
2356
+ placeName: instance.placeName,
2357
+ peers: instance.peers.map(toPublicPeer)
2358
+ }));
2359
+ }
2360
+ getConnectedInstances() {
2361
+ return this.getInstances().flatMap((instance) => {
2362
+ const peers = instance.multiplayerGroupId === void 0 ? instance.peers : instance.peers.filter((peer) => !isRuntimeRole(peer.role));
2363
+ if (peers.length === 0)
2364
+ return [];
2365
+ return [{
2366
+ id: instance.id,
2367
+ multiplayerGroupId: instance.multiplayerGroupId,
2368
+ placeId: instance.placeId,
2369
+ placeName: instance.placeName,
2370
+ peers: peerIdsByRole(peers)
2371
+ }];
2372
+ });
2373
+ }
2374
+ getConnectedMultiplayerGroups() {
2375
+ const peers = this.getPeers();
2376
+ return this.getMultiplayerGroups().map((group) => ({
2377
+ id: group.id,
2378
+ controllerInstanceId: group.controllerInstanceId,
2379
+ instances: Object.fromEntries(peers.filter((peer) => peer.multiplayerGroupId === group.id && isRuntimeRole(peer.role)).sort((left, right) => roleOrder(left.role) - roleOrder(right.role) || left.peerId.localeCompare(right.peerId)).map((peer) => [connectedRuntimeInstanceId(peer), peer.peerId]))
2380
+ }));
2241
2381
  }
2242
- getInstanceBySessionId(pluginSessionId) {
2243
- return this.instances.get(pluginSessionId);
2382
+ getMultiplayerGroups() {
2383
+ return Array.from(this.multiplayerGroupsById.values(), copyGroup);
2384
+ }
2385
+ getPublicMultiplayerGroups() {
2386
+ return this.getMultiplayerGroups().map(copyGroup);
2387
+ }
2388
+ getTopologySnapshot() {
2389
+ return {
2390
+ peers: this.getPeers(),
2391
+ instances: this.getInstances(),
2392
+ multiplayerGroups: this.getMultiplayerGroups()
2393
+ };
2394
+ }
2395
+ resolveConnectedInstanceId(instanceId) {
2396
+ const exact = this.getInstances().find((instance) => instance.id === instanceId);
2397
+ const groupedRuntime = this.getPeers().find((peer) => peer.multiplayerGroupId !== void 0 && isRuntimeRole(peer.role) && connectedRuntimeInstanceId(peer) === instanceId);
2398
+ if (exact && groupedRuntime && exact.id !== groupedRuntime.instanceId)
2399
+ return void 0;
2400
+ return exact?.id ?? groupedRuntime?.instanceId;
2401
+ }
2402
+ getInstanceIdsInScope(instanceId) {
2403
+ const resolvedInstanceId = this.resolveConnectedInstanceId(instanceId);
2404
+ if (resolvedInstanceId === void 0)
2405
+ return [];
2406
+ const groupId = this.groupIdForInstance(resolvedInstanceId);
2407
+ if (groupId) {
2408
+ return [...this.getMultiplayerGroups().find((group) => group.id === groupId)?.instanceIds ?? []];
2409
+ }
2410
+ return [resolvedInstanceId];
2411
+ }
2412
+ getPeersInScope(instanceId) {
2413
+ const instanceIds = new Set(this.getInstanceIdsInScope(instanceId));
2414
+ return this.getPeers().filter((peer) => instanceIds.has(peer.instanceId));
2244
2415
  }
2245
2416
  getPendingRequestCount() {
2246
2417
  return this.pendingRequests.size;
2247
2418
  }
2248
- updateInstanceActivity(pluginSessionId) {
2249
- const instance = this.instances.get(pluginSessionId);
2250
- if (!instance)
2419
+ updatePeerActivity(peerId) {
2420
+ const peer = this.getPeerById(peerId);
2421
+ if (!peer)
2251
2422
  return;
2252
2423
  const now = Date.now();
2253
- for (const candidate of this.instances.values()) {
2254
- if (candidate.physicalSessionId === instance.physicalSessionId) {
2255
- candidate.lastActivity = now;
2424
+ if (peer.transportPeerId === peerId) {
2425
+ for (const candidate of this.getPeers()) {
2426
+ if (candidate.transportPeerId === peerId)
2427
+ candidate.lastActivity = now;
2256
2428
  }
2429
+ return;
2257
2430
  }
2431
+ peer.lastActivity = now;
2258
2432
  }
2259
- updateInstanceMetadata(pluginSessionId, metadata) {
2260
- const inst = this.instances.get(pluginSessionId);
2261
- if (!inst)
2433
+ updatePeerMetadata(peerId, metadata) {
2434
+ const peer = this.getPeerById(peerId);
2435
+ if (!peer)
2262
2436
  return;
2263
- const priorInstanceId = inst.instanceId;
2264
2437
  if (metadata.placeId !== void 0)
2265
- inst.placeId = metadata.placeId;
2438
+ peer.placeId = metadata.placeId;
2266
2439
  if (metadata.placeName !== void 0)
2267
- inst.placeName = metadata.placeName;
2440
+ peer.placeName = metadata.placeName;
2441
+ if (metadata.placeKey !== void 0)
2442
+ peer.placeKey = metadata.placeKey;
2268
2443
  if (metadata.dataModelName !== void 0)
2269
- inst.dataModelName = metadata.dataModelName;
2444
+ peer.dataModelName = metadata.dataModelName;
2270
2445
  if (metadata.isRunning !== void 0)
2271
- inst.isRunning = metadata.isRunning;
2272
- const canonicalInstanceId = this.canonicalInstanceId(inst.instanceId, inst.placeId);
2273
- if (canonicalInstanceId !== inst.instanceId) {
2274
- const duplicate = Array.from(this.instances.values()).find((other) => other.pluginSessionId !== pluginSessionId && other.instanceId === canonicalInstanceId && other.role === inst.role);
2275
- if (!duplicate) {
2276
- this.rememberInstanceAlias(priorInstanceId, canonicalInstanceId);
2277
- this.migratePendingRequests(priorInstanceId, canonicalInstanceId);
2278
- inst.instanceId = canonicalInstanceId;
2279
- this.notifyRequestAvailable(inst.physicalSessionId);
2280
- }
2281
- }
2446
+ peer.isRunning = metadata.isRunning;
2282
2447
  }
2283
- cleanupStaleInstances() {
2448
+ cleanupStalePeers() {
2284
2449
  const now = Date.now();
2285
- for (const [id, inst] of this.instances.entries()) {
2286
- const deliveryActive = (this.deliveryOwnersByPhysicalSession.get(inst.physicalSessionId)?.size ?? 0) > 0;
2287
- if (!deliveryActive && now - inst.lastActivity > STALE_INSTANCE_MS) {
2288
- this.unregisterInstance(id);
2450
+ for (const peer of this.getPeers()) {
2451
+ const deliveryActive = (this.deliveryOwnersByTransportPeer.get(peer.transportPeerId)?.size ?? 0) > 0;
2452
+ if (!deliveryActive && now - peer.lastActivity > STALE_PEER_MS) {
2453
+ this.unregisterPeer(peer.peerId);
2289
2454
  }
2290
2455
  }
2291
- this.cleanupStaleAliases(now);
2292
2456
  }
2293
- getEquivalentInstanceIds(instanceId) {
2294
- const resolvedInstanceId = this.resolveInstanceAlias(instanceId);
2295
- const ids = /* @__PURE__ */ new Set([instanceId, resolvedInstanceId]);
2296
- const placeIds = /* @__PURE__ */ new Set();
2297
- const addPlaceId = (placeId) => {
2298
- const published = publishedInstanceId(placeId);
2299
- if (!published || placeId === void 0)
2300
- return;
2301
- ids.add(published);
2302
- placeIds.add(Math.trunc(placeId));
2457
+ routingErrorData() {
2458
+ const instances = this.getConnectedInstances();
2459
+ const multiplayerGroups = this.getConnectedMultiplayerGroups();
2460
+ return {
2461
+ instances,
2462
+ multiplayerGroups,
2463
+ count: instances.length + multiplayerGroups.length
2303
2464
  };
2304
- const placeMatch = resolvedInstanceId.match(/^place:(\d+)$/) ?? instanceId.match(/^place:(\d+)$/);
2305
- if (placeMatch)
2306
- addPlaceId(Number(placeMatch[1]));
2307
- for (const inst of this.getInstances()) {
2308
- if (ids.has(inst.instanceId)) {
2309
- addPlaceId(inst.placeId);
2310
- }
2465
+ }
2466
+ resolvedTarget(peer) {
2467
+ return {
2468
+ targetPeerId: peer.peerId,
2469
+ targetInstanceId: peer.instanceId,
2470
+ targetRole: peer.role
2471
+ };
2472
+ }
2473
+ resolveWithinScope(peers, target, errorData, selectedInstanceId) {
2474
+ if (target === "all") {
2475
+ return { ok: true, mode: "fanout", targets: peers.map((peer) => this.resolvedTarget(peer)) };
2311
2476
  }
2312
- for (const inst of this.getInstances()) {
2313
- if (inst.placeId > 0 && placeIds.has(Math.trunc(inst.placeId))) {
2314
- ids.add(inst.instanceId);
2477
+ if (target) {
2478
+ const exact = peers.find((peer) => peer.role === target);
2479
+ if (!exact) {
2480
+ return {
2481
+ ok: false,
2482
+ error: {
2483
+ code: "target_role_not_present_on_instance",
2484
+ message: `${selectedInstanceId ? `Instance "${selectedInstanceId}" scope` : "The connected scope"} has no role "${target}". Available roles: ${peers.map((peer) => peer.role).join(", ")}.`,
2485
+ data: errorData
2486
+ }
2487
+ };
2315
2488
  }
2489
+ return { ok: true, mode: "single", ...this.resolvedTarget(exact) };
2316
2490
  }
2317
- for (const [alias, entry] of this.instanceAliases.entries()) {
2318
- if (ids.has(entry.targetInstanceId))
2319
- ids.add(alias);
2491
+ const edit = peers.find((peer) => peer.role === "edit");
2492
+ if (edit)
2493
+ return { ok: true, mode: "single", ...this.resolvedTarget(edit) };
2494
+ if (peers.length === 1) {
2495
+ return { ok: true, mode: "single", ...this.resolvedTarget(peers[0]) };
2320
2496
  }
2321
- return Array.from(ids);
2497
+ return {
2498
+ ok: false,
2499
+ error: {
2500
+ code: "target_role_required",
2501
+ message: `${selectedInstanceId ? `Instance "${selectedInstanceId}" scope` : "The connected scope"} has multiple roles connected: ${peers.map((peer) => peer.role).join(", ")}. Pass target=<role>.`,
2502
+ data: errorData
2503
+ }
2504
+ };
2322
2505
  }
2323
- // Resolves (instance_id, target-role) MCP arguments to a concrete
2324
- // routing decision: either a single (instanceId, role) tuple or a fanout
2325
- // list. Returns an error result with the full instance list embedded so
2326
- // the caller (tool layer) can surface it without a second round-trip.
2327
2506
  resolveTarget(input) {
2328
- const instances = this.getInstances();
2329
- const publicList = instances.map(toPublic);
2330
- const errorData = { instances: publicList, count: publicList.length };
2331
- const { instance_id, target } = input;
2332
- const isFanout = target === "all";
2333
- const role = target && target !== "all" ? target : void 0;
2334
- if (instance_id !== void 0) {
2335
- const matchingInstances = this.matchingInstancesForInstanceId(instance_id);
2336
- if (matchingInstances.length === 0) {
2507
+ const errorData = this.routingErrorData();
2508
+ if (input.instance_id !== void 0) {
2509
+ const peers2 = this.getPeersInScope(input.instance_id);
2510
+ if (peers2.length === 0) {
2337
2511
  return {
2338
2512
  ok: false,
2339
2513
  error: {
2340
2514
  code: "unrecognized_instance_id",
2341
- message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
2515
+ message: `instance_id "${input.instance_id}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
2342
2516
  data: errorData
2343
2517
  }
2344
2518
  };
2345
2519
  }
2346
- if (isFanout) {
2347
- return {
2348
- ok: true,
2349
- mode: "fanout",
2350
- targets: matchingInstances.map((i) => ({
2351
- targetInstanceId: i.instanceId,
2352
- targetRole: i.role
2353
- }))
2354
- };
2355
- }
2356
- if (role) {
2357
- const exact = matchingInstances.find((i) => i.role === role);
2358
- if (!exact) {
2359
- return {
2360
- ok: false,
2361
- error: {
2362
- code: "target_role_not_present_on_instance",
2363
- message: `instance "${instance_id}" has no role "${role}". Available roles: ${matchingInstances.map((i) => i.role).join(", ")}.`,
2364
- data: errorData
2365
- }
2366
- };
2367
- }
2368
- return { ok: true, mode: "single", targetInstanceId: exact.instanceId, targetRole: role };
2369
- }
2370
- if (matchingInstances.length === 1) {
2371
- return {
2372
- ok: true,
2373
- mode: "single",
2374
- targetInstanceId: matchingInstances[0].instanceId,
2375
- targetRole: matchingInstances[0].role
2376
- };
2377
- }
2378
- const edit = matchingInstances.find((i) => i.role === "edit");
2379
- if (edit) {
2380
- return { ok: true, mode: "single", targetInstanceId: edit.instanceId, targetRole: "edit" };
2381
- }
2520
+ return this.resolveWithinScope(peers2, input.target, errorData, input.instance_id);
2521
+ }
2522
+ const scopeKeys = new Set(this.getPeers().map((peer) => this.peerScopeKey(peer)));
2523
+ if (scopeKeys.size === 0) {
2382
2524
  return {
2383
2525
  ok: false,
2384
2526
  error: {
2385
- code: "target_role_required",
2386
- message: `instance "${instance_id}" has multiple roles connected: ${matchingInstances.map((i) => i.role).join(", ")}. Pass target=<role>.`,
2527
+ code: "unrecognized_instance_id",
2528
+ message: "No Studio Peer is connected.",
2387
2529
  data: errorData
2388
2530
  }
2389
2531
  };
2390
2532
  }
2391
- const distinctInstanceIds = new Set(instances.map((i) => this.routingKeyForInstance(i)));
2392
- if (distinctInstanceIds.size === 0) {
2533
+ if (scopeKeys.size > 1) {
2534
+ const code = input.target ? "ambiguous_target" : "multiple_instances_connected";
2393
2535
  return {
2394
2536
  ok: false,
2395
2537
  error: {
2396
- code: "unrecognized_instance_id",
2397
- message: "No Studio plugin is connected.",
2538
+ code,
2539
+ message: input.target ? `target=${input.target} is ambiguous because multiple Studio routing scopes are connected. Pass instance_id to choose a scope.` : "Multiple Studio routing scopes are connected. Pass instance_id to disambiguate.",
2398
2540
  data: errorData
2399
2541
  }
2400
2542
  };
2401
2543
  }
2402
- if (distinctInstanceIds.size > 1) {
2403
- const errorCode2 = role ? "ambiguous_target" : "multiple_instances_connected";
2404
- 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.";
2405
- return { ok: false, error: { code: errorCode2, message: msg, data: errorData } };
2406
- }
2407
- const onlyInstanceId = distinctInstanceIds.values().next().value;
2408
- return this.resolveTarget({ instance_id: onlyInstanceId, target });
2544
+ const onlyScope = scopeKeys.values().next().value;
2545
+ const peers = this.getPeers().filter((peer) => this.peerScopeKey(peer) === onlyScope);
2546
+ return this.resolveWithinScope(peers, input.target, errorData);
2409
2547
  }
2410
- async sendRequest(endpoint, data, targetInstanceId, targetRole, timeoutMs = this.requestTimeout, signal) {
2548
+ sendRequest(endpoint, data, targetPeerId, timeoutMs = this.requestTimeout, signal) {
2411
2549
  const requestId = randomUUID();
2412
2550
  const effectiveTimeoutMs = Math.max(1, timeoutMs);
2413
2551
  if (signal?.aborted)
2414
- throw new Error("Request aborted");
2415
- return new Promise((resolve5, reject) => {
2416
- const cancelPending = (reason, error) => {
2417
- const pending = this.pendingRequests.get(requestId);
2418
- if (!pending || !this.removePendingRequest(pending))
2419
- return;
2420
- this.notifyRequestCancelled(pending, reason);
2421
- pending.reject(error);
2422
- };
2423
- const timeoutId = setTimeout(() => cancelPending("timeout", new Error("Request timeout")), effectiveTimeoutMs);
2424
- const abortListener = () => cancelPending("aborted", new Error("Request aborted"));
2425
- const request = {
2426
- id: requestId,
2427
- endpoint,
2428
- data,
2429
- targetInstanceId,
2430
- targetRole,
2431
- timestamp: Date.now(),
2432
- resolve: resolve5,
2433
- reject,
2434
- timeoutId,
2435
- timeoutMs: effectiveTimeoutMs,
2436
- abortSignal: signal,
2437
- abortListener
2438
- };
2439
- this.pendingRequests.set(requestId, request);
2440
- signal?.addEventListener("abort", abortListener, { once: true });
2441
- if (signal?.aborted)
2442
- abortListener();
2443
- if (this.pendingRequests.has(requestId)) {
2444
- for (const physicalSessionId of this.physicalSessionsForTarget(targetInstanceId, targetRole)) {
2445
- this.notifyRequestAvailable(physicalSessionId);
2446
- }
2447
- }
2448
- });
2552
+ return Promise.reject(new Error("Request aborted"));
2553
+ const { promise, resolve: resolve5, reject } = Promise.withResolvers();
2554
+ const cancelPending = (reason, error) => {
2555
+ const pending = this.pendingRequests.get(requestId);
2556
+ if (!pending || !this.removePendingRequest(pending))
2557
+ return;
2558
+ this.notifyRequestCancelled(pending, reason);
2559
+ pending.reject(error);
2560
+ };
2561
+ const timeoutId = setTimeout(() => cancelPending("timeout", new Error("Request timeout")), effectiveTimeoutMs);
2562
+ const abortListener = () => cancelPending("aborted", new Error("Request aborted"));
2563
+ const request = {
2564
+ id: requestId,
2565
+ endpoint,
2566
+ data,
2567
+ targetPeerId,
2568
+ timestamp: Date.now(),
2569
+ resolve: resolve5,
2570
+ reject,
2571
+ timeoutId,
2572
+ timeoutMs: effectiveTimeoutMs,
2573
+ abortSignal: signal,
2574
+ abortListener
2575
+ };
2576
+ this.pendingRequests.set(requestId, request);
2577
+ signal?.addEventListener("abort", abortListener, { once: true });
2578
+ if (signal?.aborted)
2579
+ abortListener();
2580
+ const target = this.getPeerById(targetPeerId);
2581
+ if (this.pendingRequests.has(requestId) && target) {
2582
+ this.notifyRequestAvailable(target.transportPeerId);
2583
+ }
2584
+ return promise;
2449
2585
  }
2450
2586
  removePendingRequest(request) {
2451
2587
  if (this.pendingRequests.get(request.id) !== request)
@@ -2457,43 +2593,44 @@ var BridgeService = class {
2457
2593
  this.pendingRequests.delete(request.id);
2458
2594
  return true;
2459
2595
  }
2460
- claimNextRequestForPhysical(physicalSessionId, claimOwner) {
2596
+ claimNextRequestForTransport(transportPeerId, claimOwner) {
2597
+ let outstandingCount = 0;
2598
+ for (const request of this.pendingRequests.values()) {
2599
+ if (request.claimOwner === claimOwner)
2600
+ outstandingCount++;
2601
+ }
2602
+ if (outstandingCount >= MAX_OUTSTANDING_REQUESTS_PER_DELIVERY_OWNER)
2603
+ return null;
2461
2604
  let oldestRequest;
2462
- let logicalSessionId = "";
2463
2605
  for (const request of this.pendingRequests.values()) {
2464
2606
  if (request.claimOwner !== void 0)
2465
2607
  continue;
2466
- let matchingSessionId;
2467
- for (const candidate of this.instances.values()) {
2468
- if (candidate.physicalSessionId === physicalSessionId && candidate.instanceId === request.targetInstanceId && candidate.role === request.targetRole) {
2469
- matchingSessionId = candidate.pluginSessionId;
2470
- break;
2471
- }
2472
- }
2473
- if (!matchingSessionId)
2608
+ const peer2 = this.getPeerById(request.targetPeerId);
2609
+ if (!peer2 || peer2.transportPeerId !== transportPeerId)
2474
2610
  continue;
2475
- if (!oldestRequest || request.timestamp < oldestRequest.timestamp) {
2611
+ if (!oldestRequest || request.timestamp < oldestRequest.timestamp)
2476
2612
  oldestRequest = request;
2477
- logicalSessionId = matchingSessionId;
2478
- }
2479
2613
  }
2480
2614
  if (!oldestRequest)
2481
2615
  return null;
2616
+ const peer = this.getPeerById(oldestRequest.targetPeerId);
2617
+ if (!peer)
2618
+ return null;
2482
2619
  oldestRequest.claimOwner = claimOwner;
2483
- oldestRequest.lastDeliveryPhysicalSessionId = physicalSessionId;
2620
+ oldestRequest.lastDeliveryTransportPeerId = transportPeerId;
2484
2621
  return {
2485
2622
  requestId: oldestRequest.id,
2486
- logicalSessionId,
2487
- target: oldestRequest.targetRole,
2623
+ peerId: oldestRequest.targetPeerId,
2624
+ target: peer.role,
2488
2625
  endpoint: oldestRequest.endpoint,
2489
2626
  data: oldestRequest.data,
2490
2627
  remainingMs: Math.max(1, oldestRequest.timeoutMs - (Date.now() - oldestRequest.timestamp))
2491
2628
  };
2492
2629
  }
2493
- claimNextCancellationForPhysical(physicalSessionId, claimOwner) {
2630
+ claimNextCancellationForTransport(transportPeerId, claimOwner) {
2494
2631
  this.prunePendingCancellations(Date.now());
2495
2632
  for (const cancellation of this.pendingCancellations.values()) {
2496
- if (cancellation.physicalSessionId !== physicalSessionId || cancellation.claimOwner !== void 0) {
2633
+ if (cancellation.transportPeerId !== transportPeerId || cancellation.claimOwner !== void 0) {
2497
2634
  continue;
2498
2635
  }
2499
2636
  cancellation.claimOwner = claimOwner;
@@ -2502,24 +2639,23 @@ var BridgeService = class {
2502
2639
  return null;
2503
2640
  }
2504
2641
  releaseDeliveryClaims(claimOwner) {
2505
- const physicalSessionIds = /* @__PURE__ */ new Set();
2642
+ const transportPeerIds = /* @__PURE__ */ new Set();
2506
2643
  for (const request of this.pendingRequests.values()) {
2507
2644
  if (request.claimOwner !== claimOwner)
2508
2645
  continue;
2509
2646
  request.claimOwner = void 0;
2510
- for (const physicalSessionId of this.physicalSessionsForTarget(request.targetInstanceId, request.targetRole)) {
2511
- physicalSessionIds.add(physicalSessionId);
2512
- }
2647
+ const peer = this.getPeerById(request.targetPeerId);
2648
+ if (peer)
2649
+ transportPeerIds.add(peer.transportPeerId);
2513
2650
  }
2514
2651
  for (const cancellation of this.pendingCancellations.values()) {
2515
2652
  if (cancellation.claimOwner !== claimOwner)
2516
2653
  continue;
2517
2654
  cancellation.claimOwner = void 0;
2518
- physicalSessionIds.add(cancellation.physicalSessionId);
2519
- }
2520
- for (const physicalSessionId of physicalSessionIds) {
2521
- this.notifyRequestAvailable(physicalSessionId);
2655
+ transportPeerIds.add(cancellation.transportPeerId);
2522
2656
  }
2657
+ for (const transportPeerId of transportPeerIds)
2658
+ this.notifyRequestAvailable(transportPeerId);
2523
2659
  }
2524
2660
  resolveRequest(requestId, response) {
2525
2661
  return this.settleRequest(requestId, (request) => request.resolve(response));
@@ -2531,13 +2667,15 @@ var BridgeService = class {
2531
2667
  const now = Date.now();
2532
2668
  this.pruneAcceptedRequestIds(now);
2533
2669
  const request = this.pendingRequests.get(requestId);
2534
- if (!request) {
2670
+ if (!request)
2535
2671
  return this.acceptedRequestIds.has(requestId) ? "already_settled" : "unknown";
2536
- }
2672
+ const deliveryTransportPeerId = request.lastDeliveryTransportPeerId;
2537
2673
  this.removePendingRequest(request);
2538
2674
  this.acceptedRequestIds.set(requestId, now);
2539
2675
  this.pruneAcceptedRequestIds(now);
2540
2676
  settle(request);
2677
+ if (deliveryTransportPeerId)
2678
+ this.notifyRequestAvailable(deliveryTransportPeerId);
2541
2679
  return "accepted";
2542
2680
  }
2543
2681
  pruneAcceptedRequestIds(now) {
@@ -2829,7 +2967,7 @@ Tool descriptions explain selection. Input schemas explain arguments. This guide
2829
2967
  ## Connection and paths
2830
2968
 
2831
2969
  - Use canonical DataModel paths returned by the tools. Paths usually start with game.
2832
- - Call get_connected_instances when more than one place may be connected. Pass the chosen row's id as instance_id on later calls.
2970
+ - 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.
2833
2971
  - Use get_place_info for the active place identity and settings.
2834
2972
 
2835
2973
  ## Discovery and edit work
@@ -2859,7 +2997,7 @@ Start solo_playtest or multiplayer_playtest before targeting a live server or cl
2859
2997
 
2860
2998
  execute_luau runs through the Studio plugin. eval_server_runtime and eval_client_runtime run inside a live game VM and share that VM's require cache with game scripts. Use the eval tools when module state or the runtime Script or LocalScript environment matters.
2861
2999
 
2862
- Read output with get_runtime_logs. Reuse nextSince or perCaptureNextSince for incremental reads instead of requesting the full buffer again.
3000
+ Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance, or nextCursorByInstance as cursor_by_instance for a Multiplayer Group, instead of requesting the full process streams again.
2863
3001
 
2864
3002
  ## Simulation and input
2865
3003
 
@@ -5388,7 +5526,7 @@ var INTERNAL_RESULT_KEYS = /* @__PURE__ */ new Set([
5388
5526
  "diagnostics",
5389
5527
  "internal",
5390
5528
  "lastActivity",
5391
- "pluginSessionId",
5529
+ "transportPeerId",
5392
5530
  "pluginVariant",
5393
5531
  "pluginVersion",
5394
5532
  "requestId",
@@ -5492,12 +5630,18 @@ function publicRoutingError(error) {
5492
5630
  return {
5493
5631
  error: error.routingError.code,
5494
5632
  message: error.routingError.message,
5633
+ count: error.routingError.data.count,
5495
5634
  instances: error.routingError.data.instances.map((instance) => ({
5496
- instance_id: instance.instanceId,
5497
- role: instance.role,
5635
+ instance_id: instance.id,
5636
+ multiplayer_group_id: instance.multiplayerGroupId,
5498
5637
  place_id: instance.placeId,
5499
5638
  place_name: instance.placeName,
5500
- running: instance.isRunning
5639
+ peers: instance.peers
5640
+ })),
5641
+ multiplayer_groups: error.routingError.data.multiplayerGroups.map((group) => ({
5642
+ multiplayer_group_id: group.id,
5643
+ controller_instance_id: group.controllerInstanceId,
5644
+ instances: group.instances
5501
5645
  }))
5502
5646
  };
5503
5647
  }
@@ -5563,7 +5707,7 @@ function serverInstructions(definitions) {
5563
5707
  const has = (...toolNames) => toolNames.every((name) => names.has(name));
5564
5708
  const instructions = ["Use canonical DataModel paths returned by tools."];
5565
5709
  if (has("get_connected_instances")) {
5566
- instructions.push("When more than one place is connected, call get_connected_instances and pass the chosen id as instance_id.");
5710
+ 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.");
5567
5711
  }
5568
5712
  if (has("search_objects", "get_project_structure", "grep_scripts", "execute_luau")) {
5569
5713
  instructions.push("Use search_objects, get_project_structure, or grep_scripts for standard discovery. Use execute_luau for custom traversal or bulk edits.");
@@ -5677,34 +5821,34 @@ var SseStudioTransport = class {
5677
5821
  queue;
5678
5822
  streams = /* @__PURE__ */ new Map();
5679
5823
  unsubscribeRequestAvailable;
5680
- unsubscribeSessionClosed;
5824
+ unsubscribePeerClosed;
5681
5825
  nextGeneration = 0;
5682
5826
  constructor(queue) {
5683
5827
  this.queue = queue;
5684
- this.unsubscribeRequestAvailable = queue.onRequestAvailable((physicalSessionId) => {
5685
- const stream = this.streams.get(physicalSessionId);
5828
+ this.unsubscribeRequestAvailable = queue.onRequestAvailable((transportPeerId) => {
5829
+ const stream = this.streams.get(transportPeerId);
5686
5830
  if (stream)
5687
5831
  this.pump(stream);
5688
5832
  });
5689
- this.unsubscribeSessionClosed = queue.onSessionClosed((route) => {
5690
- if (route.logicalSessionId === route.physicalSessionId) {
5691
- this.closePhysical(route.physicalSessionId);
5833
+ this.unsubscribePeerClosed = queue.onPeerClosed((route) => {
5834
+ if (route.peerId === route.transportPeerId) {
5835
+ this.closeTransport(route.transportPeerId);
5692
5836
  }
5693
5837
  });
5694
5838
  }
5695
5839
  get activeStreamCount() {
5696
5840
  return this.streams.size;
5697
5841
  }
5698
- canOpen(physicalSessionId) {
5699
- return this.streams.has(physicalSessionId) || this.streams.size < MAX_ACTIVE_EVENT_STREAMS;
5842
+ canOpen(transportPeerId) {
5843
+ return this.streams.has(transportPeerId) || this.streams.size < MAX_ACTIVE_EVENT_STREAMS;
5700
5844
  }
5701
- open(physicalSessionId, sink, status) {
5702
- if (!this.canOpen(physicalSessionId))
5845
+ open(transportPeerId, sink, status) {
5846
+ if (!this.canOpen(transportPeerId))
5703
5847
  return void 0;
5704
5848
  this.nextGeneration += 1;
5705
- const claimOwner = `sse:${physicalSessionId}:${this.nextGeneration}`;
5849
+ const claimOwner = `sse:${transportPeerId}:${this.nextGeneration}`;
5706
5850
  const stream = {
5707
- physicalSessionId,
5851
+ transportPeerId,
5708
5852
  claimOwner,
5709
5853
  sink,
5710
5854
  status,
@@ -5719,18 +5863,18 @@ var SseStudioTransport = class {
5719
5863
  this.pump(stream);
5720
5864
  }
5721
5865
  };
5722
- this.queue.setDeliveryActive(physicalSessionId, claimOwner, true);
5723
- this.queue.updateInstanceActivity(physicalSessionId);
5724
- const replaced = this.streams.get(physicalSessionId);
5866
+ this.queue.setDeliveryActive(transportPeerId, claimOwner, true);
5867
+ this.queue.updatePeerActivity(transportPeerId);
5868
+ const replaced = this.streams.get(transportPeerId);
5725
5869
  if (replaced)
5726
5870
  this.closeStream(replaced, true);
5727
- this.streams.set(physicalSessionId, stream);
5871
+ this.streams.set(transportPeerId, stream);
5728
5872
  sink.on("close", stream.onClose);
5729
5873
  sink.on("error", stream.onClose);
5730
5874
  sink.on("drain", stream.onDrain);
5731
5875
  stream.heartbeatTimer = setInterval(() => {
5732
5876
  if (!stream.closed && !stream.blocked) {
5733
- this.queue.updateInstanceActivity(physicalSessionId);
5877
+ this.queue.updatePeerActivity(transportPeerId);
5734
5878
  stream.statusPending = true;
5735
5879
  this.pump(stream);
5736
5880
  if (!stream.blocked) {
@@ -5741,13 +5885,13 @@ var SseStudioTransport = class {
5741
5885
  stream.heartbeatTimer.unref();
5742
5886
  this.pump(stream);
5743
5887
  return {
5744
- physicalSessionId,
5888
+ transportPeerId,
5745
5889
  close: () => this.closeStream(stream, true)
5746
5890
  };
5747
5891
  }
5748
- refreshStatus(physicalSessionId) {
5749
- if (physicalSessionId !== void 0) {
5750
- const stream = this.streams.get(physicalSessionId);
5892
+ refreshStatus(transportPeerId) {
5893
+ if (transportPeerId !== void 0) {
5894
+ const stream = this.streams.get(transportPeerId);
5751
5895
  if (stream) {
5752
5896
  stream.lastStatusJson = void 0;
5753
5897
  stream.statusPending = true;
@@ -5761,8 +5905,8 @@ var SseStudioTransport = class {
5761
5905
  this.pump(stream);
5762
5906
  }
5763
5907
  }
5764
- closePhysical(physicalSessionId) {
5765
- const stream = this.streams.get(physicalSessionId);
5908
+ closeTransport(transportPeerId) {
5909
+ const stream = this.streams.get(transportPeerId);
5766
5910
  if (stream)
5767
5911
  this.closeStream(stream, true);
5768
5912
  }
@@ -5771,10 +5915,10 @@ var SseStudioTransport = class {
5771
5915
  this.closeStream(stream, true);
5772
5916
  }
5773
5917
  this.unsubscribeRequestAvailable();
5774
- this.unsubscribeSessionClosed();
5918
+ this.unsubscribePeerClosed();
5775
5919
  }
5776
5920
  pump(stream) {
5777
- if (stream.closed || stream.blocked || this.streams.get(stream.physicalSessionId) !== stream)
5921
+ if (stream.closed || stream.blocked || this.streams.get(stream.transportPeerId) !== stream)
5778
5922
  return;
5779
5923
  if (stream.statusPending) {
5780
5924
  stream.statusPending = false;
@@ -5793,20 +5937,20 @@ var SseStudioTransport = class {
5793
5937
  }
5794
5938
  }
5795
5939
  while (!stream.closed && !stream.blocked) {
5796
- const cancellation = this.queue.claimNextCancellationForPhysical(stream.physicalSessionId, stream.claimOwner);
5940
+ const cancellation = this.queue.claimNextCancellationForTransport(stream.transportPeerId, stream.claimOwner);
5797
5941
  if (!cancellation)
5798
5942
  break;
5799
5943
  if (!this.write(stream, { kind: "cancel", ...cancellation }))
5800
5944
  return;
5801
5945
  }
5802
5946
  while (!stream.closed && !stream.blocked) {
5803
- const request = this.queue.claimNextRequestForPhysical(stream.physicalSessionId, stream.claimOwner);
5947
+ const request = this.queue.claimNextRequestForTransport(stream.transportPeerId, stream.claimOwner);
5804
5948
  if (!request)
5805
5949
  return;
5806
5950
  const event = {
5807
5951
  kind: "request",
5808
5952
  requestId: request.requestId,
5809
- logicalSessionId: request.logicalSessionId,
5953
+ peerId: request.peerId,
5810
5954
  target: request.target,
5811
5955
  endpoint: request.endpoint,
5812
5956
  data: request.data === void 0 ? null : request.data,
@@ -5839,11 +5983,11 @@ var SseStudioTransport = class {
5839
5983
  stream.sink.removeListener("close", stream.onClose);
5840
5984
  stream.sink.removeListener("error", stream.onClose);
5841
5985
  stream.sink.removeListener("drain", stream.onDrain);
5842
- if (this.streams.get(stream.physicalSessionId) === stream) {
5843
- this.streams.delete(stream.physicalSessionId);
5986
+ if (this.streams.get(stream.transportPeerId) === stream) {
5987
+ this.streams.delete(stream.transportPeerId);
5844
5988
  }
5845
- this.queue.updateInstanceActivity(stream.physicalSessionId);
5846
- this.queue.setDeliveryActive(stream.physicalSessionId, stream.claimOwner, false);
5989
+ this.queue.updatePeerActivity(stream.transportPeerId);
5990
+ this.queue.setDeliveryActive(stream.transportPeerId, stream.claimOwner, false);
5847
5991
  this.queue.releaseDeliveryClaims(stream.claimOwner);
5848
5992
  if (endSink) {
5849
5993
  try {
@@ -5855,6 +5999,32 @@ var SseStudioTransport = class {
5855
5999
  };
5856
6000
 
5857
6001
  // ../core/dist/http-server.js
6002
+ function toPassivePeer(peer) {
6003
+ return {
6004
+ instanceId: peer.instanceId,
6005
+ multiplayerGroupId: peer.multiplayerGroupId,
6006
+ role: peer.role,
6007
+ placeId: peer.placeId,
6008
+ placeName: peer.placeName,
6009
+ placeKey: peer.placeKey,
6010
+ dataModelName: peer.dataModelName,
6011
+ isRunning: peer.isRunning,
6012
+ pluginVersion: peer.pluginVersion,
6013
+ pluginVariant: peer.pluginVariant,
6014
+ serverVersion: peer.serverVersion,
6015
+ lastActivity: peer.lastActivity,
6016
+ connectedAt: peer.connectedAt
6017
+ };
6018
+ }
6019
+ function toPassiveInstance(instance) {
6020
+ return {
6021
+ id: instance.id,
6022
+ multiplayerGroupId: instance.multiplayerGroupId,
6023
+ placeId: instance.placeId,
6024
+ placeName: instance.placeName,
6025
+ peers: instance.peers.map(toPassivePeer)
6026
+ };
6027
+ }
5858
6028
  function parseLineRange(lineRange) {
5859
6029
  const validLine = (line) => line === void 0 || line >= 1;
5860
6030
  if (typeof lineRange === "string") {
@@ -5946,7 +6116,7 @@ var TOOL_HANDLERS = {
5946
6116
  manage_instance: (tools, body) => tools.manageInstance(body),
5947
6117
  solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
5948
6118
  multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
5949
- get_runtime_logs: (tools, body) => tools.getRuntimeLogs(body.target, body.since, body.tail, body.filter, body.instance_id),
6119
+ get_runtime_logs: (tools, body) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter),
5950
6120
  capture_script_profiler: (tools, body) => tools.captureScriptProfiler(body.target, {
5951
6121
  duration_ms: body.duration_ms,
5952
6122
  frequency: body.frequency,
@@ -6010,7 +6180,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6010
6180
  let lastMCPActivity = 0;
6011
6181
  let mcpServerStartTime = 0;
6012
6182
  const proxyInstances = /* @__PURE__ */ new Set();
6013
- const rejectedVersionSessions = /* @__PURE__ */ new Set();
6183
+ const rejectedVersionPeers = /* @__PURE__ */ new Set();
6014
6184
  const eventTransport = new SseStudioTransport(bridge);
6015
6185
  const eventStreamHandles = /* @__PURE__ */ new Set();
6016
6186
  const setMCPServerActive = (active) => {
@@ -6037,20 +6207,20 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6037
6207
  return false;
6038
6208
  return Date.now() - lastMCPActivity < 3e4;
6039
6209
  };
6040
- const eventStatus = (physicalSessionId) => {
6041
- const instance = bridge.getInstanceBySessionId(physicalSessionId);
6042
- const knownInstance = instance?.physicalSessionId === physicalSessionId;
6210
+ const eventStatus = (transportPeerId) => {
6211
+ const peer = bridge.getPeerById(transportPeerId);
6212
+ const knownPeer = peer?.transportPeerId === transportPeerId;
6043
6213
  return {
6044
6214
  kind: "status",
6045
- knownInstance,
6215
+ knownPeer,
6046
6216
  mcpConnected: isMCPServerActive(),
6047
6217
  serverVersion: serverConfig?.version,
6048
- pluginVersion: instance?.pluginVersion,
6049
- pluginVariant: instance?.pluginVariant
6218
+ pluginVersion: peer?.pluginVersion,
6219
+ pluginVariant: peer?.pluginVariant
6050
6220
  };
6051
6221
  };
6052
6222
  const isPluginConnected = () => {
6053
- return bridge.getInstances().length > 0;
6223
+ return bridge.getPeers().length > 0;
6054
6224
  };
6055
6225
  const allowedOrigins = new Set(security?.allowedOrigins ?? []);
6056
6226
  app.use((req, res, next) => {
@@ -6077,7 +6247,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6077
6247
  next();
6078
6248
  });
6079
6249
  const authToken = security?.authToken;
6080
- const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/instances" || path6 === "/unregister-instance-id";
6250
+ const authRequired = (path6) => path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/remove-multiplayer-group";
6081
6251
  app.use((req, res, next) => {
6082
6252
  if (!authToken || !authRequired(req.path)) {
6083
6253
  next();
@@ -6098,8 +6268,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6098
6268
  app.use(express.json({ limit: "50mb" }));
6099
6269
  app.use(express.urlencoded({ limit: "50mb", extended: true }));
6100
6270
  app.get("/health", (req, res) => {
6101
- const instances = bridge.getInstances();
6102
- const publicInstances = instances.map(toPublic);
6271
+ const peers = bridge.getPublicPeers().map(toPassivePeer);
6272
+ const instances = bridge.getPublicInstances().map(toPassiveInstance);
6273
+ const multiplayerGroups = bridge.getPublicMultiplayerGroups();
6103
6274
  res.json({
6104
6275
  status: "ok",
6105
6276
  service: "robloxstudio-mcp",
@@ -6115,9 +6286,12 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6115
6286
  processIdentity: studioLifecycleCapabilities?.processIdentity
6116
6287
  }
6117
6288
  } : {},
6118
- pluginConnected: instances.length > 0,
6289
+ pluginConnected: peers.length > 0,
6119
6290
  instanceCount: instances.length,
6120
- instances: publicInstances,
6291
+ peerCount: peers.length,
6292
+ instances,
6293
+ peers,
6294
+ multiplayerGroups,
6121
6295
  mcpServerActive: isMCPServerActive(),
6122
6296
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0,
6123
6297
  pendingRequests: bridge.getPendingRequestCount(),
@@ -6127,25 +6301,34 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6127
6301
  });
6128
6302
  });
6129
6303
  app.post("/ready", (req, res) => {
6130
- const { pluginSessionId, physicalSessionId, instanceId, role, placeId, placeName, dataModelName, isRunning, pluginVersion, pluginVariant } = req.body;
6304
+ const { peerId, transportPeerId, instanceId, multiplayerGroupId, role, placeId, placeName, placeKey, dataModelName, isRunning, pluginVersion, pluginVariant, timestamp } = req.body;
6131
6305
  const requestContext = {
6132
- physicalSessionId: typeof physicalSessionId === "string" ? physicalSessionId : void 0,
6306
+ peerId: typeof peerId === "string" ? peerId : void 0,
6307
+ transportPeerId: typeof transportPeerId === "string" ? transportPeerId : void 0,
6133
6308
  instanceId: typeof instanceId === "string" ? instanceId : void 0,
6309
+ multiplayerGroupId: typeof multiplayerGroupId === "string" ? multiplayerGroupId : void 0,
6134
6310
  role: typeof role === "string" ? role : void 0,
6135
6311
  placeId: typeof placeId === "number" ? placeId : void 0,
6136
6312
  placeName: typeof placeName === "string" ? placeName : void 0,
6313
+ placeKey: typeof placeKey === "string" ? placeKey : void 0,
6137
6314
  dataModelName: typeof dataModelName === "string" ? dataModelName : void 0,
6138
6315
  isRunning: typeof isRunning === "boolean" ? isRunning : void 0,
6139
6316
  pluginVersion: typeof pluginVersion === "string" ? pluginVersion : void 0,
6140
- pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0
6317
+ pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0,
6318
+ timestamp: typeof timestamp === "number" ? timestamp : void 0
6141
6319
  };
6142
6320
  const missingFields = [
6143
- typeof pluginSessionId !== "string" || pluginSessionId === "" ? "pluginSessionId" : void 0,
6144
- typeof physicalSessionId !== "string" || physicalSessionId === "" ? "physicalSessionId" : void 0,
6321
+ typeof peerId !== "string" || peerId === "" ? "peerId" : void 0,
6322
+ typeof transportPeerId !== "string" || transportPeerId === "" ? "transportPeerId" : void 0,
6145
6323
  typeof instanceId !== "string" || instanceId === "" ? "instanceId" : void 0,
6146
6324
  typeof role !== "string" || role === "" ? "role" : void 0,
6325
+ typeof placeId !== "number" || !Number.isFinite(placeId) ? "placeId" : void 0,
6326
+ typeof placeName !== "string" ? "placeName" : void 0,
6327
+ typeof dataModelName !== "string" ? "dataModelName" : void 0,
6328
+ typeof isRunning !== "boolean" ? "isRunning" : void 0,
6147
6329
  typeof pluginVersion !== "string" || pluginVersion === "" ? "pluginVersion" : void 0,
6148
- typeof pluginVariant !== "string" || pluginVariant === "" ? "pluginVariant" : void 0
6330
+ typeof pluginVariant !== "string" || pluginVariant === "" ? "pluginVariant" : void 0,
6331
+ typeof timestamp !== "number" || !Number.isFinite(timestamp) ? "timestamp" : void 0
6149
6332
  ].filter((field) => !!field);
6150
6333
  if (missingFields.length > 0) {
6151
6334
  res.status(400).json({
@@ -6157,6 +6340,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6157
6340
  });
6158
6341
  return;
6159
6342
  }
6343
+ if (multiplayerGroupId !== void 0 && (typeof multiplayerGroupId !== "string" || multiplayerGroupId === "")) {
6344
+ res.status(400).json({
6345
+ success: false,
6346
+ error: "invalid_multiplayer_group_id",
6347
+ message: "multiplayerGroupId must be a non-empty string when provided.",
6348
+ request: requestContext
6349
+ });
6350
+ return;
6351
+ }
6160
6352
  const serverVersion = serverConfig?.version;
6161
6353
  if (!serverVersion) {
6162
6354
  res.status(503).json({
@@ -6168,10 +6360,10 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6168
6360
  return;
6169
6361
  }
6170
6362
  if (pluginVersion !== serverVersion) {
6171
- if (!rejectedVersionSessions.has(pluginSessionId)) {
6172
- if (rejectedVersionSessions.size >= 256)
6173
- rejectedVersionSessions.clear();
6174
- rejectedVersionSessions.add(pluginSessionId);
6363
+ if (!rejectedVersionPeers.has(peerId)) {
6364
+ if (rejectedVersionPeers.size >= 256)
6365
+ rejectedVersionPeers.clear();
6366
+ rejectedVersionPeers.add(peerId);
6175
6367
  console.error(`[plugin-version-rejected] Studio plugin v${pluginVersion} (${pluginVariant}) does not match MCP server v${serverVersion} for ${instanceId}/${role}`);
6176
6368
  }
6177
6369
  res.status(426).json({
@@ -6185,24 +6377,25 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6185
6377
  return;
6186
6378
  }
6187
6379
  const isClientRole = role === "client" || /^client-[1-9]\d*$/.test(role);
6188
- const isLogicalSession = physicalSessionId !== pluginSessionId;
6189
- if (isLogicalSession && !isClientRole || !isLogicalSession && isClientRole || !isClientRole && role !== "edit" && role !== "server") {
6380
+ const isProxiedPeer = transportPeerId !== peerId;
6381
+ if (isProxiedPeer && !isClientRole || !isProxiedPeer && isClientRole || !isClientRole && role !== "edit" && role !== "server") {
6190
6382
  res.status(400).json({
6191
6383
  success: false,
6192
- error: "invalid_session_topology",
6193
- message: "Physical sessions must use the edit or server role; client roles must use a distinct physical server session.",
6384
+ error: "invalid_peer_topology",
6385
+ message: "Transport Peers must use the edit or server role; client Peers must use a distinct server transport Peer.",
6194
6386
  request: requestContext
6195
6387
  });
6196
6388
  return;
6197
6389
  }
6198
- if (isLogicalSession) {
6199
- const physicalOwner = bridge.getInstanceBySessionId(physicalSessionId);
6200
- const requestedInstanceId = typeof placeId === "number" && Number.isFinite(placeId) && placeId > 0 ? `place:${Math.trunc(placeId)}` : bridge.resolveInstanceId(instanceId);
6201
- if (!physicalOwner || physicalOwner.pluginSessionId !== physicalSessionId || physicalOwner.physicalSessionId !== physicalSessionId || physicalOwner.role !== "server" || physicalOwner.instanceId !== requestedInstanceId) {
6390
+ if (isProxiedPeer) {
6391
+ const transportOwner = bridge.getPeerById(transportPeerId);
6392
+ const sameInstance = transportOwner?.instanceId === instanceId;
6393
+ const sameMultiplayerGroup = typeof multiplayerGroupId === "string" && transportOwner?.multiplayerGroupId === multiplayerGroupId;
6394
+ if (!transportOwner || transportOwner.peerId !== transportPeerId || transportOwner.transportPeerId !== transportPeerId || transportOwner.role !== "server" || !sameInstance && !sameMultiplayerGroup) {
6202
6395
  res.status(409).json({
6203
6396
  success: false,
6204
- error: "physical_session_unavailable",
6205
- message: "A logical client requires a registered physical server session for the same Studio instance.",
6397
+ error: "transport_peer_unavailable",
6398
+ message: "A client Peer requires a registered server transport Peer in the same Instance or explicit MultiplayerGroup.",
6206
6399
  request: requestContext
6207
6400
  });
6208
6401
  return;
@@ -6210,15 +6403,17 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6210
6403
  }
6211
6404
  let result;
6212
6405
  try {
6213
- result = bridge.registerInstance({
6214
- pluginSessionId,
6215
- physicalSessionId,
6406
+ result = bridge.registerPeer({
6407
+ peerId,
6408
+ transportPeerId,
6216
6409
  instanceId,
6410
+ multiplayerGroupId,
6217
6411
  role,
6218
- placeId: typeof placeId === "number" ? placeId : 0,
6219
- placeName: typeof placeName === "string" ? placeName : "",
6220
- dataModelName: typeof dataModelName === "string" ? dataModelName : "",
6221
- isRunning: !!isRunning,
6412
+ placeId,
6413
+ placeName,
6414
+ placeKey: typeof placeKey === "string" ? placeKey : void 0,
6415
+ dataModelName,
6416
+ isRunning,
6222
6417
  pluginVersion,
6223
6418
  pluginVariant,
6224
6419
  serverVersion
@@ -6242,75 +6437,98 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6242
6437
  });
6243
6438
  return;
6244
6439
  }
6245
- eventTransport.refreshStatus(physicalSessionId);
6440
+ eventTransport.refreshStatus(transportPeerId);
6246
6441
  res.json({
6247
6442
  success: true,
6248
6443
  assignedRole: result.assignedRole,
6444
+ peerId: result.peerId,
6249
6445
  instanceId: result.instanceId,
6446
+ multiplayerGroupId: result.multiplayerGroupId,
6250
6447
  serverVersion
6251
6448
  });
6252
6449
  });
6253
6450
  app.post("/disconnect", (req, res) => {
6254
- const { pluginSessionId } = req.body;
6255
- if (pluginSessionId) {
6256
- bridge.unregisterInstance(pluginSessionId);
6451
+ const { peerId } = req.body;
6452
+ if (typeof peerId === "string" && peerId !== "") {
6453
+ bridge.unregisterPeer(peerId);
6257
6454
  }
6258
6455
  res.json({ success: true });
6259
6456
  });
6260
- app.post("/unregister-instance-id", (req, res) => {
6457
+ app.post("/unregister-instance-id", async (req, res) => {
6261
6458
  const { instanceId } = req.body;
6262
6459
  if (typeof instanceId !== "string" || instanceId.length === 0) {
6263
6460
  res.status(400).json({ error: "instanceId is required" });
6264
6461
  return;
6265
6462
  }
6266
- const removed = bridge.unregisterInstanceId(instanceId);
6463
+ const removed = await bridge.unregisterInstanceIdEverywhere(instanceId);
6464
+ res.json({ success: true, removed });
6465
+ });
6466
+ app.post("/create-multiplayer-group", async (req, res) => {
6467
+ const { groupId, controllerInstanceId } = req.body;
6468
+ if (typeof groupId !== "string" || groupId.length === 0 || typeof controllerInstanceId !== "string" || controllerInstanceId.length === 0) {
6469
+ res.status(400).json({ error: "groupId and controllerInstanceId are required" });
6470
+ return;
6471
+ }
6472
+ const group = await bridge.createMultiplayerGroupEverywhere(groupId, controllerInstanceId);
6473
+ res.json({ success: true, group });
6474
+ });
6475
+ app.post("/remove-multiplayer-group", async (req, res) => {
6476
+ const { groupId } = req.body;
6477
+ if (typeof groupId !== "string" || groupId.length === 0) {
6478
+ res.status(400).json({ error: "groupId is required" });
6479
+ return;
6480
+ }
6481
+ const removed = await bridge.removeMultiplayerGroupEverywhere(groupId);
6267
6482
  res.json({ success: true, removed });
6268
6483
  });
6269
6484
  app.get("/status", (req, res) => {
6270
- const instances = bridge.getInstances();
6271
- const publicInstances = instances.map(toPublic);
6485
+ const peers = bridge.getPublicPeers().map(toPassivePeer);
6486
+ const instances = bridge.getPublicInstances().map(toPassiveInstance);
6487
+ const multiplayerGroups = bridge.getPublicMultiplayerGroups();
6272
6488
  res.json({
6273
- pluginConnected: instances.length > 0,
6489
+ pluginConnected: peers.length > 0,
6274
6490
  instanceCount: instances.length,
6275
- instances: publicInstances,
6491
+ peerCount: peers.length,
6492
+ instances,
6493
+ peers,
6494
+ multiplayerGroups,
6276
6495
  serverVersion: serverConfig?.version,
6277
6496
  mcpServerActive: isMCPServerActive(),
6278
6497
  lastMCPActivity,
6279
6498
  uptime: mcpServerActive ? Date.now() - mcpServerStartTime : 0
6280
6499
  });
6281
6500
  });
6282
- app.get("/instances", (req, res) => {
6283
- const instances = bridge.getInstances();
6501
+ app.get("/topology", (req, res) => {
6284
6502
  res.json({
6285
- instances,
6503
+ ...bridge.getTopologySnapshot(),
6286
6504
  serverVersion: serverConfig?.version
6287
6505
  });
6288
6506
  });
6289
6507
  app.get("/events", (req, res) => {
6290
- const pluginSessionId = typeof req.query.pluginSessionId === "string" ? req.query.pluginSessionId : void 0;
6291
- if (!pluginSessionId) {
6508
+ const peerId = typeof req.query.peerId === "string" ? req.query.peerId : void 0;
6509
+ if (!peerId) {
6292
6510
  res.status(400).json({
6293
- error: "missing_plugin_session_id",
6294
- message: "pluginSessionId is required"
6511
+ error: "missing_peer_id",
6512
+ message: "peerId is required"
6295
6513
  });
6296
6514
  return;
6297
6515
  }
6298
- const instance = bridge.getInstanceBySessionId(pluginSessionId);
6299
- if (!instance) {
6516
+ const peer = bridge.getPeerById(peerId);
6517
+ if (!peer) {
6300
6518
  res.status(404).json({
6301
- error: "unknown_session",
6302
- knownInstance: false
6519
+ error: "unknown_peer",
6520
+ knownPeer: false
6303
6521
  });
6304
6522
  return;
6305
6523
  }
6306
- if (instance.physicalSessionId !== pluginSessionId) {
6524
+ if (peer.transportPeerId !== peerId) {
6307
6525
  res.status(409).json({
6308
- error: "logical_session_has_no_event_stream",
6309
- physicalSessionId: instance.physicalSessionId
6526
+ error: "peer_has_no_event_stream",
6527
+ transportPeerId: peer.transportPeerId
6310
6528
  });
6311
6529
  return;
6312
6530
  }
6313
- if (!eventTransport.canOpen(pluginSessionId)) {
6531
+ if (!eventTransport.canOpen(peerId)) {
6314
6532
  res.setHeader("Retry-After", "1");
6315
6533
  res.status(503).json({
6316
6534
  error: "event_stream_capacity_reached",
@@ -6318,14 +6536,14 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6318
6536
  });
6319
6537
  return;
6320
6538
  }
6321
- bridge.updateInstanceActivity(pluginSessionId);
6539
+ bridge.updatePeerActivity(peerId);
6322
6540
  res.status(200);
6323
6541
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
6324
6542
  res.setHeader("Cache-Control", "no-cache, no-transform");
6325
6543
  res.setHeader("Connection", "keep-alive");
6326
6544
  res.setHeader("X-Accel-Buffering", "no");
6327
6545
  res.flushHeaders();
6328
- const handle = eventTransport.open(pluginSessionId, res, () => eventStatus(pluginSessionId));
6546
+ const handle = eventTransport.open(peerId, res, () => eventStatus(peerId));
6329
6547
  if (!handle) {
6330
6548
  res.end();
6331
6549
  return;
@@ -6350,9 +6568,9 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6350
6568
  res.json({ success: true, disposition });
6351
6569
  });
6352
6570
  app.post("/proxy", async (req, res) => {
6353
- const { endpoint, data, targetInstanceId, targetRole, proxyInstanceId, timeoutMs } = req.body;
6354
- if (!endpoint || !targetInstanceId || !targetRole) {
6355
- res.status(400).json({ error: "endpoint, targetInstanceId, and targetRole are required" });
6571
+ const { endpoint, data, targetPeerId, proxyInstanceId, timeoutMs } = req.body;
6572
+ if (!endpoint || !targetPeerId) {
6573
+ res.status(400).json({ error: "endpoint and targetPeerId are required" });
6356
6574
  return;
6357
6575
  }
6358
6576
  if (timeoutMs !== void 0 && (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 3e5)) {
@@ -6367,7 +6585,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6367
6585
  req.once("aborted", abort);
6368
6586
  res.once("close", abort);
6369
6587
  try {
6370
- const response = await bridge.sendRequest(endpoint, data, targetInstanceId, targetRole, timeoutMs, controller.signal);
6588
+ const response = await bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, controller.signal);
6371
6589
  res.json({ response });
6372
6590
  } catch (error) {
6373
6591
  if (!res.headersSent && !res.destroyed) {
@@ -6470,10 +6688,9 @@ var StudioHttpClient = class {
6470
6688
  constructor(bridge) {
6471
6689
  this.bridge = bridge;
6472
6690
  }
6473
- async request(endpoint, data, targetInstanceId, targetRole, timeoutMs, signal) {
6691
+ async request(endpoint, data, targetPeerId, timeoutMs, signal) {
6474
6692
  try {
6475
- const response = await this.bridge.sendRequest(endpoint, data, targetInstanceId, targetRole, timeoutMs, signal);
6476
- return response;
6693
+ return await this.bridge.sendRequest(endpoint, data, targetPeerId, timeoutMs, signal);
6477
6694
  } catch (error) {
6478
6695
  if (error instanceof Error && error.message === "Request timeout") {
6479
6696
  throw new Error("Studio plugin connection timeout. Make sure the Roblox Studio plugin is running and activated.");
@@ -10026,9 +10243,9 @@ var RobloxStudioTools = class {
10026
10243
  this.openCloudClient = new OpenCloudClient();
10027
10244
  this.cookieClient = new RobloxCookieClient();
10028
10245
  this.instanceManager = new StudioInstanceManager();
10029
- this.bridge.onInstanceRegistered((instance) => {
10246
+ this.bridge.onPeerRegistered((peer) => {
10030
10247
  const instanceManager = this.instanceManager;
10031
- const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(instance, instanceManager));
10248
+ const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
10032
10249
  this.managedConnectionAssociations = association.catch((error) => {
10033
10250
  console.warn(`[robloxstudio-mcp] managed Studio connection association failed: ${error instanceof Error ? error.message : String(error)}`);
10034
10251
  });
@@ -10093,93 +10310,109 @@ var RobloxStudioTools = class {
10093
10310
  return { content: [{ type: "text", text: result.content }] };
10094
10311
  }
10095
10312
  _parseTextResult(result) {
10096
- const text = result?.content?.[0]?.text;
10097
- if (typeof text !== "string")
10313
+ if (result === null || typeof result !== "object" || !("content" in result) || !Array.isArray(result.content)) {
10314
+ return {};
10315
+ }
10316
+ const first = result.content[0];
10317
+ if (first === null || typeof first !== "object" || !("text" in first) || typeof first.text !== "string") {
10098
10318
  return {};
10319
+ }
10099
10320
  try {
10100
- const parsed = JSON.parse(text);
10101
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
10321
+ const parsed = JSON.parse(first.text);
10322
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? { ...parsed } : {};
10102
10323
  } catch {
10103
10324
  return {};
10104
10325
  }
10105
10326
  }
10106
- _briefRoles(instanceId, equivalentInstances = false) {
10107
- const roles = equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId);
10327
+ _briefRoles(instanceId) {
10328
+ const roles = this._rolesForScope(instanceId);
10108
10329
  return {
10109
10330
  roles,
10110
10331
  runtimeRoles: roles.filter((role) => role === "server" || /^client-\d+$/.test(role))
10111
10332
  };
10112
10333
  }
10113
- // Resolve (instance_id, target-role) → concrete (instanceId, role) and
10114
- // dispatch a single request. Throws RoutingFailure if the resolution is
10115
- // ambiguous, missing, or asks for fanout on a non-fanout-capable tool —
10116
- // the MCP transport layer surfaces it as a structured error result so
10117
- // the LLM can recover via the embedded data.instances list.
10334
+ _routingErrorData() {
10335
+ const instances = this.bridge.getConnectedInstances();
10336
+ const multiplayerGroups = this.bridge.getConnectedMultiplayerGroups();
10337
+ return {
10338
+ instances,
10339
+ multiplayerGroups,
10340
+ count: instances.length + multiplayerGroups.length
10341
+ };
10342
+ }
10343
+ _peerForRoleInScope(instanceId, role) {
10344
+ return this.bridge.getPeersInScope(instanceId).find((peer) => peer.role === role);
10345
+ }
10346
+ _requestPeer(endpoint, data, targetPeerId, timeoutMs, signal) {
10347
+ return this.client.request(endpoint, data, targetPeerId, timeoutMs, signal);
10348
+ }
10349
+ _request(endpoint, data, instanceId, role, timeoutMs, signal) {
10350
+ const peer = this._peerForRoleInScope(instanceId, role);
10351
+ if (!peer) {
10352
+ throw new RoutingFailure({
10353
+ code: "target_role_not_present_on_instance",
10354
+ message: `Routing scope for instance "${instanceId}" has no role "${role}".`,
10355
+ data: this._routingErrorData()
10356
+ });
10357
+ }
10358
+ return this._requestPeer(endpoint, data, peer.peerId, timeoutMs, signal);
10359
+ }
10360
+ // Resolve an optional Studio process plus role to one exact Peer and dispatch.
10118
10361
  async _callSingle(endpoint, data, target, instance_id, timeoutMs, signal) {
10119
- const r = this.bridge.resolveTarget({ instance_id, target });
10120
- if (!r.ok)
10121
- throw new RoutingFailure(r.error);
10122
- if (r.mode !== "single") {
10362
+ const resolved = this.bridge.resolveTarget({ instance_id, target });
10363
+ if (!resolved.ok)
10364
+ throw new RoutingFailure(resolved.error);
10365
+ if (resolved.mode !== "single") {
10123
10366
  throw new RoutingFailure({
10124
10367
  code: "target_role_not_present_on_instance",
10125
10368
  message: "This tool does not support target=all. Pick a specific role or omit target.",
10126
- data: {
10127
- instances: this.bridge.getPublicInstances(),
10128
- count: this.bridge.getInstances().length
10129
- }
10369
+ data: this._routingErrorData()
10130
10370
  });
10131
10371
  }
10132
- if (signal === void 0) {
10133
- return this.client.request(endpoint, data, r.targetInstanceId, r.targetRole, timeoutMs);
10134
- }
10135
- return this.client.request(endpoint, data, r.targetInstanceId, r.targetRole, timeoutMs, signal);
10372
+ return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal);
10136
10373
  }
10137
- // Resolves which connected place a tool should target and whether a playtest
10138
- // CLIENT peer is present on it. Used by capture/input to auto-route to the
10139
- // running client (where the live viewport + input pipeline are) without the
10140
- // caller having to pass target. Throws RoutingFailure with the standard
10141
- // instance list if the place is ambiguous (multiple connected, no instance_id).
10374
+ // Prefer the first client role in the selected process/group scope for live
10375
+ // viewport and input operations; otherwise retain the default Peer's Instance.
10142
10376
  _resolveRuntime(instance_id) {
10143
- const r = this.bridge.resolveTarget({ instance_id, target: void 0 });
10144
- if (!r.ok)
10145
- throw new RoutingFailure(r.error);
10146
- const resolvedId = r.targetInstanceId;
10147
- const equivalentIds = new Set(this.bridge.getEquivalentInstanceIds(resolvedId));
10148
- const instances = this.bridge.getInstances().filter((i) => equivalentIds.has(i.instanceId));
10149
- const client = instances.filter((inst) => inst.role.startsWith("client")).sort((a, b) => a.role.localeCompare(b.role))[0];
10150
- return { instanceId: client?.instanceId ?? resolvedId, clientRole: client?.role };
10377
+ const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
10378
+ if (!resolved.ok)
10379
+ throw new RoutingFailure(resolved.error);
10380
+ if (resolved.mode !== "single") {
10381
+ throw new RoutingFailure({
10382
+ code: "target_role_not_present_on_instance",
10383
+ message: "A single runtime target is required.",
10384
+ data: this._routingErrorData()
10385
+ });
10386
+ }
10387
+ const client = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role)).sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
10388
+ return {
10389
+ instanceId: client?.instanceId ?? resolved.targetInstanceId,
10390
+ clientRole: client?.role
10391
+ };
10151
10392
  }
10152
10393
  _resolveInstanceIdOnly(instance_id) {
10153
- const instances = this.bridge.getInstances();
10154
- const publicList = this.bridge.getPublicInstances();
10155
- const errorData = { instances: publicList, count: publicList.length };
10156
10394
  if (instance_id !== void 0) {
10157
- const resolvedInstanceId = this.bridge.resolveInstanceId(instance_id);
10158
- if (!instances.some((i) => i.instanceId === resolvedInstanceId)) {
10395
+ const resolvedInstanceId = this.bridge.resolveConnectedInstanceId(instance_id);
10396
+ if (resolvedInstanceId === void 0) {
10159
10397
  throw new RoutingFailure({
10160
10398
  code: "unrecognized_instance_id",
10161
- message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
10162
- data: errorData
10399
+ message: `instance_id "${instance_id}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
10400
+ data: this._routingErrorData()
10163
10401
  });
10164
10402
  }
10165
10403
  return resolvedInstanceId;
10166
10404
  }
10167
- const distinct = Array.from(new Set(instances.map((i) => i.instanceId)));
10168
- if (distinct.length === 0) {
10169
- throw new RoutingFailure({
10170
- code: "unrecognized_instance_id",
10171
- message: "No Studio plugin is connected.",
10172
- data: errorData
10173
- });
10174
- }
10175
- if (distinct.length > 1) {
10405
+ const resolved = this.bridge.resolveTarget({ target: void 0 });
10406
+ if (!resolved.ok)
10407
+ throw new RoutingFailure(resolved.error);
10408
+ if (resolved.mode !== "single") {
10176
10409
  throw new RoutingFailure({
10177
10410
  code: "multiple_instances_connected",
10178
- message: "Multiple Studio places are connected. Pass instance_id to disambiguate.",
10179
- data: errorData
10411
+ message: "Multiple Studio process scopes are connected. Pass instance_id to disambiguate.",
10412
+ data: this._routingErrorData()
10180
10413
  });
10181
10414
  }
10182
- return distinct[0];
10415
+ return resolved.targetInstanceId;
10183
10416
  }
10184
10417
  _resolveSingleTarget(target, instance_id) {
10185
10418
  const resolved = this.bridge.resolveTarget({ instance_id, target });
@@ -10189,27 +10422,27 @@ var RobloxStudioTools = class {
10189
10422
  throw new RoutingFailure({
10190
10423
  code: "target_role_not_present_on_instance",
10191
10424
  message: "Pick a specific target role for this tool.",
10192
- data: {
10193
- instances: this.bridge.getPublicInstances(),
10194
- count: this.bridge.getInstances().length
10195
- }
10425
+ data: this._routingErrorData()
10196
10426
  });
10197
10427
  }
10198
- return { instanceId: resolved.targetInstanceId, role: resolved.targetRole };
10199
- }
10200
- _rolesForInstance(instanceId) {
10201
- return this.bridge.getInstances().filter((i) => i.instanceId === instanceId).map((i) => i.role);
10428
+ return {
10429
+ targetPeerId: resolved.targetPeerId,
10430
+ instanceId: resolved.targetInstanceId,
10431
+ role: resolved.targetRole
10432
+ };
10202
10433
  }
10203
- _rolesForEquivalentInstances(instanceId) {
10204
- const instanceIds = new Set(this.bridge.getEquivalentInstanceIds(instanceId));
10205
- return this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId)).map((i) => i.role);
10434
+ _rolesForScope(instanceId) {
10435
+ return this.bridge.getPeersInScope(instanceId).map((peer) => peer.role);
10206
10436
  }
10207
- _clientRolesForInstance(instanceId) {
10208
- return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
10437
+ _clientRolesForScope(instanceId) {
10438
+ return this._rolesForScope(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
10209
10439
  }
10210
- _runtimeTargetsForEquivalentInstances(instanceId) {
10211
- const instanceIds = new Set(this.bridge.getEquivalentInstanceIds(instanceId));
10212
- return this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId) && (i.role === "server" || /^client-\d+$/.test(i.role))).map((i) => ({ instanceId: i.instanceId, role: i.role }));
10440
+ _runtimeTargetsForScope(instanceId) {
10441
+ return this.bridge.getPeersInScope(instanceId).filter((peer) => peer.role === "server" || /^client-\d+$/.test(peer.role)).map((peer) => ({
10442
+ targetPeerId: peer.peerId,
10443
+ instanceId: peer.instanceId,
10444
+ role: peer.role
10445
+ }));
10213
10446
  }
10214
10447
  _compactSimulationResetResult(result) {
10215
10448
  const compact = {};
@@ -10236,15 +10469,12 @@ var RobloxStudioTools = class {
10236
10469
  const selectedTarget = target ?? "edit";
10237
10470
  if (selectedTarget === "all-clients") {
10238
10471
  const instanceId = this._resolveInstanceIdOnly(instance_id);
10239
- const roles = this._clientRolesForInstance(instanceId);
10472
+ const roles = this._clientRolesForScope(instanceId);
10240
10473
  if (roles.length === 0) {
10241
10474
  throw new RoutingFailure({
10242
10475
  code: "target_role_not_present_on_instance",
10243
10476
  message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
10244
- data: {
10245
- instances: this.bridge.getPublicInstances(),
10246
- count: this.bridge.getInstances().length
10247
- }
10477
+ data: this._routingErrorData()
10248
10478
  });
10249
10479
  }
10250
10480
  return { instanceId, selectedTarget, roles };
@@ -10265,8 +10495,8 @@ var RobloxStudioTools = class {
10265
10495
  throw new Error(`${toolName} target must be "edit", "client-N", "all-clients", or "edit-and-clients" (got: ${selectedTarget})`);
10266
10496
  }
10267
10497
  const instanceId = this._resolveInstanceIdOnly(instance_id);
10268
- const connectedRoles = this._rolesForInstance(instanceId);
10269
- const clientRoles = this._clientRolesForInstance(instanceId);
10498
+ const connectedRoles = this._rolesForScope(instanceId);
10499
+ const clientRoles = this._clientRolesForScope(instanceId);
10270
10500
  const warnings = [];
10271
10501
  let roles;
10272
10502
  if (selectedTarget === "edit") {
@@ -10274,10 +10504,7 @@ var RobloxStudioTools = class {
10274
10504
  throw new RoutingFailure({
10275
10505
  code: "target_role_not_present_on_instance",
10276
10506
  message: `instance "${instanceId}" has no role "edit". Available roles: ${connectedRoles.join(", ") || "none"}.`,
10277
- data: {
10278
- instances: this.bridge.getPublicInstances(),
10279
- count: this.bridge.getInstances().length
10280
- }
10507
+ data: this._routingErrorData()
10281
10508
  });
10282
10509
  }
10283
10510
  roles = ["edit"];
@@ -10299,10 +10526,7 @@ var RobloxStudioTools = class {
10299
10526
  throw new RoutingFailure({
10300
10527
  code: "target_role_not_present_on_instance",
10301
10528
  message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
10302
- data: {
10303
- instances: this.bridge.getPublicInstances(),
10304
- count: this.bridge.getInstances().length
10305
- }
10529
+ data: this._routingErrorData()
10306
10530
  });
10307
10531
  }
10308
10532
  roles = [selectedTarget];
@@ -10330,12 +10554,12 @@ var RobloxStudioTools = class {
10330
10554
  }
10331
10555
  async _executeNetworkStateOperation(instanceId, role, operation) {
10332
10556
  const code = buildNetworkStateLuau(operation);
10333
- const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
10557
+ const response = await this._request("/api/execute-luau", { code }, instanceId, role);
10334
10558
  return this._parseExecuteLuauJsonResponse(response, `network simulation ${operation}`);
10335
10559
  }
10336
10560
  async _executeDeviceSimulatorOperation(instanceId, role, operation, options) {
10337
10561
  const code = buildDeviceSimulatorLuau(operation, options);
10338
- const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
10562
+ const response = await this._request("/api/execute-luau", { code }, instanceId, role);
10339
10563
  return this._parseExecuteLuauJsonResponse(response, `device simulator ${operation}`);
10340
10564
  }
10341
10565
  _settingsFromDeviceSimulatorState(state) {
@@ -10376,11 +10600,11 @@ var RobloxStudioTools = class {
10376
10600
  throw new Error(`capture_device_matrix cannot safely restore active custom device "${s.activeDeviceId}". Switch the simulator to default or a built-in preset first, or pass restoreAfter=false only if you intentionally accept changing the simulator state.`);
10377
10601
  }
10378
10602
  }
10379
- async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30, equivalentInstances = false) {
10603
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
10380
10604
  const deadline = Date.now() + timeoutSec * 1e3;
10381
10605
  while (Date.now() < deadline) {
10382
- const roles = equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId);
10383
- const clientRoles = equivalentInstances ? roles.filter((role) => /^client-\d+$/.test(role)) : this._clientRolesForInstance(instanceId);
10606
+ const roles = this._rolesForScope(instanceId);
10607
+ const clientRoles = this._clientRolesForScope(instanceId);
10384
10608
  const hasServer = !opts.server || roles.includes("server");
10385
10609
  const hasClients = opts.clientCount === void 0 || clientRoles.length >= opts.clientCount;
10386
10610
  const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
@@ -10392,7 +10616,7 @@ var RobloxStudioTools = class {
10392
10616
  }
10393
10617
  return {
10394
10618
  ok: false,
10395
- roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
10619
+ roles: this._rolesForScope(instanceId),
10396
10620
  timedOut: true
10397
10621
  };
10398
10622
  }
@@ -10400,8 +10624,8 @@ var RobloxStudioTools = class {
10400
10624
  const deadline = Date.now() + timeoutSec * 1e3;
10401
10625
  let exactSince;
10402
10626
  while (Date.now() < deadline) {
10403
- const roles2 = this._rolesForInstance(instanceId);
10404
- const clientCount2 = this._clientRolesForInstance(instanceId).length;
10627
+ const roles2 = this._rolesForScope(instanceId);
10628
+ const clientCount2 = this._clientRolesForScope(instanceId).length;
10405
10629
  if (clientCount2 > expectedClientCount) {
10406
10630
  return { ok: false, roles: roles2, timedOut: false, extraClients: true, clientCount: clientCount2 };
10407
10631
  }
@@ -10415,17 +10639,16 @@ var RobloxStudioTools = class {
10415
10639
  }
10416
10640
  await sleep(250);
10417
10641
  }
10418
- const roles = this._rolesForInstance(instanceId);
10419
- const clientCount = this._clientRolesForInstance(instanceId).length;
10642
+ const roles = this._rolesForScope(instanceId);
10643
+ const clientCount = this._clientRolesForScope(instanceId).length;
10420
10644
  return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
10421
10645
  }
10422
- async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60, equivalentInstances = false) {
10646
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
10423
10647
  const deadline = Date.now() + timeoutSec * 1e3;
10424
10648
  while (Date.now() < deadline) {
10425
- const instanceIds = equivalentInstances ? new Set(this.bridge.getEquivalentInstanceIds(instanceId)) : /* @__PURE__ */ new Set([instanceId]);
10426
- const instances = this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId));
10427
- const roles = instances.map((i) => i.role);
10428
- const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
10649
+ const peers = this.bridge.getPeersInScope(instanceId);
10650
+ const roles = peers.map((peer) => peer.role);
10651
+ const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
10429
10652
  if (requiredRoles.every((role) => freshRoles.has(role))) {
10430
10653
  return { ok: true, roles, timedOut: false };
10431
10654
  }
@@ -10433,7 +10656,7 @@ var RobloxStudioTools = class {
10433
10656
  }
10434
10657
  return {
10435
10658
  ok: false,
10436
- roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
10659
+ roles: this._rolesForScope(instanceId),
10437
10660
  timedOut: true
10438
10661
  };
10439
10662
  }
@@ -10788,7 +11011,7 @@ var RobloxStudioTools = class {
10788
11011
  async setNetworkProfile(profile, target, overrides, instance_id) {
10789
11012
  const values = normalizeNetworkProfile(profile, overrides);
10790
11013
  const instanceId = this._resolveInstanceIdOnly(instance_id);
10791
- const clientRoles = this._clientRolesForInstance(instanceId);
11014
+ const clientRoles = this._clientRolesForScope(instanceId);
10792
11015
  const selectedTarget = target ?? "client-1";
10793
11016
  let targetRoles;
10794
11017
  if (selectedTarget === "all-clients") {
@@ -10798,10 +11021,7 @@ var RobloxStudioTools = class {
10798
11021
  throw new RoutingFailure({
10799
11022
  code: "target_role_not_present_on_instance",
10800
11023
  message: `instance "${instanceId}" has no role "${selectedTarget}". Available client roles: ${clientRoles.join(", ") || "none"}.`,
10801
- data: {
10802
- instances: this.bridge.getPublicInstances(),
10803
- count: this.bridge.getInstances().length
10804
- }
11024
+ data: this._routingErrorData()
10805
11025
  });
10806
11026
  }
10807
11027
  targetRoles = [selectedTarget];
@@ -10812,15 +11032,12 @@ var RobloxStudioTools = class {
10812
11032
  throw new RoutingFailure({
10813
11033
  code: "target_role_not_present_on_instance",
10814
11034
  message: `instance "${instanceId}" has no connected playtest client roles. Start a playtest first.`,
10815
- data: {
10816
- instances: this.bridge.getPublicInstances(),
10817
- count: this.bridge.getInstances().length
10818
- }
11035
+ data: this._routingErrorData()
10819
11036
  });
10820
11037
  }
10821
11038
  const code = buildNetworkProfileLuau(profile, values);
10822
11039
  const responses = await Promise.allSettled(targetRoles.map(async (role) => {
10823
- const response = await this.client.request("/api/execute-luau", { code }, instanceId, role);
11040
+ const response = await this._request("/api/execute-luau", { code }, instanceId, role);
10824
11041
  const result = this._parseExecuteLuauJsonResponse(response, "set_network_profile");
10825
11042
  return { role, result };
10826
11043
  }));
@@ -11150,98 +11367,235 @@ var RobloxStudioTools = class {
11150
11367
  ]
11151
11368
  };
11152
11369
  }
11153
- async getRuntimeLogs(target, since, tail, filter, instance_id) {
11154
- const tgt = target ?? "all";
11155
- const data = {};
11156
- if (since !== void 0)
11157
- data.since = since;
11158
- if (tail !== void 0)
11159
- data.tail = tail;
11160
- if (filter !== void 0)
11161
- data.filter = filter;
11162
- const resolved = this.bridge.resolveTarget({ instance_id, target: tgt });
11163
- if (!resolved.ok)
11164
- throw new RoutingFailure(resolved.error);
11165
- if (resolved.mode === "single") {
11166
- const originPeerReliable2 = await this._isMultiplayerTestRunning(resolved.targetInstanceId);
11167
- const response = await this.client.request("/api/get-runtime-logs", data, resolved.targetInstanceId, resolved.targetRole);
11168
- response.capturedBy = resolved.targetRole;
11169
- delete response.peer;
11170
- response.originPeerReliable = originPeerReliable2;
11171
- response.peerAttribution = originPeerReliable2 ? "guaranteed_multiplayer" : "unavailable_shared_logservice";
11172
- if (originPeerReliable2)
11173
- response.peer = resolved.targetRole;
11174
- if (Array.isArray(response.entries)) {
11175
- for (const e of response.entries) {
11176
- e.capturedBy = resolved.targetRole;
11177
- delete e.peer;
11178
- if (originPeerReliable2)
11179
- e.peer = resolved.targetRole;
11180
- }
11181
- }
11182
- return {
11183
- content: [{ type: "text", text: JSON.stringify(response) }]
11184
- };
11370
+ async getRuntimeLogs(instance_id, multiplayer_group_id, cursor, cursor_by_instance, tail, filter) {
11371
+ if (instance_id !== void 0 && multiplayer_group_id !== void 0) {
11372
+ throw new Error("get_runtime_logs accepts only one of instance_id or multiplayer_group_id.");
11185
11373
  }
11186
- const targets = resolved.targets;
11187
- const originPeerReliable = targets.length > 0 ? await this._isMultiplayerTestRunning(targets[0].targetInstanceId) : false;
11188
- const responses = await Promise.allSettled(targets.map(async (t) => {
11189
- const r = await this.client.request("/api/get-runtime-logs", data, t.targetInstanceId, t.targetRole);
11190
- return { ...r, capturedBy: t.targetRole };
11191
- }));
11192
- const merged = [];
11193
- const perCaptureNextSince = {};
11194
- const perCaptureErrors = {};
11195
- let totalDropped = 0;
11196
- for (const r of responses) {
11197
- if (r.status !== "fulfilled")
11198
- continue;
11199
- const v = r.value;
11200
- const capturedBy = v.capturedBy ?? "unknown";
11201
- if (v.error) {
11202
- perCaptureErrors[capturedBy] = v.error;
11203
- continue;
11374
+ if (cursor !== void 0 && cursor_by_instance !== void 0) {
11375
+ throw new Error("get_runtime_logs accepts only one of cursor or cursor_by_instance.");
11376
+ }
11377
+ if (tail !== void 0 && (!Number.isInteger(tail) || tail < 0)) {
11378
+ throw new Error("get_runtime_logs tail must be a non-negative integer.");
11379
+ }
11380
+ const instances = this.bridge.getInstances();
11381
+ const groups = this.bridge.getMultiplayerGroups();
11382
+ let selectedGroup = multiplayer_group_id === void 0 ? void 0 : groups.find((group) => group.id === multiplayer_group_id);
11383
+ let selectedInstanceId = instance_id === void 0 ? void 0 : this._resolveInstanceIdOnly(instance_id);
11384
+ if (multiplayer_group_id !== void 0 && !selectedGroup) {
11385
+ throw new RoutingFailure({
11386
+ code: "unrecognized_instance_id",
11387
+ message: `multiplayer_group_id "${multiplayer_group_id}" is not connected.`,
11388
+ data: this._routingErrorData()
11389
+ });
11390
+ }
11391
+ if (selectedInstanceId !== void 0 && !instances.some((instance) => instance.id === selectedInstanceId)) {
11392
+ throw new RoutingFailure({
11393
+ code: "unrecognized_instance_id",
11394
+ message: `instance_id "${selectedInstanceId}" is not connected. Pass a connected top-level or grouped role-suffixed Instance ID.`,
11395
+ data: this._routingErrorData()
11396
+ });
11397
+ }
11398
+ if (selectedGroup === void 0 && selectedInstanceId === void 0) {
11399
+ const groupedInstanceIds = new Set(groups.flatMap((group) => group.instanceIds));
11400
+ const standaloneInstanceIds = instances.map((instance) => instance.id).filter((id) => !groupedInstanceIds.has(id));
11401
+ const scopeCount = groups.length + standaloneInstanceIds.length;
11402
+ if (scopeCount === 0) {
11403
+ throw new RoutingFailure({
11404
+ code: "unrecognized_instance_id",
11405
+ message: "No Studio plugin is connected.",
11406
+ data: this._routingErrorData()
11407
+ });
11408
+ }
11409
+ if (scopeCount > 1) {
11410
+ throw new RoutingFailure({
11411
+ code: "multiple_instances_connected",
11412
+ message: "Multiple Studio process scopes are connected. Pass instance_id or multiplayer_group_id.",
11413
+ data: this._routingErrorData()
11414
+ });
11204
11415
  }
11205
- if (v.nextSince !== void 0)
11206
- perCaptureNextSince[capturedBy] = v.nextSince;
11207
- totalDropped += v.totalDropped ?? 0;
11208
- for (const e of v.entries ?? []) {
11209
- const entry = { ...e };
11210
- delete entry.peer;
11211
- merged.push({ ...entry, capturedBy });
11416
+ if (groups.length === 1) {
11417
+ selectedGroup = groups[0];
11418
+ } else {
11419
+ selectedInstanceId = standaloneInstanceIds[0];
11212
11420
  }
11213
11421
  }
11214
- merged.sort((a, b) => a.ts !== b.ts ? a.ts - b.ts : a.seq - b.seq);
11215
- const DEDUP_WINDOW = 2;
11216
- const deduped = [];
11217
- for (const e of merged) {
11218
- const isDup = deduped.some((d) => d.message === e.message && d.level === e.level && Math.abs(d.ts - e.ts) <= DEDUP_WINDOW && d.capturedBy !== e.capturedBy);
11219
- if (!isDup)
11220
- deduped.push(e);
11422
+ if (selectedGroup !== void 0 && cursor !== void 0) {
11423
+ throw new Error("Use cursor_by_instance when reading a multiplayer group.");
11221
11424
  }
11222
- let final = deduped;
11223
- if (tail !== void 0 && deduped.length > tail) {
11224
- final = deduped.slice(deduped.length - tail);
11425
+ if (selectedGroup === void 0 && cursor_by_instance !== void 0) {
11426
+ throw new Error("Use cursor when reading one Instance.");
11225
11427
  }
11226
- const finalEntries = originPeerReliable ? final.map((e) => ({ ...e, peer: e.capturedBy })) : final;
11227
- const body = {
11228
- entries: finalEntries,
11229
- totalDropped,
11230
- perCaptureNextSince,
11231
- originPeerReliable,
11232
- peerAttribution: originPeerReliable ? "guaranteed_multiplayer" : "unavailable_shared_logservice"
11428
+ const decodeCursor = (value, instanceId) => {
11429
+ if (value === void 0)
11430
+ return {};
11431
+ let decoded;
11432
+ try {
11433
+ decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
11434
+ } catch {
11435
+ throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
11436
+ }
11437
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
11438
+ throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
11439
+ }
11440
+ const payload = decoded;
11441
+ if (payload.version !== 1 || payload.instanceId !== instanceId || typeof payload.peers !== "object" || payload.peers === null || Array.isArray(payload.peers)) {
11442
+ throw new Error(`get_runtime_logs cursor does not belong to Instance "${instanceId}".`);
11443
+ }
11444
+ const peers = payload.peers;
11445
+ const parsed = {};
11446
+ for (const [peerId, nextSince] of Object.entries(peers)) {
11447
+ if (typeof nextSince !== "number" || !Number.isInteger(nextSince) || nextSince < 0) {
11448
+ throw new Error(`get_runtime_logs received an invalid cursor for Instance "${instanceId}".`);
11449
+ }
11450
+ parsed[peerId] = nextSince;
11451
+ }
11452
+ return parsed;
11453
+ };
11454
+ const encodeCursor = (instanceId, peers) => {
11455
+ const orderedPeers = {};
11456
+ for (const peerId of Object.keys(peers).sort())
11457
+ orderedPeers[peerId] = peers[peerId];
11458
+ const payload = {
11459
+ version: 1,
11460
+ instanceId,
11461
+ peers: orderedPeers
11462
+ };
11463
+ return Buffer.from(JSON.stringify(payload)).toString("base64url");
11464
+ };
11465
+ const roleRank = (role) => {
11466
+ if (role === "edit")
11467
+ return 0;
11468
+ if (role === "server")
11469
+ return 1;
11470
+ const client = /^client-(\d+)$/.exec(role);
11471
+ return client ? 2 + Number(client[1]) : Number.MAX_SAFE_INTEGER;
11472
+ };
11473
+ const entryTimestamp = (entry) => {
11474
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
11475
+ return 0;
11476
+ const record = entry;
11477
+ return typeof record.ts === "number" ? record.ts : 0;
11478
+ };
11479
+ const publicEntry = (entry) => {
11480
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
11481
+ return entry;
11482
+ const record = entry;
11483
+ const copy = { ...record };
11484
+ delete copy.seq;
11485
+ return copy;
11486
+ };
11487
+ const readInstance = async (instanceId, instanceCursor) => {
11488
+ const peers = this.bridge.getPeers().filter((peer) => peer.instanceId === instanceId).sort((a, b) => roleRank(a.role) - roleRank(b.role) || a.peerId.localeCompare(b.peerId));
11489
+ const priorByPeer = decodeCursor(instanceCursor, instanceId);
11490
+ const nextByPeer = {};
11491
+ for (const peer of peers) {
11492
+ const prior = priorByPeer[peer.peerId];
11493
+ if (prior !== void 0)
11494
+ nextByPeer[peer.peerId] = prior;
11495
+ }
11496
+ if (peers.length === 0) {
11497
+ return {
11498
+ instanceId,
11499
+ error: "No connected Peer exists for this Instance.",
11500
+ nextCursor: encodeCursor(instanceId, nextByPeer),
11501
+ peerErrors: []
11502
+ };
11503
+ }
11504
+ const reads = await Promise.all(peers.map(async (peer) => {
11505
+ const data = {};
11506
+ const peerSince = priorByPeer[peer.peerId];
11507
+ if (peerSince !== void 0)
11508
+ data.since = peerSince;
11509
+ if (tail !== void 0)
11510
+ data.tail = tail;
11511
+ if (filter !== void 0)
11512
+ data.filter = filter;
11513
+ try {
11514
+ const responseValue = await this.client.request("/api/get-runtime-logs", data, peer.peerId);
11515
+ if (typeof responseValue !== "object" || responseValue === null || Array.isArray(responseValue)) {
11516
+ return {
11517
+ peerId: peer.peerId,
11518
+ role: peer.role,
11519
+ error: "Studio returned an invalid runtime log response."
11520
+ };
11521
+ }
11522
+ const response = responseValue;
11523
+ if (typeof response.error === "string") {
11524
+ return { peerId: peer.peerId, role: peer.role, error: response.error };
11525
+ }
11526
+ if (!Array.isArray(response.entries) || typeof response.totalDropped !== "number" || typeof response.nextSince !== "number") {
11527
+ return {
11528
+ peerId: peer.peerId,
11529
+ role: peer.role,
11530
+ error: "Studio returned an invalid runtime log response."
11531
+ };
11532
+ }
11533
+ return {
11534
+ peerId: peer.peerId,
11535
+ role: peer.role,
11536
+ entries: response.entries,
11537
+ totalDropped: response.totalDropped,
11538
+ nextSince: response.nextSince
11539
+ };
11540
+ } catch (error) {
11541
+ return { peerId: peer.peerId, role: peer.role, error: errorMessage(error) };
11542
+ }
11543
+ }));
11544
+ const successful = [];
11545
+ const peerErrors = [];
11546
+ for (const read of reads) {
11547
+ if ("error" in read) {
11548
+ peerErrors.push(read);
11549
+ } else {
11550
+ successful.push(read);
11551
+ nextByPeer[read.peerId] = read.nextSince;
11552
+ }
11553
+ }
11554
+ const nextCursor = encodeCursor(instanceId, nextByPeer);
11555
+ if (successful.length === 0) {
11556
+ return {
11557
+ instanceId,
11558
+ error: "Every connected Peer failed to read its runtime log buffer.",
11559
+ nextCursor,
11560
+ peerErrors
11561
+ };
11562
+ }
11563
+ let insertionOrder = 0;
11564
+ const merged = successful.flatMap((read) => read.entries.map((entry) => ({
11565
+ entry: publicEntry(entry),
11566
+ timestamp: entryTimestamp(entry),
11567
+ insertionOrder: insertionOrder++
11568
+ })));
11569
+ merged.sort((a, b) => a.timestamp - b.timestamp || a.insertionOrder - b.insertionOrder);
11570
+ const allEntries = merged.map((item) => item.entry);
11571
+ const entries = tail === void 0 ? allEntries : tail === 0 ? [] : allEntries.slice(-tail);
11572
+ const totalDropped = successful.reduce((total, read) => total + read.totalDropped, 0);
11573
+ return {
11574
+ instanceId,
11575
+ entries,
11576
+ totalDropped,
11577
+ nextCursor,
11578
+ ...peerErrors.length > 0 ? { peerErrors } : {}
11579
+ };
11233
11580
  };
11234
- if (originPeerReliable) {
11235
- body.perPeerNextSince = perCaptureNextSince;
11581
+ if (selectedGroup) {
11582
+ const connectedIds = new Set(instances.map((instance) => instance.id));
11583
+ const instanceIds = selectedGroup.instanceIds.filter((id) => connectedIds.has(id));
11584
+ const results = await Promise.all(instanceIds.map((instanceId) => readInstance(instanceId, cursor_by_instance?.[instanceId])));
11585
+ const nextCursorByInstance = {};
11586
+ for (const result2 of results)
11587
+ nextCursorByInstance[result2.instanceId] = result2.nextCursor;
11588
+ return this._textResult({
11589
+ multiplayerGroupId: selectedGroup.id,
11590
+ instances: results,
11591
+ nextCursorByInstance
11592
+ });
11236
11593
  }
11237
- if (Object.keys(perCaptureErrors).length > 0) {
11238
- body.perCaptureErrors = perCaptureErrors;
11239
- if (originPeerReliable)
11240
- body.perPeerErrors = perCaptureErrors;
11594
+ const result = await readInstance(selectedInstanceId, cursor);
11595
+ if ("error" in result) {
11596
+ throw new Error(`get_runtime_logs failed for Instance "${result.instanceId}": ${result.error}`);
11241
11597
  }
11242
- return {
11243
- content: [{ type: "text", text: JSON.stringify(body) }]
11244
- };
11598
+ return this._textResult(result);
11245
11599
  }
11246
11600
  async captureScriptProfiler(target, request = {}, instance_id) {
11247
11601
  const targetRole = target ?? "server";
@@ -11261,15 +11615,12 @@ var RobloxStudioTools = class {
11261
11615
  throw new RoutingFailure({
11262
11616
  code: "target_role_not_present_on_instance",
11263
11617
  message: 'capture_script_profiler profiles one runtime peer at a time. Pick target="server" or a specific "client-N".',
11264
- data: {
11265
- instances: this.bridge.getPublicInstances(),
11266
- count: this.bridge.getInstances().length
11267
- }
11618
+ data: this._routingErrorData()
11268
11619
  });
11269
11620
  }
11270
11621
  data.__mcp_instance_id = resolved.targetInstanceId;
11271
11622
  data.__mcp_target_role = resolved.targetRole;
11272
- const response = await this.client.request("/api/capture-script-profiler", data, resolved.targetInstanceId, resolved.targetRole);
11623
+ const response = await this._requestPeer("/api/capture-script-profiler", data, resolved.targetPeerId);
11273
11624
  const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
11274
11625
  if (body !== null && typeof body === "object" && !Array.isArray(body)) {
11275
11626
  const mutable = body;
@@ -11324,15 +11675,12 @@ var RobloxStudioTools = class {
11324
11675
  throw new RoutingFailure({
11325
11676
  code: "target_role_not_present_on_instance",
11326
11677
  message: 'capture_micro_profiler profiles one runtime peer at a time. Pick target="server" or a specific "client-N".',
11327
- data: {
11328
- instances: this.bridge.getPublicInstances(),
11329
- count: this.bridge.getInstances().length
11330
- }
11678
+ data: this._routingErrorData()
11331
11679
  });
11332
11680
  }
11333
11681
  data.__mcp_instance_id = resolved.targetInstanceId;
11334
11682
  data.__mcp_target_role = resolved.targetRole;
11335
- const response = await this.client.request("/api/capture-micro-profiler", data, resolved.targetInstanceId, resolved.targetRole);
11683
+ const response = await this._requestPeer("/api/capture-micro-profiler", data, resolved.targetPeerId);
11336
11684
  const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
11337
11685
  if (body !== null && typeof body === "object" && !Array.isArray(body)) {
11338
11686
  const mutable = body;
@@ -11381,15 +11729,11 @@ var RobloxStudioTools = class {
11381
11729
  throw new RoutingFailure({
11382
11730
  code: "target_role_not_present_on_instance",
11383
11731
  message: "This tool does not support target=all. Pick a specific role or omit target.",
11384
- data: {
11385
- instances: this.bridge.getPublicInstances(),
11386
- count: this.bridge.getInstances().length
11387
- }
11732
+ data: this._routingErrorData()
11388
11733
  });
11389
11734
  }
11390
- data.__mcp_instance_id = resolved.targetInstanceId;
11391
11735
  data.__mcp_target_role = resolved.targetRole;
11392
- const response = await this.client.request("/api/breakpoints", data, resolved.targetInstanceId, resolved.targetRole);
11736
+ const response = await this._requestPeer("/api/breakpoints", data, resolved.targetPeerId);
11393
11737
  const body = response !== null && typeof response === "object" && !Array.isArray(response) ? { ...response, target: resolved.targetRole } : response;
11394
11738
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
11395
11739
  }
@@ -11412,12 +11756,8 @@ var RobloxStudioTools = class {
11412
11756
  }
11413
11757
  return value;
11414
11758
  }
11415
- _publicInstanceKey(instance) {
11416
- return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
11417
- }
11418
- async _isLatestPublishedPlaceOpen(placeId) {
11419
- const publishedInstanceId2 = `place:${placeId}`;
11420
- return this.bridge.getPublicInstances().some((instance) => instance.placeId === placeId || instance.instanceId === publishedInstanceId2) || (await this.instanceManager.list()).some((record) => record.closedAt === void 0 && record.source === "published_place" && record.placeId === placeId);
11759
+ _publicInstanceKey(peer) {
11760
+ return `${peer.peerId}:${peer.instanceId}:${peer.connectedAt}`;
11421
11761
  }
11422
11762
  _matchesManagedLaunch(record, instance) {
11423
11763
  if (record.source === "published_place") {
@@ -11455,7 +11795,7 @@ var RobloxStudioTools = class {
11455
11795
  if (record.state === "failed" || record.state === "exited" || record.closedAt !== void 0) {
11456
11796
  return void 0;
11457
11797
  }
11458
- const candidates = this.bridge.getPublicInstances().filter((instance) => instance.role === "edit").filter((instance) => !beforeKeys.has(this._publicInstanceKey(instance))).filter((instance) => instance.connectedAt >= record.launchedAt - 1e3).filter((instance) => this._matchesManagedLaunch(record, instance)).sort((a, b) => b.connectedAt - a.connectedAt);
11798
+ const candidates = this.bridge.getPublicPeers().filter((peer) => peer.role === "edit").filter((instance) => !beforeKeys.has(this._publicInstanceKey(instance))).filter((instance) => instance.connectedAt >= record.launchedAt - 1e3).filter((instance) => this._matchesManagedLaunch(record, instance)).sort((a, b) => b.connectedAt - a.connectedAt);
11459
11799
  if (candidates[0])
11460
11800
  return candidates[0];
11461
11801
  await sleep(500);
@@ -11463,7 +11803,7 @@ var RobloxStudioTools = class {
11463
11803
  return void 0;
11464
11804
  }
11465
11805
  _managedStatus(record) {
11466
- const connected = record.instanceId ? this.bridge.getPublicInstances().filter((instance) => instance.instanceId === record.instanceId) : [];
11806
+ const connected = record.instanceId ? this.bridge.getPublicPeers().filter((peer) => peer.instanceId === record.instanceId) : [];
11467
11807
  return {
11468
11808
  launch_id: record.recordId,
11469
11809
  instance_id: record.instanceId,
@@ -11555,7 +11895,7 @@ var RobloxStudioTools = class {
11555
11895
  }
11556
11896
  if (instance_id) {
11557
11897
  const record2 = await this.instanceManager.get(instance_id);
11558
- const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
11898
+ const connected2 = this.bridge.getPublicPeers().filter((peer) => peer.instanceId === instance_id);
11559
11899
  if (!record2 && connected2.length === 0) {
11560
11900
  return this._textResult({ error: "Instance is not connected or managed.", instance_id });
11561
11901
  }
@@ -11573,10 +11913,10 @@ var RobloxStudioTools = class {
11573
11913
  return this._textResult({
11574
11914
  managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
11575
11915
  connected: this.bridge.getPublicInstances().map((instance) => ({
11576
- instance_id: instance.instanceId,
11577
- role: instance.role,
11916
+ instance_id: instance.id,
11578
11917
  place_id: instance.placeId,
11579
- place_name: instance.placeName
11918
+ place_name: instance.placeName,
11919
+ roles: instance.peers.map((peer) => peer.role).sort()
11580
11920
  }))
11581
11921
  });
11582
11922
  }
@@ -11613,8 +11953,8 @@ var RobloxStudioTools = class {
11613
11953
  message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
11614
11954
  });
11615
11955
  }
11616
- const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
11617
- const edit = connected2.find((instance) => instance.role === "edit");
11956
+ const connected2 = this.bridge.getPublicPeers().filter((peer) => peer.instanceId === instance_id);
11957
+ const edit = connected2.find((peer) => peer.role === "edit");
11618
11958
  if (!edit) {
11619
11959
  return this._textResult({
11620
11960
  error: "Instance is not connected or managed.",
@@ -11679,12 +12019,6 @@ var RobloxStudioTools = class {
11679
12019
  }
11680
12020
  const processEnvironment = parseStudioProcessEnvironmentPatch(request.process_environment);
11681
12021
  const studioWorkingDirectory = parseStudioWorkingDirectory(request.studio_working_directory);
11682
- if (launchSource === "published_place" && placeId !== void 0 && await this._isLatestPublishedPlaceOpen(placeId)) {
11683
- return this._textResult({
11684
- error: "Place is already open.",
11685
- message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
11686
- });
11687
- }
11688
12022
  const universeId = launchSource === "published_place" || launchSource === "place_revision" ? await this._deriveUniverseId(placeId) : void 0;
11689
12023
  if (request.require_process_identity !== void 0 && typeof request.require_process_identity !== "boolean") {
11690
12024
  throw new Error("require_process_identity must be a boolean when provided.");
@@ -11692,7 +12026,7 @@ var RobloxStudioTools = class {
11692
12026
  const requireProcessIdentity = request.require_process_identity === true;
11693
12027
  const waitForConnection = !requireProcessIdentity && request.wait_for_connection !== false;
11694
12028
  const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
11695
- const beforeKeys = new Set(this.bridge.getPublicInstances().map((instance) => this._publicInstanceKey(instance)));
12029
+ const beforeKeys = new Set(this.bridge.getPublicPeers().map((peer) => this._publicInstanceKey(peer)));
11696
12030
  const record = await this.instanceManager.launch({
11697
12031
  source: launchSource,
11698
12032
  localPlaceFile,
@@ -11739,7 +12073,7 @@ var RobloxStudioTools = class {
11739
12073
  }
11740
12074
  if (action === "status") {
11741
12075
  const instanceId = this._resolveInstanceIdOnly(instance_id);
11742
- const { roles, runtimeRoles } = this._briefRoles(instanceId, true);
12076
+ const { roles, runtimeRoles } = this._briefRoles(instanceId);
11743
12077
  return this._textResult({
11744
12078
  success: true,
11745
12079
  action,
@@ -11802,22 +12136,19 @@ var RobloxStudioTools = class {
11802
12136
  throw new RoutingFailure({
11803
12137
  code: "target_role_not_present_on_instance",
11804
12138
  message: "This tool does not support target=all. Pick a specific role or omit target.",
11805
- data: {
11806
- instances: this.bridge.getPublicInstances(),
11807
- count: this.bridge.getInstances().length
11808
- }
12139
+ data: this._routingErrorData()
11809
12140
  });
11810
12141
  }
11811
- const existingRuntime = this._runtimeTargetsForEquivalentInstances(resolved.targetInstanceId);
12142
+ const existingRuntime = this._runtimeTargetsForScope(resolved.targetInstanceId);
11812
12143
  if (existingRuntime.length > 0) {
11813
- const roles = this._rolesForEquivalentInstances(resolved.targetInstanceId);
12144
+ const roles = this._rolesForScope(resolved.targetInstanceId);
11814
12145
  return {
11815
12146
  content: [{
11816
12147
  type: "text",
11817
12148
  text: JSON.stringify({
11818
12149
  success: false,
11819
12150
  error: "Playtest already running.",
11820
- message: "A playtest is already running for this Studio place. Stop the current playtest before starting another.",
12151
+ message: "A playtest is already running for this Studio process scope. Stop the current playtest before starting another.",
11821
12152
  runtimeReady: true,
11822
12153
  timedOut: false,
11823
12154
  roles,
@@ -11826,11 +12157,11 @@ var RobloxStudioTools = class {
11826
12157
  }]
11827
12158
  };
11828
12159
  }
11829
- const response = await this.client.request("/api/start-playtest", data, resolved.targetInstanceId, resolved.targetRole);
12160
+ const response = await this._requestPeer("/api/start-playtest", data, resolved.targetPeerId);
11830
12161
  let wait;
11831
12162
  if (response?.success === true) {
11832
12163
  const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
11833
- wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout, true);
12164
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, timeout);
11834
12165
  }
11835
12166
  const body = wait ? {
11836
12167
  ...response,
@@ -11852,7 +12183,7 @@ var RobloxStudioTools = class {
11852
12183
  let response;
11853
12184
  let stopRequestError;
11854
12185
  try {
11855
- response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
12186
+ response = await this._request("/api/stop-playtest", {}, instanceId, "edit");
11856
12187
  } catch (error) {
11857
12188
  stopRequestError = errorMessage(error);
11858
12189
  response = {
@@ -11863,11 +12194,11 @@ var RobloxStudioTools = class {
11863
12194
  }
11864
12195
  let wait;
11865
12196
  if (response?.success === true) {
11866
- wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout, true);
11867
- } else if (this._runtimeTargetsForEquivalentInstances(instanceId).length > 0) {
12197
+ wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, timeout);
12198
+ } else if (this._runtimeTargetsForScope(instanceId).length > 0) {
11868
12199
  wait = {
11869
12200
  ok: false,
11870
- roles: this._rolesForEquivalentInstances(instanceId),
12201
+ roles: this._rolesForScope(instanceId),
11871
12202
  timedOut: false
11872
12203
  };
11873
12204
  }
@@ -11898,9 +12229,11 @@ var RobloxStudioTools = class {
11898
12229
  };
11899
12230
  }
11900
12231
  async _buildMultiplayerState(instanceId) {
11901
- const peers = this.bridge.getPublicInstances().filter((i) => i.instanceId === instanceId).sort((a, b) => a.role.localeCompare(b.role));
12232
+ const peers = this.bridge.getPublicPeers().filter((peer) => this.bridge.getInstanceIdsInScope(instanceId).includes(peer.instanceId)).sort((a, b) => a.role.localeCompare(b.role));
12233
+ const multiplayerGroup = this.bridge.getMultiplayerGroups().find((group) => group.instanceIds.includes(instanceId));
11902
12234
  const body = {
11903
12235
  instanceId,
12236
+ multiplayerGroupId: multiplayerGroup?.id,
11904
12237
  peers,
11905
12238
  peerCount: peers.length
11906
12239
  };
@@ -11910,7 +12243,7 @@ var RobloxStudioTools = class {
11910
12243
  let serverState;
11911
12244
  if (edit) {
11912
12245
  try {
11913
- editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
12246
+ editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit");
11914
12247
  body.edit = editState;
11915
12248
  } catch (err2) {
11916
12249
  body.edit = { error: err2 instanceof Error ? err2.message : String(err2) };
@@ -11918,7 +12251,7 @@ var RobloxStudioTools = class {
11918
12251
  }
11919
12252
  if (server) {
11920
12253
  try {
11921
- serverState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "server");
12254
+ serverState = await this._request("/api/multiplayer-test-state", {}, instanceId, "server");
11922
12255
  body.server = serverState;
11923
12256
  } catch (err2) {
11924
12257
  body.server = { error: err2 instanceof Error ? err2.message : String(err2) };
@@ -11935,16 +12268,16 @@ var RobloxStudioTools = class {
11935
12268
  body.error = session?.error;
11936
12269
  body.players = serverState?.players ?? [];
11937
12270
  body.playerCount = serverState?.playerCount ?? 0;
11938
- body.clientRoles = this._clientRolesForInstance(instanceId);
12271
+ body.clientRoles = this._clientRolesForScope(instanceId);
11939
12272
  return body;
11940
12273
  }
11941
12274
  async _waitForMultiplayerEditDone(instanceId, timeoutSec = 30) {
11942
12275
  const deadline = Date.now() + timeoutSec * 1e3;
11943
12276
  while (Date.now() < deadline) {
11944
- if (!this._rolesForInstance(instanceId).includes("edit"))
12277
+ if (!this._rolesForScope(instanceId).includes("edit"))
11945
12278
  return false;
11946
12279
  try {
11947
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
12280
+ const editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit");
11948
12281
  const phase = editState?.session?.phase;
11949
12282
  if (phase === "completed" || phase === "failed")
11950
12283
  return true;
@@ -11955,19 +12288,7 @@ var RobloxStudioTools = class {
11955
12288
  return false;
11956
12289
  }
11957
12290
  async _isMultiplayerTestRunning(instanceId) {
11958
- const roles = this._rolesForInstance(instanceId);
11959
- const hasServer = roles.includes("server");
11960
- const clientCount = roles.filter((role) => role.startsWith("client-")).length;
11961
- if (roles.includes("edit")) {
11962
- try {
11963
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit");
11964
- const phase = editState?.session?.phase;
11965
- if (phase === "starting" || phase === "running")
11966
- return true;
11967
- } catch {
11968
- }
11969
- }
11970
- return hasServer && clientCount >= 2;
12291
+ return this.bridge.getMultiplayerGroups().some((group) => group.instanceIds.includes(instanceId));
11971
12292
  }
11972
12293
  async _waitForMultiplayerStart(instanceId, clientCount, timeoutSec = 30, connectedAfter) {
11973
12294
  const deadline = Date.now() + timeoutSec * 1e3;
@@ -11976,8 +12297,8 @@ var RobloxStudioTools = class {
11976
12297
  const exact = await this._waitForExactClientCount(instanceId, clientCount, 0.25, 0);
11977
12298
  if (exact.ok || exact.extraClients) {
11978
12299
  if (exact.ok && connectedAfter !== void 0) {
11979
- const instances = this.bridge.getInstances().filter((inst) => inst.instanceId === instanceId);
11980
- const freshRoles = new Set(instances.filter((inst) => inst.connectedAt >= connectedAfter).map((inst) => inst.role));
12300
+ const peers = this.bridge.getPeersInScope(instanceId);
12301
+ const freshRoles = new Set(peers.filter((peer) => peer.connectedAt >= connectedAfter).map((peer) => peer.role));
11981
12302
  const freshClientCount = [...freshRoles].filter((role) => /^client-\d+$/.test(role)).length;
11982
12303
  if (!freshRoles.has("server") || freshClientCount !== clientCount) {
11983
12304
  await sleep(250);
@@ -11988,19 +12309,19 @@ var RobloxStudioTools = class {
11988
12309
  }
11989
12310
  try {
11990
12311
  const remainingMs = Math.max(1, Math.min(1e3, deadline - Date.now()));
11991
- const editState = await this.client.request("/api/multiplayer-test-state", {}, instanceId, "edit", remainingMs);
12312
+ const editState = await this._request("/api/multiplayer-test-state", {}, instanceId, "edit", remainingMs);
11992
12313
  const session = editState?.session;
11993
12314
  if (typeof session?.phase === "string") {
11994
12315
  lastPhase = session.phase;
11995
12316
  }
11996
12317
  if (session?.phase === "failed") {
11997
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: false, phase: session.phase, error: session.error };
12318
+ return { ok: false, roles: this._rolesForScope(instanceId), timedOut: false, phase: session.phase, error: session.error };
11998
12319
  }
11999
12320
  } catch {
12000
12321
  }
12001
12322
  await sleep(250);
12002
12323
  }
12003
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true, phase: lastPhase };
12324
+ return { ok: false, roles: this._rolesForScope(instanceId), timedOut: true, phase: lastPhase };
12004
12325
  }
12005
12326
  async multiplayerPlaytest(action, numPlayers, target, testArgs, value, timeout, instance_id) {
12006
12327
  if (action !== "start" && action !== "status" && action !== "add_players" && action !== "leave_client" && action !== "end") {
@@ -12008,9 +12329,11 @@ var RobloxStudioTools = class {
12008
12329
  }
12009
12330
  const briefState = async (instanceId) => {
12010
12331
  const state = await this._buildMultiplayerState(this._resolveInstanceIdOnly(instanceId));
12332
+ const roles = Array.isArray(state.peers) ? state.peers.flatMap((peer) => peer !== null && typeof peer === "object" && "role" in peer && typeof peer.role === "string" ? [peer.role] : []) : [];
12011
12333
  return {
12012
12334
  phase: state.phase,
12013
- roles: Array.isArray(state.peers) ? state.peers.map((peer) => peer.role).filter((role) => typeof role === "string") : [],
12335
+ multiplayerGroupId: typeof state.multiplayerGroupId === "string" ? state.multiplayerGroupId : void 0,
12336
+ roles,
12014
12337
  playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0,
12015
12338
  error: typeof state.error === "string" ? state.error : void 0
12016
12339
  };
@@ -12024,25 +12347,32 @@ var RobloxStudioTools = class {
12024
12347
  }
12025
12348
  if (action === "start") {
12026
12349
  const body2 = this._parseTextResult(await this.multiplayerTestStart(numPlayers, testArgs, timeout, instance_id));
12027
- const state = body2.state && typeof body2.state === "object" ? body2.state : {};
12350
+ const stateValue = body2.state;
12351
+ const state = stateValue !== null && typeof stateValue === "object" && !Array.isArray(stateValue) ? { ...stateValue } : {};
12352
+ const waitValue = body2.wait;
12353
+ const wait = waitValue !== null && typeof waitValue === "object" && !Array.isArray(waitValue) ? { ...waitValue } : {};
12028
12354
  const launched = body2.success === true && body2.ready === true;
12355
+ const multiplayerGroupId2 = typeof body2.multiplayerGroupId === "string" ? body2.multiplayerGroupId : typeof body2.testId === "string" ? body2.testId : void 0;
12029
12356
  return this._textResult(launched ? {
12030
12357
  success: true,
12031
12358
  action,
12032
12359
  message: "Multiplayer playtest started.",
12360
+ multiplayerGroupId: multiplayerGroupId2,
12033
12361
  roles: Array.isArray(body2.roles) ? body2.roles : void 0,
12034
12362
  playerCount: typeof state.playerCount === "number" ? state.playerCount : void 0
12035
12363
  } : {
12036
12364
  success: false,
12037
12365
  action,
12038
- error: body2.error ?? body2.wait?.error ?? "multiplayer_start_not_detected",
12366
+ error: body2.error ?? wait.error ?? "multiplayer_start_not_detected",
12039
12367
  message: body2.success === true ? "Multiplayer playtest start was requested, but MCP did not detect the required server/client peers before timeout." : body2.message ?? "Multiplayer playtest did not start.",
12368
+ multiplayerGroupId: multiplayerGroupId2,
12040
12369
  roles: Array.isArray(body2.roles) ? body2.roles : void 0
12041
12370
  });
12042
12371
  }
12043
12372
  if (action === "add_players") {
12044
12373
  const body2 = this._parseTextResult(await this.multiplayerTestAddPlayers(numPlayers, timeout, instance_id));
12045
- const state = body2.state && typeof body2.state === "object" ? body2.state : {};
12374
+ const stateValue = body2.state;
12375
+ const state = stateValue !== null && typeof stateValue === "object" && !Array.isArray(stateValue) ? { ...stateValue } : {};
12046
12376
  const success = body2.success === true && body2.ready === true;
12047
12377
  return this._textResult(success ? {
12048
12378
  success: true,
@@ -12074,14 +12404,17 @@ var RobloxStudioTools = class {
12074
12404
  });
12075
12405
  }
12076
12406
  const body = this._parseTextResult(await this.multiplayerTestEnd(value, timeout, instance_id));
12407
+ const multiplayerGroupId = typeof body.multiplayerGroupId === "string" ? body.multiplayerGroupId : void 0;
12077
12408
  return this._textResult(body.success === true && body.ended === true ? {
12078
12409
  success: true,
12079
12410
  action,
12411
+ multiplayerGroupId,
12080
12412
  message: body.alreadyEnded === true ? "Multiplayer playtest already ended." : body.teardownConfirmed === false ? 'Multiplayer playtest end requested; teardown still in progress. Use multiplayer_playtest action="status" to confirm.' : "Multiplayer playtest ended.",
12081
12413
  teardownConfirmed: body.teardownConfirmed === true
12082
12414
  } : {
12083
12415
  success: false,
12084
12416
  action,
12417
+ multiplayerGroupId,
12085
12418
  error: body.error ?? "end_failed",
12086
12419
  message: body.message ?? "Multiplayer playtest did not end.",
12087
12420
  roles: Array.isArray(body.roles) ? body.roles : void 0,
@@ -12093,13 +12426,13 @@ var RobloxStudioTools = class {
12093
12426
  throw new Error("numPlayers must be an integer from 1 to 8");
12094
12427
  }
12095
12428
  const editTarget = this._resolveSingleTarget("edit", instance_id);
12096
- const existingRuntime = this._runtimeTargetsForEquivalentInstances(editTarget.instanceId);
12429
+ const existingRuntime = this._runtimeTargetsForScope(editTarget.instanceId);
12097
12430
  if (existingRuntime.length > 0) {
12098
- const roles = this._rolesForEquivalentInstances(editTarget.instanceId);
12431
+ const roles = this._rolesForScope(editTarget.instanceId);
12099
12432
  return this._textResult({
12100
12433
  success: false,
12101
12434
  error: "Multiplayer playtest already running.",
12102
- message: "A Studio runtime is already connected for this place. End the existing playtest before starting another multiplayer playtest.",
12435
+ message: "A Studio runtime is already connected for this process scope. End the existing playtest before starting another multiplayer playtest.",
12103
12436
  ready: true,
12104
12437
  timedOut: false,
12105
12438
  roles,
@@ -12107,23 +12440,35 @@ var RobloxStudioTools = class {
12107
12440
  });
12108
12441
  }
12109
12442
  const startedAt = Date.now();
12110
- const response = await this.client.request("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.instanceId, editTarget.role);
12111
- if (response?.error) {
12112
- return { content: [{ type: "text", text: JSON.stringify(response) }] };
12443
+ const response = await this._requestPeer("/api/multiplayer-test-start", { numPlayers, testArgs: testArgs ?? {} }, editTarget.targetPeerId);
12444
+ const groupId = typeof response?.testId === "string" ? response.testId : void 0;
12445
+ if (response?.error || response?.success !== true || groupId === void 0) {
12446
+ if (groupId !== void 0)
12447
+ await this.bridge.removeMultiplayerGroupEverywhere(groupId);
12448
+ return this._textResult({
12449
+ ...response,
12450
+ error: response?.error ?? "Multiplayer start did not return a testId."
12451
+ });
12113
12452
  }
12453
+ await this.bridge.createMultiplayerGroupEverywhere(groupId, editTarget.instanceId);
12114
12454
  const wait = await this._waitForMultiplayerStart(editTarget.instanceId, numPlayers, timeout ?? 60, startedAt);
12115
12455
  const launched = wait.ok;
12116
12456
  const state = await this._buildMultiplayerState(editTarget.instanceId);
12117
- const success = response.success === true && wait.ok;
12457
+ const success = wait.ok;
12458
+ const runtimeStillConnected = this._runtimeTargetsForScope(editTarget.instanceId).length > 0;
12459
+ const definitelyFailed = state.phase === "failed" && !runtimeStillConnected;
12460
+ if (definitelyFailed)
12461
+ await this.bridge.removeMultiplayerGroupEverywhere(groupId);
12118
12462
  return {
12119
12463
  content: [{
12120
12464
  type: "text",
12121
12465
  text: JSON.stringify({
12122
12466
  ...response,
12467
+ multiplayerGroupId: groupId,
12123
12468
  success,
12124
12469
  ready: wait.ok,
12125
12470
  launched,
12126
- startRequested: response.success === true,
12471
+ startRequested: true,
12127
12472
  timedOut: wait.timedOut,
12128
12473
  wait,
12129
12474
  roles: wait.roles,
@@ -12145,13 +12490,15 @@ var RobloxStudioTools = class {
12145
12490
  throw new Error("numPlayers must be an integer from 1 to 8");
12146
12491
  }
12147
12492
  const serverTarget = this._resolveSingleTarget("server", instance_id);
12148
- const before = this._clientRolesForInstance(serverTarget.instanceId).length;
12149
- const response = await this.client.request("/api/multiplayer-test-add-players", { numPlayers, timeout: timeout ?? 10 }, serverTarget.instanceId, serverTarget.role);
12493
+ const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
12494
+ const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
12495
+ const before = this._clientRolesForScope(scopeInstanceId).length;
12496
+ const response = await this._requestPeer("/api/multiplayer-test-add-players", { numPlayers, timeout: timeout ?? 10 }, serverTarget.targetPeerId);
12150
12497
  if (response?.error) {
12151
12498
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
12152
12499
  }
12153
- const wait = await this._waitForExactClientCount(serverTarget.instanceId, before + numPlayers, timeout ?? 30);
12154
- const state = await this._buildMultiplayerState(serverTarget.instanceId);
12500
+ const wait = await this._waitForExactClientCount(scopeInstanceId, before + numPlayers, timeout ?? 30);
12501
+ const state = await this._buildMultiplayerState(scopeInstanceId);
12155
12502
  return {
12156
12503
  content: [{
12157
12504
  type: "text",
@@ -12171,12 +12518,14 @@ var RobloxStudioTools = class {
12171
12518
  throw new Error(`multiplayer_test_leave_client requires target=client-N (got: ${target})`);
12172
12519
  }
12173
12520
  const clientTarget = this._resolveSingleTarget(target, instance_id);
12174
- const response = await this.client.request("/api/multiplayer-test-leave-client", {}, clientTarget.instanceId, clientTarget.role);
12521
+ const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(clientTarget.instanceId));
12522
+ const scopeInstanceId = group?.controllerInstanceId ?? clientTarget.instanceId;
12523
+ const response = await this._requestPeer("/api/multiplayer-test-leave-client", {}, clientTarget.targetPeerId);
12175
12524
  if (response?.error) {
12176
12525
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
12177
12526
  }
12178
- const wait = await this._waitForRuntimeRoles(clientTarget.instanceId, { absentRole: clientTarget.role }, timeout ?? 30);
12179
- const state = await this._buildMultiplayerState(clientTarget.instanceId);
12527
+ const wait = await this._waitForRuntimeRoles(scopeInstanceId, { absentRole: clientTarget.role }, timeout ?? 30);
12528
+ const state = await this._buildMultiplayerState(scopeInstanceId);
12180
12529
  return {
12181
12530
  content: [{
12182
12531
  type: "text",
@@ -12194,29 +12543,39 @@ var RobloxStudioTools = class {
12194
12543
  let serverTarget;
12195
12544
  try {
12196
12545
  serverTarget = this._resolveSingleTarget("server", instance_id);
12197
- } catch (err2) {
12546
+ } catch (error) {
12198
12547
  const instanceId = this._resolveInstanceIdOnly(instance_id);
12199
- const hasRuntime = this._rolesForInstance(instanceId).some((role) => role === "server" || /^client-\d+$/.test(role));
12548
+ const group2 = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(instanceId));
12549
+ const hasRuntime = this._rolesForScope(instanceId).some((role) => role === "server" || /^client-\d+$/.test(role));
12200
12550
  if (!hasRuntime) {
12551
+ if (group2)
12552
+ await this.bridge.removeMultiplayerGroupEverywhere(group2.id);
12201
12553
  return this._textResult({
12202
12554
  success: true,
12555
+ multiplayerGroupId: group2?.id,
12203
12556
  ended: true,
12204
12557
  alreadyEnded: true,
12205
12558
  teardownConfirmed: true,
12206
12559
  message: "No active multiplayer test to end (already ended)."
12207
12560
  });
12208
12561
  }
12209
- throw err2;
12562
+ throw error;
12210
12563
  }
12211
- const response = await this.client.request("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.instanceId, serverTarget.role);
12564
+ const group = this.bridge.getMultiplayerGroups().find((candidate) => candidate.instanceIds.includes(serverTarget.instanceId));
12565
+ const scopeInstanceId = group?.controllerInstanceId ?? serverTarget.instanceId;
12566
+ const response = await this._requestPeer("/api/multiplayer-test-end", { value: value ?? "ended_by_mcp" }, serverTarget.targetPeerId);
12212
12567
  if (response?.error) {
12213
- return this._textResult(response);
12568
+ return this._textResult({
12569
+ ...response,
12570
+ multiplayerGroupId: group?.id
12571
+ });
12214
12572
  }
12215
- const editDone = await this._waitForMultiplayerEditDone(serverTarget.instanceId, timeout ?? 30);
12216
- const wait = await this._waitForRuntimeRoles(serverTarget.instanceId, { noRuntime: true }, timeout ?? 30);
12217
- const state = await this._buildMultiplayerState(serverTarget.instanceId);
12218
- return this._textResult({
12573
+ const editDone = await this._waitForMultiplayerEditDone(scopeInstanceId, timeout ?? 30);
12574
+ const wait = await this._waitForRuntimeRoles(scopeInstanceId, { noRuntime: true }, timeout ?? 30);
12575
+ const state = await this._buildMultiplayerState(scopeInstanceId);
12576
+ const result = this._textResult({
12219
12577
  ...response,
12578
+ multiplayerGroupId: group?.id,
12220
12579
  ended: response.success === true,
12221
12580
  teardownConfirmed: wait.ok,
12222
12581
  editDone,
@@ -12224,24 +12583,15 @@ var RobloxStudioTools = class {
12224
12583
  roles: wait.roles,
12225
12584
  state
12226
12585
  });
12586
+ if (wait.ok && group)
12587
+ await this.bridge.removeMultiplayerGroupEverywhere(group.id);
12588
+ return result;
12227
12589
  }
12228
12590
  async getConnectedInstances() {
12229
- const places = /* @__PURE__ */ new Map();
12230
- for (const peer of this.bridge.getPublicInstances()) {
12231
- let place = places.get(peer.instanceId);
12232
- if (!place) {
12233
- place = {
12234
- id: peer.instanceId,
12235
- name: peer.placeName || peer.dataModelName,
12236
- roles: []
12237
- };
12238
- places.set(peer.instanceId, place);
12239
- }
12240
- if (!place.roles.includes(peer.role)) {
12241
- place.roles.push(peer.role);
12242
- }
12243
- }
12244
- return this._textResult({ instances: [...places.values()] });
12591
+ return this._textResult({
12592
+ instances: this.bridge.getConnectedInstances(),
12593
+ multiplayerGroups: this.bridge.getConnectedMultiplayerGroups()
12594
+ });
12245
12595
  }
12246
12596
  // === Asset Tools ===
12247
12597
  async searchAssets(assetType, query, maxResults, sortBy, robloxCreatedOnly) {
@@ -12825,13 +13175,13 @@ var RobloxStudioTools = class {
12825
13175
  if (!resolved.ok)
12826
13176
  throw new RoutingFailure(resolved.error);
12827
13177
  if (resolved.mode === "single") {
12828
- const response = await this.client.request("/api/get-memory-breakdown", data, resolved.targetInstanceId, resolved.targetRole);
13178
+ const response = await this._requestPeer("/api/get-memory-breakdown", data, resolved.targetPeerId);
12829
13179
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
12830
13180
  }
12831
13181
  const targets = resolved.targets;
12832
13182
  const responses = await Promise.allSettled(targets.map(async (t) => ({
12833
13183
  peer: t.targetRole,
12834
- result: await this.client.request("/api/get-memory-breakdown", data, t.targetInstanceId, t.targetRole)
13184
+ result: await this._requestPeer("/api/get-memory-breakdown", data, t.targetPeerId)
12835
13185
  })));
12836
13186
  const body = {};
12837
13187
  for (let i = 0; i < responses.length; i++) {
@@ -12858,13 +13208,13 @@ var RobloxStudioTools = class {
12858
13208
  if (!resolved.ok)
12859
13209
  throw new RoutingFailure(resolved.error);
12860
13210
  if (resolved.mode === "single") {
12861
- const response = await this.client.request("/api/get-scene-analysis", data, resolved.targetInstanceId, resolved.targetRole);
13211
+ const response = await this._requestPeer("/api/get-scene-analysis", data, resolved.targetPeerId);
12862
13212
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
12863
13213
  }
12864
13214
  const targets = resolved.targets;
12865
13215
  const responses = await Promise.allSettled(targets.map(async (t) => ({
12866
13216
  peer: t.targetRole,
12867
- result: await this.client.request("/api/get-scene-analysis", data, t.targetInstanceId, t.targetRole)
13217
+ result: await this._requestPeer("/api/get-scene-analysis", data, t.targetPeerId)
12868
13218
  })));
12869
13219
  const body = {};
12870
13220
  for (let i = 0; i < responses.length; i++) {
@@ -13085,12 +13435,17 @@ var RobloxStudioTools = class {
13085
13435
  // ../core/dist/proxy-bridge-service.js
13086
13436
  import { randomUUID as randomUUID4 } from "crypto";
13087
13437
  var PROXY_RESPONSE_GRACE_MS = 5e3;
13438
+ function peerPublicationChanged(previous, current) {
13439
+ return previous === void 0 || previous.transportPeerId !== current.transportPeerId || previous.instanceId !== current.instanceId || previous.multiplayerGroupId !== current.multiplayerGroupId || previous.role !== current.role || previous.placeId !== current.placeId || previous.placeName !== current.placeName || previous.placeKey !== current.placeKey || previous.dataModelName !== current.dataModelName || previous.isRunning !== current.isRunning || previous.pluginVersion !== current.pluginVersion || previous.pluginVariant !== current.pluginVariant || previous.serverVersion !== current.serverVersion;
13440
+ }
13088
13441
  var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13089
13442
  primaryBaseUrl;
13090
13443
  authToken;
13091
13444
  proxyInstanceId;
13092
13445
  proxyRequestTimeout = 3e4;
13446
+ cachedPeers = [];
13093
13447
  cachedInstances = [];
13448
+ cachedMultiplayerGroups = [];
13094
13449
  initialRefresh;
13095
13450
  refreshTimer;
13096
13451
  static REFRESH_INTERVAL_MS = 1e3;
@@ -13099,8 +13454,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13099
13454
  this.primaryBaseUrl = primaryBaseUrl;
13100
13455
  this.authToken = authToken;
13101
13456
  this.proxyInstanceId = randomUUID4();
13102
- this.initialRefresh = this.refreshInstances();
13103
- this.refreshTimer = setInterval(() => this.refreshInstances(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
13457
+ this.initialRefresh = this.refreshTopology();
13458
+ this.refreshTimer = setInterval(() => this.refreshTopology(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
13104
13459
  }
13105
13460
  waitForInitialRefresh() {
13106
13461
  return this.initialRefresh;
@@ -13111,29 +13466,99 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13111
13466
  headers["X-MCP-Auth"] = this.authToken;
13112
13467
  return headers;
13113
13468
  }
13114
- async refreshInstances() {
13469
+ async refreshTopology() {
13115
13470
  try {
13116
- const res = await fetch(`${this.primaryBaseUrl}/instances`, {
13471
+ const res = await fetch(`${this.primaryBaseUrl}/topology`, {
13117
13472
  headers: this.authHeaders()
13118
13473
  });
13119
13474
  if (!res.ok)
13120
13475
  return;
13121
13476
  const body = await res.json();
13122
- if (Array.isArray(body.instances)) {
13123
- const previousKeys = new Set(this.cachedInstances.map((instance) => `${instance.pluginSessionId}\0${instance.instanceId}\0${instance.role}\0${instance.connectedAt}`));
13124
- this.cachedInstances = body.instances;
13125
- for (const instance of body.instances) {
13126
- const key = `${instance.pluginSessionId}\0${instance.instanceId}\0${instance.role}\0${instance.connectedAt}`;
13127
- if (!previousKeys.has(key))
13128
- this.notifyInstanceRegistered(toPublic(instance));
13477
+ if (!Array.isArray(body.peers) || !Array.isArray(body.instances) || !Array.isArray(body.multiplayerGroups)) {
13478
+ return;
13479
+ }
13480
+ const previousPeers = new Map(this.cachedPeers.map((peer) => [peer.peerId, peer]));
13481
+ this.cachedPeers = body.peers;
13482
+ this.cachedInstances = body.instances;
13483
+ this.cachedMultiplayerGroups = body.multiplayerGroups;
13484
+ for (const peer of body.peers) {
13485
+ if (peerPublicationChanged(previousPeers.get(peer.peerId), peer)) {
13486
+ this.notifyPeerRegistered(toPublicPeer(peer));
13129
13487
  }
13130
13488
  }
13131
13489
  } catch {
13132
13490
  }
13133
13491
  }
13492
+ getPeers() {
13493
+ return this.cachedPeers;
13494
+ }
13134
13495
  getInstances() {
13135
13496
  return this.cachedInstances;
13136
13497
  }
13498
+ getMultiplayerGroups() {
13499
+ return this.cachedMultiplayerGroups;
13500
+ }
13501
+ getTopologySnapshot() {
13502
+ return {
13503
+ peers: this.cachedPeers,
13504
+ instances: this.cachedInstances,
13505
+ multiplayerGroups: this.cachedMultiplayerGroups
13506
+ };
13507
+ }
13508
+ async createMultiplayerGroupEverywhere(groupId, controllerInstanceId) {
13509
+ const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
13510
+ method: "POST",
13511
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
13512
+ body: JSON.stringify({ groupId, controllerInstanceId })
13513
+ });
13514
+ if (!response.ok) {
13515
+ const body = await response.text().catch(() => "");
13516
+ throw new Error(`Proxy Multiplayer Group creation failed (${response.status}): ${body || response.statusText}`);
13517
+ }
13518
+ const result = await response.json();
13519
+ if (!result.group || result.group.id !== groupId) {
13520
+ throw new Error("Proxy Multiplayer Group creation returned an invalid Group.");
13521
+ }
13522
+ const group = {
13523
+ ...result.group,
13524
+ instanceIds: [...result.group.instanceIds]
13525
+ };
13526
+ this.cachedPeers = this.cachedPeers.map((peer) => peer.instanceId === controllerInstanceId ? { ...peer, multiplayerGroupId: group.id } : peer);
13527
+ this.cachedInstances = this.cachedInstances.map((instance) => instance.id === controllerInstanceId ? {
13528
+ ...instance,
13529
+ multiplayerGroupId: group.id,
13530
+ peers: instance.peers.map((peer) => ({ ...peer, multiplayerGroupId: group.id }))
13531
+ } : instance);
13532
+ this.cachedMultiplayerGroups = [
13533
+ ...this.cachedMultiplayerGroups.filter((candidate) => candidate.id !== group.id),
13534
+ group
13535
+ ];
13536
+ return group;
13537
+ }
13538
+ async removeMultiplayerGroupEverywhere(groupId) {
13539
+ const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
13540
+ method: "POST",
13541
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
13542
+ body: JSON.stringify({ groupId })
13543
+ });
13544
+ if (!response.ok) {
13545
+ const body = await response.text().catch(() => "");
13546
+ throw new Error(`Proxy Multiplayer Group removal failed (${response.status}): ${body || response.statusText}`);
13547
+ }
13548
+ const result = await response.json();
13549
+ const removed = result.removed;
13550
+ if (removed !== void 0 && removed.id !== groupId) {
13551
+ throw new Error("Proxy Multiplayer Group removal returned an invalid Group.");
13552
+ }
13553
+ this.cachedPeers = this.cachedPeers.map((peer) => peer.multiplayerGroupId === groupId ? { ...peer, multiplayerGroupId: void 0 } : peer);
13554
+ this.cachedInstances = this.cachedInstances.map((instance) => instance.multiplayerGroupId === groupId ? {
13555
+ ...instance,
13556
+ multiplayerGroupId: void 0,
13557
+ peers: instance.peers.map((peer) => ({ ...peer, multiplayerGroupId: void 0 }))
13558
+ } : instance);
13559
+ this.cachedMultiplayerGroups = this.cachedMultiplayerGroups.filter((candidate) => candidate.id !== groupId);
13560
+ return removed === void 0 ? void 0 : { ...removed, instanceIds: [...removed.instanceIds] };
13561
+ }
13137
13562
  async unregisterInstanceIdEverywhere(instanceId) {
13138
13563
  const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
13139
13564
  method: "POST",
@@ -13146,10 +13571,17 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13146
13571
  }
13147
13572
  const result = await response.json();
13148
13573
  const removed = Array.isArray(result.removed) ? result.removed : [];
13149
- const removedKeys = new Set(removed.map((inst) => `${inst.instanceId}\0${inst.role}`));
13150
- if (removedKeys.size > 0) {
13151
- this.cachedInstances = this.cachedInstances.filter((inst) => !removedKeys.has(`${inst.instanceId}\0${inst.role}`));
13152
- }
13574
+ const removedPeerIds = new Set(removed.map((peer) => peer.peerId));
13575
+ const removedInstanceIds = /* @__PURE__ */ new Set([
13576
+ instanceId,
13577
+ ...removed.map((peer) => peer.instanceId)
13578
+ ]);
13579
+ this.cachedPeers = this.cachedPeers.filter((peer) => !removedPeerIds.has(peer.peerId));
13580
+ this.cachedInstances = this.cachedInstances.filter((instance) => !removedInstanceIds.has(instance.id));
13581
+ this.cachedMultiplayerGroups = this.cachedMultiplayerGroups.map((group) => ({
13582
+ ...group,
13583
+ instanceIds: group.instanceIds.filter((id) => !removedInstanceIds.has(id))
13584
+ })).filter((group) => group.instanceIds.length > 0);
13153
13585
  return removed;
13154
13586
  }
13155
13587
  /** Called when this proxy is being discarded (e.g. promotion to primary
@@ -13160,7 +13592,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13160
13592
  this.refreshTimer = void 0;
13161
13593
  }
13162
13594
  }
13163
- async sendRequest(endpoint, data, targetInstanceId, targetRole, timeoutMs = this.proxyRequestTimeout, signal) {
13595
+ async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal) {
13164
13596
  if (signal?.aborted)
13165
13597
  throw new Error("Request aborted");
13166
13598
  const controller = new AbortController();
@@ -13181,8 +13613,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
13181
13613
  body: JSON.stringify({
13182
13614
  endpoint,
13183
13615
  data,
13184
- targetInstanceId,
13185
- targetRole,
13616
+ targetPeerId,
13186
13617
  proxyInstanceId: this.proxyInstanceId,
13187
13618
  timeoutMs: effectiveTimeoutMs
13188
13619
  }),
@@ -13337,7 +13768,7 @@ var RobloxStudioMCPServer = class {
13337
13768
  }, 5e3);
13338
13769
  const cleanupInterval = setInterval(() => {
13339
13770
  this.bridge.cleanupOldRequests();
13340
- this.bridge.cleanupStaleInstances();
13771
+ this.bridge.cleanupStalePeers();
13341
13772
  }, 5e3);
13342
13773
  const shutdown = async () => {
13343
13774
  console.error("Shutting down MCP server...");
@@ -13379,7 +13810,7 @@ var TOOL_DEFINITIONS = [
13379
13810
  properties: {
13380
13811
  instance_id: {
13381
13812
  type: "string",
13382
- description: "Connected place ID; required with multiple places."
13813
+ description: "Studio process ID when ambiguous."
13383
13814
  }
13384
13815
  }
13385
13816
  }
@@ -13406,7 +13837,7 @@ var TOOL_DEFINITIONS = [
13406
13837
  },
13407
13838
  instance_id: {
13408
13839
  type: "string",
13409
- description: "Connected place ID; required with multiple places."
13840
+ description: "Studio process ID when ambiguous."
13410
13841
  }
13411
13842
  },
13412
13843
  required: ["query"]
@@ -13430,7 +13861,7 @@ var TOOL_DEFINITIONS = [
13430
13861
  },
13431
13862
  instance_id: {
13432
13863
  type: "string",
13433
- description: "Connected place ID; required with multiple places."
13864
+ description: "Studio process ID when ambiguous."
13434
13865
  }
13435
13866
  },
13436
13867
  required: ["instancePath"]
@@ -13458,7 +13889,7 @@ var TOOL_DEFINITIONS = [
13458
13889
  },
13459
13890
  instance_id: {
13460
13891
  type: "string",
13461
- description: "Connected place ID; required with multiple places."
13892
+ description: "Studio process ID when ambiguous."
13462
13893
  }
13463
13894
  }
13464
13895
  }
@@ -13480,7 +13911,7 @@ var TOOL_DEFINITIONS = [
13480
13911
  },
13481
13912
  instance_id: {
13482
13913
  type: "string",
13483
- description: "Connected place ID; required with multiple places."
13914
+ description: "Studio process ID when ambiguous."
13484
13915
  }
13485
13916
  },
13486
13917
  required: ["instancePath", "properties"]
@@ -13505,7 +13936,7 @@ var TOOL_DEFINITIONS = [
13505
13936
  },
13506
13937
  instance_id: {
13507
13938
  type: "string",
13508
- description: "Connected place ID; required with multiple places."
13939
+ description: "Studio process ID when ambiguous."
13509
13940
  }
13510
13941
  },
13511
13942
  required: ["instancePath"]
@@ -13528,7 +13959,7 @@ var TOOL_DEFINITIONS = [
13528
13959
  },
13529
13960
  instance_id: {
13530
13961
  type: "string",
13531
- description: "Connected place ID; required with multiple places."
13962
+ description: "Studio process ID when ambiguous."
13532
13963
  }
13533
13964
  },
13534
13965
  required: ["instancePath", "source"]
@@ -13559,7 +13990,7 @@ var TOOL_DEFINITIONS = [
13559
13990
  },
13560
13991
  instance_id: {
13561
13992
  type: "string",
13562
- description: "Connected place ID; required with multiple places."
13993
+ description: "Studio process ID when ambiguous."
13563
13994
  }
13564
13995
  },
13565
13996
  required: ["instancePath", "old_string", "new_string"]
@@ -13586,7 +14017,7 @@ var TOOL_DEFINITIONS = [
13586
14017
  },
13587
14018
  instance_id: {
13588
14019
  type: "string",
13589
- description: "Connected place ID; required with multiple places."
14020
+ description: "Studio process ID when ambiguous."
13590
14021
  }
13591
14022
  },
13592
14023
  required: ["instancePath", "newContent"]
@@ -13609,7 +14040,7 @@ var TOOL_DEFINITIONS = [
13609
14040
  },
13610
14041
  instance_id: {
13611
14042
  type: "string",
13612
- description: "Connected place ID; required with multiple places."
14043
+ description: "Studio process ID when ambiguous."
13613
14044
  }
13614
14045
  },
13615
14046
  required: ["instancePath", "line_range"]
@@ -13628,7 +14059,7 @@ var TOOL_DEFINITIONS = [
13628
14059
  },
13629
14060
  instance_id: {
13630
14061
  type: "string",
13631
- description: "Connected place ID; required with multiple places."
14062
+ description: "Studio process ID when ambiguous."
13632
14063
  }
13633
14064
  },
13634
14065
  required: ["instancePath"]
@@ -13682,7 +14113,7 @@ var TOOL_DEFINITIONS = [
13682
14113
  },
13683
14114
  instance_id: {
13684
14115
  type: "string",
13685
- description: "Connected place ID if multiple are open."
14116
+ description: "Studio process ID when ambiguous."
13686
14117
  }
13687
14118
  },
13688
14119
  required: ["action"]
@@ -13705,7 +14136,7 @@ var TOOL_DEFINITIONS = [
13705
14136
  },
13706
14137
  instance_id: {
13707
14138
  type: "string",
13708
- description: "Connected place ID; required with multiple places."
14139
+ description: "Studio process ID when ambiguous."
13709
14140
  }
13710
14141
  },
13711
14142
  required: ["code"]
@@ -13724,7 +14155,7 @@ var TOOL_DEFINITIONS = [
13724
14155
  },
13725
14156
  instance_id: {
13726
14157
  type: "string",
13727
- description: "Connected place ID; required with multiple places."
14158
+ description: "Studio process ID when ambiguous."
13728
14159
  }
13729
14160
  },
13730
14161
  required: ["code"]
@@ -13747,7 +14178,7 @@ var TOOL_DEFINITIONS = [
13747
14178
  },
13748
14179
  instance_id: {
13749
14180
  type: "string",
13750
- description: "Connected place ID; required with multiple places."
14181
+ description: "Studio process ID when ambiguous."
13751
14182
  }
13752
14183
  },
13753
14184
  required: ["code"]
@@ -13807,7 +14238,7 @@ var TOOL_DEFINITIONS = [
13807
14238
  },
13808
14239
  instance_id: {
13809
14240
  type: "string",
13810
- description: "Connected place ID; required with multiple places."
14241
+ description: "Studio process ID when ambiguous."
13811
14242
  }
13812
14243
  },
13813
14244
  required: ["pattern"]
@@ -13898,7 +14329,7 @@ var TOOL_DEFINITIONS = [
13898
14329
  },
13899
14330
  instance_id: {
13900
14331
  type: "string",
13901
- description: "Connected instance for close or status; excludes launch_id."
14332
+ description: "Studio process ID for close or status; excludes launch_id."
13902
14333
  },
13903
14334
  launch_id: {
13904
14335
  type: "string",
@@ -13932,7 +14363,7 @@ var TOOL_DEFINITIONS = [
13932
14363
  },
13933
14364
  instance_id: {
13934
14365
  type: "string",
13935
- description: "Connected place ID; required with multiple places."
14366
+ description: "Studio process ID when ambiguous."
13936
14367
  }
13937
14368
  },
13938
14369
  required: ["action"]
@@ -13995,7 +14426,7 @@ var TOOL_DEFINITIONS = [
13995
14426
  },
13996
14427
  instance_id: {
13997
14428
  type: "string",
13998
- description: "Connected place ID; required with multiple places."
14429
+ description: "Studio process ID when ambiguous."
13999
14430
  }
14000
14431
  },
14001
14432
  required: ["profile"]
@@ -14019,7 +14450,7 @@ var TOOL_DEFINITIONS = [
14019
14450
  },
14020
14451
  instance_id: {
14021
14452
  type: "string",
14022
- description: "Connected place ID; required with multiple places."
14453
+ description: "Studio process ID when ambiguous."
14023
14454
  }
14024
14455
  }
14025
14456
  }
@@ -14045,7 +14476,7 @@ var TOOL_DEFINITIONS = [
14045
14476
  },
14046
14477
  instance_id: {
14047
14478
  type: "string",
14048
- description: "Connected place ID; required with multiple places."
14479
+ description: "Studio process ID when ambiguous."
14049
14480
  }
14050
14481
  }
14051
14482
  }
@@ -14071,7 +14502,7 @@ var TOOL_DEFINITIONS = [
14071
14502
  },
14072
14503
  instance_id: {
14073
14504
  type: "string",
14074
- description: "Connected place ID; required with multiple places."
14505
+ description: "Studio process ID when ambiguous."
14075
14506
  }
14076
14507
  }
14077
14508
  }
@@ -14125,7 +14556,7 @@ var TOOL_DEFINITIONS = [
14125
14556
  },
14126
14557
  instance_id: {
14127
14558
  type: "string",
14128
- description: "Connected place ID; required with multiple places."
14559
+ description: "Studio process ID when ambiguous."
14129
14560
  }
14130
14561
  }
14131
14562
  }
@@ -14207,7 +14638,7 @@ var TOOL_DEFINITIONS = [
14207
14638
  },
14208
14639
  instance_id: {
14209
14640
  type: "string",
14210
- description: "Connected place ID; required with multiple places."
14641
+ description: "Studio process ID when ambiguous."
14211
14642
  }
14212
14643
  },
14213
14644
  required: ["entries"]
@@ -14245,7 +14676,7 @@ var TOOL_DEFINITIONS = [
14245
14676
  },
14246
14677
  instance_id: {
14247
14678
  type: "string",
14248
- description: "Connected place ID; required with multiple places."
14679
+ description: "Studio process ID when ambiguous."
14249
14680
  }
14250
14681
  },
14251
14682
  required: ["action"]
@@ -14254,17 +14685,28 @@ var TOOL_DEFINITIONS = [
14254
14685
  {
14255
14686
  name: "get_runtime_logs",
14256
14687
  category: "read",
14257
- description: "Use to read recent Studio output from edit, server, or client peers.",
14688
+ description: "Use to read merged logs for one Instance or separate Instance logs for a MultiplayerGroup.",
14258
14689
  inputSchema: {
14259
14690
  type: "object",
14260
14691
  properties: {
14261
- target: {
14692
+ instance_id: {
14262
14693
  type: "string",
14263
- description: "Log buffer: edit, server, client-N, or all; all deduplicates."
14694
+ description: "Exact Studio process ID; excludes multiplayer_group_id."
14264
14695
  },
14265
- since: {
14266
- type: "number",
14267
- description: "Sequence floor; reuse returned nextSince values for later reads."
14696
+ multiplayer_group_id: {
14697
+ type: "string",
14698
+ description: "Multiplayer group ID; excludes instance_id."
14699
+ },
14700
+ cursor: {
14701
+ type: "string",
14702
+ description: "Opaque cursor returned by the previous read of one Instance. It remains correct when Peers are added or reloaded."
14703
+ },
14704
+ cursor_by_instance: {
14705
+ type: "object",
14706
+ description: "For a multiplayer group, opaque cursors keyed by Instance ID from nextCursorByInstance.",
14707
+ additionalProperties: {
14708
+ type: "string"
14709
+ }
14268
14710
  },
14269
14711
  tail: {
14270
14712
  type: "number",
@@ -14273,10 +14715,6 @@ var TOOL_DEFINITIONS = [
14273
14715
  filter: {
14274
14716
  type: "string",
14275
14717
  description: "Literal message substring applied before tail."
14276
- },
14277
- instance_id: {
14278
- type: "string",
14279
- description: "Connected place ID; required with multiple places."
14280
14718
  }
14281
14719
  }
14282
14720
  }
@@ -14338,7 +14776,7 @@ var TOOL_DEFINITIONS = [
14338
14776
  },
14339
14777
  instance_id: {
14340
14778
  type: "string",
14341
- description: "Connected place ID; required with multiple places."
14779
+ description: "Studio process ID when ambiguous."
14342
14780
  }
14343
14781
  }
14344
14782
  }
@@ -14465,7 +14903,7 @@ var TOOL_DEFINITIONS = [
14465
14903
  },
14466
14904
  instance_id: {
14467
14905
  type: "string",
14468
- description: "Connected place ID; required with multiple places."
14906
+ description: "Studio process ID when ambiguous."
14469
14907
  }
14470
14908
  }
14471
14909
  }
@@ -14516,7 +14954,7 @@ var TOOL_DEFINITIONS = [
14516
14954
  },
14517
14955
  instance_id: {
14518
14956
  type: "string",
14519
- description: "Connected place ID; required with multiple places."
14957
+ description: "Studio process ID when ambiguous."
14520
14958
  }
14521
14959
  },
14522
14960
  required: ["action"]
@@ -14526,7 +14964,7 @@ var TOOL_DEFINITIONS = [
14526
14964
  {
14527
14965
  name: "get_connected_instances",
14528
14966
  category: "read",
14529
- description: "Use to discover connected places and the roles available in each.",
14967
+ description: "Use to discover standalone Studio Instances and role-suffixed temporary Instances nested within multiplayer groups.",
14530
14968
  inputSchema: {
14531
14969
  type: "object",
14532
14970
  properties: {}
@@ -14630,7 +15068,7 @@ var TOOL_DEFINITIONS = [
14630
15068
  },
14631
15069
  instance_id: {
14632
15070
  type: "string",
14633
- description: "Connected place ID; required with multiple places."
15071
+ description: "Studio process ID when ambiguous."
14634
15072
  }
14635
15073
  },
14636
15074
  required: ["assetId"]
@@ -14707,7 +15145,7 @@ var TOOL_DEFINITIONS = [
14707
15145
  },
14708
15146
  instance_id: {
14709
15147
  type: "string",
14710
- description: "Connected place ID; required with multiple places."
15148
+ description: "Studio process ID when ambiguous."
14711
15149
  }
14712
15150
  }
14713
15151
  }
@@ -14747,7 +15185,7 @@ var TOOL_DEFINITIONS = [
14747
15185
  },
14748
15186
  instance_id: {
14749
15187
  type: "string",
14750
- description: "Connected place ID; required with multiple places."
15188
+ description: "Studio process ID when ambiguous."
14751
15189
  }
14752
15190
  },
14753
15191
  required: ["assetId"]
@@ -14807,7 +15245,7 @@ var TOOL_DEFINITIONS = [
14807
15245
  },
14808
15246
  instance_id: {
14809
15247
  type: "string",
14810
- description: "Connected place ID; required with multiple places."
15248
+ description: "Studio process ID when ambiguous."
14811
15249
  }
14812
15250
  }
14813
15251
  }
@@ -14844,7 +15282,7 @@ var TOOL_DEFINITIONS = [
14844
15282
  },
14845
15283
  instance_id: {
14846
15284
  type: "string",
14847
- description: "Connected place ID; required with multiple places."
15285
+ description: "Studio process ID when ambiguous."
14848
15286
  }
14849
15287
  },
14850
15288
  required: ["action", "x", "y"]
@@ -14880,7 +15318,7 @@ var TOOL_DEFINITIONS = [
14880
15318
  },
14881
15319
  instance_id: {
14882
15320
  type: "string",
14883
- description: "Connected place ID; required with multiple places."
15321
+ description: "Studio process ID when ambiguous."
14884
15322
  }
14885
15323
  }
14886
15324
  }
@@ -14904,7 +15342,7 @@ var TOOL_DEFINITIONS = [
14904
15342
  },
14905
15343
  instance_id: {
14906
15344
  type: "string",
14907
- description: "Connected place ID; required with multiple places."
15345
+ description: "Studio process ID when ambiguous."
14908
15346
  }
14909
15347
  }
14910
15348
  }
@@ -14937,7 +15375,7 @@ var TOOL_DEFINITIONS = [
14937
15375
  },
14938
15376
  instance_id: {
14939
15377
  type: "string",
14940
- description: "Connected place ID; required with multiple places."
15378
+ description: "Studio process ID when ambiguous."
14941
15379
  }
14942
15380
  }
14943
15381
  }
@@ -14966,7 +15404,7 @@ var TOOL_DEFINITIONS = [
14966
15404
  },
14967
15405
  instance_id: {
14968
15406
  type: "string",
14969
- description: "Connected place ID; required with multiple places."
15407
+ description: "Studio process ID when ambiguous."
14970
15408
  }
14971
15409
  },
14972
15410
  required: ["instance_paths", "output_path"]
@@ -15004,7 +15442,7 @@ var TOOL_DEFINITIONS = [
15004
15442
  },
15005
15443
  instance_id: {
15006
15444
  type: "string",
15007
- description: "Connected place ID; required with multiple places."
15445
+ description: "Studio process ID when ambiguous."
15008
15446
  }
15009
15447
  },
15010
15448
  required: ["source", "parent_path"]
@@ -15053,7 +15491,7 @@ var TOOL_DEFINITIONS = [
15053
15491
  },
15054
15492
  instance_id: {
15055
15493
  type: "string",
15056
- description: "Connected place ID; required with multiple places."
15494
+ description: "Studio process ID when ambiguous."
15057
15495
  }
15058
15496
  },
15059
15497
  required: ["pattern", "replacement"]