diff --git a/MCPForUnity/UnityMcpServer~/src/pyproject.toml b/MCPForUnity/UnityMcpServer~/src/pyproject.toml index dda99f1e..60bd5926 100644 --- a/MCPForUnity/UnityMcpServer~/src/pyproject.toml +++ b/MCPForUnity/UnityMcpServer~/src/pyproject.toml @@ -1,12 +1,13 @@ [project] name = "MCPForUnityServer" -version = "6.2.1" +version = "6.2.2" description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)." readme = "README.md" requires-python = ">=3.10" dependencies = [ "httpx>=0.27.2", - "mcp[cli]>=1.17.0", + "fastmcp>=2.12.5", + "mcp>=1.16.0", "pydantic>=2.12.0", "tomli>=2.3.0", ] diff --git a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py index c19a3174..a3577891 100644 --- a/MCPForUnity/UnityMcpServer~/src/resources/__init__.py +++ b/MCPForUnity/UnityMcpServer~/src/resources/__init__.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from mcp.server.fastmcp import FastMCP +from fastmcp import FastMCP from telemetry_decorator import telemetry_resource from registry import get_registered_resources diff --git a/MCPForUnity/UnityMcpServer~/src/server.py b/MCPForUnity/UnityMcpServer~/src/server.py index e4442eec..10b7f907 100644 --- a/MCPForUnity/UnityMcpServer~/src/server.py +++ b/MCPForUnity/UnityMcpServer~/src/server.py @@ -1,5 +1,5 @@ from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType -from mcp.server.fastmcp import FastMCP +from fastmcp import FastMCP import logging from logging.handlers import RotatingFileHandler import os diff --git a/MCPForUnity/UnityMcpServer~/src/server_version.txt b/MCPForUnity/UnityMcpServer~/src/server_version.txt index 024b066c..bee94338 100644 --- a/MCPForUnity/UnityMcpServer~/src/server_version.txt +++ b/MCPForUnity/UnityMcpServer~/src/server_version.txt @@ -1 +1 @@ -6.2.1 +6.2.3 diff --git a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py index afb6c757..502cf45f 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/__init__.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/__init__.py @@ -4,7 +4,7 @@ import logging from pathlib import Path -from mcp.server.fastmcp import FastMCP +from fastmcp import FastMCP from telemetry_decorator import telemetry_tool from registry import get_registered_tools diff --git a/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py b/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py index 03d419a0..a1489c59 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py @@ -3,7 +3,7 @@ """ from typing import Annotated, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from models import MCPResponse from registry import mcp_for_unity_tool diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py index 5e21d2ce..2d449206 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py @@ -4,7 +4,7 @@ import asyncio from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import async_send_command_with_retry @@ -29,8 +29,8 @@ async def manage_asset( filter_type: Annotated[str, "Filter type for search"] | None = None, filter_date_after: Annotated[str, "Date after which to filter"] | None = None, - page_size: Annotated[int, "Page size for pagination"] | None = None, - page_number: Annotated[int, "Page number for pagination"] | None = None + page_size: Annotated[int | float | str, "Page size for pagination"] | None = None, + page_number: Annotated[int | float | str, "Page number for pagination"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing manage_asset: {action}") # Ensure properties is a dict if None diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py index c0de76c2..f7911458 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py @@ -1,20 +1,20 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from telemetry import is_telemetry_enabled, record_tool_usage from unity_connection import send_command_with_retry @mcp_for_unity_tool( - description="Controls and queries the Unity editor's state and settings" + description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted." ) def manage_editor( ctx: Context, action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows", "get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."], - wait_for_completion: Annotated[bool, - "Optional. If True, waits for certain actions"] | None = None, + wait_for_completion: Annotated[bool | str, + "Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None, tool_name: Annotated[str, "Tool name when setting active tool"] | None = None, tag_name: Annotated[str, @@ -23,6 +23,23 @@ def manage_editor( "Layer name when adding and removing layers"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_editor: {action}") + + # Coerce boolean parameters defensively to tolerate 'true'/'false' strings + def _coerce_bool(value, default=None): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): # common truthy strings + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + wait_for_completion = _coerce_bool(wait_for_completion) + try: # Diagnostics: quick telemetry checks if action == "telemetry_status": diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py index a8ca1609..18caa1f5 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py @@ -1,12 +1,12 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( - description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." + description="Manage GameObjects. For booleans, send true/false; if your client only sends strings, 'true'/'false' are accepted. Vectors may be [x,y,z] or a string like '[x,y,z]'. For 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data." ) def manage_gameobject( ctx: Context, @@ -21,24 +21,24 @@ def manage_gameobject( "Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None, parent: Annotated[str, "Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None, - position: Annotated[list[float], - "Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None, - rotation: Annotated[list[float], - "Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None, - scale: Annotated[list[float], - "Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None, + position: Annotated[list[float] | str, + "Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, + rotation: Annotated[list[float] | str, + "Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, + scale: Annotated[list[float] | str, + "Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None, components_to_add: Annotated[list[str], "List of component names to add"] | None = None, primitive_type: Annotated[str, "Primitive type for 'create' action"] | None = None, - save_as_prefab: Annotated[bool, - "If True, saves the created GameObject as a prefab"] | None = None, + save_as_prefab: Annotated[bool | str, + "If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None, prefab_path: Annotated[str, "Path for prefab creation"] | None = None, prefab_folder: Annotated[str, "Folder for prefab creation"] | None = None, # --- Parameters for 'modify' --- - set_active: Annotated[bool, - "If True, sets the GameObject active"] | None = None, + set_active: Annotated[bool | str, + "If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None, layer: Annotated[str, "Layer name"] | None = None, components_to_remove: Annotated[list[str], "List of component names to remove"] | None = None, @@ -51,21 +51,73 @@ def manage_gameobject( # --- Parameters for 'find' --- search_term: Annotated[str, "Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None, - find_all: Annotated[bool, - "If True, finds all GameObjects matching the search term"] | None = None, - search_in_children: Annotated[bool, - "If True, searches in children of the GameObject"] | None = None, - search_inactive: Annotated[bool, - "If True, searches inactive GameObjects"] | None = None, + find_all: Annotated[bool | str, + "If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None, + search_in_children: Annotated[bool | str, + "If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None, + search_inactive: Annotated[bool | str, + "If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None, # -- Component Management Arguments -- component_name: Annotated[str, "Component name for 'add_component' and 'remove_component' actions"] | None = None, # Controls whether serialization of private [SerializeField] fields is included - includeNonPublicSerialized: Annotated[bool, - "Controls whether serialization of private [SerializeField] fields is included"] | None = None, + includeNonPublicSerialized: Annotated[bool | str, + "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: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + def _coerce_vec(value, default=None): + if value is None: + return default + import math + def _to_vec3(parts): + try: + vec = [float(parts[0]), float(parts[1]), float(parts[2])] + except (ValueError, TypeError): + return default + return vec if all(math.isfinite(n) for n in vec) else default + if isinstance(value, list) and len(value) == 3: + return _to_vec3(value) + if isinstance(value, str): + s = value.strip() + # minimal tolerant parse for "[x,y,z]" or "x,y,z" + if s.startswith("[") and s.endswith("]"): + s = s[1:-1] + # support "x,y,z" and "x y z" + parts = [p.strip() for p in (s.split(",") if "," in s else s.split())] + if len(parts) == 3: + return _to_vec3(parts) + return default + + position = _coerce_vec(position, default=position) + rotation = _coerce_vec(rotation, default=rotation) + scale = _coerce_vec(scale, default=scale) + save_as_prefab = _coerce_bool(save_as_prefab) + set_active = _coerce_bool(set_active) + find_all = _coerce_bool(find_all) + search_in_children = _coerce_bool(search_in_children) + search_inactive = _coerce_bool(search_inactive) + includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized) + 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: + search_term = tag + # Validate parameter usage to prevent silent failures if action == "find": if name is not None: diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py index ea89201c..2540e9f2 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py @@ -1,6 +1,6 @@ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py index 09494e4a..50927ca9 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_scene.py @@ -1,11 +1,11 @@ from typing import Annotated, Literal, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry -@mcp_for_unity_tool(description="Manage Unity scenes") +@mcp_for_unity_tool(description="Manage Unity scenes. Tip: For broad client compatibility, pass build_index as a quoted string (e.g., '0').") def manage_scene( ctx: Context, action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."], @@ -13,8 +13,8 @@ def manage_scene( "Scene name. Not required get_active/get_build_settings"] | None = None, path: Annotated[str, "Asset path for scene operations (default: 'Assets/')"] | None = None, - build_index: Annotated[int, - "Build index for load/build settings actions"] | None = None, + build_index: Annotated[int | str, + "Build index for load/build settings actions (accepts int or string, e.g., 0 or '0')"] | None = None, ) -> dict[str, Any]: ctx.info(f"Processing manage_scene: {action}") try: diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py index 5c31e4bd..6ed8cbca 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_script.py @@ -3,7 +3,7 @@ from typing import Annotated, Any, Literal from urllib.parse import urlparse, unquote -from mcp.server.fastmcp import FastMCP, Context +from fastmcp import FastMCP, Context from registry import mcp_for_unity_tool import unity_connection diff --git a/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py index 9c199661..19b94550 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/manage_shader.py @@ -1,7 +1,7 @@ import base64 from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry diff --git a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py index 4824bf61..d922982c 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/read_console.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/read_console.py @@ -3,34 +3,48 @@ """ from typing import Annotated, Any, Literal -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @mcp_for_unity_tool( - description="Gets messages from or clears the Unity Editor console." + description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')." ) def read_console( ctx: Context, action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None, types: Annotated[list[Literal['error', 'warning', 'log', 'all']], "Message types to get"] | None = None, - count: Annotated[int, "Max messages to return"] | None = None, + count: Annotated[int | str, "Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None, filter_text: Annotated[str, "Text filter for messages"] | None = None, since_timestamp: Annotated[str, "Get messages after this timestamp (ISO 8601)"] | None = None, format: Annotated[Literal['plain', 'detailed', 'json'], "Output format"] | None = None, - include_stacktrace: Annotated[bool, - "Include stack traces in output"] | None = None + include_stacktrace: Annotated[bool | str, + "Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None ) -> dict[str, Any]: ctx.info(f"Processing read_console: {action}") # Set defaults if values are None action = action if action is not None else 'get' types = types if types is not None else ['error', 'warning', 'log'] format = format if format is not None else 'detailed' - include_stacktrace = include_stacktrace if include_stacktrace is not None else True + # Coerce booleans defensively (strings like 'true'/'false') + def _coerce_bool(value, default=None): + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(value) + + include_stacktrace = _coerce_bool(include_stacktrace, True) # Normalize action if it's a string if isinstance(action, str): diff --git a/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py index f28fc589..d84bf7be 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/resource_tools.py @@ -11,7 +11,7 @@ from typing import Annotated, Any from urllib.parse import urlparse, unquote -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry @@ -190,13 +190,13 @@ async def list_resources( async def read_resource( ctx: Context, uri: Annotated[str, "The resource URI to read under Assets/"], - start_line: Annotated[int, + start_line: Annotated[int | float | str, "The starting line number (0-based)"] | None = None, - line_count: Annotated[int, + line_count: Annotated[int | float | str, "The number of lines to read"] | None = None, - head_bytes: Annotated[int, + head_bytes: Annotated[int | float | str, "The number of bytes to read from the start of the file"] | None = None, - tail_lines: Annotated[int, + tail_lines: Annotated[int | float | str, "The number of lines to read from the end of the file"] | None = None, project_root: Annotated[str, "The project root directory"] | None = None, @@ -351,7 +351,7 @@ async def find_in_file( ctx: Context, uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"], pattern: Annotated[str, "The regex pattern to search for"], - ignore_case: Annotated[bool, "Case-insensitive search"] | None = True, + ignore_case: Annotated[bool | str, "Case-insensitive search (accepts true/false or 'true'/'false')"] | None = True, project_root: Annotated[str, "The project root directory"] | None = None, max_results: Annotated[int, @@ -365,6 +365,20 @@ async def find_in_file( return {"success": False, "error": f"Resource not found: {uri}"} text = p.read_text(encoding="utf-8") + # Tolerant boolean coercion for clients that stringify booleans + def _coerce_bool(val, default=None): + if val is None: + return default + if isinstance(val, bool): + return val + if isinstance(val, str): + v = val.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return bool(val) + ignore_case = _coerce_bool(ignore_case, default=True) flags = re.MULTILINE if ignore_case: flags |= re.IGNORECASE diff --git a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py index 53199473..e70fd00c 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/run_tests.py @@ -1,7 +1,7 @@ """Tool for executing Unity Test Runner suites.""" from typing import Annotated, Literal, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from pydantic import BaseModel, Field from models import MCPResponse @@ -43,14 +43,31 @@ async def run_tests( ctx: Context, mode: Annotated[Literal["edit", "play"], Field( description="Unity test mode to run")] = "edit", - timeout_seconds: Annotated[int, Field( - description="Optional timeout in seconds for the Unity test run")] | None = None, + timeout_seconds: Annotated[str, Field( + description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None, ) -> RunTestsResponse: await ctx.info(f"Processing run_tests: mode={mode}") + # Coerce timeout defensively (string/float -> int) + def _coerce_int(value, default=None): + if value is None: + return default + try: + if isinstance(value, bool): + return default + if isinstance(value, int): + return int(value) + s = str(value).strip() + if s.lower() in ("", "none", "null"): + return default + return int(float(s)) + except Exception: + return default + params: dict[str, Any] = {"mode": mode} - if timeout_seconds is not None: - params["timeoutSeconds"] = timeout_seconds + ts = _coerce_int(timeout_seconds) + if ts is not None: + params["timeoutSeconds"] = ts response = await async_send_command_with_retry("run_tests", params) await ctx.info(f'Response {response}') diff --git a/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py index 59fbbc61..e339a754 100644 --- a/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py +++ b/MCPForUnity/UnityMcpServer~/src/tools/script_apply_edits.py @@ -3,7 +3,7 @@ import re from typing import Annotated, Any -from mcp.server.fastmcp import Context +from fastmcp import Context from registry import mcp_for_unity_tool from unity_connection import send_command_with_retry diff --git a/MCPForUnity/package.json b/MCPForUnity/package.json index bbd722b4..acea8749 100644 --- a/MCPForUnity/package.json +++ b/MCPForUnity/package.json @@ -1,6 +1,6 @@ { "name": "com.coplaydev.unity-mcp", - "version": "6.2.1", + "version": "6.2.3", "displayName": "MCP for Unity", "description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4", "unity": "2021.3", diff --git a/TestProjects/UnityMCPTests/ProjectSettings/boot.config b/TestProjects/UnityMCPTests/ProjectSettings/boot.config new file mode 100644 index 00000000..e69de29b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e82a6255 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# This file makes tests a package so test modules can import from each other diff --git a/tests/test_edit_normalization_and_noop.py b/tests/test_edit_normalization_and_noop.py index 3b857eaf..c2232fc4 100644 --- a/tests/test_edit_normalization_and_noop.py +++ b/tests/test_edit_normalization_and_noop.py @@ -1,31 +1,12 @@ import sys import pathlib import importlib.util -import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def _load(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) diff --git a/tests/test_get_sha.py b/tests/test_get_sha.py index c274fa61..3e9a2261 100644 --- a/tests/test_get_sha.py +++ b/tests/test_get_sha.py @@ -1,34 +1,17 @@ import sys import pathlib import importlib.util -import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp to satisfy imports without full dependency -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod diff --git a/tests/test_improved_anchor_matching.py b/tests/test_improved_anchor_matching.py index 32d30510..e56b8728 100644 --- a/tests/test_improved_anchor_matching.py +++ b/tests/test_improved_anchor_matching.py @@ -5,31 +5,12 @@ import sys import pathlib import importlib.util -import types # add server src to path and load modules ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def load_module(path, name): spec = importlib.util.spec_from_file_location(name, path) diff --git a/tests/test_manage_asset_param_coercion.py b/tests/test_manage_asset_param_coercion.py new file mode 100644 index 00000000..5c7b0815 --- /dev/null +++ b/tests/test_manage_asset_param_coercion.py @@ -0,0 +1,73 @@ +import sys +import pathlib +import importlib.util +import types +import asyncio +import os + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + + +def _load_module(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Stub fastmcp to avoid real MCP deps +fastmcp_pkg = types.ModuleType("fastmcp") + + +class _Dummy: + pass + + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +sys.modules.setdefault("fastmcp", fastmcp_pkg) + + +from tests.test_helpers import DummyContext + + +def test_manage_asset_pagination_coercion(monkeypatch): + # Import with SRC as CWD to satisfy telemetry import side effects + _prev = os.getcwd() + os.chdir(str(SRC)) + try: + manage_asset_mod = _load_module(SRC / "tools" / "manage_asset.py", "manage_asset_mod") + finally: + os.chdir(_prev) + + captured = {} + + async def fake_async_send(cmd, params, loop=None): + captured["params"] = params + return {"success": True, "data": {}} + + monkeypatch.setattr(manage_asset_mod, "async_send_command_with_retry", fake_async_send) + + result = asyncio.run( + manage_asset_mod.manage_asset( + ctx=DummyContext(), + action="search", + path="Assets", + page_size="50", + page_number="2", + ) + ) + + assert result == {"success": True, "data": {}} + assert captured["params"]["pageSize"] == 50 + assert captured["params"]["pageNumber"] == 2 + + + + + + diff --git a/tests/test_manage_gameobject_param_coercion.py b/tests/test_manage_gameobject_param_coercion.py new file mode 100644 index 00000000..d940b494 --- /dev/null +++ b/tests/test_manage_gameobject_param_coercion.py @@ -0,0 +1,71 @@ +import sys +import pathlib +import importlib.util +import types +import os + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" +sys.path.insert(0, str(SRC)) + + +def _load_module(path: pathlib.Path, name: str): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# Stub fastmcp to avoid real MCP deps +fastmcp_pkg = types.ModuleType("fastmcp") + + +class _Dummy: + pass + + +fastmcp_pkg.FastMCP = _Dummy +fastmcp_pkg.Context = _Dummy +sys.modules.setdefault("fastmcp", fastmcp_pkg) + + +from tests.test_helpers import DummyContext + + +def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch): + # Import with SRC as CWD to satisfy telemetry import side effects + _prev = os.getcwd() + os.chdir(str(SRC)) + try: + manage_go_mod = _load_module(SRC / "tools" / "manage_gameobject.py", "manage_go_mod") + finally: + os.chdir(_prev) + + captured = {} + + def fake_send(cmd, params): + captured["params"] = params + return {"success": True, "data": {}} + + monkeypatch.setattr(manage_go_mod, "send_command_with_retry", fake_send) + + # find by tag: allow tag to map to searchTerm + resp = manage_go_mod.manage_gameobject( + ctx=DummyContext(), + action="find", + search_method="by_tag", + tag="Player", + find_all="true", + search_inactive="0", + ) + # Loosen equality: wrapper may include a diagnostic message + assert resp.get("success") is True + assert "data" in resp + # ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already + assert captured["params"]["searchTerm"] == "Player" + assert captured["params"]["findAll"] == "true" or captured["params"]["findAll"] is True + assert captured["params"]["searchInactive"] in ("0", False, 0) + + diff --git a/tests/test_manage_script_uri.py b/tests/test_manage_script_uri.py index 8c227e25..e5565834 100644 --- a/tests/test_manage_script_uri.py +++ b/tests/test_manage_script_uri.py @@ -21,10 +21,8 @@ ) sys.path.insert(0, str(SRC)) -# Stub mcp.server.fastmcp to satisfy imports without full package -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") +# Stub fastmcp to avoid real MCP deps +fastmcp_pkg = types.ModuleType("fastmcp") class _Dummy: @@ -33,11 +31,7 @@ class _Dummy: fastmcp_pkg.FastMCP = _Dummy fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) +sys.modules.setdefault("fastmcp", fastmcp_pkg) # Import target module after path injection diff --git a/tests/test_read_console_truncate.py b/tests/test_read_console_truncate.py index 018c6a11..850126b1 100644 --- a/tests/test_read_console_truncate.py +++ b/tests/test_read_console_truncate.py @@ -1,33 +1,16 @@ import sys import pathlib import importlib.util -import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod diff --git a/tests/test_script_tools.py b/tests/test_script_tools.py index a3bfbfe4..0aefa27b 100644 --- a/tests/test_script_tools.py +++ b/tests/test_script_tools.py @@ -1,7 +1,6 @@ import sys import pathlib import importlib.util -import types import pytest import asyncio @@ -10,24 +9,6 @@ SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp to satisfy imports without full dependency -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def load_module(path, name): spec = importlib.util.spec_from_file_location(name, path) diff --git a/tests/test_telemetry_queue_worker.py b/tests/test_telemetry_queue_worker.py index f999fd21..d992440a 100644 --- a/tests/test_telemetry_queue_worker.py +++ b/tests/test_telemetry_queue_worker.py @@ -38,6 +38,8 @@ class _Dummy: def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod diff --git a/tests/test_validate_script_summary.py b/tests/test_validate_script_summary.py index 49e7d1f6..23ccad6d 100644 --- a/tests/test_validate_script_summary.py +++ b/tests/test_validate_script_summary.py @@ -1,33 +1,16 @@ import sys import pathlib import importlib.util -import types ROOT = pathlib.Path(__file__).resolve().parents[1] SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src" sys.path.insert(0, str(SRC)) -# stub mcp.server.fastmcp similar to test_get_sha -mcp_pkg = types.ModuleType("mcp") -server_pkg = types.ModuleType("mcp.server") -fastmcp_pkg = types.ModuleType("mcp.server.fastmcp") - - -class _Dummy: - pass - - -fastmcp_pkg.FastMCP = _Dummy -fastmcp_pkg.Context = _Dummy -server_pkg.fastmcp = fastmcp_pkg -mcp_pkg.server = server_pkg -sys.modules.setdefault("mcp", mcp_pkg) -sys.modules.setdefault("mcp.server", server_pkg) -sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg) - def _load_module(path: pathlib.Path, name: str): spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load module {name} from {path}") mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod