summer-engine 2.6.3 → 2.6.5
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +1 -1
- package/.factory-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +20 -0
- package/GEMINI.md +2 -1
- package/README.md +34 -2
- package/dist/lib/api-client.d.ts +58 -1
- package/dist/lib/api-client.js +167 -8
- package/dist/lib/game-task-plan.js +6 -2
- package/dist/mcp/server.js +50 -17
- package/dist/mcp/tools/asset-tools.js +14 -4
- package/dist/mcp/tools/generate-tools.js +6 -0
- package/dist/mcp/tools/project-tools.js +72 -5
- package/dist/mcp/tools/scene-tools.js +6 -2
- package/dist/mcp/tools/visual-tools.js +86 -11
- package/dist/mcp/tools/with-engine.d.ts +32 -7
- package/dist/mcp/tools/with-engine.js +91 -54
- package/package.json +1 -1
|
@@ -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,
|
|
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,
|
|
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.
|
|
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
|
|
17
|
-
"
|
|
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.
|
|
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
|
|
25
|
-
|
|
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"
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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 {};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { getClient, resetClient } from "../server.js";
|
|
2
2
|
import { recordMcpSession } from "../../lib/telemetry.js";
|
|
3
|
+
export const WITH_ENGINE_META = Symbol("summerWithEngineMeta");
|
|
4
|
+
function attachMeta(result, meta) {
|
|
5
|
+
Object.defineProperty(result, WITH_ENGINE_META, {
|
|
6
|
+
value: meta,
|
|
7
|
+
enumerable: false,
|
|
8
|
+
configurable: true,
|
|
9
|
+
});
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
3
12
|
// 0.5.34 Block E contract (publicsummerengine src/lib/tools/contract.ts §0.1).
|
|
4
13
|
// The async lifecycle (async-op-lifecycle.ts pollOpToTerminal) merges
|
|
5
14
|
// terminalState/errorClass onto the apply dict it returns. ONLY these two are
|
|
@@ -17,6 +26,23 @@ const TERMINAL_STATE_MESSAGES = {
|
|
|
17
26
|
denied: "Operation denied (terminalState: denied). Nothing was applied.",
|
|
18
27
|
canceled: "Operation canceled (terminalState: canceled). Nothing was applied.",
|
|
19
28
|
};
|
|
29
|
+
/** Pull the failure classifiers off an engine envelope for logging (best-effort,
|
|
30
|
+
* shape-tolerant). Does not decide success/failure — that stays in
|
|
31
|
+
* extractOpError. */
|
|
32
|
+
function readClassifiers(result) {
|
|
33
|
+
if (!result || typeof result !== "object")
|
|
34
|
+
return {};
|
|
35
|
+
const op = result;
|
|
36
|
+
return {
|
|
37
|
+
terminalState: typeof op.terminalState === "string" ? op.terminalState : undefined,
|
|
38
|
+
errorClass: typeof op.errorClass === "string" ? op.errorClass : undefined,
|
|
39
|
+
failureReason: typeof op.failureReason === "string"
|
|
40
|
+
? op.failureReason
|
|
41
|
+
: typeof op.failure_reason === "string"
|
|
42
|
+
? op.failure_reason
|
|
43
|
+
: undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
20
46
|
/**
|
|
21
47
|
* Decide whether an engine result envelope represents a FAILURE, and if so
|
|
22
48
|
* return a model-visible message. Returns null only for genuine success.
|
|
@@ -56,45 +82,35 @@ export function extractOpError(result) {
|
|
|
56
82
|
}
|
|
57
83
|
return null;
|
|
58
84
|
}
|
|
59
|
-
// Terminal states where the engine GUARANTEES the op never landed, so dropping
|
|
60
|
-
// the cached client and retrying once is safe (no double-mutation). Everything
|
|
61
|
-
// else — timed_out (may still be running), content_mismatch / denied / canceled
|
|
62
|
-
// (intentional) — must surface, never silently retry.
|
|
63
|
-
const RECONNECTABLE_TERMINAL_STATES = new Set([
|
|
64
|
-
"not_connected",
|
|
65
|
-
"identity_mismatch",
|
|
66
|
-
]);
|
|
67
|
-
function terminalStateOf(result) {
|
|
68
|
-
if (!result || typeof result !== "object")
|
|
69
|
-
return null;
|
|
70
|
-
const ts = result.terminalState;
|
|
71
|
-
return typeof ts === "string" && ts.length > 0 ? ts : null;
|
|
72
|
-
}
|
|
73
85
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
86
|
+
* An auth failure is the ONE class of thrown error that is provably pre-apply:
|
|
87
|
+
* the engine rejects on the Bearer-token check (tool_net_thread.cpp::_validate_auth)
|
|
88
|
+
* BEFORE the op is queued or applied, so reconnecting with the fresh api-token and
|
|
89
|
+
* retrying cannot double-apply a mutation. This is exactly the stale-token case
|
|
90
|
+
* after the engine rotates its api-token on relaunch.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately NARROW. A generic connection error (ECONNREFUSED / "fetch failed")
|
|
93
|
+
* thrown from inside the tool closure is NOT retriable here, because it may be a
|
|
94
|
+
* disconnect mid-operation where a mutation already partially applied — and the
|
|
95
|
+
* MCP sends no idempotency key the engine can dedup on, so a blind retry would
|
|
96
|
+
* re-apply it. The restart-between-calls case is handled proactively by
|
|
97
|
+
* getClient()'s credential-drift check; the connect/preflight-failure case is
|
|
98
|
+
* retried separately in withEngine (nothing is submitted there).
|
|
79
99
|
*
|
|
80
100
|
* Exported for unit tests.
|
|
81
101
|
*/
|
|
82
|
-
export function
|
|
102
|
+
export function isAuthError(err) {
|
|
83
103
|
const m = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
84
|
-
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
return (m.includes("401") ||
|
|
88
|
-
m.includes("403") ||
|
|
89
|
-
m.includes("unauthorized") ||
|
|
90
|
-
m.includes("econnrefused") ||
|
|
91
|
-
m.includes("econnreset") ||
|
|
92
|
-
m.includes("fetch failed") ||
|
|
93
|
-
m.includes("not running") ||
|
|
94
|
-
m.includes("not responding"));
|
|
104
|
+
return m.includes("401") || m.includes("403") || m.includes("unauthorized");
|
|
95
105
|
}
|
|
96
106
|
function buildActionHint(message) {
|
|
97
107
|
const normalized = message.toLowerCase();
|
|
108
|
+
if (normalized.includes("identity_mismatch") ||
|
|
109
|
+
normalized.includes("projectidhash mismatch") ||
|
|
110
|
+
normalized.includes("projectid mismatch") ||
|
|
111
|
+
normalized.includes("wrong project/instance")) {
|
|
112
|
+
return "Summer Engine is now on a DIFFERENT project than this session is bound to — nothing was applied (your edit did NOT land in the wrong project). If you meant to work on the now-open project, call `summer_get_project_context` to rebind to it, then retry. If not, switch the engine back to the original project first.";
|
|
113
|
+
}
|
|
98
114
|
if (normalized.includes("no scene open") || normalized.includes("no edited scene")) {
|
|
99
115
|
return "No scene is currently open. Call `summer_get_project_context` first, then `summer_open_main_scene` (or `summer_open_scene` with a known .tscn path).";
|
|
100
116
|
}
|
|
@@ -110,48 +126,69 @@ export async function withEngine(fn, opts) {
|
|
|
110
126
|
// Best-effort, fire-and-forget: count this MCP session as DAU for attribution.
|
|
111
127
|
// No await, no throw, no quota gating.
|
|
112
128
|
recordMcpSession();
|
|
113
|
-
// The engine rotates its api-token and can move ports on every launch
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
129
|
+
// The engine rotates its api-token (and can move ports) on every launch. The
|
|
130
|
+
// proactive credential-drift check in getClient() reconnects when a restart
|
|
131
|
+
// happened BETWEEN calls; this loop only covers the race where the restart
|
|
132
|
+
// lands DURING a call. We retry ONLY where nothing could have been applied:
|
|
133
|
+
// - a connect/preflight failure (getClient threw — request never submitted)
|
|
134
|
+
// - a stale-token 401/403 (engine rejects at auth, before queue/apply)
|
|
135
|
+
// A generic connection error thrown from inside fn() is NOT retried: it may be
|
|
136
|
+
// a disconnect mid-operation where a mutation already partially applied, and
|
|
137
|
+
// the MCP sends no idempotency key the engine can dedup on. A soft failure
|
|
138
|
+
// terminal state (e.g. identity_mismatch from a project switch) is surfaced,
|
|
139
|
+
// never silently retried — a retry cannot fix a project switch.
|
|
120
140
|
const MAX_ATTEMPTS = 2;
|
|
121
141
|
let lastError;
|
|
142
|
+
let retried = false;
|
|
143
|
+
let boundProjectIdHash;
|
|
122
144
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
145
|
+
if (attempt > 1)
|
|
146
|
+
retried = true;
|
|
147
|
+
let client;
|
|
148
|
+
try {
|
|
149
|
+
client = await getClient();
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Pre-submission: the request never went out, so reconnect and retry.
|
|
153
|
+
resetClient();
|
|
154
|
+
lastError = err;
|
|
155
|
+
if (attempt < MAX_ATTEMPTS)
|
|
156
|
+
continue;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
boundProjectIdHash =
|
|
160
|
+
typeof client.getBoundProjectIdHash === "function"
|
|
161
|
+
? client.getBoundProjectIdHash()
|
|
162
|
+
: undefined;
|
|
123
163
|
try {
|
|
124
|
-
const client = await getClient();
|
|
125
164
|
const result = await fn(client);
|
|
126
165
|
const opError = extractOpError(result);
|
|
127
166
|
if (opError) {
|
|
128
|
-
const ts = terminalStateOf(result);
|
|
129
|
-
if (ts && RECONNECTABLE_TERMINAL_STATES.has(ts) && attempt < MAX_ATTEMPTS) {
|
|
130
|
-
resetClient();
|
|
131
|
-
lastError = new Error(opError);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
167
|
const hint = buildActionHint(opError);
|
|
135
168
|
const message = hint ? `${opError}\n\nHint: ${hint}` : opError;
|
|
136
|
-
return { content: [{ type: "text", text: message }], isError: true };
|
|
169
|
+
return attachMeta({ content: [{ type: "text", text: message }], isError: true }, { ...readClassifiers(result), retried, boundProjectIdHash });
|
|
170
|
+
}
|
|
171
|
+
const meta = { ...readClassifiers(result), retried, boundProjectIdHash };
|
|
172
|
+
if (opts?.onResult) {
|
|
173
|
+
const short = opts.onResult(result);
|
|
174
|
+
if (short)
|
|
175
|
+
return attachMeta(short, meta);
|
|
137
176
|
}
|
|
138
177
|
if (opts?.toContent) {
|
|
139
|
-
return { content: opts.toContent(result) };
|
|
178
|
+
return attachMeta({ content: opts.toContent(result) }, meta);
|
|
140
179
|
}
|
|
141
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
180
|
+
return attachMeta({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }, meta);
|
|
142
181
|
}
|
|
143
182
|
catch (err) {
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
// connection-class throws that provably did not mutate.
|
|
183
|
+
// Drop the cached client (it may point at a dead/rotated engine). Retry
|
|
184
|
+
// once only for a provably pre-apply auth failure; anything else surfaces.
|
|
147
185
|
resetClient();
|
|
148
186
|
lastError = err;
|
|
149
|
-
if (attempt < MAX_ATTEMPTS &&
|
|
187
|
+
if (attempt < MAX_ATTEMPTS && isAuthError(err))
|
|
150
188
|
continue;
|
|
151
|
-
}
|
|
152
189
|
break;
|
|
153
190
|
}
|
|
154
191
|
}
|
|
155
192
|
const msg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
156
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
193
|
+
return attachMeta({ content: [{ type: "text", text: msg }], isError: true }, { errorClass: "transport", retried, boundProjectIdHash });
|
|
157
194
|
}
|