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 "${
|
|
308
|
+
`WebSocket send failed for studio "${targetStudioId}": ${err.message}`
|
|
284
309
|
);
|
|
285
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
2697
|
+
const filePath = join2(BUILDS_DIR, f);
|
|
2592
2698
|
try {
|
|
2593
|
-
const data = JSON.parse(
|
|
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:
|
|
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-
|
|
2925
|
+
//# sourceMappingURL=chunk-26DDWVEB.js.map
|