@npgamedev/godot-mcp-server 0.0.1 → 1.0.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/ATTRIBUTIONS.md +141 -0
- package/LICENSE +28 -0
- package/README.md +386 -4
- package/dist/extensions/extensionChanges.js +131 -0
- package/dist/extensions/extensionCommand.js +25 -0
- package/dist/extensions/extensionDiscovery.js +141 -0
- package/dist/extensions/extensionRegistrar.js +82 -0
- package/dist/extensions/extensions.js +33 -0
- package/dist/groups/builtinGroups.js +60 -0
- package/dist/groups/defs/3dTools.js +17 -0
- package/dist/groups/defs/animationAuthoring.js +16 -0
- package/dist/groups/defs/assetOps.js +6 -0
- package/dist/groups/defs/audio.js +6 -0
- package/dist/groups/defs/classdb.js +6 -0
- package/dist/groups/defs/cleanup.js +6 -0
- package/dist/groups/defs/debugger.js +6 -0
- package/dist/groups/defs/editorAdvanced.js +16 -0
- package/dist/groups/defs/inputMap.js +6 -0
- package/dist/groups/defs/layerNaming.js +6 -0
- package/dist/groups/defs/lspCodeAnalysis.js +23 -0
- package/dist/groups/defs/lspCodeNavigation.js +15 -0
- package/dist/groups/defs/navigation.js +17 -0
- package/dist/groups/defs/particles.js +21 -0
- package/dist/groups/defs/pathEditing.js +22 -0
- package/dist/groups/defs/placeholders.js +28 -0
- package/dist/groups/defs/procedural.js +6 -0
- package/dist/groups/defs/resourceIo.js +6 -0
- package/dist/groups/defs/runtimeAdvanced.js +17 -0
- package/dist/groups/defs/sceneAdvanced.js +6 -0
- package/dist/groups/defs/sceneInheritance.js +6 -0
- package/dist/groups/defs/signals.js +6 -0
- package/dist/groups/defs/spriteframes.js +16 -0
- package/dist/groups/defs/theme.js +6 -0
- package/dist/groups/defs/tilemap.js +6 -0
- package/dist/groups/defs/tileset.js +22 -0
- package/dist/groups/defs/tilesetEdit.js +21 -0
- package/dist/groups/defs/userData.js +6 -0
- package/dist/groups/extensionGroups.js +182 -0
- package/dist/groups/groupActivation.js +210 -0
- package/dist/groups/groupCatalogue.js +52 -0
- package/dist/groups/groupMatch.js +182 -0
- package/dist/groups/groupResult.js +16 -0
- package/dist/groups/groupState.js +15 -0
- package/dist/groups/groupToolHandlers.js +103 -0
- package/dist/groups/groupTypes.js +10 -0
- package/dist/groups/groups.js +205 -0
- package/dist/index.js +144 -0
- package/dist/lsp/lspClient.js +509 -0
- package/dist/lsp/lspLabels.js +105 -0
- package/dist/lsp/lspProjectScan.js +156 -0
- package/dist/lsp/lspSession.js +139 -0
- package/dist/lsp/lspStatusReporter.js +76 -0
- package/dist/lsp/lspUri.js +68 -0
- package/dist/mcp/prompts.js +59 -0
- package/dist/mcp/resources.js +113 -0
- package/dist/mcp/roots.js +30 -0
- package/dist/registration/catalogue.js +123 -0
- package/dist/registration/extensionCollision.js +38 -0
- package/dist/registration/operations.js +67 -0
- package/dist/registration/screenshotResponse.js +71 -0
- package/dist/registration/toolDispatch.js +76 -0
- package/dist/registration/toolMeta.js +104 -0
- package/dist/registration/toolRefs.js +48 -0
- package/dist/registration/toolRegistry.js +211 -0
- package/dist/registry.js +291 -0
- package/dist/registryLiveness.js +113 -0
- package/dist/security/pathGuard.js +97 -0
- package/dist/security/profiles.js +104 -0
- package/dist/security/untrusted.js +23 -0
- package/dist/shared/errorContract.js +154 -0
- package/dist/shared/errors.js +21 -0
- package/dist/shared/pagination.js +135 -0
- package/dist/shared/schemaCoercion.js +173 -0
- package/dist/shared/stableJson.js +27 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/version.js +82 -0
- package/dist/startup/cliArgs.js +103 -0
- package/dist/startup/configReload.js +57 -0
- package/dist/startup/hooks.js +89 -0
- package/dist/startup/lifecycle.js +22 -0
- package/dist/startup/portConfig.js +127 -0
- package/dist/startup/reconcile.js +81 -0
- package/dist/startup/registrars.js +59 -0
- package/dist/startup/serverMode.js +19 -0
- package/dist/startup/startupEnv.js +142 -0
- package/dist/tools/animation.js +88 -0
- package/dist/tools/asset.js +58 -0
- package/dist/tools/assetWrite.js +16 -0
- package/dist/tools/audio.js +40 -0
- package/dist/tools/classdb.js +43 -0
- package/dist/tools/collision.js +23 -0
- package/dist/tools/debug.js +46 -0
- package/dist/tools/diff.js +18 -0
- package/dist/tools/editor.js +222 -0
- package/dist/tools/file.js +38 -0
- package/dist/tools/folder.js +27 -0
- package/dist/tools/inputMap.js +54 -0
- package/dist/tools/layerNames.js +29 -0
- package/dist/tools/lsp.js +524 -0
- package/dist/tools/navigation.js +24 -0
- package/dist/tools/node.js +145 -0
- package/dist/tools/nodeManagement.js +82 -0
- package/dist/tools/particles.js +83 -0
- package/dist/tools/path.js +30 -0
- package/dist/tools/playtest.js +99 -0
- package/dist/tools/procedural.js +101 -0
- package/dist/tools/resource.js +47 -0
- package/dist/tools/runtime.js +338 -0
- package/dist/tools/save.js +64 -0
- package/dist/tools/scene.js +102 -0
- package/dist/tools/sceneInheritance.js +19 -0
- package/dist/tools/sceneQuery.js +38 -0
- package/dist/tools/script.js +75 -0
- package/dist/tools/signals.js +53 -0
- package/dist/tools/sound.js +31 -0
- package/dist/tools/spatial.js +41 -0
- package/dist/tools/spriteframes.js +94 -0
- package/dist/tools/texture.js +35 -0
- package/dist/tools/theme.js +35 -0
- package/dist/tools/threeD.js +116 -0
- package/dist/tools/tilemap.js +51 -0
- package/dist/tools/tileset.js +226 -0
- package/dist/transport/authHandshake.js +43 -0
- package/dist/transport/bridge.js +225 -0
- package/dist/transport/channel.js +352 -0
- package/dist/transport/heartbeat.js +54 -0
- package/dist/transport/runtimeConnection.js +239 -0
- package/dist/transport/tokenPath.js +98 -0
- package/package.json +97 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Convenience: the ubiquitous `file_path → res://` declaration. */
|
|
2
|
+
export const PROJECT_FILE_PATH = { param: "file_path", guard: "project" };
|
|
3
|
+
/** Resolve a PathGuard's allowed prefixes. */
|
|
4
|
+
export function guardPrefixes(g) {
|
|
5
|
+
if ("prefixes" in g)
|
|
6
|
+
return g.prefixes;
|
|
7
|
+
return g.guard === "user" ? ["user://"] : ["res://"];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Validate a single path string against the allowed prefixes — the syntactic
|
|
11
|
+
* half of FileGuard.resolve_safe. Empty/whitespace is rejected here (the
|
|
12
|
+
* validator contract); callers that treat an absent optional param as "skip"
|
|
13
|
+
* use checkPathGuard, which defers empties to the toolkit instead.
|
|
14
|
+
*/
|
|
15
|
+
export function checkPath(input, prefixes) {
|
|
16
|
+
if (input.trim().length === 0)
|
|
17
|
+
return { ok: false, reason: "empty path" };
|
|
18
|
+
const normalized = input.replace(/\\/g, "/");
|
|
19
|
+
// Reject exact `..` segments (directory traversal). Match the SEGMENT, never
|
|
20
|
+
// a substring — so `a..b` and `a.b.c` pass (the classic over-block bug).
|
|
21
|
+
for (const seg of normalized.split("/")) {
|
|
22
|
+
if (seg === "..")
|
|
23
|
+
return { ok: false, reason: `path contains '..': ${input}` };
|
|
24
|
+
}
|
|
25
|
+
// Reject absolute OS paths: drive letter (`X:`), then any leading `/` that
|
|
26
|
+
// is not the res://‑/user:// scheme. After `\`→`/`, UNC `\\host\share`
|
|
27
|
+
// becomes `//host/share` and is caught by the leading-`/` rule.
|
|
28
|
+
if (normalized.length >= 2 && normalized[1] === ":") {
|
|
29
|
+
return { ok: false, reason: `absolute OS path: ${input}` };
|
|
30
|
+
}
|
|
31
|
+
if (normalized.startsWith("/") && !normalized.startsWith("res://") && !normalized.startsWith("user://")) {
|
|
32
|
+
return { ok: false, reason: `absolute OS path: ${input}` };
|
|
33
|
+
}
|
|
34
|
+
// Require an allowed prefix.
|
|
35
|
+
if (!prefixes.some((p) => normalized.startsWith(p))) {
|
|
36
|
+
return { ok: false, reason: `path must start with one of [${prefixes.join(", ")}] (got ${input})` };
|
|
37
|
+
}
|
|
38
|
+
return { ok: true };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply a PathGuard to an input value. Skips absent / empty / whitespace-only
|
|
42
|
+
* values (an unprovided optional param defers to the toolkit — e.g.
|
|
43
|
+
* editor_save_scene with no file_path = save-in-place). Validates every element
|
|
44
|
+
* of an array param (editor_refresh.file_paths-style, if ever declared).
|
|
45
|
+
*/
|
|
46
|
+
export function checkPathGuard(g, value) {
|
|
47
|
+
const prefixes = guardPrefixes(g);
|
|
48
|
+
const values = Array.isArray(value) ? value : [value];
|
|
49
|
+
for (const v of values) {
|
|
50
|
+
if (typeof v !== "string" || v.trim().length === 0)
|
|
51
|
+
continue; // absent/empty → defer to toolkit
|
|
52
|
+
const r = checkPath(v, prefixes);
|
|
53
|
+
if (!r.ok)
|
|
54
|
+
return r;
|
|
55
|
+
}
|
|
56
|
+
return { ok: true };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Shared subset fixture — the SAME path cases are mirrored in the toolkit's
|
|
60
|
+
* FileGuard unit-test group. This file is the source of truth; keep the
|
|
61
|
+
* GDScript mirror in sync. The cross-repo invariant the duplication
|
|
62
|
+
* enforces: NO path is server-deny / toolkit-allow (a false rejection). Both
|
|
63
|
+
* suites assert their own guard against `allow` (→ ok) and `deny` (→ rejected).
|
|
64
|
+
*
|
|
65
|
+
* Note: OS-symlink escapes are NOT here — symlink resolution is out of scope
|
|
66
|
+
* on both sides (lexical canonicalization only; single-user localhost threat
|
|
67
|
+
* model), so no fixture case can express one.
|
|
68
|
+
* @internal
|
|
69
|
+
*/
|
|
70
|
+
export const PATH_FIXTURE = {
|
|
71
|
+
/** [path, prefixes] both layers must ALLOW (no false rejections). */
|
|
72
|
+
allow: [
|
|
73
|
+
["res://x.gd", ["res://"]],
|
|
74
|
+
["res://a/b/c.tscn", ["res://"]],
|
|
75
|
+
["res://addons/foo/bar.gd", ["res://"]],
|
|
76
|
+
["res://my..thing/x.gd", ["res://"]], // dots, not an exact `..` segment
|
|
77
|
+
["res://a.b.c/d.gd", ["res://"]],
|
|
78
|
+
["res://a/b/", ["res://"]], // trailing-slash dir
|
|
79
|
+
["user://saves/x.json", ["user://"]],
|
|
80
|
+
["user://screenshots/shot.png", ["res://", "user://screenshots/"]],
|
|
81
|
+
["res://shot.png", ["res://", "user://screenshots/"]],
|
|
82
|
+
],
|
|
83
|
+
/** [path, prefixes] both layers must DENY (no escapes). */
|
|
84
|
+
deny: [
|
|
85
|
+
["res://../escape.gd", ["res://"]],
|
|
86
|
+
["res://a/../../../escape", ["res://"]],
|
|
87
|
+
["../../etc/passwd", ["res://"]],
|
|
88
|
+
["/etc/passwd", ["res://"]],
|
|
89
|
+
["C:/Windows/x", ["res://"]],
|
|
90
|
+
["\\\\server\\share\\x", ["res://"]], // UNC
|
|
91
|
+
["random/x.gd", ["res://"]], // non-allowed prefix
|
|
92
|
+
["file:///etc/passwd", ["res://"]],
|
|
93
|
+
["user://x.json", ["res://"]], // wrong prefix for a project tool
|
|
94
|
+
["res://x.gd", ["user://"]], // wrong prefix for a user tool
|
|
95
|
+
["", ["res://"]], // empty
|
|
96
|
+
],
|
|
97
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool visibility — eager tools are always registered up front; read-only mode
|
|
3
|
+
* (GODOT_MCP_READ_ONLY=1) filters out mutating tools.
|
|
4
|
+
*/
|
|
5
|
+
/** 34 eager tools (discover_tools + extensions_refresh added programmatically → 36 total). */
|
|
6
|
+
export const EAGER_TOOLS = [
|
|
7
|
+
// Scene (5)
|
|
8
|
+
"scene_get_tree",
|
|
9
|
+
"scene_create_node",
|
|
10
|
+
"scene_delete_node",
|
|
11
|
+
"scene_create",
|
|
12
|
+
"scene_open",
|
|
13
|
+
// Node (8) — node_manage, node_groups, autoload_manage are eager rather than
|
|
14
|
+
// in the node_management group: dynamic activation via tools/list_changed is
|
|
15
|
+
// not robust across MCP clients, so keeping them eager is the stable choice.
|
|
16
|
+
// control_set_layout is eager too — layout is needed broadly enough that lazy
|
|
17
|
+
// activation isn't worth it.
|
|
18
|
+
"node_get_property",
|
|
19
|
+
"node_set_property",
|
|
20
|
+
"node_get_property_list",
|
|
21
|
+
"node_set_script",
|
|
22
|
+
"node_manage",
|
|
23
|
+
"node_groups",
|
|
24
|
+
"autoload_manage",
|
|
25
|
+
"control_set_layout",
|
|
26
|
+
// Script (3)
|
|
27
|
+
"script_read",
|
|
28
|
+
"script_write",
|
|
29
|
+
"script_edit",
|
|
30
|
+
// Editor (4) — editor_screenshot lives in the editor_advanced group;
|
|
31
|
+
// there is no editor_get_errors (use editor_get_console with level_filter).
|
|
32
|
+
"editor_save_scene",
|
|
33
|
+
"editor_get_console",
|
|
34
|
+
"project_get_settings",
|
|
35
|
+
"project_set_setting",
|
|
36
|
+
// Playtest (6) — these runtime tools are eager, not in the lazy runtime group.
|
|
37
|
+
// runtime_set_property is intentionally NOT listed here: it is a
|
|
38
|
+
// runtime_advanced group tool, and MODULE_ALLOWED subtracts group tools from
|
|
39
|
+
// the eager set, so listing it would be dead.
|
|
40
|
+
"game_start",
|
|
41
|
+
"game_stop",
|
|
42
|
+
"runtime_screenshot",
|
|
43
|
+
"input_simulate",
|
|
44
|
+
"runtime_get_script_vars",
|
|
45
|
+
"debugger_get_log",
|
|
46
|
+
// Signals (2) — eager rather than in the signals group: signal wiring is
|
|
47
|
+
// needed broadly enough to warrant always-on availability.
|
|
48
|
+
"signal_list",
|
|
49
|
+
"signal_manage",
|
|
50
|
+
// Assets (1) — asset_list lives in the asset_ops group, not eager.
|
|
51
|
+
"folder_create",
|
|
52
|
+
// Script diagnostics (1)
|
|
53
|
+
"script_check",
|
|
54
|
+
// Scene query (1)
|
|
55
|
+
"scene_query",
|
|
56
|
+
// Spatial (1) — eager read-only scene layout map. MUST be listed here: eager
|
|
57
|
+
// registration is gated by EAGER_TOOLS, NOT by ALL_TOOL_DEFS (which only feeds
|
|
58
|
+
// --tools-count and the static catalogue checks). Omitting it makes the tool
|
|
59
|
+
// counted-but-unregistered — absent from tools/list on every session, with no
|
|
60
|
+
// client-side fix. The test/structural.ts reachability check guards against
|
|
61
|
+
// recurrence.
|
|
62
|
+
"scene_spatial_map",
|
|
63
|
+
// High-risk tools — always eagerly registered. Risk communicated
|
|
64
|
+
// via MCP annotations (destructiveHint); agent-side filtering recommended.
|
|
65
|
+
"execute_code",
|
|
66
|
+
"node_call_method",
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* Centralized read-only predicate. A tool is allowed in read-only mode iff
|
|
70
|
+
* its annotations declare readOnlyHint: true. Strict inclusion — unannotated
|
|
71
|
+
* tools default to excluded (safe).
|
|
72
|
+
*
|
|
73
|
+
* Runtime invariant: readOnlyHint + destructiveHint is a contradiction.
|
|
74
|
+
* When detected, log a warning and treat the tool as mutating (never crash).
|
|
75
|
+
*/
|
|
76
|
+
export function isAllowedInReadOnly(annotations) {
|
|
77
|
+
if (!annotations?.readOnlyHint)
|
|
78
|
+
return false;
|
|
79
|
+
if (annotations.destructiveHint) {
|
|
80
|
+
process.stderr.write("[godot-mcp] WARNING: tool has both readOnlyHint and destructiveHint — treating as mutating\n");
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Combined read-only check: returns true when a tool should be excluded.
|
|
87
|
+
* Wraps both the mode check and the annotation check into a single call
|
|
88
|
+
* so callers don't repeat `if (readOnly && !isAllowedInReadOnly(...))`.
|
|
89
|
+
*/
|
|
90
|
+
export function isExcludedByReadOnly(readOnly, annotations) {
|
|
91
|
+
return readOnly && !isAllowedInReadOnly(annotations);
|
|
92
|
+
}
|
|
93
|
+
/** Whether read-only mode is active. */
|
|
94
|
+
export function isReadOnly() {
|
|
95
|
+
return process.env.GODOT_MCP_READ_ONLY === "1";
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the allowed-tool set for initial registration.
|
|
99
|
+
* Returns all eager tools. Read-only filtering happens at point-of-use
|
|
100
|
+
* via isAllowedInReadOnly() — no pre-computed subtraction needed.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveAllowedTools() {
|
|
103
|
+
return new Set(EAGER_TOOLS);
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Untrusted-content envelope wrapper — TypeScript mirror of
|
|
4
|
+
* addons/godot_mcp_toolkit/untrusted.gd.
|
|
5
|
+
*
|
|
6
|
+
* Wraps user-authored / project-authored content before it reaches the
|
|
7
|
+
* LLM in a nonce-tagged envelope to prevent tag-breakout injection.
|
|
8
|
+
* Most wrapping happens GDScript-side, but this is available for
|
|
9
|
+
* server-originated wrapping.
|
|
10
|
+
*/
|
|
11
|
+
/** Matches any <untrusted...> or </untrusted...> tag variant. */
|
|
12
|
+
const ENVELOPE_TAG_RE = /<\s*\/?\s*untrusted(?:-[0-9a-f]*)?(?:\s[^>]*)?\s*>/gi;
|
|
13
|
+
function generateNonce() {
|
|
14
|
+
return randomBytes(4).toString("hex");
|
|
15
|
+
}
|
|
16
|
+
function scrubEnvelopeTags(text) {
|
|
17
|
+
return text.replace(ENVELOPE_TAG_RE, "[scrubbed-envelope-tag]");
|
|
18
|
+
}
|
|
19
|
+
export function untrustedWrap(kind, source, body) {
|
|
20
|
+
const nonce = generateNonce();
|
|
21
|
+
const scrubbed = scrubEnvelopeTags(body);
|
|
22
|
+
return `<untrusted-${nonce} kind="${kind}" source="${source}">\n${scrubbed}\n</untrusted-${nonce}>`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error contract — shape every failure into the canonical MCP error
|
|
3
|
+
* response with the right code and hint. A near-pure leaf: the runtime
|
|
4
|
+
* crash-context helpers take the Bridge as a parameter, so this module
|
|
5
|
+
* depends on neither registration nor dispatch.
|
|
6
|
+
*/
|
|
7
|
+
import { BridgeError } from "./errors.js";
|
|
8
|
+
import { PAGE_FIELD } from "./pagination.js";
|
|
9
|
+
// ── Error utilities ─────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Canonical MCP failure response. Plugin emits
|
|
12
|
+
* {success: false, error, code} inside the JSON-RPC result payload;
|
|
13
|
+
* transport-level failures surface as BridgeError and are translated
|
|
14
|
+
* here. String `code` is accepted (in addition to the ErrorCode union)
|
|
15
|
+
* because bridge errors (RPC_ERROR etc.) aren't statically typed at
|
|
16
|
+
* every call site.
|
|
17
|
+
*/
|
|
18
|
+
export function toolError(code, message, hint) {
|
|
19
|
+
const payload = { success: false, error: message, code };
|
|
20
|
+
if (hint)
|
|
21
|
+
payload.hint = hint;
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
24
|
+
isError: true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* If the plugin returned {success: false, ...} inside the JSON-RPC
|
|
29
|
+
* result, translate it to an MCP isError response. Non-error successes
|
|
30
|
+
* (including idempotent create returns) have success absent or
|
|
31
|
+
* truthy and pass through unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function toolErrorFromPayload(result) {
|
|
34
|
+
if (!result || typeof result !== "object")
|
|
35
|
+
return undefined;
|
|
36
|
+
const obj = result;
|
|
37
|
+
if (obj.success !== false)
|
|
38
|
+
return undefined;
|
|
39
|
+
const code = typeof obj.code === "string" ? obj.code : "INTERNAL";
|
|
40
|
+
const error = typeof obj.error === "string" ? obj.error : "unknown error";
|
|
41
|
+
const hint = typeof obj.hint === "string" ? obj.hint : undefined;
|
|
42
|
+
return toolError(code, error, hint);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Default hints for transport-level codes thrown as a `BridgeError`.
|
|
46
|
+
*
|
|
47
|
+
* Membership invariant: only codes that can arrive as a thrown `BridgeError`
|
|
48
|
+
* (transport-level, where the toolkit never ran to attach a hint) belong here.
|
|
49
|
+
* Application-level codes the toolkit emits as `{ success: false, … }` payloads
|
|
50
|
+
* carry their own hint — do not add them: those flow through `toolErrorFromPayload`,
|
|
51
|
+
* which relays the toolkit's hint verbatim, so the toolkit is the SSOT for their
|
|
52
|
+
* wording. `LOG_BUSY` / `LOG_UNAVAILABLE` are such payload codes — their recovery
|
|
53
|
+
* advice is version-gated on the editor's Godot version (only the toolkit knows it),
|
|
54
|
+
* so a fixed server default would be stale for half the versions. `COMPILATION_FAILED`
|
|
55
|
+
* stays: it is handled as a thrown code in `runtimeErrorWithCrashContext`.
|
|
56
|
+
*/
|
|
57
|
+
const EXCEPTION_HINTS = {
|
|
58
|
+
TIMEOUT: "The editor or game may be busy, or the game failed to compile/start. " +
|
|
59
|
+
"For editor calls, try editor.wait_for_idle. For runtime calls, " +
|
|
60
|
+
"debugger_get_log returns cached output after a crash. If empty, call editor_get_console without level_filter.",
|
|
61
|
+
COMPILATION_FAILED: "The game failed to compile. Fix the script errors listed above, then call game_start again. " +
|
|
62
|
+
"If no errors are shown, call editor_refresh to retrigger them, then editor_get_console for the full log.",
|
|
63
|
+
DISCONNECTED: "Plugin WebSocket not connected. Ensure Godot is running with the plugin enabled. If running headless, launch with: godot --headless --editor --path <project>",
|
|
64
|
+
CONNECT_FAILED: "Could not connect to the Godot editor plugin. Ensure Godot is running with the plugin enabled. If running headless, launch with: godot --headless --editor --path <project>",
|
|
65
|
+
GAME_NOT_RUNNING: "No running game. Call editor_get_console(level_filter:['error']) for crash diagnostics. " +
|
|
66
|
+
"If empty, the game may have crashed — debugger_get_log serves cached output from log file after a real crash (OS signal). " +
|
|
67
|
+
"To restart: fix the errors, then game_start.",
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Hint for a TIMEOUT that fired while the call sat in the toolkit's mutation
|
|
71
|
+
* queue (serialized behind other mutations), not because anything stalled. The
|
|
72
|
+
* generic EXCEPTION_HINTS.TIMEOUT "editor or game may be busy" advice is
|
|
73
|
+
* actively misleading here — the call was healthy, just waiting its fair-FIFO
|
|
74
|
+
* turn — so this replaces it and surfaces the duplicate-on-retry risk.
|
|
75
|
+
*/
|
|
76
|
+
const SERIALIZED_QUEUE_TIMEOUT_HINT = "The command was accepted and queued behind other scene mutations, then exceeded its timeout while waiting its turn. " +
|
|
77
|
+
"Issue scene mutations sequentially rather than in parallel. A retry may re-apply an already-applied change — " +
|
|
78
|
+
"re-check state before re-issuing (duplicate-on-retry risk).";
|
|
79
|
+
/**
|
|
80
|
+
* Map a thrown BridgeError (or any Error) to a toolError response.
|
|
81
|
+
* Preserves the bridge's transport-layer code (TIMEOUT, DISCONNECTED,
|
|
82
|
+
* GAME_NOT_RUNNING, CONNECT_FAILED, ...) so the client-facing response
|
|
83
|
+
* is specific enough to retry-or-give-up on.
|
|
84
|
+
*/
|
|
85
|
+
export function toolErrorFromException(err) {
|
|
86
|
+
const code = err instanceof BridgeError ? err.code : "INTERNAL";
|
|
87
|
+
const message = err?.message ?? String(err);
|
|
88
|
+
const hint = err instanceof BridgeError && err.serializedQueueTimeout ? SERIALIZED_QUEUE_TIMEOUT_HINT : EXCEPTION_HINTS[code];
|
|
89
|
+
return toolError(code, message, hint);
|
|
90
|
+
}
|
|
91
|
+
// ── Runtime crash context ───────────────────────────────────────────
|
|
92
|
+
/**
|
|
93
|
+
* When a runtime call fails, try to auto-fetch recent errors from the
|
|
94
|
+
* debugger bridge (game-session-specific errors + log file cache), with
|
|
95
|
+
* fallback to editor_get_console for pre-game-start scenarios.
|
|
96
|
+
*/
|
|
97
|
+
async function fetchCrashContext(bridge) {
|
|
98
|
+
// Primary: debugger.get_log has error_buffer (debugger bridge) + log file lines.
|
|
99
|
+
try {
|
|
100
|
+
const result = (await bridge.call("debugger.get_log", { limit: 15 }, 5_000));
|
|
101
|
+
const parts = [];
|
|
102
|
+
const errorBuffer = Array.isArray(result?.error_buffer) ? result.error_buffer : [];
|
|
103
|
+
for (const entry of errorBuffer) {
|
|
104
|
+
const e = entry;
|
|
105
|
+
const msg = typeof e.message === "string" ? e.message : "";
|
|
106
|
+
const src = typeof e.source === "string" ? e.source : "";
|
|
107
|
+
const line = typeof e.line === "number" ? e.line : 0;
|
|
108
|
+
if (msg) {
|
|
109
|
+
parts.push(src && line ? `${msg} (${src}:${line})` : msg);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const lines = Array.isArray(result?.lines) ? result.lines : [];
|
|
113
|
+
for (const line of lines) {
|
|
114
|
+
parts.push(String(line));
|
|
115
|
+
}
|
|
116
|
+
if (parts.length > 0)
|
|
117
|
+
return parts.join("\n");
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// debugger.get_log failed — fall through to editor_get_console
|
|
121
|
+
}
|
|
122
|
+
// Fallback: editor console (e.g., no game session was ever started).
|
|
123
|
+
try {
|
|
124
|
+
const result = (await bridge.call("editor.get_console", { limit: 15, level_filter: ["error"] }, 5_000));
|
|
125
|
+
const returned = result?.[PAGE_FIELD.returned];
|
|
126
|
+
if (typeof returned === "number" && returned === 0)
|
|
127
|
+
return "";
|
|
128
|
+
const entries = result?.entries;
|
|
129
|
+
if (typeof entries === "string" && entries.length > 0)
|
|
130
|
+
return entries;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Editor might be down too — fall through to generic hint
|
|
134
|
+
}
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Handle runtime errors (TIMEOUT / GAME_NOT_RUNNING) with auto-fetched
|
|
139
|
+
* crash context from editor_get_console. Exported for custom handlers
|
|
140
|
+
* (runtime_screenshot, debugger_get_log) that don't use callAndWrap.
|
|
141
|
+
*/
|
|
142
|
+
export async function runtimeErrorWithCrashContext(bridge, err) {
|
|
143
|
+
if (err instanceof BridgeError &&
|
|
144
|
+
(err.code === "TIMEOUT" || err.code === "GAME_NOT_RUNNING" || err.code === "COMPILATION_FAILED")) {
|
|
145
|
+
const crashContext = await fetchCrashContext(bridge);
|
|
146
|
+
if (crashContext) {
|
|
147
|
+
return toolError(err.code, err.message, "Game crashed or failed to compile. Recent errors from editor console:\n" +
|
|
148
|
+
crashContext +
|
|
149
|
+
"\nFix the script errors, then game_stop + game_start to retry. " +
|
|
150
|
+
"If errors are stale, call editor_refresh to retrigger them.");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return toolErrorFromException(err);
|
|
154
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime error classes. Kept out of types.ts so that module stays pure
|
|
3
|
+
* type/interface exports only.
|
|
4
|
+
*/
|
|
5
|
+
export class BridgeError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
/**
|
|
8
|
+
* Set on a TIMEOUT that fired after the toolkit acknowledged the call with a
|
|
9
|
+
* `_queued` progress notification but before it ran (`_executing`) — i.e. the
|
|
10
|
+
* call was serialized behind other scene mutations and exhausted its window
|
|
11
|
+
* while waiting its fair-FIFO turn, not because the editor stalled. Lets the
|
|
12
|
+
* error mapper steer to a serialization-specific hint while the transport
|
|
13
|
+
* `code` stays TIMEOUT (contract-neutral).
|
|
14
|
+
*/
|
|
15
|
+
serializedQueueTimeout = false;
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = "BridgeError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Shared pieces of the self-describing pagination contract that the SERVER owns:
|
|
4
|
+
* the request-param zod fragments each paginating tool spreads into its input
|
|
5
|
+
* schema, the canonical envelope prose for tool descriptions, and the read-side
|
|
6
|
+
* field-name constants + type for the two summary handlers.
|
|
7
|
+
*
|
|
8
|
+
* REFLECT boundary — this module has NO response builder. The toolkit's
|
|
9
|
+
* `Pagination` class is the sole author of the paged response envelope; the
|
|
10
|
+
* bridge forwards `message.result` verbatim. The only server code that *reads*
|
|
11
|
+
* the paged fields is the two non-reflect summary handlers (editor console,
|
|
12
|
+
* debugger log), which read them through {@link PAGE_FIELD} — never string
|
|
13
|
+
* literals, and never by re-encoding the response.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* The request fragments are load-bearing: the catalogue's zod strips undeclared
|
|
17
|
+
* top-level params, so an `offset`/`limit` a paginating tool never spreads never
|
|
18
|
+
* reaches the toolkit handler. The coercion style differs per family — plain
|
|
19
|
+
* `z.number` for the index-window tools that predate coercion, `z.coerce` for
|
|
20
|
+
* the byte/line-window tools — so this module exposes one fragment per family
|
|
21
|
+
* rather than forcing a single shape.
|
|
22
|
+
*/
|
|
23
|
+
// ── Request-param zod fragments (spread into a tool's inputSchema) ────────────
|
|
24
|
+
/**
|
|
25
|
+
* Index-window params (`offset`/`limit`) for LIST-family paginating tools
|
|
26
|
+
* (scene_query, classdb_search, classdb_get_info).
|
|
27
|
+
*
|
|
28
|
+
* @remarks
|
|
29
|
+
* `offset` floors at 0 — a negative skip is nonsensical, and a uniform floor
|
|
30
|
+
* keeps the whole surface validating the same way. Spread this into a tool's
|
|
31
|
+
* inputSchema so the catalogue zod forwards the params instead of stripping
|
|
32
|
+
* them. Per-tool defaults and caps differ; the prose stays generic here and the
|
|
33
|
+
* tool description names its own numbers.
|
|
34
|
+
*/
|
|
35
|
+
export const offsetLimitParams = {
|
|
36
|
+
offset: z
|
|
37
|
+
.number()
|
|
38
|
+
.int()
|
|
39
|
+
.min(0)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("Skip the first N (default 0); pass next_offset back as offset until has_more is false."),
|
|
42
|
+
limit: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.positive()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Page size (default per tool); a request above the cap is clamped and limit_clamped is set."),
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Byte-window params (`offset`/`max_bytes`) for the CONTENT-byte family
|
|
51
|
+
* (save_read). `offset` is a byte offset; `max_bytes` is the window size.
|
|
52
|
+
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* These accept string input (`z.coerce`) because the byte-window tools have
|
|
55
|
+
* always coerced — keep that shipped behavior. `next_offset` from the prior
|
|
56
|
+
* response drives the next window.
|
|
57
|
+
*/
|
|
58
|
+
export const byteWindowParams = {
|
|
59
|
+
offset: z.coerce
|
|
60
|
+
.number()
|
|
61
|
+
.int()
|
|
62
|
+
.min(0)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("Byte offset to start at (default 0); pass next_offset back to page."),
|
|
65
|
+
max_bytes: z.coerce
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.positive()
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Max bytes this window (default 64 KB; cap configurable)."),
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Line-window params (`start_line`/`end_line`, both 1-based inclusive) for the
|
|
74
|
+
* CONTENT-line family (script_read).
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* These accept string input (`z.coerce`) to match the tool's shipped behavior.
|
|
78
|
+
* `next_start_line` from the prior response drives the next window.
|
|
79
|
+
*/
|
|
80
|
+
export const lineWindowParams = {
|
|
81
|
+
start_line: z.coerce
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.positive()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("1-based first line (default 1); pass next_start_line back as start_line to page."),
|
|
87
|
+
end_line: z.coerce.number().int().positive().optional().describe("1-based last line to read (inclusive)."),
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Canonical envelope prose for a paginating tool's description, so every tool
|
|
91
|
+
* documents the same shape the toolkit emits instead of describing it a dozen
|
|
92
|
+
* different ways.
|
|
93
|
+
*
|
|
94
|
+
* @param unit - Names the total the count describes (e.g. "matches", "classes",
|
|
95
|
+
* "bytes", "lines", "assets"); rendered as `total_<unit>`.
|
|
96
|
+
* @param opts - Toggles the resume-field sentence, the cursor-less nav phrase,
|
|
97
|
+
* and the mutation caveat.
|
|
98
|
+
* @returns A sentence describing `returned`, `total_<unit>`, `has_more`, and the
|
|
99
|
+
* tool's paging mechanism.
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* paginationDoc("matches", { resumable: true, mutationCaveat: true });
|
|
103
|
+
* paginationDoc("assets", { resumable: false, cursorlessNav: "narrow filters or raise limit" });
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export function paginationDoc(unit, opts = {}) {
|
|
107
|
+
const { resumable = true, cursorlessNav, mutationCaveat = false } = opts;
|
|
108
|
+
let doc = `Paged: returned, total_${unit}, has_more. `;
|
|
109
|
+
if (resumable) {
|
|
110
|
+
// The line-window family resumes on next_start_line, not the byte/index
|
|
111
|
+
// families' next_offset — the prose must name the field the tool actually
|
|
112
|
+
// returns or the caller pages with the wrong key.
|
|
113
|
+
const resumeField = unit === "lines" ? "next_start_line" : "next_offset";
|
|
114
|
+
doc += `When has_more, page via ${resumeField} until has_more is false. `;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
doc += `Cursor-less — ${cursorlessNav ?? "narrow the query or raise limit"} for more. `;
|
|
118
|
+
}
|
|
119
|
+
if (mutationCaveat) {
|
|
120
|
+
doc += "Stable only if the source is unchanged between calls. ";
|
|
121
|
+
}
|
|
122
|
+
return doc;
|
|
123
|
+
}
|
|
124
|
+
// ── Read-side field names + type (for the two non-reflect handlers) ───────────
|
|
125
|
+
/**
|
|
126
|
+
* The paged-response field names the two summary handlers read from
|
|
127
|
+
* `message.result`. Held in one place so a rename touches this module, not the
|
|
128
|
+
* handlers — the toolkit is the sole envelope author (REFLECT), so these mirror
|
|
129
|
+
* the toolkit field names exactly.
|
|
130
|
+
*/
|
|
131
|
+
export const PAGE_FIELD = {
|
|
132
|
+
returned: "returned",
|
|
133
|
+
hasMore: "has_more",
|
|
134
|
+
totalLines: "total_lines",
|
|
135
|
+
};
|