@chrrxs/robloxstudio-mcp 2.16.1 → 2.16.2

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
@@ -27,7 +27,12 @@ function toPublic(inst) {
27
27
  connectedAt: inst.connectedAt
28
28
  };
29
29
  }
30
- var RoutingFailure, STALE_INSTANCE_MS, BridgeService;
30
+ function publishedInstanceId(placeId) {
31
+ if (placeId === void 0 || !Number.isFinite(placeId) || placeId <= 0)
32
+ return void 0;
33
+ return `place:${Math.trunc(placeId)}`;
34
+ }
35
+ var RoutingFailure, STALE_INSTANCE_MS, INSTANCE_ALIAS_TTL_MS, BridgeService;
31
36
  var init_bridge_service = __esm({
32
37
  "../core/dist/bridge-service.js"() {
33
38
  "use strict";
@@ -40,21 +45,91 @@ var init_bridge_service = __esm({
40
45
  }
41
46
  };
42
47
  STALE_INSTANCE_MS = 3e4;
48
+ INSTANCE_ALIAS_TTL_MS = 5 * 60 * 1e3;
43
49
  BridgeService = class {
44
50
  pendingRequests = /* @__PURE__ */ new Map();
45
51
  // Keyed by pluginSessionId (the per-plugin GUID).
46
52
  instances = /* @__PURE__ */ new Map();
53
+ instanceAliases = /* @__PURE__ */ new Map();
47
54
  requestTimeout = 3e4;
55
+ canonicalInstanceId(instanceId, placeId) {
56
+ return publishedInstanceId(placeId) ?? instanceId;
57
+ }
58
+ rememberInstanceAlias(aliasInstanceId, targetInstanceId) {
59
+ if (aliasInstanceId === targetInstanceId)
60
+ return;
61
+ this.instanceAliases.set(aliasInstanceId, {
62
+ targetInstanceId,
63
+ lastSeen: Date.now()
64
+ });
65
+ }
66
+ resolveInstanceAlias(instanceId) {
67
+ const alias = this.instanceAliases.get(instanceId);
68
+ if (!alias)
69
+ return instanceId;
70
+ alias.lastSeen = Date.now();
71
+ return alias.targetInstanceId;
72
+ }
73
+ migratePendingRequests(fromInstanceId, toInstanceId) {
74
+ if (fromInstanceId === toInstanceId)
75
+ return;
76
+ for (const request of this.pendingRequests.values()) {
77
+ if (request.targetInstanceId === fromInstanceId) {
78
+ request.targetInstanceId = toInstanceId;
79
+ }
80
+ }
81
+ }
82
+ cleanupStaleAliases(now = Date.now()) {
83
+ for (const [alias, entry] of this.instanceAliases.entries()) {
84
+ const targetIsLive = this.getInstances().some((inst) => inst.instanceId === entry.targetInstanceId);
85
+ if (!targetIsLive && now - entry.lastSeen > INSTANCE_ALIAS_TTL_MS) {
86
+ this.instanceAliases.delete(alias);
87
+ }
88
+ }
89
+ }
90
+ routingKeyForInstance(inst) {
91
+ return publishedInstanceId(inst.placeId) ?? this.resolveInstanceAlias(inst.instanceId);
92
+ }
93
+ matchingInstancesForInstanceId(instanceId) {
94
+ const resolvedInstanceId = this.resolveInstanceAlias(instanceId);
95
+ const ids = /* @__PURE__ */ new Set([instanceId, resolvedInstanceId]);
96
+ const placeIds = /* @__PURE__ */ new Set();
97
+ const addPlaceId = (placeId) => {
98
+ const published = publishedInstanceId(placeId);
99
+ if (!published || placeId === void 0)
100
+ return;
101
+ ids.add(published);
102
+ placeIds.add(Math.trunc(placeId));
103
+ };
104
+ const placeMatch = resolvedInstanceId.match(/^place:(\d+)$/) ?? instanceId.match(/^place:(\d+)$/);
105
+ if (placeMatch)
106
+ addPlaceId(Number(placeMatch[1]));
107
+ for (const inst of this.getInstances()) {
108
+ if (ids.has(inst.instanceId))
109
+ addPlaceId(inst.placeId);
110
+ }
111
+ return this.getInstances().filter((inst) => ids.has(inst.instanceId) || inst.placeId > 0 && placeIds.has(Math.trunc(inst.placeId)));
112
+ }
113
+ resolveInstanceId(instanceId) {
114
+ return this.resolveInstanceAlias(instanceId);
115
+ }
48
116
  registerInstance(input) {
49
- const { pluginSessionId, instanceId, role } = input;
117
+ const { pluginSessionId, role } = input;
118
+ const rawInstanceId = input.instanceId;
119
+ const instanceId = this.canonicalInstanceId(rawInstanceId, input.placeId);
50
120
  const prior = this.instances.get(pluginSessionId);
51
121
  let assignedRole = role;
52
122
  const pluginVersion = input.pluginVersion ?? "";
53
123
  const pluginVariant = input.pluginVariant ?? "unknown";
54
124
  const serverVersion = input.serverVersion ?? "";
55
125
  const versionMismatch = pluginVersion !== "" && serverVersion !== "" && pluginVersion !== serverVersion;
126
+ this.rememberInstanceAlias(rawInstanceId, instanceId);
127
+ if (prior && prior.instanceId !== instanceId) {
128
+ this.rememberInstanceAlias(prior.instanceId, instanceId);
129
+ this.migratePendingRequests(prior.instanceId, instanceId);
130
+ }
56
131
  if (role === "client") {
57
- if (prior && prior.instanceId === instanceId && prior.role.match(/^client-\d+$/)) {
132
+ if (prior && prior.role.match(/^client-\d+$/)) {
58
133
  assignedRole = prior.role;
59
134
  } else {
60
135
  const used = /* @__PURE__ */ new Set();
@@ -135,6 +210,7 @@ var init_bridge_service = __esm({
135
210
  const inst = this.instances.get(pluginSessionId);
136
211
  if (!inst)
137
212
  return;
213
+ const priorInstanceId = inst.instanceId;
138
214
  if (metadata.placeId !== void 0)
139
215
  inst.placeId = metadata.placeId;
140
216
  if (metadata.placeName !== void 0)
@@ -143,6 +219,15 @@ var init_bridge_service = __esm({
143
219
  inst.dataModelName = metadata.dataModelName;
144
220
  if (metadata.isRunning !== void 0)
145
221
  inst.isRunning = metadata.isRunning;
222
+ const canonicalInstanceId = this.canonicalInstanceId(inst.instanceId, inst.placeId);
223
+ if (canonicalInstanceId !== inst.instanceId) {
224
+ const duplicate = Array.from(this.instances.values()).find((other) => other.pluginSessionId !== pluginSessionId && other.instanceId === canonicalInstanceId && other.role === inst.role);
225
+ if (!duplicate) {
226
+ this.rememberInstanceAlias(priorInstanceId, canonicalInstanceId);
227
+ this.migratePendingRequests(priorInstanceId, canonicalInstanceId);
228
+ inst.instanceId = canonicalInstanceId;
229
+ }
230
+ }
146
231
  }
147
232
  cleanupStaleInstances() {
148
233
  const now = Date.now();
@@ -151,6 +236,37 @@ var init_bridge_service = __esm({
151
236
  this.unregisterInstance(id);
152
237
  }
153
238
  }
239
+ this.cleanupStaleAliases(now);
240
+ }
241
+ getEquivalentInstanceIds(instanceId) {
242
+ const resolvedInstanceId = this.resolveInstanceAlias(instanceId);
243
+ const ids = /* @__PURE__ */ new Set([instanceId, resolvedInstanceId]);
244
+ const placeIds = /* @__PURE__ */ new Set();
245
+ const addPlaceId = (placeId) => {
246
+ const published = publishedInstanceId(placeId);
247
+ if (!published || placeId === void 0)
248
+ return;
249
+ ids.add(published);
250
+ placeIds.add(Math.trunc(placeId));
251
+ };
252
+ const placeMatch = resolvedInstanceId.match(/^place:(\d+)$/) ?? instanceId.match(/^place:(\d+)$/);
253
+ if (placeMatch)
254
+ addPlaceId(Number(placeMatch[1]));
255
+ for (const inst of this.getInstances()) {
256
+ if (ids.has(inst.instanceId)) {
257
+ addPlaceId(inst.placeId);
258
+ }
259
+ }
260
+ for (const inst of this.getInstances()) {
261
+ if (inst.placeId > 0 && placeIds.has(Math.trunc(inst.placeId))) {
262
+ ids.add(inst.instanceId);
263
+ }
264
+ }
265
+ for (const [alias, entry] of this.instanceAliases.entries()) {
266
+ if (ids.has(entry.targetInstanceId))
267
+ ids.add(alias);
268
+ }
269
+ return Array.from(ids);
154
270
  }
155
271
  // Resolves (instance_id, target-role) MCP arguments to a concrete
156
272
  // routing decision: either a single (instanceId, role) tuple or a fanout
@@ -164,7 +280,7 @@ var init_bridge_service = __esm({
164
280
  const isFanout = target === "all";
165
281
  const role = target && target !== "all" ? target : void 0;
166
282
  if (instance_id !== void 0) {
167
- const matchingInstances = instances.filter((i) => i.instanceId === instance_id);
283
+ const matchingInstances = this.matchingInstancesForInstanceId(instance_id);
168
284
  if (matchingInstances.length === 0) {
169
285
  return {
170
286
  ok: false,
@@ -197,19 +313,19 @@ var init_bridge_service = __esm({
197
313
  }
198
314
  };
199
315
  }
200
- return { ok: true, mode: "single", targetInstanceId: instance_id, targetRole: role };
316
+ return { ok: true, mode: "single", targetInstanceId: exact.instanceId, targetRole: role };
201
317
  }
202
318
  if (matchingInstances.length === 1) {
203
319
  return {
204
320
  ok: true,
205
321
  mode: "single",
206
- targetInstanceId: instance_id,
322
+ targetInstanceId: matchingInstances[0].instanceId,
207
323
  targetRole: matchingInstances[0].role
208
324
  };
209
325
  }
210
326
  const edit = matchingInstances.find((i) => i.role === "edit");
211
327
  if (edit) {
212
- return { ok: true, mode: "single", targetInstanceId: instance_id, targetRole: "edit" };
328
+ return { ok: true, mode: "single", targetInstanceId: edit.instanceId, targetRole: "edit" };
213
329
  }
214
330
  return {
215
331
  ok: false,
@@ -220,7 +336,7 @@ var init_bridge_service = __esm({
220
336
  }
221
337
  };
222
338
  }
223
- const distinctInstanceIds = new Set(instances.map((i) => i.instanceId));
339
+ const distinctInstanceIds = new Set(instances.map((i) => this.routingKeyForInstance(i)));
224
340
  if (distinctInstanceIds.size === 0) {
225
341
  return {
226
342
  ok: false,
@@ -236,7 +352,7 @@ var init_bridge_service = __esm({
236
352
  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.";
237
353
  return { ok: false, error: { code: errorCode, message: msg, data: errorData } };
238
354
  }
239
- const onlyInstanceId = instances[0].instanceId;
355
+ const onlyInstanceId = distinctInstanceIds.values().next().value;
240
356
  return this.resolveTarget({ instance_id: onlyInstanceId, target });
241
357
  }
242
358
  async sendRequest(endpoint, data, targetInstanceId, targetRole) {
@@ -3042,23 +3158,25 @@ var init_tools = __esm({
3042
3158
  if (!r.ok)
3043
3159
  throw new RoutingFailure(r.error);
3044
3160
  const resolvedId = r.targetInstanceId;
3045
- const roles = this.bridge.getInstances().filter((i) => i.instanceId === resolvedId).map((i) => i.role);
3046
- const clientRoles = roles.filter((role) => role.startsWith("client")).sort();
3047
- return { instanceId: resolvedId, clientRole: clientRoles[0] };
3161
+ const equivalentIds = new Set(this.bridge.getEquivalentInstanceIds(resolvedId));
3162
+ const instances = this.bridge.getInstances().filter((i) => equivalentIds.has(i.instanceId));
3163
+ const client = instances.filter((inst) => inst.role.startsWith("client")).sort((a, b) => a.role.localeCompare(b.role))[0];
3164
+ return { instanceId: client?.instanceId ?? resolvedId, clientRole: client?.role };
3048
3165
  }
3049
3166
  _resolveInstanceIdOnly(instance_id) {
3050
3167
  const instances = this.bridge.getInstances();
3051
3168
  const publicList = this.bridge.getPublicInstances();
3052
3169
  const errorData = { instances: publicList, count: publicList.length };
3053
3170
  if (instance_id !== void 0) {
3054
- if (!instances.some((i) => i.instanceId === instance_id)) {
3171
+ const resolvedInstanceId = this.bridge.resolveInstanceId(instance_id);
3172
+ if (!instances.some((i) => i.instanceId === resolvedInstanceId)) {
3055
3173
  throw new RoutingFailure({
3056
3174
  code: "unrecognized_instance_id",
3057
3175
  message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
3058
3176
  data: errorData
3059
3177
  });
3060
3178
  }
3061
- return instance_id;
3179
+ return resolvedInstanceId;
3062
3180
  }
3063
3181
  const distinct = Array.from(new Set(instances.map((i) => i.instanceId)));
3064
3182
  if (distinct.length === 0) {
@@ -3096,6 +3214,10 @@ var init_tools = __esm({
3096
3214
  _rolesForInstance(instanceId) {
3097
3215
  return this.bridge.getInstances().filter((i) => i.instanceId === instanceId).map((i) => i.role);
3098
3216
  }
3217
+ _rolesForEquivalentInstances(instanceId) {
3218
+ const instanceIds = new Set(this.bridge.getEquivalentInstanceIds(instanceId));
3219
+ return this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId)).map((i) => i.role);
3220
+ }
3099
3221
  _clientRolesForInstance(instanceId) {
3100
3222
  return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
3101
3223
  }
@@ -3253,12 +3375,13 @@ var init_tools = __esm({
3253
3375
  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.`);
3254
3376
  }
3255
3377
  }
3256
- async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
3378
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30, equivalentInstances = false) {
3257
3379
  const deadline = Date.now() + timeoutSec * 1e3;
3258
3380
  while (Date.now() < deadline) {
3259
- const roles = this._rolesForInstance(instanceId);
3381
+ const roles = equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId);
3382
+ const clientRoles = equivalentInstances ? roles.filter((role) => /^client-\d+$/.test(role)) : this._clientRolesForInstance(instanceId);
3260
3383
  const hasServer = !opts.server || roles.includes("server");
3261
- const hasClients = opts.clientCount === void 0 || this._clientRolesForInstance(instanceId).length >= opts.clientCount;
3384
+ const hasClients = opts.clientCount === void 0 || clientRoles.length >= opts.clientCount;
3262
3385
  const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
3263
3386
  const runtimeAbsent = !opts.noRuntime || !roles.some((role) => role === "server" || /^client-\d+$/.test(role));
3264
3387
  if (hasServer && hasClients && absent && runtimeAbsent) {
@@ -3266,7 +3389,11 @@ var init_tools = __esm({
3266
3389
  }
3267
3390
  await sleep(250);
3268
3391
  }
3269
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3392
+ return {
3393
+ ok: false,
3394
+ roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
3395
+ timedOut: true
3396
+ };
3270
3397
  }
3271
3398
  async _waitForExactClientCount(instanceId, expectedClientCount, timeoutSec = 30, stableMs = 3e3) {
3272
3399
  const deadline = Date.now() + timeoutSec * 1e3;
@@ -3291,10 +3418,11 @@ var init_tools = __esm({
3291
3418
  const clientCount = this._clientRolesForInstance(instanceId).length;
3292
3419
  return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
3293
3420
  }
3294
- async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
3421
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60, equivalentInstances = false) {
3295
3422
  const deadline = Date.now() + timeoutSec * 1e3;
3296
3423
  while (Date.now() < deadline) {
3297
- const instances = this.bridge.getInstances().filter((i) => i.instanceId === instanceId);
3424
+ const instanceIds = equivalentInstances ? new Set(this.bridge.getEquivalentInstanceIds(instanceId)) : /* @__PURE__ */ new Set([instanceId]);
3425
+ const instances = this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId));
3298
3426
  const roles = instances.map((i) => i.role);
3299
3427
  const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
3300
3428
  if (requiredRoles.every((role) => freshRoles.has(role))) {
@@ -3302,7 +3430,11 @@ var init_tools = __esm({
3302
3430
  }
3303
3431
  await sleep(250);
3304
3432
  }
3305
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3433
+ return {
3434
+ ok: false,
3435
+ roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
3436
+ timedOut: true
3437
+ };
3306
3438
  }
3307
3439
  async getFileTree(path2 = "", instance_id) {
3308
3440
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
@@ -4336,7 +4468,7 @@ ${code}`
4336
4468
  let wait;
4337
4469
  if (response?.success === true) {
4338
4470
  const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
4339
- wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
4471
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, 60, true);
4340
4472
  }
4341
4473
  const body = wait ? {
4342
4474
  ...response,
@@ -4358,7 +4490,7 @@ ${code}`
4358
4490
  const response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4359
4491
  let wait;
4360
4492
  if (response?.success === true) {
4361
- wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15);
4493
+ wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15, true);
4362
4494
  }
4363
4495
  const body = wait ? {
4364
4496
  ...response,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp",
3
- "version": "2.16.1",
3
+ "version": "2.16.2",
4
4
  "description": "MCP server for testing, debugging, and controlling Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",