sbox-mcp-server 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
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 — 131 working tools for scenes, scripts, GameObjects, components, assets, materials, audio, physics, UI, networking, publishing, world-gen, lighting & atmosphere, characters, scene layout, and type discovery.
3
+ MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — **150 tools / 142 editor handlers** for scenes, scripts, GameObjects, components, assets, materials, audio, physics, UI, networking, publishing, world-gen, lighting & atmosphere, characters, scene layout, navmesh & spatial queries, particles, self-diagnosis, console/C# execution, live docs search, and type discovery.
4
4
 
5
5
  ## Fastest install — the Claude Code plugin
6
6
 
@@ -44,7 +44,7 @@ Open your project. The bridge starts automatically. Verify with:
44
44
  Check the bridge status.
45
45
  ```
46
46
 
47
- You should see `connected: true, handlerCount: 99`.
47
+ You should see `connected: true, handlerCount: 142`. (That's the editor-side handler count; the server exposes 150 tools total — a handful run MCP-server-side and need no editor handler.)
48
48
 
49
49
  ## How it works
50
50
 
@@ -54,7 +54,9 @@ Claude Code → (stdio) → sbox-mcp-server → (file IPC) → bridge addon →
54
54
 
55
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
56
 
57
- ## Tools (131 — v1.4.0)
57
+ ## Tools (150 / 142 editor handlers — v1.5.0)
58
+
59
+ `get_bridge_status` reports `handlerCount: 142` — that's the C# handlers compiled inside the editor. Six tools run **MCP-server-side** and need no editor handler: `read_log`, `get_compile_errors`, `execute_csharp`, `search_docs`, `get_doc_page`, `list_doc_categories`. They read the log / hotload-eval / fetch docs directly, so they keep working even when the editor has crashed or stalled.
58
60
 
59
61
  | Category | Tools |
60
62
  |----------|-------|
@@ -87,14 +89,24 @@ Communication uses file-based IPC through `%TEMP%/sbox-bridge-ipc/`. The MCP ser
87
89
  | **Scene & level** *(v1.4.0)* | snap_to_ground, align_objects, distribute_objects, grid_duplicate, measure_distance |
88
90
  | **Environment** *(v1.4.0)* | scatter_props, randomize_transforms, group_objects |
89
91
  | **Object utilities** *(v1.4.0)* | find_objects, set_tint, replace_model, set_tags |
90
- | **VFX** *(v1.4.0, experimental)* | spawn_particle, create_particle_effect, add_trail, add_beam — compile but runtime rendering unverified through the bridge; use a legacy `.vpcf` for visible particles |
92
+ | **VFX** *(v1.4.0, experimental)* | spawn_particle, create_particle_effect, add_trail, add_beam — compile but do **not** render through the bridge; use `spawn_vpcf` (below) for visible particles |
93
+ | **Diagnostics** *(v1.5.0, MCP-server-side)* | read_log, get_compile_errors — read `sbox-dev.log` directly; work even when the editor has crashed |
94
+ | **Camera** *(v1.5.0)* | screenshot_from (**aim a shot at any object/point** — `take_screenshot` is fixed to the Main Camera), frame_camera (move the editor viewport) |
95
+ | **Navigation** *(v1.5.0)* | bake_navmesh, get_navmesh_path |
96
+ | **Spatial** *(v1.5.0)* | physics_overlap (volume counterpart to raycast) |
97
+ | **Reflections** *(v1.5.0)* | bake_reflections (a placed EnvmapProbe captures nothing until baked) |
98
+ | **Particles** *(v1.5.0)* | spawn_vpcf — compiled `.vpcf` via LegacyParticleSystem, the **supported** particle path |
99
+ | **Console / Exec** *(v1.5.0)* | console_run, execute_csharp *(experimental)* |
100
+ | **Object utilities** *(v1.5.0)* | remove_component, get_tags |
101
+ | **Docs search** *(v1.5.0, MCP-server-side)* | search_docs, get_doc_page, list_doc_categories — official `Facepunch/sbox-docs` |
91
102
 
92
103
  ## Working with Claude effectively
93
104
 
94
- Two disciplines prevent the iteration-loop trap:
105
+ Three disciplines prevent the iteration-loop trap:
95
106
 
96
- 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.
107
+ 1. **After visual changes, see the result — and aim the camera.** `take_screenshot` renders from the scene's Main Camera (one fixed angle), so it often won't show the thing you just changed. Use **`screenshot_from`** to point the camera at the target object/point, then read the PNG. Claude is a multimodal model — guessing about visual outcomes from code alone produces long iteration loops.
97
108
  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.
109
+ 3. **When something breaks, read the log instead of guessing.** `get_compile_errors` surfaces the latest C# compile failures and `read_log` tails `sbox-dev.log` — both MCP-server-side, so they work even if the editor crashed.
98
110
 
99
111
  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.
100
112
 
@@ -108,7 +120,7 @@ The companion plugin's `sbox-build-feature` skill encodes this workflow plus the
108
120
 
109
121
  - [Main README](https://github.com/LouSputthole/Sbox-Claude/blob/main/README.md) — full project overview
110
122
  - [INSTALL.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/INSTALL.md) — install + manual fallback
111
- - [TROUBLESHOOTING.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/TROUBLESHOOTING.md) — 10 most common failures
123
+ - [TROUBLESHOOTING.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/TROUBLESHOOTING.md) — common failures and fixes
112
124
  - [CHANGELOG.md](https://github.com/LouSputthole/Sbox-Claude/blob/main/CHANGELOG.md) — release history
113
125
  - [Plugin README](https://github.com/LouSputthole/Sbox-Claude/blob/main/plugins/sbox-claude/README.md) — Claude Code plugin docs
114
126
 
package/dist/index.js CHANGED
@@ -37,6 +37,9 @@ import { registerVisualTools } from "./tools/visuals.js";
37
37
  import { registerCharacterTools } from "./tools/characters.js";
38
38
  import { registerLevelTools } from "./tools/leveltools.js";
39
39
  import { registerObjectTools } from "./tools/objecttools.js";
40
+ import { registerDiagnosticTools } from "./tools/diagnostics.js";
41
+ import { registerDocsTools } from "./tools/docs.js";
42
+ import { registerNavigationTools } from "./tools/navigation.js";
40
43
  // ── CLI flags ──────────────────────────────────────────────────────
41
44
  const args = process.argv.slice(2);
42
45
  /** Read the package version from package.json, or return "unknown" on failure. */
@@ -72,7 +75,7 @@ ENVIRONMENT VARIABLES
72
75
  CONNECT TO CLAUDE CODE
73
76
  claude mcp add sbox -- node /path/to/sbox-mcp-server/dist/index.js
74
77
 
75
- TOOLS (131 — +32 in v1.4.0: visual, characters, scene, environment, utilities)
78
+ TOOLS (150 total / 142 s&box-editor handlers — +16 in v1.5.0)
76
79
  Project: get_project_info, list_project_files, read_file, write_file
77
80
  Scripts: create_script, edit_script, delete_script, trigger_hotload
78
81
  Scenes: list_scenes, load_scene, save_scene, create_scene
@@ -107,6 +110,17 @@ TOOLS (131 — +32 in v1.4.0: visual, characters, scene, environment, utilities)
107
110
  Environment: scatter_props, randomize_transforms, group_objects
108
111
  Utilities: find_objects, set_tint, replace_model, set_tags
109
112
  VFX (exp): spawn_particle, create_particle_effect, add_trail, add_beam
113
+
114
+ ── New in v1.5.0 ───────────────────────────────────
115
+ Diagnostics: read_log, get_compile_errors (MCP-server-side — work even if the editor crashed)
116
+ Camera: screenshot_from (AIM a shot at an object/point — take_screenshot is fixed to the Main Camera), frame_camera
117
+ Navigation: bake_navmesh, get_navmesh_path
118
+ Spatial: physics_overlap (volume counterpart to raycast)
119
+ Reflections: bake_reflections
120
+ Particles: spawn_vpcf (compiled .vpcf via LegacyParticleSystem — the supported particle path)
121
+ Console/Exec: console_run, execute_csharp (experimental)
122
+ Object utils: remove_component, get_tags
123
+ Docs search: search_docs, get_doc_page, list_doc_categories (MCP-server-side — official Facepunch/sbox-docs)
110
124
  `);
111
125
  process.exit(0);
112
126
  }
@@ -164,6 +178,9 @@ registerVisualTools(server, bridge);
164
178
  registerCharacterTools(server, bridge);
165
179
  registerLevelTools(server, bridge);
166
180
  registerObjectTools(server, bridge);
181
+ registerDiagnosticTools(server, bridge);
182
+ registerDocsTools(server, bridge);
183
+ registerNavigationTools(server, bridge);
167
184
  /** Start the MCP server on stdio and attempt initial Bridge connection. */
168
185
  async function main() {
169
186
  const transport = new StdioServerTransport();
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ export declare function registerDiagnosticTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=diagnostics.d.ts.map
@@ -0,0 +1,325 @@
1
+ import { z } from "zod";
2
+ import { existsSync, readFileSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ /**
5
+ * Diagnostic tools (Batch 24 — "let Claude see its own errors"): read s&box's
6
+ * editor log so Claude can check compile errors, exceptions, and Log.Info
7
+ * output directly — instead of flying blind or relying on the user to relay
8
+ * them.
9
+ *
10
+ * Deliberately reads the log FILE on the Node side (not over the bridge IPC),
11
+ * so it works even when the s&box editor has crashed and the bridge is down —
12
+ * which is exactly when you need the log most.
13
+ *
14
+ * Log path resolution:
15
+ * 1. SBOX_LOG_PATH env var (explicit override — use this on macOS/Linux or
16
+ * non-Steam installs).
17
+ * 2. Windows Steam auto-detect: parse steamapps/libraryfolders.vdf for each
18
+ * library, look for steamapps/common/sbox/logs/sbox-dev.log, pick newest.
19
+ */
20
+ function locateSboxLog() {
21
+ const tried = [];
22
+ const env = process.env.SBOX_LOG_PATH;
23
+ if (env) {
24
+ tried.push(env);
25
+ if (existsSync(env))
26
+ return { path: env, tried };
27
+ }
28
+ if (process.platform === "win32") {
29
+ const steamRoots = [
30
+ "C:\\Program Files (x86)\\Steam",
31
+ "C:\\Program Files\\Steam",
32
+ ];
33
+ const libs = [];
34
+ for (const steam of steamRoots) {
35
+ const vdf = join(steam, "steamapps", "libraryfolders.vdf");
36
+ if (existsSync(vdf)) {
37
+ libs.push(steam);
38
+ try {
39
+ const txt = readFileSync(vdf, "utf-8");
40
+ for (const m of txt.matchAll(/"path"\s+"([^"]+)"/g)) {
41
+ libs.push(m[1].replace(/\\\\/g, "\\"));
42
+ }
43
+ }
44
+ catch {
45
+ /* ignore unreadable vdf */
46
+ }
47
+ }
48
+ }
49
+ const candidates = [];
50
+ for (const lib of libs) {
51
+ const p = join(lib, "steamapps", "common", "sbox", "logs", "sbox-dev.log");
52
+ tried.push(p);
53
+ if (existsSync(p))
54
+ candidates.push(p);
55
+ }
56
+ if (candidates.length > 0) {
57
+ candidates.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
58
+ return { path: candidates[0], tried };
59
+ }
60
+ }
61
+ return { path: null, tried };
62
+ }
63
+ function tailLines(text, n) {
64
+ const lines = text.split(/\r?\n/);
65
+ return lines.slice(Math.max(0, lines.length - n));
66
+ }
67
+ export function registerDiagnosticTools(server, bridge) {
68
+ // ── read_log ───────────────────────────────────────────────────────
69
+ server.tool("read_log", "Read s&box's editor log (sbox-dev.log) so Claude can see compile errors, exceptions, and Log.Info output directly. Reads the log file (not via the bridge), so it works even when the editor has crashed. If auto-detection fails (non-Windows / non-Steam install), set the SBOX_LOG_PATH environment variable to the full log path.", {
70
+ lines: z
71
+ .number()
72
+ .int()
73
+ .optional()
74
+ .describe("How many lines from the end to return (default 80, max 1000)"),
75
+ filter: z
76
+ .string()
77
+ .optional()
78
+ .describe("Only return lines containing this substring (case-insensitive)"),
79
+ }, async (params) => {
80
+ const { path, tried } = locateSboxLog();
81
+ if (!path) {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: "Error: couldn't locate sbox-dev.log. Set the SBOX_LOG_PATH environment variable to its full path.\nTried:\n" +
87
+ tried.join("\n"),
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ let n = params.lines ?? 80;
93
+ if (n < 1)
94
+ n = 1;
95
+ if (n > 1000)
96
+ n = 1000;
97
+ let text;
98
+ try {
99
+ text = readFileSync(path, "utf-8");
100
+ }
101
+ catch (e) {
102
+ return {
103
+ content: [{ type: "text", text: `Error reading ${path}: ${e.message}` }],
104
+ };
105
+ }
106
+ let out = tailLines(text, n);
107
+ if (params.filter) {
108
+ const f = params.filter.toLowerCase();
109
+ out = out.filter((l) => l.toLowerCase().includes(f));
110
+ }
111
+ const header = `# ${path}\n# last ${n} lines${params.filter ? ` · filter "${params.filter}"` : ""}\n\n`;
112
+ return { content: [{ type: "text", text: header + out.join("\n") }] };
113
+ });
114
+ // ── get_compile_errors ─────────────────────────────────────────────
115
+ server.tool("get_compile_errors", "Scan the recent s&box log for compile errors and exceptions — the fast way for Claude to confirm whether its last script/addon edit actually compiled. Reads sbox-dev.log directly (works even if the editor is mid-crash). Returns the matching lines, or an all-clear.", {
116
+ lines: z
117
+ .number()
118
+ .int()
119
+ .optional()
120
+ .describe("How many lines from the end to scan (default 400, max 4000)"),
121
+ }, async (params) => {
122
+ const { path } = locateSboxLog();
123
+ if (!path) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: "Error: couldn't locate sbox-dev.log. Set the SBOX_LOG_PATH environment variable.",
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ let n = params.lines ?? 400;
134
+ if (n < 1)
135
+ n = 1;
136
+ if (n > 4000)
137
+ n = 4000;
138
+ let text;
139
+ try {
140
+ text = readFileSync(path, "utf-8");
141
+ }
142
+ catch (e) {
143
+ return {
144
+ content: [{ type: "text", text: `Error reading ${path}: ${e.message}` }],
145
+ };
146
+ }
147
+ const recent = tailLines(text, n);
148
+ const re = /(error CS\d+|Compile of .* Failed|Exception|Couldn't add project|Broken Reference|StackTrace|^\s*at Sandbox\.)/i;
149
+ const hits = recent.filter((l) => re.test(l));
150
+ if (hits.length === 0) {
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: `No compile errors or exceptions in the last ${n} log lines — looks clean.\n(${path})`,
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ return {
161
+ content: [
162
+ {
163
+ type: "text",
164
+ text: `Found ${hits.length} error/exception line(s) in the last ${n} log lines:\n\n${hits.join("\n")}`,
165
+ },
166
+ ],
167
+ };
168
+ });
169
+ // ── frame_camera ─────────────────────────────────────────────────── (bridge)
170
+ server.tool("frame_camera", "Aim the s&box EDITOR viewport camera at a GameObject (by id) or a world point (position + optional radius), then call take_screenshot to capture that view. This is how Claude points its own screenshots at what it's working on — frame a spawned object, then screenshot to verify it actually looks right.", {
171
+ id: z.string().optional().describe("GUID of a GameObject to frame on"),
172
+ position: z
173
+ .object({ x: z.number(), y: z.number(), z: z.number() })
174
+ .optional()
175
+ .describe("World point to frame on (use instead of id)"),
176
+ radius: z
177
+ .number()
178
+ .optional()
179
+ .describe("Frame radius around the position, in units (default 128)"),
180
+ }, async (params) => {
181
+ const res = await bridge.send("frame_camera", params);
182
+ if (!res.success) {
183
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
184
+ }
185
+ return {
186
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
187
+ };
188
+ });
189
+ // ── screenshot_from ──────────────────────────────────────────────── (bridge)
190
+ server.tool("screenshot_from", "Take a screenshot from a chosen angle. take_screenshot is locked to the scene's main camera; this temporarily moves that camera to frame your target, captures, then restores it — so Claude can finally AIM its own screenshots. Pass id (frame an object) OR position {x,y,z} with optional lookAt {x,y,z} or rotation {pitch,yaw,roll}. After it returns, read the newest PNG in the editor's screenshots folder.", {
191
+ id: z.string().optional().describe("GUID of a GameObject to frame"),
192
+ position: z
193
+ .object({ x: z.number(), y: z.number(), z: z.number() })
194
+ .optional()
195
+ .describe("Camera world position (use instead of id)"),
196
+ lookAt: z
197
+ .object({ x: z.number(), y: z.number(), z: z.number() })
198
+ .optional()
199
+ .describe("World point to look at (pair with position)"),
200
+ rotation: z
201
+ .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
202
+ .optional()
203
+ .describe("Explicit camera rotation (pair with position)"),
204
+ width: z.number().int().optional().describe("Screenshot width (default 1920)"),
205
+ height: z.number().int().optional().describe("Screenshot height (default 1080)"),
206
+ }, async (params) => {
207
+ const res = await bridge.send("screenshot_from", params);
208
+ if (!res.success) {
209
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
210
+ }
211
+ return {
212
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
213
+ };
214
+ });
215
+ // ── console_run ──────────────────────────────────────────────────── (bridge)
216
+ server.tool("console_run", "Run an s&box console command / ConCmd via Sandbox.ConsoleSystem.Run — e.g. a cvar ('sv_cheats 1') or a registered command. Also the invocation primitive behind execute_csharp.", {
217
+ command: z.string().describe("The console command line to run"),
218
+ }, async (params) => {
219
+ const res = await bridge.send("console_run", params);
220
+ if (!res.success) {
221
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
222
+ }
223
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
224
+ });
225
+ // ── execute_csharp ───────────────────────────────────────────────── (orchestrated)
226
+ let execCounter = 0;
227
+ server.tool("execute_csharp", "EXPERIMENTAL. Compile + run a C# snippet inside the s&box EDITOR (which is unsandboxed): writes a temp [ConCmd] into the project's Editor/ folder, hotloads, runs it, reads the result/exception from the log, then deletes the temp file. With expression:true, returns the JSON value of a single expression; otherwise runs statements. CAVEATS: each call triggers a hotload (~2-8s) and recompiles the project's editor assembly; a snippet that fails to compile is reported + cleaned up, but briefly taints the editor assembly until cleanup.", {
228
+ code: z
229
+ .string()
230
+ .describe("C# to run (editor-context, unsandboxed). With expression:true, a single expression; otherwise statements."),
231
+ expression: z
232
+ .boolean()
233
+ .optional()
234
+ .describe("Treat code as an expression and return its JSON value (default false)"),
235
+ timeoutMs: z
236
+ .number()
237
+ .int()
238
+ .optional()
239
+ .describe("Max wait for compile + run, ms (default 20000)"),
240
+ }, async (params) => {
241
+ const id = `${Date.now().toString(36)}${++execCounter}`;
242
+ const cmd = `claude_exec_${id}`;
243
+ const filePath = `Editor/__Exec_${id}.cs`;
244
+ const marker = `[EXEC ${id}]`;
245
+ const inner = params.expression
246
+ ? `var __r = (${params.code});\n\t\t\tSandbox.Log.Info( "${marker} RESULT=" + System.Text.Json.JsonSerializer.Serialize( __r ) );`
247
+ : `${params.code}\n\t\t\tSandbox.Log.Info( "${marker} DONE" );`;
248
+ const cs = `using Editor;\nusing Sandbox;\nusing System;\n\n` +
249
+ `public static class __Exec_${id}\n{\n` +
250
+ `\t[ConCmd( "${cmd}" )]\n\tpublic static void Run()\n\t{\n` +
251
+ `\t\ttry\n\t\t{\n\t\t\t${inner}\n\t\t}\n` +
252
+ `\t\tcatch ( System.Exception __e ) { Sandbox.Log.Error( "${marker} ERROR=" + __e.Message ); }\n` +
253
+ `\t}\n}\n`;
254
+ const timeout = params.timeoutMs ?? 20000;
255
+ const wr = await bridge.send("write_file", { path: filePath, content: cs });
256
+ if (!wr.success) {
257
+ return { content: [{ type: "text", text: `execute_csharp: failed to write temp file: ${wr.error}` }] };
258
+ }
259
+ await bridge.send("trigger_hotload", {});
260
+ const { path: logPath } = locateSboxLog();
261
+ const startedAt = Date.now();
262
+ let found = null;
263
+ let compileErr = null;
264
+ while (Date.now() - startedAt < timeout) {
265
+ await new Promise((r) => setTimeout(r, 1500));
266
+ await bridge.send("console_run", { command: cmd });
267
+ if (logPath) {
268
+ try {
269
+ const txt = readFileSync(logPath, "utf-8");
270
+ const tail = txt.slice(Math.max(0, txt.length - 30000));
271
+ const mi = tail.lastIndexOf(marker);
272
+ if (mi >= 0) {
273
+ found = tail.slice(mi).split(/\r?\n/)[0];
274
+ break;
275
+ }
276
+ if (/__Exec_/.test(tail) && /(error CS\d+|Compile of .* Failed)/i.test(tail)) {
277
+ const errs = tail.split(/\r?\n/).filter((l) => /error CS\d+/i.test(l)).slice(-6).join("\n");
278
+ if (errs) {
279
+ compileErr = errs;
280
+ break;
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ /* log not ready yet */
286
+ }
287
+ }
288
+ }
289
+ // cleanup: remove the temp file + hotload back to a clean assembly
290
+ try {
291
+ await bridge.send("delete_script", { path: filePath });
292
+ }
293
+ catch {
294
+ /* best effort */
295
+ }
296
+ try {
297
+ await bridge.send("trigger_hotload", {});
298
+ }
299
+ catch {
300
+ /* best effort */
301
+ }
302
+ if (compileErr) {
303
+ return { content: [{ type: "text", text: `execute_csharp: compile error —\n${compileErr}` }] };
304
+ }
305
+ if (found) {
306
+ let out = found;
307
+ if (found.includes("RESULT="))
308
+ out = "RESULT = " + found.split("RESULT=")[1].trim();
309
+ else if (found.includes("ERROR="))
310
+ out = "Runtime exception: " + found.split("ERROR=")[1].trim();
311
+ else if (found.includes("DONE"))
312
+ out = "Executed (no return value).";
313
+ return { content: [{ type: "text", text: `execute_csharp ${id}: ${out}` }] };
314
+ }
315
+ return {
316
+ content: [
317
+ {
318
+ type: "text",
319
+ text: `execute_csharp ${id}: no result captured within ${timeout}ms — the snippet may still be compiling, or the log marker wasn't found. Try read_log with filter "${marker}".`,
320
+ },
321
+ ],
322
+ };
323
+ });
324
+ }
325
+ //# sourceMappingURL=diagnostics.js.map
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ export declare function registerDocsTools(server: McpServer, _bridge: BridgeClient): void;
4
+ //# sourceMappingURL=docs.d.ts.map
@@ -0,0 +1,334 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Documentation tools (Batch 25 — "let Claude read the real s&box docs"): search
4
+ * and fetch the official s&box guide documentation so Claude can ground itself in
5
+ * Facepunch's own pages instead of guessing from possibly-stale training data.
6
+ *
7
+ * The s&box guide docs live in the PUBLIC GitHub repo Facepunch/sbox-docs
8
+ * (branch master): ~225 Markdown pages under docs/<category>/.../<page>.md, each
9
+ * starting with YAML frontmatter (title, icon, created, updated).
10
+ *
11
+ * Two HTTP sources, both fetched with Node 18+ built-in fetch (no npm deps):
12
+ * 1. INDEX (once, cached in memory): the GitHub git-tree API lists every file
13
+ * in the repo. Requires a User-Agent header or GitHub returns 403. Honors an
14
+ * optional GITHUB_TOKEN env var for a higher rate limit (never required).
15
+ * 2. PAGE CONTENT: raw.githubusercontent.com serves the Markdown as text/plain
16
+ * with no rate limit and no UA needed.
17
+ *
18
+ * Per page we derive:
19
+ * - category: the 2nd path segment (docs/networking/rpcs.md -> "networking")
20
+ * - slug: the path with leading "docs/" and trailing ".md" removed
21
+ * - webUrl: https://sbox.game/dev/doc/<slug> (a trailing "/index" is stripped)
22
+ *
23
+ * All three tools are read-only and never throw — fetch failures are caught and
24
+ * surfaced as a plain text error in the tool result.
25
+ */
26
+ /** GitHub git-tree API for the docs repo (lists every file, recursive). */
27
+ const TREE_URL = "https://api.github.com/repos/Facepunch/sbox-docs/git/trees/master?recursive=1";
28
+ /** Base for raw Markdown page content (text/plain, no rate limit). */
29
+ const RAW_BASE = "https://raw.githubusercontent.com/Facepunch/sbox-docs/master/";
30
+ /** Public web home for a rendered doc page. */
31
+ const WEB_BASE = "https://sbox.game/dev/doc/";
32
+ /** Module-level cache so the index is fetched at most once per ~30 min. */
33
+ let indexCache = null;
34
+ let indexFetchedAt = 0;
35
+ /** Re-fetch the index if it is older than this (handles long-lived processes). */
36
+ const INDEX_TTL_MS = 30 * 60 * 1000;
37
+ /** Compute slug/category/webUrl for a repo-relative docs path. */
38
+ function buildEntry(path) {
39
+ // "docs/networking/rpcs.md" -> "networking/rpcs"
40
+ const slug = path.replace(/^docs\//, "").replace(/\.md$/i, "");
41
+ const segments = path.split("/");
42
+ // path[0] is "docs"; the category is the next segment (if any).
43
+ const category = segments.length > 2 ? segments[1] : "";
44
+ // A trailing "/index" doesn't appear in the public web URL.
45
+ const webSlug = slug.replace(/\/index$/i, "");
46
+ return { path, slug, category, webUrl: WEB_BASE + webSlug };
47
+ }
48
+ /**
49
+ * Ensure the doc index is loaded (fetching + building it once), reusing the
50
+ * module-level cache. Returns null on any fetch/parse failure so callers can
51
+ * report a clean error. Never throws.
52
+ */
53
+ async function getIndex() {
54
+ const now = Date.now();
55
+ if (indexCache && now - indexFetchedAt < INDEX_TTL_MS) {
56
+ return indexCache;
57
+ }
58
+ try {
59
+ const headers = {
60
+ // GitHub API rejects requests without a User-Agent (403).
61
+ "User-Agent": "sbox-mcp-server",
62
+ Accept: "application/vnd.github+json",
63
+ };
64
+ const token = process.env.GITHUB_TOKEN;
65
+ if (token)
66
+ headers["Authorization"] = `Bearer ${token}`;
67
+ const res = await fetch(TREE_URL, { headers });
68
+ if (!res.ok) {
69
+ return null;
70
+ }
71
+ const json = await res.json();
72
+ const tree = Array.isArray(json?.tree) ? json.tree : [];
73
+ const entries = [];
74
+ for (const node of tree) {
75
+ const p = node?.path ?? "";
76
+ // Keep only Markdown pages under docs/.
77
+ if (node?.type === "blob" &&
78
+ p.startsWith("docs/") &&
79
+ /\.md$/i.test(p)) {
80
+ entries.push(buildEntry(p));
81
+ }
82
+ }
83
+ if (entries.length === 0) {
84
+ // Empty tree almost certainly means an API hiccup, not a real result.
85
+ return null;
86
+ }
87
+ indexCache = entries;
88
+ indexFetchedAt = now;
89
+ return indexCache;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ /** Tokenize a query into lowercased, non-empty words. */
96
+ function tokenize(s) {
97
+ return s
98
+ .toLowerCase()
99
+ .split(/[^a-z0-9]+/i)
100
+ .filter((t) => t.length > 0);
101
+ }
102
+ /** Strip the leading YAML frontmatter block from a Markdown document. */
103
+ function stripFrontmatter(md) {
104
+ return md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
105
+ }
106
+ /** Pull the `title:` value out of YAML frontmatter, if present. */
107
+ function parseTitle(md) {
108
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
109
+ if (!fm)
110
+ return null;
111
+ const line = fm[1].match(/^\s*title\s*:\s*(.+?)\s*$/m);
112
+ if (!line)
113
+ return null;
114
+ // Drop surrounding quotes if the title was quoted.
115
+ return line[1].replace(/^["']|["']$/g, "").trim() || null;
116
+ }
117
+ /** Derive a human title for an entry without fetching its content. */
118
+ function titleFromSlug(slug) {
119
+ const last = slug.split("/").pop() ?? slug;
120
+ return last.replace(/[-_]/g, " ");
121
+ }
122
+ export function registerDocsTools(server, _bridge) {
123
+ // ── search_docs ────────────────────────────────────────────────────
124
+ server.tool("search_docs", "Search the official s&box guide documentation (the Facepunch/sbox-docs pages rendered at sbox.game/dev/doc) by keyword. Returns the best-matching pages with their title, category, slug, and web URL — use the slug with get_doc_page to read one. The s&box API changes between SDK versions, so prefer these real docs over guessing. Read-only.", {
125
+ query: z
126
+ .string()
127
+ .describe("Keywords to match against page titles, slugs, and paths"),
128
+ category: z
129
+ .string()
130
+ .optional()
131
+ .describe('Optional category filter (the 2nd path segment, e.g. "networking", "ui"). Use list_doc_categories to see them.'),
132
+ limit: z
133
+ .number()
134
+ .int()
135
+ .optional()
136
+ .describe("Max results to return (default 10, max 50)"),
137
+ }, async (params) => {
138
+ const index = await getIndex();
139
+ if (!index) {
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text",
144
+ text: "Error: couldn't load the s&box docs index from GitHub (network error or rate limit). Try again shortly; set the GITHUB_TOKEN environment variable to raise the rate limit.",
145
+ },
146
+ ],
147
+ };
148
+ }
149
+ let limit = params.limit ?? 10;
150
+ if (limit < 1)
151
+ limit = 1;
152
+ if (limit > 50)
153
+ limit = 50;
154
+ const cat = params.category?.toLowerCase().trim();
155
+ let pages = index;
156
+ if (cat) {
157
+ pages = pages.filter((p) => p.category.toLowerCase() === cat);
158
+ }
159
+ const tokens = tokenize(params.query);
160
+ const scored = pages
161
+ .map((p) => {
162
+ const title = titleFromSlug(p.slug).toLowerCase();
163
+ const slug = p.slug.toLowerCase();
164
+ const path = p.path.toLowerCase();
165
+ let score = 0;
166
+ for (const t of tokens) {
167
+ // Title and slug are the strongest signals; path is a weak fallback.
168
+ if (title.includes(t))
169
+ score += 5;
170
+ if (slug.includes(t))
171
+ score += 4;
172
+ if (path.includes(t))
173
+ score += 1;
174
+ }
175
+ return { p, score };
176
+ })
177
+ .filter((s) => s.score > 0)
178
+ .sort((a, b) => b.score - a.score)
179
+ .slice(0, limit);
180
+ if (scored.length === 0) {
181
+ return {
182
+ content: [
183
+ {
184
+ type: "text",
185
+ text: `No s&box docs matched "${params.query}"${cat ? ` in category "${params.category}"` : ""}. Try broader keywords, or call list_doc_categories.`,
186
+ },
187
+ ],
188
+ };
189
+ }
190
+ const results = scored.map(({ p }) => ({
191
+ title: titleFromSlug(p.slug),
192
+ category: p.category,
193
+ slug: p.slug,
194
+ webUrl: p.webUrl,
195
+ }));
196
+ return {
197
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
198
+ };
199
+ });
200
+ // ── get_doc_page ───────────────────────────────────────────────────
201
+ server.tool("get_doc_page", "Fetch the full Markdown of one s&box guide page (from search_docs). Pass a slug like \"networking/rpcs\", a raw repo path like \"docs/networking/rpcs.md\", or a full sbox.game/dev/doc URL. YAML frontmatter is stripped. Long pages are paginated: pass chunk to read further (the footer tells you when there's more). Read-only.", {
202
+ slug: z
203
+ .string()
204
+ .describe('Page slug (e.g. "networking/rpcs"), a "docs/...md" path, or a full sbox.game/dev/doc URL'),
205
+ chunk: z
206
+ .number()
207
+ .int()
208
+ .optional()
209
+ .describe("1-based chunk index for long pages (default 1)"),
210
+ chunkSize: z
211
+ .number()
212
+ .int()
213
+ .optional()
214
+ .describe("Max characters per chunk (default 8000)"),
215
+ }, async (params) => {
216
+ const index = await getIndex();
217
+ if (!index) {
218
+ return {
219
+ content: [
220
+ {
221
+ type: "text",
222
+ text: "Error: couldn't load the s&box docs index from GitHub (network error or rate limit). Try again shortly; set the GITHUB_TOKEN environment variable to raise the rate limit.",
223
+ },
224
+ ],
225
+ };
226
+ }
227
+ // Normalize the input into a slug: accept a full web URL, a raw repo path,
228
+ // or a bare slug.
229
+ let raw = params.slug.trim();
230
+ if (raw.startsWith("http")) {
231
+ const idx = raw.indexOf("/dev/doc/");
232
+ if (idx >= 0)
233
+ raw = raw.slice(idx + "/dev/doc/".length);
234
+ }
235
+ // Strip a leading "docs/" and a trailing ".md" if a path was passed.
236
+ const wanted = raw
237
+ .replace(/^\/+/, "")
238
+ .replace(/^docs\//, "")
239
+ .replace(/\.md$/i, "")
240
+ .replace(/\/+$/, "");
241
+ // Resolve against the index: exact slug, then a slug that points at the
242
+ // page's index file (e.g. "networking" -> "networking/index").
243
+ const entry = index.find((p) => p.slug.toLowerCase() === wanted.toLowerCase()) ??
244
+ index.find((p) => p.slug.toLowerCase() === `${wanted.toLowerCase()}/index`);
245
+ if (!entry) {
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: `Error: no s&box doc page matched "${params.slug}". Use search_docs to find a valid slug.`,
251
+ },
252
+ ],
253
+ };
254
+ }
255
+ let md;
256
+ try {
257
+ const res = await fetch(RAW_BASE + entry.path);
258
+ if (!res.ok) {
259
+ return {
260
+ content: [
261
+ {
262
+ type: "text",
263
+ text: `Error: failed to fetch ${entry.path} (HTTP ${res.status}).`,
264
+ },
265
+ ],
266
+ };
267
+ }
268
+ md = await res.text();
269
+ }
270
+ catch (e) {
271
+ return {
272
+ content: [
273
+ {
274
+ type: "text",
275
+ text: `Error fetching ${entry.path}: ${e.message}`,
276
+ },
277
+ ],
278
+ };
279
+ }
280
+ const title = parseTitle(md) ?? titleFromSlug(entry.slug);
281
+ const body = stripFrontmatter(md);
282
+ const header = `# ${title}\nSource: ${entry.webUrl}\n\n`;
283
+ let chunkSize = params.chunkSize ?? 8000;
284
+ if (chunkSize < 500)
285
+ chunkSize = 500;
286
+ // Short pages: return the whole body in one shot.
287
+ if (body.length <= chunkSize) {
288
+ return { content: [{ type: "text", text: header + body }] };
289
+ }
290
+ // Long pages: slice into chunks and return the requested 1-based chunk.
291
+ const total = Math.ceil(body.length / chunkSize);
292
+ let i = params.chunk ?? 1;
293
+ if (i < 1)
294
+ i = 1;
295
+ if (i > total)
296
+ i = total;
297
+ const start = (i - 1) * chunkSize;
298
+ const slice = body.slice(start, start + chunkSize);
299
+ let footer = `\n\n— chunk ${i}/${total}`;
300
+ if (i < total) {
301
+ footer += `; call get_doc_page again with chunk=${i + 1} for more —`;
302
+ }
303
+ else {
304
+ footer += " (end) —";
305
+ }
306
+ return { content: [{ type: "text", text: header + slice + footer }] };
307
+ });
308
+ // ── list_doc_categories ────────────────────────────────────────────
309
+ server.tool("list_doc_categories", "List the categories of the official s&box guide documentation, with how many pages each has. Use a category name to filter search_docs. Read-only.", {}, async () => {
310
+ const index = await getIndex();
311
+ if (!index) {
312
+ return {
313
+ content: [
314
+ {
315
+ type: "text",
316
+ text: "Error: couldn't load the s&box docs index from GitHub (network error or rate limit). Try again shortly; set the GITHUB_TOKEN environment variable to raise the rate limit.",
317
+ },
318
+ ],
319
+ };
320
+ }
321
+ const counts = new Map();
322
+ for (const p of index) {
323
+ const key = p.category || "(root)";
324
+ counts.set(key, (counts.get(key) ?? 0) + 1);
325
+ }
326
+ const result = Array.from(counts.entries())
327
+ .map(([category, pageCount]) => ({ category, pageCount }))
328
+ .sort((a, b) => b.pageCount - a.pageCount);
329
+ return {
330
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
331
+ };
332
+ });
333
+ }
334
+ //# sourceMappingURL=docs.js.map
@@ -0,0 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ export declare function registerNavigationTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=navigation.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Navigation tools (Batch 27) — REAL editor operations, not component wrappers.
4
+ *
5
+ * bake_navmesh runs the editor's static NavMesh bake (NavMesh.BakeNavMesh) so
6
+ * NavMeshAgents can pathfind; get_navmesh_path queries the baked mesh for a
7
+ * route (NavMesh.GetSimplePath). Neither is reachable via add_component — they
8
+ * operate on the scene's navmesh itself, which is why they earn dedicated tools.
9
+ */
10
+ const Vec3 = z
11
+ .object({
12
+ x: z.number().describe("X"),
13
+ y: z.number().describe("Y"),
14
+ z: z.number().describe("Z"),
15
+ })
16
+ .describe("A world-space Vector3");
17
+ export function registerNavigationTools(server, bridge) {
18
+ // ── bake_navmesh ────────────────────────────────────────────────────
19
+ server.tool("bake_navmesh", "Enable + bake the active scene's navigation mesh so NavMeshAgents can pathfind. This is an editor operation (NavMesh.BakeNavMesh), not a component. The bake runs ASYNC — it returns immediately with baking:true; the editor shows a progress bar and isGenerating flips false when done (give it a moment before querying paths). Optional agent params let you size the mesh to your characters.", {
20
+ agentRadius: z.number().optional().describe("Agent radius (default scene setting)"),
21
+ agentHeight: z.number().optional().describe("Agent height"),
22
+ agentStepSize: z.number().optional().describe("Max step-up height"),
23
+ agentMaxSlope: z.number().optional().describe("Max walkable slope, degrees"),
24
+ includeStaticBodies: z
25
+ .boolean()
26
+ .optional()
27
+ .describe("Include static physics bodies in the bake"),
28
+ }, async (params) => {
29
+ const res = await bridge.send("bake_navmesh", params);
30
+ if (!res.success) {
31
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
32
+ }
33
+ return {
34
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
35
+ };
36
+ });
37
+ // ── get_navmesh_path ────────────────────────────────────────────────
38
+ server.tool("get_navmesh_path", "Query the baked navmesh for a walkable path between two world points (NavMesh.GetSimplePath). Returns the ordered path points, or reachable:false if no route exists. Requires bake_navmesh to have run first. Read-only — useful for validating connectivity, AI patrol routes, and spawn reachability.", {
39
+ from: Vec3.describe("Start point (world space)"),
40
+ to: Vec3.describe("Destination point (world space)"),
41
+ }, async (params) => {
42
+ const res = await bridge.send("get_navmesh_path", params);
43
+ if (!res.success) {
44
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
45
+ }
46
+ return {
47
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
48
+ };
49
+ });
50
+ }
51
+ //# sourceMappingURL=navigation.js.map
@@ -100,23 +100,25 @@ export function registerNetworkingTools(server, bridge) {
100
100
  };
101
101
  });
102
102
  // ── add_sync_property ─────────────────────────────────────────────
103
- server.tool("add_sync_property", "Add a [Sync] networked property to a C# script. The property auto-replicates across the network", {
103
+ server.tool("add_sync_property", "Annotate an EXISTING public property in a C# script with the [Sync] attribute so s&box replicates it across the network. This does NOT create a new property — the property named by `propertyName` must already be declared in the file; the tool only inserts the [Sync] attribute above it", {
104
104
  path: z
105
105
  .string()
106
106
  .describe("Relative path to the script file (e.g. 'code/Player.cs')"),
107
- propertyName: z.string().describe("Name for the new property"),
107
+ propertyName: z
108
+ .string()
109
+ .describe("Name of the existing public property to annotate with [Sync]"),
108
110
  propertyType: z
109
111
  .string()
110
112
  .optional()
111
- .describe("C# type (float, int, string, Vector3, bool, etc.). Defaults to 'float'"),
113
+ .describe("Currently ignored not yet implemented. The addon only annotates an existing property; it does not declare a new one, so the type comes from the existing declaration"),
112
114
  syncFlags: z
113
115
  .string()
114
116
  .optional()
115
- .describe("Sync flags: 'FromHost' (host→clients only). Omit for bidirectional"),
117
+ .describe("Currently ignored not yet implemented. The addon emits a plain [Sync] with no SyncFlags"),
116
118
  defaultValue: z
117
119
  .string()
118
120
  .optional()
119
- .describe("Default value expression (e.g. '100f', 'true', 'Vector3.Zero')"),
121
+ .describe("Currently ignored not yet implemented. The addon does not create or initialize a property, so no default is written"),
120
122
  }, async (params) => {
121
123
  const res = await bridge.send("add_sync_property", params);
122
124
  if (!res.success) {
@@ -127,7 +129,7 @@ export function registerNetworkingTools(server, bridge) {
127
129
  };
128
130
  });
129
131
  // ── add_rpc_method ────────────────────────────────────────────────
130
- server.tool("add_rpc_method", "Add an RPC method to a C# script. Supports [Rpc.Broadcast] (all clients), [Rpc.Host] (host only), [Rpc.Owner] (owner only)", {
132
+ server.tool("add_rpc_method", "Generate an EMPTY, no-argument RPC method stub in a C# script for you to fill in. The addon inserts the chosen RPC attribute ([Rpc.Broadcast] all clients, [Rpc.Host] host only, [Rpc.Owner] owner only) above a parameterless method with an empty body — it does NOT add parameters or generate body code", {
131
133
  path: z
132
134
  .string()
133
135
  .describe("Relative path to the script file"),
@@ -139,11 +141,11 @@ export function registerNetworkingTools(server, bridge) {
139
141
  methodParams: z
140
142
  .string()
141
143
  .optional()
142
- .describe("Method parameters as C# signature (e.g. 'float damage, Vector3 hitPos')"),
144
+ .describe("Currently ignored not yet implemented. The addon emits a no-argument method; add parameters yourself afterward"),
143
145
  body: z
144
146
  .string()
145
147
  .optional()
146
- .describe("Method body code (without braces). Defaults to a log statement"),
148
+ .describe("Currently ignored not yet implemented. The addon emits an empty method body; fill it in yourself afterward"),
147
149
  }, async (params) => {
148
150
  const res = await bridge.send("add_rpc_method", params);
149
151
  if (!res.success) {
@@ -76,5 +76,31 @@ export function registerObjectTools(server, bridge) {
76
76
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
77
77
  };
78
78
  });
79
+ // ── remove_component ───────────────────────────────────────────────
80
+ server.tool("remove_component", "Remove a component from a GameObject by type name (e.g. 'PointLight', 'ModelRenderer'). Removes the first match; pass all:true to remove every matching one. The counterpart to add_component_with_properties.", {
81
+ id: z.string().describe("GUID of the GameObject"),
82
+ component: z.string().describe("Component type name to remove"),
83
+ all: z.boolean().optional().describe("Remove all matching components, not just the first"),
84
+ }, async (params) => {
85
+ const res = await bridge.send("remove_component", params);
86
+ if (!res.success) {
87
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
88
+ }
89
+ return {
90
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
91
+ };
92
+ });
93
+ // ── get_tags ───────────────────────────────────────────────────────
94
+ server.tool("get_tags", "Read the tags currently on a GameObject. (Pair with set_tags to add/remove/clear, and find_objects to query by tag.)", {
95
+ id: z.string().describe("GUID of the GameObject"),
96
+ }, async (params) => {
97
+ const res = await bridge.send("get_tags", params);
98
+ if (!res.success) {
99
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
100
+ }
101
+ return {
102
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
103
+ };
104
+ });
79
105
  }
80
106
  //# sourceMappingURL=objecttools.js.map
@@ -126,5 +126,27 @@ export function registerPhysicsTools(server, bridge) {
126
126
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
127
127
  };
128
128
  });
129
+ // ── physics_overlap ───────────────────────────────────────────────
130
+ server.tool("physics_overlap", "Spatial volume query: return the GameObjects whose colliders intersect a SPHERE (center + radius) or a BOX (center + size) — the volume counterpart to raycast's ray. Use it for 'what's near this point' / 'what's inside this trigger volume' checks (proximity, blast radius, spawn-clearance). Read-only.", {
131
+ center: z
132
+ .object({ x: z.number(), y: z.number(), z: z.number() })
133
+ .describe("Center of the query volume (world space)"),
134
+ radius: z
135
+ .number()
136
+ .optional()
137
+ .describe("Sphere radius. Provide this OR size (box), not both"),
138
+ size: z
139
+ .object({ x: z.number(), y: z.number(), z: z.number() })
140
+ .optional()
141
+ .describe("Full box size (not half-extents). Provide this OR radius"),
142
+ }, async (params) => {
143
+ const res = await bridge.send("physics_overlap", params);
144
+ if (!res.success) {
145
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
146
+ }
147
+ return {
148
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
149
+ };
150
+ });
129
151
  }
130
152
  //# sourceMappingURL=physics.js.map
@@ -74,8 +74,18 @@ export function registerProjectTools(server, bridge) {
74
74
  content: z.string().describe("The full file content to write"),
75
75
  }, async (params) => {
76
76
  const res = await bridge.send("write_file", params);
77
- if (!res.success) {
78
- return { content: [{ type: "text", text: `Error: ${res.error}` }] };
77
+ // The handler can report a logical failure inside res.data even when the
78
+ // transport call "succeeded" (res.success === true). Only claim success
79
+ // when there is no error field anywhere; otherwise surface the real error
80
+ // instead of a misleading "written successfully".
81
+ const dataError = res.data && typeof res.data === "object"
82
+ ? res.data.error
83
+ : undefined;
84
+ if (!res.success || dataError) {
85
+ const message = res.error ?? (dataError ? String(dataError) : undefined);
86
+ return {
87
+ content: [{ type: "text", text: `Error: ${message ?? "write_file failed"}` }],
88
+ };
79
89
  }
80
90
  return {
81
91
  content: [
@@ -255,5 +255,35 @@ export function registerVisualTools(server, bridge) {
255
255
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
256
256
  };
257
257
  });
258
+ // ── spawn_vpcf ──────────────────────────────────────────────────────
259
+ server.tool("spawn_vpcf", "Spawn a REAL particle system by playing a compiled .vpcf asset through LegacyParticleSystem — the reliable path that actually renders, unlike spawn_particle/create_particle_effect (which build a runtime ParticleEffect graph that shows nothing). Defaults to 'particles/impact.generic.vpcf' (a sparks/impact burst — the only particle .vpcf reliably present; set looped + a warm tint for a fire-ish effect). Pass your own compiled .vpcf logical path if you have one. Screenshot-verifiable in edit mode.", {
260
+ vpcf: z
261
+ .string()
262
+ .optional()
263
+ .describe("Logical .vpcf path (default 'particles/impact.generic.vpcf'). NOT the .vpcf_c or .sbox/cloud cache path."),
264
+ position: Vector3Schema.optional().describe("World position (default origin)"),
265
+ name: z.string().optional().describe("GameObject name"),
266
+ looped: z.boolean().optional().describe("Loop the effect (default true)"),
267
+ playbackSpeed: z.number().optional().describe("Playback speed multiplier"),
268
+ tint: ColorSchema.optional().describe("Color tint (e.g. orange for fire); applied to the live SceneObject if it's ready this frame"),
269
+ }, async (params) => {
270
+ const res = await bridge.send("spawn_vpcf", params);
271
+ if (!res.success) {
272
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
273
+ }
274
+ return {
275
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
276
+ };
277
+ });
278
+ // ── bake_reflections ────────────────────────────────────────────────
279
+ server.tool("bake_reflections", "Bake all EnvmapProbe reflection probes in the scene (EnvmapProbe.BakeAll) so they actually capture their surroundings — placing a probe with add_envmap_probe does nothing visible until it's baked. This is a real editor compute step, not a component setter. Runs async; re-screenshot after a moment to see reflections appear on shiny surfaces.", {}, async (params) => {
280
+ const res = await bridge.send("bake_reflections", params);
281
+ if (!res.success) {
282
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
283
+ }
284
+ return {
285
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
286
+ };
287
+ });
258
288
  }
259
289
  //# sourceMappingURL=visuals.js.map
@@ -6,7 +6,10 @@ import * as os from "os";
6
6
  *
7
7
  * There is NO socket. Despite the legacy `host`/`port` fields, communication is
8
8
  * entirely through a shared temp directory:
9
- * - MCP server writes request files (req_*.json)
9
+ * - MCP server writes request files (req_*.json) atomically — it writes
10
+ * req_*.json.tmp first, then renames it into place so the addon can never
11
+ * read a half-written request. The addon consumes only req_*.json and
12
+ * ignores *.tmp.
10
13
  * - s&box addon polls for them, processes on the main editor thread, and writes
11
14
  * response files (res_*.json)
12
15
  * - MCP server polls for response files
@@ -167,13 +170,24 @@ export class BridgeClient {
167
170
  if (!fs.existsSync(this.ipcDir)) {
168
171
  fs.mkdirSync(this.ipcDir, { recursive: true });
169
172
  }
170
- // Write request file
173
+ // Write request file atomically: write to a .tmp sibling, then rename into
174
+ // place. The C# poller only consumes `req_*.json` (it ignores `*.tmp`), so
175
+ // it can never observe a half-written request for a large payload — the
176
+ // rename is atomic on the same volume.
171
177
  const reqPath = path.join(this.ipcDir, `req_${id}.json`);
178
+ const tmpPath = `${reqPath}.tmp`;
172
179
  const resPath = path.join(this.ipcDir, `res_${id}.json`);
173
180
  try {
174
- fs.writeFileSync(reqPath, JSON.stringify(request), "utf8");
181
+ fs.writeFileSync(tmpPath, JSON.stringify(request), "utf8");
182
+ fs.renameSync(tmpPath, reqPath);
175
183
  }
176
184
  catch (err) {
185
+ // Best-effort cleanup of a partial temp file so it doesn't linger.
186
+ try {
187
+ if (fs.existsSync(tmpPath))
188
+ fs.unlinkSync(tmpPath);
189
+ }
190
+ catch { }
177
191
  return {
178
192
  id,
179
193
  success: false,
@@ -245,11 +259,20 @@ export class BridgeClient {
245
259
  if (!fs.existsSync(this.ipcDir)) {
246
260
  fs.mkdirSync(this.ipcDir, { recursive: true });
247
261
  }
262
+ // Write atomically (temp + rename) so the C# poller never reads a partial
263
+ // batch request file. See the note in send() above.
248
264
  const reqPath = path.join(this.ipcDir, `req_${id}.json`);
265
+ const tmpPath = `${reqPath}.tmp`;
249
266
  try {
250
- fs.writeFileSync(reqPath, JSON.stringify(request), "utf8");
267
+ fs.writeFileSync(tmpPath, JSON.stringify(request), "utf8");
268
+ fs.renameSync(tmpPath, reqPath);
251
269
  }
252
270
  catch (err) {
271
+ try {
272
+ if (fs.existsSync(tmpPath))
273
+ fs.unlinkSync(tmpPath);
274
+ }
275
+ catch { }
253
276
  return {
254
277
  id,
255
278
  success: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbox-mcp-server",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",