summer-engine 2.6.5 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +3 -1
  3. package/.codex-plugin/plugin.json +5 -2
  4. package/.cursor-plugin/plugin.json +4 -1
  5. package/.opencode/plugins/summer.js +1 -1
  6. package/AGENTS.md +8 -4
  7. package/CHANGELOG.md +41 -0
  8. package/GEMINI.md +5 -4
  9. package/README.md +25 -20
  10. package/_persona/summer/SKILL.md +1 -0
  11. package/commands/summer.md +13 -0
  12. package/dist/bin/summer.js +4 -0
  13. package/dist/commands/debug.d.ts +2 -0
  14. package/dist/commands/debug.js +44 -0
  15. package/dist/commands/doctor.js +112 -8
  16. package/dist/commands/skills.js +1 -1
  17. package/dist/lib/api-client.d.ts +22 -6
  18. package/dist/lib/api-client.js +161 -36
  19. package/dist/lib/async-op-lifecycle.d.ts +7 -2
  20. package/dist/lib/async-op-lifecycle.js +32 -9
  21. package/dist/lib/cloud/engine-bridge.js +9 -1
  22. package/dist/lib/debug-report.d.ts +50 -0
  23. package/dist/lib/debug-report.js +308 -0
  24. package/dist/lib/game-task-plan.js +4 -4
  25. package/dist/lib/mcp-boot-notice.d.ts +3 -0
  26. package/dist/lib/mcp-boot-notice.js +7 -0
  27. package/dist/lib/mcp-log.d.ts +3 -0
  28. package/dist/lib/mcp-log.js +72 -0
  29. package/dist/lib/skills-registry.d.ts +25 -1
  30. package/dist/lib/skills-registry.js +46 -0
  31. package/dist/mcp/server.d.ts +2 -2
  32. package/dist/mcp/server.js +79 -7
  33. package/dist/mcp/tools/asset-tools.js +33 -12
  34. package/dist/mcp/tools/cloud-tools.js +15 -15
  35. package/dist/mcp/tools/debug-tools.js +44 -1
  36. package/dist/mcp/tools/file-tools.d.ts +2 -0
  37. package/dist/mcp/tools/file-tools.js +154 -0
  38. package/dist/mcp/tools/generate-tools.js +143 -5
  39. package/dist/mcp/tools/project-tools.js +25 -20
  40. package/dist/mcp/tools/scene-tools.js +101 -24
  41. package/dist/mcp/tools/with-engine.js +4 -4
  42. package/hooks/pre-commit-doctor.sh +0 -0
  43. package/hooks/run-hook.cmd +0 -0
  44. package/hooks/session-start.sh +0 -0
  45. package/package.json +6 -2
  46. package/skills/2d-assets/create-asset-sheet/SKILL.md +17 -15
  47. package/skills/debugging/debug/SKILL.md +23 -1
  48. package/skills/deployment/remote-deploy/SKILL.md +107 -0
  49. package/skills/scene-and-project/brainstorm-game/SKILL.md +4 -4
  50. package/skills/scene-and-project/summer-cloud/SKILL.md +92 -0
  51. package/skills/scripting-patterns/gdscript-patterns/SKILL.md +1 -1
  52. package/skills/workflow/brainstorming/scripts/start-server.sh +0 -0
  53. package/skills/workflow/brainstorming/scripts/stop-server.sh +0 -0
  54. package/skills/workflow/investigating-bugs/find-polluter.sh +0 -0
  55. package/skills/workflow/mcpupdate/SKILL.md +6 -6
  56. package/skills/workflow/using-summer/SKILL.md +20 -1
  57. package/skills/workflow/writing-skills/render-graphs.js +0 -0
@@ -7,16 +7,17 @@ import { registerDebugTools } from "./tools/debug-tools.js";
7
7
  import { registerVisualTools } from "./tools/visual-tools.js";
8
8
  import { WITH_ENGINE_META } from "./tools/with-engine.js";
9
9
  import { registerProjectTools } from "./tools/project-tools.js";
10
+ import { registerFileTools } from "./tools/file-tools.js";
10
11
  import { registerAssetTools } from "./tools/asset-tools.js";
11
12
  import { registerGenerateTools } from "./tools/generate-tools.js";
12
13
  import { registerCloudTools } from "./tools/cloud-tools.js";
14
+ import { getCachedBootDriftNotice as getCachedNotice, setCachedBootDriftNotice, } from "../lib/mcp-boot-notice.js";
15
+ import { appendMcpLogEvent } from "../lib/mcp-log.js";
13
16
  import { buildBootDriftNotice, fetchLatestRegistryVersion, } from "../lib/version-check.js";
14
17
  const require = createRequire(import.meta.url);
15
18
  const { version } = require("../../package.json");
16
- let cachedBootDriftNotice = null;
17
- export function getCachedBootDriftNotice() {
18
- return cachedBootDriftNotice;
19
- }
19
+ let processDiagnosticsInstalled = false;
20
+ export const getCachedBootDriftNotice = getCachedNotice;
20
21
  /**
21
22
  * Fire-and-forget probe of the npm registry on MCP boot. Caches the result for
22
23
  * the session. We never await this in startup so a slow / offline registry
@@ -27,7 +28,7 @@ async function probeBootDrift() {
27
28
  const registry = await fetchLatestRegistryVersion();
28
29
  // Agent is unknown at the MCP layer; use a generic placeholder. Doctor still
29
30
  // prescribes the correct agent-aware command via setup recommendations.
30
- cachedBootDriftNotice = buildBootDriftNotice(version, registry);
31
+ setCachedBootDriftNotice(buildBootDriftNotice(version, registry));
31
32
  }
32
33
  let cachedClient = null;
33
34
  export async function getClient() {
@@ -69,8 +70,56 @@ export function resetClient() {
69
70
  * out to produce pathological payloads in practice, the right fix is
70
71
  * adding range/depth/filter parameters to that tool — not a blanket cap.
71
72
  */
73
+ function errorDetails(error) {
74
+ if (error instanceof Error) {
75
+ return {
76
+ name: error.name,
77
+ message: error.message,
78
+ stack: error.stack,
79
+ };
80
+ }
81
+ return { message: String(error) };
82
+ }
83
+ function writeMcpStderr(event, details = {}) {
84
+ process.stderr.write(`[summer-mcp] ${JSON.stringify({ event, ...details })}\n`);
85
+ }
86
+ function installMcpProcessDiagnostics() {
87
+ if (processDiagnosticsInstalled)
88
+ return;
89
+ processDiagnosticsInstalled = true;
90
+ process.once("uncaughtException", (error) => {
91
+ const details = errorDetails(error);
92
+ appendMcpLogEvent("mcp:uncaught_exception", details);
93
+ writeMcpStderr("mcp:uncaught_exception", {
94
+ message: details.message,
95
+ log: "~/.summer/mcp.log",
96
+ });
97
+ process.exit(1);
98
+ });
99
+ process.once("unhandledRejection", (reason) => {
100
+ const details = errorDetails(reason);
101
+ appendMcpLogEvent("mcp:unhandled_rejection", details);
102
+ writeMcpStderr("mcp:unhandled_rejection", {
103
+ message: details.message,
104
+ log: "~/.summer/mcp.log",
105
+ });
106
+ process.exit(1);
107
+ });
108
+ process.once("SIGTERM", () => {
109
+ appendMcpLogEvent("mcp:sigterm", { pid: process.pid });
110
+ process.exit(0);
111
+ });
112
+ process.once("SIGINT", () => {
113
+ appendMcpLogEvent("mcp:sigint", { pid: process.pid });
114
+ process.exit(0);
115
+ });
116
+ process.once("exit", (code) => {
117
+ appendMcpLogEvent("mcp:exit", { pid: process.pid, code });
118
+ });
119
+ }
72
120
  function installResultSizeLogger(server) {
73
121
  const original = server.tool.bind(server);
122
+ let registeredToolCount = 0;
74
123
  server.tool = function patchedTool(...args) {
75
124
  if (args.length < 2)
76
125
  return original(...args);
@@ -81,6 +130,7 @@ function installResultSizeLogger(server) {
81
130
  const handler = args[lastIdx];
82
131
  if (typeof handler !== "function")
83
132
  return original(...args);
133
+ registeredToolCount += 1;
84
134
  const wrapped = async (...handlerArgs) => {
85
135
  const startedAt = Date.now();
86
136
  const result = await handler(...handlerArgs);
@@ -133,6 +183,11 @@ function installResultSizeLogger(server) {
133
183
  chars,
134
184
  bytes,
135
185
  });
186
+ appendMcpLogEvent("mcp:result_size", {
187
+ toolName: name,
188
+ chars,
189
+ bytes,
190
+ });
136
191
  process.stderr.write(`[summer-mcp] ${line}\n`);
137
192
  }
138
193
  }
@@ -146,25 +201,42 @@ function installResultSizeLogger(server) {
146
201
  newArgs[lastIdx] = wrapped;
147
202
  return original(...newArgs);
148
203
  };
204
+ return () => registeredToolCount;
149
205
  }
150
206
  export async function startMcpServer() {
207
+ installMcpProcessDiagnostics();
208
+ appendMcpLogEvent("mcp:start", {
209
+ version,
210
+ pid: process.pid,
211
+ node: process.version,
212
+ cwd: process.cwd(),
213
+ });
151
214
  const server = new McpServer({
152
215
  name: "summer-engine",
153
216
  version,
154
217
  });
155
218
  // Passive observability: log result-size to stderr but do not modify
156
219
  // results. See installResultSizeLogger above.
157
- installResultSizeLogger(server);
220
+ const getRegisteredToolCount = installResultSizeLogger(server);
158
221
  registerSceneTools(server);
159
222
  registerDebugTools(server);
160
223
  registerVisualTools(server);
161
224
  registerProjectTools(server);
225
+ registerFileTools(server);
162
226
  registerAssetTools(server);
163
227
  registerGenerateTools(server);
164
228
  registerCloudTools(server);
165
229
  // Fire-and-forget — never block tool registration on the npm registry.
166
- void probeBootDrift();
230
+ void probeBootDrift().catch((error) => {
231
+ setCachedBootDriftNotice(null);
232
+ appendMcpLogEvent("mcp:boot_drift_probe_failed", errorDetails(error));
233
+ });
167
234
  const transport = new StdioServerTransport();
168
235
  await server.connect(transport);
236
+ appendMcpLogEvent("mcp:ready", {
237
+ version,
238
+ pid: process.pid,
239
+ toolCount: getRegisteredToolCount(),
240
+ });
169
241
  process.stderr.write(`[summer-mcp] MCP server running v${version}.\n`);
170
242
  }
@@ -168,7 +168,10 @@ async function buildImportEntriesForAsset(asset, targetPath) {
168
168
  return { imports: [{ url: fileUrl, path }], importPath: path };
169
169
  }
170
170
  async function importResolvedAsset(args) {
171
- const { asset, parent, path, name } = args;
171
+ const { asset, parent, scenePath, path, name } = args;
172
+ if (parent && !scenePath) {
173
+ throw new Error("scenePath is required when importing an asset into a scene");
174
+ }
172
175
  const { imports, importPath } = await buildImportEntriesForAsset(asset, path);
173
176
  const client = await getClient();
174
177
  const importResult = imports.length === 1
@@ -176,20 +179,30 @@ async function importResolvedAsset(args) {
176
179
  { op: "ImportFromUrl", url: imports[0].url, path: imports[0].path },
177
180
  ])
178
181
  : await client.executeOps([{ op: "ImportFromUrlBatch", imports }]);
179
- const ok = importResult?.results?.[0]?.ok;
180
- if (!ok) {
181
- throw new Error("Could not import asset. Check engine logs.");
182
+ const importReceipts = importResult?.results ?? [];
183
+ const importFailure = importReceipts.find((receipt) => receipt?.ok === false);
184
+ if (importResult?.status === "error" || importFailure) {
185
+ throw new Error(importFailure?.error || "Could not import asset. Check engine logs.");
182
186
  }
183
187
  let addedToScene = false;
188
+ let sceneReceipt = null;
184
189
  if (parent && asset.type === "3d_model") {
185
- await client.executeOps([
190
+ sceneReceipt = await client.executeIdentityBoundOps([
186
191
  {
187
192
  op: "InstantiateScene",
188
193
  parent,
189
194
  scene: importPath,
190
195
  name: sanitizeNodeName(name || asset.title),
191
196
  },
192
- ]);
197
+ { op: "SaveScene" },
198
+ ], { scenePath });
199
+ const placementReceipts = sceneReceipt?.results ?? [];
200
+ const placementFailure = placementReceipts.find((receipt) => receipt?.ok !== true);
201
+ if (sceneReceipt?.status === "error" ||
202
+ placementReceipts.length !== 2 ||
203
+ placementFailure) {
204
+ throw new Error(placementFailure?.error || `Could not add asset to ${scenePath}`);
205
+ }
193
206
  addedToScene = true;
194
207
  }
195
208
  return {
@@ -200,6 +213,8 @@ async function importResolvedAsset(args) {
200
213
  importedTo: importPath,
201
214
  addedToScene,
202
215
  parent: parent || null,
216
+ scenePath: scenePath || null,
217
+ sceneReceipt,
203
218
  };
204
219
  }
205
220
  function jsonSuccess(data) {
@@ -345,8 +360,8 @@ Cloud tool — works WITHOUT the Summer Engine app open.`, {
345
360
  Use this when the asset was just generated, selected from my assets, or returned
346
361
  by search. Unlike summer_import_asset, this does not search or guess: it fetches
347
362
  the asset by ID, downloads it through Summer Engine, and runs Godot's import
348
- pipeline. For 3D models, pass parent to instantiate it in the open scene after
349
- import.
363
+ pipeline. For 3D models, pass parent and scenePath to instantiate it in that
364
+ exact scene; it does not need to be the active editor tab.
350
365
 
351
366
  Requires the Summer Engine app to be open with the project loaded (generation
352
367
  itself does not — only this import step does).`, {
@@ -359,11 +374,15 @@ itself does not — only this import step does).`, {
359
374
  .string()
360
375
  .optional()
361
376
  .describe("Optional parent node path. For 3D models only; imports and instantiates under this node."),
377
+ scenePath: z
378
+ .string()
379
+ .optional()
380
+ .describe("Required with parent. Exact res:// scene that receives the 3D model."),
362
381
  name: z
363
382
  .string()
364
383
  .optional()
365
384
  .describe("Optional scene node name when parent is provided."),
366
- }, async ({ assetId, path, parent, name }) => {
385
+ }, async ({ assetId, path, parent, scenePath, name }) => {
367
386
  const fetched = await getAssetApi(assetId);
368
387
  if (fetched.error || !fetched.asset) {
369
388
  return jsonError({
@@ -375,6 +394,7 @@ itself does not — only this import step does).`, {
375
394
  const imported = await importResolvedAsset({
376
395
  asset: fetched.asset,
377
396
  parent,
397
+ scenePath,
378
398
  path,
379
399
  name,
380
400
  });
@@ -390,7 +410,7 @@ itself does not — only this import step does).`, {
390
410
  return jsonError({
391
411
  error: "import_failed",
392
412
  message: msg,
393
- hint: "Make sure Summer Engine is running and the target scene is open.",
413
+ hint: "Make sure Summer Engine is running and pass the exact scenePath when parent is set.",
394
414
  });
395
415
  }
396
416
  });
@@ -402,9 +422,10 @@ Searches, picks the top result, downloads and imports it, then optionally adds i
402
422
  Requires authentication. If the user gets an auth error, they need to run 'npx summer-engine login' in their terminal first. Summer Engine must be running.`, {
403
423
  query: z.string().describe("What to find, e.g. 'low-poly tree', 'wooden crate'"),
404
424
  parent: z.string().optional().describe("Parent node path to add the asset under, e.g. './World'. If omitted, only imports (no scene placement)"),
425
+ scenePath: z.string().optional().describe("Required with parent. Exact res:// target scene path."),
405
426
  assetType: z.enum(["2d_image", "animation", "3d_model", "audio", "music", "all"]).default("3d_model").describe("Preferred asset type"),
406
427
  source: z.enum(["library", "my_assets", "all"]).default("all").describe("Where to search before importing"),
407
- }, async ({ query, parent, assetType, source }) => {
428
+ }, async ({ query, parent, scenePath, assetType, source }) => {
408
429
  const searchResult = await searchAssetsApi({ query, assetType, limit: 5, source });
409
430
  if (searchResult.error) {
410
431
  return {
@@ -443,7 +464,7 @@ Requires authentication. If the user gets an auth error, they need to run 'npx s
443
464
  };
444
465
  }
445
466
  try {
446
- const imported = await importResolvedAsset({ asset: best, parent });
467
+ const imported = await importResolvedAsset({ asset: best, parent, scenePath });
447
468
  // 2D sprites and audio: import only; user adds to scene manually
448
469
  return {
449
470
  content: [
@@ -15,28 +15,28 @@ function textJson(value) {
15
15
  };
16
16
  }
17
17
  export function registerCloudTools(server) {
18
- server.tool("summer_cloud_init", "Enable Summer Cloud for a project.", projectArg.shape, async (args) => textJson(await cloudInit({ project: args.project, face: "mcp" })));
19
- server.tool("summer_cloud_status", "Show Summer Cloud sync status.", projectArg.shape, async (args) => textJson(await cloudStatus({ project: args.project, face: "mcp" })));
20
- server.tool("summer_cloud_push", "Push local project changes to Summer Cloud.", {
18
+ server.tool("summer_cloud_init", "Enable Summer Cloud (content-addressed whole-project sync) for a project, writing the git-tracked summer-cloud.json binding. Use once per project before the first push, or after deleting the binding to fork the project into a new cloud line.", projectArg.shape, async (args) => textJson(await cloudInit({ project: args.project, face: "mcp" })));
19
+ server.tool("summer_cloud_status", "Show what a sync would move: file counts, paths queued to push or pull, and any waiting conflict sets. Always call this before push or pull so you can report the delete and change counts to the user; never sync blind.", projectArg.shape, async (args) => textJson(await cloudStatus({ project: args.project, face: "mcp" })));
20
+ server.tool("summer_cloud_push", "Upload local project changes to Summer Cloud as a new restorable version (use to back up big assets, move to another machine, or snapshot before a risky bulk op). confirmDeletes gates large deletions (more than 10 files, or >=20% of the tree, or the whole tree): run summer_cloud_status first, show the exact delete count to the user, and only set it once they confirm. Never pass confirmDeletes blindly.", {
21
21
  ...projectArg.shape,
22
- confirmDeletes: z.boolean().default(false).describe("Required when a push would delete many cloud files."),
23
- bootstrap: bootstrap.optional().describe("No-base bootstrap choice."),
24
- adoptPath: z.boolean().optional().describe("Accept that the project folder moved and update the recorded path."),
22
+ confirmDeletes: z.boolean().default(false).describe("Gate for large cloud deletions. Required when a push would delete more than 10 files, at least 20% of the tracked tree, or the entire tree. Surface the delete count from summer_cloud_status to the user before setting this; never set it blindly."),
23
+ bootstrap: bootstrap.optional().describe("No-base bootstrap choice when local and cloud both have files: keep-cloud, keep-local, or merge. Present the choice to the user."),
24
+ adoptPath: z.boolean().optional().describe("Accept that the project folder moved and update the recorded path. Only set after confirming the move is intentional, not a copy."),
25
25
  }, async (args) => textJson(await cloudPush({ ...args, face: "mcp" })));
26
- server.tool("summer_cloud_pull", "Pull Summer Cloud changes into the local project.", {
26
+ server.tool("summer_cloud_pull", "Download Summer Cloud changes into the local project (use to fetch a teammate's bytes or hydrate on a second machine). A local checkpoint is written before any destructive apply, and concurrent edits become recoverable conflict sets rather than silent overwrites. Run summer_cloud_status first.", {
27
27
  ...projectArg.shape,
28
- bootstrap: bootstrap.optional().describe("No-base bootstrap choice."),
29
- adoptPath: z.boolean().optional().describe("Accept that the project folder moved and update the recorded path."),
28
+ bootstrap: bootstrap.optional().describe("No-base bootstrap choice when local and cloud both have files: keep-cloud, keep-local, or merge. Present the choice to the user."),
29
+ adoptPath: z.boolean().optional().describe("Accept that the project folder moved and update the recorded path. Only set after confirming the move is intentional, not a copy."),
30
30
  }, async (args) => textJson(await cloudPull({ ...args, face: "mcp" })));
31
- server.tool("summer_cloud_restore", "Restore a retained cloud version (creates a new head version server-side, then pulls), or a local pre-sync checkpoint via checkpointStamp.", {
31
+ server.tool("summer_cloud_restore", "Roll a project back to an earlier state. Pass version to restore a retained cloud version (creates a new head version server-side, then pulls); pass checkpointStamp to restore a local pre-sync checkpoint for unpushed work. The restore restores bytes but does not delete files a later sync added, so review the extraneous-file list it returns.", {
32
32
  ...projectArg.shape,
33
- version: z.number().int().positive().optional().describe("Cloud version to restore."),
34
- checkpointStamp: z.string().optional().describe("Local checkpoint stamp (see summer_cloud_checkpoints)."),
33
+ version: z.number().int().positive().optional().describe("Cloud version to restore (see summer_cloud_status / version history)."),
34
+ checkpointStamp: z.string().optional().describe("Local checkpoint stamp (see summer_cloud_checkpoints). Use for unpushed local-only work."),
35
35
  }, async (args) => textJson(await cloudRestore({ project: args.project, version: args.version, checkpoint: args.checkpointStamp, face: "mcp" })));
36
- server.tool("summer_cloud_checkpoints", "List local pre-sync checkpoints.", projectArg.shape, async (args) => textJson(await cloudCheckpoints({ project: args.project, face: "mcp" })));
37
- server.tool("summer_cloud_conflicts", "List local Summer Cloud conflict sets, or restore a preserved conflict file with restorePath.", {
36
+ server.tool("summer_cloud_checkpoints", "List the local pre-sync checkpoints taken before destructive applies. Use to find a checkpoint stamp to pass to summer_cloud_restore when recovering local-only work.", projectArg.shape, async (args) => textJson(await cloudCheckpoints({ project: args.project, face: "mcp" })));
37
+ server.tool("summer_cloud_conflicts", "List preserved Summer Cloud conflict sets (the losing bytes from concurrent edits, never discarded), or recover one by passing restorePath to re-enter it as a fresh edit. Use after a push or pull reports conflicts so the user can choose what to keep.", {
38
38
  ...projectArg.shape,
39
- restorePath: z.string().optional().describe("Project-relative path to restore from the conflict sets."),
39
+ restorePath: z.string().optional().describe("Project-relative path to restore from the conflict sets, re-entered as a fresh edit."),
40
40
  set: z.string().optional().describe("Conflict set stamp; defaults to the newest set containing the path."),
41
41
  }, async (args) => textJson(await cloudConflicts({ project: args.project, restorePath: args.restorePath, set: args.set, face: "mcp" })));
42
42
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { withEngine } from "./with-engine.js";
3
3
  import { shapeEngineLogResponse } from "../../lib/log-filters.js";
4
+ import { createDebugReportArtifact } from "../../lib/debug-report.js";
4
5
  export function registerDebugTools(server) {
5
6
  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.
6
7
 
@@ -92,11 +93,53 @@ After starting, use summer_get_diagnostics to check for runtime errors.
92
93
  You can run a specific scene instead of the main scene — useful for testing individual levels or UI screens.`, {
93
94
  scene: z.string().optional().describe("Scene to run instead of main scene, e.g. 'res://levels/test_level.tscn'"),
94
95
  }, async ({ scene }) => withEngine(async (client) => client.play(scene)));
95
- server.tool("summer_stop", "Stop the running game. Call this before making scene changes — some operations require the game to not be running.", {}, async () => withEngine(async (client) => client.stop()));
96
+ server.tool("summer_stop", "Stop the running game. Use after runtime verification or when you intentionally need to restart the running instance; ordinary editor scene mutations do not require a blanket stop.", {}, async () => withEngine(async (client) => client.stop()));
96
97
  server.tool("summer_is_running", "Check if the game is currently running. Returns the active scene path if running.", {}, async () => withEngine(async (client) => client.executeOps([{ op: "IsGameRunning" }])));
97
98
  server.tool("summer_get_script_errors", `Check a GDScript file for parse/compile errors without running the game.
98
99
 
99
100
  Use after writing or editing a .gd file to verify it compiles. Returns line numbers, error messages, and severity. Much faster than running the game to discover script errors.`, {
100
101
  path: z.string().describe("Script path, e.g. 'res://scripts/player.gd' or 'res://player_controller.gd'"),
101
102
  }, async ({ path }) => withEngine(async (client) => client.getScriptErrors(path)));
103
+ server.tool("summer_create_debug_report", `Create a support-ready Markdown report for /summer debug.
104
+
105
+ Use this when the user says "/summer debug", asks to send Summer a bug report,
106
+ or needs a portable artifact from a failing Codex/cloud/agent session. The
107
+ report includes Summer doctor checks, engine health, diagnostics, console
108
+ output, debugger errors/warnings, and an agent handoff prompt. It omits auth
109
+ tokens and project file contents, but the user should still review it before
110
+ sending because local paths and stack traces may appear.`, {
111
+ issue: z.string().optional().describe("User-visible issue or repro summary."),
112
+ output_path: z.string().optional().describe("Where to write the Markdown report. Defaults to the open project root, or the current working directory if no project is open."),
113
+ include_play_session: z.boolean().optional().default(false).describe("Launch the game briefly and collect post-play diagnostics."),
114
+ play_wait_ms: z.number().optional().default(2500).describe("Milliseconds to wait after launching the game when include_play_session is true."),
115
+ max_console_lines: z.number().optional().default(200).describe("Console lines to include after filtering/deduping."),
116
+ max_debugger_entries: z.number().optional().default(100).describe("Debugger error/warning entries to include after filtering/deduping."),
117
+ include_doctor: z.boolean().optional().default(true).describe("Include summer doctor checks in the report."),
118
+ }, async ({ issue, output_path, include_play_session, play_wait_ms, max_console_lines, max_debugger_entries, include_doctor, }) => {
119
+ const artifact = await createDebugReportArtifact({
120
+ issue,
121
+ outputPath: output_path,
122
+ includePlaySession: include_play_session,
123
+ playWaitMs: play_wait_ms,
124
+ maxConsoleLines: max_console_lines,
125
+ maxDebuggerEntries: max_debugger_entries,
126
+ includeDoctor: include_doctor,
127
+ });
128
+ return {
129
+ content: [
130
+ {
131
+ type: "text",
132
+ text: JSON.stringify({
133
+ ok: true,
134
+ path: artifact.path,
135
+ engineConnected: artifact.report.engine.connected,
136
+ generatedAt: artifact.report.generatedAt,
137
+ issue: artifact.report.issue,
138
+ doctorSummary: artifact.report.doctor?.summary ?? null,
139
+ reviewNote: "Report omits auth tokens, but the user should review local paths and stack traces before sending.",
140
+ }, null, 2),
141
+ },
142
+ ],
143
+ };
144
+ });
102
145
  }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerFileTools(server: McpServer): void;
@@ -0,0 +1,154 @@
1
+ import { z } from "zod";
2
+ import { withEngine } from "./with-engine.js";
3
+ // MCP clients may issue tool calls concurrently. Keep the complete read->write
4
+ // transaction ordered for one project file while allowing unrelated files to
5
+ // proceed in parallel. The tail is always released in `finally`, so a rejected
6
+ // tool call cannot wedge later edits.
7
+ const fileMutationTails = new Map();
8
+ async function withFileMutationTurn(projectIdHash, path, fn) {
9
+ // Windows project paths are case-insensitive. Canonicalize only the lock key;
10
+ // preserve the caller's exact safe path in the operation sent to the engine.
11
+ const canonicalPathKey = path.replace(/\\/g, "/").toLowerCase();
12
+ const key = `${projectIdHash ?? "unbound"}\n${canonicalPathKey}`;
13
+ const previous = fileMutationTails.get(key) ?? Promise.resolve();
14
+ let release;
15
+ const gate = new Promise((resolve) => {
16
+ release = resolve;
17
+ });
18
+ const current = previous.catch(() => undefined).then(() => gate);
19
+ fileMutationTails.set(key, current);
20
+ await previous.catch(() => undefined);
21
+ try {
22
+ return await fn();
23
+ }
24
+ finally {
25
+ release();
26
+ if (fileMutationTails.get(key) === current) {
27
+ fileMutationTails.delete(key);
28
+ }
29
+ }
30
+ }
31
+ function asRecord(value) {
32
+ return value && typeof value === "object" && !Array.isArray(value)
33
+ ? value
34
+ : null;
35
+ }
36
+ function safeProjectPath(path) {
37
+ const normalized = path.trim().replace(/\\/g, "/");
38
+ if (!normalized.startsWith("res://") || normalized.includes("..")) {
39
+ throw new Error("File path must be a traversal-free res:// project path.");
40
+ }
41
+ return normalized;
42
+ }
43
+ function validSha256(value) {
44
+ return typeof value === "string" && /^[0-9a-f]{64}$/i.test(value);
45
+ }
46
+ function readTextPayload(result) {
47
+ const root = asRecord(result);
48
+ const data = asRecord(root?.data);
49
+ if (root?.ok === false) {
50
+ throw new Error(String(root.error ?? "Engine could not read the file."));
51
+ }
52
+ if (data?.encoding === "binary" || typeof data?.content !== "string") {
53
+ throw new Error("Safe text mutation refused: the engine did not return text content.");
54
+ }
55
+ if (data.truncated === true) {
56
+ throw new Error("Safe text mutation refused: the file exceeds the 1 MB read limit.");
57
+ }
58
+ const sha256 = typeof data.sha256 === "string" ? data.sha256 : undefined;
59
+ if (!validSha256(sha256)) {
60
+ throw new Error("Safe text mutation refused: the engine did not return a full-file sha256 receipt.");
61
+ }
62
+ return {
63
+ content: data.content,
64
+ sha256,
65
+ size: typeof data.size === "number" ? data.size : undefined,
66
+ };
67
+ }
68
+ function occurrenceCount(content, needle) {
69
+ let count = 0;
70
+ let offset = 0;
71
+ while (true) {
72
+ const index = content.indexOf(needle, offset);
73
+ if (index < 0)
74
+ return count;
75
+ count++;
76
+ offset = index + needle.length;
77
+ }
78
+ }
79
+ export function registerFileTools(server) {
80
+ server.tool("summer_read_file", `Read a text file from the engine-bound project and return its full-file sha256 receipt.
81
+
82
+ Use the returned sha256 as expected_sha256 when overwriting an existing file. Paths must begin with res://.`, {
83
+ path: z.string().describe("Project path, e.g. res://scripts/player.gd"),
84
+ max_bytes: z.number().int().positive().max(1_000_000).default(200_000),
85
+ }, async ({ path, max_bytes }) => withEngine(async (client) => client.readProjectFile(safeProjectPath(path), max_bytes)));
86
+ server.tool("summer_write_file", `Create or safely overwrite a complete text file through the identity-bound engine.
87
+
88
+ For a new file, set create_only:true. For an existing file, first call summer_read_file and pass its sha256 as expected_sha256. Exactly one guard is required; unguarded writes fail closed. Supports scripts, .tscn scenes, .tres resources, JSON, docs, and project config.`, {
89
+ path: z.string().describe("Project path, e.g. res://scenes/player.tscn"),
90
+ content: z.string().describe("Complete new UTF-8 file content."),
91
+ create_only: z.boolean().optional().describe("Required for a new file; refuses if the path already exists."),
92
+ expected_sha256: z.string().optional().describe("Required for overwriting an existing file; obtain from summer_read_file."),
93
+ }, async ({ path, content, create_only, expected_sha256 }) => withEngine(async (client) => {
94
+ const safePath = safeProjectPath(path);
95
+ const guardedCreate = create_only === true;
96
+ const guardedOverwrite = validSha256(expected_sha256);
97
+ if (expected_sha256 !== undefined && !guardedOverwrite) {
98
+ throw new Error("Safe write refused: expected_sha256 must be a 64-character hexadecimal sha256 from summer_read_file. Nothing was written.");
99
+ }
100
+ if (guardedCreate === guardedOverwrite) {
101
+ throw new Error("Safe write requires exactly one guard: create_only:true for a new file, or a 64-character expected_sha256 from summer_read_file for an existing file. Nothing was written.");
102
+ }
103
+ const op = {
104
+ op: "WriteFile",
105
+ path: safePath,
106
+ content,
107
+ };
108
+ if (guardedCreate)
109
+ op.mustNotExist = true;
110
+ if (guardedOverwrite)
111
+ op.expectedSha256 = expected_sha256.toLowerCase();
112
+ return withFileMutationTurn(client.getBoundProjectIdHash(), safePath, () => client.executeIdentityBoundOps([op]));
113
+ }));
114
+ server.tool("summer_replace_text", `Safely replace text in an existing project file through the identity-bound engine.
115
+
116
+ The MCP server reads the complete file, requires a unique match by default, computes the new content, and submits a sha256-guarded WriteFile. Set replace_all:true only when every exact occurrence should change.`, {
117
+ path: z.string().describe("Project path, e.g. res://scripts/player.gd"),
118
+ old_text: z.string().min(1).describe("Exact text to replace."),
119
+ new_text: z.string().describe("Replacement text; may be empty."),
120
+ replace_all: z.boolean().default(false).describe("Replace every exact occurrence instead of requiring one unique match."),
121
+ }, async ({ path, old_text, new_text, replace_all }) => withEngine(async (client) => {
122
+ const safePath = safeProjectPath(path);
123
+ return withFileMutationTurn(client.getBoundProjectIdHash(), safePath, async () => {
124
+ const current = readTextPayload(await client.readProjectFile(safePath, 1_000_000));
125
+ const matches = occurrenceCount(current.content, old_text);
126
+ if (matches === 0) {
127
+ throw new Error("Safe replace refused: old_text was not found. Nothing was written.");
128
+ }
129
+ if (!replace_all && matches !== 1) {
130
+ throw new Error(`Safe replace refused: old_text matched ${matches} times. Provide a unique span or set replace_all:true. Nothing was written.`);
131
+ }
132
+ const content = replace_all
133
+ ? current.content.split(old_text).join(new_text)
134
+ : current.content.replace(old_text, new_text);
135
+ if (content === current.content) {
136
+ return {
137
+ ok: true,
138
+ noOp: true,
139
+ path: safePath,
140
+ sha256: current.sha256,
141
+ matches,
142
+ };
143
+ }
144
+ return client.executeIdentityBoundOps([
145
+ {
146
+ op: "WriteFile",
147
+ path: safePath,
148
+ content,
149
+ expectedSha256: current.sha256,
150
+ },
151
+ ]);
152
+ });
153
+ }));
154
+ }