sbox-mcp-server 1.9.0 → 1.12.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
@@ -44,6 +44,7 @@ import { registerSelfTestTools } from "./tools/selftest.js";
44
44
  import { registerGameplayTools } from "./tools/gameplay.js";
45
45
  import { registerNpcTools } from "./tools/npc.js";
46
46
  import { registerInspectionTools } from "./tools/inspection.js";
47
+ import { registerInputTools } from "./tools/inputs.js";
47
48
  // ── CLI flags ──────────────────────────────────────────────────────
48
49
  const args = process.argv.slice(2);
49
50
  /** Read the package version from package.json, or return "unknown" on failure. */
@@ -191,6 +192,7 @@ registerSelfTestTools(server, bridge);
191
192
  registerGameplayTools(server, bridge);
192
193
  registerNpcTools(server, bridge);
193
194
  registerInspectionTools(server, bridge);
195
+ registerInputTools(server, bridge);
194
196
  /** Start the MCP server on stdio and attempt initial Bridge connection. */
195
197
  async function main() {
196
198
  const transport = new StdioServerTransport();
@@ -76,5 +76,27 @@ export function registerAssetTools(server, bridge) {
76
76
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
77
77
  };
78
78
  });
79
+ // ── copy_asset_with_dependencies ─────────────────────────────────
80
+ server.tool("copy_asset_with_dependencies", "Copy a project asset and its full dependency closure (via Asset.GetReferences(deep:true)) into a target directory, preserving relative path structure so material references to textures keep resolving. SHADOW GUARD: refuses to write under core engine trees (models/citizen, models/dev, materials/dev, materials/default) -- copying there triggers an infinite asset-recompile loop (BRIDGE_GOTCHAS #5). Cloud/procedural/transient assets are skipped with a reason. Returns { copied:[{from,to}], skipped:[{path,reason}], count, note }.", {
81
+ sourcePath: z
82
+ .string()
83
+ .describe("Absolute path OR project-relative path of the source asset (e.g. 'models/props/crate.vmdl'). Use search_assets to find the exact path."),
84
+ targetDir: z
85
+ .string()
86
+ .optional()
87
+ .describe("Project-relative destination directory (e.g. 'Assets/library'). Defaults to 'Assets/library'"),
88
+ overwrite: z
89
+ .boolean()
90
+ .optional()
91
+ .describe("Overwrite existing files at the destination. Defaults to false"),
92
+ }, async (params) => {
93
+ const res = await bridge.send("copy_asset_with_dependencies", params);
94
+ if (!res.success) {
95
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
96
+ }
97
+ return {
98
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
99
+ };
100
+ });
79
101
  }
80
102
  //# sourceMappingURL=assets.js.map
@@ -9,17 +9,28 @@ import { z } from "zod";
9
9
  * change, take_screenshot and read the result. Animation *playback* is
10
10
  * runtime, but poses preview in-editor via PlayAnimationsInEditorScene.
11
11
  */
12
- const ColorSchema = z
13
- .object({
12
+ // Colour / vector accepted as EITHER an object OR a comma string, passed
13
+ // through unchanged. The C# handler parses both forms (source of truth). See
14
+ // the cross-language vector/color contract.
15
+ const ColorObject = z.object({
14
16
  r: z.number().min(0).describe("Red, 0-1"),
15
17
  g: z.number().min(0).describe("Green, 0-1"),
16
18
  b: z.number().min(0).describe("Blue, 0-1"),
17
19
  a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
18
- })
19
- .describe("RGBA colour as 0-1 floats (model tint)");
20
+ });
21
+ const ColorSchema = z
22
+ .union([
23
+ ColorObject,
24
+ z.string().describe('Comma string "r,g,b,a", e.g. "1,0,0,1"'),
25
+ ])
26
+ .describe('RGBA colour — object {r,g,b,a} (0-1) OR comma string "r,g,b,a" (model tint)');
27
+ const Vector3Object = z.object({ x: z.number(), y: z.number(), z: z.number() });
20
28
  const Vector3Schema = z
21
- .object({ x: z.number(), y: z.number(), z: z.number() })
22
- .describe("World position {x,y,z}");
29
+ .union([
30
+ Vector3Object,
31
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
32
+ ])
33
+ .describe('World vector — object {x,y,z} OR comma string "x,y,z"');
23
34
  const RotationSchema = z
24
35
  .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
25
36
  .describe("Rotation {pitch,yaw,roll} in degrees");
@@ -241,8 +252,9 @@ export function registerCharacterTools(server, bridge) {
241
252
  z.number(),
242
253
  z.boolean(),
243
254
  z.object({ x: z.number(), y: z.number(), z: z.number() }),
255
+ z.string().describe('Vector as a comma string "x,y,z"'),
244
256
  ])
245
- .describe("Value: number (float), boolean, or {x,y,z} vector"),
257
+ .describe('Value: number (float), boolean, or a vector as {x,y,z} OR a comma string "x,y,z"'),
246
258
  type: z
247
259
  .enum(["float", "int", "bool", "vector"])
248
260
  .optional()
@@ -74,5 +74,28 @@ export function registerComponentTools(server, bridge) {
74
74
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
75
75
  };
76
76
  });
77
+ // ── invoke_method ────────────────────────────────────────────────
78
+ server.tool("invoke_method", "Call a public method BY NAME on a component of a live scene GameObject, passing ARGUMENTS. The with-args sibling of invoke_button (which only calls parameterless [Button]/methods on a scene component). Finds a public method matching name + arg-count, coerces each JSON arg to the parameter type (primitives/enums; Color/Vector3 as comma strings '1,0,0,1'; asset refs as a path; GameObject/Component refs as a target GUID), invokes it, and returns the method's return value as a string (null for void). Returns success=false with a clear error on resolve/coerce/throw", {
79
+ id: z.string().describe("GUID of the GameObject"),
80
+ component: z
81
+ .string()
82
+ .optional()
83
+ .describe("Component type name to target (e.g. 'Health', 'PlayerController'). Omit to search all components on the object for a method matching name + arg-count"),
84
+ method: z
85
+ .string()
86
+ .describe("Name of the public method to call (e.g. 'TakeDamage', 'AddGold')"),
87
+ args: z
88
+ .array(z.unknown())
89
+ .optional()
90
+ .describe("Ordered arguments, each coerced to the matching parameter's type. Numbers/bools/strings pass through; Color/Vector3/Rotation as comma strings '1,0,0,1'; enum member names; ASSET refs as a path ('models/dev/box.vmdl'); GameObject/Component refs as a target GUID. Omit (or []) for a no-arg method"),
91
+ }, async (params) => {
92
+ const res = await bridge.send("invoke_method", params);
93
+ if (!res.success) {
94
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
95
+ }
96
+ return {
97
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
98
+ };
99
+ });
77
100
  }
78
101
  //# sourceMappingURL=components.js.map
@@ -94,6 +94,15 @@ function tailLines(text, n) {
94
94
  const lines = text.split(/\r?\n/);
95
95
  return lines.slice(Math.max(0, lines.length - n));
96
96
  }
97
+ // A 3D point accepted as EITHER an object {x,y,z} OR a comma string "x,y,z",
98
+ // passed through unchanged. The C# handler parses both forms (source of truth).
99
+ // See the cross-language vector/color contract.
100
+ const Vector3Schema = z
101
+ .union([
102
+ z.object({ x: z.number(), y: z.number(), z: z.number() }),
103
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
104
+ ])
105
+ .describe('World point — object {x,y,z} OR comma string "x,y,z"');
97
106
  export function registerDiagnosticTools(server, bridge) {
98
107
  // ── read_log ───────────────────────────────────────────────────────
99
108
  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.", {
@@ -142,7 +151,7 @@ export function registerDiagnosticTools(server, bridge) {
142
151
  return { content: [{ type: "text", text: header + out.join("\n") }] };
143
152
  });
144
153
  // ── get_compile_errors ─────────────────────────────────────────────
145
- 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.", {
154
+ 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). Filters out the noisy 'Broken Reference: package.local.* (the compiler failed)' cascade (which masks the real cause) and surfaces the underlying '[Generic] Error | ...CSxxxx... file:line' diagnostics. Returns the real error lines, or an all-clear.", {
146
155
  lines: z
147
156
  .number()
148
157
  .int()
@@ -175,9 +184,38 @@ export function registerDiagnosticTools(server, bridge) {
175
184
  };
176
185
  }
177
186
  const recent = tailLines(text, n);
178
- const re = /(error CS\d+|Compile of .* Failed|Exception|Couldn't add project|Broken Reference|StackTrace|^\s*at Sandbox\.)/i;
179
- const hits = recent.filter((l) => re.test(l));
180
- if (hits.length === 0) {
187
+ // The cascade: when a project's code fails to compile, every dependent
188
+ // package (including the bridge's own editor assembly) emits a
189
+ // "Broken Reference: package.local.<x> (the compiler failed)"
190
+ // line. There can be dozens of these and they MASK the real diagnostic —
191
+ // so we drop them and surface the actual CSxxxx / [Generic] Error lines.
192
+ const cascadeRe = /Broken Reference:.*\(the compiler failed\)/i;
193
+ // Real compile diagnostics. We accept:
194
+ // - any line carrying a C# error code (error CS#### or "CS#### | file:line")
195
+ // - the "[Generic] Error | ..." diagnostic lines s&box emits
196
+ // - genuine compile-failure / exception markers
197
+ // Whitelist (always surface, even with NO file path), e.g. a bare
198
+ // location like "- :352,1" that s&box prints for project-level errors.
199
+ const realErrorRe = /(error CS\d+|\bCS\d{3,5}\b|\[Generic\]\s*Error|Compile of .* Failed|Couldn't add project|Unhandled [Ee]xception|^\s*at Sandbox\.|StackTrace)/;
200
+ const noFileWhitelistRe = /^\s*-\s*:\d+,\d+/; // e.g. "- :352,1"
201
+ const cascadeLines = recent.filter((l) => cascadeRe.test(l));
202
+ const realHits = recent.filter((l) => !cascadeRe.test(l) && (realErrorRe.test(l) || noFileWhitelistRe.test(l)));
203
+ if (realHits.length === 0) {
204
+ if (cascadeLines.length > 0) {
205
+ // We saw the masking cascade but none of the underlying diagnostics
206
+ // fell within the scanned window — point Claude at the fuller log.
207
+ return {
208
+ content: [
209
+ {
210
+ type: "text",
211
+ text: `Saw ${cascadeLines.length} "Broken Reference: package.local.* (the compiler failed)" cascade line(s), ` +
212
+ `but the real compile error isn't in the last ${n} lines (the cascade masks it). ` +
213
+ `The underlying CSxxxx / [Generic] Error line is likely just above — call read_log ` +
214
+ `with more lines (e.g. lines: 1000) to find it, or get_compile_errors with a larger 'lines'.\n(${path})`,
215
+ },
216
+ ],
217
+ };
218
+ }
181
219
  return {
182
220
  content: [
183
221
  {
@@ -187,11 +225,14 @@ export function registerDiagnosticTools(server, bridge) {
187
225
  ],
188
226
  };
189
227
  }
228
+ const suffix = cascadeLines.length > 0
229
+ ? `\n\n(Filtered out ${cascadeLines.length} "Broken Reference … (the compiler failed)" cascade line(s) that mask the real cause.)`
230
+ : "";
190
231
  return {
191
232
  content: [
192
233
  {
193
234
  type: "text",
194
- text: `Found ${hits.length} error/exception line(s) in the last ${n} log lines:\n\n${hits.join("\n")}`,
235
+ text: `Found ${realHits.length} real error line(s) in the last ${n} log lines:\n\n${realHits.join("\n")}${suffix}`,
195
236
  },
196
237
  ],
197
238
  };
@@ -199,10 +240,9 @@ export function registerDiagnosticTools(server, bridge) {
199
240
  // ── frame_camera ─────────────────────────────────────────────────── (bridge)
200
241
  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.", {
201
242
  id: z.string().optional().describe("GUID of a GameObject to frame on"),
202
- position: z
203
- .object({ x: z.number(), y: z.number(), z: z.number() })
243
+ position: Vector3Schema
204
244
  .optional()
205
- .describe("World point to frame on (use instead of id)"),
245
+ .describe('World point to frame on (use instead of id) — object {x,y,z} or comma string "x,y,z"'),
206
246
  radius: z
207
247
  .number()
208
248
  .optional()
@@ -219,14 +259,12 @@ export function registerDiagnosticTools(server, bridge) {
219
259
  // ── screenshot_from ──────────────────────────────────────────────── (bridge)
220
260
  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.", {
221
261
  id: z.string().optional().describe("GUID of a GameObject to frame"),
222
- position: z
223
- .object({ x: z.number(), y: z.number(), z: z.number() })
262
+ position: Vector3Schema
224
263
  .optional()
225
- .describe("Camera world position (use instead of id)"),
226
- lookAt: z
227
- .object({ x: z.number(), y: z.number(), z: z.number() })
264
+ .describe('Camera world position (use instead of id) — object {x,y,z} or comma string "x,y,z"'),
265
+ lookAt: Vector3Schema
228
266
  .optional()
229
- .describe("World point to look at (pair with position)"),
267
+ .describe('World point to look at (pair with position) — object {x,y,z} or comma string "x,y,z"'),
230
268
  rotation: z
231
269
  .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
232
270
  .optional()
@@ -458,14 +496,12 @@ export function registerDiagnosticTools(server, bridge) {
458
496
  // ── capture_view ──────────────────────────────────────────────────── (bridge, Batch 34)
459
497
  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
498
  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() })
499
+ position: Vector3Schema
463
500
  .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() })
501
+ .describe('Camera world position (temp camera; use instead of id) — object {x,y,z} or comma string "x,y,z"'),
502
+ lookAt: Vector3Schema
467
503
  .optional()
468
- .describe("World point to look at (pair with position)"),
504
+ .describe('World point to look at (pair with position) — object {x,y,z} or comma string "x,y,z"'),
469
505
  rotation: z
470
506
  .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
471
507
  .optional()
@@ -9,13 +9,21 @@ import { z } from "zod";
9
9
  * Uses shared Zod schemas (Vector3Schema, RotationSchema) for consistent
10
10
  * Vector3 and Rotation parameter validation across multiple tools.
11
11
  */
12
- const Vector3Schema = z
13
- .object({
12
+ // A 3D vector accepted as EITHER an object {x,y,z} OR a comma string "x,y,z".
13
+ // The value is passed through to the bridge unchanged; the C# handler parses
14
+ // both forms (C# is the source of truth for parsing). See the cross-language
15
+ // vector/color contract.
16
+ const Vector3Object = z.object({
14
17
  x: z.number().describe("X coordinate"),
15
18
  y: z.number().describe("Y coordinate"),
16
19
  z: z.number().describe("Z coordinate"),
17
- })
18
- .describe("3D vector with x, y, z components");
20
+ });
21
+ const Vector3Schema = z
22
+ .union([
23
+ Vector3Object,
24
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
25
+ ])
26
+ .describe('3D vector — object {x,y,z} OR comma string "x,y,z"');
19
27
  const RotationSchema = z
20
28
  .object({
21
29
  pitch: z.number().describe("Pitch angle in degrees"),
@@ -35,7 +43,7 @@ export function registerGameObjectTools(server, bridge) {
35
43
  scale: z
36
44
  .union([z.number(), Vector3Schema])
37
45
  .optional()
38
- .describe("Uniform scale (number) or per-axis scale (Vector3)"),
46
+ .describe('Uniform scale (number) or per-axis scale — object {x,y,z} or comma string "x,y,z"'),
39
47
  parent: z
40
48
  .string()
41
49
  .optional()
@@ -126,7 +134,7 @@ export function registerGameObjectTools(server, bridge) {
126
134
  scale: z
127
135
  .union([z.number(), Vector3Schema])
128
136
  .optional()
129
- .describe("New scale — uniform number or per-axis Vector3"),
137
+ .describe('New scale — uniform number, per-axis object {x,y,z}, or comma string "x,y,z"'),
130
138
  local: z
131
139
  .boolean()
132
140
  .optional()
@@ -14,13 +14,20 @@ import { z } from "zod";
14
14
  * The `sbox-scaffold-game` skill orchestrates these into a playable starter.
15
15
  * All scene/file-mutating; refused during play mode by the bridge dispatch.
16
16
  */
17
- const Vector3Schema = z
18
- .object({
17
+ // A 3D vector accepted as EITHER an object {x,y,z} OR a comma string "x,y,z",
18
+ // passed through unchanged. The C# handler parses both forms (source of truth).
19
+ // See the cross-language vector/color contract.
20
+ const Vector3Object = z.object({
19
21
  x: z.number().describe("X coordinate"),
20
22
  y: z.number().describe("Y coordinate"),
21
23
  z: z.number().describe("Z coordinate"),
22
- })
23
- .describe("3D vector with x, y, z components");
24
+ });
25
+ const Vector3Schema = z
26
+ .union([
27
+ Vector3Object,
28
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
29
+ ])
30
+ .describe('3D vector — object {x,y,z} OR comma string "x,y,z"');
24
31
  const RotationSchema = z
25
32
  .object({
26
33
  pitch: z.number().describe("Pitch angle in degrees"),
@@ -182,7 +189,127 @@ export function registerGameplayTools(server, bridge) {
182
189
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
183
190
  };
184
191
  });
185
- // ── create_pickup ─────────────────────────────────────────────────
192
+ // ── create_economy_wallet ─────────────────────────────────────────
193
+ server.tool("create_economy_wallet", "Generate a host-authoritative currency Wallet component: a [Sync(SyncFlags.FromHost)] Money balance (only the host can write it — plain [Sync] money is the classic economy exploit) with AddMoney / TrySpend / SetMoney / CanAfford and an OnMoneyChanged event. Single-player safe. Optionally attached to an existing GameObject by GUID (after a hotload). Pairs with a save system for persistence. Mined from the most-requested currency pattern across 51 games.", {
194
+ name: z.string().optional().describe("Class name. Defaults to 'Wallet'"),
195
+ directory: z
196
+ .string()
197
+ .optional()
198
+ .describe("Subdirectory for the .cs file. Defaults to 'Code'"),
199
+ startingMoney: z
200
+ .number()
201
+ .int()
202
+ .optional()
203
+ .describe("Initial balance the host seeds on start. Defaults to 0"),
204
+ targetId: z
205
+ .string()
206
+ .optional()
207
+ .describe("GUID of an existing GameObject to attach the Wallet to (only attaches if the type is already loaded — hotload first)"),
208
+ }, async (params) => {
209
+ const res = await bridge.send("create_economy_wallet", params);
210
+ if (!res.success) {
211
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
212
+ }
213
+ return {
214
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
215
+ };
216
+ });
217
+ // ── create_round_phase_machine ────────────────────────────────────
218
+ server.tool("create_round_phase_machine", "Generate a host-authoritative round/phase machine: a [Sync(SyncFlags.FromHost)] CurrentPhase cycled through your named phases on a per-phase timer (host-only), with a static OnPhaseChanged event that fires on every machine. Great for round/match flow, match phases, or a day/night cycle. Single-player safe. Optionally attached to an existing GameObject by GUID (after a hotload). Mined from the round-flow pattern across the 51 games.", {
219
+ name: z.string().optional().describe("Class name. Defaults to 'GameDirector'"),
220
+ directory: z.string().optional().describe("Subdirectory for the .cs file. Defaults to 'Code'"),
221
+ phases: z
222
+ .array(z.string())
223
+ .optional()
224
+ .describe('Ordered phase names (become an enum), e.g. ["Lobby","Day","Night","Payout"]. Defaults to ["Lobby","Active","Ended"]'),
225
+ duration: z
226
+ .number()
227
+ .optional()
228
+ .describe("Default seconds per phase (each phase also gets its own tunable [Property]). Defaults to 60"),
229
+ loop: z
230
+ .boolean()
231
+ .optional()
232
+ .describe("Loop back to the first phase after the last (true) or hold on the last phase (false). Defaults to true"),
233
+ targetId: z
234
+ .string()
235
+ .optional()
236
+ .describe("GUID of an existing GameObject to attach to (only if the type is already loaded — hotload first)"),
237
+ }, async (params) => {
238
+ const res = await bridge.send("create_round_phase_machine", params);
239
+ if (!res.success) {
240
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
241
+ }
242
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
243
+ });
244
+ // ── create_day_night_clock ────────────────────────────────────────
245
+ server.tool("create_day_night_clock", "Generate a host-authoritative time-of-day clock: [Sync(SyncFlags.FromHost)] TimeOfDay (0–24) + Day advancing by Time.Delta, IsDay/IsNight from sunrise/sunset hours, and static OnNewDay / OnDayNightChanged events to drive lighting, NPC schedules, or spawns. Single-player safe. Pairs with create_round_phase_machine. Optionally attached to a GameObject by GUID (after a hotload).", {
246
+ name: z.string().optional().describe("Class name. Defaults to 'DayNightClock'"),
247
+ directory: z.string().optional().describe("Subdirectory for the .cs file. Defaults to 'Code'"),
248
+ dayLengthSeconds: z.number().optional().describe("Real seconds per in-game day. Defaults to 600 (10 min)"),
249
+ startHour: z.number().optional().describe("Hour the clock starts at (0–24). Defaults to 8"),
250
+ sunriseHour: z.number().optional().describe("Hour day begins. Defaults to 6"),
251
+ sunsetHour: z.number().optional().describe("Hour night begins. Defaults to 20"),
252
+ targetId: z.string().optional().describe("GUID of an existing GameObject to attach to (hotload first)"),
253
+ }, async (params) => {
254
+ const res = await bridge.send("create_day_night_clock", params);
255
+ if (!res.success) {
256
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
257
+ }
258
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
259
+ });
260
+ // ── create_interactable ───────────────────────────────────────────
261
+ server.tool("create_interactable", "Generate a Component.IPressable interactable: the built-in PlayerController 'use' key drives Press()/Hover()/Blur() with no custom player code. Includes a static OnPressed event, an optional cooldown (TimeUntil), and a private OnPress() extensionpoint for effects. For host-authoritative side-effects call an [Rpc.Host] from OnPress(). The Prompt property is left to your game's HUD. Optionally attached to an existing GameObject by GUID (after a hotload).", {
262
+ name: z.string().optional().describe("Class name. Defaults to 'Interactable'"),
263
+ directory: z.string().optional().describe("Subdirectory for the .cs file. Defaults to 'Code'"),
264
+ prompt: z
265
+ .string()
266
+ .optional()
267
+ .describe("Prompt string shown by the game's HUD when hovering. Defaults to 'Press'"),
268
+ cooldownSeconds: z
269
+ .number()
270
+ .optional()
271
+ .describe("Seconds before the interactable can be pressed again. 0 = no cooldown. Defaults to 0"),
272
+ targetId: z
273
+ .string()
274
+ .optional()
275
+ .describe("GUID of an existing GameObject to attach the component to (only attaches if the type is already loaded — hotload first)"),
276
+ }, async (params) => {
277
+ const res = await bridge.send("create_interactable", params);
278
+ if (!res.success) {
279
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
280
+ }
281
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
282
+ });
283
+ // -- create_weighted_loot_table ----------------------------------------
284
+ server.tool("create_weighted_loot_table", "Generate a cumulative-weight random loot picker: parallel Name/Weight lists (inspector-editable), a Roll() method that returns a winning entry name and fires a static OnLoot event, and optional pity (guarantee the last/rarest entry after PityAfter consecutive non-rare rolls). Roll() is host-authoritative -- only call it on the host and replicate the result (clients rolling their own loot is equivalent to clients writing their own money balance). Optionally attached to an existing GameObject by GUID (after a hotload).", {
285
+ name: z.string().optional().describe("Class name. Defaults to 'LootTable'"),
286
+ directory: z
287
+ .string()
288
+ .optional()
289
+ .describe("Subdirectory for the .cs file. Defaults to 'Code'"),
290
+ entries: z
291
+ .union([
292
+ z.array(z.object({ name: z.string(), weight: z.number() })),
293
+ z.string().describe('Compact "name:weight,name:weight" string, e.g. "common:70,uncommon:25,rare:5"'),
294
+ ])
295
+ .optional()
296
+ .describe("Loot table entries. Defaults to common:70 / uncommon:25 / rare:5"),
297
+ pity: z
298
+ .boolean()
299
+ .optional()
300
+ .describe("If true, guarantee the last (rarest) entry after PityAfter consecutive non-rare rolls. Defaults to false"),
301
+ targetId: z
302
+ .string()
303
+ .optional()
304
+ .describe("GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first)"),
305
+ }, async (params) => {
306
+ const res = await bridge.send("create_weighted_loot_table", params);
307
+ if (!res.success) {
308
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
309
+ }
310
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
311
+ });
312
+ // -- create_pickup -----------------------------------------------------
186
313
  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
314
  name: z.string().optional().describe("Class name. Defaults to 'Pickup'"),
188
315
  directory: z
@@ -223,5 +350,33 @@ export function registerGameplayTools(server, bridge) {
223
350
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
224
351
  };
225
352
  });
353
+ // -- create_save_system ------------------------------------------------
354
+ server.tool("create_save_system", "Generate a versioned save-system component: a SaveData POCO with Version bump on schema change, dirty-flag autosave on a TimeUntil timer, clamp-on-load Sanitize() for corrupt/hand-edited saves, and delete-on-version-mismatch to start fresh instead of crashing. Runs only on the owning machine (IsProxy guard). Fires static OnLoaded/OnSaved hooks for HUD and analytics. FileSystem.Data.ReadJsonOrDefault/WriteJson verified live on the current SDK. Optionally attached to an existing GameObject by GUID (after a hotload).", {
355
+ name: z.string().optional().describe("Class name. Defaults to 'SaveSystem'"),
356
+ directory: z.string().optional().describe("Subdirectory for the .cs file. Defaults to 'Code'"),
357
+ fileName: z
358
+ .string()
359
+ .optional()
360
+ .describe("Save file name under FileSystem.Data (e.g. 'save.json'). Defaults to 'save.json'"),
361
+ version: z
362
+ .number()
363
+ .int()
364
+ .optional()
365
+ .describe("Schema version embedded in SaveData. Old saves with a different version start fresh. Defaults to 1"),
366
+ autosaveSeconds: z
367
+ .number()
368
+ .optional()
369
+ .describe("Seconds between autosave ticks (0 disables autosave). Defaults to 10"),
370
+ targetId: z
371
+ .string()
372
+ .optional()
373
+ .describe("GUID of an existing GameObject to attach to (only if the type is already loaded -- hotload first)"),
374
+ }, async (params) => {
375
+ const res = await bridge.send("create_save_system", params);
376
+ if (!res.success) {
377
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
378
+ }
379
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
380
+ });
226
381
  }
227
382
  //# sourceMappingURL=gameplay.js.map
@@ -0,0 +1,18 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { BridgeClient } from "../transport/bridge-client.js";
3
+ /**
4
+ * Input action tools — register the custom named verbs a scaffolded game needs.
5
+ *
6
+ * s&box stores input actions in the project's `.sbproj` under
7
+ * `Metadata.InputSettings.Actions[]` (each: { Name, KeyboardCode, GamepadCode?,
8
+ * GroupName }). A game that defines NO actions is handed a default set by the
9
+ * engine (Forward/Back/Left/Right/Jump/Use/...). The catch: the moment a game
10
+ * writes its own InputSettings block, that block becomes the authoritative
11
+ * full list — so the handler seeds the full default set when none exists, then
12
+ * appends the custom action, so movement/use survive.
13
+ *
14
+ * ensure_input_action — add a named action (e.g. "interact") if missing so
15
+ * Input.Pressed("interact") actually fires in play mode.
16
+ */
17
+ export declare function registerInputTools(server: McpServer, bridge: BridgeClient): void;
18
+ //# sourceMappingURL=inputs.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Input action tools — register the custom named verbs a scaffolded game needs.
4
+ *
5
+ * s&box stores input actions in the project's `.sbproj` under
6
+ * `Metadata.InputSettings.Actions[]` (each: { Name, KeyboardCode, GamepadCode?,
7
+ * GroupName }). A game that defines NO actions is handed a default set by the
8
+ * engine (Forward/Back/Left/Right/Jump/Use/...). The catch: the moment a game
9
+ * writes its own InputSettings block, that block becomes the authoritative
10
+ * full list — so the handler seeds the full default set when none exists, then
11
+ * appends the custom action, so movement/use survive.
12
+ *
13
+ * ensure_input_action — add a named action (e.g. "interact") if missing so
14
+ * Input.Pressed("interact") actually fires in play mode.
15
+ */
16
+ export function registerInputTools(server, bridge) {
17
+ // ── ensure_input_action ───────────────────────────────────────────
18
+ server.tool("ensure_input_action", 'Register a custom named INPUT ACTION in the project so a generated game\'s custom verbs work in play mode. Writes to <project>.sbproj → Metadata.InputSettings.Actions[]. Idempotent: if the action already exists it is left alone (pass update=true to rebind its key). If the project has no InputSettings yet, the full DEFAULT action set (Forward/Back/Left/Right/Jump/Use/attack1/...) is seeded first so player movement/use are preserved — the engine only auto-injects defaults when a game defines NONE. After adding, call it from game code with Input.Pressed("name") / Input.Down("name") / Input.Released("name"). Note: input config is read at project load, so restart_editor (or reload the project) for a new action to take effect in play mode.', {
19
+ name: z
20
+ .string()
21
+ .describe('The action verb game code will call, e.g. "interact", "sprint", "drop". Matches Input.Pressed("interact").'),
22
+ keyboardKey: z
23
+ .string()
24
+ .optional()
25
+ .describe('Default keyboard binding, e.g. "e", "f", "space", "mouse1", "shift". Omit to add the action with no default key (player can bind it).'),
26
+ group: z
27
+ .string()
28
+ .optional()
29
+ .describe('UI group the action is listed under in the bindings menu (e.g. "Actions", "Movement", "Other"). Defaults to "Actions".'),
30
+ update: z
31
+ .boolean()
32
+ .optional()
33
+ .describe("If the action already exists, rebind its keyboardKey to the provided value instead of leaving it untouched. Default false (idempotent no-op when present)."),
34
+ }, async (params) => {
35
+ const res = await bridge.send("ensure_input_action", params);
36
+ if (!res.success) {
37
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
38
+ }
39
+ return {
40
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
41
+ };
42
+ });
43
+ }
44
+ //# sourceMappingURL=inputs.js.map
@@ -38,6 +38,30 @@ export function registerInspectionTools(server, bridge) {
38
38
  return { content: [{ type: "text", text: `Error: ${res.error}` }] };
39
39
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
40
40
  });
41
+ // ── sandbox_lint ──────────────────────────────────────────────────
42
+ server.tool("sandbox_lint", "Static-scan the project's C# for s&box sandbox whitelist violations BEFORE they cause compile errors: System.MathF (use MathX), System.Math (use MathX), Array.Clone() (use .ToArray()), System.Net / raw sockets (use Sandbox.Http), System.IO.File (use FileSystem.Data), and raw System.Threading.Thread (use async/Task or GameTask). Returns { scanned, findings: [{file, line, match, advice}], clean }. Scope to a subdirectory with the directory param.", {
43
+ directory: z
44
+ .string()
45
+ .optional()
46
+ .describe("Subdirectory under the project root to scan (e.g. 'Code', 'Code/Player'). Defaults to 'Code'"),
47
+ }, async (params) => {
48
+ const res = await bridge.send("sandbox_lint", params);
49
+ if (!res.success)
50
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
51
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
52
+ });
53
+ // ── razor_lint ────────────────────────────────────────────────────
54
+ server.tool("razor_lint", "Static-scan .razor and .razor.scss files for the silent footguns that crash the Razor transpiler or stylesheet engine with no useful error message: switch expressions inside @code blocks (use if/else instead), non-ASCII/emoji inside @code (move to markup or a string constant), PanelComponent subclasses missing a BuildHash override (panel never re-renders), and root uppercase type-selector rules in .razor.scss (silently skipped -- use a class selector like .my-panel). Returns { scanned, findings: [{file, line, match, advice}], clean } matching the sandbox_lint shape.", {
55
+ directory: z
56
+ .string()
57
+ .optional()
58
+ .describe("Subdirectory under the project root to scan (e.g. 'UI', 'Code'). Defaults to 'Code'"),
59
+ }, async (params) => {
60
+ const res = await bridge.send("razor_lint", params);
61
+ if (!res.success)
62
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
63
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
64
+ });
41
65
  // ── scene_validate ────────────────────────────────────────────────
42
66
  server.tool("scene_validate", "Validate the active scene for the silent setup footguns that break controllers/physics/cameras: no CameraComponent, no player controller, multiple root Rigidbodies, a Rigidbody with MotionEnabled=false fighting a kinematic root, IsTrigger colliders that Scene.Trace will ignore, child Rigidbodies breaking collider binding, and missing required child anchors. Returns each issue with the GameObject and the exact fix.", {}, async () => {
43
67
  const res = await bridge.send("scene_validate", {});
@@ -87,9 +111,12 @@ export function registerInspectionTools(server, bridge) {
87
111
  .default("press")
88
112
  .describe("press = one tick (Pressed); hold = held for durationMs; release = clear a held action"),
89
113
  analogMove: z
90
- .object({ x: z.number(), y: z.number(), z: z.number().default(0) })
114
+ .union([
115
+ z.object({ x: z.number(), y: z.number(), z: z.number().default(0) }),
116
+ z.string().describe('Comma string "x,y,z", e.g. "1,0,0"'),
117
+ ])
91
118
  .optional()
92
- .describe("AnalogMove vector to apply (forward/left)"),
119
+ .describe('AnalogMove vector to apply (forward/left) -- object {x,y,z} or comma string "x,y,z"'),
93
120
  analogLook: z
94
121
  .object({ pitch: z.number(), yaw: z.number(), roll: z.number().default(0) })
95
122
  .optional()
@@ -4,9 +4,28 @@ import { z } from "zod";
4
4
  * grid-duplicate, and measure. Transform-level operations for arranging a
5
5
  * scene — all verifiable via the editor (screenshot or hierarchy/state).
6
6
  */
7
+ // Vector / colour accepted as EITHER an object OR a comma string, passed
8
+ // through unchanged. The C# handler parses both forms (source of truth). See
9
+ // the cross-language vector/color contract.
10
+ const Vector3Object = z.object({ x: z.number(), y: z.number(), z: z.number() });
7
11
  const Vector3Schema = z
8
- .object({ x: z.number(), y: z.number(), z: z.number() })
9
- .describe("Vector {x,y,z}");
12
+ .union([
13
+ Vector3Object,
14
+ z.string().describe('Comma string "x,y,z", e.g. "100,100,100"'),
15
+ ])
16
+ .describe('Vector — object {x,y,z} OR comma string "x,y,z"');
17
+ const ColorObject = z.object({
18
+ r: z.number().min(0),
19
+ g: z.number().min(0),
20
+ b: z.number().min(0),
21
+ a: z.number().min(0).max(1).optional(),
22
+ });
23
+ const ColorSchema = z
24
+ .union([
25
+ ColorObject,
26
+ z.string().describe('Comma string "r,g,b,a", e.g. "1,0,0,1"'),
27
+ ])
28
+ .describe('RGBA colour — object {r,g,b,a} (0-1) OR comma string "r,g,b,a"');
10
29
  export function registerLevelTools(server, bridge) {
11
30
  // ── snap_to_ground ─────────────────────────────────────────────────
12
31
  server.tool("snap_to_ground", "Drop a GameObject straight down onto the surface below it (physics raycast). Works best on collider-less props (an object with its own collider may self-hit). Optional offset lifts it off the surface.", {
@@ -94,15 +113,9 @@ export function registerLevelTools(server, bridge) {
94
113
  snapToGround: z.boolean().optional().describe("Raycast each onto the surface below (default true)"),
95
114
  scaleMin: z.number().optional().describe("Min uniform scale (default 1)"),
96
115
  scaleMax: z.number().optional().describe("Max uniform scale (default 1; set >min for size variation)"),
97
- tint: z
98
- .object({
99
- r: z.number().min(0),
100
- g: z.number().min(0),
101
- b: z.number().min(0),
102
- a: z.number().min(0).max(1).optional(),
103
- })
116
+ tint: ColorSchema
104
117
  .optional()
105
- .describe("Tint applied to every copy"),
118
+ .describe('Tint applied to every copy — object {r,g,b,a} or comma string "r,g,b,a"'),
106
119
  seed: z.number().int().optional().describe("PRNG seed for a reproducible layout (default 1)"),
107
120
  group: z.boolean().optional().describe("Parent all copies under one group object (default true)"),
108
121
  name: z.string().optional().describe("Base name for the props/group (default 'Prop')"),
package/dist/tools/npc.js CHANGED
@@ -16,13 +16,19 @@ import { z } from "zod";
16
16
  * Mirrors the templates.ts / navigation.ts module shape: zod params, one
17
17
  * bridge.send per tool, JSON.stringify(res.data) on success.
18
18
  */
19
+ // A world-space Vector3 accepted as EITHER an object {x,y,z} OR a comma string
20
+ // "x,y,z", passed through unchanged. The C# handler parses both forms (source of
21
+ // truth). See the cross-language vector/color contract.
19
22
  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");
23
+ .union([
24
+ z.object({
25
+ x: z.number().describe("X"),
26
+ y: z.number().describe("Y"),
27
+ z: z.number().describe("Z"),
28
+ }),
29
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
30
+ ])
31
+ .describe('A world-space Vector3 — object {x,y,z} OR comma string "x,y,z"');
26
32
  export function registerNpcTools(server, bridge) {
27
33
  // ── create_npc_brain ──────────────────────────────────────────────
28
34
  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.", {
@@ -5,14 +5,22 @@ import { z } from "zod";
5
5
  * the composable workhorse — query GUIDs, then feed them to align/distribute/
6
6
  * set_tint/group/etc.
7
7
  */
8
- const ColorSchema = z
9
- .object({
8
+ // A colour accepted as EITHER an object {r,g,b,a} OR a comma string "r,g,b,a".
9
+ // The value is passed through to the bridge unchanged; the C# handler parses
10
+ // both forms (C# is the source of truth). See the cross-language vector/color
11
+ // contract.
12
+ const ColorObject = z.object({
10
13
  r: z.number().min(0).describe("Red, 0-1"),
11
14
  g: z.number().min(0).describe("Green, 0-1"),
12
15
  b: z.number().min(0).describe("Blue, 0-1"),
13
16
  a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
14
- })
15
- .describe("RGBA colour as 0-1 floats");
17
+ });
18
+ const ColorSchema = z
19
+ .union([
20
+ ColorObject,
21
+ z.string().describe('Comma string "r,g,b,a", e.g. "1,0,0,1"'),
22
+ ])
23
+ .describe('RGBA colour — object {r,g,b,a} (0-1) OR comma string "r,g,b,a"');
16
24
  export function registerObjectTools(server, bridge) {
17
25
  // ── find_objects ───────────────────────────────────────────────────
18
26
  server.tool("find_objects", "Query the scene for GameObjects by name (case-insensitive substring), component type name, and/or tag — combine filters (AND). Returns {id,name} for matches (limit default 50, max 500). Read-only; works during play. Use it to get GUIDs to feed into align/distribute/set_tint/group/delete/etc.", {
@@ -33,12 +41,24 @@ export function registerObjectTools(server, bridge) {
33
41
  };
34
42
  });
35
43
  // ── set_tint ───────────────────────────────────────────────────────
36
- server.tool("set_tint", "Set the renderer tint colour on one object (id) or many (ids) at once. Works on any ModelRenderer/SkinnedModelRenderer.", {
44
+ server.tool("set_tint", 'Set the renderer tint colour on one object (id) or many (ids) at once. Works on any ModelRenderer/SkinnedModelRenderer. Pass the colour as "tint" (or its alias "color"); each accepts an object {r,g,b,a} OR a comma string "r,g,b,a".', {
37
45
  id: z.string().optional().describe("Single GameObject GUID"),
38
46
  ids: z.array(z.string()).optional().describe("Multiple GameObject GUIDs"),
39
- tint: ColorSchema.describe("Tint colour to apply"),
47
+ tint: ColorSchema.optional().describe("Tint colour to apply (object or comma string)"),
48
+ color: ColorSchema.optional().describe('Alias for "tint" (object or comma string)'),
40
49
  }, async (params) => {
41
- const res = await bridge.send("set_tint", params);
50
+ // "color" is an accepted alias for "tint"; normalize to "tint" and pass
51
+ // the value through unchanged (C# parses object-or-string).
52
+ const { color, tint, ...rest } = params;
53
+ const tintValue = tint ?? color;
54
+ if (tintValue === undefined) {
55
+ return {
56
+ content: [
57
+ { type: "text", text: 'Error: provide "tint" (or its alias "color").' },
58
+ ],
59
+ };
60
+ }
61
+ const res = await bridge.send("set_tint", { ...rest, tint: tintValue });
42
62
  if (!res.success) {
43
63
  return { content: [{ type: "text", text: `Error: ${res.error}` }] };
44
64
  }
@@ -1,8 +1,4 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { BridgeClient } from "../transport/bridge-client.js";
3
- /**
4
- * Physics tools: add_physics, add_collider, add_joint, raycast.
5
- * Manages rigidbodies, colliders, physics constraints, and ray tracing.
6
- */
7
3
  export declare function registerPhysicsTools(server: McpServer, bridge: BridgeClient): void;
8
4
  //# sourceMappingURL=physics.d.ts.map
@@ -3,6 +3,15 @@ import { z } from "zod";
3
3
  * Physics tools: add_physics, add_collider, add_joint, raycast.
4
4
  * Manages rigidbodies, colliders, physics constraints, and ray tracing.
5
5
  */
6
+ // A 3D vector accepted as EITHER an object {x,y,z} OR a comma string "x,y,z",
7
+ // passed through unchanged. The C# handler parses both forms (source of truth).
8
+ // See the cross-language vector/color contract.
9
+ const Vector3Schema = z
10
+ .union([
11
+ z.object({ x: z.number(), y: z.number(), z: z.number() }),
12
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
13
+ ])
14
+ .describe('3D vector — object {x,y,z} OR comma string "x,y,z"');
6
15
  export function registerPhysicsTools(server, bridge) {
7
16
  // ── add_physics ───────────────────────────────────────────────────
8
17
  server.tool("add_physics", "Add a Rigidbody and collider to a GameObject, making it a dynamic physics object. Auto-selects BoxCollider if no collider type specified", {
@@ -38,10 +47,9 @@ export function registerPhysicsTools(server, bridge) {
38
47
  .boolean()
39
48
  .optional()
40
49
  .describe("If true, the collider acts as a trigger (no physics collision). Defaults to false"),
41
- size: z
42
- .object({ x: z.number(), y: z.number(), z: z.number() })
50
+ size: Vector3Schema
43
51
  .optional()
44
- .describe("Size for BoxCollider (x, y, z dimensions)"),
52
+ .describe('Size for BoxCollider (x, y, z dimensions) — object {x,y,z} or comma string "x,y,z"'),
45
53
  radius: z
46
54
  .number()
47
55
  .optional()
@@ -90,15 +98,12 @@ export function registerPhysicsTools(server, bridge) {
90
98
  });
91
99
  // ── raycast ───────────────────────────────────────────────────────
92
100
  server.tool("raycast", "Perform a physics raycast (Scene.Trace.Ray) and return hit results. Useful for line-of-sight checks, object placement, and collision detection", {
93
- start: z
94
- .object({ x: z.number(), y: z.number(), z: z.number() })
95
- .describe("Ray start position (world space)"),
96
- end: z
97
- .object({ x: z.number(), y: z.number(), z: z.number() })
101
+ start: Vector3Schema
102
+ .describe('Ray start position (world space) object {x,y,z} or comma string "x,y,z"'),
103
+ end: Vector3Schema
98
104
  .optional()
99
105
  .describe("Ray end position. Use either end or direction+maxDistance"),
100
- direction: z
101
- .object({ x: z.number(), y: z.number(), z: z.number() })
106
+ direction: Vector3Schema
102
107
  .optional()
103
108
  .describe("Ray direction (normalized). Used with maxDistance instead of end"),
104
109
  maxDistance: z
@@ -128,15 +133,13 @@ export function registerPhysicsTools(server, bridge) {
128
133
  });
129
134
  // ── physics_overlap ───────────────────────────────────────────────
130
135
  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)"),
136
+ center: Vector3Schema
137
+ .describe('Center of the query volume (world space) object {x,y,z} or comma string "x,y,z"'),
134
138
  radius: z
135
139
  .number()
136
140
  .optional()
137
141
  .describe("Sphere radius. Provide this OR size (box), not both"),
138
- size: z
139
- .object({ x: z.number(), y: z.number(), z: z.number() })
142
+ size: Vector3Schema
140
143
  .optional()
141
144
  .describe("Full box size (not half-extents). Provide this OR radius"),
142
145
  }, async (params) => {
@@ -121,5 +121,84 @@ export function registerPlayModeTools(server, bridge) {
121
121
  content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
122
122
  };
123
123
  });
124
+ // ── drive_player (EXPERIMENTAL) ──────────────────────────────────
125
+ server.tool("drive_player", "EXPERIMENTAL — Drive the active PlayerController DURING PLAY MODE across multiple frames: synthesize sustained look (EyeAngles), analog movement (wish velocity), and/or hold a named action down long enough that Input.Pressed fires its rising edge. This is the reliable alternative to simulate_input, which only sets an action for ONE frame and so MISSES edge-triggered controls (Input.Pressed) and cannot inject analog move/look at all. Requires start_play first. Runs ASYNC across editor frames and returns immediately — the job keeps applying for `frames` (or `durationMs`) frames. After it runs, confirm with drive_player_status, then verify the actual effect with capture_view / get_runtime_property. Provide at least one of look / lookDelta / move / action.", {
126
+ id: z
127
+ .string()
128
+ .optional()
129
+ .describe("GUID of the GameObject holding the controller. Omit to auto-resolve the first PlayerController (or *Controller with EyeAngles/WishVelocity) in the play scene"),
130
+ component: z
131
+ .string()
132
+ .optional()
133
+ .describe("Controller component type name to target (e.g. 'PlayerController'). Omit to auto-detect"),
134
+ frames: z
135
+ .number()
136
+ .int()
137
+ .optional()
138
+ .describe("How many editor frames to drive for (1–1800, ~60/sec). Default 30 (~0.5s). Takes precedence over durationMs"),
139
+ durationMs: z
140
+ .number()
141
+ .int()
142
+ .optional()
143
+ .describe("Duration in ms, converted at ~60fps (ignored if `frames` is given). Default ~500ms"),
144
+ look: z
145
+ .union([
146
+ z.object({
147
+ pitch: z.number().default(0),
148
+ yaw: z.number().default(0),
149
+ roll: z.number().default(0),
150
+ }),
151
+ z.array(z.number()),
152
+ z.string(),
153
+ ])
154
+ .optional()
155
+ .describe("Absolute EyeAngles target {pitch,yaw,roll} held for the whole duration (aim the camera/body). Object, [pitch,yaw,roll], or 'pitch,yaw,roll'. pitch is clamped to ±89"),
156
+ lookDelta: z
157
+ .union([
158
+ z.object({
159
+ pitch: z.number().default(0),
160
+ yaw: z.number().default(0),
161
+ roll: z.number().default(0),
162
+ }),
163
+ z.array(z.number()),
164
+ z.string(),
165
+ ])
166
+ .optional()
167
+ .describe("Per-frame EyeAngles delta added each frame (turn/pan over time, e.g. {yaw:2} to sweep right). Combine with `frames` to control total rotation"),
168
+ move: z
169
+ .union([
170
+ z.object({ x: z.number().default(0), y: z.number().default(0) }),
171
+ z.array(z.number()),
172
+ z.string(),
173
+ ])
174
+ .optional()
175
+ .describe("Analog movement in the controller's facing frame: x = forward(+)/back(-), y = left(+)/right(-). Magnitude clamped to 1. e.g. {x:1} walks forward for the whole duration"),
176
+ moveSpeed: z
177
+ .number()
178
+ .optional()
179
+ .describe("Units/sec used when synthesizing WishVelocity from `move` (default 160). Ignored if the controller exposes its own AnalogMove field"),
180
+ action: z
181
+ .string()
182
+ .optional()
183
+ .describe("A named input action ('jump','use','attack1',…) HELD DOWN every frame for the whole duration, so Input.Pressed catches the edge single-frame simulate_input misses. Auto-released on the final frame"),
184
+ }, async (params) => {
185
+ const res = await bridge.send("drive_player", params);
186
+ if (!res.success) {
187
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
188
+ }
189
+ return {
190
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
191
+ };
192
+ });
193
+ // ── drive_player_status (EXPERIMENTAL) ───────────────────────────
194
+ server.tool("drive_player_status", "EXPERIMENTAL — Read the result of the most recently FINISHED drive_player job: which controller members were actually written (EyeAngles / WishVelocity / AnalogMove…), how many frames applied, and why it ended. Because drive_player runs across frames and returns immediately, this is how you confirm it took effect. Returns lastResult=null if no job has finished yet.", {}, async () => {
195
+ const res = await bridge.send("drive_player_status");
196
+ if (!res.success) {
197
+ return { content: [{ type: "text", text: `Error: ${res.error}` }] };
198
+ }
199
+ return {
200
+ content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }],
201
+ };
202
+ });
124
203
  }
125
204
  //# sourceMappingURL=playmode.js.map
@@ -25,13 +25,16 @@ export function registerPrefabTools(server, bridge) {
25
25
  .string()
26
26
  .describe("Path to the .prefab file (e.g. 'prefabs/enemies/grunt.prefab')"),
27
27
  position: z
28
- .object({
29
- x: z.number(),
30
- y: z.number(),
31
- z: z.number(),
32
- })
28
+ .union([
29
+ z.object({
30
+ x: z.number(),
31
+ y: z.number(),
32
+ z: z.number(),
33
+ }),
34
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,64"'),
35
+ ])
33
36
  .optional()
34
- .describe("World position to spawn at. Defaults to origin"),
37
+ .describe('World position to spawn at — object {x,y,z} or comma string "x,y,z". Defaults to origin'),
35
38
  rotation: z
36
39
  .object({
37
40
  pitch: z.number(),
@@ -42,9 +42,12 @@ export function registerTemplateTools(server, bridge) {
42
42
  .optional()
43
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
44
  spawnPosition: z
45
- .object({ x: z.number(), y: z.number(), z: z.number() })
45
+ .union([
46
+ z.object({ x: z.number(), y: z.number(), z: z.number() }),
47
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,64"'),
48
+ ])
46
49
  .optional()
47
- .describe("When placeInScene is true, the world position to spawn the player rig at. Defaults to the origin."),
50
+ .describe('When placeInScene is true, the world position to spawn the player rig at — object {x,y,z} or comma string "x,y,z". Defaults to the origin.'),
48
51
  }, async (params) => {
49
52
  const res = await bridge.send("create_player_controller", params);
50
53
  if (!res.success) {
package/dist/tools/ui.js CHANGED
@@ -76,9 +76,12 @@ export function registerUITools(server, bridge) {
76
76
  .optional()
77
77
  .describe("Name for the UI GameObject. Defaults to 'World UI'"),
78
78
  position: z
79
- .object({ x: z.number(), y: z.number(), z: z.number() })
79
+ .union([
80
+ z.object({ x: z.number(), y: z.number(), z: z.number() }),
81
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,64"'),
82
+ ])
80
83
  .optional()
81
- .describe("World position for the panel"),
84
+ .describe('World position for the panel — object {x,y,z} or comma string "x,y,z"'),
82
85
  rotation: z
83
86
  .object({
84
87
  pitch: z.number(),
@@ -8,17 +8,28 @@ import { z } from "zod";
8
8
  * (which can't even set a Color). After any change, screenshot the scene and read
9
9
  * the result — this layer is where the screenshot loop matters most.
10
10
  */
11
- const ColorSchema = z
12
- .object({
11
+ // Colour / vector accepted as EITHER an object OR a comma string, passed
12
+ // through unchanged. The C# handler parses both forms (source of truth). See
13
+ // the cross-language vector/color contract.
14
+ const ColorObject = z.object({
13
15
  r: z.number().min(0).describe("Red, 0-1"),
14
16
  g: z.number().min(0).describe("Green, 0-1"),
15
17
  b: z.number().min(0).describe("Blue, 0-1"),
16
18
  a: z.number().min(0).max(1).optional().describe("Alpha, 0-1 (default 1)"),
17
- })
18
- .describe("RGBA colour as 0-1 floats");
19
+ });
20
+ const ColorSchema = z
21
+ .union([
22
+ ColorObject,
23
+ z.string().describe('Comma string "r,g,b,a", e.g. "1,0,0,1"'),
24
+ ])
25
+ .describe('RGBA colour — object {r,g,b,a} (0-1) OR comma string "r,g,b,a"');
26
+ const Vector3Object = z.object({ x: z.number(), y: z.number(), z: z.number() });
19
27
  const Vector3Schema = z
20
- .object({ x: z.number(), y: z.number(), z: z.number() })
21
- .describe("World position {x,y,z}");
28
+ .union([
29
+ Vector3Object,
30
+ z.string().describe('Comma string "x,y,z", e.g. "0,0,200"'),
31
+ ])
32
+ .describe('World position — object {x,y,z} OR comma string "x,y,z"');
22
33
  const RotationSchema = z
23
34
  .object({ pitch: z.number(), yaw: z.number(), roll: z.number() })
24
35
  .describe("Rotation {pitch,yaw,roll} in degrees");
@@ -81,10 +92,9 @@ export function registerVisualTools(server, bridge) {
81
92
  heightWidth: z.number().optional().describe("cubemap: height-fog band width"),
82
93
  heightExponent: z.number().optional().describe("cubemap: height-fog falloff exponent"),
83
94
  strength: z.number().optional().describe("volumetric: fog density/strength"),
84
- size: z
85
- .object({ x: z.number(), y: z.number(), z: z.number() })
95
+ size: Vector3Schema
86
96
  .optional()
87
- .describe("volumetric: bounds size (units) centred on the object"),
97
+ .describe('volumetric: bounds size (units) centred on the object — object {x,y,z} or comma string "x,y,z"'),
88
98
  }, async (params) => {
89
99
  const res = await bridge.send("set_fog", params);
90
100
  if (!res.success) {
package/package.json CHANGED
@@ -1,55 +1,55 @@
1
- {
2
- "name": "sbox-mcp-server",
3
- "version": "1.9.0",
4
- "description": "MCP Server for s&box game engine — enables Claude to build games through conversation",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "bin": {
8
- "sbox-mcp-server": "dist/index.js",
9
- "sbox-mcp": "dist/index.js"
10
- },
11
- "files": [
12
- "dist/**/*.js",
13
- "dist/**/*.d.ts",
14
- "README.md",
15
- "LICENSE"
16
- ],
17
- "scripts": {
18
- "build": "tsc",
19
- "test": "npm run build && node --test",
20
- "start": "node dist/index.js",
21
- "dev": "tsc --watch",
22
- "prepublishOnly": "npm run build"
23
- },
24
- "keywords": [
25
- "mcp",
26
- "sbox",
27
- "s&box",
28
- "game-engine",
29
- "claude",
30
- "ai",
31
- "source2",
32
- "model-context-protocol"
33
- ],
34
- "repository": {
35
- "type": "git",
36
- "url": "git+https://github.com/LouSputthole/Sbox-Claude.git"
37
- },
38
- "author": "sboxskins.gg (https://sboxskins.gg)",
39
- "homepage": "https://sboxskins.gg",
40
- "bugs": {
41
- "url": "https://github.com/LouSputthole/Sbox-Claude/issues"
42
- },
43
- "dependencies": {
44
- "@modelcontextprotocol/sdk": "^1.12.1",
45
- "zod": "^3.24.0"
46
- },
47
- "devDependencies": {
48
- "@types/node": "^22.0.0",
49
- "typescript": "^5.7.0"
50
- },
51
- "engines": {
52
- "node": ">=18.0.0"
53
- },
54
- "license": "AGPL-3.0-or-later"
55
- }
1
+ {
2
+ "name": "sbox-mcp-server",
3
+ "version": "1.12.0",
4
+ "description": "MCP Server for s&box game engine — enables Claude to build games through conversation",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "sbox-mcp-server": "dist/index.js",
9
+ "sbox-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist/**/*.js",
13
+ "dist/**/*.d.ts",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "npm run build && node --test",
20
+ "start": "node dist/index.js",
21
+ "dev": "tsc --watch",
22
+ "prepublishOnly": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "mcp",
26
+ "sbox",
27
+ "s&box",
28
+ "game-engine",
29
+ "claude",
30
+ "ai",
31
+ "source2",
32
+ "model-context-protocol"
33
+ ],
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/LouSputthole/Sbox-Claude.git"
37
+ },
38
+ "author": "sboxskins.gg (https://sboxskins.gg)",
39
+ "homepage": "https://sboxskins.gg",
40
+ "bugs": {
41
+ "url": "https://github.com/LouSputthole/Sbox-Claude/issues"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.12.1",
45
+ "zod": "^3.24.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.0.0",
49
+ "typescript": "^5.7.0"
50
+ },
51
+ "engines": {
52
+ "node": ">=18.0.0"
53
+ },
54
+ "license": "AGPL-3.0-or-later"
55
+ }