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