conduit-mcp 2.1.7 → 2.1.9

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.
@@ -40,11 +40,12 @@ var log = {
40
40
  // src/bridge.ts
41
41
  var REQUEST_TIMEOUT_MS = 6e4;
42
42
  var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
43
- var HEARTBEAT_TIMEOUT_MS = 15e3;
43
+ var HEARTBEAT_TIMEOUT_MS = 2e4;
44
44
  var PING_INTERVAL_MS = 1e4;
45
45
  var LONG_POLL_TIMEOUT_MS = 25e3;
46
46
  var MAX_PORT_RETRIES = 10;
47
47
  var REGISTRATION_TIMEOUT_MS = 1e4;
48
+ var FIRST_CONNECT_WAIT_MS = 3e3;
48
49
  var Bridge = class extends EventEmitter {
49
50
  constructor(port = 3200) {
50
51
  super();
@@ -212,18 +213,26 @@ var Bridge = class extends EventEmitter {
212
213
  const id = generateId();
213
214
  const request = { id, method, params };
214
215
  const json = JSON.stringify(request);
216
+ if (this.studios.size === 0 && this.httpStudios.size === 0) {
217
+ await this.waitForFirstStudio(FIRST_CONNECT_WAIT_MS);
218
+ if (this.studios.size === 0 && this.httpStudios.size === 0) {
219
+ throw this.buildPluginNotConnectedError(method);
220
+ }
221
+ }
215
222
  const studio = this.resolveTargetStudio();
223
+ const targetStudioId = this.activeStudioId;
216
224
  const effectiveTimeout = timeoutMs ?? REQUEST_TIMEOUT_MS;
217
225
  return new Promise((resolve, reject) => {
218
226
  const timer = setTimeout(() => {
219
227
  this.pendingRequests.delete(id);
220
- reject(
221
- new Error(
222
- `Request ${method} timed out after ${effectiveTimeout}ms \u2014 is the Conduit plugin running in Roblox Studio?`
223
- )
224
- );
228
+ reject(this.buildTimeoutError(method, effectiveTimeout, targetStudioId));
225
229
  }, effectiveTimeout);
226
- this.pendingRequests.set(id, { resolve, reject, timer });
230
+ this.pendingRequests.set(id, {
231
+ resolve,
232
+ reject,
233
+ timer,
234
+ studioId: targetStudioId
235
+ });
227
236
  if (studio) {
228
237
  studio.ws.send(json, (err) => {
229
238
  if (err) {
@@ -249,6 +258,73 @@ var Bridge = class extends EventEmitter {
249
258
  }
250
259
  });
251
260
  }
261
+ // Wait for any studio to register, up to timeoutMs. Resolves immediately if
262
+ // one is already connected. Used to absorb the startup race between MCP
263
+ // initialize (instant) and the plugin's WebSocket handshake (~hundreds of ms).
264
+ waitForFirstStudio(timeoutMs) {
265
+ if (this.studios.size > 0 || this.httpStudios.size > 0) {
266
+ return Promise.resolve();
267
+ }
268
+ return new Promise((resolve) => {
269
+ const timer = setTimeout(() => {
270
+ this.off("studio-connected", onConnect);
271
+ resolve();
272
+ }, timeoutMs);
273
+ const onConnect = () => {
274
+ clearTimeout(timer);
275
+ resolve();
276
+ };
277
+ this.once("studio-connected", onConnect);
278
+ });
279
+ }
280
+ buildPluginNotConnectedError(method) {
281
+ const err = new Error(
282
+ `Cannot ${method}: the Roblox Studio plugin is not connected. Open Roblox Studio with the Conduit plugin enabled \u2014 it auto-connects when HttpService is allowed (Game Settings \u2192 Security \u2192 Allow HTTP Requests). Check the Conduit dashboard in Studio for connection status.`
283
+ );
284
+ err.code = "PLUGIN_NOT_CONNECTED";
285
+ return err;
286
+ }
287
+ /**
288
+ * Classify why a request timed out. Without this, three very different failure modes
289
+ * ("plugin isn't running", "socket is dead", "plugin got the request but its handler
290
+ * is stuck") all surface as the same generic "timed out" error, so callers can't
291
+ * decide whether to retry, reconnect, or surface the hang to the user.
292
+ *
293
+ * We inspect studio state *at timeout fire time* (not request send time) because
294
+ * the socket may have died mid-flight.
295
+ */
296
+ buildTimeoutError(method, timeoutMs, targetStudioId) {
297
+ const base = `Request ${method} timed out after ${timeoutMs}ms`;
298
+ if (!targetStudioId) {
299
+ const err2 = new Error(
300
+ `${base}: no Roblox Studio plugin was connected when the request was dispatched.`
301
+ );
302
+ err2.code = "PLUGIN_NOT_CONNECTED";
303
+ return err2;
304
+ }
305
+ const studio = this.studios.get(targetStudioId);
306
+ if (!studio || studio.ws.readyState !== WebSocket.OPEN) {
307
+ const err2 = new Error(
308
+ `${base}: the plugin WebSocket for studio "${targetStudioId}" closed before a response arrived.`
309
+ );
310
+ err2.code = "PLUGIN_DISCONNECTED";
311
+ return err2;
312
+ }
313
+ const lastBeat = this.lastHeartbeats.get(targetStudioId);
314
+ const age = lastBeat !== void 0 ? Date.now() - lastBeat : Infinity;
315
+ if (age > HEARTBEAT_TIMEOUT_MS) {
316
+ const err2 = new Error(
317
+ `${base}: the plugin stopped sending heartbeats (${Math.round(age / 1e3)}s ago) \u2014 Studio may be frozen.`
318
+ );
319
+ err2.code = "PLUGIN_UNRESPONSIVE";
320
+ return err2;
321
+ }
322
+ const err = new Error(
323
+ `${base}: the plugin is still responsive but the "${method}" handler did not return in time. This usually means user Lua is running long or the mutation queue is stalled on a prior command.`
324
+ );
325
+ err.code = "HANDLER_TIMEOUT";
326
+ return err;
327
+ }
252
328
  // ── Internal helpers ───────────────────────────────────────────
253
329
  getActiveStudio() {
254
330
  if (this.activeStudioId) {
@@ -274,6 +350,11 @@ var Bridge = class extends EventEmitter {
274
350
  studio.ws.terminate();
275
351
  this.studios.delete(studioId);
276
352
  this.lastHeartbeats.delete(studioId);
353
+ this.failPendingForStudio(
354
+ studioId,
355
+ "PLUGIN_UNRESPONSIVE",
356
+ `the plugin connection went stale and was evicted before responding`
357
+ );
277
358
  this.emit("studio-disconnected", studio.info);
278
359
  log.info(`Evicted stale studio: ${studioId}`);
279
360
  }
@@ -415,6 +496,11 @@ var Bridge = class extends EventEmitter {
415
496
  if (current && current.ws === ws) {
416
497
  this.studios.delete(studioId);
417
498
  this.lastHeartbeats.delete(studioId);
499
+ this.failPendingForStudio(
500
+ studioId,
501
+ "PLUGIN_DISCONNECTED",
502
+ `the plugin WebSocket closed before a response arrived`
503
+ );
418
504
  this.emit("studio-disconnected", info);
419
505
  log.info(`Studio disconnected: ${studioId}`);
420
506
  if (this.activeStudioId === studioId) {
@@ -434,6 +520,19 @@ var Bridge = class extends EventEmitter {
434
520
  }
435
521
  });
436
522
  }
523
+ failPendingForStudio(studioId, code, reason) {
524
+ for (const [id, pending] of this.pendingRequests) {
525
+ if (pending.studioId === studioId) {
526
+ clearTimeout(pending.timer);
527
+ this.pendingRequests.delete(id);
528
+ const err = new Error(
529
+ `Request aborted: ${reason} (studio: ${studioId}). The plugin should auto-reconnect \u2014 please retry.`
530
+ );
531
+ err.code = code;
532
+ pending.reject(err);
533
+ }
534
+ }
535
+ }
437
536
  handlePluginMessage(msg) {
438
537
  if (isBridgeError(msg)) {
439
538
  const pending = this.pendingRequests.get(msg.id);
@@ -475,6 +574,12 @@ var Bridge = class extends EventEmitter {
475
574
  const info = this.httpStudios.get(studioId);
476
575
  this.httpStudios.delete(studioId);
477
576
  this.lastHeartbeats.delete(studioId);
577
+ this.httpPendingCommands.delete(studioId);
578
+ this.failPendingForStudio(
579
+ studioId,
580
+ "PLUGIN_UNRESPONSIVE",
581
+ `the HTTP-fallback plugin stopped polling for ${Math.round(HEARTBEAT_TIMEOUT_MS / 1e3)}s`
582
+ );
478
583
  this.emit("studio-disconnected", info);
479
584
  if (this.activeStudioId === studioId) {
480
585
  const remaining = this.getStudios();
@@ -2601,4 +2706,4 @@ async function startServer(port = 3200, options = {}) {
2601
2706
  export {
2602
2707
  startServer
2603
2708
  };
2604
- //# sourceMappingURL=chunk-TUOCT4PR.js.map
2709
+ //# sourceMappingURL=chunk-2OAUC6UN.js.map