sbox-mcp-server 1.3.2 → 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.
@@ -0,0 +1,289 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Visual & atmosphere tools (Batch 17): lighting, post-processing, fog, sky,
4
+ * reflection probes, and compose-it-all presets.
5
+ *
6
+ * These wrap s&box's visual components with sensible parameters so Claude can
7
+ * author scene mood directly, instead of hand-driving add_component_with_properties
8
+ * (which can't even set a Color). After any change, screenshot the scene and read
9
+ * the result — this layer is where the screenshot loop matters most.
10
+ */
11
+ const ColorSchema = z
12
+ .object({
13
+ r: z.number().min(0).describe("Red, 0-1"),
14
+ g: z.number().min(0).describe("Green, 0-1"),
15
+ b: z.number().min(0).describe("Blue, 0-1"),
16
+ a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
17
+ })
18
+ .describe("RGBA colour as 0-1 floats");
19
+ const Vector3Schema = z
20
+ .object({ x: z.number(), y: z.number(), z: z.number() })
21
+ .describe("World position {x,y,z}");
22
+ const RotationSchema = z
23
+ .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
24
+ .describe("Rotation {pitch,yaw,roll} in degrees");
25
+ export function registerVisualTools(server, bridge) {
26
+ // ── add_light ──────────────────────────────────────────────────────
27
+ server.tool("add_light", "Add a light to the active scene. NOTE: s&box lights have no separate brightness field — intensity is the colour magnitude, so 'brightness' scales the colour (use >1 for bright/HDR). Types: directional = sun (aim it with rotation), point = omni-directional (range), spot = cone (range + coneInner/coneOuter degrees), ambient = global fill light.", {
28
+ type: z
29
+ .enum(["directional", "point", "spot", "ambient"])
30
+ .describe("Light type"),
31
+ name: z.string().optional().describe("GameObject name"),
32
+ color: ColorSchema.optional().describe("Light colour (default white)"),
33
+ brightness: z
34
+ .number()
35
+ .optional()
36
+ .describe("Intensity multiplier on the colour (default 1; try 2-10 for point/spot)"),
37
+ range: z
38
+ .number()
39
+ .optional()
40
+ .describe("point/spot only: falloff radius in units (maps to Radius)"),
41
+ coneInner: z
42
+ .number()
43
+ .optional()
44
+ .describe("spot only: inner cone angle in degrees"),
45
+ coneOuter: z
46
+ .number()
47
+ .optional()
48
+ .describe("spot only: outer cone angle in degrees"),
49
+ shadows: z.boolean().optional().describe("Cast shadows (default true)"),
50
+ skyColor: ColorSchema.optional().describe("directional only: ambient sky colour for the upper hemisphere"),
51
+ position: Vector3Schema.optional().describe("World position"),
52
+ rotation: RotationSchema.optional().describe("World rotation — sets the aim direction for directional/spot lights"),
53
+ parentId: z.string().optional().describe("GUID of a parent GameObject"),
54
+ }, async (params) => {
55
+ const res = await bridge.send("add_light", params);
56
+ if (!res.success) {
57
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
58
+ }
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
61
+ };
62
+ });
63
+ // ── set_fog ────────────────────────────────────────────────────────
64
+ server.tool("set_fog", "Add or update distance fog in the active scene. v1 supports gradient fog (atmospheric distance haze — great for mood/horror). Re-running on the same target updates it rather than duplicating.", {
65
+ type: z
66
+ .enum(["gradient"])
67
+ .optional()
68
+ .describe("Fog type (default gradient; cubemap/volumetric coming later)"),
69
+ name: z.string().optional().describe("GameObject name when creating a new fog object"),
70
+ targetId: z
71
+ .string()
72
+ .optional()
73
+ .describe("GUID of an existing GameObject to host the fog (else a new 'Gradient Fog' object is created)"),
74
+ color: ColorSchema.optional().describe("Fog colour"),
75
+ startDistance: z
76
+ .number()
77
+ .optional()
78
+ .describe("Distance (units) where fog begins"),
79
+ endDistance: z
80
+ .number()
81
+ .optional()
82
+ .describe("Distance (units) where fog reaches full density"),
83
+ height: z.number().optional().describe("World height the fog settles around"),
84
+ falloff: z
85
+ .number()
86
+ .optional()
87
+ .describe("Distance falloff exponent (higher = sharper onset)"),
88
+ }, async (params) => {
89
+ const res = await bridge.send("set_fog", params);
90
+ if (!res.success) {
91
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
92
+ }
93
+ return {
94
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
95
+ };
96
+ });
97
+ // ── add_post_process ─────────────────────────────────────────────────
98
+ server.tool("add_post_process", "Add (or update) a post-processing effect on the scene's main camera (auto-enables post-processing). Generic: pass the effect component name + any of its properties. Examples — Bloom {Strength, Threshold, Tint}, Tonemapping, ColorAdjustments {Saturation, Brightness, Contrast}, Vignette {Intensity, Color}, FilmGrain, DepthOfField, ChromaticAberration, MotionBlur, Sharpen, AmbientOcclusion. Call describe_type <Effect> to discover a given effect's properties.", {
99
+ effect: z
100
+ .string()
101
+ .describe("Post-process component type name, e.g. 'Bloom', 'Vignette', 'ColorAdjustments'"),
102
+ properties: z
103
+ .record(z.any())
104
+ .optional()
105
+ .describe("Property name -> value. Floats/ints/bools as numbers/bools, colours as {r,g,b,a}, enums as their string name."),
106
+ cameraId: z
107
+ .string()
108
+ .optional()
109
+ .describe("GUID of a specific camera GameObject (default: the scene's main camera)"),
110
+ }, async (params) => {
111
+ const res = await bridge.send("add_post_process", params);
112
+ if (!res.success) {
113
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
114
+ }
115
+ return {
116
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
117
+ };
118
+ });
119
+ // ── set_skybox ───────────────────────────────────────────────────────
120
+ server.tool("set_skybox", "Set the scene's 2D skybox tint / indirect lighting (re-uses an existing SkyBox2D or creates one). Darken the tint for night/dusk. Optionally point it at a .vmat sky material.", {
121
+ tint: ColorSchema.optional().describe("Sky tint colour"),
122
+ indirectLighting: z
123
+ .boolean()
124
+ .optional()
125
+ .describe("Whether the sky contributes indirect/ambient light"),
126
+ material: z.string().optional().describe("Path to a .vmat sky material (optional)"),
127
+ name: z.string().optional().describe("Name for the sky GameObject if one is created"),
128
+ }, async (params) => {
129
+ const res = await bridge.send("set_skybox", params);
130
+ if (!res.success) {
131
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
132
+ }
133
+ return {
134
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
135
+ };
136
+ });
137
+ // ── apply_atmosphere (preset) ─────────────────────────────────────────
138
+ server.tool("apply_atmosphere", "One-call scene mood: composes ambient + directional light, gradient fog, and a camera post-fx stack (tonemap + colour grade + vignette) tuned for the chosen mood. Idempotent — re-runs update the same 'Atmosphere *' objects.", {
139
+ mood: z
140
+ .enum(["horror-night", "foggy-dawn", "overcast", "warm-interior"])
141
+ .describe("Atmosphere preset"),
142
+ }, async (params) => {
143
+ const res = await bridge.send("apply_atmosphere", 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
+ });
151
+ // ── apply_post_fx_look (preset) ───────────────────────────────────────
152
+ server.tool("apply_post_fx_look", "Apply just a camera post-processing look (no lights/fog): cinematic (tonemap + bloom + soft vignette), filmic-horror (desaturated, high-contrast, heavy vignette, film grain), or clean (tonemap only).", {
153
+ look: z
154
+ .enum(["cinematic", "filmic-horror", "clean"])
155
+ .describe("Post-fx look preset"),
156
+ }, async (params) => {
157
+ const res = await bridge.send("apply_post_fx_look", params);
158
+ if (!res.success) {
159
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
160
+ }
161
+ return {
162
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
163
+ };
164
+ });
165
+ // ── add_envmap_probe ─────────────────────────────────────────────────
166
+ server.tool("add_envmap_probe", "Add an environment reflection/ambient probe (EnvmapProbe) at a position with a cubic influence volume — captures local reflections and indirect light for nearby surfaces.", {
167
+ name: z.string().optional().describe("GameObject name"),
168
+ position: Vector3Schema.optional().describe("World position (centre of the probe)"),
169
+ size: z.number().optional().describe("Cubic influence size in units (default 1024)"),
170
+ tint: ColorSchema.optional().describe("Tint applied to the captured environment"),
171
+ feathering: z
172
+ .number()
173
+ .optional()
174
+ .describe("Edge feathering 0-1 for blending between overlapping probes"),
175
+ }, async (params) => {
176
+ const res = await bridge.send("add_envmap_probe", params);
177
+ if (!res.success) {
178
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
179
+ }
180
+ return {
181
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
182
+ };
183
+ });
184
+ // ── spawn_particle (Batch 18: VFX) ───────────────────────────────────
185
+ server.tool("spawn_particle", "Spawn an additive particle effect (no texture asset needed): kind = fire (rising flame), embers (slow drifting glow), or sparks (a one-shot burst). Renders as tinted glowing dots — great for campfires, torches, and impacts. (smoke needs a soft sprite; not in v1.)", {
186
+ kind: z
187
+ .enum(["fire", "embers", "sparks", "magic", "dust", "blood", "snow"])
188
+ .describe("Particle preset"),
189
+ position: Vector3Schema.optional().describe("World position"),
190
+ color: ColorSchema.optional().describe("Override the particle tint"),
191
+ name: z.string().optional().describe("GameObject name"),
192
+ }, async (params) => {
193
+ const res = await bridge.send("spawn_particle", params);
194
+ if (!res.success) {
195
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
196
+ }
197
+ return {
198
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
199
+ };
200
+ });
201
+ // ── add_trail ────────────────────────────────────────────────────────
202
+ server.tool("add_trail", "Attach a motion trail (TrailRenderer) to an existing GameObject (via targetId) so it leaves a trail as it moves — or create a standalone trail object. Only visible while the object is moving.", {
203
+ targetId: z.string().optional().describe("GUID of the GameObject to attach the trail to (else a new 'Trail' object is made)"),
204
+ position: Vector3Schema.optional().describe("World position when creating a standalone trail"),
205
+ lifetime: z.number().optional().describe("How long (seconds) trail points persist"),
206
+ maxPoints: z.number().optional().describe("Max points in the trail"),
207
+ pointDistance: z.number().optional().describe("Min distance between trail points"),
208
+ name: z.string().optional().describe("GameObject name when creating a new one"),
209
+ }, async (params) => {
210
+ const res = await bridge.send("add_trail", params);
211
+ if (!res.success) {
212
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
213
+ }
214
+ return {
215
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
216
+ };
217
+ });
218
+ // ── add_beam ─────────────────────────────────────────────────────────
219
+ server.tool("add_beam", "Create an energy/laser beam (BeamEffect) from a position to a target point — additive, tintable. Good for lasers, tracers, magic beams.", {
220
+ position: Vector3Schema.optional().describe("Beam start (world position of the beam object)"),
221
+ target: Vector3Schema.optional().describe("Beam end point in world space (default: 128u up)"),
222
+ width: z.number().optional().describe("Beam width/scale (default 4)"),
223
+ color: ColorSchema.optional().describe("Beam colour (default white)"),
224
+ name: z.string().optional().describe("GameObject name"),
225
+ }, async (params) => {
226
+ const res = await bridge.send("add_beam", params);
227
+ if (!res.success) {
228
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
229
+ }
230
+ return {
231
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
232
+ };
233
+ });
234
+ // ── create_particle_effect (generic / raw params) ────────────────────
235
+ server.tool("create_particle_effect", "Build a custom additive particle effect from raw params (ParticleEffect + cone emitter + sprite renderer). Use this when the spawn_particle presets aren't what you want. Texture-free (additive Texture.White glow).", {
236
+ position: Vector3Schema.optional().describe("World position"),
237
+ color: ColorSchema.optional().describe("Particle tint (default white)"),
238
+ rate: z.number().optional().describe("Particles per second when looping (default 30)"),
239
+ burst: z.number().optional().describe("Particle count for a one-shot burst when loop=false (default 30)"),
240
+ loop: z.boolean().optional().describe("Continuous emission (default true) vs a single burst"),
241
+ lifetime: z.number().optional().describe("Particle lifetime in seconds (default 2)"),
242
+ size: z.number().optional().describe("Particle size (default 4)"),
243
+ speed: z.number().optional().describe("Emission speed along the cone (default 100)"),
244
+ coneAngle: z.number().optional().describe("Cone half-angle in degrees; ~85 ≈ hemisphere (default 40)"),
245
+ gravity: z.number().optional().describe("Downward force (default 0 = none)"),
246
+ additive: z.boolean().optional().describe("Additive (glow) blending (default true)"),
247
+ maxParticles: z.number().optional().describe("Max live particles (default 500)"),
248
+ name: z.string().optional().describe("GameObject name"),
249
+ }, async (params) => {
250
+ const res = await bridge.send("create_particle_effect", params);
251
+ if (!res.success) {
252
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
253
+ }
254
+ return {
255
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
256
+ };
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
+ });
288
+ }
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.3.2",
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",