sbox-mcp-server 1.3.0 → 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/README.md CHANGED
@@ -1,55 +1,60 @@
1
1
  # sbox-mcp-server
2
2
 
3
- MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — 78 working tools for scenes, scripts, GameObjects, components, assets, materials, audio, physics, UI, networking, publishing, and more.
3
+ MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — 99 working tools for scenes, scripts, GameObjects, components, assets, materials, audio, physics, UI, networking, publishing, world-gen, and type discovery.
4
4
 
5
- ## Quick Start
5
+ ## Fastest install — the Claude Code plugin
6
6
 
7
- ### 1. Install the Bridge Addon in s&box
7
+ If you use Claude Code, the easiest install is the companion plugin. It registers this MCP server automatically, ships a workflow skill, and includes the `sbox-game-dev` specialist agent.
8
8
 
9
- The Bridge Addon runs inside the s&box editor and receives commands from this MCP server.
10
-
11
- **From source:**
12
- ```bash
13
- git clone https://github.com/lousputthole/sbox-claude.git
9
+ ```
10
+ /plugin marketplace add LouSputthole/Sbox-Claude
11
+ /plugin install sbox-claude
14
12
  ```
15
13
 
16
- Then in s&box:
17
- 1. Open your project in the s&box editor
18
- 2. Go to **Library Manager** and create a new library called **"claudebridge"**
19
- 3. Copy `sbox-bridge-addon/Editor/MyEditorMenu.cs` into the library's `Editor/` folder
20
- 4. Restart s&box
14
+ You still need to install the s&box-side **bridge addon** into your project's `Libraries/` folder (see step 1 below). The plugin handles the Claude side; the addon handles the s&box side.
21
15
 
22
- ### 2. Build the MCP Server
16
+ ## Manual install three steps
23
17
 
24
- ```bash
25
- cd sbox-claude/sbox-mcp-server
26
- npm install
27
- npm run build
18
+ ### 1. Install the bridge addon in s&box
19
+
20
+ The bridge addon runs inside the s&box editor and receives commands from this MCP server. It MUST live inside a project's `Libraries/` folder — putting it in s&box's global `addons/` will silently fail to compile.
21
+
22
+ ```powershell
23
+ git clone https://github.com/LouSputthole/Sbox-Claude.git
24
+ cd Sbox-Claude
25
+ .\install.ps1 -RemoveStaleAddons # Windows, auto-detects your s&box project
26
+ ./install.sh --remove-stale # Linux/Mac/WSL
28
27
  ```
29
28
 
30
- ### 3. Connect to Claude Code
29
+ See [INSTALL.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/INSTALL.md) for the full guide and manual fallback.
30
+
31
+ ### 2. Register the MCP server with Claude Code
31
32
 
32
33
  ```bash
33
- claude mcp add sbox -- node /path/to/sbox-mcp-server/dist/index.js
34
+ claude mcp add sbox -- npx sbox-mcp-server
34
35
  ```
35
36
 
36
- ### 4. Open the Bridge Dock
37
-
38
- In s&box, go to **View > Claude Bridge** to open the dock panel. The dock **must be visible** for commands to be processed.
37
+ This is the bare command — equivalent to what the plugin's `.mcp.json` does for you.
39
38
 
40
- That's it. Start Claude Code and start building.
39
+ ### 3. Open s&box
41
40
 
42
- ## How It Works
41
+ Open your project. The bridge starts automatically. Verify with:
43
42
 
44
43
  ```
45
- Claude Code --> (stdio) --> sbox-mcp-server --> (file IPC) --> Bridge Addon --> s&box Editor
44
+ Check the bridge status.
46
45
  ```
47
46
 
48
- Communication uses **file-based IPC** through `%TEMP%/sbox-bridge-ipc/`. The MCP server writes request JSON files, the Bridge Addon (running inside s&box) polls for them, processes on the main editor thread, and writes response files back.
47
+ You should see `connected: true, handlerCount: 99`.
49
48
 
50
- WebSocket is not used — s&box's sandboxed C# environment does not allow `System.Net`.
49
+ ## How it works
51
50
 
52
- ## Tools (78 working, 89 defined)
51
+ ```
52
+ Claude Code → (stdio) → sbox-mcp-server → (file IPC) → bridge addon → s&box editor
53
+ ```
54
+
55
+ Communication uses file-based IPC through `%TEMP%/sbox-bridge-ipc/`. The MCP server writes request JSON files, the bridge addon (running inside s&box) polls and processes on the main editor thread, then writes response files back. WebSocket is not used — s&box's sandboxed C# environment blocks `System.Net`.
56
+
57
+ ## Tools (99 working)
53
58
 
54
59
  | Category | Tools |
55
60
  |----------|-------|
@@ -57,8 +62,8 @@ WebSocket is not used — s&box's sandboxed C# environment does not allow `Syste
57
62
  | **Scripts** | create_script, edit_script, delete_script, trigger_hotload |
58
63
  | **Scenes** | list_scenes, load_scene, save_scene, create_scene |
59
64
  | **GameObjects** | create/delete/duplicate/rename, set_parent/enabled/transform |
60
- | **Components** | get/set_property, get_all_properties, list_available, add_component |
61
- | **Hierarchy** | get_scene_hierarchy, get/select/focus_object |
65
+ | **Components** | get/set_property, get_all_properties, list_available, add_component, set_prefab_ref |
66
+ | **Hierarchy** | get_scene_hierarchy (with `maxDepth` + `rootId`), get/select/focus_object |
62
67
  | **Assets** | search_assets, list_asset_library, install_asset, get_asset_info |
63
68
  | **Materials** | assign_model, create/assign_material, set_material_property |
64
69
  | **Audio** | list_sounds, create_sound_event, assign_sound, play_sound_preview |
@@ -69,20 +74,38 @@ WebSocket is not used — s&box's sandboxed C# environment does not allow `Syste
69
74
  | **Physics** | add_physics, add_collider, add_joint, raycast |
70
75
  | **UI** | create_razor_ui, add_screen_panel, add_world_panel |
71
76
  | **Templates** | create_player/npc_controller, create_game_manager, create_trigger_zone |
72
- | **Networking** | network_helper, configure/status, spawn, ownership, sync, RPCs, templates |
73
- | **Publishing** | project_config, validate, thumbnail, package_details, install_asset |
77
+ | **Networking** | network_helper, configure/status, spawn, ownership, sync, RPCs, lobby/event templates |
78
+ | **Publishing** | project_config, validate, thumbnail, package_details |
79
+ | **World gen** | invoke_button, list_component_buttons, raycast_terrain, build_terrain_mesh |
80
+ | **Map edit** | add_terrain_hill/clearing/trail, clear_terrain_features, sculpt_terrain |
81
+ | **Caves / Forest** | add_cave_waypoint, clear_cave_path, add_forest_poi/trail, set_forest_seed, clear_forest_pois, paint_forest_density |
82
+ | **Placement** | place_along_path |
83
+ | **Discovery** | describe_type, search_types, get_method_signature, find_in_project |
74
84
  | **Status** | get_bridge_status |
75
85
 
76
- ### Not implementable (no s&box API)
86
+ ## Working with Claude effectively
77
87
 
78
- pause_play, resume_play, get_console_output, get_compile_errors, clear_console, build_project, get_build_status, clean_build, export_project, prepare_publish
88
+ Two disciplines prevent the iteration-loop trap:
89
+
90
+ 1. **After visual changes, call `take_screenshot` and read the PNG.** Claude is a multimodal model — it can see the result. Guessing about visual outcomes from code alone produces long iteration loops.
91
+ 2. **Before writing code that touches an unfamiliar s&box type, call `describe_type` or `search_types`.** Reflection is the source of truth; training data goes stale across SDK versions.
92
+
93
+ The companion plugin's `sbox-build-feature` skill encodes this workflow plus the common gotchas. If you're not using the plugin, the same rules apply manually.
79
94
 
80
95
  ## Requirements
81
96
 
82
97
  - **Node.js 18+**
83
- - **s&box** with the Bridge Addon installed
98
+ - **s&box** with the bridge addon installed in your project's `Libraries/` folder
84
99
  - **Claude Code**
85
100
 
101
+ ## Documentation
102
+
103
+ - [Main README](https://github.com/LouSputthole/Sbox-Claude/blob/main/README.md) — full project overview
104
+ - [INSTALL.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/INSTALL.md) — install + manual fallback
105
+ - [TROUBLESHOOTING.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/TROUBLESHOOTING.md) — 10 most common failures
106
+ - [CHANGELOG.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/CHANGELOG.md) — release history
107
+ - [Plugin README](https://github.com/LouSputthole/Sbox-Claude/blob/main/plugins/sbox-claude/README.md) — Claude Code plugin docs
108
+
86
109
  ## License
87
110
 
88
111
  **GPL-3.0** — see [LICENSE](../LICENSE) for details.
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
@@ -100,8 +102,32 @@ TOOLS (99 working — was 109; 10 unimplementable tools removed in v1.3.0)
100
102
  const server = new McpServer({
101
103
  name: "sbox-mcp",
102
104
  version: getVersion(),
105
+ }, {
106
+ // The `instructions` field surfaces every Claude Code session that uses this
107
+ // server (the way other MCP servers like Supabase / TurboTax do). Use it to
108
+ // tell Claude how to work effectively with the bridge — the disciplines that
109
+ // are easy to skip without a reminder.
110
+ instructions: `You are working with the s&box Claude Bridge — a file-based IPC bridge into the s&box game engine editor.
111
+
112
+ To get good results:
113
+
114
+ 1. Always call \`mcp__sbox__get_bridge_status\` first to confirm the bridge addon is connected and s&box is running. If ping responds but other tools time out, the editor side isn't processing requests.
115
+
116
+ 2. For visual changes (models, positions, animations, UI panels, lighting), call \`mcp__sbox__take_screenshot\` after the change and READ THE PNG yourself. You're a multimodal model — you can see the result. Guessing about visual outcomes from code alone produces long iteration loops. The screenshot tool saves to <sbox-install>/screenshots/sbox.<timestamp>.png — list the newest file and read it.
117
+
118
+ 3. Before writing code that touches an unfamiliar s&box type, call \`mcp__sbox__describe_type\` or \`mcp__sbox__search_types\`. s&box's API changes between SDK versions — reflection is the source of truth, not training data.
119
+
120
+ 4. \`get_scene_hierarchy\` honors \`maxDepth\` (default 10) and accepts optional \`rootId\` to traverse from a specific GameObject. Use these to avoid dumping the entire scene into a tool result.
121
+
122
+ 5. Scene-mutating tools (create_gameobject, set_property, etc.) refuse during play mode and return a clear error. Stop play before making scene edits.
123
+
124
+ If you're running inside Claude Code, install the companion plugin for the full workflow:
125
+ /plugin marketplace add LouSputthole/Sbox-Claude
126
+ /plugin install sbox-claude
127
+
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.`,
103
129
  });
104
- // 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.
105
131
  const bridge = new BridgeClient(process.env.SBOX_BRIDGE_HOST ?? "127.0.0.1", parseInt(process.env.SBOX_BRIDGE_PORT ?? "29015", 10));
106
132
  // Register all tools
107
133
  registerProjectTools(server, bridge);
@@ -135,6 +161,7 @@ async function main() {
135
161
  console.error(" ║ https://sboxskins.gg ║");
136
162
  console.error(" ╚═══════════════════════════════════════════════════╝");
137
163
  console.error("");
164
+ console.error(`[sbox-mcp] IPC directory: ${bridge.getIpcDir()}`);
138
165
  // Attempt initial connection to s&box (non-fatal if it fails)
139
166
  try {
140
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.0",
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"