sbox-mcp-server 1.5.1 → 1.6.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 +4 -4
- package/dist/tools/characters.js +51 -0
- package/dist/tools/diagnostics.js +136 -2
- package/dist/tools/project.js +14 -0
- package/package.json +1 -1
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 — **150 tools
|
|
3
|
+
MCP Server for the s&box game engine. Lets Claude Code build s&box games through conversation — **150+ tools** 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
|
|
47
|
+
You should see `connected: true` with a healthy `handlerCount`. (That's the editor-side handler count; the server exposes a few more tools total — a handful run MCP-server-side and need no editor handler.)
|
|
48
48
|
|
|
49
49
|
## How it works
|
|
50
50
|
|
|
@@ -54,9 +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 (
|
|
57
|
+
## Tools (v1.5.2)
|
|
58
58
|
|
|
59
|
-
`get_bridge_status` reports `handlerCount
|
|
59
|
+
`get_bridge_status` reports the `handlerCount` — 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.
|
|
60
60
|
|
|
61
61
|
| Category | Tools |
|
|
62
62
|
|----------|-------|
|
package/dist/tools/characters.js
CHANGED
|
@@ -205,5 +205,56 @@ export function registerCharacterTools(server, bridge) {
|
|
|
205
205
|
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
206
206
|
};
|
|
207
207
|
});
|
|
208
|
+
// ── list_animations ───────────────────────────────────────────────── (Batch 33)
|
|
209
|
+
server.tool("list_animations", "List the animation sequences available on a GameObject's SkinnedModelRenderer (a spawned Citizen or animated model), plus whether it's driven by an AnimationGraph. Call this before play_animation or set_animgraph_param to see valid names.", {
|
|
210
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
211
|
+
}, async (params) => {
|
|
212
|
+
const res = await bridge.send("list_animations", params);
|
|
213
|
+
if (!res.success) {
|
|
214
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
// ── play_animation ─────────────────────────────────────────────────
|
|
221
|
+
server.tool("play_animation", "Play a named animation sequence on a GameObject's SkinnedModelRenderer (sets the Sequence). Best for models with raw sequences; for AnimationGraph characters (Citizen) prefer set_animgraph_param. The renderer needs PlayAnimationsInEditorScene = true to animate in-editor — then screenshot to verify. Use list_animations for valid names.", {
|
|
222
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
223
|
+
animation: z.string().describe("Sequence name to play (see list_animations)"),
|
|
224
|
+
looping: z.boolean().optional().describe("Loop the sequence (default: the model's setting)"),
|
|
225
|
+
speed: z.number().optional().describe("Playback rate multiplier (default 1)"),
|
|
226
|
+
}, async (params) => {
|
|
227
|
+
const res = await bridge.send("play_animation", params);
|
|
228
|
+
if (!res.success) {
|
|
229
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
// ── set_animgraph_param ────────────────────────────────────────────
|
|
236
|
+
server.tool("set_animgraph_param", "Set an AnimationGraph parameter on a GameObject's SkinnedModelRenderer (calls Set). This drives Citizen/animgraph motion — e.g. 'move_x'/'move_y' (float), 'b_grounded'/'b_ducked' (bool), or a Vector3. Pose previews in-editor when PlayAnimationsInEditorScene is on; screenshot to verify. Param names are defined by the model's animation graph.", {
|
|
237
|
+
id: z.string().describe("GUID of the GameObject with a SkinnedModelRenderer"),
|
|
238
|
+
param: z.string().describe("Animgraph parameter name, e.g. 'move_x', 'b_grounded'"),
|
|
239
|
+
value: z
|
|
240
|
+
.union([
|
|
241
|
+
z.number(),
|
|
242
|
+
z.boolean(),
|
|
243
|
+
z.object({ x: z.number(), y: z.number(), z: z.number() }),
|
|
244
|
+
])
|
|
245
|
+
.describe("Value: number (float), boolean, or {x,y,z} vector"),
|
|
246
|
+
type: z
|
|
247
|
+
.enum(["float", "int", "bool", "vector"])
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("Force the parameter type (default: inferred from value)"),
|
|
250
|
+
}, async (params) => {
|
|
251
|
+
const res = await bridge.send("set_animgraph_param", params);
|
|
252
|
+
if (!res.success) {
|
|
253
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
257
|
+
};
|
|
258
|
+
});
|
|
208
259
|
}
|
|
209
260
|
//# sourceMappingURL=characters.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync, statSync, readdirSync } from "fs";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
4
|
/**
|
|
5
5
|
* Diagnostic tools (Batch 24 — "let Claude see its own errors"): read s&box's
|
|
6
6
|
* editor log so Claude can check compile errors, exceptions, and Log.Info
|
|
@@ -60,6 +60,36 @@ function locateSboxLog() {
|
|
|
60
60
|
}
|
|
61
61
|
return { path: null, tried };
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Derive the editor's screenshots folder from the located log path
|
|
65
|
+
* (<sbox>/logs/sbox-dev.log → <sbox>/screenshots). SBOX_SCREENSHOTS_DIR overrides.
|
|
66
|
+
*/
|
|
67
|
+
function locateScreenshotsDir() {
|
|
68
|
+
if (process.env.SBOX_SCREENSHOTS_DIR)
|
|
69
|
+
return process.env.SBOX_SCREENSHOTS_DIR;
|
|
70
|
+
const { path } = locateSboxLog();
|
|
71
|
+
if (!path)
|
|
72
|
+
return null;
|
|
73
|
+
return join(dirname(dirname(path)), "screenshots");
|
|
74
|
+
}
|
|
75
|
+
/** Newest .png in a dir with mtime strictly greater than afterMs, or null. */
|
|
76
|
+
function newestPng(dir, afterMs) {
|
|
77
|
+
try {
|
|
78
|
+
let best = null;
|
|
79
|
+
for (const f of readdirSync(dir)) {
|
|
80
|
+
if (!f.toLowerCase().endsWith(".png"))
|
|
81
|
+
continue;
|
|
82
|
+
const fp = join(dir, f);
|
|
83
|
+
const m = statSync(fp).mtimeMs;
|
|
84
|
+
if (m > afterMs && (!best || m > best.mtimeMs))
|
|
85
|
+
best = { path: fp, mtimeMs: m };
|
|
86
|
+
}
|
|
87
|
+
return best;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
function tailLines(text, n) {
|
|
64
94
|
const lines = text.split(/\r?\n/);
|
|
65
95
|
return lines.slice(Math.max(0, lines.length - n));
|
|
@@ -321,5 +351,109 @@ export function registerDiagnosticTools(server, bridge) {
|
|
|
321
351
|
],
|
|
322
352
|
};
|
|
323
353
|
});
|
|
354
|
+
// ── get_bounds ─────────────────────────────────────────────────────── (bridge, Batch 33)
|
|
355
|
+
server.tool("get_bounds", "Get a GameObject's world-space bounding box — center, size, extents, mins/maxs, and a radius. Useful for placing/framing objects and sizing camera moves. Reads GameObject.GetBounds(); objects with no renderer report empty:true with their world position.", {
|
|
356
|
+
id: z.string().describe("GUID of the GameObject to measure"),
|
|
357
|
+
}, async (params) => {
|
|
358
|
+
const res = await bridge.send("get_bounds", params);
|
|
359
|
+
if (!res.success) {
|
|
360
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
361
|
+
}
|
|
362
|
+
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
363
|
+
});
|
|
364
|
+
// ── screenshot_orbit ──────────────────────────────────────────────── (orchestrated, Batch 33)
|
|
365
|
+
server.tool("screenshot_orbit", "Capture a GameObject from several angles in ONE call — orbits the scene's main camera around the object and screenshots each angle, so Claude can verify 3D work from multiple sides instead of guessing from one. Drives get_bounds (framing) + screenshot_from per angle (each its own frame, the reliable capture path). Returns the saved PNG paths in order — READ them to inspect.", {
|
|
366
|
+
id: z.string().describe("GUID of the GameObject to orbit"),
|
|
367
|
+
shots: z
|
|
368
|
+
.number()
|
|
369
|
+
.int()
|
|
370
|
+
.optional()
|
|
371
|
+
.describe("Number of angles around the object (default 4, clamped 2-8)"),
|
|
372
|
+
elevation: z
|
|
373
|
+
.number()
|
|
374
|
+
.optional()
|
|
375
|
+
.describe("Camera height factor: 0 = level, 1 = high (default 0.4)"),
|
|
376
|
+
distance: z
|
|
377
|
+
.number()
|
|
378
|
+
.optional()
|
|
379
|
+
.describe("Camera distance in units (default: auto from bounds)"),
|
|
380
|
+
width: z.number().int().optional().describe("Screenshot width (default 1280)"),
|
|
381
|
+
height: z.number().int().optional().describe("Screenshot height (default 720)"),
|
|
382
|
+
}, async (params) => {
|
|
383
|
+
const b = await bridge.send("get_bounds", { id: params.id });
|
|
384
|
+
if (!b.success) {
|
|
385
|
+
return { content: [{ type: "text", text: `Error (get_bounds): ${b.error}` }] };
|
|
386
|
+
}
|
|
387
|
+
const data = b.data;
|
|
388
|
+
const c = data.center;
|
|
389
|
+
const sizeLen = Math.hypot(data.size.x, data.size.y, data.size.z);
|
|
390
|
+
const dist = params.distance ?? Math.max(sizeLen * 1.6, 150);
|
|
391
|
+
let shots = params.shots ?? 4;
|
|
392
|
+
shots = Math.max(2, Math.min(8, shots));
|
|
393
|
+
const elev = params.elevation ?? 0.4;
|
|
394
|
+
const w = params.width ?? 1280;
|
|
395
|
+
const h = params.height ?? 720;
|
|
396
|
+
const ssDir = locateScreenshotsDir();
|
|
397
|
+
let lastMtime = 0;
|
|
398
|
+
if (ssDir) {
|
|
399
|
+
const n = newestPng(ssDir, 0);
|
|
400
|
+
if (n)
|
|
401
|
+
lastMtime = n.mtimeMs;
|
|
402
|
+
}
|
|
403
|
+
const results = [];
|
|
404
|
+
for (let i = 0; i < shots; i++) {
|
|
405
|
+
// s&box names screenshots at 1-second granularity, so two shots in the
|
|
406
|
+
// same wall-clock second overwrite each other. Space them out.
|
|
407
|
+
if (i > 0)
|
|
408
|
+
await new Promise((res) => setTimeout(res, 1100));
|
|
409
|
+
const ang = (2 * Math.PI * i) / shots;
|
|
410
|
+
const dx = Math.cos(ang);
|
|
411
|
+
const dy = Math.sin(ang);
|
|
412
|
+
const len = Math.hypot(dx, dy, elev) || 1;
|
|
413
|
+
const camPos = {
|
|
414
|
+
x: c.x + (dx / len) * dist,
|
|
415
|
+
y: c.y + (dy / len) * dist,
|
|
416
|
+
z: c.z + (elev / len) * dist,
|
|
417
|
+
};
|
|
418
|
+
const deg = Math.round((ang * 180) / Math.PI);
|
|
419
|
+
const r = await bridge.send("screenshot_from", {
|
|
420
|
+
position: camPos,
|
|
421
|
+
lookAt: c,
|
|
422
|
+
width: w,
|
|
423
|
+
height: h,
|
|
424
|
+
});
|
|
425
|
+
if (!r.success) {
|
|
426
|
+
results.push({ angle: deg, error: r.error });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
let file = null;
|
|
430
|
+
if (ssDir) {
|
|
431
|
+
const started = Date.now();
|
|
432
|
+
while (Date.now() - started < 4000) {
|
|
433
|
+
const n = newestPng(ssDir, lastMtime);
|
|
434
|
+
if (n) {
|
|
435
|
+
file = n.path;
|
|
436
|
+
lastMtime = n.mtimeMs;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
await new Promise((res) => setTimeout(res, 200));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
results.push({ angle: deg, position: camPos, file });
|
|
443
|
+
}
|
|
444
|
+
const files = results
|
|
445
|
+
.filter((s) => typeof s.file === "string")
|
|
446
|
+
.map((s) => s.file);
|
|
447
|
+
const summary = {
|
|
448
|
+
orbited: data.name ?? params.id,
|
|
449
|
+
center: c,
|
|
450
|
+
distance: Math.round(dist),
|
|
451
|
+
shots: results,
|
|
452
|
+
note: ssDir
|
|
453
|
+
? `Captured ${files.length}/${shots} angle(s). READ these PNGs to inspect the object from each side:\n${files.join("\n")}`
|
|
454
|
+
: `Captured ${shots} angle(s), but couldn't locate the screenshots folder (set SBOX_LOG_PATH or SBOX_SCREENSHOTS_DIR). Read the newest PNGs in <sbox>/screenshots/.`,
|
|
455
|
+
};
|
|
456
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
457
|
+
});
|
|
324
458
|
}
|
|
325
459
|
//# sourceMappingURL=diagnostics.js.map
|
package/dist/tools/project.js
CHANGED
|
@@ -96,5 +96,19 @@ export function registerProjectTools(server, bridge) {
|
|
|
96
96
|
],
|
|
97
97
|
};
|
|
98
98
|
});
|
|
99
|
+
// ── recompile_asset ──────────────────────────────────────────────
|
|
100
|
+
server.tool("recompile_asset", "Compile a project asset by path — registers it with the editor's AssetSystem then compiles it (e.g. .vmat → .vmat_c). Use after writing/editing an asset with write_file so the change takes effect without a manual editor step. Verified on materials. NOTE: the engine exposes no reachable particle (.vpcf) compiler, so this can't compile particles — author those in s&box's particle editor, then spawn_vpcf plays them.", {
|
|
101
|
+
path: z
|
|
102
|
+
.string()
|
|
103
|
+
.describe("Asset path — project-relative (e.g. 'materials/foo.vmat') or absolute"),
|
|
104
|
+
}, async (params) => {
|
|
105
|
+
const res = await bridge.send("recompile_asset", params);
|
|
106
|
+
if (!res.success) {
|
|
107
|
+
return { content: [{ type: "text", text: `Error: ${res.error}` }] };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
|
|
111
|
+
};
|
|
112
|
+
});
|
|
99
113
|
}
|
|
100
114
|
//# sourceMappingURL=project.js.map
|