sbox-mcp-server 1.3.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -113
- package/dist/index.js +34 -1
- package/dist/tools/characters.d.ts +4 -0
- package/dist/tools/characters.js +209 -0
- package/dist/tools/diagnostics.d.ts +4 -0
- package/dist/tools/diagnostics.js +325 -0
- package/dist/tools/docs.d.ts +4 -0
- package/dist/tools/docs.js +334 -0
- package/dist/tools/leveltools.d.ts +4 -0
- package/dist/tools/leveltools.js +148 -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.d.ts +4 -0
- package/dist/tools/objecttools.js +106 -0
- package/dist/tools/physics.js +22 -0
- package/dist/tools/project.js +12 -2
- package/dist/tools/visuals.d.ts +4 -0
- package/dist/tools/visuals.js +289 -0
- package/dist/transport/bridge-client.js +27 -4
- package/package.json +1 -1
|
@@ -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
|
|
@@ -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
|