conduit-mcp 2.2.0 → 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;
@@ -280,9 +305,11 @@ var Bridge = class extends EventEmitter {
280
305
  clearTimeout(timer);
281
306
  this.pendingRequests.delete(id);
282
307
  log.warn(
283
- `WebSocket send failed for studio "${this.activeStudioId}": ${err.message}`
308
+ `WebSocket send failed for studio "${targetStudioId}": ${err.message}`
284
309
  );
285
- this.evictStaleStudio(this.activeStudioId);
310
+ if (targetStudioId) {
311
+ this.evictStaleStudio(targetStudioId);
312
+ }
286
313
  reject(
287
314
  new Error(
288
315
  `Failed to send to Roblox Studio: ${err.message}. The plugin connection may have dropped \u2014 it should auto-reconnect shortly. Please retry.`
@@ -454,7 +481,8 @@ var Bridge = class extends EventEmitter {
454
481
  studioId: msg.studioId,
455
482
  placeId: msg.placeId,
456
483
  placeName: msg.placeName,
457
- connectedAt: Date.now()
484
+ connectedAt: Date.now(),
485
+ pluginVersion: msg.version
458
486
  });
459
487
  return;
460
488
  }
@@ -529,6 +557,7 @@ var Bridge = class extends EventEmitter {
529
557
  log.info(
530
558
  `Studio registered: ${studioId}` + (info.placeName ? ` (${info.placeName})` : "")
531
559
  );
560
+ this.warnOnVersionMismatch(info);
532
561
  this.startHeartbeatMonitor();
533
562
  this.startPingInterval();
534
563
  if (ws.__earlyClose) {
@@ -583,6 +612,14 @@ var Bridge = class extends EventEmitter {
583
612
  }
584
613
  });
585
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
+ }
586
623
  failPendingForStudio(studioId, code, reason) {
587
624
  for (const [id, pending] of this.pendingRequests) {
588
625
  if (pending.studioId === studioId) {
@@ -737,6 +774,7 @@ var Bridge = class extends EventEmitter {
737
774
  status: "ok",
738
775
  connected: this.isConnected,
739
776
  port: this.actualPort,
777
+ serverVersion: getServerVersion(),
740
778
  studios: this.getStudios(),
741
779
  activeStudioId: this.activeStudioId
742
780
  })
@@ -745,7 +783,11 @@ var Bridge = class extends EventEmitter {
745
783
  }
746
784
  if (req.method === "GET" && pathname === "/poll") {
747
785
  const studioId = parsedUrl.searchParams.get("studioId") ?? this.activeStudioId ?? "_default";
748
- this.handlePoll(studioId, res);
786
+ this.handlePoll(
787
+ studioId,
788
+ parsedUrl.searchParams.get("version") ?? void 0,
789
+ res
790
+ );
749
791
  return;
750
792
  }
751
793
  if (req.method === "POST" && pathname === "/result") {
@@ -760,17 +802,19 @@ var Bridge = class extends EventEmitter {
760
802
  res.writeHead(404);
761
803
  res.end("Not found");
762
804
  }
763
- handlePoll(studioId, res) {
805
+ handlePoll(studioId, pluginVersion, res) {
764
806
  if (!this.studios.has(studioId) && studioId !== "_default") {
765
807
  if (!this.httpStudios.has(studioId)) {
766
808
  const info = {
767
809
  studioId,
768
- connectedAt: Date.now()
810
+ connectedAt: Date.now(),
811
+ pluginVersion
769
812
  };
770
813
  this.httpStudios.set(studioId, info);
771
814
  this.lastHeartbeats.set(studioId, Date.now());
772
815
  this.emit("studio-connected", info);
773
816
  log.info(`HTTP-only studio registered: ${studioId}`);
817
+ this.warnOnVersionMismatch(info);
774
818
  this.startHeartbeatMonitor();
775
819
  }
776
820
  this.lastHeartbeats.set(studioId, Date.now());
@@ -1719,14 +1763,18 @@ ${outputText}
1719
1763
  // src/tools/playtest.ts
1720
1764
  import { z as z4 } from "zod";
1721
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;
1722
1770
  function register4(server, bridge) {
1723
1771
  server.registerTool(
1724
1772
  "playtest",
1725
1773
  {
1726
1774
  title: "Playtest Control & Virtual Input",
1727
- 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.",
1728
1776
  inputSchema: z4.object({
1729
- 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"),
1730
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)"),
1731
1779
  code: z4.string().optional().describe("Lua code to execute (for 'execute' action)"),
1732
1780
  // get_output params
@@ -1778,6 +1826,62 @@ ${logText}
1778
1826
  content: [{ type: "text", text: applyTokenBudget(text2, void 0) }]
1779
1827
  };
1780
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
+ }
1781
1885
  if (params.action === "inspect") {
1782
1886
  if (!params.expression) {
1783
1887
  return {
@@ -2485,12 +2589,14 @@ function register8(server, bridge) {
2485
2589
  ]
2486
2590
  };
2487
2591
  }
2592
+ const serverVersion = getServerVersion();
2488
2593
  const lines = studios.map((s) => {
2489
2594
  const active = s.studioId === activeId ? " **\u2190 active**" : "";
2490
2595
  const place = s.placeName ? ` \u2014 ${s.placeName}` : "";
2491
2596
  const placeId = s.placeId ? ` (Place ID: ${s.placeId})` : "";
2492
2597
  const duration = Math.floor((Date.now() - s.connectedAt) / 1e3);
2493
- 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}`;
2494
2600
  });
2495
2601
  const text = `**Connected Studios (${studios.length}):**
2496
2602
  ${lines.join("\n")}`;
@@ -2536,16 +2642,16 @@ import { z as z9 } from "zod";
2536
2642
 
2537
2643
  // src/utils/builds.ts
2538
2644
  import { homedir } from "os";
2539
- import { join } from "path";
2645
+ import { join as join2 } from "path";
2540
2646
  import {
2541
2647
  existsSync,
2542
2648
  mkdirSync,
2543
- readFileSync,
2649
+ readFileSync as readFileSync2,
2544
2650
  writeFileSync,
2545
2651
  readdirSync,
2546
2652
  statSync
2547
2653
  } from "fs";
2548
- var BUILDS_DIR = join(homedir(), ".conduit", "builds");
2654
+ var BUILDS_DIR = join2(homedir(), ".conduit", "builds");
2549
2655
  function ensureDir() {
2550
2656
  if (!existsSync(BUILDS_DIR)) {
2551
2657
  mkdirSync(BUILDS_DIR, { recursive: true });
@@ -2566,7 +2672,7 @@ function saveBuild(name, root, description) {
2566
2672
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2567
2673
  root
2568
2674
  };
2569
- const filePath = join(BUILDS_DIR, `${name}.json`);
2675
+ const filePath = join2(BUILDS_DIR, `${name}.json`);
2570
2676
  writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
2571
2677
  return {
2572
2678
  name,
@@ -2578,19 +2684,19 @@ function saveBuild(name, root, description) {
2578
2684
  }
2579
2685
  function loadBuild(name) {
2580
2686
  validateBuildName(name);
2581
- const filePath = join(BUILDS_DIR, `${name}.json`);
2687
+ const filePath = join2(BUILDS_DIR, `${name}.json`);
2582
2688
  if (!existsSync(filePath)) {
2583
2689
  throw new Error(`Build "${name}" not found. Use builds --action list to see available builds.`);
2584
2690
  }
2585
- return JSON.parse(readFileSync(filePath, "utf-8"));
2691
+ return JSON.parse(readFileSync2(filePath, "utf-8"));
2586
2692
  }
2587
2693
  function listBuilds() {
2588
2694
  ensureDir();
2589
2695
  const files = readdirSync(BUILDS_DIR).filter((f) => f.endsWith(".json"));
2590
2696
  return files.map((f) => {
2591
- const filePath = join(BUILDS_DIR, f);
2697
+ const filePath = join2(BUILDS_DIR, f);
2592
2698
  try {
2593
- const data = JSON.parse(readFileSync(filePath, "utf-8"));
2699
+ const data = JSON.parse(readFileSync2(filePath, "utf-8"));
2594
2700
  const rootObj = data.root;
2595
2701
  return {
2596
2702
  name: data.name,
@@ -2742,7 +2848,7 @@ async function startServer(port = 3200, options = {}) {
2742
2848
  const server = new McpServer(
2743
2849
  {
2744
2850
  name: "conduit-mcp",
2745
- version: "2.0.0"
2851
+ version: getServerVersion()
2746
2852
  },
2747
2853
  {
2748
2854
  instructions: [
@@ -2793,6 +2899,7 @@ async function startServer(port = 3200, options = {}) {
2793
2899
  if (shuttingDown) return;
2794
2900
  shuttingDown = true;
2795
2901
  log.info("Shutting down...");
2902
+ setTimeout(() => process.exit(1), 5e3).unref();
2796
2903
  await bridge.stop();
2797
2904
  await server.close();
2798
2905
  process.exit(0);
@@ -2815,4 +2922,4 @@ async function startServer(port = 3200, options = {}) {
2815
2922
  export {
2816
2923
  startServer
2817
2924
  };
2818
- //# sourceMappingURL=chunk-NV56BC2A.js.map
2925
+ //# sourceMappingURL=chunk-26DDWVEB.js.map