unity-open-mcp 1.1.0 → 1.3.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 +33 -2
- package/dist/agent-identity.d.ts +12 -0
- package/dist/agent-identity.js +2 -0
- package/dist/agent-identity.js.map +1 -1
- package/dist/batch-spawn.d.ts +39 -0
- package/dist/batch-spawn.js +287 -69
- package/dist/batch-spawn.js.map +1 -1
- package/dist/capabilities/build-capabilities.js +2 -0
- package/dist/capabilities/build-capabilities.js.map +1 -1
- package/dist/capabilities/discovery.d.ts +51 -0
- package/dist/capabilities/discovery.js +87 -0
- package/dist/capabilities/discovery.js.map +1 -0
- package/dist/capabilities/lifecycle.d.ts +6 -0
- package/dist/capabilities/lifecycle.js +17 -1
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.js +1 -0
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/child-supervision.d.ts +6 -0
- package/dist/child-supervision.js +12 -0
- package/dist/child-supervision.js.map +1 -1
- package/dist/cli/args.d.ts +24 -2
- package/dist/cli/args.js +97 -2
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/cli.js +42 -3
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/help-text.d.ts +1 -0
- package/dist/cli/help-text.js +71 -3
- package/dist/cli/help-text.js.map +1 -1
- package/dist/cli/json-guards.d.ts +2 -0
- package/dist/cli/json-guards.js +5 -0
- package/dist/cli/json-guards.js.map +1 -0
- package/dist/cli/setup-command.d.ts +88 -0
- package/dist/cli/setup-command.js +444 -0
- package/dist/cli/setup-command.js.map +1 -0
- package/dist/cli/update-command.d.ts +30 -0
- package/dist/cli/update-command.js +271 -0
- package/dist/cli/update-command.js.map +1 -0
- package/dist/compile-verify.d.ts +3 -0
- package/dist/compile-verify.js +4 -0
- package/dist/compile-verify.js.map +1 -1
- package/dist/constants.d.ts +10 -2
- package/dist/constants.js +10 -2
- package/dist/constants.js.map +1 -1
- package/dist/dialog-dismiss.d.ts +108 -12
- package/dist/dialog-dismiss.js +294 -109
- package/dist/dialog-dismiss.js.map +1 -1
- package/dist/dialog-policy.d.ts +55 -9
- package/dist/dialog-policy.js +85 -15
- package/dist/dialog-policy.js.map +1 -1
- package/dist/editor-search.d.ts +2 -0
- package/dist/editor-search.js +8 -0
- package/dist/editor-search.js.map +1 -0
- package/dist/gate-error.d.ts +3 -0
- package/dist/gate-error.js.map +1 -1
- package/dist/index.js +55 -1
- package/dist/index.js.map +1 -1
- package/dist/instance-discovery.d.ts +16 -0
- package/dist/instance-discovery.js +45 -0
- package/dist/instance-discovery.js.map +1 -1
- package/dist/jobs/adapters.d.ts +15 -0
- package/dist/jobs/adapters.js +147 -0
- package/dist/jobs/adapters.js.map +1 -0
- package/dist/jobs/handler.d.ts +3 -0
- package/dist/jobs/handler.js +29 -0
- package/dist/jobs/handler.js.map +1 -0
- package/dist/jobs/job-manager.d.ts +123 -0
- package/dist/jobs/job-manager.js +290 -0
- package/dist/jobs/job-manager.js.map +1 -0
- package/dist/live-client.d.ts +50 -2
- package/dist/live-client.js +221 -26
- package/dist/live-client.js.map +1 -1
- package/dist/local-tools.d.ts +17 -0
- package/dist/local-tools.js +47 -0
- package/dist/local-tools.js.map +1 -0
- package/dist/process-diagnostics.d.ts +47 -20
- package/dist/process-diagnostics.js +30 -15
- package/dist/process-diagnostics.js.map +1 -1
- package/dist/project-path.d.ts +86 -0
- package/dist/project-path.js +159 -0
- package/dist/project-path.js.map +1 -0
- package/dist/release-lookup.d.ts +32 -0
- package/dist/release-lookup.js +147 -0
- package/dist/release-lookup.js.map +1 -0
- package/dist/routers.d.ts +17 -3
- package/dist/routers.js +28 -8
- package/dist/routers.js.map +1 -1
- package/dist/running-unity.d.ts +21 -0
- package/dist/running-unity.js +50 -0
- package/dist/running-unity.js.map +1 -1
- package/dist/schema-defaults.d.ts +4 -3
- package/dist/schema-defaults.js +20 -6
- package/dist/schema-defaults.js.map +1 -1
- package/dist/server-instructions.js +7 -1
- package/dist/server-instructions.js.map +1 -1
- package/dist/server.d.ts +10 -3
- package/dist/server.js +24 -25
- package/dist/server.js.map +1 -1
- package/dist/setup/portable-config.d.ts +60 -0
- package/dist/setup/portable-config.js +249 -0
- package/dist/setup/portable-config.js.map +1 -0
- package/dist/skill/SKILL.md +68 -0
- package/dist/skill/copy-references.d.ts +2 -0
- package/dist/skill/copy-references.js +16 -0
- package/dist/skill/copy-references.js.map +1 -0
- package/dist/skill/generate-skill.js +5 -3
- package/dist/skill/generate-skill.js.map +1 -1
- package/dist/skill/references/batch-and-gates.md +142 -0
- package/dist/skill/references/compile-and-safe-mode.md +163 -0
- package/dist/skill/references/discovery-and-groups.md +30 -0
- package/dist/skill/references/routing-and-lifecycle.md +62 -0
- package/dist/skill/references/senses-and-tests.md +41 -0
- package/dist/skill/references/yaml-and-offline-work.md +33 -0
- package/dist/templates/mcp-wrapper.sh +35 -0
- package/dist/tool-contract.d.ts +8 -0
- package/dist/tool-contract.js +253 -0
- package/dist/tool-contract.js.map +1 -0
- package/dist/tool-router.d.ts +62 -7
- package/dist/tool-router.js +535 -61
- package/dist/tool-router.js.map +1 -1
- package/dist/tool-session-state.d.ts +6 -0
- package/dist/tool-session-state.js +14 -0
- package/dist/tool-session-state.js.map +1 -1
- package/dist/tools/agent-capabilities.js +9 -0
- package/dist/tools/agent-capabilities.js.map +1 -1
- package/dist/tools/batch-execute.js +17 -14
- package/dist/tools/batch-execute.js.map +1 -1
- package/dist/tools/bridge-status.js +4 -5
- package/dist/tools/bridge-status.js.map +1 -1
- package/dist/tools/compile-check.js +6 -4
- package/dist/tools/compile-check.js.map +1 -1
- package/dist/tools/component-get.js +2 -2
- package/dist/tools/component-get.js.map +1 -1
- package/dist/tools/component-modify.js +4 -4
- package/dist/tools/component-modify.js.map +1 -1
- package/dist/tools/execute-csharp.js +8 -4
- package/dist/tools/execute-csharp.js.map +1 -1
- package/dist/tools/gameobject-modify.js +2 -1
- package/dist/tools/gameobject-modify.js.map +1 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +10 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsim-key.js +12 -14
- package/dist/tools/inputsim-key.js.map +1 -1
- package/dist/tools/inputsim-pointer.js +3 -3
- package/dist/tools/inputsim-pointer.js.map +1 -1
- package/dist/tools/inputsim-step.js +7 -8
- package/dist/tools/inputsim-step.js.map +1 -1
- package/dist/tools/inputsim-touch.js +9 -10
- package/dist/tools/inputsim-touch.js.map +1 -1
- package/dist/tools/jobs.d.ts +40 -0
- package/dist/tools/jobs.js +18 -0
- package/dist/tools/jobs.js.map +1 -0
- package/dist/tools/manage-tools.js +10 -5
- package/dist/tools/manage-tools.js.map +1 -1
- package/dist/tools/project-commands.d.ts +40 -0
- package/dist/tools/project-commands.js +23 -0
- package/dist/tools/project-commands.js.map +1 -0
- package/dist/tools/read-compile-errors.js +14 -93
- package/dist/tools/read-compile-errors.js.map +1 -1
- package/dist/tools/resource-pressure.js +27 -17
- package/dist/tools/resource-pressure.js.map +1 -1
- package/dist/tools/scan-paths.js +4 -4
- package/dist/tools/scan-paths.js.map +1 -1
- package/dist/tools/schema-fragments.d.ts +15 -3
- package/dist/tools/schema-fragments.js +23 -13
- package/dist/tools/schema-fragments.js.map +1 -1
- package/dist/tools/upgrade.d.ts +40 -0
- package/dist/tools/upgrade.js +55 -0
- package/dist/tools/upgrade.js.map +1 -0
- package/dist/unity-install-discovery.d.ts +24 -0
- package/dist/unity-install-discovery.js +113 -4
- package/dist/unity-install-discovery.js.map +1 -1
- package/package.json +4 -3
- package/templates/mcp-wrapper.sh +35 -0
package/dist/tool-router.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
import { testRunOperation, projectCommandOperation } from "./jobs/adapters.js";
|
|
2
|
+
import { JobManager, JobManagerError } from "./jobs/job-manager.js";
|
|
3
|
+
import { handleJobs } from "./jobs/handler.js";
|
|
4
|
+
import { PROCESS_AGENT_ID, PROCESS_IDENTITY } from "./agent-identity.js";
|
|
5
|
+
import { buildEditorSearch } from "./editor-search.js";
|
|
6
|
+
import { withSchemaDefaults } from "./schema-defaults.js";
|
|
7
|
+
import { validateSchema, wireArguments } from "./tool-contract.js";
|
|
8
|
+
import { discoverTools, compactDiscoverySchema } from "./capabilities/discovery.js";
|
|
9
|
+
import { PingCache } from "./ping-cache.js";
|
|
10
|
+
import { resolveProjectPath, validateUnityProjectRoot } from "./project-path.js";
|
|
11
|
+
import { resolvePort, resolveAuthToken } from "./instance-discovery.js";
|
|
12
|
+
import { skipsReloadProbe } from "./local-tools.js";
|
|
13
|
+
import { confirmBridgeReleaseTag, resolveLatestVersion } from "./release-lookup.js";
|
|
14
|
+
import { LiveClient } from "./live-client.js";
|
|
1
15
|
import { AssetModelCache, isCompressible, routeCompressible } from "./compressible-router.js";
|
|
2
16
|
import { listAssetsOffline, findReferencesOffline, dependenciesOffline } from "./offline.js";
|
|
3
17
|
import { resolveEditorLogPath, readLogTail, DEFAULT_LOG_TAIL_BYTES, detectStaleLog, detectStaleAssembly, detectErrorsPredatingEdits, parseLogAuthorship, compareLogAuthorship } from "./unity-log.js";
|
|
@@ -53,7 +67,8 @@ const defaultHubBackend = {
|
|
|
53
67
|
getInstallPath: () => getInstallPath(),
|
|
54
68
|
setInstallPath: (path) => setInstallPath(path),
|
|
55
69
|
};
|
|
56
|
-
import { BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS } from "./batch-spawn.js";
|
|
70
|
+
import { BatchSpawn, BATCH_TOOL_NAMES, ALWAYS_BATCH_TOOLS, activeHeadlessRun } from "./batch-spawn.js";
|
|
71
|
+
import { filterVisibleTools } from "./tool-session-state.js";
|
|
57
72
|
import { readProfileAndDetail, applyPaging, attachPagination, foldVerifyResult, parseResultBody, withResultBody, isOutputProfile, } from "./output-profile.js";
|
|
58
73
|
/**
|
|
59
74
|
* Single source of truth for the `_source` tag. Every route-tagging site goes
|
|
@@ -186,6 +201,17 @@ function transformLiveResult(result, source, meta, transform) {
|
|
|
186
201
|
function localError(code, message) {
|
|
187
202
|
return sourceResult({ error: { code, message } }, "local", true);
|
|
188
203
|
}
|
|
204
|
+
const defaultReleaseLookup = {
|
|
205
|
+
latest: () => resolveLatestVersion(globalThis.fetch.bind(globalThis)),
|
|
206
|
+
confirmBridgeTag: (version) => confirmBridgeReleaseTag(version, globalThis.fetch.bind(globalThis)),
|
|
207
|
+
};
|
|
208
|
+
let releaseLookup = defaultReleaseLookup;
|
|
209
|
+
/** Test seam for the upgrade route's network lookups. Returns a restore function. */
|
|
210
|
+
export function setReleaseLookupForTest(lookup) {
|
|
211
|
+
const previous = releaseLookup;
|
|
212
|
+
releaseLookup = lookup ?? defaultReleaseLookup;
|
|
213
|
+
return () => { releaseLookup = previous; };
|
|
214
|
+
}
|
|
189
215
|
// M26 Plan 2 — gate-consistent envelope shape for the mutating Hub control
|
|
190
216
|
// tools (install_editor / install_modules / set_install_path). These are
|
|
191
217
|
// system-level ops, not project-asset mutations, so paths_hint is N/A and the
|
|
@@ -341,7 +367,7 @@ function groupLiveRefsByKind(paths) {
|
|
|
341
367
|
return counts;
|
|
342
368
|
}
|
|
343
369
|
// Operator-facing "what to do next" hint for each coarse status. Kept short
|
|
344
|
-
// and action-oriented so
|
|
370
|
+
// and action-oriented so an operator UI can render it inline.
|
|
345
371
|
function bridgeStatusNextStep(status, wedge) {
|
|
346
372
|
if (status === "wedged") {
|
|
347
373
|
return wedge?.reason === "main_thread_wedged"
|
|
@@ -392,6 +418,57 @@ function bridgeStatusNextStep(status, wedge) {
|
|
|
392
418
|
"Bridge) and confirm it is started.";
|
|
393
419
|
}
|
|
394
420
|
}
|
|
421
|
+
// This server's own headless batch run holds the project. The Unity process
|
|
422
|
+
// scan deliberately skips that child (it is not an Editor the user owns), so
|
|
423
|
+
// Editor-ownership surfaces would otherwise report "no Unity running" and send
|
|
424
|
+
// the agent to relaunch via the Hub, which would collide with the run.
|
|
425
|
+
function describeHeadlessRun(run, now = Date.now()) {
|
|
426
|
+
return {
|
|
427
|
+
tool: run.tool,
|
|
428
|
+
operation: run.operation,
|
|
429
|
+
pid: run.pid,
|
|
430
|
+
startedAt: new Date(run.startedAt).toISOString(),
|
|
431
|
+
elapsedMs: Math.max(0, now - run.startedAt),
|
|
432
|
+
timeoutMs: run.timeoutMs,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function headlessRunSentence(run, now = Date.now()) {
|
|
436
|
+
const elapsedS = Math.round(Math.max(0, now - run.startedAt) / 1000);
|
|
437
|
+
const timeoutS = Math.round(run.timeoutMs / 1000);
|
|
438
|
+
return (`The only Unity process for this project is this MCP server's own headless ` +
|
|
439
|
+
`run for ${run.tool}` +
|
|
440
|
+
(run.pid !== null ? ` (pid ${run.pid})` : "") +
|
|
441
|
+
`, started ${elapsedS}s ago with a ${timeoutS}s timeout. It is not an ` +
|
|
442
|
+
`interactive Editor, and it exits on its own when the operation finishes ` +
|
|
443
|
+
`or the timeout elapses.`);
|
|
444
|
+
}
|
|
445
|
+
const HEADLESS_RUN_NEXT_STEPS = [
|
|
446
|
+
"Wait for the pending headless call to return; it releases the project " +
|
|
447
|
+
"when it finishes or when its timeout elapses.",
|
|
448
|
+
"Do not relaunch Unity for this project via the Hub meanwhile: Unity " +
|
|
449
|
+
"allows one Editor per project, so a second instance collides with the " +
|
|
450
|
+
"headless run's project lock.",
|
|
451
|
+
];
|
|
452
|
+
function headlessRunErrorResult(action, run, projectPath) {
|
|
453
|
+
const now = Date.now();
|
|
454
|
+
const tail = action === "restart_editor"
|
|
455
|
+
? " restart_editor never targets the server's own headless run, so there is no Editor to restart."
|
|
456
|
+
: " resource_pressure watches the interactive Editor's descriptor leak, which a short-lived headless run does not accumulate.";
|
|
457
|
+
return sourceResult({
|
|
458
|
+
error: {
|
|
459
|
+
code: "headless_run_in_progress",
|
|
460
|
+
message: headlessRunSentence(run, now) + tail,
|
|
461
|
+
projectPath,
|
|
462
|
+
...describeHeadlessRun(run, now),
|
|
463
|
+
},
|
|
464
|
+
agentNextSteps: [
|
|
465
|
+
...HEADLESS_RUN_NEXT_STEPS,
|
|
466
|
+
action === "restart_editor"
|
|
467
|
+
? "Call restart_editor again only if an interactive Editor for this project hangs after the run completes."
|
|
468
|
+
: "Once the run completes and an interactive Editor is open, call resource_pressure again; pass an explicit pid to sample the headless process anyway.",
|
|
469
|
+
],
|
|
470
|
+
}, "local", true);
|
|
471
|
+
}
|
|
395
472
|
/**
|
|
396
473
|
* specs/feedback.md 2026-08-24 — wire-contract staleness block.
|
|
397
474
|
*
|
|
@@ -471,6 +548,60 @@ export class ToolRouter {
|
|
|
471
548
|
onToolListChanged;
|
|
472
549
|
hubBackend;
|
|
473
550
|
globalLogPathOverride;
|
|
551
|
+
jobManager;
|
|
552
|
+
get jobs() { return this.jobManager ??= new JobManager(); }
|
|
553
|
+
closeJobs() { this.jobManager?.close(); }
|
|
554
|
+
async routeJobs(args, identity = PROCESS_IDENTITY) {
|
|
555
|
+
const definition = ALL_TOOLS.find(t => t.name === "unity_open_mcp_jobs");
|
|
556
|
+
const errors = validateSchema(args, definition.inputSchema);
|
|
557
|
+
if (errors.length)
|
|
558
|
+
return localError("invalid_arguments", errors.join("; "));
|
|
559
|
+
const owner = { project: this.projectPath || "default", agent: identity.agent,
|
|
560
|
+
...(identity.port === undefined ? {} : { port: identity.port }) };
|
|
561
|
+
try {
|
|
562
|
+
// A port override names a bridge, not a project: bind the job client to
|
|
563
|
+
// the Editor whose lock claims that port (its bearer token and its lock
|
|
564
|
+
// diagnostics), exactly like a direct override call. resolveAuthToken
|
|
565
|
+
// yields no token for an explicit port, and the default project's lock
|
|
566
|
+
// would describe the wrong Editor.
|
|
567
|
+
const clientFor = (jobOwner) => jobOwner.port === undefined
|
|
568
|
+
? jobOwner.agent === PROCESS_AGENT_ID ? this.live : this.live.forAgent(jobOwner.agent)
|
|
569
|
+
: LiveClient.forPortOverride(jobOwner.port, this.projectPath, jobOwner.agent);
|
|
570
|
+
if (args.action === "start" && typeof args.tool_or_command === "string") {
|
|
571
|
+
const name = args.tool_or_command, key = args.idempotency_key;
|
|
572
|
+
if (name === "unity_senses_run_tests") {
|
|
573
|
+
if (!this.jobs.hasOperation(name))
|
|
574
|
+
this.jobs.register(name, testRunOperation(clientFor));
|
|
575
|
+
}
|
|
576
|
+
else if (name.startsWith("project.") && !(typeof key === "string" && this.jobs.hasKey(owner, key))) {
|
|
577
|
+
// A retry whose key already names a job gets that job (or
|
|
578
|
+
// idempotency_conflict) from start() without the catalog, which may
|
|
579
|
+
// be unreachable mid-reload or declare a contract changed since.
|
|
580
|
+
// Re-read the catalog on every new start: a domain reload can change the
|
|
581
|
+
// command's async/cancellable declaration, and an adapter frozen at
|
|
582
|
+
// first registration would refuse the job for the rest of the session.
|
|
583
|
+
const catalog = await clientFor(owner).projectCommands({ action: "describe", id: name });
|
|
584
|
+
if (catalog.error) {
|
|
585
|
+
// A transport/catalog failure (bridge down, reloading, too old) is
|
|
586
|
+
// not "this command is unsupported": surface the catalog's own
|
|
587
|
+
// code so the agent retries instead of abandoning the plan.
|
|
588
|
+
const failure = catalog.error;
|
|
589
|
+
throw new JobManagerError(failure.code ?? "catalog_unavailable", failure.message ?? "Project command catalog is unavailable.");
|
|
590
|
+
}
|
|
591
|
+
const command = catalog.command;
|
|
592
|
+
if (!command?.available || !command.async)
|
|
593
|
+
throw new JobManagerError("job_operation_unsupported", "Target must be an available async project command.");
|
|
594
|
+
const cancellable = command.cancellable === true;
|
|
595
|
+
if (this.jobs.operation(name)?.cancellable !== cancellable)
|
|
596
|
+
this.jobs.register(name, projectCommandOperation(name, cancellable, clientFor), true);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return sourceResult(await handleJobs(this.jobs, owner, args), "local");
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
return localError(error instanceof JobManagerError ? error.code : "invalid_arguments", error instanceof Error ? error.message : String(error));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
474
605
|
modelCache = new AssetModelCache();
|
|
475
606
|
/**
|
|
476
607
|
* M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
|
|
@@ -487,15 +618,24 @@ export class ToolRouter {
|
|
|
487
618
|
if (this.routeHandlers)
|
|
488
619
|
return this.routeHandlers;
|
|
489
620
|
const handlers = new Map([
|
|
490
|
-
//
|
|
621
|
+
// Disk reads and live compiler evidence with an offline log fallback.
|
|
491
622
|
["unity_open_mcp_list_assets", (_l, a) => this.routeListAssets(a)],
|
|
492
|
-
["unity_open_mcp_read_compile_errors", (
|
|
623
|
+
["unity_open_mcp_read_compile_errors", (l, a) => this.routeReadCompileErrors(a, l)],
|
|
493
624
|
// Local-routed tools (server-only; no live/batch hop).
|
|
494
625
|
["unity_senses_pull_events", (l, a) => this.routePullEvents(a, l)],
|
|
495
626
|
["unity_open_mcp_capabilities", (l, a) => this.routeCapabilities(a, l)],
|
|
496
627
|
["unity_open_mcp_list_rules", (_l, a) => this.routeListRules(a)],
|
|
497
628
|
["unity_open_mcp_generate_skill", (_l, a) => this.routeGenerateSkill(a)],
|
|
498
|
-
["unity_open_mcp_manage_tools", (l, a) => this.routeManageTools(a, l)],
|
|
629
|
+
["unity_open_mcp_manage_tools", (l, a, id) => this.routeManageTools(a, l, id)],
|
|
630
|
+
["unity_open_mcp_project_commands", (l, a) => this.routeProjectCommands(a, l)],
|
|
631
|
+
// Live tool whose release lookups (npm latest, GitHub tag) run HERE so
|
|
632
|
+
// the Editor main thread never blocks on the network.
|
|
633
|
+
["unity_open_mcp_upgrade", (l, a) => this.routeUpgrade(a, l)],
|
|
634
|
+
// The owner is the request identity (agent id + per-request port
|
|
635
|
+
// override), never the live client's: the default client always sends
|
|
636
|
+
// the process agent id, whatever _meta.agentId the call carried. Direct,
|
|
637
|
+
// manage_tools(invoke) and port-override calls therefore share one owner.
|
|
638
|
+
["unity_open_mcp_jobs", (_l, a, id) => this.routeJobs(a, id)],
|
|
499
639
|
["unity_open_mcp_bridge_status", (l, a) => this.routeBridgeStatus(a, l)],
|
|
500
640
|
// M31 Plan 3 — Editor fd-exhaustion operator surfaces. restart_editor
|
|
501
641
|
// is the reactive kill half (acts AFTER the Editor is hung);
|
|
@@ -545,28 +685,132 @@ export class ToolRouter {
|
|
|
545
685
|
this.hubBackend = hubBackend;
|
|
546
686
|
this.globalLogPathOverride = globalLogPathOverride;
|
|
547
687
|
}
|
|
548
|
-
async route(toolName, args) {
|
|
549
|
-
return this.routeCore(this.live, toolName, args);
|
|
688
|
+
async route(toolName, args, identity = PROCESS_IDENTITY) {
|
|
689
|
+
return this.routeCore(this.live, toolName, args, identity);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* `capabilities` / `bridge_status` with an explicit project_path, from any
|
|
693
|
+
* entry point (direct, manage_tools invoke, port override). The probe router
|
|
694
|
+
* gets the arguments without project_path, so it dispatches normally. A
|
|
695
|
+
* per-request port override is refused rather than dropped or mixed in:
|
|
696
|
+
* project_path brings its own port/auth discovery, and pinging the override
|
|
697
|
+
* port would present another bridge's answer as the probed project's.
|
|
698
|
+
*/
|
|
699
|
+
async routeProjectProbe(toolName, args, identity) {
|
|
700
|
+
if (typeof args.project_path !== "string" || !args.project_path.trim())
|
|
701
|
+
return localError("invalid_project_path", "project_path must name a Unity project root.");
|
|
702
|
+
if (identity.port !== undefined)
|
|
703
|
+
return localError("invalid_arguments", "project_path discovers that project's own bridge port; drop the _meta.port (or port) override for this call.");
|
|
704
|
+
const selected = resolveProjectPath({ flagPath: args.project_path }).absolute;
|
|
705
|
+
if (!validateUnityProjectRoot(selected).valid)
|
|
706
|
+
return localError("invalid_project_path", "project_path must contain Assets, Packages, and ProjectSettings.");
|
|
707
|
+
const { project_path: _selected, ...rest } = args;
|
|
708
|
+
return this.projectProbeRouter(selected).route(toolName, rest, identity);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Router for a read-only probe of another project: its own port/auth
|
|
712
|
+
* discovery and a throwaway copy of this session. Discovery still reconciles
|
|
713
|
+
* auto-activation against the probed project's inventory, so the probe
|
|
714
|
+
* reports that project's view, but only the copy changes — this session's
|
|
715
|
+
* active groups and tools/list stay put, and no list_changed is wired. The
|
|
716
|
+
* probe tools are local-only and never spawn Unity; the probe's own batch
|
|
717
|
+
* router only answers headless availability for that project's editor.
|
|
718
|
+
*/
|
|
719
|
+
projectProbeRouter(projectPath) {
|
|
720
|
+
const live = new LiveClient(resolvePort(projectPath), new PingCache(), resolveAuthToken(projectPath), projectPath);
|
|
721
|
+
return new ToolRouter(live, this.projectProbeBatch(projectPath), projectPath, this.eventStream, this.sessionState.clone());
|
|
722
|
+
}
|
|
723
|
+
// Batch routers of probed projects, keyed by project path and bounded to
|
|
724
|
+
// the most recently probed few. Constructing a BatchSpawn walks every Hub
|
|
725
|
+
// install root synchronously; a polled probe must not pay that on every
|
|
726
|
+
// call, and the router keeps its own editor-resolution cache (revalidated
|
|
727
|
+
// when the project version or the env inputs change).
|
|
728
|
+
static PROBE_BATCH_CACHE_SIZE = 8;
|
|
729
|
+
probeBatchByProject = new Map();
|
|
730
|
+
projectProbeBatch(projectPath) {
|
|
731
|
+
const cached = this.probeBatchByProject.get(projectPath);
|
|
732
|
+
if (cached) {
|
|
733
|
+
// Re-insert so the map keeps insertion order as recency order.
|
|
734
|
+
this.probeBatchByProject.delete(projectPath);
|
|
735
|
+
this.probeBatchByProject.set(projectPath, cached);
|
|
736
|
+
return cached;
|
|
737
|
+
}
|
|
738
|
+
const batch = new BatchSpawn({ projectPath });
|
|
739
|
+
this.probeBatchByProject.set(projectPath, batch);
|
|
740
|
+
while (this.probeBatchByProject.size > ToolRouter.PROBE_BATCH_CACHE_SIZE) {
|
|
741
|
+
const oldest = this.probeBatchByProject.keys().next().value;
|
|
742
|
+
if (oldest === undefined)
|
|
743
|
+
break;
|
|
744
|
+
this.probeBatchByProject.delete(oldest);
|
|
745
|
+
}
|
|
746
|
+
return batch;
|
|
550
747
|
}
|
|
551
748
|
/**
|
|
552
749
|
* M23 Plan 3 — route with a per-call live-client override. Used when a
|
|
553
750
|
* per-request `_meta.port` override targets a different bridge instance than
|
|
554
751
|
* the default. The override client bypasses shared session state (it is a
|
|
555
752
|
* fresh LiveClient aimed at the override port); all other routing logic is
|
|
556
|
-
* identical to {@link route}.
|
|
557
|
-
*
|
|
753
|
+
* identical to {@link route}. `identity` carries the override port and the
|
|
754
|
+
* request's agent id.
|
|
558
755
|
*/
|
|
559
|
-
async routeOverride(toolName, args, overrideLive) {
|
|
560
|
-
return this.routeCore(overrideLive, toolName, args);
|
|
756
|
+
async routeOverride(toolName, args, overrideLive, identity) {
|
|
757
|
+
return this.routeCore(overrideLive, toolName, args, identity);
|
|
561
758
|
}
|
|
562
|
-
async routeCore(live, toolName, args) {
|
|
759
|
+
async routeCore(live, toolName, args, identity) {
|
|
760
|
+
// Every entry point funnels through here, so an explicit project_path is
|
|
761
|
+
// retargeted before validation and wiring wherever the call came from.
|
|
762
|
+
if ((toolName === "unity_open_mcp_capabilities" || toolName === "unity_open_mcp_bridge_status") && args.project_path !== undefined)
|
|
763
|
+
return this.routeProjectProbe(toolName, args, identity);
|
|
764
|
+
const definition = ALL_TOOLS.find(t => t.name === toolName);
|
|
765
|
+
if (definition) {
|
|
766
|
+
const errors = validateSchema(args, definition.inputSchema);
|
|
767
|
+
if (errors.length)
|
|
768
|
+
return sourceResult({ error: { code: "invalid_arguments", message: errors.join("; ") }, errors, _route: { route: "local" } }, "local", true);
|
|
769
|
+
const notes = [];
|
|
770
|
+
const wire = wireArguments(args, definition.inputSchema, notes);
|
|
771
|
+
if (toolName === "unity_open_mcp_batch_execute" && Array.isArray(wire.commands)) {
|
|
772
|
+
wire.commands = wire.commands.map(command => {
|
|
773
|
+
const nested = ALL_TOOLS.find(t => t.name === command.tool);
|
|
774
|
+
return nested
|
|
775
|
+
? { ...command, params: wireArguments(command.params ?? {}, nested.inputSchema, notes) }
|
|
776
|
+
: command;
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
// Recursive dispatch only after validation; alias notes are attached to the normal result.
|
|
780
|
+
return this.routeValidated(live, toolName, wire, notes, identity);
|
|
781
|
+
}
|
|
782
|
+
return this.routeValidated(live, toolName, args, [], identity);
|
|
783
|
+
}
|
|
784
|
+
async routeValidated(live, toolName, args, notes, identity) {
|
|
785
|
+
const result = await this.routeUnchecked(live, toolName, args, identity);
|
|
786
|
+
if (notes.length)
|
|
787
|
+
result.content.push({ type: "text", text: JSON.stringify({ deprecations: [...new Set(notes)] }) });
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
async routeUnchecked(live, toolName, args, identity) {
|
|
791
|
+
const localProbe = skipsReloadProbe(toolName);
|
|
792
|
+
const lock = !localProbe && this.projectPath ? readInstanceLock(this.projectPath) : null;
|
|
793
|
+
// The lock describes the default project's Editor. A per-request port
|
|
794
|
+
// override aimed elsewhere has no lock of its own (override clients never
|
|
795
|
+
// read locks) and its LiveClient handles that bridge's readiness, so the
|
|
796
|
+
// default Editor's reload must not refuse it. An override naming the
|
|
797
|
+
// default Editor's own port still targets the reloading Editor.
|
|
798
|
+
const targetsLockedEditor = identity.port === undefined || identity.port === lock?.port;
|
|
799
|
+
if (targetsLockedEditor && classifyInstance(lock) === "reloading") {
|
|
800
|
+
// One bounded re-probe. No headless fallback can acquire this Editor's project.
|
|
801
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
802
|
+
const available = await live.isLiveAvailable(750);
|
|
803
|
+
if (!available || classifyInstance(readInstanceLock(this.projectPath)) === "reloading") {
|
|
804
|
+
return sourceResult({ error: { code: "editor_reloading", message: "The live Editor is compiling or reloading. Retry after it settles." }, retryAfterMs: 1000 }, "local", true);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
563
807
|
// Named-tool dispatch (T3.2): the handler map covers every exact-name
|
|
564
808
|
// route. The per-tool policy rationale (offline / local / hub / offline-
|
|
565
809
|
// first / verify-fold / scene-paging) lives in each routeXxx method's
|
|
566
810
|
// header comment.
|
|
567
811
|
const handler = this.getRouteHandlers().get(toolName);
|
|
568
812
|
if (handler) {
|
|
569
|
-
return handler(live, args);
|
|
813
|
+
return handler(live, args, identity);
|
|
570
814
|
}
|
|
571
815
|
// Compact drill-down reads: offline-first for text-serialized assets, fall
|
|
572
816
|
// back to live bridge for binary formats. The compressible-router handles
|
|
@@ -713,7 +957,7 @@ export class ToolRouter {
|
|
|
713
957
|
return meta;
|
|
714
958
|
}
|
|
715
959
|
}
|
|
716
|
-
async routeReadCompileErrors(args) {
|
|
960
|
+
async routeReadCompileErrors(args, live) {
|
|
717
961
|
const tailBytes = typeof args.tail_bytes === "number" && args.tail_bytes >= 4096
|
|
718
962
|
? Math.min(Math.floor(args.tail_bytes), 1048576)
|
|
719
963
|
: DEFAULT_LOG_TAIL_BYTES;
|
|
@@ -731,6 +975,40 @@ export class ToolRouter {
|
|
|
731
975
|
const resolvedLog = resolveEditorLogPath(this.projectPath, undefined, livePid, this.globalLogPathOverride);
|
|
732
976
|
const logPath = resolvedLog.path;
|
|
733
977
|
const tail = readLogTail(logPath, tailBytes);
|
|
978
|
+
const compile = await live.readCompileState?.();
|
|
979
|
+
if (compile && compile.status !== "indeterminate") {
|
|
980
|
+
const historical = summarizeProjectHealth(tail.content);
|
|
981
|
+
const failed = compile.status === "compile_failed";
|
|
982
|
+
const pipelineErrors = failed && Array.isArray(compile.errors) ? compile.errors : [];
|
|
983
|
+
// Unity can report a failed compile the bridge never observed (it ran
|
|
984
|
+
// before the bridge domain loaded, e.g. the Editor-startup compile), so
|
|
985
|
+
// the pipeline holds no diagnostics for it. Editor.log does: promote its
|
|
986
|
+
// errors instead of answering "failed" with nothing to fix.
|
|
987
|
+
const fromLog = failed && pipelineErrors.length === 0;
|
|
988
|
+
const currentErrors = fromLog ? historical.compilerErrors : pipelineErrors;
|
|
989
|
+
return sourceResult({
|
|
990
|
+
...compile, errorCount: currentErrors.length, errors: currentErrors,
|
|
991
|
+
errorSource: fromLog ? "Editor.log" : "CompilationPipeline",
|
|
992
|
+
unhealthy: failed,
|
|
993
|
+
staleAssembly: compile.status === "assembly_stale",
|
|
994
|
+
headline: compile.status === "no_errors_found" ? "Current compile is clean; source content matches the completed generation."
|
|
995
|
+
: compile.status === "currently_compiling" ? "Compilation is active; wait for completion."
|
|
996
|
+
: compile.status === "assembly_stale" ? "Source content changed after the last compile; recompile before trusting assemblies."
|
|
997
|
+
: !fromLog ? "Current compilation failed. Fix the compiler diagnostics and recompile."
|
|
998
|
+
: currentErrors.length > 0
|
|
999
|
+
? "Current compilation failed in a compile the bridge did not observe (e.g. at Editor " +
|
|
1000
|
+
`startup); errors are the ${currentErrors.length} compiler diagnostic(s) from Editor.log. ` +
|
|
1001
|
+
"Fix them and recompile."
|
|
1002
|
+
: "Current compilation failed in a compile the bridge did not observe (e.g. at Editor " +
|
|
1003
|
+
"startup), and the Editor.log tail holds no compiler diagnostics. Raise tail_bytes or " +
|
|
1004
|
+
`recompile (${toolHintReference("unity_open_mcp_recompile_scripts")}) to capture them.`,
|
|
1005
|
+
compileSource: "CompilationPipeline", logPath, logSource: resolvedLog.reason,
|
|
1006
|
+
logAuthorship: parseLogAuthorship(tail.content),
|
|
1007
|
+
historicalLogErrors: fromLog ? [] : historical.compilerErrors,
|
|
1008
|
+
historicalLogIssues: historical.issues,
|
|
1009
|
+
logReadError: tail.error ?? null,
|
|
1010
|
+
}, "live");
|
|
1011
|
+
}
|
|
734
1012
|
if (tail.error) {
|
|
735
1013
|
return {
|
|
736
1014
|
content: [
|
|
@@ -1122,6 +1400,13 @@ export class ToolRouter {
|
|
|
1122
1400
|
};
|
|
1123
1401
|
}
|
|
1124
1402
|
async routeCapabilities(args, live) {
|
|
1403
|
+
if (["tool_name", "query", "group", "tag", "available", "active", "route", "mutating"].some(k => args[k] !== undefined)) {
|
|
1404
|
+
if (args.kind !== undefined && args.kind !== "tools")
|
|
1405
|
+
return localError("invalid_arguments", "Tool discovery filters require kind: tools (or omit kind).");
|
|
1406
|
+
const inventory = await this.fetchInventoryAndReconcile(live);
|
|
1407
|
+
const found = discoverTools(ALL_TOOLS, BATCH_TOOL_NAMES, this.sessionState, inventory?.tools, args, this.batch.headlessAvailable());
|
|
1408
|
+
return sourceResult(found, "local", !!found.error);
|
|
1409
|
+
}
|
|
1125
1410
|
const kind = args.kind === "tools" || args.kind === "rules" || args.kind === "fixes"
|
|
1126
1411
|
? args.kind
|
|
1127
1412
|
: undefined;
|
|
@@ -1229,9 +1514,49 @@ export class ToolRouter {
|
|
|
1229
1514
|
// when reachable; when the bridge is offline it reports `available: null`
|
|
1230
1515
|
// (unknown) and the agent should fall back to `unity_open_mcp_capabilities`
|
|
1231
1516
|
// for the authoritative compiled-state report.
|
|
1232
|
-
async routeManageTools(args, live) {
|
|
1517
|
+
async routeManageTools(args, live, identity) {
|
|
1233
1518
|
const action = typeof args.action === "string" ? args.action : "";
|
|
1234
1519
|
const group = typeof args.group === "string" ? args.group.trim() : "";
|
|
1520
|
+
const actionKeys = {
|
|
1521
|
+
list_groups: [], reset: [], activate: ["group"], deactivate: ["group"],
|
|
1522
|
+
suggest: ["intent", "tags"], activate_for: ["intent", "tags"],
|
|
1523
|
+
invoke: ["tool_name", "arguments"], editor_search: ["search_text", "asset_type", "open_ui"],
|
|
1524
|
+
};
|
|
1525
|
+
const ignored = Object.keys(args).filter(k => k !== "action" && actionKeys[action] && !actionKeys[action].includes(k));
|
|
1526
|
+
if (ignored.length)
|
|
1527
|
+
return this.manageToolsError("invalid_arguments", `Keys not used by ${action}: ${ignored.join(", ")}`);
|
|
1528
|
+
if (action === "editor_search") {
|
|
1529
|
+
const query = buildEditorSearch(String(args.search_text ?? ""), args.asset_type);
|
|
1530
|
+
if (args.open_ui !== true)
|
|
1531
|
+
return sourceResult({ query, opened: false, purpose: "Human collaboration; use search_assets/find_references for structured results." }, "local");
|
|
1532
|
+
const result = await this.routeCore(live, "unity_open_mcp_execute_csharp", {
|
|
1533
|
+
code: `UnityEditor.Search.SearchService.ShowWindow(UnityEditor.Search.SearchService.CreateContext(${JSON.stringify(query)})); return ${JSON.stringify(query)};`,
|
|
1534
|
+
read_only: true,
|
|
1535
|
+
}, identity);
|
|
1536
|
+
// The Search window opens through a read_only snippet, which a running
|
|
1537
|
+
// project job refuses (the bridge cannot verify a snippet writes
|
|
1538
|
+
// nothing). The handoff is optional UI, so report the query instead of
|
|
1539
|
+
// surfacing the refusal as a failed search.
|
|
1540
|
+
const body = result.isError ? parseResultBody(result) : null;
|
|
1541
|
+
const mutation = body?.mutation;
|
|
1542
|
+
const refusal = mutation?.error ?? body?.error;
|
|
1543
|
+
if (refusal?.code === "job_busy") {
|
|
1544
|
+
return sourceResult({
|
|
1545
|
+
query, opened: false, reason: "job_busy",
|
|
1546
|
+
...(refusal.job ? { job: refusal.job } : {}),
|
|
1547
|
+
message: "Unity Search cannot be opened while an asynchronous project command job runs. " +
|
|
1548
|
+
"Paste the query into Unity Search manually, or retry open_ui once the job reaches a terminal state.",
|
|
1549
|
+
}, "local");
|
|
1550
|
+
}
|
|
1551
|
+
result.content.push({ type: "text", text: JSON.stringify({ query, opened: !result.isError }) });
|
|
1552
|
+
return result;
|
|
1553
|
+
}
|
|
1554
|
+
if (action === "invoke") {
|
|
1555
|
+
const name = typeof args.tool_name === "string" ? args.tool_name : "";
|
|
1556
|
+
if (name === "unity_open_mcp_manage_tools" || !filterVisibleTools(ALL_TOOLS, this.sessionState).some(t => t.name === name))
|
|
1557
|
+
return this.manageToolsError("tool_not_active", "Activate the target group first; recursive manage_tools invocation is forbidden.");
|
|
1558
|
+
return this.routeCore(live, name, withSchemaDefaults(ALL_TOOLS.find(t => t.name === name), (args.arguments ?? {})), identity);
|
|
1559
|
+
}
|
|
1235
1560
|
if (action === "list_groups") {
|
|
1236
1561
|
return this.manageToolsListGroups(live);
|
|
1237
1562
|
}
|
|
@@ -1299,7 +1624,94 @@ export class ToolRouter {
|
|
|
1299
1624
|
});
|
|
1300
1625
|
}
|
|
1301
1626
|
return this.manageToolsError("unknown_action", `Unknown action '${action}'. Valid actions: list_groups, activate, ` +
|
|
1302
|
-
`deactivate, reset, suggest, activate_for.`);
|
|
1627
|
+
`deactivate, reset, suggest, activate_for, invoke, editor_search.`);
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* `unity_open_mcp_upgrade` is a live tool, but its network lookups run HERE
|
|
1631
|
+
* rather than inside the bridge: the bridge tool executes on the Editor main
|
|
1632
|
+
* thread, where a 10 s registry timeout would freeze the Editor and every
|
|
1633
|
+
* queued bridge request. The bridge only ever receives a resolved
|
|
1634
|
+
* `target_version`, and an apply that will re-pin through UPM is forwarded
|
|
1635
|
+
* only after the `bridge-v<version>` release tag was confirmed to exist —
|
|
1636
|
+
* the same preview → confirm → apply order the bridge window follows.
|
|
1637
|
+
*/
|
|
1638
|
+
async routeUpgrade(args, live) {
|
|
1639
|
+
const forwarded = { ...args };
|
|
1640
|
+
if (forwarded.target_version === undefined) {
|
|
1641
|
+
try {
|
|
1642
|
+
forwarded.target_version = (await releaseLookup.latest()).version;
|
|
1643
|
+
}
|
|
1644
|
+
catch (error) {
|
|
1645
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1646
|
+
return injectRouteMeta(localError("latest_version_failed", `Could not resolve the latest published version: ${detail}. Pass target_version explicitly.`), { route: "local" });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (forwarded.dry_run === false && forwarded.update_upm !== false) {
|
|
1650
|
+
// Preview first: only a plan that really re-pins through UPM needs the
|
|
1651
|
+
// GitHub tag check (an embedded or file: bridge install skips UPM, so it
|
|
1652
|
+
// must also work offline).
|
|
1653
|
+
const preview = await live.route("unity_open_mcp_upgrade", { ...forwarded, dry_run: true });
|
|
1654
|
+
const previewBody = parseResultBody(preview);
|
|
1655
|
+
const mutation = previewBody?.mutation;
|
|
1656
|
+
const output = (mutation && typeof mutation === "object" ? mutation.output : previewBody);
|
|
1657
|
+
if (preview.isError || (output && typeof output === "object" && output.error !== undefined)) {
|
|
1658
|
+
return injectRouteMeta(preview, { route: "live" });
|
|
1659
|
+
}
|
|
1660
|
+
if (output && typeof output === "object" && output.upmEnabled === true) {
|
|
1661
|
+
const version = String(forwarded.target_version);
|
|
1662
|
+
const tag = await releaseLookup.confirmBridgeTag(version);
|
|
1663
|
+
if (!tag.confirmed) {
|
|
1664
|
+
return injectRouteMeta(localError("release_tag_unavailable", tag.error ?? `Release tag bridge-v${version} was not found.`), { route: "local" });
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return injectRouteMeta(await live.route("unity_open_mcp_upgrade", forwarded), { route: "live" });
|
|
1669
|
+
}
|
|
1670
|
+
async routeProjectCommands(args, live) {
|
|
1671
|
+
if (args.action === "invoke") {
|
|
1672
|
+
const allowed = ["action", "command_id", "args", "schema_version", "paths_hint", "gate", "ignore_scene_dirty", "confirm_bypass", "timeout_ms"];
|
|
1673
|
+
let identity = { id: args.command_id };
|
|
1674
|
+
const fail = (code, message) => injectRouteMeta(sourceResult({ projectCommand: identity, error: { code, message } }, "local", true), { route: "local" });
|
|
1675
|
+
if (!args.command_id || Object.keys(args).some(k => !allowed.includes(k)))
|
|
1676
|
+
return fail("invalid_arguments", "invoke requires command_id and accepts command args plus transport safety options only.");
|
|
1677
|
+
const catalog = await live.projectCommands({ action: "describe", id: args.command_id });
|
|
1678
|
+
if (catalog.error)
|
|
1679
|
+
return injectRouteMeta(sourceResult(catalog, "live", true), { route: "live" });
|
|
1680
|
+
const command = catalog.command;
|
|
1681
|
+
if (!command?.available || !command.invocationSupported || !command.inputSchema || !command.schemaVersion)
|
|
1682
|
+
return fail("command_unavailable", "Command is unavailable or this bridge does not support invocation.");
|
|
1683
|
+
identity = { id: command.id, schemaVersion: command.schemaVersion, declaringAssembly: command.declaringAssembly,
|
|
1684
|
+
declaringType: command.declaringType, lifecycle: command.lifecycle };
|
|
1685
|
+
if (args.schema_version !== undefined && args.schema_version !== command.schemaVersion)
|
|
1686
|
+
return fail("command_schema_changed", "Describe the command again before invoking its changed contract.");
|
|
1687
|
+
const errors = validateSchema(args.args ?? {}, command.inputSchema);
|
|
1688
|
+
if (errors.length)
|
|
1689
|
+
return fail("invalid_arguments", errors.join("; "));
|
|
1690
|
+
// Deprecated parameter aliases are published like built-in aliases.
|
|
1691
|
+
const notes = [];
|
|
1692
|
+
const wire = wireArguments(args.args ?? {}, command.inputSchema, notes);
|
|
1693
|
+
const result = await live.route("unity_open_mcp_project_commands", { ...args, args: wire, schema_version: command.schemaVersion }, command.lifecycle);
|
|
1694
|
+
// Reload/transport failures have no bridge envelope; retain the exact catalog identity.
|
|
1695
|
+
result.content = result.content.map(item => {
|
|
1696
|
+
if (item.type !== "text")
|
|
1697
|
+
return item;
|
|
1698
|
+
try {
|
|
1699
|
+
const body = JSON.parse(item.text);
|
|
1700
|
+
if (body && typeof body === "object" && !Array.isArray(body) && !body.projectCommand)
|
|
1701
|
+
return { ...item, text: JSON.stringify({ ...body, projectCommand: identity }) };
|
|
1702
|
+
}
|
|
1703
|
+
catch { /* Preserve non-JSON transport diagnostics. */ }
|
|
1704
|
+
return item;
|
|
1705
|
+
});
|
|
1706
|
+
if (notes.length)
|
|
1707
|
+
result.content.push({ type: "text", text: JSON.stringify({ deprecations: [...new Set(notes)] }) });
|
|
1708
|
+
return injectRouteMeta(result, { route: "live" });
|
|
1709
|
+
}
|
|
1710
|
+
const allowed = args.action === "describe" ? ["action", "id"] : ["action", "query", "tags", "group", "package", "offset", "limit"];
|
|
1711
|
+
if (Object.keys(args).some(k => !allowed.includes(k)) || (args.action === "describe" && !args.id))
|
|
1712
|
+
return sourceResult({ error: { code: "invalid_arguments", message: "describe requires an exact id only; list accepts filters and paging only." } }, "local", true);
|
|
1713
|
+
const body = await live.projectCommands(args);
|
|
1714
|
+
return injectRouteMeta(sourceResult(body, "live", !!body.error), { route: "live" });
|
|
1303
1715
|
}
|
|
1304
1716
|
async manageToolsListGroups(live) {
|
|
1305
1717
|
// M20 Plan 7 / T20.7.0 — fetch the compiled-state inventory once and
|
|
@@ -1388,6 +1800,7 @@ export class ToolRouter {
|
|
|
1388
1800
|
// `availableReason`. Emits `notifications/tools/list_changed` exactly once
|
|
1389
1801
|
// when the visible set changed (idempotent when already active).
|
|
1390
1802
|
async manageToolsActivateFor(rec, live) {
|
|
1803
|
+
const groupsBeforeInventory = new Set(this.sessionState.activeGroups());
|
|
1391
1804
|
const inventory = await this.fetchInventoryAndReconcile(live);
|
|
1392
1805
|
const compiledAvailability = this.computeCompiledAvailability(inventory, groupToTools());
|
|
1393
1806
|
const before = this.sessionState.activeGroups();
|
|
@@ -1422,6 +1835,7 @@ export class ToolRouter {
|
|
|
1422
1835
|
intent: rec,
|
|
1423
1836
|
groups,
|
|
1424
1837
|
activated,
|
|
1838
|
+
tools: compactDiscoverySchema(discoverTools(ALL_TOOLS, BATCH_TOOL_NAMES, this.sessionState, inventory?.tools, { groups: this.sessionState.activeGroups().filter(g => !groupsBeforeInventory.has(g)), profile: "full", page_size: 1000 }, this.batch.headlessAvailable()).tools),
|
|
1425
1839
|
skipped,
|
|
1426
1840
|
unavailable,
|
|
1427
1841
|
activeGroups: this.sessionState.activeGroups(),
|
|
@@ -1735,6 +2149,11 @@ export class ToolRouter {
|
|
|
1735
2149
|
else {
|
|
1736
2150
|
status = "stopped";
|
|
1737
2151
|
}
|
|
2152
|
+
// A "stopped" bridge while this server's own headless run holds the
|
|
2153
|
+
// project: the process scan skips that child, so without this hint the
|
|
2154
|
+
// generic nextStep would send the agent to launch Unity into the run's
|
|
2155
|
+
// project lock.
|
|
2156
|
+
const headlessRun = status === "stopped" ? activeHeadlessRun(this.projectPath) : null;
|
|
1738
2157
|
const body = {
|
|
1739
2158
|
status,
|
|
1740
2159
|
// Coarse ready flag for clients that want a single boolean: true only
|
|
@@ -1784,7 +2203,12 @@ export class ToolRouter {
|
|
|
1784
2203
|
// `stale: true` block is the signal to reinstall the Unity package
|
|
1785
2204
|
// rather than file a regression.
|
|
1786
2205
|
wireContract: wireContractBlock(pingReachable, pingBody?.wireContract),
|
|
1787
|
-
|
|
2206
|
+
...(headlessRun !== null ? { headlessRun: describeHeadlessRun(headlessRun) } : {}),
|
|
2207
|
+
nextStep: headlessRun !== null
|
|
2208
|
+
? headlessRunSentence(headlessRun) +
|
|
2209
|
+
" The bridge stays offline until then. Wait for the pending headless call to return; " +
|
|
2210
|
+
"do not launch Unity for this project meanwhile."
|
|
2211
|
+
: bridgeStatusNextStep(status, wedge),
|
|
1788
2212
|
};
|
|
1789
2213
|
// bridge_status never reports an error — even a stopped bridge is a
|
|
1790
2214
|
// successful status read. _source=local because the synthesis happens in
|
|
@@ -1916,6 +2340,16 @@ export class ToolRouter {
|
|
|
1916
2340
|
pid = null;
|
|
1917
2341
|
}
|
|
1918
2342
|
}
|
|
2343
|
+
// No interactive Editor, but this server's own headless run holds the
|
|
2344
|
+
// project: say so (both dry-run and confirmed) instead of the misleading
|
|
2345
|
+
// "relaunch via the Hub" of unity_process_not_found. The scan never
|
|
2346
|
+
// returns that child, so it is never a kill target.
|
|
2347
|
+
if (pid === null) {
|
|
2348
|
+
const headlessRun = activeHeadlessRun(this.projectPath);
|
|
2349
|
+
if (headlessRun !== null) {
|
|
2350
|
+
return headlessRunErrorResult("restart_editor", headlessRun, this.projectPath);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
1919
2353
|
// 3. Dry-run branch: confirm was false/absent. Return the diagnosis +
|
|
1920
2354
|
// the PID the tool WOULD kill, with no side effect. Lets an agent
|
|
1921
2355
|
// preview before committing. feedback #2: include the PID source and
|
|
@@ -2117,6 +2551,10 @@ export class ToolRouter {
|
|
|
2117
2551
|
}
|
|
2118
2552
|
}
|
|
2119
2553
|
if (pid === null) {
|
|
2554
|
+
const headlessRun = activeHeadlessRun(this.projectPath);
|
|
2555
|
+
if (headlessRun !== null) {
|
|
2556
|
+
return headlessRunErrorResult("resource_pressure", headlessRun, this.projectPath);
|
|
2557
|
+
}
|
|
2120
2558
|
return sourceResult({
|
|
2121
2559
|
error: {
|
|
2122
2560
|
code: "unity_process_not_found",
|
|
@@ -2140,74 +2578,110 @@ export class ToolRouter {
|
|
|
2140
2578
|
const probe = countFileDescriptors(pid);
|
|
2141
2579
|
const count = probe.count;
|
|
2142
2580
|
const approximate = "approximate" in probe ? probe.approximate : false;
|
|
2143
|
-
const
|
|
2581
|
+
const partial = "partial" in probe ? probe.partial === true : false;
|
|
2582
|
+
// The threshold softening is about WHAT the metric counts, not about how
|
|
2583
|
+
// precise the number is. Only Windows HandleCount counts more than fds; a
|
|
2584
|
+
// timed-out lsof is imprecise but still counts real fds (a lower bound),
|
|
2585
|
+
// so it takes the normal warn/critical thresholds — softening it made a
|
|
2586
|
+
// partial 900/1024 report `ok` on the very platform this tool targets.
|
|
2587
|
+
const broaderThanFds = probe.method === "handle_count";
|
|
2588
|
+
const headroom = computeFdHeadroom(count, broaderThanFds, ceiling);
|
|
2144
2589
|
const ts = Date.now();
|
|
2145
2590
|
this.sessionState.recordFdSample({ ts, pid, count });
|
|
2146
2591
|
const samples = this.sessionState.fdSamplesSnapshot();
|
|
2147
2592
|
const trend = analyzeFdTrend(samples, ceiling);
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
|
|
2152
|
-
//
|
|
2593
|
+
// Samples are per-PID (as the tool description states): after an Editor
|
|
2594
|
+
// restart the ring still holds the previous process's entries — report
|
|
2595
|
+
// only this PID's series. The trend already analyzes the latest PID only.
|
|
2596
|
+
const pidSamples = samples.filter((s) => s.pid === pid);
|
|
2597
|
+
// 4. Decide whether to raise an operator warning. Alarm on:
|
|
2598
|
+
// - a real fd count at/past the ceiling (`over_ceiling` from lsof//proc):
|
|
2599
|
+
// POSIX allocates the lowest free descriptor number, so every NEW
|
|
2600
|
+
// descriptor now lands above the ceiling and the next IOSelector
|
|
2601
|
+
// registration (any async pipe/socket — e.g. the Bee build driver)
|
|
2602
|
+
// can hang the Editor. A fresh healthy Unity 6 editor sits at ~160
|
|
2603
|
+
// fds; real counts past 1024 are never legitimate now that
|
|
2604
|
+
// countLsofFdRows excludes the cwd/txt/mmap rows that once inflated
|
|
2605
|
+
// the count.
|
|
2153
2606
|
// - a monotonic leak (the actionable trend signal), or
|
|
2154
2607
|
// - approaching the ceiling from below (warn/critical).
|
|
2155
|
-
//
|
|
2156
|
-
//
|
|
2157
|
-
//
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2608
|
+
// The one downgraded case is a Windows HandleCount probe over the
|
|
2609
|
+
// ceiling: handle counts cover kernel/GDI/user objects and routinely
|
|
2610
|
+
// exceed 1024 on a healthy process, so it gets a `pressureNote` and
|
|
2611
|
+
// alarms only on the trend. `rising` is any net-positive delta that is
|
|
2612
|
+
// NOT a qualifying leak — it fires on ordinary drift, so it never
|
|
2613
|
+
// alarms on its own.
|
|
2160
2614
|
const fdMethod = probe.method;
|
|
2161
2615
|
const probeReason = "reason" in probe ? probe.reason : null;
|
|
2162
2616
|
const probeMessage = "message" in probe ? probe.message : null;
|
|
2163
|
-
|
|
2617
|
+
// A partial (timed-out) lsof listing is a LOWER BOUND on real fds, so an
|
|
2618
|
+
// over-ceiling verdict from it still stands; only the Windows HandleCount
|
|
2619
|
+
// metric measures something broader than fds. Same predicate that picked
|
|
2620
|
+
// the headroom thresholds above, so the two can never disagree.
|
|
2621
|
+
const overCeilingRealFds = headroom.state === "over_ceiling" && !broaderThanFds;
|
|
2622
|
+
const shouldWarn = overCeilingRealFds ||
|
|
2623
|
+
trend.state === "leaking" ||
|
|
2164
2624
|
headroom.state === "warn" ||
|
|
2165
2625
|
headroom.state === "critical";
|
|
2166
2626
|
const warningState = shouldWarn
|
|
2167
2627
|
? {
|
|
2168
|
-
level: headroom.state === "critical"
|
|
2628
|
+
level: headroom.state === "critical" || overCeilingRealFds
|
|
2169
2629
|
? "critical"
|
|
2170
2630
|
: trend.state === "leaking"
|
|
2171
2631
|
? "leaking"
|
|
2172
2632
|
: "warn",
|
|
2173
|
-
message:
|
|
2174
|
-
? `Editor fd
|
|
2175
|
-
`
|
|
2176
|
-
`
|
|
2177
|
-
`
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
`
|
|
2187
|
-
`
|
|
2188
|
-
`
|
|
2189
|
-
|
|
2190
|
-
`
|
|
2191
|
-
|
|
2192
|
-
|
|
2633
|
+
message: overCeilingRealFds
|
|
2634
|
+
? `Editor fd count (${count}) is at or above the ${ceiling}-descriptor ` +
|
|
2635
|
+
`Mono IOSelector ceiling. Every newly allocated descriptor number now ` +
|
|
2636
|
+
`lands above the ceiling, so the next async pipe/socket registration ` +
|
|
2637
|
+
`(e.g. the Bee build driver's IPC) can hang the Editor mid-build` +
|
|
2638
|
+
(trend.state === "leaking"
|
|
2639
|
+
? ` — and the count is still climbing (trend delta ${trend.delta} ` +
|
|
2640
|
+
`over ${trend.sampleCount} sample(s))`
|
|
2641
|
+
: "") +
|
|
2642
|
+
`. Save scene work and restart Unity via the Hub now.`
|
|
2643
|
+
: headroom.state === "critical"
|
|
2644
|
+
? broaderThanFds
|
|
2645
|
+
? `Windows handle count is at ${Math.floor(headroom.pressureRatio * 100)}% ` +
|
|
2646
|
+
`of the ${ceiling}-descriptor ceiling proxy. HandleCount covers ` +
|
|
2647
|
+
`kernel/GDI/user objects — far broader than fds — so this is a soft ` +
|
|
2648
|
+
`signal, not fd risk: watch the trend, and treat a monotonic climb ` +
|
|
2649
|
+
`(trend.state "leaking") as the real alarm.`
|
|
2650
|
+
: `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
|
|
2651
|
+
`the ${ceiling}-descriptor ceiling — the next domain reload is ` +
|
|
2652
|
+
`likely to trip the Bee build-driver hang. Save scene work and restart ` +
|
|
2653
|
+
`Unity via the Hub now, before the Editor hangs.`
|
|
2654
|
+
: trend.state === "leaking"
|
|
2655
|
+
? headroom.state === "over_ceiling"
|
|
2656
|
+
? `Editor handle count (${count}) is ABOVE the ${ceiling} ceiling ` +
|
|
2657
|
+
`proxy and climbing monotonically (leak in progress): trend ` +
|
|
2658
|
+
`delta ${trend.delta} over ${trend.sampleCount} sample(s). ` +
|
|
2659
|
+
`Save scene work and restart Unity via the Hub now.`
|
|
2660
|
+
: `Editor fd usage is climbing monotonically across samples (leak in ` +
|
|
2661
|
+
`progress): trend delta ${trend.delta} over ${trend.sampleCount} ` +
|
|
2662
|
+
`sample(s). Save scene work and plan a restart before the count ` +
|
|
2663
|
+
`crosses the ${ceiling}-descriptor ceiling.`
|
|
2664
|
+
: `Editor fd usage is at ${Math.floor(headroom.pressureRatio * 100)}% of ` +
|
|
2665
|
+
`the ${ceiling}-descriptor ceiling. Monitor the trend; if it keeps ` +
|
|
2666
|
+
`climbing across domain reloads, save scene work and restart Unity ` +
|
|
2667
|
+
`via the Hub before the Editor hangs.`,
|
|
2193
2668
|
}
|
|
2194
2669
|
: null;
|
|
2195
|
-
// Informational
|
|
2670
|
+
// Informational note for the remaining soft case: a Windows HandleCount
|
|
2671
|
+
// over the ceiling proxy with no qualifying leak trend.
|
|
2196
2672
|
const pressureNote = !shouldWarn && headroom.state === "over_ceiling"
|
|
2197
|
-
? `
|
|
2198
|
-
`proxy, but
|
|
2199
|
-
`
|
|
2200
|
-
`
|
|
2201
|
-
`
|
|
2202
|
-
`expected on a healthy process. Treat this as a concern ONLY if the trend ` +
|
|
2203
|
-
`becomes a monotonic leak (leaking) — re-sample after the next domain ` +
|
|
2204
|
-
`reload to establish one.`
|
|
2673
|
+
? `Windows handle count (${count}) is above the ${ceiling} fd-ceiling ` +
|
|
2674
|
+
`proxy, but HandleCount covers kernel/GDI/user objects — far broader ` +
|
|
2675
|
+
`than Unix fds — so this is expected on a healthy process. Treat it as ` +
|
|
2676
|
+
`a concern ONLY if the trend becomes a monotonic leak (leaking) — ` +
|
|
2677
|
+
`re-sample after the next domain reload to establish one.`
|
|
2205
2678
|
: null;
|
|
2206
2679
|
return sourceResult({
|
|
2207
2680
|
pid,
|
|
2208
2681
|
fdCount: count,
|
|
2209
2682
|
fdMethod,
|
|
2210
2683
|
approximate,
|
|
2684
|
+
...(partial ? { partial } : {}),
|
|
2211
2685
|
ceiling,
|
|
2212
2686
|
ceilingSource,
|
|
2213
2687
|
headroom: headroom.headroom,
|
|
@@ -2215,12 +2689,12 @@ export class ToolRouter {
|
|
|
2215
2689
|
state: headroom.state,
|
|
2216
2690
|
reliable: headroom.reliable,
|
|
2217
2691
|
trend,
|
|
2218
|
-
samples:
|
|
2692
|
+
samples: pidSamples.map((s) => ({
|
|
2219
2693
|
ts: s.ts,
|
|
2220
2694
|
pid: s.pid,
|
|
2221
2695
|
count: s.count,
|
|
2222
2696
|
})),
|
|
2223
|
-
sampleCount:
|
|
2697
|
+
sampleCount: pidSamples.length,
|
|
2224
2698
|
launchContextCaveat: LAUNCH_CONTEXT_CAVEAT,
|
|
2225
2699
|
...(probeReason !== null ? { probeReason } : {}),
|
|
2226
2700
|
...(probeMessage !== null ? { probeMessage } : {}),
|