unity-open-mcp 0.4.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +20 -14
  2. package/dist/batch-spawn.d.ts +4 -0
  3. package/dist/batch-spawn.js +55 -11
  4. package/dist/batch-spawn.js.map +1 -1
  5. package/dist/capabilities/build-capabilities.js +6 -0
  6. package/dist/capabilities/build-capabilities.js.map +1 -1
  7. package/dist/capabilities/cost-hints.js +30 -0
  8. package/dist/capabilities/cost-hints.js.map +1 -1
  9. package/dist/capabilities/lifecycle.js +13 -0
  10. package/dist/capabilities/lifecycle.js.map +1 -1
  11. package/dist/capabilities/tool-groups.js +4 -0
  12. package/dist/capabilities/tool-groups.js.map +1 -1
  13. package/dist/cli/commands.js +9 -8
  14. package/dist/cli/commands.js.map +1 -1
  15. package/dist/cli/ping-poller.js +20 -0
  16. package/dist/cli/ping-poller.js.map +1 -1
  17. package/dist/constants.d.ts +89 -0
  18. package/dist/constants.js +109 -0
  19. package/dist/constants.js.map +1 -0
  20. package/dist/dialog-dismiss.d.ts +0 -7
  21. package/dist/dialog-dismiss.js +17 -19
  22. package/dist/dialog-dismiss.js.map +1 -1
  23. package/dist/hub-control.d.ts +6 -2
  24. package/dist/hub-control.js +8 -8
  25. package/dist/hub-control.js.map +1 -1
  26. package/dist/index.js +6 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/instance-discovery.js +2 -1
  29. package/dist/instance-discovery.js.map +1 -1
  30. package/dist/live-client.d.ts +72 -0
  31. package/dist/live-client.js +387 -255
  32. package/dist/live-client.js.map +1 -1
  33. package/dist/offline/api.d.ts +171 -0
  34. package/dist/offline/api.js +852 -0
  35. package/dist/offline/api.js.map +1 -0
  36. package/dist/offline/hierarchy.d.ts +24 -0
  37. package/dist/offline/hierarchy.js +242 -0
  38. package/dist/offline/hierarchy.js.map +1 -0
  39. package/dist/offline/index-builders.d.ts +11 -0
  40. package/dist/offline/index-builders.js +202 -0
  41. package/dist/offline/index-builders.js.map +1 -0
  42. package/dist/offline/model.d.ts +8 -0
  43. package/dist/offline/model.js +188 -0
  44. package/dist/offline/model.js.map +1 -0
  45. package/dist/offline/names.d.ts +7 -0
  46. package/dist/offline/names.js +20 -0
  47. package/dist/offline/names.js.map +1 -0
  48. package/dist/offline/overrides.d.ts +17 -0
  49. package/dist/offline/overrides.js +266 -0
  50. package/dist/offline/overrides.js.map +1 -0
  51. package/dist/offline/parse.d.ts +25 -0
  52. package/dist/offline/parse.js +236 -0
  53. package/dist/offline/parse.js.map +1 -0
  54. package/dist/offline/paths.d.ts +4 -0
  55. package/dist/offline/paths.js +23 -0
  56. package/dist/offline/paths.js.map +1 -0
  57. package/dist/offline/primitives.d.ts +17 -0
  58. package/dist/offline/primitives.js +195 -0
  59. package/dist/offline/primitives.js.map +1 -0
  60. package/dist/offline/references.d.ts +9 -0
  61. package/dist/offline/references.js +55 -0
  62. package/dist/offline/references.js.map +1 -0
  63. package/dist/offline/types.d.ts +65 -0
  64. package/dist/offline/types.js +154 -0
  65. package/dist/offline/types.js.map +1 -0
  66. package/dist/offline.d.ts +2 -180
  67. package/dist/offline.js +27 -2282
  68. package/dist/offline.js.map +1 -1
  69. package/dist/project-health.d.ts +1 -1
  70. package/dist/project-health.js +66 -3
  71. package/dist/project-health.js.map +1 -1
  72. package/dist/routers.js +6 -5
  73. package/dist/routers.js.map +1 -1
  74. package/dist/running-unity.d.ts +105 -0
  75. package/dist/running-unity.js +387 -0
  76. package/dist/running-unity.js.map +1 -0
  77. package/dist/skill/client-paths.d.ts +22 -0
  78. package/dist/skill/client-paths.js +66 -5
  79. package/dist/skill/client-paths.js.map +1 -1
  80. package/dist/skill/generate-skill.d.ts +43 -4
  81. package/dist/skill/generate-skill.js +132 -34
  82. package/dist/skill/generate-skill.js.map +1 -1
  83. package/dist/tool-router.d.ts +12 -0
  84. package/dist/tool-router.js +112 -100
  85. package/dist/tool-router.js.map +1 -1
  86. package/dist/tools/batch-execute.d.ts +2 -0
  87. package/dist/tools/batch-execute.js +83 -0
  88. package/dist/tools/batch-execute.js.map +1 -0
  89. package/dist/tools/bridge-status.js +8 -1
  90. package/dist/tools/bridge-status.js.map +1 -1
  91. package/dist/tools/component-get.js +44 -11
  92. package/dist/tools/component-get.js.map +1 -1
  93. package/dist/tools/editor-status.js +3 -2
  94. package/dist/tools/editor-status.js.map +1 -1
  95. package/dist/tools/execute-csharp.js +3 -2
  96. package/dist/tools/execute-csharp.js.map +1 -1
  97. package/dist/tools/generate-skill.js +8 -0
  98. package/dist/tools/generate-skill.js.map +1 -1
  99. package/dist/tools/index.d.ts +1 -0
  100. package/dist/tools/index.js +29 -17
  101. package/dist/tools/index.js.map +1 -1
  102. package/dist/tools/inputsystem-asset-create.js +4 -4
  103. package/dist/tools/inputsystem-asset-create.js.map +1 -1
  104. package/dist/tools/invoke-method.js +2 -1
  105. package/dist/tools/invoke-method.js.map +1 -1
  106. package/dist/tools/navigation-surface-add.js +6 -5
  107. package/dist/tools/navigation-surface-add.js.map +1 -1
  108. package/dist/tools/probuilder-create-shape.js +4 -4
  109. package/dist/tools/probuilder-create-shape.js.map +1 -1
  110. package/dist/tools/read-compile-errors.js +44 -31
  111. package/dist/tools/read-compile-errors.js.map +1 -1
  112. package/dist/tools/scene-get-data.js +10 -2
  113. package/dist/tools/scene-get-data.js.map +1 -1
  114. package/dist/tools/scene-save.js +16 -5
  115. package/dist/tools/scene-save.js.map +1 -1
  116. package/dist/tools/scene-set-active.js +22 -6
  117. package/dist/tools/scene-set-active.js.map +1 -1
  118. package/dist/tools/scene-unload.js +20 -6
  119. package/dist/tools/scene-unload.js.map +1 -1
  120. package/package.json +1 -1
@@ -3,6 +3,8 @@ import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from "nod
3
3
  import { join } from "node:path";
4
4
  import { pollAndDismissDialogs, readDismissConfig, } from "./dialog-dismiss.js";
5
5
  import { readInstanceLock, classifyInstance, lockPath, isPidAlive, statusDir, hasRecentPendingTestRun } from "./instance-discovery.js";
6
+ import { bridgeBaseUrl, BRIDGE_DEFAULT_TIMEOUT_MS, PORT_ENV_VAR, PROJECT_PATH_ENV_VAR, } from "./constants.js";
7
+ import { findUnityForProject } from "./running-unity.js";
6
8
  import { makeErrorResult } from "./results.js";
7
9
  import { checkBridgeCompat, isVersionCheckSuppressed, } from "./compat.js";
8
10
  // M23 Plan 3 — structured retry policy + env-overridable tunables. Replaces
@@ -20,17 +22,9 @@ import { lifecycleFor } from "./capabilities/lifecycle.js";
20
22
  // fair round-robin queue can schedule across agents).
21
23
  import { PROCESS_AGENT_ID } from "./agent-identity.js";
22
24
  const PING_TIMEOUT_MS = 5_000;
23
- // The bridge's default per-tool wait before IT gives up and returns a timeout
24
- // envelope (packages/bridge/Editor/Bridge/BridgeRequestBody.cs
25
- // DefaultTimeoutMs). The client fetch timeout must never preempt this — if the
26
- // client aborts first it re-POSTs while the bridge is still processing the
27
- // original Work, manufacturing duplicate side-effects (specs/feedback.md entry
28
- // 4B/4A). postTool floors its fetch timeout at BRIDGE_DEFAULT_TIMEOUT_MS +
29
- // slack so a small/absent timeout_ms can't make the client give up before the
30
- // bridge. Kept as a literal here (not imported from the C# side) because the
31
- // bridge assembly isn't readable from the TS server; the cross-reference above
32
- // is the contract — bump both together.
33
- const BRIDGE_DEFAULT_TIMEOUT_MS = 30_000;
25
+ // BRIDGE_DEFAULT_TIMEOUT_MS now lives in constants.ts (single source shared
26
+ // with the bridge C# parity test). The client fetch timeout must never
27
+ // preempt the bridge's default wait — see constants.ts for the contract.
34
28
  const DIRECT_RESPONSE_TOOLS = new Set([
35
29
  "unity_open_mcp_validate_edit",
36
30
  "unity_open_mcp_checkpoint_create",
@@ -62,6 +56,10 @@ const DIRECT_RESPONSE_TOOLS = new Set([
62
56
  function sleep(ms) {
63
57
  return new Promise((resolve) => setTimeout(resolve, ms));
64
58
  }
59
+ const COMPILE_REPOST_SENTINEL = { __compileRepost: 0 };
60
+ function isCompileRepostSentinel(v) {
61
+ return typeof v === "object" && v !== null && "__compileRepost" in v;
62
+ }
65
63
  /**
66
64
  * Decide whether {@link LiveClient#postTool}'s catch block should re-POST after
67
65
  * a transient fetch failure. Exported so the retry policy is unit-testable in
@@ -104,9 +102,9 @@ export function shouldRetryPostAfterFailure(err, endpointChanged) {
104
102
  const OFFLINE_HINT = "Ensure the Unity Editor is open with the Agent Bridge running. " +
105
103
  "The bridge port is per-project (20000 + sha256(projectPath) % 10000), not " +
106
104
  "fixed — if Unity is open the MCP server may be aimed at the wrong port. " +
107
- "Check the instance lock at ~/.unity-open-mcp/instances/<sha256(projectPath)>.json " +
108
- "for the live port/pid, or set UNITY_OPEN_MCP_BRIDGE_PORT. If Unity is not " +
109
- "open, launch it (or set UNITY_PATH + UNITY_PROJECT_PATH for batch fallback).";
105
+ `Check the instance lock at ~/.unity-open-mcp/instances/<sha256(projectPath)>.json ` +
106
+ `for the live port/pid, or set ${PORT_ENV_VAR}. If Unity is not ` +
107
+ `open, launch it (or set UNITY_PATH + ${PROJECT_PATH_ENV_VAR} for batch fallback).`;
110
108
  /**
111
109
  * Build a per-instance offline hint that names THIS project's lock file path
112
110
  * and (when the lock is readable) its port/pid/state, so an agent debugging a
@@ -176,7 +174,7 @@ export class LiveClient {
176
174
  * env-pinned port to the lock port. */
177
175
  envPort;
178
176
  constructor(port, pingCache, authToken, projectPath, agentId = PROCESS_AGENT_ID, envPort) {
179
- this.baseUrl = `http://127.0.0.1:${port}`;
177
+ this.baseUrl = bridgeBaseUrl(port);
180
178
  this.pingCache = pingCache;
181
179
  this.authToken = authToken;
182
180
  this.projectPath = projectPath;
@@ -256,6 +254,16 @@ export class LiveClient {
256
254
  };
257
255
  }
258
256
  catch {
257
+ // M27 Plan 1 — ping is the first probe many agents use to decide
258
+ // whether to call read_compile_errors. Before returning a generic
259
+ // bridge_offline, check the cold Safe Mode signature (no lock + a live
260
+ // Unity process for this project) so the agent gets the same
261
+ // bridge_compile_failed recovery hint as the mid-session dead-bridge
262
+ // path. Without this an agent hitting ping first would see only
263
+ // bridge_offline and have to guess.
264
+ const coldSafeMode = this.coldSafeModeResult();
265
+ if (coldSafeMode)
266
+ return coldSafeMode;
259
267
  return makeErrorResult({
260
268
  code: "bridge_offline",
261
269
  message: `Bridge is not reachable at ${this.baseUrl}. ${buildOfflineHint(this.projectPath)}`,
@@ -362,258 +370,298 @@ export class LiveClient {
362
370
  },
363
371
  });
364
372
  }
373
+ /**
374
+ * POST a tool call to the bridge and shape the response. Decomposed in
375
+ * M28-refactoring Plan 3 (T3.3) into named steps so retry, compile-wait,
376
+ * transient-offline handling, and result shaping are independently readable.
377
+ *
378
+ * Pipeline:
379
+ * 1. {@link postToolFetch} — POST with a timeout floored above the bridge's
380
+ * own default wait, handling 503-compile-retry and non-OK HTTP.
381
+ * 2. {@link shapeToolResult} — parse the body and dispatch mutation-envelope
382
+ * vs direct-body vs inline-image vs reload/corruption outcomes.
383
+ * 3. On a transient fetch failure, {@link handlePostFailure} classifies +
384
+ * recovers (re-syncing the endpoint, waiting out a reload, or surfacing
385
+ * the structured offline/dead-bridge error) and re-POSTs only when safe.
386
+ *
387
+ * The retry/compile contract is unchanged from the former monolithic body.
388
+ */
365
389
  async postTool(toolName, args, retryOn503) {
366
390
  try {
367
- const timeoutMs = typeof args.timeout_ms === "number" ? args.timeout_ms : 60_000;
368
- // Floor the client fetch timeout at the bridge's own default wait + slack
369
- // so the client never aborts before the bridge has had a chance to return
370
- // its timeout envelope. Without this floor, a small/absent timeout_ms
371
- // (MCP hosts often omit the field → schema default 30000, or smaller)
372
- // makes the client re-POST while the bridge is still processing →
373
- // duplicate mutations (specs/feedback.md entry 4B). An explicit large
374
- // timeout_ms still wins via the Math.max.
375
- const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
376
- const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
377
- method: "POST",
378
- headers: { "Content-Type": "application/json; charset=utf-8" },
379
- body: JSON.stringify(args),
380
- }, fetchTimeout);
381
- if (res.status === 503 && retryOn503) {
382
- const compileError = await this.waitForCompile();
383
- if (compileError)
384
- return compileError;
391
+ const res = await this.postToolFetch(toolName, args, retryOn503);
392
+ // 503 + compile settled cleanly → re-POST once without 503 retry.
393
+ if (isCompileRepostSentinel(res)) {
385
394
  return this.postTool(toolName, args, false);
386
395
  }
387
- if (!res.ok) {
388
- const body = (await res
389
- .json()
390
- .catch(() => null));
391
- return makeErrorResult({
392
- code: "bridge_http_error",
393
- message: body?.error?.message ?? `Bridge returned HTTP ${res.status}`,
394
- detail: body ?? {
395
- error: {
396
- code: "bridge_http_error",
397
- message: `HTTP ${res.status}`,
398
- },
396
+ return res;
397
+ }
398
+ catch (err) {
399
+ return this.handlePostFailure(toolName, args, err, retryOn503);
400
+ }
401
+ }
402
+ /**
403
+ * Step 1 of {@link postTool}: perform the HTTP POST with the timeout floor,
404
+ * then handle 503-compile-retry and non-OK HTTP. Returns either a shaped
405
+ * {@link CallToolResult} (handed straight back by postTool) or a sentinel
406
+ * `{ _repostAfterCompile: true }` signaling a clean compile settle that
407
+ * should re-POST without 503 retry. Throws on transient fetch failure so the
408
+ * caller's catch runs {@link handlePostFailure}.
409
+ */
410
+ async postToolFetch(toolName, args, retryOn503) {
411
+ const timeoutMs = typeof args.timeout_ms === "number" ? args.timeout_ms : 60_000;
412
+ // Floor the client fetch timeout at the bridge's own default wait + slack
413
+ // so the client never aborts before the bridge has had a chance to return
414
+ // its timeout envelope. Without this floor, a small/absent timeout_ms
415
+ // (MCP hosts often omit the field → schema default 30000, or smaller)
416
+ // makes the client re-POST while the bridge is still processing →
417
+ // duplicate mutations (specs/feedback.md entry 4B). An explicit large
418
+ // timeout_ms still wins via the Math.max.
419
+ const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
420
+ const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
421
+ method: "POST",
422
+ headers: { "Content-Type": "application/json; charset=utf-8" },
423
+ body: JSON.stringify(args),
424
+ }, fetchTimeout);
425
+ if (res.status === 503 && retryOn503) {
426
+ const compileError = await this.waitForCompile();
427
+ if (compileError)
428
+ return compileError;
429
+ return COMPILE_REPOST_SENTINEL;
430
+ }
431
+ if (!res.ok) {
432
+ const body = (await res
433
+ .json()
434
+ .catch(() => null));
435
+ return makeErrorResult({
436
+ code: "bridge_http_error",
437
+ message: body?.error?.message ?? `Bridge returned HTTP ${res.status}`,
438
+ detail: body ?? {
439
+ error: {
440
+ code: "bridge_http_error",
441
+ message: `HTTP ${res.status}`,
399
442
  },
400
- });
401
- }
402
- // The bridge returns TWO response shapes from /tools/*:
403
- // 1. A MUTATION envelope: { mutation: { success, ... }, gate: {...} }
404
- // for gate-tracked mutating tools (gameobject_*, assets_*, etc.).
405
- // 2. A DIRECT body: the tool's own JSON (e.g. { status, count, tags })
406
- // for read-only / state-only tools the bridge classifies as
407
- // DirectResponseTools (editor_get_tags, asmdef_list, shader_list_all,
408
- // scene_list_opened, material_get_properties, ~80 total).
409
- //
410
- // Previously postTool dispatched off a hardcoded DIRECT_RESPONSE_TOOLS set
411
- // that was ~60 entries behind the bridge's set. Tools missing from it fell
412
- // through to deriveIsError(body), which dereferences body.mutation.success
413
- // — undefined for a direct body → TypeError. That thrown TypeError landed
414
- // in the connection-recovery catch block, surfacing a misleading
415
- // bridge_offline (and, with the entry-2026-07-02-b retry change, looping).
416
- //
417
- // The fix: detect the envelope shape from the BODY itself. A response with
418
- // a top-level `mutation` object is a mutation envelope; anything else is a
419
- // direct body. This is reliable because the bridge's DirectResponseTools
420
- // path (HandleDirectResponseTool) never emits a `mutation` field and the
421
- // gate path always does (BuildMutationEnvelope). The DIRECT_RESPONSE_TOOLS
422
- // set is now only the inlineImage-unwrap allowlist (capture_inline), kept
423
- // for that one special-case transform, not for shape routing.
424
- //
425
- // Capture the raw text first so the unparsable-body branch below can
426
- // distinguish an EMPTY body (the signature of a compile-reload tool that
427
- // triggered a domain reload mid-response — the bridge tore down its HTTP
428
- // socket and the in-flight body was lost) from a SUBSTANTIAL-but-corrupt
429
- // body (e.g. an execute_csharp snippet whose OutputSerializer output was
430
- // truncated by a TypeLoadException). The reload case is a likely-success
431
- // outcome; the corruption case is a real error. See specs/feedback.md
432
- // 2026-07-05 (reload) + 2026-07-03-c (corruption).
433
- const rawText = await res.text();
434
- let parsed = null;
435
- try {
436
- parsed = JSON.parse(rawText);
437
- }
438
- catch {
439
- parsed = null;
440
- }
441
- // capture_inline returns an `inlineImage` base64 PNG field. Unwrap it into
442
- // an MCP image content block alongside a text metadata block
443
- // ([{type: image}, {type: text}]). The image carries the same viewable
444
- // payload a file-path screenshot would; the metadata keeps the view /
445
- // resolution / byteLength fields without the base64 blob.
446
- const inlineImage = parsed?.inlineImage;
447
- if (DIRECT_RESPONSE_TOOLS.has(toolName) &&
448
- parsed != null &&
449
- typeof inlineImage === "string" &&
450
- inlineImage.length > 0 &&
451
- parsed.error == null) {
452
- const mimeType = typeof parsed.mimeType === "string" ? parsed.mimeType : "image/png";
453
- const metadata = { ...parsed };
454
- delete metadata.inlineImage;
455
- return {
456
- content: [
457
- { type: "image", data: inlineImage, mimeType },
458
- { type: "text", text: JSON.stringify(metadata) },
459
- ],
460
- isError: false,
461
- };
462
- }
463
- // Mutation envelope vs direct body, decided from the body itself.
464
- if (parsed != null && typeof parsed.mutation === "object" && parsed.mutation !== null) {
465
- const body = parsed;
466
- return {
467
- content: [{ type: "text", text: JSON.stringify(body) }],
468
- isError: deriveIsError(body),
469
- };
470
- }
471
- // Malformed bridge body — JSON.parse rejected (parsed === null) on what
472
- // should be a 200 OK application/json response. The bridge's two response
473
- // shapes (BuildGateEnvelope, BuildDirectToolErrorJson) are always valid
474
- // JSON, so a parse failure means the response was corrupted in transit or
475
- // — the case that motivated this guard — a mutating tool's serialized
476
- // output was interpolated raw into the gate envelope and produced
477
- // unbalanced JSON (e.g. an execute_csharp snippet whose result threw a
478
- // TypeLoadException during OutputSerializer's reflective walk).
479
- //
480
- // Previously this branch silently degraded to isError:false with an empty
481
- // `{}` body, manufacturing a fake success. For compile-reload tools,
482
- // annotateCompileVerify then stamped `_compileVerify` onto that `{}`,
483
- // producing a malformed envelope `{"_compileVerify":{...}}` with NO
484
- // `mutation` object — violating the documented contract and confusing
485
- // agents that key off mutation.success (specs/feedback.md entry
486
- // 2026-07-03-c). Surface a structured error instead so the failure is
487
- // visible and annotateCompileVerify (gated on !result.isError) never
488
- // annotates a body that lost its mutation block.
489
- //
490
- // Compile-reload special case (specs/feedback.md 2026-07-05 entry): for
491
- // tools with the restart_then_settle lifecycle, an EMPTY (or near-empty)
492
- // body on a 200 OK is the signature of a SUCCESSFUL mutation that
493
- // triggered a domain reload mid-response — the bridge tore down its HTTP
494
- // socket for the reload, the in-flight response was lost, and the agent
495
- // observes a body that is empty or truncated to a few bytes. The
496
- // mutation almost certainly committed (reimport_package,
497
- // asmdef_create/modify, package_add/remove, … all commit before the
498
- // reload). Returning bridge_response_unparsable here would frame a
499
- // successful operation as corruption. Instead surface a typed
500
- // triggered_reload outcome that tells the agent to verify post-state
501
- // rather than suspect a corrupt response.
502
- //
503
- // The reload vs. corruption split keys off rawText length: a torn-down
504
- // socket delivers zero or a handful of bytes (no Content-Length / chunk
505
- // completing), while an OutputSerializer corruption leaves a substantial
506
- // half-written envelope. A SUBSTANTIAL unparseable body on a
507
- // compile-reload tool is STILL treated as corruption (the original
508
- // 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
509
- if (parsed == null) {
510
- const isEmptyLikeReload = rawText.trim().length === 0;
511
- if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
512
- return {
513
- content: [
514
- {
515
- type: "text",
516
- text: JSON.stringify({
517
- status: "triggered_reload",
518
- // The bridge returned 200 (the mutation reached the
519
- // dispatcher and the response was started) but the body was
520
- // empty — the hallmark of a domain reload tearing down the
521
- // socket mid-response. The mutation very likely committed;
522
- // treat this as "verify post-state" rather than "response
523
- // corrupt, retry".
524
- triggeredReload: true,
525
- message: `Bridge returned HTTP 200 for '${toolName}' with an empty ` +
526
- `response body. '${toolName}' is a compile-reload tool, so ` +
527
- `this is the expected signature of a mutation that triggered ` +
528
- `a domain reload mid-response — the bridge tore down its HTTP ` +
529
- `socket for the reload and the in-flight response was lost ` +
530
- `before any bytes were written. The mutation very likely ` +
531
- `committed (compile-reload tools commit before the reload ` +
532
- `begins). Do NOT treat this as corruption and do NOT retry ` +
533
- `blindly (that would re-run the mutation). Verify post-state: ` +
534
- `call unity_open_mcp_editor_status / bridge_status to confirm ` +
535
- `the reload settled, then unity_open_mcp_read_compile_errors ` +
536
- `to confirm there are no compile errors.`,
537
- agentNextSteps: [
538
- "Poll unity_open_mcp_editor_status / bridge_status until the bridge reports running and not compiling.",
539
- "Call unity_open_mcp_read_compile_errors to confirm the recompiled assembly has no CSxxxx errors (note: if the response carries staleLogSuspected, force a recompile via reimport_package / compile_check first).",
540
- "Do NOT retry the original call — the mutation likely committed before the reload. Verify the effect via the appropriate read tool instead.",
541
- ],
542
- _route: { route: "live", outcome: "triggered_reload" },
543
- }),
544
- },
545
- ],
546
- isError: false,
547
- };
548
- }
549
- return makeErrorResult({
550
- code: "bridge_response_unparsable",
551
- message: `Bridge returned HTTP 200 for '${toolName}' but the response body ` +
552
- `was not valid JSON. This usually means a mutating tool's serialized ` +
553
- `output was corrupted (e.g. an execute_csharp snippet whose result ` +
554
- `threw during serialization). The result is unreliable — do not trust ` +
555
- `any partial output. Check editor_status / bridge_status, then retry. ` +
556
- `Endpoint: ${this.baseUrl}.`,
557
- });
558
- }
559
- // Direct body: return it verbatim. isError when the tool reported a
560
- // top-level error field (the bridge's direct-response convention).
443
+ },
444
+ });
445
+ }
446
+ return this.shapeToolResult(toolName, res);
447
+ }
448
+ /**
449
+ * Step 2 of {@link postTool}: parse the bridge response body and shape it into
450
+ * a {@link CallToolResult}. Handles the four response outcomes:
451
+ * - inline-image unwrap (capture_inline → MCP image block + metadata),
452
+ * - mutation envelope (derive isError from mutation.success),
453
+ * - compile-reload empty body (triggered_reload — likely-success domain
454
+ * reload tore down the socket mid-response),
455
+ * - direct body (verbatim, isError when a top-level error field is present),
456
+ * - unparsable body (bridge_response_unparsable corruption error).
457
+ */
458
+ async shapeToolResult(toolName, res) {
459
+ // The bridge returns TWO response shapes from /tools/*:
460
+ // 1. A MUTATION envelope: { mutation: { success, ... }, gate: {...} }
461
+ // for gate-tracked mutating tools (gameobject_*, assets_*, etc.).
462
+ // 2. A DIRECT body: the tool's own JSON (e.g. { status, count, tags })
463
+ // for read-only / state-only tools the bridge classifies as
464
+ // DirectResponseTools (editor_get_tags, asmdef_list, shader_list_all,
465
+ // scene_list_opened, material_get_properties, ~80 total).
466
+ //
467
+ // Previously postTool dispatched off a hardcoded DIRECT_RESPONSE_TOOLS set
468
+ // that was ~60 entries behind the bridge's set. Tools missing from it fell
469
+ // through to deriveIsError(body), which dereferences body.mutation.success
470
+ // — undefined for a direct body → TypeError. That thrown TypeError landed
471
+ // in the connection-recovery catch block, surfacing a misleading
472
+ // bridge_offline (and, with the entry-2026-07-02-b retry change, looping).
473
+ //
474
+ // The fix: detect the envelope shape from the BODY itself. A response with
475
+ // a top-level `mutation` object is a mutation envelope; anything else is a
476
+ // direct body. This is reliable because the bridge's DirectResponseTools
477
+ // path (HandleDirectResponseTool) never emits a `mutation` field and the
478
+ // gate path always does (BuildMutationEnvelope). The DIRECT_RESPONSE_TOOLS
479
+ // set is now only the inlineImage-unwrap allowlist (capture_inline), kept
480
+ // for that one special-case transform, not for shape routing.
481
+ //
482
+ // Capture the raw text first so the unparsable-body branch below can
483
+ // distinguish an EMPTY body (the signature of a compile-reload tool that
484
+ // triggered a domain reload mid-response — the bridge tore down its HTTP
485
+ // socket and the in-flight body was lost) from a SUBSTANTIAL-but-corrupt
486
+ // body (e.g. an execute_csharp snippet whose OutputSerializer output was
487
+ // truncated by a TypeLoadException). The reload case is a likely-success
488
+ // outcome; the corruption case is a real error. See specs/feedback.md
489
+ // 2026-07-05 (reload) + 2026-07-03-c (corruption).
490
+ const rawText = await res.text();
491
+ let parsed = null;
492
+ try {
493
+ parsed = JSON.parse(rawText);
494
+ }
495
+ catch {
496
+ parsed = null;
497
+ }
498
+ // capture_inline returns an `inlineImage` base64 PNG field. Unwrap it into
499
+ // an MCP image content block alongside a text metadata block
500
+ // ([{type: image}, {type: text}]). The image carries the same viewable
501
+ // payload a file-path screenshot would; the metadata keeps the view /
502
+ // resolution / byteLength fields without the base64 blob.
503
+ const inlineImage = parsed?.inlineImage;
504
+ if (DIRECT_RESPONSE_TOOLS.has(toolName) &&
505
+ parsed != null &&
506
+ typeof inlineImage === "string" &&
507
+ inlineImage.length > 0 &&
508
+ parsed.error == null) {
509
+ const mimeType = typeof parsed.mimeType === "string" ? parsed.mimeType : "image/png";
510
+ const metadata = { ...parsed };
511
+ delete metadata.inlineImage;
561
512
  return {
562
513
  content: [
563
- {
564
- type: "text",
565
- text: JSON.stringify(parsed),
566
- },
514
+ { type: "image", data: inlineImage, mimeType },
515
+ { type: "text", text: JSON.stringify(metadata) },
567
516
  ],
568
- isError: parsed.error != null,
517
+ isError: false,
569
518
  };
570
519
  }
571
- catch (err) {
572
- // M20 Plan 4-5 / T-fix-2 — transient POST failure (ECONNREFUSED /
573
- // timeout during a domain reload). Classify + recover before declaring
574
- // offline. handleTransientOffline re-syncs the cached endpoint from the
575
- // lock first (so a Unity restart self-heals).
576
- //
577
- // Re-POST discipline (specs/feedback.md entry 4A — duplicate side-effects):
578
- // only re-POST when the endpoint CHANGED during recovery (a restart → new
579
- // bridge that never saw the original POST, so a retry is safe). When the
580
- // endpoint is unchanged the same live bridge is still processing — or has
581
- // queued — the original POST's Work; re-POSTing would run the mutation a
582
- // second time. In that case surface the timeout instead of retrying.
583
- //
584
- // shouldRetryPostAfterFailure additionally allows a re-POST on a genuine
585
- // CONNECTION failure (TypeError — the socket never connected / was reset
586
- // before the body flushed / ECONNREFUSED), because no bytes reached the
587
- // server and no side effect is possible. This must NOT fire for a
588
- // TypeError thrown by response parsing (e.g. deriveIsError on a malformed
589
- // body) — that path is now guarded by parsing the body defensively above,
590
- // but as a belt-and-braces measure the response-parsing path is structured
591
- // so it cannot throw into this catch (every .json() is .catch()-guarded).
592
- const endpointBefore = this.baseUrl;
593
- const recovered = await this.handleTransientOffline("post");
594
- if (recovered !== null)
595
- return recovered;
596
- const endpointChanged = this.baseUrl !== endpointBefore;
597
- if (shouldRetryPostAfterFailure(err, endpointChanged)) {
598
- return this.postTool(toolName, args, retryOn503);
520
+ // Mutation envelope vs direct body, decided from the body itself.
521
+ if (parsed != null && typeof parsed.mutation === "object" && parsed.mutation !== null) {
522
+ const body = parsed;
523
+ return {
524
+ content: [{ type: "text", text: JSON.stringify(body) }],
525
+ isError: deriveIsError(body),
526
+ };
527
+ }
528
+ // Malformed bridge body — JSON.parse rejected (parsed === null) on what
529
+ // should be a 200 OK application/json response. The bridge's two response
530
+ // shapes (BuildGateEnvelope, BuildDirectToolErrorJson) are always valid
531
+ // JSON, so a parse failure means the response was corrupted in transit or
532
+ // — the case that motivated this guard — a mutating tool's serialized
533
+ // output was interpolated raw into the gate envelope and produced
534
+ // unbalanced JSON (e.g. an execute_csharp snippet whose result threw a
535
+ // TypeLoadException during OutputSerializer's reflective walk).
536
+ //
537
+ // Previously this branch silently degraded to isError:false with an empty
538
+ // `{}` body, manufacturing a fake success. For compile-reload tools,
539
+ // annotateCompileVerify then stamped `_compileVerify` onto that `{}`,
540
+ // producing a malformed envelope `{"_compileVerify":{...}}` with NO
541
+ // `mutation` object — violating the documented contract and confusing
542
+ // agents that key off mutation.success (specs/feedback.md entry
543
+ // 2026-07-03-c). Surface a structured error instead so the failure is
544
+ // visible and annotateCompileVerify (gated on !result.isError) never
545
+ // annotates a body that lost its mutation block.
546
+ //
547
+ // Compile-reload special case (specs/feedback.md 2026-07-05 entry): for
548
+ // tools with the restart_then_settle lifecycle, an EMPTY (or near-empty)
549
+ // body on a 200 OK is the signature of a SUCCESSFUL mutation that
550
+ // triggered a domain reload mid-response — the bridge tore down its HTTP
551
+ // socket for the reload, the in-flight response was lost, and the agent
552
+ // observes a body that is empty or truncated to a few bytes. The
553
+ // mutation almost certainly committed (reimport_package,
554
+ // asmdef_create/modify, package_add/remove, … all commit before the
555
+ // reload). Returning bridge_response_unparsable here would frame a
556
+ // successful operation as corruption. Instead surface a typed
557
+ // triggered_reload outcome that tells the agent to verify post-state
558
+ // rather than suspect a corrupt response.
559
+ //
560
+ // The reload vs. corruption split keys off rawText length: a torn-down
561
+ // socket delivers zero or a handful of bytes (no Content-Length / chunk
562
+ // completing), while an OutputSerializer corruption leaves a substantial
563
+ // half-written envelope. A SUBSTANTIAL unparseable body on a
564
+ // compile-reload tool is STILL treated as corruption (the original
565
+ // 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
566
+ if (parsed == null) {
567
+ const isEmptyLikeReload = rawText.trim().length === 0;
568
+ if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
569
+ return {
570
+ content: [
571
+ {
572
+ type: "text",
573
+ text: JSON.stringify({
574
+ status: "triggered_reload",
575
+ // The bridge returned 200 (the mutation reached the
576
+ // dispatcher and the response was started) but the body was
577
+ // empty — the hallmark of a domain reload tearing down the
578
+ // socket mid-response. The mutation very likely committed;
579
+ // treat this as "verify post-state" rather than "response
580
+ // corrupt, retry".
581
+ triggeredReload: true,
582
+ message: `Bridge returned HTTP 200 for '${toolName}' with an empty ` +
583
+ `response body. '${toolName}' is a compile-reload tool, so ` +
584
+ `this is the expected signature of a mutation that triggered ` +
585
+ `a domain reload mid-response — the bridge tore down its HTTP ` +
586
+ `socket for the reload and the in-flight response was lost ` +
587
+ `before any bytes were written. The mutation very likely ` +
588
+ `committed (compile-reload tools commit before the reload ` +
589
+ `begins). Do NOT treat this as corruption and do NOT retry ` +
590
+ `blindly (that would re-run the mutation). Verify post-state: ` +
591
+ `call unity_open_mcp_editor_status / bridge_status to confirm ` +
592
+ `the reload settled, then unity_open_mcp_read_compile_errors ` +
593
+ `to confirm there are no compile errors.`,
594
+ agentNextSteps: [
595
+ "Poll unity_open_mcp_editor_status / bridge_status until the bridge reports running and not compiling.",
596
+ "Call unity_open_mcp_read_compile_errors to confirm the recompiled assembly has no CSxxxx errors (note: if the response carries staleLogSuspected, force a recompile via reimport_package / compile_check first).",
597
+ "Do NOT retry the original call — the mutation likely committed before the reload. Verify the effect via the appropriate read tool instead.",
598
+ ],
599
+ _route: { route: "live", outcome: "triggered_reload" },
600
+ }),
601
+ },
602
+ ],
603
+ isError: false,
604
+ };
599
605
  }
600
606
  return makeErrorResult({
601
- code: "bridge_offline",
602
- message: `Tool '${toolName}' did not return from the bridge within the client ` +
603
- `timeout. The bridge endpoint did not change (no restart detected) and ` +
604
- `the failure was a response timeout (not a connection failure), so ` +
605
- `the request was NOT retried — re-POSTing would risk running the ` +
606
- `mutation twice on the same bridge. If the editor was briefly slow ` +
607
- `(unfocused, GC, a long main-thread op), the original call may still ` +
608
- `complete there; verify the effect before retrying. Endpoint: ${this.baseUrl}.`,
609
- detail: {
610
- error: {
611
- code: "bridge_offline",
612
- message: `Client timeout against unchanged endpoint ${this.baseUrl}; not retried to avoid duplicate side-effects.`,
613
- },
614
- },
607
+ code: "bridge_response_unparsable",
608
+ message: `Bridge returned HTTP 200 for '${toolName}' but the response body ` +
609
+ `was not valid JSON. This usually means a mutating tool's serialized ` +
610
+ `output was corrupted (e.g. an execute_csharp snippet whose result ` +
611
+ `threw during serialization). The result is unreliable — do not trust ` +
612
+ `any partial output. Check editor_status / bridge_status, then retry. ` +
613
+ `Endpoint: ${this.baseUrl}.`,
615
614
  });
616
615
  }
616
+ // Direct body: return it verbatim. isError when the tool reported a
617
+ // top-level error field (the bridge's direct-response convention).
618
+ return {
619
+ content: [
620
+ {
621
+ type: "text",
622
+ text: JSON.stringify(parsed),
623
+ },
624
+ ],
625
+ isError: parsed.error != null,
626
+ };
627
+ }
628
+ /**
629
+ * Step 3 of {@link postTool}: classify + recover from a transient POST
630
+ * failure (ECONNREFUSED / timeout during a domain reload). Re-syncs the cached
631
+ * endpoint from the lock first (so a Unity restart self-heals), waits out a
632
+ * reload, and re-POSTs ONLY when safe (endpoint changed during recovery, or a
633
+ * genuine connection failure where no bytes reached the server).
634
+ *
635
+ * Re-POST discipline (specs/feedback.md entry 4A — duplicate side-effects):
636
+ * when the endpoint is unchanged the same live bridge is still processing —
637
+ * or has queued — the original POST's Work; re-POSTing would run the mutation
638
+ * a second time. In that case surface the timeout instead of retrying.
639
+ */
640
+ async handlePostFailure(toolName, args, err, retryOn503) {
641
+ const endpointBefore = this.baseUrl;
642
+ const recovered = await this.handleTransientOffline("post");
643
+ if (recovered !== null)
644
+ return recovered;
645
+ const endpointChanged = this.baseUrl !== endpointBefore;
646
+ if (shouldRetryPostAfterFailure(err, endpointChanged)) {
647
+ return this.postTool(toolName, args, retryOn503);
648
+ }
649
+ return makeErrorResult({
650
+ code: "bridge_offline",
651
+ message: `Tool '${toolName}' did not return from the bridge within the client ` +
652
+ `timeout. The bridge endpoint did not change (no restart detected) and ` +
653
+ `the failure was a response timeout (not a connection failure), so ` +
654
+ `the request was NOT retried — re-POSTing would risk running the ` +
655
+ `mutation twice on the same bridge. If the editor was briefly slow ` +
656
+ `(unfocused, GC, a long main-thread op), the original call may still ` +
657
+ `complete there; verify the effect before retrying. Endpoint: ${this.baseUrl}.`,
658
+ detail: {
659
+ error: {
660
+ code: "bridge_offline",
661
+ message: `Client timeout against unchanged endpoint ${this.baseUrl}; not retried to avoid duplicate side-effects.`,
662
+ },
663
+ },
664
+ });
617
665
  }
618
666
  async ensureReady() {
619
667
  try {
@@ -684,6 +732,15 @@ export class LiveClient {
684
732
  catch {
685
733
  // Re-probe also failed — fall through to the offline error below.
686
734
  }
735
+ // M27 Plan 1 — before declaring a generic bridge_offline, check the
736
+ // cold Safe Mode signature: no lock (bridge never compiled) + a live
737
+ // Unity process for this project. A match surfaces the same
738
+ // bridge_compile_failed shape as the mid-session dead-bridge path so
739
+ // the agent gets the read_compile_errors recovery hint instead of a
740
+ // generic offline error it would have to guess past.
741
+ const coldSafeMode = this.coldSafeModeResult();
742
+ if (coldSafeMode)
743
+ return coldSafeMode;
687
744
  return makeErrorResult({
688
745
  code: "bridge_offline",
689
746
  message: `Bridge is not reachable at ${this.baseUrl}. ${buildOfflineHint(this.projectPath)}`,
@@ -774,6 +831,15 @@ export class LiveClient {
774
831
  }
775
832
  }
776
833
  void operation; // log-only parameter, reserved for future telemetry
834
+ // M27 Plan 1 — before declaring a generic bridge_offline, check the cold
835
+ // Safe Mode signature. handleTransientOffline is the shared recovery
836
+ // exhaustion point for both the ping and POST paths; surfacing
837
+ // bridge_compile_failed here covers tool POSTs that never reached the
838
+ // bridge (no listener, no lock, Unity in Safe Mode) without each caller
839
+ // having to repeat the scan.
840
+ const coldSafeMode = this.coldSafeModeResult();
841
+ if (coldSafeMode)
842
+ return coldSafeMode;
777
843
  const retryMsg = `Bridge is not reachable at ${this.baseUrl} after ${maxAttempts} retries. ${buildOfflineHint(this.projectPath)}`;
778
844
  return makeErrorResult({
779
845
  code: "bridge_offline",
@@ -822,12 +888,12 @@ export class LiveClient {
822
888
  return false;
823
889
  const portChanged = typeof lock.port === "number" &&
824
890
  lock.port > 0 &&
825
- this.baseUrl !== `http://127.0.0.1:${lock.port}`;
891
+ this.baseUrl !== bridgeBaseUrl(lock.port);
826
892
  const tokenChanged = lock.authToken !== this.authToken;
827
893
  if (!portChanged && !tokenChanged)
828
894
  return false;
829
895
  if (portChanged)
830
- this.baseUrl = `http://127.0.0.1:${lock.port}`;
896
+ this.baseUrl = bridgeBaseUrl(lock.port);
831
897
  if (tokenChanged)
832
898
  this.authToken = lock.authToken;
833
899
  return true;
@@ -899,6 +965,72 @@ export class LiveClient {
899
965
  isError: true,
900
966
  };
901
967
  }
968
+ /**
969
+ * M27 Plan 1 — cold Safe Mode detection. The mirror of
970
+ * {@link deadBridgeResult} for the no-lock case. When Unity launches
971
+ * straight into Safe Mode, the bridge's `[InitializeOnLoad]` never runs, so
972
+ * no instance lock is written and {@link classifyInstance} returns `gone` —
973
+ * the same classification as "Unity is not running." Without a positive
974
+ * signal the agent cannot tell that `read_compile_errors` is the right next
975
+ * step; it gets a generic `bridge_offline` and must guess.
976
+ *
977
+ * This method scans for a live Unity process whose `-projectPath` references
978
+ * this project. A match is a strong positive signal of cold Safe Mode (the
979
+ * only other case that produces no lock + a live Unity process is "bridge
980
+ * toolbar off", which shares the same recovery — check compile errors /
981
+ * enable the bridge). Returns the SAME `bridge_compile_failed` shape as
982
+ * {@link deadBridgeResult} so the agent-facing contract is identical to the
983
+ * mid-session dead-bridge path; returns null when no Unity process matches
984
+ * (genuine offline — the caller falls back to `bridge_offline`).
985
+ *
986
+ * Like {@link deadBridgeResult} this is cheap to call repeatedly: the scan
987
+ * is the only new cost, and it runs only when classification is `gone`
988
+ * (lock absent or PID dead) — never on the happy path or during a reload.
989
+ */
990
+ coldSafeModeResult() {
991
+ if (!this.projectPath)
992
+ return null;
993
+ let classification;
994
+ try {
995
+ classification = classifyInstance(readInstanceLock(this.projectPath));
996
+ }
997
+ catch {
998
+ return null;
999
+ }
1000
+ // Only the no-lock / dead-PID case is a candidate for cold Safe Mode. A
1001
+ // live lock with a stale heartbeat is the mid-session dead-bridge path —
1002
+ // handled by deadBridgeResult, not here.
1003
+ if (classification !== "gone")
1004
+ return null;
1005
+ const proc = findUnityForProject(this.projectPath);
1006
+ if (!proc)
1007
+ return null;
1008
+ return {
1009
+ content: [
1010
+ {
1011
+ type: "text",
1012
+ text: JSON.stringify({
1013
+ error: {
1014
+ code: "bridge_compile_failed",
1015
+ message: "Unity is running but the bridge is unreachable and no " +
1016
+ "instance lock was found — Unity likely launched straight " +
1017
+ "into Safe Mode (the bridge assembly failed to compile from " +
1018
+ "a cold start, so [InitializeOnLoad] never wrote a lock). " +
1019
+ "The HTTP listener will not return until the C# error is " +
1020
+ "fixed. Call unity_open_mcp_read_compile_errors to retrieve " +
1021
+ "the compiler errors from Unity's Editor.log, fix the cited " +
1022
+ "file/line, then trigger a recompile (e.g. a no-op edit + " +
1023
+ "focus Unity, or unity_open_mcp_compile_check once the " +
1024
+ "source compiles). If Unity is NOT in safe mode, the bridge " +
1025
+ "toolbar toggle may be off — open the bridge window (Unity " +
1026
+ "menu: Tools/Unity Open MCP Bridge) and confirm it is started.",
1027
+ },
1028
+ }),
1029
+ },
1030
+ ],
1031
+ isError: true,
1032
+ };
1033
+ }
902
1034
  async waitForCompile() {
903
1035
  const compileWaitMs = this.retry.compileWaitMs;
904
1036
  const deadline = Date.now() + compileWaitMs;