@chrrxs/robloxstudio-mcp-inspector 2.16.1 → 2.16.3

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,9 +3214,17 @@ 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
  }
3224
+ _runtimeTargetsForEquivalentInstances(instanceId) {
3225
+ const instanceIds = new Set(this.bridge.getEquivalentInstanceIds(instanceId));
3226
+ 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 }));
3227
+ }
3102
3228
  _resolveDeviceSimulatorSingleTarget(target, instance_id, toolName) {
3103
3229
  const selectedTarget = target ?? "edit";
3104
3230
  if (selectedTarget === "server" || selectedTarget === "all" || selectedTarget === "all-clients" || selectedTarget === "edit-proxy") {
@@ -3253,12 +3379,13 @@ var init_tools = __esm({
3253
3379
  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
3380
  }
3255
3381
  }
3256
- async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
3382
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30, equivalentInstances = false) {
3257
3383
  const deadline = Date.now() + timeoutSec * 1e3;
3258
3384
  while (Date.now() < deadline) {
3259
- const roles = this._rolesForInstance(instanceId);
3385
+ const roles = equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId);
3386
+ const clientRoles = equivalentInstances ? roles.filter((role) => /^client-\d+$/.test(role)) : this._clientRolesForInstance(instanceId);
3260
3387
  const hasServer = !opts.server || roles.includes("server");
3261
- const hasClients = opts.clientCount === void 0 || this._clientRolesForInstance(instanceId).length >= opts.clientCount;
3388
+ const hasClients = opts.clientCount === void 0 || clientRoles.length >= opts.clientCount;
3262
3389
  const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
3263
3390
  const runtimeAbsent = !opts.noRuntime || !roles.some((role) => role === "server" || /^client-\d+$/.test(role));
3264
3391
  if (hasServer && hasClients && absent && runtimeAbsent) {
@@ -3266,7 +3393,11 @@ var init_tools = __esm({
3266
3393
  }
3267
3394
  await sleep(250);
3268
3395
  }
3269
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3396
+ return {
3397
+ ok: false,
3398
+ roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
3399
+ timedOut: true
3400
+ };
3270
3401
  }
3271
3402
  async _waitForExactClientCount(instanceId, expectedClientCount, timeoutSec = 30, stableMs = 3e3) {
3272
3403
  const deadline = Date.now() + timeoutSec * 1e3;
@@ -3291,10 +3422,11 @@ var init_tools = __esm({
3291
3422
  const clientCount = this._clientRolesForInstance(instanceId).length;
3292
3423
  return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
3293
3424
  }
3294
- async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
3425
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60, equivalentInstances = false) {
3295
3426
  const deadline = Date.now() + timeoutSec * 1e3;
3296
3427
  while (Date.now() < deadline) {
3297
- const instances = this.bridge.getInstances().filter((i) => i.instanceId === instanceId);
3428
+ const instanceIds = equivalentInstances ? new Set(this.bridge.getEquivalentInstanceIds(instanceId)) : /* @__PURE__ */ new Set([instanceId]);
3429
+ const instances = this.bridge.getInstances().filter((i) => instanceIds.has(i.instanceId));
3298
3430
  const roles = instances.map((i) => i.role);
3299
3431
  const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
3300
3432
  if (requiredRoles.every((role) => freshRoles.has(role))) {
@@ -3302,7 +3434,11 @@ var init_tools = __esm({
3302
3434
  }
3303
3435
  await sleep(250);
3304
3436
  }
3305
- return { ok: false, roles: this._rolesForInstance(instanceId), timedOut: true };
3437
+ return {
3438
+ ok: false,
3439
+ roles: equivalentInstances ? this._rolesForEquivalentInstances(instanceId) : this._rolesForInstance(instanceId),
3440
+ timedOut: true
3441
+ };
3306
3442
  }
3307
3443
  async getFileTree(path2 = "", instance_id) {
3308
3444
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
@@ -4336,7 +4472,7 @@ ${code}`
4336
4472
  let wait;
4337
4473
  if (response?.success === true) {
4338
4474
  const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
4339
- wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
4475
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, 60, true);
4340
4476
  }
4341
4477
  const body = wait ? {
4342
4478
  ...response,
@@ -4355,10 +4491,27 @@ ${code}`
4355
4491
  }
4356
4492
  async stopPlaytest(instance_id) {
4357
4493
  const { instanceId } = this._resolveSingleTarget("edit", instance_id);
4358
- const response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4494
+ let response;
4495
+ let stopRequestError;
4496
+ try {
4497
+ response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4498
+ } catch (error) {
4499
+ stopRequestError = errorMessage(error);
4500
+ response = {
4501
+ success: false,
4502
+ error: "Edit stop request failed.",
4503
+ detail: stopRequestError
4504
+ };
4505
+ }
4359
4506
  let wait;
4360
4507
  if (response?.success === true) {
4361
- wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15);
4508
+ wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15, true);
4509
+ } else if (this._runtimeTargetsForEquivalentInstances(instanceId).length > 0) {
4510
+ wait = {
4511
+ ok: false,
4512
+ roles: this._rolesForEquivalentInstances(instanceId),
4513
+ timedOut: false
4514
+ };
4362
4515
  }
4363
4516
  const body = wait ? {
4364
4517
  ...response,
@@ -4366,6 +4519,22 @@ ${code}`
4366
4519
  timedOut: wait.timedOut,
4367
4520
  roles: wait.roles
4368
4521
  } : response;
4522
+ if (wait && !wait.ok) {
4523
+ const runtimeRoles = wait.roles.filter((role) => role === "server" || /^client-\d+$/.test(role));
4524
+ const failureBody = {
4525
+ ...body,
4526
+ success: false,
4527
+ error: "Playtest teardown did not complete.",
4528
+ message: response?.success === true ? wait.timedOut ? "Stop signal was accepted, but runtime peers did not disconnect before timeout." : "Stop signal was accepted, but runtime peers are still connected." : "Edit stop request failed, and runtime peers are still connected.",
4529
+ stopSignalAccepted: response?.success === true,
4530
+ stopRequestError,
4531
+ runtimeRoles,
4532
+ possibleCause: "A game shutdown hook such as BindToClose may be blocking Studio teardown. No runtime hard-stop or synthetic keyboard fallback was attempted."
4533
+ };
4534
+ return {
4535
+ content: [{ type: "text", text: JSON.stringify(failureBody) }]
4536
+ };
4537
+ }
4369
4538
  return {
4370
4539
  content: [{ type: "text", text: JSON.stringify(body) }]
4371
4540
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.16.1",
3
+ "version": "2.16.3",
4
4
  "description": "Read-only MCP server for inspecting and debugging Roblox Studio from AI coding tools",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",