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 =
|
|
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
|
-
*
|
|
102
|
-
* 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.
|
|
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(
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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, {
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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.
|
|
705
|
-
|
|
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-
|
|
2818
|
+
//# sourceMappingURL=chunk-NV56BC2A.js.map
|