sbox-mcp-server 1.3.1 → 1.4.0

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.
@@ -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.4.0",
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"