@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.
Files changed (129) hide show
  1. package/ATTRIBUTIONS.md +141 -0
  2. package/LICENSE +28 -0
  3. package/README.md +386 -4
  4. package/dist/extensions/extensionChanges.js +131 -0
  5. package/dist/extensions/extensionCommand.js +25 -0
  6. package/dist/extensions/extensionDiscovery.js +141 -0
  7. package/dist/extensions/extensionRegistrar.js +82 -0
  8. package/dist/extensions/extensions.js +33 -0
  9. package/dist/groups/builtinGroups.js +60 -0
  10. package/dist/groups/defs/3dTools.js +17 -0
  11. package/dist/groups/defs/animationAuthoring.js +16 -0
  12. package/dist/groups/defs/assetOps.js +6 -0
  13. package/dist/groups/defs/audio.js +6 -0
  14. package/dist/groups/defs/classdb.js +6 -0
  15. package/dist/groups/defs/cleanup.js +6 -0
  16. package/dist/groups/defs/debugger.js +6 -0
  17. package/dist/groups/defs/editorAdvanced.js +16 -0
  18. package/dist/groups/defs/inputMap.js +6 -0
  19. package/dist/groups/defs/layerNaming.js +6 -0
  20. package/dist/groups/defs/lspCodeAnalysis.js +23 -0
  21. package/dist/groups/defs/lspCodeNavigation.js +15 -0
  22. package/dist/groups/defs/navigation.js +17 -0
  23. package/dist/groups/defs/particles.js +21 -0
  24. package/dist/groups/defs/pathEditing.js +22 -0
  25. package/dist/groups/defs/placeholders.js +28 -0
  26. package/dist/groups/defs/procedural.js +6 -0
  27. package/dist/groups/defs/resourceIo.js +6 -0
  28. package/dist/groups/defs/runtimeAdvanced.js +17 -0
  29. package/dist/groups/defs/sceneAdvanced.js +6 -0
  30. package/dist/groups/defs/sceneInheritance.js +6 -0
  31. package/dist/groups/defs/signals.js +6 -0
  32. package/dist/groups/defs/spriteframes.js +16 -0
  33. package/dist/groups/defs/theme.js +6 -0
  34. package/dist/groups/defs/tilemap.js +6 -0
  35. package/dist/groups/defs/tileset.js +22 -0
  36. package/dist/groups/defs/tilesetEdit.js +21 -0
  37. package/dist/groups/defs/userData.js +6 -0
  38. package/dist/groups/extensionGroups.js +182 -0
  39. package/dist/groups/groupActivation.js +210 -0
  40. package/dist/groups/groupCatalogue.js +52 -0
  41. package/dist/groups/groupMatch.js +182 -0
  42. package/dist/groups/groupResult.js +16 -0
  43. package/dist/groups/groupState.js +15 -0
  44. package/dist/groups/groupToolHandlers.js +103 -0
  45. package/dist/groups/groupTypes.js +10 -0
  46. package/dist/groups/groups.js +205 -0
  47. package/dist/index.js +144 -0
  48. package/dist/lsp/lspClient.js +509 -0
  49. package/dist/lsp/lspLabels.js +105 -0
  50. package/dist/lsp/lspProjectScan.js +156 -0
  51. package/dist/lsp/lspSession.js +139 -0
  52. package/dist/lsp/lspStatusReporter.js +76 -0
  53. package/dist/lsp/lspUri.js +68 -0
  54. package/dist/mcp/prompts.js +59 -0
  55. package/dist/mcp/resources.js +113 -0
  56. package/dist/mcp/roots.js +30 -0
  57. package/dist/registration/catalogue.js +123 -0
  58. package/dist/registration/extensionCollision.js +38 -0
  59. package/dist/registration/operations.js +67 -0
  60. package/dist/registration/screenshotResponse.js +71 -0
  61. package/dist/registration/toolDispatch.js +76 -0
  62. package/dist/registration/toolMeta.js +104 -0
  63. package/dist/registration/toolRefs.js +48 -0
  64. package/dist/registration/toolRegistry.js +211 -0
  65. package/dist/registry.js +291 -0
  66. package/dist/registryLiveness.js +113 -0
  67. package/dist/security/pathGuard.js +97 -0
  68. package/dist/security/profiles.js +104 -0
  69. package/dist/security/untrusted.js +23 -0
  70. package/dist/shared/errorContract.js +154 -0
  71. package/dist/shared/errors.js +21 -0
  72. package/dist/shared/pagination.js +135 -0
  73. package/dist/shared/schemaCoercion.js +173 -0
  74. package/dist/shared/stableJson.js +27 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/shared/version.js +82 -0
  77. package/dist/startup/cliArgs.js +103 -0
  78. package/dist/startup/configReload.js +57 -0
  79. package/dist/startup/hooks.js +89 -0
  80. package/dist/startup/lifecycle.js +22 -0
  81. package/dist/startup/portConfig.js +127 -0
  82. package/dist/startup/reconcile.js +81 -0
  83. package/dist/startup/registrars.js +59 -0
  84. package/dist/startup/serverMode.js +19 -0
  85. package/dist/startup/startupEnv.js +142 -0
  86. package/dist/tools/animation.js +88 -0
  87. package/dist/tools/asset.js +58 -0
  88. package/dist/tools/assetWrite.js +16 -0
  89. package/dist/tools/audio.js +40 -0
  90. package/dist/tools/classdb.js +43 -0
  91. package/dist/tools/collision.js +23 -0
  92. package/dist/tools/debug.js +46 -0
  93. package/dist/tools/diff.js +18 -0
  94. package/dist/tools/editor.js +222 -0
  95. package/dist/tools/file.js +38 -0
  96. package/dist/tools/folder.js +27 -0
  97. package/dist/tools/inputMap.js +54 -0
  98. package/dist/tools/layerNames.js +29 -0
  99. package/dist/tools/lsp.js +524 -0
  100. package/dist/tools/navigation.js +24 -0
  101. package/dist/tools/node.js +145 -0
  102. package/dist/tools/nodeManagement.js +82 -0
  103. package/dist/tools/particles.js +83 -0
  104. package/dist/tools/path.js +30 -0
  105. package/dist/tools/playtest.js +99 -0
  106. package/dist/tools/procedural.js +101 -0
  107. package/dist/tools/resource.js +47 -0
  108. package/dist/tools/runtime.js +338 -0
  109. package/dist/tools/save.js +64 -0
  110. package/dist/tools/scene.js +102 -0
  111. package/dist/tools/sceneInheritance.js +19 -0
  112. package/dist/tools/sceneQuery.js +38 -0
  113. package/dist/tools/script.js +75 -0
  114. package/dist/tools/signals.js +53 -0
  115. package/dist/tools/sound.js +31 -0
  116. package/dist/tools/spatial.js +41 -0
  117. package/dist/tools/spriteframes.js +94 -0
  118. package/dist/tools/texture.js +35 -0
  119. package/dist/tools/theme.js +35 -0
  120. package/dist/tools/threeD.js +116 -0
  121. package/dist/tools/tilemap.js +51 -0
  122. package/dist/tools/tileset.js +226 -0
  123. package/dist/transport/authHandshake.js +43 -0
  124. package/dist/transport/bridge.js +225 -0
  125. package/dist/transport/channel.js +352 -0
  126. package/dist/transport/heartbeat.js +54 -0
  127. package/dist/transport/runtimeConnection.js +239 -0
  128. package/dist/transport/tokenPath.js +98 -0
  129. 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
+ };