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 =
|
|
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
|
-
*
|
|
103
|
-
* Returns true
|
|
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(
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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.
|
|
772
|
-
|
|
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-
|
|
2818
|
+
//# sourceMappingURL=chunk-NV56BC2A.js.map
|