unity-open-mcp 0.5.0 → 0.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 (124) hide show
  1. package/README.md +24 -73
  2. package/dist/batch-spawn.d.ts +4 -0
  3. package/dist/batch-spawn.js +73 -18
  4. package/dist/batch-spawn.js.map +1 -1
  5. package/dist/capabilities/build-capabilities.d.ts +5 -5
  6. package/dist/capabilities/build-capabilities.js +2 -2
  7. package/dist/capabilities/cost-hints.js +20 -0
  8. package/dist/capabilities/cost-hints.js.map +1 -1
  9. package/dist/capabilities/lifecycle.js +1 -1
  10. package/dist/capabilities/lifecycle.js.map +1 -1
  11. package/dist/capabilities/tool-groups.d.ts +6 -9
  12. package/dist/capabilities/tool-groups.js +11 -13
  13. package/dist/capabilities/tool-groups.js.map +1 -1
  14. package/dist/cli/commands.js +9 -8
  15. package/dist/cli/commands.js.map +1 -1
  16. package/dist/compressible-router.js +6 -0
  17. package/dist/compressible-router.js.map +1 -1
  18. package/dist/constants.d.ts +89 -0
  19. package/dist/constants.js +109 -0
  20. package/dist/constants.js.map +1 -0
  21. package/dist/dialog-dismiss.d.ts +0 -7
  22. package/dist/dialog-dismiss.js +17 -19
  23. package/dist/dialog-dismiss.js.map +1 -1
  24. package/dist/hub-control.d.ts +6 -2
  25. package/dist/hub-control.js +8 -8
  26. package/dist/hub-control.js.map +1 -1
  27. package/dist/index.js +12 -13
  28. package/dist/index.js.map +1 -1
  29. package/dist/instance-discovery.js +2 -1
  30. package/dist/instance-discovery.js.map +1 -1
  31. package/dist/live-client.d.ts +49 -0
  32. package/dist/live-client.js +292 -255
  33. package/dist/live-client.js.map +1 -1
  34. package/dist/offline/api.d.ts +171 -0
  35. package/dist/offline/api.js +855 -0
  36. package/dist/offline/api.js.map +1 -0
  37. package/dist/offline/hierarchy.d.ts +24 -0
  38. package/dist/offline/hierarchy.js +242 -0
  39. package/dist/offline/hierarchy.js.map +1 -0
  40. package/dist/offline/index-builders.d.ts +11 -0
  41. package/dist/offline/index-builders.js +202 -0
  42. package/dist/offline/index-builders.js.map +1 -0
  43. package/dist/offline/model.d.ts +8 -0
  44. package/dist/offline/model.js +188 -0
  45. package/dist/offline/model.js.map +1 -0
  46. package/dist/offline/names.d.ts +7 -0
  47. package/dist/offline/names.js +20 -0
  48. package/dist/offline/names.js.map +1 -0
  49. package/dist/offline/overrides.d.ts +17 -0
  50. package/dist/offline/overrides.js +266 -0
  51. package/dist/offline/overrides.js.map +1 -0
  52. package/dist/offline/parse.d.ts +25 -0
  53. package/dist/offline/parse.js +236 -0
  54. package/dist/offline/parse.js.map +1 -0
  55. package/dist/offline/paths.d.ts +4 -0
  56. package/dist/offline/paths.js +23 -0
  57. package/dist/offline/paths.js.map +1 -0
  58. package/dist/offline/primitives.d.ts +17 -0
  59. package/dist/offline/primitives.js +195 -0
  60. package/dist/offline/primitives.js.map +1 -0
  61. package/dist/offline/references.d.ts +9 -0
  62. package/dist/offline/references.js +55 -0
  63. package/dist/offline/references.js.map +1 -0
  64. package/dist/offline/types.d.ts +65 -0
  65. package/dist/offline/types.js +154 -0
  66. package/dist/offline/types.js.map +1 -0
  67. package/dist/offline.d.ts +2 -180
  68. package/dist/offline.js +27 -2282
  69. package/dist/offline.js.map +1 -1
  70. package/dist/project-health.d.ts +1 -1
  71. package/dist/project-health.js +66 -3
  72. package/dist/project-health.js.map +1 -1
  73. package/dist/routers.js +6 -5
  74. package/dist/routers.js.map +1 -1
  75. package/dist/tool-router.d.ts +13 -0
  76. package/dist/tool-router.js +179 -251
  77. package/dist/tool-router.js.map +1 -1
  78. package/dist/tool-session-state.js +18 -9
  79. package/dist/tool-session-state.js.map +1 -1
  80. package/dist/tools/apply-fix.js +7 -2
  81. package/dist/tools/apply-fix.js.map +1 -1
  82. package/dist/tools/batch-execute.js +8 -2
  83. package/dist/tools/batch-execute.js.map +1 -1
  84. package/dist/tools/bridge-status.js +1 -2
  85. package/dist/tools/bridge-status.js.map +1 -1
  86. package/dist/tools/checkpoint-create.js +1 -0
  87. package/dist/tools/checkpoint-create.js.map +1 -1
  88. package/dist/tools/component-get.js +44 -11
  89. package/dist/tools/component-get.js.map +1 -1
  90. package/dist/tools/delta.js +1 -0
  91. package/dist/tools/delta.js.map +1 -1
  92. package/dist/tools/dependencies.js +4 -1
  93. package/dist/tools/dependencies.js.map +1 -1
  94. package/dist/tools/editor-status.js +3 -2
  95. package/dist/tools/editor-status.js.map +1 -1
  96. package/dist/tools/execute-csharp.js +3 -2
  97. package/dist/tools/execute-csharp.js.map +1 -1
  98. package/dist/tools/find-references.js +8 -1
  99. package/dist/tools/find-references.js.map +1 -1
  100. package/dist/tools/index.js +19 -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/manage-tools.js +10 -10
  107. package/dist/tools/manage-tools.js.map +1 -1
  108. package/dist/tools/navigation-surface-add.js +6 -5
  109. package/dist/tools/navigation-surface-add.js.map +1 -1
  110. package/dist/tools/probuilder-create-shape.js +4 -4
  111. package/dist/tools/probuilder-create-shape.js.map +1 -1
  112. package/dist/tools/read-compile-errors.js +44 -31
  113. package/dist/tools/read-compile-errors.js.map +1 -1
  114. package/dist/tools/scene-get-data.js +10 -2
  115. package/dist/tools/scene-get-data.js.map +1 -1
  116. package/dist/tools/scene-save.js +16 -5
  117. package/dist/tools/scene-save.js.map +1 -1
  118. package/dist/tools/scene-set-active.js +22 -6
  119. package/dist/tools/scene-set-active.js.map +1 -1
  120. package/dist/tools/scene-unload.js +20 -6
  121. package/dist/tools/scene-unload.js.map +1 -1
  122. package/dist/tools/search-assets.js +3 -1
  123. package/dist/tools/search-assets.js.map +1 -1
  124. package/package.json +1 -1
@@ -3,6 +3,7 @@ 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";
6
7
  import { findUnityForProject } from "./running-unity.js";
7
8
  import { makeErrorResult } from "./results.js";
8
9
  import { checkBridgeCompat, isVersionCheckSuppressed, } from "./compat.js";
@@ -21,17 +22,9 @@ import { lifecycleFor } from "./capabilities/lifecycle.js";
21
22
  // fair round-robin queue can schedule across agents).
22
23
  import { PROCESS_AGENT_ID } from "./agent-identity.js";
23
24
  const PING_TIMEOUT_MS = 5_000;
24
- // The bridge's default per-tool wait before IT gives up and returns a timeout
25
- // envelope (packages/bridge/Editor/Bridge/BridgeRequestBody.cs
26
- // DefaultTimeoutMs). The client fetch timeout must never preempt this — if the
27
- // client aborts first it re-POSTs while the bridge is still processing the
28
- // original Work, manufacturing duplicate side-effects (specs/feedback.md entry
29
- // 4B/4A). postTool floors its fetch timeout at BRIDGE_DEFAULT_TIMEOUT_MS +
30
- // slack so a small/absent timeout_ms can't make the client give up before the
31
- // bridge. Kept as a literal here (not imported from the C# side) because the
32
- // bridge assembly isn't readable from the TS server; the cross-reference above
33
- // is the contract — bump both together.
34
- 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.
35
28
  const DIRECT_RESPONSE_TOOLS = new Set([
36
29
  "unity_open_mcp_validate_edit",
37
30
  "unity_open_mcp_checkpoint_create",
@@ -63,6 +56,10 @@ const DIRECT_RESPONSE_TOOLS = new Set([
63
56
  function sleep(ms) {
64
57
  return new Promise((resolve) => setTimeout(resolve, ms));
65
58
  }
59
+ const COMPILE_REPOST_SENTINEL = { __compileRepost: 0 };
60
+ function isCompileRepostSentinel(v) {
61
+ return typeof v === "object" && v !== null && "__compileRepost" in v;
62
+ }
66
63
  /**
67
64
  * Decide whether {@link LiveClient#postTool}'s catch block should re-POST after
68
65
  * a transient fetch failure. Exported so the retry policy is unit-testable in
@@ -105,9 +102,9 @@ export function shouldRetryPostAfterFailure(err, endpointChanged) {
105
102
  const OFFLINE_HINT = "Ensure the Unity Editor is open with the Agent Bridge running. " +
106
103
  "The bridge port is per-project (20000 + sha256(projectPath) % 10000), not " +
107
104
  "fixed — if Unity is open the MCP server may be aimed at the wrong port. " +
108
- "Check the instance lock at ~/.unity-open-mcp/instances/<sha256(projectPath)>.json " +
109
- "for the live port/pid, or set UNITY_OPEN_MCP_BRIDGE_PORT. If Unity is not " +
110
- "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).`;
111
108
  /**
112
109
  * Build a per-instance offline hint that names THIS project's lock file path
113
110
  * and (when the lock is readable) its port/pid/state, so an agent debugging a
@@ -177,7 +174,7 @@ export class LiveClient {
177
174
  * env-pinned port to the lock port. */
178
175
  envPort;
179
176
  constructor(port, pingCache, authToken, projectPath, agentId = PROCESS_AGENT_ID, envPort) {
180
- this.baseUrl = `http://127.0.0.1:${port}`;
177
+ this.baseUrl = bridgeBaseUrl(port);
181
178
  this.pingCache = pingCache;
182
179
  this.authToken = authToken;
183
180
  this.projectPath = projectPath;
@@ -373,258 +370,298 @@ export class LiveClient {
373
370
  },
374
371
  });
375
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
+ */
376
389
  async postTool(toolName, args, retryOn503) {
377
390
  try {
378
- const timeoutMs = typeof args.timeout_ms === "number" ? args.timeout_ms : 60_000;
379
- // Floor the client fetch timeout at the bridge's own default wait + slack
380
- // so the client never aborts before the bridge has had a chance to return
381
- // its timeout envelope. Without this floor, a small/absent timeout_ms
382
- // (MCP hosts often omit the field → schema default 30000, or smaller)
383
- // makes the client re-POST while the bridge is still processing →
384
- // duplicate mutations (specs/feedback.md entry 4B). An explicit large
385
- // timeout_ms still wins via the Math.max.
386
- const fetchTimeout = Math.max(timeoutMs + 10_000, BRIDGE_DEFAULT_TIMEOUT_MS + 10_000);
387
- const res = await this.fetchWithTimeout(`/tools/${toolName}`, {
388
- method: "POST",
389
- headers: { "Content-Type": "application/json; charset=utf-8" },
390
- body: JSON.stringify(args),
391
- }, fetchTimeout);
392
- if (res.status === 503 && retryOn503) {
393
- const compileError = await this.waitForCompile();
394
- if (compileError)
395
- 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)) {
396
394
  return this.postTool(toolName, args, false);
397
395
  }
398
- if (!res.ok) {
399
- const body = (await res
400
- .json()
401
- .catch(() => null));
402
- return makeErrorResult({
403
- code: "bridge_http_error",
404
- message: body?.error?.message ?? `Bridge returned HTTP ${res.status}`,
405
- detail: body ?? {
406
- error: {
407
- code: "bridge_http_error",
408
- message: `HTTP ${res.status}`,
409
- },
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}`,
410
442
  },
411
- });
412
- }
413
- // The bridge returns TWO response shapes from /tools/*:
414
- // 1. A MUTATION envelope: { mutation: { success, ... }, gate: {...} }
415
- // for gate-tracked mutating tools (gameobject_*, assets_*, etc.).
416
- // 2. A DIRECT body: the tool's own JSON (e.g. { status, count, tags })
417
- // for read-only / state-only tools the bridge classifies as
418
- // DirectResponseTools (editor_get_tags, asmdef_list, shader_list_all,
419
- // scene_list_opened, material_get_properties, ~80 total).
420
- //
421
- // Previously postTool dispatched off a hardcoded DIRECT_RESPONSE_TOOLS set
422
- // that was ~60 entries behind the bridge's set. Tools missing from it fell
423
- // through to deriveIsError(body), which dereferences body.mutation.success
424
- // — undefined for a direct body → TypeError. That thrown TypeError landed
425
- // in the connection-recovery catch block, surfacing a misleading
426
- // bridge_offline (and, with the entry-2026-07-02-b retry change, looping).
427
- //
428
- // The fix: detect the envelope shape from the BODY itself. A response with
429
- // a top-level `mutation` object is a mutation envelope; anything else is a
430
- // direct body. This is reliable because the bridge's DirectResponseTools
431
- // path (HandleDirectResponseTool) never emits a `mutation` field and the
432
- // gate path always does (BuildMutationEnvelope). The DIRECT_RESPONSE_TOOLS
433
- // set is now only the inlineImage-unwrap allowlist (capture_inline), kept
434
- // for that one special-case transform, not for shape routing.
435
- //
436
- // Capture the raw text first so the unparsable-body branch below can
437
- // distinguish an EMPTY body (the signature of a compile-reload tool that
438
- // triggered a domain reload mid-response — the bridge tore down its HTTP
439
- // socket and the in-flight body was lost) from a SUBSTANTIAL-but-corrupt
440
- // body (e.g. an execute_csharp snippet whose OutputSerializer output was
441
- // truncated by a TypeLoadException). The reload case is a likely-success
442
- // outcome; the corruption case is a real error. See specs/feedback.md
443
- // 2026-07-05 (reload) + 2026-07-03-c (corruption).
444
- const rawText = await res.text();
445
- let parsed = null;
446
- try {
447
- parsed = JSON.parse(rawText);
448
- }
449
- catch {
450
- parsed = null;
451
- }
452
- // capture_inline returns an `inlineImage` base64 PNG field. Unwrap it into
453
- // an MCP image content block alongside a text metadata block
454
- // ([{type: image}, {type: text}]). The image carries the same viewable
455
- // payload a file-path screenshot would; the metadata keeps the view /
456
- // resolution / byteLength fields without the base64 blob.
457
- const inlineImage = parsed?.inlineImage;
458
- if (DIRECT_RESPONSE_TOOLS.has(toolName) &&
459
- parsed != null &&
460
- typeof inlineImage === "string" &&
461
- inlineImage.length > 0 &&
462
- parsed.error == null) {
463
- const mimeType = typeof parsed.mimeType === "string" ? parsed.mimeType : "image/png";
464
- const metadata = { ...parsed };
465
- delete metadata.inlineImage;
466
- return {
467
- content: [
468
- { type: "image", data: inlineImage, mimeType },
469
- { type: "text", text: JSON.stringify(metadata) },
470
- ],
471
- isError: false,
472
- };
473
- }
474
- // Mutation envelope vs direct body, decided from the body itself.
475
- if (parsed != null && typeof parsed.mutation === "object" && parsed.mutation !== null) {
476
- const body = parsed;
477
- return {
478
- content: [{ type: "text", text: JSON.stringify(body) }],
479
- isError: deriveIsError(body),
480
- };
481
- }
482
- // Malformed bridge body — JSON.parse rejected (parsed === null) on what
483
- // should be a 200 OK application/json response. The bridge's two response
484
- // shapes (BuildGateEnvelope, BuildDirectToolErrorJson) are always valid
485
- // JSON, so a parse failure means the response was corrupted in transit or
486
- // — the case that motivated this guard — a mutating tool's serialized
487
- // output was interpolated raw into the gate envelope and produced
488
- // unbalanced JSON (e.g. an execute_csharp snippet whose result threw a
489
- // TypeLoadException during OutputSerializer's reflective walk).
490
- //
491
- // Previously this branch silently degraded to isError:false with an empty
492
- // `{}` body, manufacturing a fake success. For compile-reload tools,
493
- // annotateCompileVerify then stamped `_compileVerify` onto that `{}`,
494
- // producing a malformed envelope `{"_compileVerify":{...}}` with NO
495
- // `mutation` object — violating the documented contract and confusing
496
- // agents that key off mutation.success (specs/feedback.md entry
497
- // 2026-07-03-c). Surface a structured error instead so the failure is
498
- // visible and annotateCompileVerify (gated on !result.isError) never
499
- // annotates a body that lost its mutation block.
500
- //
501
- // Compile-reload special case (specs/feedback.md 2026-07-05 entry): for
502
- // tools with the restart_then_settle lifecycle, an EMPTY (or near-empty)
503
- // body on a 200 OK is the signature of a SUCCESSFUL mutation that
504
- // triggered a domain reload mid-response — the bridge tore down its HTTP
505
- // socket for the reload, the in-flight response was lost, and the agent
506
- // observes a body that is empty or truncated to a few bytes. The
507
- // mutation almost certainly committed (reimport_package,
508
- // asmdef_create/modify, package_add/remove, … all commit before the
509
- // reload). Returning bridge_response_unparsable here would frame a
510
- // successful operation as corruption. Instead surface a typed
511
- // triggered_reload outcome that tells the agent to verify post-state
512
- // rather than suspect a corrupt response.
513
- //
514
- // The reload vs. corruption split keys off rawText length: a torn-down
515
- // socket delivers zero or a handful of bytes (no Content-Length / chunk
516
- // completing), while an OutputSerializer corruption leaves a substantial
517
- // half-written envelope. A SUBSTANTIAL unparseable body on a
518
- // compile-reload tool is STILL treated as corruption (the original
519
- // 2026-07-03-c contract) — that path keeps bridge_response_unparsable.
520
- if (parsed == null) {
521
- const isEmptyLikeReload = rawText.trim().length === 0;
522
- if (isEmptyLikeReload && lifecycleFor(toolName).class === "compile-reload") {
523
- return {
524
- content: [
525
- {
526
- type: "text",
527
- text: JSON.stringify({
528
- status: "triggered_reload",
529
- // The bridge returned 200 (the mutation reached the
530
- // dispatcher and the response was started) but the body was
531
- // empty — the hallmark of a domain reload tearing down the
532
- // socket mid-response. The mutation very likely committed;
533
- // treat this as "verify post-state" rather than "response
534
- // corrupt, retry".
535
- triggeredReload: true,
536
- message: `Bridge returned HTTP 200 for '${toolName}' with an empty ` +
537
- `response body. '${toolName}' is a compile-reload tool, so ` +
538
- `this is the expected signature of a mutation that triggered ` +
539
- `a domain reload mid-response — the bridge tore down its HTTP ` +
540
- `socket for the reload and the in-flight response was lost ` +
541
- `before any bytes were written. The mutation very likely ` +
542
- `committed (compile-reload tools commit before the reload ` +
543
- `begins). Do NOT treat this as corruption and do NOT retry ` +
544
- `blindly (that would re-run the mutation). Verify post-state: ` +
545
- `call unity_open_mcp_editor_status / bridge_status to confirm ` +
546
- `the reload settled, then unity_open_mcp_read_compile_errors ` +
547
- `to confirm there are no compile errors.`,
548
- agentNextSteps: [
549
- "Poll unity_open_mcp_editor_status / bridge_status until the bridge reports running and not compiling.",
550
- "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).",
551
- "Do NOT retry the original call — the mutation likely committed before the reload. Verify the effect via the appropriate read tool instead.",
552
- ],
553
- _route: { route: "live", outcome: "triggered_reload" },
554
- }),
555
- },
556
- ],
557
- isError: false,
558
- };
559
- }
560
- return makeErrorResult({
561
- code: "bridge_response_unparsable",
562
- message: `Bridge returned HTTP 200 for '${toolName}' but the response body ` +
563
- `was not valid JSON. This usually means a mutating tool's serialized ` +
564
- `output was corrupted (e.g. an execute_csharp snippet whose result ` +
565
- `threw during serialization). The result is unreliable — do not trust ` +
566
- `any partial output. Check editor_status / bridge_status, then retry. ` +
567
- `Endpoint: ${this.baseUrl}.`,
568
- });
569
- }
570
- // Direct body: return it verbatim. isError when the tool reported a
571
- // 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;
572
512
  return {
573
513
  content: [
574
- {
575
- type: "text",
576
- text: JSON.stringify(parsed),
577
- },
514
+ { type: "image", data: inlineImage, mimeType },
515
+ { type: "text", text: JSON.stringify(metadata) },
578
516
  ],
579
- isError: parsed.error != null,
517
+ isError: false,
580
518
  };
581
519
  }
582
- catch (err) {
583
- // M20 Plan 4-5 / T-fix-2 — transient POST failure (ECONNREFUSED /
584
- // timeout during a domain reload). Classify + recover before declaring
585
- // offline. handleTransientOffline re-syncs the cached endpoint from the
586
- // lock first (so a Unity restart self-heals).
587
- //
588
- // Re-POST discipline (specs/feedback.md entry 4A — duplicate side-effects):
589
- // only re-POST when the endpoint CHANGED during recovery (a restart → new
590
- // bridge that never saw the original POST, so a retry is safe). When the
591
- // endpoint is unchanged the same live bridge is still processing — or has
592
- // queued — the original POST's Work; re-POSTing would run the mutation a
593
- // second time. In that case surface the timeout instead of retrying.
594
- //
595
- // shouldRetryPostAfterFailure additionally allows a re-POST on a genuine
596
- // CONNECTION failure (TypeError — the socket never connected / was reset
597
- // before the body flushed / ECONNREFUSED), because no bytes reached the
598
- // server and no side effect is possible. This must NOT fire for a
599
- // TypeError thrown by response parsing (e.g. deriveIsError on a malformed
600
- // body) — that path is now guarded by parsing the body defensively above,
601
- // but as a belt-and-braces measure the response-parsing path is structured
602
- // so it cannot throw into this catch (every .json() is .catch()-guarded).
603
- const endpointBefore = this.baseUrl;
604
- const recovered = await this.handleTransientOffline("post");
605
- if (recovered !== null)
606
- return recovered;
607
- const endpointChanged = this.baseUrl !== endpointBefore;
608
- if (shouldRetryPostAfterFailure(err, endpointChanged)) {
609
- 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
+ };
610
605
  }
611
606
  return makeErrorResult({
612
- code: "bridge_offline",
613
- message: `Tool '${toolName}' did not return from the bridge within the client ` +
614
- `timeout. The bridge endpoint did not change (no restart detected) and ` +
615
- `the failure was a response timeout (not a connection failure), so ` +
616
- `the request was NOT retried — re-POSTing would risk running the ` +
617
- `mutation twice on the same bridge. If the editor was briefly slow ` +
618
- `(unfocused, GC, a long main-thread op), the original call may still ` +
619
- `complete there; verify the effect before retrying. Endpoint: ${this.baseUrl}.`,
620
- detail: {
621
- error: {
622
- code: "bridge_offline",
623
- message: `Client timeout against unchanged endpoint ${this.baseUrl}; not retried to avoid duplicate side-effects.`,
624
- },
625
- },
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}.`,
626
614
  });
627
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
+ });
628
665
  }
629
666
  async ensureReady() {
630
667
  try {
@@ -851,12 +888,12 @@ export class LiveClient {
851
888
  return false;
852
889
  const portChanged = typeof lock.port === "number" &&
853
890
  lock.port > 0 &&
854
- this.baseUrl !== `http://127.0.0.1:${lock.port}`;
891
+ this.baseUrl !== bridgeBaseUrl(lock.port);
855
892
  const tokenChanged = lock.authToken !== this.authToken;
856
893
  if (!portChanged && !tokenChanged)
857
894
  return false;
858
895
  if (portChanged)
859
- this.baseUrl = `http://127.0.0.1:${lock.port}`;
896
+ this.baseUrl = bridgeBaseUrl(lock.port);
860
897
  if (tokenChanged)
861
898
  this.authToken = lock.authToken;
862
899
  return true;