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 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 WebSocket, and registers all tool handlers. Each tool domain (project,
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, SBOX_BRIDGE_PORT
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 WebSocket, and registers all tool handlers. Each tool domain (project,
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, SBOX_BRIDGE_PORT
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
- SBOX_BRIDGE_HOST Bridge WebSocket host (default: 127.0.0.1)
64
- SBOX_BRIDGE_PORT Bridge WebSocket port (default: 29015)
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 connects to s&box editor via WebSocket
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();
@@ -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
- let latencyMs = -1;
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
- // Try to get editor version via project info
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
- host: bridge.getHost(),
29
- port: bridge.getPort(),
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
- const text = connected
37
- ? `Bridge connected (${bridge.getHost()}:${bridge.getPort()}, ${latencyMs}ms latency)`
38
- : `Bridge NOT connected (${bridge.getHost()}:${bridge.getPort()}). Is s&box running?`;
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
- * File-based IPC transport for communicating with the s&box Bridge Addon.
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
- * Instead of WebSocket, this uses a shared temp directory where:
5
- * - MCP server writes request files (req_*.json)
6
- * - s&box addon polls for them, processes, and writes response files (res_*.json)
7
- * - MCP server polls for response files
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
- * Check if the s&box Bridge is running by looking for the status file.
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
- * Check if bridge is alive by looking for status file.
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 = path.join(os.tmpdir(), "sbox-bridge-ipc");
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
- * Check if the s&box Bridge is running by looking for the status file.
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 statusPath = path.join(this.ipcDir, "status.json");
30
- if (fs.existsSync(statusPath)) {
31
- try {
32
- const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
33
- if (status.running) {
34
- this.connected = true;
35
- this.lastPongTime = Date.now();
36
- return;
37
- }
38
- }
39
- catch {
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 file found at ${statusPath}. Is s&box running with the Bridge Addon?`);
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: "Not connected to s&box Bridge. Make sure s&box is running with the Bridge Addon installed.",
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: `Request timed out after ${timeoutMs}ms`,
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
- // Strip UTF-8 BOM that C#'s File.WriteAllText prepends
105
- const responseJson = fs.readFileSync(resPath, "utf8").replace(/^\uFEFF/, "");
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: `Batch request timed out after ${timeoutMs}ms`,
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
- // Strip UTF-8 BOM that C#'s File.WriteAllText prepends
176
- const responseJson = fs.readFileSync(resPath, "utf8").replace(/^\uFEFF/, "");
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
- * Check if bridge is alive by looking for status file.
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
- try {
198
- if (fs.existsSync(statusPath)) {
199
- const status = JSON.parse(fs.readFileSync(statusPath, "utf8"));
200
- if (status.running) {
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
- // Re-check status file
211
- const statusPath = path.join(this.ipcDir, "status.json");
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.1",
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"