From 8e122e37ceedcd81e951600ac137cd0b4de31a6e Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:37:12 -0400 Subject: [PATCH 1/2] Plugin eviction, CLI camera/graphics, minor fixes Handle plugin session evictions and extend CLI/docs; misc Unity tooling fixes. - Server: PluginRegistry.register now returns (session, evicted_session_id). PluginHub consumes the evicted id to cancel ping tasks, clear pending commands, remove state, and close the old WebSocket to avoid orphaned connections after reconnection races. Tests updated to unpack the returned tuple. - CLI/docs: Add extensive camera, graphics, packages, and texture command examples and raw command snippets across CLI guides and examples; update command reference tables. - Unity editor tools: SkyboxOps adds a CustomReflectionTexture accessor to handle Unity API differences (RenderSettings.customReflection vs customReflectionTexture). ManageScript now ignores C# keywords when detecting duplicate methods to prevent false positives; added unit test to cover keyword cases. - Misc: Small docstring tweaks in services/tools/utils.py and skill docs updated to reference manage_camera screenshots. --- .../Editor/Tools/Graphics/SkyboxOps.cs | 26 ++- MCPForUnity/Editor/Tools/ManageScript.cs | 16 ++ Server/src/cli/CLI_USAGE_GUIDE.md | 196 ++++++++++++++++++ Server/src/services/tools/utils.py | 4 +- Server/src/transport/plugin_hub.py | 30 ++- Server/src/transport/plugin_registry.py | 11 +- .../test_plugin_registry_user_isolation.py | 6 +- .../tests/test_transport_characterization.py | 4 +- .../Tools/ManageScriptValidationTests.cs | 33 +++ docs/guides/CLI_EXAMPLE.md | 68 ++++++ docs/guides/CLI_USAGE.md | 104 +++++++++- unity-mcp-skill/SKILL.md | 4 +- 12 files changed, 484 insertions(+), 18 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs index 4ae2dbfe8..ec681e448 100644 --- a/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs +++ b/MCPForUnity/Editor/Tools/Graphics/SkyboxOps.cs @@ -10,6 +10,22 @@ namespace MCPForUnity.Editor.Tools.Graphics { internal static class SkyboxOps { + static Texture CustomReflectionTexture + { + get => +#if UNITY_2022_1_OR_NEWER + RenderSettings.customReflectionTexture; +#else + RenderSettings.customReflection; +#endif + set { +#if UNITY_2022_1_OR_NEWER + RenderSettings.customReflectionTexture = value; +#else + RenderSettings.customReflection = value; +#endif + } + } // --------------------------------------------------------------- // skybox_get — read all environment settings // --------------------------------------------------------------- @@ -70,8 +86,8 @@ public static object GetEnvironment(JObject @params) bounces = RenderSettings.reflectionBounces, mode = RenderSettings.defaultReflectionMode.ToString(), resolution = RenderSettings.defaultReflectionResolution, - customCubemap = RenderSettings.customReflectionTexture != null - ? AssetDatabase.GetAssetPath(RenderSettings.customReflectionTexture) + customCubemap = CustomReflectionTexture != null + ? AssetDatabase.GetAssetPath(CustomReflectionTexture) : null }, sun = sun != null @@ -301,7 +317,7 @@ public static object SetReflection(JObject @params) { var cubemap = AssetDatabase.LoadAssetAtPath(cubemapPath); if (cubemap != null) - RenderSettings.customReflectionTexture = cubemap; + CustomReflectionTexture = cubemap; else return new ErrorResponse($"Cubemap not found at '{cubemapPath}'."); } @@ -318,8 +334,8 @@ public static object SetReflection(JObject @params) bounces = RenderSettings.reflectionBounces, mode = RenderSettings.defaultReflectionMode.ToString(), resolution = RenderSettings.defaultReflectionResolution, - customCubemap = RenderSettings.customReflectionTexture != null - ? AssetDatabase.GetAssetPath(RenderSettings.customReflectionTexture) + customCubemap = CustomReflectionTexture != null + ? AssetDatabase.GetAssetPath(CustomReflectionTexture) : null } }; diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index 4ebf1b06b..7f1edf5b4 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -2741,6 +2741,7 @@ private static void CheckDuplicateMethodSignatures(string contents, System.Colle foreach (Match sm in sigMatches) { string methodName = sm.Groups[1].Value; + if (IsCSharpKeyword(methodName)) continue; int paramCount = CountTopLevelParams(sm.Groups[2].Value); string paramTypes = ExtractParamTypes(sm.Groups[2].Value); string containingType = containingTypeArr[sm.Index]; @@ -2752,6 +2753,21 @@ private static void CheckDuplicateMethodSignatures(string contents, System.Colle } } + private static readonly System.Collections.Generic.HashSet CSharpKeywords = + new System.Collections.Generic.HashSet(System.StringComparer.Ordinal) + { + "if", "else", "for", "foreach", "while", "do", "switch", "case", + "try", "catch", "finally", "throw", "return", "yield", "await", + "lock", "using", "fixed", "checked", "unchecked", "typeof", "sizeof", + "nameof", "default", "new", "stackalloc", "when", "in", "is", "as", + "ref", "out", "params", "this", "base", "null", "true", "false", + "get", "set", "var", "dynamic", "where", "from", "select", "group", + "into", "orderby", "join", "let", "on", "equals", "by", "ascending", + "descending" + }; + + private static bool IsCSharpKeyword(string name) => CSharpKeywords.Contains(name); + /// /// Validates semantic rules and common coding issues /// diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index dfe0e3666..f0813749a 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -604,12 +604,205 @@ unity-mcp animation set-parameter "Character" "IsRunning" true --type bool unity-mcp animation set-parameter "Character" "Jump" "" --type trigger ``` +### Camera Commands + +```bash +# Check Cinemachine availability +unity-mcp camera ping + +# List all cameras in scene +unity-mcp camera list + +# Create cameras (plain or with Cinemachine presets) +unity-mcp camera create # Basic camera +unity-mcp camera create --name "FollowCam" --preset follow --follow "Player" --look-at "Player" +unity-mcp camera create --preset third_person --follow "Player" --fov 50 +unity-mcp camera create --preset dolly --look-at "Player" +unity-mcp camera create --preset top_down --follow "Player" +unity-mcp camera create --preset side_scroller --follow "Player" +unity-mcp camera create --preset static --fov 40 + +# Set targets on existing camera +unity-mcp camera set-target "FollowCam" --follow "Player" --look-at "Enemy" + +# Lens settings +unity-mcp camera set-lens "MainCam" --fov 60 --near 0.1 --far 1000 +unity-mcp camera set-lens "OrthoCamera" --ortho-size 10 + +# Priority (higher = preferred by CinemachineBrain) +unity-mcp camera set-priority "FollowCam" --priority 15 + +# Cinemachine Body/Aim/Noise configuration +unity-mcp camera set-body "FollowCam" --body-type "CinemachineFollow" +unity-mcp camera set-body "FollowCam" --body-type "CinemachineFollow" --props '{"TrackerSettings": {"BindingMode": 1}}' +unity-mcp camera set-aim "FollowCam" --aim-type "CinemachineRotationComposer" +unity-mcp camera set-noise "FollowCam" --amplitude 1.5 --frequency 0.5 + +# Extensions +unity-mcp camera add-extension "FollowCam" CinemachineConfiner3D +unity-mcp camera remove-extension "FollowCam" CinemachineConfiner3D + +# Brain (ensure Brain exists on main camera, set default blend) +unity-mcp camera ensure-brain +unity-mcp camera ensure-brain --blend-style "EaseInOut" --blend-duration 1.5 +unity-mcp camera brain-status +unity-mcp camera set-blend --style "Cut" --duration 0 + +# Force/release camera override +unity-mcp camera force "FollowCam" +unity-mcp camera release + +# Screenshots +unity-mcp camera screenshot +unity-mcp camera screenshot --file-name "my_capture" --super-size 2 +unity-mcp camera screenshot --camera-ref "SecondCamera" --include-image +unity-mcp camera screenshot --max-resolution 256 +unity-mcp camera screenshot --batch surround --max-resolution 256 +unity-mcp camera screenshot --batch orbit --look-at "Player" +unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +``` + +### Graphics Commands + +```bash +# Check graphics system status +unity-mcp graphics ping + +# --- Volumes --- +# Create a Volume (global or local) +unity-mcp graphics volume-create --name "PostProcessing" --global +unity-mcp graphics volume-create --name "LocalFog" --local --weight 0.8 --priority 1 + +# Add/remove/configure effects on a Volume +unity-mcp graphics volume-add-effect --target "PostProcessing" --effect "Bloom" +unity-mcp graphics volume-set-effect --target "PostProcessing" --effect "Bloom" -p intensity 1.5 -p threshold 0.9 +unity-mcp graphics volume-remove-effect --target "PostProcessing" --effect "Bloom" +unity-mcp graphics volume-info --target "PostProcessing" +unity-mcp graphics volume-set-properties --target "PostProcessing" --weight 0.5 --priority 2 --local +unity-mcp graphics volume-list-effects +unity-mcp graphics volume-create-profile --path "Assets/Profiles/MyProfile.asset" --name "MyProfile" + +# --- Render Pipeline --- +unity-mcp graphics pipeline-info +unity-mcp graphics pipeline-settings +unity-mcp graphics pipeline-set-quality --level "High" +unity-mcp graphics pipeline-set-settings -s renderScale 1.5 -s msaaSampleCount 4 + +# --- Light Baking --- +unity-mcp graphics bake-start +unity-mcp graphics bake-start --sync # Wait for completion +unity-mcp graphics bake-status +unity-mcp graphics bake-cancel +unity-mcp graphics bake-clear +unity-mcp graphics bake-settings +unity-mcp graphics bake-set-settings -s lightmapResolution 64 -s directSamples 32 +unity-mcp graphics bake-reflection-probe --target "ReflectionProbe1" +unity-mcp graphics bake-create-probes --name "LightProbes" --spacing 5 +unity-mcp graphics bake-create-reflection --name "ReflProbe" --resolution 512 --mode Realtime + +# --- Rendering Stats --- +unity-mcp graphics stats +unity-mcp graphics stats-memory +unity-mcp graphics stats-debug-mode --mode "Wireframe" + +# --- URP Renderer Features --- +unity-mcp graphics feature-list +unity-mcp graphics feature-add --type "ScreenSpaceAmbientOcclusion" --name "SSAO" +unity-mcp graphics feature-remove --name "SSAO" +unity-mcp graphics feature-configure --name "SSAO" -p Intensity 1.5 -p Radius 0.3 +unity-mcp graphics feature-reorder --order "0,2,1,3" +unity-mcp graphics feature-toggle --name "SSAO" --active +unity-mcp graphics feature-toggle --name "SSAO" --inactive + +# --- Skybox & Environment --- +unity-mcp graphics skybox-info +unity-mcp graphics skybox-set-material --material "Assets/Materials/NightSky.mat" +unity-mcp graphics skybox-set-properties -p _Tint "0.5,0.5,1,1" -p _Exposure 1.2 +unity-mcp graphics skybox-set-ambient --mode Flat --color "0.2,0.2,0.3" +unity-mcp graphics skybox-set-ambient --mode Trilight --color "0.4,0.6,0.8" --equator-color "0.3,0.3,0.3" --ground-color "0.1,0.1,0.1" +unity-mcp graphics skybox-set-fog --enable --mode ExponentialSquared --color "0.7,0.8,0.9" --density 0.02 +unity-mcp graphics skybox-set-fog --disable +unity-mcp graphics skybox-set-reflection --intensity 1.0 --bounces 2 --mode Custom --resolution 256 +unity-mcp graphics skybox-set-sun --target "DirectionalLight" +``` + +### Package Commands + +```bash +# Check package manager status +unity-mcp packages ping + +# List installed packages +unity-mcp packages list + +# Search Unity registry +unity-mcp packages search "cinemachine" +unity-mcp packages search "probuilder" + +# Get package details +unity-mcp packages info "com.unity.cinemachine" + +# Install / remove packages +unity-mcp packages add "com.unity.cinemachine" +unity-mcp packages add "com.unity.cinemachine@4.1.1" +unity-mcp packages remove "com.unity.cinemachine" +unity-mcp packages remove "com.unity.cinemachine" --force # Skip confirmation + +# Embed package for local editing +unity-mcp packages embed "com.unity.cinemachine" + +# Force package re-resolution +unity-mcp packages resolve + +# Check async operation status +unity-mcp packages status + +# Scoped registries +unity-mcp packages list-registries +unity-mcp packages add-registry "My Registry" --url "https://registry.example.com" -s "com.example" +unity-mcp packages remove-registry "My Registry" +``` + +### Texture Commands + +```bash +# Create procedural textures +unity-mcp texture create "Assets/Textures/Red.png" --width 128 --height 128 --color "1,0,0,1" +unity-mcp texture create "Assets/Textures/Check.png" --pattern checkerboard --palette "1,0,0,1;0,0,1,1" +unity-mcp texture create "Assets/Textures/Brick.png" --width 256 --height 256 --pattern brick +unity-mcp texture create "Assets/Textures/Grid.png" --pattern grid --width 512 --height 512 + +# Available patterns: checkerboard, stripes, stripes_h, stripes_v, stripes_diag, dots, grid, brick + +# Create from image file +unity-mcp texture create "Assets/Textures/Photo.png" --image-path "/path/to/source.png" + +# Create with custom import settings +unity-mcp texture create "Assets/Textures/Normal.png" --import-settings '{"textureType": "NormalMap", "filterMode": "Trilinear"}' + +# Create sprites (auto-configures import settings for 2D) +unity-mcp texture sprite "Assets/Sprites/Player.png" --width 32 --height 32 --color "0,0.5,1,1" +unity-mcp texture sprite "Assets/Sprites/Tile.png" --pattern checkerboard --ppu 16 --pivot "0.5,0" + +# Modify existing texture pixels +unity-mcp texture modify "Assets/Textures/Existing.png" --set-pixels '{"x":0,"y":0,"width":16,"height":16,"color":[1,0,0,1]}' + +# Delete texture +unity-mcp texture delete "Assets/Textures/Old.png" +unity-mcp texture delete "Assets/Textures/Old.png" --force +``` + ### Code Commands ```bash # Read source files unity-mcp code read "Assets/Scripts/Player.cs" unity-mcp code read "Assets/Scripts/Player.cs" --start-line 10 --line-count 20 + +# Search with regex +unity-mcp code search "class.*Player" "Assets/Scripts/Player.cs" +unity-mcp code search "TODO|FIXME" "Assets/Scripts/Utils.cs" +unity-mcp code search "void Update" "Assets/Scripts/Game.cs" --max-results 20 ``` ### Raw Commands @@ -622,6 +815,9 @@ unity-mcp raw manage_scene '{"action": "get_active"}' unity-mcp raw manage_gameobject '{"action": "create", "name": "Test"}' unity-mcp raw manage_components '{"action": "add", "target": "Test", "componentType": "Rigidbody"}' unity-mcp raw manage_editor '{"action": "play"}' +unity-mcp raw manage_camera '{"action": "screenshot", "include_image": true}' +unity-mcp raw manage_graphics '{"action": "volume_info", "target": "PostProcess"}' +unity-mcp raw manage_packages '{"action": "list"}' ``` --- diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index b7c1a3e4d..aea7323b4 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -406,7 +406,7 @@ def extract_screenshot_images(response: dict[str, Any]) -> "ToolResult | None": """If a Unity response contains inline base64 images, return a ToolResult with TextContent + ImageContent blocks. Returns None for normal text-only responses. - Shared by manage_scene and manage_camera screenshot handling. + Shared screenshot handling (used by manage_camera). """ from fastmcp.server.server import ToolResult from mcp.types import TextContent, ImageContent @@ -476,7 +476,7 @@ def build_screenshot_params( """Populate screenshot-related keys in *params* dict. Returns an error dict if validation fails, or None on success. - Shared by manage_scene and manage_camera screenshot handling. + Shared screenshot handling (used by manage_camera). """ if screenshot_file_name: params["fileName"] = screenshot_file_name diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 4821f8e1b..290a55917 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -426,8 +426,29 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) response = RegisteredMessage(session_id=session_id) await websocket.send_json(response.model_dump()) - session = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id) + session, evicted_session_id = await registry.register(session_id, project_name, project_hash, unity_version, project_path, user_id=user_id) + evicted_ws = None async with lock: + # Clean up the evicted session's connection, ping loop, and pending commands + # so they don't linger as orphans after a domain-reload reconnection race. + if evicted_session_id: + evicted_ws = cls._connections.pop(evicted_session_id, None) + old_ping = cls._ping_tasks.pop(evicted_session_id, None) + if old_ping and not old_ping.done(): + old_ping.cancel() + cls._last_pong.pop(evicted_session_id, None) + for command_id, entry in list(cls._pending.items()): + if entry.get("session_id") == evicted_session_id: + future = entry.get("future") if isinstance(entry, dict) else None + if future and not future.done(): + future.set_exception( + PluginDisconnectedError( + f"Unity plugin session {evicted_session_id} superseded by {session_id}" + ) + ) + cls._pending.pop(command_id, None) + logger.info(f"Evicted previous session {evicted_session_id} for same instance") + cls._connections[session.session_id] = websocket # Initialize last pong time and start ping loop for this session cls._last_pong[session_id] = time.monotonic() @@ -439,6 +460,13 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) ping_task = asyncio.create_task(cls._ping_loop(session_id, websocket)) cls._ping_tasks[session_id] = ping_task + # Close evicted WebSocket outside the lock to avoid blocking + if evicted_ws is not None: + try: + await evicted_ws.close(code=1001) + except Exception: + pass + if user_id: logger.info(f"Plugin registered: {project_name} ({project_hash}) for user {user_id}") else: diff --git a/Server/src/transport/plugin_registry.py b/Server/src/transport/plugin_registry.py index 5f8f75b29..a8b34ec5b 100644 --- a/Server/src/transport/plugin_registry.py +++ b/Server/src/transport/plugin_registry.py @@ -55,12 +55,16 @@ async def register( unity_version: str, project_path: str | None = None, user_id: str | None = None, - ) -> PluginSession: + ) -> tuple[PluginSession, str | None]: """Register (or replace) a plugin session. If an existing session already claims the same ``project_hash`` (and ``user_id`` in remote-hosted mode) it will be replaced, ensuring that reconnect scenarios always map to the latest WebSocket connection. + + Returns: + A tuple of (new_session, evicted_session_id). The evicted ID is None + when no previous session was replaced. """ if config.http_remote_hosted and not user_id: raise ValueError("user_id is required in remote-hosted mode") @@ -79,6 +83,7 @@ async def register( ) # Remove old mapping for this hash if it existed under a different session + evicted_session_id: str | None = None if user_id: # Remote-hosted mode: use composite key (user_id, project_hash) composite_key = (user_id, project_hash) @@ -86,16 +91,18 @@ async def register( composite_key) if previous_session_id and previous_session_id != session_id: self._sessions.pop(previous_session_id, None) + evicted_session_id = previous_session_id self._user_hash_to_session[composite_key] = session_id else: # Local mode: use project_hash only previous_session_id = self._hash_to_session.get(project_hash) if previous_session_id and previous_session_id != session_id: self._sessions.pop(previous_session_id, None) + evicted_session_id = previous_session_id self._hash_to_session[project_hash] = session_id self._sessions[session_id] = session - return session + return session, evicted_session_id async def touch(self, session_id: str) -> None: """Update the ``connected_at`` timestamp when a heartbeat is received.""" diff --git a/Server/tests/integration/test_plugin_registry_user_isolation.py b/Server/tests/integration/test_plugin_registry_user_isolation.py index 2ce0aa047..f2e30c299 100644 --- a/Server/tests/integration/test_plugin_registry_user_isolation.py +++ b/Server/tests/integration/test_plugin_registry_user_isolation.py @@ -10,7 +10,7 @@ class TestRegistryUserIsolation: @pytest.mark.asyncio async def test_register_with_user_id_stores_composite_key(self): registry = PluginRegistry() - session = await registry.register( + session, _ = await registry.register( "sess-1", "MyProject", "hash1", "2022.3", user_id="user-A" ) assert session.user_id == "user-A" @@ -33,8 +33,8 @@ async def test_get_session_id_by_hash(self): async def test_cross_user_isolation_same_hash(self): """Two users registering with the same project_hash get independent sessions.""" registry = PluginRegistry() - sess_a = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA") - sess_b = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB") + sess_a, _ = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA") + sess_b, _ = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB") assert sess_a.session_id == "sA" assert sess_b.session_id == "sB" diff --git a/Server/tests/test_transport_characterization.py b/Server/tests/test_transport_characterization.py index f8b04b65e..45e35e6a8 100644 --- a/Server/tests/test_transport_characterization.py +++ b/Server/tests/test_transport_characterization.py @@ -816,7 +816,7 @@ async def test_registry_registers_session(self, plugin_registry): Current behavior: register() creates a new PluginSession and stores it by session_id and project_hash. """ - session = await plugin_registry.register( + session, _ = await plugin_registry.register( session_id="sess-abc", project_name="TestProject", project_hash="hash123", @@ -904,7 +904,7 @@ async def test_registry_touch_updates_connected_at(self, plugin_registry): """ Current behavior: touch() updates the connected_at timestamp on heartbeat. """ - session = await plugin_registry.register( + session, _ = await plugin_registry.register( session_id="sess-y", project_name="Project", project_hash="hash-y", diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs index fb0c09593..259feb4ca 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs @@ -286,5 +286,38 @@ public void Process(Dictionary other) { } Assert.IsTrue(HasDuplicateMethodError(errors), "Generic param duplicates with different names should be flagged"); } + + // --- Keyword false positive tests --- + + [Test] + public void DuplicateDetection_CSharpKeywords_NotMatchedAsMethods() + { + string code = @"using UnityEngine; +public class Foo : MonoBehaviour +{ + public void Update() + { + if (true) { } + if (true) { } + for (int i = 0; i < 10; i++) { } + for (int j = 0; j < 5; j++) { } + while (true) { break; } + while (false) { break; } + foreach (var x in new int[0]) { } + foreach (var y in new int[0]) { } + switch (0) { default: break; } + switch (1) { default: break; } + lock (this) { } + lock (this) { } + using (var d = new System.IO.MemoryStream()) { } + using (var e = new System.IO.MemoryStream()) { } + typeof(int); + typeof(string); + } +}"; + var errors = CallValidateScriptSyntaxUnity(code); + Assert.IsFalse(HasDuplicateMethodError(errors), + "C# keywords (if, for, while, etc.) should not be matched as duplicate methods"); + } } } diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index dd9c3d731..c9f90a359 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -145,6 +145,71 @@ unity-mcp vfx line create-circle "Name" --radius N unity-mcp vfx trail info|set-time|clear "Name" [time] ``` +**Camera Operations** +```bash +unity-mcp camera ping # Check Cinemachine +unity-mcp camera list # List all cameras +unity-mcp camera create --name "Cam" --preset follow --follow "Player" +unity-mcp camera set-target "Cam" --follow "Player" --look-at "Enemy" +unity-mcp camera set-lens "Cam" --fov 60 --near 0.1 +unity-mcp camera set-priority "Cam" --priority 15 +unity-mcp camera set-body "Cam" --body-type "CinemachineFollow" +unity-mcp camera set-aim "Cam" --aim-type "CinemachineRotationComposer" +unity-mcp camera set-noise "Cam" --amplitude 1.5 --frequency 0.5 +unity-mcp camera ensure-brain --blend-style "EaseInOut" --blend-duration 1.5 +unity-mcp camera force "Cam" # Force Brain to use camera +unity-mcp camera release # Release override +``` + +**Graphics Operations** +```bash +# Volumes +unity-mcp graphics volume-create --name "PostFX" --global +unity-mcp graphics volume-add-effect --target "PostFX" --effect "Bloom" +unity-mcp graphics volume-set-effect --target "PostFX" --effect "Bloom" -p intensity 1.5 +unity-mcp graphics volume-info --target "PostFX" +# Pipeline +unity-mcp graphics pipeline-info +unity-mcp graphics pipeline-set-quality --level "High" +# Baking +unity-mcp graphics bake-start [--sync] +unity-mcp graphics bake-status +unity-mcp graphics bake-create-probes --spacing 5 +# Stats +unity-mcp graphics stats +unity-mcp graphics stats-memory +# URP Features +unity-mcp graphics feature-list +unity-mcp graphics feature-add --type "ScreenSpaceAmbientOcclusion" +# Skybox +unity-mcp graphics skybox-info +unity-mcp graphics skybox-set-fog --enable --mode ExponentialSquared --density 0.02 +unity-mcp graphics skybox-set-sun --target "DirectionalLight" +``` + +**Package Operations** +```bash +unity-mcp packages list # List installed +unity-mcp packages search "cinemachine" # Search registry +unity-mcp packages info "com.unity.cinemachine" # Details +unity-mcp packages add "com.unity.cinemachine" # Install +unity-mcp packages add "com.unity.cinemachine@4.1.1" # Specific version +unity-mcp packages remove "com.unity.cinemachine" [--force] +unity-mcp packages embed "com.unity.cinemachine" # Local editing +unity-mcp packages resolve # Re-resolve +unity-mcp packages list-registries +unity-mcp packages add-registry "Name" --url URL -s "com.example" +``` + +**Texture Operations** +```bash +unity-mcp texture create "Assets/Textures/Red.png" --color "1,0,0,1" +unity-mcp texture create "Assets/Textures/Check.png" --pattern checkerboard --width 256 --height 256 +unity-mcp texture sprite "Assets/Sprites/Player.png" --width 32 --height 32 --ppu 16 +unity-mcp texture modify "Assets/Textures/Img.png" --set-pixels '{"x":0,"y":0,"width":16,"height":16,"color":[1,0,0,1]}' +unity-mcp texture delete "Assets/Textures/Old.png" [--force] +``` + **Lighting & UI** ```bash unity-mcp lighting create "Name" --type Point|Spot [--intensity N] [--position X Y Z] @@ -164,6 +229,9 @@ unity-mcp batch template > commands.json ```bash unity-mcp raw tool_name 'JSON_params' unity-mcp raw manage_scene '{"action":"get_active"}' +unity-mcp raw manage_camera '{"action":"screenshot","include_image":true}' +unity-mcp raw manage_graphics '{"action":"volume_info","target":"PostFX"}' +unity-mcp raw manage_packages '{"action":"list"}' ``` ### Note on MCP Server diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 81bdc6f09..112f04601 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -362,6 +362,101 @@ unity-mcp ui create-button "StartBtn" --parent "MainCanvas" --text "Start" unity-mcp ui create-image "Background" --parent "MainCanvas" ``` +### Camera Operations + +```bash +unity-mcp camera ping # Check Cinemachine availability +unity-mcp camera list # List all cameras +unity-mcp camera create --name "Cam" --preset follow --follow "Player" +unity-mcp camera set-target "Cam" --follow "Player" --look-at "Enemy" +unity-mcp camera set-lens "Cam" --fov 60 --near 0.1 --far 1000 +unity-mcp camera set-priority "Cam" --priority 15 +unity-mcp camera set-body "Cam" --body-type "CinemachineFollow" +unity-mcp camera set-aim "Cam" --aim-type "CinemachineRotationComposer" +unity-mcp camera set-noise "Cam" --amplitude 1.5 --frequency 0.5 +unity-mcp camera add-extension "Cam" CinemachineConfiner3D +unity-mcp camera ensure-brain --blend-style "EaseInOut" --blend-duration 1.5 +unity-mcp camera brain-status +unity-mcp camera force "Cam" # Force Brain to use camera +unity-mcp camera release # Release override +unity-mcp camera screenshot --file-name "capture" --super-size 2 +unity-mcp camera screenshot --batch orbit --look-at "Player" --max-resolution 256 +unity-mcp camera screenshot-multiview --look-at "Player" --max-resolution 480 +``` + +### Graphics Operations + +```bash +# Volumes +unity-mcp graphics volume-create --name "PostFX" --global +unity-mcp graphics volume-add-effect --target "PostFX" --effect "Bloom" +unity-mcp graphics volume-set-effect --target "PostFX" --effect "Bloom" -p intensity 1.5 +unity-mcp graphics volume-info --target "PostFX" +unity-mcp graphics volume-list-effects + +# Render Pipeline +unity-mcp graphics pipeline-info +unity-mcp graphics pipeline-set-quality --level "High" +unity-mcp graphics pipeline-set-settings -s renderScale 1.5 + +# Light Baking +unity-mcp graphics bake-start [--sync] +unity-mcp graphics bake-status +unity-mcp graphics bake-cancel +unity-mcp graphics bake-settings +unity-mcp graphics bake-create-probes --spacing 5 +unity-mcp graphics bake-create-reflection --resolution 512 + +# Stats & Debug +unity-mcp graphics stats +unity-mcp graphics stats-memory +unity-mcp graphics stats-debug-mode --mode "Wireframe" + +# URP Renderer Features +unity-mcp graphics feature-list +unity-mcp graphics feature-add --type "ScreenSpaceAmbientOcclusion" +unity-mcp graphics feature-configure --name "SSAO" -p Intensity 1.5 +unity-mcp graphics feature-toggle --name "SSAO" --active|--inactive + +# Skybox & Environment +unity-mcp graphics skybox-info +unity-mcp graphics skybox-set-material --material "Assets/Materials/Sky.mat" +unity-mcp graphics skybox-set-ambient --mode Flat --color "0.2,0.2,0.3" +unity-mcp graphics skybox-set-fog --enable --mode ExponentialSquared --density 0.02 +unity-mcp graphics skybox-set-reflection --intensity 1.0 --bounces 2 +unity-mcp graphics skybox-set-sun --target "DirectionalLight" +``` + +### Package Operations + +```bash +unity-mcp packages ping # Check package manager +unity-mcp packages list # List installed packages +unity-mcp packages search "cinemachine" # Search registry +unity-mcp packages info "com.unity.cinemachine" # Package details +unity-mcp packages add "com.unity.cinemachine" # Install package +unity-mcp packages add "com.unity.cinemachine@4.1.1" # Specific version +unity-mcp packages remove "com.unity.cinemachine" [--force] +unity-mcp packages embed "com.unity.cinemachine" # Embed for local editing +unity-mcp packages resolve # Force re-resolution +unity-mcp packages status # Check async op +unity-mcp packages list-registries +unity-mcp packages add-registry "Name" --url URL -s "com.example" +unity-mcp packages remove-registry "Name" +``` + +### Texture Operations + +```bash +unity-mcp texture create "Assets/Textures/Red.png" --color "1,0,0,1" +unity-mcp texture create "Assets/Textures/Check.png" --pattern checkerboard --width 256 --height 256 +unity-mcp texture create "Assets/Textures/Img.png" --image-path "/path/to/source.png" +unity-mcp texture sprite "Assets/Sprites/Player.png" --width 32 --height 32 --ppu 16 +unity-mcp texture modify "Assets/Textures/Img.png" --set-pixels '{"x":0,"y":0,"width":16,"height":16,"color":[1,0,0,1]}' +unity-mcp texture delete "Assets/Textures/Old.png" [--force] +# Patterns: checkerboard, stripes, stripes_h, stripes_v, stripes_diag, dots, grid, brick +``` + ### Raw Commands For any MCP tool not covered by dedicated commands: @@ -369,6 +464,9 @@ For any MCP tool not covered by dedicated commands: ```bash unity-mcp raw manage_scene '{"action": "get_hierarchy", "max_nodes": 100}' unity-mcp raw read_console '{"count": 20}' +unity-mcp raw manage_camera '{"action": "screenshot", "include_image": true}' +unity-mcp raw manage_graphics '{"action": "volume_info", "target": "PostFX"}' +unity-mcp raw manage_packages '{"action": "list"}' ``` --- @@ -388,11 +486,15 @@ unity-mcp raw read_console '{"count": 20}' | `asset` | `search`, `info`, `create`, `delete`, `duplicate`, `move`, `rename`, `import`, `mkdir` | | `prefab` | `open`, `close`, `save`, `create` | | `material` | `info`, `create`, `set-color`, `set-property`, `assign`, `set-renderer-color` | +| `camera` | `ping`, `list`, `create`, `set-target`, `set-lens`, `set-priority`, `set-body`, `set-aim`, `set-noise`, `add-extension`, `remove-extension`, `ensure-brain`, `brain-status`, `set-blend`, `force`, `release`, `screenshot`, `screenshot-multiview` | +| `graphics` | `ping`, `volume-create`, `volume-add-effect`, `volume-set-effect`, `volume-remove-effect`, `volume-info`, `volume-set-properties`, `volume-list-effects`, `volume-create-profile`, `pipeline-info`, `pipeline-settings`, `pipeline-set-quality`, `pipeline-set-settings`, `bake-start`, `bake-cancel`, `bake-status`, `bake-clear`, `bake-settings`, `bake-set-settings`, `bake-reflection-probe`, `bake-create-probes`, `bake-create-reflection`, `stats`, `stats-memory`, `stats-debug-mode`, `feature-list`, `feature-add`, `feature-remove`, `feature-configure`, `feature-reorder`, `feature-toggle`, `skybox-info`, `skybox-set-material`, `skybox-set-properties`, `skybox-set-ambient`, `skybox-set-fog`, `skybox-set-reflection`, `skybox-set-sun` | +| `packages` | `ping`, `list`, `search`, `info`, `add`, `remove`, `embed`, `resolve`, `status`, `list-registries`, `add-registry`, `remove-registry` | +| `texture` | `create`, `sprite`, `modify`, `delete` | | `vfx particle` | `info`, `play`, `stop`, `pause`, `restart`, `clear` | | `vfx line` | `info`, `set-positions`, `create-line`, `create-circle`, `clear` | | `vfx trail` | `info`, `set-time`, `clear` | | `vfx` | `raw` (access all 60+ actions) | -| `probuilder` | `create-shape`, `create-poly`, `info`, `raw` (access all 21 actions) | +| `probuilder` | `create-shape`, `create-poly`, `info`, `raw` (access all 35+ actions) | | `batch` | `run`, `inline`, `template` | | `animation` | `play`, `set-parameter` | | `audio` | `play`, `stop`, `volume` | diff --git a/unity-mcp-skill/SKILL.md b/unity-mcp-skill/SKILL.md index aa780bd06..83a8824a6 100644 --- a/unity-mcp-skill/SKILL.md +++ b/unity-mcp-skill/SKILL.md @@ -24,7 +24,7 @@ Before applying a template: 2. Understand the scene → mcpforunity://scene/gameobject-api 3. Find what you need → find_gameobjects or resources 4. Take action → tools (manage_gameobject, create_script, script_apply_edits, apply_text_edits, validate_script, delete_script, get_sha, etc.) -5. Verify results → read_console, capture_screenshot (in manage_scene), resources +5. Verify results → read_console, manage_camera(action="screenshot"), resources ``` ## Critical Best Practices @@ -90,7 +90,7 @@ manage_gameobject(action="look_at", target="MainCamera", look_at_target="Player" manage_camera(action="screenshot", camera="MainCamera", include_image=True, max_resolution=512) # → Analyze image, decide next action -# Alternative: use manage_camera for screenshot (same underlying infrastructure) +# Screenshot from a different camera manage_camera(action="screenshot", camera="FollowCam", include_image=True, max_resolution=512) manage_camera(action="screenshot_multiview", max_resolution=480) # 6-angle contact sheet ``` From abc5832b9e3ccfc4ab631aefcbf546f92b4a4d88 Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:55:34 -0400 Subject: [PATCH 2/2] Doc update --- Server/src/cli/CLI_USAGE_GUIDE.md | 4 ++-- Server/src/transport/plugin_hub.py | 16 +++++++++++-- .../test_plugin_registry_user_isolation.py | 24 +++++++++++++++++-- docs/guides/CLI_EXAMPLE.md | 4 ++-- docs/guides/CLI_USAGE.md | 6 ++--- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/Server/src/cli/CLI_USAGE_GUIDE.md b/Server/src/cli/CLI_USAGE_GUIDE.md index f0813749a..75d1cb8f6 100644 --- a/Server/src/cli/CLI_USAGE_GUIDE.md +++ b/Server/src/cli/CLI_USAGE_GUIDE.md @@ -816,8 +816,8 @@ unity-mcp raw manage_gameobject '{"action": "create", "name": "Test"}' unity-mcp raw manage_components '{"action": "add", "target": "Test", "componentType": "Rigidbody"}' unity-mcp raw manage_editor '{"action": "play"}' unity-mcp raw manage_camera '{"action": "screenshot", "include_image": true}' -unity-mcp raw manage_graphics '{"action": "volume_info", "target": "PostProcess"}' -unity-mcp raw manage_packages '{"action": "list"}' +unity-mcp raw manage_graphics '{"action": "volume_get_info", "target": "PostProcessing"}' +unity-mcp raw manage_packages '{"action": "list_packages"}' ``` --- diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 290a55917..1a71f30a7 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -437,16 +437,24 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) if old_ping and not old_ping.done(): old_ping.cancel() cls._last_pong.pop(evicted_session_id, None) + cancelled_commands = [] for command_id, entry in list(cls._pending.items()): if entry.get("session_id") == evicted_session_id: - future = entry.get("future") if isinstance(entry, dict) else None + future = entry.get("future") if future and not future.done(): future.set_exception( PluginDisconnectedError( f"Unity plugin session {evicted_session_id} superseded by {session_id}" ) ) + cancelled_commands.append(command_id) cls._pending.pop(command_id, None) + if cancelled_commands: + logger.info( + "Evicted session %s: cancelled pending commands %s", + evicted_session_id, + cancelled_commands, + ) logger.info(f"Evicted previous session {evicted_session_id} for same instance") cls._connections[session.session_id] = websocket @@ -465,7 +473,11 @@ async def _handle_register(self, websocket: WebSocket, payload: RegisterMessage) try: await evicted_ws.close(code=1001) except Exception: - pass + logger.debug( + "Failed to close evicted WebSocket for session %s", + evicted_session_id, + exc_info=True, + ) if user_id: logger.info(f"Plugin registered: {project_name} ({project_hash}) for user {user_id}") diff --git a/Server/tests/integration/test_plugin_registry_user_isolation.py b/Server/tests/integration/test_plugin_registry_user_isolation.py index f2e30c299..009fb956d 100644 --- a/Server/tests/integration/test_plugin_registry_user_isolation.py +++ b/Server/tests/integration/test_plugin_registry_user_isolation.py @@ -29,15 +29,35 @@ async def test_get_session_id_by_hash(self): not_found = await registry.get_session_id_by_hash("h1", "uB") assert not_found is None + @pytest.mark.asyncio + async def test_register_same_user_same_hash_evicts_previous_session(self): + """Same user + project_hash: second registration evicts the first session.""" + registry = PluginRegistry() + + first_session, first_evicted = await registry.register( + "sess-1", "MyProject", "hash1", "2022.3", user_id="user-A" + ) + assert first_session.session_id == "sess-1" + assert first_evicted is None + + second_session, second_evicted = await registry.register( + "sess-2", "MyProject", "hash1", "2022.3", user_id="user-A" + ) + assert second_session.session_id == "sess-2" + assert second_evicted == "sess-1" + @pytest.mark.asyncio async def test_cross_user_isolation_same_hash(self): """Two users registering with the same project_hash get independent sessions.""" registry = PluginRegistry() - sess_a, _ = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA") - sess_b, _ = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB") + sess_a, evicted_a = await registry.register("sA", "Proj", "hash1", "2022", user_id="userA") + sess_b, evicted_b = await registry.register("sB", "Proj", "hash1", "2022", user_id="userB") assert sess_a.session_id == "sA" assert sess_b.session_id == "sB" + # Different users should not evict each other's sessions + assert evicted_a is None + assert evicted_b is None # Each user resolves to their own session assert await registry.get_session_id_by_hash("hash1", "userA") == "sA" diff --git a/docs/guides/CLI_EXAMPLE.md b/docs/guides/CLI_EXAMPLE.md index c9f90a359..6de104ed7 100644 --- a/docs/guides/CLI_EXAMPLE.md +++ b/docs/guides/CLI_EXAMPLE.md @@ -230,8 +230,8 @@ unity-mcp batch template > commands.json unity-mcp raw tool_name 'JSON_params' unity-mcp raw manage_scene '{"action":"get_active"}' unity-mcp raw manage_camera '{"action":"screenshot","include_image":true}' -unity-mcp raw manage_graphics '{"action":"volume_info","target":"PostFX"}' -unity-mcp raw manage_packages '{"action":"list"}' +unity-mcp raw manage_graphics '{"action":"volume_get_info","target":"PostProcessing"}' +unity-mcp raw manage_packages '{"action":"list_packages"}' ``` ### Note on MCP Server diff --git a/docs/guides/CLI_USAGE.md b/docs/guides/CLI_USAGE.md index 112f04601..1c9601363 100644 --- a/docs/guides/CLI_USAGE.md +++ b/docs/guides/CLI_USAGE.md @@ -185,7 +185,7 @@ unity-mcp custom_tool list |--------|------|-------------| | `--filename, -f` | string | Output filename (default: timestamp-based) | | `--supersize, -s` | int | Resolution multiplier 1–4 for file-saved screenshots | -| `--camera, -c` | string | Camera name/path/ID (default: Camera.main) | +| `--camera-ref` | string | Camera name/path/ID (default: Camera.main) | | `--include-image` | flag | Return base64 PNG inline in the response | | `--max-resolution, -r` | int | Max longest-edge pixels (default 640) | | `--batch, -b` | string | `surround` (6 angles) or `orbit` (configurable grid) | @@ -465,8 +465,8 @@ For any MCP tool not covered by dedicated commands: unity-mcp raw manage_scene '{"action": "get_hierarchy", "max_nodes": 100}' unity-mcp raw read_console '{"count": 20}' unity-mcp raw manage_camera '{"action": "screenshot", "include_image": true}' -unity-mcp raw manage_graphics '{"action": "volume_info", "target": "PostFX"}' -unity-mcp raw manage_packages '{"action": "list"}' +unity-mcp raw manage_graphics '{"action": "volume_get_info", "target": "PostProcessing"}' +unity-mcp raw manage_packages '{"action": "list_packages"}' ``` ---