sbox-mcp-server 1.6.0 → 1.7.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/dist/index.js CHANGED
@@ -40,6 +40,9 @@ import { registerObjectTools } from "./tools/objecttools.js";
40
40
  import { registerDiagnosticTools } from "./tools/diagnostics.js";
41
41
  import { registerDocsTools } from "./tools/docs.js";
42
42
  import { registerNavigationTools } from "./tools/navigation.js";
43
+ import { registerSelfTestTools } from "./tools/selftest.js";
44
+ import { registerGameplayTools } from "./tools/gameplay.js";
45
+ import { registerNpcTools } from "./tools/npc.js";
43
46
  // ── CLI flags ──────────────────────────────────────────────────────
44
47
  const args = process.argv.slice(2);
45
48
  /** Read the package version from package.json, or return "unknown" on failure. */
@@ -183,6 +186,9 @@ registerObjectTools(server, bridge);
183
186
  registerDiagnosticTools(server, bridge);
184
187
  registerDocsTools(server, bridge);
185
188
  registerNavigationTools(server, bridge);
189
+ registerSelfTestTools(server, bridge);
190
+ registerGameplayTools(server, bridge);
191
+ registerNpcTools(server, bridge);
186
192
  /** Start the MCP server on stdio and attempt initial Bridge connection. */
187
193
  async function main() {
188
194
  const transport = new StdioServerTransport();
@@ -56,7 +56,7 @@ export function registerComponentTools(server, bridge) {
56
56
  };
57
57
  });
58
58
  // ── add_component_with_properties ────────────────────────────────
59
- server.tool("add_component_with_properties", "Add a component to a GameObject and configure its properties in one call. Use list_available_components to find valid types", {
59
+ server.tool("add_component_with_properties", "Add a component to a GameObject and configure its properties in one call (properties PERSIST through save+reload). Use list_available_components to find valid types. Returns appliedProperties + failedProperties so you can see exactly what stuck", {
60
60
  id: z.string().describe("GUID of the GameObject"),
61
61
  component: z
62
62
  .string()
@@ -64,7 +64,7 @@ export function registerComponentTools(server, bridge) {
64
64
  properties: z
65
65
  .record(z.unknown())
66
66
  .optional()
67
- .describe("Key-value map of property names to values. Values are auto-converted to the correct type"),
67
+ .describe("Key-value map of property names to values, each auto-converted to the property's real type. Primitives '5'/true; Color/Vector3 as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('Model':'models/dev/box.vmdl', 'MaterialOverride':'materials/x.vmat'); GameObject/Component refs as a target GUID. Best-effort per key — failures are reported in failedProperties, not silently dropped"),
68
68
  }, async (params) => {
69
69
  const res = await bridge.send("add_component_with_properties", params);
70
70
  if (!res.success) {
@@ -455,5 +455,31 @@ export function registerDiagnosticTools(server, bridge) {
455
455
  };
456
456
  return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
457
457
  });
458
+ // ── capture_view ──────────────────────────────────────────────────── (bridge, Batch 34)
459
+ server.tool("capture_view", "Capture a PNG of the scene from a camera — and crucially this WORKS IN PLAY MODE, capturing the RUNNING game (via CameraComponent.RenderToBitmap, unlike take_screenshot/screenshot_from which are edit-only). With no args it renders the live main camera = the player's POV (incl. HUD). Pass position {x,y,z} (+ lookAt or rotation) or id (a GameObject to frame) to capture from a temporary camera that never disturbs the game's own camera. Returns the saved PNG's absolute 'path' — READ it to see the result.", {
460
+ id: z.string().optional().describe("GUID of a GameObject to frame (uses a temp camera)"),
461
+ position: z
462
+ .object({ x: z.number(), y: z.number(), z: z.number() })
463
+ .optional()
464
+ .describe("Camera world position (temp camera; use instead of id)"),
465
+ lookAt: z
466
+ .object({ x: z.number(), y: z.number(), z: z.number() })
467
+ .optional()
468
+ .describe("World point to look at (pair with position)"),
469
+ rotation: z
470
+ .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
471
+ .optional()
472
+ .describe("Explicit camera rotation (pair with position)"),
473
+ fov: z.number().optional().describe("Field of view for the temp camera"),
474
+ renderUI: z.boolean().optional().describe("Include UI/HUD (default true). Renders world + world-space UI but NOT fullscreen screen-space panels (lobby/title overlays) — so capture_view sees 'through' menus; use take_screenshot for screen-space UI."),
475
+ width: z.number().int().optional().describe("Width (default 1280)"),
476
+ height: z.number().int().optional().describe("Height (default 720)"),
477
+ }, async (params) => {
478
+ const res = await bridge.send("capture_view", params);
479
+ if (!res.success) {
480
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
481
+ }
482
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
483
+ });
458
484
  }
459
485
  //# 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 registerGameplayTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=gameplay.d.ts.map
@@ -0,0 +1,227 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Gameplay scaffold tools — Phase 1 of "playable game in one ask".
4
+ *
5
+ * Two low-level capability gap-fillers:
6
+ * - set_component_reference wire a component property to a LIVE scene object
7
+ * - add_component_to_new_object atomic create-GO + add-component + props
8
+ *
9
+ * Three system scaffolds (generate a clean, self-contained .cs, optionally place it):
10
+ * - create_objective_system the win/lose primitive (ObjectiveManager)
11
+ * - create_health_system Health/damage component
12
+ * - create_pickup trigger-based collectible
13
+ *
14
+ * The `sbox-scaffold-game` skill orchestrates these into a playable starter.
15
+ * All scene/file-mutating; refused during play mode by the bridge dispatch.
16
+ */
17
+ const Vector3Schema = z
18
+ .object({
19
+ x: z.number().describe("X coordinate"),
20
+ y: z.number().describe("Y coordinate"),
21
+ z: z.number().describe("Z coordinate"),
22
+ })
23
+ .describe("3D vector with x, y, z components");
24
+ const RotationSchema = z
25
+ .object({
26
+ pitch: z.number().describe("Pitch angle in degrees"),
27
+ yaw: z.number().describe("Yaw angle in degrees"),
28
+ roll: z.number().describe("Roll angle in degrees"),
29
+ })
30
+ .describe("Euler rotation with pitch, yaw, roll in degrees");
31
+ export function registerGameplayTools(server, bridge) {
32
+ // ── set_component_reference ───────────────────────────────────────
33
+ // Assign one scene GameObject (or a component on it) to a component property.
34
+ // set_property can now also set a GameObject/Component ref from a GUID, but this
35
+ // tool is the ergonomic choice for refs: it can pull a specific component type off
36
+ // the target (targetComponent) and validates the wiring. set_prefab_ref is for
37
+ // PREFAB assets. Wires live scene objects: Spawner.SpawnPoint = thatEmpty,
38
+ // Camera follows thatPlayer, Door.Hinge = thatPivot.
39
+ server.tool("set_component_reference", "Wire a component's GameObject/Component-typed property to ANOTHER live object in the scene by GUID (e.g. ObjectiveManager.Player = the player, a camera's follow target, a door's hinge). Preferred for object/component refs (can pick a specific component type off the target via targetComponent, and validates). set_property also accepts a GUID for ref props; set_prefab_ref is for prefab assets. Set clear:true to null the reference", {
40
+ id: z
41
+ .string()
42
+ .describe("GUID of the GameObject that HOLDS the component you're writing into"),
43
+ component: z
44
+ .string()
45
+ .describe("Component type name on that object (e.g. 'ObjectiveManager', 'CameraComponent')"),
46
+ property: z
47
+ .string()
48
+ .describe("The property to set (must be a GameObject- or Component-typed property)"),
49
+ targetId: z
50
+ .string()
51
+ .optional()
52
+ .describe("GUID of the GameObject to reference. Required unless clear:true"),
53
+ targetComponent: z
54
+ .string()
55
+ .optional()
56
+ .describe("If the property is a Component subtype, the specific component type to pull off the target object. Omit to auto-match by the property's type"),
57
+ clear: z
58
+ .boolean()
59
+ .optional()
60
+ .describe("If true, set the reference to null instead of assigning a target"),
61
+ }, async (params) => {
62
+ const res = await bridge.send("set_component_reference", params);
63
+ if (!res.success) {
64
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
65
+ }
66
+ return {
67
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
68
+ };
69
+ });
70
+ // ── add_component_to_new_object ───────────────────────────────────
71
+ server.tool("add_component_to_new_object", "Create a new GameObject, add a component, set its properties, and optionally parent/position/tag it — all in one atomic call. Collapses the create_gameobject → add_component_with_properties → set_parent sequence. NOTE: a freshly GENERATED component type only resolves after a trigger_hotload; generate the script, hotload, THEN call this", {
72
+ name: z
73
+ .string()
74
+ .optional()
75
+ .describe("Display name for the new GameObject. Defaults to the component type name"),
76
+ component: z
77
+ .string()
78
+ .describe("Component type name to add (e.g. 'CameraComponent', 'ObjectiveManager'). Use list_available_components to find valid types"),
79
+ properties: z
80
+ .record(z.unknown())
81
+ .optional()
82
+ .describe("Key-value map of property names to values, auto-converted to the right type (same convention as add_component_with_properties)"),
83
+ position: Vector3Schema.optional().describe("World position"),
84
+ rotation: RotationSchema.optional().describe("World rotation"),
85
+ scale: Vector3Schema.optional().describe("World scale (per-axis)"),
86
+ parentId: z
87
+ .string()
88
+ .optional()
89
+ .describe("GUID of a parent GameObject. Omit for scene root"),
90
+ tags: z
91
+ .array(z.string())
92
+ .optional()
93
+ .describe("Tags to add to the new GameObject (e.g. ['player'])"),
94
+ }, async (params) => {
95
+ const res = await bridge.send("add_component_to_new_object", params);
96
+ if (!res.success) {
97
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
98
+ }
99
+ return {
100
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
101
+ };
102
+ });
103
+ // ── create_objective_system ───────────────────────────────────────
104
+ // The win/lose primitive — turns "objects in a scene" into "a game with a goal".
105
+ server.tool("create_objective_system", "Generate an ObjectiveManager component — the win/lose brain of a game. Tracks an objective (collect_all / reach_goal / survive_time / eliminate_all), fires a win, and handles a lose condition (fall below kill-Z / timer / out of lives). Self-contained C#; other systems call ObjectiveManager.Instance. Optionally placed as a scene singleton", {
106
+ name: z
107
+ .string()
108
+ .optional()
109
+ .describe("Class name. Defaults to 'ObjectiveManager'"),
110
+ directory: z
111
+ .string()
112
+ .optional()
113
+ .describe("Subdirectory for the .cs file. Defaults to 'Code'"),
114
+ objective: z
115
+ .enum(["collect_all", "reach_goal", "survive_time", "eliminate_all"])
116
+ .optional()
117
+ .describe("Win condition. Defaults to 'reach_goal'"),
118
+ targetCount: z
119
+ .number()
120
+ .int()
121
+ .optional()
122
+ .describe("How many to collect/eliminate (for collect_all / eliminate_all). Defaults to 3"),
123
+ timeLimit: z
124
+ .number()
125
+ .optional()
126
+ .describe("Seconds — survive this long to win (survive_time) or before losing (loseOn=timer). Defaults to 60"),
127
+ loseOn: z
128
+ .enum(["fall", "timer", "lives", "none"])
129
+ .optional()
130
+ .describe("Lose condition. 'fall' = player drops below killZ. Defaults to 'fall'"),
131
+ killZ: z
132
+ .number()
133
+ .optional()
134
+ .describe("World Z below which the player is considered fallen out of the world. Defaults to -1000"),
135
+ lives: z
136
+ .number()
137
+ .int()
138
+ .optional()
139
+ .describe("Lives before game over (loseOn=lives). Defaults to 1"),
140
+ placeInScene: z
141
+ .boolean()
142
+ .optional()
143
+ .describe("Place the manager as a scene singleton. Defaults to true. (Only attaches if the type is already loaded — generate, hotload, then it places; otherwise add it after hotload.)"),
144
+ }, async (params) => {
145
+ const res = await bridge.send("create_objective_system", params);
146
+ if (!res.success) {
147
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
148
+ }
149
+ return {
150
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
151
+ };
152
+ });
153
+ // ── create_health_system ──────────────────────────────────────────
154
+ server.tool("create_health_system", "Generate a Health component: MaxHealth, [Sync] CurrentHealth, TakeDamage/Heal, an OnDeath event, optional regen and respawn. Host-authoritative damage when networked, single-player safe. Optionally attached to an existing GameObject by GUID", {
155
+ name: z.string().optional().describe("Class name. Defaults to 'Health'"),
156
+ directory: z
157
+ .string()
158
+ .optional()
159
+ .describe("Subdirectory for the .cs file. Defaults to 'Code'"),
160
+ maxHealth: z
161
+ .number()
162
+ .optional()
163
+ .describe("Starting/maximum health. Defaults to 100"),
164
+ regen: z
165
+ .boolean()
166
+ .optional()
167
+ .describe("Include passive health regeneration after a delay. Defaults to false"),
168
+ respawn: z
169
+ .boolean()
170
+ .optional()
171
+ .describe("On death, respawn at a RespawnPoint (wire it with set_component_reference) instead of disabling. Defaults to false"),
172
+ targetId: z
173
+ .string()
174
+ .optional()
175
+ .describe("GUID of an existing GameObject to attach the Health component to (only attaches if the type is already loaded — hotload first)"),
176
+ }, async (params) => {
177
+ const res = await bridge.send("create_health_system", params);
178
+ if (!res.success) {
179
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
180
+ }
181
+ return {
182
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
183
+ };
184
+ });
185
+ // ── create_pickup ─────────────────────────────────────────────────
186
+ server.tool("create_pickup", "Generate a trigger-based collectible component. On enter by a tagged object it raises OnCollected (wire it to your objective/score system) and despawns. Optionally builds a visible pickup GameObject with a trigger SphereCollider (+ a model) in one call", {
187
+ name: z.string().optional().describe("Class name. Defaults to 'Pickup'"),
188
+ directory: z
189
+ .string()
190
+ .optional()
191
+ .describe("Subdirectory for the .cs file. Defaults to 'Code'"),
192
+ action: z
193
+ .enum(["score", "heal", "item", "custom"])
194
+ .optional()
195
+ .describe("Effect flavour (all self-contained; the heal/item branches show the typed call to a companion system in comments). Defaults to 'score'"),
196
+ amount: z
197
+ .number()
198
+ .optional()
199
+ .describe("Magnitude of the effect (score points, heal amount). Defaults to 1"),
200
+ filterTag: z
201
+ .string()
202
+ .optional()
203
+ .describe("Only collect for objects with this tag. Defaults to 'player'"),
204
+ placeInScene: z
205
+ .boolean()
206
+ .optional()
207
+ .describe("Also build a pickup GameObject (trigger SphereCollider + optional model). Defaults to false"),
208
+ position: Vector3Schema.optional().describe("World position when placeInScene is true"),
209
+ radius: z
210
+ .number()
211
+ .optional()
212
+ .describe("Trigger sphere radius when placed. Defaults to 24"),
213
+ model: z
214
+ .string()
215
+ .optional()
216
+ .describe("Optional model path for a visible pickup (e.g. 'models/dev/box.vmdl'). Cloud assets must be installed first"),
217
+ }, async (params) => {
218
+ const res = await bridge.send("create_pickup", params);
219
+ if (!res.success) {
220
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
221
+ }
222
+ return {
223
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
224
+ };
225
+ });
226
+ }
227
+ //# sourceMappingURL=gameplay.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 registerNpcTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=npc.d.ts.map
@@ -0,0 +1,195 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * NPC Brains (Feature Wave #3) — give bridge-built NPCs actual behavior.
4
+ *
5
+ * create_npc_brain generates a finite-state-machine Component (idle/patrol/
6
+ * wander/chase/search/flee/ambush) driven by occlusion-aware perception (FOV
7
+ * cone + range + line-of-sight trace + hearing) with last-known-position
8
+ * memory — the decision layer on top of the existing movement substrate
9
+ * (bake_navmesh / get_navmesh_path / create_npc_controller). place_patrol_route
10
+ * + assign_patrol_route make a patrol authorable end-to-end; create_npc_spawner
11
+ * is the swarm/wave backbone. simulate_npc_perception is the keystone verifier:
12
+ * it runs the EXACT line-of-sight check the brain uses, in EDIT mode, without
13
+ * play — so "does the tree block the NPC's view" is checkable structurally
14
+ * (the bridge can't see a real chase in a single static screenshot).
15
+ *
16
+ * Mirrors the templates.ts / navigation.ts module shape: zod params, one
17
+ * bridge.send per tool, JSON.stringify(res.data) on success.
18
+ */
19
+ const Vec3 = z
20
+ .object({
21
+ x: z.number().describe("X"),
22
+ y: z.number().describe("Y"),
23
+ z: z.number().describe("Z"),
24
+ })
25
+ .describe("A world-space Vector3");
26
+ export function registerNpcTools(server, bridge) {
27
+ // ── create_npc_brain ──────────────────────────────────────────────
28
+ server.tool("create_npc_brain", "Generate an NpcBrain Component: a behavior state machine (Idle/Patrol/Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception — FOV cone + sight range + a line-of-sight trace (respects walls/trees) + proximity hearing — with last-known-position memory (lose-LOS -> search -> give up -> resume). This is the decision layer on top of bake_navmesh / NavMeshAgent movement. Pick a behavior preset, then tune via the generated [Property] fields with set_property. After generating: trigger_hotload + get_compile_errors, place a route with place_patrol_route + assign_patrol_route, bake_navmesh, and verify perception in EDIT mode with simulate_npc_perception (chase/search behavior needs play mode). The component is added to a GameObject like any other; it auto-adds a NavMeshAgent in OnStart.", {
29
+ name: z
30
+ .string()
31
+ .optional()
32
+ .describe("Class/file name. Defaults to 'NpcBrain'. Sanitized to a valid C# identifier."),
33
+ directory: z
34
+ .string()
35
+ .optional()
36
+ .describe("Subdirectory under the project root for the .cs file. Defaults to 'Code'."),
37
+ behavior: z
38
+ .enum(["patrol", "guard", "hunter", "swarm", "skittish"])
39
+ .optional()
40
+ .describe("Preset (sets StartState + flee toggle): 'patrol' (walk waypoints), 'guard' (Ambush near spawn until a target enters range), 'hunter' (patrol->chase->search, the Sasquatch), 'swarm' (wander/idle->chase nearest, RUN mobs), 'skittish' (chase but flee on low health). The generated file is the same shape; the preset just changes defaults. Defaults to 'hunter'."),
41
+ targetTag: z
42
+ .string()
43
+ .optional()
44
+ .describe("Tag the NPC hunts (its candidates are GameObjects with this tag). Defaults to 'player'."),
45
+ moveSpeed: z.number().optional().describe("Patrol/wander speed (NavMeshAgent MaxSpeed). Default 130."),
46
+ chaseSpeed: z.number().optional().describe("Chase/flee speed. Default 200."),
47
+ sightRange: z.number().optional().describe("Max sight distance. Default 1500."),
48
+ fovDegrees: z
49
+ .number()
50
+ .optional()
51
+ .describe("Full field-of-view cone angle in degrees. Default 110. (Baked into a cosine threshold for cheap, trig-free checks.)"),
52
+ eyeHeight: z.number().optional().describe("Trace origin height above the NPC's feet. Default 64."),
53
+ hearingRadius: z
54
+ .number()
55
+ .optional()
56
+ .describe("Proximity-hearing radius — a target within it is investigated (sets last-known-pos) but NOT instantly aggroed. Default 600."),
57
+ giveUpTime: z
58
+ .number()
59
+ .optional()
60
+ .describe("Seconds to search after losing line-of-sight before giving up and resuming the start state. Default 6."),
61
+ searchRadius: z.number().optional().describe("Wander radius around the last-known position while searching. Default 400."),
62
+ waypointStopDistance: z
63
+ .number()
64
+ .optional()
65
+ .describe("How close the NPC must get to a waypoint/target before it counts as reached. Default 80."),
66
+ canFlee: z.boolean().optional().describe("Enable the Flee state (else the NPC never flees). Defaults from the preset."),
67
+ fleeHealthFrac: z
68
+ .number()
69
+ .optional()
70
+ .describe("Flee when CurrentHealthFrac drops to/below this (the game sets CurrentHealthFrac 0..1). Default 0.25."),
71
+ networked: z
72
+ .boolean()
73
+ .optional()
74
+ .describe("When true (default), emit a host-authoritative brain: 'if (IsProxy) return;' + [Sync] CurrentState. NOTE: a no-session solo playtest makes everything a proxy, so a networked brain won't think until a host session exists — pass false to iterate solo in the edit scene."),
75
+ }, async (params) => {
76
+ const res = await bridge.send("create_npc_brain", params);
77
+ if (!res.success) {
78
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
79
+ }
80
+ return {
81
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
82
+ };
83
+ });
84
+ // ── place_patrol_route ────────────────────────────────────────────
85
+ server.tool("place_patrol_route", "Place a set of waypoint GameObjects (tagged empties) for a patrol route and group them under a parent route object — authorable in one call. Optionally snaps each point to the ground (raycast down) so waypoints sit on the navmesh, not floating. Returns the route parent GUID + ordered waypoint GUIDs to feed into assign_patrol_route. Validate connectivity afterward with get_navmesh_path between consecutive waypoints (catches a 'point in a wall').", {
86
+ points: z
87
+ .array(Vec3)
88
+ .min(2)
89
+ .describe("Ordered world positions for the route (at least 2)."),
90
+ name: z.string().optional().describe("Route name. Defaults to 'PatrolRoute'. Waypoints are named <route>_WP0, _WP1, ..."),
91
+ tag: z.string().optional().describe("Tag applied to each waypoint. Defaults to 'waypoint'."),
92
+ snapToGround: z
93
+ .boolean()
94
+ .optional()
95
+ .describe("Drop each point onto the surface below via a downward raycast. Default true."),
96
+ parentId: z
97
+ .string()
98
+ .optional()
99
+ .describe("Existing parent GameObject GUID to nest the waypoints under; otherwise a new route empty is created at the points' centroid."),
100
+ }, async (params) => {
101
+ const res = await bridge.send("place_patrol_route", params);
102
+ if (!res.success) {
103
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
104
+ }
105
+ return {
106
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
107
+ };
108
+ });
109
+ // ── assign_patrol_route ───────────────────────────────────────────
110
+ server.tool("assign_patrol_route", "Wire a placed route (or an arbitrary ordered GUID list) into an NpcBrain's Waypoints list on a target NPC. This is the list-of-GameObject-references case that plain set_property can't express. Pass either waypointIds (explicit order) or routeId (a route parent whose children become the waypoints in hierarchy order). The list count is returned; List<GameObject> refs may read back as handles/GUIDs via get_property, so trust the count or confirm patrol in play mode.", {
111
+ npcId: z
112
+ .string()
113
+ .describe("GUID of the GameObject holding the NpcBrain (or any component with a List<GameObject> waypoint property)."),
114
+ waypointIds: z
115
+ .array(z.string())
116
+ .optional()
117
+ .describe("Ordered waypoint GameObject GUIDs (e.g. from place_patrol_route). Takes precedence over routeId."),
118
+ routeId: z
119
+ .string()
120
+ .optional()
121
+ .describe("A route parent GUID whose children (in hierarchy order) become the waypoints."),
122
+ property: z
123
+ .string()
124
+ .optional()
125
+ .describe("The List<GameObject> property name to set. Defaults to 'Waypoints'. (Use 'SpawnPoints' to wire spawn points on a spawner.)"),
126
+ }, async (params) => {
127
+ const res = await bridge.send("assign_patrol_route", params);
128
+ if (!res.success) {
129
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
130
+ }
131
+ return {
132
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
133
+ };
134
+ });
135
+ // ── create_npc_spawner ────────────────────────────────────────────
136
+ server.tool("create_npc_spawner", "Generate a spawner Component that instantiates an NPC prefab over time / in escalating waves at spawn points, capped by maxAlive. RUN's swarm backbone and Sasquatched's round-start spawn. After generating: set NpcPrefab via set_prefab_ref, set SpawnPoints (reuse place_patrol_route to make a set of empties, then assign_patrol_route with property='SpawnPoints'), trigger_hotload + get_compile_errors. Verify by watching the GameObject count over time in play mode. Networked spawns use NetworkSpawn() and are host-only.", {
137
+ name: z.string().optional().describe("Class/file name. Defaults to 'NpcSpawner'."),
138
+ directory: z.string().optional().describe("Subdirectory under the project root. Defaults to 'Code'."),
139
+ mode: z
140
+ .enum(["continuous", "waves", "burst"])
141
+ .optional()
142
+ .describe("'continuous' (one every interval), 'waves' (a batch every interval, waveCount times), 'burst' (one batch then stop). Default 'waves'."),
143
+ count: z.number().optional().describe("NPCs per wave (waves) or per batch (burst/continuous batch). Default 5."),
144
+ interval: z.number().optional().describe("Seconds between spawns (continuous) or between waves (waves). Default 8."),
145
+ waveCount: z.number().optional().describe("Number of waves (waves mode). Default 3."),
146
+ waveGrowth: z
147
+ .number()
148
+ .optional()
149
+ .describe("Multiply count each wave (>1 = escalating). Default 1.0."),
150
+ radius: z.number().optional().describe("Random scatter radius around a spawn point. Default 200."),
151
+ maxAlive: z
152
+ .number()
153
+ .optional()
154
+ .describe("Cap on concurrent live NPCs (important so swarms don't melt the frame rate). Default 12."),
155
+ networked: z
156
+ .boolean()
157
+ .optional()
158
+ .describe("When true (default), spawn via NetworkSpawn() (host-only, try/catch solo-safe) so clients see the NPCs; false = a plain local Clone for solo/edit testing."),
159
+ }, async (params) => {
160
+ const res = await bridge.send("create_npc_spawner", params);
161
+ if (!res.success) {
162
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
163
+ }
164
+ return {
165
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
166
+ };
167
+ });
168
+ // ── simulate_npc_perception ───────────────────────────────────────
169
+ server.tool("simulate_npc_perception", "READ-ONLY edit-mode verifier: evaluate the NPC's perception math RIGHT NOW without entering play mode. Given an NPC (reads its NpcBrain SightRange/FovDegrees/EyeHeight/TargetTag + transform) and either a targetId or a point, it runs the SAME line-of-sight check the brain uses — FOV cone (dot vs the baked cosine), sight-range gate, and an occlusion trace from the eye to the target — and reports the result AND why. This is the keystone verifier: it makes the perception layer checkable in edit mode (no flaky screenshot timing) — e.g. place the Sasquatch, place a camper behind a tree, and confirm the tree blocks LOS. Call params override the brain's values, so it also works before/without an NpcBrain (uses defaults). Safe in play mode too (read-only, like raycast).", {
170
+ npcId: z
171
+ .string()
172
+ .describe("GUID of the NPC GameObject (ideally with an NpcBrain; its perception [Property] values are read)."),
173
+ targetId: z
174
+ .string()
175
+ .optional()
176
+ .describe("GUID of the target GameObject to test visibility to (e.g. a player). Provide this OR point."),
177
+ point: Vec3.optional().describe("A raw world point to test visibility to. Provide this OR targetId."),
178
+ sightRange: z.number().optional().describe("Override the sight range for this check (else read from the NpcBrain / default 1500)."),
179
+ fovDegrees: z.number().optional().describe("Override the FOV cone angle for this check (else read from the NpcBrain / default 110)."),
180
+ eyeHeight: z.number().optional().describe("Override the eye height for this check (else read from the NpcBrain / default 64)."),
181
+ targetTag: z
182
+ .string()
183
+ .optional()
184
+ .describe("Override the target tag (canSee also requires the target to carry this tag; else read from the NpcBrain / default 'player')."),
185
+ }, async (params) => {
186
+ const res = await bridge.send("simulate_npc_perception", params);
187
+ if (!res.success) {
188
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
189
+ }
190
+ return {
191
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
192
+ };
193
+ });
194
+ }
195
+ //# sourceMappingURL=npc.js.map
@@ -76,11 +76,13 @@ export function registerPlayModeTools(server, bridge) {
76
76
  };
77
77
  });
78
78
  // ── set_property ─────────────────────────────────────────────────
79
- server.tool("set_property", "Set a property value on a component (editor mode). Reads the value back to confirm", {
79
+ server.tool("set_property", "Set a property value on a component (editor mode), and PERSIST it (survives save+reload). Handles primitives, enums, value types (Color/Vector3 as comma strings), AND references: pass an asset PATH for Model/Material/Texture/SoundEvent props, or a GameObject GUID for GameObject/Component-typed props (resolved like set_component_reference). Returns success=false with a clear error if a path/GUID can't be resolved (no more silent null). For wiring object refs prefer set_component_reference; for prefab refs use set_prefab_ref", {
80
80
  id: z.string().describe("GUID of the GameObject"),
81
81
  component: z.string().describe("Component type name"),
82
82
  property: z.string().describe("Property name to set"),
83
- value: z.unknown().describe("New value — auto-converted to the correct type"),
83
+ value: z
84
+ .unknown()
85
+ .describe("New value as a string. Primitive: '5', 'true'. Color/Vector3: '1,0,0,1' / '0,0,200'. Enum: the member name. Asset ref (Model/Material/...): the asset path e.g. 'models/dev/box.vmdl'. GameObject/Component ref: the target GameObject's GUID. Empty/'null' clears the property"),
84
86
  }, async (params) => {
85
87
  const res = await bridge.send("set_property", params);
86
88
  if (!res.success) {
@@ -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 registerSelfTestTools(server: McpServer, bridge: BridgeClient): void;
4
+ //# sourceMappingURL=selftest.d.ts.map
@@ -0,0 +1,121 @@
1
+ import { existsSync, unlinkSync } from "fs";
2
+ import { join } from "path";
3
+ function report(checks, override, runId) {
4
+ const pass = checks.filter((c) => c.ok).length;
5
+ const total = checks.length;
6
+ let verdict = override;
7
+ if (!verdict) {
8
+ verdict =
9
+ pass === total
10
+ ? `HEALTHY — ${pass}/${total} checks passed`
11
+ : pass >= total - 1
12
+ ? `DEGRADED — ${pass}/${total} passed`
13
+ : `BROKEN — only ${pass}/${total} passed`;
14
+ }
15
+ const lines = checks
16
+ .map((c) => ` ${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? " — " + c.detail : ""}`)
17
+ .join("\n");
18
+ const json = JSON.stringify({ verdict, pass, total, runId: runId ?? null, checks }, null, 2);
19
+ return { content: [{ type: "text", text: `run_self_test: ${verdict}\n\n${lines}\n\n${json}` }] };
20
+ }
21
+ export function registerSelfTestTools(server, bridge) {
22
+ server.tool("run_self_test", "Run an end-to-end health check of the bridge: create a temp object, add a component, assign + measure a model, capture a screenshot, recompile a temp asset, remove the component — then clean it all up, reporting pass/fail per subsystem. Use it to confirm the install works or to catch regressions before a release. Safe and self-cleaning; refuses to run in play mode.", {}, async () => {
23
+ const runId = Date.now().toString(36);
24
+ const checks = [];
25
+ const add = (name, ok, detail = "") => checks.push({ name, ok, detail });
26
+ const send = (cmd, p = {}) => bridge.send(cmd, p, 15000);
27
+ let createdId = null;
28
+ let projectRoot = null;
29
+ let tempVmatAbs = null;
30
+ const tempVmatRel = `materials/__selftest_${runId}.vmat`;
31
+ try {
32
+ // 0. Connectivity
33
+ const pi = await send("get_project_info");
34
+ if (!pi.success) {
35
+ add("connectivity", false, pi.error ?? "no response");
36
+ return report(checks, "BROKEN — bridge not responding. Is s&box running with the Claude Bridge addon?");
37
+ }
38
+ projectRoot = pi.data?.path ?? null;
39
+ add("connectivity", true, "get_project_info round-trip OK");
40
+ // Pre-flight: refuse in play mode (the battery mutates the scene)
41
+ const ip = await send("is_playing");
42
+ if (ip.success && ip.data?.isPlaying) {
43
+ add("play-mode guard", false, "editor is in play mode");
44
+ return report(checks, "ABORTED — stop play mode first; the self-test mutates the scene.");
45
+ }
46
+ // 1. Create a temp object
47
+ const cg = await send("create_gameobject", { name: `__selftest_${runId}` });
48
+ const cgd = cg.data;
49
+ createdId = cgd?.id ?? cgd?.gameObject?.id ?? cgd?.guid ?? null;
50
+ if (!cg.success || !createdId) {
51
+ add("create_gameobject", false, cg.error ?? "no id returned");
52
+ return report(checks, "BROKEN — couldn't create a GameObject.");
53
+ }
54
+ add("create_gameobject", true, `id ${createdId}`);
55
+ // 2. Add a component
56
+ const ac = await send("add_component_with_properties", { id: createdId, component: "ModelRenderer" });
57
+ add("add_component", ac.success, ac.success ? "ModelRenderer added" : ac.error ?? "fail");
58
+ // 3. Assign a model
59
+ const am = await send("assign_model", { id: createdId, model: "models/dev/box.vmdl" });
60
+ add("assign_model", am.success, am.success ? "box.vmdl" : am.error ?? "fail");
61
+ // 4. Bounds round-trip — non-empty bounds prove the model write took effect
62
+ const gb = await send("get_bounds", { id: createdId });
63
+ const sz = gb.data?.size;
64
+ const nonEmpty = !!sz && Math.abs(sz.x ?? 0) + Math.abs(sz.y ?? 0) + Math.abs(sz.z ?? 0) > 0.001;
65
+ add("get_bounds", gb.success && nonEmpty, gb.success ? (nonEmpty ? "non-empty bounds" : "empty bounds (model not applied?)") : gb.error ?? "fail");
66
+ // 5. Capture (the new RenderToBitmap path)
67
+ const cv = await send("capture_view", { width: 640, height: 360 });
68
+ const cvPath = cv.data?.path;
69
+ const capOk = cv.success && !!cvPath && existsSync(cvPath);
70
+ add("capture_view", capOk, capOk ? "PNG written" : cv.error ?? "no PNG produced");
71
+ if (cvPath && existsSync(cvPath)) {
72
+ try {
73
+ unlinkSync(cvPath);
74
+ }
75
+ catch {
76
+ /* best effort */
77
+ }
78
+ }
79
+ // 6. Recompile a temp asset
80
+ const wf = await send("write_file", {
81
+ path: tempVmatRel,
82
+ content: `// selftest\n"Layer0"\n{\n\tshader "shaders/complex.shader"\n}\n`,
83
+ });
84
+ if (wf.success) {
85
+ if (projectRoot)
86
+ tempVmatAbs = join(projectRoot, tempVmatRel.replace(/\//g, "\\"));
87
+ const rc = await send("recompile_asset", { path: tempVmatRel });
88
+ add("recompile_asset", rc.success, rc.success ? "compiled temp .vmat" : rc.error ?? "fail");
89
+ }
90
+ else {
91
+ add("recompile_asset", false, `write_file failed: ${wf.error ?? "?"}`);
92
+ }
93
+ // 7. Remove the component (symmetric with step 2)
94
+ const rm = await send("remove_component", { id: createdId, component: "ModelRenderer" });
95
+ add("remove_component", rm.success, rm.success ? "removed" : rm.error ?? "fail");
96
+ return report(checks, null, runId);
97
+ }
98
+ finally {
99
+ // Cleanup — runs on success, throw, or early-return.
100
+ if (createdId) {
101
+ try {
102
+ await send("delete_gameobject", { id: createdId });
103
+ }
104
+ catch {
105
+ /* best effort */
106
+ }
107
+ }
108
+ for (const f of [tempVmatAbs, tempVmatAbs ? tempVmatAbs + "_c" : null]) {
109
+ if (f && existsSync(f)) {
110
+ try {
111
+ unlinkSync(f);
112
+ }
113
+ catch {
114
+ /* best effort */
115
+ }
116
+ }
117
+ }
118
+ }
119
+ });
120
+ }
121
+ //# sourceMappingURL=selftest.js.map
@@ -1,4 +1,18 @@
1
1
  import { z } from "zod";
2
+ import { readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { dirname, join } from "path";
5
+ // The running MCP server's own version (from package.json) — compared against the
6
+ // addon's reported BridgeVersion to catch a stale server vs. a newer addon (or vice
7
+ // versa), the #1 source of "the new tools aren't showing up" confusion.
8
+ let SERVER_VERSION = "unknown";
9
+ try {
10
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf-8"));
11
+ SERVER_VERSION = pkg.version ?? "unknown";
12
+ }
13
+ catch {
14
+ /* best-effort */
15
+ }
2
16
  /**
3
17
  * Diagnostic and health-check tool (get_bridge_status).
4
18
  * Reports connection state, latency, host/port, and editor version.
@@ -10,34 +24,41 @@ export function registerStatusTools(server, bridge) {
10
24
  const ipcDir = bridge.getIpcDir();
11
25
  const heartbeatAgeMs = bridge.getHeartbeatAgeMs();
12
26
  let latencyMs = null;
13
- let editorVersion = null;
27
+ let bridgeVersion = null;
28
+ let handlerCount = null;
14
29
  let roundTripOk = false;
15
30
  if (connected) {
16
31
  latencyMs = await bridge.ping();
17
- // A real round-trip distinguishes a live editor from one whose
18
- // heartbeat is fresh but whose request loop is stalled.
32
+ // A real round-trip via the addon's get_bridge_status command distinguishes
33
+ // a live editor from one whose heartbeat is fresh but whose request loop is
34
+ // stalled, and surfaces the bridge version + handler count.
19
35
  try {
20
- const res = await bridge.send("get_project_info", {}, 5000);
36
+ const res = await bridge.send("get_bridge_status", {}, 5000);
21
37
  roundTripOk = res.success;
22
38
  if (res.success && res.data) {
23
39
  const data = res.data;
24
- editorVersion = data.editorVersion ?? null;
40
+ bridgeVersion = data.version ?? null;
41
+ handlerCount = data.handlerCount ?? null;
25
42
  }
26
43
  }
27
44
  catch {
28
45
  // Non-fatal
29
46
  }
30
47
  }
48
+ const versionsAligned = bridgeVersion == null || bridgeVersion === SERVER_VERSION;
31
49
  const status = {
32
50
  connected,
33
51
  ipcDir,
34
52
  heartbeatAgeMs,
35
53
  roundTripOk,
54
+ bridgeVersion,
55
+ mcpServerVersion: SERVER_VERSION,
56
+ versionsAligned,
57
+ handlerCount,
36
58
  latencyMs: connected ? latencyMs : null,
37
59
  lastPong: connected
38
60
  ? new Date(bridge.getLastPongTime()).toISOString()
39
61
  : null,
40
- editorVersion,
41
62
  // legacy/cosmetic — there is no socket; transport is file IPC
42
63
  host: bridge.getHost(),
43
64
  port: bridge.getPort(),
@@ -47,7 +68,7 @@ export function registerStatusTools(server, bridge) {
47
68
  text = `Bridge NOT connected — no recent heartbeat in ${ipcDir}. Is s&box running with the Claude Bridge addon?`;
48
69
  }
49
70
  else if (roundTripOk) {
50
- text = `Bridge connected and responding (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago).`;
71
+ text = `Bridge connected and responding — addon v${bridgeVersion ?? "?"} / server v${SERVER_VERSION}, ${handlerCount ?? "?"} handlers (IPC: ${ipcDir}, heartbeat ${heartbeatAgeMs ?? "?"}ms ago).${versionsAligned ? "" : ` ⚠️ Version mismatch — restart Claude Code (and/or republish the addon) so the MCP server and addon match.`}`;
51
72
  }
52
73
  else {
53
74
  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.`;
@@ -8,7 +8,7 @@ import { z } from "zod";
8
8
  */
9
9
  export function registerTemplateTools(server, bridge) {
10
10
  // ── create_player_controller ──────────────────────────────────────
11
- server.tool("create_player_controller", "Generate a player controller script with WASD movement, mouse look, jumping, and sprint. Supports first-person and third-person camera modes", {
11
+ server.tool("create_player_controller", "Generate a player controller script with WASD movement, mouse look, jumping, and sprint. Supports first-person, third-person, and top-down movement modes. Optionally places a player rig (GameObject + CharacterController + Camera) in the scene — note the generated component is attached AFTER a trigger_hotload (it isn't in the TypeLibrary until a recompile)", {
12
12
  name: z
13
13
  .string()
14
14
  .optional()
@@ -18,9 +18,9 @@ export function registerTemplateTools(server, bridge) {
18
18
  .optional()
19
19
  .describe("Subdirectory under code/ for the file"),
20
20
  type: z
21
- .enum(["first_person", "third_person"])
21
+ .enum(["first_person", "third_person", "top_down"])
22
22
  .optional()
23
- .describe("Camera mode: 'first_person' or 'third_person'. Defaults to 'first_person'"),
23
+ .describe("Movement mode: 'first_person' (mouse-look body+camera, WASD relative to facing), 'third_person' (mouse yaw, WASD relative to facing, boom camera), or 'top_down' (screen-relative WASD, fixed overhead camera, no jump). Defaults to 'first_person'"),
24
24
  moveSpeed: z
25
25
  .number()
26
26
  .optional()
@@ -28,11 +28,23 @@ export function registerTemplateTools(server, bridge) {
28
28
  jumpForce: z
29
29
  .number()
30
30
  .optional()
31
- .describe("Jump force. Defaults to 350"),
31
+ .describe("Jump force (ignored for top_down). Defaults to 350"),
32
32
  sprintMultiplier: z
33
33
  .number()
34
34
  .optional()
35
- .describe("Sprint speed multiplier. Defaults to 1.5"),
35
+ .describe("Sprint speed multiplier (held 'run' action). Defaults to 1.5"),
36
+ placeInScene: z
37
+ .boolean()
38
+ .optional()
39
+ .describe("If true, build a player rig in the scene: a GameObject (tagged 'player') with a CharacterController and (unless createCamera=false) a Camera. The generated controller component is NOT attached in this call — trigger_hotload then add_component_with_properties on the returned GameObject. Defaults to false (file-only)."),
40
+ createCamera: z
41
+ .boolean()
42
+ .optional()
43
+ .describe("When placeInScene is true, also create a Camera (FP/TP: child at eye/boom offset; top_down: fixed overhead). Defaults to true."),
44
+ spawnPosition: z
45
+ .object({ x: z.number(), y: z.number(), z: z.number() })
46
+ .optional()
47
+ .describe("When placeInScene is true, the world position to spawn the player rig at. Defaults to the origin."),
36
48
  }, async (params) => {
37
49
  const res = await bridge.send("create_player_controller", params);
38
50
  if (!res.success) {
@@ -17,13 +17,13 @@ import { z } from "zod";
17
17
  */
18
18
  export function registerWorldTools(server, bridge) {
19
19
  // ── invoke_button ────────────────────────────────────────────────
20
- server.tool("invoke_button", "Press a [Button] on a component (e.g. 'Build Terrain' on MapBuilder). Matches by attribute label, then method name. The keystone for driving any component-with-buttons workflow.", {
20
+ server.tool("invoke_button", "Call a no-argument public method on a component. Matching is tried in order: (1) a [Button] attribute label, (2) the exact method NAME, (3) case-insensitive name with spaces stripped. So this calls ANY parameterless public method, not only [Button]-attributed ones (e.g. 'StartGame' works even without a [Button]). This is the general 'call a component method to drive game state' tool. LIMITATION: parameterless methods only methods that take arguments are skipped. (list_component_buttons only lists [Button] methods, so a plain method may be invokable yet not appear there.)", {
21
21
  component: z
22
22
  .string()
23
- .describe("Component type name (e.g. 'MapBuilder', 'CaveBuilder')"),
23
+ .describe("Component type name (e.g. 'MapBuilder', 'SasquatchedGame')"),
24
24
  button: z
25
25
  .string()
26
- .describe("Button label or method name (e.g. 'Build Terrain', 'Generate Forest')"),
26
+ .describe("A [Button] label OR a public no-arg method name (e.g. 'Build Terrain', 'StartGame'); case- and space-insensitive"),
27
27
  id: z
28
28
  .string()
29
29
  .optional()
@@ -35,7 +35,7 @@ export function registerWorldTools(server, bridge) {
35
35
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
36
36
  });
37
37
  // ── list_component_buttons ───────────────────────────────────────
38
- server.tool("list_component_buttons", "List all [Button]s available on a component. Use before invoke_button to discover what's there.", {
38
+ server.tool("list_component_buttons", "List the [Button]-attributed methods on a component. NOTE: this only finds methods decorated with [Button]; invoke_button can ALSO call any plain public no-arg method by name, so a method missing here may still be invokable. Use describe_type / get_method_signature to find non-button methods.", {
39
39
  component: z.string().describe("Component type name"),
40
40
  id: z.string().optional().describe("Optional GameObject GUID"),
41
41
  }, async (params) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbox-mcp-server",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",