@chrrxs/robloxstudio-mcp-inspector 2.16.0 → 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) {
@@ -386,30 +502,60 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig) {
386
502
  });
387
503
  app.post("/ready", (req, res) => {
388
504
  const { pluginSessionId, instanceId, role, placeId, placeName, dataModelName, isRunning, pluginVersion, pluginVariant } = req.body;
505
+ const requestContext = {
506
+ instanceId: typeof instanceId === "string" ? instanceId : void 0,
507
+ role: typeof role === "string" ? role : void 0,
508
+ placeId: typeof placeId === "number" ? placeId : void 0,
509
+ placeName: typeof placeName === "string" ? placeName : void 0,
510
+ dataModelName: typeof dataModelName === "string" ? dataModelName : void 0,
511
+ isRunning: typeof isRunning === "boolean" ? isRunning : void 0,
512
+ pluginVersion: typeof pluginVersion === "string" ? pluginVersion : void 0,
513
+ pluginVariant: typeof pluginVariant === "string" ? pluginVariant : void 0
514
+ };
389
515
  if (!pluginSessionId || !instanceId || !role) {
516
+ const missingFields = [
517
+ !pluginSessionId ? "pluginSessionId" : void 0,
518
+ !instanceId ? "instanceId" : void 0,
519
+ !role ? "role" : void 0
520
+ ].filter((field) => !!field);
390
521
  res.status(400).json({
391
522
  success: false,
392
- error: "pluginSessionId, instanceId, and role are required"
523
+ error: "missing_ready_fields",
524
+ message: `/ready missing required field(s): ${missingFields.join(", ")}`,
525
+ missingFields,
526
+ request: requestContext
527
+ });
528
+ return;
529
+ }
530
+ let result;
531
+ try {
532
+ result = bridge.registerInstance({
533
+ pluginSessionId,
534
+ instanceId,
535
+ role,
536
+ placeId: typeof placeId === "number" ? placeId : 0,
537
+ placeName: typeof placeName === "string" ? placeName : "",
538
+ dataModelName: typeof dataModelName === "string" ? dataModelName : "",
539
+ isRunning: !!isRunning,
540
+ pluginVersion: typeof pluginVersion === "string" ? pluginVersion : "",
541
+ pluginVariant: typeof pluginVariant === "string" ? pluginVariant : "unknown",
542
+ serverVersion: serverConfig?.version ?? ""
543
+ });
544
+ } catch (err) {
545
+ res.status(500).json({
546
+ success: false,
547
+ error: "ready_registration_exception",
548
+ message: err instanceof Error ? err.message : String(err),
549
+ request: requestContext
393
550
  });
394
551
  return;
395
552
  }
396
- const result = bridge.registerInstance({
397
- pluginSessionId,
398
- instanceId,
399
- role,
400
- placeId: typeof placeId === "number" ? placeId : 0,
401
- placeName: typeof placeName === "string" ? placeName : "",
402
- dataModelName: typeof dataModelName === "string" ? dataModelName : "",
403
- isRunning: !!isRunning,
404
- pluginVersion: typeof pluginVersion === "string" ? pluginVersion : "",
405
- pluginVariant: typeof pluginVariant === "string" ? pluginVariant : "unknown",
406
- serverVersion: serverConfig?.version ?? ""
407
- });
408
553
  if (!result.ok) {
409
554
  res.status(409).json({
410
555
  success: false,
411
556
  error: result.error.code,
412
557
  message: result.error.message,
558
+ request: requestContext,
413
559
  existing: result.error.existing
414
560
  });
415
561
  return;
@@ -3012,23 +3158,25 @@ var init_tools = __esm({
3012
3158
  if (!r.ok)
3013
3159
  throw new RoutingFailure(r.error);
3014
3160
  const resolvedId = r.targetInstanceId;
3015
- const roles = this.bridge.getInstances().filter((i) => i.instanceId === resolvedId).map((i) => i.role);
3016
- const clientRoles = roles.filter((role) => role.startsWith("client")).sort();
3017
- 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 };
3018
3165
  }
3019
3166
  _resolveInstanceIdOnly(instance_id) {
3020
3167
  const instances = this.bridge.getInstances();
3021
3168
  const publicList = this.bridge.getPublicInstances();
3022
3169
  const errorData = { instances: publicList, count: publicList.length };
3023
3170
  if (instance_id !== void 0) {
3024
- if (!instances.some((i) => i.instanceId === instance_id)) {
3171
+ const resolvedInstanceId = this.bridge.resolveInstanceId(instance_id);
3172
+ if (!instances.some((i) => i.instanceId === resolvedInstanceId)) {
3025
3173
  throw new RoutingFailure({
3026
3174
  code: "unrecognized_instance_id",
3027
3175
  message: `instance_id "${instance_id}" is not connected. Pass one from data.instances.`,
3028
3176
  data: errorData
3029
3177
  });
3030
3178
  }
3031
- return instance_id;
3179
+ return resolvedInstanceId;
3032
3180
  }
3033
3181
  const distinct = Array.from(new Set(instances.map((i) => i.instanceId)));
3034
3182
  if (distinct.length === 0) {
@@ -3066,6 +3214,10 @@ var init_tools = __esm({
3066
3214
  _rolesForInstance(instanceId) {
3067
3215
  return this.bridge.getInstances().filter((i) => i.instanceId === instanceId).map((i) => i.role);
3068
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
+ }
3069
3221
  _clientRolesForInstance(instanceId) {
3070
3222
  return this._rolesForInstance(instanceId).filter((role) => /^client-\d+$/.test(role)).sort((a, b) => Number(a.slice("client-".length)) - Number(b.slice("client-".length)));
3071
3223
  }
@@ -3223,12 +3375,13 @@ var init_tools = __esm({
3223
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.`);
3224
3376
  }
3225
3377
  }
3226
- async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30) {
3378
+ async _waitForRuntimeRoles(instanceId, opts, timeoutSec = 30, equivalentInstances = false) {
3227
3379
  const deadline = Date.now() + timeoutSec * 1e3;
3228
3380
  while (Date.now() < deadline) {
3229
- 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);
3230
3383
  const hasServer = !opts.server || roles.includes("server");
3231
- const hasClients = opts.clientCount === void 0 || this._clientRolesForInstance(instanceId).length >= opts.clientCount;
3384
+ const hasClients = opts.clientCount === void 0 || clientRoles.length >= opts.clientCount;
3232
3385
  const absent = opts.absentRole === void 0 || !roles.includes(opts.absentRole);
3233
3386
  const runtimeAbsent = !opts.noRuntime || !roles.some((role) => role === "server" || /^client-\d+$/.test(role));
3234
3387
  if (hasServer && hasClients && absent && runtimeAbsent) {
@@ -3236,7 +3389,11 @@ var init_tools = __esm({
3236
3389
  }
3237
3390
  await sleep(250);
3238
3391
  }
3239
- 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
+ };
3240
3397
  }
3241
3398
  async _waitForExactClientCount(instanceId, expectedClientCount, timeoutSec = 30, stableMs = 3e3) {
3242
3399
  const deadline = Date.now() + timeoutSec * 1e3;
@@ -3261,10 +3418,11 @@ var init_tools = __esm({
3261
3418
  const clientCount = this._clientRolesForInstance(instanceId).length;
3262
3419
  return { ok: false, roles, timedOut: true, extraClients: clientCount > expectedClientCount, clientCount };
3263
3420
  }
3264
- async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60) {
3421
+ async _waitForRuntimeRolesFresh(instanceId, connectedAfter, requiredRoles, timeoutSec = 60, equivalentInstances = false) {
3265
3422
  const deadline = Date.now() + timeoutSec * 1e3;
3266
3423
  while (Date.now() < deadline) {
3267
- 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));
3268
3426
  const roles = instances.map((i) => i.role);
3269
3427
  const freshRoles = new Set(instances.filter((i) => i.connectedAt >= connectedAfter).map((i) => i.role));
3270
3428
  if (requiredRoles.every((role) => freshRoles.has(role))) {
@@ -3272,7 +3430,11 @@ var init_tools = __esm({
3272
3430
  }
3273
3431
  await sleep(250);
3274
3432
  }
3275
- 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
+ };
3276
3438
  }
3277
3439
  async getFileTree(path2 = "", instance_id) {
3278
3440
  const response = await this._callSingle("/api/file-tree", { path: path2 }, void 0, instance_id);
@@ -4306,7 +4468,7 @@ ${code}`
4306
4468
  let wait;
4307
4469
  if (response?.success === true) {
4308
4470
  const requiredRoles = mode === "play" ? ["server", "client-1"] : ["server"];
4309
- wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles);
4471
+ wait = await this._waitForRuntimeRolesFresh(resolved.targetInstanceId, startedAt, requiredRoles, 60, true);
4310
4472
  }
4311
4473
  const body = wait ? {
4312
4474
  ...response,
@@ -4324,9 +4486,20 @@ ${code}`
4324
4486
  };
4325
4487
  }
4326
4488
  async stopPlaytest(instance_id) {
4327
- const response = await this._callSingle("/api/stop-playtest", {}, "edit", instance_id);
4489
+ const { instanceId } = this._resolveSingleTarget("edit", instance_id);
4490
+ const response = await this.client.request("/api/stop-playtest", {}, instanceId, "edit");
4491
+ let wait;
4492
+ if (response?.success === true) {
4493
+ wait = await this._waitForRuntimeRoles(instanceId, { noRuntime: true }, 15, true);
4494
+ }
4495
+ const body = wait ? {
4496
+ ...response,
4497
+ runtimeStopped: wait.ok,
4498
+ timedOut: wait.timedOut,
4499
+ roles: wait.roles
4500
+ } : response;
4328
4501
  return {
4329
- content: [{ type: "text", text: JSON.stringify(response) }]
4502
+ content: [{ type: "text", text: JSON.stringify(body) }]
4330
4503
  };
4331
4504
  }
4332
4505
  async getPlaytestOutput(target, instance_id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrrxs/robloxstudio-mcp-inspector",
3
- "version": "2.16.0",
3
+ "version": "2.16.2",
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",