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
@@ -10,6 +10,7 @@ import { generateSkill } from "./skill/generate-skill.js";
10
10
  import { knownClientKeys } from "./skill/client-paths.js";
11
11
  import { ALL_TOOLS } from "./tools/index.js";
12
12
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
13
+ import { PORT_ENV_VAR } from "./constants.js";
13
14
  import { findUnityForProject } from "./running-unity.js";
14
15
  import { listInstalledEditors, fetchAvailableReleases, openInstallDeepLink, getInstallPath, setInstallPath, } from "./hub-control.js";
15
16
  /** Default Hub-control backend — calls the real hub-control functions. */
@@ -20,8 +21,50 @@ const defaultHubBackend = {
20
21
  getInstallPath: () => getInstallPath(),
21
22
  setInstallPath: (path) => setInstallPath(path),
22
23
  };
23
- import { BATCH_TOOL_NAMES } from "./batch-spawn.js";
24
+ import { BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS } from "./batch-spawn.js";
24
25
  import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, } from "./output-profile.js";
26
+ /**
27
+ * Single source of truth for the `_source` tag. Every route-tagging site goes
28
+ * through one of the two helpers below so live / offline / local stay
29
+ * symmetric — a new route adding `_source` inline (the omission that slipped
30
+ * through before T6.1) is caught by the "no inline `_source` outside the
31
+ * helper" rule and grep.
32
+ *
33
+ * - {@link withSource} stamps `_source` on a body object literal (the local /
34
+ * offline paths that build the JSON themselves).
35
+ * - {@link tagSource} stamps `_source` on an already-built {@link
36
+ * CallToolResult} (the live / offline-result paths that wrap a bridge or
37
+ * disk result).
38
+ */
39
+ function withSource(body, source) {
40
+ return { ...body, _source: source };
41
+ }
42
+ /**
43
+ * Tag a CallToolResult's JSON body with `_source` so callers switching on the
44
+ * field see a defined value for live-routed drill-downs (find_references /
45
+ * dependencies). A no-op when the first content block is not parseable JSON;
46
+ * preserves `isError` and other content blocks.
47
+ */
48
+ function tagSource(result, source) {
49
+ const body = parseResultBody(result);
50
+ if (body === null)
51
+ return result;
52
+ return withResultBody(result, withSource(body, source));
53
+ }
54
+ /**
55
+ * Build a single-block CallToolResult from a body object, tagging it with
56
+ * `_source`. The common shape for local/offline routes that synthesize the
57
+ * entire response in the MCP server (no bridge tool endpoint). Accepts the
58
+ * typed result shapes (ListAssetsResult, CapabilitiesResult, …) returned by
59
+ * the offline/local builders.
60
+ */
61
+ function sourceResult(body, source, isError = false) {
62
+ const tagged = withSource(body, source);
63
+ return {
64
+ content: [{ type: "text", text: JSON.stringify(tagged) }],
65
+ isError,
66
+ };
67
+ }
25
68
  function injectRouteMeta(result, meta) {
26
69
  if (result.content.length === 0)
27
70
  return result;
@@ -50,12 +93,7 @@ function injectRouteMeta(result, meta) {
50
93
  // missing-parameter refusals before any side effect runs). Tagged _source=local
51
94
  // because it is resolved entirely in the MCP server.
52
95
  function localError(code, message) {
53
- return {
54
- content: [
55
- { type: "text", text: JSON.stringify({ error: { code, message }, _source: "local" }) },
56
- ],
57
- isError: true,
58
- };
96
+ return sourceResult({ error: { code, message } }, "local", true);
59
97
  }
60
98
  // M26 Plan 2 — gate-consistent envelope shape for the mutating Hub control
61
99
  // tools (install_editor / install_modules / set_install_path). These are
@@ -63,7 +101,7 @@ function localError(code, message) {
63
101
  // gate is skipped (gate.skipped=true). The shape matches MutationEnvelope so
64
102
  // agents parse it uniformly alongside bridge-routed mutating tools.
65
103
  function hubMutationEnvelope(opts) {
66
- const body = {
104
+ return sourceResult({
67
105
  mutation: {
68
106
  success: opts.success,
69
107
  output: opts.output,
@@ -76,12 +114,7 @@ function hubMutationEnvelope(opts) {
76
114
  delta: null,
77
115
  },
78
116
  agentNextSteps: opts.nextSteps,
79
- _source: "local",
80
- };
81
- return {
82
- content: [{ type: "text", text: JSON.stringify(body) }],
83
- isError: opts.isError,
84
- };
117
+ }, "local", opts.isError);
85
118
  }
86
119
  function activeGroupsEqual(a, b) {
87
120
  if (a.length !== b.length)
@@ -239,6 +272,50 @@ export class ToolRouter {
239
272
  onToolListChanged;
240
273
  hubBackend;
241
274
  modelCache = new AssetModelCache();
275
+ /**
276
+ * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
277
+ * former ~70-block `if (toolName === "...")` chain in routeCore. Each entry
278
+ * binds the tool name to its route-handler method. The map is built lazily on
279
+ * first use (the handlers are bound instance methods, so it cannot be a
280
+ * static field). Adding a new named-tool route is now a map entry, not a new
281
+ * `if` block. Tools that share a handler (validate_edit + scan_paths) or are
282
+ * reached via a predicate (isCompressible) are handled below the lookup — the
283
+ * map covers only exact-name dispatch.
284
+ */
285
+ routeHandlers;
286
+ getRouteHandlers() {
287
+ if (this.routeHandlers)
288
+ return this.routeHandlers;
289
+ const handlers = new Map([
290
+ // Offline-routed tools (resolved from disk; never hit the bridge).
291
+ ["unity_open_mcp_list_assets", (_l, a) => this.routeListAssets(a)],
292
+ ["unity_open_mcp_read_compile_errors", (_l, a) => this.routeReadCompileErrors(a)],
293
+ // Local-routed tools (server-only; no live/batch hop).
294
+ ["unity_senses_pull_events", (l, a) => this.routePullEvents(a, l)],
295
+ ["unity_open_mcp_capabilities", (l, a) => this.routeCapabilities(a, l)],
296
+ ["unity_open_mcp_list_rules", (_l, a) => this.routeListRules(a)],
297
+ ["unity_open_mcp_generate_skill", (_l, a) => this.routeGenerateSkill(a)],
298
+ ["unity_open_mcp_manage_tools", (l, a) => this.routeManageTools(a, l)],
299
+ ["unity_open_mcp_bridge_status", (l, a) => this.routeBridgeStatus(a, l)],
300
+ // Hub control tools (local-routed; system-level ops).
301
+ ["unity_open_mcp_hub_list_editors", () => this.routeHubListEditors()],
302
+ ["unity_open_mcp_hub_available_releases", () => this.routeHubAvailableReleases()],
303
+ ["unity_open_mcp_hub_install_editor", (_l, a) => this.routeHubInstallEditor(a)],
304
+ ["unity_open_mcp_hub_install_modules", (_l, a) => this.routeHubInstallModules(a)],
305
+ ["unity_open_mcp_hub_get_install_path", () => this.routeHubGetInstallPath()],
306
+ ["unity_open_mcp_hub_set_install_path", (_l, a) => this.routeHubSetInstallPath(a)],
307
+ // Offline-first reference tools (fall back to live when the bridge is up).
308
+ ["unity_open_mcp_find_references", (l, a) => this.routeFindReferences(a, l)],
309
+ ["unity_open_mcp_dependencies", (l, a) => this.routeDependencies(a, l)],
310
+ // Verify tools — share a single handler (toolName carried through).
311
+ ["unity_open_mcp_validate_edit", (l, a) => this.routeVerifyResult("unity_open_mcp_validate_edit", a, l)],
312
+ ["unity_open_mcp_scan_paths", (l, a) => this.routeVerifyResult("unity_open_mcp_scan_paths", a, l)],
313
+ // Scene data — output-profile + paging applied server-side.
314
+ ["unity_open_mcp_scene_get_data", (l, a) => this.routeSceneGetData(a, l)],
315
+ ]);
316
+ this.routeHandlers = handlers;
317
+ return handlers;
318
+ }
242
319
  constructor(live, batch, projectPath, eventStream, sessionState, onToolListChanged,
243
320
  // M26 Plan 2 — injectable Hub-control backend. Omit in production (the
244
321
  // default calls the real hub-control functions); tests pass a fake to
@@ -267,101 +344,13 @@ export class ToolRouter {
267
344
  return this.routeCore(overrideLive, toolName, args);
268
345
  }
269
346
  async routeCore(live, toolName, args) {
270
- // list_assets — always offline (no live equivalent needed).
271
- if (toolName === "unity_open_mcp_list_assets") {
272
- return this.routeListAssets(args);
273
- }
274
- // read_compile_errors — always offline. Reads Unity's Editor.log directly;
275
- // the one channel that works when the bridge assembly itself has failed to
276
- // compile (every in-bridge channel is dead with it, and compile_check can't
277
- // run). Never touches the bridge or spawns Unity.
278
- if (toolName === "unity_open_mcp_read_compile_errors") {
279
- return this.routeReadCompileErrors(args);
280
- }
281
- // M13 T4.4 — bridge event stream pull. The MCP server is the only long-lived
282
- // hop between the bridge and the agent; a per-process SSE reader amortizes
283
- // the connection and shares the queue across pulls. Lives only when the
284
- // bridge is reachable; an unreachable bridge returns a clear error instead
285
- // of hanging on connect.
286
- if (toolName === "unity_senses_pull_events") {
287
- return this.routePullEvents(args, live);
288
- }
289
- // capabilities — local capability-discovery surface (no live/batch hop).
290
- if (toolName === "unity_open_mcp_capabilities") {
291
- return this.routeCapabilities(args, live);
292
- }
293
- // list_rules — local rule catalog (no live/batch hop). Lets an agent
294
- // discover which rules apply to which asset kinds before calling
295
- // scan_paths / validate_edit.
296
- if (toolName === "unity_open_mcp_list_rules") {
297
- return this.routeListRules(args);
298
- }
299
- // generate_skill — local skill generation (no live/batch hop).
300
- if (toolName === "unity_open_mcp_generate_skill") {
301
- return this.routeGenerateSkill(args);
302
- }
303
- // M18 Plan 2 / T18.2.2 — manage_tools is server-only. It mutates the
304
- // per-session tool-group visibility state that lives in the MCP server
305
- // (ToolSessionState). The bridge does not track session state — it answers
306
- // manage_tools meta-calls by reporting the compiled tool set; the MCP
307
- // server applies the activate / deactivate filter to ListTools. Always
308
- // visible regardless of the current active set (meta-tool).
309
- if (toolName === "unity_open_mcp_manage_tools") {
310
- return this.routeManageTools(args, live);
311
- }
312
- // testsuite-tauri phase-3 — bridge_status. Operator-only health snapshot.
313
- // Server-resolved (classifyInstance + one /ping via the LiveClient); never
314
- // hits the bridge tool endpoint and never spawns Unity. Returns a coarse
315
- // `status` token (running/compiling/stopped/dead_bridge) the Validation
316
- // Suite app drives its offline-scenario gate off. Read-only, gate-free.
317
- if (toolName === "unity_open_mcp_bridge_status") {
318
- return this.routeBridgeStatus(args, live);
319
- }
320
- // M26 Plan 2 — Unity Hub control tools. Local-routed: resolved inside the
321
- // MCP server (filesystem discovery + Unity archive feed + unityhub:// deep
322
- // link + Hub CLI). Never hit the bridge tool endpoint, never spawn Unity.
323
- // Mutating members are system-level ops (paths_hint N/A, gate-free).
324
- if (toolName === "unity_open_mcp_hub_list_editors") {
325
- return this.routeHubListEditors();
326
- }
327
- if (toolName === "unity_open_mcp_hub_available_releases") {
328
- return this.routeHubAvailableReleases();
329
- }
330
- if (toolName === "unity_open_mcp_hub_install_editor") {
331
- return this.routeHubInstallEditor(args);
332
- }
333
- if (toolName === "unity_open_mcp_hub_install_modules") {
334
- return this.routeHubInstallModules(args);
335
- }
336
- if (toolName === "unity_open_mcp_hub_get_install_path") {
337
- return this.routeHubGetInstallPath();
338
- }
339
- if (toolName === "unity_open_mcp_hub_set_install_path") {
340
- return this.routeHubSetInstallPath(args);
341
- }
342
- // find_references — offline-first when no bridge is connected; live
343
- // ReferenceGraph remains available when the bridge is up.
344
- if (toolName === "unity_open_mcp_find_references") {
345
- return this.routeFindReferences(args, live);
346
- }
347
- // dependencies — offline-first (M24 Plan 2 / T24.2). Forward + reverse
348
- // edges + impact analysis all compute from YAML on disk when no bridge is
349
- // connected; the live DependenciesTool remains available when the bridge
350
- // is up. The offline path is the only one that exposes include_impact
351
- // (transitive reverse closure) today.
352
- if (toolName === "unity_open_mcp_dependencies") {
353
- return this.routeDependencies(args, live);
354
- }
355
- // M22 — verify tools. The bridge returns the full issues[] list; we fold +
356
- // page server-side per the output profile (compact = counts only).
357
- if (toolName === "unity_open_mcp_validate_edit" || toolName === "unity_open_mcp_scan_paths") {
358
- return this.routeVerifyResult(toolName, args, live);
359
- }
360
- // M22 — scene_get_data. Resolves the output profile (onto detail) and pages
361
- // the node stream server-side; the bridge detail/max_nodes params keep
362
- // working as back-compat aliases.
363
- if (toolName === "unity_open_mcp_scene_get_data") {
364
- return this.routeSceneGetData(args, live);
347
+ // Named-tool dispatch (T3.2): the handler map covers every exact-name
348
+ // route. The per-tool policy rationale (offline / local / hub / offline-
349
+ // first / verify-fold / scene-paging) lives in each routeXxx method's
350
+ // header comment.
351
+ const handler = this.getRouteHandlers().get(toolName);
352
+ if (handler) {
353
+ return handler(live, args);
365
354
  }
366
355
  // Compact drill-down reads: offline-first for text-serialized assets, fall
367
356
  // back to live bridge for binary formats. The compressible-router handles
@@ -377,12 +366,27 @@ export class ToolRouter {
377
366
  // running it against a live Editor that already compiled successfully
378
367
  // would trivially report "compile_passed" and never surface the broken
379
368
  // state the tool exists to diagnose.
380
- if (toolName === "unity_open_mcp_compile_check") {
381
- console.error(`[unity-open-mcp] Route: ${toolName} -> batch (compile check always spawns fresh)`);
369
+ //
370
+ // Verify-family tools (scan_all / baseline_create / regression_check) are
371
+ // batch-only: they require the headless verify package and are NOT
372
+ // registered on the live bridge. Routing them live (the previous default
373
+ // when the bridge was up) yielded a bare `tool_not_found`. An open Editor
374
+ // holding the project lock surfaces as `editor_instance_locked` from the
375
+ // batch spawn (the expected degraded outcome — agents should read the lock
376
+ // code, not chase a live registration miss).
377
+ //
378
+ // validate_edit / scan_paths are intentionally excluded: those ARE
379
+ // registered on the live bridge and stay live-first (routeVerifyResult
380
+ // below handles their output-profile folding). The always-batch policy is
381
+ // declared once in ALWAYS_BATCH_TOOLS (tool name → reason); do not add a
382
+ // second pinned branch for a new always-batch tool — extend the map.
383
+ const alwaysBatchReason = ALWAYS_BATCH_TOOLS.get(toolName);
384
+ if (alwaysBatchReason !== undefined) {
385
+ console.error(`[unity-open-mcp] Route: ${toolName} -> batch (always-batch: ${alwaysBatchReason})`);
382
386
  const result = await this.batch.route(toolName, args);
383
387
  return injectRouteMeta(result, {
384
388
  route: "batch",
385
- fallbackReason: "compile_check_always_batch",
389
+ fallbackReason: alwaysBatchReason,
386
390
  });
387
391
  }
388
392
  if (!canBatch && !isPing) {
@@ -500,38 +504,29 @@ export class ToolRouter {
500
504
  if (stale.staleLogSuspected)
501
505
  staleLog = stale;
502
506
  }
503
- return {
504
- content: [
505
- {
506
- type: "text",
507
- text: JSON.stringify({
508
- status,
509
- unhealthy: health.unhealthy,
510
- headline: health.headline,
511
- errorCount: errors.length,
512
- errors,
513
- // Package / assembly issues from the same log tail. Empty when the
514
- // only red flags are compiler errors (the common case).
515
- issues: health.issues,
516
- issueCount: health.issues.length,
517
- logPath,
518
- tailBytes: tail.bytes,
519
- // Present ONLY when at least one cited source file is newer than
520
- // Editor.log — the log's most-recent error block may be stale and
521
- // the agent should force a recompile before trusting the errors.
522
- ...(staleLog
523
- ? {
524
- staleLogSuspected: true,
525
- staleLogHint: staleLog.hint,
526
- staleLogNewerFiles: staleLog.newerFiles,
527
- }
528
- : {}),
529
- _source: "offline",
530
- }),
531
- },
532
- ],
533
- isError: false,
534
- };
507
+ return sourceResult({
508
+ status,
509
+ unhealthy: health.unhealthy,
510
+ headline: health.headline,
511
+ errorCount: errors.length,
512
+ errors,
513
+ // Package / assembly issues from the same log tail. Empty when the
514
+ // only red flags are compiler errors (the common case).
515
+ issues: health.issues,
516
+ issueCount: health.issues.length,
517
+ logPath,
518
+ tailBytes: tail.bytes,
519
+ // Present ONLY when at least one cited source file is newer than
520
+ // Editor.log — the log's most-recent error block may be stale and
521
+ // the agent should force a recompile before trusting the errors.
522
+ ...(staleLog
523
+ ? {
524
+ staleLogSuspected: true,
525
+ staleLogHint: staleLog.hint,
526
+ staleLogNewerFiles: staleLog.newerFiles,
527
+ }
528
+ : {}),
529
+ }, "offline");
535
530
  }
536
531
  async routeListAssets(args) {
537
532
  try {
@@ -541,10 +536,7 @@ export class ToolRouter {
541
536
  maxPerFolder: typeof args.max_per_folder === "number" ? args.max_per_folder : 30,
542
537
  projectRoot: this.projectPath,
543
538
  });
544
- return {
545
- content: [{ type: "text", text: JSON.stringify({ ...result, _source: "offline" }) }],
546
- isError: false,
547
- };
539
+ return sourceResult(result, "offline");
548
540
  }
549
541
  catch (err) {
550
542
  const message = err instanceof Error ? err.message : String(err);
@@ -576,7 +568,7 @@ export class ToolRouter {
576
568
  "Ensure the Unity Editor is open with the Agent Bridge running. " +
577
569
  "The bridge port is per-project (20000 + sha256(projectPath) % 10000), not fixed — " +
578
570
  `if Unity is open, check the instance lock at ${lockPath(this.projectPath)} ` +
579
- "for the live port/pid, or set UNITY_OPEN_MCP_BRIDGE_PORT.",
571
+ `for the live port/pid, or set ${PORT_ENV_VAR}.`,
580
572
  },
581
573
  }),
582
574
  },
@@ -623,24 +615,14 @@ export class ToolRouter {
623
615
  fixes: FIX_CATALOG,
624
616
  availableBridgeTools,
625
617
  }, { kind, includePlanned });
626
- return {
627
- content: [
628
- { type: "text", text: JSON.stringify({ ...result, _source: "local" }) },
629
- ],
630
- isError: false,
631
- };
618
+ return sourceResult(result, "local");
632
619
  }
633
620
  async routeListRules(args) {
634
621
  const assetKind = typeof args.asset_kind === "string" ? args.asset_kind : undefined;
635
622
  const extension = typeof args.extension === "string" ? args.extension : undefined;
636
623
  const implementedOnly = args.implemented_only === true;
637
624
  const result = listRules({ rules: RULE_CATALOG, fixes: FIX_CATALOG }, { assetKind, extension, implementedOnly });
638
- return {
639
- content: [
640
- { type: "text", text: JSON.stringify({ ...result, _source: "local" }) },
641
- ],
642
- isError: false,
643
- };
625
+ return sourceResult(result, "local");
644
626
  }
645
627
  async routeGenerateSkill(args) {
646
628
  const write = args.write === true;
@@ -664,20 +646,11 @@ export class ToolRouter {
664
646
  clients: clients.length > 0 ? clients : undefined,
665
647
  includeWorkflow,
666
648
  });
667
- return {
668
- content: [
669
- {
670
- type: "text",
671
- text: JSON.stringify({
672
- skill: result.skill,
673
- project: result.project,
674
- written: result.written,
675
- _source: "local",
676
- }),
677
- },
678
- ],
679
- isError: false,
680
- };
649
+ return sourceResult({
650
+ skill: result.skill,
651
+ project: result.project,
652
+ written: result.written,
653
+ }, "local");
681
654
  }
682
655
  catch (err) {
683
656
  const message = err instanceof Error ? err.message : String(err);
@@ -719,7 +692,7 @@ export class ToolRouter {
719
692
  return this.manageToolsResult({
720
693
  reset: true,
721
694
  activeGroups: this.sessionState.activeGroups(),
722
- message: "Tool-group visibility restored to defaults (`core` only). " +
695
+ message: "Tool-group visibility restored to the five default-on groups. " +
723
696
  "MCP clients that support listChanged will refresh ListTools automatically.",
724
697
  });
725
698
  }
@@ -796,26 +769,17 @@ export class ToolRouter {
796
769
  tools,
797
770
  };
798
771
  });
799
- return {
800
- content: [
801
- {
802
- type: "text",
803
- text: JSON.stringify({
804
- groups,
805
- activeGroups: this.sessionState.activeGroups(),
806
- note: "Activate a group to add its tools to your ListTools surface; " +
807
- "deactivate to hide them. State is per-session and ephemeral — " +
808
- "it resets to `core`-only when the MCP server restarts. " +
809
- "Compiled-state availability (the `available` field) reflects " +
810
- "whether the Unity domain dependency is compiled in; use " +
811
- "unity_open_mcp_capabilities for the authoritative compiled-state " +
812
- "report.",
813
- _source: "local",
814
- }),
815
- },
816
- ],
817
- isError: false,
818
- };
772
+ return sourceResult({
773
+ groups,
774
+ activeGroups: this.sessionState.activeGroups(),
775
+ note: "Activate a group to add its tools to your ListTools surface; " +
776
+ "deactivate to hide them. State is per-session and ephemeral — " +
777
+ "it resets to the five default-on groups when the MCP server restarts. " +
778
+ "Compiled-state availability (the `available` field) reflects " +
779
+ "whether the Unity domain dependency is compiled in; use " +
780
+ "unity_open_mcp_capabilities for the authoritative compiled-state " +
781
+ "report.",
782
+ }, "local");
819
783
  }
820
784
  /**
821
785
  * Notify MCP clients when the filtered ListTools surface changed. Skips the
@@ -927,23 +891,10 @@ export class ToolRouter {
927
891
  return out;
928
892
  }
929
893
  manageToolsResult(body) {
930
- return {
931
- content: [
932
- { type: "text", text: JSON.stringify({ ...body, _source: "local" }) },
933
- ],
934
- isError: false,
935
- };
894
+ return sourceResult(body, "local");
936
895
  }
937
896
  manageToolsError(code, message) {
938
- return {
939
- content: [
940
- {
941
- type: "text",
942
- text: JSON.stringify({ error: { code, message }, _source: "local" }),
943
- },
944
- ],
945
- isError: true,
946
- };
897
+ return sourceResult({ error: { code, message } }, "local", true);
947
898
  }
948
899
  // testsuite-tauri phase-3 — bridge_status. Combines the instance-lock
949
900
  // classifier (the same pure function the LiveClient uses for dead-bridge
@@ -1089,15 +1040,11 @@ export class ToolRouter {
1089
1040
  }
1090
1041
  : { reachable: false },
1091
1042
  nextStep: bridgeStatusNextStep(status),
1092
- _source: "local",
1093
1043
  };
1094
1044
  // bridge_status never reports an error — even a stopped bridge is a
1095
1045
  // successful status read. _source=local because the synthesis happens in
1096
1046
  // the MCP server (no bridge tool endpoint, no batch Unity).
1097
- return {
1098
- content: [{ type: "text", text: JSON.stringify(body) }],
1099
- isError: false,
1100
- };
1047
+ return sourceResult(body, "local");
1101
1048
  }
1102
1049
  // ── M26 Plan 2 — Unity Hub control (local-routed) ────────────────
1103
1050
  //
@@ -1109,29 +1056,16 @@ export class ToolRouter {
1109
1056
  // level ops where the project-asset gate does not apply (paths_hint N/A).
1110
1057
  async routeHubListEditors() {
1111
1058
  const editors = this.hubBackend.listInstalledEditors();
1112
- const body = {
1113
- editors,
1114
- count: editors.length,
1115
- _source: "local",
1116
- };
1117
- return {
1118
- content: [{ type: "text", text: JSON.stringify(body) }],
1119
- isError: false,
1120
- };
1059
+ return sourceResult({ editors, count: editors.length }, "local");
1121
1060
  }
1122
1061
  async routeHubAvailableReleases() {
1123
1062
  const result = await this.hubBackend.fetchAvailableReleases();
1124
- const body = {
1063
+ return sourceResult({
1125
1064
  entries: result.entries,
1126
1065
  count: result.entries.length,
1127
1066
  stale: result.stale,
1128
1067
  fetchedAt: result.fetchedAt,
1129
- _source: "local",
1130
- };
1131
- return {
1132
- content: [{ type: "text", text: JSON.stringify(body) }],
1133
- isError: false,
1134
- };
1068
+ }, "local");
1135
1069
  }
1136
1070
  async routeHubInstallEditor(args) {
1137
1071
  const version = typeof args.version === "string" ? args.version : "";
@@ -1192,16 +1126,7 @@ export class ToolRouter {
1192
1126
  }
1193
1127
  async routeHubGetInstallPath() {
1194
1128
  const res = this.hubBackend.getInstallPath();
1195
- const body = {
1196
- path: res.path,
1197
- source: res.source,
1198
- error: res.error,
1199
- _source: "local",
1200
- };
1201
- return {
1202
- content: [{ type: "text", text: JSON.stringify(body) }],
1203
- isError: res.error !== null,
1204
- };
1129
+ return sourceResult({ path: res.path, source: res.source, error: res.error }, "local", res.error !== null);
1205
1130
  }
1206
1131
  async routeHubSetInstallPath(args) {
1207
1132
  const path = typeof args.path === "string" ? args.path : "";
@@ -1231,14 +1156,19 @@ export class ToolRouter {
1231
1156
  console.error("[unity-open-mcp] Route: find_references -> live");
1232
1157
  // The live bridge only honors max_results (no detail/per-file/threshold).
1233
1158
  // Ask for the full set when paging so the cursor can walk it; otherwise
1234
- // honor the legacy max_results cap.
1159
+ // honor the legacy max_results cap. The 0 here is the server-internal
1160
+ // "unlimited" sentinel (documented in the find-references schema); callers
1161
+ // never pass it — the schema declares minimum: 1 for caller-facing use.
1235
1162
  const liveArgs = {
1236
1163
  ...args,
1237
1164
  max_results: wantPaging ? 0 : (typeof args.max_results === "number" ? args.max_results : 100),
1238
1165
  };
1239
1166
  const result = await live.route("unity_open_mcp_find_references", liveArgs);
1240
1167
  const folded = foldFindReferencesLive(result, profile, wantPaging, args);
1241
- return injectRouteMeta(folded, { route: "live" });
1168
+ // T6.1 — tag _source=live so the response matches the offline path's
1169
+ // _source=offline tag (clients switching on _source get a defined value
1170
+ // for the common live case instead of undefined).
1171
+ return injectRouteMeta(tagSource(folded, "live"), { route: "live" });
1242
1172
  }
1243
1173
  console.error("[unity-open-mcp] Route: find_references -> offline (live bridge unavailable)");
1244
1174
  const assetPath = typeof args.asset_path === "string" ? args.asset_path : undefined;
@@ -1265,6 +1195,8 @@ export class ToolRouter {
1265
1195
  guid,
1266
1196
  detail,
1267
1197
  // When paging, fetch the full set; otherwise honor the legacy cap.
1198
+ // 0 is the server-internal "unlimited" sentinel (see find-references
1199
+ // schema description); the schema's caller-facing minimum is 1.
1268
1200
  maxResults: wantPaging ? 0 : (typeof args.max_results === "number" ? args.max_results : 100),
1269
1201
  maxPerFile: typeof args.max_per_file === "number" ? args.max_per_file : 5,
1270
1202
  patternThreshold: typeof args.pattern_threshold === "number" ? args.pattern_threshold : 0,
@@ -1275,12 +1207,7 @@ export class ToolRouter {
1275
1207
  const withPaging = wantPaging
1276
1208
  ? pageFindReferencesResult(result, args.page_size, typeof args.cursor === "string" ? args.cursor : undefined)
1277
1209
  : result;
1278
- return {
1279
- content: [
1280
- { type: "text", text: JSON.stringify({ ...withPaging, _source: "offline" }) },
1281
- ],
1282
- isError: false,
1283
- };
1210
+ return sourceResult(withPaging, "offline");
1284
1211
  }
1285
1212
  catch (err) {
1286
1213
  const message = err instanceof Error ? err.message : String(err);
@@ -1338,7 +1265,8 @@ export class ToolRouter {
1338
1265
  max_results: typeof args.max_results === "number" ? args.max_results : 100,
1339
1266
  };
1340
1267
  const result = await live.route("unity_open_mcp_dependencies", liveArgs);
1341
- return injectRouteMeta(result, { route: "live" });
1268
+ // T6.1 — tag _source=live for parity with the offline route's _source.
1269
+ return injectRouteMeta(tagSource(result, "live"), { route: "live" });
1342
1270
  }
1343
1271
  console.error(includeImpact && liveAvailable
1344
1272
  ? "[unity-open-mcp] Route: dependencies -> offline (include_impact is offline-only)"