summer-engine 2.6.1 → 2.6.3
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/README.md +27 -7
- package/dist/bin/summer.js +1 -1
- package/dist/commands/setup.js +9 -1
- package/dist/commands/skills.js +15 -5
- package/dist/lib/agent-config.d.ts +1 -1
- package/dist/lib/agent-config.js +53 -15
- package/dist/lib/api-client.d.ts +17 -0
- package/dist/lib/api-client.js +24 -0
- package/dist/lib/skills-registry.d.ts +66 -66
- package/dist/lib/skills-registry.js +1 -0
- package/dist/mcp/server.js +13 -1
- package/dist/mcp/tools/visual-tools.d.ts +9 -0
- package/dist/mcp/tools/visual-tools.js +51 -0
- package/dist/mcp/tools/with-engine.d.ts +27 -5
- package/dist/mcp/tools/with-engine.js +80 -15
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
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
|
+
import { registerVisualTools } from "./tools/visual-tools.js";
|
|
7
8
|
import { registerProjectTools } from "./tools/project-tools.js";
|
|
8
9
|
import { registerAssetTools } from "./tools/asset-tools.js";
|
|
9
10
|
import { registerGenerateTools } from "./tools/generate-tools.js";
|
|
@@ -30,7 +31,17 @@ async function probeBootDrift() {
|
|
|
30
31
|
let cachedClient = null;
|
|
31
32
|
export async function getClient() {
|
|
32
33
|
if (cachedClient) {
|
|
33
|
-
|
|
34
|
+
// The engine rotates its api-token (and can change ports) on every launch, so
|
|
35
|
+
// a cached client can outlive the engine instance it was built for. If the
|
|
36
|
+
// on-disk creds drifted, a silent engine restart happened — drop the stale
|
|
37
|
+
// client and reconnect transparently, instead of surfacing the resulting 401
|
|
38
|
+
// as a "disconnected from the project" error.
|
|
39
|
+
if (await cachedClient.credentialsChanged()) {
|
|
40
|
+
cachedClient = null;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
return cachedClient;
|
|
44
|
+
}
|
|
34
45
|
}
|
|
35
46
|
try {
|
|
36
47
|
cachedClient = await EngineApiClient.connect();
|
|
@@ -113,6 +124,7 @@ export async function startMcpServer() {
|
|
|
113
124
|
installResultSizeLogger(server);
|
|
114
125
|
registerSceneTools(server);
|
|
115
126
|
registerDebugTools(server);
|
|
127
|
+
registerVisualTools(server);
|
|
116
128
|
registerProjectTools(server);
|
|
117
129
|
registerAssetTools(server);
|
|
118
130
|
registerGenerateTools(server);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* Visual capture tools. Unlike the in-product chat agent (a text-only "brain"
|
|
4
|
+
* that needs a separate vision model to describe frames for it), an MCP client
|
|
5
|
+
* like Claude Code can SEE images directly. So we hand the raw engine frame back
|
|
6
|
+
* as an MCP image content block — no vision-model prepass, no paraphrase. The
|
|
7
|
+
* model reviews the actual pixels.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerVisualTools(server: McpServer): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { withEngine } from "./with-engine.js";
|
|
3
|
+
/**
|
|
4
|
+
* Visual capture tools. Unlike the in-product chat agent (a text-only "brain"
|
|
5
|
+
* that needs a separate vision model to describe frames for it), an MCP client
|
|
6
|
+
* like Claude Code can SEE images directly. So we hand the raw engine frame back
|
|
7
|
+
* as an MCP image content block — no vision-model prepass, no paraphrase. The
|
|
8
|
+
* model reviews the actual pixels.
|
|
9
|
+
*/
|
|
10
|
+
export function registerVisualTools(server) {
|
|
11
|
+
server.tool("summer_screenshot", `Capture a frame from Summer Engine and return it as an image you can look at directly.
|
|
12
|
+
|
|
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
|
+
|
|
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.
|
|
18
|
+
|
|
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
|
+
target: z
|
|
21
|
+
.enum(["viewport", "game"])
|
|
22
|
+
.optional()
|
|
23
|
+
.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(), {
|
|
26
|
+
toContent: (snap) => {
|
|
27
|
+
// withEngine only calls toContent on success (ok:true, error cleared
|
|
28
|
+
// by extractOpError). Missing image bytes on a "success" is still
|
|
29
|
+
// possible defensively, so fall back to text rather than emit a
|
|
30
|
+
// broken image block.
|
|
31
|
+
if (!snap.base64) {
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
type: "text",
|
|
35
|
+
text: snap.error ||
|
|
36
|
+
"Snapshot succeeded but returned no image data. Try again, or use summer_get_scene_tree to inspect structurally.",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
const dims = snap.width && snap.height ? `${snap.width}x${snap.height}` : "unknown size";
|
|
41
|
+
const label = target === "game" ? "Running game frame" : "Editor viewport";
|
|
42
|
+
return [
|
|
43
|
+
{ 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
|
+
},
|
|
48
|
+
];
|
|
49
|
+
},
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { getClient } from "../server.js";
|
|
2
|
+
type ToolResultContent = {
|
|
3
|
+
type: "text";
|
|
4
|
+
text: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "image";
|
|
7
|
+
data: string;
|
|
8
|
+
mimeType: string;
|
|
9
|
+
};
|
|
2
10
|
type ToolResult = {
|
|
3
|
-
content:
|
|
4
|
-
type: "text";
|
|
5
|
-
text: string;
|
|
6
|
-
}[];
|
|
11
|
+
content: ToolResultContent[];
|
|
7
12
|
isError?: boolean;
|
|
8
13
|
};
|
|
14
|
+
/** Options for {@link withEngine}. `toContent` maps a successful engine result
|
|
15
|
+
* to MCP content blocks — used by the screenshot tool to hand the raw frame
|
|
16
|
+
* back as an image block instead of JSON-stringified text. Only runs on genuine
|
|
17
|
+
* success (after extractOpError clears); failures still surface as text. */
|
|
18
|
+
export interface WithEngineOptions<T> {
|
|
19
|
+
toContent?: (result: T) => ToolResultContent[];
|
|
20
|
+
}
|
|
9
21
|
/**
|
|
10
22
|
* Decide whether an engine result envelope represents a FAILURE, and if so
|
|
11
23
|
* return a model-visible message. Returns null only for genuine success.
|
|
@@ -20,5 +32,15 @@ type ToolResult = {
|
|
|
20
32
|
* Exported for unit tests.
|
|
21
33
|
*/
|
|
22
34
|
export declare function extractOpError(result: unknown): string | null;
|
|
23
|
-
|
|
35
|
+
/**
|
|
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.
|
|
41
|
+
*
|
|
42
|
+
* Exported for unit tests.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isReconnectableThrow(err: unknown): boolean;
|
|
45
|
+
export declare function withEngine<T>(fn: (client: Awaited<ReturnType<typeof getClient>>) => Promise<T>, opts?: WithEngineOptions<T>): Promise<ToolResult>;
|
|
24
46
|
export {};
|
|
@@ -56,6 +56,43 @@ export function extractOpError(result) {
|
|
|
56
56
|
}
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
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
|
+
/**
|
|
74
|
+
* A thrown transport error is safe to retry only when the engine provably never
|
|
75
|
+
* applied the op: a stale-token rejection (401/403, after the engine rotated its
|
|
76
|
+
* api-token on relaunch) or an unreachable/closed port (connection refused/reset,
|
|
77
|
+
* after the engine moved ports or is mid-restart). A timeout or any 5xx may have
|
|
78
|
+
* mutated, so those surface instead of risking a double-apply on retry.
|
|
79
|
+
*
|
|
80
|
+
* Exported for unit tests.
|
|
81
|
+
*/
|
|
82
|
+
export function isReconnectableThrow(err) {
|
|
83
|
+
const m = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
84
|
+
if (m.includes("timed out") || m.includes("timeout") || m.includes("aborted")) {
|
|
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"));
|
|
95
|
+
}
|
|
59
96
|
function buildActionHint(message) {
|
|
60
97
|
const normalized = message.toLowerCase();
|
|
61
98
|
if (normalized.includes("no scene open") || normalized.includes("no edited scene")) {
|
|
@@ -69,24 +106,52 @@ function buildActionHint(message) {
|
|
|
69
106
|
}
|
|
70
107
|
return null;
|
|
71
108
|
}
|
|
72
|
-
export async function withEngine(fn) {
|
|
109
|
+
export async function withEngine(fn, opts) {
|
|
73
110
|
// Best-effort, fire-and-forget: count this MCP session as DAU for attribution.
|
|
74
111
|
// No await, no throw, no quota gating.
|
|
75
112
|
recordMcpSession();
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
113
|
+
// The engine rotates its api-token and can move ports on every launch, so a
|
|
114
|
+
// restart that lands DURING a tool call shows up as a stale-token 401, an
|
|
115
|
+
// ECONNREFUSED on the old port, or a soft not_connected / identity_mismatch
|
|
116
|
+
// terminal state. Drop the cached client and retry ONCE so a transient drop
|
|
117
|
+
// heals itself (getClient reconnects with the fresh creds) instead of
|
|
118
|
+
// surfacing as a "disconnected" error. Only provably-not-applied failures are
|
|
119
|
+
// retried — see RECONNECTABLE_TERMINAL_STATES / isReconnectableThrow.
|
|
120
|
+
const MAX_ATTEMPTS = 2;
|
|
121
|
+
let lastError;
|
|
122
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
123
|
+
try {
|
|
124
|
+
const client = await getClient();
|
|
125
|
+
const result = await fn(client);
|
|
126
|
+
const opError = extractOpError(result);
|
|
127
|
+
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
|
+
const hint = buildActionHint(opError);
|
|
135
|
+
const message = hint ? `${opError}\n\nHint: ${hint}` : opError;
|
|
136
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
137
|
+
}
|
|
138
|
+
if (opts?.toContent) {
|
|
139
|
+
return { content: opts.toContent(result) };
|
|
140
|
+
}
|
|
141
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// A thrown error means the cached client may be pointed at a dead/rotated
|
|
145
|
+
// engine — always drop it (prior behavior). Retry once only for
|
|
146
|
+
// connection-class throws that provably did not mutate.
|
|
147
|
+
resetClient();
|
|
148
|
+
lastError = err;
|
|
149
|
+
if (attempt < MAX_ATTEMPTS && isReconnectableThrow(err)) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
84
153
|
}
|
|
85
|
-
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
86
|
-
}
|
|
87
|
-
catch (err) {
|
|
88
|
-
resetClient();
|
|
89
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
-
return { content: [{ type: "text", text: msg }], isError: true };
|
|
91
154
|
}
|
|
155
|
+
const msg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
156
|
+
return { content: [{ type: "text", text: msg }], isError: true };
|
|
92
157
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "summer-engine",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"description": "Summer CLI and MCP server for Summer Engine, the AI game engine. Install the engine, connect Claude Code, Cursor, Codex, Gemini, and other agents, and build real games. Skills, hooks, and plugins included.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"summer-engine",
|