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.
- package/README.md +24 -73
- package/dist/batch-spawn.d.ts +4 -0
- package/dist/batch-spawn.js +73 -18
- package/dist/batch-spawn.js.map +1 -1
- package/dist/capabilities/build-capabilities.d.ts +5 -5
- package/dist/capabilities/build-capabilities.js +2 -2
- package/dist/capabilities/cost-hints.js +20 -0
- package/dist/capabilities/cost-hints.js.map +1 -1
- package/dist/capabilities/lifecycle.js +1 -1
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.d.ts +6 -9
- package/dist/capabilities/tool-groups.js +11 -13
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/cli/commands.js +9 -8
- package/dist/cli/commands.js.map +1 -1
- package/dist/compressible-router.js +6 -0
- package/dist/compressible-router.js.map +1 -1
- package/dist/constants.d.ts +89 -0
- package/dist/constants.js +109 -0
- package/dist/constants.js.map +1 -0
- package/dist/dialog-dismiss.d.ts +0 -7
- package/dist/dialog-dismiss.js +17 -19
- package/dist/dialog-dismiss.js.map +1 -1
- package/dist/hub-control.d.ts +6 -2
- package/dist/hub-control.js +8 -8
- package/dist/hub-control.js.map +1 -1
- package/dist/index.js +12 -13
- package/dist/index.js.map +1 -1
- package/dist/instance-discovery.js +2 -1
- package/dist/instance-discovery.js.map +1 -1
- package/dist/live-client.d.ts +49 -0
- package/dist/live-client.js +292 -255
- package/dist/live-client.js.map +1 -1
- package/dist/offline/api.d.ts +171 -0
- package/dist/offline/api.js +855 -0
- package/dist/offline/api.js.map +1 -0
- package/dist/offline/hierarchy.d.ts +24 -0
- package/dist/offline/hierarchy.js +242 -0
- package/dist/offline/hierarchy.js.map +1 -0
- package/dist/offline/index-builders.d.ts +11 -0
- package/dist/offline/index-builders.js +202 -0
- package/dist/offline/index-builders.js.map +1 -0
- package/dist/offline/model.d.ts +8 -0
- package/dist/offline/model.js +188 -0
- package/dist/offline/model.js.map +1 -0
- package/dist/offline/names.d.ts +7 -0
- package/dist/offline/names.js +20 -0
- package/dist/offline/names.js.map +1 -0
- package/dist/offline/overrides.d.ts +17 -0
- package/dist/offline/overrides.js +266 -0
- package/dist/offline/overrides.js.map +1 -0
- package/dist/offline/parse.d.ts +25 -0
- package/dist/offline/parse.js +236 -0
- package/dist/offline/parse.js.map +1 -0
- package/dist/offline/paths.d.ts +4 -0
- package/dist/offline/paths.js +23 -0
- package/dist/offline/paths.js.map +1 -0
- package/dist/offline/primitives.d.ts +17 -0
- package/dist/offline/primitives.js +195 -0
- package/dist/offline/primitives.js.map +1 -0
- package/dist/offline/references.d.ts +9 -0
- package/dist/offline/references.js +55 -0
- package/dist/offline/references.js.map +1 -0
- package/dist/offline/types.d.ts +65 -0
- package/dist/offline/types.js +154 -0
- package/dist/offline/types.js.map +1 -0
- package/dist/offline.d.ts +2 -180
- package/dist/offline.js +27 -2282
- package/dist/offline.js.map +1 -1
- package/dist/project-health.d.ts +1 -1
- package/dist/project-health.js +66 -3
- package/dist/project-health.js.map +1 -1
- package/dist/routers.js +6 -5
- package/dist/routers.js.map +1 -1
- package/dist/tool-router.d.ts +13 -0
- package/dist/tool-router.js +179 -251
- package/dist/tool-router.js.map +1 -1
- package/dist/tool-session-state.js +18 -9
- package/dist/tool-session-state.js.map +1 -1
- package/dist/tools/apply-fix.js +7 -2
- package/dist/tools/apply-fix.js.map +1 -1
- package/dist/tools/batch-execute.js +8 -2
- package/dist/tools/batch-execute.js.map +1 -1
- package/dist/tools/bridge-status.js +1 -2
- package/dist/tools/bridge-status.js.map +1 -1
- package/dist/tools/checkpoint-create.js +1 -0
- package/dist/tools/checkpoint-create.js.map +1 -1
- package/dist/tools/component-get.js +44 -11
- package/dist/tools/component-get.js.map +1 -1
- package/dist/tools/delta.js +1 -0
- package/dist/tools/delta.js.map +1 -1
- package/dist/tools/dependencies.js +4 -1
- package/dist/tools/dependencies.js.map +1 -1
- package/dist/tools/editor-status.js +3 -2
- package/dist/tools/editor-status.js.map +1 -1
- package/dist/tools/execute-csharp.js +3 -2
- package/dist/tools/execute-csharp.js.map +1 -1
- package/dist/tools/find-references.js +8 -1
- package/dist/tools/find-references.js.map +1 -1
- package/dist/tools/index.js +19 -17
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsystem-asset-create.js +4 -4
- package/dist/tools/inputsystem-asset-create.js.map +1 -1
- package/dist/tools/invoke-method.js +2 -1
- package/dist/tools/invoke-method.js.map +1 -1
- package/dist/tools/manage-tools.js +10 -10
- package/dist/tools/manage-tools.js.map +1 -1
- package/dist/tools/navigation-surface-add.js +6 -5
- package/dist/tools/navigation-surface-add.js.map +1 -1
- package/dist/tools/probuilder-create-shape.js +4 -4
- package/dist/tools/probuilder-create-shape.js.map +1 -1
- package/dist/tools/read-compile-errors.js +44 -31
- package/dist/tools/read-compile-errors.js.map +1 -1
- package/dist/tools/scene-get-data.js +10 -2
- package/dist/tools/scene-get-data.js.map +1 -1
- package/dist/tools/scene-save.js +16 -5
- package/dist/tools/scene-save.js.map +1 -1
- package/dist/tools/scene-set-active.js +22 -6
- package/dist/tools/scene-set-active.js.map +1 -1
- package/dist/tools/scene-unload.js +20 -6
- package/dist/tools/scene-unload.js.map +1 -1
- package/dist/tools/search-assets.js +3 -1
- package/dist/tools/search-assets.js.map +1 -1
- package/package.json +1 -1
package/dist/live-client.js
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
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
|
|
379
|
-
//
|
|
380
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
576
|
-
text: JSON.stringify(parsed),
|
|
577
|
-
},
|
|
514
|
+
{ type: "image", data: inlineImage, mimeType },
|
|
515
|
+
{ type: "text", text: JSON.stringify(metadata) },
|
|
578
516
|
],
|
|
579
|
-
isError:
|
|
517
|
+
isError: false,
|
|
580
518
|
};
|
|
581
519
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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: "
|
|
613
|
-
message: `
|
|
614
|
-
`
|
|
615
|
-
`
|
|
616
|
-
`
|
|
617
|
-
`
|
|
618
|
-
`
|
|
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 !==
|
|
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 =
|
|
896
|
+
this.baseUrl = bridgeBaseUrl(lock.port);
|
|
860
897
|
if (tokenChanged)
|
|
861
898
|
this.authToken = lock.authToken;
|
|
862
899
|
return true;
|