summer-engine 2.8.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,25 @@
2
2
 
3
3
  All notable changes to summer-engine will be documented here. Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
4
4
 
5
- ## [Unreleased]
5
+ ## [2.8.1] — 2026-08-18 — "Scene mutations work again on engine 0.5.60+"
6
+
7
+ ### Added
8
+ - `summer_get_scene_tree` accepts optional `depth` and `limit` params (engine defaults: depth 2, limit 200 — a 102-node scene silently truncated to 61 nodes at the defaults). The engine only honors them on scene-targeted reads, so the tool resolves the current scene path first when needed and says so when it can't. `summer_get_project_context` accepts an optional `settingsPrefix` and, without one, trims `project.data.entries` to a curated prefix set (application/, display/, input/, physics/, rendering/) instead of returning the full ~188KB settings dump — the payload declares the trim via `settingsTruncated`/`totalSettings`/`settingsHint`.
9
+ - `summer_screenshot` scene captures accept `nodePath` (frame a specific node, honest `node_not_found` failure) and new framing directions `back`/`left`/`right`; captions report the resolved framing and any render retries. Requires engine 0.5.62+ to take effect — older engines silently ignore the new fields.
10
+ - `summer_get_diagnostics` returns a prioritized bounded view (errors first, then warnings, capped info tail) with honest suppression counters, plus `includeAll: true` for the untrimmed payload. Severity + recency + caps only — no noise-pattern matching.
11
+ - `scripts/compat-smoke.sh`: a latest-MCP × candidate-engine release gate that drives the real built MCP server against a live engine and fails loudly on batch-contract incompatibilities (the class of bug that broke 2.7.0–2.8.0 × engine 0.5.60+). Run it before every engine release and every npm publish.
12
+
13
+ ### Fixed
14
+ - `summer_create_scene` no longer uses the destructive temporary-template strategy (open current scene → delete its children → save-as → restore). It now writes a minimal `.tscn` through the identity-bound engine `WriteFile` with a create-only guard, never touches the open scene, verifies by reading the file back, and gained a `rootType` param (Node3D/Node2D/Control). `allow_temporary_scene_mutation` remains accepted as a deprecated no-op.
15
+ - **Scene mutations were completely broken against engine 0.5.60/0.5.61.** The engine now requires `SaveScene`, `InstantiateScene`, `ReplaceNode`, `SimulateInput`, and the `Run*`/`Import*`/`Git*` ops to travel as their own single-op request, and rejects any multi-op batch containing one of them wholesale (`failure_reason: "unsupported_transport"`). Since 2.7.0 appended `SaveScene` to every mutation batch, every `summer_add_node`/`summer_set_prop`/`summer_batch`/`summer_create_scene` call was rejected before anything executed. Mutation batches are now automatically split into sequential requests around single-only ops; all receipts are preserved and merged, and a mutation that applied followed by a save that failed is reported honestly (including which ops already applied and which were not sent).
16
+ - Engine failures no longer hide the precise rejection. `extractOpError` previously returned a generic `"Engine operation failed (terminalState: failed)"` without inspecting `results[]`; it now surfaces the failed op's own error and `failure_reason` (envelope or per-op, either spelling), rendered as JSON when a classifier is present so agents can key on `failure_reason` reliably.
17
+ - `save_frame` is documented with its required `name` argument everywhere (`save_frame()` with no args is a probe script error), plus the deferred scene-mount pattern that avoids black frames.
18
+ - SimulateInput guidance corrected: it IS reachable over MCP/CLI as a single op against the running game (`failure_reason: "not_running"`/`"unsupported"` are the real failure modes); `"unsupported_transport"` only means it was batched with other ops. The previous claim that it needs the in-editor bridge on every build was stale.
19
+ - The scene-preview synthetic-camera note no longer claims the scene has no camera — the engine always synthesizes the preview camera; `sceneHasCamera` is the authoritative signal and keeps its own warning.
20
+ - `summer login` waits 15 minutes on one session id (was 2) with periodic reminders, covering first-time account creation + email confirmation. The gateway never expires a pending session, so the single id stays valid the whole window.
21
+ - Removed the stale "Engine mirror only" banner from the README (it shipped to npm and the public repo, and its claims were wrong).
22
+
23
+ ## [2.8.0] — 2026-08-17 — "Multi-editor MCP routing"
6
24
 
7
25
  ### Added
8
26
  - MCP discovers every live Summer editor through `~/.summer/instances/` and automatically binds local tools to the editor whose project contains the agent's current working directory.
package/README.md CHANGED
@@ -1,7 +1,5 @@
1
1
  # Summer Engine: the AI game engine
2
2
 
3
- > **Engine mirror only.** This directory is intentionally marked `private` and must never be published to npm. Review and reconcile changes into [SummerEngine/summer-engine-agent](https://github.com/SummerEngine/summer-engine-agent), which is the only npm release source.
4
-
5
3
  Build real 2D and 3D Summer games with Summer Engine, the Summer SDK, and
6
4
  GDScript. The agent layer can help author code and scenes, but creators remain
7
5
  in control of changes and release decisions. Local export support depends on
@@ -324,7 +322,7 @@ summer_batch ops:[{op:"RunVerification", probe_source:"…", max_seconds:20}]
324
322
  → {ok, results, frames, out_dir}
325
323
  ```
326
324
 
327
- Unlike `--headless`, that instance has a real renderer, so `save_frame()` writes an actual image. Probe API: `report()` / `save_frame()` / `dump_tree()` / `press()` / `key()` / `finish()`. **`press()` and `key()` are coroutines — `await` them.** Every project scaffolded by `summer create` ships a working example at `tests/autopilot/`.
325
+ Unlike `--headless`, that instance has a real renderer, so `save_frame("name")` writes an actual image (the name argument is required). Probe API: `report(name, value)` / `save_frame(name)` / `dump_tree()` / `press(action)` / `key(keycode)` / `finish()`. **`press()` and `key()` are coroutines — `await` them.** Every project scaffolded by `summer create` ships a working example at `tests/autopilot/`.
328
326
 
329
327
  `SimulateInput` is **not** reachable from MCP or the CLI, on any build. It needs the in-editor chat bridge's async reply channel; every queued caller is answered `{ok:false, failure_reason:"unsupported_transport"}`. Use the probe.
330
328
 
@@ -4,7 +4,13 @@ import open from "open";
4
4
  import { getAuthToken, getCreatorToken, saveCreatorToken, saveLoginSession, } from "../lib/auth.js";
5
5
  import { getCreatorApiUrl, getGatewayUrl } from "../lib/config.js";
6
6
  const POLL_INTERVAL_MS = 2000;
7
- const POLL_TIMEOUT_MS = 120000;
7
+ // One generous window on ONE session id. First-time users may need to create an
8
+ // account and confirm an email before they can approve the CLI. The gateway
9
+ // never expires a pending session (the completed payload waits under this id
10
+ // for pickup), so the worst mistake would be rotating ids mid-wait — the user
11
+ // would approve the original browser tab while we poll a different session.
12
+ const POLL_TIMEOUT_MS = 900000;
13
+ const HEARTBEAT_MS = 30000;
8
14
  export const loginCommand = new Command("login")
9
15
  .description("Sign in to Summer Engine via your browser")
10
16
  .option("--creator", "Connect a separately scoped Summercraft creator token for publishing")
@@ -121,9 +127,15 @@ export async function runLogin(overrides = {}) {
121
127
  deps.log("Waiting for authentication...");
122
128
  const pollUrl = `${gatewayUrl}/api/auth/cli-login?session=${encodeURIComponent(sessionId)}`;
123
129
  const startTime = deps.now();
130
+ let lastHeartbeat = startTime;
124
131
  let lastError = null;
125
132
  while (deps.now() - startTime < POLL_TIMEOUT_MS) {
126
133
  await deps.sleep(POLL_INTERVAL_MS);
134
+ if (deps.now() - lastHeartbeat >= HEARTBEAT_MS) {
135
+ lastHeartbeat = deps.now();
136
+ deps.log('Still waiting — finish signing in (or creating your account) in the browser, then click "Yes, Sign In". Same link: ' +
137
+ loginUrl);
138
+ }
127
139
  try {
128
140
  const res = await deps.fetch(pollUrl, {
129
141
  signal: AbortSignal.timeout(5000),
@@ -161,5 +173,5 @@ export async function runLogin(overrides = {}) {
161
173
  lastError = err instanceof Error ? err.message : "Network error";
162
174
  }
163
175
  }
164
- throw new Error(`Login timed out after 120 seconds.${lastError ? ` Last error: ${lastError}` : ""} Recovery: run "summer login" again, complete "Yes, Sign In" in the browser, and return to the terminal within two minutes.`);
176
+ throw new Error(`Login timed out after ${Math.round(POLL_TIMEOUT_MS / 60000)} minutes.${lastError ? ` Last error: ${lastError}` : ""} Recovery: run "summer login" again and complete "Yes, Sign In" in the browser tab it opens.`);
165
177
  }
@@ -29,6 +29,15 @@ export type EngineSnapshot = {
29
29
  sceneHasCamera?: boolean;
30
30
  sceneHadLight?: boolean;
31
31
  usedSyntheticCamera?: boolean;
32
+ /** Scene-preview only: the framing the engine actually rendered with
33
+ * ("auto" resolves to "iso"). */
34
+ framing?: string;
35
+ /** Scene-preview only: path (relative to the scene root) of the node the
36
+ * camera framed when nodePath was given. */
37
+ framedNode?: string;
38
+ /** Scene-preview only: how many blank-readback retries the engine needed
39
+ * before the capture produced a non-blank frame (0 = clean first read). */
40
+ renderRetries?: number;
32
41
  /** Set true when the bound projectIdHash no longer matches the engine's live
33
42
  * health hash at capture time — the frame may be from the WRONG project
34
43
  * (item 4, client-side drift check). */
@@ -86,8 +95,11 @@ export declare class EngineApiClient {
86
95
  */
87
96
  executeIdentityBoundOps(ops: Record<string, unknown>[], options?: Record<string, unknown>): Promise<unknown>;
88
97
  readProjectFile(path: string, maxBytes?: number): Promise<unknown>;
89
- getSceneState(scenePath?: string): Promise<unknown>;
90
- getProjectState(): Promise<unknown>;
98
+ getSceneState(scenePath?: string, options?: {
99
+ depth?: number;
100
+ limit?: number;
101
+ }): Promise<unknown>;
102
+ getProjectState(prefix?: string): Promise<unknown>;
91
103
  getDiagnostics(): Promise<unknown>;
92
104
  inspectNode(path: string): Promise<unknown>;
93
105
  inspectResource(path: string): Promise<unknown>;
@@ -117,13 +129,18 @@ export declare class EngineApiClient {
117
129
  * Offscreen scene render via the `ScenePreview` op over /api/ops (no game
118
130
  * boot; physics/animations are static). Mirrors the web previewScene op input
119
131
  * (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
120
- * node_path? } — scene_path optional, engine defaults to the open scene. The
121
- * result carries image_base64 + mime (+ width/height) plus the P4.3 confession
122
- * fields (scene_has_camera / scene_had_light / used_synthetic_camera).
132
+ * node? } — scene_path optional, engine defaults to the open scene. `node` is
133
+ * the canonical focus-node arg (the engine also accepts the legacy web wire
134
+ * name `node_path`); an unresolvable node fails with failure_reason
135
+ * "node_not_found". framing "auto" is an alias of "iso"; the engine also
136
+ * accepts "top"/"front"/"back"/"left"/"right" and echoes the resolved framing.
137
+ * The result carries image_base64 + mime (+ width/height) plus the P4.3
138
+ * confession fields (scene_has_camera / scene_had_light /
139
+ * used_synthetic_camera) and framing / framed_node / render_retries.
123
140
  */
124
141
  scenePreview(input?: {
125
142
  scenePath?: string;
126
- framing?: "auto" | "top" | "front" | "iso";
143
+ framing?: "auto" | "iso" | "top" | "front" | "back" | "left" | "right";
127
144
  size?: [number, number];
128
145
  nodePath?: string;
129
146
  }): Promise<EngineSnapshot>;
@@ -330,14 +330,33 @@ export class EngineApiClient {
330
330
  });
331
331
  return this.request("GET", `/api/state/read-file?${query.toString()}`);
332
332
  }
333
- async getSceneState(scenePath) {
334
- const query = scenePath
335
- ? `?scene=${encodeURIComponent(scenePath)}`
336
- : "";
333
+ async getSceneState(scenePath, options) {
334
+ // depth/limit only take effect on targeted (scene=) reads: the engine
335
+ // routes those through the live command queue and forwards every query
336
+ // param into StateProvider::scene_state (tool_net_thread.cpp
337
+ // _parse_state_args). An UNtargeted read is answered from a pre-published
338
+ // snapshot built with the defaults (depth 2, limit 200) and its query
339
+ // params are ignored — callers who need depth/limit must pass scenePath
340
+ // (summer_get_scene_tree resolves the current scene path for this).
341
+ const parts = [];
342
+ if (scenePath)
343
+ parts.push(`scene=${encodeURIComponent(scenePath)}`);
344
+ if (options?.depth !== undefined)
345
+ parts.push(`depth=${options.depth}`);
346
+ if (options?.limit !== undefined)
347
+ parts.push(`limit=${options.limit}`);
348
+ const query = parts.length ? `?${parts.join("&")}` : "";
337
349
  return this.request("GET", `/api/state/scene${query}`);
338
350
  }
339
- async getProjectState() {
340
- return this.request("GET", "/api/state/project");
351
+ async getProjectState(prefix) {
352
+ // ?prefix= is sent for forward-compatibility, but current engine builds
353
+ // IGNORE it: /api/state/project is always served from the snapshot, which
354
+ // is published with empty args (local_api_server.cpp
355
+ // _maybe_publish_snapshot), so StateProvider::project_state never sees the
356
+ // query. Callers that need a prefix subset must also filter client-side
357
+ // (summer_get_project_context does).
358
+ const query = prefix ? `?prefix=${encodeURIComponent(prefix)}` : "";
359
+ return this.request("GET", `/api/state/project${query}`);
341
360
  }
342
361
  async getDiagnostics() {
343
362
  return this.request("GET", "/api/state/diagnostics");
@@ -475,6 +494,9 @@ export class EngineApiClient {
475
494
  sceneHasCamera: boolFrom(payload.scene_has_camera),
476
495
  sceneHadLight: boolFrom(payload.scene_had_light),
477
496
  usedSyntheticCamera: boolFrom(payload.used_synthetic_camera),
497
+ framing: stringFrom(payload.framing),
498
+ framedNode: stringFrom(payload.framed_node),
499
+ renderRetries: numberFrom(payload.render_retries),
478
500
  projectMismatch,
479
501
  metadata: withoutImageData(payload),
480
502
  };
@@ -525,9 +547,14 @@ export class EngineApiClient {
525
547
  * Offscreen scene render via the `ScenePreview` op over /api/ops (no game
526
548
  * boot; physics/animations are static). Mirrors the web previewScene op input
527
549
  * (snapshot-tools.ts): { op:'ScenePreview', scene_path?, framing?, size?,
528
- * node_path? } — scene_path optional, engine defaults to the open scene. The
529
- * result carries image_base64 + mime (+ width/height) plus the P4.3 confession
530
- * fields (scene_has_camera / scene_had_light / used_synthetic_camera).
550
+ * node? } — scene_path optional, engine defaults to the open scene. `node` is
551
+ * the canonical focus-node arg (the engine also accepts the legacy web wire
552
+ * name `node_path`); an unresolvable node fails with failure_reason
553
+ * "node_not_found". framing "auto" is an alias of "iso"; the engine also
554
+ * accepts "top"/"front"/"back"/"left"/"right" and echoes the resolved framing.
555
+ * The result carries image_base64 + mime (+ width/height) plus the P4.3
556
+ * confession fields (scene_has_camera / scene_had_light /
557
+ * used_synthetic_camera) and framing / framed_node / render_retries.
531
558
  */
532
559
  async scenePreview(input) {
533
560
  const opInput = { op: "ScenePreview" };
@@ -539,7 +566,7 @@ export class EngineApiClient {
539
566
  if (input?.size)
540
567
  opInput.size = input.size;
541
568
  if (input?.nodePath)
542
- opInput.node_path = input.nodePath;
569
+ opInput.node = input.nodePath;
543
570
  let response;
544
571
  try {
545
572
  // executeOps stamps the bound identity so a drifted project is rejected
@@ -1,2 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * Prioritized, bounded view of the engine diagnostics payload:
4
+ * - console.messages reordered: errors first, then warnings, then a capped tail
5
+ * of info/std/editor noise (engine order is newest-first; that order is
6
+ * preserved within each severity bucket, so the kept tail is the most recent).
7
+ * - debugger.errors_data untouched; debugger.warnings_data capped to the most
8
+ * recent DEBUGGER_WARNING_CAP entries (engine emits newest-first).
9
+ * - a `_view` block with honest counters and a hint pointing at includeAll.
10
+ * Shape-tolerant: anything unexpected passes through unchanged.
11
+ * Exported for unit tests.
12
+ */
13
+ export declare function prioritizeDiagnostics(payload: unknown): unknown;
2
14
  export declare function registerDebugTools(server: McpServer): void;
@@ -2,16 +2,115 @@ import { z } from "zod";
2
2
  import { withEngine } from "./with-engine.js";
3
3
  import { shapeEngineLogResponse } from "../../lib/log-filters.js";
4
4
  import { createDebugReportArtifact } from "../../lib/debug-report.js";
5
+ // summer_get_diagnostics view shaping. The engine serves /api/state/diagnostics
6
+ // from a pre-published snapshot (empty args — query params are NOT forwarded on
7
+ // that route), so trimming has to happen here. The snapshot defaults bury
8
+ // task-specific failures under baseline info noise (50 console messages, up to
9
+ // 100 debugger entries); this reorders by severity and caps ONLY the
10
+ // low-severity bodies. All counts stay intact and honest. No pattern-matching
11
+ // against specific noise strings — severity + recency + caps only.
12
+ const CONSOLE_NOISE_TAIL_CAP = 10;
13
+ const DEBUGGER_WARNING_CAP = 20;
14
+ /**
15
+ * Prioritized, bounded view of the engine diagnostics payload:
16
+ * - console.messages reordered: errors first, then warnings, then a capped tail
17
+ * of info/std/editor noise (engine order is newest-first; that order is
18
+ * preserved within each severity bucket, so the kept tail is the most recent).
19
+ * - debugger.errors_data untouched; debugger.warnings_data capped to the most
20
+ * recent DEBUGGER_WARNING_CAP entries (engine emits newest-first).
21
+ * - a `_view` block with honest counters and a hint pointing at includeAll.
22
+ * Shape-tolerant: anything unexpected passes through unchanged.
23
+ * Exported for unit tests.
24
+ */
25
+ export function prioritizeDiagnostics(payload) {
26
+ if (!payload || typeof payload !== "object")
27
+ return payload;
28
+ const root = payload;
29
+ if (!root.data || typeof root.data !== "object")
30
+ return payload;
31
+ const data = root.data;
32
+ const shapedData = { ...data };
33
+ let totalConsole = 0;
34
+ let shownConsole = 0;
35
+ let suppressedInfo = 0;
36
+ if (data.console && typeof data.console === "object") {
37
+ const c = data.console;
38
+ totalConsole = typeof c.total === "number" ? c.total : 0;
39
+ if (Array.isArray(c.messages)) {
40
+ const errors = [];
41
+ const warnings = [];
42
+ const rest = [];
43
+ for (const m of c.messages) {
44
+ const type = m && typeof m === "object" ? m.type : undefined;
45
+ if (type === "error")
46
+ errors.push(m);
47
+ else if (type === "warning")
48
+ warnings.push(m);
49
+ else
50
+ rest.push(m);
51
+ }
52
+ const keptRest = rest.slice(0, CONSOLE_NOISE_TAIL_CAP);
53
+ suppressedInfo = rest.length - keptRest.length;
54
+ const messages = [...errors, ...warnings, ...keptRest];
55
+ shownConsole = messages.length;
56
+ shapedData.console = { ...c, messages, returned: messages.length };
57
+ }
58
+ }
59
+ let totalDebugger = 0;
60
+ let shownDebugger = 0;
61
+ let suppressedDebuggerWarnings = 0;
62
+ if (data.debugger && typeof data.debugger === "object") {
63
+ const g = data.debugger;
64
+ totalDebugger =
65
+ (typeof g.errors === "number" ? g.errors : 0) +
66
+ (typeof g.warnings === "number" ? g.warnings : 0);
67
+ const errorsData = Array.isArray(g.errors_data) ? g.errors_data : [];
68
+ if (Array.isArray(g.warnings_data)) {
69
+ const keptWarnings = g.warnings_data.slice(0, DEBUGGER_WARNING_CAP);
70
+ suppressedDebuggerWarnings = g.warnings_data.length - keptWarnings.length;
71
+ shownDebugger = errorsData.length + keptWarnings.length;
72
+ shapedData.debugger = { ...g, warnings_data: keptWarnings };
73
+ }
74
+ else {
75
+ shownDebugger = errorsData.length;
76
+ }
77
+ }
78
+ return {
79
+ ...root,
80
+ data: shapedData,
81
+ _view: {
82
+ mode: "prioritized",
83
+ totalConsole,
84
+ shownConsole,
85
+ suppressedInfo,
86
+ totalDebugger,
87
+ shownDebugger,
88
+ suppressedDebuggerWarnings,
89
+ hint: "All counts are complete; only low-severity message bodies were trimmed. Re-call with includeAll: true for the untrimmed payload, or use summer_get_console / summer_get_debugger_warnings for targeted reads.",
90
+ },
91
+ };
92
+ }
5
93
  export function registerDebugTools(server) {
6
94
  server.tool("summer_get_diagnostics", `Quick overview of all errors and warnings from both the editor console and the runtime debugger. Returns error counts and a guidance message.
7
95
 
8
96
  ALWAYS call this FIRST before diving into summer_get_console or summer_get_debugger_errors. It tells you where to look.
9
97
 
98
+ By default the response is a prioritized view: errors first, then warnings, then a small capped tail of recent info/std noise. Counts (console totals, debugger totals) are always complete — only low-severity message bodies are trimmed, and a "_view" block reports exactly what was suppressed. Pass includeAll: true for the full untrimmed engine payload.
99
+
10
100
  Typical workflow after making changes:
11
101
  1. summer_get_diagnostics — are there issues?
12
102
  2. If errors: summer_get_console or summer_get_debugger_errors for details
13
103
  3. Fix the issues
14
- 4. summer_get_diagnostics again to verify`, {}, async () => withEngine(async (client) => client.getDiagnostics()));
104
+ 4. summer_get_diagnostics again to verify`, {
105
+ includeAll: z
106
+ .boolean()
107
+ .optional()
108
+ .default(false)
109
+ .describe("Return the full engine diagnostics payload untrimmed (no severity reordering, no info-noise cap)."),
110
+ }, async ({ includeAll }) => withEngine(async (client) => {
111
+ const raw = await client.getDiagnostics();
112
+ return includeAll ? raw : prioritizeDiagnostics(raw);
113
+ }));
15
114
  server.tool("summer_get_console", `Read recent messages from the editor's Output panel.
16
115
 
17
116
  Output is post-processed for token economy: consecutive identical messages collapse into one entry with a "(×N)" count suffix, and the response carries a "_filter" summary so you can see what was hidden. Use errors_only=true (default) to drop info/std noise; use raw=true to bypass all shaping.
@@ -88,6 +88,62 @@ function getCurrentScene(projectState, sceneState, health) {
88
88
  pickString(healthRoot, ["scene", "scenePath", "scene_path"]) ??
89
89
  null);
90
90
  }
91
+ function getScenePathFromSceneState(sceneState) {
92
+ const root = asRecord(sceneState);
93
+ return (pickString(asRecord(root?.provenance), ["scenePath", "scene_path"]) ??
94
+ pickString(asRecord(root?.data), ["scenePath", "scene_path"]) ??
95
+ null);
96
+ }
97
+ /** Default prefix set kept in summer_get_project_context when no settingsPrefix
98
+ * is given. The full settings dump measured 188KB (~47k tokens) on a real
99
+ * project; these prefixes cover what agents actually key on (main scene,
100
+ * window size, input actions, physics, renderer). */
101
+ const CONTEXT_SETTINGS_PREFIXES = [
102
+ "application/",
103
+ "display/",
104
+ "input/",
105
+ "physics/",
106
+ "rendering/",
107
+ ];
108
+ /**
109
+ * Bound the project-state payload. The engine ignores ?prefix= on
110
+ * /api/state/project (snapshot-served with empty args — see
111
+ * ApiClient.getProjectState), so ALL filtering here is client-side:
112
+ * - settingsPrefix given: keep only entries under that prefix.
113
+ * - no prefix: keep only the curated CONTEXT_SETTINGS_PREFIXES.
114
+ * Trimming is always declared in the payload (settingsTruncated /
115
+ * totalSettings / hint); everything outside data.entries is preserved.
116
+ */
117
+ function trimProjectSettings(projectState, settingsPrefix) {
118
+ const root = asRecord(projectState);
119
+ const data = asRecord(root?.data);
120
+ const entries = data?.entries;
121
+ if (!root || !data || !Array.isArray(entries))
122
+ return projectState;
123
+ const keyOf = (entry) => stringFrom(asRecord(entry)?.key);
124
+ const kept = settingsPrefix
125
+ ? entries.filter((entry) => keyOf(entry)?.startsWith(settingsPrefix) ?? false)
126
+ : entries.filter((entry) => {
127
+ const key = keyOf(entry);
128
+ return key !== null && CONTEXT_SETTINGS_PREFIXES.some((p) => key.startsWith(p));
129
+ });
130
+ return {
131
+ ...root,
132
+ data: {
133
+ ...data,
134
+ entries: kept,
135
+ settingsTruncated: kept.length < entries.length,
136
+ totalSettings: entries.length,
137
+ returnedSettings: kept.length,
138
+ ...(settingsPrefix
139
+ ? { settingsPrefix }
140
+ : {
141
+ settingsPrefixesIncluded: [...CONTEXT_SETTINGS_PREFIXES],
142
+ settingsHint: "Settings were trimmed to the curated prefixes above to bound payload size. Pass settingsPrefix (e.g. 'audio/' or 'layer_names/') to read another settings group.",
143
+ }),
144
+ },
145
+ };
146
+ }
91
147
  export function registerProjectTools(server) {
92
148
  server.tool("summer_start_game_task", `Start here for any substantial AI game-building task.
93
149
 
@@ -180,12 +236,12 @@ anti-patterns, and recovery steps.`, {}, async () => ({
180
236
  "summer_stop -> stop when runtime verification is finished; editor scene mutations are not categorically blocked by a running game, but an existing game instance may need a restart to observe them",
181
237
  ],
182
238
  "4_interactive": [
183
- "To prove input-driven behavior (does jump/move/shoot actually work), use RunVerification. It is the ONLY interactive route available to MCP, and it is a real one — do NOT hand this rung to the user.",
239
+ "To prove input-driven behavior (does jump/move/shoot actually work), prefer RunVerification — a scripted, repeatable probe. SimulateInput is also reachable as a single op against the RUNNING game (see rawOpsViaBatch); do NOT hand this rung to the user while either route works.",
184
240
  "RunVerification: spawn a hidden, disposable game instance that runs a GDScript probe (press inputs, read state, save frames) and dies — never touches the user's editor. Returns results.json + frames. Send it as a raw op through summer_batch (see rawOpsViaBatch).",
185
- "Unlike the editor's own --headless mode, the verify instance renders REAL PIXELS, so save_frame() writes a real image and Performance.TIME_FPS reports a real number.",
241
+ "Unlike the editor's own --headless mode, the verify instance renders REAL PIXELS, so save_frame('name') writes a real image and Performance.TIME_FPS reports a real number. save_frame REQUIRES a name argument — save_frame() with no args is a script error (probe fails).",
242
+ "Mount probe scenes DEFERRED: get_tree().root.add_child.call_deferred(instance); await get_tree().process_frame; await settle(). A direct add_child during _ready can hit the parent-busy guard and 'succeed' while capturing a black frame.",
186
243
  "press()/key() are COROUTINES — 'await press(\"move_right\", 500)' or the hold never elapses and the input does nothing.",
187
244
  "Assert on physics-frame-derived state (positions after N 'await get_tree().physics_frame'), which is reproducible. press(hold_ms) waits on wall clock, so distance-travelled jitters run to run — assert 'moved more than X', never an exact value.",
188
- "SimulateInput is NOT reachable from MCP — see rawOpsViaBatch. Do not attempt it as a fallback.",
189
245
  ],
190
246
  },
191
247
  // HONESTY — mirror the in-product agent's vision rules. A capture is
@@ -219,8 +275,9 @@ anti-patterns, and recovery steps.`, {}, async () => ({
219
275
  // engine ops that have no dedicated tool are still reachable.
220
276
  rawOpsViaBatch: [
221
277
  "summer_batch runs an array of raw engine ops in one undo group; each op is passed through untouched, so newer engine ops with no dedicated tool are still callable.",
222
- "RunVerification (hidden probe instance): summer_batch ops:[{op:'RunVerification', probe_source:'<gdscript>', max_seconds:20}]. probe_source extends SummerProbeBase and uses report()/save_frame()/press()/key()/finish(); returns {ok, results, frames, out_dir} or {ok:false, failure_reason: spawn_failed|timeout|bad_args|no_project|probe_not_node}.",
223
- "SimulateInput is NOT reachable this way, on any engine build. It needs the in-editor chat bridge's async reply channel; every queued caller (MCP, CLI) is answered with {ok:false, failure_reason:'unsupported_transport'}. Use RunVerification's press()/key() instead.",
278
+ "RunVerification (hidden probe instance): summer_batch ops:[{op:'RunVerification', probe_source:'<gdscript>', max_seconds:20}]. probe_source extends SummerProbeBase and uses report(name, value)/save_frame(name)/press(action)/key(keycode)/finish(); returns {ok, results, frames, out_dir} or {ok:false, failure_reason: spawn_failed|timeout|bad_args|no_project|probe_not_node}. save_frame REQUIRES a name argument.",
279
+ "SimulateInput (drive the RUNNING game — summer_play first): summer_batch ops:[{op:'SimulateInput', type:'action', action:'jump', pressed:true}], sent ALONE as the only op. failure_reason 'not_running' = start the game first; 'unsupported' = the running game build predates the handler — use RunVerification instead.",
280
+ "SINGLE-OP CONTRACT: the engine rejects any multi-op batch containing SaveScene, InstantiateScene, ReplaceNode, SimulateInput, ViewportSnapshot, GameSnapshot, Run*/Import* or Git* ops (failure_reason 'unsupported_transport', nothing executes). summer_batch splits these into sequential requests for you; when composing raw batches keep them as their own call anyway.",
224
281
  "WriteFile and ReplaceText are rejected here by design — use summer_write_file / summer_replace_text so project identity, content guards and same-file ordering are enforced.",
225
282
  "You do not need an engine op to run a shell command: your own host already has a shell. The engine binary that runs project scripts is at OS.get_executable_path() (on macOS, /Applications/Summer.app/Contents/MacOS/Summer); see the summer-cli headless-scripting skill.",
226
283
  "These are runtime ops, not scene mutations — the batch undo group is a harmless no-op for them.",
@@ -233,7 +290,7 @@ anti-patterns, and recovery steps.`, {}, async () => ({
233
290
  "If a mutation is rejected with identity_mismatch: the engine switched projects — call summer_get_project_context to rebind (only if you meant to follow it), then retry.",
234
291
  "If a guarded file mutation is rejected with content mismatch: call summer_read_file again, review the new content, and retry with its new sha256 only if the edit is still valid.",
235
292
  "If a run op returns 'unsupported' / an unknown-op error: this engine build predates it — fall back to summer_play + summer_get_debugger_errors.",
236
- "If SimulateInput returns 'unsupported_transport': that is permanent for MCP, not a build gap. Rewrite the check as a RunVerification probe.",
293
+ "If any op returns 'unsupported_transport': it was batched with other ops. Resend it as the ONLY op in the request (nothing from the rejected batch was applied).",
237
294
  ],
238
295
  debugging: [
239
296
  "Set SUMMER_MCP_DEBUG=1 in the MCP server's environment to log a structured stderr line per tool call (tool, ok, durationMs, terminalState, errorClass, failureReason, retried, boundProjectIdHash). With the flag OFF, only failures are logged. Use it to see exactly which op failed and why.",
@@ -249,19 +306,33 @@ anti-patterns, and recovery steps.`, {}, async () => ({
249
306
  - main scene path from project settings
250
307
  - lightweight .summer project memory summary when project path is available
251
308
 
252
- Use this first in every fresh chat to avoid guessing scene filenames or editing the wrong scene.`, {}, async () => withEngine(async (client) => {
253
- const [health, projectState, sceneState] = await Promise.all([
309
+ Use this first in every fresh chat to avoid guessing scene filenames or editing the wrong scene.
310
+
311
+ Project settings in project.data.entries are trimmed to a curated prefix set
312
+ (application/, display/, input/, physics/, rendering/) to bound payload size —
313
+ the untrimmed dump can exceed 45k tokens. The payload declares the trim
314
+ (settingsTruncated, totalSettings, settingsPrefixesIncluded). Pass
315
+ settingsPrefix to read a specific settings group (e.g. 'audio/') instead.`, {
316
+ settingsPrefix: z
317
+ .string()
318
+ .optional()
319
+ .describe("Only return project settings whose key starts with this prefix, e.g. 'audio/' or 'application/config/'. Omit for the curated default set."),
320
+ }, async ({ settingsPrefix }) => withEngine(async (client) => {
321
+ const [health, fullProjectState, sceneState] = await Promise.all([
254
322
  client.health(),
255
- client.getProjectState(),
323
+ client.getProjectState(settingsPrefix),
256
324
  client.getSceneState().catch((err) => ({
257
325
  ok: false,
258
326
  error: err instanceof Error ? err.message : String(err),
259
327
  })),
260
328
  ]);
261
- const projectPath = getProjectPath(projectState, health);
262
- const projectName = getProjectName(projectState, health);
263
- const mainScene = getMainScene(projectState);
264
- const currentScene = getCurrentScene(projectState, sceneState, health);
329
+ // Derived fields read the UNtrimmed state so a narrow settingsPrefix
330
+ // cannot hide mainScene/projectPath from the context summary.
331
+ const projectState = trimProjectSettings(fullProjectState, settingsPrefix);
332
+ const projectPath = getProjectPath(fullProjectState, health);
333
+ const projectName = getProjectName(fullProjectState, health);
334
+ const mainScene = getMainScene(fullProjectState);
335
+ const currentScene = getCurrentScene(fullProjectState, sceneState, health);
265
336
  // This tool is the deliberate (re)bind point: capture the currently-open
266
337
  // project as the one this session's mutations are pinned to. After an
267
338
  // engine project switch the agent calls this to intentionally follow the
@@ -326,9 +397,48 @@ Example: Bind jump to Space and W:
326
397
  server.tool("summer_get_scene_tree", `Get a scene tree. Pass scenePath to read that exact in-memory/open scene;
327
398
  omit it only when you intentionally want the currently visible editor scene.
328
399
  Scene mutations load their explicit target, so a follow-up targeted read does
329
- not require OpenScene.`, {
400
+ not require OpenScene.
401
+
402
+ The engine defaults to depth 2 and limit 200 nodes and SILENTLY truncates
403
+ deeper hierarchies (the response then carries truncated: true and a visited
404
+ count lower than the real node count). Pass an explicit depth (e.g. 10) to
405
+ read a full tree — a 102-node scene returns only 61 nodes at the defaults.`, {
330
406
  scenePath: z.string().optional().describe("Exact res:// scene path to inspect"),
331
- }, async ({ scenePath }) => withEngine(async (client) => client.getSceneState(scenePath)));
407
+ depth: z
408
+ .number()
409
+ .int()
410
+ .positive()
411
+ .optional()
412
+ .describe("Maximum tree depth to walk. Engine default is 2 — pass a larger value for deep hierarchies."),
413
+ limit: z
414
+ .number()
415
+ .int()
416
+ .positive()
417
+ .optional()
418
+ .describe("Maximum number of nodes to return. Engine default is 200."),
419
+ }, async ({ scenePath, depth, limit }) => withEngine(async (client) => {
420
+ if (depth === undefined && limit === undefined) {
421
+ return client.getSceneState(scenePath);
422
+ }
423
+ // The engine honors depth/limit ONLY on targeted (scene=) reads; an
424
+ // untargeted read is served from a pre-published snapshot built with
425
+ // the defaults, and its query params are dropped. Resolve the current
426
+ // scene path first so depth/limit actually take effect.
427
+ let target = scenePath;
428
+ if (!target) {
429
+ const snapshot = await client.getSceneState();
430
+ target = getScenePathFromSceneState(snapshot) ?? undefined;
431
+ if (!target) {
432
+ const record = asRecord(snapshot);
433
+ return {
434
+ ...(record ?? { snapshot }),
435
+ depthLimitApplied: false,
436
+ note: "depth/limit were IGNORED: the current scene path could not be resolved, and the engine only honors depth/limit on scene-targeted reads. Pass scenePath explicitly to apply them.",
437
+ };
438
+ }
439
+ }
440
+ return client.getSceneState(target, { depth, limit });
441
+ }));
332
442
  server.tool("summer_import_from_url", `Download a file from a URL and import it into the project. Triggers Godot's full import pipeline — generates .import files, extracts textures from .glb models, creates materials.
333
443
 
334
444
  Use this for:
@@ -1,7 +1,20 @@
1
1
  import { z } from "zod";
2
- import { withEngine } from "./with-engine.js";
3
- import { readFile } from "fs/promises";
4
- import { join } from "path";
2
+ import { withEngine, extractOpError } from "./with-engine.js";
3
+ // Engine ops that MUST be dispatched as their own single-op request. Mirrors
4
+ // _summer_requires_single_async_dispatch (local_api_server.cpp, engine 0.5.60+):
5
+ // the engine rejects any multi-op batch containing one of these WHOLESALE —
6
+ // nothing in the batch executes, and the batch fails with per-op
7
+ // failure_reason "unsupported_transport"/"skipped". Git ops are covered by the
8
+ // prefix check below.
9
+ const SINGLE_ONLY_OPS = new Set([
10
+ "SaveScene", "InstantiateScene", "ReplaceNode",
11
+ "SimulateInput", "ViewportSnapshot", "GameSnapshot",
12
+ "RunCommand", "RunVerification", "RunEditorScript",
13
+ "ImportFromUrl", "ImportFromUrlBatch", "ExtractZipFromUrl",
14
+ ]);
15
+ function isSingleOnlyOp(kind) {
16
+ return SINGLE_ONLY_OPS.has(kind) || kind.startsWith("Git");
17
+ }
5
18
  function sceneMutationOps(ops) {
6
19
  const saveIndexes = ops
7
20
  .map((op, index) => op.op === "SaveScene" ? index : -1)
@@ -17,23 +30,72 @@ function sceneMutationOps(ops) {
17
30
  }
18
31
  return [...ops, { op: "SaveScene" }];
19
32
  }
20
- function executeSceneMutation(client, scenePath, ops, options) {
21
- return client.executeIdentityBoundOps(sceneMutationOps(ops), {
22
- ...(options ?? {}),
23
- scenePath,
24
- });
25
- }
26
- async function readMainSceneFromProject(projectPath) {
27
- if (!projectPath)
28
- return null;
29
- try {
30
- const text = await readFile(join(projectPath, "project.godot"), "utf-8");
31
- const match = text.match(/run\/main_scene="([^"]+)"/);
32
- return match?.[1] ?? null;
33
+ /** Split an op list into sequential engine requests: consecutive batchable ops
34
+ * stay grouped, each single-only op becomes its own request. Order preserved. */
35
+ function chunkOpsForDispatch(ops) {
36
+ const chunks = [];
37
+ let current = [];
38
+ for (const op of ops) {
39
+ if (isSingleOnlyOp(String(op.op ?? ""))) {
40
+ if (current.length > 0) {
41
+ chunks.push(current);
42
+ current = [];
43
+ }
44
+ chunks.push([op]);
45
+ }
46
+ else {
47
+ current.push(op);
48
+ }
33
49
  }
34
- catch {
35
- return null;
50
+ if (current.length > 0)
51
+ chunks.push(current);
52
+ return chunks;
53
+ }
54
+ /** Execute an op list as sequential engine requests honoring the single-op
55
+ * dispatch contract. Receipts from every request are preserved and merged, so
56
+ * an op that applied followed by one that failed is reported honestly instead
57
+ * of masked. */
58
+ async function executeOpsChunked(send, ops) {
59
+ const chunks = chunkOpsForDispatch(ops);
60
+ if (chunks.length === 1)
61
+ return send(chunks[0]);
62
+ const receipts = [];
63
+ const combinedResults = [];
64
+ for (let i = 0; i < chunks.length; i++) {
65
+ const receipt = await send(chunks[i]);
66
+ receipts.push(receipt);
67
+ const envelope = (receipt ?? {});
68
+ if (Array.isArray(envelope.results)) {
69
+ combinedResults.push(...envelope.results);
70
+ }
71
+ if (extractOpError(receipt)) {
72
+ const failedKind = String(chunks[i][0]?.op ?? "batch");
73
+ const appliedOps = chunks.slice(0, i).flat().map((op) => String(op.op ?? ""));
74
+ const notSent = chunks.slice(i + 1).flat().map((op) => String(op.op ?? ""));
75
+ const baseError = (typeof envelope.error === "string" && envelope.error) || `Engine request failed (${failedKind}).`;
76
+ const honestError = appliedOps.length > 0
77
+ ? `${baseError} NOTE: ${appliedOps.length} earlier op(s) already applied (${appliedOps.join(", ")})` +
78
+ (failedKind === "SaveScene"
79
+ ? " — the scene is modified in the editor but NOT saved to disk. Fix the problem, then call summer_save_scene."
80
+ : ".") +
81
+ (notSent.length > 0 ? ` Not sent: ${notSent.join(", ")}.` : "")
82
+ : baseError;
83
+ return {
84
+ ...envelope,
85
+ error: honestError,
86
+ results: combinedResults,
87
+ receipts,
88
+ };
89
+ }
36
90
  }
91
+ const last = (receipts[receipts.length - 1] ?? {});
92
+ return { ...last, results: combinedResults, requests: chunks.length, receipts };
93
+ }
94
+ /** Scene mutation entry point: appends the transaction-boundary SaveScene, then
95
+ * dispatches with the single-op contract (mutations batch together, SaveScene
96
+ * and other single-only ops travel alone). */
97
+ function executeSceneMutation(client, scenePath, ops, options) {
98
+ return executeOpsChunked((chunk) => client.executeIdentityBoundOps(chunk, { ...(options ?? {}), scenePath }), sceneMutationOps(ops));
37
99
  }
38
100
  function requireSuccessfulOps(result, context) {
39
101
  const receipt = (result ?? {});
@@ -49,61 +111,94 @@ function requireSuccessfulOps(result, context) {
49
111
  }
50
112
  return receipt;
51
113
  }
114
+ /** Compose the minimal valid .tscn text scene: header + one root node.
115
+ * `uid` and `load_steps` are optional in the engine parser
116
+ * (resource_format_text.cpp treats a missing uid as INVALID_ID); the editor
117
+ * assigns a uid on first save. */
118
+ function minimalSceneText(rootName, rootType) {
119
+ return `[gd_scene format=3]\n\n[node name="${rootName}" type="${rootType}"]\n`;
120
+ }
121
+ // Node names reject . : @ / " %; the type must be a plain class name.
122
+ // Both are interpolated into quoted .tscn fields, so validate before composing.
123
+ const VALID_ROOT_NAME = /^[A-Za-z_][A-Za-z0-9_\- ]*$/;
124
+ const VALID_ROOT_TYPE = /^[A-Za-z_][A-Za-z0-9_]*$/;
52
125
  export function registerSceneTools(server) {
53
- server.tool("summer_create_scene", `Create a new empty scene file safely.
126
+ server.tool("summer_create_scene", `Create a new empty scene file. Writes a minimal .tscn through the identity-bound engine with a create-only guard: it fails if the path already exists, and it never touches the currently open scene.
54
127
 
55
- IMPORTANT:
56
- - This tool uses a temporary mutation strategy: it opens a template scene, removes children, saves to a new path, then restores the previous scene.
57
- - To prevent accidental destructive actions, you MUST explicitly set allow_temporary_scene_mutation=true.
58
- - If you don't want this strategy, stop and ask the user for manual scene creation in the editor.
128
+ The new scene is on disk but NOT opened. Call summer_open_scene to start editing it, then summer_add_node to build it out.
59
129
 
60
130
  Recommended workflow:
61
131
  1) Call summer_get_project_context
62
- 2) Call summer_open_main_scene (optional)
63
- 3) Call summer_create_scene with a new path`, {
132
+ 2) Call summer_create_scene with a new res:// path
133
+ 3) Call summer_open_scene with that path`, {
64
134
  path: z.string().describe("New scene path, e.g. 'res://scenes/empty_level.tscn'"),
65
135
  rootName: z.string().default("Main").describe("Root node name for the new scene"),
136
+ rootType: z
137
+ .string()
138
+ .default("Node3D")
139
+ .describe("Root node type. Default 'Node3D' (3D scenes); common alternatives: 'Node2D' (2D scenes), 'Control' (UI scenes), 'Node' (logic-only)."),
66
140
  allow_temporary_scene_mutation: z
67
141
  .boolean()
68
- .default(false)
69
- .describe("Safety gate. Must be true to proceed."),
70
- }, async ({ path, rootName, allow_temporary_scene_mutation }) => withEngine(async (client) => {
71
- if (!allow_temporary_scene_mutation) {
72
- throw new Error("Refusing to create scene without explicit approval. Re-run with allow_temporary_scene_mutation=true.");
142
+ .optional()
143
+ .describe("DEPRECATED, ignored. Scene creation no longer mutates any open scene; kept only so older callers don't break."),
144
+ }, async ({ path, rootName, rootType }) => withEngine(async (client) => {
145
+ const safePath = path.trim().replace(/\\/g, "/");
146
+ if (!safePath.startsWith("res://") || safePath.includes("..")) {
147
+ throw new Error("Scene path must be a traversal-free res:// project path.");
148
+ }
149
+ if (!safePath.endsWith(".tscn")) {
150
+ throw new Error("New scenes must use the text format: the path must end in .tscn.");
151
+ }
152
+ if (!VALID_ROOT_NAME.test(rootName)) {
153
+ throw new Error(`Invalid rootName "${rootName}": use letters, digits, underscores, hyphens, or spaces, starting with a letter or underscore.`);
154
+ }
155
+ if (!VALID_ROOT_TYPE.test(rootType)) {
156
+ throw new Error(`Invalid rootType "${rootType}": must be a plain class name like 'Node3D', 'Node2D', or 'Control'.`);
73
157
  }
74
- const health = (await client.health());
75
- const currentScene = typeof health.scene === "string" && health.scene.length > 0 ? health.scene : null;
76
- const projectPath = typeof health.project_path === "string" ? health.project_path : undefined;
77
- const mainScene = await readMainSceneFromProject(projectPath);
78
- const templateScene = currentScene || mainScene;
79
- if (!templateScene) {
80
- throw new Error("No scene open and could not resolve main scene. Call summer_get_project_context first, then open a known scene.");
158
+ // Same engine-routed guarded write path summer_write_file uses:
159
+ // identity-bound, and mustNotExist makes the engine refuse (create-only)
160
+ // if the path already exists. The open scene is never touched.
161
+ const receipt = requireSuccessfulOps(await client.executeIdentityBoundOps([
162
+ {
163
+ op: "WriteFile",
164
+ path: safePath,
165
+ content: minimalSceneText(rootName, rootType),
166
+ mustNotExist: true,
167
+ },
168
+ ]), `Creating ${safePath}`);
169
+ // Post-write verification: read the file back through the engine. The
170
+ // write receipt already passed; a read-back failure is reported honestly
171
+ // instead of failing the create or being masked.
172
+ let verified = false;
173
+ let verifyError;
174
+ try {
175
+ const read = (await client.readProjectFile(safePath, 4096));
176
+ const content = typeof read?.data?.content === "string" ? read.data.content : null;
177
+ if (read?.ok === false) {
178
+ verifyError = String(read.error ?? "engine could not read the file back");
179
+ }
180
+ else if (content === null) {
181
+ verifyError = "engine returned no text content on read-back";
182
+ }
183
+ else if (!content.includes(`[node name="${rootName}"`)) {
184
+ verifyError = "read-back content does not contain the expected root node";
185
+ }
186
+ else {
187
+ verified = true;
188
+ }
81
189
  }
82
- await client.executeOps([{ op: "OpenScene", path: templateScene }]);
83
- const tree = (await client.getSceneState(templateScene));
84
- const children = tree.data?.children ?? tree.children ?? [];
85
- const removeOps = children
86
- .map((c) => c.path)
87
- .filter((p) => typeof p === "string" && p.length > 0)
88
- .map((p) => ({ op: "RemoveNode", path: `./${p}` }));
89
- const createOps = [
90
- ...removeOps,
91
- { op: "SetProp", path: ".", key: "name", value: rootName },
92
- { op: "SaveScene", path },
93
- ];
94
- const createReceipt = requireSuccessfulOps(await executeSceneMutation(client, templateScene, createOps, {
95
- groupUndo: true,
96
- }), `Creating ${path}`);
97
- if (currentScene && currentScene !== path) {
98
- requireSuccessfulOps(await client.executeOps([{ op: "OpenScene", path: currentScene }]), `Restoring ${currentScene}`);
190
+ catch (err) {
191
+ verifyError = err instanceof Error ? err.message : String(err);
99
192
  }
100
193
  return {
101
194
  ok: true,
102
- created: path,
195
+ created: safePath,
103
196
  rootName,
104
- templateScene,
105
- restoredScene: currentScene,
106
- receipt: createReceipt,
197
+ rootType,
198
+ verified,
199
+ ...(verifyError ? { verifyError } : {}),
200
+ receipt,
201
+ hint: "The new scene is on disk but not open. Call summer_open_scene to start editing it.",
107
202
  };
108
203
  }));
109
204
  server.tool("summer_add_node", `Add a new node to the scene tree.
@@ -261,13 +356,18 @@ Each op in the array uses the same format as the individual tools:
261
356
  - {"op": "SetResourceProperty", "nodePath": "Floor", "resourceProperty": "mesh", "subProperty": "size", "value": "Vector2(20, 20)"}
262
357
 
263
358
  RAW RUNTIME OPS (interactive verification — structured failure_reason passes through verbatim):
264
- - RunVerification — spawn a hidden, disposable game instance that runs a GDScript probe and dies (never touches the editor): {"op": "RunVerification", "probe_source": "extends SummerProbeBase\\nfunc _ready(): await super._ready(); report('ok', true); finish()", "max_seconds": 20}. Returns {ok, results, frames, out_dir}. Probe API: report()/save_frame()/press()/key()/finish(). This is the ONLY way to drive input from MCP, and unlike the editor it renders real pixels, so save_frame() produces a real image.
265
- - SimulateInput is NOT reachable from MCP or the CLI. It requires the in-editor chat bridge's async reply channel; every queued caller gets {"ok": false, "failure_reason": "unsupported_transport"} on every engine build. Do not send it and do not treat that reply as a version problem — use RunVerification's press()/key() instead.
359
+ - RunVerification — spawn a hidden, disposable game instance that runs a GDScript probe and dies (never touches the editor): {"op": "RunVerification", "probe_source": "...", "max_seconds": 20}. Returns {ok, results, frames, out_dir}. Probe API: report(name, value) / save_frame(name) / press(action) / key(keycode) / finish(). save_frame REQUIRES a name argument — save_frame() with no args is a script error. Mount scenes deferred: get_tree().root.add_child.call_deferred(instance); await get_tree().process_frame; await settle() — a direct add_child in _ready can hit the parent-busy guard and capture a black frame.
360
+ - SimulateInput — inject an action/key/mouse/axis into the RUNNING game (summer_play first): {"op": "SimulateInput", "type": "action", "action": "jump", "pressed": true}. It MUST be sent alone (single-op batch). failure_reason "not_running" = start the game first; "unsupported" = the running game build predates the handler — fall back to RunVerification or ask the user.
266
361
 
267
362
  Do not mix OpenScene with scene mutations in one batch. OpenScene is a UI action;
268
363
  send it separately. scenePath selects every mutation target. The tool appends one
269
364
  final SaveScene when the batch mutates a scene; if supplied explicitly, SaveScene
270
- must appear exactly once and be the final operation.`, {
365
+ must appear exactly once and be the final operation. The engine requires
366
+ SaveScene, InstantiateScene, ReplaceNode, SimulateInput, and the Run*/Import*
367
+ ops to travel as their own request, so this tool automatically splits your op
368
+ list into sequential requests around them — each split chunk is its own undo
369
+ step, and if a later chunk fails the receipt reports exactly which earlier ops
370
+ already applied.`, {
271
371
  scenePath: z.string().optional().describe("Required when ops contains scene mutations; exact res:// target scene path"),
272
372
  ops: z.array(z.record(z.unknown())).describe("Array of operation objects, each with 'op' plus its parameters"),
273
373
  }, async ({ scenePath, ops }) => withEngine(async (client) => {
@@ -291,6 +391,6 @@ must appear exactly once and be the final operation.`, {
291
391
  const options = { groupUndo: true, ...(scenePath ? { scenePath } : {}) };
292
392
  return needsScenePath
293
393
  ? executeSceneMutation(client, scenePath, ops, options)
294
- : client.executeOps(ops, options);
394
+ : executeOpsChunked((chunk) => client.executeOps(chunk, options), ops);
295
395
  }));
296
396
  }
@@ -18,7 +18,7 @@ target:
18
18
  It does NOT use the scene's environment/sky, and it injects a synthetic camera and light when the scene has none. The scene's WorldEnvironment — sky, fog, tonemap, glow, SSAO, ambient — is replaced by a flat preview environment. So this target CANNOT verify lighting, mood, or any material property that depends on the environment: change them and the frame comes back identical. For those, boot the game or run a RunVerification probe, whose instance renders the real environment.
19
19
  "game" — a frame from the RUNNING game (real runtime state). Start the game first (summer_play). Not available over a plain local connection — needs the Summer desktop app bridge.
20
20
 
21
- Static frame only — one moment, not motion. For a SEQUENCE of frames over time, or for anything lighting-dependent when the desktop bridge is unavailable, use a RunVerification probe's save_frame() — its instance has a real renderer.`, {
21
+ Static frame only — one moment, not motion. For a SEQUENCE of frames over time, or for anything lighting-dependent when the desktop bridge is unavailable, use a RunVerification probe's save_frame(name) — its instance has a real renderer.`, {
22
22
  target: z
23
23
  .enum(["viewport", "scene", "game"])
24
24
  .optional()
@@ -29,9 +29,12 @@ Static frame only — one moment, not motion. For a SEQUENCE of frames over time
29
29
  .optional()
30
30
  .describe('target:"scene" only. Full scene path, e.g. "res://main.tscn". Omit to render the currently-open scene.'),
31
31
  framing: z
32
- .enum(["auto", "top", "front", "iso"])
32
+ .enum(["auto", "iso", "top", "front", "back", "left", "right"])
33
33
  .optional()
34
- .describe('target:"scene" only. Camera framing preset. Default: auto.'),
34
+ .describe('target:"scene" only, 3D scenes. Camera direction preset: "iso" = 3/4 diagonal view, ' +
35
+ '"top" = straight down, "front" = camera at +Z, "back" = camera at -Z, ' +
36
+ '"left" = camera at -X, "right" = camera at +X. "auto" (default) is an alias of "iso". ' +
37
+ "The result reports the resolved framing."),
35
38
  size: z
36
39
  .array(z.number().int().positive())
37
40
  .length(2)
@@ -40,7 +43,10 @@ Static frame only — one moment, not motion. For a SEQUENCE of frames over time
40
43
  nodePath: z
41
44
  .string()
42
45
  .optional()
43
- .describe('target:"scene" only. Scene-tree node path to frame on.'),
46
+ .describe('target:"scene" only. Node path relative to the scene root (e.g. "Player/Mesh") to frame ' +
47
+ "INSTEAD of the whole scene — the camera fits that node's combined bounds (3D visual AABBs " +
48
+ "or 2D rects, children included). A bare unique name is also found recursively. " +
49
+ 'Fails with failure_reason "node_not_found" when the path does not resolve (no silent whole-scene fallback).'),
44
50
  }, async ({ target, scenePath, framing, size, nodePath }) => withEngine(async (client) => {
45
51
  if (target === "game")
46
52
  return client.gameSnapshot();
@@ -112,11 +118,28 @@ Static frame only — one moment, not motion. For a SEQUENCE of frames over time
112
118
  if (snap.sceneHadLight === false) {
113
119
  warnings.push("WARNING: this scene has no light — lit materials may appear black when played.");
114
120
  }
121
+ // The engine ALWAYS synthesizes the preview camera (preview_ops.cpp
122
+ // sets used_synthetic_camera unconditionally) — the flag says nothing
123
+ // about the scene's own cameras. sceneHasCamera above is the
124
+ // authoritative "does this scene have a camera" answer.
115
125
  if (snap.usedSyntheticCamera) {
116
- warnings.push("NOTE: this preview used a synthetic camera the engine added just for the render — the scene itself has no camera, so it will NOT frame like this when played.");
126
+ warnings.push("NOTE: this preview is framed by a synthetic render camera, NOT the scene's own camera — the played game will not frame like this image.");
117
127
  }
118
128
  }
119
- const caption = `${label} (${dims}). Saved to ${snap.localPath ?? "n/a"}. Review the image above and describe what you actually see.` +
129
+ // Scene-preview capture details: resolved framing ("auto" -> "iso"),
130
+ // which node was framed (confirms nodePath resolved), and how many
131
+ // blank-readback retries the engine needed (0 = omitted).
132
+ const details = [];
133
+ if (target === "scene") {
134
+ if (snap.framing)
135
+ details.push(`framing: ${snap.framing}`);
136
+ if (snap.framedNode)
137
+ details.push(`framed node: ${snap.framedNode}`);
138
+ if (snap.renderRetries)
139
+ details.push(`render retries: ${snap.renderRetries}`);
140
+ }
141
+ const detailNote = details.length ? `; ${details.join(", ")}` : "";
142
+ const caption = `${label} (${dims}${detailNote}). Saved to ${snap.localPath ?? "n/a"}. Review the image above and describe what you actually see.` +
120
143
  (warnings.length ? `\n\n${warnings.join("\n")}` : "");
121
144
  return [
122
145
  { type: "image", data: snap.base64, mimeType: snap.mime || "image/jpeg" },
@@ -9,6 +9,13 @@ function attachMeta(result, meta) {
9
9
  });
10
10
  return result;
11
11
  }
12
+ function getFailureReason(value) {
13
+ return typeof value.failureReason === "string"
14
+ ? value.failureReason
15
+ : typeof value.failure_reason === "string"
16
+ ? value.failure_reason
17
+ : undefined;
18
+ }
12
19
  // 0.5.34 Block E contract (publicsummerengine src/lib/tools/contract.ts §0.1).
13
20
  // The async lifecycle (async-op-lifecycle.ts pollOpToTerminal) merges
14
21
  // terminalState/errorClass onto the apply dict it returns. ONLY these two are
@@ -36,14 +43,11 @@ function readClassifiers(result) {
36
43
  if (!result || typeof result !== "object")
37
44
  return {};
38
45
  const op = result;
46
+ const failed = op.results?.find((entry) => entry.ok === false);
39
47
  return {
40
48
  terminalState: typeof op.terminalState === "string" ? op.terminalState : undefined,
41
49
  errorClass: typeof op.errorClass === "string" ? op.errorClass : undefined,
42
- failureReason: typeof op.failureReason === "string"
43
- ? op.failureReason
44
- : typeof op.failure_reason === "string"
45
- ? op.failure_reason
46
- : undefined,
50
+ failureReason: getFailureReason(op) ?? (failed ? getFailureReason(failed) : undefined),
47
51
  };
48
52
  }
49
53
  /**
@@ -63,25 +67,53 @@ export function extractOpError(result) {
63
67
  if (!result || typeof result !== "object")
64
68
  return null;
65
69
  const op = result;
70
+ const firstFailed = op.results?.find((r) => r.ok === false);
71
+ // The engine stamps failure classifiers on the envelope OR per-op inside
72
+ // results[] (the batch gate does the latter) — read both.
73
+ const failureReason = getFailureReason(op) ?? (firstFailed ? getFailureReason(firstFailed) : undefined);
74
+ // Classified failures are rendered as JSON so callers can reliably read
75
+ // failure_reason instead of scraping a sentence. A plain-text "Hint:" line may
76
+ // follow the JSON in the final tool text. Unclassified failures stay plain.
77
+ const classify = (message) => {
78
+ if (!failureReason)
79
+ return message;
80
+ return JSON.stringify({
81
+ error: message,
82
+ failure_reason: failureReason,
83
+ ...(typeof firstFailed?.error === "string" && firstFailed.error.length > 0 && firstFailed.error !== message
84
+ ? { op_error: firstFailed.error }
85
+ : {}),
86
+ ...(typeof firstFailed?.op === "string" ? { op: firstFailed.op } : {}),
87
+ ...(typeof op.terminalState === "string" ? { terminalState: op.terminalState } : {}),
88
+ ...(typeof op.errorClass === "string" ? { errorClass: op.errorClass } : {}),
89
+ }, null, 2);
90
+ };
66
91
  // Failure terminalState takes precedence — it is set by the async lifecycle
67
92
  // exactly when the op did not land (timeout, backpressure, lease/identity
68
- // rejection, cancellation), often with NO results[] to inspect.
93
+ // rejection, cancellation), sometimes with NO results[] to inspect. Prefer the
94
+ // precise engine rejection (envelope error, then the failed op's error) over
95
+ // the generic terminal-state sentence.
69
96
  const ts = op.terminalState;
70
97
  if (typeof ts === "string" && ts.length > 0 && !SUCCESS_TERMINAL_STATES.has(ts)) {
71
- if (typeof op.error === "string" && op.error.length > 0)
72
- return op.error;
73
- return TERMINAL_STATE_MESSAGES[ts] ?? `Engine operation failed (terminalState: ${ts}).`;
98
+ const message = (typeof op.error === "string" && op.error.length > 0 && op.error) ||
99
+ (typeof firstFailed?.error === "string" && firstFailed.error.length > 0 && firstFailed.error) ||
100
+ TERMINAL_STATE_MESSAGES[ts] ||
101
+ `Engine operation failed (terminalState: ${ts}).`;
102
+ return classify(message);
74
103
  }
75
- // An explicit ok:false / status:"error" is a failure even when the engine
76
- // omitted an error string — surface it rather than mask it (matches the web
77
- // contract `isFailureSignal`).
78
- if (op.ok === false)
79
- return op.error || "Engine operation failed (ok: false).";
80
- if (op.status === "error")
81
- return op.error || "Engine operation failed (status: error).";
82
- const firstFailed = op.results?.find((r) => r.ok === false);
83
- if (firstFailed) {
84
- return firstFailed.error || `Engine op failed${firstFailed.op ? ` (${firstFailed.op})` : ""}.`;
104
+ // An explicit ok:false / status:"error" / failed op inside results[] is a
105
+ // failure even when the engine omitted an error string — surface it rather
106
+ // than mask it (matches the web contract `isFailureSignal`). The envelope
107
+ // error is kept when present; the failed op's own error backs it up.
108
+ if (op.ok === false || op.status === "error" || firstFailed) {
109
+ const message = (typeof op.error === "string" && op.error.length > 0 && op.error) ||
110
+ (typeof firstFailed?.error === "string" && firstFailed.error.length > 0 && firstFailed.error) ||
111
+ (firstFailed
112
+ ? `Engine op failed${firstFailed.op ? ` (${firstFailed.op})` : ""}.`
113
+ : op.ok === false
114
+ ? "Engine operation failed (ok: false)."
115
+ : "Engine operation failed (status: error).");
116
+ return classify(message);
85
117
  }
86
118
  return null;
87
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summer-engine",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "description": "Local Summer CLI and MCP server for Summer Engine. Install and run the engine, connect Claude Code, Cursor, Codex, Gemini, and other agents, and build real games with bundled skills, hooks, and plugins.",
5
5
  "keywords": [
6
6
  "summer-engine",