sbox-mcp-server 1.3.1 → 1.3.2
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.
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -5
- package/dist/tools/status.js +23 -8
- package/dist/transport/bridge-client.d.ts +44 -7
- package/dist/transport/bridge-client.js +139 -52
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* Entry point for the sbox-mcp MCP server.
|
|
4
4
|
*
|
|
5
5
|
* Creates an MCP server (stdio transport), connects to the s&box Bridge Addon
|
|
6
|
-
* via
|
|
6
|
+
* via file-based IPC (a shared temp dir), and registers all tool handlers. Each tool domain (project,
|
|
7
7
|
* scripts, console, scenes, etc.) has its own register function in src/tools/.
|
|
8
8
|
*
|
|
9
9
|
* CLI flags: --version / -v, --help / -h
|
|
10
|
-
* Environment: SBOX_BRIDGE_HOST,
|
|
10
|
+
* Environment: SBOX_BRIDGE_IPC_DIR (the real knob); SBOX_BRIDGE_HOST / SBOX_BRIDGE_PORT (legacy, cosmetic)
|
|
11
11
|
*/
|
|
12
12
|
export {};
|
|
13
13
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* Entry point for the sbox-mcp MCP server.
|
|
4
4
|
*
|
|
5
5
|
* Creates an MCP server (stdio transport), connects to the s&box Bridge Addon
|
|
6
|
-
* via
|
|
6
|
+
* via file-based IPC (a shared temp dir), and registers all tool handlers. Each tool domain (project,
|
|
7
7
|
* scripts, console, scenes, etc.) has its own register function in src/tools/.
|
|
8
8
|
*
|
|
9
9
|
* CLI flags: --version / -v, --help / -h
|
|
10
|
-
* Environment: SBOX_BRIDGE_HOST,
|
|
10
|
+
* Environment: SBOX_BRIDGE_IPC_DIR (the real knob); SBOX_BRIDGE_HOST / SBOX_BRIDGE_PORT (legacy, cosmetic)
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync } from "fs";
|
|
13
13
|
import { fileURLToPath } from "url";
|
|
@@ -60,8 +60,10 @@ USAGE
|
|
|
60
60
|
node dist/index.js --version Show version
|
|
61
61
|
|
|
62
62
|
ENVIRONMENT VARIABLES
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
SBOX_BRIDGE_IPC_DIR IPC directory — MUST match the s&box addon's dir.
|
|
64
|
+
Default: <os tmpdir>/sbox-bridge-ipc
|
|
65
|
+
SBOX_BRIDGE_HOST Legacy/cosmetic — shown in get_bridge_status only
|
|
66
|
+
SBOX_BRIDGE_PORT Legacy/cosmetic — shown in get_bridge_status only
|
|
65
67
|
|
|
66
68
|
CONNECT TO CLAUDE CODE
|
|
67
69
|
claude mcp add sbox -- node /path/to/sbox-mcp-server/dist/index.js
|
|
@@ -125,7 +127,7 @@ If you're running inside Claude Code, install the companion plugin for the full
|
|
|
125
127
|
|
|
126
128
|
The plugin ships an \`sbox-build-feature\` skill that codifies the workflow above plus a list of common s&box gotchas (MathF not available in sandbox, Cloud assets ephemeral, head bone case-sensitive, CitizenAnimationHelper.IkRightHand works at runtime, etc.). Read its SKILL.md before starting non-trivial features.`,
|
|
127
129
|
});
|
|
128
|
-
// Bridge client
|
|
130
|
+
// Bridge client talks to the s&box editor via file IPC. host/port are cosmetic.
|
|
129
131
|
const bridge = new BridgeClient(process.env.SBOX_BRIDGE_HOST ?? "127.0.0.1", parseInt(process.env.SBOX_BRIDGE_PORT ?? "29015", 10));
|
|
130
132
|
// Register all tools
|
|
131
133
|
registerProjectTools(server, bridge);
|
|
@@ -159,6 +161,7 @@ async function main() {
|
|
|
159
161
|
console.error(" ║ https://sboxskins.gg ║");
|
|
160
162
|
console.error(" ╚═══════════════════════════════════════════════════╝");
|
|
161
163
|
console.error("");
|
|
164
|
+
console.error(`[sbox-mcp] IPC directory: ${bridge.getIpcDir()}`);
|
|
162
165
|
// Attempt initial connection to s&box (non-fatal if it fails)
|
|
163
166
|
try {
|
|
164
167
|
await bridge.connect();
|
package/dist/tools/status.js
CHANGED
|
@@ -6,14 +6,18 @@ export function registerStatusTools(server, bridge) {
|
|
|
6
6
|
// ── get_bridge_status ────────────────────────────────────────────
|
|
7
7
|
server.tool("get_bridge_status", "Check the connection status to the s&box Bridge — whether it's connected, latency, host/port, and editor info. Useful for debugging", {}, async () => {
|
|
8
8
|
const connected = bridge.isConnected();
|
|
9
|
-
|
|
9
|
+
const ipcDir = bridge.getIpcDir();
|
|
10
|
+
const heartbeatAgeMs = bridge.getHeartbeatAgeMs();
|
|
11
|
+
let latencyMs = null;
|
|
10
12
|
let editorVersion = null;
|
|
13
|
+
let roundTripOk = false;
|
|
11
14
|
if (connected) {
|
|
12
|
-
// Measure round-trip ping
|
|
13
15
|
latencyMs = await bridge.ping();
|
|
14
|
-
//
|
|
16
|
+
// A real round-trip — distinguishes a live editor from one whose
|
|
17
|
+
// heartbeat is fresh but whose request loop is stalled.
|
|
15
18
|
try {
|
|
16
19
|
const res = await bridge.send("get_project_info", {}, 5000);
|
|
20
|
+
roundTripOk = res.success;
|
|
17
21
|
if (res.success && res.data) {
|
|
18
22
|
const data = res.data;
|
|
19
23
|
editorVersion = data.editorVersion ?? null;
|
|
@@ -25,17 +29,28 @@ export function registerStatusTools(server, bridge) {
|
|
|
25
29
|
}
|
|
26
30
|
const status = {
|
|
27
31
|
connected,
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
ipcDir,
|
|
33
|
+
heartbeatAgeMs,
|
|
34
|
+
roundTripOk,
|
|
30
35
|
latencyMs: connected ? latencyMs : null,
|
|
31
36
|
lastPong: connected
|
|
32
37
|
? new Date(bridge.getLastPongTime()).toISOString()
|
|
33
38
|
: null,
|
|
34
39
|
editorVersion,
|
|
40
|
+
// legacy/cosmetic — there is no socket; transport is file IPC
|
|
41
|
+
host: bridge.getHost(),
|
|
42
|
+
port: bridge.getPort(),
|
|
35
43
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
let text;
|
|
45
|
+
if (!connected) {
|
|
46
|
+
text = `Bridge NOT connected — no recent heartbeat in ${ipcDir}. Is s&box running with the Claude Bridge addon?`;
|
|
47
|
+
}
|
|
48
|
+
else if (roundTripOk) {
|
|
49
|
+
text = `Bridge connected and responding (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago).`;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
text = `Bridge heartbeat is live but a test round-trip FAILED — the editor isn't draining requests. IPC: ${ipcDir}. Check the s&box editor console for [SboxBridge] lines.`;
|
|
53
|
+
}
|
|
39
54
|
return {
|
|
40
55
|
content: [
|
|
41
56
|
{
|
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Max age of the editor's status heartbeat before we consider the bridge dead.
|
|
3
|
+
* The addon refreshes the heartbeat roughly once per second from its frame
|
|
4
|
+
* loop, so this gives generous margin for GC pauses / frame hitches while still
|
|
5
|
+
* catching a closed, crashed, or frame-stalled editor within a few seconds.
|
|
6
|
+
*/
|
|
7
|
+
export declare const STATUS_STALE_MS = 5000;
|
|
8
|
+
/** Resolve the IPC directory, honoring an explicit override. */
|
|
9
|
+
export declare function resolveIpcDir(): string;
|
|
10
|
+
/** Result of inspecting the editor's status.json heartbeat. */
|
|
11
|
+
export interface StatusClassification {
|
|
12
|
+
/** The editor reported `running: true`. */
|
|
13
|
+
running: boolean;
|
|
14
|
+
/** The heartbeat is recent enough to trust (or the addon predates heartbeats). */
|
|
15
|
+
fresh: boolean;
|
|
16
|
+
/** Age of the heartbeat in ms, or null if the addon doesn't emit one. */
|
|
17
|
+
heartbeatMs: number | null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether a parsed status.json means the bridge is live.
|
|
3
21
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
22
|
+
* A recent heartbeat → fresh. A stale heartbeat → not fresh (editor closed,
|
|
23
|
+
* crashed, or frame loop stalled). No heartbeat field at all → treated as fresh
|
|
24
|
+
* for backward compatibility with addons built before v1.3.2 (so upgrading the
|
|
25
|
+
* MCP server alone never regresses a working setup to "disconnected").
|
|
8
26
|
*/
|
|
27
|
+
export declare function classifyStatus(status: unknown, nowMs: number, staleMs: number): StatusClassification;
|
|
28
|
+
/**
|
|
29
|
+
* Build a timeout error that names WHICH side of the IPC broke, so a 30s hang
|
|
30
|
+
* is actionable instead of opaque.
|
|
31
|
+
*/
|
|
32
|
+
export declare function describeTimeout(opts: {
|
|
33
|
+
reqConsumed: boolean;
|
|
34
|
+
ipcDir: string;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
command: string;
|
|
37
|
+
}): string;
|
|
9
38
|
/** A single command request sent to the s&box Bridge. */
|
|
10
39
|
export interface BridgeRequest {
|
|
11
40
|
id: string;
|
|
@@ -32,8 +61,16 @@ export declare class BridgeClient {
|
|
|
32
61
|
static readonly POLL_INTERVAL_MS = 50;
|
|
33
62
|
static readonly STATUS_CHECK_INTERVAL_MS = 5000;
|
|
34
63
|
constructor(host?: string, port?: number);
|
|
64
|
+
/** The directory this client reads/writes IPC files in. */
|
|
65
|
+
getIpcDir(): string;
|
|
66
|
+
private statusPath;
|
|
67
|
+
/** Read + classify the editor's status heartbeat. Never throws. */
|
|
68
|
+
readStatus(): StatusClassification;
|
|
69
|
+
/** Age of the editor's last heartbeat in ms, or null if unavailable. */
|
|
70
|
+
getHeartbeatAgeMs(): number | null;
|
|
35
71
|
/**
|
|
36
|
-
*
|
|
72
|
+
* Verify the s&box Bridge is live (recent heartbeat), throwing a specific
|
|
73
|
+
* error if it is missing or stale.
|
|
37
74
|
*/
|
|
38
75
|
connect(): Promise<void>;
|
|
39
76
|
/**
|
|
@@ -48,7 +85,7 @@ export declare class BridgeClient {
|
|
|
48
85
|
params?: Record<string, unknown>;
|
|
49
86
|
}>, timeoutMs?: number): Promise<BridgeResponse>;
|
|
50
87
|
/**
|
|
51
|
-
*
|
|
88
|
+
* Liveness check. Returns elapsed ms if the heartbeat is recent, else -1.
|
|
52
89
|
*/
|
|
53
90
|
ping(): Promise<number>;
|
|
54
91
|
isConnected(): boolean;
|
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as os from "os";
|
|
4
|
+
/**
|
|
5
|
+
* File-based IPC transport for communicating with the s&box Bridge Addon.
|
|
6
|
+
*
|
|
7
|
+
* There is NO socket. Despite the legacy `host`/`port` fields, communication is
|
|
8
|
+
* entirely through a shared temp directory:
|
|
9
|
+
* - MCP server writes request files (req_*.json)
|
|
10
|
+
* - s&box addon polls for them, processes on the main editor thread, and writes
|
|
11
|
+
* response files (res_*.json)
|
|
12
|
+
* - MCP server polls for response files
|
|
13
|
+
*
|
|
14
|
+
* The addon also maintains `status.json` as a HEARTBEAT (rewritten from the
|
|
15
|
+
* editor frame loop). "Connected" means that heartbeat is recent — not merely
|
|
16
|
+
* that the file exists. A write-once status file used to make the bridge report
|
|
17
|
+
* "connected" forever after the first run, even with the editor closed.
|
|
18
|
+
*/
|
|
19
|
+
/** Default IPC directory name under the system temp dir. */
|
|
20
|
+
const IPC_DIR_NAME = "sbox-bridge-ipc";
|
|
21
|
+
/** Strip a leading UTF-8 BOM (older addons may prepend one to IPC files). */
|
|
22
|
+
function stripBom(s) {
|
|
23
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Max age of the editor's status heartbeat before we consider the bridge dead.
|
|
27
|
+
* The addon refreshes the heartbeat roughly once per second from its frame
|
|
28
|
+
* loop, so this gives generous margin for GC pauses / frame hitches while still
|
|
29
|
+
* catching a closed, crashed, or frame-stalled editor within a few seconds.
|
|
30
|
+
*/
|
|
31
|
+
export const STATUS_STALE_MS = 5000;
|
|
32
|
+
/** Resolve the IPC directory, honoring an explicit override. */
|
|
33
|
+
export function resolveIpcDir() {
|
|
34
|
+
const override = process.env.SBOX_BRIDGE_IPC_DIR;
|
|
35
|
+
if (override && override.trim().length > 0)
|
|
36
|
+
return override;
|
|
37
|
+
return path.join(os.tmpdir(), IPC_DIR_NAME);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Decide whether a parsed status.json means the bridge is live.
|
|
41
|
+
*
|
|
42
|
+
* A recent heartbeat → fresh. A stale heartbeat → not fresh (editor closed,
|
|
43
|
+
* crashed, or frame loop stalled). No heartbeat field at all → treated as fresh
|
|
44
|
+
* for backward compatibility with addons built before v1.3.2 (so upgrading the
|
|
45
|
+
* MCP server alone never regresses a working setup to "disconnected").
|
|
46
|
+
*/
|
|
47
|
+
export function classifyStatus(status, nowMs, staleMs) {
|
|
48
|
+
if (!status || typeof status !== "object") {
|
|
49
|
+
return { running: false, fresh: false, heartbeatMs: null };
|
|
50
|
+
}
|
|
51
|
+
const s = status;
|
|
52
|
+
const running = s.running === true;
|
|
53
|
+
const hb = s.heartbeat;
|
|
54
|
+
if (typeof hb === "string") {
|
|
55
|
+
const t = Date.parse(hb);
|
|
56
|
+
if (!Number.isNaN(t)) {
|
|
57
|
+
const heartbeatMs = nowMs - t;
|
|
58
|
+
return { running, fresh: heartbeatMs <= staleMs, heartbeatMs };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Old addon (no parseable heartbeat) — don't regress working setups.
|
|
62
|
+
return { running, fresh: true, heartbeatMs: null };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build a timeout error that names WHICH side of the IPC broke, so a 30s hang
|
|
66
|
+
* is actionable instead of opaque.
|
|
67
|
+
*/
|
|
68
|
+
export function describeTimeout(opts) {
|
|
69
|
+
const { reqConsumed, ipcDir, timeoutMs, command } = opts;
|
|
70
|
+
if (!reqConsumed) {
|
|
71
|
+
return (`Request '${command}' timed out after ${timeoutMs}ms. The s&box editor never picked up the request ` +
|
|
72
|
+
`(its req_*.json file was not consumed). Likely causes: s&box isn't running, the Claude Bridge addon ` +
|
|
73
|
+
`failed to load, or the editor and MCP server resolved different IPC directories (server is using: ` +
|
|
74
|
+
`${ipcDir}). Open the s&box editor console and check for [SboxBridge] lines — it logs the directory it ` +
|
|
75
|
+
`is watching; set SBOX_BRIDGE_IPC_DIR on both sides if they disagree.`);
|
|
76
|
+
}
|
|
77
|
+
return (`Request '${command}' timed out after ${timeoutMs}ms. The editor consumed the request but never wrote a ` +
|
|
78
|
+
`response. Its frame loop may be stalled (e.g. the s&box window is unfocused or minimized) or the handler ` +
|
|
79
|
+
`errored. Check the s&box editor console for [SboxBridge] errors.`);
|
|
80
|
+
}
|
|
4
81
|
/**
|
|
5
82
|
* File-based IPC client that communicates with the s&box Bridge Addon.
|
|
6
83
|
*/
|
|
@@ -14,33 +91,56 @@ export class BridgeClient {
|
|
|
14
91
|
static POLL_INTERVAL_MS = 50; // 50ms polling for responses
|
|
15
92
|
static STATUS_CHECK_INTERVAL_MS = 5000;
|
|
16
93
|
constructor(host = "127.0.0.1", port = 29015) {
|
|
94
|
+
// host/port are legacy/cosmetic — surfaced in get_bridge_status only. The
|
|
95
|
+
// real transport is the file IPC directory below.
|
|
17
96
|
this.host = host;
|
|
18
97
|
this.port = port;
|
|
19
|
-
this.ipcDir =
|
|
98
|
+
this.ipcDir = resolveIpcDir();
|
|
99
|
+
}
|
|
100
|
+
/** The directory this client reads/writes IPC files in. */
|
|
101
|
+
getIpcDir() {
|
|
102
|
+
return this.ipcDir;
|
|
103
|
+
}
|
|
104
|
+
statusPath() {
|
|
105
|
+
return path.join(this.ipcDir, "status.json");
|
|
106
|
+
}
|
|
107
|
+
/** Read + classify the editor's status heartbeat. Never throws. */
|
|
108
|
+
readStatus() {
|
|
109
|
+
try {
|
|
110
|
+
// Strip a UTF-8 BOM in case an older addon wrote one.
|
|
111
|
+
const raw = stripBom(fs.readFileSync(this.statusPath(), "utf8"));
|
|
112
|
+
return classifyStatus(JSON.parse(raw), Date.now(), STATUS_STALE_MS);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { running: false, fresh: false, heartbeatMs: null };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Age of the editor's last heartbeat in ms, or null if unavailable. */
|
|
119
|
+
getHeartbeatAgeMs() {
|
|
120
|
+
return this.readStatus().heartbeatMs;
|
|
20
121
|
}
|
|
21
122
|
/**
|
|
22
|
-
*
|
|
123
|
+
* Verify the s&box Bridge is live (recent heartbeat), throwing a specific
|
|
124
|
+
* error if it is missing or stale.
|
|
23
125
|
*/
|
|
24
126
|
async connect() {
|
|
25
|
-
// Ensure IPC directory exists
|
|
26
127
|
if (!fs.existsSync(this.ipcDir)) {
|
|
27
128
|
fs.mkdirSync(this.ipcDir, { recursive: true });
|
|
28
129
|
}
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// Status file exists but is malformed
|
|
41
|
-
}
|
|
130
|
+
const s = this.readStatus();
|
|
131
|
+
if (s.running && s.fresh) {
|
|
132
|
+
this.connected = true;
|
|
133
|
+
this.lastPongTime = Date.now();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.connected = false;
|
|
137
|
+
if (s.running && !s.fresh) {
|
|
138
|
+
throw new Error(`s&box Bridge heartbeat is stale at ${this.statusPath()} (last beat ${s.heartbeatMs}ms ago, ` +
|
|
139
|
+
`limit ${STATUS_STALE_MS}ms). The editor likely closed, crashed, or its frame loop stalled. ` +
|
|
140
|
+
`IPC dir: ${this.ipcDir}`);
|
|
42
141
|
}
|
|
43
|
-
throw new Error(`Cannot connect to s&box Bridge. No status
|
|
142
|
+
throw new Error(`Cannot connect to s&box Bridge. No live status at ${this.statusPath()}. Is s&box running with the ` +
|
|
143
|
+
`Claude Bridge addon? (MCP server IPC dir: ${this.ipcDir})`);
|
|
44
144
|
}
|
|
45
145
|
/**
|
|
46
146
|
* Send a command to the s&box Bridge and wait for its response.
|
|
@@ -51,11 +151,13 @@ export class BridgeClient {
|
|
|
51
151
|
try {
|
|
52
152
|
await this.connect();
|
|
53
153
|
}
|
|
54
|
-
catch {
|
|
154
|
+
catch (err) {
|
|
55
155
|
return {
|
|
56
156
|
id: "",
|
|
57
157
|
success: false,
|
|
58
|
-
error:
|
|
158
|
+
error: err instanceof Error
|
|
159
|
+
? err.message
|
|
160
|
+
: "Not connected to s&box Bridge. Make sure s&box is running with the Claude Bridge addon installed.",
|
|
59
161
|
};
|
|
60
162
|
}
|
|
61
163
|
}
|
|
@@ -85,6 +187,8 @@ export class BridgeClient {
|
|
|
85
187
|
// Check timeout
|
|
86
188
|
if (Date.now() - startTime > timeoutMs) {
|
|
87
189
|
clearInterval(poll);
|
|
190
|
+
// Whether the editor ever read the request tells us which side broke.
|
|
191
|
+
const reqConsumed = !fs.existsSync(reqPath);
|
|
88
192
|
// Clean up request file if still there
|
|
89
193
|
try {
|
|
90
194
|
if (fs.existsSync(reqPath))
|
|
@@ -94,15 +198,15 @@ export class BridgeClient {
|
|
|
94
198
|
resolve({
|
|
95
199
|
id,
|
|
96
200
|
success: false,
|
|
97
|
-
error:
|
|
201
|
+
error: describeTimeout({ reqConsumed, ipcDir: this.ipcDir, timeoutMs, command }),
|
|
98
202
|
});
|
|
99
203
|
return;
|
|
100
204
|
}
|
|
101
205
|
// Check for response file
|
|
102
206
|
if (fs.existsSync(resPath)) {
|
|
103
207
|
try {
|
|
104
|
-
//
|
|
105
|
-
const responseJson = fs.readFileSync(resPath, "utf8")
|
|
208
|
+
// Defensively strip a BOM in case an older addon wrote one.
|
|
209
|
+
const responseJson = stripBom(fs.readFileSync(resPath, "utf8"));
|
|
106
210
|
const response = JSON.parse(responseJson);
|
|
107
211
|
// Clean up response file
|
|
108
212
|
try {
|
|
@@ -128,11 +232,11 @@ export class BridgeClient {
|
|
|
128
232
|
try {
|
|
129
233
|
await this.connect();
|
|
130
234
|
}
|
|
131
|
-
catch {
|
|
235
|
+
catch (err) {
|
|
132
236
|
return {
|
|
133
237
|
id: "",
|
|
134
238
|
success: false,
|
|
135
|
-
error: "Not connected to s&box Bridge.",
|
|
239
|
+
error: err instanceof Error ? err.message : "Not connected to s&box Bridge.",
|
|
136
240
|
};
|
|
137
241
|
}
|
|
138
242
|
}
|
|
@@ -158,6 +262,7 @@ export class BridgeClient {
|
|
|
158
262
|
const poll = setInterval(() => {
|
|
159
263
|
if (Date.now() - startTime > timeoutMs) {
|
|
160
264
|
clearInterval(poll);
|
|
265
|
+
const reqConsumed = !fs.existsSync(reqPath);
|
|
161
266
|
try {
|
|
162
267
|
if (fs.existsSync(reqPath))
|
|
163
268
|
fs.unlinkSync(reqPath);
|
|
@@ -166,14 +271,14 @@ export class BridgeClient {
|
|
|
166
271
|
resolve({
|
|
167
272
|
id,
|
|
168
273
|
success: false,
|
|
169
|
-
error:
|
|
274
|
+
error: describeTimeout({ reqConsumed, ipcDir: this.ipcDir, timeoutMs, command: "batch" }),
|
|
170
275
|
});
|
|
171
276
|
return;
|
|
172
277
|
}
|
|
173
278
|
if (fs.existsSync(resPath)) {
|
|
174
279
|
try {
|
|
175
|
-
//
|
|
176
|
-
const responseJson = fs.readFileSync(resPath, "utf8")
|
|
280
|
+
// Defensively strip a BOM in case an older addon wrote one.
|
|
281
|
+
const responseJson = stripBom(fs.readFileSync(resPath, "utf8"));
|
|
177
282
|
const response = JSON.parse(responseJson);
|
|
178
283
|
try {
|
|
179
284
|
fs.unlinkSync(resPath);
|
|
@@ -189,38 +294,20 @@ export class BridgeClient {
|
|
|
189
294
|
});
|
|
190
295
|
}
|
|
191
296
|
/**
|
|
192
|
-
*
|
|
297
|
+
* Liveness check. Returns elapsed ms if the heartbeat is recent, else -1.
|
|
193
298
|
*/
|
|
194
299
|
async ping() {
|
|
195
|
-
const statusPath = path.join(this.ipcDir, "status.json");
|
|
196
300
|
const start = Date.now();
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
this.lastPongTime = Date.now();
|
|
202
|
-
return Date.now() - start;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
301
|
+
const s = this.readStatus();
|
|
302
|
+
if (s.running && s.fresh) {
|
|
303
|
+
this.lastPongTime = Date.now();
|
|
304
|
+
return Date.now() - start;
|
|
205
305
|
}
|
|
206
|
-
catch { }
|
|
207
306
|
return -1;
|
|
208
307
|
}
|
|
209
308
|
isConnected() {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
if (fs.existsSync(statusPath)) {
|
|
214
|
-
const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
|
|
215
|
-
this.connected = !!status.running;
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
this.connected = false;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
this.connected = false;
|
|
223
|
-
}
|
|
309
|
+
const s = this.readStatus();
|
|
310
|
+
this.connected = s.running && s.fresh;
|
|
224
311
|
return this.connected;
|
|
225
312
|
}
|
|
226
313
|
getHost() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbox-mcp-server",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "MCP Server for s&box game engine — enables Claude to build games through conversation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc",
|
|
19
|
+
"test": "npm run build && node --test",
|
|
19
20
|
"start": "node dist/index.js",
|
|
20
21
|
"dev": "tsc --watch",
|
|
21
22
|
"prepublishOnly": "npm run build"
|