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 = 3e3;
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
- * Try to shut down a stale Conduit instance on the target port.
103
- * Returns true if the port was freed (or was already free).
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(true), 500);
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
- log.warn(
140
- `Port ${port} still in use after eviction \u2014 trying ${port + 1}`
141
- );
142
- tryPort(port + 1);
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 "${this.activeStudioId}": ${err.message}`
308
+ `WebSocket send failed for studio "${targetStudioId}": ${err.message}`
243
309
  );
244
- this.evictStaleStudio(this.activeStudioId);
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
- log.warn(
606
- `Ping timeout for studio "${studioId}" \u2014 terminating connection`
607
- );
608
- ws.terminate();
609
- return;
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
- ws.ping();
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(studioId, res);
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.writeHead(200, { "Content-Type": "application/json" });
772
- waiter.res.end(JSON.stringify(cmd));
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
- return `- \`${s.studioId}\`${place}${placeId} \u2014 connected ${duration}s ago${active}`;
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 = join(homedir(), ".conduit", "builds");
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 = join(BUILDS_DIR, `${name}.json`);
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 = join(BUILDS_DIR, `${name}.json`);
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(readFileSync(filePath, "utf-8"));
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 = join(BUILDS_DIR, f);
2697
+ const filePath = join2(BUILDS_DIR, f);
2493
2698
  try {
2494
- const data = JSON.parse(readFileSync(filePath, "utf-8"));
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: "2.0.0"
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-2OAUC6UN.js.map
2925
+ //# sourceMappingURL=chunk-26DDWVEB.js.map