conduit-mcp 2.1.8 → 2.2.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.
@@ -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 = 1e4;
48
49
  var Bridge = class extends EventEmitter {
49
50
  constructor(port = 3200) {
50
51
  super();
@@ -67,6 +68,9 @@ var Bridge = class extends EventEmitter {
67
68
  // HTTP fallback state (per-studio)
68
69
  httpPendingCommands = /* @__PURE__ */ new Map();
69
70
  httpPollWaiters = /* @__PURE__ */ new Map();
71
+ // Set by the host process; reports whether the MCP client driving this server
72
+ // is still attached. Used to refuse /shutdown while we're actively serving.
73
+ clientAliveCheck = null;
70
74
  get isConnected() {
71
75
  for (const [, studio] of this.studios) {
72
76
  if (studio.ws.readyState === WebSocket.OPEN) return true;
@@ -98,8 +102,37 @@ var Bridge = class extends EventEmitter {
98
102
  }
99
103
  // ── Server lifecycle ───────────────────────────────────────────
100
104
  /**
101
- * Try to shut down a stale Conduit instance on the target port.
102
- * Returns true if the port was freed (or was already free).
105
+ * Check whether the process holding a port is a healthy Conduit server.
106
+ * Returns true only when /health answers with the Conduit signature
107
+ * meaning another live MCP session is being served on that port.
108
+ */
109
+ async isLiveConduit(port) {
110
+ return new Promise((resolve) => {
111
+ const req = http.request(
112
+ { hostname: "127.0.0.1", port, path: "/health", method: "GET", timeout: 2e3 },
113
+ (res) => {
114
+ let body = "";
115
+ res.on("data", (chunk) => body += chunk);
116
+ res.on("end", () => {
117
+ try {
118
+ resolve(JSON.parse(body)?.status === "ok");
119
+ } catch {
120
+ resolve(false);
121
+ }
122
+ });
123
+ }
124
+ );
125
+ req.on("error", () => resolve(false));
126
+ req.on("timeout", () => {
127
+ req.destroy();
128
+ resolve(false);
129
+ });
130
+ req.end();
131
+ });
132
+ }
133
+ /**
134
+ * Try to shut down a wedged Conduit instance on the target port.
135
+ * Returns true if the shutdown request was accepted.
103
136
  */
104
137
  async evictStaleInstance(port) {
105
138
  return new Promise((resolve) => {
@@ -113,7 +146,7 @@ var Bridge = class extends EventEmitter {
113
146
  },
114
147
  (res) => {
115
148
  res.resume();
116
- setTimeout(() => resolve(true), 500);
149
+ setTimeout(() => resolve(res.statusCode === 200), 500);
117
150
  }
118
151
  );
119
152
  req.on("error", () => resolve(false));
@@ -125,20 +158,29 @@ var Bridge = class extends EventEmitter {
125
158
  });
126
159
  }
127
160
  async start() {
128
- await this.evictStaleInstance(this.port);
129
161
  return new Promise((resolve, reject) => {
130
162
  let attempts = 0;
131
- const tryPort = (port) => {
163
+ const tryPort = (port, allowEvict) => {
132
164
  const server = http.createServer(
133
165
  (req, res) => this.handleHttp(req, res)
134
166
  );
135
- server.once("error", (err) => {
167
+ server.once("error", async (err) => {
136
168
  if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
137
169
  attempts++;
138
- log.warn(
139
- `Port ${port} still in use after eviction \u2014 trying ${port + 1}`
140
- );
141
- tryPort(port + 1);
170
+ if (await this.isLiveConduit(port)) {
171
+ log.info(
172
+ `Port ${port} is serving another Conduit session \u2014 trying ${port + 1}`
173
+ );
174
+ tryPort(port + 1, true);
175
+ return;
176
+ }
177
+ if (allowEvict && await this.evictStaleInstance(port)) {
178
+ log.warn(`Evicted unresponsive instance on port ${port} \u2014 retrying`);
179
+ tryPort(port, false);
180
+ return;
181
+ }
182
+ log.warn(`Port ${port} in use \u2014 trying ${port + 1}`);
183
+ tryPort(port + 1, true);
142
184
  } else {
143
185
  reject(err);
144
186
  }
@@ -152,7 +194,7 @@ var Bridge = class extends EventEmitter {
152
194
  resolve(port);
153
195
  });
154
196
  };
155
- tryPort(this.port);
197
+ tryPort(this.port, true);
156
198
  });
157
199
  }
158
200
  async stop() {
@@ -212,6 +254,12 @@ var Bridge = class extends EventEmitter {
212
254
  const id = generateId();
213
255
  const request = { id, method, params };
214
256
  const json = JSON.stringify(request);
257
+ if (this.studios.size === 0 && this.httpStudios.size === 0) {
258
+ await this.waitForFirstStudio(FIRST_CONNECT_WAIT_MS);
259
+ if (this.studios.size === 0 && this.httpStudios.size === 0) {
260
+ throw this.buildPluginNotConnectedError(method);
261
+ }
262
+ }
215
263
  const studio = this.resolveTargetStudio();
216
264
  const targetStudioId = this.activeStudioId;
217
265
  const effectiveTimeout = timeoutMs ?? REQUEST_TIMEOUT_MS;
@@ -220,7 +268,12 @@ var Bridge = class extends EventEmitter {
220
268
  this.pendingRequests.delete(id);
221
269
  reject(this.buildTimeoutError(method, effectiveTimeout, targetStudioId));
222
270
  }, effectiveTimeout);
223
- this.pendingRequests.set(id, { resolve, reject, timer });
271
+ this.pendingRequests.set(id, {
272
+ resolve,
273
+ reject,
274
+ timer,
275
+ studioId: targetStudioId
276
+ });
224
277
  if (studio) {
225
278
  studio.ws.send(json, (err) => {
226
279
  if (err) {
@@ -246,6 +299,32 @@ var Bridge = class extends EventEmitter {
246
299
  }
247
300
  });
248
301
  }
302
+ // Wait for any studio to register, up to timeoutMs. Resolves immediately if
303
+ // one is already connected. Used to absorb the startup race between MCP
304
+ // initialize (instant) and the plugin's WebSocket handshake (~hundreds of ms).
305
+ waitForFirstStudio(timeoutMs) {
306
+ if (this.studios.size > 0 || this.httpStudios.size > 0) {
307
+ return Promise.resolve();
308
+ }
309
+ return new Promise((resolve) => {
310
+ const timer = setTimeout(() => {
311
+ this.off("studio-connected", onConnect);
312
+ resolve();
313
+ }, timeoutMs);
314
+ const onConnect = () => {
315
+ clearTimeout(timer);
316
+ resolve();
317
+ };
318
+ this.once("studio-connected", onConnect);
319
+ });
320
+ }
321
+ buildPluginNotConnectedError(method) {
322
+ const err = new Error(
323
+ `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.`
324
+ );
325
+ err.code = "PLUGIN_NOT_CONNECTED";
326
+ return err;
327
+ }
249
328
  /**
250
329
  * Classify why a request timed out. Without this, three very different failure modes
251
330
  * ("plugin isn't running", "socket is dead", "plugin got the request but its handler
@@ -312,6 +391,11 @@ var Bridge = class extends EventEmitter {
312
391
  studio.ws.terminate();
313
392
  this.studios.delete(studioId);
314
393
  this.lastHeartbeats.delete(studioId);
394
+ this.failPendingForStudio(
395
+ studioId,
396
+ "PLUGIN_UNRESPONSIVE",
397
+ `the plugin connection went stale and was evicted before responding`
398
+ );
315
399
  this.emit("studio-disconnected", studio.info);
316
400
  log.info(`Evicted stale studio: ${studioId}`);
317
401
  }
@@ -412,9 +496,31 @@ var Bridge = class extends EventEmitter {
412
496
  }
413
497
  this.studios.set(studioId, { ws, info });
414
498
  this.lastHeartbeats.set(studioId, Date.now());
499
+ if (this.httpStudios.has(studioId)) {
500
+ this.httpStudios.delete(studioId);
501
+ const waiters = this.httpPollWaiters.get(studioId) ?? [];
502
+ for (const waiter of waiters) {
503
+ clearTimeout(waiter.timer);
504
+ waiter.res.writeHead(204);
505
+ waiter.res.end();
506
+ }
507
+ this.httpPollWaiters.delete(studioId);
508
+ const queued = this.httpPendingCommands.get(studioId) ?? [];
509
+ this.httpPendingCommands.delete(studioId);
510
+ for (const cmd of queued) {
511
+ ws.send(JSON.stringify(cmd));
512
+ }
513
+ if (queued.length > 0) {
514
+ log.info(
515
+ `Re-dispatched ${queued.length} queued command(s) over WebSocket for studio ${studioId}`
516
+ );
517
+ }
518
+ }
415
519
  ws.isAlive = true;
520
+ ws.missedPongs = 0;
416
521
  ws.on("pong", () => {
417
522
  ws.isAlive = true;
523
+ ws.missedPongs = 0;
418
524
  });
419
525
  if (this.activeStudioId === null || this.activeStudioId === studioId) {
420
526
  this.activeStudioId = studioId;
@@ -453,6 +559,11 @@ var Bridge = class extends EventEmitter {
453
559
  if (current && current.ws === ws) {
454
560
  this.studios.delete(studioId);
455
561
  this.lastHeartbeats.delete(studioId);
562
+ this.failPendingForStudio(
563
+ studioId,
564
+ "PLUGIN_DISCONNECTED",
565
+ `the plugin WebSocket closed before a response arrived`
566
+ );
456
567
  this.emit("studio-disconnected", info);
457
568
  log.info(`Studio disconnected: ${studioId}`);
458
569
  if (this.activeStudioId === studioId) {
@@ -472,6 +583,19 @@ var Bridge = class extends EventEmitter {
472
583
  }
473
584
  });
474
585
  }
586
+ failPendingForStudio(studioId, code, reason) {
587
+ for (const [id, pending] of this.pendingRequests) {
588
+ if (pending.studioId === studioId) {
589
+ clearTimeout(pending.timer);
590
+ this.pendingRequests.delete(id);
591
+ const err = new Error(
592
+ `Request aborted: ${reason} (studio: ${studioId}). The plugin should auto-reconnect \u2014 please retry.`
593
+ );
594
+ err.code = code;
595
+ pending.reject(err);
596
+ }
597
+ }
598
+ }
475
599
  handlePluginMessage(msg) {
476
600
  if (isBridgeError(msg)) {
477
601
  const pending = this.pendingRequests.get(msg.id);
@@ -499,6 +623,10 @@ var Bridge = class extends EventEmitter {
499
623
  const now = Date.now();
500
624
  for (const [studioId, lastBeat] of this.lastHeartbeats) {
501
625
  if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) {
626
+ if (!this.studios.has(studioId) && (this.httpPollWaiters.get(studioId)?.length ?? 0) > 0) {
627
+ this.lastHeartbeats.set(studioId, now);
628
+ continue;
629
+ }
502
630
  const studio = this.studios.get(studioId);
503
631
  if (studio) {
504
632
  log.warn(
@@ -513,6 +641,12 @@ var Bridge = class extends EventEmitter {
513
641
  const info = this.httpStudios.get(studioId);
514
642
  this.httpStudios.delete(studioId);
515
643
  this.lastHeartbeats.delete(studioId);
644
+ this.httpPendingCommands.delete(studioId);
645
+ this.failPendingForStudio(
646
+ studioId,
647
+ "PLUGIN_UNRESPONSIVE",
648
+ `the HTTP-fallback plugin stopped polling for ${Math.round(HEARTBEAT_TIMEOUT_MS / 1e3)}s`
649
+ );
516
650
  this.emit("studio-disconnected", info);
517
651
  if (this.activeStudioId === studioId) {
518
652
  const remaining = this.getStudios();
@@ -535,14 +669,23 @@ var Bridge = class extends EventEmitter {
535
669
  for (const [studioId, studio] of this.studios) {
536
670
  const ws = studio.ws;
537
671
  if (ws.isAlive === false) {
538
- log.warn(
539
- `Ping timeout for studio "${studioId}" \u2014 terminating connection`
540
- );
541
- ws.terminate();
542
- return;
672
+ ws.missedPongs = (ws.missedPongs ?? 0) + 1;
673
+ if (ws.missedPongs >= 2) {
674
+ log.warn(
675
+ `No pong from studio "${studioId}" after ${ws.missedPongs} pings \u2014 terminating connection`
676
+ );
677
+ ws.terminate();
678
+ continue;
679
+ }
680
+ log.warn(`Missed pong from studio "${studioId}" \u2014 retrying`);
681
+ } else {
682
+ ws.missedPongs = 0;
543
683
  }
544
684
  ws.isAlive = false;
545
- ws.ping();
685
+ try {
686
+ ws.ping();
687
+ } catch {
688
+ }
546
689
  }
547
690
  }, PING_INTERVAL_MS);
548
691
  }
@@ -573,6 +716,12 @@ var Bridge = class extends EventEmitter {
573
716
  res.end("Forbidden");
574
717
  return;
575
718
  }
719
+ if (this.clientAliveCheck && this.clientAliveCheck()) {
720
+ log.warn("Refusing shutdown request \u2014 MCP client is still attached");
721
+ res.writeHead(409);
722
+ res.end("busy");
723
+ return;
724
+ }
576
725
  log.info("Received shutdown request from new Conduit instance");
577
726
  res.writeHead(200);
578
727
  res.end("ok");
@@ -665,6 +814,16 @@ var Bridge = class extends EventEmitter {
665
814
  const waiters = this.httpPollWaiters.get(studioId) ?? [];
666
815
  waiters.push({ res, timer });
667
816
  this.httpPollWaiters.set(studioId, waiters);
817
+ res.on("close", () => {
818
+ if (res.writableEnded) return;
819
+ clearTimeout(timer);
820
+ const current = this.httpPollWaiters.get(studioId);
821
+ if (current) {
822
+ const idx = current.findIndex((w) => w.res === res);
823
+ if (idx !== -1) current.splice(idx, 1);
824
+ if (current.length === 0) this.httpPollWaiters.delete(studioId);
825
+ }
826
+ });
668
827
  }
669
828
  handleResult(req, res) {
670
829
  const MAX_BODY_SIZE = 10 * 1024 * 1024;
@@ -699,10 +858,17 @@ var Bridge = class extends EventEmitter {
699
858
  const queue = this.httpPendingCommands.get(studioId) ?? [];
700
859
  while (waiters.length > 0 && queue.length > 0) {
701
860
  const waiter = waiters.shift();
702
- const cmd = queue.shift();
703
861
  clearTimeout(waiter.timer);
704
- waiter.res.writeHead(200, { "Content-Type": "application/json" });
705
- waiter.res.end(JSON.stringify(cmd));
862
+ if (waiter.res.destroyed || waiter.res.writableEnded) {
863
+ continue;
864
+ }
865
+ const cmd = queue.shift();
866
+ try {
867
+ waiter.res.writeHead(200, { "Content-Type": "application/json" });
868
+ waiter.res.end(JSON.stringify(cmd));
869
+ } catch {
870
+ queue.unshift(cmd);
871
+ }
706
872
  }
707
873
  if (waiters.length === 0) this.httpPollWaiters.delete(studioId);
708
874
  if (queue.length === 0) this.httpPendingCommands.delete(studioId);
@@ -2634,9 +2800,19 @@ async function startServer(port = 3200, options = {}) {
2634
2800
  bridge.on("shutdown", shutdown);
2635
2801
  process.on("SIGINT", shutdown);
2636
2802
  process.on("SIGTERM", shutdown);
2803
+ let clientAlive = true;
2804
+ const onClientGone = () => {
2805
+ if (!clientAlive) return;
2806
+ clientAlive = false;
2807
+ log.info("MCP client disconnected (stdin closed) \u2014 shutting down");
2808
+ void shutdown();
2809
+ };
2810
+ process.stdin.on("end", onClientGone);
2811
+ process.stdin.on("close", onClientGone);
2812
+ bridge.clientAliveCheck = () => clientAlive;
2637
2813
  }
2638
2814
 
2639
2815
  export {
2640
2816
  startServer
2641
2817
  };
2642
- //# sourceMappingURL=chunk-LNENHU6Z.js.map
2818
+ //# sourceMappingURL=chunk-NV56BC2A.js.map