conduit-mcp 2.1.9 → 2.2.1
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.
|
@@ -37,6 +37,31 @@ var log = {
|
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// src/utils/version.ts
|
|
41
|
+
import { readFileSync } from "fs";
|
|
42
|
+
import { dirname, join } from "path";
|
|
43
|
+
import { fileURLToPath } from "url";
|
|
44
|
+
var cached = null;
|
|
45
|
+
function getServerVersion() {
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
for (const rel of ["..", join("..", "..")]) {
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(
|
|
51
|
+
readFileSync(join(here, rel, "package.json"), "utf-8")
|
|
52
|
+
);
|
|
53
|
+
if (pkg.name === "conduit-mcp" && typeof pkg.version === "string") {
|
|
54
|
+
const version = pkg.version;
|
|
55
|
+
cached = version;
|
|
56
|
+
return version;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
cached = "unknown";
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
|
|
40
65
|
// src/bridge.ts
|
|
41
66
|
var REQUEST_TIMEOUT_MS = 6e4;
|
|
42
67
|
var HEARTBEAT_CHECK_INTERVAL_MS = 5e3;
|
|
@@ -45,7 +70,7 @@ var PING_INTERVAL_MS = 1e4;
|
|
|
45
70
|
var LONG_POLL_TIMEOUT_MS = 25e3;
|
|
46
71
|
var MAX_PORT_RETRIES = 10;
|
|
47
72
|
var REGISTRATION_TIMEOUT_MS = 1e4;
|
|
48
|
-
var FIRST_CONNECT_WAIT_MS =
|
|
73
|
+
var FIRST_CONNECT_WAIT_MS = 1e4;
|
|
49
74
|
var Bridge = class extends EventEmitter {
|
|
50
75
|
constructor(port = 3200) {
|
|
51
76
|
super();
|
|
@@ -68,6 +93,9 @@ var Bridge = class extends EventEmitter {
|
|
|
68
93
|
// HTTP fallback state (per-studio)
|
|
69
94
|
httpPendingCommands = /* @__PURE__ */ new Map();
|
|
70
95
|
httpPollWaiters = /* @__PURE__ */ new Map();
|
|
96
|
+
// Set by the host process; reports whether the MCP client driving this server
|
|
97
|
+
// is still attached. Used to refuse /shutdown while we're actively serving.
|
|
98
|
+
clientAliveCheck = null;
|
|
71
99
|
get isConnected() {
|
|
72
100
|
for (const [, studio] of this.studios) {
|
|
73
101
|
if (studio.ws.readyState === WebSocket.OPEN) return true;
|
|
@@ -99,8 +127,37 @@ var Bridge = class extends EventEmitter {
|
|
|
99
127
|
}
|
|
100
128
|
// ── Server lifecycle ───────────────────────────────────────────
|
|
101
129
|
/**
|
|
102
|
-
*
|
|
103
|
-
* Returns true
|
|
130
|
+
* Check whether the process holding a port is a healthy Conduit server.
|
|
131
|
+
* Returns true only when /health answers with the Conduit signature —
|
|
132
|
+
* meaning another live MCP session is being served on that port.
|
|
133
|
+
*/
|
|
134
|
+
async isLiveConduit(port) {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const req = http.request(
|
|
137
|
+
{ hostname: "127.0.0.1", port, path: "/health", method: "GET", timeout: 2e3 },
|
|
138
|
+
(res) => {
|
|
139
|
+
let body = "";
|
|
140
|
+
res.on("data", (chunk) => body += chunk);
|
|
141
|
+
res.on("end", () => {
|
|
142
|
+
try {
|
|
143
|
+
resolve(JSON.parse(body)?.status === "ok");
|
|
144
|
+
} catch {
|
|
145
|
+
resolve(false);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
req.on("error", () => resolve(false));
|
|
151
|
+
req.on("timeout", () => {
|
|
152
|
+
req.destroy();
|
|
153
|
+
resolve(false);
|
|
154
|
+
});
|
|
155
|
+
req.end();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Try to shut down a wedged Conduit instance on the target port.
|
|
160
|
+
* Returns true if the shutdown request was accepted.
|
|
104
161
|
*/
|
|
105
162
|
async evictStaleInstance(port) {
|
|
106
163
|
return new Promise((resolve) => {
|
|
@@ -114,7 +171,7 @@ var Bridge = class extends EventEmitter {
|
|
|
114
171
|
},
|
|
115
172
|
(res) => {
|
|
116
173
|
res.resume();
|
|
117
|
-
setTimeout(() => resolve(
|
|
174
|
+
setTimeout(() => resolve(res.statusCode === 200), 500);
|
|
118
175
|
}
|
|
119
176
|
);
|
|
120
177
|
req.on("error", () => resolve(false));
|
|
@@ -126,20 +183,29 @@ var Bridge = class extends EventEmitter {
|
|
|
126
183
|
});
|
|
127
184
|
}
|
|
128
185
|
async start() {
|
|
129
|
-
await this.evictStaleInstance(this.port);
|
|
130
186
|
return new Promise((resolve, reject) => {
|
|
131
187
|
let attempts = 0;
|
|
132
|
-
const tryPort = (port) => {
|
|
188
|
+
const tryPort = (port, allowEvict) => {
|
|
133
189
|
const server = http.createServer(
|
|
134
190
|
(req, res) => this.handleHttp(req, res)
|
|
135
191
|
);
|
|
136
|
-
server.once("error", (err) => {
|
|
192
|
+
server.once("error", async (err) => {
|
|
137
193
|
if (err.code === "EADDRINUSE" && attempts < MAX_PORT_RETRIES) {
|
|
138
194
|
attempts++;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
195
|
+
if (await this.isLiveConduit(port)) {
|
|
196
|
+
log.info(
|
|
197
|
+
`Port ${port} is serving another Conduit session \u2014 trying ${port + 1}`
|
|
198
|
+
);
|
|
199
|
+
tryPort(port + 1, true);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (allowEvict && await this.evictStaleInstance(port)) {
|
|
203
|
+
log.warn(`Evicted unresponsive instance on port ${port} \u2014 retrying`);
|
|
204
|
+
tryPort(port, false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
log.warn(`Port ${port} in use \u2014 trying ${port + 1}`);
|
|
208
|
+
tryPort(port + 1, true);
|
|
143
209
|
} else {
|
|
144
210
|
reject(err);
|
|
145
211
|
}
|
|
@@ -153,7 +219,7 @@ var Bridge = class extends EventEmitter {
|
|
|
153
219
|
resolve(port);
|
|
154
220
|
});
|
|
155
221
|
};
|
|
156
|
-
tryPort(this.port);
|
|
222
|
+
tryPort(this.port, true);
|
|
157
223
|
});
|
|
158
224
|
}
|
|
159
225
|
async stop() {
|
|
@@ -239,9 +305,11 @@ var Bridge = class extends EventEmitter {
|
|
|
239
305
|
clearTimeout(timer);
|
|
240
306
|
this.pendingRequests.delete(id);
|
|
241
307
|
log.warn(
|
|
242
|
-
`WebSocket send failed for studio "${
|
|
308
|
+
`WebSocket send failed for studio "${targetStudioId}": ${err.message}`
|
|
243
309
|
);
|
|
244
|
-
|
|
310
|
+
if (targetStudioId) {
|
|
311
|
+
this.evictStaleStudio(targetStudioId);
|
|
312
|
+
}
|
|
245
313
|
reject(
|
|
246
314
|
new Error(
|
|
247
315
|
`Failed to send to Roblox Studio: ${err.message}. The plugin connection may have dropped \u2014 it should auto-reconnect shortly. Please retry.`
|
|
@@ -413,7 +481,8 @@ var Bridge = class extends EventEmitter {
|
|
|
413
481
|
studioId: msg.studioId,
|
|
414
482
|
placeId: msg.placeId,
|
|
415
483
|
placeName: msg.placeName,
|
|
416
|
-
connectedAt: Date.now()
|
|
484
|
+
connectedAt: Date.now(),
|
|
485
|
+
pluginVersion: msg.version
|
|
417
486
|
});
|
|
418
487
|
return;
|
|
419
488
|
}
|
|
@@ -455,9 +524,31 @@ var Bridge = class extends EventEmitter {
|
|
|
455
524
|
}
|
|
456
525
|
this.studios.set(studioId, { ws, info });
|
|
457
526
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
527
|
+
if (this.httpStudios.has(studioId)) {
|
|
528
|
+
this.httpStudios.delete(studioId);
|
|
529
|
+
const waiters = this.httpPollWaiters.get(studioId) ?? [];
|
|
530
|
+
for (const waiter of waiters) {
|
|
531
|
+
clearTimeout(waiter.timer);
|
|
532
|
+
waiter.res.writeHead(204);
|
|
533
|
+
waiter.res.end();
|
|
534
|
+
}
|
|
535
|
+
this.httpPollWaiters.delete(studioId);
|
|
536
|
+
const queued = this.httpPendingCommands.get(studioId) ?? [];
|
|
537
|
+
this.httpPendingCommands.delete(studioId);
|
|
538
|
+
for (const cmd of queued) {
|
|
539
|
+
ws.send(JSON.stringify(cmd));
|
|
540
|
+
}
|
|
541
|
+
if (queued.length > 0) {
|
|
542
|
+
log.info(
|
|
543
|
+
`Re-dispatched ${queued.length} queued command(s) over WebSocket for studio ${studioId}`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
458
547
|
ws.isAlive = true;
|
|
548
|
+
ws.missedPongs = 0;
|
|
459
549
|
ws.on("pong", () => {
|
|
460
550
|
ws.isAlive = true;
|
|
551
|
+
ws.missedPongs = 0;
|
|
461
552
|
});
|
|
462
553
|
if (this.activeStudioId === null || this.activeStudioId === studioId) {
|
|
463
554
|
this.activeStudioId = studioId;
|
|
@@ -466,6 +557,7 @@ var Bridge = class extends EventEmitter {
|
|
|
466
557
|
log.info(
|
|
467
558
|
`Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
|
|
468
559
|
);
|
|
560
|
+
this.warnOnVersionMismatch(info);
|
|
469
561
|
this.startHeartbeatMonitor();
|
|
470
562
|
this.startPingInterval();
|
|
471
563
|
if (ws.__earlyClose) {
|
|
@@ -520,6 +612,14 @@ var Bridge = class extends EventEmitter {
|
|
|
520
612
|
}
|
|
521
613
|
});
|
|
522
614
|
}
|
|
615
|
+
warnOnVersionMismatch(info) {
|
|
616
|
+
const serverVersion = getServerVersion();
|
|
617
|
+
if (info.pluginVersion && serverVersion !== "unknown" && info.pluginVersion !== serverVersion) {
|
|
618
|
+
log.warn(
|
|
619
|
+
`Plugin version ${info.pluginVersion} does not match server ${serverVersion} \u2014 run "npx conduit-mcp --install" and restart Studio to update the plugin`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
523
623
|
failPendingForStudio(studioId, code, reason) {
|
|
524
624
|
for (const [id, pending] of this.pendingRequests) {
|
|
525
625
|
if (pending.studioId === studioId) {
|
|
@@ -560,6 +660,10 @@ var Bridge = class extends EventEmitter {
|
|
|
560
660
|
const now = Date.now();
|
|
561
661
|
for (const [studioId, lastBeat] of this.lastHeartbeats) {
|
|
562
662
|
if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) {
|
|
663
|
+
if (!this.studios.has(studioId) && (this.httpPollWaiters.get(studioId)?.length ?? 0) > 0) {
|
|
664
|
+
this.lastHeartbeats.set(studioId, now);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
563
667
|
const studio = this.studios.get(studioId);
|
|
564
668
|
if (studio) {
|
|
565
669
|
log.warn(
|
|
@@ -602,14 +706,23 @@ var Bridge = class extends EventEmitter {
|
|
|
602
706
|
for (const [studioId, studio] of this.studios) {
|
|
603
707
|
const ws = studio.ws;
|
|
604
708
|
if (ws.isAlive === false) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
709
|
+
ws.missedPongs = (ws.missedPongs ?? 0) + 1;
|
|
710
|
+
if (ws.missedPongs >= 2) {
|
|
711
|
+
log.warn(
|
|
712
|
+
`No pong from studio "${studioId}" after ${ws.missedPongs} pings \u2014 terminating connection`
|
|
713
|
+
);
|
|
714
|
+
ws.terminate();
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
log.warn(`Missed pong from studio "${studioId}" \u2014 retrying`);
|
|
718
|
+
} else {
|
|
719
|
+
ws.missedPongs = 0;
|
|
610
720
|
}
|
|
611
721
|
ws.isAlive = false;
|
|
612
|
-
|
|
722
|
+
try {
|
|
723
|
+
ws.ping();
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
613
726
|
}
|
|
614
727
|
}, PING_INTERVAL_MS);
|
|
615
728
|
}
|
|
@@ -640,6 +753,12 @@ var Bridge = class extends EventEmitter {
|
|
|
640
753
|
res.end("Forbidden");
|
|
641
754
|
return;
|
|
642
755
|
}
|
|
756
|
+
if (this.clientAliveCheck && this.clientAliveCheck()) {
|
|
757
|
+
log.warn("Refusing shutdown request \u2014 MCP client is still attached");
|
|
758
|
+
res.writeHead(409);
|
|
759
|
+
res.end("busy");
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
643
762
|
log.info("Received shutdown request from new Conduit instance");
|
|
644
763
|
res.writeHead(200);
|
|
645
764
|
res.end("ok");
|
|
@@ -655,6 +774,7 @@ var Bridge = class extends EventEmitter {
|
|
|
655
774
|
status: "ok",
|
|
656
775
|
connected: this.isConnected,
|
|
657
776
|
port: this.actualPort,
|
|
777
|
+
serverVersion: getServerVersion(),
|
|
658
778
|
studios: this.getStudios(),
|
|
659
779
|
activeStudioId: this.activeStudioId
|
|
660
780
|
})
|
|
@@ -663,7 +783,11 @@ var Bridge = class extends EventEmitter {
|
|
|
663
783
|
}
|
|
664
784
|
if (req.method === "GET" && pathname === "/poll") {
|
|
665
785
|
const studioId = parsedUrl.searchParams.get("studioId") ?? this.activeStudioId ?? "_default";
|
|
666
|
-
this.handlePoll(
|
|
786
|
+
this.handlePoll(
|
|
787
|
+
studioId,
|
|
788
|
+
parsedUrl.searchParams.get("version") ?? void 0,
|
|
789
|
+
res
|
|
790
|
+
);
|
|
667
791
|
return;
|
|
668
792
|
}
|
|
669
793
|
if (req.method === "POST" && pathname === "/result") {
|
|
@@ -678,17 +802,19 @@ var Bridge = class extends EventEmitter {
|
|
|
678
802
|
res.writeHead(404);
|
|
679
803
|
res.end("Not found");
|
|
680
804
|
}
|
|
681
|
-
handlePoll(studioId, res) {
|
|
805
|
+
handlePoll(studioId, pluginVersion, res) {
|
|
682
806
|
if (!this.studios.has(studioId) && studioId !== "_default") {
|
|
683
807
|
if (!this.httpStudios.has(studioId)) {
|
|
684
808
|
const info = {
|
|
685
809
|
studioId,
|
|
686
|
-
connectedAt: Date.now()
|
|
810
|
+
connectedAt: Date.now(),
|
|
811
|
+
pluginVersion
|
|
687
812
|
};
|
|
688
813
|
this.httpStudios.set(studioId, info);
|
|
689
814
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
690
815
|
this.emit("studio-connected", info);
|
|
691
816
|
log.info(`HTTP-only studio registered: ${studioId}`);
|
|
817
|
+
this.warnOnVersionMismatch(info);
|
|
692
818
|
this.startHeartbeatMonitor();
|
|
693
819
|
}
|
|
694
820
|
this.lastHeartbeats.set(studioId, Date.now());
|
|
@@ -732,6 +858,16 @@ var Bridge = class extends EventEmitter {
|
|
|
732
858
|
const waiters = this.httpPollWaiters.get(studioId) ?? [];
|
|
733
859
|
waiters.push({ res, timer });
|
|
734
860
|
this.httpPollWaiters.set(studioId, waiters);
|
|
861
|
+
res.on("close", () => {
|
|
862
|
+
if (res.writableEnded) return;
|
|
863
|
+
clearTimeout(timer);
|
|
864
|
+
const current = this.httpPollWaiters.get(studioId);
|
|
865
|
+
if (current) {
|
|
866
|
+
const idx = current.findIndex((w) => w.res === res);
|
|
867
|
+
if (idx !== -1) current.splice(idx, 1);
|
|
868
|
+
if (current.length === 0) this.httpPollWaiters.delete(studioId);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
735
871
|
}
|
|
736
872
|
handleResult(req, res) {
|
|
737
873
|
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
@@ -766,10 +902,17 @@ var Bridge = class extends EventEmitter {
|
|
|
766
902
|
const queue = this.httpPendingCommands.get(studioId) ?? [];
|
|
767
903
|
while (waiters.length > 0 && queue.length > 0) {
|
|
768
904
|
const waiter = waiters.shift();
|
|
769
|
-
const cmd = queue.shift();
|
|
770
905
|
clearTimeout(waiter.timer);
|
|
771
|
-
waiter.res.
|
|
772
|
-
|
|
906
|
+
if (waiter.res.destroyed || waiter.res.writableEnded) {
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
const cmd = queue.shift();
|
|
910
|
+
try {
|
|
911
|
+
waiter.res.writeHead(200, { "Content-Type": "application/json" });
|
|
912
|
+
waiter.res.end(JSON.stringify(cmd));
|
|
913
|
+
} catch {
|
|
914
|
+
queue.unshift(cmd);
|
|
915
|
+
}
|
|
773
916
|
}
|
|
774
917
|
if (waiters.length === 0) this.httpPollWaiters.delete(studioId);
|
|
775
918
|
if (queue.length === 0) this.httpPendingCommands.delete(studioId);
|
|
@@ -1620,14 +1763,18 @@ ${outputText}
|
|
|
1620
1763
|
// src/tools/playtest.ts
|
|
1621
1764
|
import { z as z4 } from "zod";
|
|
1622
1765
|
var playtestStartedAt = null;
|
|
1766
|
+
var errorWatchCursor = null;
|
|
1767
|
+
var errorWatchSeenAtCursor = /* @__PURE__ */ new Set();
|
|
1768
|
+
var errorWatchStudioId = null;
|
|
1769
|
+
var ERROR_WATCH_FETCH_LIMIT = 500;
|
|
1623
1770
|
function register4(server, bridge) {
|
|
1624
1771
|
server.registerTool(
|
|
1625
1772
|
"playtest",
|
|
1626
1773
|
{
|
|
1627
1774
|
title: "Playtest Control & Virtual Input",
|
|
1628
|
-
description: "Control Roblox Studio playtesting and simulate user input.\n\nIMPORTANT: Use mode='run' (default) for reliable MCP control during playtest. Run mode keeps the plugin connection stable. Play mode (F5) may briefly drop the connection during transition.\n\nActions:\n- `start`: Begin a playtest session. Default: Run mode (F8, server-side). Set mode='play' for Play mode (F5, client with player character \u2014 connection may briefly drop).\n- `stop`: End the current playtest.\n- `execute`: Run Lua code in the game context. Works in both edit mode and during playtest.\n- `get_output`: Get console/log output from Studio (works in edit mode and during playtest).\n- `inspect`: Evaluate a Luau expression and return the typed result. Can access Workspace, ServerStorage, Players, etc.\n- `navigate`: Walk a player character to a position using PathfindingService (requires active playtest with a player character).\n- `mouse_click`: Simulate a mouse click at screen coordinates (requires active playtest).\n- `mouse_move`: Move the virtual mouse to screen coordinates (requires active playtest).\n- `key_press`: Press and release a key (requires active playtest).\n- `key_down`: Hold a key down (requires active playtest).\n- `key_up`: Release a held key (requires active playtest).\n- `screenshot`: Capture the viewport. Useful for seeing the game state visually.",
|
|
1775
|
+
description: "Control Roblox Studio playtesting and simulate user input.\n\nIMPORTANT: Use mode='run' (default) for reliable MCP control during playtest. Run mode keeps the plugin connection stable. Play mode (F5) may briefly drop the connection during transition.\n\nActions:\n- `start`: Begin a playtest session. Default: Run mode (F8, server-side). Set mode='play' for Play mode (F5, client with player character \u2014 connection may briefly drop).\n- `stop`: End the current playtest.\n- `execute`: Run Lua code in the game context. Works in both edit mode and during playtest.\n- `get_output`: Get console/log output from Studio (works in edit mode and during playtest).\n- `get_new_errors`: Return only errors/warnings that appeared since the previous get_new_errors call (first call: since playtest start, or recent history). Cheap to poll in edit\u2192test\u2192fix loops.\n- `inspect`: Evaluate a Luau expression and return the typed result. Can access Workspace, ServerStorage, Players, etc.\n- `navigate`: Walk a player character to a position using PathfindingService (requires active playtest with a player character).\n- `mouse_click`: Simulate a mouse click at screen coordinates (requires active playtest).\n- `mouse_move`: Move the virtual mouse to screen coordinates (requires active playtest).\n- `key_press`: Press and release a key (requires active playtest).\n- `key_down`: Hold a key down (requires active playtest).\n- `key_up`: Release a held key (requires active playtest).\n- `screenshot`: Capture the viewport. Useful for seeing the game state visually.",
|
|
1629
1776
|
inputSchema: z4.object({
|
|
1630
|
-
action: z4.enum(["start", "stop", "execute", "get_output", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up", "screenshot"]).describe("Playtest action"),
|
|
1777
|
+
action: z4.enum(["start", "stop", "execute", "get_output", "get_new_errors", "inspect", "navigate", "mouse_click", "mouse_move", "key_press", "key_down", "key_up", "screenshot"]).describe("Playtest action"),
|
|
1631
1778
|
mode: z4.enum(["play", "run"]).default("run").describe("Playtest mode: 'run' (F8, default, reliable MCP control) or 'play' (F5, full client with player \u2014 connection may briefly drop)"),
|
|
1632
1779
|
code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
|
|
1633
1780
|
// get_output params
|
|
@@ -1679,6 +1826,62 @@ ${logText}
|
|
|
1679
1826
|
content: [{ type: "text", text: applyTokenBudget(text2, void 0) }]
|
|
1680
1827
|
};
|
|
1681
1828
|
}
|
|
1829
|
+
if (params.action === "get_new_errors") {
|
|
1830
|
+
const activeStudio = bridge.getActiveStudioId();
|
|
1831
|
+
if (activeStudio !== errorWatchStudioId) {
|
|
1832
|
+
errorWatchStudioId = activeStudio;
|
|
1833
|
+
errorWatchCursor = null;
|
|
1834
|
+
errorWatchSeenAtCursor = /* @__PURE__ */ new Set();
|
|
1835
|
+
}
|
|
1836
|
+
const since = errorWatchCursor ?? playtestStartedAt ?? void 0;
|
|
1837
|
+
const result2 = await bridge.send("get_log_output", {
|
|
1838
|
+
messageTypes: ["MessageError", "MessageWarning"],
|
|
1839
|
+
since,
|
|
1840
|
+
limit: ERROR_WATCH_FETCH_LIMIT
|
|
1841
|
+
});
|
|
1842
|
+
const keyOf = (l) => `${l.messageType}|${l.message}`;
|
|
1843
|
+
const fresh = result2.logs.filter(
|
|
1844
|
+
(l) => errorWatchCursor === null || l.timestamp > errorWatchCursor || l.timestamp === errorWatchCursor && !errorWatchSeenAtCursor.has(keyOf(l))
|
|
1845
|
+
);
|
|
1846
|
+
if (fresh.length > 0) {
|
|
1847
|
+
const maxTs = Math.max(...fresh.map((l) => l.timestamp));
|
|
1848
|
+
if (maxTs !== errorWatchCursor) {
|
|
1849
|
+
errorWatchSeenAtCursor = /* @__PURE__ */ new Set();
|
|
1850
|
+
}
|
|
1851
|
+
errorWatchCursor = maxTs;
|
|
1852
|
+
for (const l of fresh) {
|
|
1853
|
+
if (l.timestamp === maxTs) {
|
|
1854
|
+
errorWatchSeenAtCursor.add(keyOf(l));
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
if (fresh.length === 0) {
|
|
1859
|
+
return {
|
|
1860
|
+
content: [{ type: "text", text: "No new errors or warnings." }]
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
const displayLimit = params.limit ?? 50;
|
|
1864
|
+
const shown = fresh.slice(0, displayLimit);
|
|
1865
|
+
const notes = [];
|
|
1866
|
+
if (fresh.length > shown.length) {
|
|
1867
|
+
notes.push(
|
|
1868
|
+
`_\u2026${fresh.length - shown.length} newer entries omitted (oldest shown first \u2014 the first occurrence is usually the root cause)._`
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
if (result2.total > result2.logs.length) {
|
|
1872
|
+
notes.push(
|
|
1873
|
+
`_Log buffer overflowed: ${result2.total - result2.logs.length} entries were dropped before this check._`
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
const text2 = `**New Errors/Warnings** (${fresh.length})
|
|
1877
|
+
|
|
1878
|
+
\`\`\`
|
|
1879
|
+
` + shown.map((l) => `[${l.messageType}] ${l.message}`).join("\n") + "\n```" + (notes.length > 0 ? `
|
|
1880
|
+
${notes.join("\n")}` : "");
|
|
1881
|
+
return {
|
|
1882
|
+
content: [{ type: "text", text: applyTokenBudget(text2, void 0) }]
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1682
1885
|
if (params.action === "inspect") {
|
|
1683
1886
|
if (!params.expression) {
|
|
1684
1887
|
return {
|
|
@@ -2386,12 +2589,14 @@ function register8(server, bridge) {
|
|
|
2386
2589
|
]
|
|
2387
2590
|
};
|
|
2388
2591
|
}
|
|
2592
|
+
const serverVersion = getServerVersion();
|
|
2389
2593
|
const lines = studios.map((s) => {
|
|
2390
2594
|
const active = s.studioId === activeId ? " **\u2190 active**" : "";
|
|
2391
2595
|
const place = s.placeName ? ` \u2014 ${s.placeName}` : "";
|
|
2392
2596
|
const placeId = s.placeId ? ` (Place ID: ${s.placeId})` : "";
|
|
2393
2597
|
const duration = Math.floor((Date.now() - s.connectedAt) / 1e3);
|
|
2394
|
-
|
|
2598
|
+
const version = s.pluginVersion && serverVersion !== "unknown" && s.pluginVersion !== serverVersion ? ` \u26A0 plugin v${s.pluginVersion} \u2260 server v${serverVersion} \u2014 user should run \`npx conduit-mcp --install\` and restart Studio` : "";
|
|
2599
|
+
return `- \`${s.studioId}\`${place}${placeId} \u2014 connected ${duration}s ago${active}${version}`;
|
|
2395
2600
|
});
|
|
2396
2601
|
const text = `**Connected Studios (${studios.length}):**
|
|
2397
2602
|
${lines.join("\n")}`;
|
|
@@ -2437,16 +2642,16 @@ import { z as z9 } from "zod";
|
|
|
2437
2642
|
|
|
2438
2643
|
// src/utils/builds.ts
|
|
2439
2644
|
import { homedir } from "os";
|
|
2440
|
-
import { join } from "path";
|
|
2645
|
+
import { join as join2 } from "path";
|
|
2441
2646
|
import {
|
|
2442
2647
|
existsSync,
|
|
2443
2648
|
mkdirSync,
|
|
2444
|
-
readFileSync,
|
|
2649
|
+
readFileSync as readFileSync2,
|
|
2445
2650
|
writeFileSync,
|
|
2446
2651
|
readdirSync,
|
|
2447
2652
|
statSync
|
|
2448
2653
|
} from "fs";
|
|
2449
|
-
var BUILDS_DIR =
|
|
2654
|
+
var BUILDS_DIR = join2(homedir(), ".conduit", "builds");
|
|
2450
2655
|
function ensureDir() {
|
|
2451
2656
|
if (!existsSync(BUILDS_DIR)) {
|
|
2452
2657
|
mkdirSync(BUILDS_DIR, { recursive: true });
|
|
@@ -2467,7 +2672,7 @@ function saveBuild(name, root, description) {
|
|
|
2467
2672
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2468
2673
|
root
|
|
2469
2674
|
};
|
|
2470
|
-
const filePath =
|
|
2675
|
+
const filePath = join2(BUILDS_DIR, `${name}.json`);
|
|
2471
2676
|
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
2472
2677
|
return {
|
|
2473
2678
|
name,
|
|
@@ -2479,19 +2684,19 @@ function saveBuild(name, root, description) {
|
|
|
2479
2684
|
}
|
|
2480
2685
|
function loadBuild(name) {
|
|
2481
2686
|
validateBuildName(name);
|
|
2482
|
-
const filePath =
|
|
2687
|
+
const filePath = join2(BUILDS_DIR, `${name}.json`);
|
|
2483
2688
|
if (!existsSync(filePath)) {
|
|
2484
2689
|
throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
|
|
2485
2690
|
}
|
|
2486
|
-
return JSON.parse(
|
|
2691
|
+
return JSON.parse(readFileSync2(filePath, "utf-8"));
|
|
2487
2692
|
}
|
|
2488
2693
|
function listBuilds() {
|
|
2489
2694
|
ensureDir();
|
|
2490
2695
|
const files = readdirSync(BUILDS_DIR).filter((f) => f.endsWith(".json"));
|
|
2491
2696
|
return files.map((f) => {
|
|
2492
|
-
const filePath =
|
|
2697
|
+
const filePath = join2(BUILDS_DIR, f);
|
|
2493
2698
|
try {
|
|
2494
|
-
const data = JSON.parse(
|
|
2699
|
+
const data = JSON.parse(readFileSync2(filePath, "utf-8"));
|
|
2495
2700
|
const rootObj = data.root;
|
|
2496
2701
|
return {
|
|
2497
2702
|
name: data.name,
|
|
@@ -2643,7 +2848,7 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2643
2848
|
const server = new McpServer(
|
|
2644
2849
|
{
|
|
2645
2850
|
name: "conduit-mcp",
|
|
2646
|
-
version:
|
|
2851
|
+
version: getServerVersion()
|
|
2647
2852
|
},
|
|
2648
2853
|
{
|
|
2649
2854
|
instructions: [
|
|
@@ -2694,6 +2899,7 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2694
2899
|
if (shuttingDown) return;
|
|
2695
2900
|
shuttingDown = true;
|
|
2696
2901
|
log.info("Shutting down...");
|
|
2902
|
+
setTimeout(() => process.exit(1), 5e3).unref();
|
|
2697
2903
|
await bridge.stop();
|
|
2698
2904
|
await server.close();
|
|
2699
2905
|
process.exit(0);
|
|
@@ -2701,9 +2907,19 @@ async function startServer(port = 3200, options = {}) {
|
|
|
2701
2907
|
bridge.on("shutdown", shutdown);
|
|
2702
2908
|
process.on("SIGINT", shutdown);
|
|
2703
2909
|
process.on("SIGTERM", shutdown);
|
|
2910
|
+
let clientAlive = true;
|
|
2911
|
+
const onClientGone = () => {
|
|
2912
|
+
if (!clientAlive) return;
|
|
2913
|
+
clientAlive = false;
|
|
2914
|
+
log.info("MCP client disconnected (stdin closed) \u2014 shutting down");
|
|
2915
|
+
void shutdown();
|
|
2916
|
+
};
|
|
2917
|
+
process.stdin.on("end", onClientGone);
|
|
2918
|
+
process.stdin.on("close", onClientGone);
|
|
2919
|
+
bridge.clientAliveCheck = () => clientAlive;
|
|
2704
2920
|
}
|
|
2705
2921
|
|
|
2706
2922
|
export {
|
|
2707
2923
|
startServer
|
|
2708
2924
|
};
|
|
2709
|
-
//# sourceMappingURL=chunk-
|
|
2925
|
+
//# sourceMappingURL=chunk-26DDWVEB.js.map
|