diff --git a/MCPForUnity/Editor/Tools/JsonUtil.cs b/MCPForUnity/Editor/Tools/JsonUtil.cs new file mode 100644 index 00000000..00cbe3f6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/JsonUtil.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + internal static class JsonUtil + { + /// + /// If @params[paramName] is a JSON string, parse it to a JObject in-place. + /// Logs a warning on parse failure and leaves the original value. + /// + internal static void CoerceJsonStringParameter(JObject @params, string paramName) + { + if (@params == null || string.IsNullOrEmpty(paramName)) return; + var token = @params[paramName]; + if (token != null && token.Type == JTokenType.String) + { + try + { + var parsed = JObject.Parse(token.ToString()); + @params[paramName] = parsed; + } + catch (Newtonsoft.Json.JsonReaderException e) + { + Debug.LogWarning($"[MCP] Could not parse '{paramName}' JSON string: {e.Message}"); + } + } + } + } +} + + diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 1a952f37..dbf49f2a 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -63,6 +63,9 @@ public static object HandleCommand(JObject @params) // Common parameters string path = @params["path"]?.ToString(); + // Coerce string JSON to JObject for 'properties' if provided as a JSON string + JsonUtil.CoerceJsonStringParameter(@params, "properties"); + try { switch (action) diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 40504a87..b6219ec1 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -66,6 +66,9 @@ public static object HandleCommand(JObject @params) bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true // --- End add parameter --- + // Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string + JsonUtil.CoerceJsonStringParameter(@params, "componentProperties"); + // --- Prefab Redirection Check --- string targetPath = targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; diff --git a/MCPForUnity/UnityMcpServer~/src/port_discovery.py b/MCPForUnity/UnityMcpServer~/src/port_discovery.py index b936f967..c759e745 100644 --- a/MCPForUnity/UnityMcpServer~/src/port_discovery.py +++ b/MCPForUnity/UnityMcpServer~/src/port_discovery.py @@ -56,7 +56,7 @@ def list_candidate_files() -> List[Path]: @staticmethod def _try_probe_unity_mcp(port: int) -> bool: """Quickly check if a MCP for Unity listener is on this port. - Tries a short TCP connect, sends 'ping', expects a JSON 'pong'. + Tries a short TCP connect, sends 'ping', expects Unity bridge welcome message. """ try: with socket.create_connection(("127.0.0.1", port), PortDiscovery.CONNECT_TIMEOUT) as s: @@ -64,8 +64,8 @@ def _try_probe_unity_mcp(port: int) -> bool: try: s.sendall(b"ping") data = s.recv(512) - # Minimal validation: look for a success pong response - if data and b'"message":"pong"' in data: + # Check for Unity bridge welcome message format + if data and (b"WELCOME UNITY-MCP" in data or b'"message":"pong"' in data): return True except Exception: return False diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py index 2d449206..4810daba 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py @@ -2,6 +2,7 @@ Defines the manage_asset tool for interacting with Unity assets. """ import asyncio +import json from typing import Annotated, Any, Literal from fastmcp import Context @@ -33,6 +34,14 @@ async def manage_asset( page_number: Annotated[int | float | str, "Page number for pagination"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing manage_asset: {action}") + # Coerce 'properties' from JSON string to dict for client compatibility + if isinstance(properties, str): + try: + properties = json.loads(properties) + ctx.info("manage_asset: coerced properties from JSON string to dict") + except json.JSONDecodeError as e: + ctx.warn(f"manage_asset: failed to parse properties JSON string: {e}") + # Leave properties as-is; Unity side may handle defaults # Ensure properties is a dict if None if properties is None: properties = {} diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py index 18caa1f5..f74d142b 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,3 +1,4 @@ +import json from typing import Annotated, Any, Literal from fastmcp import Context @@ -42,8 +43,9 @@ def manage_gameobject( layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], "List of component names to remove"] | None = None, - component_properties: Annotated[dict[str, dict[str, Any]], + component_properties: Annotated[dict[str, dict[str, Any]] | str, """Dictionary of component names to their properties to set. For example: + Can also be provided as a JSON string representation of the dict. `{"MyScript": {"otherObject": {"find": "Player", "method": "by_name"}}}` assigns GameObject `{"MyScript": {"playerHealth": {"find": "Player", "component": "HealthComponent"}}}` assigns Component Example set nested property: @@ -65,7 +67,7 @@ def manage_gameobject( "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_gameobject: {action}") - + # Coercers to tolerate stringified booleans and vectors def _coerce_bool(value, default=None): if value is None: @@ -113,6 +115,13 @@ def _to_vec3(parts): search_inactive = _coerce_bool(search_inactive) includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) + # Coerce 'component_properties' from JSON string to dict for client compatibility + if isinstance(component_properties, str): + try: + component_properties = json.loads(component_properties) + ctx.info("manage_gameobject: coerced component_properties from JSON string to dict") + except json.JSONDecodeError as e: + return {"success": False, "message": f"Invalid JSON in component_properties: {e}"} try: # Map tag to search_term when search_method is by_tag for backward compatibility if action == "find" and search_method == "by_tag" and tag is not None and search_term is None: diff --git a/MCPForUnity/UnityMcpServer~/src/uv.lock b/MCPForUnity/UnityMcpServer~/src/uv.lock index b6d29e27..f10f1782 100644 --- a/MCPForUnity/UnityMcpServer~/src/uv.lock +++ b/MCPForUnity/UnityMcpServer~/src/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -35,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -132,6 +141,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -173,7 +191,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.17.0" +version = "1.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -188,9 +206,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/79/5724a540df19e192e8606c543cdcf162de8eb435077520cca150f7365ec0/mcp-1.17.0.tar.gz", hash = "sha256:1b57fabf3203240ccc48e39859faf3ae1ccb0b571ff798bbedae800c73c6df90", size = 477951, upload-time = "2025-10-10T12:16:44.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/72/3751feae343a5ad07959df713907b5c3fbaed269d697a14b0c449080cf2e/mcp-1.17.0-py3-none-any.whl", hash = "sha256:0660ef275cada7a545af154db3082f176cf1d2681d5e35ae63e014faf0a35d40", size = 167737, upload-time = "2025-10-10T12:16:42.863Z" }, + { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" }, ] [package.optional-dependencies] @@ -201,7 +219,7 @@ cli = [ [[package]] name = "mcpforunityserver" -version = "6.0.0" +version = "6.2.1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -210,13 +228,22 @@ dependencies = [ { name = "tomli" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27.2" }, { name = "mcp", extras = ["cli"], specifier = ">=1.17.0" }, { name = "pydantic", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "tomli", specifier = ">=2.3.0" }, ] +provides-extras = ["dev"] [[package]] name = "mdurl" @@ -227,6 +254,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.0" @@ -374,13 +419,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, ] [[package]] @@ -671,7 +748,7 @@ wheels = [ [[package]] name = "typer" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -679,9 +756,9 @@ dependencies = [ { name = "shellingham" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" }, + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, ] [[package]] diff --git a/TestProjects/UnityMCPTests/Assets/Editor.meta b/TestProjects/UnityMCPTests/Assets/Materials.meta similarity index 77% rename from TestProjects/UnityMCPTests/Assets/Editor.meta rename to TestProjects/UnityMCPTests/Assets/Materials.meta index 79828f3a..7ad588cf 100644 --- a/TestProjects/UnityMCPTests/Assets/Editor.meta +++ b/TestProjects/UnityMCPTests/Assets/Materials.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 46421b2ea84fe4b1a903e2483cff3958 +guid: bacdb2f03a45d448888245e6ac9cca1b folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs index f34130d2..e371d9a3 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Helpers/WriteToConfigTests.cs @@ -134,7 +134,12 @@ public void DoesNotAddEnvOrDisabled_ForTrae() var configPath = Path.Combine(_tempRoot, "trae.json"); WriteInitialConfig(configPath, isVSCode: false, command: _fakeUvPath, directory: "/old/path"); - var client = new McpClient { name = "Trae", mcpType = McpTypes.Trae }; + if (!Enum.TryParse("Trae", out var traeValue)) + { + Assert.Ignore("McpTypes.Trae not available in this package version; skipping test."); + } + + var client = new McpClient { name = "Trae", mcpType = traeValue }; InvokeWriteToConfig(configPath, client); var root = JObject.Parse(File.ReadAllText(configPath)); diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs new file mode 100644 index 00000000..a5d3c208 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs @@ -0,0 +1,169 @@ +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using System.Collections; +using UnityEditor; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Tools; +using System; + +namespace Tests.EditMode +{ + /// + /// Tests specifically for MCP tool parameter handling issues. + /// These tests focus on the actual problems we encountered: + /// 1. JSON parameter parsing in manage_asset and manage_gameobject tools + /// 2. Material creation with properties through MCP tools + /// 3. Material assignment through MCP tools + /// + public class MCPToolParameterTests + { + private const string TempDir = "Assets/Temp/MCPToolParameterTests"; + + [SetUp] + public void SetUp() + { + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempDir)) + { + AssetDatabase.CreateFolder("Assets/Temp", "MCPToolParameterTests"); + } + } + [Test] + public void Test_ManageAsset_ShouldAcceptJSONProperties() + { + var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat"; + + // Build params with properties as a JSON string (to be coerced) + var p = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = "{\"shader\": \"Universal Render Pipeline/Lit\", \"color\": [0,0,1,1]}" + }; + + try + { + var raw = ManageAsset.HandleCommand(p); + var result = raw as JObject ?? JObject.FromObject(raw); + Assert.IsNotNull(result, "Handler should return a JObject result"); + Assert.IsTrue(result!.Value("success"), result.ToString()); + + var mat = AssetDatabase.LoadAssetAtPath(matPath); + Assert.IsNotNull(mat, "Material should be created at path"); + if (mat.HasProperty("_Color")) + { + Assert.AreEqual(Color.blue, mat.GetColor("_Color")); + } + } + finally + { + if (AssetDatabase.LoadAssetAtPath(matPath) != null) + { + AssetDatabase.DeleteAsset(matPath); + } + AssetDatabase.Refresh(); + } + } + + [Test] + public void Test_ManageGameObject_ShouldAcceptJSONComponentProperties() + { + var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat"; + + // Create a material first (object-typed properties) + var createMat = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = new JObject { ["shader"] = "Universal Render Pipeline/Lit", ["color"] = new JArray(0,0,1,1) } + }; + var createMatRes = ManageAsset.HandleCommand(createMat); + var createMatObj = createMatRes as JObject ?? JObject.FromObject(createMatRes); + Assert.IsTrue(createMatObj.Value("success"), createMatObj.ToString()); + + // Create a sphere + var createGo = new JObject { ["action"] = "create", ["name"] = "MCPParamTestSphere", ["primitiveType"] = "Sphere" }; + var createGoRes = ManageGameObject.HandleCommand(createGo); + var createGoObj = createGoRes as JObject ?? JObject.FromObject(createGoRes); + Assert.IsTrue(createGoObj.Value("success"), createGoObj.ToString()); + + try + { + // Assign material via JSON string componentProperties (coercion path) + var compJsonObj = new JObject { ["MeshRenderer"] = new JObject { ["sharedMaterial"] = matPath } }; + var compJson = compJsonObj.ToString(Newtonsoft.Json.Formatting.None); + var modify = new JObject + { + ["action"] = "modify", + ["target"] = "MCPParamTestSphere", + ["searchMethod"] = "by_name", + ["componentProperties"] = compJson + }; + var raw = ManageGameObject.HandleCommand(modify); + var result = raw as JObject ?? JObject.FromObject(raw); + Assert.IsTrue(result.Value("success"), result.ToString()); + } + finally + { + var go = GameObject.Find("MCPParamTestSphere"); + if (go != null) UnityEngine.Object.DestroyImmediate(go); + if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); + AssetDatabase.Refresh(); + } + } + + [Test] + public void Test_JSONParsing_ShouldWorkInMCPTools() + { + var matPath = $"{TempDir}/JsonMat_{Guid.NewGuid().ToString("N")}.mat"; + + // manage_asset with JSON string properties (coercion path) + var createMat = new JObject + { + ["action"] = "create", + ["path"] = matPath, + ["assetType"] = "Material", + ["properties"] = "{\"shader\": \"Universal Render Pipeline/Lit\", \"color\": [0,0,1,1]}" + }; + var createResRaw = ManageAsset.HandleCommand(createMat); + var createRes = createResRaw as JObject ?? JObject.FromObject(createResRaw); + Assert.IsTrue(createRes.Value("success"), createRes.ToString()); + + // Create sphere and assign material (object-typed componentProperties) + var go = new JObject { ["action"] = "create", ["name"] = "MCPParamJSONSphere", ["primitiveType"] = "Sphere" }; + var goRes = ManageGameObject.HandleCommand(go); + var goObj = goRes as JObject ?? JObject.FromObject(goRes); + Assert.IsTrue(goObj.Value("success"), goObj.ToString()); + + try + { + var compJsonObj = new JObject { ["MeshRenderer"] = new JObject { ["sharedMaterial"] = matPath } }; + var compJson = compJsonObj.ToString(Newtonsoft.Json.Formatting.None); + var modify = new JObject + { + ["action"] = "modify", + ["target"] = "MCPParamJSONSphere", + ["searchMethod"] = "by_name", + ["componentProperties"] = compJson + }; + var modResRaw = ManageGameObject.HandleCommand(modify); + var modRes = modResRaw as JObject ?? JObject.FromObject(modResRaw); + Assert.IsTrue(modRes.Value("success"), modRes.ToString()); + } + finally + { + var goObj2 = GameObject.Find("MCPParamJSONSphere"); + if (goObj2 != null) UnityEngine.Object.DestroyImmediate(goObj2); + if (AssetDatabase.LoadAssetAtPath(matPath) != null) AssetDatabase.DeleteAsset(matPath); + AssetDatabase.Refresh(); + } + } + + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs.meta new file mode 100644 index 00000000..36eb52b6 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPToolParameterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 80144860477bb4293acf4669566b27b8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs new file mode 100644 index 00000000..f37cbf90 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs @@ -0,0 +1,198 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using MCPForUnity.Editor.Tools; + +namespace MCPForUnityTests.Editor.Tools +{ + public class MaterialParameterToolTests + { + private const string TempRoot = "Assets/Temp/MaterialParameterToolTests"; + private string _matPath; // unique per test run + private GameObject _sphere; + + [SetUp] + public void SetUp() + { + _matPath = $"{TempRoot}/BlueURP_{Guid.NewGuid().ToString("N")}.mat"; + if (!AssetDatabase.IsValidFolder("Assets/Temp")) + { + AssetDatabase.CreateFolder("Assets", "Temp"); + } + if (!AssetDatabase.IsValidFolder(TempRoot)) + { + AssetDatabase.CreateFolder("Assets/Temp", "MaterialParameterToolTests"); + } + // Ensure any leftover material from previous runs is removed + if (AssetDatabase.LoadAssetAtPath(_matPath) != null) + { + AssetDatabase.DeleteAsset(_matPath); + AssetDatabase.Refresh(); + } + // Hard-delete any stray files on disk (in case GUID lookup fails) + var abs = Path.Combine(Directory.GetCurrentDirectory(), _matPath); + try + { + if (File.Exists(abs)) File.Delete(abs); + if (File.Exists(abs + ".meta")) File.Delete(abs + ".meta"); + } + catch { /* best-effort cleanup */ } + AssetDatabase.Refresh(); + } + + [TearDown] + public void TearDown() + { + if (_sphere != null) + { + UnityEngine.Object.DestroyImmediate(_sphere); + _sphere = null; + } + if (AssetDatabase.LoadAssetAtPath(_matPath) != null) + { + AssetDatabase.DeleteAsset(_matPath); + } + AssetDatabase.Refresh(); + } + + private static JObject ToJObject(object result) + { + return result as JObject ?? JObject.FromObject(result); + } + + [Test] + public void CreateMaterial_WithObjectProperties_SucceedsAndSetsColor() + { + // Ensure a clean state if a previous run left the asset behind (uses _matPath now) + if (AssetDatabase.LoadAssetAtPath(_matPath) != null) + { + AssetDatabase.DeleteAsset(_matPath); + AssetDatabase.Refresh(); + } + var createParams = new JObject + { + ["action"] = "create", + ["path"] = _matPath, + ["assetType"] = "Material", + ["properties"] = new JObject + { + ["shader"] = "Universal Render Pipeline/Lit", + ["color"] = new JArray(0f, 0f, 1f, 1f) + } + }; + + var result = ToJObject(ManageAsset.HandleCommand(createParams)); + Assert.IsTrue(result.Value("success"), result.Value("error")); + + var mat = AssetDatabase.LoadAssetAtPath(_matPath); + Assert.IsNotNull(mat, "Material should exist at path."); + // Verify color if shader exposes _Color + if (mat.HasProperty("_Color")) + { + Assert.AreEqual(Color.blue, mat.GetColor("_Color")); + } + } + + private void CreateTestMaterial() + { + var createParams = new JObject + { + ["action"] = "create", + ["path"] = _matPath, + ["assetType"] = "Material", + ["properties"] = new JObject + { + ["shader"] = "Universal Render Pipeline/Lit", + ["color"] = new JArray(0f, 0f, 1f, 1f) + } + }; + var result = ToJObject(ManageAsset.HandleCommand(createParams)); + Assert.IsTrue(result.Value("success"), result.Value("error")); + } + + private void CreateSphere() + { + var createGo = new JObject + { + ["action"] = "create", + ["name"] = "ToolTestSphere", + ["primitiveType"] = "Sphere" + }; + var createGoResult = ToJObject(ManageGameObject.HandleCommand(createGo)); + Assert.IsTrue(createGoResult.Value("success"), createGoResult.Value("error")); + _sphere = GameObject.Find("ToolTestSphere"); + Assert.IsNotNull(_sphere, "Sphere should be created."); + } + + [Test] + public void AssignMaterial_ToSphere_UsingComponentPropertiesObject_Succeeds() + { + CreateTestMaterial(); + CreateSphere(); + + // Create a sphere via handler + + + // Assign material via object-typed componentProperties + var modifyParams = new JObject + { + ["action"] = "modify", + ["target"] = "ToolTestSphere", + ["searchMethod"] = "by_name", + ["componentProperties"] = new JObject + { + ["MeshRenderer"] = new JObject + { + ["sharedMaterial"] = _matPath + } + } + }; + + var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams)); + Assert.IsTrue(modifyResult.Value("success"), modifyResult.Value("error")); + + var renderer = _sphere.GetComponent(); + Assert.IsNotNull(renderer, "Sphere should have MeshRenderer."); + Assert.IsNotNull(renderer.sharedMaterial, "sharedMaterial should be assigned."); + StringAssert.StartsWith("BlueURP_", renderer.sharedMaterial.name); + } + + [Test] + public void ReadRendererData_DoesNotInstantiateMaterial_AndIncludesSharedMaterial() + { + CreateTestMaterial(); + CreateSphere(); + var modifyParams = new JObject + { + ["action"] = "modify", + ["target"] = "ToolTestSphere", + ["searchMethod"] = "by_name", + ["componentProperties"] = new JObject + { + ["MeshRenderer"] = new JObject { ["sharedMaterial"] = _matPath } + } + }; + var modifyResult = ToJObject(ManageGameObject.HandleCommand(modifyParams)); + Assert.IsTrue(modifyResult.Value("success"), modifyResult.Value("error")); + + var renderer = _sphere.GetComponent(); + int beforeId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0; + + var data = MCPForUnity.Editor.Helpers.GameObjectSerializer.GetComponentData(renderer) as System.Collections.Generic.Dictionary; + Assert.IsNotNull(data, "Serializer should return data."); + + int afterId = renderer.sharedMaterial != null ? renderer.sharedMaterial.GetInstanceID() : 0; + Assert.AreEqual(beforeId, afterId, "sharedMaterial instance must not change (no instantiation in EditMode)."); + + if (data.TryGetValue("properties", out var propsObj) && propsObj is System.Collections.Generic.Dictionary props) + { + Assert.IsTrue( + props.ContainsKey("sharedMaterial") || props.ContainsKey("material") || props.ContainsKey("sharedMaterials") || props.ContainsKey("materials"), + "Serialized data should include material info."); + } + } + } +} \ No newline at end of file diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs.meta new file mode 100644 index 00000000..266ae453 --- /dev/null +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bd76b616a816c47a79c4a3da4c307cff +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/TestProjects/UnityMCPTests/Packages/manifest.json b/TestProjects/UnityMCPTests/Packages/manifest.json index b8bbe318..0bd78a67 100644 --- a/TestProjects/UnityMCPTests/Packages/manifest.json +++ b/TestProjects/UnityMCPTests/Packages/manifest.json @@ -1,6 +1,7 @@ { "dependencies": { "com.coplaydev.unity-mcp": "file:../../../MCPForUnity", + "com.unity.ai.navigation": "1.1.4", "com.unity.collab-proxy": "2.5.2", "com.unity.feature.development": "1.0.1", "com.unity.ide.rider": "3.0.31", @@ -9,7 +10,7 @@ "com.unity.ide.windsurf": "https://github.com/Asuta/com.unity.ide.windsurf.git", "com.unity.test-framework": "1.1.33", "com.unity.textmeshpro": "3.0.6", - "com.unity.timeline": "1.6.5", + "com.unity.timeline": "1.7.5", "com.unity.ugui": "1.0.0", "com.unity.visualscripting": "1.9.4", "com.unity.modules.ai": "1.0.0", diff --git a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json index ad11087f..3c7b4c18 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json +++ b/TestProjects/UnityMCPTests/ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json @@ -1,6 +1,4 @@ { - "m_Name": "Settings", - "m_Path": "ProjectSettings/Packages/com.unity.testtools.codecoverage/Settings.json", "m_Dictionary": { "m_DictionaryValues": [] } diff --git a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt index 1a62a673..105db72a 100644 --- a/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt +++ b/TestProjects/UnityMCPTests/ProjectSettings/ProjectVersion.txt @@ -1,2 +1,2 @@ -m_EditorVersion: 2021.3.45f2 -m_EditorVersionWithRevision: 2021.3.45f2 (88f88f591b2e) +m_EditorVersion: 2022.3.6f1 +m_EditorVersionWithRevision: 2022.3.6f1 (b9e6e7e9fa2d)