diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..ede57d4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+Editor/MCPBridgeServer.Routes.g.cs text eol=lf
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..55b0643
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,33 @@
+name: Checks
+
+on:
+ push:
+ branches: ["**"]
+ paths:
+ - "Editor/**"
+ - "tools~/**"
+ - ".github/workflows/checks.yml"
+ pull_request:
+ paths:
+ - "Editor/**"
+ - "tools~/**"
+ - ".github/workflows/checks.yml"
+
+concurrency:
+ group: checks-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ route-registry:
+ name: route registry drift check
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 22
+ - name: Verify MCPBridgeServer.Routes.g.cs matches the dispatch switch
+ run: node tools~/generate-routes.mjs --check
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a83d262..8512417 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,127 @@
All notable changes to this package will be documented in this file.
+## [2.39.3] - 2026-07-23
+
+### Fixed (Discord ProBuilder report — "shared-mesh hazards + 8 tool traps")
+- **A1 — `unity_gameobject_duplicate` is now ProBuilder-safe.** `Object.Instantiate` of a ProBuilder object made the clone's MeshFilter share the source's *runtime* mesh; deleting either object later ran `ProBuilderMesh.OnDestroy`, which destroys that mesh — blanking every copy at once (the report's "delete 3 chairs → 21 remaining chairs invisible"). Each cloned `ProBuilderMesh` is now given its own independent mesh via ProBuilder's `MakeUnique()`, so the duplicate is a fully independent, still-editable object. Live-verified: source and clone no longer share a mesh, and destroying the source leaves the clone's mesh intact (24 verts). Response reports `proBuilderMeshesIsolated`.
+- **A1 — `unity_gameobject_delete` guards the shared-mesh hazard.** Deleting an object whose runtime (non-asset) mesh is still referenced by MeshFilters outside its subtree (clones) is now refused with `requiresForce`/`sharedWith` instead of silently blanking the others; pass `force:true` to override. The external-sharer scan **includes inactive objects** (`FindObjectsInactive.Include`) — a toggled-off sibling was the one blind spot that would otherwise let the exact hazard through silently. Only runtime meshes are scanned, and only when the target actually has one, so normal asset-mesh deletes stay on the fast path. Live-verified with both active and inactive siblings; `force:true` deletes.
+- **B2 — material lookup is consistent and never fails silently.** `create_shape`'s `material` accepted a bare name but, when it didn't resolve, fell back to the default with `success:true` and no signal; `set_face_material` demanded a full asset path. Both now share one resolver that accepts a full path OR a bare name (exact AssetDatabase match). `create_shape` surfaces `materialWarning` + echoes `appliedMaterial` when a requested material can't be found, so the fallback is never silent. A bare name that matches **more than one** material (common with asset packs / generic names) is resolved deterministically and disclosed — `materialWarning` names the ambiguity and `appliedMaterialPath` echoes exactly which asset was applied — rather than silently picking an arbitrary one. This also resolves **B1** (combine "losing" per-piece materials): with real materials actually applied, `CombineMeshes.Combine` preserves the submeshes — the previous single-submesh result came from every piece silently sharing the default (live-reproduced: two cubes with distinct materials combine to 2 submeshes `[MAT_A, MAT_B]`).
+- **B3 — `bevel_edges` reports no-ops.** Bevel silently does nothing on some configurations (coplanar/interior faces left by a CSG cut); the response now includes `changed` (vertex/face-count delta) and a `note` when nothing moved.
+- **B5 — MeshCollider stays in sync after every rebuild.** A `MeshCollider` on a ProBuilder object kept stale cooked collision after combine/boolean/any edit; the shared rebuild path now re-points it (null-then-set forces a re-cook). Live-verified after an extrude (24→40 verts, collider matches).
+- **B8 — `create_shape` takes `layer`, `addCollider`, and `parent`.** Created objects landed on the Default layer, with no collider, at the scene root — forcing a follow-up call each time. All three are now applied at creation (layer by name or index; a MeshCollider matching the mesh; reparent by path), with `layerWarning`/`parentWarning` on an unknown layer/path. The response echoes `layer` and `hasCollider`.
+
+Inherent ProBuilder behavior, surfaced rather than "fixed": **B6** (a `set_face_material` submesh on a shared mesh doesn't sync sibling renderers — now mitigated by the A1 independent-mesh duplicate) and **B7** (structural ops renumber faces — re-enumerate before the next op). **C** (the five 2026-07-22 battle-test bugs) re-verified still fixed.
+
+## [2.39.2] - 2026-07-23
+
+### Security / Fixed (pre-merge multi-expert audit)
+- **CRITICAL — asmdef commands now confined to the project.** Every `asmdef/*` handler (`create`, `info`, `add-references`, `remove-references`, `set-platforms`, `update-settings`, `create-ref`) took the caller's raw `path` straight into `File.ReadAllText`/`WriteAllText` — the one asset-writing surface the data-safety wave hadn't retrofitted. A `../../` or absolute `path` could read/write outside the project. All seven now resolve through `MCPAssetSafety.TryResolveProjectPath` (traversal + absolute rejected); live-verified that `../../…/hosts` and `C:/Users/Public/evil.asmdef` are refused while a normal `Assets/….asmdef` create/info/reference still works.
+- **CRITICAL — legacy synchronous endpoint no longer self-deadlocks on deferred routes.** A direct (non-`queue/submit`) POST to a deferred route (`testing/list-tests`) blocked the editor for the full sync timeout (the main-thread pump can't drain while the same update tick is blocked). Such routes now return a clear 409 directing to `queue/submit`; the orphaned `ExecuteOnMainThreadDeferred` deadlock primitive is removed. The async queue path (what the server uses) is unaffected.
+- **`probuilder/combine` is now fully undoable** — it didn't record the surviving target before merging, so `undo/last`/Ctrl+Z restored the consumed sources but left the target merged (partial undo). Verified: after undo the target returns to its pre-merge face count AND the sources come back.
+- **Deferred tickets no longer open an undo group** — their collapse fires an arbitrary number of frames later and could fold a concurrent agent's undo group into theirs, corrupting per-action undo. Deferred actions (already excluded from history) now never open a group.
+- **`shadergraph/set-node-property` no longer reports success when it changed nothing** — a missing property now returns a clear error instead of `success:true` on an unchanged file; the value is written through a literal `MatchEvaluator` so a `$`/`$1` in the value can't splice in captured regex text.
+- **ProBuilder null-arg hardening** — explicit-JSON `null` for `name`/`material`/a `faceIndices` element now yields a clear validation error instead of a `NullReferenceException`; `execute-code` dictionary results are item-capped like lists; toolbar dot textures are freed before domain reload (no per-recompile native-texture leak); dead local removed from `translate-faces`.
+
+## [2.39.1] - 2026-07-23
+
+### Security (merge-readiness audit of the news feature)
+- **News feed links are now confined to http/https before reaching the OS shell.** `MCPNewsService` parsed the devlog RSS ` ` and handed it verbatim to `Application.OpenURL` (which routes through the OS shell, i.e. any registered URI-scheme handler, `file://`, UNC paths). A spoofed/compromised feed could have turned one click in the News panel into a file/handler open. Links are now validated (`Uri` absolute + `http`/`https` only) at parse time — a bad-scheme item never becomes a clickable post — with a defense-in-depth re-check in `OpenPost`. Verified live: `file://`, `javascript:`, `steam://`, `ms-msdt:`, UNC and `vscode://` links are all dropped; a crafted `file://` item with a disguised `` title is discarded while legit posts survive.
+- **Feed text can no longer inject UI markup.** Titles/categories are stripped of `<`/`>` (UI Toolkit labels render rich text by default) and `/` (a `GenericMenu` submenu separator) at parse; the dashboard news labels also set `enableRichText = false`. Prevents a compromised feed from styling an entry to impersonate official plugin UI.
+- **Bounded feed ingestion** — responses over 1 MB are rejected and at most 50 posts are parsed, so an oversized/malicious response can't drive unbounded allocation or a giant EditorPrefs write on the main thread.
+- **Seen-set integrity** — `;` (the EditorPrefs delimiter) is stripped from slugs so a crafted slug can't corrupt the read-state store.
+
+### Fixed
+- **Self-test resume after domain reload** — a mid-battery domain reload resumed via `EditorApplication.delayCall`, which can be dropped during `InitializeOnLoad`; it now resumes on the first `EditorApplication.update` (always fires). New **ProBuilder self-test probe** (creates/verifies/destroys a cube when ProBuilder is installed, passes through when absent).
+
+## [2.39.0] - 2026-07-22
+
+### Fixed (Discord battle-test report — "floats silently dropped + 4 more bugs")
+- **BUG 1 (MAJOR): non-integer numeric parameters were silently dropped on decimal-comma locales.** Root cause: handlers read numbers via `value.ToString()` (current culture: `18.8` → `"18,8"` on e.g. fr-FR) then parsed with InvariantCulture — fail → silent fallback to the default. Integers and strings were unaffected, which is why it looked like "floats rejected". New shared `MCPArgs` reads the boxed `double`/`long` MiniJson actually delivers **typed-first** (no string round-trip); ProBuilder's `GetFloat`/`GetInt`/`GetBool` and UMA's `GetOptionalFloat` now delegate to it. Reproduced and verified under a forced `fr-FR` culture; the reporter's exact case (a 1.4 × 0.72 × 0.7 cube) now applies verbatim, and float `translate_faces` genuinely moves vertices.
+- **Design rule from the report — no silent fallback, no silent success:** a parameter that is present but not interpretable now **throws a clear error** (`Parameter 'width' is not a valid number: 'abc'`) instead of defaulting, and `create-shape` echoes the **actually applied** `appliedSize`/`appliedPosition` so a dropped value is visible in the response.
+- **BUG 2 (create-shape "race"): same root cause as BUG 1** — the "lost" positions (12.8 / 15.2) were non-integer and got dropped; the queue serializes writes one per frame and `CreateShape` reads only its own arguments. Verified: 3 concurrent creates with float positions all land exactly where requested.
+- **BUG 3: `probuilder/info` bounds unreliable** — local mesh bounds are now recalculated before reporting (stale after vertex edits), and the response adds **`worldBounds`** (renderer AABB — reflects transform scale/rotation/position, which is what placement logic actually needs). Verified: a 2×-scaled cube reports local 1.4×0.97×0.7 and world 2.8×1.94×1.4.
+- **BUG 4: `probuilder/boolean` left both operands overlapping the result** — sources are now **deleted by default** (Undo-tracked, one undo restores everything); pass `deleteSources:false` to keep them. New `name` parameter for the result; the response reports `sourceInstanceIds` + `sourcesDeleted`.
+
+## [2.38.0] - 2026-07-19
+
+### Added (studio news notifications)
+- **Mobile-style devlog notifications** — the plugin now surfaces new posts from the AnkleBreaker devlog (`anklebreaker-studio.com/devlog`): the MCP toolbar element shows an unseen badge (`MCP ●1`; a real accent-orange badge pill on the pre-6000.3 fallback toolbar), the toolbar dropdown gains an **AnkleBreaker News** section (latest 5 posts, unseen marked, click opens + marks read, Mark All Read, Open Devlog Page), and the dashboard gains a full **News panel** (all posts with category chips + dates, unseen highlighted in the brand accent).
+- `MCPNewsService`: polls the devlog RSS feed at most every 6 hours (plain GET, nothing sent), caches posts across domain reloads, and tracks read state **per user** (global EditorPrefs — reading a post once clears it in every project). First run seeds the backlog as read except the newest post, so a fresh install shows a single gentle "1", not six. Fully opt-out via Settings → News Notifications (toolbar menu or dashboard).
+
+### Changed (dashboard reworked in UI Toolkit, studio branding)
+- **The dashboard (Window → AB Unity MCP) is rebuilt in UI Toolkit** on the AnkleBreaker theme — the deep warm-brown + molten-orange palette of the studio website, shared with the welcome window's brand stylesheet (new `MCPTheme` loads it cross-assembly + `MCPDashboardStyles.uss`). Every capability of the old IMGUI window is preserved: connection status, server controls, request queue with per-agent depth, project context management, active agent sessions, recent actions, feature categories with self-test status/details/toggles, settings (auto-start, manual port, MPPM, reset), version + update check.
+- Dynamic sections refresh on a 750 ms schedule but only rebuild when a content signature changes; each section refreshes in isolation (one failing data source can't blank the others) and self-heals if the layout-restore path stomps its content (first population is deferred one frame past Unity's view-data restore).
+
+## [2.37.1] - 2026-07-19
+
+### Fixed (found by the live ProBuilder level-build test scenario)
+- **ProBuilder shapes were created with a NULL material** — they rendered with the magenta missing-material look and crashed boolean CSG ("Value cannot be null. Parameter name: key", CSG keys a dictionary by material). `create-shape` now always ends with a real material (the explicit `material` arg, else ProBuilder's default), and `boolean` pre-flights both operands, assigning the default to any null slot (Undo-tracked, disclosed as `materialsDefaulted: true` in the result).
+- **`boolean` results were double-offset** — CSG output vertices are in world space, but the result object was also placed at the target's position, pushing the mesh a full offset away (a carved wall landed outside the level). The result now sits at the identity transform, then gets its pivot centered on the geometry so it behaves like a normal object for later transforms.
+- **`graphics/scene-capture` captured a stale camera** — a backgrounded editor never repaints the SceneView, so its camera lagged behind code-driven `LookAt`/focus changes and captures showed the old view. The render camera is now synced to the view's pivot/rotation/size before rendering, so captures always reflect the requested view.
+
+## [2.37.0] - 2026-07-19
+
+### Changed (context efficiency)
+- **`scene/hierarchy` is dense by default** — per-node fields carrying their default value are omitted, and an absent field means the default: `active` (true), `tag` (Untagged), `layer` (Default), `position` (origin; Unity's approximate `Vector3 ==` treats float noise as origin), the universal `Transform` component entry, and `childCount` when a complete `children` array already implies it. Non-default information is always emitted. Measured **43% smaller** on a representative 221-node hierarchy (42.3KB → 24.4KB); scenes with many empty organizer objects cut more. **Behavior change:** consumers that require the old always-present per-node shape pass `verbose:true` (the server companion 2.35.0 declares the parameter).
+
+## [2.36.0] - 2026-07-18
+
+### Added (per-action / per-agent Undo)
+- **`undo/last` — revert the most recent undoable MCP action as a whole.** Each write action now reverts cleanly as one step (a whole create/edit/boolean, not one internal operation). With `agentId`, targets that agent's most recent action. Honest about Unity's LINEAR undo: if newer actions are stacked on top of the target, `undo/last` refuses to cascade and lists exactly which actions would also be reverted unless `force:true` is passed.
+- **`undo/history` is now a real per-agent action log** — returns recent MCP actions (newest first, optional `agentId`/`count` filters) with per-agent attribution, target object, an `undoable` flag, and the current Unity undo group — instead of only the current group name.
+
+### Changed (multi-agent queue — each action is now independently revertable)
+- **The queue wraps every WRITE action in its own named, collapsed Undo group** (`MCPRequestQueue`). Previously the recorded undo group used a `GetCurrentGroup()` before/after diff that missed most real edits (e.g. `RegisterCreatedObjectUndo` doesn't advance the group), so per-action undo was effectively unavailable. Now each agent's action is a clean, named entry in Unity's Undo history and a precise revert target for `undo/last`.
+- **Undoability is detected from the actual undo stack**, not guessed: an action is only marked undoable if it genuinely registered an undo op (measured via the internal `Undo.GetRecords`), so reads that slip past the read classifier — and `execute-code`, whose temp-host churn registers incidental undo — never become misleading `undo/last` targets. Reads, undo/redo ops, empty groups and failures stay non-undoable. Fails open if the internal API is ever unavailable.
+
+## [2.35.0] - 2026-07-18
+
+### Added (ProBuilder integration — `com.unity.probuilder`)
+- **Full ProBuilder command surface** (`MCPProBuilderCommands`, 14 handlers under the `probuilder/*` routes) — create parametric shapes and edit real, still-editable ProBuilder meshes through ProBuilder's public API:
+ - `create-shape` — cube, plane, cylinder, prism, stair, cone, door, pipe, arch, sphere (icosphere), torus, with per-shape parameters (sides, steps, thickness, angle, subdivisions, …).
+ - Geometry edits — `extrude-faces` (face/individual/vertex-normal), `bevel-edges`, `subdivide`, `delete-faces`, `translate-faces`, `flip-normals`.
+ - Materials — `set-face-material` (per-face submesh assignment).
+ - Boolean CSG — `boolean` (union / subtract / intersect); the result is a new editable ProBuilder object.
+ - `combine` (merge ProBuilder meshes), `probuilderize` (convert a plain mesh to ProBuilder), `center-pivot`, `export-mesh` (bake to a `.asset`, guarded by the shared overwrite check).
+- **Tolerated-when-missing integration** — the whole surface is gated on `PROBUILDER_INSTALLED` (asmdef `versionDefines` on `com.unity.probuilder >= 4.0.0`); when ProBuilder is absent every handler returns a clear "not installed" message instead of failing to compile, matching the existing UMA pattern. New `probuilder` capability category in `MCPSettingsManager`.
+- Every mutation registers Undo (`Undo.RegisterCompleteObjectUndo` / `RegisterCreatedObjectUndo`), so ProBuilder edits compose with the multi-agent queue's per-action undo tracking. `export-mesh` uses `outputPath` (distinct from the `path`/`instanceId` used to resolve the source object) and confines writes under `Assets/` via `MCPAssetSafety`.
+
+## [2.34.0] - 2026-07-18
+
+### Fixed (data safety — several handlers could silently destroy user assets)
+- **`script/create` and `script/update` no longer write outside the project or destroy source files** — the project root was computed with `dataPath.Replace("/Assets","")`, which strips **every** `/Assets` occurrence, so a project under a path containing `/Assets` resolved to the wrong root and writes landed outside the project (the import then silently no-oped). Root is now `Path.GetDirectoryName(Application.dataPath)`. `update` with missing **or empty** content used to truncate the target script to zero bytes — empty content is now rejected. `create` no longer silently overwrites an existing script. All paths are canonicalized and confined under `Assets/`/`Packages/` (traversal and absolute-path escapes rejected).
+- **Asset creators no longer silently overwrite existing assets** — `AssetDatabase.CreateAsset` on an existing path destroys the asset and every reference to it (the GUID is reused). ScriptableObject, Terrain (`create`), Animator Controller, Animation Clip, Material, and `asset/import` now refuse to replace an existing asset unless `overwrite: true` is passed. The overwrite check tests the canonical on-disk path **and** the AssetDatabase, so a non-canonical spelling (`Assets//X`, `Assets/./X`) or a not-yet-imported file can't slip past it. New shared `MCPAssetSafety` helper centralizes root resolution, path confinement, and the overwrite guard.
+- **Queue double-execution** — a request whose 30s sync waiter already gave up (`TimedOut`) stayed in the queue and still executed later, so a client retry ran a non-idempotent action (create, package add, import) twice. Timed-out tickets are now dropped before execution (race-safe under the queue lock).
+- **`component/set-property` reported success without applying the value** — Color/Vector2/3/4/Rect branches silently did nothing when the value wasn't an object (e.g. an agent passing `"1,0,0,1"` or an array); they now return a clear error.
+- **`asmdef` remove-references removed the wrong reference** — a substring `Contains` match removed `Unity.InputSystem.ForUI` when asked to remove `Unity.InputSystem` (breaking compilation while reporting success); now an exact name/GUID match, and a no-match is reported rather than churning a recompile.
+
+### Fixed (ShaderGraph — all four asset-corruption bugs in [#18](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/issues/18))
+- ShaderGraph create/add-node/connect/disconnect/remove-node now go through ShaderGraph's real `GraphData` model (new `MCPShaderGraphApi` reflection wrapper over `GraphData` / `MultiJson` / `FileUtilities`) instead of regex/JSON string surgery, so the serialized `.shadergraph` is **always** a valid, importable asset:
+ - `create` built an identical invalid 809-byte graph for every template (no target, dangling `m_OutputNode`, failed import); it now builds a real URP Lit/Unlit graph with its default surface/vertex blocks (or a valid target-less graph for `blank`), and refuses to silently downgrade an explicit URP template to blank when URP isn't installed.
+ - `add-node` produced empty-slot nodes that broke import and dropped the requested position; nodes now get their real slots and the position is honored (`positionX`/`positionY` or `x`/`y`).
+ - `disconnect` matched by output slot only (dropping sibling edges) and blanked surviving multi-line edges to `m_Id:""`; it now removes exactly the tuple-matched edges, survivors untouched.
+ - `remove-node` had the same edge-blanking corruption; it now uses `GraphData.RemoveNode`.
+ - `connect` validates slot compatibility and refuses cycles instead of blindly inserting edge JSON.
+ - Fail-closed: if the ShaderGraph API can't be resolved (version drift), these handlers return a clear error instead of corrupting anything.
+
+### Changed
+- **`overwrite: true` opt-in** on the asset-creator handlers is the escape hatch for the new overwrite guards (see the companion `unity-mcp-server` 2.32.0 schema additions). `editor/state` and `project/info` now report the project path via the corrected root helper.
+
+## [2.33.0] - 2026-07-18
+
+### Fixed
+- **`/api/context` always returned HTTP 500** — `GetContextResponse` reads `EditorPrefs` (main-thread-only) but ran on the HTTP ThreadPool thread, so every Project Context call threw; both context routes now run through `ExecuteOnMainThread` like every other synchronous route. Root cause + fix per PR [#17](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/17) by @rcasaleiro.
+- **`GetRegisteredRoutes` drift** — the hand-maintained route list had drifted to ~150 of 321 routes with several wrong names (e.g. `editor/undo` vs the real `undo/perform`), silently breaking the server's dynamic tool discovery. The list is now **generated from the dispatch switch** into `MCPBridgeServer.Routes.g.cs` by `tools~/generate-routes.mjs`; a CI workflow fails on drift.
+- **`editor/execute-code` returned numbers as strings** — result serialization ToString'd every reflected property and list element (`42` became `"42"`, nested objects flattened to type names). New depth-capped recursive serializer preserves primitive types, recurses dicts/lists/anonymous objects (depth 4, 1000-item cap), and renders `UnityEngine.Object` graphs compactly instead of exploding their property trees.
+- **Action history truncated 64-bit ids** — `MCPActionRecord.TargetInstanceId` was `int` but Unity 6.5 EntityIds are 64-bit opaque strings; the field is now a string end to end (record, persistence DTO, history window, select-target). Old persisted entries lose only this field on first load.
+- **Error responses leaked exception stack traces to the wire** — traces now go to the editor log only.
+- **macOS: ExecuteCode could not find Roslyn** — added `Contents/Resources/Scripting` to the assembly search paths, per PR [#19](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/19) by @JetNik.
+
+### Added
+- **Browser CSRF / DNS-rebinding guard** — the bridge can execute arbitrary editor code but accepted any local HTTP request; requests with a non-loopback `Host` or any non-loopback `Origin` (browser pages always attach one on cross-origin fetches) are now rejected with 403 before touching editor state. Local tools (no Origin header) are unaffected.
+- **Capability handshake (plugin half)** — `/api/ping` advertises a monotonic `protocolVersion` (1) and `pluginVersion` (from PackageInfo); unknown routes return HTTP 404 on the legacy path so servers can distinguish "feature missing" from "call failed" and degrade gracefully across version drift. Pairs with `unity-mcp-server` 2.31.0. Re-implementation of PR [#20](https://github.com/AnkleBreaker-Studio/unity-mcp-plugin/pull/20) by @D3vCrow.
+
## [2.32.0] - 2026-06-02
### Added
diff --git a/Editor/AnkleBreaker.UnityMCP.Editor.asmdef b/Editor/AnkleBreaker.UnityMCP.Editor.asmdef
index f851fe2..555f8e4 100755
--- a/Editor/AnkleBreaker.UnityMCP.Editor.asmdef
+++ b/Editor/AnkleBreaker.UnityMCP.Editor.asmdef
@@ -5,7 +5,10 @@
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"UMA_Core",
- "UMA_Core_Editor"
+ "UMA_Core_Editor",
+ "Unity.ProBuilder",
+ "Unity.ProBuilder.Editor",
+ "Unity.ProBuilder.Csg"
],
"includePlatforms": [
"Editor"
@@ -16,6 +19,12 @@
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
- "versionDefines": [],
+ "versionDefines": [
+ {
+ "name": "com.unity.probuilder",
+ "expression": "4.0.0",
+ "define": "PROBUILDER_INSTALLED"
+ }
+ ],
"noEngineReferences": false
}
\ No newline at end of file
diff --git a/Editor/MCPActionHistory.cs b/Editor/MCPActionHistory.cs
index 6e81770..f63ea53 100644
--- a/Editor/MCPActionHistory.cs
+++ b/Editor/MCPActionHistory.cs
@@ -208,7 +208,7 @@ private static void SaveToDisk()
status = r.Status ?? "",
executionTimeMs = r.ExecutionTimeMs,
errorMessage = r.ErrorMessage ?? "",
- targetInstanceId = r.TargetInstanceId,
+ targetInstanceId = r.TargetInstanceId ?? "",
targetPath = r.TargetPath ?? "",
targetType = r.TargetType ?? "",
undoGroup = r.UndoGroup,
@@ -289,7 +289,7 @@ private class HistoryEntry
public string status;
public long executionTimeMs;
public string errorMessage;
- public int targetInstanceId;
+ public string targetInstanceId; // string since 64-bit EntityId support. JsonUtility tolerates the old int→string scalar mismatch (parses to ""); LoadFromDisk is try/catch-guarded so a hard failure would drop the whole history file, not one field.
public string targetPath;
public string targetType;
public int undoGroup;
diff --git a/Editor/MCPActionHistoryWindow.cs b/Editor/MCPActionHistoryWindow.cs
index 1bc1d83..de54739 100644
--- a/Editor/MCPActionHistoryWindow.cs
+++ b/Editor/MCPActionHistoryWindow.cs
@@ -431,7 +431,7 @@ private void DrawDetailPanel()
}
// Select button
- if (r.TargetInstanceId != 0)
+ if (!string.IsNullOrEmpty(r.TargetInstanceId))
{
if (GUILayout.Button("Select", EditorStyles.miniButton, GUILayout.Width(45)))
SelectTargetObject(r);
@@ -453,8 +453,8 @@ private void DrawDetailPanel()
DrawDetailRow("Target", r.TargetPath);
if (!string.IsNullOrEmpty(r.TargetType))
DrawDetailRow("Target Type", r.TargetType);
- if (r.TargetInstanceId != 0)
- DrawDetailRow("Instance ID", r.TargetInstanceId.ToString());
+ if (!string.IsNullOrEmpty(r.TargetInstanceId))
+ DrawDetailRow("Instance ID", r.TargetInstanceId);
if (r.UndoGroup >= 0)
DrawDetailRow("Undo Group", r.UndoGroup.ToString());
@@ -508,7 +508,7 @@ private void DrawDetailRow(string label, string value, Color? valueColor = null)
private void SelectTargetObject(MCPActionRecord record)
{
- if (record.TargetInstanceId != 0)
+ if (!string.IsNullOrEmpty(record.TargetInstanceId))
{
var obj = MCPObjectId.ToObject(record.TargetInstanceId);
if (obj != null)
diff --git a/Editor/MCPActionRecord.cs b/Editor/MCPActionRecord.cs
index e86ce37..a722cc7 100644
--- a/Editor/MCPActionRecord.cs
+++ b/Editor/MCPActionRecord.cs
@@ -21,8 +21,11 @@ public class MCPActionRecord
public long ExecutionTimeMs { get; set; }
public string ErrorMessage { get; set; }
- // Target object tracking
- public int TargetInstanceId { get; set; } // 0 = no target
+ // Target object tracking. String because Unity 6.5 EntityIds are 64-bit
+ // values carried as opaque decimal strings on the wire (see MCPObjectId) —
+ // an int here silently truncated them. null/empty = no target. Old int-typed
+ // persisted entries: JsonUtility parses the scalar-type mismatch to "".
+ public string TargetInstanceId { get; set; }
public string TargetPath { get; set; }
public string TargetType { get; set; } // GameObject, Component, Asset, Script, Scene, etc.
@@ -62,15 +65,12 @@ public void ExtractTargetFromResult(object result)
{
if (!(result is Dictionary dict)) return;
- // Instance ID
- if (dict.TryGetValue("instanceId", out var idObj))
+ // Instance ID — stored verbatim as a string (lossless for 64-bit EntityIds)
+ if (dict.TryGetValue("instanceId", out var idObj) && idObj != null)
{
- if (idObj is int intId)
- TargetInstanceId = intId;
- else if (idObj is long longId)
- TargetInstanceId = (int)longId;
- else if (int.TryParse(idObj?.ToString(), out int parsed))
- TargetInstanceId = parsed;
+ string id = idObj.ToString();
+ if (!string.IsNullOrEmpty(id))
+ TargetInstanceId = id;
}
// Path
@@ -124,7 +124,7 @@ public string ToCopyString()
if (!string.IsNullOrEmpty(TargetPath))
sb.AppendLine($"Target: {TargetPath}");
- if (TargetInstanceId != 0)
+ if (!string.IsNullOrEmpty(TargetInstanceId))
sb.AppendLine($"InstanceId: {TargetInstanceId}");
if (!string.IsNullOrEmpty(ErrorMessage))
sb.AppendLine($"Error: {ErrorMessage}");
@@ -154,7 +154,7 @@ public Dictionary ToDict()
{ "status", Status ?? "" },
{ "executionTimeMs", ExecutionTimeMs },
{ "errorMessage", ErrorMessage ?? "" },
- { "targetInstanceId", TargetInstanceId },
+ { "targetInstanceId", TargetInstanceId ?? "" },
{ "targetPath", TargetPath ?? "" },
{ "targetType", TargetType ?? "" },
{ "undoGroup", UndoGroup },
diff --git a/Editor/MCPAnimationCommands.cs b/Editor/MCPAnimationCommands.cs
index 225d880..cce002b 100644
--- a/Editor/MCPAnimationCommands.cs
+++ b/Editor/MCPAnimationCommands.cs
@@ -36,6 +36,11 @@ public static object CreateController(Dictionary args)
}
}
+ // Don't silently replace an existing controller (all states/transitions lost).
+ var controllerOverwrite = MCPAssetSafety.OverwriteGuard(path, args);
+ if (controllerOverwrite != null)
+ return controllerOverwrite;
+
var controller = AnimatorController.CreateAnimatorControllerAtPath(path);
if (controller == null)
return new { error = "Failed to create animator controller" };
@@ -387,6 +392,11 @@ public static object CreateClip(Dictionary args)
}
}
+ // Don't silently wipe an existing clip's curves.
+ var clipOverwrite = MCPAssetSafety.OverwriteGuard(path, args);
+ if (clipOverwrite != null)
+ return clipOverwrite;
+
var clip = new AnimationClip();
clip.name = Path.GetFileNameWithoutExtension(path);
diff --git a/Editor/MCPArgs.cs b/Editor/MCPArgs.cs
new file mode 100644
index 0000000..6a153ad
--- /dev/null
+++ b/Editor/MCPArgs.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// Typed-first argument readers for command handlers.
+ ///
+ /// MiniJson delivers JSON numbers as boxed double / long . The old
+ /// pattern value.ToString() + invariant TryParse silently DROPPED every
+ /// non-integer number on machines whose current culture writes a decimal comma
+ /// (18.8 → "18,8" → parse fail → default) — the exact "floats silently ignored"
+ /// bug from the battle-test report. Readers here unbox the typed value directly
+ /// and only fall back to invariant string parsing for string inputs.
+ ///
+ /// Design rule (also from the report): a parameter that is PRESENT but not
+ /// interpretable throws instead of silently falling back to the default —
+ /// a loud failure costs seconds, a silent wrong default costs a debugging session.
+ ///
+ internal static class MCPArgs
+ {
+ public static float GetFloat(Dictionary args, string key, float defaultValue)
+ {
+ if (args == null || !args.TryGetValue(key, out object value) || value == null)
+ return defaultValue;
+
+ switch (value)
+ {
+ case double d: return (float)d;
+ case float f: return f;
+ case long l: return l;
+ case int i: return i;
+ case string s:
+ if (float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed))
+ return parsed;
+ throw new ArgumentException($"Parameter '{key}' is not a valid number: '{s}'.");
+ default:
+ throw new ArgumentException($"Parameter '{key}' must be a number (got {value.GetType().Name}).");
+ }
+ }
+
+ public static int GetInt(Dictionary args, string key, int defaultValue)
+ {
+ if (args == null || !args.TryGetValue(key, out object value) || value == null)
+ return defaultValue;
+
+ switch (value)
+ {
+ case long l: return checked((int)l);
+ case int i: return i;
+ case double d:
+ // Accept integral doubles (8.0) — reject genuinely fractional values loudly.
+ double rounded = Math.Round(d);
+ if (Math.Abs(d - rounded) < 1e-6) return checked((int)rounded);
+ throw new ArgumentException($"Parameter '{key}' must be an integer (got {d.ToString(CultureInfo.InvariantCulture)}).");
+ case string s:
+ if (int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
+ return parsed;
+ throw new ArgumentException($"Parameter '{key}' is not a valid integer: '{s}'.");
+ default:
+ throw new ArgumentException($"Parameter '{key}' must be an integer (got {value.GetType().Name}).");
+ }
+ }
+
+ public static bool GetBool(Dictionary args, string key, bool defaultValue)
+ {
+ if (args == null || !args.TryGetValue(key, out object value) || value == null)
+ return defaultValue;
+
+ switch (value)
+ {
+ case bool b: return b;
+ case string s:
+ string lower = s.ToLowerInvariant();
+ if (lower == "true" || lower == "1") return true;
+ if (lower == "false" || lower == "0") return false;
+ throw new ArgumentException($"Parameter '{key}' is not a valid boolean: '{s}'.");
+ case long l: return l != 0;
+ default:
+ throw new ArgumentException($"Parameter '{key}' must be a boolean (got {value.GetType().Name}).");
+ }
+ }
+ }
+}
diff --git a/Editor/MCPArgs.cs.meta b/Editor/MCPArgs.cs.meta
new file mode 100644
index 0000000..ddb7f9b
--- /dev/null
+++ b/Editor/MCPArgs.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5d2c8e7a4f9b41c3a6e8d0b2f1c74604
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPAssemblyDefCommands.cs b/Editor/MCPAssemblyDefCommands.cs
index db3f49a..cc16b9c 100644
--- a/Editor/MCPAssemblyDefCommands.cs
+++ b/Editor/MCPAssemblyDefCommands.cs
@@ -27,14 +27,18 @@ public static object CreateAssemblyDef(Dictionary args)
if (!path.EndsWith(".asmdef"))
path += ".asmdef";
+ // Confine to Assets/|Packages/ — reject traversal/absolute writes (data-safety).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
string asmName = args.ContainsKey("name") ? args["name"].ToString()
: Path.GetFileNameWithoutExtension(path);
- // Ensure directory exists
+ // Ensure directory exists (asset-relative path — the confined path is validated above)
EnsureDirectoryExists(path);
// Check if file already exists
- if (File.Exists(path))
+ if (File.Exists(fullPath))
return new { error = $"Assembly definition already exists at '{path}'. Use asmdef/info to inspect or asmdef/update to modify it." };
// Build the asmdef JSON
@@ -55,7 +59,7 @@ public static object CreateAssemblyDef(Dictionary args)
};
string json = FormatAsmdefJson(asmdef);
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
@@ -78,10 +82,15 @@ public static object GetAssemblyDefInfo(Dictionary args)
if (string.IsNullOrEmpty(path))
return new { error = "path is required" };
- if (!File.Exists(path))
+ // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef
+ // 'path' can't read/write files outside the project (data-safety, matches siblings).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
+ if (!File.Exists(fullPath))
return new { error = $"File not found: '{path}'" };
- string json = File.ReadAllText(path);
+ string json = File.ReadAllText(fullPath);
var asmdef = MiniJson.Deserialize(json) as Dictionary;
if (asmdef == null)
return new { error = "Failed to parse assembly definition JSON" };
@@ -170,14 +179,19 @@ public static object AddReferences(Dictionary args)
if (string.IsNullOrEmpty(path))
return new { error = "path is required" };
- if (!File.Exists(path))
+ // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef
+ // 'path' can't read/write files outside the project (data-safety, matches siblings).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
+ if (!File.Exists(fullPath))
return new { error = $"File not found: '{path}'" };
var newRefs = BuildStringList(args, "references");
if (newRefs.Count == 0)
return new { error = "references array is required and must not be empty" };
- string json = File.ReadAllText(path);
+ string json = File.ReadAllText(fullPath);
var asmdef = MiniJson.Deserialize(json) as Dictionary;
if (asmdef == null)
return new { error = "Failed to parse assembly definition JSON" };
@@ -208,7 +222,7 @@ public static object AddReferences(Dictionary args)
asmdef["references"] = existingRefs.Cast().ToList();
json = FormatAsmdefJson(asmdef);
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
@@ -232,14 +246,19 @@ public static object RemoveReferences(Dictionary args)
if (string.IsNullOrEmpty(path))
return new { error = "path is required" };
- if (!File.Exists(path))
+ // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef
+ // 'path' can't read/write files outside the project (data-safety, matches siblings).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
+ if (!File.Exists(fullPath))
return new { error = $"File not found: '{path}'" };
var refsToRemove = BuildStringList(args, "references");
if (refsToRemove.Count == 0)
return new { error = "references array is required and must not be empty" };
- string json = File.ReadAllText(path);
+ string json = File.ReadAllText(fullPath);
var asmdef = MiniJson.Deserialize(json) as Dictionary;
if (asmdef == null)
return new { error = "Failed to parse assembly definition JSON" };
@@ -251,10 +270,12 @@ public static object RemoveReferences(Dictionary args)
var removed = new List();
foreach (string refToRemove in refsToRemove)
{
- // Try to match by name or GUID
+ // Match by EXACT name or GUID only. A substring Contains match used to remove
+ // the wrong reference (removing "Unity.InputSystem" also matched
+ // "Unity.InputSystem.ForUI"), breaking compilation while reporting success.
string match = existingRefs.FirstOrDefault(r =>
r.Equals(refToRemove, StringComparison.OrdinalIgnoreCase) ||
- r.Contains(refToRemove));
+ r.Equals("GUID:" + refToRemove, StringComparison.OrdinalIgnoreCase));
if (match != null)
{
@@ -263,10 +284,23 @@ public static object RemoveReferences(Dictionary args)
}
}
+ // Nothing matched — don't churn a rewrite + recompile for a no-op, and don't
+ // report a misleading success (exact-match now, so a stale name simply isn't there).
+ if (removed.Count == 0)
+ {
+ return new
+ {
+ success = false,
+ path = path,
+ removed = removed,
+ warning = "No matching references found (exact name/GUID match). Nothing was changed.",
+ };
+ }
+
asmdef["references"] = existingRefs.Cast().ToList();
json = FormatAsmdefJson(asmdef);
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
@@ -289,10 +323,15 @@ public static object SetPlatforms(Dictionary args)
if (string.IsNullOrEmpty(path))
return new { error = "path is required" };
- if (!File.Exists(path))
+ // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef
+ // 'path' can't read/write files outside the project (data-safety, matches siblings).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
+ if (!File.Exists(fullPath))
return new { error = $"File not found: '{path}'" };
- string json = File.ReadAllText(path);
+ string json = File.ReadAllText(fullPath);
var asmdef = MiniJson.Deserialize(json) as Dictionary;
if (asmdef == null)
return new { error = "Failed to parse assembly definition JSON" };
@@ -304,7 +343,7 @@ public static object SetPlatforms(Dictionary args)
asmdef["excludePlatforms"] = BuildStringList(args, "excludePlatforms").Cast().ToList();
json = FormatAsmdefJson(asmdef);
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
@@ -327,10 +366,15 @@ public static object UpdateSettings(Dictionary args)
if (string.IsNullOrEmpty(path))
return new { error = "path is required" };
- if (!File.Exists(path))
+ // Confine to Assets/|Packages/ — reject traversal/absolute paths so a raw asmdef
+ // 'path' can't read/write files outside the project (data-safety, matches siblings).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
+ if (!File.Exists(fullPath))
return new { error = $"File not found: '{path}'" };
- string json = File.ReadAllText(path);
+ string json = File.ReadAllText(fullPath);
var asmdef = MiniJson.Deserialize(json) as Dictionary;
if (asmdef == null)
return new { error = "Failed to parse assembly definition JSON" };
@@ -381,7 +425,7 @@ public static object UpdateSettings(Dictionary args)
return new { error = "No settings to update. Provide at least one of: name, rootNamespace, allowUnsafeCode, overrideReferences, autoReferenced, noEngineReferences, defineConstraints, precompiledReferences, versionDefines" };
json = FormatAsmdefJson(asmdef);
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
@@ -408,13 +452,17 @@ public static object CreateAssemblyRef(Dictionary args)
if (!path.EndsWith(".asmref"))
path += ".asmref";
+ // Confine to Assets/|Packages/ — reject traversal/absolute writes (data-safety).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+
string targetAssembly = args.ContainsKey("reference") ? args["reference"].ToString() : "";
if (string.IsNullOrEmpty(targetAssembly))
return new { error = "reference is required (name of the assembly definition to reference, e.g. 'MyGame.Runtime')" };
EnsureDirectoryExists(path);
- if (File.Exists(path))
+ if (File.Exists(fullPath))
return new { error = $"Assembly reference already exists at '{path}'" };
// Resolve to GUID format
@@ -429,7 +477,7 @@ public static object CreateAssemblyRef(Dictionary args)
// Pretty-print manually
json = json.Replace("{", "{\n ").Replace("}", "\n}");
- File.WriteAllText(path, json);
+ File.WriteAllText(fullPath, json);
AssetDatabase.ImportAsset(path);
return new
diff --git a/Editor/MCPAssetCommands.cs b/Editor/MCPAssetCommands.cs
index 8a9d002..fd6b22c 100755
--- a/Editor/MCPAssetCommands.cs
+++ b/Editor/MCPAssetCommands.cs
@@ -71,13 +71,24 @@ public static object Import(Dictionary args)
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(dest))
return new { error = "sourcePath and destinationPath are required" };
- string fullDest = Path.Combine(Application.dataPath.Replace("/Assets", ""), dest);
+ if (!File.Exists(source))
+ return new { error = $"Source file not found: {source}" };
+
+ // Confine the destination under the project (correct root, no traversal escape).
+ if (!MCPAssetSafety.TryResolveProjectPath(dest, out string fullDest, out string pathError))
+ return new { error = pathError };
+
+ // Don't silently overwrite an existing project asset unless asked.
+ var overwriteError = MCPAssetSafety.OverwriteGuard(dest, args);
+ if (overwriteError != null)
+ return overwriteError;
+
string destDir = Path.GetDirectoryName(fullDest);
if (!Directory.Exists(destDir))
Directory.CreateDirectory(destDir);
File.Copy(source, fullDest, true);
- AssetDatabase.ImportAsset(dest);
+ AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(dest));
return new { success = true, importedPath = dest };
}
@@ -176,6 +187,11 @@ public static object CreateMaterial(Dictionary args)
var shader = Shader.Find(shaderName);
if (shader == null) return new { error = $"Shader '{shaderName}' not found" };
+ // Don't reset an existing tuned material back to defaults.
+ var materialOverwrite = MCPAssetSafety.OverwriteGuard(path, args);
+ if (materialOverwrite != null)
+ return materialOverwrite;
+
var material = new Material(shader);
if (args.ContainsKey("color"))
diff --git a/Editor/MCPAssetSafety.cs b/Editor/MCPAssetSafety.cs
new file mode 100644
index 0000000..edf091c
--- /dev/null
+++ b/Editor/MCPAssetSafety.cs
@@ -0,0 +1,131 @@
+using System;
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// Shared guards for asset-writing MCP handlers. Two recurring data-loss classes
+ /// are centralized here:
+ /// 1. Path resolution — the project root is the folder CONTAINING Assets/, i.e.
+ /// Path.GetDirectoryName(Application.dataPath). The old idiom
+ /// dataPath.Replace("/Assets","") stripped EVERY "/Assets" occurrence, so a
+ /// project under a path containing "/Assets" resolved to the wrong root and
+ /// writes landed outside the project (import silently no-ops). Paths are also
+ /// canonicalized and confined under the project root so "../" or an absolute
+ /// path can't escape and clobber arbitrary files on disk.
+ /// 2. Overwrite — CreateAsset/File.WriteAllText on an existing asset destroys the
+ /// user's asset (and, for CreateAsset, every reference to it since the GUID is
+ /// reused). Callers gate on AssetWouldOverwrite unless the caller passes overwrite:true.
+ ///
+ internal static class MCPAssetSafety
+ {
+ /// Absolute path of the folder containing Assets/ (and Packages/, ProjectSettings/).
+ internal static string ProjectRoot => Path.GetDirectoryName(Application.dataPath);
+
+ ///
+ /// Resolve a project-relative asset path (e.g. "Assets/Scripts/X.cs") to an absolute
+ /// path, confined under the project root. Returns false with a message if the path is
+ /// empty, absolute, or escapes the project via "..".
+ ///
+ internal static bool TryResolveProjectPath(string assetPath, out string fullPath, out string error)
+ {
+ fullPath = null;
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(assetPath))
+ {
+ error = "path is required";
+ return false;
+ }
+
+ string root, rootFull, combined;
+ try
+ {
+ // IsPathRooted throws on invalid path chars on Mono — keep it inside the try.
+ if (Path.IsPathRooted(assetPath))
+ {
+ error = $"path must be project-relative (under Assets/ or Packages/), got absolute: {assetPath}";
+ return false;
+ }
+ root = ProjectRoot;
+ rootFull = Path.GetFullPath(root);
+ combined = Path.GetFullPath(Path.Combine(root, assetPath));
+ }
+ catch (Exception ex)
+ {
+ error = $"invalid path '{assetPath}': {ex.Message}";
+ return false;
+ }
+
+ string sep = Path.DirectorySeparatorChar.ToString();
+ string rootPrefix = rootFull.EndsWith(sep) ? rootFull : rootFull + sep;
+
+ // Path comparison is case-insensitive only where the filesystem is (Windows/macOS);
+ // on Linux it must be ordinal so a case-variant sibling can't slip through.
+ var cmp = Application.platform == RuntimePlatform.LinuxEditor
+ ? StringComparison.Ordinal
+ : StringComparison.OrdinalIgnoreCase;
+
+ if (!combined.StartsWith(rootPrefix, cmp))
+ {
+ error = $"path escapes the project root: {assetPath}";
+ return false;
+ }
+
+ // Enforce the stated contract: writes/reads live under Assets/ or Packages/,
+ // never project metadata (ProjectSettings/, Library/, .git/, ...).
+ string relative = combined.Substring(rootPrefix.Length).Replace('\\', '/');
+ string firstSegment = relative.Split('/')[0];
+ if (!firstSegment.Equals("Assets", cmp) && !firstSegment.Equals("Packages", cmp))
+ {
+ error = $"path must be under Assets/ or Packages/, got: {assetPath}";
+ return false;
+ }
+
+ fullPath = combined;
+ return true;
+ }
+
+ /// Project-relative asset path normalized to forward slashes (for AssetDatabase APIs).
+ internal static string ToAssetDatabasePath(string assetPath)
+ {
+ return assetPath.Replace('\\', '/');
+ }
+
+ ///
+ /// True if something already lives at this project path — checked against BOTH the
+ /// canonical on-disk path AND the AssetDatabase. A raw-string DB lookup alone was
+ /// bypassable by a non-canonical spelling ("Assets//X", "Assets/./X") and blind to a
+ /// file written moments earlier but not yet imported.
+ ///
+ internal static bool AssetWouldOverwrite(string assetPath)
+ {
+ if (TryResolveProjectPath(assetPath, out string fullPath, out _) && File.Exists(fullPath))
+ return true;
+ return AssetDatabase.LoadMainAssetAtPath(ToAssetDatabasePath(assetPath)) != null;
+ }
+
+ ///
+ /// Standard overwrite guard for asset creators. Returns an error object to return
+ /// directly, or null if creation may proceed. Pass the caller's args so a caller can
+ /// opt in with overwrite:true.
+ ///
+ internal static object OverwriteGuard(string assetPath, System.Collections.Generic.Dictionary args)
+ {
+ bool overwrite = args != null && args.ContainsKey("overwrite")
+ && args["overwrite"] != null
+ && (args["overwrite"].ToString().ToLowerInvariant() == "true" || args["overwrite"].ToString() == "1");
+ if (!overwrite && AssetWouldOverwrite(assetPath))
+ {
+ return new
+ {
+ error = $"An asset already exists at '{ToAssetDatabasePath(assetPath)}'. Pass overwrite:true to replace it (this destroys the existing asset and its references).",
+ existingAsset = ToAssetDatabasePath(assetPath),
+ };
+ }
+ return null;
+ }
+ }
+}
diff --git a/Editor/MCPAssetSafety.cs.meta b/Editor/MCPAssetSafety.cs.meta
new file mode 100644
index 0000000..e3453db
--- /dev/null
+++ b/Editor/MCPAssetSafety.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b4e535f372394edd98d200cef31ef0f6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPBridgeServer.Routes.g.cs b/Editor/MCPBridgeServer.Routes.g.cs
new file mode 100644
index 0000000..1be805f
--- /dev/null
+++ b/Editor/MCPBridgeServer.Routes.g.cs
@@ -0,0 +1,357 @@
+//
+// Generated by tools~/generate-routes.mjs from the RouteRequest dispatch in
+// MCPBridgeServer.cs — DO NOT EDIT BY HAND. Regenerate after adding a route:
+// node tools~/generate-routes.mjs
+// CI runs --check mode and fails when this file is out of date.
+using System.Collections.Generic;
+
+namespace UnityMCP.Editor
+{
+ public static partial class MCPBridgeServer
+ {
+ /// Every route the bridge can dispatch (337 routes).
+ internal static readonly string[] GeneratedRoutes = new string[]
+ {
+ "_meta/routes",
+ "agents/list",
+ "agents/log",
+ "amplify/add-node",
+ "amplify/close",
+ "amplify/connect",
+ "amplify/create-from-template",
+ "amplify/create-shader",
+ "amplify/disconnect",
+ "amplify/disconnect-all",
+ "amplify/duplicate-node",
+ "amplify/focus-node",
+ "amplify/get-connections",
+ "amplify/get-node-types",
+ "amplify/get-nodes",
+ "amplify/info",
+ "amplify/list",
+ "amplify/list-functions",
+ "amplify/master-node-info",
+ "amplify/move-node",
+ "amplify/node-info",
+ "amplify/open",
+ "amplify/remove-node",
+ "amplify/save",
+ "amplify/set-node-property",
+ "amplify/status",
+ "animation/add-event",
+ "animation/add-keyframe",
+ "animation/add-layer",
+ "animation/add-parameter",
+ "animation/add-state",
+ "animation/add-transition",
+ "animation/assign-controller",
+ "animation/clip-info",
+ "animation/controller-info",
+ "animation/create-blend-tree",
+ "animation/create-clip",
+ "animation/create-controller",
+ "animation/get-blend-tree",
+ "animation/get-curve-keyframes",
+ "animation/get-events",
+ "animation/remove-curve",
+ "animation/remove-event",
+ "animation/remove-keyframe",
+ "animation/remove-layer",
+ "animation/remove-parameter",
+ "animation/remove-state",
+ "animation/remove-transition",
+ "animation/set-clip-curve",
+ "animation/set-clip-settings",
+ "asmdef/add-references",
+ "asmdef/create",
+ "asmdef/create-ref",
+ "asmdef/info",
+ "asmdef/list",
+ "asmdef/remove-references",
+ "asmdef/set-platforms",
+ "asmdef/update-settings",
+ "asset/create-material",
+ "asset/create-prefab",
+ "asset/delete",
+ "asset/import",
+ "asset/instantiate-prefab",
+ "asset/list",
+ "audio/create-source",
+ "audio/info",
+ "audio/set-global",
+ "build/start",
+ "compilation/errors",
+ "component/add",
+ "component/batch-wire",
+ "component/get-properties",
+ "component/get-referenceable",
+ "component/remove",
+ "component/set-property",
+ "component/set-reference",
+ "console/clear",
+ "console/log",
+ "constraint/add",
+ "constraint/info",
+ "debugger/enable",
+ "debugger/event-details",
+ "debugger/events",
+ "editor/execute-code",
+ "editor/execute-menu-item",
+ "editor/play-mode",
+ "editor/state",
+ "editorprefs/delete",
+ "editorprefs/get",
+ "editorprefs/set",
+ "gameobject/create",
+ "gameobject/delete",
+ "gameobject/info",
+ "gameobject/set-transform",
+ "graphics/asset-preview",
+ "graphics/game-capture",
+ "graphics/lighting-summary",
+ "graphics/material-info",
+ "graphics/mesh-info",
+ "graphics/prefab-render",
+ "graphics/renderer-info",
+ "graphics/scene-capture",
+ "graphics/texture-info",
+ "input/add-action",
+ "input/add-binding",
+ "input/add-composite-binding",
+ "input/add-map",
+ "input/create",
+ "input/info",
+ "input/remove-action",
+ "input/remove-map",
+ "lighting/create",
+ "lighting/create-light-probe-group",
+ "lighting/create-reflection-probe",
+ "lighting/info",
+ "lighting/set-environment",
+ "lod/create",
+ "lod/info",
+ "mppm/activate-player",
+ "mppm/deactivate-player",
+ "mppm/list-players",
+ "navigation/add-agent",
+ "navigation/add-obstacle",
+ "navigation/bake",
+ "navigation/clear",
+ "navigation/info",
+ "navigation/set-destination",
+ "packages/add",
+ "packages/info",
+ "packages/list",
+ "packages/remove",
+ "packages/search",
+ "particle/create",
+ "particle/info",
+ "particle/playback",
+ "particle/set-emission",
+ "particle/set-main",
+ "particle/set-shape",
+ "physics/collision-matrix",
+ "physics/overlap-box",
+ "physics/overlap-sphere",
+ "physics/raycast",
+ "physics/set-collision-layer",
+ "physics/set-gravity",
+ "ping",
+ "playerprefs/delete",
+ "playerprefs/delete-all",
+ "playerprefs/get",
+ "playerprefs/set",
+ "prefab-asset/add-component",
+ "prefab-asset/add-gameobject",
+ "prefab-asset/apply-variant-override",
+ "prefab-asset/compare-variant",
+ "prefab-asset/get-properties",
+ "prefab-asset/hierarchy",
+ "prefab-asset/remove-component",
+ "prefab-asset/remove-gameobject",
+ "prefab-asset/revert-variant-override",
+ "prefab-asset/set-property",
+ "prefab-asset/set-reference",
+ "prefab-asset/transfer-variant-overrides",
+ "prefab-asset/variant-info",
+ "prefab/apply-overrides",
+ "prefab/create-variant",
+ "prefab/duplicate",
+ "prefab/info",
+ "prefab/reparent",
+ "prefab/revert-overrides",
+ "prefab/set-active",
+ "prefab/set-object-reference",
+ "prefab/unpack",
+ "probuilder/bevel-edges",
+ "probuilder/boolean",
+ "probuilder/center-pivot",
+ "probuilder/combine",
+ "probuilder/create-shape",
+ "probuilder/delete-faces",
+ "probuilder/export-mesh",
+ "probuilder/extrude-faces",
+ "probuilder/flip-normals",
+ "probuilder/info",
+ "probuilder/probuilderize",
+ "probuilder/set-face-material",
+ "probuilder/subdivide",
+ "probuilder/translate-faces",
+ "profiler/analyze",
+ "profiler/enable",
+ "profiler/frame-data",
+ "profiler/memory",
+ "profiler/memory-breakdown",
+ "profiler/memory-snapshot",
+ "profiler/memory-status",
+ "profiler/memory-top-assets",
+ "profiler/stats",
+ "project/info",
+ "renderer/set-material",
+ "scenario/activate",
+ "scenario/create",
+ "scenario/info",
+ "scenario/list",
+ "scenario/start",
+ "scenario/status",
+ "scenario/stop",
+ "scene/hierarchy",
+ "scene/info",
+ "scene/new",
+ "scene/open",
+ "scene/save",
+ "sceneview/info",
+ "sceneview/set-camera",
+ "screenshot/editor-window",
+ "screenshot/game",
+ "screenshot/scene",
+ "script/create",
+ "script/read",
+ "script/update",
+ "scriptableobject/create",
+ "scriptableobject/info",
+ "scriptableobject/list-types",
+ "scriptableobject/set-field",
+ "search/assets",
+ "search/by-component",
+ "search/by-layer",
+ "search/by-name",
+ "search/by-shader",
+ "search/by-tag",
+ "search/missing-references",
+ "search/scene-stats",
+ "selection/find-by-type",
+ "selection/focus-scene-view",
+ "selection/get",
+ "selection/set",
+ "settings/physics",
+ "settings/player",
+ "settings/quality",
+ "settings/quality-level",
+ "settings/render-pipeline",
+ "settings/set-physics",
+ "settings/set-player",
+ "settings/set-time",
+ "settings/time",
+ "shadergraph/add-node",
+ "shadergraph/connect",
+ "shadergraph/create",
+ "shadergraph/disconnect",
+ "shadergraph/get-edges",
+ "shadergraph/get-node-types",
+ "shadergraph/get-nodes",
+ "shadergraph/get-properties",
+ "shadergraph/info",
+ "shadergraph/list",
+ "shadergraph/list-shaders",
+ "shadergraph/list-subgraphs",
+ "shadergraph/list-vfx",
+ "shadergraph/open",
+ "shadergraph/open-vfx",
+ "shadergraph/remove-node",
+ "shadergraph/set-node-property",
+ "shadergraph/status",
+ "spriteatlas/add",
+ "spriteatlas/create",
+ "spriteatlas/delete",
+ "spriteatlas/info",
+ "spriteatlas/list",
+ "spriteatlas/remove",
+ "spriteatlas/settings",
+ "taglayer/add-tag",
+ "taglayer/info",
+ "taglayer/set-layer",
+ "taglayer/set-static",
+ "taglayer/set-tag",
+ "terrain/add-detail-prototype",
+ "terrain/add-layer",
+ "terrain/add-tree-prototype",
+ "terrain/clear-detail",
+ "terrain/clear-trees",
+ "terrain/create",
+ "terrain/create-grid",
+ "terrain/export-heightmap",
+ "terrain/fill-layer",
+ "terrain/flatten",
+ "terrain/get-height",
+ "terrain/get-heights-region",
+ "terrain/get-steepness",
+ "terrain/get-tree-instances",
+ "terrain/import-heightmap",
+ "terrain/info",
+ "terrain/list",
+ "terrain/noise",
+ "terrain/paint-detail",
+ "terrain/paint-layer",
+ "terrain/place-trees",
+ "terrain/raise-lower",
+ "terrain/remove-layer",
+ "terrain/remove-tree-prototype",
+ "terrain/resize",
+ "terrain/scatter-detail",
+ "terrain/set-height",
+ "terrain/set-heights-region",
+ "terrain/set-holes",
+ "terrain/set-neighbors",
+ "terrain/set-settings",
+ "terrain/smooth",
+ "testing/get-job",
+ "testing/list-tests",
+ "testing/run-tests",
+ "texture/info",
+ "texture/reimport",
+ "texture/set-import",
+ "texture/set-normalmap",
+ "texture/set-sprite",
+ "ui/create-canvas",
+ "ui/create-element",
+ "ui/info",
+ "ui/set-image",
+ "ui/set-text",
+ "uma/create-overlay",
+ "uma/create-race",
+ "uma/create-slot",
+ "uma/create-wardrobe-from-fbx",
+ "uma/create-wardrobe-recipe",
+ "uma/edit-race",
+ "uma/get-project-config",
+ "uma/inspect-fbx",
+ "uma/list-global-library",
+ "uma/list-uma-materials",
+ "uma/list-wardrobe-slots",
+ "uma/rebuild-global-library",
+ "uma/register-assets",
+ "uma/rename-asset",
+ "uma/verify-recipe",
+ "uma/wardrobe-equip",
+ "undo/clear",
+ "undo/history",
+ "undo/last",
+ "undo/perform",
+ "undo/redo",
+ };
+
+ /// Fast membership lookup used for unknown-route 404s.
+ internal static readonly HashSet KnownRoutes = new HashSet(GeneratedRoutes);
+ }
+}
diff --git a/Editor/MCPBridgeServer.Routes.g.cs.meta b/Editor/MCPBridgeServer.Routes.g.cs.meta
new file mode 100644
index 0000000..562c9d9
--- /dev/null
+++ b/Editor/MCPBridgeServer.Routes.g.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ee5ba3ed730a49bd914a9991093ac86a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPBridgeServer.cs b/Editor/MCPBridgeServer.cs
index 3c597ba..ccb6c1a 100644
--- a/Editor/MCPBridgeServer.cs
+++ b/Editor/MCPBridgeServer.cs
@@ -20,7 +20,7 @@ namespace UnityMCP.Editor
/// Both modes go through MCPRequestQueue for fair round-robin scheduling.
///
[InitializeOnLoad]
- public static class MCPBridgeServer
+ public static partial class MCPBridgeServer
{
private static HttpListener _listener;
private static Thread _listenerThread;
@@ -46,6 +46,26 @@ private static readonly Dictionary, Ac
{ "testing/list-tests", MCPTestRunnerCommands.ListTests },
};
+ // ─── Capability handshake (unity-mcp-server PRs #32/#20) ───
+ // One monotonic int, bumped whenever the bridge gains a wire-visible capability.
+ // Servers compare it to decide between fast paths and graceful fallbacks.
+ // v1: baseline — advertises the handshake itself + unknown-route 404s.
+ private const int ProtocolVersion = 1;
+
+ private static string _pluginVersion;
+ private static string PluginVersion
+ {
+ get
+ {
+ if (_pluginVersion == null)
+ {
+ var info = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(MCPBridgeServer).Assembly);
+ _pluginVersion = info != null ? info.version : "unknown";
+ }
+ return _pluginVersion;
+ }
+ }
+
// SessionState key to persist running state across domain reloads (Play Mode, recompile)
private const string WasRunningKey = "UnityMCP_WasRunningBeforeReload";
@@ -303,6 +323,54 @@ private static void ListenLoop()
// ─── Request Handler ───
+ ///
+ /// True when the request comes from a local non-browser client: loopback Host
+ /// (or none) and no cross-site Origin. Browser pages always attach their page
+ /// Origin on cross-origin fetches — the vehicle CSRF and DNS-rebinding ride on.
+ /// A "null" Origin (file:// pages, sandboxed frames) is rejected too.
+ ///
+ private static bool IsTrustedLocalRequest(HttpListenerRequest request)
+ {
+ string host = request.Headers["Host"];
+ if (!string.IsNullOrEmpty(host))
+ {
+ string hostName = host;
+ int colon = host.LastIndexOf(':');
+ bool bracketed = host.IndexOf(']') >= 0;
+ // Bare unbracketed IPv6 ("::1") has multiple colons and no brackets —
+ // don't treat its last colon as a port separator.
+ bool bareIpv6 = !bracketed && host.IndexOf(':') != colon;
+ if (!bareIpv6 && colon >= 0 && host.IndexOf(']') < colon)
+ hostName = host.Substring(0, colon);
+ hostName = hostName.Trim('[', ']');
+ if (!IsLoopbackHostName(hostName))
+ return false;
+ }
+
+ string origin = request.Headers["Origin"];
+ if (!string.IsNullOrEmpty(origin))
+ {
+ // Parse and match the origin host EXACTLY. A StartsWith("http://localhost")
+ // prefix check would let http://localhost.evil.com straight through — and
+ // with execute-code behind this guard that is a browser-reachable RCE.
+ // Uri.TryCreate also rejects the literal "null" Origin (file:// pages,
+ // sandboxed frames), which we deliberately do not trust.
+ if (!Uri.TryCreate(origin, UriKind.Absolute, out var originUri))
+ return false;
+ if (!IsLoopbackHostName(originUri.Host.Trim('[', ']')))
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool IsLoopbackHostName(string hostName)
+ {
+ return hostName == "127.0.0.1"
+ || hostName == "::1"
+ || string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase);
+ }
+
private static void HandleRequest(HttpListenerContext context)
{
var request = context.Request;
@@ -310,6 +378,18 @@ private static void HandleRequest(HttpListenerContext context)
try
{
+ // ─── Cross-origin / DNS-rebinding guard ───
+ // The bridge can execute arbitrary editor code, so only same-machine
+ // tools may talk to it. Legit clients (Node MCP server, curl) send a
+ // loopback Host and no Origin header; a browser page doing a CSRF/
+ // DNS-rebinding fetch always attaches its page Origin (or a non-loopback
+ // Host), which we reject before touching any editor state.
+ if (!IsTrustedLocalRequest(request))
+ {
+ SendJson(response, 403, new { error = "Forbidden: only local, non-browser clients may call the MCP bridge" });
+ return;
+ }
+
string path = request.Url.AbsolutePath.TrimStart('/');
if (!path.StartsWith("api/"))
{
@@ -345,25 +425,42 @@ private static void HandleRequest(HttpListenerContext context)
}
// ═══ Project Context endpoints (read-only, no queue needed) ═══
+ // Must run on the main thread: GetContextResponse reads EditorPrefs
+ // (main-thread-only), which made every context call throw HTTP 500
+ // from this ThreadPool thread (community PR #17 by rcasaleiro).
if (apiPath == "context")
{
- SendJson(response, 200, MCPContextManager.GetContextResponse());
+ SendJson(response, 200, ExecuteOnMainThread(() => MCPContextManager.GetContextResponse()));
return;
}
if (apiPath.StartsWith("context/"))
{
string category = apiPath.Substring("context/".Length);
- SendJson(response, 200, MCPContextManager.GetContextResponse(category));
+ SendJson(response, 200, ExecuteOnMainThread(() => MCPContextManager.GetContextResponse(category)));
return;
}
// ═══ Deferred paths (Unity APIs with async callbacks) ═══
- if (_deferredRoutes.TryGetValue(apiPath, out var deferredHandler))
+ // These complete via an async main-thread callback and MUST go through the async
+ // queue (queue/submit → SubmitDeferredRequest, which is non-blocking). Running one
+ // on this synchronous endpoint self-deadlocks the editor for the full sync timeout:
+ // the ticket executes on the main thread and blocks on the main-thread pump, which
+ // can't drain until the current update tick returns — but it's blocked inside it.
+ // The Node server always uses queue/submit; this guard only trips a raw/legacy
+ // direct POST, turning a 30s hang into an actionable error.
+ if (_deferredRoutes.ContainsKey(apiPath))
{
- var result = MCPRequestQueue.ExecuteWithTracking(agentId, apiPath,
- () => ExecuteOnMainThreadDeferred(resolve =>
- deferredHandler(ParseJson(body), resolve)));
- SendJson(response, 200, result);
+ SendJson(response, 409, new { error = $"Route '{apiPath}' must be called via the async queue (POST /api/queue/submit), not the synchronous endpoint." });
+ return;
+ }
+
+ // ═══ Unknown routes: 404 before dispatch (capability handshake) ═══
+ // Lets servers distinguish "this plugin doesn't have that feature"
+ // from a failed call and degrade gracefully. KnownRoutes is generated
+ // from the dispatch switch (tools~/generate-routes.mjs, CI-checked).
+ if (!KnownRoutes.Contains(apiPath))
+ {
+ SendJson(response, 404, new { error = $"Unknown route: {apiPath}" });
return;
}
@@ -376,7 +473,9 @@ private static void HandleRequest(HttpListenerContext context)
}
catch (Exception ex)
{
- SendJson(response, 500, new { error = ex.Message, stackTrace = ex.StackTrace });
+ // Full stack trace goes to the editor log only — never to the wire.
+ Debug.LogError($"[AB-UMCP] Request failed: {ex.Message}\n{ex.StackTrace}");
+ SendJson(response, 500, new { error = ex.Message });
}
}
@@ -463,87 +562,12 @@ private static string ExtractCategory(string path)
///
private static object GetRegisteredRoutes()
{
- // We collect routes by reflecting on the switch cases in RouteRequest.
- // Since C# doesn't easily let us introspect switch cases at runtime,
- // we maintain a static list of all registered route prefixes/categories.
- var routes = new List
- {
- "ping",
- "editor/state", "editor/play-mode", "editor/execute-menu-item", "editor/undo", "editor/redo", "editor/undo-history",
- "scene/info", "scene/open", "scene/save", "scene/new", "scene/hierarchy", "scene/stats",
- "gameobject/create", "gameobject/delete", "gameobject/info", "gameobject/set-transform",
- "gameobject/duplicate", "gameobject/set-active", "gameobject/reparent",
- "component/add", "component/remove", "component/get-properties", "component/set-property",
- "component/set-reference", "component/batch-wire", "component/get-referenceable",
- "asset/list", "asset/import", "asset/delete", "asset/create-prefab", "asset/instantiate-prefab",
- "script/create", "script/read", "script/update", "script/execute-code",
- "material/create", "material/set-material",
- "build/build", "build/play-mode",
- "console/log", "console/clear",
- "compilation/errors",
- "selection/get", "selection/set", "selection/focus-scene-view", "selection/find-by-type",
- "search/by-component", "search/by-tag", "search/by-layer", "search/by-name",
- "search/assets", "search/missing-references",
- "screenshot/game", "screenshot/scene",
- "prefab/info", "prefab/set-object-reference",
- "packages/list", "packages/add", "packages/remove", "packages/search", "packages/info",
- "project/info",
- // Animation
- "animation/create-controller", "animation/get-controller", "animation/add-state",
- "animation/remove-state", "animation/add-transition", "animation/remove-transition",
- "animation/set-parameter", "animation/remove-parameter", "animation/get-parameters",
- "animation/create-clip", "animation/set-clip-curve", "animation/get-clip-info",
- "animation/set-state-motion", "animation/add-layer", "animation/remove-layer",
- "animation/get-layers", "animation/set-default-state", "animation/add-blend-tree",
- // Physics
- "physics/raycast", "physics/overlap-sphere", "physics/settings",
- "physics/add-joint", "physics/get-joint", "physics/set-joint",
- // Audio
- "audio/play", "audio/stop", "audio/get-info", "audio/set-property",
- // UI
- "ui/create-canvas", "ui/add-element", "ui/set-rect", "ui/set-text",
- "ui/set-image", "ui/set-button", "ui/get-hierarchy",
- // Lighting
- "lighting/create", "lighting/set-property", "lighting/bake", "lighting/get-settings",
- "lighting/set-settings", "lighting/get-probes",
- // NavMesh
- "navmesh/bake", "navmesh/add-agent", "navmesh/set-area", "navmesh/get-info",
- "navmesh/add-obstacle", "navmesh/add-link",
- // ShaderGraph
- "shadergraph/create", "shadergraph/get-info", "shadergraph/add-node",
- "shadergraph/remove-node", "shadergraph/connect", "shadergraph/disconnect",
- "shadergraph/set-property", "shadergraph/list-nodes", "shadergraph/get-connections",
- // Amplify
- "amplify/list", "amplify/info", "amplify/open", "amplify/list-functions",
- "amplify/get-node-types", "amplify/get-nodes", "amplify/get-connections",
- "amplify/create-shader", "amplify/add-node", "amplify/remove-node",
- "amplify/connect", "amplify/disconnect", "amplify/node-info",
- "amplify/set-node-property", "amplify/move-node",
- // Graphics
- "graphics/camera-info", "graphics/render-settings", "graphics/set-render-settings",
- "graphics/texture-info", "graphics/renderer-info", "graphics/lighting-summary",
- // Terrain
- "terrain/create", "terrain/info", "terrain/set-height", "terrain/flatten",
- "terrain/add-layer", "terrain/get-height", "terrain/list",
- "terrain/raise-lower", "terrain/smooth", "terrain/noise",
- "terrain/set-heights-region", "terrain/get-heights-region",
- "terrain/remove-layer", "terrain/paint-layer", "terrain/fill-layer",
- "terrain/add-tree-prototype", "terrain/remove-tree-prototype",
- "terrain/place-trees", "terrain/clear-trees", "terrain/get-tree-instances",
- "terrain/add-detail-prototype", "terrain/paint-detail",
- "terrain/scatter-detail", "terrain/clear-detail",
- "terrain/set-holes", "terrain/set-settings", "terrain/resize",
- "terrain/create-grid", "terrain/set-neighbors",
- "terrain/import-heightmap", "terrain/export-heightmap", "terrain/get-steepness",
- // Particle System
- "particle/create", "particle/info", "particle/set-main", "particle/set-emission",
- "particle/set-shape", "particle/set-velocity", "particle/set-color",
- "particle/set-size", "particle/set-renderer",
- };
-
- // Group by category
+ // The route list is GENERATED from the RouteRequest dispatch switch by
+ // tools~/generate-routes.mjs into MCPBridgeServer.Routes.g.cs (CI-checked).
+ // The previous hand-maintained list had drifted to ~150 of ~320 routes
+ // with several wrong names, silently breaking dynamic tool discovery.
var grouped = new Dictionary>();
- foreach (var route in routes)
+ foreach (var route in GeneratedRoutes)
{
string cat = ExtractCategory(route);
if (!grouped.ContainsKey(cat)) grouped[cat] = new List();
@@ -552,9 +576,9 @@ private static object GetRegisteredRoutes()
return new Dictionary
{
- { "routes", routes },
+ { "routes", GeneratedRoutes },
{ "categories", grouped },
- { "totalRoutes", routes.Count }
+ { "totalRoutes", GeneratedRoutes.Length }
};
}
@@ -592,7 +616,12 @@ private static object RouteRequest(string path, string method, string body)
platform = Application.platform.ToString(),
isClone = MCPInstanceRegistry.IsParrelSyncClone(),
cloneIndex = MCPInstanceRegistry.GetParrelSyncCloneIndex(),
- processId = System.Diagnostics.Process.GetCurrentProcess().Id
+ processId = System.Diagnostics.Process.GetCurrentProcess().Id,
+ // Capability handshake: servers gate newer wire features on this
+ // monotonic int so the pair degrades gracefully across version
+ // drift (server and plugin ship on separate release trains).
+ protocolVersion = ProtocolVersion,
+ pluginVersion = PluginVersion
};
// ─── Editor State ───
@@ -1050,6 +1079,8 @@ private static object RouteRequest(string path, string method, string body)
// ─── Undo ───
case "undo/perform":
return MCPUndoCommands.PerformUndo(ParseJson(body));
+ case "undo/last":
+ return MCPUndoCommands.UndoLast(ParseJson(body));
case "undo/redo":
return MCPUndoCommands.PerformRedo(ParseJson(body));
case "undo/history":
@@ -1057,6 +1088,36 @@ private static object RouteRequest(string path, string method, string body)
case "undo/clear":
return MCPUndoCommands.ClearUndo(ParseJson(body));
+ // ─── ProBuilder ───
+ case "probuilder/create-shape":
+ return MCPProBuilderCommands.CreateShape(ParseJson(body));
+ case "probuilder/info":
+ return MCPProBuilderCommands.GetInfo(ParseJson(body));
+ case "probuilder/extrude-faces":
+ return MCPProBuilderCommands.ExtrudeFaces(ParseJson(body));
+ case "probuilder/bevel-edges":
+ return MCPProBuilderCommands.BevelEdges(ParseJson(body));
+ case "probuilder/subdivide":
+ return MCPProBuilderCommands.Subdivide(ParseJson(body));
+ case "probuilder/delete-faces":
+ return MCPProBuilderCommands.DeleteFaces(ParseJson(body));
+ case "probuilder/translate-faces":
+ return MCPProBuilderCommands.TranslateFaces(ParseJson(body));
+ case "probuilder/flip-normals":
+ return MCPProBuilderCommands.FlipNormals(ParseJson(body));
+ case "probuilder/set-face-material":
+ return MCPProBuilderCommands.SetFaceMaterial(ParseJson(body));
+ case "probuilder/boolean":
+ return MCPProBuilderCommands.BooleanOp(ParseJson(body));
+ case "probuilder/combine":
+ return MCPProBuilderCommands.Combine(ParseJson(body));
+ case "probuilder/probuilderize":
+ return MCPProBuilderCommands.ProBuilderize(ParseJson(body));
+ case "probuilder/center-pivot":
+ return MCPProBuilderCommands.CenterPivot(ParseJson(body));
+ case "probuilder/export-mesh":
+ return MCPProBuilderCommands.ExportMesh(ParseJson(body));
+
// ─── Screenshot / Scene View ───
case "screenshot/game":
return MCPScreenshotCommands.CaptureGameView(ParseJson(body));
@@ -1380,49 +1441,12 @@ private static object ExecuteOnMainThread(Func action)
return new { error = $"Timeout waiting for Unity main thread after {MCPRequestQueue.SyncTimeoutMs / 1000}s" };
if (exception != null)
- return new { error = exception.Message, stackTrace = exception.StackTrace };
-
- return result;
- }
-
- ///
- /// Execute an action on the main thread that completes asynchronously via callback.
- /// Unlike ExecuteOnMainThread, the calling thread blocks until the resolve callback
- /// is invoked — not when the action returns. Use for Unity APIs whose callbacks
- /// fire on a subsequent editor frame (e.g. TestRunnerApi.RetrieveTestList).
- ///
- private static object ExecuteOnMainThreadDeferred(Action> asyncAction)
- {
- object result = null;
- Exception exception = null;
- var resetEvent = new ManualResetEventSlim(false);
-
- lock (_mainThreadQueue)
{
- _mainThreadQueue.Enqueue(() =>
- {
- try
- {
- asyncAction(r =>
- {
- result = r;
- resetEvent.Set();
- });
- }
- catch (Exception ex)
- {
- exception = ex;
- resetEvent.Set();
- }
- });
+ // Trace goes to the editor log only — never to the wire.
+ Debug.LogError($"[AB-UMCP] Main-thread execution failed: {exception.Message}\n{exception.StackTrace}");
+ return new { error = exception.Message };
}
- if (!resetEvent.Wait(MCPRequestQueue.SyncTimeoutMs))
- return new { error = $"Timeout waiting for Unity callback after {MCPRequestQueue.SyncTimeoutMs / 1000}s" };
-
- if (exception != null)
- return new { error = exception.Message, stackTrace = exception.StackTrace };
-
return result;
}
diff --git a/Editor/MCPComponentCommands.cs b/Editor/MCPComponentCommands.cs
index 0d55e64..7a73a12 100755
--- a/Editor/MCPComponentCommands.cs
+++ b/Editor/MCPComponentCommands.cs
@@ -575,6 +575,15 @@ internal static object GetSerializedValue(SerializedProperty prop)
}
}
+ /// Short human description of a value's shape for error messages.
+ private static string DescribeValue(object value)
+ {
+ if (value == null) return "null";
+ if (value is string s) return $"string \"{s}\"";
+ if (value is System.Collections.IList) return "an array";
+ return $"{value.GetType().Name} ({value})";
+ }
+
internal static void SetSerializedValue(SerializedProperty prop, object value)
{
switch (prop.propertyType)
@@ -593,36 +602,40 @@ internal static void SetSerializedValue(SerializedProperty prop, object value)
break;
case SerializedPropertyType.Color:
var cd = value as Dictionary;
- if (cd != null)
- prop.colorValue = new Color(
- Convert.ToSingle(cd.GetValueOrDefault("r", 0f)),
- Convert.ToSingle(cd.GetValueOrDefault("g", 0f)),
- Convert.ToSingle(cd.GetValueOrDefault("b", 0f)),
- Convert.ToSingle(cd.GetValueOrDefault("a", 1f)));
+ if (cd == null)
+ throw new ArgumentException($"Color property '{prop.name}' expects an object {{r,g,b,a}}, got {DescribeValue(value)}.");
+ prop.colorValue = new Color(
+ Convert.ToSingle(cd.GetValueOrDefault("r", 0f)),
+ Convert.ToSingle(cd.GetValueOrDefault("g", 0f)),
+ Convert.ToSingle(cd.GetValueOrDefault("b", 0f)),
+ Convert.ToSingle(cd.GetValueOrDefault("a", 1f)));
break;
case SerializedPropertyType.Vector2:
var v2d = value as Dictionary;
- if (v2d != null)
- prop.vector2Value = new Vector2(
- Convert.ToSingle(v2d.GetValueOrDefault("x", 0f)),
- Convert.ToSingle(v2d.GetValueOrDefault("y", 0f)));
+ if (v2d == null)
+ throw new ArgumentException($"Vector2 property '{prop.name}' expects an object {{x,y}}, got {DescribeValue(value)}.");
+ prop.vector2Value = new Vector2(
+ Convert.ToSingle(v2d.GetValueOrDefault("x", 0f)),
+ Convert.ToSingle(v2d.GetValueOrDefault("y", 0f)));
break;
case SerializedPropertyType.Vector3:
var vd = value as Dictionary;
- if (vd != null)
- prop.vector3Value = new Vector3(
- Convert.ToSingle(vd.GetValueOrDefault("x", 0f)),
- Convert.ToSingle(vd.GetValueOrDefault("y", 0f)),
- Convert.ToSingle(vd.GetValueOrDefault("z", 0f)));
+ if (vd == null)
+ throw new ArgumentException($"Vector3 property '{prop.name}' expects an object {{x,y,z}}, got {DescribeValue(value)}.");
+ prop.vector3Value = new Vector3(
+ Convert.ToSingle(vd.GetValueOrDefault("x", 0f)),
+ Convert.ToSingle(vd.GetValueOrDefault("y", 0f)),
+ Convert.ToSingle(vd.GetValueOrDefault("z", 0f)));
break;
case SerializedPropertyType.Vector4:
var v4d = value as Dictionary;
- if (v4d != null)
- prop.vector4Value = new Vector4(
- Convert.ToSingle(v4d.GetValueOrDefault("x", 0f)),
- Convert.ToSingle(v4d.GetValueOrDefault("y", 0f)),
- Convert.ToSingle(v4d.GetValueOrDefault("z", 0f)),
- Convert.ToSingle(v4d.GetValueOrDefault("w", 0f)));
+ if (v4d == null)
+ throw new ArgumentException($"Vector4 property '{prop.name}' expects an object {{x,y,z,w}}, got {DescribeValue(value)}.");
+ prop.vector4Value = new Vector4(
+ Convert.ToSingle(v4d.GetValueOrDefault("x", 0f)),
+ Convert.ToSingle(v4d.GetValueOrDefault("y", 0f)),
+ Convert.ToSingle(v4d.GetValueOrDefault("z", 0f)),
+ Convert.ToSingle(v4d.GetValueOrDefault("w", 0f)));
break;
case SerializedPropertyType.Enum:
if (value is string enumName)
@@ -640,12 +653,13 @@ internal static void SetSerializedValue(SerializedProperty prop, object value)
break;
case SerializedPropertyType.Rect:
var rd = value as Dictionary;
- if (rd != null)
- prop.rectValue = new Rect(
- Convert.ToSingle(rd.GetValueOrDefault("x", 0f)),
- Convert.ToSingle(rd.GetValueOrDefault("y", 0f)),
- Convert.ToSingle(rd.GetValueOrDefault("width", 0f)),
- Convert.ToSingle(rd.GetValueOrDefault("height", 0f)));
+ if (rd == null)
+ throw new ArgumentException($"Rect property '{prop.name}' expects an object {{x,y,width,height}}, got {DescribeValue(value)}.");
+ prop.rectValue = new Rect(
+ Convert.ToSingle(rd.GetValueOrDefault("x", 0f)),
+ Convert.ToSingle(rd.GetValueOrDefault("y", 0f)),
+ Convert.ToSingle(rd.GetValueOrDefault("width", 0f)),
+ Convert.ToSingle(rd.GetValueOrDefault("height", 0f)));
break;
case SerializedPropertyType.ObjectReference:
prop.objectReferenceValue = ResolveObjectReference(value);
diff --git a/Editor/MCPDashboardStyles.uss b/Editor/MCPDashboardStyles.uss
new file mode 100644
index 0000000..3d1c202
--- /dev/null
+++ b/Editor/MCPDashboardStyles.uss
@@ -0,0 +1,178 @@
+/* AnkleBreaker MCP Dashboard styles — layered on top of the shared brand sheet
+ * (UnityMcpWelcomeTheme.uss: warm-brown bg, molten-orange accent #d97a2a/#f4a047).
+ * BEM: block .ab-dash, elements __x, modifiers --y. */
+
+.hidden {
+ display: none;
+}
+
+.ab-dash__scroll {
+ flex-grow: 1;
+ padding: 10px 12px;
+}
+
+/* ---- Status dots ---------------------------------------------------------- */
+
+.ab-dash__dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 5px;
+ margin-right: 7px;
+ flex-shrink: 0;
+ background-color: rgb(128, 128, 128);
+}
+
+.ab-dash__dot--green { background-color: rgb(74, 201, 93); }
+.ab-dash__dot--red { background-color: rgb(226, 74, 62); }
+.ab-dash__dot--yellow { background-color: rgb(230, 204, 43); }
+.ab-dash__dot--grey { background-color: rgb(128, 128, 128); }
+.ab-dash__dot--blue { background-color: rgb(102, 179, 255); }
+.ab-dash__dot--accent { background-color: rgb(244, 160, 71); }
+
+/* ---- Rows / text ---------------------------------------------------------- */
+
+.ab-dash__row {
+ flex-direction: row;
+ align-items: center;
+ margin-bottom: 3px;
+}
+
+.ab-dash__grow {
+ flex-grow: 1;
+}
+
+.ab-dash__label {
+ color: rgb(232, 224, 214);
+}
+
+.ab-dash__bold {
+ -unity-font-style: bold;
+ color: rgb(238, 230, 222);
+}
+
+.ab-dash__mini {
+ font-size: 10px;
+ color: rgba(232, 224, 214, 0.62);
+}
+
+.ab-dash__accent-text {
+ color: rgb(244, 160, 71);
+}
+
+.ab-dash__blue-text {
+ color: rgb(102, 179, 255);
+}
+
+.ab-dash__green-text { color: rgb(74, 201, 93); }
+.ab-dash__red-text { color: rgb(226, 74, 62); }
+.ab-dash__yellow-text { color: rgb(230, 204, 43); }
+
+/* ---- Foldout sections ----------------------------------------------------- */
+
+.ab-window .unity-foldout__text {
+ -unity-font-style: bold;
+ font-size: 12px;
+ color: rgb(238, 230, 222);
+}
+
+.ab-window .unity-foldout__toggle {
+ margin-bottom: 2px;
+}
+
+.ab-dash__section {
+ margin-bottom: 10px;
+}
+
+/* ---- Badges / pills ------------------------------------------------------- */
+
+.ab-badge {
+ -unity-text-align: middle-center;
+ -unity-font-style: bold;
+ font-size: 9px;
+ color: rgb(31, 23, 15);
+ background-color: rgb(244, 160, 71);
+ border-radius: 7px;
+ padding: 1px 5px;
+ margin-left: 6px;
+}
+
+.ab-chip {
+ font-size: 9px;
+ color: rgba(244, 160, 71, 0.9);
+ background-color: rgba(217, 122, 42, 0.14);
+ border-width: 1px;
+ border-color: rgba(217, 122, 42, 0.35);
+ border-radius: 3px;
+ padding: 0 4px;
+ margin-left: 6px;
+}
+
+/* ---- News ----------------------------------------------------------------- */
+
+.ab-news__item {
+ flex-direction: row;
+ align-items: center;
+ padding: 5px 6px;
+ border-radius: 4px;
+ margin-bottom: 2px;
+ border-left-width: 2px;
+ border-left-color: rgba(0, 0, 0, 0);
+}
+
+.ab-news__item:hover {
+ background-color: rgba(217, 122, 42, 0.14);
+}
+
+.ab-news__item--unseen {
+ border-left-color: rgb(244, 160, 71);
+ background-color: rgba(217, 122, 42, 0.08);
+}
+
+.ab-news__title {
+ color: rgb(235, 227, 217);
+ flex-shrink: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* Truncate-with-ellipsis for any row label that must never push its neighbors. */
+.ab-dash__ellipsis {
+ flex-shrink: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.ab-news__item--unseen .ab-news__title {
+ -unity-font-style: bold;
+ color: rgb(244, 160, 71);
+}
+
+/* ---- Progress ------------------------------------------------------------- */
+
+.ab-dash__progress-track {
+ height: 6px;
+ border-radius: 3px;
+ background-color: rgba(217, 122, 42, 0.15);
+ flex-grow: 1;
+ margin-left: 8px;
+ overflow: hidden;
+}
+
+.ab-dash__progress-fill {
+ height: 6px;
+ background-color: rgb(217, 122, 42);
+}
+
+/* ---- Details boxes -------------------------------------------------------- */
+
+.ab-dash__details {
+ background-color: rgba(0, 0, 0, 0.25);
+ border-radius: 4px;
+ padding: 6px 8px;
+ margin: 2px 0 6px 22px;
+ white-space: normal;
+ font-size: 10px;
+ color: rgba(232, 224, 214, 0.8);
+}
diff --git a/Editor/MCPDashboardStyles.uss.meta b/Editor/MCPDashboardStyles.uss.meta
new file mode 100644
index 0000000..a748b01
--- /dev/null
+++ b/Editor/MCPDashboardStyles.uss.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9e5a7c3f14b84d2a86e0f9d1c7b35503
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
diff --git a/Editor/MCPDashboardWindow.cs b/Editor/MCPDashboardWindow.cs
index 018c881..8e42488 100644
--- a/Editor/MCPDashboardWindow.cs
+++ b/Editor/MCPDashboardWindow.cs
@@ -1,788 +1,847 @@
-using System.Collections.Generic;
-using UnityEditor;
-using UnityEngine;
-
-namespace UnityMCP.Editor
-{
- ///
- /// Editor window providing an overview of AB Unity MCP status, feature categories,
- /// server controls, queue monitoring, settings, and active agent sessions.
- /// Accessible via Window > AB Unity MCP.
- ///
- public class MCPDashboardWindow : EditorWindow
- {
- private Vector2 _scrollPosition;
- private bool _settingsFoldout = false;
- private bool _agentsFoldout = true;
- private bool _categoriesFoldout = true;
- private bool _queueFoldout = true;
- private bool _contextFoldout = true;
- private bool _recentActionsFoldout = true;
- private string _expandedTestCategory = null;
-
- private static readonly Color ColorGreen = new Color(0.2f, 0.8f, 0.2f);
- private static readonly Color ColorRed = new Color(0.9f, 0.2f, 0.2f);
- private static readonly Color ColorYellow = new Color(0.9f, 0.8f, 0.1f);
- private static readonly Color ColorGrey = new Color(0.5f, 0.5f, 0.5f);
- private static readonly Color ColorBlue = new Color(0.4f, 0.7f, 1.0f);
-
- private GUIStyle _headerStyle;
- private GUIStyle _subHeaderStyle;
- private GUIStyle _dotStyle;
- private bool _stylesInitialized;
-
- [MenuItem("Window/AB Unity MCP")]
- public static void ShowWindow()
- {
- var window = GetWindow("AB Unity MCP");
- window.minSize = new Vector2(340, 500);
- }
-
- private void InitStyles()
- {
- if (_stylesInitialized) return;
-
- _headerStyle = new GUIStyle(EditorStyles.largeLabel)
- {
- fontSize = 16,
- fontStyle = FontStyle.Bold,
- };
-
- _subHeaderStyle = new GUIStyle(EditorStyles.boldLabel)
- {
- fontSize = 12,
- };
-
- _dotStyle = new GUIStyle(EditorStyles.label)
- {
- fontSize = 18,
- alignment = TextAnchor.MiddleCenter,
- fixedWidth = 22,
- };
-
- _stylesInitialized = true;
- }
-
- private void OnInspectorUpdate()
- {
- // Repaint periodically for live status
- Repaint();
- }
-
- private void OnGUI()
- {
- InitStyles();
- _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
-
- DrawHeader();
- EditorGUILayout.Space(6);
- DrawConnectionStatus();
- EditorGUILayout.Space(4);
- DrawServerControls();
- EditorGUILayout.Space(8);
- DrawQueueStatus();
- EditorGUILayout.Space(8);
- DrawProjectContext();
- EditorGUILayout.Space(8);
- DrawAgentSessions();
- EditorGUILayout.Space(8);
- DrawRecentActions();
- EditorGUILayout.Space(8);
- DrawCategoryStatus();
- EditorGUILayout.Space(8);
- DrawSettings();
- EditorGUILayout.Space(8);
- DrawVersionInfo();
-
- EditorGUILayout.EndScrollView();
- }
-
- // ─── Header ───
-
- private void DrawHeader()
- {
- EditorGUILayout.BeginHorizontal();
- GUILayout.FlexibleSpace();
- EditorGUILayout.LabelField("AnkleBreaker Unity MCP", _headerStyle, GUILayout.Height(28));
- GUILayout.FlexibleSpace();
- EditorGUILayout.EndHorizontal();
- }
-
- // ─── Connection Status ───
-
- private void DrawConnectionStatus()
- {
- bool running = MCPBridgeServer.IsRunning;
-
- EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
-
- // Status dot
- var prevColor = GUI.color;
- GUI.color = running ? ColorGreen : ColorRed;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- EditorGUILayout.LabelField(
- running ? "Server Running" : "Server Stopped",
- EditorStyles.boldLabel);
-
- GUILayout.FlexibleSpace();
-
- // Show actual active port when running, settings port when stopped
- int displayPort = running ? MCPBridgeServer.ActivePort : MCPSettingsManager.Port;
- string portLabel = running && !MCPSettingsManager.UseManualPort
- ? $"Port {displayPort} (auto)"
- : $"Port {displayPort}";
- EditorGUILayout.LabelField(portLabel, GUILayout.Width(100));
-
- // Cache values once per event to prevent Layout/Repaint mismatch.
- // Using local bools ensures the same controls exist in both passes.
- int agents = MCPRequestQueue.ActiveSessionCount;
- int queued = MCPRequestQueue.TotalQueuedCount;
- bool showAgents = agents > 0;
- bool showQueued = queued > 0;
-
- // Always draw the same number of controls regardless of state —
- // hide them with alpha when inactive to avoid IMGUI control count mismatch.
- var savedAlpha = GUI.color.a;
-
- // Agent count indicator
- GUI.color = showAgents ? ColorGreen : new Color(0, 0, 0, 0);
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = showAgents ? new Color(prevColor.r, prevColor.g, prevColor.b, savedAlpha) : new Color(0, 0, 0, 0);
- EditorGUILayout.LabelField(showAgents ? $"{agents} agent{(agents > 1 ? "s" : "")}" : "", GUILayout.Width(65));
-
- // Queue count indicator
- GUI.color = showQueued ? ColorYellow : new Color(0, 0, 0, 0);
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = showQueued ? new Color(prevColor.r, prevColor.g, prevColor.b, savedAlpha) : new Color(0, 0, 0, 0);
- EditorGUILayout.LabelField(showQueued ? $"{queued} queued" : "", GUILayout.Width(65));
-
- GUI.color = prevColor;
-
- EditorGUILayout.EndHorizontal();
-
- // ParrelSync clone indicator (shown below the main status bar)
- if (MCPInstanceRegistry.IsParrelSyncClone())
- {
- EditorGUILayout.BeginHorizontal();
- GUILayout.Space(24);
- var cloneStyle = new GUIStyle(EditorStyles.miniLabel)
- {
- normal = { textColor = ColorBlue },
- fontStyle = FontStyle.Italic,
- };
- int cloneIdx = MCPInstanceRegistry.GetParrelSyncCloneIndex();
- EditorGUILayout.LabelField(
- $"\u2937 ParrelSync Clone #{cloneIdx}",
- cloneStyle);
- EditorGUILayout.EndHorizontal();
- }
- }
-
- // ─── Server Controls ───
-
- private void DrawServerControls()
- {
- EditorGUILayout.BeginHorizontal();
-
- bool running = MCPBridgeServer.IsRunning;
-
- GUI.enabled = !running;
- if (GUILayout.Button("Start", GUILayout.Height(24)))
- MCPBridgeServer.Start();
-
- GUI.enabled = running;
- if (GUILayout.Button("Stop", GUILayout.Height(24)))
- MCPBridgeServer.Stop();
-
- GUI.enabled = true;
- if (GUILayout.Button("Restart", GUILayout.Height(24)))
- {
- MCPBridgeServer.Stop();
- EditorApplication.delayCall += () => MCPBridgeServer.Start();
- }
-
- EditorGUILayout.EndHorizontal();
- }
-
- // ─── Queue Status (Multi-Agent) ───
-
- private void DrawQueueStatus()
- {
- _queueFoldout = EditorGUILayout.Foldout(_queueFoldout, "Request Queue", true, EditorStyles.foldoutHeader);
- if (!_queueFoldout) return;
-
- var queueInfo = MCPRequestQueue.GetQueueInfo();
-
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- // Summary row
- EditorGUILayout.BeginHorizontal();
-
- int totalQueued = 0;
- if (queueInfo.ContainsKey("totalQueued"))
- int.TryParse(queueInfo["totalQueued"].ToString(), out totalQueued);
-
- int activeAgents = 0;
- if (queueInfo.ContainsKey("activeAgents"))
- int.TryParse(queueInfo["activeAgents"].ToString(), out activeAgents);
-
- int cacheSize = 0;
- if (queueInfo.ContainsKey("completedCacheSize"))
- int.TryParse(queueInfo["completedCacheSize"].ToString(), out cacheSize);
-
- var prevColor = GUI.color;
- GUI.color = totalQueued > 0 ? ColorYellow : ColorGreen;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- string statusText = totalQueued > 0
- ? $"{totalQueued} pending | {activeAgents} agents | {cacheSize} cached"
- : $"Idle | {activeAgents} agents | {cacheSize} cached";
- EditorGUILayout.LabelField(statusText, EditorStyles.miniLabel);
-
- EditorGUILayout.EndHorizontal();
-
- // Per-agent breakdown (if any queued)
- if (queueInfo.ContainsKey("perAgentQueued") && queueInfo["perAgentQueued"] is Dictionary perAgent)
- {
- if (perAgent.Count > 0)
- {
- EditorGUILayout.Space(2);
- EditorGUILayout.LabelField("Per-Agent Queue Depth:", EditorStyles.miniLabel);
-
- foreach (var kvp in perAgent)
- {
- EditorGUILayout.BeginHorizontal();
- GUILayout.Space(24);
-
- int depth = 0;
- int.TryParse(kvp.Value.ToString(), out depth);
-
- var agentColor = depth > 0 ? ColorYellow : ColorGreen;
- GUI.color = agentColor;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- EditorGUILayout.LabelField(kvp.Key, GUILayout.Width(160));
- EditorGUILayout.LabelField($"{depth} pending", GUILayout.Width(80));
-
- EditorGUILayout.EndHorizontal();
- }
- }
- }
-
- EditorGUILayout.EndVertical();
- }
-
- // ─── Project Context ───
-
- private void DrawProjectContext()
- {
- _contextFoldout = EditorGUILayout.Foldout(_contextFoldout, "Project Context", true, EditorStyles.foldoutHeader);
- if (!_contextFoldout) return;
-
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- // Enabled toggle
- EditorGUILayout.BeginHorizontal();
- bool enabled = EditorGUILayout.Toggle("Enable Context", MCPSettingsManager.ContextEnabled);
- if (enabled != MCPSettingsManager.ContextEnabled)
- MCPSettingsManager.ContextEnabled = enabled;
- GUILayout.FlexibleSpace();
-
- // Buttons
- if (GUILayout.Button("Create Templates", GUILayout.Width(110), GUILayout.Height(18)))
- {
- int created = MCPContextManager.CreateDefaultTemplates();
- if (created > 0)
- EditorUtility.DisplayDialog("Templates Created",
- $"Created {created} template file(s) in:\n{MCPSettingsManager.ContextPath}", "OK");
- else
- EditorUtility.DisplayDialog("Templates Exist",
- "All template files already exist.", "OK");
- }
-
- if (GUILayout.Button("Open Folder", GUILayout.Width(90), GUILayout.Height(18)))
- {
- string folderPath = MCPContextManager.GetContextFolderPath();
- if (System.IO.Directory.Exists(folderPath))
- EditorUtility.RevealInFinder(folderPath);
- else
- EditorUtility.DisplayDialog("Folder Not Found",
- $"Context folder does not exist yet.\nClick 'Create Templates' to set it up.\n\n{folderPath}", "OK");
- }
-
- EditorGUILayout.EndHorizontal();
-
- if (!enabled)
- {
- EditorGUILayout.HelpBox("Project context is disabled. Agents will not receive project documentation.", MessageType.Info);
- EditorGUILayout.EndVertical();
- return;
- }
-
- // Path display
- EditorGUILayout.LabelField("Path:", MCPSettingsManager.ContextPath, EditorStyles.miniLabel);
-
- // File list
- var files = MCPContextManager.GetContextFileList();
- bool anyFiles = false;
-
- foreach (var file in files)
- {
- if (!file.IsStandard && !file.Exists) continue; // Don't show missing custom files
-
- anyFiles = true;
- EditorGUILayout.BeginHorizontal();
-
- var prevColor = GUI.color;
- if (file.Exists && file.SizeBytes > 0)
- GUI.color = ColorGreen;
- else if (file.Exists)
- GUI.color = ColorYellow;
- else
- GUI.color = ColorGrey;
-
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- string displayName = file.Category;
- EditorGUILayout.LabelField(displayName, GUILayout.MinWidth(140));
-
- if (file.Exists)
- {
- string sizeLabel = file.SizeBytes > 1024
- ? $"{file.SizeBytes / 1024f:0.#} KB"
- : $"{file.SizeBytes} B";
- EditorGUILayout.LabelField(
- file.SizeBytes == 0 ? "empty" : sizeLabel,
- EditorStyles.miniLabel, GUILayout.Width(60));
- }
- else
- {
- EditorGUILayout.LabelField("not created", EditorStyles.miniLabel, GUILayout.Width(60));
- }
-
- GUILayout.FlexibleSpace();
- EditorGUILayout.EndHorizontal();
- }
-
- if (!anyFiles)
- {
- EditorGUILayout.HelpBox(
- "No context files found. Click 'Create Templates' to get started.",
- MessageType.Info);
- }
-
- EditorGUILayout.EndVertical();
- }
-
- // ─── Recent Actions ───
-
- private void DrawRecentActions()
- {
- _recentActionsFoldout = EditorGUILayout.Foldout(_recentActionsFoldout, "Recent Actions", true, EditorStyles.foldoutHeader);
- if (!_recentActionsFoldout) return;
-
- var recent = MCPActionHistory.GetRecent(8);
-
- if (recent.Count == 0)
- {
- EditorGUILayout.HelpBox("No actions recorded yet.", MessageType.Info);
- return;
- }
-
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- // Show newest first
- for (int i = recent.Count - 1; i >= 0; i--)
- {
- var r = recent[i];
- EditorGUILayout.BeginHorizontal();
-
- // Status dot
- var prevColor = GUI.color;
- Color dotColor;
- switch (r.Status)
- {
- case "Completed": dotColor = ColorGreen; break;
- case "Failed": dotColor = ColorRed; break;
- default: dotColor = ColorYellow; break;
- }
- GUI.color = dotColor;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- // Timestamp
- EditorGUILayout.LabelField(r.Timestamp.ToString("HH:mm:ss"),
- EditorStyles.miniLabel, GUILayout.Width(55));
-
- // Agent (short)
- string agent = r.AgentId ?? "?";
- if (agent.Length > 10) agent = agent.Substring(0, 8) + "..";
- prevColor = GUI.color;
- GUI.color = ColorBlue;
- EditorGUILayout.LabelField(agent, EditorStyles.miniLabel, GUILayout.Width(65));
- GUI.color = prevColor;
-
- // Action command
- string cmd = MCPActionRecord.ExtractCommand(r.ActionName);
- EditorGUILayout.LabelField(cmd, EditorStyles.miniLabel, GUILayout.Width(100));
-
- // Target (truncated)
- string target = r.TargetPath ?? "";
- if (target.Length > 25)
- target = ".." + target.Substring(target.Length - 23);
- EditorGUILayout.LabelField(target, EditorStyles.miniLabel);
-
- GUILayout.FlexibleSpace();
- EditorGUILayout.EndHorizontal();
- }
-
- // Open full history button
- EditorGUILayout.Space(2);
- EditorGUILayout.BeginHorizontal();
- GUILayout.FlexibleSpace();
- string btnLabel = MCPActionHistory.Count > 8
- ? $"Open Full History ({MCPActionHistory.Count} actions)"
- : "Open Full History";
- if (GUILayout.Button(btnLabel, GUILayout.Width(200), GUILayout.Height(20)))
- {
- MCPActionHistoryWindow.ShowWindow();
- }
- GUILayout.FlexibleSpace();
- EditorGUILayout.EndHorizontal();
-
- EditorGUILayout.EndVertical();
- }
-
- // ─── Feature Categories + Test Status ───
-
- private void DrawCategoryStatus()
- {
- _categoriesFoldout = EditorGUILayout.Foldout(_categoriesFoldout, "Feature Categories", true, EditorStyles.foldoutHeader);
- if (!_categoriesFoldout) return;
-
- // Test controls bar
- EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
-
- // Summary
- int passed = MCPSelfTest.PassedCount;
- int failed = MCPSelfTest.FailedCount;
- int warnings = MCPSelfTest.WarningCount;
- int total = MCPSettingsManager.GetAllCategoryNames().Length;
-
- if (MCPSelfTest.IsRunning)
- {
- EditorGUILayout.LabelField(
- $"Testing: {MCPSelfTest.CurrentCategory}...",
- EditorStyles.miniLabel);
- var rect = GUILayoutUtility.GetRect(100, 16, GUILayout.ExpandWidth(true));
- EditorGUI.ProgressBar(rect, MCPSelfTest.Progress, $"{(int)(MCPSelfTest.Progress * 100)}%");
- }
- else if (MCPSelfTest.LastRunTime > System.DateTime.MinValue)
- {
- string summary = "";
- if (failed > 0)
- summary += $"{failed} failed ";
- if (warnings > 0)
- summary += $"{warnings} warn ";
- summary += $"{passed}/{total} passed ";
-
- var richStyle = new GUIStyle(EditorStyles.miniLabel) { richText = true };
- EditorGUILayout.LabelField(summary, richStyle, GUILayout.ExpandWidth(true));
- }
- else
- {
- EditorGUILayout.LabelField("No tests run yet", EditorStyles.miniLabel);
- }
-
- GUILayout.FlexibleSpace();
-
- GUI.enabled = !MCPSelfTest.IsRunning && MCPBridgeServer.IsRunning;
- if (GUILayout.Button("Run Tests", GUILayout.Width(80), GUILayout.Height(20)))
- {
- MCPSelfTest.RunAllAsync();
- }
- GUI.enabled = true;
-
- EditorGUILayout.EndHorizontal();
-
- // Category rows
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- string[] categories = MCPSettingsManager.GetAllCategoryNames();
- foreach (var cat in categories)
- {
- bool enabled = MCPSettingsManager.IsCategoryEnabled(cat);
- var testResult = MCPSelfTest.GetResult(cat);
-
- EditorGUILayout.BeginHorizontal();
-
- // Status dot — reflects test status when available, else enabled/disabled
- var prevColor = GUI.color;
- Color dotColor = GetCategoryDotColor(enabled, testResult);
- GUI.color = dotColor;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- // Pretty name
- string displayName = char.ToUpper(cat[0]) + cat.Substring(1);
- EditorGUILayout.LabelField(displayName, GUILayout.Width(100));
-
- // Test status label — always draw both controls to avoid IMGUI control count mismatch
- bool hasTested = testResult != null && testResult.Status != MCPTestResult.TestStatus.Untested;
- bool hasDetails = hasTested && (testResult.Status == MCPTestResult.TestStatus.Failed ||
- testResult.Status == MCPTestResult.TestStatus.Warning);
-
- if (hasTested)
- {
- string statusLabel = GetTestStatusText(testResult);
- var statusStyle = new GUIStyle(EditorStyles.miniLabel)
- {
- normal = { textColor = dotColor },
- };
- EditorGUILayout.LabelField(statusLabel, statusStyle, GUILayout.Width(90));
- }
- else
- {
- EditorGUILayout.LabelField("\u2014", EditorStyles.miniLabel, GUILayout.Width(90));
- }
-
- // Always draw the details button to keep control count stable
- if (hasDetails)
- {
- if (GUILayout.Button("?", GUILayout.Width(20), GUILayout.Height(16)))
- {
- _expandedTestCategory = _expandedTestCategory == cat ? null : cat;
- }
- }
- else
- {
- // Invisible placeholder — same control, no visual
- var transparent = GUI.color;
- GUI.color = new Color(0, 0, 0, 0);
- GUILayout.Button("", GUILayout.Width(20), GUILayout.Height(16));
- GUI.color = transparent;
- }
-
- GUILayout.FlexibleSpace();
-
- bool newEnabled = EditorGUILayout.Toggle(enabled, GUILayout.Width(30));
- if (newEnabled != enabled)
- MCPSettingsManager.SetCategoryEnabled(cat, newEnabled);
-
- EditorGUILayout.EndHorizontal();
-
- // Expanded error details
- if (_expandedTestCategory == cat && testResult != null &&
- !string.IsNullOrEmpty(testResult.Details))
- {
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
- EditorGUILayout.SelectableLabel(
- testResult.Details,
- EditorStyles.wordWrappedMiniLabel,
- GUILayout.MinHeight(36));
- EditorGUILayout.EndVertical();
- }
- }
-
- EditorGUILayout.EndVertical();
- }
-
- private Color GetCategoryDotColor(bool enabled, MCPTestResult result)
- {
- if (!enabled) return ColorGrey;
- if (result == null || result.Status == MCPTestResult.TestStatus.Untested)
- return enabled ? ColorGreen : ColorGrey;
-
- switch (result.Status)
- {
- case MCPTestResult.TestStatus.Passed: return ColorGreen;
- case MCPTestResult.TestStatus.Warning: return ColorYellow;
- case MCPTestResult.TestStatus.Failed: return ColorRed;
- default: return ColorGrey;
- }
- }
-
- private string GetTestStatusText(MCPTestResult result)
- {
- switch (result.Status)
- {
- case MCPTestResult.TestStatus.Passed:
- return $"\u2713 {result.DurationMs:0}ms";
- case MCPTestResult.TestStatus.Warning:
- return $"\u26A0 {result.Message}";
- case MCPTestResult.TestStatus.Failed:
- return $"\u2717 {result.Message}";
- default:
- return "\u2014";
- }
- }
-
- // ─── Agent Sessions ───
-
- private void DrawAgentSessions()
- {
- _agentsFoldout = EditorGUILayout.Foldout(_agentsFoldout, "Active Agent Sessions", true, EditorStyles.foldoutHeader);
- if (!_agentsFoldout) return;
-
- var sessions = MCPRequestQueue.GetActiveSessions();
-
- if (sessions.Count == 0)
- {
- EditorGUILayout.HelpBox("No active agent sessions.", MessageType.Info);
- return;
- }
-
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- foreach (var session in sessions)
- {
- EditorGUILayout.BeginHorizontal();
-
- var prevColor = GUI.color;
- GUI.color = ColorGreen;
- GUILayout.Label("\u25CF", _dotStyle, GUILayout.Width(22));
- GUI.color = prevColor;
-
- string agentId = session.ContainsKey("agentId") ? session["agentId"].ToString() : "?";
- string action = session.ContainsKey("currentAction") ? session["currentAction"].ToString() : "idle";
- object totalObj = session.ContainsKey("totalActions") ? session["totalActions"] : 0;
- object queuedObj = session.ContainsKey("queuedRequests") ? session["queuedRequests"] : 0;
- object completedObj = session.ContainsKey("completedRequests") ? session["completedRequests"] : 0;
- object avgMs = session.ContainsKey("averageResponseTimeMs") ? session["averageResponseTimeMs"] : 0;
-
- EditorGUILayout.LabelField(agentId, EditorStyles.boldLabel, GUILayout.Width(160));
- EditorGUILayout.LabelField(action, GUILayout.MinWidth(80));
- GUILayout.FlexibleSpace();
-
- // Queue + completed stats
- int queuedInt = 0;
- int.TryParse(queuedObj.ToString(), out queuedInt);
-
- var richStyle = new GUIStyle(EditorStyles.miniLabel) { richText = true };
- string stats = $"{totalObj} total";
- if (queuedInt > 0)
- stats += $" {queuedInt}q ";
- stats += $" {completedObj}ok ";
- stats += $" {avgMs}ms";
-
- EditorGUILayout.LabelField(stats, richStyle, GUILayout.Width(170));
-
- EditorGUILayout.EndHorizontal();
- }
-
- EditorGUILayout.EndVertical();
- }
-
- // ─── Settings ───
-
- private void DrawSettings()
- {
- _settingsFoldout = EditorGUILayout.Foldout(_settingsFoldout, "Settings", true, EditorStyles.foldoutHeader);
- if (!_settingsFoldout) return;
-
- EditorGUILayout.BeginVertical(EditorStyles.helpBox);
-
- // ─── General ───
- EditorGUILayout.LabelField("General", EditorStyles.boldLabel);
-
- bool autoStart = EditorGUILayout.Toggle("Auto-start on Editor Load", MCPSettingsManager.AutoStart);
- if (autoStart != MCPSettingsManager.AutoStart)
- MCPSettingsManager.AutoStart = autoStart;
-
- EditorGUILayout.Space(6);
-
- // ─── Port ───
- EditorGUILayout.LabelField("Port", EditorStyles.boldLabel);
-
- bool useManual = EditorGUILayout.Toggle("Use Manual Port", MCPSettingsManager.UseManualPort);
- if (useManual != MCPSettingsManager.UseManualPort)
- MCPSettingsManager.UseManualPort = useManual;
-
- if (useManual)
- {
- // Manual port entry
- EditorGUILayout.BeginHorizontal();
- int port = EditorGUILayout.IntField("Server Port", MCPSettingsManager.Port);
- if (port != MCPSettingsManager.Port && port > 1024 && port < 65536)
- {
- MCPSettingsManager.Port = port;
- }
- EditorGUILayout.EndHorizontal();
-
- if (MCPBridgeServer.IsRunning && MCPBridgeServer.ActivePort != MCPSettingsManager.Port)
- EditorGUILayout.HelpBox("Restart server to apply port change.", MessageType.Info);
- }
- else
- {
- // Auto-select info
- string autoInfo = MCPBridgeServer.IsRunning
- ? $"Auto-selected port {MCPBridgeServer.ActivePort} (range: {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd})"
- : $"Will auto-select from range {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd}";
- EditorGUILayout.HelpBox(autoInfo, MessageType.None);
- }
-
- EditorGUILayout.Space(6);
-
- // ─── Multiplayer Play Mode (MPPM) ───
- EditorGUILayout.LabelField("Multiplayer Play Mode (MPPM)", EditorStyles.boldLabel);
-
- // Start on MPPM Virtual Players
- bool startOnVP = EditorGUILayout.Toggle(
- new GUIContent("Start on Virtual Players",
- "When off, the MCP bridge does not auto-start on Multiplayer Play Mode " +
- "virtual players — only on the main Editor. Manual start still works."),
- MCPSettingsManager.StartOnVirtualPlayers);
- if (startOnVP != MCPSettingsManager.StartOnVirtualPlayers)
- MCPSettingsManager.StartOnVirtualPlayers = startOnVP;
-
- EditorGUILayout.Space(4);
-
- // Reset button
- if (GUILayout.Button("Reset All Settings to Defaults"))
- {
- if (EditorUtility.DisplayDialog("Reset Settings",
- "Reset all MCP settings to defaults?", "Reset", "Cancel"))
- {
- MCPSettingsManager.ResetToDefaults();
- }
- }
-
- EditorGUILayout.EndVertical();
- }
-
- // ─── Version Info ───
-
- private void DrawVersionInfo()
- {
- EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
- EditorGUILayout.LabelField($"Plugin Version: {MCPUpdateChecker.CurrentVersion}", GUILayout.Width(155));
- GUILayout.FlexibleSpace();
-
- if (GUILayout.Button("Check for Updates", GUILayout.Width(130)))
- {
- MCPUpdateChecker.CheckForUpdates((hasUpdate, latestVersion) =>
- {
- if (hasUpdate)
- {
- EditorUtility.DisplayDialog("Update Available",
- $"A new version ({latestVersion}) is available.\n" +
- "Update via Unity Package Manager.",
- "OK");
- }
- else
- {
- EditorUtility.DisplayDialog("Up to Date",
- "You are running the latest version.", "OK");
- }
- });
- }
-
- EditorGUILayout.EndHorizontal();
- }
- }
-}
+using System.Collections.Generic;
+using System.Text;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// Editor window providing an overview of AB Unity MCP status, feature categories,
+ /// server controls, queue monitoring, studio news, settings, and active agent
+ /// sessions. Accessible via Window > AB Unity MCP.
+ ///
+ /// UI Toolkit, themed to the AnkleBreaker studio palette (shared brand sheet via
+ /// ). Dynamic sections refresh on a schedule and only rebuild
+ /// their rows when a cheap content signature changes.
+ ///
+ public class MCPDashboardWindow : EditorWindow
+ {
+ private const int RefreshIntervalMs = 750;
+
+ [MenuItem("Window/AB Unity MCP")]
+ public static void ShowWindow()
+ {
+ var window = GetWindow("AB Unity MCP");
+ window.minSize = new Vector2(360, 500);
+ }
+
+ // Cached roots for dynamic sections (rebuilt when their signature changes).
+ private VisualElement _statusRows;
+ private Button _startBtn;
+ private Button _stopBtn;
+ private VisualElement _newsRows;
+ private VisualElement _queueRows;
+ private VisualElement _contextRows;
+ private VisualElement _agentRows;
+ private VisualElement _actionRows;
+ private VisualElement _categoryRows;
+ private VisualElement _testBar;
+ private Toggle _autoStartToggle;
+ private Toggle _newsToggle;
+ private Toggle _manualPortToggle;
+ private IntegerField _portField;
+ private VisualElement _portManualGroup;
+ private Label _portAutoInfo;
+ private Label _portRestartHint;
+ private Toggle _mppmToggle;
+
+ private string _statusSig, _newsSig, _queueSig, _contextSig, _agentSig, _actionSig, _categorySig;
+ private string _expandedTestCategory;
+
+ private void OnDisable()
+ {
+ MCPNewsService.Changed -= OnNewsChanged;
+ }
+
+ public void CreateGUI()
+ {
+ MCPTheme.Apply(rootVisualElement);
+ MCPNewsService.Changed += OnNewsChanged;
+
+ var scroll = new ScrollView();
+ scroll.AddToClassList("ab-dash__scroll");
+ rootVisualElement.Add(scroll);
+
+ BuildHeader(scroll);
+ BuildStatus(scroll);
+ BuildControls(scroll);
+ BuildNews(scroll);
+ BuildFoldout(scroll, "Request Queue", true, out _queueRows);
+ BuildContext(scroll);
+ BuildFoldout(scroll, "Active Agent Sessions", true, out _agentRows);
+ BuildActions(scroll);
+ BuildCategories(scroll);
+ BuildSettings(scroll);
+ BuildVersion(scroll);
+
+ // First population is deferred one frame: on layout-restored windows Unity applies
+ // saved view-data AFTER CreateGUI, which can stomp children added synchronously here.
+ rootVisualElement.schedule.Execute(RefreshAll);
+ rootVisualElement.schedule.Execute(RefreshAll).Every(RefreshIntervalMs);
+ }
+
+ private void OnNewsChanged() => RefreshNews();
+
+ private void RefreshAll()
+ {
+ // Each section refreshes in isolation: a transient failure (e.g. a data source
+ // hiccup around a domain reload) must not blank the other sections, and resetting
+ // the failed section's signature makes it rebuild — and self-heal — on the next tick.
+ Guarded(RefreshStatus, () => _statusSig = null);
+ Guarded(RefreshControls, null);
+ Guarded(RefreshNews, () => _newsSig = null);
+ Guarded(RefreshQueue, () => _queueSig = null);
+ Guarded(RefreshContext, () => _contextSig = null);
+ Guarded(RefreshAgents, () => _agentSig = null);
+ Guarded(RefreshActions, () => _actionSig = null);
+ Guarded(RefreshCategories, () => _categorySig = null);
+ Guarded(RefreshSettings, null);
+ }
+
+ private static void Guarded(System.Action refresh, System.Action resetSignature)
+ {
+ try { refresh(); }
+ catch (System.Exception)
+ {
+ try { resetSignature?.Invoke(); } catch { }
+ }
+ }
+
+ // ─── Small builders ──────────────────────────────────────────────
+
+ private static VisualElement Row(VisualElement parent)
+ {
+ var row = new VisualElement();
+ row.AddToClassList("ab-dash__row");
+ parent.Add(row);
+ return row;
+ }
+
+ private static VisualElement Dot(VisualElement row, string colorClass)
+ {
+ var dot = new VisualElement();
+ dot.AddToClassList("ab-dash__dot");
+ dot.AddToClassList(colorClass);
+ row.Add(dot);
+ return dot;
+ }
+
+ private static Label Text(VisualElement row, string text, string styleClass)
+ {
+ var label = new Label(text);
+ label.AddToClassList(styleClass);
+ row.Add(label);
+ return label;
+ }
+
+ private static void Grow(VisualElement row)
+ {
+ var spacer = new VisualElement();
+ spacer.AddToClassList("ab-dash__grow");
+ row.Add(spacer);
+ }
+
+ private Foldout BuildFoldout(VisualElement parent, string title, bool open, out VisualElement rows)
+ {
+ var foldout = new Foldout { text = title, value = open };
+ foldout.AddToClassList("ab-dash__section");
+ parent.Add(foldout);
+
+ var box = new VisualElement();
+ box.AddToClassList("ab-box");
+ foldout.Add(box);
+ rows = box;
+ return foldout;
+ }
+
+ // ─── Header ──────────────────────────────────────────────────────
+
+ private void BuildHeader(VisualElement parent)
+ {
+ var title = new Label("AnkleBreaker Unity MCP");
+ title.AddToClassList("ab-title");
+ parent.Add(title);
+
+ var subtitle = new Label("Multi-agent MCP bridge for the Unity Editor");
+ subtitle.AddToClassList("ab-subtitle");
+ parent.Add(subtitle);
+ }
+
+ // ─── Connection status ───────────────────────────────────────────
+
+ private void BuildStatus(VisualElement parent)
+ {
+ _statusRows = new VisualElement();
+ _statusRows.AddToClassList("ab-box");
+ parent.Add(_statusRows);
+ }
+
+ private void RefreshStatus()
+ {
+ bool running = MCPBridgeServer.IsRunning;
+ int agents = MCPRequestQueue.ActiveSessionCount;
+ int queued = MCPRequestQueue.TotalQueuedCount;
+ bool clone = MCPInstanceRegistry.IsParrelSyncClone();
+ int displayPort = running ? MCPBridgeServer.ActivePort : MCPSettingsManager.Port;
+
+ string sig = $"{running}|{displayPort}|{agents}|{queued}|{clone}";
+ if (sig == _statusSig && _statusRows.childCount > 0) return;
+ _statusSig = sig;
+
+ _statusRows.Clear();
+
+ var main = Row(_statusRows);
+ Dot(main, running ? "ab-dash__dot--green" : "ab-dash__dot--red");
+ Text(main, running ? "Server Running" : "Server Stopped", "ab-dash__bold");
+ Grow(main);
+ string portLabel = running && !MCPSettingsManager.UseManualPort
+ ? $"Port {displayPort} (auto)" : $"Port {displayPort}";
+ Text(main, portLabel, "ab-dash__mini");
+
+ if (agents > 0 || queued > 0)
+ {
+ var counts = Row(_statusRows);
+ if (agents > 0)
+ {
+ Dot(counts, "ab-dash__dot--green");
+ Text(counts, $"{agents} agent{(agents > 1 ? "s" : "")}", "ab-dash__label");
+ }
+ if (queued > 0)
+ {
+ Dot(counts, "ab-dash__dot--yellow");
+ Text(counts, $"{queued} queued", "ab-dash__label");
+ }
+ }
+
+ if (clone)
+ {
+ var cloneRow = Row(_statusRows);
+ Text(cloneRow, $"⤷ ParrelSync Clone #{MCPInstanceRegistry.GetParrelSyncCloneIndex()}", "ab-dash__blue-text");
+ }
+ }
+
+ // ─── Server controls ─────────────────────────────────────────────
+
+ private void BuildControls(VisualElement parent)
+ {
+ var row = Row(parent);
+ row.AddToClassList("ab-dash__section");
+
+ _startBtn = new Button(OnStartClicked) { text = "Start" };
+ _stopBtn = new Button(OnStopClicked) { text = "Stop" };
+ var restart = new Button(OnRestartClicked) { text = "Restart" };
+ foreach (var b in new[] { _startBtn, _stopBtn, restart })
+ {
+ b.AddToClassList("ab-dash__grow");
+ row.Add(b);
+ }
+ }
+
+ private void OnStartClicked() => MCPBridgeServer.Start();
+ private void OnStopClicked() => MCPBridgeServer.Stop();
+
+ private void OnRestartClicked()
+ {
+ MCPBridgeServer.Stop();
+ EditorApplication.delayCall += () => MCPBridgeServer.Start();
+ }
+
+ private void RefreshControls()
+ {
+ bool running = MCPBridgeServer.IsRunning;
+ _startBtn.SetEnabled(!running);
+ _stopBtn.SetEnabled(running);
+ }
+
+ // ─── AnkleBreaker news ───────────────────────────────────────────
+
+ private void BuildNews(VisualElement parent)
+ {
+ BuildFoldout(parent, "AnkleBreaker News", true, out _newsRows);
+ }
+
+ private void RefreshNews()
+ {
+ if (_newsRows == null) return;
+
+ var posts = MCPNewsService.Posts;
+ var sb = new StringBuilder();
+ sb.Append(MCPNewsService.Enabled).Append('|').Append(MCPNewsService.UnseenCount)
+ .Append('|').Append(MCPNewsService.LastError ?? "");
+ foreach (var p in posts) sb.Append('|').Append(p.Slug);
+ string sig = sb.ToString();
+ if (sig == _newsSig && _newsRows.childCount > 0) return;
+ _newsSig = sig;
+
+ _newsRows.Clear();
+
+ if (!MCPNewsService.Enabled)
+ {
+ Text(_newsRows, "News notifications are disabled.", "ab-dash__mini");
+ var enableBtn = new Button(OnEnableNewsClicked) { text = "Enable News" };
+ _newsRows.Add(enableBtn);
+ return;
+ }
+
+ var header = Row(_newsRows);
+ int unseen = MCPNewsService.UnseenCount;
+ Text(header, unseen > 0 ? $"{unseen} new post{(unseen > 1 ? "s" : "")}" : "You're all caught up",
+ unseen > 0 ? "ab-dash__accent-text" : "ab-dash__mini");
+ Grow(header);
+ if (unseen > 0)
+ header.Add(new Button(MCPNewsService.MarkAllSeen) { text = "Mark All Read" });
+ header.Add(new Button(OnOpenDevlogClicked) { text = "Devlog" });
+ header.Add(new Button(MCPNewsService.ForceRefresh) { text = "↺" });
+
+ if (posts.Count == 0)
+ {
+ Text(_newsRows, MCPNewsService.LastError == null
+ ? "Fetching studio news…"
+ : $"Couldn't reach the devlog ({MCPNewsService.LastError})", "ab-dash__mini");
+ return;
+ }
+
+ foreach (var post in posts)
+ {
+ var item = new VisualElement();
+ item.AddToClassList("ab-news__item");
+ if (MCPNewsService.IsUnseen(post))
+ item.AddToClassList("ab-news__item--unseen");
+
+ // Feed-derived text: disable rich text so markup in a title can never render
+ // (defense-in-depth — MCPNewsService already strips angle brackets at parse).
+ var title = new Label(post.Title) { enableRichText = false };
+ title.AddToClassList("ab-news__title");
+ item.Add(title);
+
+ Grow(item);
+
+ if (!string.IsNullOrEmpty(post.Category))
+ {
+ var chip = new Label(post.Category) { enableRichText = false };
+ chip.AddToClassList("ab-chip");
+ item.Add(chip);
+ }
+
+ if (post.PubDateUtc.Ticks > 0)
+ {
+ var date = new Label(post.PubDateUtc.ToLocalTime().ToString("d MMM yyyy"));
+ date.AddToClassList("ab-dash__mini");
+ date.style.marginLeft = 6;
+ item.Add(date);
+ }
+
+ var captured = post;
+ item.RegisterCallback(_ => MCPNewsService.OpenPost(captured));
+ _newsRows.Add(item);
+ }
+ }
+
+ private void OnEnableNewsClicked()
+ {
+ MCPNewsService.Enabled = true;
+ MCPNewsService.ForceRefresh();
+ }
+
+ private void OnOpenDevlogClicked() => Application.OpenURL(MCPNewsService.DevlogUrl);
+
+ // ─── Request queue ───────────────────────────────────────────────
+
+ private void RefreshQueue()
+ {
+ var info = MCPRequestQueue.GetQueueInfo();
+ int totalQueued = ReadInt(info, "totalQueued");
+ int activeAgents = ReadInt(info, "activeAgents");
+ int cacheSize = ReadInt(info, "completedCacheSize");
+
+ var sb = new StringBuilder();
+ sb.Append(totalQueued).Append('|').Append(activeAgents).Append('|').Append(cacheSize);
+ var perAgent = info.TryGetValue("perAgentQueued", out var pa) ? pa as Dictionary : null;
+ if (perAgent != null)
+ foreach (var kvp in perAgent) sb.Append('|').Append(kvp.Key).Append(':').Append(kvp.Value);
+ string sig = sb.ToString();
+ if (sig == _queueSig && _queueRows.childCount > 0) return;
+ _queueSig = sig;
+
+ _queueRows.Clear();
+
+ var summary = Row(_queueRows);
+ Dot(summary, totalQueued > 0 ? "ab-dash__dot--yellow" : "ab-dash__dot--green");
+ string statusText = totalQueued > 0
+ ? $"{totalQueued} pending · {activeAgents} agents · {cacheSize} cached"
+ : $"Idle · {activeAgents} agents · {cacheSize} cached";
+ Text(summary, statusText, "ab-dash__label");
+
+ if (perAgent != null && perAgent.Count > 0)
+ {
+ Text(_queueRows, "Per-agent queue depth:", "ab-dash__mini");
+ foreach (var kvp in perAgent)
+ {
+ int depth = 0;
+ int.TryParse(kvp.Value.ToString(), out depth);
+ var row = Row(_queueRows);
+ Dot(row, depth > 0 ? "ab-dash__dot--yellow" : "ab-dash__dot--green");
+ Text(row, kvp.Key, "ab-dash__label");
+ Grow(row);
+ Text(row, $"{depth} pending", "ab-dash__mini");
+ }
+ }
+ }
+
+ // ─── Project context ─────────────────────────────────────────────
+
+ private void BuildContext(VisualElement parent)
+ {
+ BuildFoldout(parent, "Project Context", true, out _contextRows);
+ }
+
+ private void RefreshContext()
+ {
+ bool enabled = MCPSettingsManager.ContextEnabled;
+ var files = MCPContextManager.GetContextFileList();
+
+ var sb = new StringBuilder();
+ sb.Append(enabled).Append('|').Append(MCPSettingsManager.ContextPath);
+ foreach (var f in files) sb.Append('|').Append(f.Category).Append(':').Append(f.Exists).Append(':').Append(f.SizeBytes);
+ string sig = sb.ToString();
+ if (sig == _contextSig && _contextRows.childCount > 0) return;
+ _contextSig = sig;
+
+ _contextRows.Clear();
+
+ var header = Row(_contextRows);
+ var toggle = new Toggle("Enable Context") { value = enabled };
+ toggle.RegisterValueChangedCallback(OnContextToggled);
+ header.Add(toggle);
+ Grow(header);
+ header.Add(new Button(OnCreateTemplatesClicked) { text = "Create Templates" });
+ header.Add(new Button(OnOpenContextFolderClicked) { text = "Open Folder" });
+
+ if (!enabled)
+ {
+ Text(_contextRows, "Project context is disabled. Agents will not receive project documentation.", "ab-dash__mini");
+ return;
+ }
+
+ Text(_contextRows, $"Path: {MCPSettingsManager.ContextPath}", "ab-dash__mini");
+
+ bool anyFiles = false;
+ foreach (var file in files)
+ {
+ if (!file.IsStandard && !file.Exists) continue;
+ anyFiles = true;
+
+ var row = Row(_contextRows);
+ string dotClass = file.Exists && file.SizeBytes > 0 ? "ab-dash__dot--green"
+ : file.Exists ? "ab-dash__dot--yellow" : "ab-dash__dot--grey";
+ Dot(row, dotClass);
+ Text(row, file.Category, "ab-dash__label");
+ Grow(row);
+ string sizeLabel = !file.Exists ? "not created"
+ : file.SizeBytes == 0 ? "empty"
+ : file.SizeBytes > 1024 ? $"{file.SizeBytes / 1024f:0.#} KB" : $"{file.SizeBytes} B";
+ Text(row, sizeLabel, "ab-dash__mini");
+ }
+
+ if (!anyFiles)
+ Text(_contextRows, "No context files found. Click 'Create Templates' to get started.", "ab-dash__mini");
+ }
+
+ private void OnContextToggled(ChangeEvent evt) => MCPSettingsManager.ContextEnabled = evt.newValue;
+
+ private void OnCreateTemplatesClicked()
+ {
+ int created = MCPContextManager.CreateDefaultTemplates();
+ EditorUtility.DisplayDialog(
+ created > 0 ? "Templates Created" : "Templates Exist",
+ created > 0
+ ? $"Created {created} template file(s) in:\n{MCPSettingsManager.ContextPath}"
+ : "All template files already exist.",
+ "OK");
+ }
+
+ private void OnOpenContextFolderClicked()
+ {
+ string folderPath = MCPContextManager.GetContextFolderPath();
+ if (System.IO.Directory.Exists(folderPath))
+ EditorUtility.RevealInFinder(folderPath);
+ else
+ EditorUtility.DisplayDialog("Folder Not Found",
+ $"Context folder does not exist yet.\nClick 'Create Templates' to set it up.\n\n{folderPath}", "OK");
+ }
+
+ // ─── Agent sessions ──────────────────────────────────────────────
+
+ private void RefreshAgents()
+ {
+ var sessions = MCPRequestQueue.GetActiveSessions();
+
+ var sb = new StringBuilder();
+ foreach (var s in sessions)
+ foreach (var kvp in s) sb.Append(kvp.Key).Append(':').Append(kvp.Value).Append('|');
+ string sig = sb.ToString();
+ if (sig == _agentSig && _agentRows.childCount > 0) return;
+ _agentSig = sig;
+
+ _agentRows.Clear();
+
+ if (sessions.Count == 0)
+ {
+ Text(_agentRows, "No active agent sessions.", "ab-dash__mini");
+ return;
+ }
+
+ foreach (var session in sessions)
+ {
+ var row = Row(_agentRows);
+ Dot(row, "ab-dash__dot--green");
+ Text(row, Read(session, "agentId", "?"), "ab-dash__bold");
+ var action = Text(row, Read(session, "currentAction", "idle"), "ab-dash__mini");
+ action.AddToClassList("ab-dash__ellipsis");
+ action.style.marginLeft = 8;
+ Grow(row);
+ string stats = $"{Read(session, "totalActions", "0")} total · " +
+ $"{Read(session, "queuedRequests", "0")}q · " +
+ $"{Read(session, "completedRequests", "0")}ok · " +
+ $"{Read(session, "averageResponseTimeMs", "0")}ms";
+ Text(row, stats, "ab-dash__mini").AddToClassList("ab-dash__ellipsis");
+ }
+ }
+
+ // ─── Recent actions ──────────────────────────────────────────────
+
+ private void BuildActions(VisualElement parent)
+ {
+ BuildFoldout(parent, "Recent Actions", true, out _actionRows);
+ }
+
+ private void RefreshActions()
+ {
+ var recent = MCPActionHistory.GetRecent(8);
+
+ var sb = new StringBuilder();
+ foreach (var r in recent) sb.Append(r.Id).Append(':').Append(r.Status).Append('|');
+ string sig = sb.ToString();
+ if (sig == _actionSig && _actionRows.childCount > 0) return;
+ _actionSig = sig;
+
+ _actionRows.Clear();
+
+ if (recent.Count == 0)
+ {
+ Text(_actionRows, "No actions recorded yet.", "ab-dash__mini");
+ return;
+ }
+
+ for (int i = recent.Count - 1; i >= 0; i--)
+ {
+ var r = recent[i];
+ var row = Row(_actionRows);
+ string dotClass = r.Status == "Completed" ? "ab-dash__dot--green"
+ : r.Status == "Failed" ? "ab-dash__dot--red" : "ab-dash__dot--yellow";
+ Dot(row, dotClass);
+ Text(row, r.Timestamp.ToString("HH:mm:ss"), "ab-dash__mini");
+
+ string agent = r.AgentId ?? "?";
+ if (agent.Length > 10) agent = agent.Substring(0, 8) + "..";
+ Text(row, agent, "ab-dash__blue-text").style.marginLeft = 6;
+
+ Text(row, MCPActionRecord.ExtractCommand(r.ActionName), "ab-dash__label").style.marginLeft = 6;
+
+ string target = r.TargetPath ?? "";
+ if (target.Length > 28) target = ".." + target.Substring(target.Length - 26);
+ var targetLabel = Text(row, target, "ab-dash__mini");
+ targetLabel.AddToClassList("ab-dash__ellipsis");
+ targetLabel.style.marginLeft = 6;
+ Grow(row);
+ }
+
+ var footer = Row(_actionRows);
+ Grow(footer);
+ string btnLabel = MCPActionHistory.Count > 8
+ ? $"Open Full History ({MCPActionHistory.Count} actions)" : "Open Full History";
+ footer.Add(new Button(MCPActionHistoryWindow.ShowWindow) { text = btnLabel });
+ Grow(footer);
+ }
+
+ // ─── Feature categories + tests ──────────────────────────────────
+
+ private void BuildCategories(VisualElement parent)
+ {
+ var foldout = BuildFoldout(parent, "Feature Categories", true, out _categoryRows);
+ _testBar = new VisualElement();
+ _testBar.AddToClassList("ab-dash__row");
+ foldout.Insert(0, _testBar);
+ }
+
+ private void RefreshCategories()
+ {
+ string[] categories = MCPSettingsManager.GetAllCategoryNames();
+
+ var sb = new StringBuilder();
+ sb.Append(MCPSelfTest.IsRunning).Append('|').Append(MCPSelfTest.Progress.ToString("0.00"))
+ .Append('|').Append(MCPSelfTest.CurrentCategory).Append('|').Append(_expandedTestCategory)
+ .Append('|').Append(MCPBridgeServer.IsRunning);
+ foreach (var cat in categories)
+ {
+ var r = MCPSelfTest.GetResult(cat);
+ sb.Append('|').Append(cat).Append(':').Append(MCPSettingsManager.IsCategoryEnabled(cat))
+ .Append(':').Append(r == null ? "-" : r.Status.ToString()).Append(':').Append(r?.Message);
+ }
+ string sig = sb.ToString();
+ if (sig == _categorySig && _categoryRows.childCount > 0) return;
+ _categorySig = sig;
+
+ RefreshTestBar();
+
+ _categoryRows.Clear();
+ foreach (var cat in categories)
+ {
+ bool enabled = MCPSettingsManager.IsCategoryEnabled(cat);
+ var result = MCPSelfTest.GetResult(cat);
+
+ var row = Row(_categoryRows);
+ Dot(row, CategoryDotClass(enabled, result));
+ Text(row, char.ToUpper(cat[0]) + cat.Substring(1), "ab-dash__label");
+
+ bool hasTested = result != null && result.Status != MCPTestResult.TestStatus.Untested;
+ if (hasTested)
+ {
+ string statusClass = result.Status == MCPTestResult.TestStatus.Passed ? "ab-dash__green-text"
+ : result.Status == MCPTestResult.TestStatus.Warning ? "ab-dash__yellow-text" : "ab-dash__red-text";
+ Text(row, TestStatusText(result), statusClass).style.marginLeft = 8;
+
+ bool hasDetails = result.Status == MCPTestResult.TestStatus.Failed ||
+ result.Status == MCPTestResult.TestStatus.Warning;
+ if (hasDetails && !string.IsNullOrEmpty(result.Details))
+ {
+ string captured = cat;
+ var detailsBtn = new Button(() => ToggleDetails(captured)) { text = "?" };
+ detailsBtn.style.marginLeft = 4;
+ row.Add(detailsBtn);
+ }
+ }
+
+ Grow(row);
+
+ var toggle = new Toggle { value = enabled };
+ string catCapture = cat;
+ toggle.RegisterValueChangedCallback(evt => MCPSettingsManager.SetCategoryEnabled(catCapture, evt.newValue));
+ row.Add(toggle);
+
+ if (_expandedTestCategory == cat && result != null && !string.IsNullOrEmpty(result.Details))
+ {
+ var details = new Label(result.Details);
+ details.AddToClassList("ab-dash__details");
+ _categoryRows.Add(details);
+ }
+ }
+ }
+
+ private void ToggleDetails(string category)
+ {
+ _expandedTestCategory = _expandedTestCategory == category ? null : category;
+ _categorySig = null;
+ RefreshCategories();
+ }
+
+ private void RefreshTestBar()
+ {
+ _testBar.Clear();
+
+ if (MCPSelfTest.IsRunning)
+ {
+ Text(_testBar, $"Testing: {MCPSelfTest.CurrentCategory}…", "ab-dash__mini");
+ var track = new VisualElement();
+ track.AddToClassList("ab-dash__progress-track");
+ var fill = new VisualElement();
+ fill.AddToClassList("ab-dash__progress-fill");
+ fill.style.width = Length.Percent(Mathf.Clamp01(MCPSelfTest.Progress) * 100f);
+ track.Add(fill);
+ _testBar.Add(track);
+ return;
+ }
+
+ if (MCPSelfTest.LastRunTime > System.DateTime.MinValue)
+ {
+ int failed = MCPSelfTest.FailedCount;
+ int warnings = MCPSelfTest.WarningCount;
+ int total = MCPSettingsManager.GetAllCategoryNames().Length;
+ if (failed > 0) Text(_testBar, $"{failed} failed", "ab-dash__red-text").style.marginRight = 8;
+ if (warnings > 0) Text(_testBar, $"{warnings} warn", "ab-dash__yellow-text").style.marginRight = 8;
+ Text(_testBar, $"{MCPSelfTest.PassedCount}/{total} passed", "ab-dash__green-text");
+ }
+ else
+ {
+ Text(_testBar, "No tests run yet", "ab-dash__mini");
+ }
+
+ Grow(_testBar);
+ var runBtn = new Button(MCPSelfTest.RunAllAsync) { text = "Run Tests" };
+ runBtn.SetEnabled(!MCPSelfTest.IsRunning && MCPBridgeServer.IsRunning);
+ _testBar.Add(runBtn);
+ }
+
+ private static string CategoryDotClass(bool enabled, MCPTestResult result)
+ {
+ if (!enabled) return "ab-dash__dot--grey";
+ if (result == null || result.Status == MCPTestResult.TestStatus.Untested) return "ab-dash__dot--green";
+ switch (result.Status)
+ {
+ case MCPTestResult.TestStatus.Passed: return "ab-dash__dot--green";
+ case MCPTestResult.TestStatus.Warning: return "ab-dash__dot--yellow";
+ case MCPTestResult.TestStatus.Failed: return "ab-dash__dot--red";
+ default: return "ab-dash__dot--grey";
+ }
+ }
+
+ private static string TestStatusText(MCPTestResult result)
+ {
+ switch (result.Status)
+ {
+ case MCPTestResult.TestStatus.Passed: return $"✓ {result.DurationMs:0}ms";
+ case MCPTestResult.TestStatus.Warning: return $"⚠ {result.Message}";
+ case MCPTestResult.TestStatus.Failed: return $"✗ {result.Message}";
+ default: return "—";
+ }
+ }
+
+ // ─── Settings ────────────────────────────────────────────────────
+
+ private void BuildSettings(VisualElement parent)
+ {
+ BuildFoldout(parent, "Settings", false, out var box);
+
+ Text(box, "General", "ab-section-heading");
+
+ _autoStartToggle = new Toggle("Auto-start on Editor Load") { value = MCPSettingsManager.AutoStart };
+ _autoStartToggle.RegisterValueChangedCallback(OnAutoStartToggled);
+ box.Add(_autoStartToggle);
+
+ _newsToggle = new Toggle("News Notifications") { value = MCPNewsService.Enabled };
+ _newsToggle.RegisterValueChangedCallback(OnNewsToggled);
+ box.Add(_newsToggle);
+
+ Text(box, "Port", "ab-section-heading");
+
+ _manualPortToggle = new Toggle("Use Manual Port") { value = MCPSettingsManager.UseManualPort };
+ _manualPortToggle.RegisterValueChangedCallback(OnManualPortToggled);
+ box.Add(_manualPortToggle);
+
+ _portManualGroup = new VisualElement();
+ _portField = new IntegerField("Server Port") { value = MCPSettingsManager.Port };
+ _portField.RegisterValueChangedCallback(OnPortChanged);
+ _portManualGroup.Add(_portField);
+ _portRestartHint = Text(_portManualGroup, "Restart server to apply port change.", "ab-dash__accent-text");
+ box.Add(_portManualGroup);
+
+ _portAutoInfo = Text(box, "", "ab-dash__mini");
+
+ Text(box, "Multiplayer Play Mode (MPPM)", "ab-section-heading");
+
+ _mppmToggle = new Toggle("Start on Virtual Players")
+ {
+ value = MCPSettingsManager.StartOnVirtualPlayers,
+ tooltip = "When off, the MCP bridge does not auto-start on Multiplayer Play Mode " +
+ "virtual players — only on the main Editor. Manual start still works.",
+ };
+ _mppmToggle.RegisterValueChangedCallback(OnMppmToggled);
+ box.Add(_mppmToggle);
+
+ var resetBtn = new Button(OnResetClicked) { text = "Reset All Settings to Defaults" };
+ resetBtn.style.marginTop = 8;
+ box.Add(resetBtn);
+ }
+
+ private void OnAutoStartToggled(ChangeEvent evt) => MCPSettingsManager.AutoStart = evt.newValue;
+ private void OnNewsToggled(ChangeEvent evt) => MCPNewsService.Enabled = evt.newValue;
+ private void OnManualPortToggled(ChangeEvent evt) => MCPSettingsManager.UseManualPort = evt.newValue;
+ private void OnMppmToggled(ChangeEvent evt) => MCPSettingsManager.StartOnVirtualPlayers = evt.newValue;
+
+ private void OnPortChanged(ChangeEvent evt)
+ {
+ if (evt.newValue > 1024 && evt.newValue < 65536)
+ MCPSettingsManager.Port = evt.newValue;
+ }
+
+ private void OnResetClicked()
+ {
+ if (EditorUtility.DisplayDialog("Reset Settings", "Reset all MCP settings to defaults?", "Reset", "Cancel"))
+ {
+ MCPSettingsManager.ResetToDefaults();
+ _statusSig = _newsSig = _queueSig = _contextSig = _agentSig = _actionSig = _categorySig = null;
+ }
+ }
+
+ private void RefreshSettings()
+ {
+ _autoStartToggle.SetValueWithoutNotify(MCPSettingsManager.AutoStart);
+ _newsToggle.SetValueWithoutNotify(MCPNewsService.Enabled);
+
+ bool manual = MCPSettingsManager.UseManualPort;
+ _manualPortToggle.SetValueWithoutNotify(manual);
+ _portManualGroup.EnableInClassList("hidden", !manual);
+ _portAutoInfo.EnableInClassList("hidden", manual);
+
+ if (manual)
+ {
+ // Don't clobber the field while the user is typing in it.
+ bool editing = _portField.focusController != null
+ && _portField.focusController.focusedElement != null
+ && _portField.Contains(_portField.focusController.focusedElement as VisualElement);
+ if (!editing)
+ _portField.SetValueWithoutNotify(MCPSettingsManager.Port);
+ bool mismatch = MCPBridgeServer.IsRunning && MCPBridgeServer.ActivePort != MCPSettingsManager.Port;
+ _portRestartHint.EnableInClassList("hidden", !mismatch);
+ }
+ else
+ {
+ _portAutoInfo.text = MCPBridgeServer.IsRunning
+ ? $"Auto-selected port {MCPBridgeServer.ActivePort} (range: {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd})"
+ : $"Will auto-select from range {MCPInstanceRegistry.PortRangeStart}-{MCPInstanceRegistry.PortRangeEnd}";
+ }
+
+ _mppmToggle.SetValueWithoutNotify(MCPSettingsManager.StartOnVirtualPlayers);
+ }
+
+ // ─── Version footer ──────────────────────────────────────────────
+
+ private void BuildVersion(VisualElement parent)
+ {
+ var box = new VisualElement();
+ box.AddToClassList("ab-box");
+ var row = Row(box);
+ Text(row, $"Plugin Version: {MCPUpdateChecker.CurrentVersion}", "ab-dash__mini");
+ Grow(row);
+ row.Add(new Button(OnCheckUpdatesClicked) { text = "Check for Updates" });
+ parent.Add(box);
+ }
+
+ private void OnCheckUpdatesClicked()
+ {
+ MCPUpdateChecker.CheckForUpdates((hasUpdate, latestVersion) =>
+ {
+ EditorUtility.DisplayDialog(
+ hasUpdate ? "Update Available" : "Up to Date",
+ hasUpdate
+ ? $"A new version ({latestVersion}) is available.\nUpdate via Unity Package Manager."
+ : "You are running the latest version.",
+ "OK");
+ });
+ }
+
+ // ─── Helpers ─────────────────────────────────────────────────────
+
+ private static string Read(Dictionary dict, string key, string fallback) =>
+ dict.TryGetValue(key, out var v) && v != null ? v.ToString() : fallback;
+
+ private static int ReadInt(Dictionary dict, string key)
+ {
+ int value = 0;
+ if (dict.TryGetValue(key, out var v) && v != null)
+ int.TryParse(v.ToString(), out value);
+ return value;
+ }
+ }
+}
diff --git a/Editor/MCPEditorCommands.cs b/Editor/MCPEditorCommands.cs
index 562b9d0..527be33 100755
--- a/Editor/MCPEditorCommands.cs
+++ b/Editor/MCPEditorCommands.cs
@@ -23,7 +23,7 @@ public static object GetEditorState()
{ "sceneDirty", scene.isDirty },
{ "unityVersion", Application.unityVersion },
{ "platform", EditorUserBuildSettings.activeBuildTarget.ToString() },
- { "projectPath", Application.dataPath.Replace("/Assets", "") },
+ { "projectPath", MCPAssetSafety.ProjectRoot.Replace('\\', '/') },
};
}
@@ -121,6 +121,9 @@ private static bool TryLoadRoslyn()
searchDirs.Add(Path.Combine(data, "Tools", "BuildPipeline", "Compilation", "ApiUpdater"));
// ScriptUpdater also ships Roslyn (Mono-compatible)
searchDirs.Add(Path.Combine(data, "Tools", "ScriptUpdater"));
+ // macOS: recent editors ship Roslyn under Contents/Resources/Scripting
+ // (community PR #19 by JetNik — ExecuteCode was broken on macOS without it)
+ searchDirs.Add(Path.Combine(data, "Resources", "Scripting"));
// DotNetSdkRoslyn contains .NET Core assemblies — may fail on Mono, tried last
searchDirs.Add(Path.Combine(data, "DotNetSdkRoslyn"));
}
@@ -456,60 +459,109 @@ private static object SerializeResult(object result)
if (result == null)
return new { success = true, result = (object)null };
- // Primitives and strings
- if (result is string || result is int || result is float || result is double
- || result is bool || result is long || result is decimal)
- return new Dictionary { { "success", true }, { "result", result } };
-
- // Unity Vector types
- if (result is Vector2 v2)
- return new Dictionary { { "success", true }, { "result", new { x = v2.x, y = v2.y } } };
- if (result is Vector3 v3)
- return new Dictionary { { "success", true }, { "result", new { x = v3.x, y = v3.y, z = v3.z } } };
- if (result is Color col)
- return new Dictionary { { "success", true }, { "result", new { r = col.r, g = col.g, b = col.b, a = col.a } } };
-
- // Dictionaries
- if (result is System.Collections.IDictionary dict)
- return new Dictionary { { "success", true }, { "result", result } };
-
- // Lists and arrays - serialize elements
- if (result is System.Collections.IList list)
+ object serialized = SerializeValue(result, 0);
+
+ // Preserve the historical { result, count } shape for top-level lists.
+ if (result is System.Collections.IList && serialized is List items)
+ return new Dictionary { { "success", true }, { "result", items }, { "count", items.Count } };
+
+ if (serialized is string str && !(result is string))
+ {
+ // Opaque object fell back to ToString — keep the type name like before.
+ return new Dictionary
+ {
+ { "success", true },
+ { "result", str },
+ { "type", result.GetType().Name },
+ };
+ }
+
+ return new Dictionary { { "success", true }, { "result", serialized } };
+ }
+
+ private const int MaxSerializeDepth = 4;
+ private const int MaxSerializeItems = 1000;
+
+ ///
+ /// Recursively serialize a value, PRESERVING primitive types. The previous
+ /// implementation ToString'd every list element and reflected property, so
+ /// numbers came back as strings ("307" instead of 307) and nested objects
+ /// flattened to type names. Depth/item caps keep pathological returns bounded.
+ ///
+ private static object SerializeValue(object value, int depth)
+ {
+ if (value == null) return null;
+
+ if (value is string || value is bool
+ || value is int || value is long || value is short || value is byte
+ || value is float || value is double || value is decimal
+ || value is uint || value is ulong || value is ushort || value is sbyte)
+ return value;
+
+ if (value is Vector2 v2)
+ return new Dictionary { { "x", v2.x }, { "y", v2.y } };
+ if (value is Vector3 v3)
+ return new Dictionary { { "x", v3.x }, { "y", v3.y }, { "z", v3.z } };
+ if (value is Color col)
+ return new Dictionary { { "r", col.r }, { "g", col.g }, { "b", col.b }, { "a", col.a } };
+
+ if (depth >= MaxSerializeDepth)
+ return value.ToString();
+
+ if (value is System.Collections.IDictionary dictionary)
+ {
+ var obj = new Dictionary();
+ foreach (System.Collections.DictionaryEntry entry in dictionary)
+ {
+ if (obj.Count >= MaxSerializeItems)
+ {
+ obj["_truncated"] = $"... (truncated at {MaxSerializeItems} entries)";
+ break;
+ }
+ obj[entry.Key != null ? entry.Key.ToString() : "null"] = SerializeValue(entry.Value, depth + 1);
+ }
+ return obj;
+ }
+
+ if (value is System.Collections.IEnumerable enumerable)
{
var items = new List();
- foreach (var item in list)
- items.Add(item?.ToString());
- return new Dictionary { { "success", true }, { "result", items }, { "count", items.Count } };
+ foreach (var item in enumerable)
+ {
+ if (items.Count >= MaxSerializeItems)
+ {
+ items.Add($"... (truncated at {MaxSerializeItems} items)");
+ break;
+ }
+ items.Add(SerializeValue(item, depth + 1));
+ }
+ return items;
}
- // Anonymous types and complex objects - serialize via reflection
- var type = result.GetType();
- if (type.Name.Contains("AnonymousType") || type.IsClass)
+ var type = value.GetType();
+ // UnityEngine.Object graphs (GameObject, Component, ...) are cyclic and
+ // property access can throw — represent them compactly as ToString.
+ if (!typeof(UnityEngine.Object).IsAssignableFrom(type)
+ && (type.Name.Contains("AnonymousType") || type.IsClass))
{
try
{
var props = type.GetProperties();
- if (props.Length > 0)
+ if (props.Length > 0 && props.Length <= 64)
{
var obj = new Dictionary();
foreach (var prop in props)
{
- try { obj[prop.Name] = prop.GetValue(result)?.ToString(); }
+ try { obj[prop.Name] = SerializeValue(prop.GetValue(value), depth + 1); }
catch { obj[prop.Name] = ""; }
}
- return new Dictionary { { "success", true }, { "result", obj } };
+ return obj;
}
}
catch { }
}
- // Fallback: ToString
- return new Dictionary
- {
- { "success", true },
- { "result", result.ToString() },
- { "type", type.Name },
- };
+ return value.ToString();
}
}
}
diff --git a/Editor/MCPGameObjectCommands.cs b/Editor/MCPGameObjectCommands.cs
index 8d12f1d..ae34991 100755
--- a/Editor/MCPGameObjectCommands.cs
+++ b/Editor/MCPGameObjectCommands.cs
@@ -58,11 +58,62 @@ public static object Delete(Dictionary args)
var go = FindGameObject(args);
if (go == null) return new { error = "GameObject not found" };
+ // Shared runtime-mesh guard: a ProBuilderMesh owns a runtime mesh; if the object was
+ // cloned with duplicate/Object.Instantiate, its copies' MeshFilters point at that SAME
+ // mesh. Destroying this object runs ProBuilderMesh.OnDestroy, which destroys the mesh —
+ // and every copy goes invisible at once. Refuse unless force:true, and say how many
+ // objects would be hit. Only runtime (non-asset) meshes are at risk, so deleting
+ // normal asset-mesh objects stays on the fast path (report A1).
+ bool force = args.ContainsKey("force") && Convert.ToBoolean(args["force"]);
+ if (!force)
+ {
+ int sharedWith = CountExternalSharersOfRuntimeMesh(go);
+ if (sharedWith > 0)
+ return new
+ {
+ error = $"Refused: this object's runtime mesh is shared by {sharedWith} other object(s) (e.g. ProBuilder clones made with duplicate/Instantiate). Deleting it would blank their mesh too. Give each copy an independent mesh first (duplicate now does this automatically), or pass force:true to delete anyway.",
+ requiresForce = true,
+ sharedWith,
+ };
+ }
+
string name = go.name;
Undo.DestroyObjectImmediate(go);
return new { success = true, deleted = name };
}
+ ///
+ /// Count MeshFilters OUTSIDE the given object's subtree that reference a runtime (non-asset)
+ /// mesh used inside the subtree. Non-zero means deleting the object would destroy a mesh
+ /// still in use elsewhere (the ProBuilder shared-mesh hazard). Fast-paths to 0 when the
+ /// subtree has no runtime meshes (the common case), so normal deletes pay no scan.
+ ///
+ private static int CountExternalSharersOfRuntimeMesh(GameObject go)
+ {
+ var runtimeMeshes = new HashSet();
+ foreach (var mf in go.GetComponentsInChildren(true))
+ if (mf.sharedMesh != null && !AssetDatabase.Contains(mf.sharedMesh))
+ runtimeMeshes.Add(mf.sharedMesh);
+ if (runtimeMeshes.Count == 0) return 0;
+
+ var inSubtree = new HashSet();
+ foreach (var tr in go.GetComponentsInChildren(true))
+ inSubtree.Add(tr);
+
+ int count = 0;
+ // Include INACTIVE objects: FindObjectsByType's default excludes them, which would let
+ // an inactive sibling that shares the runtime mesh slip past the guard — the delete
+ // would then blank it silently (the exact hazard this guards). Matches the explicit
+ // opt-in used by FindGameObject above.
+ foreach (var mf in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None))
+ {
+ if (mf.sharedMesh == null) continue;
+ if (inSubtree.Contains(mf.transform)) continue;
+ if (runtimeMeshes.Contains(mf.sharedMesh)) count++;
+ }
+ return count;
+ }
+
public static object GetInfo(Dictionary args)
{
var go = FindGameObject(args);
diff --git a/Editor/MCPGraphicsCommands.cs b/Editor/MCPGraphicsCommands.cs
index 5b281f3..7a7f28d 100644
--- a/Editor/MCPGraphicsCommands.cs
+++ b/Editor/MCPGraphicsCommands.cs
@@ -142,6 +142,11 @@ public static object CaptureSceneView(Dictionary args)
try
{
var camera = sceneView.camera;
+ // A backgrounded editor doesn't repaint the SceneView, so its camera can lag
+ // behind pivot/rotation/size changes made through code (LookAt, focus). Sync
+ // the render camera to the view state so captures reflect the requested view.
+ camera.transform.rotation = sceneView.rotation;
+ camera.transform.position = sceneView.pivot - sceneView.rotation * Vector3.forward * sceneView.cameraDistance;
rt = new RenderTexture(width, height, 24);
camera.targetTexture = rt;
camera.Render();
diff --git a/Editor/MCPNewsService.cs b/Editor/MCPNewsService.cs
new file mode 100644
index 0000000..32174dc
--- /dev/null
+++ b/Editor/MCPNewsService.cs
@@ -0,0 +1,416 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// Studio news notifications: polls the AnkleBreaker devlog RSS feed and tracks
+ /// which posts this user has seen, so the toolbar can show a mobile-style unseen
+ /// badge and the dashboard a news panel.
+ ///
+ /// Privacy: a plain GET of the public feed, at most every few hours, nothing sent.
+ /// All state is user-scoped (global EditorPrefs, NOT per-project) — reading a post
+ /// once marks it read everywhere. Disable entirely with .
+ /// First run seeds every existing post as seen EXCEPT the newest, so a fresh
+ /// install shows a gentle "1" instead of the whole backlog.
+ ///
+ [InitializeOnLoad]
+ public static class MCPNewsService
+ {
+ public const string FeedUrl = "https://anklebreaker-studio.com/devlog/feed.xml";
+ public const string DevlogUrl = "https://anklebreaker-studio.com/devlog";
+
+ private const string KeyEnabled = "UnityMCP_news_Enabled";
+ private const string KeySeenSlugs = "UnityMCP_news_SeenSlugs";
+ private const string KeyNextCheckTicks = "UnityMCP_news_NextCheckTicks";
+ private const string KeyCachedPosts = "UnityMCP_news_CachedPosts";
+
+ private const double CheckIntervalHours = 6.0;
+ private const int MaxSeenSlugs = 100;
+ // Bounds on remote feed content: a compromised/oversized response must not be able
+ // to OOM the editor or write a huge blob to EditorPrefs (registry-backed) on the main thread.
+ private const int MaxPosts = 50;
+ private const int MaxTitleLength = 200;
+ private const int MaxFeedBytes = 1_048_576; // 1 MB — the devlog feed is a few KB
+
+ public sealed class Post
+ {
+ public string Slug;
+ public string Title;
+ public string Url;
+ public string Category;
+ public DateTime PubDateUtc;
+ }
+
+ /// Fired on the main thread whenever posts or unseen state change.
+ public static event Action Changed;
+
+ private static readonly List _posts = new List();
+ private static HashSet _seen;
+ private static bool _inFlight;
+ private static double _nextTickCheck;
+
+ public static IReadOnlyList Posts => _posts;
+ public static DateTime LastFetchUtc { get; private set; }
+ public static string LastError { get; private set; }
+
+ public static bool Enabled
+ {
+ get => EditorPrefs.GetBool(KeyEnabled, true);
+ set
+ {
+ if (value == Enabled) return;
+ EditorPrefs.SetBool(KeyEnabled, value);
+ RaiseChanged();
+ }
+ }
+
+ public static int UnseenCount
+ {
+ get
+ {
+ if (!Enabled) return 0;
+ int count = 0;
+ for (int i = 0; i < _posts.Count; i++)
+ if (!Seen.Contains(_posts[i].Slug))
+ count++;
+ return count;
+ }
+ }
+
+ static MCPNewsService()
+ {
+ LoadCache();
+ EditorApplication.update += Tick;
+ }
+
+ public static bool IsUnseen(Post post) =>
+ post != null && Enabled && !Seen.Contains(post.Slug);
+
+ public static void MarkSeen(Post post)
+ {
+ if (post == null || Seen.Contains(post.Slug)) return;
+ Seen.Add(post.Slug);
+ SaveSeen();
+ RaiseChanged();
+ }
+
+ public static void MarkAllSeen()
+ {
+ bool changed = false;
+ foreach (var p in _posts)
+ changed |= Seen.Add(p.Slug);
+ if (!changed) return;
+ SaveSeen();
+ RaiseChanged();
+ }
+
+ /// Open a post in the browser and mark it read.
+ public static void OpenPost(Post post)
+ {
+ if (post == null || string.IsNullOrEmpty(post.Url)) return;
+ // Defense-in-depth: even though ParseFeed already rejects non-web links, never
+ // hand anything but a validated http/https URL to the OS shell via OpenURL.
+ if (!IsSafeWebUrl(post.Url))
+ {
+ Debug.LogWarning($"[AB-UMCP] Refusing to open non-web news URL: {post.Url}");
+ MarkSeen(post);
+ return;
+ }
+ Application.OpenURL(post.Url);
+ MarkSeen(post);
+ }
+
+ /// Fetch the feed now, regardless of the schedule.
+ public static void ForceRefresh() => Fetch();
+
+ // ─── Scheduling ───
+
+ private static void Tick()
+ {
+ // The real work is hours apart — only look at the clock once a minute.
+ if (EditorApplication.timeSinceStartup < _nextTickCheck) return;
+ _nextTickCheck = EditorApplication.timeSinceStartup + 60.0;
+
+ if (!Enabled || _inFlight) return;
+ long due = ReadTicks(KeyNextCheckTicks);
+ if (DateTime.UtcNow.Ticks < due) return;
+ Fetch();
+ }
+
+ private static void Fetch()
+ {
+ if (_inFlight) return;
+ _inFlight = true;
+
+ UnityWebRequest req = UnityWebRequest.Get(FeedUrl);
+ req.timeout = 15;
+ req.SendWebRequest().completed += _ => OnFeedDone(req);
+ }
+
+ private static void OnFeedDone(UnityWebRequest req)
+ {
+ string xml = req.result == UnityWebRequest.Result.Success ? req.downloadHandler.text : null;
+ string error = req.result == UnityWebRequest.Result.Success ? null : req.error;
+ req.Dispose();
+ _inFlight = false;
+
+ // Success or failure, don't hammer the site — next attempt one interval away.
+ WriteTicks(KeyNextCheckTicks, DateTime.UtcNow.AddHours(CheckIntervalHours).Ticks);
+
+ if (string.IsNullOrEmpty(xml))
+ {
+ LastError = error ?? "Empty feed response";
+ return;
+ }
+
+ // Bound the body before parsing — an oversized response must not drive
+ // unbounded allocation or a giant EditorPrefs write. The real feed is a few KB.
+ if (xml.Length > MaxFeedBytes)
+ {
+ LastError = "Feed response too large";
+ return;
+ }
+
+ List parsed = ParseFeed(xml);
+ if (parsed.Count == 0)
+ {
+ LastError = "Feed contained no posts";
+ return;
+ }
+
+ LastError = null;
+ LastFetchUtc = DateTime.UtcNow;
+
+ bool firstRun = !EditorPrefs.HasKey(KeySeenSlugs);
+
+ _posts.Clear();
+ _posts.AddRange(parsed);
+ _posts.Sort(NewestFirst);
+
+ if (firstRun)
+ {
+ // Everything that already exists is old news to a fresh install —
+ // except the newest post, which greets the user with a single badge.
+ for (int i = 1; i < _posts.Count; i++)
+ Seen.Add(_posts[i].Slug);
+ SaveSeen();
+ }
+
+ SaveCache();
+ RaiseChanged();
+ }
+
+ private static int NewestFirst(Post a, Post b) => b.PubDateUtc.CompareTo(a.PubDateUtc);
+
+ // ─── Feed parsing (same tolerant string scanning the welcome window uses) ───
+
+ private static List ParseFeed(string xml)
+ {
+ var posts = new List();
+ int cursor = 0;
+ while (posts.Count < MaxPosts)
+ {
+ int start = xml.IndexOf("- ", cursor, StringComparison.Ordinal);
+ if (start < 0) break;
+ int end = xml.IndexOf("
", start, StringComparison.Ordinal);
+ if (end < 0) break;
+ string item = xml.Substring(start + 6, end - start - 6);
+ cursor = end + 7;
+
+ string link = StripCData(Between(item, " ", ""));
+ string title = SanitizeText(Decode(StripCData(Between(item, "", " "))));
+ if (string.IsNullOrEmpty(link) || string.IsNullOrEmpty(title)) continue;
+ link = link.Trim();
+
+ // SECURITY: only accept http/https links. The link is later handed to
+ // Application.OpenURL (the OS shell) — a compromised or spoofed feed must
+ // not be able to smuggle file://, a UNC path, or an OS URI-scheme handler.
+ // Rejecting here means such an item never becomes a clickable Post at all.
+ if (!IsSafeWebUrl(link)) continue;
+
+ var post = new Post
+ {
+ Title = title,
+ Url = link,
+ Slug = SlugOf(link),
+ Category = SanitizeText(Decode(StripCData(Between(item, "", " ")))) ?? "",
+ };
+
+ string pub = StripCData(Between(item, "", " "));
+ DateTime when;
+ post.PubDateUtc = !string.IsNullOrEmpty(pub) && DateTime.TryParse(
+ pub, CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out when)
+ ? when
+ : DateTime.MinValue;
+
+ posts.Add(post);
+ }
+ return posts;
+ }
+
+ /// True only for well-formed absolute http/https URLs.
+ internal static bool IsSafeWebUrl(string url)
+ {
+ return !string.IsNullOrEmpty(url)
+ && Uri.TryCreate(url, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+ }
+
+ ///
+ /// Neutralize remote feed text before it reaches UI Toolkit / GenericMenu:
+ /// strip rich-text markup (labels interpret <color>/<b> by default) and
+ /// the '/' GenericMenu submenu separator; collapse to a bounded single line.
+ ///
+ private static string SanitizeText(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value;
+ var sb = new System.Text.StringBuilder(value.Length);
+ foreach (char c in value)
+ {
+ if (c == '<' || c == '>') continue; // rich-text tags
+ if (c == '/') { sb.Append('⁄'); continue; } // GenericMenu separator → fraction slash
+ if (c == '\r' || c == '\n' || c == '\t') { sb.Append(' '); continue; }
+ sb.Append(c);
+ }
+ string cleaned = sb.ToString().Trim();
+ return cleaned.Length > MaxTitleLength ? cleaned.Substring(0, MaxTitleLength - 1) + "…" : cleaned;
+ }
+
+ private static string SlugOf(string url)
+ {
+ string trimmed = url.TrimEnd('/');
+ int slash = trimmed.LastIndexOf('/');
+ string slug = slash >= 0 ? trimmed.Substring(slash + 1) : trimmed;
+ // The seen-set is ';'-delimited in EditorPrefs — a ';' in a slug would split
+ // one entry into two on reload and never round-trip. Drop the delimiter.
+ return slug.Replace(";", "");
+ }
+
+ private static string Between(string source, string startTag, string endTag)
+ {
+ if (string.IsNullOrEmpty(source)) return null;
+ int start = source.IndexOf(startTag, StringComparison.Ordinal);
+ if (start < 0) return null;
+ start += startTag.Length;
+ int end = source.IndexOf(endTag, start, StringComparison.Ordinal);
+ return end < 0 ? null : source.Substring(start, end - start);
+ }
+
+ private static string StripCData(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value;
+ value = value.Trim();
+ const string open = "";
+ if (value.StartsWith(open, StringComparison.Ordinal) && value.EndsWith(close, StringComparison.Ordinal))
+ return value.Substring(open.Length, value.Length - open.Length - close.Length).Trim();
+ return value;
+ }
+
+ private static string Decode(string value)
+ {
+ if (string.IsNullOrEmpty(value)) return value;
+ return value.Replace("'", "'").Replace("'", "'").Replace(""", "\"")
+ .Replace("<", "<").Replace(">", ">").Replace("&", "&").Trim();
+ }
+
+ // ─── Persistence ───
+
+ private static HashSet Seen
+ {
+ get
+ {
+ if (_seen != null) return _seen;
+ _seen = new HashSet(StringComparer.Ordinal);
+ string raw = EditorPrefs.GetString(KeySeenSlugs, "");
+ foreach (var slug in raw.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
+ _seen.Add(slug);
+ return _seen;
+ }
+ }
+
+ private static void SaveSeen()
+ {
+ // Keep the set bounded: current feed slugs always survive the cap.
+ var ordered = new List();
+ foreach (var p in _posts)
+ if (Seen.Contains(p.Slug))
+ ordered.Add(p.Slug);
+ foreach (var slug in Seen)
+ if (!ordered.Contains(slug) && ordered.Count < MaxSeenSlugs)
+ ordered.Add(slug);
+ EditorPrefs.SetString(KeySeenSlugs, string.Join(";", ordered));
+ }
+
+ private static void SaveCache()
+ {
+ var list = new List();
+ foreach (var p in _posts)
+ {
+ list.Add(new Dictionary
+ {
+ { "slug", p.Slug },
+ { "title", p.Title },
+ { "url", p.Url },
+ { "category", p.Category },
+ { "ticks", p.PubDateUtc.Ticks.ToString(CultureInfo.InvariantCulture) },
+ });
+ }
+ EditorPrefs.SetString(KeyCachedPosts, MiniJson.Serialize(list));
+ }
+
+ private static void LoadCache()
+ {
+ string json = EditorPrefs.GetString(KeyCachedPosts, "");
+ if (string.IsNullOrEmpty(json)) return;
+ try
+ {
+ if (!(MiniJson.Deserialize(json) is List list)) return;
+ _posts.Clear();
+ foreach (var o in list)
+ {
+ if (!(o is Dictionary d)) continue;
+ var post = new Post
+ {
+ Slug = d.TryGetValue("slug", out var s) ? s as string : null,
+ Title = d.TryGetValue("title", out var t) ? t as string : null,
+ Url = d.TryGetValue("url", out var u) ? u as string : null,
+ Category = d.TryGetValue("category", out var c) ? c as string ?? "" : "",
+ };
+ long ticks;
+ post.PubDateUtc = d.TryGetValue("ticks", out var k) && long.TryParse(k as string, out ticks)
+ ? new DateTime(ticks, DateTimeKind.Utc)
+ : DateTime.MinValue;
+ if (!string.IsNullOrEmpty(post.Slug) && !string.IsNullOrEmpty(post.Title))
+ _posts.Add(post);
+ }
+ _posts.Sort(NewestFirst);
+ }
+ catch
+ {
+ _posts.Clear();
+ }
+ }
+
+ private static long ReadTicks(string key)
+ {
+ long ticks;
+ return long.TryParse(EditorPrefs.GetString(key, "0"), out ticks) ? ticks : 0L;
+ }
+
+ private static void WriteTicks(string key, long value) =>
+ EditorPrefs.SetString(key, value.ToString(CultureInfo.InvariantCulture));
+
+ private static void RaiseChanged()
+ {
+ try { Changed?.Invoke(); }
+ catch (Exception ex) { Debug.LogWarning($"[AB-UMCP] News listener threw: {ex.Message}"); }
+ }
+ }
+}
diff --git a/Editor/MCPNewsService.cs.meta b/Editor/MCPNewsService.cs.meta
new file mode 100644
index 0000000..a48e82a
--- /dev/null
+++ b/Editor/MCPNewsService.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7c4e9a1f28d34b6a90f1c5e8b2a7d301
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPPrefabCommands.cs b/Editor/MCPPrefabCommands.cs
index a7eda85..696e423 100644
--- a/Editor/MCPPrefabCommands.cs
+++ b/Editor/MCPPrefabCommands.cs
@@ -324,15 +324,49 @@ public static object Duplicate(Dictionary args)
if (go.transform.parent != null)
duplicate.transform.SetParent(go.transform.parent);
+ // ProBuilder-safe clone: Object.Instantiate makes the copy's MeshFilter share the SAME
+ // runtime mesh the source's ProBuilderMesh owns. Deleting either object later
+ // (ProBuilderMesh.OnDestroy destroys that mesh) would blank ALL the copies. Give each
+ // cloned ProBuilderMesh its own independent mesh via ProBuilder's MakeUnique() so the
+ // duplicate is a fully independent, still-editable object (report A1).
+ int pbIsolated = MakeProBuilderClonesIndependent(duplicate);
+
Undo.RegisterCreatedObjectUndo(duplicate, $"Duplicate {go.name}");
- return new Dictionary
+ var result = new Dictionary
{
{ "success", true },
{ "original", go.name },
{ "duplicate", duplicate.name },
{ "instanceId", MCPObjectId.Get(duplicate) },
};
+ if (pbIsolated > 0) result["proBuilderMeshesIsolated"] = pbIsolated;
+ return result;
+ }
+
+ ///
+ /// After an Object.Instantiate of a GameObject tree, give every cloned ProBuilderMesh an
+ /// independent runtime mesh (ProBuilder's MakeUnique) so the clone no longer shares the
+ /// source's mesh. Without this the copies share one mesh, and destroying any one of them
+ /// (or the source) blanks the rest — the core of the shared-mesh hazard (report A1).
+ /// Returns the count of ProBuilder meshes isolated; a harmless 0 when ProBuilder is absent.
+ ///
+ private static int MakeProBuilderClonesIndependent(GameObject clone)
+ {
+#if PROBUILDER_INSTALLED
+ int count = 0;
+ foreach (var pb in clone.GetComponentsInChildren(true))
+ {
+ pb.MakeUnique();
+ pb.ToMesh();
+ pb.Refresh();
+ UnityEditor.ProBuilder.EditorUtility.SynchronizeWithMeshFilter(pb);
+ count++;
+ }
+ return count;
+#else
+ return 0;
+#endif
}
///
diff --git a/Editor/MCPProBuilderCommands.cs b/Editor/MCPProBuilderCommands.cs
new file mode 100644
index 0000000..23475e3
--- /dev/null
+++ b/Editor/MCPProBuilderCommands.cs
@@ -0,0 +1,732 @@
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+#if PROBUILDER_INSTALLED
+using System;
+using System.Linq;
+using UnityEngine.ProBuilder;
+using UnityEngine.ProBuilder.MeshOperations;
+using UnityEditor.ProBuilder;
+#endif
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// ProBuilder integration (com.unity.probuilder). Create and edit editable geometry —
+ /// shapes, extrude/bevel/subdivide, per-face materials, boolean ops, ProBuilderize — all
+ /// through ProBuilder's public API so the resulting meshes are real ProBuilder objects the
+ /// user can keep editing in the editor. Every mutation registers Undo, so it composes with
+ /// the multi-agent queue's per-action undo tracking.
+ ///
+ /// The whole surface is gated on PROBUILDER_INSTALLED (asmdef versionDefine). When ProBuilder
+ /// isn't in the project, every handler returns a clear "not installed" error instead of
+ /// failing to compile.
+ ///
+ public static class MCPProBuilderCommands
+ {
+#if !PROBUILDER_INSTALLED
+ private static object NotInstalled()
+ {
+ return new Dictionary
+ {
+ { "error", "ProBuilder (com.unity.probuilder) is not installed. Install it via Package Manager to use probuilder/* tools." },
+ { "hint", "Window > Package Manager > Unity Registry > ProBuilder > Install." },
+ };
+ }
+
+ public static object CreateShape(Dictionary args) => NotInstalled();
+ public static object GetInfo(Dictionary args) => NotInstalled();
+ public static object ExtrudeFaces(Dictionary args) => NotInstalled();
+ public static object BevelEdges(Dictionary args) => NotInstalled();
+ public static object Subdivide(Dictionary args) => NotInstalled();
+ public static object DeleteFaces(Dictionary args) => NotInstalled();
+ public static object SetFaceMaterial(Dictionary args) => NotInstalled();
+ public static object BooleanOp(Dictionary args) => NotInstalled();
+ public static object ProBuilderize(Dictionary args) => NotInstalled();
+ public static object Combine(Dictionary args) => NotInstalled();
+ public static object TranslateFaces(Dictionary args) => NotInstalled();
+ public static object FlipNormals(Dictionary args) => NotInstalled();
+ public static object CenterPivot(Dictionary args) => NotInstalled();
+ public static object ExportMesh(Dictionary args) => NotInstalled();
+#else
+ // ─────────────────────────────────────────────
+ // Create
+ // ─────────────────────────────────────────────
+
+ public static object CreateShape(Dictionary args)
+ {
+ string shape = args.ContainsKey("shape") ? args["shape"].ToString().ToLowerInvariant() : "cube";
+ float w = GetFloat(args, "width", 1f), h = GetFloat(args, "height", 1f), d = GetFloat(args, "depth", 1f);
+ var size = new Vector3(w, h, d);
+
+ ProBuilderMesh pb;
+ switch (shape)
+ {
+ case "cube": pb = ShapeGenerator.GenerateCube(PivotLocation.Center, size); break;
+ case "plane": pb = ShapeGenerator.GeneratePlane(PivotLocation.Center, w, d, GetInt(args, "widthSegments", 1), GetInt(args, "lengthSegments", 1), Axis.Up); break;
+ case "cylinder": pb = ShapeGenerator.GenerateCylinder(PivotLocation.Center, GetInt(args, "sides", 8), Mathf.Max(w, d) * 0.5f, h, GetInt(args, "heightSegments", 0), 1); break;
+ case "prism": pb = ShapeGenerator.GeneratePrism(PivotLocation.Center, size); break;
+ case "stair": pb = ShapeGenerator.GenerateStair(PivotLocation.Center, size, GetInt(args, "steps", 6), GetBool(args, "sides", true)); break;
+ case "cone": pb = ShapeGenerator.GenerateCone(PivotLocation.Center, Mathf.Max(w, d) * 0.5f, h, GetInt(args, "sides", 8)); break;
+ case "door": pb = ShapeGenerator.GenerateDoor(PivotLocation.Center, w, h, GetFloat(args, "ledge", 0.15f), GetFloat(args, "legWidth", 0.25f), d); break;
+ case "pipe": pb = ShapeGenerator.GeneratePipe(PivotLocation.Center, Mathf.Max(w, d) * 0.5f, h, GetFloat(args, "thickness", 0.1f), GetInt(args, "sides", 8), GetInt(args, "heightSegments", 1)); break;
+ case "arch": pb = ShapeGenerator.GenerateArch(PivotLocation.Center, GetFloat(args, "angle", 180f), Mathf.Max(w, d) * 0.5f, GetFloat(args, "thickness", 0.2f), d, GetInt(args, "sides", 6), true, true, true, true, true); break;
+ case "sphere":
+ case "icosahedron": pb = ShapeGenerator.GenerateIcosahedron(PivotLocation.Center, Mathf.Max(w, h, d) * 0.5f, GetInt(args, "subdivisions", 2), true, true); break;
+ case "torus": pb = ShapeGenerator.GenerateTorus(PivotLocation.Center, GetInt(args, "rows", 12), GetInt(args, "columns", 16), Mathf.Max(w, d) * 0.5f, GetFloat(args, "tubeRadius", 0.2f), true, 360f, 360f, true); break;
+ default:
+ return Error($"Unknown shape '{shape}'. Supported: cube, plane, cylinder, prism, stair, cone, door, pipe, arch, sphere, torus.");
+ }
+
+ var go = pb.gameObject;
+ go.name = args.ContainsKey("name") && args["name"] != null ? args["name"].ToString() : ("PB_" + shape);
+ if (args.ContainsKey("position") && args["position"] is Dictionary p)
+ go.transform.position = new Vector3(GetFloat(p, "x", 0), GetFloat(p, "y", 0), GetFloat(p, "z", 0));
+
+ // ShapeGenerator leaves the renderer's material NULL — the shape renders magenta
+ // and a null material key crashes ProBuilder's CSG. Always end with a real material:
+ // the requested one when given, ProBuilder's default otherwise. A requested-but-
+ // unresolved material used to fall back to default SILENTLY (the response still said
+ // success) — now it's surfaced as materialWarning so the caller never has to
+ // re-inspect the renderer to discover the fallback (report B2).
+ string matWarning = null;
+ string matPath = null;
+ Material mat = null;
+ if (args.ContainsKey("material") && args["material"] != null)
+ {
+ var spec = args["material"].ToString();
+ mat = ResolveMaterial(spec, out matPath, out var matAmbiguity);
+ if (mat == null)
+ matWarning = $"Material '{spec}' not found (searched by asset path and by name) — applied ProBuilder's default instead.";
+ else
+ matWarning = matAmbiguity; // null unless a bare name matched >1 asset
+ }
+ pb.SetMaterial(pb.faces, mat != null ? mat : BuiltinMaterials.defaultMaterial);
+
+ Rebuild(pb);
+
+ // Project-convention params applied at creation so an object doesn't need a follow-up
+ // call for each (a common source of serial omissions, report B8): layer (name or
+ // index), a MeshCollider, and a hierarchy parent. Set before RegisterCreatedObjectUndo
+ // so the whole configured object is captured as one undoable creation.
+ string layerWarning = ApplyLayer(go, args);
+ bool addedCollider = false;
+ if (GetBool(args, "addCollider", false))
+ {
+ // Unity fake-null: '??' would keep a dead wrapper instead of adding the collider,
+ // so check with the overridden '== null' (csharp-unity skill §4).
+ var col = go.GetComponent();
+ if (col == null) col = go.AddComponent();
+ col.sharedMesh = go.GetComponent().sharedMesh;
+ addedCollider = true;
+ }
+ string parentWarning = ApplyParent(go, args);
+
+ Undo.RegisterCreatedObjectUndo(go, "Create ProBuilder " + shape);
+ // Echo the ACTUALLY-applied dimensions/position/material/layer so a dropped or
+ // misparsed param is visible in the response instead of silently defaulting
+ // (battle-test rule).
+ var data = new Dictionary
+ {
+ { "shape", shape },
+ { "name", go.name },
+ { "appliedSize", V(size) },
+ { "appliedPosition", V(go.transform.position) },
+ { "layer", LayerMask.LayerToName(go.layer) },
+ { "hasCollider", addedCollider },
+ };
+ if (mat != null) data["appliedMaterial"] = mat.name;
+ if (matPath != null) data["appliedMaterialPath"] = matPath;
+ if (matWarning != null) data["materialWarning"] = matWarning;
+ if (layerWarning != null) data["layerWarning"] = layerWarning;
+ if (parentWarning != null) data["parentWarning"] = parentWarning;
+ return Ok(pb, data);
+ }
+
+ // ─────────────────────────────────────────────
+ // Inspect
+ // ─────────────────────────────────────────────
+
+ public static object GetInfo(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+
+ // Local bounds can be stale after vertex edits — recalculate before reporting
+ // (battle-test BUG 3), and report the world AABB too: local bounds never reflect
+ // the transform (scale/rotation), which is what agents actually place against.
+ var mf = pb.GetComponent();
+ var b = new Bounds();
+ if (mf != null && mf.sharedMesh != null)
+ {
+ mf.sharedMesh.RecalculateBounds();
+ b = mf.sharedMesh.bounds;
+ }
+ var mr = pb.GetComponent();
+ var world = mr != null ? mr.bounds : new Bounds(pb.transform.position, Vector3.zero);
+
+ var materials = pb.faces.Select(f => f.submeshIndex).Distinct().OrderBy(i => i).ToArray();
+ return new Dictionary
+ {
+ { "success", true },
+ { "name", pb.name },
+ { "instanceId", MCPObjectId.Get(pb.gameObject) },
+ { "isProBuilder", true },
+ { "faceCount", pb.faceCount },
+ { "vertexCount", pb.vertexCount },
+ { "edgeCount", pb.edgeCount },
+ { "sharedVertexCount", pb.sharedVertices.Count },
+ { "submeshCount", materials.Length },
+ { "bounds", new Dictionary {
+ { "center", V(b.center) }, { "size", V(b.size) } } },
+ { "worldBounds", new Dictionary {
+ { "center", V(world.center) }, { "size", V(world.size) } } },
+ };
+ }
+
+ // ─────────────────────────────────────────────
+ // Geometry edits
+ // ─────────────────────────────────────────────
+
+ public static object ExtrudeFaces(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ var faces = ResolveFaces(pb, args, out var faceErr);
+ if (faceErr != null) return faceErr;
+ float distance = GetFloat(args, "distance", 0.5f);
+ var method = ParseExtrudeMethod(args);
+
+ UndoRecord(pb, "Extrude Faces");
+ var newFaces = ExtrudeElements.Extrude(pb, faces, method, distance);
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "extrudedFaces", faces.Count }, { "newFaces", newFaces?.Length ?? 0 }, { "distance", distance } });
+ }
+
+ public static object BevelEdges(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ var faces = ResolveFaces(pb, args, out var faceErr);
+ if (faceErr != null) return faceErr;
+ float amount = GetFloat(args, "amount", 0.1f);
+ // Bevel every edge of the selected faces (a face-driven bevel — the common case).
+ var edges = faces.SelectMany(f => f.edges).Distinct().ToList();
+
+ UndoRecord(pb, "Bevel Edges");
+ int vBefore = pb.vertexCount, fBefore = pb.faceCount;
+ Bevel.BevelEdges(pb, edges, amount);
+ Rebuild(pb);
+ // Bevel silently no-ops on some configurations (e.g. coplanar interior faces left by a
+ // CSG cut): the call succeeds but geometry is unchanged. Surface that explicitly so the
+ // caller isn't left believing a bevel landed when nothing moved (report B3).
+ bool changed = pb.vertexCount != vBefore || pb.faceCount != fBefore;
+ var bevelData = new Dictionary { { "beveledEdges", edges.Count }, { "amount", amount }, { "changed", changed } };
+ if (!changed)
+ bevelData["note"] = "Bevel produced no geometry change (edges may be coplanar/interior, or amount too small). Face indices are unchanged.";
+ return Ok(pb, bevelData);
+ }
+
+ public static object Subdivide(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList();
+ if (faces == null) return Error("Invalid faceIndices.");
+
+ UndoRecord(pb, "Subdivide");
+ ConnectElements.Connect(pb, faces);
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "subdividedFaces", faces.Count } });
+ }
+
+ public static object DeleteFaces(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ var faces = ResolveFaces(pb, args, out var faceErr);
+ if (faceErr != null) return faceErr;
+ if (faces.Count >= pb.faceCount)
+ return Error("Refusing to delete every face (that would empty the mesh). Delete a subset or delete the GameObject instead.");
+
+ UndoRecord(pb, "Delete Faces");
+ DeleteElements.DeleteFaces(pb, faces);
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "deletedFaces", faces.Count } });
+ }
+
+ public static object TranslateFaces(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ var faces = ResolveFaces(pb, args, out var faceErr);
+ if (faceErr != null) return faceErr;
+ var d = args.ContainsKey("translation") && args["translation"] is Dictionary t
+ ? new Vector3(GetFloat(t, "x", 0), GetFloat(t, "y", 0), GetFloat(t, "z", 0))
+ : Vector3.zero;
+
+ UndoRecord(pb, "Move Faces");
+ pb.TranslateVertices(faces.SelectMany(f => f.indexes).Distinct().ToArray(), d);
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "movedFaces", faces.Count }, { "translation", V(d) } });
+ }
+
+ public static object FlipNormals(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ UndoRecord(pb, "Flip Normals");
+ foreach (var f in pb.faces) f.Reverse();
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "flippedFaces", pb.faceCount } });
+ }
+
+ // ─────────────────────────────────────────────
+ // Materials
+ // ─────────────────────────────────────────────
+
+ public static object SetFaceMaterial(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ if (!args.ContainsKey("material") || args["material"] == null) return Error("material (asset path or name) is required.");
+ // Same resolution as create_shape: full asset path OR a bare material name. These two
+ // tools used to diverge (create_shape took a name, set_face_material demanded a path) —
+ // report B2. They now accept identical forms.
+ var mat = ResolveMaterial(args["material"].ToString(), out var matPath, out var matAmbiguity);
+ if (mat == null) return Error($"Material not found (searched by asset path and by name): {args["material"]}");
+ var faces = args.ContainsKey("faceIndices") ? ResolveFaces(pb, args, out var fe) : pb.faces.ToList();
+ if (faces == null) return Error("Invalid faceIndices.");
+
+ UndoRecord(pb, "Set Face Material");
+ pb.SetMaterial(faces, mat);
+ Rebuild(pb);
+ var smData = new Dictionary { { "material", mat.name }, { "faces", faces.Count } };
+ if (matPath != null) smData["materialPath"] = matPath;
+ if (matAmbiguity != null) smData["materialWarning"] = matAmbiguity;
+ return Ok(pb, smData);
+ }
+
+ // ─────────────────────────────────────────────
+ // Boolean / combine / convert
+ // ─────────────────────────────────────────────
+
+ public static object BooleanOp(Dictionary args)
+ {
+ string op = args.ContainsKey("operation") ? args["operation"].ToString().ToLowerInvariant() : "union";
+ var a = ResolveGameObject(args, "targetPath", "targetInstanceId");
+ var b = ResolveGameObject(args, "otherPath", "otherInstanceId");
+ if (a == null || b == null) return Error("Both targetPath/targetInstanceId and otherPath/otherInstanceId must resolve to GameObjects.");
+
+ string method;
+ switch (op)
+ {
+ case "union": method = "Union"; break;
+ case "subtract": case "difference": method = "Subtract"; break;
+ case "intersect": case "intersection": method = "Intersect"; break;
+ default: return Error($"Unknown boolean operation '{op}'. Use union, subtract, or intersect.");
+ }
+ // CSG builds a material lookup table and throws on a null key — meshes with
+ // missing material slots (e.g. hand-built or older shapes) get ProBuilder's
+ // default assigned first (Undo-tracked, disclosed in the result).
+ bool materialsDefaulted = EnsureRendererMaterials(a) | EnsureRendererMaterials(b);
+ // ProBuilder's CSG class is internal, so invoke it via reflection.
+ var mesh = InvokeCsg(method, a, b, out var csgErr);
+ if (mesh == null) return Error(csgErr ?? "Boolean operation produced no mesh (are both objects solid meshes?).");
+ var result = mesh;
+
+ string sourceAId = MCPObjectId.Get(a);
+ string sourceBId = MCPObjectId.Get(b);
+
+ // CSG output vertices are in WORLD space — the result object must sit at the
+ // identity transform (setting it to an operand's position double-offsets the mesh).
+ var go = new GameObject(args.ContainsKey("name") && args["name"] != null
+ ? args["name"].ToString()
+ : $"PB_Boolean_{op}");
+ var mf = go.AddComponent();
+ mf.sharedMesh = result;
+ var mr = go.AddComponent();
+ var srcMat = a.GetComponent() != null ? a.GetComponent().sharedMaterial : null;
+ mr.sharedMaterial = srcMat;
+ // Make the boolean result editable in ProBuilder: add a ProBuilderMesh to the
+ // result object and import the generated mesh into it. Pass the CSG mesh directly —
+ // AddComponent nulls the MeshFilter's sharedMesh.
+ var pb = Undo.AddComponent(go);
+ var imported = MeshImporter_TryImport(pb, result, srcMat != null ? new[] { srcMat } : new Material[0]);
+ if (imported)
+ {
+ Rebuild(pb);
+ // Move the pivot from the world origin to the geometry center so the result
+ // behaves like a normal object for later transforms/edits.
+ pb.CenterPivot(null);
+ Rebuild(pb);
+ }
+ Undo.RegisterCreatedObjectUndo(go, "ProBuilder Boolean");
+
+ // The result fully replaces the operands' volume — leaving both sources alive
+ // and overlapping it surprised every agent in the battle test (BUG 4). Default
+ // is now to remove them (Undo-tracked); pass deleteSources:false to keep them.
+ bool deleteSources = GetBool(args, "deleteSources", true);
+ if (deleteSources)
+ {
+ Undo.DestroyObjectImmediate(a);
+ Undo.DestroyObjectImmediate(b);
+ }
+
+ var boolResult = new Dictionary
+ {
+ { "success", true },
+ { "operation", op },
+ { "name", go.name },
+ { "instanceId", MCPObjectId.Get(go) },
+ { "vertexCount", result.vertexCount },
+ { "editableProBuilder", imported },
+ { "sourceInstanceIds", new List { sourceAId, sourceBId } },
+ { "sourcesDeleted", deleteSources },
+ };
+ if (materialsDefaulted) boolResult["materialsDefaulted"] = true;
+ return boolResult;
+ }
+
+ public static object Combine(Dictionary args)
+ {
+ if (!(args.ContainsKey("paths") && args["paths"] is List raw) || raw.Count < 2)
+ return Error("paths (array of >= 2 GameObject paths) is required.");
+ var meshes = new List();
+ foreach (var o in raw)
+ {
+ var go = GameObject.Find(o.ToString());
+ var pb = go != null ? go.GetComponent() : null;
+ if (pb == null) return Error($"'{o}' is not a ProBuilder object (ProBuilderize it first).");
+ meshes.Add(pb);
+ }
+ // Record the surviving target BEFORE merging so undo/last (or Ctrl+Z) restores its
+ // pre-merge geometry — otherwise only the destroyed sources come back (partial undo).
+ UndoRecord(meshes[0], "Combine ProBuilder Meshes");
+ var result = CombineMeshes.Combine(meshes, meshes[0]);
+ foreach (var pb in result) Rebuild(pb);
+ // ProBuilder merges into the first mesh and destroys the rest.
+ for (int i = 1; i < meshes.Count; i++)
+ if (meshes[i] != null) Undo.DestroyObjectImmediate(meshes[i].gameObject);
+ return Ok(meshes[0], new Dictionary { { "combined", raw.Count } });
+ }
+
+ public static object ProBuilderize(Dictionary args)
+ {
+ var go = ResolveGameObject(args, "path", "instanceId");
+ if (go == null) return Error("GameObject not found.");
+ if (go.GetComponent() != null) return Error("GameObject is already a ProBuilder object.");
+ var mf = go.GetComponent();
+ if (mf == null || mf.sharedMesh == null) return Error("GameObject has no MeshFilter/mesh to convert.");
+ // Capture source mesh + materials BEFORE adding ProBuilderMesh (which nulls sharedMesh).
+ var srcMesh = mf.sharedMesh;
+ var srcMats = go.GetComponent() != null ? go.GetComponent().sharedMaterials : new Material[0];
+
+ var pb = Undo.AddComponent(go);
+ bool ok = MeshImporter_TryImport(pb, srcMesh, srcMats);
+ if (!ok) { Undo.DestroyObjectImmediate(pb); return Error("Failed to import the mesh into ProBuilder."); }
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "converted", true } });
+ }
+
+ // ─────────────────────────────────────────────
+ // Pivot / export
+ // ─────────────────────────────────────────────
+
+ public static object CenterPivot(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ UndoRecord(pb, "Center Pivot");
+ pb.CenterPivot(null);
+ Rebuild(pb);
+ return Ok(pb, new Dictionary { { "centeredPivot", true } });
+ }
+
+ public static object ExportMesh(Dictionary args)
+ {
+ if (!TryResolve(args, out var pb, out var err)) return err;
+ // Distinct key from the 'path' used to RESOLVE the object, so an object identified by
+ // hierarchy path can still be exported to a chosen asset path.
+ if (!args.ContainsKey("outputPath") || args["outputPath"] == null)
+ return Error("outputPath (e.g. 'Assets/Meshes/MyMesh.asset') is required.");
+ string path = args["outputPath"].ToString();
+ if (!path.EndsWith(".asset")) path += ".asset";
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out _, out var pathError)) return Error(pathError);
+ var over = MCPAssetSafety.OverwriteGuard(path, args);
+ if (over != null) return over;
+
+ var mesh = UnityEngine.Object.Instantiate(pb.GetComponent().sharedMesh);
+ mesh.name = pb.name;
+ AssetDatabase.CreateAsset(mesh, MCPAssetSafety.ToAssetDatabasePath(path));
+ AssetDatabase.SaveAssets();
+ return new Dictionary { { "success", true }, { "assetPath", path }, { "vertexCount", mesh.vertexCount } };
+ }
+
+ // ─────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────
+
+ private static void Rebuild(ProBuilderMesh pb)
+ {
+ pb.ToMesh();
+ pb.Refresh();
+ UnityEditor.ProBuilder.EditorUtility.SynchronizeWithMeshFilter(pb);
+ // A rebuild repopulates the MeshFilter's mesh; a MeshCollider on the same object keeps
+ // its stale cooked collision (or points at the old mesh) after combine/boolean/any
+ // edit. Re-point it (null then set forces a re-cook) so physics matches the visible
+ // geometry (report B5). Cheap null-check when there's no collider.
+ var col = pb.GetComponent();
+ if (col != null)
+ {
+ var mf = pb.GetComponent();
+ col.sharedMesh = null;
+ col.sharedMesh = mf != null ? mf.sharedMesh : null;
+ }
+ }
+
+ private static void UndoRecord(ProBuilderMesh pb, string msg)
+ {
+ // Public Undo API — records the ProBuilderMesh so an undo restores geometry and
+ // composes with the queue's per-action undo-group tracking.
+ Undo.RegisterCompleteObjectUndo(pb, msg);
+ }
+
+ ///
+ /// Resolve a material spec that may be a full asset path OR a bare name (e.g.
+ /// "MAT_Chair_Red"). The path is tried first; a bare name is looked up through the
+ /// AssetDatabase with an exact-leaf-name match. This makes create_shape and
+ /// set_face_material accept the SAME forms (report B2: they used to diverge —
+ /// create_shape silently defaulted on a bare name, set_face_material hard-required a
+ /// path). Returns null when nothing matches; 'resolvedPath' is the asset path loaded;
+ /// 'ambiguityWarning' is non-null when a bare name matched more than one asset (a
+ /// deterministic, disclosed choice is made rather than a silent arbitrary one).
+ ///
+ private static Material ResolveMaterial(string spec, out string resolvedPath, out string ambiguityWarning)
+ {
+ resolvedPath = null;
+ ambiguityWarning = null;
+ if (string.IsNullOrEmpty(spec)) return null;
+ // 1) Direct asset path (the historically-required, unambiguous form).
+ var direct = AssetDatabase.LoadAssetAtPath(spec);
+ if (direct != null) { resolvedPath = spec; return direct; }
+ // 2) Bare name (or path without extension): collect ALL exact leaf-name matches.
+ string leaf = System.IO.Path.GetFileNameWithoutExtension(spec);
+ if (string.IsNullOrEmpty(leaf)) return null;
+ var matches = new List();
+ foreach (var guid in AssetDatabase.FindAssets("t:Material " + leaf))
+ {
+ var path = AssetDatabase.GUIDToAssetPath(guid);
+ if (string.Equals(System.IO.Path.GetFileNameWithoutExtension(path), leaf, StringComparison.Ordinal))
+ matches.Add(path);
+ }
+ if (matches.Count == 0) return null;
+ // Same-named materials in different folders are common (asset packs, generic names).
+ // FindAssets gives no stable order, so pick deterministically (sorted) AND disclose the
+ // ambiguity instead of silently applying whichever came first — the caller can pass a
+ // full path to disambiguate. resolvedPath is always echoed so the choice is visible.
+ matches.Sort(StringComparer.Ordinal);
+ resolvedPath = matches[0];
+ if (matches.Count > 1)
+ ambiguityWarning = $"Material name '{leaf}' is ambiguous ({matches.Count} matches) — used '{matches[0]}'. Pass a full asset path to select a specific one.";
+ return AssetDatabase.LoadAssetAtPath(resolvedPath);
+ }
+
+ ///
+ /// Apply the optional 'layer' arg (a layer NAME or an int index). Returns a warning
+ /// string when the requested layer doesn't exist (rather than silently leaving the
+ /// object on its current layer), else null (report B8).
+ ///
+ private static string ApplyLayer(GameObject go, Dictionary args)
+ {
+ if (!args.ContainsKey("layer") || args["layer"] == null) return null;
+ var raw = args["layer"];
+ int idx;
+ if (raw is long l) idx = (int)l;
+ else if (raw is int i) idx = i;
+ else if (raw is double db) idx = (int)db;
+ else
+ {
+ var name = raw.ToString();
+ idx = int.TryParse(name, out var parsed) ? parsed : LayerMask.NameToLayer(name);
+ }
+ if (idx < 0 || idx > 31)
+ return $"Layer '{raw}' not found — left the object on '{LayerMask.LayerToName(go.layer)}'.";
+ go.layer = idx;
+ return null;
+ }
+
+ ///
+ /// Reparent to the optional 'parent' arg (a hierarchy path), keeping world position.
+ /// Returns a warning when the parent path can't be found, else null (report B8).
+ ///
+ private static string ApplyParent(GameObject go, Dictionary args)
+ {
+ if (!args.ContainsKey("parent") || args["parent"] == null) return null;
+ var path = args["parent"].ToString();
+ if (string.IsNullOrEmpty(path)) return null;
+ // GameObject.Find matches an ACTIVE object by hierarchy path — try that first.
+ var parent = GameObject.Find(path);
+ // Fallback for a bare NAME (no '/'): GameObject.Find can't see inactive objects (same
+ // Unity gotcha as the delete guard), so a toggled-off container ("Furniture") would be
+ // missed. Match it across inactive objects too, but only accept a UNIQUE match so we
+ // never silently grab the wrong container.
+ if (parent == null && !path.Contains("/"))
+ {
+ foreach (var g in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None))
+ {
+ if (g.name != path) continue;
+ if (parent != null) return $"Parent '{path}' matches multiple inactive objects — left the object at the scene root; pass a full hierarchy path.";
+ parent = g;
+ }
+ }
+ if (parent == null) return $"Parent '{path}' not found — left the object at the scene root.";
+ go.transform.SetParent(parent.transform, true);
+ return null;
+ }
+
+ ///
+ /// Replace null material slots with ProBuilder's default material. CSG keys a
+ /// dictionary by material and throws ("Value cannot be null. Parameter name: key")
+ /// when any slot is null. Undo-tracked; returns whether anything changed.
+ ///
+ private static bool EnsureRendererMaterials(GameObject go)
+ {
+ var mr = go.GetComponent();
+ if (mr == null) return false;
+ var mats = mr.sharedMaterials;
+ bool changed = false;
+ if (mats == null || mats.Length == 0)
+ {
+ mats = new[] { BuiltinMaterials.defaultMaterial };
+ changed = true;
+ }
+ else
+ {
+ for (int i = 0; i < mats.Length; i++)
+ if (mats[i] == null) { mats[i] = BuiltinMaterials.defaultMaterial; changed = true; }
+ }
+ if (changed)
+ {
+ Undo.RegisterCompleteObjectUndo(mr, "Assign Default Material");
+ mr.sharedMaterials = mats;
+ }
+ return changed;
+ }
+
+ /// Invoke ProBuilder's internal CSG.Union/Subtract/Intersect via reflection.
+ private static Mesh InvokeCsg(string method, GameObject a, GameObject b, out string error)
+ {
+ error = null;
+ try
+ {
+ var csg = System.AppDomain.CurrentDomain.GetAssemblies()
+ .SelectMany(asm => { try { return asm.GetTypes(); } catch { return new System.Type[0]; } })
+ .FirstOrDefault(t => t.Name == "CSG" && t.Namespace != null && t.Namespace.Contains("ProBuilder"));
+ if (csg == null) { error = "ProBuilder CSG type not found."; return null; }
+ var mi = csg.GetMethod(method, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
+ null, new[] { typeof(GameObject), typeof(GameObject) }, null);
+ if (mi == null) { error = $"ProBuilder CSG.{method} not found."; return null; }
+ // CSG.Union/Subtract/Intersect return a Csg.Model; its .mesh property is the built Mesh.
+ var modelObj = mi.Invoke(null, new object[] { a, b });
+ if (modelObj == null) { error = "Boolean operation returned no result."; return null; }
+ var meshProp = modelObj.GetType().GetProperty("mesh");
+ var mesh = meshProp != null ? meshProp.GetValue(modelObj) as Mesh : null;
+ if (mesh == null) { error = "Boolean result had no mesh."; return null; }
+ return mesh;
+ }
+ catch (System.Exception ex) { error = "Boolean op failed: " + (ex.InnerException ?? ex).Message; return null; }
+ }
+
+ private static bool TryResolve(Dictionary args, out ProBuilderMesh pb, out object error)
+ {
+ pb = null; error = null;
+ var go = ResolveGameObject(args, "path", "instanceId");
+ if (go == null) { error = Error("GameObject not found (pass 'path' or 'instanceId')."); return false; }
+ pb = go.GetComponent();
+ if (pb == null) { error = Error($"'{go.name}' is not a ProBuilder object. Use probuilder/probuilderize first."); return false; }
+ return true;
+ }
+
+ private static GameObject ResolveGameObject(Dictionary args, string pathKey, string idKey)
+ {
+ if (args.ContainsKey(idKey) && args[idKey] != null)
+ {
+ var obj = MCPObjectId.ToObject(args[idKey]);
+ if (obj is GameObject g) return g;
+ if (obj is Component c) return c.gameObject;
+ }
+ if (args.ContainsKey(pathKey) && args[pathKey] != null)
+ return GameObject.Find(args[pathKey].ToString());
+ return null;
+ }
+
+ private static List ResolveFaces(ProBuilderMesh pb, Dictionary args, out object error)
+ {
+ error = null;
+ if (!args.ContainsKey("faceIndices") || !(args["faceIndices"] is List raw))
+ {
+ error = Error("faceIndices (array of face indices) is required. Use probuilder/info for the face count.");
+ return null;
+ }
+ var faces = new List();
+ foreach (var o in raw)
+ {
+ if (o == null || !int.TryParse(o.ToString(), out int idx) || idx < 0 || idx >= pb.faceCount)
+ { error = Error($"Face index out of range: {o} (mesh has {pb.faceCount} faces)."); return null; }
+ faces.Add(pb.faces[idx]);
+ }
+ if (faces.Count == 0) { error = Error("faceIndices is empty."); return null; }
+ return faces;
+ }
+
+ ///
+ /// Import a source mesh + materials into . The mesh and materials
+ /// MUST be captured by the caller BEFORE adding the ProBuilderMesh component — adding it
+ /// resets the MeshFilter's sharedMesh to an empty mesh, so reading it afterwards yields null.
+ ///
+ private static bool MeshImporter_TryImport(ProBuilderMesh pb, Mesh sourceMesh, Material[] mats)
+ {
+ if (sourceMesh == null) return false;
+ try
+ {
+ var importer = new MeshImporter(sourceMesh, mats ?? new Material[0], pb);
+ importer.Import(new MeshImportSettings { quads = true, smoothing = true, smoothingAngle = 30f });
+ return true;
+ }
+ catch { return false; }
+ }
+
+ private static ExtrudeMethod ParseExtrudeMethod(Dictionary args)
+ {
+ string m = args.ContainsKey("method") ? args["method"].ToString().ToLowerInvariant() : "faceNormal";
+ switch (m)
+ {
+ case "individual": case "individualfaces": return ExtrudeMethod.IndividualFaces;
+ case "vertexnormal": return ExtrudeMethod.VertexNormal;
+ default: return ExtrudeMethod.FaceNormal;
+ }
+ }
+
+ private static object Ok(ProBuilderMesh pb, Dictionary extra)
+ {
+ var d = new Dictionary
+ {
+ { "success", true },
+ { "name", pb.name },
+ { "instanceId", MCPObjectId.Get(pb.gameObject) },
+ { "faceCount", pb.faceCount },
+ { "vertexCount", pb.vertexCount },
+ };
+ foreach (var kv in extra) d[kv.Key] = kv.Value;
+ return d;
+ }
+
+ private static Dictionary V(Vector3 v) =>
+ new Dictionary { { "x", v.x }, { "y", v.y }, { "z", v.z } };
+#endif
+
+ // ─── Shared arg helpers (available in both branches) ───
+ // Typed-first via MCPArgs: the old value.ToString() + invariant-parse round-trip
+ // silently dropped every non-integer number on decimal-comma locales (battle-test
+ // BUG 1); present-but-invalid params now throw instead of defaulting silently.
+ private static object Error(string msg) => new Dictionary { { "error", msg } };
+
+ private static float GetFloat(Dictionary a, string k, float def) => MCPArgs.GetFloat(a, k, def);
+
+ private static int GetInt(Dictionary a, string k, int def) => MCPArgs.GetInt(a, k, def);
+
+ private static bool GetBool(Dictionary a, string k, bool def) => MCPArgs.GetBool(a, k, def);
+ }
+}
diff --git a/Editor/MCPProBuilderCommands.cs.meta b/Editor/MCPProBuilderCommands.cs.meta
new file mode 100644
index 0000000..cae164e
--- /dev/null
+++ b/Editor/MCPProBuilderCommands.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1b32080e54cb485c92b0ea6b4f78a436
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPProjectCommands.cs b/Editor/MCPProjectCommands.cs
index 398a481..640949a 100755
--- a/Editor/MCPProjectCommands.cs
+++ b/Editor/MCPProjectCommands.cs
@@ -12,7 +12,7 @@ public static class MCPProjectCommands
{
public static object GetInfo()
{
- string projectPath = Application.dataPath.Replace("/Assets", "");
+ string projectPath = MCPAssetSafety.ProjectRoot.Replace('\\', '/');
// Get scenes in build settings
var buildScenes = EditorBuildSettings.scenes
diff --git a/Editor/MCPRequestQueue.cs b/Editor/MCPRequestQueue.cs
index fd420bf..922ab86 100644
--- a/Editor/MCPRequestQueue.cs
+++ b/Editor/MCPRequestQueue.cs
@@ -256,6 +256,21 @@ public static void ProcessNextRequests()
batch = DequeueNextBatch();
if (batch == null || batch.Count == 0) return;
+ // Drop tickets whose sync waiter already gave up (TimedOut). The client was
+ // told the call failed and may have retried; executing the abandoned ticket
+ // now would run a non-idempotent action a second time. ExecuteWithTracking
+ // sets TimedOut under this same lock, so this check is race-safe.
+ batch.RemoveAll(t =>
+ {
+ if (t.Status == RequestStatus.TimedOut)
+ {
+ _executingTickets.Remove(t.TicketId);
+ return true;
+ }
+ return false;
+ });
+ if (batch.Count == 0) return;
+
// Mark all as executing and track in-flight
foreach (var t in batch)
{
@@ -267,8 +282,30 @@ public static void ProcessNextRequests()
// --- Execute OUTSIDE lock (main thread) ---
foreach (var ticket in batch)
{
- // Capture undo group before execution for undo support
- int undoGroupBefore = UnityEditor.Undo.GetCurrentGroup();
+ // Give each WRITE action its own named, collapsed Undo group so it can be
+ // reverted independently (per-action / per-agent undo via undo/last) and shows
+ // up named in Unity's Undo history. Reads and undo/redo ops don't open a group,
+ // so they never clutter the history or shift group indices out from under a
+ // pending undo. GetCurrentGroup() alone is unreliable — many write ops (e.g.
+ // RegisterCreatedObjectUndo) don't advance it — so we open the group explicitly.
+ //
+ // Deferred actions are EXCLUDED: their completion fires an arbitrary number of
+ // frames later, so a CollapseUndoOperations then would fold ANY other agent's
+ // interleaved group into this one (corrupting per-action undo bookkeeping). They
+ // are already excluded from history recording below, so they need no group.
+ bool opensUndoGroup =
+ ticket.DeferredAction == null
+ && !IsReadOperation(ticket.ActionName)
+ && !(ticket.ActionName != null && ticket.ActionName.StartsWith("undo/"));
+ int undoGroup = -1;
+ int undoRecordsBefore = -1;
+ if (opensUndoGroup)
+ {
+ undoRecordsBefore = CountUndoRecords();
+ UnityEditor.Undo.IncrementCurrentGroup();
+ undoGroup = UnityEditor.Undo.GetCurrentGroup();
+ UnityEditor.Undo.SetCurrentGroupName(ticket.ActionName ?? "MCP Action");
+ }
// Deferred actions complete via callback on a future editor frame.
if (ticket.DeferredAction != null)
@@ -327,7 +364,23 @@ public static void ProcessNextRequests()
ticket.CompletedAt = DateTime.UtcNow;
ticket.Action = null; // Free the closure
- int undoGroupAfter = UnityEditor.Undo.GetCurrentGroup();
+ // Fold everything this action registered into its single named group so one
+ // undo/last (or a native Ctrl+Z) reverts the whole action as one step.
+ if (undoGroup >= 0)
+ UnityEditor.Undo.CollapseUndoOperations(undoGroup);
+
+ // An action is undoable only if it ACTUALLY registered an undo op. Reads that
+ // slip past IsReadOperation, and execute-code that only inspects, open an empty
+ // group we must not offer as an undo target (reverting it would be a confusing
+ // no-op). Fail open: if the internal record-count API is unavailable, keep the
+ // group (old behavior). We can only prove "empty" when both counts are valid.
+ bool didRegisterUndo = undoGroup >= 0;
+ if (didRegisterUndo && undoRecordsBefore >= 0)
+ {
+ int undoRecordsAfter = CountUndoRecords();
+ if (undoRecordsAfter >= 0 && undoRecordsAfter <= undoRecordsBefore)
+ didRegisterUndo = false;
+ }
// Record action in history
try
@@ -341,7 +394,14 @@ public static void ProcessNextRequests()
Status = ticket.Status.ToString(),
ExecutionTimeMs = ticket.ExecutionTimeMs,
ErrorMessage = ticket.ErrorMessage,
- UndoGroup = undoGroupAfter != undoGroupBefore ? undoGroupBefore : -1,
+ // Only a completed write action that registered an undo op is undoable;
+ // its dedicated group is the revert target for undo/last. Reads, empty
+ // groups, undo-ops and failures stay -1. execute-code is excluded too:
+ // it's an introspection escape hatch whose temp-host churn registers undo
+ // but shouldn't shadow the agent's real edits as an undo/last target
+ // (its group still isolates that churn; native Ctrl+Z still reaches it).
+ UndoGroup = (ticket.Status == RequestStatus.Completed && didRegisterUndo
+ && ticket.ActionName != "editor/execute-code") ? undoGroup : -1,
};
// Try to extract target object info from result
@@ -581,6 +641,38 @@ private static bool IsReadOperation(string actionName)
|| lower.Contains("/status");
}
+ // Cached reflection for UnityEditor.Undo.GetRecords(List, List) — the
+ // internal API the Undo History window uses. Lets us tell whether an action actually
+ // put something on the undo stack (see the undo-group logic in ProcessNextRequests).
+ private static System.Reflection.MethodInfo _getUndoRecords;
+ private static bool _getUndoRecordsResolved;
+ private static readonly List _undoScratchU = new List();
+ private static readonly List _undoScratchR = new List();
+
+ /// Current undo-stack depth, or -1 if the internal API is unavailable.
+ private static int CountUndoRecords()
+ {
+ try
+ {
+ if (!_getUndoRecordsResolved)
+ {
+ _getUndoRecords = typeof(UnityEditor.Undo).GetMethod(
+ "GetRecords",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static,
+ null,
+ new[] { typeof(List), typeof(List) },
+ null);
+ _getUndoRecordsResolved = true;
+ }
+ if (_getUndoRecords == null) return -1;
+ _undoScratchU.Clear();
+ _undoScratchR.Clear();
+ _getUndoRecords.Invoke(null, new object[] { _undoScratchU, _undoScratchR });
+ return _undoScratchU.Count;
+ }
+ catch { return -1; }
+ }
+
private static void PurgeEmptyQueues()
{
for (int i = _rrOrder.Count - 1; i >= 0; i--)
diff --git a/Editor/MCPSceneCommands.cs b/Editor/MCPSceneCommands.cs
index da4f5f9..c8bce86 100755
--- a/Editor/MCPSceneCommands.cs
+++ b/Editor/MCPSceneCommands.cs
@@ -83,6 +83,13 @@ public static object GetHierarchy(Dictionary args)
if (args != null && args.ContainsKey("maxNodes"))
maxNodes = System.Convert.ToInt32(args["maxNodes"]);
+ // Dense by default: per-node fields that carry their default value (active=true,
+ // tag=Untagged, layer=Default, position=origin, the universal Transform component,
+ // childCount implied by a complete children array) are omitted — on real scenes
+ // that's most of the payload. verbose:true restores the full per-node shape.
+ bool verbose = args != null && args.ContainsKey("verbose") && args["verbose"] != null
+ && (args["verbose"].ToString().ToLowerInvariant() == "true" || args["verbose"].ToString() == "1");
+
// Optional: only return hierarchy under a specific parent path
string parentPath = null;
if (args != null && args.ContainsKey("parentPath"))
@@ -112,7 +119,7 @@ public static object GetHierarchy(Dictionary args)
foreach (var root in startObjects)
{
- var node = BuildHierarchyNode(root, 0, maxDepth, ref nodeCount, maxNodes);
+ var node = BuildHierarchyNode(root, 0, maxDepth, ref nodeCount, maxNodes, verbose);
if (node != null)
hierarchy.Add(node);
if (nodeCount >= maxNodes)
@@ -158,7 +165,7 @@ private static void CountRecursive(GameObject go, ref int count)
}
private static Dictionary BuildHierarchyNode(
- GameObject go, int depth, int maxDepth, ref int nodeCount, int maxNodes)
+ GameObject go, int depth, int maxDepth, ref int nodeCount, int maxNodes, bool verbose)
{
if (nodeCount >= maxNodes)
return null;
@@ -168,21 +175,30 @@ private static Dictionary BuildHierarchyNode(
var components = new List();
foreach (var comp in go.GetComponents())
{
- if (comp != null)
- components.Add(comp.GetType().Name);
+ if (comp == null) continue;
+ string typeName = comp.GetType().Name;
+ // Dense mode: every GameObject has a Transform — listing it is pure noise.
+ // (RectTransform stays: it distinguishes UI objects.)
+ if (!verbose && typeName == "Transform") continue;
+ components.Add(typeName);
}
var node = new Dictionary
{
{ "name", go.name },
{ "instanceId", MCPObjectId.Get(go) },
- { "active", go.activeSelf },
- { "tag", go.tag },
- { "layer", LayerMask.LayerToName(go.layer) },
- { "components", components },
- { "position", VectorToDict(go.transform.position) },
};
+ // Dense mode omits default-valued fields; their absence means the default.
+ // verbose:true restores the always-present shape.
+ if (verbose || !go.activeSelf) node["active"] = go.activeSelf;
+ if (verbose || !go.CompareTag("Untagged")) node["tag"] = go.tag;
+ if (verbose || go.layer != 0) node["layer"] = LayerMask.LayerToName(go.layer);
+ if (verbose || components.Count > 0) node["components"] = components;
+ // Vector3 == uses an approximate comparison, so float noise still counts as origin.
+ if (verbose || go.transform.position != Vector3.zero)
+ node["position"] = VectorToDict(go.transform.position);
+
if (depth < maxDepth && go.transform.childCount > 0)
{
var children = new List();
@@ -196,13 +212,15 @@ private static Dictionary BuildHierarchyNode(
node["childrenTruncated"] = true;
break;
}
- var childNode = BuildHierarchyNode(go.transform.GetChild(i).gameObject, depth + 1, maxDepth, ref nodeCount, maxNodes);
+ var childNode = BuildHierarchyNode(go.transform.GetChild(i).gameObject, depth + 1, maxDepth, ref nodeCount, maxNodes, verbose);
if (childNode != null)
children.Add(childNode);
}
if (children.Count > 0)
node["children"] = children;
- if (!node.ContainsKey("childCount"))
+ // A complete children array implies childCount — only emit when it adds
+ // information (truncated above, or verbose callers wanting the old shape).
+ if (!node.ContainsKey("childCount") && (verbose || children.Count != go.transform.childCount))
node["childCount"] = go.transform.childCount;
}
else if (go.transform.childCount > 0)
diff --git a/Editor/MCPScriptCommands.cs b/Editor/MCPScriptCommands.cs
index 6a94d5f..fa705c1 100755
--- a/Editor/MCPScriptCommands.cs
+++ b/Editor/MCPScriptCommands.cs
@@ -12,19 +12,24 @@ public static object Create(Dictionary args)
string path = args.ContainsKey("path") ? args["path"].ToString() : "";
string content = args.ContainsKey("content") ? args["content"].ToString() : "";
- if (string.IsNullOrEmpty(path))
- return new { error = "path is required" };
if (string.IsNullOrEmpty(content))
return new { error = "content is required" };
- string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path);
- string dir = Path.GetDirectoryName(fullPath);
+ // Resolve under the project root and reject traversal/absolute escapes.
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new { error = pathError };
+
+ // Never silently overwrite an existing script (source-code loss).
+ var overwriteError = MCPAssetSafety.OverwriteGuard(path, args);
+ if (overwriteError != null)
+ return overwriteError;
+ string dir = Path.GetDirectoryName(fullPath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(fullPath, content);
- AssetDatabase.ImportAsset(path);
+ AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(path));
return new { success = true, path, size = content.Length };
}
@@ -32,10 +37,9 @@ public static object Create(Dictionary args)
public static object Read(Dictionary args)
{
string path = args.ContainsKey("path") ? args["path"].ToString() : "";
- if (string.IsNullOrEmpty(path))
- return new { error = "path is required" };
- string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path);
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new { error = pathError };
if (!File.Exists(fullPath))
return new { error = $"File not found: {path}" };
@@ -53,18 +57,23 @@ public static object Read(Dictionary args)
public static object Update(Dictionary args)
{
string path = args.ContainsKey("path") ? args["path"].ToString() : "";
- string content = args.ContainsKey("content") ? args["content"].ToString() : "";
- if (string.IsNullOrEmpty(path))
- return new { error = "path is required" };
+ // Update REQUIRES non-empty content: an unvalidated empty/missing content used to
+ // truncate the target source file to zero bytes and import the wreckage. Empty
+ // string is the most likely accidental shape (a templating var that resolved to ""),
+ // so reject it like Create does — clearing a file must be deliberate, not a default.
+ string content = (args.ContainsKey("content") ? args["content"]?.ToString() : null);
+ if (string.IsNullOrEmpty(content))
+ return new { error = "content is required (non-empty). To intentionally clear a file, write a single newline." };
- string fullPath = Path.Combine(Application.dataPath.Replace("/Assets", ""), path);
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new { error = pathError };
if (!File.Exists(fullPath))
- return new { error = $"File not found: {path}" };
+ return new { error = $"File not found: {path}. Use script/create for a new file." };
File.WriteAllText(fullPath, content);
- AssetDatabase.ImportAsset(path);
+ AssetDatabase.ImportAsset(MCPAssetSafety.ToAssetDatabasePath(path));
return new { success = true, path, size = content.Length };
}
diff --git a/Editor/MCPScriptableObjectCommands.cs b/Editor/MCPScriptableObjectCommands.cs
index cf15af3..0d9d1dc 100644
--- a/Editor/MCPScriptableObjectCommands.cs
+++ b/Editor/MCPScriptableObjectCommands.cs
@@ -55,6 +55,12 @@ public static object CreateScriptableObject(Dictionary args)
}
}
+ // Never silently replace an existing asset (CreateAsset reuses the GUID and
+ // destroys the old asset plus every reference to it).
+ var overwriteError = MCPAssetSafety.OverwriteGuard(path, args);
+ if (overwriteError != null)
+ return overwriteError;
+
var so = ScriptableObject.CreateInstance(soType);
AssetDatabase.CreateAsset(so, path);
AssetDatabase.SaveAssets();
diff --git a/Editor/MCPSelfTest.cs b/Editor/MCPSelfTest.cs
index 21bef43..f68e7d7 100644
--- a/Editor/MCPSelfTest.cs
+++ b/Editor/MCPSelfTest.cs
@@ -111,11 +111,26 @@ static MCPSelfTest()
if (resumeIndex >= 0)
{
Debug.Log($"[MCP SelfTest] Domain reload detected mid-run — resuming from index {resumeIndex}");
- // Delay resume by one frame to let the editor finish initializing
- EditorApplication.delayCall += () => ResumeFromIndex(resumeIndex);
+ // Resume on the first editor update. delayCall registered during
+ // InitializeOnLoad can be silently dropped by the reload (observed: a
+ // battery interrupted at the asmdef/asset recompile never resumed);
+ // an update subscription always fires.
+ _pendingResumeIndex = resumeIndex;
+ EditorApplication.update += ResumePendingOnce;
}
}
+ private static int _pendingResumeIndex = -1;
+
+ private static void ResumePendingOnce()
+ {
+ EditorApplication.update -= ResumePendingOnce;
+ int index = _pendingResumeIndex;
+ _pendingResumeIndex = -1;
+ if (index >= 0)
+ ResumeFromIndex(index);
+ }
+
// ─── Persistence helpers ─────────────────────────────────────
/// Tracks current step index for domain-reload resume. -1 = not running.
@@ -233,6 +248,7 @@ private static void RestoreFromSession()
{ "navigation", TestNavigation },
{ "packagemanager", TestPackageManager },
{ "particle", TestParticle },
+ { "probuilder", TestProBuilder },
{ "prefabasset", TestPrefabAsset },
{ "prefs", TestPrefs },
{ "projectsettings",TestProjectSettings },
@@ -1167,6 +1183,42 @@ private static string TestUI()
}
}
+ // --- ProBuilder ---
+ private static string TestProBuilder()
+ {
+#if PROBUILDER_INSTALLED
+ GameObject probe = null;
+ try
+ {
+ var args = new Dictionary { { "shape", "cube" }, { "name", "__mcp_selftest_pb" } };
+ var result = MCPProBuilderCommands.CreateShape(args) as Dictionary;
+ if (result == null || !result.ContainsKey("success"))
+ return "ProBuilder.CreateShape returned no success payload";
+ probe = GameObject.Find("__mcp_selftest_pb");
+ if (probe == null)
+ return "ProBuilder.CreateShape did not create the probe object";
+ if (!result.ContainsKey("faceCount") || Convert.ToInt32(result["faceCount"]) != 6)
+ return "ProBuilder cube probe has unexpected face count";
+ return null;
+ }
+ catch (Exception ex)
+ {
+ return $"ProBuilder.CreateShape threw: {ex.Message}";
+ }
+ finally
+ {
+ if (probe != null) UnityEngine.Object.DestroyImmediate(probe);
+ else
+ {
+ var leftover = GameObject.Find("__mcp_selftest_pb");
+ if (leftover != null) UnityEngine.Object.DestroyImmediate(leftover);
+ }
+ }
+#else
+ return null; // ProBuilder not installed — pass (handler not compiled)
+#endif
+ }
+
// --- UMA ---
private static string TestUMA()
{
diff --git a/Editor/MCPSettingsManager.cs b/Editor/MCPSettingsManager.cs
index df083c3..34522f9 100644
--- a/Editor/MCPSettingsManager.cs
+++ b/Editor/MCPSettingsManager.cs
@@ -109,7 +109,7 @@ private static void MigrateString(string name, string prefix)
"amplify", "animation", "asmdef", "asset", "audio", "build", "component", "console",
"constraint", "debugger", "editor", "gameobject", "graphics", "input", "lighting",
"memoryprofiler", "mppm", "navigation", "packagemanager", "particle", "physics", "prefab",
- "prefabasset", "prefs", "profiler", "project", "projectsettings", "renderer",
+ "prefabasset", "prefs", "probuilder", "profiler", "project", "projectsettings", "renderer",
"scenario", "scene", "screenshot", "script", "scriptableobject", "search",
"selection", "shadergraph", "spriteatlas", "taglayer", "terrain", "testing",
"texture", "ui", "uma", "undo"
diff --git a/Editor/MCPShaderGraphApi.cs b/Editor/MCPShaderGraphApi.cs
new file mode 100644
index 0000000..45cea58
--- /dev/null
+++ b/Editor/MCPShaderGraphApi.cs
@@ -0,0 +1,292 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using UnityEditor;
+using UnityEngine;
+
+namespace UnityMCP.Editor
+{
+ ///
+ /// Reflection wrapper over ShaderGraph's REAL editor API (GraphData / MultiJson /
+ /// FileUtilities). ShaderGraph edits used to be done by regex/string surgery on the
+ /// .shadergraph JSON, which produced invalid graphs (dangling output node, blanked
+ /// edges, unbound property nodes → import NRE). Round-tripping through the actual
+ /// GraphData model guarantees the serialized result is always valid, because
+ /// ShaderGraph itself writes it — exactly what the editor does on save.
+ ///
+ /// All types are internal to Unity.ShaderGraph.Editor / the URP editor assembly, so
+ /// every access is reflected. Handles are resolved once and cached; Available is false
+ /// if the ShaderGraph package or an expected member is missing (older/newer versions),
+ /// and callers fall back to a clear error rather than corrupting anything.
+ ///
+ internal static class MCPShaderGraphApi
+ {
+ internal static bool Available { get; private set; }
+ internal static string UnavailableReason { get; private set; }
+
+ private static Type _graphDataT, _targetT, _blockFieldT, _absNodeT, _slotRefT, _materialSlotT, _messageManagerT, _multiJsonT, _fileUtilT, _propertyNodeT;
+ private static Type _urpTargetT, _litSubT, _unlitSubT, _spriteLitSubT, _spriteUnlitSubT;
+ private static PropertyInfo _messageManagerProp, _objectIdProp, _drawStateProp, _edgesProp, _drawPositionProp;
+ private static MethodInfo _addContexts, _initOutputs, _getActiveBlocks, _addRemoveBlocks, _onEnable, _addNode, _getNodeFromId, _connect, _removeEdge, _removeNode, _getNodesGeneric, _getSlotsGeneric, _trySetSubTarget;
+ private static MethodInfo _serialize, _deserializeGeneric, _writeToDisk, _writeGraphToDisk;
+ private static ConstructorInfo _slotRefCtor;
+
+ static MCPShaderGraphApi()
+ {
+ try { Initialize(); Available = true; }
+ catch (Exception ex) { Available = false; UnavailableReason = ex.Message; }
+ }
+
+ private static Type Req(Assembly asm, string fullName)
+ {
+ var t = asm.GetType(fullName);
+ if (t == null) throw new InvalidOperationException($"ShaderGraph type not found: {fullName}");
+ return t;
+ }
+
+ private static Type FindType(Assembly asm, string simpleName)
+ {
+ try { return asm.GetTypes().FirstOrDefault(t => t.Name == simpleName); }
+ catch (ReflectionTypeLoadException e) { return e.Types.FirstOrDefault(t => t != null && t.Name == simpleName); }
+ }
+
+ private static void Initialize()
+ {
+ var sg = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Unity.ShaderGraph.Editor")
+ ?? throw new InvalidOperationException("Unity.ShaderGraph.Editor assembly not loaded");
+ var urp = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.GetName().Name == "Unity.RenderPipelines.Universal.Editor");
+
+ _graphDataT = Req(sg, "UnityEditor.ShaderGraph.GraphData");
+ _targetT = Req(sg, "UnityEditor.ShaderGraph.Target");
+ _blockFieldT = Req(sg, "UnityEditor.ShaderGraph.BlockFieldDescriptor");
+ _absNodeT = Req(sg, "UnityEditor.ShaderGraph.AbstractMaterialNode");
+ _slotRefT = Req(sg, "UnityEditor.Graphing.SlotReference");
+ _materialSlotT = Req(sg, "UnityEditor.ShaderGraph.MaterialSlot");
+ _multiJsonT = Req(sg, "UnityEditor.ShaderGraph.Serialization.MultiJson");
+ _fileUtilT = Req(sg, "UnityEditor.ShaderGraph.FileUtilities");
+ _propertyNodeT = sg.GetType("UnityEditor.ShaderGraph.PropertyNode");
+ _messageManagerT = FindType(sg, "MessageManager") ?? throw new InvalidOperationException("MessageManager not found");
+
+ const BindingFlags PIF = BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic;
+ const BindingFlags SF = BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic;
+
+ _messageManagerProp = _graphDataT.GetProperty("messageManager", PIF) ?? throw new InvalidOperationException("messageManager");
+ _objectIdProp = _absNodeT.GetProperty("objectId", PIF) ?? throw new InvalidOperationException("objectId");
+ _drawStateProp = _absNodeT.GetProperty("drawState", PIF) ?? throw new InvalidOperationException("drawState");
+ _edgesProp = _graphDataT.GetProperty("edges", PIF) ?? throw new InvalidOperationException("edges");
+ _drawPositionProp = _drawStateProp.PropertyType.GetProperty("position", PIF) ?? throw new InvalidOperationException("drawState.position");
+
+ _addContexts = _graphDataT.GetMethod("AddContexts", PIF);
+ _initOutputs = _graphDataT.GetMethod("InitializeOutputs", PIF);
+ _getActiveBlocks = _graphDataT.GetMethod("GetActiveBlocksForAllActiveTargets", PIF);
+ _addRemoveBlocks = _graphDataT.GetMethod("AddRemoveBlocksFromActiveList", PIF);
+ _onEnable = _graphDataT.GetMethod("OnEnable", PIF);
+ _addNode = _graphDataT.GetMethod("AddNode", PIF, null, new[] { _absNodeT }, null);
+ // GetNodeFromId has both a generic and a non-generic (string) overload, so a
+ // typed GetMethod is ambiguous — pick the non-generic one explicitly.
+ _getNodeFromId = _graphDataT.GetMethods(PIF).FirstOrDefault(m =>
+ m.Name == "GetNodeFromId" && !m.IsGenericMethod &&
+ m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == typeof(string));
+ _connect = _graphDataT.GetMethod("Connect", PIF, null, new[] { _slotRefT, _slotRefT }, null);
+ _removeEdge = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "RemoveEdge" && m.GetParameters().Length == 1);
+ _removeNode = _graphDataT.GetMethods(PIF).FirstOrDefault(m =>
+ m.Name == "RemoveNode" && m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType == _absNodeT);
+ _getNodesGeneric = _graphDataT.GetMethods(PIF).FirstOrDefault(m => m.Name == "GetNodes" && m.IsGenericMethod);
+ _getSlotsGeneric = _absNodeT.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == "GetSlots" && m.IsGenericMethod && m.GetParameters().Length == 1);
+ _trySetSubTarget = _urpTargetTryResolve(urp);
+ _slotRefCtor = _slotRefT.GetConstructor(new[] { _absNodeT, typeof(int) }) ?? throw new InvalidOperationException("SlotReference ctor");
+
+ _serialize = _multiJsonT.GetMethod("Serialize", SF);
+ // Pin the 4-arg Deserialize(T, string, JsonObject, bool) shape LoadGraph invokes;
+ // a different arity in some version would otherwise TargetParameterCountException at
+ // call time despite Available==true. Fail closed at init instead.
+ _deserializeGeneric = _multiJsonT.GetMethods(SF).FirstOrDefault(m => m.Name == "Deserialize" && m.IsGenericMethod && m.GetParameters().Length == 4);
+ _writeToDisk = _fileUtilT.GetMethod("WriteToDisk", SF);
+ _writeGraphToDisk = _fileUtilT.GetMethod("WriteShaderGraphToDisk", SF);
+
+ if (_addContexts == null || _initOutputs == null || _getActiveBlocks == null || _addRemoveBlocks == null ||
+ _onEnable == null || _addNode == null || _getNodeFromId == null || _connect == null || _removeEdge == null ||
+ _removeNode == null || _getNodesGeneric == null || _serialize == null || _deserializeGeneric == null ||
+ _writeToDisk == null || _writeGraphToDisk == null)
+ throw new InvalidOperationException("One or more ShaderGraph API methods not found (version mismatch)");
+ }
+
+ private static MethodInfo _urpTargetTryResolve(Assembly urp)
+ {
+ if (urp == null) return null;
+ _urpTargetT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalTarget");
+ _litSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalLitSubTarget");
+ _unlitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalUnlitSubTarget");
+ _spriteLitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalSpriteLitSubTarget");
+ _spriteUnlitSubT = urp.GetType("UnityEditor.Rendering.Universal.ShaderGraph.UniversalSpriteUnlitSubTarget");
+ return _urpTargetT?.GetMethod("TrySetActiveSubTarget");
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // High-level operations
+ // ─────────────────────────────────────────────────────────────
+
+ /// Map a template name to a URP SubTarget type; null = blank (no target).
+ internal static Type ResolveTemplateSubTarget(string template)
+ {
+ switch ((template ?? "").ToLowerInvariant())
+ {
+ case "urp_lit":
+ case "lit":
+ case "": return _litSubT;
+ case "urp_unlit":
+ case "unlit": return _unlitSubT;
+ case "urp_sprite_lit":
+ case "sprite_lit": return _spriteLitSubT;
+ case "urp_sprite_unlit":
+ case "sprite_unlit": return _spriteUnlitSubT;
+ case "blank":
+ case "empty": return null;
+ default: return _litSubT;
+ }
+ }
+
+ internal static bool IsUrpAvailable => _urpTargetT != null && _trySetSubTarget != null;
+
+ ///
+ /// Create a valid new graph on disk. subTargetType null → a blank graph (valid, no
+ /// active target; the user picks one in-editor). Returns null on success, else an error message.
+ ///
+ internal static string CreateGraph(string assetPath, string fullPath, Type subTargetType)
+ {
+ var graph = NewGraph();
+ _addContexts.Invoke(graph, null);
+
+ if (subTargetType != null)
+ {
+ if (!IsUrpAvailable) return "URP Shader Graph targets are not available in this project; use template 'blank'.";
+ var target = Activator.CreateInstance(_urpTargetT);
+ _trySetSubTarget.Invoke(target, new object[] { subTargetType });
+ var targets = Array.CreateInstance(_targetT, 1);
+ targets.SetValue(target, 0);
+ _initOutputs.Invoke(graph, new object[] { targets, Array.CreateInstance(_blockFieldT, 0) });
+ _addRemoveBlocks.Invoke(graph, new object[] { _getActiveBlocks.Invoke(graph, null) });
+ }
+
+ _onEnable.Invoke(graph, null);
+ string text = (string)_serialize.Invoke(null, new object[] { graph });
+ _writeToDisk.Invoke(null, new object[] { assetPath, text });
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
+ return null;
+ }
+
+ /// Load an existing .shadergraph into a live, mutable GraphData.
+ internal static object LoadGraph(string fullPath)
+ {
+ string text = File.ReadAllText(fullPath);
+ var graph = NewGraph();
+ _deserializeGeneric.MakeGenericMethod(_graphDataT).Invoke(null, new object[] { graph, text, null, false });
+ _onEnable.Invoke(graph, null);
+ return graph;
+ }
+
+ /// Serialize a mutated graph back to disk and reimport.
+ internal static void SaveGraph(string assetPath, object graph)
+ {
+ _writeGraphToDisk.Invoke(null, new object[] { assetPath, graph });
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
+ }
+
+ private static object NewGraph()
+ {
+ var graph = Activator.CreateInstance(_graphDataT);
+ _messageManagerProp.SetValue(graph, Activator.CreateInstance(_messageManagerT));
+ return graph;
+ }
+
+ /// Instantiate a node type, set its position, and add it. Returns its objectId.
+ internal static string AddNode(object graph, Type nodeType, float x, float y)
+ {
+ var node = Activator.CreateInstance(nodeType);
+ var draw = _drawStateProp.GetValue(node);
+ _drawPositionProp.SetValue(draw, new Rect(x, y, 208, 300));
+ _drawStateProp.SetValue(node, draw); // DrawState is a struct — write back
+ _addNode.Invoke(graph, new object[] { node });
+ return (string)_objectIdProp.GetValue(node);
+ }
+
+ internal static object GetNodeFromId(object graph, string nodeId) => _getNodeFromId.Invoke(graph, new object[] { nodeId });
+
+ /// Remove a node and its connected edges (byte-faithful to survivors).
+ internal static void RemoveNode(object graph, object node) => _removeNode.Invoke(graph, new object[] { node });
+
+ ///
+ /// True if the template names a specific pipeline target we can't build because that
+ /// pipeline's editor assembly isn't present (so create would silently fall back to blank).
+ ///
+ internal static bool IsUnavailablePipelineTemplate(string template)
+ {
+ string t = (template ?? "").ToLowerInvariant();
+ bool wantsUrp = t.StartsWith("urp_") || t == "lit" || t == "unlit" || t.StartsWith("sprite_");
+ return wantsUrp && !IsUrpAvailable;
+ }
+
+ /// Find a slot id on a node by input/output and (optional) explicit id. Returns int.MinValue if none.
+ internal static int FindSlotId(object node, bool wantInput, int explicitId)
+ {
+ var listT = typeof(List<>).MakeGenericType(_materialSlotT);
+ var list = Activator.CreateInstance(listT);
+ _getSlotsGeneric.MakeGenericMethod(_materialSlotT).Invoke(node, new object[] { list });
+ foreach (var slot in (IEnumerable)list)
+ {
+ bool isInput = (bool)slot.GetType().GetProperty("isInputSlot").GetValue(slot);
+ int id = (int)slot.GetType().GetProperty("id").GetValue(slot);
+ if (isInput != wantInput) continue;
+ if (explicitId != int.MinValue) { if (id == explicitId) return id; }
+ else return id; // first matching-direction slot
+ }
+ return int.MinValue;
+ }
+
+ /// Connect output(node,slot) → input(node,slot). Returns null on success or an error.
+ internal static string Connect(object graph, object outNode, int outSlot, object inNode, int inSlot)
+ {
+ var outRef = _slotRefCtor.Invoke(new object[] { outNode, outSlot });
+ var inRef = _slotRefCtor.Invoke(new object[] { inNode, inSlot });
+ var edge = _connect.Invoke(graph, new object[] { outRef, inRef });
+ return edge == null ? "Connection refused by ShaderGraph (incompatible slots or would create a cycle)." : null;
+ }
+
+ /// Remove edges matching the 4-tuple (any component <0/null = wildcard). Returns removed count.
+ internal static int Disconnect(object graph, string outNodeId, int outSlot, string inNodeId, int inSlot)
+ {
+ var toRemove = new List();
+ foreach (var edge in (IEnumerable)_edgesProp.GetValue(graph))
+ {
+ var outSlotRef = edge.GetType().GetProperty("outputSlot").GetValue(edge);
+ var inSlotRef = edge.GetType().GetProperty("inputSlot").GetValue(edge);
+ string eOut = SlotRefNodeId(outSlotRef);
+ string eIn = SlotRefNodeId(inSlotRef);
+ int eOutSlot = (int)outSlotRef.GetType().GetProperty("slotId").GetValue(outSlotRef);
+ int eInSlot = (int)inSlotRef.GetType().GetProperty("slotId").GetValue(inSlotRef);
+
+ if (outNodeId != null && eOut != outNodeId) continue;
+ if (inNodeId != null && eIn != inNodeId) continue;
+ if (outSlot >= 0 && eOutSlot != outSlot) continue;
+ if (inSlot >= 0 && eInSlot != inSlot) continue;
+ toRemove.Add(edge);
+ }
+ foreach (var edge in toRemove)
+ _removeEdge.Invoke(graph, new object[] { edge });
+ return toRemove.Count;
+ }
+
+ private static string SlotRefNodeId(object slotRef)
+ {
+ // SlotReference exposes the owning node; read its objectId.
+ var nodeProp = slotRef.GetType().GetProperty("node", BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
+ var node = nodeProp?.GetValue(slotRef);
+ return node != null ? (string)_objectIdProp.GetValue(node) : null;
+ }
+
+ internal static Type ResolveNodeType(string name) => MCPShaderGraphCommands.ResolveShaderGraphNodeType(name);
+ }
+}
diff --git a/Editor/MCPShaderGraphApi.cs.meta b/Editor/MCPShaderGraphApi.cs.meta
new file mode 100644
index 0000000..1d4f030
--- /dev/null
+++ b/Editor/MCPShaderGraphApi.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c5528d1ff08b4c278dab088262e95bda
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/MCPShaderGraphCommands.cs b/Editor/MCPShaderGraphCommands.cs
index 50651ad..9bdcd39 100644
--- a/Editor/MCPShaderGraphCommands.cs
+++ b/Editor/MCPShaderGraphCommands.cs
@@ -389,89 +389,46 @@ public static object CreateShaderGraph(Dictionary args)
if (!path.EndsWith(".shadergraph"))
path += ".shadergraph";
- if (File.Exists(Path.Combine(Application.dataPath, "..", path)))
- return new Dictionary { { "error", $"File already exists at: {path}" } };
-
string template = args.ContainsKey("template") ? args["template"].ToString().ToLower() : "urp_lit";
- try
- {
- // Try using ShaderGraph's internal API to create via menu items
- // This is the most reliable approach as the JSON format is complex and version-dependent
-
- // First ensure directory exists
- string dir = Path.GetDirectoryName(Path.Combine(Application.dataPath, "..", path));
- if (!Directory.Exists(dir))
- Directory.CreateDirectory(dir);
+ if (!MCPShaderGraphApi.Available)
+ return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } };
- // Use ProjectWindowUtil for reliable creation
- bool created = false;
+ // Don't silently downgrade an explicit URP template to blank when URP isn't installed.
+ if (MCPShaderGraphApi.IsUnavailablePipelineTemplate(template))
+ return new Dictionary { { "error", $"Template '{template}' needs the URP Shader Graph package, which isn't installed. Use template 'blank' for a target-less graph." } };
- // Try menu item approach - create in a temp location then move
- string menuPath = GetMenuPathForTemplate(template);
+ // Resolve under the project root; guard against overwriting an existing graph
+ // (OverwriteGuard checks the canonical path + disk, so overwrite:true works).
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
+ var overwriteError = MCPAssetSafety.OverwriteGuard(path, args);
+ if (overwriteError != null)
+ return overwriteError;
- if (!string.IsNullOrEmpty(menuPath))
- {
- // Select the target folder first
- string folderPath = Path.GetDirectoryName(path);
- var folder = AssetDatabase.LoadAssetAtPath(folderPath);
- if (folder != null)
- Selection.activeObject = folder;
-
- // Create using internal API via reflection
- try
- {
- // Try to find the shader graph creation type
- Type createActionType = null;
- foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
- {
- if (asm.GetName().Name == "Unity.ShaderGraph.Editor")
- {
- createActionType = asm.GetType("UnityEditor.ShaderGraph.CreateShaderGraph");
- break;
- }
- }
-
- if (createActionType != null)
- {
- // Invoke the creation method
- var createMethod = createActionType.GetMethod("CreateGraph",
- BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic);
- if (createMethod != null)
- {
- createMethod.Invoke(null, new object[] { path });
- created = true;
- }
- }
- }
- catch { }
- }
-
- // Fallback: create a minimal .shadergraph file
- if (!created)
- {
- string graphContent = GetMinimalShaderGraphJson(template, Path.GetFileNameWithoutExtension(path));
- string fullPath = Path.Combine(Application.dataPath, "..", path);
- File.WriteAllText(fullPath, graphContent);
- AssetDatabase.ImportAsset(path);
- created = true;
- }
+ try
+ {
+ string dir = Path.GetDirectoryName(fullPath);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
- if (created)
- {
- AssetDatabase.Refresh();
- return new Dictionary
- {
- { "success", true },
- { "assetPath", path },
- { "template", template },
- { "note", "Shader graph created. Open it in the Shader Graph editor to add nodes." },
- };
- }
+ // Build the graph through ShaderGraph's real GraphData model so the output is
+ // always a valid, importable asset (the old hand-built JSON produced an
+ // invalid graph with a dangling output node — issue #18 bug 1).
+ Type subTarget = MCPShaderGraphApi.ResolveTemplateSubTarget(template);
+ string createError = MCPShaderGraphApi.CreateGraph(MCPAssetSafety.ToAssetDatabasePath(path), fullPath, subTarget);
+ if (createError != null)
+ return new Dictionary { { "error", createError } };
+ bool blank = subTarget == null;
return new Dictionary
{
- { "error", "Failed to create shader graph. Try creating it manually via Assets > Create > Shader Graph." },
+ { "success", true },
+ { "assetPath", path },
+ { "template", template },
+ { "note", blank
+ ? "Blank shader graph created (no active target). Open it and add a target, or use a 'urp_lit'/'urp_unlit' template."
+ : "Shader graph created with a URP target and its default blocks. Add nodes with shadergraph/add-node." },
};
}
catch (Exception ex)
@@ -617,58 +574,9 @@ public static object OpenVFXGraph(Dictionary args)
}
// ─── Helpers ───
-
- private static string GetMenuPathForTemplate(string template)
- {
- switch (template)
- {
- case "urp_lit": return "Assets/Create/Shader Graph/URP/Lit Shader Graph";
- case "urp_unlit": return "Assets/Create/Shader Graph/URP/Unlit Shader Graph";
- case "urp_sprite_lit": return "Assets/Create/Shader Graph/URP/Sprite Lit Shader Graph";
- case "urp_sprite_unlit": return "Assets/Create/Shader Graph/URP/Sprite Unlit Shader Graph";
- case "urp_decal": return "Assets/Create/Shader Graph/URP/Decal Shader Graph";
- case "hdrp_lit": return "Assets/Create/Shader Graph/HDRP/Lit Shader Graph";
- case "hdrp_unlit": return "Assets/Create/Shader Graph/HDRP/Unlit Shader Graph";
- case "blank": return "Assets/Create/Shader Graph/Blank Shader Graph";
- default: return null;
- }
- }
-
- private static string GetMinimalShaderGraphJson(string template, string name)
- {
- // Minimal valid .shadergraph file structure
- // This creates a basic graph that Unity can parse and open in the editor
- return $@"{{
- ""m_SGVersion"": 3,
- ""m_Type"": ""UnityEditor.ShaderGraph.GraphData"",
- ""m_ObjectId"": ""{Guid.NewGuid():N}"",
- ""m_Properties"": [],
- ""m_Keywords"": [],
- ""m_Dropdowns"": [],
- ""m_CategoryData"": [],
- ""m_Nodes"": [],
- ""m_GroupDatas"": [],
- ""m_StickyNoteDatas"": [],
- ""m_Edges"": [],
- ""m_VertexContext"": {{
- ""m_Position"": {{ ""x"": 0.0, ""y"": 0.0 }},
- ""m_Blocks"": []
- }},
- ""m_FragmentContext"": {{
- ""m_Position"": {{ ""x"": 200.0, ""y"": 0.0 }},
- ""m_Blocks"": []
- }},
- ""m_PreviewData"": {{
- ""serializedMesh"": {{ ""m_SerializedMesh"": """", ""m_Guid"": """" }}
- }},
- ""m_Path"": ""Shader Graphs"",
- ""m_GraphPrecision"": 1,
- ""m_PreviewMode"": 2,
- ""m_OutputNode"": {{
- ""m_Id"": ""{Guid.NewGuid():N}""
- }}
-}}";
- }
+ // (Graph creation and editing now go through MCPShaderGraphApi / the real
+ // ShaderGraph GraphData model; the old hand-built JSON template and node
+ // templates were removed — they produced invalid/corrupt graphs, issue #18.)
private static object PackageNotInstalledError(string packageName)
{
@@ -820,61 +728,29 @@ public static object AddGraphNode(Dictionary args)
string path = args["path"].ToString();
string nodeType = args["nodeType"].ToString();
- float posX = args.ContainsKey("positionX") ? Convert.ToSingle(args["positionX"]) : 0f;
- float posY = args.ContainsKey("positionY") ? Convert.ToSingle(args["positionY"]) : 0f;
-
- string fullPath = Path.Combine(Application.dataPath, "..", path);
+ // Accept positionX/positionY (canonical) and x/y (common alias).
+ float posX = args.ContainsKey("positionX") ? Convert.ToSingle(args["positionX"]) : args.ContainsKey("x") ? Convert.ToSingle(args["x"]) : 0f;
+ float posY = args.ContainsKey("positionY") ? Convert.ToSingle(args["positionY"]) : args.ContainsKey("y") ? Convert.ToSingle(args["y"]) : 0f;
+
+ if (!MCPShaderGraphApi.Available)
+ return new Dictionary { { "error", "ShaderGraph real-API access unavailable: " + MCPShaderGraphApi.UnavailableReason } };
+ if (!MCPAssetSafety.TryResolveProjectPath(path, out string fullPath, out string pathError))
+ return new Dictionary { { "error", pathError } };
if (!File.Exists(fullPath))
return new Dictionary { { "error", $"File not found: {path}" } };
+ Type resolvedType = ResolveShaderGraphNodeType(nodeType);
+ if (resolvedType == null)
+ return new Dictionary { { "error", $"Unknown node type: {nodeType}. Use 'shadergraph/get-node-types' to list available types." } };
+
try
{
- // Find the node type in ShaderGraph assembly
- Type resolvedType = ResolveShaderGraphNodeType(nodeType);
-
- string nodeId = Guid.NewGuid().ToString("N").Substring(0, 24);
- string nodeJson;
-
- if (resolvedType != null)
- {
- // Try to serialize via reflection
- nodeJson = TrySerializeNodeViaReflection(resolvedType, nodeId, posX, posY);
- }
- else
- {
- // Use template-based approach for common types
- nodeJson = GetNodeTemplate(nodeType, nodeId, posX, posY);
- }
-
- if (string.IsNullOrEmpty(nodeJson))
- return new Dictionary
- {
- { "error", $"Unknown node type: {nodeType}. Use 'shadergraph/get-node-types' to list available types." },
- };
-
- // Read the file and insert the node
- string content = File.ReadAllText(fullPath);
-
- // Add node reference to the main GraphData block
- string nodeRef = $"{{\"m_Id\":\"{nodeId}\"}}";
-
- // Find m_Nodes array in the graph data and add the reference
- int nodesArrayEnd = FindJsonArrayEnd(content, "m_Nodes");
- if (nodesArrayEnd < 0)
- return new Dictionary { { "error", "Could not find m_Nodes array in graph file" } };
-
- // Insert reference before the closing bracket of m_Nodes
- string nodesArrayContent = content.Substring(0, nodesArrayEnd);
- bool hasExistingNodes = nodesArrayContent.TrimEnd().EndsWith("}");
- string separator = hasExistingNodes ? "," : "";
- content = content.Insert(nodesArrayEnd, separator + nodeRef);
-
- // Append the full node JSON as a new block at the end of the file
- // In MultiJson format, each object is a separate top-level JSON
- content = content.TrimEnd() + "\n\n" + nodeJson;
-
- File.WriteAllText(fullPath, content);
- AssetDatabase.ImportAsset(path);
+ // Load → mutate → save through the real GraphData model. Nodes get their real
+ // slots and the position is honored; the graph is guaranteed valid on save
+ // (old string-surgery produced empty-slot nodes and dropped the position — #18 bugs 3,4).
+ var graph = MCPShaderGraphApi.LoadGraph(fullPath);
+ string nodeId = MCPShaderGraphApi.AddNode(graph, resolvedType, posX, posY);
+ MCPShaderGraphApi.SaveGraph(MCPAssetSafety.ToAssetDatabasePath(path), graph);
return new Dictionary
{
@@ -883,7 +759,6 @@ public static object AddGraphNode(Dictionary args)
{ "nodeId", nodeId },
{ "nodeType", nodeType },
{ "position", new Dictionary { { "x", posX }, { "y", posY } } },
- { "note", "Node added. The graph will update when opened in Shader Graph editor." },
};
}
catch (Exception ex)
@@ -906,54 +781,30 @@ public static object RemoveGraphNode(Dictionary