conduit-mcp 2.1.9 → 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.
@@ -45,7 +45,7 @@ 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
+ var FIRST_CONNECT_WAIT_MS = 1e4;
49
49
  var Bridge = class extends EventEmitter {
50
50
  constructor(port = 3200) {
51
51
  super();
@@ -68,6 +68,9 @@ var Bridge = class extends EventEmitter {
68
68
  // HTTP fallback state (per-studio)
69
69
  httpPendingCommands = /* @__PURE__ */ new Map();
70
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;
71
74
  get isConnected() {
72
75
  for (const [, studio] of this.studios) {
73
76
  if (studio.ws.readyState === WebSocket.OPEN) return true;
@@ -99,8 +102,37 @@ var Bridge = class extends EventEmitter {
99
102
  }
100
103
  // ── Server lifecycle ───────────────────────────────────────────
101
104
  /**
102
- * Try to shut down a stale Conduit instance on the target port.
103
- * 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.
104
136
  */
105
137
  async evictStaleInstance(port) {
106
138
  return new Promise((resolve) => {
@@ -114,7 +146,7 @@ var Bridge = class extends EventEmitter {
114
146
  },
115
147
  (res) => {
116
148
  res.resume();
117
- setTimeout(() => resolve(true), 500);
149
+ setTimeout(() => resolve(res.statusCode === 200), 500);
118
150
  }
119
151
  );
120
152
  req.on("error", () => resolve(false));
@@ -126,20 +158,29 @@ var Bridge = class extends EventEmitter {
126
158
  });
127
159
  }
128
160
  async start() {
129
- await this.evictStaleInstance(this.port);
130
161
  return new Promise((resolve, reject) => {
131
162
  let attempts = 0;
132
- const tryPort = (port) => {
163
+ const tryPort = (port, allowEvict) => {
133
164
  const server = http.createServer(
134
165
  (req, res) => this.handleHttp(req, res)
135
166
  );
136
- server.once("error", (err) => {
167
+ server.once("error", async (err) => {
137
168
  if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
138
169
  attempts++;
139
- log.warn(
140
- `Port ${port} still in use after eviction \u2014 trying ${port + 1}`
141
- );
142
- 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);
143
184
  } else {
144
185
  reject(err);
145
186
  }
@@ -153,7 +194,7 @@ var Bridge = class extends EventEmitter {
153
194
  resolve(port);
154
195
  });
155
196
  };
156
- tryPort(this.port);
197
+ tryPort(this.port, true);
157
198
  });
158
199
  }
159
200
  async stop() {
@@ -455,9 +496,31 @@ var Bridge = class extends EventEmitter {
455
496
  }
456
497
  this.studios.set(studioId, { ws, info });
457
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
+ }
458
519
  ws.isAlive = true;
520
+ ws.missedPongs = 0;
459
521
  ws.on("pong", () => {
460
522
  ws.isAlive = true;
523
+ ws.missedPongs = 0;
461
524
  });
462
525
  if (this.activeStudioId === null || this.activeStudioId === studioId) {
463
526
  this.activeStudioId = studioId;
@@ -560,6 +623,10 @@ var Bridge = class extends EventEmitter {
560
623
  const now = Date.now();
561
624
  for (const [studioId, lastBeat] of this.lastHeartbeats) {
562
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
+ }
563
630
  const studio = this.studios.get(studioId);
564
631
  if (studio) {
565
632
  log.warn(
@@ -602,14 +669,23 @@ var Bridge = class extends EventEmitter {
602
669
  for (const [studioId, studio] of this.studios) {
603
670
  const ws = studio.ws;
604
671
  if (ws.isAlive === false) {
605
- log.warn(
606
- `Ping timeout for studio "${studioId}" \u2014 terminating connection`
607
- );
608
- ws.terminate();
609
- 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;
610
683
  }
611
684
  ws.isAlive = false;
612
- ws.ping();
685
+ try {
686
+ ws.ping();
687
+ } catch {
688
+ }
613
689
  }
614
690
  }, PING_INTERVAL_MS);
615
691
  }
@@ -640,6 +716,12 @@ var Bridge = class extends EventEmitter {
640
716
  res.end("Forbidden");
641
717
  return;
642
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
+ }
643
725
  log.info("Received shutdown request from new Conduit instance");
644
726
  res.writeHead(200);
645
727
  res.end("ok");
@@ -732,6 +814,16 @@ var Bridge = class extends EventEmitter {
732
814
  const waiters = this.httpPollWaiters.get(studioId) ?? [];
733
815
  waiters.push({ res, timer });
734
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
+ });
735
827
  }
736
828
  handleResult(req, res) {
737
829
  const MAX_BODY_SIZE = 10 * 1024 * 1024;
@@ -766,10 +858,17 @@ var Bridge = class extends EventEmitter {
766
858
  const queue = this.httpPendingCommands.get(studioId) ?? [];
767
859
  while (waiters.length > 0 && queue.length > 0) {
768
860
  const waiter = waiters.shift();
769
- const cmd = queue.shift();
770
861
  clearTimeout(waiter.timer);
771
- waiter.res.writeHead(200, { "Content-Type": "application/json" });
772
- 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
+ }
773
872
  }
774
873
  if (waiters.length === 0) this.httpPollWaiters.delete(studioId);
775
874
  if (queue.length === 0) this.httpPendingCommands.delete(studioId);
@@ -2701,9 +2800,19 @@ async function startServer(port = 3200, options = {}) {
2701
2800
  bridge.on("shutdown", shutdown);
2702
2801
  process.on("SIGINT", shutdown);
2703
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;
2704
2813
  }
2705
2814
 
2706
2815
  export {
2707
2816
  startServer
2708
2817
  };
2709
- //# sourceMappingURL=chunk-2OAUC6UN.js.map
2818
+ //# sourceMappingURL=chunk-NV56BC2A.js.map