summer-engine 2.6.3 → 2.6.6

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.
@@ -308,10 +308,14 @@ function buildMcpToolPlan(mode, target, assetPolicy, verification, goal = "") {
308
308
  assets.push("summer_search_assets", "summer_import_asset");
309
309
  }
310
310
  if (verification !== "none") {
311
- verify.push("summer_get_script_errors", "summer_get_diagnostics");
311
+ // Rung 1-2 of the ladder: does it compile / does it look right.
312
+ verify.push("summer_get_script_errors", "summer_get_diagnostics", "summer_screenshot");
312
313
  }
313
314
  if (verification === "full") {
314
- verify.push("summer_clear_console", "summer_play", "summer_get_debugger_errors", "summer_stop");
315
+ // Rung 3: run it and read runtime errors. Ordered as the composed loop —
316
+ // clear -> play -> read runtime errors -> look at the live frame -> stop.
317
+ // (There is no single "verify" tool; the agent composes these.)
318
+ verify.push("summer_clear_console", "summer_play", "summer_get_debugger_errors", "summer_screenshot", "summer_stop");
315
319
  }
316
320
  return { start, mutate, assets, verify };
317
321
  }
@@ -5,6 +5,7 @@ import { EngineApiClient } from "../lib/api-client.js";
5
5
  import { registerSceneTools } from "./tools/scene-tools.js";
6
6
  import { registerDebugTools } from "./tools/debug-tools.js";
7
7
  import { registerVisualTools } from "./tools/visual-tools.js";
8
+ import { WITH_ENGINE_META } from "./tools/with-engine.js";
8
9
  import { registerProjectTools } from "./tools/project-tools.js";
9
10
  import { registerAssetTools } from "./tools/asset-tools.js";
10
11
  import { registerGenerateTools } from "./tools/generate-tools.js";
@@ -49,7 +50,10 @@ export async function getClient() {
49
50
  }
50
51
  catch {
51
52
  cachedClient = null;
52
- throw new Error("Summer Engine is not running. Open it first, or run: npx summer-engine run");
53
+ throw new Error("Summer Engine is not running. Open it first, or run: npx summer-engine run\n" +
54
+ "Note: only tools that touch the local project need the engine. Cloud tools " +
55
+ "(summer_generate_*, summer_search_assets, summer_list_my_assets, summer_get_asset, " +
56
+ "summer_check_job) work right now without it — they only need 'npx summer-engine login'.");
53
57
  }
54
58
  }
55
59
  export function resetClient() {
@@ -78,30 +82,59 @@ function installResultSizeLogger(server) {
78
82
  if (typeof handler !== "function")
79
83
  return original(...args);
80
84
  const wrapped = async (...handlerArgs) => {
85
+ const startedAt = Date.now();
81
86
  const result = await handler(...handlerArgs);
87
+ const durationMs = Date.now() - startedAt;
82
88
  try {
83
89
  if (result &&
84
90
  typeof result === "object" &&
85
91
  "content" in result &&
86
92
  Array.isArray(result.content)) {
87
- let chars = 0;
88
- for (const entry of result
89
- .content) {
90
- if (entry && entry.type === "text" && typeof entry.text === "string") {
91
- chars += entry.text.length;
93
+ const debug = process.env.SUMMER_MCP_DEBUG === "1";
94
+ const isError = result.isError === true;
95
+ // withEngine stamps failure classifiers + retried + bound hash on a
96
+ // non-enumerable symbol so we can log them here without every tool
97
+ // threading its own name through withEngine.
98
+ const meta = result[WITH_ENGINE_META];
99
+ // Structured per-call line. Verbose behind SUMMER_MCP_DEBUG=1; when the
100
+ // flag is off, log ONLY failures — the success path stays quiet.
101
+ if (debug || isError) {
102
+ const callLine = JSON.stringify({
103
+ event: "mcp:tool_call",
104
+ tool: name,
105
+ ok: !isError,
106
+ isError,
107
+ durationMs,
108
+ ...(meta?.terminalState ? { terminalState: meta.terminalState } : {}),
109
+ ...(meta?.errorClass ? { errorClass: meta.errorClass } : {}),
110
+ ...(meta?.failureReason ? { failureReason: meta.failureReason } : {}),
111
+ ...(meta?.retried ? { retried: true } : {}),
112
+ ...(meta?.boundProjectIdHash
113
+ ? { boundProjectIdHash: meta.boundProjectIdHash }
114
+ : {}),
115
+ });
116
+ process.stderr.write(`[summer-mcp] ${callLine}\n`);
117
+ }
118
+ if (debug) {
119
+ let chars = 0;
120
+ for (const entry of result
121
+ .content) {
122
+ if (entry && entry.type === "text" && typeof entry.text === "string") {
123
+ chars += entry.text.length;
124
+ }
92
125
  }
126
+ const bytes = Buffer.byteLength(result.content
127
+ .filter((e) => e && e.type === "text" && typeof e.text === "string")
128
+ .map((e) => e.text)
129
+ .join(""), "utf8");
130
+ const line = JSON.stringify({
131
+ event: "mcp:result_size",
132
+ tool_name: name,
133
+ chars,
134
+ bytes,
135
+ });
136
+ process.stderr.write(`[summer-mcp] ${line}\n`);
93
137
  }
94
- const bytes = Buffer.byteLength(result.content
95
- .filter((e) => e && e.type === "text" && typeof e.text === "string")
96
- .map((e) => e.text)
97
- .join(""), "utf8");
98
- const line = JSON.stringify({
99
- event: "mcp:result_size",
100
- tool_name: name,
101
- chars,
102
- bytes,
103
- });
104
- process.stderr.write(`[summer-mcp] ${line}\n`);
105
138
  }
106
139
  }
107
140
  catch {
@@ -234,6 +234,7 @@ Sources:
234
234
  Uses hybrid search: keywords + semantic similarity. Finds assets by name AND by meaning.
235
235
  Returns asset names, types, preview URLs, and import-ready file URLs.
236
236
 
237
+ Cloud tool — works WITHOUT the Summer Engine app open.
237
238
  Requires authentication (so we can attribute usage and apply per-user rate limits): run 'npx summer-engine login' first.`, {
238
239
  query: z.string().describe("Natural language search, e.g. 'low-poly tree', 'sci-fi weapon'. For my_assets, can be empty to list recent."),
239
240
  assetType: z.enum(["2d_image", "animation", "3d_model", "audio", "music", "all"]).default("all").describe("Filter by asset type"),
@@ -273,7 +274,9 @@ Use this after generation jobs complete, when the user says "the model I made",
273
274
  into the local project.
274
275
 
275
276
  Returns exact asset IDs plus file/import URLs. Use summer_get_asset for full
276
- metadata or summer_import_asset_by_id to import a specific result.`, {
277
+ metadata or summer_import_asset_by_id to import a specific result.
278
+
279
+ Cloud tool — works WITHOUT the Summer Engine app open.`, {
277
280
  query: z
278
281
  .string()
279
282
  .default("")
@@ -304,7 +307,9 @@ metadata or summer_import_asset_by_id to import a specific result.`, {
304
307
  Use this after a generation job returns assetId/rigAssetId/animationAssetId, or
305
308
  after summer_list_my_assets/search results when you need the stable file URL,
306
309
  download URL, viewer URL, metadata, license, visibility, parent chain, or
307
- provider details.`, {
310
+ provider details.
311
+
312
+ Cloud tool — works WITHOUT the Summer Engine app open.`, {
308
313
  assetId: z.string().describe("Summer ArtAssets id"),
309
314
  }, async ({ assetId }) => {
310
315
  const result = await getAssetApi(assetId);
@@ -320,7 +325,9 @@ provider details.`, {
320
325
 
321
326
  Today this usually returns the Cloudinary URL plus Summer's download proxy.
322
327
  The response shape is future-proofed for signed URLs, so prefer this tool over
323
- handing users raw fileUrl when they explicitly ask to download.`, {
328
+ handing users raw fileUrl when they explicitly ask to download.
329
+
330
+ Cloud tool — works WITHOUT the Summer Engine app open.`, {
324
331
  assetId: z.string().describe("Summer ArtAssets id"),
325
332
  role: z
326
333
  .enum(["primary", "thumbnail"])
@@ -339,7 +346,10 @@ Use this when the asset was just generated, selected from my assets, or returned
339
346
  by search. Unlike summer_import_asset, this does not search or guess: it fetches
340
347
  the asset by ID, downloads it through Summer Engine, and runs Godot's import
341
348
  pipeline. For 3D models, pass parent to instantiate it in the open scene after
342
- import.`, {
349
+ import.
350
+
351
+ Requires the Summer Engine app to be open with the project loaded (generation
352
+ itself does not — only this import step does).`, {
343
353
  assetId: z.string().describe("Summer ArtAssets id"),
344
354
  path: z
345
355
  .string()
@@ -232,6 +232,7 @@ The 'options' object is passed directly to the AI provider for full control.
232
232
  Returns the asset with fileUrl (hosted) and localPath (temp file on disk).
233
233
  Use the Read tool on localPath to show the image to the user for approval.
234
234
 
235
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
235
236
  Requires authentication: run 'npx summer-engine login' first.`, {
236
237
  prompt: z.string().describe("Description of the image to generate"),
237
238
  model: z
@@ -290,6 +291,7 @@ The 'options' object is passed directly to ElevenLabs, so you can set any
290
291
  provider-specific parameter: stability, similarity_boost, style, speed, etc.
291
292
 
292
293
  Returns the generated audio URL and asset metadata.
294
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
293
295
  Requires authentication: run 'npx summer-engine login' first.`, {
294
296
  capability: z
295
297
  .string()
@@ -378,6 +380,7 @@ Example:
378
380
  By default, waits for completion (up to 5 min) and returns the result directly.
379
381
  Set wait=false to get the jobId immediately and poll manually with summer_check_job.
380
382
 
383
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
381
384
  Requires authentication: run 'npx summer-engine login' first.`, {
382
385
  prompt: z
383
386
  .string()
@@ -449,6 +452,7 @@ If imageUrl is provided, switches to image-to-video mode.
449
452
  Pass provider-specific params in 'options' (negative_prompt, num_frames, etc.).
450
453
 
451
454
  Returns the generated video URL and asset metadata.
455
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
452
456
  Requires authentication: run 'npx summer-engine login' first.`, {
453
457
  prompt: z
454
458
  .string()
@@ -498,6 +502,7 @@ waits automatically.
498
502
 
499
503
  Status values: waiting, active, completed, failed, delayed, unknown.
500
504
 
505
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
501
506
  Requires authentication: run 'npx summer-engine login' first.`, {
502
507
  jobId: z.string().describe("The job ID returned by an async generation tool"),
503
508
  }, async ({ jobId }) => {
@@ -558,6 +563,7 @@ Custom prompt-driven motion is on the roadmap; not yet shipped. For one-off
558
563
  signature moves not on the curated list, fall back to hand-authoring in the
559
564
  Godot editor or importing from Mixamo.
560
565
 
566
+ Cloud tool — runs on Summer's servers and works WITHOUT the Summer Engine app open.
561
567
  Requires authentication: run 'npx summer-engine login' first.`, {
562
568
  rigAssetId: z
563
569
  .string()
@@ -128,7 +128,8 @@ mutating the project.`, {
128
128
  server.tool("summer_get_agent_playbook", `AI-first operating guide for Summer Engine MCP.
129
129
 
130
130
  Call this at the start of a fresh chat before touching scenes.
131
- It returns the safe workflow, anti-patterns, and recovery steps.`, {}, async () => ({
131
+ It returns the safe workflow, the verification ladder, honesty rules,
132
+ anti-patterns, and recovery steps.`, {}, async () => ({
132
133
  content: [
133
134
  {
134
135
  type: "text",
@@ -137,9 +138,9 @@ It returns the safe workflow, anti-patterns, and recovery steps.`, {}, async ()
137
138
  "Understand the request and outline a brief plan before reaching for tools.",
138
139
  "Default medium is host file tools: write/edit .gd/.cs/.tscn/.tres/.json/docs/config directly as text.",
139
140
  "Use Summer MCP only when you need the LIVE engine: play/stop, diagnostics, screenshots/verification, navmesh or light bake, runtime inspect, or asset import.",
140
- "Call summer_get_project_context first so you do not guess scene paths or the project language.",
141
+ "Call summer_get_project_context first so you do not guess scene paths or the project language. It also BINDS this session to the currently-open project (see projectBinding below).",
141
142
  "Use projectMemory from summer_get_project_context to decide which .summer Markdown files to read before creative/audio/dialogue/level/character work.",
142
- "After writing code, PLAY the scene you just made and read summer_get_diagnostics; iterate until it launches clean instead of waiting for the user to navigate to it.",
143
+ "After writing code, VERIFY through the engine (see verificationLadder) instead of waiting for the user to navigate to it.",
143
144
  ],
144
145
  safeDefaults: [
145
146
  "Never guess scene filenames (main.tscn/Main.tscn) -- get them from summer_get_project_context.",
@@ -153,7 +154,54 @@ It returns the safe workflow, anti-patterns, and recovery steps.`, {}, async ()
153
154
  "1. Understand what the user wants (one pass, no tool spelunking first).",
154
155
  "2. Outline the plan fast and briefly; proceed once it is clearly right.",
155
156
  "3. Execute in pure code -- edit .gd/.tscn/.tres as text (GDScript by default, C# only if the project already uses it).",
156
- "4. Play the scene/game (start with the scene you just made), read console + script/debugger errors via summer_get_diagnostics, fix, and repeat until it launches clean.",
157
+ "4. Verify through the engine using the verificationLadder below, fix, and repeat until it launches clean.",
158
+ ],
159
+ // The MCP-native verification ladder. Climb only as high as the
160
+ // change demands: a script edit needs rung 1; a gameplay change
161
+ // needs rungs 3-4. Each rung is a REAL engine call, not a claim.
162
+ verificationLadder: {
163
+ "1_compiles": [
164
+ "After writing/editing a .gd file, call summer_get_script_errors <path> — compiles without running the game.",
165
+ "For a project-wide sweep of editor + runtime issues, call summer_get_diagnostics (it tells you whether to then read summer_get_console / summer_get_debugger_errors).",
166
+ ],
167
+ "2_looks_right": [
168
+ "To SEE the result, call summer_screenshot. Pick the target deliberately:",
169
+ " target:'viewport' (default) = the editor's CURRENT open tab, exactly as it looks now. Edit-time check. No game boot.",
170
+ " target:'scene' = an OFFSCREEN render of a scene FILE (pass scenePath). No game boot; physics/particles/animations are STATIC at t=0. Best for 'is the composition/scale right' without disturbing the open tab. It also confesses if the scene has no Camera3D (renders grey/black when played) or no light (lit materials look black) — READ those warnings.",
171
+ " target:'game' = a frame from the RUNNING game (real runtime state). summer_play FIRST. Over a plain local HTTP connection this returns an honest failure (needs the Summer desktop bridge); when it fails, fall back to viewport/scene or ask the user.",
172
+ ],
173
+ "3_runs": [
174
+ "Compose the run-and-check yourself — there is no single 'verify' tool:",
175
+ "summer_clear_console -> clean slate",
176
+ "summer_play [scene] -> boot the game (or a specific scene)",
177
+ "summer_get_debugger_errors -> runtime errors (null refs, missing nodes, physics)",
178
+ "summer_screenshot target:'game' -> optional visual of the live frame",
179
+ "summer_stop -> ALWAYS stop; scene mutations are refused while the game runs",
180
+ ],
181
+ "4_interactive": [
182
+ "To prove input-driven behavior (does jump/move/shoot actually work), you have two routes when the engine build supports them (both go through summer_batch as raw ops — see rawOpsViaBatch):",
183
+ "SimulateInput: inject a single action/key/mouse/axis into the RUNNING game, then re-check screenshot/debugger. Requires summer_play first.",
184
+ "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. Best for a scripted 'does X happen when I do Y' assertion.",
185
+ "If neither op is available on this engine build (failure_reason:'unsupported' or an unknown-op error comes back), do NOT fake it — ask the user to try the interaction and report what they see.",
186
+ ],
187
+ },
188
+ // HONESTY — mirror the in-product agent's vision rules. A capture is
189
+ // a fact, not a formality; a failed capture is itself a result.
190
+ honestyRules: [
191
+ "NEVER describe an image you did not actually receive. If summer_screenshot returned isError or a text-only fallback (no image block), say the capture failed and why — do not invent what the frame 'probably' shows.",
192
+ "A failed or blocked capture is a RESULT, not a dead end: report it, then climb down the ladder (scene->viewport) or ask the user for a screenshot.",
193
+ "Describe only what is actually in the returned frame. Do not pad with expected content.",
194
+ "target:'scene' is STATIC (t=0) and may use a synthetic camera — do not claim animation/particles/gameplay motion from it, and heed its no-camera / no-light / synthetic-camera warnings.",
195
+ "Pass structured engine failures (failure_reason, terminalState, errorClass) through to the user verbatim — never soften 'unsupported' or 'bridge_required' into a vague 'it didn't work'.",
196
+ ],
197
+ // The session is pinned to the project open when you first called
198
+ // summer_get_project_context. This keeps a mutation from landing in
199
+ // the WRONG project after an in-place project switch.
200
+ projectBinding: [
201
+ "summer_get_project_context binds this session to the currently-open project and is the deliberate (re)bind point.",
202
+ "If the engine later switches projects in place, mutating ops are REJECTED with identity_mismatch (nothing is applied — your edit did NOT land in the wrong project).",
203
+ "summer_screenshot adds a projectMismatch WARNING when the engine's live project no longer matches this binding — the frame may be from the wrong project; do not trust it until you rebind.",
204
+ "To intentionally follow the switch, call summer_get_project_context again to rebind, then retry.",
157
205
  ],
158
206
  liveEngineFlow: [
159
207
  "Use this flow ONLY when you genuinely need live engine state (navmesh/light bake, instancing into an already-open scene, runtime inspect):",
@@ -164,11 +212,24 @@ It returns the safe workflow, anti-patterns, and recovery steps.`, {}, async ()
164
212
  "summer_save_scene",
165
213
  "summer_get_diagnostics",
166
214
  ],
215
+ // summer_batch forwards each {op, ...} verbatim to the engine, so
216
+ // engine ops that have no dedicated tool are still reachable.
217
+ rawOpsViaBatch: [
218
+ "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.",
219
+ "SimulateInput (drive the RUNNING game): summer_batch ops:[{op:'SimulateInput', type:'action', action:'jump', pressed:true}]. type is 'action' | 'key' | 'mouse_click' | 'axis'. summer_play first. Structured failure_reasons (incl 'unsupported' on older builds) come back verbatim — surface them.",
220
+ "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}.",
221
+ "These are runtime ops, not scene mutations — the batch undo group is a harmless no-op for them.",
222
+ ],
167
223
  recovery: [
168
224
  "If you see 'no scene open': run summer_open_main_scene.",
169
225
  "If open_scene fails: re-check mainScene from summer_get_project_context.",
170
- "If save fails: verify scene is open and game is not running.",
226
+ "If save fails: verify scene is open and game is not running (summer_is_running / summer_stop).",
227
+ "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.",
171
228
  "If a .tscn you wrote keeps reverting: the editor has that scene open -- reload or close the tab, then write again.",
229
+ "If a run op or SimulateInput returns 'unsupported' / an unknown-op error: this engine build predates it — fall back to summer_play + summer_get_debugger_errors, or ask the user to interact.",
230
+ ],
231
+ debugging: [
232
+ "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.",
172
233
  ],
173
234
  }, null, 2),
174
235
  },
@@ -194,6 +255,11 @@ Use this first in every fresh chat to avoid guessing scene filenames or editing
194
255
  const projectName = getProjectName(projectState, health);
195
256
  const mainScene = getMainScene(projectState);
196
257
  const currentScene = getCurrentScene(projectState, sceneState, health);
258
+ // This tool is the deliberate (re)bind point: capture the currently-open
259
+ // project as the one this session's mutations are pinned to. After an
260
+ // engine project switch the agent calls this to intentionally follow the
261
+ // new project; subsequent edits then target it instead of being rejected.
262
+ const boundProjectIdHash = await client.rebind();
197
263
  return {
198
264
  health,
199
265
  project: projectState,
@@ -202,6 +268,7 @@ Use this first in every fresh chat to avoid guessing scene filenames or editing
202
268
  projectPath,
203
269
  currentScene,
204
270
  mainScene,
271
+ boundProjectIdHash,
205
272
  projectMemory: getProjectMemorySummary(projectPath),
206
273
  guidance: mainScene
207
274
  ? "Use `summer_open_scene` with `mainScene` if no scene is open."
@@ -201,7 +201,7 @@ Example: inspect a light to see its energy, color, shadow settings before changi
201
201
  Use when you need the sub-properties of a resource attached to a node. For example, summer_inspect_node tells you a MeshInstance3D has a "StandardMaterial3D" material — this tool tells you that material's albedo_color, metallic, roughness, etc.`, {
202
202
  path: z.string().describe("Resource path, e.g. 'res://materials/ground.tres' or 'res://models/player.glb'"),
203
203
  }, async ({ path }) => withEngine(async (client) => client.inspectResource(path)));
204
- server.tool("summer_batch", `Execute multiple operations in a single call, grouped into one undo step.
204
+ server.tool("summer_batch", `Execute multiple operations in a single call, grouped into one undo step. Each op is forwarded to the engine VERBATIM, so this is also how you reach engine ops that have no dedicated tool.
205
205
 
206
206
  The user can undo everything with a single Ctrl+Z. Use this when building something that involves multiple nodes and properties — e.g., creating a player character with collision, camera, and properties.
207
207
 
@@ -209,7 +209,11 @@ Each op in the array uses the same format as the individual tools:
209
209
  - {"op": "AddNode", "parent": "/", "type": "MeshInstance3D", "name": "Floor"}
210
210
  - {"op": "SetProp", "path": "Floor", "key": "position", "value": "Vector3(0, -1, 0)"}
211
211
  - {"op": "SetProp", "path": "Floor", "key": "mesh", "value": "PlaneMesh"}
212
- - {"op": "SetResourceProperty", "nodePath": "Floor", "resourceProperty": "mesh", "subProperty": "size", "value": "Vector2(20, 20)"}`, {
212
+ - {"op": "SetResourceProperty", "nodePath": "Floor", "resourceProperty": "mesh", "subProperty": "size", "value": "Vector2(20, 20)"}
213
+
214
+ RAW RUNTIME OPS (interactive verification — engine-build dependent; structured failure_reason incl "unsupported" passes through verbatim):
215
+ - SimulateInput — drive the RUNNING game (summer_play first): {"op": "SimulateInput", "type": "action", "action": "jump", "pressed": true}. type is "action" | "key" | "mouse_click" | "axis".
216
+ - 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().`, {
213
217
  ops: z.array(z.record(z.unknown())).describe("Array of operation objects, each with 'op' plus its parameters"),
214
218
  }, async ({ ops }) => withEngine(async (client) => client.executeOps(ops, { groupUndo: true })));
215
219
  }
@@ -13,16 +13,68 @@ export function registerVisualTools(server) {
13
13
  Use this to visually verify your work: scene layout, asset placement, scale, framing, lighting, materials, missing/untextured assets, or runtime gameplay state. You see the actual pixels — no description layer in between.
14
14
 
15
15
  target:
16
- "viewport" (default) — the editor's current 3D/2D scene view. Works without running the game. Use for edit-time checks of how the scene looks.
17
- "game" — a frame from the running game. The game must be started first (summer_play). Use to verify runtime behavior.
16
+ "viewport" (default) — the editor's CURRENT view (whatever scene/tab is open). No game boot. Use for edit-time checks of how the scene looks right now.
17
+ "scene" — an OFFSCREEN render of a scene file (no game boot; physics/particles/animations are static at t=0). Optionally pass scenePath/framing/size/nodePath. Use for a quick composed look at a scene without touching the editor's open tab.
18
+ "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.
18
19
 
19
- Static frame only — one moment, not motion. On macOS the game often runs in a floating window that cannot be captured; if a game capture fails, prefer "viewport", or ask the user to share a screenshot.`, {
20
+ Static frame only — one moment, not motion. If a game capture is unavailable, use "viewport" or "scene", or ask the user to share a screenshot of the running game.`, {
20
21
  target: z
21
- .enum(["viewport", "game"])
22
+ .enum(["viewport", "scene", "game"])
22
23
  .optional()
23
24
  .default("viewport")
24
- .describe('"viewport" = editor scene view (default), "game" = running game frame'),
25
- }, async ({ target }) => withEngine(async (client) => target === "game" ? client.gameSnapshot() : client.viewportSnapshot(), {
25
+ .describe('"viewport" = editor current view (default), "scene" = offscreen render of a scene file, "game" = running game frame'),
26
+ scenePath: z
27
+ .string()
28
+ .optional()
29
+ .describe('target:"scene" only. Full scene path, e.g. "res://main.tscn". Omit to render the currently-open scene.'),
30
+ framing: z
31
+ .enum(["auto", "top", "front", "iso"])
32
+ .optional()
33
+ .describe('target:"scene" only. Camera framing preset. Default: auto.'),
34
+ size: z
35
+ .array(z.number().int().positive())
36
+ .length(2)
37
+ .optional()
38
+ .describe('target:"scene" only. Output image [width, height] in pixels.'),
39
+ nodePath: z
40
+ .string()
41
+ .optional()
42
+ .describe('target:"scene" only. Scene-tree node path to frame on.'),
43
+ }, async ({ target, scenePath, framing, size, nodePath }) => withEngine(async (client) => {
44
+ if (target === "game")
45
+ return client.gameSnapshot();
46
+ if (target === "scene")
47
+ return client.scenePreview({
48
+ scenePath,
49
+ framing,
50
+ size: size,
51
+ nodePath,
52
+ });
53
+ return client.viewportSnapshot();
54
+ }, {
55
+ // Game capture is structurally blocked over local HTTP today (409
56
+ // bridge_required). Treat that as a clean, honest failure rather than a
57
+ // truncated generic error — fail loud (isError) so the model does not
58
+ // proceed as if it saw the game. Written so that when the engine starts
59
+ // answering over HTTP (P4.4) `failureReason` never fires and the normal
60
+ // image path below just works.
61
+ onResult: (snap) => {
62
+ if (target === "game" && snap.failureReason === "bridge_required") {
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: "Game capture is not available over this connection (requires the Summer desktop app bridge). " +
68
+ "Use target:'viewport' for the editor view, target:'scene' for an offscreen scene render, " +
69
+ "or ask the user to describe / screenshot the running game.\n\n" +
70
+ `Engine reason: ${snap.error ?? "unsupported_transport"}`,
71
+ },
72
+ ],
73
+ isError: true,
74
+ };
75
+ }
76
+ return null;
77
+ },
26
78
  toContent: (snap) => {
27
79
  // withEngine only calls toContent on success (ok:true, error cleared
28
80
  // by extractOpError). Missing image bytes on a "success" is still
@@ -38,13 +90,36 @@ Static frame only — one moment, not motion. On macOS the game often runs in a
38
90
  ];
39
91
  }
40
92
  const dims = snap.width && snap.height ? `${snap.width}x${snap.height}` : "unknown size";
41
- const label = target === "game" ? "Running game frame" : "Editor viewport";
93
+ const label = target === "game"
94
+ ? "Running game frame"
95
+ : target === "scene"
96
+ ? "Scene preview (offscreen render, physics/animations static)"
97
+ : "Editor viewport";
98
+ const warnings = [];
99
+ // Project-drift warning (item 4): the engine may have switched
100
+ // projects since this session bound — this frame could be from the
101
+ // WRONG project.
102
+ if (snap.projectMismatch) {
103
+ warnings.push("WARNING: the engine is now on a DIFFERENT project than this session is bound to — this frame may be from the wrong project. Call summer_get_project_context to rebind before trusting it.");
104
+ }
105
+ // Scene-preview confession fields (P4.3): a scene with no camera plays
106
+ // grey/black.
107
+ if (target === "scene") {
108
+ if (snap.sceneHasCamera === false) {
109
+ warnings.push("WARNING: this scene has no Camera3D — it will render grey/black when played.");
110
+ }
111
+ if (snap.sceneHadLight === false) {
112
+ warnings.push("WARNING: this scene has no light — lit materials may appear black when played.");
113
+ }
114
+ if (snap.usedSyntheticCamera) {
115
+ 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.");
116
+ }
117
+ }
118
+ const caption = `${label} (${dims}). Saved to ${snap.localPath ?? "n/a"}. Review the image above and describe what you actually see.` +
119
+ (warnings.length ? `\n\n${warnings.join("\n")}` : "");
42
120
  return [
43
121
  { type: "image", data: snap.base64, mimeType: snap.mime || "image/jpeg" },
44
- {
45
- type: "text",
46
- text: `${label} (${dims}). Saved to ${snap.localPath ?? "n/a"}. Review the image above and describe what you actually see.`,
47
- },
122
+ { type: "text", text: caption },
48
123
  ];
49
124
  },
50
125
  }));
@@ -11,12 +11,29 @@ type ToolResult = {
11
11
  content: ToolResultContent[];
12
12
  isError?: boolean;
13
13
  };
14
+ /** Diagnostics stamped by withEngine onto its ToolResult under a non-enumerable
15
+ * key so the server.ts result logger can emit a single structured stderr line
16
+ * per tool call WITHOUT every one of the ~31 callers having to thread its own
17
+ * tool name through. Never serialized to the model (non-enumerable). */
18
+ export interface WithEngineMeta {
19
+ terminalState?: string;
20
+ errorClass?: string;
21
+ failureReason?: string;
22
+ retried: boolean;
23
+ boundProjectIdHash?: string;
24
+ }
25
+ export declare const WITH_ENGINE_META: unique symbol;
14
26
  /** Options for {@link withEngine}. `toContent` maps a successful engine result
15
27
  * to MCP content blocks — used by the screenshot tool to hand the raw frame
16
28
  * back as an image block instead of JSON-stringified text. Only runs on genuine
17
- * success (after extractOpError clears); failures still surface as text. */
29
+ * success (after extractOpError clears); failures still surface as text.
30
+ *
31
+ * `onResult` runs FIRST on a genuine success and may short-circuit to a custom
32
+ * ToolResult (e.g. a fail-loud message for a structurally-blocked capture).
33
+ * Return null to fall through to toContent / the default JSON text. */
18
34
  export interface WithEngineOptions<T> {
19
35
  toContent?: (result: T) => ToolResultContent[];
36
+ onResult?: (result: T) => ToolResult | null;
20
37
  }
21
38
  /**
22
39
  * Decide whether an engine result envelope represents a FAILURE, and if so
@@ -33,14 +50,22 @@ export interface WithEngineOptions<T> {
33
50
  */
34
51
  export declare function extractOpError(result: unknown): string | null;
35
52
  /**
36
- * A thrown transport error is safe to retry only when the engine provably never
37
- * applied the op: a stale-token rejection (401/403, after the engine rotated its
38
- * api-token on relaunch) or an unreachable/closed port (connection refused/reset,
39
- * after the engine moved ports or is mid-restart). A timeout or any 5xx may have
40
- * mutated, so those surface instead of risking a double-apply on retry.
53
+ * An auth failure is the ONE class of thrown error that is provably pre-apply:
54
+ * the engine rejects on the Bearer-token check (tool_net_thread.cpp::_validate_auth)
55
+ * BEFORE the op is queued or applied, so reconnecting with the fresh api-token and
56
+ * retrying cannot double-apply a mutation. This is exactly the stale-token case
57
+ * after the engine rotates its api-token on relaunch.
58
+ *
59
+ * Deliberately NARROW. A generic connection error (ECONNREFUSED / "fetch failed")
60
+ * thrown from inside the tool closure is NOT retriable here, because it may be a
61
+ * disconnect mid-operation where a mutation already partially applied — and the
62
+ * MCP sends no idempotency key the engine can dedup on, so a blind retry would
63
+ * re-apply it. The restart-between-calls case is handled proactively by
64
+ * getClient()'s credential-drift check; the connect/preflight-failure case is
65
+ * retried separately in withEngine (nothing is submitted there).
41
66
  *
42
67
  * Exported for unit tests.
43
68
  */
44
- export declare function isReconnectableThrow(err: unknown): boolean;
69
+ export declare function isAuthError(err: unknown): boolean;
45
70
  export declare function withEngine<T>(fn: (client: Awaited<ReturnType<typeof getClient>>) => Promise<T>, opts?: WithEngineOptions<T>): Promise<ToolResult>;
46
71
  export {};