From 09661a8fcb787e05ece49b799b06ffb886d846bc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 10:47:19 -0400 Subject: [PATCH 01/32] refactor: Split ParseColorOrDefault into two overloads and change default to Color.white --- MCPForUnity/Editor/Helpers/VectorParsing.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs b/MCPForUnity/Editor/Helpers/VectorParsing.cs index a7337e005..2c5ab7cce 100644 --- a/MCPForUnity/Editor/Helpers/VectorParsing.cs +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs @@ -226,14 +226,14 @@ public static Vector3 ParseVector3OrDefault(JToken token, Vector3 defaultValue = } /// - /// Parses a JToken into a Color, returning a default value if parsing fails. - /// Added for ManageVFX refactoring. + /// Parses a JToken into a Color, returning Color.white if parsing fails and no default is specified. /// - public static Color ParseColorOrDefault(JToken token, Color defaultValue = default) - { - if (defaultValue == default) defaultValue = Color.black; - return ParseColor(token) ?? defaultValue; - } + public static Color ParseColorOrDefault(JToken token) => ParseColor(token) ?? Color.white; + + /// + /// Parses a JToken into a Color, returning the specified default if parsing fails. + /// + public static Color ParseColorOrDefault(JToken token, Color defaultValue) => ParseColor(token) ?? defaultValue; /// From e48879b26cf067049548251e686dc9ce2199d9ad Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 11:42:41 -0400 Subject: [PATCH 02/32] Auto-format Python code --- Server/src/main.py | 3 +- Server/src/services/custom_tool_service.py | 3 +- .../src/services/resources/editor_state_v2.py | 32 +- Server/src/services/resources/gameobject.py | 25 +- .../state/external_changes_scanner.py | 7 +- Server/src/services/tools/batch_execute.py | 21 +- .../services/tools/debug_request_context.py | 2 +- Server/src/services/tools/find_gameobjects.py | 17 +- Server/src/services/tools/find_in_file.py | 30 +- .../src/services/tools/manage_components.py | 13 +- .../src/services/tools/manage_gameobject.py | 50 ++- Server/src/services/tools/manage_material.py | 48 +- Server/src/services/tools/manage_scene.py | 35 +- .../tools/manage_scriptable_object.py | 21 +- Server/src/services/tools/manage_shader.py | 3 +- Server/src/services/tools/manage_vfx.py | 419 +++++++++++------- Server/src/services/tools/preflight.py | 17 +- Server/src/services/tools/read_console.py | 6 +- Server/src/services/tools/refresh_unity.py | 21 +- Server/src/services/tools/run_tests.py | 24 +- .../src/services/tools/script_apply_edits.py | 2 +- .../src/services/tools/set_active_instance.py | 10 +- Server/src/services/tools/test_jobs.py | 29 +- Server/src/services/tools/utils.py | 30 +- Server/src/transport/legacy/port_discovery.py | 4 +- Server/src/transport/plugin_hub.py | 27 +- .../transport/unity_instance_middleware.py | 7 +- Server/src/transport/unity_transport.py | 3 +- 28 files changed, 554 insertions(+), 355 deletions(-) diff --git a/Server/src/main.py b/Server/src/main.py index 7e9c77f1e..2e03cd829 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -30,7 +30,8 @@ ) for _name in _typing_names: if not hasattr(builtins, _name) and hasattr(_typing, _name): - setattr(builtins, _name, getattr(_typing, _name)) # type: ignore[attr-defined] + # type: ignore[attr-defined] + setattr(builtins, _name, getattr(_typing, _name)) except Exception: pass diff --git a/Server/src/services/custom_tool_service.py b/Server/src/services/custom_tool_service.py index 80034822c..adbe14baa 100644 --- a/Server/src/services/custom_tool_service.py +++ b/Server/src/services/custom_tool_service.py @@ -310,7 +310,8 @@ def resolve_project_id_for_unity_instance(unity_instance: str | None) -> str | N # This matches the hash Unity uses when registering tools via WebSocket. if target.hash: return target.hash - logger.warning(f"Unity instance {target.id} has empty hash; cannot resolve project ID") + logger.warning( + f"Unity instance {target.id} has empty hash; cannot resolve project ID") return None except Exception: logger.debug( diff --git a/Server/src/services/resources/editor_state_v2.py b/Server/src/services/resources/editor_state_v2.py index 212d114d8..371d2ca8e 100644 --- a/Server/src/services/resources/editor_state_v2.py +++ b/Server/src/services/resources/editor_state_v2.py @@ -40,7 +40,8 @@ async def _infer_single_instance_id(ctx: Context) -> str | None: from transport.plugin_hub import PluginHub sessions_data = await PluginHub.get_sessions() - sessions = sessions_data.sessions if hasattr(sessions_data, "sessions") else {} + sessions = sessions_data.sessions if hasattr( + sessions_data, "sessions") else {} if isinstance(sessions, dict) and len(sessions) == 1: session = next(iter(sessions.values())) project = getattr(session, "project", None) @@ -209,9 +210,11 @@ async def get_editor_state_v2(ctx: Context) -> MCPResponse: ) if isinstance(legacy, dict) and not legacy.get("success", True): return MCPResponse(**legacy) - state_v2 = _build_v2_from_legacy(legacy if isinstance(legacy, dict) else {}) + state_v2 = _build_v2_from_legacy( + legacy if isinstance(legacy, dict) else {}) else: - state_v2 = response.get("data") if isinstance(response.get("data"), dict) else {} + state_v2 = response.get("data") if isinstance( + response.get("data"), dict) else {} # Ensure required v2 marker exists even if Unity returns partial. state_v2.setdefault("schema_version", "unity-mcp/editor_state@2") state_v2.setdefault("observed_at_unix_ms", _now_unix_ms()) @@ -241,11 +244,14 @@ async def get_editor_state_v2(ctx: Context) -> MCPResponse: # Cache the project root for this instance (best-effort). proj_resp = await get_project_info(ctx) - proj = proj_resp.model_dump() if hasattr(proj_resp, "model_dump") else proj_resp + proj = proj_resp.model_dump() if hasattr( + proj_resp, "model_dump") else proj_resp proj_data = proj.get("data") if isinstance(proj, dict) else None - project_root = proj_data.get("projectRoot") if isinstance(proj_data, dict) else None + project_root = proj_data.get("projectRoot") if isinstance( + proj_data, dict) else None if isinstance(project_root, str) and project_root.strip(): - external_changes_scanner.set_project_root(instance_id, project_root) + external_changes_scanner.set_project_root( + instance_id, project_root) ext = external_changes_scanner.update_and_get(instance_id) @@ -255,16 +261,18 @@ async def get_editor_state_v2(ctx: Context) -> MCPResponse: state_v2["assets"] = assets # IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative # for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner. - assets["external_changes_dirty"] = bool(ext.get("external_changes_dirty", False)) - assets["external_changes_last_seen_unix_ms"] = ext.get("external_changes_last_seen_unix_ms") + assets["external_changes_dirty"] = bool( + ext.get("external_changes_dirty", False)) + assets["external_changes_last_seen_unix_ms"] = ext.get( + "external_changes_last_seen_unix_ms") # Extra bookkeeping fields (server-only) are safe to add under assets. - assets["external_changes_dirty_since_unix_ms"] = ext.get("dirty_since_unix_ms") - assets["external_changes_last_cleared_unix_ms"] = ext.get("last_cleared_unix_ms") + assets["external_changes_dirty_since_unix_ms"] = ext.get( + "dirty_since_unix_ms") + assets["external_changes_last_cleared_unix_ms"] = ext.get( + "last_cleared_unix_ms") except Exception: # Best-effort; do not fail readiness resource if filesystem scan can't run. pass state_v2 = _enrich_advice_and_staleness(state_v2) return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2) - - diff --git a/Server/src/services/resources/gameobject.py b/Server/src/services/resources/gameobject.py index 491471db9..6cf0b46a5 100644 --- a/Server/src/services/resources/gameobject.py +++ b/Server/src/services/resources/gameobject.py @@ -47,7 +47,7 @@ def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | N async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse: """ Returns documentation for the GameObject resource API. - + This is a helper resource that explains how to use the parameterized GameObject resources which require an instance ID. """ @@ -134,18 +134,18 @@ class GameObjectResponse(MCPResponse): async def get_gameobject(ctx: Context, instance_id: str) -> MCPResponse: """Get GameObject data by instance ID.""" unity_instance = get_unity_instance_from_context(ctx) - + id_int, error = _validate_instance_id(instance_id) if error: return error - + response = await send_with_unity_instance( async_send_command_with_retry, unity_instance, "get_gameobject", {"instanceID": id_int} ) - + return _normalize_response(response) @@ -173,7 +173,7 @@ class ComponentsResponse(MCPResponse): description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters." ) async def get_gameobject_components( - ctx: Context, + ctx: Context, instance_id: str, page_size: int = 25, cursor: int = 0, @@ -181,11 +181,11 @@ async def get_gameobject_components( ) -> MCPResponse: """Get all components on a GameObject.""" unity_instance = get_unity_instance_from_context(ctx) - + id_int, error = _validate_instance_id(instance_id) if error: return error - + response = await send_with_unity_instance( async_send_command_with_retry, unity_instance, @@ -197,7 +197,7 @@ async def get_gameobject_components( "includeProperties": include_properties } ) - + return _normalize_response(response) @@ -219,17 +219,17 @@ class SingleComponentResponse(MCPResponse): description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties." ) async def get_gameobject_component( - ctx: Context, + ctx: Context, instance_id: str, component_name: str ) -> MCPResponse: """Get a specific component on a GameObject.""" unity_instance = get_unity_instance_from_context(ctx) - + id_int, error = _validate_instance_id(instance_id) if error: return error - + response = await send_with_unity_instance( async_send_command_with_retry, unity_instance, @@ -239,6 +239,5 @@ async def get_gameobject_component( "componentName": component_name } ) - - return _normalize_response(response) + return _normalize_response(response) diff --git a/Server/src/services/state/external_changes_scanner.py b/Server/src/services/state/external_changes_scanner.py index 9227f7a84..303998e64 100644 --- a/Server/src/services/state/external_changes_scanner.py +++ b/Server/src/services/state/external_changes_scanner.py @@ -116,7 +116,8 @@ def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesS st.manifest_last_mtime_ns = None return [] - mtime_ns = getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)) + mtime_ns = getattr(stat, "st_mtime_ns", int( + stat.st_mtime * 1_000_000_000)) if st.extra_roots is not None and st.manifest_last_mtime_ns == mtime_ns: return [Path(p) for p in st.extra_roots if p] @@ -143,7 +144,7 @@ def _resolve_manifest_extra_roots(self, project_root: Path, st: ExternalChangesS v = ver.strip() if not v.startswith("file:"): continue - suffix = v[len("file:") :].strip() + suffix = v[len("file:"):].strip() # Handle file:///abs/path or file:/abs/path if suffix.startswith("///"): candidate = Path("/" + suffix.lstrip("/")) @@ -242,5 +243,3 @@ def update_and_get(self, instance_id: str) -> dict[str, int | bool | None]: # Global singleton (simple, process-local) external_changes_scanner = ExternalChangesScanner() - - diff --git a/Server/src/services/tools/batch_execute.py b/Server/src/services/tools/batch_execute.py index 3ad7f887e..bd480e3d1 100644 --- a/Server/src/services/tools/batch_execute.py +++ b/Server/src/services/tools/batch_execute.py @@ -31,15 +31,19 @@ async def batch_execute( ctx: Context, commands: Annotated[list[dict[str, Any]], "List of commands with 'tool' and 'params' keys."], - parallel: Annotated[bool | None, "Attempt to run read-only commands in parallel"] = None, - fail_fast: Annotated[bool | None, "Stop processing after the first failure"] = None, - max_parallelism: Annotated[int | None, "Hint for the maximum number of parallel workers"] = None, + parallel: Annotated[bool | None, + "Attempt to run read-only commands in parallel"] = None, + fail_fast: Annotated[bool | None, + "Stop processing after the first failure"] = None, + max_parallelism: Annotated[int | None, + "Hint for the maximum number of parallel workers"] = None, ) -> dict[str, Any]: """Proxy the batch_execute tool to the Unity Editor transporter.""" unity_instance = get_unity_instance_from_context(ctx) if not isinstance(commands, list) or not commands: - raise ValueError("'commands' must be a non-empty list of command specifications") + raise ValueError( + "'commands' must be a non-empty list of command specifications") if len(commands) > MAX_COMMANDS_PER_BATCH: raise ValueError( @@ -49,18 +53,21 @@ async def batch_execute( normalized_commands: list[dict[str, Any]] = [] for index, command in enumerate(commands): if not isinstance(command, dict): - raise ValueError(f"Command at index {index} must be an object with 'tool' and 'params' keys") + raise ValueError( + f"Command at index {index} must be an object with 'tool' and 'params' keys") tool_name = command.get("tool") params = command.get("params", {}) if not tool_name or not isinstance(tool_name, str): - raise ValueError(f"Command at index {index} is missing a valid 'tool' name") + raise ValueError( + f"Command at index {index} is missing a valid 'tool' name") if params is None: params = {} if not isinstance(params, dict): - raise ValueError(f"Command '{tool_name}' must specify parameters as an object/dict") + raise ValueError( + f"Command '{tool_name}' must specify parameters as an object/dict") normalized_commands.append({ "tool": tool_name, diff --git a/Server/src/services/tools/debug_request_context.py b/Server/src/services/tools/debug_request_context.py index 3948e70ef..d3539e2e2 100644 --- a/Server/src/services/tools/debug_request_context.py +++ b/Server/src/services/tools/debug_request_context.py @@ -48,7 +48,7 @@ def debug_request_context(ctx: Context) -> dict[str, Any]: middleware = get_unity_instance_middleware() derived_key = middleware.get_session_key(ctx) active_instance = middleware.get_active_instance(ctx) - + # Debugging middleware internals # NOTE: These fields expose internal implementation details and may change between versions. with middleware._lock: diff --git a/Server/src/services/tools/find_gameobjects.py b/Server/src/services/tools/find_gameobjects.py index 02b5db7ae..8ca8fc927 100644 --- a/Server/src/services/tools/find_gameobjects.py +++ b/Server/src/services/tools/find_gameobjects.py @@ -20,19 +20,23 @@ async def find_gameobjects( ctx: Context, search_term: Annotated[str, "The value to search for (name, tag, layer name, component type, or path)"], search_method: Annotated[ - Literal["by_name", "by_tag", "by_layer", "by_component", "by_path", "by_id"], + Literal["by_name", "by_tag", "by_layer", + "by_component", "by_path", "by_id"], "How to search for GameObjects" ] = "by_name", - include_inactive: Annotated[bool | str, "Include inactive GameObjects in search"] | None = None, - page_size: Annotated[int | str, "Number of results per page (default: 50, max: 500)"] | None = None, - cursor: Annotated[int | str, "Pagination cursor (offset for next page)"] | None = None, + include_inactive: Annotated[bool | str, + "Include inactive GameObjects in search"] | None = None, + page_size: Annotated[int | str, + "Number of results per page (default: 50, max: 500)"] | None = None, + cursor: Annotated[int | str, + "Pagination cursor (offset for next page)"] | None = None, ) -> dict[str, Any]: """ Search for GameObjects and return their instance IDs. - + This is a focused search tool optimized for finding GameObjects efficiently. It returns only instance IDs to minimize payload size. - + For detailed GameObject information, use the returned IDs with: - unity://scene/gameobject/{id} - Get full GameObject data - unity://scene/gameobject/{id}/components - Get all components @@ -83,4 +87,3 @@ async def find_gameobjects( except Exception as e: return {"success": False, "message": f"Error searching GameObjects: {e!s}"} - diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py index c3470937e..a6ae32140 100644 --- a/Server/src/services/tools/find_in_file.py +++ b/Server/src/services/tools/find_in_file.py @@ -78,7 +78,8 @@ async def find_in_file( pattern: Annotated[str, "The regex pattern to search for"], project_root: Annotated[str | None, "Optional project root path"] = None, max_results: Annotated[int, "Cap results to avoid huge payloads"] = 200, - ignore_case: Annotated[bool | str | None, "Case insensitive search"] = True, + ignore_case: Annotated[bool | str | None, + "Case insensitive search"] = True, ) -> dict[str, Any]: # project_root is currently unused but kept for interface consistency unity_instance = get_unity_instance_from_context(ctx) @@ -86,7 +87,7 @@ async def find_in_file( f"Processing find_in_file: {uri} (unity_instance={unity_instance or 'default'})") name, directory = _split_uri(uri) - + # 1. Read file content via Unity read_resp = await send_with_unity_instance( async_send_command_with_retry, @@ -110,7 +111,7 @@ async def find_in_file( "utf-8")).decode("utf-8", "replace") except (ValueError, TypeError, base64.binascii.Error): contents = contents or "" - + if contents is None: return {"success": False, "message": "Could not read file content."} @@ -128,26 +129,26 @@ async def find_in_file( except re.error as e: return {"success": False, "message": f"Invalid regex pattern: {e}"} - # If the regex is not multiline specific (doesn't contain \n literal match logic), + # If the regex is not multiline specific (doesn't contain \n literal match logic), # we could iterate lines. But users might use multiline regexes. # Let's search the whole content and map back to lines. - + found = list(regex.finditer(contents)) - + results = [] count = 0 - + for m in found: if count >= max_results: break - + start_idx = m.start() end_idx = m.end() - + # Calculate line number # Count newlines up to start_idx line_num = contents.count('\n', 0, start_idx) + 1 - + # Get line content for excerpt # Find start of line line_start = contents.rfind('\n', 0, start_idx) + 1 @@ -155,15 +156,15 @@ async def find_in_file( line_end = contents.find('\n', start_idx) if line_end == -1: line_end = len(contents) - + line_content = contents[line_start:line_end] - + # Create excerpt # We can just return the line content as excerpt - + results.append({ "line": line_num, - "content": line_content.strip(), # detailed match info? + "content": line_content.strip(), # detailed match info? "match": m.group(0), "start": start_idx, "end": end_idx @@ -178,4 +179,3 @@ async def find_in_file( "total_matches": len(found) } } - diff --git a/Server/src/services/tools/manage_components.py b/Server/src/services/tools/manage_components.py index 1d5ddda47..d9a1d91c2 100644 --- a/Server/src/services/tools/manage_components.py +++ b/Server/src/services/tools/manage_components.py @@ -35,8 +35,10 @@ async def manage_components( "How to find the target GameObject" ] | None = None, # For set_property action - single property - property: Annotated[str, "Property name to set (for set_property action)"] | None = None, - value: Annotated[Any, "Value to set (for set_property action)"] | None = None, + property: Annotated[str, + "Property name to set (for set_property action)"] | None = None, + value: Annotated[Any, + "Value to set (for set_property action)"] | None = None, # For add/set_property - multiple properties properties: Annotated[ dict[str, Any], @@ -45,12 +47,12 @@ async def manage_components( ) -> dict[str, Any]: """ Manage components on GameObjects. - + Actions: - add: Add a new component to a GameObject - remove: Remove a component from a GameObject - set_property: Set one or more properties on a component - + Examples: - Add Rigidbody: action="add", target="Player", component_type="Rigidbody" - Remove BoxCollider: action="remove", target=-12345, component_type="BoxCollider" @@ -85,7 +87,7 @@ async def manage_components( properties, props_error = normalize_properties(properties) if props_error: return {"success": False, "message": props_error} - + # --- Validate value parameter for serialization issues --- if value is not None and isinstance(value, str) and value in ("[object Object]", "undefined"): return {"success": False, "message": f"value received invalid input: '{value}'. Expected an actual value."} @@ -127,4 +129,3 @@ async def manage_components( except Exception as e: return {"success": False, "message": f"Error managing component: {e!s}"} - diff --git a/Server/src/services/tools/manage_gameobject.py b/Server/src/services/tools/manage_gameobject.py index eb4435861..a618252f0 100644 --- a/Server/src/services/tools/manage_gameobject.py +++ b/Server/src/services/tools/manage_gameobject.py @@ -21,7 +21,7 @@ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None: """ if value is None: return default - + # If already a list/tuple with 3 elements, convert to floats if isinstance(value, (list, tuple)) and len(value) == 3: try: @@ -29,7 +29,7 @@ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None: return vec if all(math.isfinite(n) for n in vec) else default except (ValueError, TypeError): return default - + # Try parsing as JSON string if isinstance(value, str): parsed = parse_json_payload(value) @@ -39,7 +39,7 @@ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None: return vec if all(math.isfinite(n) for n in vec) else default except (ValueError, TypeError): pass - + # Handle legacy comma-separated strings "1,2,3" or "[1,2,3]" s = value.strip() if s.startswith("[") and s.endswith("]"): @@ -51,7 +51,7 @@ def _normalize_vector(value: Any, default: Any = None) -> list[float] | None: return vec if all(math.isfinite(n) for n in vec) else default except (ValueError, TypeError): pass - + return default @@ -62,23 +62,23 @@ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any """ if value is None: return None, None - + # Already a dict - validate structure if isinstance(value, dict): return value, None - + # Try parsing as JSON string if isinstance(value, str): # Check for obviously invalid values if value in ("[object Object]", "undefined", "null", ""): return None, f"component_properties received invalid value: '{value}'. Expected a JSON object like {{\"ComponentName\": {{\"property\": value}}}}" - + parsed = parse_json_payload(value) if isinstance(parsed, dict): return parsed, None - + return None, f"component_properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}" - + return None, f"component_properties must be a dict or JSON string, got {type(value).__name__}" @@ -91,7 +91,8 @@ def _normalize_component_properties(value: Any) -> tuple[dict[str, dict[str, Any ) async def manage_gameobject( ctx: Context, - action: Annotated[Literal["create", "modify", "delete", "duplicate", "move_relative"], "Action to perform on GameObject."] | None = None, + action: Annotated[Literal["create", "modify", "delete", "duplicate", + "move_relative"], "Action to perform on GameObject."] | None = None, target: Annotated[str, "GameObject identifier by name or path for modify/delete/component actions"] | None = None, search_method: Annotated[Literal["by_id", "by_name", "by_path", "by_tag", "by_layer", "by_component"], @@ -145,10 +146,14 @@ async def manage_gameobject( includeNonPublicSerialized: Annotated[bool | str, "Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None, # --- Paging/safety for get_components --- - page_size: Annotated[int | str, "Page size for get_components paging."] | None = None, - cursor: Annotated[int | str, "Opaque cursor for get_components paging (offset)."] | None = None, - max_components: Annotated[int | str, "Hard cap on returned components per request (safety)."] | None = None, - include_properties: Annotated[bool | str, "If true, include serialized component properties (bounded)."] | None = None, + page_size: Annotated[int | str, + "Page size for get_components paging."] | None = None, + cursor: Annotated[int | str, + "Opaque cursor for get_components paging (offset)."] | None = None, + max_components: Annotated[int | str, + "Hard cap on returned components per request (safety)."] | None = None, + include_properties: Annotated[bool | str, + "If true, include serialized component properties (bounded)."] | None = None, # --- Parameters for 'duplicate' --- new_name: Annotated[str, "New name for the duplicated object (default: SourceName_Copy)"] | None = None, @@ -156,13 +161,13 @@ async def manage_gameobject( "Offset from original/reference position as [x, y, z] array"] | None = None, # --- Parameters for 'move_relative' --- reference_object: Annotated[str, - "Reference object for relative movement (required for move_relative)"] | None = None, + "Reference object for relative movement (required for move_relative)"] | None = None, direction: Annotated[Literal["left", "right", "up", "down", "forward", "back", "front", "backward", "behind"], - "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None, + "Direction for relative movement (e.g., 'right', 'up', 'forward')"] | None = None, distance: Annotated[float, - "Distance to move in the specified direction (default: 1.0)"] | None = None, + "Distance to move in the specified direction (default: 1.0)"] | None = None, world_space: Annotated[bool | str, - "If True (default), use world space directions; if False, use reference object's local directions"] | None = None, + "If True (default), use world space directions; if False, use reference object's local directions"] | None = None, ) -> dict[str, Any]: # Get active instance from session state # Removed session_state import @@ -183,7 +188,7 @@ async def manage_gameobject( rotation = _normalize_vector(rotation) scale = _normalize_vector(scale) offset = _normalize_vector(offset) - + # --- Normalize boolean parameters --- save_as_prefab = coerce_bool(save_as_prefab) set_active = coerce_bool(set_active) @@ -193,17 +198,18 @@ async def manage_gameobject( includeNonPublicSerialized = coerce_bool(includeNonPublicSerialized) include_properties = coerce_bool(include_properties) world_space = coerce_bool(world_space, default=True) - + # --- Normalize integer parameters --- page_size = coerce_int(page_size, default=None) cursor = coerce_int(cursor, default=None) max_components = coerce_int(max_components, default=None) # --- Normalize component_properties with detailed error handling --- - component_properties, comp_props_error = _normalize_component_properties(component_properties) + component_properties, comp_props_error = _normalize_component_properties( + component_properties) if comp_props_error: return {"success": False, "message": comp_props_error} - + try: # Validate parameter usage to prevent silent failures if action in ["create", "modify"]: diff --git a/Server/src/services/tools/manage_material.py b/Server/src/services/tools/manage_material.py index f131238af..d3c62cda4 100644 --- a/Server/src/services/tools/manage_material.py +++ b/Server/src/services/tools/manage_material.py @@ -21,7 +21,7 @@ def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]: """ if value is None: return None, None - + # Already a list - validate if isinstance(value, (list, tuple)): if len(value) in (3, 4): @@ -30,12 +30,12 @@ def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]: except (ValueError, TypeError): return None, f"color values must be numbers, got {value}" return None, f"color must have 3 or 4 components, got {len(value)}" - + # Try parsing as string if isinstance(value, str): if value in ("[object Object]", "undefined", "null", ""): return None, f"color received invalid value: '{value}'. Expected [r, g, b] or [r, g, b, a]" - + parsed = parse_json_payload(value) if isinstance(parsed, (list, tuple)) and len(parsed) in (3, 4): try: @@ -43,7 +43,7 @@ def _normalize_color(value: Any) -> tuple[list[float] | None, str | None]: except (ValueError, TypeError): return None, f"color values must be numbers, got {parsed}" return None, f"Failed to parse color string: {value}" - + return None, f"color must be a list or JSON string, got {type(value).__name__}" @@ -65,27 +65,35 @@ async def manage_material( "set_renderer_color", "get_material_info" ], "Action to perform."], - + # Common / Shared - material_path: Annotated[str, "Path to material asset (Assets/...)"] | None = None, - property: Annotated[str, "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None, + material_path: Annotated[str, + "Path to material asset (Assets/...)"] | None = None, + property: Annotated[str, + "Shader property name (e.g., _BaseColor, _MainTex)"] | None = None, # create shader: Annotated[str, "Shader name (default: Standard)"] | None = None, - properties: Annotated[dict[str, Any], "Initial properties to set as {name: value} dict."] | None = None, - + properties: Annotated[dict[str, Any], + "Initial properties to set as {name: value} dict."] | None = None, + # set_material_shader_property - value: Annotated[list | float | int | str | bool | None, "Value to set (color array, float, texture path/instruction)"] | None = None, - + value: Annotated[list | float | int | str | bool | None, + "Value to set (color array, float, texture path/instruction)"] | None = None, + # set_material_color / set_renderer_color - color: Annotated[list[float], "Color as [r, g, b] or [r, g, b, a] array."] | None = None, - + color: Annotated[list[float], + "Color as [r, g, b] or [r, g, b, a] array."] | None = None, + # assign_material_to_renderer / set_renderer_color - target: Annotated[str, "Target GameObject (name, path, or find instruction)"] | None = None, - search_method: Annotated[Literal["by_name", "by_path", "by_tag", "by_layer", "by_component"], "Search method for target"] | None = None, + target: Annotated[str, + "Target GameObject (name, path, or find instruction)"] | None = None, + search_method: Annotated[Literal["by_name", "by_path", "by_tag", + "by_layer", "by_component"], "Search method for target"] | None = None, slot: Annotated[int, "Material slot index (0-based)"] | None = None, - mode: Annotated[Literal["shared", "instance", "property_block"], "Assignment/modification mode"] | None = None, - + mode: Annotated[Literal["shared", "instance", "property_block"], + "Assignment/modification mode"] | None = None, + ) -> dict[str, Any]: unity_instance = get_unity_instance_from_context(ctx) @@ -93,12 +101,12 @@ async def manage_material( color, color_error = _normalize_color(color) if color_error: return {"success": False, "message": color_error} - + # --- Normalize properties with validation --- properties, props_error = normalize_properties(properties) if props_error: return {"success": False, "message": props_error} - + # --- Normalize value (parse JSON if string) --- value = parse_json_payload(value) if isinstance(value, str) and value in ("[object Object]", "undefined"): @@ -132,5 +140,5 @@ async def manage_material( "manage_material", params_dict, ) - + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/src/services/tools/manage_scene.py b/Server/src/services/tools/manage_scene.py index 094c96cb7..2a29b906e 100644 --- a/Server/src/services/tools/manage_scene.py +++ b/Server/src/services/tools/manage_scene.py @@ -33,16 +33,25 @@ async def manage_scene( path: Annotated[str, "Scene path."] | None = None, build_index: Annotated[int | str, "Unity build index (quote as string, e.g., '0')."] | None = None, - screenshot_file_name: Annotated[str, "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None, - screenshot_super_size: Annotated[int | str, "Screenshot supersize multiplier (integer ≥1). Optional." ] | None = None, + screenshot_file_name: Annotated[str, + "Screenshot file name (optional). Defaults to timestamp when omitted."] | None = None, + screenshot_super_size: Annotated[int | str, + "Screenshot supersize multiplier (integer ≥1). Optional."] | None = None, # --- get_hierarchy paging/safety --- - parent: Annotated[str | int, "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None, - page_size: Annotated[int | str, "Page size for get_hierarchy paging."] | None = None, - cursor: Annotated[int | str, "Opaque cursor for paging (offset)."] | None = None, - max_nodes: Annotated[int | str, "Hard cap on returned nodes per request (safety)."] | None = None, - max_depth: Annotated[int | str, "Accepted for forward-compatibility; current paging returns a single level."] | None = None, - max_children_per_node: Annotated[int | str, "Child paging hint (safety)."] | None = None, - include_transform: Annotated[bool | str, "If true, include local transform in node summaries."] | None = None, + parent: Annotated[str | int, + "Optional parent GameObject reference (name/path/instanceID) to list direct children."] | None = None, + page_size: Annotated[int | str, + "Page size for get_hierarchy paging."] | None = None, + cursor: Annotated[int | str, + "Opaque cursor for paging (offset)."] | None = None, + max_nodes: Annotated[int | str, + "Hard cap on returned nodes per request (safety)."] | None = None, + max_depth: Annotated[int | str, + "Accepted for forward-compatibility; current paging returns a single level."] | None = None, + max_children_per_node: Annotated[int | str, + "Child paging hint (safety)."] | None = None, + include_transform: Annotated[bool | str, + "If true, include local transform in node summaries."] | None = None, ) -> dict[str, Any]: # Get active instance from session state # Removed session_state import @@ -57,8 +66,10 @@ async def manage_scene( coerced_cursor = coerce_int(cursor, default=None) coerced_max_nodes = coerce_int(max_nodes, default=None) coerced_max_depth = coerce_int(max_depth, default=None) - coerced_max_children_per_node = coerce_int(max_children_per_node, default=None) - coerced_include_transform = coerce_bool(include_transform, default=None) + coerced_max_children_per_node = coerce_int( + max_children_per_node, default=None) + coerced_include_transform = coerce_bool( + include_transform, default=None) params: dict[str, Any] = {"action": action} if name: @@ -71,7 +82,7 @@ async def manage_scene( params["fileName"] = screenshot_file_name if coerced_super_size is not None: params["superSize"] = coerced_super_size - + # get_hierarchy paging/safety params (optional) if parent is not None: params["parent"] = parent diff --git a/Server/src/services/tools/manage_scriptable_object.py b/Server/src/services/tools/manage_scriptable_object.py index 746ae9b4d..b86504979 100644 --- a/Server/src/services/tools/manage_scriptable_object.py +++ b/Server/src/services/tools/manage_scriptable_object.py @@ -33,14 +33,20 @@ async def manage_scriptable_object( ctx: Context, action: Annotated[Literal["create", "modify"], "Action to perform: create or modify."], # --- create params --- - type_name: Annotated[str | None, "Namespace-qualified ScriptableObject type name (for create)."] = None, - folder_path: Annotated[str | None, "Target folder under Assets/... (for create)."] = None, - asset_name: Annotated[str | None, "Asset file name without extension (for create)."] = None, - overwrite: Annotated[bool | str | None, "If true, overwrite existing asset at same path (for create)."] = None, + type_name: Annotated[str | None, + "Namespace-qualified ScriptableObject type name (for create)."] = None, + folder_path: Annotated[str | None, + "Target folder under Assets/... (for create)."] = None, + asset_name: Annotated[str | None, + "Asset file name without extension (for create)."] = None, + overwrite: Annotated[bool | str | None, + "If true, overwrite existing asset at same path (for create)."] = None, # --- modify params --- - target: Annotated[dict[str, Any] | str | None, "Target asset reference {guid|path} (for modify)."] = None, + target: Annotated[dict[str, Any] | str | None, + "Target asset reference {guid|path} (for modify)."] = None, # --- shared --- - patches: Annotated[list[dict[str, Any]] | str | None, "Patch list (or JSON string) to apply."] = None, + patches: Annotated[list[dict[str, Any]] | str | None, + "Patch list (or JSON string) to apply."] = None, ) -> dict[str, Any]: unity_instance = get_unity_instance_from_context(ctx) @@ -75,6 +81,3 @@ async def manage_scriptable_object( ) await ctx.info(f"Response {response}") return response if isinstance(response, dict) else {"success": False, "message": "Unexpected response from Unity."} - - - diff --git a/Server/src/services/tools/manage_shader.py b/Server/src/services/tools/manage_shader.py index 1865a7051..e8188bb98 100644 --- a/Server/src/services/tools/manage_shader.py +++ b/Server/src/services/tools/manage_shader.py @@ -14,7 +14,8 @@ description="Manages shader scripts in Unity (create, read, update, delete). Read-only action: read. Modifying actions: create, update, delete.", annotations=ToolAnnotations( title="Manage Shader", - destructiveHint=True, # Note: 'read' action is non-destructive; 'create', 'update', 'delete' are destructive + # Note: 'read' action is non-destructive; 'create', 'update', 'delete' are destructive + destructiveHint=True, ), ) async def manage_shader( diff --git a/Server/src/services/tools/manage_vfx.py b/Server/src/services/tools/manage_vfx.py index 567fa9853..a584946d3 100644 --- a/Server/src/services/tools/manage_vfx.py +++ b/Server/src/services/tools/manage_vfx.py @@ -11,7 +11,7 @@ # All possible actions grouped by component type PARTICLE_ACTIONS = [ "particle_get_info", "particle_set_main", "particle_set_emission", "particle_set_shape", - "particle_set_color_over_lifetime", "particle_set_size_over_lifetime", + "particle_set_color_over_lifetime", "particle_set_size_over_lifetime", "particle_set_velocity_over_lifetime", "particle_set_noise", "particle_set_renderer", "particle_enable_module", "particle_play", "particle_stop", "particle_pause", "particle_restart", "particle_clear", "particle_add_burst", "particle_clear_bursts" @@ -39,7 +39,8 @@ "trail_set_material", "trail_set_properties", "trail_clear", "trail_emit" ] -ALL_ACTIONS = ["ping"] + PARTICLE_ACTIONS + VFX_ACTIONS + LINE_ACTIONS + TRAIL_ACTIONS +ALL_ACTIONS = ["ping"] + PARTICLE_ACTIONS + \ + VFX_ACTIONS + LINE_ACTIONS + TRAIL_ACTIONS @mcp_for_unity_tool( @@ -126,173 +127,282 @@ async def manage_vfx( ctx: Context, action: Annotated[str, "Action to perform. Use prefix: particle_, vfx_, line_, or trail_"], - + # Target specification (common) - REQUIRED for most actions # Using str | None to accept any string format - target: Annotated[str | None, "Target GameObject with the VFX component. Use name (e.g. 'Fire'), path ('Effects/Fire'), instance ID, or tag. The GameObject MUST have the required component (ParticleSystem/VisualEffect/LineRenderer/TrailRenderer) for the action prefix."] = None, + target: Annotated[str | None, + "Target GameObject with the VFX component. Use name (e.g. 'Fire'), path ('Effects/Fire'), instance ID, or tag. The GameObject MUST have the required component (ParticleSystem/VisualEffect/LineRenderer/TrailRenderer) for the action prefix."] = None, search_method: Annotated[ Literal["by_id", "by_name", "by_path", "by_tag", "by_layer"] | None, "How to find target: by_name (default), by_path (hierarchy path), by_id (instance ID - most reliable), by_tag, by_layer" ] = None, - + # === PARTICLE SYSTEM PARAMETERS === # Main module - All use Any to accept string coercion from MCP clients - duration: Annotated[Any, "[Particle] Duration in seconds (number or string)"] = None, - looping: Annotated[Any, "[Particle] Whether to loop (bool or string 'true'/'false')"] = None, - prewarm: Annotated[Any, "[Particle] Prewarm the system (bool or string)"] = None, - start_delay: Annotated[Any, "[Particle] Start delay (number or MinMaxCurve dict)"] = None, - start_lifetime: Annotated[Any, "[Particle] Particle lifetime (number or MinMaxCurve dict)"] = None, - start_speed: Annotated[Any, "[Particle] Initial speed (number or MinMaxCurve dict)"] = None, - start_size: Annotated[Any, "[Particle] Initial size (number or MinMaxCurve dict)"] = None, - start_rotation: Annotated[Any, "[Particle] Initial rotation (number or MinMaxCurve dict)"] = None, - start_color: Annotated[Any, "[Particle/VFX] Start color [r,g,b,a] (array, dict, or JSON string)"] = None, - gravity_modifier: Annotated[Any, "[Particle] Gravity multiplier (number or MinMaxCurve dict)"] = None, - simulation_space: Annotated[Literal["Local", "World", "Custom"] | None, "[Particle] Simulation space"] = None, - scaling_mode: Annotated[Literal["Hierarchy", "Local", "Shape"] | None, "[Particle] Scaling mode"] = None, - play_on_awake: Annotated[Any, "[Particle] Play on awake (bool or string)"] = None, - max_particles: Annotated[Any, "[Particle] Maximum particles (integer or string)"] = None, - + duration: Annotated[Any, + "[Particle] Duration in seconds (number or string)"] = None, + looping: Annotated[Any, + "[Particle] Whether to loop (bool or string 'true'/'false')"] = None, + prewarm: Annotated[Any, + "[Particle] Prewarm the system (bool or string)"] = None, + start_delay: Annotated[Any, + "[Particle] Start delay (number or MinMaxCurve dict)"] = None, + start_lifetime: Annotated[Any, + "[Particle] Particle lifetime (number or MinMaxCurve dict)"] = None, + start_speed: Annotated[Any, + "[Particle] Initial speed (number or MinMaxCurve dict)"] = None, + start_size: Annotated[Any, + "[Particle] Initial size (number or MinMaxCurve dict)"] = None, + start_rotation: Annotated[Any, + "[Particle] Initial rotation (number or MinMaxCurve dict)"] = None, + start_color: Annotated[Any, + "[Particle/VFX] Start color [r,g,b,a] (array, dict, or JSON string)"] = None, + gravity_modifier: Annotated[Any, + "[Particle] Gravity multiplier (number or MinMaxCurve dict)"] = None, + simulation_space: Annotated[Literal["Local", "World", + "Custom"] | None, "[Particle] Simulation space"] = None, + scaling_mode: Annotated[Literal["Hierarchy", "Local", + "Shape"] | None, "[Particle] Scaling mode"] = None, + play_on_awake: Annotated[Any, + "[Particle] Play on awake (bool or string)"] = None, + max_particles: Annotated[Any, + "[Particle] Maximum particles (integer or string)"] = None, + # Emission - rate_over_time: Annotated[Any, "[Particle] Emission rate over time (number or MinMaxCurve dict)"] = None, - rate_over_distance: Annotated[Any, "[Particle] Emission rate over distance (number or MinMaxCurve dict)"] = None, - + rate_over_time: Annotated[Any, + "[Particle] Emission rate over time (number or MinMaxCurve dict)"] = None, + rate_over_distance: Annotated[Any, + "[Particle] Emission rate over distance (number or MinMaxCurve dict)"] = None, + # Shape - shape_type: Annotated[Literal["Sphere", "Hemisphere", "Cone", "Box", "Circle", "Edge", "Donut"] | None, "[Particle] Shape type"] = None, - radius: Annotated[Any, "[Particle/Line] Shape radius (number or string)"] = None, - radius_thickness: Annotated[Any, "[Particle] Radius thickness 0-1 (number or string)"] = None, + shape_type: Annotated[Literal["Sphere", "Hemisphere", "Cone", "Box", + "Circle", "Edge", "Donut"] | None, "[Particle] Shape type"] = None, + radius: Annotated[Any, + "[Particle/Line] Shape radius (number or string)"] = None, + radius_thickness: Annotated[Any, + "[Particle] Radius thickness 0-1 (number or string)"] = None, angle: Annotated[Any, "[Particle] Cone angle (number or string)"] = None, arc: Annotated[Any, "[Particle] Arc angle (number or string)"] = None, - + # Noise - strength: Annotated[Any, "[Particle] Noise strength (number or MinMaxCurve dict)"] = None, - frequency: Annotated[Any, "[Particle] Noise frequency (number or string)"] = None, - scroll_speed: Annotated[Any, "[Particle] Noise scroll speed (number or MinMaxCurve dict)"] = None, - damping: Annotated[Any, "[Particle] Noise damping (bool or string)"] = None, - octave_count: Annotated[Any, "[Particle] Noise octaves 1-4 (integer or string)"] = None, - quality: Annotated[Literal["Low", "Medium", "High"] | None, "[Particle] Noise quality"] = None, - + strength: Annotated[Any, + "[Particle] Noise strength (number or MinMaxCurve dict)"] = None, + frequency: Annotated[Any, + "[Particle] Noise frequency (number or string)"] = None, + scroll_speed: Annotated[Any, + "[Particle] Noise scroll speed (number or MinMaxCurve dict)"] = None, + damping: Annotated[Any, + "[Particle] Noise damping (bool or string)"] = None, + octave_count: Annotated[Any, + "[Particle] Noise octaves 1-4 (integer or string)"] = None, + quality: Annotated[Literal["Low", "Medium", "High"] + | None, "[Particle] Noise quality"] = None, + # Module control - module: Annotated[str | None, "[Particle] Module name to enable/disable"] = None, - enabled: Annotated[Any, "[Particle] Enable/disable module (bool or string)"] = None, - + module: Annotated[str | None, + "[Particle] Module name to enable/disable"] = None, + enabled: Annotated[Any, + "[Particle] Enable/disable module (bool or string)"] = None, + # Burst - time: Annotated[Any, "[Particle/Trail] Burst time or trail duration (number or string)"] = None, + time: Annotated[Any, + "[Particle/Trail] Burst time or trail duration (number or string)"] = None, count: Annotated[Any, "[Particle] Burst count (integer or string)"] = None, - min_count: Annotated[Any, "[Particle] Min burst count (integer or string)"] = None, - max_count: Annotated[Any, "[Particle] Max burst count (integer or string)"] = None, - cycles: Annotated[Any, "[Particle] Burst cycles (integer or string)"] = None, - interval: Annotated[Any, "[Particle] Burst interval (number or string)"] = None, - probability: Annotated[Any, "[Particle] Burst probability 0-1 (number or string)"] = None, - + min_count: Annotated[Any, + "[Particle] Min burst count (integer or string)"] = None, + max_count: Annotated[Any, + "[Particle] Max burst count (integer or string)"] = None, + cycles: Annotated[Any, + "[Particle] Burst cycles (integer or string)"] = None, + interval: Annotated[Any, + "[Particle] Burst interval (number or string)"] = None, + probability: Annotated[Any, + "[Particle] Burst probability 0-1 (number or string)"] = None, + # Playback - with_children: Annotated[Any, "[Particle] Apply to children (bool or string)"] = None, - + with_children: Annotated[Any, + "[Particle] Apply to children (bool or string)"] = None, + # === VFX GRAPH PARAMETERS === # Asset management - asset_name: Annotated[str | None, "[VFX] Name for new VFX asset (without .vfx extension)"] = None, - folder_path: Annotated[str | None, "[VFX] Folder path for new asset (default: Assets/VFX)"] = None, - template: Annotated[str | None, "[VFX] Template name for new asset (use vfx_list_templates to see available)"] = None, - asset_path: Annotated[str | None, "[VFX] Path to VFX asset to assign (e.g. Assets/VFX/MyEffect.vfx)"] = None, - overwrite: Annotated[Any, "[VFX] Overwrite existing asset (bool or string)"] = None, - folder: Annotated[str | None, "[VFX] Folder to search for assets (for vfx_list_assets)"] = None, - search: Annotated[str | None, "[VFX] Search pattern for assets (for vfx_list_assets)"] = None, - + asset_name: Annotated[str | None, + "[VFX] Name for new VFX asset (without .vfx extension)"] = None, + folder_path: Annotated[str | None, + "[VFX] Folder path for new asset (default: Assets/VFX)"] = None, + template: Annotated[str | None, + "[VFX] Template name for new asset (use vfx_list_templates to see available)"] = None, + asset_path: Annotated[str | None, + "[VFX] Path to VFX asset to assign (e.g. Assets/VFX/MyEffect.vfx)"] = None, + overwrite: Annotated[Any, + "[VFX] Overwrite existing asset (bool or string)"] = None, + folder: Annotated[str | None, + "[VFX] Folder to search for assets (for vfx_list_assets)"] = None, + search: Annotated[str | None, + "[VFX] Search pattern for assets (for vfx_list_assets)"] = None, + # Runtime parameters parameter: Annotated[str | None, "[VFX] Exposed parameter name"] = None, - value: Annotated[Any, "[VFX] Parameter value (number, bool, array, or string)"] = None, + value: Annotated[Any, + "[VFX] Parameter value (number, bool, array, or string)"] = None, texture_path: Annotated[str | None, "[VFX] Texture asset path"] = None, mesh_path: Annotated[str | None, "[VFX] Mesh asset path"] = None, - gradient: Annotated[Any, "[VFX/Line/Trail] Gradient {colorKeys, alphaKeys} or {startColor, endColor} (dict or JSON string)"] = None, - curve: Annotated[Any, "[VFX] Animation curve keys or {startValue, endValue} (array, dict, or JSON string)"] = None, + gradient: Annotated[Any, + "[VFX/Line/Trail] Gradient {colorKeys, alphaKeys} or {startColor, endColor} (dict or JSON string)"] = None, + curve: Annotated[Any, + "[VFX] Animation curve keys or {startValue, endValue} (array, dict, or JSON string)"] = None, event_name: Annotated[str | None, "[VFX] Event name to send"] = None, - velocity: Annotated[Any, "[VFX] Event velocity [x,y,z] (array or JSON string)"] = None, + velocity: Annotated[Any, + "[VFX] Event velocity [x,y,z] (array or JSON string)"] = None, size: Annotated[Any, "[VFX] Event size (number or string)"] = None, lifetime: Annotated[Any, "[VFX] Event lifetime (number or string)"] = None, - play_rate: Annotated[Any, "[VFX] Playback speed multiplier (number or string)"] = None, + play_rate: Annotated[Any, + "[VFX] Playback speed multiplier (number or string)"] = None, seed: Annotated[Any, "[VFX] Random seed (integer or string)"] = None, - reset_seed_on_play: Annotated[Any, "[VFX] Reset seed on play (bool or string)"] = None, - + reset_seed_on_play: Annotated[Any, + "[VFX] Reset seed on play (bool or string)"] = None, + # === LINE/TRAIL RENDERER PARAMETERS === - positions: Annotated[Any, "[Line] Positions [[x,y,z], ...] (array or JSON string)"] = None, - position: Annotated[Any, "[Line/Trail] Single position [x,y,z] (array or JSON string)"] = None, + positions: Annotated[Any, + "[Line] Positions [[x,y,z], ...] (array or JSON string)"] = None, + position: Annotated[Any, + "[Line/Trail] Single position [x,y,z] (array or JSON string)"] = None, index: Annotated[Any, "[Line] Position index (integer or string)"] = None, - + # Width - width: Annotated[Any, "[Line/Trail] Uniform width (number or string)"] = None, - start_width: Annotated[Any, "[Line/Trail] Start width (number or string)"] = None, - end_width: Annotated[Any, "[Line/Trail] End width (number or string)"] = None, - width_curve: Annotated[Any, "[Line/Trail] Width curve (number or dict)"] = None, - width_multiplier: Annotated[Any, "[Line/Trail] Width multiplier (number or string)"] = None, - + width: Annotated[Any, + "[Line/Trail] Uniform width (number or string)"] = None, + start_width: Annotated[Any, + "[Line/Trail] Start width (number or string)"] = None, + end_width: Annotated[Any, + "[Line/Trail] End width (number or string)"] = None, + width_curve: Annotated[Any, + "[Line/Trail] Width curve (number or dict)"] = None, + width_multiplier: Annotated[Any, + "[Line/Trail] Width multiplier (number or string)"] = None, + # Color - color: Annotated[Any, "[Line/Trail/VFX] Color [r,g,b,a] (array or JSON string)"] = None, - start_color_line: Annotated[Any, "[Line/Trail] Start color (array or JSON string)"] = None, - end_color: Annotated[Any, "[Line/Trail] End color (array or JSON string)"] = None, - + color: Annotated[Any, + "[Line/Trail/VFX] Color [r,g,b,a] (array or JSON string)"] = None, + start_color_line: Annotated[Any, + "[Line/Trail] Start color (array or JSON string)"] = None, + end_color: Annotated[Any, + "[Line/Trail] End color (array or JSON string)"] = None, + # Material & properties - material_path: Annotated[str | None, "[Particle/Line/Trail] Material asset path"] = None, - trail_material_path: Annotated[str | None, "[Particle] Trail material asset path"] = None, - loop: Annotated[Any, "[Line] Connect end to start (bool or string)"] = None, - use_world_space: Annotated[Any, "[Line] Use world space (bool or string)"] = None, - num_corner_vertices: Annotated[Any, "[Line/Trail] Corner vertices (integer or string)"] = None, - num_cap_vertices: Annotated[Any, "[Line/Trail] Cap vertices (integer or string)"] = None, - alignment: Annotated[Literal["View", "Local", "TransformZ"] | None, "[Line/Trail] Alignment"] = None, - texture_mode: Annotated[Literal["Stretch", "Tile", "DistributePerSegment", "RepeatPerSegment"] | None, "[Line/Trail] Texture mode"] = None, - generate_lighting_data: Annotated[Any, "[Line/Trail] Generate lighting data for GI (bool or string)"] = None, - sorting_order: Annotated[Any, "[Line/Trail/Particle] Sorting order (integer or string)"] = None, - sorting_layer_name: Annotated[str | None, "[Renderer] Sorting layer name"] = None, - sorting_layer_id: Annotated[Any, "[Renderer] Sorting layer ID (integer or string)"] = None, - render_mode: Annotated[str | None, "[Particle] Render mode (Billboard, Stretch, HorizontalBillboard, VerticalBillboard, Mesh, None)"] = None, - sort_mode: Annotated[str | None, "[Particle] Sort mode (None, Distance, OldestInFront, YoungestInFront, Depth)"] = None, - + material_path: Annotated[str | None, + "[Particle/Line/Trail] Material asset path"] = None, + trail_material_path: Annotated[str | None, + "[Particle] Trail material asset path"] = None, + loop: Annotated[Any, + "[Line] Connect end to start (bool or string)"] = None, + use_world_space: Annotated[Any, + "[Line] Use world space (bool or string)"] = None, + num_corner_vertices: Annotated[Any, + "[Line/Trail] Corner vertices (integer or string)"] = None, + num_cap_vertices: Annotated[Any, + "[Line/Trail] Cap vertices (integer or string)"] = None, + alignment: Annotated[Literal["View", "Local", "TransformZ"] + | None, "[Line/Trail] Alignment"] = None, + texture_mode: Annotated[Literal["Stretch", "Tile", "DistributePerSegment", + "RepeatPerSegment"] | None, "[Line/Trail] Texture mode"] = None, + generate_lighting_data: Annotated[Any, + "[Line/Trail] Generate lighting data for GI (bool or string)"] = None, + sorting_order: Annotated[Any, + "[Line/Trail/Particle] Sorting order (integer or string)"] = None, + sorting_layer_name: Annotated[str | None, + "[Renderer] Sorting layer name"] = None, + sorting_layer_id: Annotated[Any, + "[Renderer] Sorting layer ID (integer or string)"] = None, + render_mode: Annotated[str | None, + "[Particle] Render mode (Billboard, Stretch, HorizontalBillboard, VerticalBillboard, Mesh, None)"] = None, + sort_mode: Annotated[str | None, + "[Particle] Sort mode (None, Distance, OldestInFront, YoungestInFront, Depth)"] = None, + # === RENDERER COMMON PROPERTIES (Shadows, Lighting, Probes) === - shadow_casting_mode: Annotated[Literal["Off", "On", "TwoSided", "ShadowsOnly"] | None, "[Renderer] Shadow casting mode"] = None, - receive_shadows: Annotated[Any, "[Renderer] Receive shadows (bool or string)"] = None, - shadow_bias: Annotated[Any, "[Renderer] Shadow bias (number or string)"] = None, - light_probe_usage: Annotated[Literal["Off", "BlendProbes", "UseProxyVolume", "CustomProvided"] | None, "[Renderer] Light probe usage mode"] = None, - reflection_probe_usage: Annotated[Literal["Off", "BlendProbes", "BlendProbesAndSkybox", "Simple"] | None, "[Renderer] Reflection probe usage mode"] = None, - motion_vector_generation_mode: Annotated[Literal["Camera", "Object", "ForceNoMotion"] | None, "[Renderer] Motion vector generation mode"] = None, - rendering_layer_mask: Annotated[Any, "[Renderer] Rendering layer mask for SRP (integer or string)"] = None, - + shadow_casting_mode: Annotated[Literal["Off", "On", "TwoSided", + "ShadowsOnly"] | None, "[Renderer] Shadow casting mode"] = None, + receive_shadows: Annotated[Any, + "[Renderer] Receive shadows (bool or string)"] = None, + shadow_bias: Annotated[Any, + "[Renderer] Shadow bias (number or string)"] = None, + light_probe_usage: Annotated[Literal["Off", "BlendProbes", "UseProxyVolume", + "CustomProvided"] | None, "[Renderer] Light probe usage mode"] = None, + reflection_probe_usage: Annotated[Literal["Off", "BlendProbes", "BlendProbesAndSkybox", + "Simple"] | None, "[Renderer] Reflection probe usage mode"] = None, + motion_vector_generation_mode: Annotated[Literal["Camera", "Object", + "ForceNoMotion"] | None, "[Renderer] Motion vector generation mode"] = None, + rendering_layer_mask: Annotated[Any, + "[Renderer] Rendering layer mask for SRP (integer or string)"] = None, + # === PARTICLE RENDERER SPECIFIC === - min_particle_size: Annotated[Any, "[Particle] Min particle size relative to viewport (number or string)"] = None, - max_particle_size: Annotated[Any, "[Particle] Max particle size relative to viewport (number or string)"] = None, - length_scale: Annotated[Any, "[Particle] Length scale for stretched billboard (number or string)"] = None, - velocity_scale: Annotated[Any, "[Particle] Velocity scale for stretched billboard (number or string)"] = None, - camera_velocity_scale: Annotated[Any, "[Particle] Camera velocity scale for stretched billboard (number or string)"] = None, - normal_direction: Annotated[Any, "[Particle] Normal direction 0-1 (number or string)"] = None, - pivot: Annotated[Any, "[Particle] Pivot offset [x,y,z] (array or JSON string)"] = None, - flip: Annotated[Any, "[Particle] Flip [x,y,z] (array or JSON string)"] = None, - allow_roll: Annotated[Any, "[Particle] Allow roll for mesh particles (bool or string)"] = None, - + min_particle_size: Annotated[Any, + "[Particle] Min particle size relative to viewport (number or string)"] = None, + max_particle_size: Annotated[Any, + "[Particle] Max particle size relative to viewport (number or string)"] = None, + length_scale: Annotated[Any, + "[Particle] Length scale for stretched billboard (number or string)"] = None, + velocity_scale: Annotated[Any, + "[Particle] Velocity scale for stretched billboard (number or string)"] = None, + camera_velocity_scale: Annotated[Any, + "[Particle] Camera velocity scale for stretched billboard (number or string)"] = None, + normal_direction: Annotated[Any, + "[Particle] Normal direction 0-1 (number or string)"] = None, + pivot: Annotated[Any, + "[Particle] Pivot offset [x,y,z] (array or JSON string)"] = None, + flip: Annotated[Any, + "[Particle] Flip [x,y,z] (array or JSON string)"] = None, + allow_roll: Annotated[Any, + "[Particle] Allow roll for mesh particles (bool or string)"] = None, + # Shape creation (line_create_*) - start: Annotated[Any, "[Line] Start point [x,y,z] (array or JSON string)"] = None, - end: Annotated[Any, "[Line] End point [x,y,z] (array or JSON string)"] = None, - center: Annotated[Any, "[Line] Circle/arc center [x,y,z] (array or JSON string)"] = None, - segments: Annotated[Any, "[Line] Number of segments (integer or string)"] = None, - normal: Annotated[Any, "[Line] Normal direction [x,y,z] (array or JSON string)"] = None, - start_angle: Annotated[Any, "[Line] Arc start angle degrees (number or string)"] = None, - end_angle: Annotated[Any, "[Line] Arc end angle degrees (number or string)"] = None, - control_point1: Annotated[Any, "[Line] Bezier control point 1 (array or JSON string)"] = None, - control_point2: Annotated[Any, "[Line] Bezier control point 2 (cubic) (array or JSON string)"] = None, - + start: Annotated[Any, + "[Line] Start point [x,y,z] (array or JSON string)"] = None, + end: Annotated[Any, + "[Line] End point [x,y,z] (array or JSON string)"] = None, + center: Annotated[Any, + "[Line] Circle/arc center [x,y,z] (array or JSON string)"] = None, + segments: Annotated[Any, + "[Line] Number of segments (integer or string)"] = None, + normal: Annotated[Any, + "[Line] Normal direction [x,y,z] (array or JSON string)"] = None, + start_angle: Annotated[Any, + "[Line] Arc start angle degrees (number or string)"] = None, + end_angle: Annotated[Any, + "[Line] Arc end angle degrees (number or string)"] = None, + control_point1: Annotated[Any, + "[Line] Bezier control point 1 (array or JSON string)"] = None, + control_point2: Annotated[Any, + "[Line] Bezier control point 2 (cubic) (array or JSON string)"] = None, + # Trail specific - min_vertex_distance: Annotated[Any, "[Trail] Min vertex distance (number or string)"] = None, - autodestruct: Annotated[Any, "[Trail] Destroy when finished (bool or string)"] = None, + min_vertex_distance: Annotated[Any, + "[Trail] Min vertex distance (number or string)"] = None, + autodestruct: Annotated[Any, + "[Trail] Destroy when finished (bool or string)"] = None, emitting: Annotated[Any, "[Trail] Is emitting (bool or string)"] = None, - + # Common vector params for shape/velocity - x: Annotated[Any, "[Particle] Velocity X (number or MinMaxCurve dict)"] = None, - y: Annotated[Any, "[Particle] Velocity Y (number or MinMaxCurve dict)"] = None, - z: Annotated[Any, "[Particle] Velocity Z (number or MinMaxCurve dict)"] = None, - speed_modifier: Annotated[Any, "[Particle] Speed modifier (number or MinMaxCurve dict)"] = None, - space: Annotated[Literal["Local", "World"] | None, "[Particle] Velocity space"] = None, - separate_axes: Annotated[Any, "[Particle] Separate XYZ axes (bool or string)"] = None, - size_over_lifetime: Annotated[Any, "[Particle] Size over lifetime (number or MinMaxCurve dict)"] = None, - size_x: Annotated[Any, "[Particle] Size X (number or MinMaxCurve dict)"] = None, - size_y: Annotated[Any, "[Particle] Size Y (number or MinMaxCurve dict)"] = None, - size_z: Annotated[Any, "[Particle] Size Z (number or MinMaxCurve dict)"] = None, - + x: Annotated[Any, + "[Particle] Velocity X (number or MinMaxCurve dict)"] = None, + y: Annotated[Any, + "[Particle] Velocity Y (number or MinMaxCurve dict)"] = None, + z: Annotated[Any, + "[Particle] Velocity Z (number or MinMaxCurve dict)"] = None, + speed_modifier: Annotated[Any, + "[Particle] Speed modifier (number or MinMaxCurve dict)"] = None, + space: Annotated[Literal["Local", "World"] | + None, "[Particle] Velocity space"] = None, + separate_axes: Annotated[Any, + "[Particle] Separate XYZ axes (bool or string)"] = None, + size_over_lifetime: Annotated[Any, + "[Particle] Size over lifetime (number or MinMaxCurve dict)"] = None, + size_x: Annotated[Any, + "[Particle] Size X (number or MinMaxCurve dict)"] = None, + size_y: Annotated[Any, + "[Particle] Size Y (number or MinMaxCurve dict)"] = None, + size_z: Annotated[Any, + "[Particle] Size Z (number or MinMaxCurve dict)"] = None, + ) -> dict[str, Any]: """Unified VFX management tool.""" @@ -302,7 +412,8 @@ async def manage_vfx( # Validate action against known actions using normalized value if action_normalized not in ALL_ACTIONS: # Provide helpful error with closest matches by prefix - prefix = action_normalized.split("_")[0] + "_" if "_" in action_normalized else "" + prefix = action_normalized.split( + "_")[0] + "_" if "_" in action_normalized else "" available_by_prefix = { "particle_": PARTICLE_ACTIONS, "vfx_": VFX_ACTIONS, @@ -328,13 +439,13 @@ async def manage_vfx( # Build parameters dict with normalized action to stay consistent with Unity params_dict: dict[str, Any] = {"action": action_normalized} - + # Target if target is not None: params_dict["target"] = target if search_method is not None: params_dict["searchMethod"] = search_method - + # === PARTICLE SYSTEM === # Pass through all values - C# side handles parsing (ParseColor, ParseVector3, ParseMinMaxCurve, ToObject) if duration is not None: @@ -365,13 +476,13 @@ async def manage_vfx( params_dict["playOnAwake"] = play_on_awake if max_particles is not None: params_dict["maxParticles"] = max_particles - + # Emission if rate_over_time is not None: params_dict["rateOverTime"] = rate_over_time if rate_over_distance is not None: params_dict["rateOverDistance"] = rate_over_distance - + # Shape if shape_type is not None: params_dict["shapeType"] = shape_type @@ -383,7 +494,7 @@ async def manage_vfx( params_dict["angle"] = angle if arc is not None: params_dict["arc"] = arc - + # Noise if strength is not None: params_dict["strength"] = strength @@ -397,13 +508,13 @@ async def manage_vfx( params_dict["octaveCount"] = octave_count if quality is not None: params_dict["quality"] = quality - + # Module if module is not None: params_dict["module"] = module if enabled is not None: params_dict["enabled"] = enabled - + # Burst if time is not None: params_dict["time"] = time @@ -419,11 +530,11 @@ async def manage_vfx( params_dict["interval"] = interval if probability is not None: params_dict["probability"] = probability - + # Playback if with_children is not None: params_dict["withChildren"] = with_children - + # === VFX GRAPH === # Asset management parameters if asset_name is not None: @@ -440,7 +551,7 @@ async def manage_vfx( params_dict["folder"] = folder if search is not None: params_dict["search"] = search - + # Runtime parameters if parameter is not None: params_dict["parameter"] = parameter @@ -468,7 +579,7 @@ async def manage_vfx( params_dict["seed"] = seed if reset_seed_on_play is not None: params_dict["resetSeedOnPlay"] = reset_seed_on_play - + # === LINE/TRAIL RENDERER === if positions is not None: params_dict["positions"] = positions @@ -476,7 +587,7 @@ async def manage_vfx( params_dict["position"] = position if index is not None: params_dict["index"] = index - + # Width if width is not None: params_dict["width"] = width @@ -488,7 +599,7 @@ async def manage_vfx( params_dict["widthCurve"] = width_curve if width_multiplier is not None: params_dict["widthMultiplier"] = width_multiplier - + # Color if color is not None: params_dict["color"] = color @@ -496,7 +607,7 @@ async def manage_vfx( params_dict["startColor"] = start_color_line if end_color is not None: params_dict["endColor"] = end_color - + # Material & properties if material_path is not None: params_dict["materialPath"] = material_path @@ -526,7 +637,7 @@ async def manage_vfx( params_dict["renderMode"] = render_mode if sort_mode is not None: params_dict["sortMode"] = sort_mode - + # Renderer common properties (shadows, lighting, probes) if shadow_casting_mode is not None: params_dict["shadowCastingMode"] = shadow_casting_mode @@ -542,7 +653,7 @@ async def manage_vfx( params_dict["motionVectorGenerationMode"] = motion_vector_generation_mode if rendering_layer_mask is not None: params_dict["renderingLayerMask"] = rendering_layer_mask - + # Particle renderer specific if min_particle_size is not None: params_dict["minParticleSize"] = min_particle_size @@ -562,7 +673,7 @@ async def manage_vfx( params_dict["flip"] = flip if allow_roll is not None: params_dict["allowRoll"] = allow_roll - + # Shape creation if start is not None: params_dict["start"] = start @@ -582,7 +693,7 @@ async def manage_vfx( params_dict["controlPoint1"] = control_point1 if control_point2 is not None: params_dict["controlPoint2"] = control_point2 - + # Trail specific if min_vertex_distance is not None: params_dict["minVertexDistance"] = min_vertex_distance @@ -590,7 +701,7 @@ async def manage_vfx( params_dict["autodestruct"] = autodestruct if emitting is not None: params_dict["emitting"] = emitting - + # Velocity/size axes if x is not None: params_dict["x"] = x @@ -612,10 +723,10 @@ async def manage_vfx( params_dict["sizeY"] = size_y if size_z is not None: params_dict["sizeZ"] = size_z - + # Remove None values params_dict = {k: v for k, v in params_dict.items() if v is not None} - + # Send to Unity result = await send_with_unity_instance( async_send_command_with_retry, @@ -623,5 +734,5 @@ async def manage_vfx( "manage_vfx", params_dict, ) - + return result if isinstance(result, dict) else {"success": False, "message": str(result)} diff --git a/Server/src/services/tools/preflight.py b/Server/src/services/tools/preflight.py index 8b44365ab..b04e0de87 100644 --- a/Server/src/services/tools/preflight.py +++ b/Server/src/services/tools/preflight.py @@ -46,7 +46,8 @@ async def preflight( try: from services.resources.editor_state_v2 import get_editor_state_v2 state_resp = await get_editor_state_v2(ctx) - state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp + state = state_resp.model_dump() if hasattr( + state_resp, "model_dump") else state_resp except Exception: # If we cannot determine readiness, fall back to proceeding (tools already contain retry logic). return None @@ -80,9 +81,12 @@ async def preflight( if wait_for_no_compile: deadline = time.monotonic() + float(max_wait_s) while True: - compilation = data.get("compilation") if isinstance(data, dict) else None - is_compiling = isinstance(compilation, dict) and compilation.get("is_compiling") is True - is_domain_reload_pending = isinstance(compilation, dict) and compilation.get("is_domain_reload_pending") is True + compilation = data.get("compilation") if isinstance( + data, dict) else None + is_compiling = isinstance(compilation, dict) and compilation.get( + "is_compiling") is True + is_domain_reload_pending = isinstance(compilation, dict) and compilation.get( + "is_domain_reload_pending") is True if not is_compiling and not is_domain_reload_pending: break if time.monotonic() >= deadline: @@ -93,7 +97,8 @@ async def preflight( try: from services.resources.editor_state_v2 import get_editor_state_v2 state_resp = await get_editor_state_v2(ctx) - state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp + state = state_resp.model_dump() if hasattr( + state_resp, "model_dump") else state_resp data = state.get("data") if isinstance(state, dict) else None if not isinstance(data, dict): return None @@ -103,5 +108,3 @@ async def preflight( # Staleness: if the snapshot is stale, proceed (tools will still run), but callers that read resources can back off. # In future we may make this strict for some tools. return None - - diff --git a/Server/src/services/tools/read_console.py b/Server/src/services/tools/read_console.py index cd429cb1a..5f61362f9 100644 --- a/Server/src/services/tools/read_console.py +++ b/Server/src/services/tools/read_console.py @@ -37,8 +37,10 @@ async def read_console( filter_text: Annotated[str, "Text filter for messages"] | None = None, since_timestamp: Annotated[str, "Get messages after this timestamp (ISO 8601)"] | None = None, - page_size: Annotated[int | str, "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None, - cursor: Annotated[int | str, "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None, + page_size: Annotated[int | str, + "Page size for paginated console reads. Defaults to 50 when omitted."] | None = None, + cursor: Annotated[int | str, + "Opaque cursor for paging (0-based offset). Defaults to 0."] | None = None, format: Annotated[Literal['plain', 'detailed', 'json'], "Output format"] | None = None, include_stacktrace: Annotated[bool | str, diff --git a/Server/src/services/tools/refresh_unity.py b/Server/src/services/tools/refresh_unity.py index 623cd9a2b..b596a181f 100644 --- a/Server/src/services/tools/refresh_unity.py +++ b/Server/src/services/tools/refresh_unity.py @@ -25,9 +25,12 @@ async def refresh_unity( ctx: Context, mode: Annotated[Literal["if_dirty", "force"], "Refresh mode"] = "if_dirty", - scope: Annotated[Literal["assets", "scripts", "all"], "Refresh scope"] = "all", - compile: Annotated[Literal["none", "request"], "Whether to request compilation"] = "none", - wait_for_ready: Annotated[bool, "If true, wait until editor_state.advice.ready_for_tools is true"] = True, + scope: Annotated[Literal["assets", "scripts", "all"], + "Refresh scope"] = "all", + compile: Annotated[Literal["none", "request"], + "Whether to request compilation"] = "none", + wait_for_ready: Annotated[bool, + "If true, wait until editor_state.advice.ready_for_tools is true"] = True, ) -> MCPResponse | dict[str, Any]: unity_instance = get_unity_instance_from_context(ctx) @@ -53,7 +56,8 @@ async def refresh_unity( hint = response.get("hint") err = (response.get("error") or response.get("message") or "") reason = _extract_response_reason(response) - is_retryable = (hint == "retry") or ("disconnected" in str(err).lower()) + is_retryable = (hint == "retry") or ( + "disconnected" in str(err).lower()) if (not wait_for_ready) or (not is_retryable): return MCPResponse(**response) if reason not in {"reloading", "no_unity_session"}: @@ -68,9 +72,12 @@ async def refresh_unity( while time.monotonic() - start < timeout_s: state_resp = await get_editor_state_v2(ctx) - state = state_resp.model_dump() if hasattr(state_resp, "model_dump") else state_resp - data = (state or {}).get("data") if isinstance(state, dict) else None - advice = (data or {}).get("advice") if isinstance(data, dict) else None + state = state_resp.model_dump() if hasattr( + state_resp, "model_dump") else state_resp + data = (state or {}).get("data") if isinstance( + state, dict) else None + advice = (data or {}).get( + "advice") if isinstance(data, dict) else None if isinstance(advice, dict) and advice.get("ready_for_tools") is True: break await asyncio.sleep(0.25) diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 542fb6ff2..0a2ab0ed4 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -52,14 +52,22 @@ class RunTestsResponse(MCPResponse): ) async def run_tests( ctx: Context, - mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode", - timeout_seconds: Annotated[int | str, "Optional timeout in seconds for the test run"] | None = None, - test_names: Annotated[list[str] | str, "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None, - group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None, - category_names: Annotated[list[str] | str, "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None, - assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None, - include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False, - include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, + mode: Annotated[Literal["EditMode", "PlayMode"], + "Unity test mode to run"] = "EditMode", + timeout_seconds: Annotated[int | str, + "Optional timeout in seconds for the test run"] | None = None, + test_names: Annotated[list[str] | str, + "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None, + group_names: Annotated[list[str] | str, + "Same as test_names, except it allows for Regex"] | None = None, + category_names: Annotated[list[str] | str, + "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None, + assembly_names: Annotated[list[str] | str, + "Assembly names to filter tests by"] | None = None, + include_failed_tests: Annotated[bool, + "Include details for failed/skipped tests only (default: false)"] = False, + include_details: Annotated[bool, + "Include details for all tests (default: false)"] = False, ) -> RunTestsResponse | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) diff --git a/Server/src/services/tools/script_apply_edits.py b/Server/src/services/tools/script_apply_edits.py index ac1d09ec4..605ba3e4b 100644 --- a/Server/src/services/tools/script_apply_edits.py +++ b/Server/src/services/tools/script_apply_edits.py @@ -380,7 +380,7 @@ async def script_apply_edits( unity_instance = get_unity_instance_from_context(ctx) await ctx.info( f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})") - + # Parse edits if they came as a stringified JSON edits = parse_json_payload(edits) if not isinstance(edits, list): diff --git a/Server/src/services/tools/set_active_instance.py b/Server/src/services/tools/set_active_instance.py index cb20b3107..71809f463 100644 --- a/Server/src/services/tools/set_active_instance.py +++ b/Server/src/services/tools/set_active_instance.py @@ -65,7 +65,7 @@ async def set_active_instance( return { "success": False, "error": f"Instance '{value}' not found. " - "Use unity://instances to copy an exact Name@hash." + "Use unity://instances to copy an exact Name@hash." } else: lookup = value.lower() @@ -80,7 +80,7 @@ async def set_active_instance( return { "success": False, "error": f"Instance hash '{value}' does not match any running Unity editors. " - "Use unity://instances to confirm the available hashes." + "Use unity://instances to confirm the available hashes." } if len(matches) > 1: matching_ids = ", ".join( @@ -89,10 +89,10 @@ async def set_active_instance( return { "success": False, "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). " - "Provide the full Name@hash from unity://instances." + "Provide the full Name@hash from unity://instances." } resolved = matches[0] - + if resolved is None: # Should be unreachable due to logic above, but satisfies static analysis return { @@ -106,7 +106,7 @@ async def set_active_instance( # The session key is an internal detail but useful for debugging response. middleware.set_active_instance(ctx, resolved.id) session_key = middleware.get_session_key(ctx) - + return { "success": True, "message": f"Active instance set to {resolved.id}", diff --git a/Server/src/services/tools/test_jobs.py b/Server/src/services/tools/test_jobs.py index f059b2e88..0800e3a17 100644 --- a/Server/src/services/tools/test_jobs.py +++ b/Server/src/services/tools/test_jobs.py @@ -23,13 +23,20 @@ ) async def run_tests_async( ctx: Context, - mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode", - test_names: Annotated[list[str] | str, "Full names of specific tests to run"] | None = None, - group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None, - category_names: Annotated[list[str] | str, "NUnit category names to filter by"] | None = None, - assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None, - include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False, - include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, + mode: Annotated[Literal["EditMode", "PlayMode"], + "Unity test mode to run"] = "EditMode", + test_names: Annotated[list[str] | str, + "Full names of specific tests to run"] | None = None, + group_names: Annotated[list[str] | str, + "Same as test_names, except it allows for Regex"] | None = None, + category_names: Annotated[list[str] | str, + "NUnit category names to filter by"] | None = None, + assembly_names: Annotated[list[str] | str, + "Assembly names to filter tests by"] | None = None, + include_failed_tests: Annotated[bool, + "Include details for failed/skipped tests only (default: false)"] = False, + include_details: Annotated[bool, + "Include details for all tests (default: false)"] = False, ) -> dict[str, Any] | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) @@ -83,8 +90,10 @@ def _coerce_string_list(value) -> list[str] | None: async def get_test_job( ctx: Context, job_id: Annotated[str, "Job id returned by run_tests_async"], - include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False, - include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, + include_failed_tests: Annotated[bool, + "Include details for failed/skipped tests only (default: false)"] = False, + include_details: Annotated[bool, + "Include details for all tests (default: false)"] = False, ) -> dict[str, Any] | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) @@ -103,5 +112,3 @@ async def get_test_job( if isinstance(response, dict) and not response.get("success", True): return MCPResponse(**response) return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() - - diff --git a/Server/src/services/tools/utils.py b/Server/src/services/tools/utils.py index 4d7ef907b..dd6e90d00 100644 --- a/Server/src/services/tools/utils.py +++ b/Server/src/services/tools/utils.py @@ -8,6 +8,7 @@ _TRUTHY = {"true", "1", "yes", "on"} _FALSY = {"false", "0", "no", "off"} + def coerce_bool(value: Any, default: bool | None = None) -> bool | None: """Attempt to coerce a loosely-typed value to a boolean.""" if value is None: @@ -27,26 +28,26 @@ def coerce_bool(value: Any, default: bool | None = None) -> bool | None: def parse_json_payload(value: Any) -> Any: """ Attempt to parse a value that might be a JSON string into its native object. - + This is a tolerant parser used to handle cases where MCP clients or LLMs serialize complex objects (lists, dicts) into strings. It also handles scalar values like numbers, booleans, and null. - + Args: value: The input value (can be str, list, dict, etc.) - + Returns: The parsed JSON object/list if the input was a valid JSON string, or the original value if parsing failed or wasn't necessary. """ if not isinstance(value, str): return value - + val_trimmed = value.strip() - + # Fast path: if it doesn't look like JSON structure, return as is if not ( - (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or + (val_trimmed.startswith("{") and val_trimmed.endswith("}")) or (val_trimmed.startswith("[") and val_trimmed.endswith("]")) or val_trimmed in ("true", "false", "null") or (val_trimmed.replace(".", "", 1).replace("-", "", 1).isdigit()) @@ -93,37 +94,38 @@ def coerce_float(value: Any, default: float | None = None) -> float | None: return float(s) except (TypeError, ValueError): return default - + + def normalize_properties(value: Any) -> tuple[dict[str, Any] | None, str | None]: """ Robustly normalize a properties parameter to a dict. - + Handles various input formats from MCP clients/LLMs: - None -> (None, None) - dict -> (dict, None) - JSON string -> (parsed_dict, None) or (None, error_message) - Invalid values -> (None, error_message) - + Returns: Tuple of (parsed_dict, error_message). If error_message is set, parsed_dict is None. """ if value is None: return None, None - + # Already a dict - return as-is if isinstance(value, dict): return value, None - + # Try parsing as string if isinstance(value, str): # Check for obviously invalid values from serialization bugs if value in ("[object Object]", "undefined", "null", ""): return None, f"properties received invalid value: '{value}'. Expected a JSON object like {{\"key\": value}}" - + parsed = parse_json_payload(value) if isinstance(parsed, dict): return parsed, None - + return None, f"properties must be a JSON object (dict), got string that parsed to {type(parsed).__name__}" - + return None, f"properties must be a dict or JSON string, got {type(value).__name__}" diff --git a/Server/src/transport/legacy/port_discovery.py b/Server/src/transport/legacy/port_discovery.py index a3b50bd75..85a86a69a 100644 --- a/Server/src/transport/legacy/port_discovery.py +++ b/Server/src/transport/legacy/port_discovery.py @@ -279,9 +279,9 @@ def discover_all_unity_instances() -> list[UnityInstanceInfo]: if freshness.tzinfo: from datetime import timezone now = datetime.now(timezone.utc) - + age_s = (now - freshness).total_seconds() - + if is_reloading and age_s < 60: pass # keep it, status="reloading" else: diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 86cdea8a7..4cff37cc5 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -48,7 +48,8 @@ class PluginHub(WebSocketEndpoint): # Fast-path commands should never block the client for long; return a retry hint instead. # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading # or is throttled while unfocused. - _FAST_FAIL_COMMANDS: set[str] = {"read_console", "get_editor_state", "ping"} + _FAST_FAIL_COMMANDS: set[str] = { + "read_console", "get_editor_state", "ping"} _registry: PluginRegistry | None = None _connections: dict[str, WebSocket] = {} @@ -118,7 +119,8 @@ async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None: ] for command_id in pending_ids: entry = cls._pending.get(command_id) - future = entry.get("future") if isinstance(entry, dict) else None + future = entry.get("future") if isinstance( + entry, dict) else None if future and not future.done(): future.set_exception( PluginDisconnectedError( @@ -145,7 +147,8 @@ async def send_command(cls, session_id: str, command_type: str, params: dict[str server_wait_s = float(cls.COMMAND_TIMEOUT) if command_type in cls._FAST_FAIL_COMMANDS: try: - fast_timeout = float(os.environ.get("UNITY_MCP_FAST_COMMAND_TIMEOUT", "3")) + fast_timeout = float(os.environ.get( + "UNITY_MCP_FAST_COMMAND_TIMEOUT", "3")) except Exception: fast_timeout = 3.0 unity_timeout_s = fast_timeout @@ -180,7 +183,8 @@ async def send_command(cls, session_id: str, command_type: str, params: dict[str if command_id in cls._pending: raise RuntimeError( f"Duplicate command id generated: {command_id}") - cls._pending[command_id] = {"future": future, "session_id": session_id} + cls._pending[command_id] = { + "future": future, "session_id": session_id} try: msg = ExecuteCommandMessage( @@ -370,7 +374,8 @@ async def _resolve_session_id(cls, unity_instance: str | None) -> str: max_wait_s = float( os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0")) except ValueError as e: - raw_val = os.environ.get("UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0") + raw_val = os.environ.get( + "UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S", "2.0") logger.warning( "Invalid UNITY_MCP_SESSION_RESOLVE_MAX_WAIT_S=%r, using default 2.0: %s", raw_val, e) @@ -448,7 +453,8 @@ async def _try_once() -> tuple[str | None, int]: ) # At this point we've given the plugin ample time to reconnect; surface # a clear error so the client can prompt the user to open Unity. - raise NoUnitySessionError("No Unity plugins are currently connected") + raise NoUnitySessionError( + "No Unity plugins are currently connected") return session_id @@ -481,9 +487,11 @@ async def send_command_for_instance( # register_tools (which can be delayed by EditorApplication.delayCall). if command_type in cls._FAST_FAIL_COMMANDS and command_type != "ping": try: - max_wait_s = float(os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")) + max_wait_s = float(os.environ.get( + "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6")) except ValueError as e: - raw_val = os.environ.get("UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6") + raw_val = os.environ.get( + "UNITY_MCP_SESSION_READY_WAIT_SECONDS", "6") logger.warning( "Invalid UNITY_MCP_SESSION_READY_WAIT_SECONDS=%r, using default 6.0: %s", raw_val, e) @@ -499,7 +507,8 @@ async def send_command_for_instance( # The Unity-side dispatcher responds with {status:"success", result:{message:"pong"}} if isinstance(probe, dict) and probe.get("status") == "success": - result = probe.get("result") if isinstance(probe.get("result"), dict) else {} + result = probe.get("result") if isinstance( + probe.get("result"), dict) else {} if result.get("message") == "pong": break await asyncio.sleep(0.1) diff --git a/Server/src/transport/unity_instance_middleware.py b/Server/src/transport/unity_instance_middleware.py index f9c8942e4..4866ad0b9 100644 --- a/Server/src/transport/unity_instance_middleware.py +++ b/Server/src/transport/unity_instance_middleware.py @@ -27,7 +27,7 @@ def get_unity_instance_middleware() -> 'UnityInstanceMiddleware': if _unity_instance_middleware is None: # Auto-initialize if not set (lazy singleton) to handle import order or test cases _unity_instance_middleware = UnityInstanceMiddleware() - + return _unity_instance_middleware @@ -102,7 +102,8 @@ async def _maybe_autoselect_instance(self, ctx) -> str | None: sessions = sessions_data.sessions or {} ids: list[str] = [] for session_info in sessions.values(): - project = getattr(session_info, "project", None) or "Unknown" + project = getattr( + session_info, "project", None) or "Unknown" hash_value = getattr(session_info, "hash", None) if hash_value: ids.append(f"{project}@{hash_value}") @@ -183,7 +184,7 @@ async def _inject_unity_instance(self, context: MiddlewareContext) -> None: # But for stdio transport (no PluginHub needed or maybe partially configured), # we should be careful not to clear instance just because PluginHub can't resolve it. # The 'active_instance' (Name@hash) might be valid for stdio even if PluginHub fails. - + session_id: str | None = None # Only validate via PluginHub if we are actually using HTTP transport # OR if we want to support hybrid mode. For now, let's be permissive. diff --git a/Server/src/transport/unity_transport.py b/Server/src/transport/unity_transport.py index ebbc52fc1..07d3d4b27 100644 --- a/Server/src/transport/unity_transport.py +++ b/Server/src/transport/unity_transport.py @@ -105,7 +105,8 @@ async def send_with_unity_instance( # Fail fast with a retry hint instead of hanging for COMMAND_TIMEOUT. # The client can decide whether retrying is appropriate for the command. return normalize_unity_response( - MCPResponse(success=False, error=err, hint="retry").model_dump() + MCPResponse(success=False, error=err, + hint="retry").model_dump() ) if unity_instance: From 69e62bb413de79978ff1563ed12bfcecbca7d5fe Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 11:42:51 -0400 Subject: [PATCH 03/32] Remove unused Python module --- Server/src/routes/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Server/src/routes/__init__.py diff --git a/Server/src/routes/__init__.py b/Server/src/routes/__init__.py deleted file mode 100644 index e69de29bb..000000000 From 7ee51f6c6ec4a8427a7d4cfe6f6371a01d98fa5c Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 12:07:10 -0400 Subject: [PATCH 04/32] Refactored VFX functionality into multiple files Tested everything, works like a charm --- MCPForUnity/Editor/Tools/ManageVFX.cs | 1702 ----------------- MCPForUnity/Editor/Tools/ManageVfx.meta | 8 + .../Editor/Tools/ManageVfx/LineCreate.cs | 130 ++ .../Editor/Tools/ManageVfx/LineCreate.cs.meta | 11 + .../Editor/Tools/ManageVfx/LineRead.cs | 52 + .../Editor/Tools/ManageVfx/LineRead.cs.meta | 11 + .../Editor/Tools/ManageVfx/LineWrite.cs | 139 ++ .../Editor/Tools/ManageVfx/LineWrite.cs.meta | 11 + .../Editor/Tools/ManageVfx/ManageVFX.cs | 781 ++++++++ .../Tools/{ => ManageVfx}/ManageVFX.cs.meta | 0 .../Editor/Tools/ManageVfx/ManageVfxCommon.cs | 22 + .../Tools/ManageVfx/ManageVfxCommon.cs.meta | 11 + .../Editor/Tools/ManageVfx/ParticleCommon.cs | 87 + .../Tools/ManageVfx/ParticleCommon.cs.meta | 11 + .../Editor/Tools/ManageVfx/ParticleControl.cs | 100 + .../Tools/ManageVfx/ParticleControl.cs.meta | 11 + .../Editor/Tools/ManageVfx/ParticleRead.cs | 75 + .../Tools/ManageVfx/ParticleRead.cs.meta | 11 + .../Editor/Tools/ManageVfx/ParticleWrite.cs | 217 +++ .../Tools/ManageVfx/ParticleWrite.cs.meta | 11 + .../Editor/Tools/ManageVfx/TrailControl.cs | 33 + .../Tools/ManageVfx/TrailControl.cs.meta | 11 + .../Editor/Tools/ManageVfx/TrailRead.cs | 49 + .../Editor/Tools/ManageVfx/TrailRead.cs.meta | 11 + .../Editor/Tools/ManageVfx/TrailWrite.cs | 90 + .../Editor/Tools/ManageVfx/TrailWrite.cs.meta | 11 + 26 files changed, 1904 insertions(+), 1702 deletions(-) delete mode 100644 MCPForUnity/Editor/Tools/ManageVFX.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs rename MCPForUnity/Editor/Tools/{ => ManageVfx}/ManageVFX.cs.meta (100%) create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs create mode 100644 MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVFX.cs b/MCPForUnity/Editor/Tools/ManageVFX.cs deleted file mode 100644 index c685b67f0..000000000 --- a/MCPForUnity/Editor/Tools/ManageVFX.cs +++ /dev/null @@ -1,1702 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Helpers; -using UnityEngine; -using UnityEditor; - -#if UNITY_VFX_GRAPH //Please enable the symbol in the project settings for VisualEffectGraph to work -using UnityEngine.VFX; -#endif - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Tool for managing Unity VFX components: - /// - ParticleSystem (legacy particle effects) - /// - Visual Effect Graph (modern GPU particles, currently only support HDRP, other SRPs may not work) - /// - LineRenderer (lines, bezier curves, shapes) - /// - TrailRenderer (motion trails) - /// - More to come based on demand and feedback! - /// - [McpForUnityTool("manage_vfx", AutoRegister = false)] - public static class ManageVFX - { - public static object HandleCommand(JObject @params) - { - string action = @params["action"]?.ToString(); - if (string.IsNullOrEmpty(action)) - { - return new { success = false, message = "Action is required" }; - } - - try - { - string actionLower = action.ToLowerInvariant(); - - // Route to appropriate handler based on action prefix - if (actionLower == "ping") - { - return new { success = true, tool = "manage_vfx", components = new[] { "ParticleSystem", "VisualEffect", "LineRenderer", "TrailRenderer" } }; - } - - // ParticleSystem actions (particle_*) - if (actionLower.StartsWith("particle_")) - { - return HandleParticleSystemAction(@params, actionLower.Substring(9)); - } - - // VFX Graph actions (vfx_*) - if (actionLower.StartsWith("vfx_")) - { - return HandleVFXGraphAction(@params, actionLower.Substring(4)); - } - - // LineRenderer actions (line_*) - if (actionLower.StartsWith("line_")) - { - return HandleLineRendererAction(@params, actionLower.Substring(5)); - } - - // TrailRenderer actions (trail_*) - if (actionLower.StartsWith("trail_")) - { - return HandleTrailRendererAction(@params, actionLower.Substring(6)); - } - - return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: particle_, vfx_, line_, or trail_" }; - } - catch (Exception ex) - { - return new { success = false, message = ex.Message, stackTrace = ex.StackTrace }; - } - } - - #region Common Helpers - - // Parsing delegates for use with RendererHelpers - private static Color ParseColor(JToken token) => VectorParsing.ParseColorOrDefault(token); - private static Vector3 ParseVector3(JToken token) => VectorParsing.ParseVector3OrDefault(token); - private static Vector4 ParseVector4(JToken token) => VectorParsing.ParseVector4OrDefault(token); - private static Gradient ParseGradient(JToken token) => VectorParsing.ParseGradientOrDefault(token); - private static AnimationCurve ParseAnimationCurve(JToken token, float defaultValue = 1f) - => VectorParsing.ParseAnimationCurveOrDefault(token, defaultValue); - - // Object resolution - delegates to ObjectResolver - private static GameObject FindTargetGameObject(JObject @params) - => ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString()); - private static Material FindMaterialByPath(string path) - => ObjectResolver.ResolveMaterial(path); - - #endregion - - // ==================== PARTICLE SYSTEM ==================== - #region ParticleSystem - - private static object HandleParticleSystemAction(JObject @params, string action) - { - switch (action) - { - case "get_info": return ParticleGetInfo(@params); - case "set_main": return ParticleSetMain(@params); - case "set_emission": return ParticleSetEmission(@params); - case "set_shape": return ParticleSetShape(@params); - case "set_color_over_lifetime": return ParticleSetColorOverLifetime(@params); - case "set_size_over_lifetime": return ParticleSetSizeOverLifetime(@params); - case "set_velocity_over_lifetime": return ParticleSetVelocityOverLifetime(@params); - case "set_noise": return ParticleSetNoise(@params); - case "set_renderer": return ParticleSetRenderer(@params); - case "enable_module": return ParticleEnableModule(@params); - case "play": return ParticleControl(@params, "play"); - case "stop": return ParticleControl(@params, "stop"); - case "pause": return ParticleControl(@params, "pause"); - case "restart": return ParticleControl(@params, "restart"); - case "clear": return ParticleControl(@params, "clear"); - case "add_burst": return ParticleAddBurst(@params); - case "clear_bursts": return ParticleClearBursts(@params); - default: - return new { success = false, message = $"Unknown particle action: {action}. Valid: get_info, set_main, set_emission, set_shape, set_color_over_lifetime, set_size_over_lifetime, set_velocity_over_lifetime, set_noise, set_renderer, enable_module, play, stop, pause, restart, clear, add_burst, clear_bursts" }; - } - } - - private static ParticleSystem FindParticleSystem(JObject @params) - { - GameObject go = FindTargetGameObject(@params); - return go?.GetComponent(); - } - - private static ParticleSystem.MinMaxCurve ParseMinMaxCurve(JToken token, float defaultValue = 1f) - { - if (token == null) - return new ParticleSystem.MinMaxCurve(defaultValue); - - if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) - { - return new ParticleSystem.MinMaxCurve(token.ToObject()); - } - - if (token is JObject obj) - { - string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "constant"; - - switch (mode) - { - case "constant": - float constant = obj["value"]?.ToObject() ?? defaultValue; - return new ParticleSystem.MinMaxCurve(constant); - - case "random_between_constants": - case "two_constants": - float min = obj["min"]?.ToObject() ?? 0f; - float max = obj["max"]?.ToObject() ?? 1f; - return new ParticleSystem.MinMaxCurve(min, max); - - case "curve": - AnimationCurve curve = ParseAnimationCurve(obj, defaultValue); - return new ParticleSystem.MinMaxCurve(obj["multiplier"]?.ToObject() ?? 1f, curve); - - default: - return new ParticleSystem.MinMaxCurve(defaultValue); - } - } - - return new ParticleSystem.MinMaxCurve(defaultValue); - } - - private static ParticleSystem.MinMaxGradient ParseMinMaxGradient(JToken token) - { - if (token == null) - return new ParticleSystem.MinMaxGradient(Color.white); - - if (token is JArray arr && arr.Count >= 3) - { - return new ParticleSystem.MinMaxGradient(ParseColor(arr)); - } - - if (token is JObject obj) - { - string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "color"; - - switch (mode) - { - case "color": - return new ParticleSystem.MinMaxGradient(ParseColor(obj["color"])); - - case "two_colors": - Color colorMin = ParseColor(obj["colorMin"]); - Color colorMax = ParseColor(obj["colorMax"]); - return new ParticleSystem.MinMaxGradient(colorMin, colorMax); - - case "gradient": - return new ParticleSystem.MinMaxGradient(ParseGradient(obj)); - - default: - return new ParticleSystem.MinMaxGradient(Color.white); - } - } - - return new ParticleSystem.MinMaxGradient(Color.white); - } - - private static object ParticleGetInfo(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) - { - return new { success = false, message = "ParticleSystem not found" }; - } - - var main = ps.main; - var emission = ps.emission; - var shape = ps.shape; - var renderer = ps.GetComponent(); - - return new - { - success = true, - data = new - { - gameObject = ps.gameObject.name, - isPlaying = ps.isPlaying, - isPaused = ps.isPaused, - particleCount = ps.particleCount, - main = new - { - duration = main.duration, - looping = main.loop, - startLifetime = main.startLifetime.constant, - startSpeed = main.startSpeed.constant, - startSize = main.startSize.constant, - gravityModifier = main.gravityModifier.constant, - simulationSpace = main.simulationSpace.ToString(), - maxParticles = main.maxParticles - }, - emission = new - { - enabled = emission.enabled, - rateOverTime = emission.rateOverTime.constant, - burstCount = emission.burstCount - }, - shape = new - { - enabled = shape.enabled, - shapeType = shape.shapeType.ToString(), - radius = shape.radius, - angle = shape.angle - }, - renderer = renderer != null ? new { - renderMode = renderer.renderMode.ToString(), - sortMode = renderer.sortMode.ToString(), - material = renderer.sharedMaterial?.name, - trailMaterial = renderer.trailMaterial?.name, - minParticleSize = renderer.minParticleSize, - maxParticleSize = renderer.maxParticleSize, - // Shadows & lighting - shadowCastingMode = renderer.shadowCastingMode.ToString(), - receiveShadows = renderer.receiveShadows, - lightProbeUsage = renderer.lightProbeUsage.ToString(), - reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(), - // Sorting - sortingOrder = renderer.sortingOrder, - sortingLayerName = renderer.sortingLayerName, - renderingLayerMask = renderer.renderingLayerMask - } : null - } - }; - } - - private static object ParticleSetMain(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Main"); - var main = ps.main; - var changes = new List(); - - if (@params["duration"] != null) { main.duration = @params["duration"].ToObject(); changes.Add("duration"); } - if (@params["looping"] != null) { main.loop = @params["looping"].ToObject(); changes.Add("looping"); } - if (@params["prewarm"] != null) { main.prewarm = @params["prewarm"].ToObject(); changes.Add("prewarm"); } - if (@params["startDelay"] != null) { main.startDelay = ParseMinMaxCurve(@params["startDelay"], 0f); changes.Add("startDelay"); } - if (@params["startLifetime"] != null) { main.startLifetime = ParseMinMaxCurve(@params["startLifetime"], 5f); changes.Add("startLifetime"); } - if (@params["startSpeed"] != null) { main.startSpeed = ParseMinMaxCurve(@params["startSpeed"], 5f); changes.Add("startSpeed"); } - if (@params["startSize"] != null) { main.startSize = ParseMinMaxCurve(@params["startSize"], 1f); changes.Add("startSize"); } - if (@params["startRotation"] != null) { main.startRotation = ParseMinMaxCurve(@params["startRotation"], 0f); changes.Add("startRotation"); } - if (@params["startColor"] != null) { main.startColor = ParseMinMaxGradient(@params["startColor"]); changes.Add("startColor"); } - if (@params["gravityModifier"] != null) { main.gravityModifier = ParseMinMaxCurve(@params["gravityModifier"], 0f); changes.Add("gravityModifier"); } - if (@params["simulationSpace"] != null && Enum.TryParse(@params["simulationSpace"].ToString(), true, out var simSpace)) { main.simulationSpace = simSpace; changes.Add("simulationSpace"); } - if (@params["scalingMode"] != null && Enum.TryParse(@params["scalingMode"].ToString(), true, out var scaleMode)) { main.scalingMode = scaleMode; changes.Add("scalingMode"); } - if (@params["playOnAwake"] != null) { main.playOnAwake = @params["playOnAwake"].ToObject(); changes.Add("playOnAwake"); } - if (@params["maxParticles"] != null) { main.maxParticles = @params["maxParticles"].ToObject(); changes.Add("maxParticles"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetEmission(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Emission"); - var emission = ps.emission; - var changes = new List(); - - if (@params["enabled"] != null) { emission.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } - if (@params["rateOverTime"] != null) { emission.rateOverTime = ParseMinMaxCurve(@params["rateOverTime"], 10f); changes.Add("rateOverTime"); } - if (@params["rateOverDistance"] != null) { emission.rateOverDistance = ParseMinMaxCurve(@params["rateOverDistance"], 0f); changes.Add("rateOverDistance"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated emission: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetShape(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Shape"); - var shape = ps.shape; - var changes = new List(); - - if (@params["enabled"] != null) { shape.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } - if (@params["shapeType"] != null && Enum.TryParse(@params["shapeType"].ToString(), true, out var shapeType)) { shape.shapeType = shapeType; changes.Add("shapeType"); } - if (@params["radius"] != null) { shape.radius = @params["radius"].ToObject(); changes.Add("radius"); } - if (@params["radiusThickness"] != null) { shape.radiusThickness = @params["radiusThickness"].ToObject(); changes.Add("radiusThickness"); } - if (@params["angle"] != null) { shape.angle = @params["angle"].ToObject(); changes.Add("angle"); } - if (@params["arc"] != null) { shape.arc = @params["arc"].ToObject(); changes.Add("arc"); } - if (@params["position"] != null) { shape.position = ParseVector3(@params["position"]); changes.Add("position"); } - if (@params["rotation"] != null) { shape.rotation = ParseVector3(@params["rotation"]); changes.Add("rotation"); } - if (@params["scale"] != null) { shape.scale = ParseVector3(@params["scale"]); changes.Add("scale"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated shape: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetColorOverLifetime(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Color Over Lifetime"); - var col = ps.colorOverLifetime; - var changes = new List(); - - if (@params["enabled"] != null) { col.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } - if (@params["color"] != null) { col.color = ParseMinMaxGradient(@params["color"]); changes.Add("color"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetSizeOverLifetime(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Size Over Lifetime"); - var sol = ps.sizeOverLifetime; - var changes = new List(); - - // Auto-enable module if size properties are being set (unless explicitly disabled) - bool hasSizeProperty = @params["size"] != null || @params["sizeX"] != null || - @params["sizeY"] != null || @params["sizeZ"] != null; - if (hasSizeProperty && @params["enabled"] == null && !sol.enabled) - { - sol.enabled = true; - changes.Add("enabled"); - } - else if (@params["enabled"] != null) - { - sol.enabled = @params["enabled"].ToObject(); - changes.Add("enabled"); - } - - if (@params["separateAxes"] != null) { sol.separateAxes = @params["separateAxes"].ToObject(); changes.Add("separateAxes"); } - if (@params["size"] != null) { sol.size = ParseMinMaxCurve(@params["size"], 1f); changes.Add("size"); } - if (@params["sizeX"] != null) { sol.x = ParseMinMaxCurve(@params["sizeX"], 1f); changes.Add("sizeX"); } - if (@params["sizeY"] != null) { sol.y = ParseMinMaxCurve(@params["sizeY"], 1f); changes.Add("sizeY"); } - if (@params["sizeZ"] != null) { sol.z = ParseMinMaxCurve(@params["sizeZ"], 1f); changes.Add("sizeZ"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetVelocityOverLifetime(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Velocity Over Lifetime"); - var vol = ps.velocityOverLifetime; - var changes = new List(); - - if (@params["enabled"] != null) { vol.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } - if (@params["space"] != null && Enum.TryParse(@params["space"].ToString(), true, out var space)) { vol.space = space; changes.Add("space"); } - if (@params["x"] != null) { vol.x = ParseMinMaxCurve(@params["x"], 0f); changes.Add("x"); } - if (@params["y"] != null) { vol.y = ParseMinMaxCurve(@params["y"], 0f); changes.Add("y"); } - if (@params["z"] != null) { vol.z = ParseMinMaxCurve(@params["z"], 0f); changes.Add("z"); } - if (@params["speedModifier"] != null) { vol.speedModifier = ParseMinMaxCurve(@params["speedModifier"], 1f); changes.Add("speedModifier"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetNoise(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Set ParticleSystem Noise"); - var noise = ps.noise; - var changes = new List(); - - if (@params["enabled"] != null) { noise.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } - if (@params["strength"] != null) { noise.strength = ParseMinMaxCurve(@params["strength"], 1f); changes.Add("strength"); } - if (@params["frequency"] != null) { noise.frequency = @params["frequency"].ToObject(); changes.Add("frequency"); } - if (@params["scrollSpeed"] != null) { noise.scrollSpeed = ParseMinMaxCurve(@params["scrollSpeed"], 0f); changes.Add("scrollSpeed"); } - if (@params["damping"] != null) { noise.damping = @params["damping"].ToObject(); changes.Add("damping"); } - if (@params["octaveCount"] != null) { noise.octaveCount = @params["octaveCount"].ToObject(); changes.Add("octaveCount"); } - if (@params["quality"] != null && Enum.TryParse(@params["quality"].ToString(), true, out var quality)) { noise.quality = quality; changes.Add("quality"); } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Updated noise: {string.Join(", ", changes)}" }; - } - - private static object ParticleSetRenderer(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - var renderer = ps.GetComponent(); - if (renderer == null) return new { success = false, message = "ParticleSystemRenderer not found" }; - - Undo.RecordObject(renderer, "Set ParticleSystem Renderer"); - var changes = new List(); - - // ParticleSystem-specific render modes - if (@params["renderMode"] != null && Enum.TryParse(@params["renderMode"].ToString(), true, out var renderMode)) { renderer.renderMode = renderMode; changes.Add("renderMode"); } - if (@params["sortMode"] != null && Enum.TryParse(@params["sortMode"].ToString(), true, out var sortMode)) { renderer.sortMode = sortMode; changes.Add("sortMode"); } - - // Particle size limits - if (@params["minParticleSize"] != null) { renderer.minParticleSize = @params["minParticleSize"].ToObject(); changes.Add("minParticleSize"); } - if (@params["maxParticleSize"] != null) { renderer.maxParticleSize = @params["maxParticleSize"].ToObject(); changes.Add("maxParticleSize"); } - - // Stretched billboard settings - if (@params["lengthScale"] != null) { renderer.lengthScale = @params["lengthScale"].ToObject(); changes.Add("lengthScale"); } - if (@params["velocityScale"] != null) { renderer.velocityScale = @params["velocityScale"].ToObject(); changes.Add("velocityScale"); } - if (@params["cameraVelocityScale"] != null) { renderer.cameraVelocityScale = @params["cameraVelocityScale"].ToObject(); changes.Add("cameraVelocityScale"); } - if (@params["normalDirection"] != null) { renderer.normalDirection = @params["normalDirection"].ToObject(); changes.Add("normalDirection"); } - - // Alignment and pivot - if (@params["alignment"] != null && Enum.TryParse(@params["alignment"].ToString(), true, out var alignment)) { renderer.alignment = alignment; changes.Add("alignment"); } - if (@params["pivot"] != null) { renderer.pivot = ParseVector3(@params["pivot"]); changes.Add("pivot"); } - if (@params["flip"] != null) { renderer.flip = ParseVector3(@params["flip"]); changes.Add("flip"); } - if (@params["allowRoll"] != null) { renderer.allowRoll = @params["allowRoll"].ToObject(); changes.Add("allowRoll"); } - - //special case for particle system renderer - if (@params["shadowBias"] != null) { renderer.shadowBias = @params["shadowBias"].ToObject(); changes.Add("shadowBias"); } - - // Common Renderer properties (shadows, lighting, probes, sorting) - RendererHelpers.ApplyCommonRendererProperties(renderer, @params, changes); - - // Material - if (@params["materialPath"] != null) - { - var findInst = new JObject { ["find"] = @params["materialPath"].ToString() }; - Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; - if (mat != null) { renderer.sharedMaterial = mat; changes.Add("material"); } - } - if (@params["trailMaterialPath"] != null) - { - var findInst = new JObject { ["find"] = @params["trailMaterialPath"].ToString() }; - Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; - if (mat != null) { renderer.trailMaterial = mat; changes.Add("trailMaterial"); } - } - - EditorUtility.SetDirty(renderer); - return new { success = true, message = $"Updated renderer: {string.Join(", ", changes)}" }; - } - - private static object ParticleEnableModule(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - string moduleName = @params["module"]?.ToString()?.ToLowerInvariant(); - bool enabled = @params["enabled"]?.ToObject() ?? true; - - if (string.IsNullOrEmpty(moduleName)) return new { success = false, message = "Module name required" }; - - Undo.RecordObject(ps, $"Toggle {moduleName}"); - - switch (moduleName.Replace("_", "")) - { - case "emission": var em = ps.emission; em.enabled = enabled; break; - case "shape": var sh = ps.shape; sh.enabled = enabled; break; - case "coloroverlifetime": var col = ps.colorOverLifetime; col.enabled = enabled; break; - case "sizeoverlifetime": var sol = ps.sizeOverLifetime; sol.enabled = enabled; break; - case "velocityoverlifetime": var vol = ps.velocityOverLifetime; vol.enabled = enabled; break; - case "noise": var n = ps.noise; n.enabled = enabled; break; - case "collision": var coll = ps.collision; coll.enabled = enabled; break; - case "trails": var tr = ps.trails; tr.enabled = enabled; break; - case "lights": var li = ps.lights; li.enabled = enabled; break; - default: return new { success = false, message = $"Unknown module: {moduleName}" }; - } - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Module '{moduleName}' {(enabled ? "enabled" : "disabled")}" }; - } - - private static object ParticleControl(JObject @params, string action) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - bool withChildren = @params["withChildren"]?.ToObject() ?? true; - - switch (action) - { - case "play": ps.Play(withChildren); break; - case "stop": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmitting); break; - case "pause": ps.Pause(withChildren); break; - case "restart": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(withChildren); break; - case "clear": ps.Clear(withChildren); break; - } - - return new { success = true, message = $"ParticleSystem {action}" }; - } - - private static object ParticleAddBurst(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Add Burst"); - var emission = ps.emission; - - float time = @params["time"]?.ToObject() ?? 0f; - short minCount = (short)(@params["minCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); - short maxCount = (short)(@params["maxCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); - int cycles = @params["cycles"]?.ToObject() ?? 1; - float interval = @params["interval"]?.ToObject() ?? 0.01f; - - var burst = new ParticleSystem.Burst(time, minCount, maxCount, cycles, interval); - burst.probability = @params["probability"]?.ToObject() ?? 1f; - - int idx = emission.burstCount; - var bursts = new ParticleSystem.Burst[idx + 1]; - emission.GetBursts(bursts); - bursts[idx] = burst; - emission.SetBursts(bursts); - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Added burst at t={time}", burstIndex = idx }; - } - - private static object ParticleClearBursts(JObject @params) - { - ParticleSystem ps = FindParticleSystem(@params); - if (ps == null) return new { success = false, message = "ParticleSystem not found" }; - - Undo.RecordObject(ps, "Clear Bursts"); - var emission = ps.emission; - int count = emission.burstCount; - emission.SetBursts(new ParticleSystem.Burst[0]); - - EditorUtility.SetDirty(ps); - return new { success = true, message = $"Cleared {count} bursts" }; - } - - #endregion - - // ==================== VFX GRAPH ==================== - #region VFX Graph - - private static object HandleVFXGraphAction(JObject @params, string action) - { -#if !UNITY_VFX_GRAPH - return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; -#else - switch (action) - { - // Asset management - case "create_asset": return VFXCreateAsset(@params); - case "assign_asset": return VFXAssignAsset(@params); - case "list_templates": return VFXListTemplates(@params); - case "list_assets": return VFXListAssets(@params); - - // Runtime parameter control - case "get_info": return VFXGetInfo(@params); - case "set_float": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetFloat(n, v)); - case "set_int": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetInt(n, v)); - case "set_bool": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetBool(n, v)); - case "set_vector2": return VFXSetVector(@params, 2); - case "set_vector3": return VFXSetVector(@params, 3); - case "set_vector4": return VFXSetVector(@params, 4); - case "set_color": return VFXSetColor(@params); - case "set_gradient": return VFXSetGradient(@params); - case "set_texture": return VFXSetTexture(@params); - case "set_mesh": return VFXSetMesh(@params); - case "set_curve": return VFXSetCurve(@params); - case "send_event": return VFXSendEvent(@params); - case "play": return VFXControl(@params, "play"); - case "stop": return VFXControl(@params, "stop"); - case "pause": return VFXControl(@params, "pause"); - case "reinit": return VFXControl(@params, "reinit"); - case "set_playback_speed": return VFXSetPlaybackSpeed(@params); - case "set_seed": return VFXSetSeed(@params); - default: - return new { success = false, message = $"Unknown vfx action: {action}. Valid: create_asset, assign_asset, list_templates, list_assets, get_info, set_float, set_int, set_bool, set_vector2/3/4, set_color, set_gradient, set_texture, set_mesh, set_curve, send_event, play, stop, pause, reinit, set_playback_speed, set_seed" }; - } -#endif - } - -#if UNITY_VFX_GRAPH - private static VisualEffect FindVisualEffect(JObject @params) - { - GameObject go = FindTargetGameObject(@params); - return go?.GetComponent(); - } - - /// - /// Creates a new VFX Graph asset file from a template - /// - private static object VFXCreateAsset(JObject @params) - { - string assetName = @params["assetName"]?.ToString(); - string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX"; - string template = @params["template"]?.ToString() ?? "empty"; - - if (string.IsNullOrEmpty(assetName)) - return new { success = false, message = "assetName is required" }; - - // Ensure folder exists - if (!AssetDatabase.IsValidFolder(folderPath)) - { - string[] folders = folderPath.Split('/'); - string currentPath = folders[0]; - for (int i = 1; i < folders.Length; i++) - { - string newPath = currentPath + "/" + folders[i]; - if (!AssetDatabase.IsValidFolder(newPath)) - { - AssetDatabase.CreateFolder(currentPath, folders[i]); - } - currentPath = newPath; - } - } - - string assetPath = $"{folderPath}/{assetName}.vfx"; - - // Check if asset already exists - if (AssetDatabase.LoadAssetAtPath(assetPath) != null) - { - bool overwrite = @params["overwrite"]?.ToObject() ?? false; - if (!overwrite) - return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." }; - AssetDatabase.DeleteAsset(assetPath); - } - - // Find and copy template - string templatePath = FindVFXTemplate(template); - UnityEngine.VFX.VisualEffectAsset newAsset = null; - - if (!string.IsNullOrEmpty(templatePath) && System.IO.File.Exists(templatePath)) - { - // templatePath is a full filesystem path, need to copy file directly - // Get the full destination path - string projectRoot = System.IO.Path.GetDirectoryName(Application.dataPath); - string fullDestPath = System.IO.Path.Combine(projectRoot, assetPath); - - // Ensure directory exists - string destDir = System.IO.Path.GetDirectoryName(fullDestPath); - if (!System.IO.Directory.Exists(destDir)) - System.IO.Directory.CreateDirectory(destDir); - - // Copy the file - System.IO.File.Copy(templatePath, fullDestPath, true); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - else - { - // Create empty VFX asset using reflection to access internal API - // Note: Develop in Progress, TODO:// Find authenticated way to create VFX asset - try - { - // Try to use VisualEffectAssetEditorUtility.CreateNewAsset if available - var utilityType = System.Type.GetType("UnityEditor.VFX.VisualEffectAssetEditorUtility, Unity.VisualEffectGraph.Editor"); - if (utilityType != null) - { - var createMethod = utilityType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static); - if (createMethod != null) - { - createMethod.Invoke(null, new object[] { assetPath }); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - - // Fallback: Create a ScriptableObject-based asset - if (newAsset == null) - { - // Try direct creation via internal constructor - var resourceType = System.Type.GetType("UnityEditor.VFX.VisualEffectResource, Unity.VisualEffectGraph.Editor"); - if (resourceType != null) - { - var createMethod = resourceType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); - if (createMethod != null) - { - var resource = createMethod.Invoke(null, new object[] { assetPath }); - AssetDatabase.Refresh(); - newAsset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - } - } - catch (Exception ex) - { - return new { success = false, message = $"Failed to create VFX asset: {ex.Message}" }; - } - } - - if (newAsset == null) - { - return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." }; - } - - return new - { - success = true, - message = $"Created VFX asset: {assetPath}", - data = new - { - assetPath = assetPath, - assetName = newAsset.name, - template = template - } - }; - } - - /// - /// Finds VFX template path by name - /// - private static string FindVFXTemplate(string templateName) - { - // Get the actual filesystem path for the VFX Graph package using PackageManager API - var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); - - var searchPaths = new List(); - - if (packageInfo != null) - { - // Use the resolved path from PackageManager (handles Library/PackageCache paths) - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); - } - - // Also search project-local paths - searchPaths.Add("Assets/VFX/Templates"); - - string[] templatePatterns = new[] - { - $"{templateName}.vfx", - $"VFX{templateName}.vfx", - $"Simple{templateName}.vfx", - $"{templateName}VFX.vfx" - }; - - foreach (string basePath in searchPaths) - { - if (!System.IO.Directory.Exists(basePath)) continue; - - foreach (string pattern in templatePatterns) - { - string[] files = System.IO.Directory.GetFiles(basePath, pattern, System.IO.SearchOption.AllDirectories); - if (files.Length > 0) - return files[0]; - } - - // Also search by partial match - try - { - string[] allVfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); - foreach (string file in allVfxFiles) - { - if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower())) - return file; - } - } - catch { } - } - - // Search in project assets - string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName); - if (guids.Length > 0) - { - return AssetDatabase.GUIDToAssetPath(guids[0]); - } - - return null; - } - - /// - /// Assigns a VFX asset to a VisualEffect component - /// - private static object VFXAssignAsset(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect component not found" }; - - string assetPath = @params["assetPath"]?.ToString(); - if (string.IsNullOrEmpty(assetPath)) - return new { success = false, message = "assetPath is required" }; - - // Normalize path - if (!assetPath.StartsWith("Assets/") && !assetPath.StartsWith("Packages/")) - assetPath = "Assets/" + assetPath; - if (!assetPath.EndsWith(".vfx")) - assetPath += ".vfx"; - - var asset = AssetDatabase.LoadAssetAtPath(assetPath); - if (asset == null) - { - // Try searching by name - string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath); - string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}"); - if (guids.Length > 0) - { - assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); - asset = AssetDatabase.LoadAssetAtPath(assetPath); - } - } - - if (asset == null) - return new { success = false, message = $"VFX asset not found: {assetPath}" }; - - Undo.RecordObject(vfx, "Assign VFX Asset"); - vfx.visualEffectAsset = asset; - EditorUtility.SetDirty(vfx); - - return new - { - success = true, - message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}", - data = new - { - gameObject = vfx.gameObject.name, - assetName = asset.name, - assetPath = assetPath - } - }; - } - - /// - /// Lists available VFX templates - /// - private static object VFXListTemplates(JObject @params) - { - var templates = new List(); - - // Get the actual filesystem path for the VFX Graph package using PackageManager API - var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); - - var searchPaths = new List(); - - if (packageInfo != null) - { - // Use the resolved path from PackageManager (handles Library/PackageCache paths) - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); - searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); - } - - // Also search project-local paths - searchPaths.Add("Assets/VFX/Templates"); - searchPaths.Add("Assets/VFX"); - - // Precompute normalized package path for comparison - string normalizedPackagePath = null; - if (packageInfo != null) - { - normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/"); - } - - // Precompute the Assets base path for converting absolute paths to project-relative - string assetsBasePath = Application.dataPath.Replace("\\", "/"); - - foreach (string basePath in searchPaths) - { - if (!System.IO.Directory.Exists(basePath)) continue; - - try - { - string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); - foreach (string file in vfxFiles) - { - string absolutePath = file.Replace("\\", "/"); - string name = System.IO.Path.GetFileNameWithoutExtension(file); - bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath); - - // Convert absolute path to project-relative path - string projectRelativePath; - if (isPackage) - { - // For package paths, convert to Packages/... format - projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length); - } - else if (absolutePath.StartsWith(assetsBasePath)) - { - // For project assets, convert to Assets/... format - projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length); - } - else - { - // Fallback: use the absolute path if we can't determine the relative path - projectRelativePath = absolutePath; - } - - templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" }); - } - } - catch { } - } - - // Also search project assets - string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset"); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - if (!templates.Any(t => ((dynamic)t).path == path)) - { - string name = System.IO.Path.GetFileNameWithoutExtension(path); - templates.Add(new { name = name, path = path, source = "project" }); - } - } - - return new - { - success = true, - data = new - { - count = templates.Count, - templates = templates - } - }; - } - - /// - /// Lists all VFX assets in the project - /// - private static object VFXListAssets(JObject @params) - { - string searchFolder = @params["folder"]?.ToString(); - string searchPattern = @params["search"]?.ToString(); - - string filter = "t:VisualEffectAsset"; - if (!string.IsNullOrEmpty(searchPattern)) - filter += " " + searchPattern; - - string[] guids; - if (!string.IsNullOrEmpty(searchFolder)) - guids = AssetDatabase.FindAssets(filter, new[] { searchFolder }); - else - guids = AssetDatabase.FindAssets(filter); - - var assets = new List(); - foreach (string guid in guids) - { - string path = AssetDatabase.GUIDToAssetPath(guid); - var asset = AssetDatabase.LoadAssetAtPath(path); - if (asset != null) - { - assets.Add(new - { - name = asset.name, - path = path, - guid = guid - }); - } - } - - return new - { - success = true, - data = new - { - count = assets.Count, - assets = assets - } - }; - } - - private static object VFXGetInfo(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - return new - { - success = true, - data = new - { - gameObject = vfx.gameObject.name, - assetName = vfx.visualEffectAsset?.name ?? "None", - aliveParticleCount = vfx.aliveParticleCount, - culled = vfx.culled, - pause = vfx.pause, - playRate = vfx.playRate, - startSeed = vfx.startSeed - } - }; - } - - private static object VFXSetParameter(JObject @params, Action setter) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - JToken valueToken = @params["value"]; - if (valueToken == null) return new { success = false, message = "Value required" }; - - Undo.RecordObject(vfx, $"Set VFX {param}"); - T value = valueToken.ToObject(); - setter(vfx, param, value); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set {param} = {value}" }; - } - - private static object VFXSetVector(JObject @params, int dims) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Vector4 vec = ParseVector4(@params["value"]); - Undo.RecordObject(vfx, $"Set VFX {param}"); - - switch (dims) - { - case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break; - case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break; - case 4: vfx.SetVector4(param, vec); break; - } - - EditorUtility.SetDirty(vfx); - return new { success = true, message = $"Set {param}" }; - } - - private static object VFXSetColor(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Color color = ParseColor(@params["value"]); - Undo.RecordObject(vfx, $"Set VFX Color {param}"); - vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a)); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set color {param}" }; - } - - private static object VFXSetGradient(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - Gradient gradient = ParseGradient(@params["gradient"]); - Undo.RecordObject(vfx, $"Set VFX Gradient {param}"); - vfx.SetGradient(param, gradient); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set gradient {param}" }; - } - - private static object VFXSetTexture(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - string path = @params["texturePath"]?.ToString(); - if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and texturePath required" }; - - var findInst = new JObject { ["find"] = path }; - Texture tex = ManageGameObject.FindObjectByInstruction(findInst, typeof(Texture)) as Texture; - if (tex == null) return new { success = false, message = $"Texture not found: {path}" }; - - Undo.RecordObject(vfx, $"Set VFX Texture {param}"); - vfx.SetTexture(param, tex); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set texture {param} = {tex.name}" }; - } - - private static object VFXSetMesh(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - string path = @params["meshPath"]?.ToString(); - if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and meshPath required" }; - - var findInst = new JObject { ["find"] = path }; - Mesh mesh = ManageGameObject.FindObjectByInstruction(findInst, typeof(Mesh)) as Mesh; - if (mesh == null) return new { success = false, message = $"Mesh not found: {path}" }; - - Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); - vfx.SetMesh(param, mesh); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set mesh {param} = {mesh.name}" }; - } - - private static object VFXSetCurve(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string param = @params["parameter"]?.ToString(); - if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; - - AnimationCurve curve = ParseAnimationCurve(@params["curve"], 1f); - Undo.RecordObject(vfx, $"Set VFX Curve {param}"); - vfx.SetAnimationCurve(param, curve); - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set curve {param}" }; - } - - private static object VFXSendEvent(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - string eventName = @params["eventName"]?.ToString(); - if (string.IsNullOrEmpty(eventName)) return new { success = false, message = "Event name required" }; - - VFXEventAttribute attr = vfx.CreateVFXEventAttribute(); - if (@params["position"] != null) attr.SetVector3("position", ParseVector3(@params["position"])); - if (@params["velocity"] != null) attr.SetVector3("velocity", ParseVector3(@params["velocity"])); - if (@params["color"] != null) { var c = ParseColor(@params["color"]); attr.SetVector3("color", new Vector3(c.r, c.g, c.b)); } - if (@params["size"] != null) attr.SetFloat("size", @params["size"].ToObject()); - if (@params["lifetime"] != null) attr.SetFloat("lifetime", @params["lifetime"].ToObject()); - - vfx.SendEvent(eventName, attr); - return new { success = true, message = $"Sent event '{eventName}'" }; - } - - private static object VFXControl(JObject @params, string action) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - switch (action) - { - case "play": vfx.Play(); break; - case "stop": vfx.Stop(); break; - case "pause": vfx.pause = !vfx.pause; break; - case "reinit": vfx.Reinit(); break; - } - - return new { success = true, message = $"VFX {action}", isPaused = vfx.pause }; - } - - private static object VFXSetPlaybackSpeed(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - float rate = @params["playRate"]?.ToObject() ?? 1f; - Undo.RecordObject(vfx, "Set VFX Play Rate"); - vfx.playRate = rate; - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set play rate = {rate}" }; - } - - private static object VFXSetSeed(JObject @params) - { - VisualEffect vfx = FindVisualEffect(@params); - if (vfx == null) return new { success = false, message = "VisualEffect not found" }; - - uint seed = @params["seed"]?.ToObject() ?? 0; - bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject() ?? true; - - Undo.RecordObject(vfx, "Set VFX Seed"); - vfx.startSeed = seed; - vfx.resetSeedOnPlay = resetOnPlay; - EditorUtility.SetDirty(vfx); - - return new { success = true, message = $"Set seed = {seed}" }; - } -#endif - - #endregion - - // ==================== LINE RENDERER ==================== - #region LineRenderer - - private static object HandleLineRendererAction(JObject @params, string action) - { - switch (action) - { - case "get_info": return LineGetInfo(@params); - case "set_positions": return LineSetPositions(@params); - case "add_position": return LineAddPosition(@params); - case "set_position": return LineSetPosition(@params); - case "set_width": return LineSetWidth(@params); - case "set_color": return LineSetColor(@params); - case "set_material": return LineSetMaterial(@params); - case "set_properties": return LineSetProperties(@params); - case "clear": return LineClear(@params); - case "create_line": return LineCreateLine(@params); - case "create_circle": return LineCreateCircle(@params); - case "create_arc": return LineCreateArc(@params); - case "create_bezier": return LineCreateBezier(@params); - default: - return new { success = false, message = $"Unknown line action: {action}. Valid: get_info, set_positions, add_position, set_position, set_width, set_color, set_material, set_properties, clear, create_line, create_circle, create_arc, create_bezier" }; - } - } - - private static LineRenderer FindLineRenderer(JObject @params) - { - GameObject go = FindTargetGameObject(@params); - return go?.GetComponent(); - } - - private static object LineGetInfo(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - var positions = new Vector3[lr.positionCount]; - lr.GetPositions(positions); - - return new - { - success = true, - data = new - { - gameObject = lr.gameObject.name, - positionCount = lr.positionCount, - positions = positions.Select(p => new { x = p.x, y = p.y, z = p.z }).ToArray(), - startWidth = lr.startWidth, - endWidth = lr.endWidth, - loop = lr.loop, - useWorldSpace = lr.useWorldSpace, - alignment = lr.alignment.ToString(), - textureMode = lr.textureMode.ToString(), - numCornerVertices = lr.numCornerVertices, - numCapVertices = lr.numCapVertices, - generateLightingData = lr.generateLightingData, - material = lr.sharedMaterial?.name, - // Shadows & lighting - shadowCastingMode = lr.shadowCastingMode.ToString(), - receiveShadows = lr.receiveShadows, - lightProbeUsage = lr.lightProbeUsage.ToString(), - reflectionProbeUsage = lr.reflectionProbeUsage.ToString(), - // Sorting - sortingOrder = lr.sortingOrder, - sortingLayerName = lr.sortingLayerName, - renderingLayerMask = lr.renderingLayerMask - } - }; - } - - private static object LineSetPositions(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - JArray posArr = @params["positions"] as JArray; - if (posArr == null) return new { success = false, message = "Positions array required" }; - - var positions = posArr.Select(p => ParseVector3(p)).ToArray(); - - Undo.RecordObject(lr, "Set Line Positions"); - lr.positionCount = positions.Length; - lr.SetPositions(positions); - EditorUtility.SetDirty(lr); - - return new { success = true, message = $"Set {positions.Length} positions" }; - } - - private static object LineAddPosition(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Vector3 pos = ParseVector3(@params["position"]); - - Undo.RecordObject(lr, "Add Line Position"); - int idx = lr.positionCount; - lr.positionCount = idx + 1; - lr.SetPosition(idx, pos); - EditorUtility.SetDirty(lr); - - return new { success = true, message = $"Added position at index {idx}", index = idx }; - } - - private static object LineSetPosition(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - int index = @params["index"]?.ToObject() ?? -1; - if (index < 0 || index >= lr.positionCount) return new { success = false, message = $"Invalid index {index}" }; - - Vector3 pos = ParseVector3(@params["position"]); - - Undo.RecordObject(lr, "Set Line Position"); - lr.SetPosition(index, pos); - EditorUtility.SetDirty(lr); - - return new { success = true, message = $"Set position at index {index}" }; - } - - private static object LineSetWidth(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Undo.RecordObject(lr, "Set Line Width"); - var changes = new List(); - - RendererHelpers.ApplyWidthProperties(@params, changes, - v => lr.startWidth = v, v => lr.endWidth = v, - v => lr.widthCurve = v, v => lr.widthMultiplier = v, - ParseAnimationCurve); - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object LineSetColor(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Undo.RecordObject(lr, "Set Line Color"); - var changes = new List(); - - RendererHelpers.ApplyColorProperties(@params, changes, - v => lr.startColor = v, v => lr.endColor = v, - v => lr.colorGradient = v, - ParseColor, ParseGradient, fadeEndAlpha: false); - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object LineSetMaterial(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - return RendererHelpers.SetRendererMaterial(lr, @params, "Set Line Material", FindMaterialByPath); - } - - private static object LineSetProperties(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Undo.RecordObject(lr, "Set Line Properties"); - var changes = new List(); - - // Line-specific properties - RendererHelpers.ApplyLineTrailProperties(@params, changes, - v => lr.loop = v, v => lr.useWorldSpace = v, - v => lr.numCornerVertices = v, v => lr.numCapVertices = v, - v => lr.alignment = v, v => lr.textureMode = v, - v => lr.generateLightingData = v); - - // Common Renderer properties (shadows, lighting, probes, sorting) - RendererHelpers.ApplyCommonRendererProperties(lr, @params, changes); - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object LineClear(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - int count = lr.positionCount; - Undo.RecordObject(lr, "Clear Line"); - lr.positionCount = 0; - EditorUtility.SetDirty(lr); - - return new { success = true, message = $"Cleared {count} positions" }; - } - - private static object LineCreateLine(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Vector3 start = ParseVector3(@params["start"]); - Vector3 end = ParseVector3(@params["end"]); - - Undo.RecordObject(lr, "Create Line"); - lr.positionCount = 2; - lr.SetPosition(0, start); - lr.SetPosition(1, end); - EditorUtility.SetDirty(lr); - - return new { success = true, message = "Created line" }; - } - - private static object LineCreateCircle(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Vector3 center = ParseVector3(@params["center"]); - float radius = @params["radius"]?.ToObject() ?? 1f; - int segments = @params["segments"]?.ToObject() ?? 32; - Vector3 normal = @params["normal"] != null ? ParseVector3(@params["normal"]).normalized : Vector3.up; - - Vector3 right = Vector3.Cross(normal, Vector3.forward); - if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); - right = right.normalized; - Vector3 forward = Vector3.Cross(right, normal).normalized; - - Undo.RecordObject(lr, "Create Circle"); - lr.positionCount = segments; - lr.loop = true; - - for (int i = 0; i < segments; i++) - { - float angle = (float)i / segments * Mathf.PI * 2f; - Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; - lr.SetPosition(i, point); - } - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Created circle with {segments} segments" }; - } - - private static object LineCreateArc(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Vector3 center = ParseVector3(@params["center"]); - float radius = @params["radius"]?.ToObject() ?? 1f; - float startAngle = (@params["startAngle"]?.ToObject() ?? 0f) * Mathf.Deg2Rad; - float endAngle = (@params["endAngle"]?.ToObject() ?? 180f) * Mathf.Deg2Rad; - int segments = @params["segments"]?.ToObject() ?? 16; - Vector3 normal = @params["normal"] != null ? ParseVector3(@params["normal"]).normalized : Vector3.up; - - Vector3 right = Vector3.Cross(normal, Vector3.forward); - if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); - right = right.normalized; - Vector3 forward = Vector3.Cross(right, normal).normalized; - - Undo.RecordObject(lr, "Create Arc"); - lr.positionCount = segments + 1; - lr.loop = false; - - for (int i = 0; i <= segments; i++) - { - float t = (float)i / segments; - float angle = Mathf.Lerp(startAngle, endAngle, t); - Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; - lr.SetPosition(i, point); - } - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Created arc with {segments} segments" }; - } - - private static object LineCreateBezier(JObject @params) - { - LineRenderer lr = FindLineRenderer(@params); - if (lr == null) return new { success = false, message = "LineRenderer not found" }; - - Vector3 start = ParseVector3(@params["start"]); - Vector3 end = ParseVector3(@params["end"]); - Vector3 cp1 = ParseVector3(@params["controlPoint1"] ?? @params["control1"]); - Vector3 cp2 = @params["controlPoint2"] != null || @params["control2"] != null - ? ParseVector3(@params["controlPoint2"] ?? @params["control2"]) - : cp1; - int segments = @params["segments"]?.ToObject() ?? 32; - bool isQuadratic = @params["controlPoint2"] == null && @params["control2"] == null; - - Undo.RecordObject(lr, "Create Bezier"); - lr.positionCount = segments + 1; - lr.loop = false; - - for (int i = 0; i <= segments; i++) - { - float t = (float)i / segments; - Vector3 point; - - if (isQuadratic) - { - float u = 1 - t; - point = u * u * start + 2 * u * t * cp1 + t * t * end; - } - else - { - float u = 1 - t; - point = u * u * u * start + 3 * u * u * t * cp1 + 3 * u * t * t * cp2 + t * t * t * end; - } - - lr.SetPosition(i, point); - } - - EditorUtility.SetDirty(lr); - return new { success = true, message = $"Created {(isQuadratic ? "quadratic" : "cubic")} Bezier" }; - } - - #endregion - - // ==================== TRAIL RENDERER ==================== - #region TrailRenderer - - private static object HandleTrailRendererAction(JObject @params, string action) - { - switch (action) - { - case "get_info": return TrailGetInfo(@params); - case "set_time": return TrailSetTime(@params); - case "set_width": return TrailSetWidth(@params); - case "set_color": return TrailSetColor(@params); - case "set_material": return TrailSetMaterial(@params); - case "set_properties": return TrailSetProperties(@params); - case "clear": return TrailClear(@params); - case "emit": return TrailEmit(@params); - default: - return new { success = false, message = $"Unknown trail action: {action}. Valid: get_info, set_time, set_width, set_color, set_material, set_properties, clear, emit" }; - } - } - - private static TrailRenderer FindTrailRenderer(JObject @params) - { - GameObject go = FindTargetGameObject(@params); - return go?.GetComponent(); - } - - private static object TrailGetInfo(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - return new - { - success = true, - data = new - { - gameObject = tr.gameObject.name, - time = tr.time, - startWidth = tr.startWidth, - endWidth = tr.endWidth, - minVertexDistance = tr.minVertexDistance, - emitting = tr.emitting, - autodestruct = tr.autodestruct, - positionCount = tr.positionCount, - alignment = tr.alignment.ToString(), - textureMode = tr.textureMode.ToString(), - numCornerVertices = tr.numCornerVertices, - numCapVertices = tr.numCapVertices, - generateLightingData = tr.generateLightingData, - material = tr.sharedMaterial?.name, - // Shadows & lighting - shadowCastingMode = tr.shadowCastingMode.ToString(), - receiveShadows = tr.receiveShadows, - lightProbeUsage = tr.lightProbeUsage.ToString(), - reflectionProbeUsage = tr.reflectionProbeUsage.ToString(), - // Sorting - sortingOrder = tr.sortingOrder, - sortingLayerName = tr.sortingLayerName, - renderingLayerMask = tr.renderingLayerMask - } - }; - } - - private static object TrailSetTime(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - float time = @params["time"]?.ToObject() ?? 5f; - - Undo.RecordObject(tr, "Set Trail Time"); - tr.time = time; - EditorUtility.SetDirty(tr); - - return new { success = true, message = $"Set trail time to {time}s" }; - } - - private static object TrailSetWidth(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - Undo.RecordObject(tr, "Set Trail Width"); - var changes = new List(); - - RendererHelpers.ApplyWidthProperties(@params, changes, - v => tr.startWidth = v, v => tr.endWidth = v, - v => tr.widthCurve = v, v => tr.widthMultiplier = v, - ParseAnimationCurve); - - EditorUtility.SetDirty(tr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object TrailSetColor(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - Undo.RecordObject(tr, "Set Trail Color"); - var changes = new List(); - - RendererHelpers.ApplyColorProperties(@params, changes, - v => tr.startColor = v, v => tr.endColor = v, - v => tr.colorGradient = v, - ParseColor, ParseGradient, fadeEndAlpha: true); - - EditorUtility.SetDirty(tr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object TrailSetMaterial(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - return RendererHelpers.SetRendererMaterial(tr, @params, "Set Trail Material", FindMaterialByPath); - } - - private static object TrailSetProperties(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - Undo.RecordObject(tr, "Set Trail Properties"); - var changes = new List(); - - // Trail-specific properties (not shared with LineRenderer) - if (@params["minVertexDistance"] != null) { tr.minVertexDistance = @params["minVertexDistance"].ToObject(); changes.Add("minVertexDistance"); } - if (@params["autodestruct"] != null) { tr.autodestruct = @params["autodestruct"].ToObject(); changes.Add("autodestruct"); } - if (@params["emitting"] != null) { tr.emitting = @params["emitting"].ToObject(); changes.Add("emitting"); } - - // Shared Line/Trail properties - RendererHelpers.ApplyLineTrailProperties(@params, changes, - null, null, // Trail doesn't have loop or useWorldSpace - v => tr.numCornerVertices = v, v => tr.numCapVertices = v, - v => tr.alignment = v, v => tr.textureMode = v, - v => tr.generateLightingData = v); - - // Common Renderer properties (shadows, lighting, probes, sorting) - RendererHelpers.ApplyCommonRendererProperties(tr, @params, changes); - - EditorUtility.SetDirty(tr); - return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; - } - - private static object TrailClear(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - - Undo.RecordObject(tr, "Clear Trail"); - tr.Clear(); - return new { success = true, message = "Trail cleared" }; - } - - private static object TrailEmit(JObject @params) - { - TrailRenderer tr = FindTrailRenderer(@params); - if (tr == null) return new { success = false, message = "TrailRenderer not found" }; - -#if UNITY_2021_1_OR_NEWER - Vector3 pos = ParseVector3(@params["position"]); - tr.AddPosition(pos); - return new { success = true, message = $"Emitted at ({pos.x}, {pos.y}, {pos.z})" }; -#else - return new { success = false, message = "AddPosition requires Unity 2021.1+" }; -#endif - } - - #endregion - } -} diff --git a/MCPForUnity/Editor/Tools/ManageVfx.meta b/MCPForUnity/Editor/Tools/ManageVfx.meta new file mode 100644 index 000000000..681a567af --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 23895f2c168674806b490c74a69e4b9c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs b/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs new file mode 100644 index 000000000..254b5335b --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs @@ -0,0 +1,130 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class LineCreate + { + public static object CreateLine(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]); + Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]); + + Undo.RecordObject(lr, "Create Line"); + lr.positionCount = 2; + lr.SetPosition(0, start); + lr.SetPosition(1, end); + EditorUtility.SetDirty(lr); + + return new { success = true, message = "Created line" }; + } + + public static object CreateCircle(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]); + float radius = @params["radius"]?.ToObject() ?? 1f; + int segments = @params["segments"]?.ToObject() ?? 32; + Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up; + + Vector3 right = Vector3.Cross(normal, Vector3.forward); + if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); + right = right.normalized; + Vector3 forward = Vector3.Cross(right, normal).normalized; + + Undo.RecordObject(lr, "Create Circle"); + lr.positionCount = segments; + lr.loop = true; + + for (int i = 0; i < segments; i++) + { + float angle = (float)i / segments * Mathf.PI * 2f; + Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; + lr.SetPosition(i, point); + } + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created circle with {segments} segments" }; + } + + public static object CreateArc(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 center = ManageVfxCommon.ParseVector3(@params["center"]); + float radius = @params["radius"]?.ToObject() ?? 1f; + float startAngle = (@params["startAngle"]?.ToObject() ?? 0f) * Mathf.Deg2Rad; + float endAngle = (@params["endAngle"]?.ToObject() ?? 180f) * Mathf.Deg2Rad; + int segments = @params["segments"]?.ToObject() ?? 16; + Vector3 normal = @params["normal"] != null ? ManageVfxCommon.ParseVector3(@params["normal"]).normalized : Vector3.up; + + Vector3 right = Vector3.Cross(normal, Vector3.forward); + if (right.sqrMagnitude < 0.001f) right = Vector3.Cross(normal, Vector3.up); + right = right.normalized; + Vector3 forward = Vector3.Cross(right, normal).normalized; + + Undo.RecordObject(lr, "Create Arc"); + lr.positionCount = segments + 1; + lr.loop = false; + + for (int i = 0; i <= segments; i++) + { + float t = (float)i / segments; + float angle = Mathf.Lerp(startAngle, endAngle, t); + Vector3 point = center + (right * Mathf.Cos(angle) + forward * Mathf.Sin(angle)) * radius; + lr.SetPosition(i, point); + } + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created arc with {segments} segments" }; + } + + public static object CreateBezier(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 start = ManageVfxCommon.ParseVector3(@params["start"]); + Vector3 end = ManageVfxCommon.ParseVector3(@params["end"]); + Vector3 cp1 = ManageVfxCommon.ParseVector3(@params["controlPoint1"] ?? @params["control1"]); + Vector3 cp2 = @params["controlPoint2"] != null || @params["control2"] != null + ? ManageVfxCommon.ParseVector3(@params["controlPoint2"] ?? @params["control2"]) + : cp1; + int segments = @params["segments"]?.ToObject() ?? 32; + bool isQuadratic = @params["controlPoint2"] == null && @params["control2"] == null; + + Undo.RecordObject(lr, "Create Bezier"); + lr.positionCount = segments + 1; + lr.loop = false; + + for (int i = 0; i <= segments; i++) + { + float t = (float)i / segments; + Vector3 point; + + if (isQuadratic) + { + float u = 1 - t; + point = u * u * start + 2 * u * t * cp1 + t * t * end; + } + else + { + float u = 1 - t; + point = u * u * u * start + 3 * u * u * t * cp1 + 3 * u * t * t * cp2 + t * t * t * end; + } + + lr.SetPosition(i, point); + } + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Created {(isQuadratic ? "quadratic" : "cubic")} Bezier" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta new file mode 100644 index 000000000..bf0e133bb --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6d553d3837ecc4d999225bc9b3160a26 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs b/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs new file mode 100644 index 000000000..09c0a45bd --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs @@ -0,0 +1,52 @@ +using System.Linq; +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class LineRead + { + public static LineRenderer FindLineRenderer(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static object GetInfo(JObject @params) + { + LineRenderer lr = FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + var positions = new Vector3[lr.positionCount]; + lr.GetPositions(positions); + + return new + { + success = true, + data = new + { + gameObject = lr.gameObject.name, + positionCount = lr.positionCount, + positions = positions.Select(p => new { x = p.x, y = p.y, z = p.z }).ToArray(), + startWidth = lr.startWidth, + endWidth = lr.endWidth, + loop = lr.loop, + useWorldSpace = lr.useWorldSpace, + alignment = lr.alignment.ToString(), + textureMode = lr.textureMode.ToString(), + numCornerVertices = lr.numCornerVertices, + numCapVertices = lr.numCapVertices, + generateLightingData = lr.generateLightingData, + material = lr.sharedMaterial?.name, + shadowCastingMode = lr.shadowCastingMode.ToString(), + receiveShadows = lr.receiveShadows, + lightProbeUsage = lr.lightProbeUsage.ToString(), + reflectionProbeUsage = lr.reflectionProbeUsage.ToString(), + sortingOrder = lr.sortingOrder, + sortingLayerName = lr.sortingLayerName, + renderingLayerMask = lr.renderingLayerMask + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta new file mode 100644 index 000000000..efeccc419 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: df77cf0ca14344b0cb2f1b84c5eb15e7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs b/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs new file mode 100644 index 000000000..d4dfee7a3 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class LineWrite + { + public static object SetPositions(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + JArray posArr = @params["positions"] as JArray; + if (posArr == null) return new { success = false, message = "Positions array required" }; + + var positions = new Vector3[posArr.Count]; + for (int i = 0; i < posArr.Count; i++) + { + positions[i] = ManageVfxCommon.ParseVector3(posArr[i]); + } + + Undo.RecordObject(lr, "Set Line Positions"); + lr.positionCount = positions.Length; + lr.SetPositions(positions); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Set {positions.Length} positions" }; + } + + public static object AddPosition(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + + Undo.RecordObject(lr, "Add Line Position"); + int idx = lr.positionCount; + lr.positionCount = idx + 1; + lr.SetPosition(idx, pos); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Added position at index {idx}", index = idx }; + } + + public static object SetPosition(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + int index = @params["index"]?.ToObject() ?? -1; + if (index < 0 || index >= lr.positionCount) return new { success = false, message = $"Invalid index {index}" }; + + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + + Undo.RecordObject(lr, "Set Line Position"); + lr.SetPosition(index, pos); + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Set position at index {index}" }; + } + + public static object SetWidth(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Undo.RecordObject(lr, "Set Line Width"); + var changes = new List(); + + RendererHelpers.ApplyWidthProperties(@params, changes, + v => lr.startWidth = v, v => lr.endWidth = v, + v => lr.widthCurve = v, v => lr.widthMultiplier = v, + ManageVfxCommon.ParseAnimationCurve); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetColor(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Undo.RecordObject(lr, "Set Line Color"); + var changes = new List(); + + RendererHelpers.ApplyColorProperties(@params, changes, + v => lr.startColor = v, v => lr.endColor = v, + v => lr.colorGradient = v, + ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: false); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetMaterial(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + return RendererHelpers.SetRendererMaterial(lr, @params, "Set Line Material", ManageVfxCommon.FindMaterialByPath); + } + + public static object SetProperties(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + Undo.RecordObject(lr, "Set Line Properties"); + var changes = new List(); + + RendererHelpers.ApplyLineTrailProperties(@params, changes, + v => lr.loop = v, v => lr.useWorldSpace = v, + v => lr.numCornerVertices = v, v => lr.numCapVertices = v, + v => lr.alignment = v, v => lr.textureMode = v, + v => lr.generateLightingData = v); + + RendererHelpers.ApplyCommonRendererProperties(lr, @params, changes); + + EditorUtility.SetDirty(lr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object Clear(JObject @params) + { + LineRenderer lr = LineRead.FindLineRenderer(@params); + if (lr == null) return new { success = false, message = "LineRenderer not found" }; + + int count = lr.positionCount; + Undo.RecordObject(lr, "Clear Line"); + lr.positionCount = 0; + EditorUtility.SetDirty(lr); + + return new { success = true, message = $"Cleared {count} positions" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta new file mode 100644 index 000000000..08e2637f9 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3911acc5a6a6a494cb88a647e0426d67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs b/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs new file mode 100644 index 000000000..2f5a868f4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs @@ -0,0 +1,781 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.ManageVfx; +using UnityEngine; +using UnityEditor; + +#if UNITY_VFX_GRAPH //Please enable the symbol in the project settings for VisualEffectGraph to work +using UnityEngine.VFX; +#endif + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Tool for managing Unity VFX components: + /// - ParticleSystem (legacy particle effects) + /// - Visual Effect Graph (modern GPU particles, currently only support HDRP, other SRPs may not work) + /// - LineRenderer (lines, bezier curves, shapes) + /// - TrailRenderer (motion trails) + /// - More to come based on demand and feedback! + /// + [McpForUnityTool("manage_vfx", AutoRegister = false)] + public static class ManageVFX + { + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString(); + if (string.IsNullOrEmpty(action)) + { + return new { success = false, message = "Action is required" }; + } + + try + { + string actionLower = action.ToLowerInvariant(); + + // Route to appropriate handler based on action prefix + if (actionLower == "ping") + { + return new { success = true, tool = "manage_vfx", components = new[] { "ParticleSystem", "VisualEffect", "LineRenderer", "TrailRenderer" } }; + } + + // ParticleSystem actions (particle_*) + if (actionLower.StartsWith("particle_")) + { + return HandleParticleSystemAction(@params, actionLower.Substring(9)); + } + + // VFX Graph actions (vfx_*) + if (actionLower.StartsWith("vfx_")) + { + return HandleVFXGraphAction(@params, actionLower.Substring(4)); + } + + // LineRenderer actions (line_*) + if (actionLower.StartsWith("line_")) + { + return HandleLineRendererAction(@params, actionLower.Substring(5)); + } + + // TrailRenderer actions (trail_*) + if (actionLower.StartsWith("trail_")) + { + return HandleTrailRendererAction(@params, actionLower.Substring(6)); + } + + return new { success = false, message = $"Unknown action: {action}. Actions must be prefixed with: particle_, vfx_, line_, or trail_" }; + } + catch (Exception ex) + { + return new { success = false, message = ex.Message, stackTrace = ex.StackTrace }; + } + } + + private static object HandleParticleSystemAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return ParticleRead.GetInfo(@params); + case "set_main": return ParticleWrite.SetMain(@params); + case "set_emission": return ParticleWrite.SetEmission(@params); + case "set_shape": return ParticleWrite.SetShape(@params); + case "set_color_over_lifetime": return ParticleWrite.SetColorOverLifetime(@params); + case "set_size_over_lifetime": return ParticleWrite.SetSizeOverLifetime(@params); + case "set_velocity_over_lifetime": return ParticleWrite.SetVelocityOverLifetime(@params); + case "set_noise": return ParticleWrite.SetNoise(@params); + case "set_renderer": return ParticleWrite.SetRenderer(@params); + case "enable_module": return ParticleControl.EnableModule(@params); + case "play": return ParticleControl.Control(@params, "play"); + case "stop": return ParticleControl.Control(@params, "stop"); + case "pause": return ParticleControl.Control(@params, "pause"); + case "restart": return ParticleControl.Control(@params, "restart"); + case "clear": return ParticleControl.Control(@params, "clear"); + case "add_burst": return ParticleControl.AddBurst(@params); + case "clear_bursts": return ParticleControl.ClearBursts(@params); + default: + return new { success = false, message = $"Unknown particle action: {action}. Valid: get_info, set_main, set_emission, set_shape, set_color_over_lifetime, set_size_over_lifetime, set_velocity_over_lifetime, set_noise, set_renderer, enable_module, play, stop, pause, restart, clear, add_burst, clear_bursts" }; + } + } + + // ==================== VFX GRAPH ==================== + #region VFX Graph + + private static object HandleVFXGraphAction(JObject @params, string action) + { +#if !UNITY_VFX_GRAPH + return new { success = false, message = "VFX Graph package (com.unity.visualeffectgraph) not installed" }; +#else + switch (action) + { + // Asset management + case "create_asset": return VFXCreateAsset(@params); + case "assign_asset": return VFXAssignAsset(@params); + case "list_templates": return VFXListTemplates(@params); + case "list_assets": return VFXListAssets(@params); + + // Runtime parameter control + case "get_info": return VFXGetInfo(@params); + case "set_float": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetFloat(n, v)); + case "set_int": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetInt(n, v)); + case "set_bool": return VFXSetParameter(@params, (vfx, n, v) => vfx.SetBool(n, v)); + case "set_vector2": return VFXSetVector(@params, 2); + case "set_vector3": return VFXSetVector(@params, 3); + case "set_vector4": return VFXSetVector(@params, 4); + case "set_color": return VFXSetColor(@params); + case "set_gradient": return VFXSetGradient(@params); + case "set_texture": return VFXSetTexture(@params); + case "set_mesh": return VFXSetMesh(@params); + case "set_curve": return VFXSetCurve(@params); + case "send_event": return VFXSendEvent(@params); + case "play": return VFXControl(@params, "play"); + case "stop": return VFXControl(@params, "stop"); + case "pause": return VFXControl(@params, "pause"); + case "reinit": return VFXControl(@params, "reinit"); + case "set_playback_speed": return VFXSetPlaybackSpeed(@params); + case "set_seed": return VFXSetSeed(@params); + default: + return new { success = false, message = $"Unknown vfx action: {action}. Valid: create_asset, assign_asset, list_templates, list_assets, get_info, set_float, set_int, set_bool, set_vector2/3/4, set_color, set_gradient, set_texture, set_mesh, set_curve, send_event, play, stop, pause, reinit, set_playback_speed, set_seed" }; + } +#endif + } + +#if UNITY_VFX_GRAPH + private static VisualEffect FindVisualEffect(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + /// + /// Creates a new VFX Graph asset file from a template + /// + private static object VFXCreateAsset(JObject @params) + { + string assetName = @params["assetName"]?.ToString(); + string folderPath = @params["folderPath"]?.ToString() ?? "Assets/VFX"; + string template = @params["template"]?.ToString() ?? "empty"; + + if (string.IsNullOrEmpty(assetName)) + return new { success = false, message = "assetName is required" }; + + // Ensure folder exists + if (!AssetDatabase.IsValidFolder(folderPath)) + { + string[] folders = folderPath.Split('/'); + string currentPath = folders[0]; + for (int i = 1; i < folders.Length; i++) + { + string newPath = currentPath + "/" + folders[i]; + if (!AssetDatabase.IsValidFolder(newPath)) + { + AssetDatabase.CreateFolder(currentPath, folders[i]); + } + currentPath = newPath; + } + } + + string assetPath = $"{folderPath}/{assetName}.vfx"; + + // Check if asset already exists + if (AssetDatabase.LoadAssetAtPath(assetPath) != null) + { + bool overwrite = @params["overwrite"]?.ToObject() ?? false; + if (!overwrite) + return new { success = false, message = $"Asset already exists at {assetPath}. Set overwrite=true to replace." }; + AssetDatabase.DeleteAsset(assetPath); + } + + // Find and copy template + string templatePath = FindVFXTemplate(template); + UnityEngine.VFX.VisualEffectAsset newAsset = null; + + if (!string.IsNullOrEmpty(templatePath) && System.IO.File.Exists(templatePath)) + { + // templatePath is a full filesystem path, need to copy file directly + // Get the full destination path + string projectRoot = System.IO.Path.GetDirectoryName(Application.dataPath); + string fullDestPath = System.IO.Path.Combine(projectRoot, assetPath); + + // Ensure directory exists + string destDir = System.IO.Path.GetDirectoryName(fullDestPath); + if (!System.IO.Directory.Exists(destDir)) + System.IO.Directory.CreateDirectory(destDir); + + // Copy the file + System.IO.File.Copy(templatePath, fullDestPath, true); + AssetDatabase.Refresh(); + newAsset = AssetDatabase.LoadAssetAtPath(assetPath); + } + else + { + // Create empty VFX asset using reflection to access internal API + // Note: Develop in Progress, TODO:// Find authenticated way to create VFX asset + try + { + // Try to use VisualEffectAssetEditorUtility.CreateNewAsset if available + var utilityType = System.Type.GetType("UnityEditor.VFX.VisualEffectAssetEditorUtility, Unity.VisualEffectGraph.Editor"); + if (utilityType != null) + { + var createMethod = utilityType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static); + if (createMethod != null) + { + createMethod.Invoke(null, new object[] { assetPath }); + AssetDatabase.Refresh(); + newAsset = AssetDatabase.LoadAssetAtPath(assetPath); + } + } + + // Fallback: Create a ScriptableObject-based asset + if (newAsset == null) + { + // Try direct creation via internal constructor + var resourceType = System.Type.GetType("UnityEditor.VFX.VisualEffectResource, Unity.VisualEffectGraph.Editor"); + if (resourceType != null) + { + var createMethod = resourceType.GetMethod("CreateNewAsset", BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic); + if (createMethod != null) + { + var resource = createMethod.Invoke(null, new object[] { assetPath }); + AssetDatabase.Refresh(); + newAsset = AssetDatabase.LoadAssetAtPath(assetPath); + } + } + } + } + catch (Exception ex) + { + return new { success = false, message = $"Failed to create VFX asset: {ex.Message}" }; + } + } + + if (newAsset == null) + { + return new { success = false, message = "Failed to create VFX asset. Try using a template from list_templates." }; + } + + return new + { + success = true, + message = $"Created VFX asset: {assetPath}", + data = new + { + assetPath = assetPath, + assetName = newAsset.name, + template = template + } + }; + } + + /// + /// Finds VFX template path by name + /// + private static string FindVFXTemplate(string templateName) + { + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + + string[] templatePatterns = new[] + { + $"{templateName}.vfx", + $"VFX{templateName}.vfx", + $"Simple{templateName}.vfx", + $"{templateName}VFX.vfx" + }; + + foreach (string basePath in searchPaths) + { + if (!System.IO.Directory.Exists(basePath)) continue; + + foreach (string pattern in templatePatterns) + { + string[] files = System.IO.Directory.GetFiles(basePath, pattern, System.IO.SearchOption.AllDirectories); + if (files.Length > 0) + return files[0]; + } + + // Also search by partial match + try + { + string[] allVfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in allVfxFiles) + { + if (System.IO.Path.GetFileNameWithoutExtension(file).ToLower().Contains(templateName.ToLower())) + return file; + } + } + catch { } + } + + // Search in project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset " + templateName); + if (guids.Length > 0) + { + return AssetDatabase.GUIDToAssetPath(guids[0]); + } + + return null; + } + + /// + /// Assigns a VFX asset to a VisualEffect component + /// + private static object VFXAssignAsset(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect component not found" }; + + string assetPath = @params["assetPath"]?.ToString(); + if (string.IsNullOrEmpty(assetPath)) + return new { success = false, message = "assetPath is required" }; + + // Normalize path + if (!assetPath.StartsWith("Assets/") && !assetPath.StartsWith("Packages/")) + assetPath = "Assets/" + assetPath; + if (!assetPath.EndsWith(".vfx")) + assetPath += ".vfx"; + + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + if (asset == null) + { + // Try searching by name + string searchName = System.IO.Path.GetFileNameWithoutExtension(assetPath); + string[] guids = AssetDatabase.FindAssets($"t:VisualEffectAsset {searchName}"); + if (guids.Length > 0) + { + assetPath = AssetDatabase.GUIDToAssetPath(guids[0]); + asset = AssetDatabase.LoadAssetAtPath(assetPath); + } + } + + if (asset == null) + return new { success = false, message = $"VFX asset not found: {assetPath}" }; + + Undo.RecordObject(vfx, "Assign VFX Asset"); + vfx.visualEffectAsset = asset; + EditorUtility.SetDirty(vfx); + + return new + { + success = true, + message = $"Assigned VFX asset '{asset.name}' to {vfx.gameObject.name}", + data = new + { + gameObject = vfx.gameObject.name, + assetName = asset.name, + assetPath = assetPath + } + }; + } + + /// + /// Lists available VFX templates + /// + private static object VFXListTemplates(JObject @params) + { + var templates = new List(); + + // Get the actual filesystem path for the VFX Graph package using PackageManager API + var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath("Packages/com.unity.visualeffectgraph"); + + var searchPaths = new List(); + + if (packageInfo != null) + { + // Use the resolved path from PackageManager (handles Library/PackageCache paths) + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Editor/Templates")); + searchPaths.Add(System.IO.Path.Combine(packageInfo.resolvedPath, "Samples")); + } + + // Also search project-local paths + searchPaths.Add("Assets/VFX/Templates"); + searchPaths.Add("Assets/VFX"); + + // Precompute normalized package path for comparison + string normalizedPackagePath = null; + if (packageInfo != null) + { + normalizedPackagePath = packageInfo.resolvedPath.Replace("\\", "/"); + } + + // Precompute the Assets base path for converting absolute paths to project-relative + string assetsBasePath = Application.dataPath.Replace("\\", "/"); + + foreach (string basePath in searchPaths) + { + if (!System.IO.Directory.Exists(basePath)) continue; + + try + { + string[] vfxFiles = System.IO.Directory.GetFiles(basePath, "*.vfx", System.IO.SearchOption.AllDirectories); + foreach (string file in vfxFiles) + { + string absolutePath = file.Replace("\\", "/"); + string name = System.IO.Path.GetFileNameWithoutExtension(file); + bool isPackage = normalizedPackagePath != null && absolutePath.StartsWith(normalizedPackagePath); + + // Convert absolute path to project-relative path + string projectRelativePath; + if (isPackage) + { + // For package paths, convert to Packages/... format + projectRelativePath = "Packages/" + packageInfo.name + absolutePath.Substring(normalizedPackagePath.Length); + } + else if (absolutePath.StartsWith(assetsBasePath)) + { + // For project assets, convert to Assets/... format + projectRelativePath = "Assets" + absolutePath.Substring(assetsBasePath.Length); + } + else + { + // Fallback: use the absolute path if we can't determine the relative path + projectRelativePath = absolutePath; + } + + templates.Add(new { name = name, path = projectRelativePath, source = isPackage ? "package" : "project" }); + } + } + catch { } + } + + // Also search project assets + string[] guids = AssetDatabase.FindAssets("t:VisualEffectAsset"); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + if (!templates.Any(t => ((dynamic)t).path == path)) + { + string name = System.IO.Path.GetFileNameWithoutExtension(path); + templates.Add(new { name = name, path = path, source = "project" }); + } + } + + return new + { + success = true, + data = new + { + count = templates.Count, + templates = templates + } + }; + } + + /// + /// Lists all VFX assets in the project + /// + private static object VFXListAssets(JObject @params) + { + string searchFolder = @params["folder"]?.ToString(); + string searchPattern = @params["search"]?.ToString(); + + string filter = "t:VisualEffectAsset"; + if (!string.IsNullOrEmpty(searchPattern)) + filter += " " + searchPattern; + + string[] guids; + if (!string.IsNullOrEmpty(searchFolder)) + guids = AssetDatabase.FindAssets(filter, new[] { searchFolder }); + else + guids = AssetDatabase.FindAssets(filter); + + var assets = new List(); + foreach (string guid in guids) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + assets.Add(new + { + name = asset.name, + path = path, + guid = guid + }); + } + } + + return new + { + success = true, + data = new + { + count = assets.Count, + assets = assets + } + }; + } + + private static object VFXGetInfo(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + return new + { + success = true, + data = new + { + gameObject = vfx.gameObject.name, + assetName = vfx.visualEffectAsset?.name ?? "None", + aliveParticleCount = vfx.aliveParticleCount, + culled = vfx.culled, + pause = vfx.pause, + playRate = vfx.playRate, + startSeed = vfx.startSeed + } + }; + } + + private static object VFXSetParameter(JObject @params, Action setter) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; + + JToken valueToken = @params["value"]; + if (valueToken == null) return new { success = false, message = "Value required" }; + + Undo.RecordObject(vfx, $"Set VFX {param}"); + T value = valueToken.ToObject(); + setter(vfx, param, value); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set {param} = {value}" }; + } + + private static object VFXSetVector(JObject @params, int dims) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; + + Vector4 vec = ManageVfxCommon.ParseVector4(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX {param}"); + + switch (dims) + { + case 2: vfx.SetVector2(param, new Vector2(vec.x, vec.y)); break; + case 3: vfx.SetVector3(param, new Vector3(vec.x, vec.y, vec.z)); break; + case 4: vfx.SetVector4(param, vec); break; + } + + EditorUtility.SetDirty(vfx); + return new { success = true, message = $"Set {param}" }; + } + + private static object VFXSetColor(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; + + Color color = ManageVfxCommon.ParseColor(@params["value"]); + Undo.RecordObject(vfx, $"Set VFX Color {param}"); + vfx.SetVector4(param, new Vector4(color.r, color.g, color.b, color.a)); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set color {param}" }; + } + + private static object VFXSetGradient(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; + + Gradient gradient = ManageVfxCommon.ParseGradient(@params["gradient"]); + Undo.RecordObject(vfx, $"Set VFX Gradient {param}"); + vfx.SetGradient(param, gradient); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set gradient {param}" }; + } + + private static object VFXSetTexture(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + string path = @params["texturePath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and texturePath required" }; + + var findInst = new JObject { ["find"] = path }; + Texture tex = ManageGameObject.FindObjectByInstruction(findInst, typeof(Texture)) as Texture; + if (tex == null) return new { success = false, message = $"Texture not found: {path}" }; + + Undo.RecordObject(vfx, $"Set VFX Texture {param}"); + vfx.SetTexture(param, tex); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set texture {param} = {tex.name}" }; + } + + private static object VFXSetMesh(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + string path = @params["meshPath"]?.ToString(); + if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and meshPath required" }; + + var findInst = new JObject { ["find"] = path }; + Mesh mesh = ManageGameObject.FindObjectByInstruction(findInst, typeof(Mesh)) as Mesh; + if (mesh == null) return new { success = false, message = $"Mesh not found: {path}" }; + + Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); + vfx.SetMesh(param, mesh); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set mesh {param} = {mesh.name}" }; + } + + private static object VFXSetCurve(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string param = @params["parameter"]?.ToString(); + if (string.IsNullOrEmpty(param)) return new { success = false, message = "Parameter name required" }; + + AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(@params["curve"], 1f); + Undo.RecordObject(vfx, $"Set VFX Curve {param}"); + vfx.SetAnimationCurve(param, curve); + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set curve {param}" }; + } + + private static object VFXSendEvent(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + string eventName = @params["eventName"]?.ToString(); + if (string.IsNullOrEmpty(eventName)) return new { success = false, message = "Event name required" }; + + VFXEventAttribute attr = vfx.CreateVFXEventAttribute(); + if (@params["position"] != null) attr.SetVector3("position", ManageVfxCommon.ParseVector3(@params["position"])); + if (@params["velocity"] != null) attr.SetVector3("velocity", ManageVfxCommon.ParseVector3(@params["velocity"])); + if (@params["color"] != null) { var c = ManageVfxCommon.ParseColor(@params["color"]); attr.SetVector3("color", new Vector3(c.r, c.g, c.b)); } + if (@params["size"] != null) attr.SetFloat("size", @params["size"].ToObject()); + if (@params["lifetime"] != null) attr.SetFloat("lifetime", @params["lifetime"].ToObject()); + + vfx.SendEvent(eventName, attr); + return new { success = true, message = $"Sent event '{eventName}'" }; + } + + private static object VFXControl(JObject @params, string action) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + switch (action) + { + case "play": vfx.Play(); break; + case "stop": vfx.Stop(); break; + case "pause": vfx.pause = !vfx.pause; break; + case "reinit": vfx.Reinit(); break; + } + + return new { success = true, message = $"VFX {action}", isPaused = vfx.pause }; + } + + private static object VFXSetPlaybackSpeed(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + float rate = @params["playRate"]?.ToObject() ?? 1f; + Undo.RecordObject(vfx, "Set VFX Play Rate"); + vfx.playRate = rate; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set play rate = {rate}" }; + } + + private static object VFXSetSeed(JObject @params) + { + VisualEffect vfx = FindVisualEffect(@params); + if (vfx == null) return new { success = false, message = "VisualEffect not found" }; + + uint seed = @params["seed"]?.ToObject() ?? 0; + bool resetOnPlay = @params["resetSeedOnPlay"]?.ToObject() ?? true; + + Undo.RecordObject(vfx, "Set VFX Seed"); + vfx.startSeed = seed; + vfx.resetSeedOnPlay = resetOnPlay; + EditorUtility.SetDirty(vfx); + + return new { success = true, message = $"Set seed = {seed}" }; + } +#endif + + #endregion + + private static object HandleLineRendererAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return LineRead.GetInfo(@params); + case "set_positions": return LineWrite.SetPositions(@params); + case "add_position": return LineWrite.AddPosition(@params); + case "set_position": return LineWrite.SetPosition(@params); + case "set_width": return LineWrite.SetWidth(@params); + case "set_color": return LineWrite.SetColor(@params); + case "set_material": return LineWrite.SetMaterial(@params); + case "set_properties": return LineWrite.SetProperties(@params); + case "clear": return LineWrite.Clear(@params); + case "create_line": return LineCreate.CreateLine(@params); + case "create_circle": return LineCreate.CreateCircle(@params); + case "create_arc": return LineCreate.CreateArc(@params); + case "create_bezier": return LineCreate.CreateBezier(@params); + default: + return new { success = false, message = $"Unknown line action: {action}. Valid: get_info, set_positions, add_position, set_position, set_width, set_color, set_material, set_properties, clear, create_line, create_circle, create_arc, create_bezier" }; + } + } + + private static object HandleTrailRendererAction(JObject @params, string action) + { + switch (action) + { + case "get_info": return TrailRead.GetInfo(@params); + case "set_time": return TrailWrite.SetTime(@params); + case "set_width": return TrailWrite.SetWidth(@params); + case "set_color": return TrailWrite.SetColor(@params); + case "set_material": return TrailWrite.SetMaterial(@params); + case "set_properties": return TrailWrite.SetProperties(@params); + case "clear": return TrailControl.Clear(@params); + case "emit": return TrailControl.Emit(@params); + default: + return new { success = false, message = $"Unknown trail action: {action}. Valid: get_info, set_time, set_width, set_color, set_material, set_properties, clear, emit" }; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVFX.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVFX.cs.meta rename to MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs b/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs new file mode 100644 index 000000000..255a8c2d6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json.Linq; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class ManageVfxCommon + { + public static Color ParseColor(JToken token) => VectorParsing.ParseColorOrDefault(token); + public static Vector3 ParseVector3(JToken token) => VectorParsing.ParseVector3OrDefault(token); + public static Vector4 ParseVector4(JToken token) => VectorParsing.ParseVector4OrDefault(token); + public static Gradient ParseGradient(JToken token) => VectorParsing.ParseGradientOrDefault(token); + public static AnimationCurve ParseAnimationCurve(JToken token, float defaultValue = 1f) + => VectorParsing.ParseAnimationCurveOrDefault(token, defaultValue); + + public static GameObject FindTargetGameObject(JObject @params) + => ObjectResolver.ResolveGameObject(@params["target"], @params["searchMethod"]?.ToString()); + + public static Material FindMaterialByPath(string path) + => ObjectResolver.ResolveMaterial(path); + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta new file mode 100644 index 000000000..6d1d28b27 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1c5e603b26d2f47529394c1ec6b8ed79 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs b/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs new file mode 100644 index 000000000..c7d146961 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class ParticleCommon + { + public static ParticleSystem FindParticleSystem(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static ParticleSystem.MinMaxCurve ParseMinMaxCurve(JToken token, float defaultValue = 1f) + { + if (token == null) + return new ParticleSystem.MinMaxCurve(defaultValue); + + if (token.Type == JTokenType.Float || token.Type == JTokenType.Integer) + { + return new ParticleSystem.MinMaxCurve(token.ToObject()); + } + + if (token is JObject obj) + { + string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "constant"; + + switch (mode) + { + case "constant": + float constant = obj["value"]?.ToObject() ?? defaultValue; + return new ParticleSystem.MinMaxCurve(constant); + + case "random_between_constants": + case "two_constants": + float min = obj["min"]?.ToObject() ?? 0f; + float max = obj["max"]?.ToObject() ?? 1f; + return new ParticleSystem.MinMaxCurve(min, max); + + case "curve": + AnimationCurve curve = ManageVfxCommon.ParseAnimationCurve(obj, defaultValue); + return new ParticleSystem.MinMaxCurve(obj["multiplier"]?.ToObject() ?? 1f, curve); + + default: + return new ParticleSystem.MinMaxCurve(defaultValue); + } + } + + return new ParticleSystem.MinMaxCurve(defaultValue); + } + + public static ParticleSystem.MinMaxGradient ParseMinMaxGradient(JToken token) + { + if (token == null) + return new ParticleSystem.MinMaxGradient(Color.white); + + if (token is JArray arr && arr.Count >= 3) + { + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(arr)); + } + + if (token is JObject obj) + { + string mode = obj["mode"]?.ToString()?.ToLowerInvariant() ?? "color"; + + switch (mode) + { + case "color": + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseColor(obj["color"])); + + case "two_colors": + Color colorMin = ManageVfxCommon.ParseColor(obj["colorMin"]); + Color colorMax = ManageVfxCommon.ParseColor(obj["colorMax"]); + return new ParticleSystem.MinMaxGradient(colorMin, colorMax); + + case "gradient": + return new ParticleSystem.MinMaxGradient(ManageVfxCommon.ParseGradient(obj)); + + default: + return new ParticleSystem.MinMaxGradient(Color.white); + } + } + + return new ParticleSystem.MinMaxGradient(Color.white); + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta new file mode 100644 index 000000000..fa9388a26 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a3a91aa6f6b9c4121a2ccc1a8147bbf9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs b/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs new file mode 100644 index 000000000..00cd6838a --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs @@ -0,0 +1,100 @@ +using System; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class ParticleControl + { + public static object EnableModule(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + string moduleName = @params["module"]?.ToString()?.ToLowerInvariant(); + bool enabled = @params["enabled"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(moduleName)) return new { success = false, message = "Module name required" }; + + Undo.RecordObject(ps, $"Toggle {moduleName}"); + + switch (moduleName.Replace("_", "")) + { + case "emission": var em = ps.emission; em.enabled = enabled; break; + case "shape": var sh = ps.shape; sh.enabled = enabled; break; + case "coloroverlifetime": var col = ps.colorOverLifetime; col.enabled = enabled; break; + case "sizeoverlifetime": var sol = ps.sizeOverLifetime; sol.enabled = enabled; break; + case "velocityoverlifetime": var vol = ps.velocityOverLifetime; vol.enabled = enabled; break; + case "noise": var n = ps.noise; n.enabled = enabled; break; + case "collision": var coll = ps.collision; coll.enabled = enabled; break; + case "trails": var tr = ps.trails; tr.enabled = enabled; break; + case "lights": var li = ps.lights; li.enabled = enabled; break; + default: return new { success = false, message = $"Unknown module: {moduleName}" }; + } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Module '{moduleName}' {(enabled ? "enabled" : "disabled")}" }; + } + + public static object Control(JObject @params, string action) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + bool withChildren = @params["withChildren"]?.ToObject() ?? true; + + switch (action) + { + case "play": ps.Play(withChildren); break; + case "stop": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmitting); break; + case "pause": ps.Pause(withChildren); break; + case "restart": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(withChildren); break; + case "clear": ps.Clear(withChildren); break; + } + + return new { success = true, message = $"ParticleSystem {action}" }; + } + + public static object AddBurst(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Add Burst"); + var emission = ps.emission; + + float time = @params["time"]?.ToObject() ?? 0f; + short minCount = (short)(@params["minCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); + short maxCount = (short)(@params["maxCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); + int cycles = @params["cycles"]?.ToObject() ?? 1; + float interval = @params["interval"]?.ToObject() ?? 0.01f; + + var burst = new ParticleSystem.Burst(time, minCount, maxCount, cycles, interval); + burst.probability = @params["probability"]?.ToObject() ?? 1f; + + int idx = emission.burstCount; + var bursts = new ParticleSystem.Burst[idx + 1]; + emission.GetBursts(bursts); + bursts[idx] = burst; + emission.SetBursts(bursts); + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Added burst at t={time}", burstIndex = idx }; + } + + public static object ClearBursts(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Clear Bursts"); + var emission = ps.emission; + int count = emission.burstCount; + emission.SetBursts(new ParticleSystem.Burst[0]); + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Cleared {count} bursts" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta new file mode 100644 index 000000000..d30d0272b --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04e1bfb655f184337943edd5a3fbbcdb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs b/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs new file mode 100644 index 000000000..6d0b74921 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs @@ -0,0 +1,75 @@ +using Newtonsoft.Json.Linq; +using System.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class ParticleRead + { + public static object GetInfo(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) + { + return new { success = false, message = "ParticleSystem not found" }; + } + + var main = ps.main; + var emission = ps.emission; + var shape = ps.shape; + var renderer = ps.GetComponent(); + + return new + { + success = true, + data = new + { + gameObject = ps.gameObject.name, + isPlaying = ps.isPlaying, + isPaused = ps.isPaused, + particleCount = ps.particleCount, + main = new + { + duration = main.duration, + looping = main.loop, + startLifetime = main.startLifetime.constant, + startSpeed = main.startSpeed.constant, + startSize = main.startSize.constant, + gravityModifier = main.gravityModifier.constant, + simulationSpace = main.simulationSpace.ToString(), + maxParticles = main.maxParticles + }, + emission = new + { + enabled = emission.enabled, + rateOverTime = emission.rateOverTime.constant, + burstCount = emission.burstCount + }, + shape = new + { + enabled = shape.enabled, + shapeType = shape.shapeType.ToString(), + radius = shape.radius, + angle = shape.angle + }, + renderer = renderer != null ? new + { + renderMode = renderer.renderMode.ToString(), + sortMode = renderer.sortMode.ToString(), + material = renderer.sharedMaterial?.name, + trailMaterial = renderer.trailMaterial?.name, + minParticleSize = renderer.minParticleSize, + maxParticleSize = renderer.maxParticleSize, + shadowCastingMode = renderer.shadowCastingMode.ToString(), + receiveShadows = renderer.receiveShadows, + lightProbeUsage = renderer.lightProbeUsage.ToString(), + reflectionProbeUsage = renderer.reflectionProbeUsage.ToString(), + sortingOrder = renderer.sortingOrder, + sortingLayerName = renderer.sortingLayerName, + renderingLayerMask = renderer.renderingLayerMask + } : null + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta new file mode 100644 index 000000000..a4a7dffb4 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 74bb7c48a4e1944bcba43b3619653cb9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs b/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs new file mode 100644 index 000000000..a01bbb453 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class ParticleWrite + { + public static object SetMain(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Main"); + var main = ps.main; + var changes = new List(); + + if (@params["duration"] != null) { main.duration = @params["duration"].ToObject(); changes.Add("duration"); } + if (@params["looping"] != null) { main.loop = @params["looping"].ToObject(); changes.Add("looping"); } + if (@params["prewarm"] != null) { main.prewarm = @params["prewarm"].ToObject(); changes.Add("prewarm"); } + if (@params["startDelay"] != null) { main.startDelay = ParticleCommon.ParseMinMaxCurve(@params["startDelay"], 0f); changes.Add("startDelay"); } + if (@params["startLifetime"] != null) { main.startLifetime = ParticleCommon.ParseMinMaxCurve(@params["startLifetime"], 5f); changes.Add("startLifetime"); } + if (@params["startSpeed"] != null) { main.startSpeed = ParticleCommon.ParseMinMaxCurve(@params["startSpeed"], 5f); changes.Add("startSpeed"); } + if (@params["startSize"] != null) { main.startSize = ParticleCommon.ParseMinMaxCurve(@params["startSize"], 1f); changes.Add("startSize"); } + if (@params["startRotation"] != null) { main.startRotation = ParticleCommon.ParseMinMaxCurve(@params["startRotation"], 0f); changes.Add("startRotation"); } + if (@params["startColor"] != null) { main.startColor = ParticleCommon.ParseMinMaxGradient(@params["startColor"]); changes.Add("startColor"); } + if (@params["gravityModifier"] != null) { main.gravityModifier = ParticleCommon.ParseMinMaxCurve(@params["gravityModifier"], 0f); changes.Add("gravityModifier"); } + if (@params["simulationSpace"] != null && Enum.TryParse(@params["simulationSpace"].ToString(), true, out var simSpace)) { main.simulationSpace = simSpace; changes.Add("simulationSpace"); } + if (@params["scalingMode"] != null && Enum.TryParse(@params["scalingMode"].ToString(), true, out var scaleMode)) { main.scalingMode = scaleMode; changes.Add("scalingMode"); } + if (@params["playOnAwake"] != null) { main.playOnAwake = @params["playOnAwake"].ToObject(); changes.Add("playOnAwake"); } + if (@params["maxParticles"] != null) { main.maxParticles = @params["maxParticles"].ToObject(); changes.Add("maxParticles"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetEmission(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Emission"); + var emission = ps.emission; + var changes = new List(); + + if (@params["enabled"] != null) { emission.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["rateOverTime"] != null) { emission.rateOverTime = ParticleCommon.ParseMinMaxCurve(@params["rateOverTime"], 10f); changes.Add("rateOverTime"); } + if (@params["rateOverDistance"] != null) { emission.rateOverDistance = ParticleCommon.ParseMinMaxCurve(@params["rateOverDistance"], 0f); changes.Add("rateOverDistance"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated emission: {string.Join(", ", changes)}" }; + } + + public static object SetShape(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Shape"); + var shape = ps.shape; + var changes = new List(); + + if (@params["enabled"] != null) { shape.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["shapeType"] != null && Enum.TryParse(@params["shapeType"].ToString(), true, out var shapeType)) { shape.shapeType = shapeType; changes.Add("shapeType"); } + if (@params["radius"] != null) { shape.radius = @params["radius"].ToObject(); changes.Add("radius"); } + if (@params["radiusThickness"] != null) { shape.radiusThickness = @params["radiusThickness"].ToObject(); changes.Add("radiusThickness"); } + if (@params["angle"] != null) { shape.angle = @params["angle"].ToObject(); changes.Add("angle"); } + if (@params["arc"] != null) { shape.arc = @params["arc"].ToObject(); changes.Add("arc"); } + if (@params["position"] != null) { shape.position = ManageVfxCommon.ParseVector3(@params["position"]); changes.Add("position"); } + if (@params["rotation"] != null) { shape.rotation = ManageVfxCommon.ParseVector3(@params["rotation"]); changes.Add("rotation"); } + if (@params["scale"] != null) { shape.scale = ManageVfxCommon.ParseVector3(@params["scale"]); changes.Add("scale"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated shape: {string.Join(", ", changes)}" }; + } + + public static object SetColorOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Color Over Lifetime"); + var col = ps.colorOverLifetime; + var changes = new List(); + + if (@params["enabled"] != null) { col.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["color"] != null) { col.color = ParticleCommon.ParseMinMaxGradient(@params["color"]); changes.Add("color"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetSizeOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Size Over Lifetime"); + var sol = ps.sizeOverLifetime; + var changes = new List(); + + bool hasSizeProperty = @params["size"] != null || @params["sizeX"] != null || + @params["sizeY"] != null || @params["sizeZ"] != null; + if (hasSizeProperty && @params["enabled"] == null && !sol.enabled) + { + sol.enabled = true; + changes.Add("enabled"); + } + else if (@params["enabled"] != null) + { + sol.enabled = @params["enabled"].ToObject(); + changes.Add("enabled"); + } + + if (@params["separateAxes"] != null) { sol.separateAxes = @params["separateAxes"].ToObject(); changes.Add("separateAxes"); } + if (@params["size"] != null) { sol.size = ParticleCommon.ParseMinMaxCurve(@params["size"], 1f); changes.Add("size"); } + if (@params["sizeX"] != null) { sol.x = ParticleCommon.ParseMinMaxCurve(@params["sizeX"], 1f); changes.Add("sizeX"); } + if (@params["sizeY"] != null) { sol.y = ParticleCommon.ParseMinMaxCurve(@params["sizeY"], 1f); changes.Add("sizeY"); } + if (@params["sizeZ"] != null) { sol.z = ParticleCommon.ParseMinMaxCurve(@params["sizeZ"], 1f); changes.Add("sizeZ"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetVelocityOverLifetime(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Velocity Over Lifetime"); + var vol = ps.velocityOverLifetime; + var changes = new List(); + + if (@params["enabled"] != null) { vol.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["space"] != null && Enum.TryParse(@params["space"].ToString(), true, out var space)) { vol.space = space; changes.Add("space"); } + if (@params["x"] != null) { vol.x = ParticleCommon.ParseMinMaxCurve(@params["x"], 0f); changes.Add("x"); } + if (@params["y"] != null) { vol.y = ParticleCommon.ParseMinMaxCurve(@params["y"], 0f); changes.Add("y"); } + if (@params["z"] != null) { vol.z = ParticleCommon.ParseMinMaxCurve(@params["z"], 0f); changes.Add("z"); } + if (@params["speedModifier"] != null) { vol.speedModifier = ParticleCommon.ParseMinMaxCurve(@params["speedModifier"], 1f); changes.Add("speedModifier"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetNoise(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + Undo.RecordObject(ps, "Set ParticleSystem Noise"); + var noise = ps.noise; + var changes = new List(); + + if (@params["enabled"] != null) { noise.enabled = @params["enabled"].ToObject(); changes.Add("enabled"); } + if (@params["strength"] != null) { noise.strength = ParticleCommon.ParseMinMaxCurve(@params["strength"], 1f); changes.Add("strength"); } + if (@params["frequency"] != null) { noise.frequency = @params["frequency"].ToObject(); changes.Add("frequency"); } + if (@params["scrollSpeed"] != null) { noise.scrollSpeed = ParticleCommon.ParseMinMaxCurve(@params["scrollSpeed"], 0f); changes.Add("scrollSpeed"); } + if (@params["damping"] != null) { noise.damping = @params["damping"].ToObject(); changes.Add("damping"); } + if (@params["octaveCount"] != null) { noise.octaveCount = @params["octaveCount"].ToObject(); changes.Add("octaveCount"); } + if (@params["quality"] != null && Enum.TryParse(@params["quality"].ToString(), true, out var quality)) { noise.quality = quality; changes.Add("quality"); } + + EditorUtility.SetDirty(ps); + return new { success = true, message = $"Updated noise: {string.Join(", ", changes)}" }; + } + + public static object SetRenderer(JObject @params) + { + ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); + if (ps == null) return new { success = false, message = "ParticleSystem not found" }; + + var renderer = ps.GetComponent(); + if (renderer == null) return new { success = false, message = "ParticleSystemRenderer not found" }; + + Undo.RecordObject(renderer, "Set ParticleSystem Renderer"); + var changes = new List(); + + if (@params["renderMode"] != null && Enum.TryParse(@params["renderMode"].ToString(), true, out var renderMode)) { renderer.renderMode = renderMode; changes.Add("renderMode"); } + if (@params["sortMode"] != null && Enum.TryParse(@params["sortMode"].ToString(), true, out var sortMode)) { renderer.sortMode = sortMode; changes.Add("sortMode"); } + + if (@params["minParticleSize"] != null) { renderer.minParticleSize = @params["minParticleSize"].ToObject(); changes.Add("minParticleSize"); } + if (@params["maxParticleSize"] != null) { renderer.maxParticleSize = @params["maxParticleSize"].ToObject(); changes.Add("maxParticleSize"); } + + if (@params["lengthScale"] != null) { renderer.lengthScale = @params["lengthScale"].ToObject(); changes.Add("lengthScale"); } + if (@params["velocityScale"] != null) { renderer.velocityScale = @params["velocityScale"].ToObject(); changes.Add("velocityScale"); } + if (@params["cameraVelocityScale"] != null) { renderer.cameraVelocityScale = @params["cameraVelocityScale"].ToObject(); changes.Add("cameraVelocityScale"); } + if (@params["normalDirection"] != null) { renderer.normalDirection = @params["normalDirection"].ToObject(); changes.Add("normalDirection"); } + + if (@params["alignment"] != null && Enum.TryParse(@params["alignment"].ToString(), true, out var alignment)) { renderer.alignment = alignment; changes.Add("alignment"); } + if (@params["pivot"] != null) { renderer.pivot = ManageVfxCommon.ParseVector3(@params["pivot"]); changes.Add("pivot"); } + if (@params["flip"] != null) { renderer.flip = ManageVfxCommon.ParseVector3(@params["flip"]); changes.Add("flip"); } + if (@params["allowRoll"] != null) { renderer.allowRoll = @params["allowRoll"].ToObject(); changes.Add("allowRoll"); } + + if (@params["shadowBias"] != null) { renderer.shadowBias = @params["shadowBias"].ToObject(); changes.Add("shadowBias"); } + + RendererHelpers.ApplyCommonRendererProperties(renderer, @params, changes); + + if (@params["materialPath"] != null) + { + var findInst = new JObject { ["find"] = @params["materialPath"].ToString() }; + Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; + if (mat != null) { renderer.sharedMaterial = mat; changes.Add("material"); } + } + if (@params["trailMaterialPath"] != null) + { + var findInst = new JObject { ["find"] = @params["trailMaterialPath"].ToString() }; + Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; + if (mat != null) { renderer.trailMaterial = mat; changes.Add("trailMaterial"); } + } + + EditorUtility.SetDirty(renderer); + return new { success = true, message = $"Updated renderer: {string.Join(", ", changes)}" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta new file mode 100644 index 000000000..1512de120 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a68818a59fac4e2c83ad23433ddc9c1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs b/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs new file mode 100644 index 000000000..e9626afa2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class TrailControl + { + public static object Clear(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + Undo.RecordObject(tr, "Clear Trail"); + tr.Clear(); + return new { success = true, message = "Trail cleared" }; + } + + public static object Emit(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + +#if UNITY_2021_1_OR_NEWER + Vector3 pos = ManageVfxCommon.ParseVector3(@params["position"]); + tr.AddPosition(pos); + return new { success = true, message = $"Emitted at ({pos.x}, {pos.y}, {pos.z})" }; +#else + return new { success = false, message = "AddPosition requires Unity 2021.1+" }; +#endif + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta new file mode 100644 index 000000000..84a2f3c64 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: edebad99699494d5585418395a2bf518 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs b/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs new file mode 100644 index 000000000..f0abd0aae --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json.Linq; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class TrailRead + { + public static TrailRenderer FindTrailRenderer(JObject @params) + { + GameObject go = ManageVfxCommon.FindTargetGameObject(@params); + return go?.GetComponent(); + } + + public static object GetInfo(JObject @params) + { + TrailRenderer tr = FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + return new + { + success = true, + data = new + { + gameObject = tr.gameObject.name, + time = tr.time, + startWidth = tr.startWidth, + endWidth = tr.endWidth, + minVertexDistance = tr.minVertexDistance, + emitting = tr.emitting, + autodestruct = tr.autodestruct, + positionCount = tr.positionCount, + alignment = tr.alignment.ToString(), + textureMode = tr.textureMode.ToString(), + numCornerVertices = tr.numCornerVertices, + numCapVertices = tr.numCapVertices, + generateLightingData = tr.generateLightingData, + material = tr.sharedMaterial?.name, + shadowCastingMode = tr.shadowCastingMode.ToString(), + receiveShadows = tr.receiveShadows, + lightProbeUsage = tr.lightProbeUsage.ToString(), + reflectionProbeUsage = tr.reflectionProbeUsage.ToString(), + sortingOrder = tr.sortingOrder, + sortingLayerName = tr.sortingLayerName, + renderingLayerMask = tr.renderingLayerMask + } + }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta new file mode 100644 index 000000000..388cd9eef --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2921f0042777b4ebbaec4c79c60908a1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs b/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs new file mode 100644 index 000000000..d65c5368d --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.ManageVfx +{ + internal static class TrailWrite + { + public static object SetTime(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + float time = @params["time"]?.ToObject() ?? 5f; + + Undo.RecordObject(tr, "Set Trail Time"); + tr.time = time; + EditorUtility.SetDirty(tr); + + return new { success = true, message = $"Set trail time to {time}s" }; + } + + public static object SetWidth(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + Undo.RecordObject(tr, "Set Trail Width"); + var changes = new List(); + + RendererHelpers.ApplyWidthProperties(@params, changes, + v => tr.startWidth = v, v => tr.endWidth = v, + v => tr.widthCurve = v, v => tr.widthMultiplier = v, + ManageVfxCommon.ParseAnimationCurve); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetColor(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + Undo.RecordObject(tr, "Set Trail Color"); + var changes = new List(); + + RendererHelpers.ApplyColorProperties(@params, changes, + v => tr.startColor = v, v => tr.endColor = v, + v => tr.colorGradient = v, + ManageVfxCommon.ParseColor, ManageVfxCommon.ParseGradient, fadeEndAlpha: true); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + + public static object SetMaterial(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + return RendererHelpers.SetRendererMaterial(tr, @params, "Set Trail Material", ManageVfxCommon.FindMaterialByPath); + } + + public static object SetProperties(JObject @params) + { + TrailRenderer tr = TrailRead.FindTrailRenderer(@params); + if (tr == null) return new { success = false, message = "TrailRenderer not found" }; + + Undo.RecordObject(tr, "Set Trail Properties"); + var changes = new List(); + + if (@params["minVertexDistance"] != null) { tr.minVertexDistance = @params["minVertexDistance"].ToObject(); changes.Add("minVertexDistance"); } + if (@params["autodestruct"] != null) { tr.autodestruct = @params["autodestruct"].ToObject(); changes.Add("autodestruct"); } + if (@params["emitting"] != null) { tr.emitting = @params["emitting"].ToObject(); changes.Add("emitting"); } + + RendererHelpers.ApplyLineTrailProperties(@params, changes, + null, null, + v => tr.numCornerVertices = v, v => tr.numCapVertices = v, + v => tr.alignment = v, v => tr.textureMode = v, + v => tr.generateLightingData = v); + + RendererHelpers.ApplyCommonRendererProperties(tr, @params, changes); + + EditorUtility.SetDirty(tr); + return new { success = true, message = $"Updated: {string.Join(", ", changes)}" }; + } + } +} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta b/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta new file mode 100644 index 000000000..27ffbfaa0 --- /dev/null +++ b/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 33ba432240c134206a4f71ab24f0fb3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From e68fbb608e8fff27bea691ff264a51140c3b4f17 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 12:10:53 -0400 Subject: [PATCH 05/32] Rename ManageVfx folder to just Vfx We know what it's managing --- MCPForUnity/Editor/Tools/{ManageVfx.meta => Vfx.meta} | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineCreate.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineCreate.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineRead.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineRead.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineWrite.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineWrite.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVFX.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVFX.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVfxCommon.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/ManageVfxCommon.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleCommon.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/ParticleCommon.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleControl.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/ParticleControl.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleRead.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/ParticleRead.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleWrite.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/ParticleWrite.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailControl.cs | 2 +- .../Editor/Tools/{ManageVfx => Vfx}/TrailControl.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailRead.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailRead.cs.meta | 0 MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailWrite.cs | 2 +- MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailWrite.cs.meta | 0 25 files changed, 13 insertions(+), 13 deletions(-) rename MCPForUnity/Editor/Tools/{ManageVfx.meta => Vfx.meta} (77%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineCreate.cs (99%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineCreate.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineRead.cs (97%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineRead.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineWrite.cs (99%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/LineWrite.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVFX.cs (99%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVFX.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVfxCommon.cs (95%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ManageVfxCommon.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleCommon.cs (98%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleCommon.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleControl.cs (99%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleControl.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleRead.cs (98%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleRead.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleWrite.cs (99%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/ParticleWrite.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailControl.cs (96%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailControl.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailRead.cs (97%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailRead.cs.meta (100%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailWrite.cs (98%) rename MCPForUnity/Editor/Tools/{ManageVfx => Vfx}/TrailWrite.cs.meta (100%) diff --git a/MCPForUnity/Editor/Tools/ManageVfx.meta b/MCPForUnity/Editor/Tools/Vfx.meta similarity index 77% rename from MCPForUnity/Editor/Tools/ManageVfx.meta rename to MCPForUnity/Editor/Tools/Vfx.meta index 681a567af..b128ae39c 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx.meta +++ b/MCPForUnity/Editor/Tools/Vfx.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 23895f2c168674806b490c74a69e4b9c +guid: 1805768600c6a4228bae31231f2a4a9f folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs b/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs similarity index 99% rename from MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs rename to MCPForUnity/Editor/Tools/Vfx/LineCreate.cs index 254b5335b..da04509a9 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs +++ b/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs @@ -2,7 +2,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class LineCreate { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta b/MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/LineCreate.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/LineCreate.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs b/MCPForUnity/Editor/Tools/Vfx/LineRead.cs similarity index 97% rename from MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs rename to MCPForUnity/Editor/Tools/Vfx/LineRead.cs index 09c0a45bd..3dd0c06f8 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs +++ b/MCPForUnity/Editor/Tools/Vfx/LineRead.cs @@ -2,7 +2,7 @@ using Newtonsoft.Json.Linq; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class LineRead { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta b/MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/LineRead.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/LineRead.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs b/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs similarity index 99% rename from MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs rename to MCPForUnity/Editor/Tools/Vfx/LineWrite.cs index d4dfee7a3..c4f9aa493 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs @@ -4,7 +4,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class LineWrite { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta b/MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/LineWrite.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/LineWrite.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs similarity index 99% rename from MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs rename to MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs index 2f5a868f4..768eb3409 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs @@ -5,7 +5,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Tools.ManageVfx; +using MCPForUnity.Editor.Tools.Vfx; using UnityEngine; using UnityEditor; diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ManageVFX.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs b/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs similarity index 95% rename from MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs rename to MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs index 255a8c2d6..aa2d668cf 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs @@ -2,7 +2,7 @@ using MCPForUnity.Editor.Helpers; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class ManageVfxCommon { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ManageVfxCommon.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ManageVfxCommon.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs similarity index 98% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs rename to MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs index c7d146961..b94187333 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json.Linq; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class ParticleCommon { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleCommon.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ParticleCommon.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs similarity index 99% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs rename to MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs index 00cd6838a..cab4dca84 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs @@ -3,7 +3,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class ParticleControl { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleControl.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs similarity index 98% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs rename to MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs index 6d0b74921..46506dbe9 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs @@ -2,7 +2,7 @@ using System.Linq; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class ParticleRead { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleRead.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs similarity index 99% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs rename to MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs index a01bbb453..b52e6b143 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs @@ -5,7 +5,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class ParticleWrite { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/ParticleWrite.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs b/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs similarity index 96% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs rename to MCPForUnity/Editor/Tools/Vfx/TrailControl.cs index e9626afa2..18db2ffd8 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs +++ b/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs @@ -2,7 +2,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class TrailControl { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta b/MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailControl.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/TrailControl.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs b/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs similarity index 97% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs rename to MCPForUnity/Editor/Tools/Vfx/TrailRead.cs index f0abd0aae..4fae75aac 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs +++ b/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs @@ -1,7 +1,7 @@ using Newtonsoft.Json.Linq; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class TrailRead { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta b/MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailRead.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/TrailRead.cs.meta diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs b/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs similarity index 98% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs rename to MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs index d65c5368d..06e1a1113 100644 --- a/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs @@ -5,7 +5,7 @@ using UnityEditor; using UnityEngine; -namespace MCPForUnity.Editor.Tools.ManageVfx +namespace MCPForUnity.Editor.Tools.Vfx { internal static class TrailWrite { diff --git a/MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta b/MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageVfx/TrailWrite.cs.meta rename to MCPForUnity/Editor/Tools/Vfx/TrailWrite.cs.meta From 47ba35fb0b5b72d9cd30245c315430efc52b777b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 12:14:43 -0400 Subject: [PATCH 06/32] Clean up whitespace on plugin tools and resources --- .../Editor/Resources/Editor/EditorStateV2.cs | 2 - .../Resources/Scene/GameObjectResource.cs | 1 - MCPForUnity/Editor/Tools/FindGameObjects.cs | 1 - MCPForUnity/Editor/Tools/GetTestJob.cs | 2 - MCPForUnity/Editor/Tools/JsonUtil.cs | 2 - MCPForUnity/Editor/Tools/ManageComponents.cs | 1 - MCPForUnity/Editor/Tools/ManageGameObject.cs | 14 +- MCPForUnity/Editor/Tools/ManageMaterial.cs | 146 +++++++++--------- MCPForUnity/Editor/Tools/RefreshUnity.cs | 2 - MCPForUnity/Editor/Tools/RunTestsAsync.cs | 2 - 10 files changed, 83 insertions(+), 90 deletions(-) diff --git a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs index 33ac97058..d131bdbf0 100644 --- a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs +++ b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs @@ -25,5 +25,3 @@ public static object HandleCommand(JObject @params) } } } - - diff --git a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs index c04e956b5..2588349f5 100644 --- a/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs +++ b/MCPForUnity/Editor/Resources/Scene/GameObjectResource.cs @@ -282,4 +282,3 @@ public static object HandleCommand(JObject @params) } } } - diff --git a/MCPForUnity/Editor/Tools/FindGameObjects.cs b/MCPForUnity/Editor/Tools/FindGameObjects.cs index 890865a76..f4b951549 100644 --- a/MCPForUnity/Editor/Tools/FindGameObjects.cs +++ b/MCPForUnity/Editor/Tools/FindGameObjects.cs @@ -70,4 +70,3 @@ public static object HandleCommand(JObject @params) } } } - diff --git a/MCPForUnity/Editor/Tools/GetTestJob.cs b/MCPForUnity/Editor/Tools/GetTestJob.cs index 8983155d1..f618b7edd 100644 --- a/MCPForUnity/Editor/Tools/GetTestJob.cs +++ b/MCPForUnity/Editor/Tools/GetTestJob.cs @@ -50,5 +50,3 @@ public static object HandleCommand(JObject @params) } } } - - diff --git a/MCPForUnity/Editor/Tools/JsonUtil.cs b/MCPForUnity/Editor/Tools/JsonUtil.cs index 4225954e2..74b745d56 100644 --- a/MCPForUnity/Editor/Tools/JsonUtil.cs +++ b/MCPForUnity/Editor/Tools/JsonUtil.cs @@ -29,5 +29,3 @@ internal static void CoerceJsonStringParameter(JObject @params, string paramName } } } - - diff --git a/MCPForUnity/Editor/Tools/ManageComponents.cs b/MCPForUnity/Editor/Tools/ManageComponents.cs index 7f5a8b0b7..2564e674b 100644 --- a/MCPForUnity/Editor/Tools/ManageComponents.cs +++ b/MCPForUnity/Editor/Tools/ManageComponents.cs @@ -328,4 +328,3 @@ private static string TrySetProperty(Component component, string propertyName, J #endregion } } - diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs index 4a01007ca..9e7fd8756 100644 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/ManageGameObject.cs @@ -348,7 +348,7 @@ private static object CreateGameObject(JObject @params) return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}."); } } - + try { newGo.tag = tag; @@ -589,7 +589,7 @@ string searchMethod { // Ensure the tag is not empty, if empty, it means "Untagged" implicitly string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - + // Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning) if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet)) { @@ -603,7 +603,7 @@ string searchMethod return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}."); } } - + try { targetGo.tag = tagToSet; @@ -835,7 +835,7 @@ private static object DuplicateGameObject(JObject @params, JToken targetToken, s // Handle parent if (parentToken != null) { - if (parentToken.Type == JTokenType.Null || + if (parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) { // Explicit null parent - move to root @@ -1509,7 +1509,7 @@ private static bool SetProperty(object target, string memberName, JToken value) } // Try both original and normalized names - PropertyInfo propInfo = type.GetProperty(memberName, flags) + PropertyInfo propInfo = type.GetProperty(memberName, flags) ?? type.GetProperty(normalizedName, flags); if (propInfo != null && propInfo.CanWrite) { @@ -1528,7 +1528,7 @@ private static bool SetProperty(object target, string memberName, JToken value) else { // Try both original and normalized names for fields - FieldInfo fieldInfo = type.GetField(memberName, flags) + FieldInfo fieldInfo = type.GetField(memberName, flags) ?? type.GetField(normalizedName, flags); if (fieldInfo != null) // Check if !IsLiteral? { @@ -2061,4 +2061,4 @@ private static int LevenshteinDistance(string s1, string s2) // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. // They are now in Helpers.GameObjectSerializer } -} \ No newline at end of file +} diff --git a/MCPForUnity/Editor/Tools/ManageMaterial.cs b/MCPForUnity/Editor/Tools/ManageMaterial.cs index 0c61e4d8c..3904017b4 100644 --- a/MCPForUnity/Editor/Tools/ManageMaterial.cs +++ b/MCPForUnity/Editor/Tools/ManageMaterial.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Helpers; -using UnityEngine; using UnityEditor; +using UnityEngine; namespace MCPForUnity.Editor.Tools { @@ -28,16 +28,16 @@ public static object HandleCommand(JObject @params) case "create": return CreateMaterial(@params); - + case "set_material_shader_property": return SetMaterialShaderProperty(@params); - + case "set_material_color": return SetMaterialColor(@params); case "assign_material_to_renderer": return AssignMaterialToRenderer(@params); - + case "set_renderer_color": return SetRendererColor(@params); @@ -57,7 +57,7 @@ public static object HandleCommand(JObject @params) private static string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) return path; - + // Normalize separators and ensure Assets/ root path = AssetPathUtility.SanitizeAssetPath(path); @@ -66,7 +66,7 @@ private static string NormalizePath(string path) { path += ".mat"; } - + return path; } @@ -94,23 +94,23 @@ private static object SetMaterialShaderProperty(JObject @params) // Normalize alias/casing once for all code paths property = MaterialOps.ResolvePropertyName(mat, property); - + // 1. Try handling Texture instruction explicitly (ManageMaterial special feature) if (value.Type == JTokenType.Object) { - // Check if it looks like an instruction - if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method"))) - { - Texture tex = ObjectResolver.Resolve(obj, typeof(Texture)) as Texture; - if (tex != null && mat.HasProperty(property)) - { - mat.SetTexture(property, tex); - EditorUtility.SetDirty(mat); - return new SuccessResponse($"Set texture property {property} on {mat.name}"); - } - } - } - + // Check if it looks like an instruction + if (value is JObject obj && (obj.ContainsKey("find") || obj.ContainsKey("method"))) + { + Texture tex = ObjectResolver.Resolve(obj, typeof(Texture)) as Texture; + if (tex != null && mat.HasProperty(property)) + { + mat.SetTexture(property, tex); + EditorUtility.SetDirty(mat); + return new SuccessResponse($"Set texture property {property} on {mat.name}"); + } + } + } + // 2. Fallback to standard logic via MaterialOps (handles Colors, Floats, Strings->Path) bool success = MaterialOps.TrySetShaderProperty(mat, property, value, UnityJsonSerializer.Instance); @@ -145,7 +145,7 @@ private static object SetMaterialColor(JObject @params) } Color color; - try + try { color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance); } @@ -199,7 +199,7 @@ private static object AssignMaterialToRenderer(JObject @params) string searchMethod = @params["searchMethod"]?.ToString(); string materialPath = NormalizePath(@params["materialPath"]?.ToString()); int slot = @params["slot"]?.ToObject() ?? 0; - + if (string.IsNullOrEmpty(target) || string.IsNullOrEmpty(materialPath)) { return new ErrorResponse("target and materialPath are required"); @@ -207,7 +207,7 @@ private static object AssignMaterialToRenderer(JObject @params) var goInstruction = new JObject { ["find"] = target }; if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; - + GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { @@ -217,7 +217,7 @@ private static object AssignMaterialToRenderer(JObject @params) Renderer renderer = go.GetComponent(); if (renderer == null) { - return new ErrorResponse($"GameObject {go.name} has no Renderer component"); + return new ErrorResponse($"GameObject {go.name} has no Renderer component"); } var matInstruction = new JObject { ["find"] = materialPath }; @@ -232,11 +232,11 @@ private static object AssignMaterialToRenderer(JObject @params) Material[] sharedMats = renderer.sharedMaterials; if (slot < 0 || slot >= sharedMats.Length) { - return new ErrorResponse($"Slot {slot} out of bounds (count: {sharedMats.Length})"); + return new ErrorResponse($"Slot {slot} out of bounds (count: {sharedMats.Length})"); } sharedMats[slot] = mat; - renderer.sharedMaterials = sharedMats; + renderer.sharedMaterials = sharedMats; EditorUtility.SetDirty(renderer); return new SuccessResponse($"Assigned material {mat.name} to {go.name} slot {slot}"); @@ -248,7 +248,7 @@ private static object SetRendererColor(JObject @params) string searchMethod = @params["searchMethod"]?.ToString(); JToken colorToken = @params["color"]; int slot = @params["slot"]?.ToObject() ?? 0; - string mode = @params["mode"]?.ToString() ?? "property_block"; + string mode = @params["mode"]?.ToString() ?? "property_block"; if (string.IsNullOrEmpty(target) || colorToken == null) { @@ -256,7 +256,7 @@ private static object SetRendererColor(JObject @params) } Color color; - try + try { color = MaterialOps.ParseColor(colorToken, UnityJsonSerializer.Instance); } @@ -267,7 +267,7 @@ private static object SetRendererColor(JObject @params) var goInstruction = new JObject { ["find"] = target }; if (!string.IsNullOrEmpty(searchMethod)) goInstruction["method"] = searchMethod; - + GameObject go = ObjectResolver.Resolve(goInstruction, typeof(GameObject)) as GameObject; if (go == null) { @@ -277,7 +277,7 @@ private static object SetRendererColor(JObject @params) Renderer renderer = go.GetComponent(); if (renderer == null) { - return new ErrorResponse($"GameObject {go.name} has no Renderer component"); + return new ErrorResponse($"GameObject {go.name} has no Renderer component"); } if (mode == "property_block") @@ -289,7 +289,7 @@ private static object SetRendererColor(JObject @params) MaterialPropertyBlock block = new MaterialPropertyBlock(); renderer.GetPropertyBlock(block, slot); - + if (renderer.sharedMaterials[slot] != null) { Material mat = renderer.sharedMaterials[slot]; @@ -301,7 +301,7 @@ private static object SetRendererColor(JObject @params) { block.SetColor("_Color", color); } - + renderer.SetPropertyBlock(block, slot); EditorUtility.SetDirty(renderer); return new SuccessResponse($"Set renderer color (PropertyBlock) on slot {slot}"); @@ -310,16 +310,16 @@ private static object SetRendererColor(JObject @params) { if (slot >= 0 && slot < renderer.sharedMaterials.Length) { - Material mat = renderer.sharedMaterials[slot]; - if (mat == null) - { - return new ErrorResponse($"No material in slot {slot}"); - } - Undo.RecordObject(mat, "Set Material Color"); - if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); - else mat.SetColor("_Color", color); - EditorUtility.SetDirty(mat); - return new SuccessResponse("Set shared material color"); + Material mat = renderer.sharedMaterials[slot]; + if (mat == null) + { + return new ErrorResponse($"No material in slot {slot}"); + } + Undo.RecordObject(mat, "Set Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + EditorUtility.SetDirty(mat); + return new SuccessResponse("Set shared material color"); } return new ErrorResponse("Invalid slot"); } @@ -327,20 +327,20 @@ private static object SetRendererColor(JObject @params) { if (slot >= 0 && slot < renderer.materials.Length) { - Material mat = renderer.materials[slot]; - if (mat == null) - { - return new ErrorResponse($"No material in slot {slot}"); - } - // Note: Undo cannot fully revert material instantiation - Undo.RecordObject(mat, "Set Instance Material Color"); - if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); - else mat.SetColor("_Color", color); - return new SuccessResponse("Set instance material color", new { warning = "Material instance created; Undo cannot fully revert instantiation." }); - } - return new ErrorResponse("Invalid slot"); - } - + Material mat = renderer.materials[slot]; + if (mat == null) + { + return new ErrorResponse($"No material in slot {slot}"); + } + // Note: Undo cannot fully revert material instantiation + Undo.RecordObject(mat, "Set Instance Material Color"); + if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color); + else mat.SetColor("_Color", color); + return new SuccessResponse("Set instance material color", new { warning = "Material instance created; Undo cannot fully revert instantiation." }); + } + return new ErrorResponse("Invalid slot"); + } + return new ErrorResponse($"Unknown mode: {mode}"); } @@ -349,7 +349,7 @@ private static object GetMaterialInfo(JObject @params) string materialPath = NormalizePath(@params["materialPath"]?.ToString()); if (string.IsNullOrEmpty(materialPath)) { - return new ErrorResponse("materialPath is required"); + return new ErrorResponse("materialPath is required"); } var findInstruction = new JObject { ["find"] = materialPath }; @@ -359,7 +359,7 @@ private static object GetMaterialInfo(JObject @params) { return new ErrorResponse($"Could not find material at path: {materialPath}"); } - + Shader shader = mat.shader; var properties = new List(); @@ -416,17 +416,19 @@ private static object GetMaterialInfo(JObject @params) string name = ShaderUtil.GetPropertyName(shader, i); ShaderUtil.ShaderPropertyType type = ShaderUtil.GetPropertyType(shader, i); string description = ShaderUtil.GetPropertyDescription(shader, i); - + object currentValue = null; - try { + try + { if (mat.HasProperty(name)) { - switch (type) { - case ShaderUtil.ShaderPropertyType.Color: + switch (type) + { + case ShaderUtil.ShaderPropertyType.Color: var c = mat.GetColor(name); currentValue = new { r = c.r, g = c.g, b = c.b, a = c.a }; break; - case ShaderUtil.ShaderPropertyType.Vector: + case ShaderUtil.ShaderPropertyType.Vector: var v = mat.GetVector(name); currentValue = new { x = v.x, y = v.y, z = v.z, w = v.w }; break; @@ -435,11 +437,14 @@ private static object GetMaterialInfo(JObject @params) case ShaderUtil.ShaderPropertyType.TexEnv: currentValue = mat.GetTexture(name)?.name ?? "null"; break; } } - } catch (Exception ex) { + } + catch (Exception ex) + { currentValue = $""; } - - properties.Add(new { + + properties.Add(new + { name = name, type = type.ToString(), description = description, @@ -448,7 +453,8 @@ private static object GetMaterialInfo(JObject @params) } #endif - return new SuccessResponse($"Retrieved material info for {mat.name}", new { + return new SuccessResponse($"Retrieved material info for {mat.name}", new + { material = mat.name, shader = shader.name, properties = properties @@ -461,7 +467,7 @@ private static object CreateMaterial(JObject @params) string shaderName = @params["shader"]?.ToString() ?? "Standard"; JToken colorToken = @params["color"]; string colorProperty = @params["property"]?.ToString(); - + JObject properties = null; JToken propsToken = @params["properties"]; if (propsToken != null) @@ -486,7 +492,7 @@ private static object CreateMaterial(JObject @params) // This check catches edge cases where normalization might fail if (!materialPath.StartsWith("Assets/")) { - return new ErrorResponse($"Invalid path '{materialPath}'. Path must be within Assets/ folder."); + return new ErrorResponse($"Invalid path '{materialPath}'. Path must be within Assets/ folder."); } Shader shader = RenderPipelineUtility.ResolveShader(shaderName); diff --git a/MCPForUnity/Editor/Tools/RefreshUnity.cs b/MCPForUnity/Editor/Tools/RefreshUnity.cs index 7dd2f0df4..f33164173 100644 --- a/MCPForUnity/Editor/Tools/RefreshUnity.cs +++ b/MCPForUnity/Editor/Tools/RefreshUnity.cs @@ -171,5 +171,3 @@ void Tick() } } } - - diff --git a/MCPForUnity/Editor/Tools/RunTestsAsync.cs b/MCPForUnity/Editor/Tools/RunTestsAsync.cs index 5550c1f43..3353cb8be 100644 --- a/MCPForUnity/Editor/Tools/RunTestsAsync.cs +++ b/MCPForUnity/Editor/Tools/RunTestsAsync.cs @@ -124,5 +124,3 @@ string[] ParseStringArray(string key) } } } - - From 8321db281a310e98555d5441f104f2db0cf1e0fc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 13:46:52 -0400 Subject: [PATCH 07/32] Make ManageGameObject less of a monolith by splitting it out into different files --- MCPForUnity/Editor/Tools/GameObjects.meta | 8 + .../Tools/GameObjects/ComponentResolver.cs | 142 ++ .../GameObjects/ComponentResolver.cs.meta | 11 + .../GameObjects/GameObjectComponentHelpers.cs | 410 ++++ .../GameObjectComponentHelpers.cs.meta | 11 + .../Tools/GameObjects/GameObjectCreate.cs | 309 +++ .../GameObjects/GameObjectCreate.cs.meta | 11 + .../Tools/GameObjects/GameObjectDelete.cs | 45 + .../GameObjects/GameObjectDelete.cs.meta | 11 + .../Tools/GameObjects/GameObjectDuplicate.cs | 86 + .../GameObjects/GameObjectDuplicate.cs.meta | 11 + .../Tools/GameObjects/GameObjectHandlers.cs | 22 + .../GameObjects/GameObjectHandlers.cs.meta | 11 + .../Tools/GameObjects/GameObjectModify.cs | 237 ++ .../GameObjects/GameObjectModify.cs.meta | 11 + .../GameObjects/GameObjectMoveRelative.cs | 119 + .../GameObjectMoveRelative.cs.meta | 11 + .../Tools/GameObjects/ManageGameObject.cs | 130 ++ .../ManageGameObject.cs.meta | 0 .../GameObjects/ManageGameObjectCommon.cs | 210 ++ .../ManageGameObjectCommon.cs.meta | 11 + MCPForUnity/Editor/Tools/ManageAsset.cs | 4 +- MCPForUnity/Editor/Tools/ManageGameObject.cs | 2064 ----------------- MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs | 3 +- .../Tools/GameObjectAPIStressTests.cs | 1 + .../EditMode/Tools/MCPToolParameterTests.cs | 1 + .../Tools/ManageGameObjectCreateTests.cs | 2 +- .../Tools/ManageGameObjectDeleteTests.cs | 2 +- .../Tools/ManageGameObjectModifyTests.cs | 2 +- .../EditMode/Tools/ManageGameObjectTests.cs | 1 + .../Tools/MaterialParameterToolTests.cs | 1 + 31 files changed, 1828 insertions(+), 2070 deletions(-) create mode 100644 MCPForUnity/Editor/Tools/GameObjects.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs rename MCPForUnity/Editor/Tools/{ => GameObjects}/ManageGameObject.cs.meta (100%) create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs create mode 100644 MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta delete mode 100644 MCPForUnity/Editor/Tools/ManageGameObject.cs diff --git a/MCPForUnity/Editor/Tools/GameObjects.meta b/MCPForUnity/Editor/Tools/GameObjects.meta new file mode 100644 index 000000000..a1ba389ed --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b61d0e8082ed14c1fb500648007bba7a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs b/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs new file mode 100644 index 000000000..589374a15 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs @@ -0,0 +1,142 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools +{ + /// + /// Component resolver that delegates to UnityTypeResolver. + /// Kept for backwards compatibility. + /// + internal static class ComponentResolver + { + /// + /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. + /// Delegates to UnityTypeResolver.TryResolve with Component constraint. + /// + public static bool TryResolve(string nameOrFullName, out Type type, out string error) + { + return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component)); + } + + /// + /// Gets all accessible property and field names from a component type. + /// + public static List GetAllComponentProperties(Type componentType) + { + if (componentType == null) return new List(); + + var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => p.Name); + + var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) + .Where(f => !f.IsInitOnly && !f.IsLiteral) + .Select(f => f.Name); + + // Also include SerializeField private fields (common in Unity) + var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.GetCustomAttribute() != null) + .Select(f => f.Name); + + return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); + } + + /// + /// Suggests the most likely property matches for a user's input using fuzzy matching. + /// Uses Levenshtein distance, substring matching, and common naming pattern heuristics. + /// + public static List GetFuzzyPropertySuggestions(string userInput, List availableProperties) + { + if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) + return new List(); + + var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; + if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); + PropertySuggestionCache[cacheKey] = suggestions; + return suggestions; + } + catch (Exception ex) + { + McpLog.Warn($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); + return new List(); + } + } + + private static readonly Dictionary> PropertySuggestionCache = new(); + + /// + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. + /// + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) + { + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) + { + var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + if (cleanedProperty == cleanedInput) + { + suggestions.Add(property); + continue; + } + + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) + { + suggestions.Add(property); + continue; + } + + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); + } + } + + return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) + .Take(3) + .ToList(); + } + + /// + /// Calculates Levenshtein distance between two strings for similarity matching. + /// + private static int LevenshteinDistance(string s1, string s2) + { + if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; + if (string.IsNullOrEmpty(s2)) return s1.Length; + + var matrix = new int[s1.Length + 1, s2.Length + 1]; + + for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; + for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; + + for (int i = 1; i <= s1.Length; i++) + { + for (int j = 1; j <= s2.Length; j++) + { + int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, + matrix[i, j - 1] + 1), + matrix[i - 1, j - 1] + cost); + } + } + + return matrix[s1.Length, s2.Length]; + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta new file mode 100644 index 000000000..6738443d2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/ComponentResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f5e5a46bdebc040c68897fa4b5e689c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs new file mode 100644 index 000000000..e37d70a64 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs @@ -0,0 +1,410 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using MCPForUnity.Runtime.Serialization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectComponentHelpers + { + internal static object AddComponentInternal(GameObject targetGo, string typeName, JObject properties) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return new ErrorResponse($"Component type '{typeName}' not found or is not a valid Component."); + } + if (!typeof(Component).IsAssignableFrom(componentType)) + { + return new ErrorResponse($"Type '{typeName}' is not a Component."); + } + + if (componentType == typeof(Transform)) + { + return new ErrorResponse("Cannot add another Transform component."); + } + + bool isAdding2DPhysics = typeof(Rigidbody2D).IsAssignableFrom(componentType) || typeof(Collider2D).IsAssignableFrom(componentType); + bool isAdding3DPhysics = typeof(Rigidbody).IsAssignableFrom(componentType) || typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) + { + return new ErrorResponse($"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider."); + } + } + else if (isAdding3DPhysics) + { + if (targetGo.GetComponent() != null || targetGo.GetComponent() != null) + { + return new ErrorResponse($"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider."); + } + } + + try + { + Component newComponent = Undo.AddComponent(targetGo, componentType); + if (newComponent == null) + { + return new ErrorResponse($"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." + ); + } + + if (newComponent is Light light) + { + light.type = LightType.Directional; + } + + if (properties != null) + { + var setResult = SetComponentPropertiesInternal(targetGo, typeName, properties, newComponent); + if (setResult != null) + { + Undo.DestroyObjectImmediate(newComponent); + return setResult; + } + } + + return null; + } + catch (Exception e) + { + return new ErrorResponse($"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}"); + } + } + + internal static object RemoveComponentInternal(GameObject targetGo, string typeName) + { + if (targetGo == null) + { + return new ErrorResponse("Target GameObject is null."); + } + + Type componentType = FindType(typeName); + if (componentType == null) + { + return new ErrorResponse($"Component type '{typeName}' not found for removal."); + } + + if (componentType == typeof(Transform)) + { + return new ErrorResponse("Cannot remove the Transform component."); + } + + Component componentToRemove = targetGo.GetComponent(componentType); + if (componentToRemove == null) + { + return new ErrorResponse($"Component '{typeName}' not found on '{targetGo.name}' to remove."); + } + + try + { + Undo.DestroyObjectImmediate(componentToRemove); + return null; + } + catch (Exception e) + { + return new ErrorResponse($"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}"); + } + } + + internal static object SetComponentPropertiesInternal(GameObject targetGo, string componentTypeName, JObject properties, Component targetComponentInstance = null) + { + Component targetComponent = targetComponentInstance; + if (targetComponent == null) + { + if (ComponentResolver.TryResolve(componentTypeName, out var compType, out var compError)) + { + targetComponent = targetGo.GetComponent(compType); + } + else + { + targetComponent = targetGo.GetComponent(componentTypeName); + } + } + if (targetComponent == null) + { + return new ErrorResponse($"Component '{componentTypeName}' not found on '{targetGo.name}' to set properties."); + } + + Undo.RecordObject(targetComponent, "Set Component Properties"); + + var failures = new List(); + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + + try + { + bool setResult = SetProperty(targetComponent, propName, propValue); + if (!setResult) + { + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties); + var msg = suggestions.Any() + ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" + : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; + McpLog.Warn($"[ManageGameObject] {msg}"); + failures.Add(msg); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageGameObject] Error setting property '{propName}' on '{componentTypeName}': {e.Message}"); + failures.Add($"Error setting '{propName}': {e.Message}"); + } + } + + EditorUtility.SetDirty(targetComponent); + return failures.Count == 0 + ? null + : new ErrorResponse($"One or more properties failed on '{componentTypeName}'.", new { errors = failures }); + } + + private static JsonSerializer InputSerializer => UnityJsonSerializer.Instance; + + private static bool SetProperty(object target, string memberName, JToken value) + { + Type type = target.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + string normalizedName = Helpers.ParamCoercion.NormalizePropertyName(memberName); + var inputSerializer = InputSerializer; + + try + { + if (memberName.Contains('.') || memberName.Contains('[')) + { + return SetNestedProperty(target, memberName, value, inputSerializer); + } + + PropertyInfo propInfo = type.GetProperty(memberName, flags) ?? type.GetProperty(normalizedName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + FieldInfo fieldInfo = type.GetField(memberName, flags) ?? type.GetField(normalizedName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase) + ?? type.GetField(normalizedName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (npField != null && npField.GetCustomAttribute() != null) + { + object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + npField.SetValue(target, convertedValue); + return true; + } + } + } + } + } + catch (Exception ex) + { + McpLog.Error($"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}"); + } + return false; + } + + private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) + { + try + { + string[] pathParts = SplitPropertyPath(path); + if (pathParts.Length == 0) + return false; + + object currentObject = target; + Type currentType = currentObject.GetType(); + BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + for (int i = 0; i < pathParts.Length - 1; i++) + { + string part = pathParts[i]; + bool isArray = false; + int arrayIndex = -1; + + if (part.Contains("[")) + { + int startBracket = part.IndexOf('['); + int endBracket = part.IndexOf(']'); + if (startBracket > 0 && endBracket > startBracket) + { + string indexStr = part.Substring(startBracket + 1, endBracket - startBracket - 1); + if (int.TryParse(indexStr, out arrayIndex)) + { + isArray = true; + part = part.Substring(0, startBracket); + } + } + } + + PropertyInfo propInfo = currentType.GetProperty(part, flags); + FieldInfo fieldInfo = null; + if (propInfo == null) + { + fieldInfo = currentType.GetField(part, flags); + if (fieldInfo == null) + { + McpLog.Warn($"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'"); + return false; + } + } + + currentObject = propInfo != null ? propInfo.GetValue(currentObject) : fieldInfo.GetValue(currentObject); + if (currentObject == null) + { + McpLog.Warn($"[SetNestedProperty] Property '{part}' is null, cannot access nested properties."); + return false; + } + + if (isArray) + { + if (currentObject is Material[]) + { + var materials = currentObject as Material[]; + if (arrayIndex < 0 || arrayIndex >= materials.Length) + { + McpLog.Warn($"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})"); + return false; + } + currentObject = materials[arrayIndex]; + } + else if (currentObject is System.Collections.IList) + { + var list = currentObject as System.Collections.IList; + if (arrayIndex < 0 || arrayIndex >= list.Count) + { + McpLog.Warn($"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})"); + return false; + } + currentObject = list[arrayIndex]; + } + else + { + McpLog.Warn($"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index."); + return false; + } + } + + currentType = currentObject.GetType(); + } + + string finalPart = pathParts[pathParts.Length - 1]; + + if (currentObject is Material material && finalPart.StartsWith("_")) + { + return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer); + } + + PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); + if (finalPropInfo != null && finalPropInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalPropInfo.SetValue(currentObject, convertedValue); + return true; + } + } + else + { + FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); + if (finalFieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalFieldInfo.SetValue(currentObject, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + McpLog.Error($"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}"); + } + + return false; + } + + private static string[] SplitPropertyPath(string path) + { + List parts = new List(); + int startIndex = 0; + bool inBrackets = false; + + for (int i = 0; i < path.Length; i++) + { + char c = path[i]; + + if (c == '[') + { + inBrackets = true; + } + else if (c == ']') + { + inBrackets = false; + } + else if (c == '.' && !inBrackets) + { + parts.Add(path.Substring(startIndex, i - startIndex)); + startIndex = i + 1; + } + } + if (startIndex < path.Length) + { + parts.Add(path.Substring(startIndex)); + } + return parts.ToArray(); + } + + private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) + { + return PropertyConversion.ConvertToType(token, targetType); + } + + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + return resolvedType; + } + + if (!string.IsNullOrEmpty(error)) + { + McpLog.Warn($"[FindType] {error}"); + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta new file mode 100644 index 000000000..b22c165d3 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectComponentHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b580af06e2d3a4788960f3f779edac54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs new file mode 100644 index 000000000..a94ed10c6 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs @@ -0,0 +1,309 @@ +#nullable disable +using System; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectCreate + { + internal static object Handle(JObject @params) + { + string name = @params["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return new ErrorResponse("'name' parameter is required for 'create' action."); + } + + // Get prefab creation parameters + bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; + string prefabPath = @params["prefabPath"]?.ToString(); + string tag = @params["tag"]?.ToString(); + string primitiveType = @params["primitiveType"]?.ToString(); + GameObject newGo = null; + + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; + if (!string.IsNullOrEmpty(prefabPath)) + { + if (!prefabPath.Contains("/") && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + string prefabNameOnly = prefabPath; + McpLog.Info($"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'"); + string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); + if (guids.Length == 0) + { + return new ErrorResponse($"Prefab named '{prefabNameOnly}' not found anywhere in the project."); + } + else if (guids.Length > 1) + { + string foundPaths = string.Join(", ", guids.Select(g => AssetDatabase.GUIDToAssetPath(g))); + return new ErrorResponse($"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path."); + } + else + { + prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); + McpLog.Info($"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'"); + } + } + else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + McpLog.Warn($"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending."); + prefabPath += ".prefab"; + } + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) + { + try + { + newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; + + if (newGo == null) + { + McpLog.Error($"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject."); + return new ErrorResponse($"Failed to instantiate prefab at '{prefabPath}'."); + } + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + Undo.RegisterCreatedObjectUndo(newGo, $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'"); + McpLog.Info($"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'."); + } + catch (Exception e) + { + return new ErrorResponse($"Error instantiating prefab '{prefabPath}': {e.Message}"); + } + } + else + { + McpLog.Warn($"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified."); + } + } + + // --- Fallback: Create Primitive or Empty GameObject --- + bool createdNewObject = false; + if (newGo == null) + { + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType)Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse("'name' parameter is required when creating a primitive."); + } + createdNewObject = true; + } + catch (ArgumentException) + { + return new ErrorResponse($"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}"); + } + catch (Exception e) + { + return new ErrorResponse($"Failed to create primitive '{primitiveType}': {e.Message}"); + } + } + else + { + if (string.IsNullOrEmpty(name)) + { + return new ErrorResponse("'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive."); + } + newGo = new GameObject(name); + createdNewObject = true; + } + + if (createdNewObject) + { + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); + } + } + + if (newGo == null) + { + return new ErrorResponse("Failed to create or instantiate the GameObject."); + } + + Undo.RecordObject(newGo.transform, "Set GameObject Transform"); + Undo.RecordObject(newGo, "Set GameObject Properties"); + + // Set Parent + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject parentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if (parentGo == null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Parent specified ('{parentToken}') but not found."); + } + newGo.transform.SetParent(parentGo.transform, true); + } + + // Set Transform + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); + + if (position.HasValue) newGo.transform.localPosition = position.Value; + if (rotation.HasValue) newGo.transform.localEulerAngles = rotation.Value; + if (scale.HasValue) newGo.transform.localScale = scale.Value; + + // Set Tag + if (!string.IsNullOrEmpty(tag)) + { + if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag)) + { + McpLog.Info($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it."); + try + { + InternalEditorUtility.AddTag(tag); + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}."); + } + } + + try + { + newGo.tag = tag; + } + catch (Exception ex) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}."); + } + } + + // Set Layer + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newGo.layer = layerId; + } + else + { + McpLog.Warn($"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer."); + } + } + + // Add Components + if (@params["componentsToAdd"] is JArray componentsToAddArray) + { + foreach (var compToken in componentsToAddArray) + { + string typeName = null; + JObject properties = null; + + if (compToken.Type == JTokenType.String) + { + typeName = compToken.ToString(); + } + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = GameObjectComponentHelpers.AddComponentInternal(newGo, typeName, properties); + if (addResult != null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return addResult; + } + } + else + { + McpLog.Warn($"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}"); + } + } + } + + // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true + GameObject finalInstance = newGo; + if (createdNewObject && saveAsPrefab) + { + string finalPrefabPath = prefabPath; + if (string.IsNullOrEmpty(finalPrefabPath)) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse("'prefabPath' is required when 'saveAsPrefab' is true and creating a new object."); + } + if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + McpLog.Info($"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'"); + finalPrefabPath += ".prefab"; + } + + try + { + string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); + if (!string.IsNullOrEmpty(directoryPath) && !System.IO.Directory.Exists(directoryPath)) + { + System.IO.Directory.CreateDirectory(directoryPath); + AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); + McpLog.Info($"[ManageGameObject.Create] Created directory for prefab: {directoryPath}"); + } + + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect(newGo, finalPrefabPath, InteractionMode.UserAction); + + if (finalInstance == null) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions."); + } + McpLog.Info($"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected."); + } + catch (Exception e) + { + UnityEngine.Object.DestroyImmediate(newGo); + return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + } + } + + Selection.activeGameObject = finalInstance; + + string messagePrefabPath = + finalInstance == null + ? originalPrefabPath + : AssetDatabase.GetAssetPath(PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) ?? (UnityEngine.Object)finalInstance); + + string successMessage; + if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) + { + successMessage = $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; + } + else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) + { + successMessage = $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; + } + else + { + successMessage = $"GameObject '{finalInstance.name}' created successfully in scene."; + } + + return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta new file mode 100644 index 000000000..7c6589b68 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectCreate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0931774a07e4b4626b4261dd8d0974c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs new file mode 100644 index 000000000..d4516166e --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs @@ -0,0 +1,45 @@ +#nullable disable +using System.Collections.Generic; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectDelete + { + internal static object Handle(JToken targetToken, string searchMethod) + { + List targets = ManageGameObjectCommon.FindObjectsInternal(targetToken, searchMethod, true); + + if (targets.Count == 0) + { + return new ErrorResponse($"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + List deletedObjects = new List(); + foreach (var targetGo in targets) + { + if (targetGo != null) + { + string goName = targetGo.name; + int goId = targetGo.GetInstanceID(); + Undo.DestroyObjectImmediate(targetGo); + deletedObjects.Add(new { name = goName, instanceID = goId }); + } + } + + if (deletedObjects.Count > 0) + { + string message = + targets.Count == 1 + ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." + : $"{deletedObjects.Count} GameObjects deleted successfully."; + return new SuccessResponse(message, deletedObjects); + } + + return new ErrorResponse("Failed to delete target GameObject(s)."); + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta new file mode 100644 index 000000000..7d95eabd7 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 505a482aaf60b415abd794737a630b10 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs new file mode 100644 index 000000000..39c5737e3 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs @@ -0,0 +1,86 @@ +#nullable disable +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectDuplicate + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + GameObject sourceGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod); + if (sourceGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + string newName = @params["new_name"]?.ToString(); + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? offset = VectorParsing.ParseVector3(@params["offset"]); + JToken parentToken = @params["parent"]; + + GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo); + Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}"); + + if (!string.IsNullOrEmpty(newName)) + { + duplicatedGo.name = newName; + } + else + { + duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy"; + } + + if (position.HasValue) + { + duplicatedGo.transform.position = position.Value; + } + else if (offset.HasValue) + { + duplicatedGo.transform.position = sourceGo.transform.position + offset.Value; + } + + if (parentToken != null) + { + if (parentToken.Type == JTokenType.Null || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) + { + duplicatedGo.transform.SetParent(null); + } + else + { + GameObject newParent = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if (newParent != null) + { + duplicatedGo.transform.SetParent(newParent.transform, true); + } + else + { + McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Keeping original parent."); + } + } + } + else + { + duplicatedGo.transform.SetParent(sourceGo.transform.parent, true); + } + + EditorUtility.SetDirty(duplicatedGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + Selection.activeGameObject = duplicatedGo; + + return new SuccessResponse( + $"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.", + new + { + originalName = sourceGo.name, + originalId = sourceGo.GetInstanceID(), + duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo) + } + ); + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta new file mode 100644 index 000000000..95811e907 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 698728d56425a47af92a45377031a48b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs new file mode 100644 index 000000000..bca36879d --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs @@ -0,0 +1,22 @@ +#nullable disable +using Newtonsoft.Json.Linq; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectHandlers + { + internal static object Create(JObject @params) => GameObjectCreate.Handle(@params); + + internal static object Modify(JObject @params, JToken targetToken, string searchMethod) + => GameObjectModify.Handle(@params, targetToken, searchMethod); + + internal static object Delete(JToken targetToken, string searchMethod) + => GameObjectDelete.Handle(targetToken, searchMethod); + + internal static object Duplicate(JObject @params, JToken targetToken, string searchMethod) + => GameObjectDuplicate.Handle(@params, targetToken, searchMethod); + + internal static object MoveRelative(JObject @params, JToken targetToken, string searchMethod) + => GameObjectMoveRelative.Handle(@params, targetToken, searchMethod); + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta new file mode 100644 index 000000000..90df456d8 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectHandlers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f3cf2313460d44a09b258d2ee04c5ef0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs new file mode 100644 index 000000000..fab9ed7d2 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -0,0 +1,237 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectModify + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); + Undo.RecordObject(targetGo, "Modify GameObject Properties"); + + bool modified = false; + + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) + { + targetGo.name = name; + modified = true; + } + + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject newParentGo = ManageGameObjectCommon.FindObjectInternal(parentToken, "by_id_or_name_or_path"); + if ( + newParentGo == null + && !(parentToken.Type == JTokenType.Null + || (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) + ) + { + return new ErrorResponse($"New parent ('{parentToken}') not found."); + } + if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) + { + return new ErrorResponse($"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop."); + } + if (targetGo.transform.parent != (newParentGo?.transform)) + { + targetGo.transform.SetParent(newParentGo?.transform, true); + modified = true; + } + } + + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + string tag = @params["tag"]?.ToString(); + if (tag != null && targetGo.tag != tag) + { + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + + if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet)) + { + McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it."); + try + { + InternalEditorUtility.AddTag(tagToSet); + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}."); + } + } + + try + { + targetGo.tag = tagToSet; + modified = true; + } + catch (Exception ex) + { + return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}."); + } + } + + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1 && layerName != "Default") + { + return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name."); + } + if (layerId != -1 && targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + Vector3? position = VectorParsing.ParseVector3(@params["position"]); + Vector3? rotation = VectorParsing.ParseVector3(@params["rotation"]); + Vector3? scale = VectorParsing.ParseVector3(@params["scale"]); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + if (@params["componentsToRemove"] is JArray componentsToRemoveArray) + { + foreach (var compToken in componentsToRemoveArray) + { + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + var removeResult = GameObjectComponentHelpers.RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; + modified = true; + } + } + } + + if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) + { + foreach (var compToken in componentsToAddArrayModify) + { + string typeName = null; + JObject properties = null; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = GameObjectComponentHelpers.AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; + modified = true; + } + } + } + + var componentErrors = new List(); + if (@params["componentProperties"] is JObject componentPropertiesObj) + { + foreach (var prop in componentPropertiesObj.Properties()) + { + string compName = prop.Name; + JObject propertiesToSet = prop.Value as JObject; + if (propertiesToSet != null) + { + var setResult = GameObjectComponentHelpers.SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); + if (setResult != null) + { + componentErrors.Add(setResult); + } + else + { + modified = true; + } + } + } + } + + if (componentErrors.Count > 0) + { + var aggregatedErrors = new List(); + foreach (var errorObj in componentErrors) + { + try + { + var dataProp = errorObj?.GetType().GetProperty("data"); + var dataVal = dataProp?.GetValue(errorObj); + if (dataVal != null) + { + var errorsProp = dataVal.GetType().GetProperty("errors"); + var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; + if (errorsEnum != null) + { + foreach (var item in errorsEnum) + { + var s = item?.ToString(); + if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); + } + } + } + } + catch { } + } + + return new ErrorResponse( + $"One or more component property operations failed on '{targetGo.name}'.", + new { componentErrors = componentErrors, errors = aggregatedErrors } + ); + } + + if (!modified) + { + return new SuccessResponse( + $"No modifications applied to GameObject '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + EditorUtility.SetDirty(targetGo); + return new SuccessResponse( + $"GameObject '{targetGo.name}' modified successfully.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta new file mode 100644 index 000000000..3249f3e87 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec5e33513bd094257a26ef6f75ea4574 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs new file mode 100644 index 000000000..3335d287c --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs @@ -0,0 +1,119 @@ +#nullable disable +using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class GameObjectMoveRelative + { + internal static object Handle(JObject @params, JToken targetToken, string searchMethod) + { + GameObject targetGo = ManageGameObjectCommon.FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return new ErrorResponse($"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'."); + } + + JToken referenceToken = @params["reference_object"]; + if (referenceToken == null) + { + return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action."); + } + + GameObject referenceGo = ManageGameObjectCommon.FindObjectInternal(referenceToken, "by_id_or_name_or_path"); + if (referenceGo == null) + { + return new ErrorResponse($"Reference object '{referenceToken}' not found."); + } + + string direction = @params["direction"]?.ToString()?.ToLower(); + float distance = @params["distance"]?.ToObject() ?? 1f; + Vector3? customOffset = VectorParsing.ParseVector3(@params["offset"]); + bool useWorldSpace = @params["world_space"]?.ToObject() ?? true; + + Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}"); + + Vector3 newPosition; + + if (customOffset.HasValue) + { + if (useWorldSpace) + { + newPosition = referenceGo.transform.position + customOffset.Value; + } + else + { + newPosition = referenceGo.transform.TransformPoint(customOffset.Value); + } + } + else if (!string.IsNullOrEmpty(direction)) + { + Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace); + newPosition = referenceGo.transform.position + directionVector * distance; + } + else + { + return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action."); + } + + targetGo.transform.position = newPosition; + + EditorUtility.SetDirty(targetGo); + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + + return new SuccessResponse( + $"Moved '{targetGo.name}' relative to '{referenceGo.name}'.", + new + { + movedObject = targetGo.name, + referenceObject = referenceGo.name, + newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z }, + direction = direction, + distance = distance, + gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + } + ); + } + + private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace) + { + if (useWorldSpace) + { + switch (direction) + { + case "right": return Vector3.right; + case "left": return Vector3.left; + case "up": return Vector3.up; + case "down": return Vector3.down; + case "forward": + case "front": return Vector3.forward; + case "back": + case "backward": + case "behind": return Vector3.back; + default: + McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return Vector3.forward; + } + } + + switch (direction) + { + case "right": return referenceTransform.right; + case "left": return -referenceTransform.right; + case "up": return referenceTransform.up; + case "down": return -referenceTransform.up; + case "forward": + case "front": return referenceTransform.forward; + case "back": + case "backward": + case "behind": return -referenceTransform.forward; + default: + McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); + return referenceTransform.forward; + } + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta new file mode 100644 index 000000000..3f29f708e --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectMoveRelative.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b19997a165de45c2af3ada79a6d3f08 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs new file mode 100644 index 000000000..6d406df28 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs @@ -0,0 +1,130 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using MCPForUnity.Editor.Helpers; // For Response class +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + /// + /// Handles GameObject manipulation within the current scene (CRUD, find, components). + /// + [McpForUnityTool("manage_gameobject", AutoRegister = false)] + public static class ManageGameObject + { + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return new ErrorResponse("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return new ErrorResponse("Action parameter is required."); + } + + // Parameters used by various actions + JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) + string name = @params["name"]?.ToString(); + + // --- Usability Improvement: Alias 'name' to 'target' for modification actions --- + // If 'target' is missing but 'name' is provided, and we aren't creating a new object, + // assume the user meant "find object by name". + if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create") + { + targetToken = name; + // We don't update @params["target"] because we use targetToken locally mostly, + // but some downstream methods might parse @params directly. Let's update @params too for safety. + @params["target"] = name; + } + // ------------------------------------------------------------------------------- + + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); + string tag = @params["tag"]?.ToString(); + string layer = @params["layer"]?.ToString(); + JToken parentToken = @params["parent"]; + + // Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string + var componentPropsToken = @params["componentProperties"]; + if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String) + { + try + { + var parsed = JObject.Parse(componentPropsToken.ToString()); + @params["componentProperties"] = parsed; + } + catch (Exception e) + { + McpLog.Warn($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}"); + } + } + + // --- Prefab Asset Check --- + // Prefab assets require different tools. Only 'create' (instantiation) is valid here. + string targetPath = + targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; + if ( + !string.IsNullOrEmpty(targetPath) + && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + && action != "create" // Allow prefab instantiation + ) + { + return new ErrorResponse( + $"Target '{targetPath}' is a prefab asset. " + + $"Use 'manage_asset' with action='modify' for prefab asset modifications, " + + $"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode." + ); + } + // --- End Prefab Asset Check --- + + try + { + switch (action) + { + // --- Primary lifecycle actions (kept in manage_gameobject) --- + case "create": + return GameObjectCreate.Handle(@params); + case "modify": + return GameObjectModify.Handle(@params, targetToken, searchMethod); + case "delete": + return GameObjectDelete.Handle(targetToken, searchMethod); + case "duplicate": + return GameObjectDuplicate.Handle(@params, targetToken, searchMethod); + case "move_relative": + return GameObjectMoveRelative.Handle(@params, targetToken, searchMethod); + + default: + return new ErrorResponse($"Unknown action: '{action}'."); + } + } + catch (Exception e) + { + McpLog.Error($"[ManageGameObject] Action '{action}' failed: {e}"); + return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); + } + } + + /// + /// Finds a specific UnityEngine.Object based on a find instruction JObject. + /// Primarily used by UnityEngineObjectConverter during deserialization. + /// + /// + /// This method now delegates to ObjectResolver.Resolve() for cleaner architecture. + /// Kept for backwards compatibility with existing code. + /// + public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) + { + return ObjectResolver.Resolve(instruction, targetType); + } + + + } +} diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta similarity index 100% rename from MCPForUnity/Editor/Tools/ManageGameObject.cs.meta rename to MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs.meta diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs new file mode 100644 index 000000000..38d743f12 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs @@ -0,0 +1,210 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.Linq; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools; +using Newtonsoft.Json.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace MCPForUnity.Editor.Tools.GameObjects +{ + internal static class ManageGameObjectCommon + { + internal static GameObject FindObjectInternal(JToken targetToken, string searchMethod, JObject findParams = null) + { + bool findAll = findParams?["findAll"]?.ToObject() ?? false; + + if ( + targetToken?.Type == JTokenType.Integer + || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) + ) + { + findAll = false; + } + + List results = FindObjectsInternal(targetToken, searchMethod, findAll, findParams); + return results.Count > 0 ? results[0] : null; + } + + internal static List FindObjectsInternal( + JToken targetToken, + string searchMethod, + bool findAll, + JObject findParams = null + ) + { + List results = new List(); + string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); + bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; + bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; + + if (string.IsNullOrEmpty(searchMethod)) + { + if (targetToken?.Type == JTokenType.Integer) + searchMethod = "by_id"; + else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) + searchMethod = "by_path"; + else + searchMethod = "by_name"; + } + + GameObject rootSearchObject = null; + if (searchInChildren && targetToken != null) + { + rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); + if (rootSearchObject == null) + { + McpLog.Warn($"[ManageGameObject.Find] Root object '{targetToken}' for child search not found."); + return results; + } + } + + switch (searchMethod) + { + case "by_id": + if (int.TryParse(searchTerm, out int instanceId)) + { + var allObjects = GetAllSceneObjects(searchInactive); + GameObject obj = allObjects.FirstOrDefault(go => go.GetInstanceID() == instanceId); + if (obj != null) + results.Add(obj); + } + break; + + case "by_name": + var searchPoolName = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); + break; + + case "by_path": + Transform foundTransform = rootSearchObject + ? rootSearchObject.transform.Find(searchTerm) + : GameObject.Find(searchTerm)?.transform; + if (foundTransform != null) + results.Add(foundTransform.gameObject); + break; + + case "by_tag": + var searchPoolTag = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); + break; + + case "by_layer": + var searchPoolLayer = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(searchInactive) + .Select(t => t.gameObject) + : GetAllSceneObjects(searchInactive); + if (int.TryParse(searchTerm, out int layerIndex)) + { + results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); + } + else + { + int namedLayer = LayerMask.NameToLayer(searchTerm); + if (namedLayer != -1) + results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); + } + break; + + case "by_component": + Type componentType = FindType(searchTerm); + if (componentType != null) + { + IEnumerable searchPoolComp; + if (rootSearchObject) + { + searchPoolComp = rootSearchObject + .GetComponentsInChildren(componentType, searchInactive) + .Select(c => (c as Component).gameObject); + } + else + { + searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive) + .Cast() + .Select(c => c.gameObject); + } + results.AddRange(searchPoolComp.Where(go => go != null)); + } + else + { + McpLog.Warn($"[ManageGameObject.Find] Component type not found: {searchTerm}"); + } + break; + + case "by_id_or_name_or_path": + if (int.TryParse(searchTerm, out int id)) + { + var allObjectsId = GetAllSceneObjects(true); + GameObject objById = allObjectsId.FirstOrDefault(go => go.GetInstanceID() == id); + if (objById != null) + { + results.Add(objById); + break; + } + } + + GameObject objByPath = GameObject.Find(searchTerm); + if (objByPath != null) + { + results.Add(objByPath); + break; + } + + var allObjectsName = GetAllSceneObjects(true); + results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); + break; + + default: + McpLog.Warn($"[ManageGameObject.Find] Unknown search method: {searchMethod}"); + break; + } + + if (!findAll && results.Count > 1) + { + return new List { results[0] }; + } + + return results.Distinct().ToList(); + } + + private static IEnumerable GetAllSceneObjects(bool includeInactive) + { + var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + var allObjects = new List(); + foreach (var root in rootObjects) + { + allObjects.AddRange( + root.GetComponentsInChildren(includeInactive) + .Select(t => t.gameObject) + ); + } + return allObjects; + } + + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + return resolvedType; + } + + if (!string.IsNullOrEmpty(error)) + { + McpLog.Warn($"[FindType] {error}"); + } + + return null; + } + } +} diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta new file mode 100644 index 000000000..51c64c809 --- /dev/null +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObjectCommon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6bf0edf3cd2af46729294682cee3bee4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Tools/ManageAsset.cs b/MCPForUnity/Editor/Tools/ManageAsset.cs index 9cdf627ad..a285c9dcb 100644 --- a/MCPForUnity/Editor/Tools/ManageAsset.cs +++ b/MCPForUnity/Editor/Tools/ManageAsset.cs @@ -7,7 +7,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; // For Response class -using static MCPForUnity.Editor.Tools.ManageGameObject; +using MCPForUnity.Editor.Tools; #if UNITY_6000_0_OR_NEWER using PhysicsMaterialType = UnityEngine.PhysicsMaterial; @@ -425,7 +425,7 @@ prop.Value is JObject componentProperties { // Deprecated: Prefer manage_scriptable_object for robust patching. // Kept for simple property setting fallback on existing assets if manage_scriptable_object isn't used. - modified |= ApplyObjectProperties(so, properties); + modified |= ApplyObjectProperties(so, properties); } // Example: Modifying TextureImporter settings else if (asset is Texture) diff --git a/MCPForUnity/Editor/Tools/ManageGameObject.cs b/MCPForUnity/Editor/Tools/ManageGameObject.cs deleted file mode 100644 index 9e7fd8756..000000000 --- a/MCPForUnity/Editor/Tools/ManageGameObject.cs +++ /dev/null @@ -1,2064 +0,0 @@ -#nullable disable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using MCPForUnity.Editor.Helpers; // For Response class -using MCPForUnity.Runtime.Serialization; -using Newtonsoft.Json; // Added for JsonSerializationException -using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.Compilation; // For CompilationPipeline -using UnityEditor.SceneManagement; -using UnityEditorInternal; -using UnityEngine; -using UnityEngine.SceneManagement; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Handles GameObject manipulation within the current scene (CRUD, find, components). - /// - [McpForUnityTool("manage_gameobject", AutoRegister = false)] - public static class ManageGameObject - { - // Use shared serializer from helper class (backwards-compatible alias) - internal static JsonSerializer InputSerializer => UnityJsonSerializer.Instance; - - // --- Main Handler --- - - public static object HandleCommand(JObject @params) - { - if (@params == null) - { - return new ErrorResponse("Parameters cannot be null."); - } - - string action = @params["action"]?.ToString().ToLower(); - if (string.IsNullOrEmpty(action)) - { - return new ErrorResponse("Action parameter is required."); - } - - // Parameters used by various actions - JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) - string name = @params["name"]?.ToString(); - - // --- Usability Improvement: Alias 'name' to 'target' for modification actions --- - // If 'target' is missing but 'name' is provided, and we aren't creating a new object, - // assume the user meant "find object by name". - if (targetToken == null && !string.IsNullOrEmpty(name) && action != "create") - { - targetToken = name; - // We don't update @params["target"] because we use targetToken locally mostly, - // but some downstream methods might parse @params directly. Let's update @params too for safety. - @params["target"] = name; - } - // ------------------------------------------------------------------------------- - - string searchMethod = @params["searchMethod"]?.ToString().ToLower(); - string tag = @params["tag"]?.ToString(); - string layer = @params["layer"]?.ToString(); - JToken parentToken = @params["parent"]; - - // Coerce string JSON to JObject for 'componentProperties' if provided as a JSON string - var componentPropsToken = @params["componentProperties"]; - if (componentPropsToken != null && componentPropsToken.Type == JTokenType.String) - { - try - { - var parsed = JObject.Parse(componentPropsToken.ToString()); - @params["componentProperties"] = parsed; - } - catch (Exception e) - { - McpLog.Warn($"[ManageGameObject] Could not parse 'componentProperties' JSON string: {e.Message}"); - } - } - - // --- Prefab Asset Check --- - // Prefab assets require different tools. Only 'create' (instantiation) is valid here. - string targetPath = - targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; - if ( - !string.IsNullOrEmpty(targetPath) - && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) - && action != "create" // Allow prefab instantiation - ) - { - return new ErrorResponse( - $"Target '{targetPath}' is a prefab asset. " + - $"Use 'manage_asset' with action='modify' for prefab asset modifications, " + - $"or 'manage_prefabs' with action='open_stage' to edit the prefab in isolation mode." - ); - } - // --- End Prefab Asset Check --- - - try - { - switch (action) - { - // --- Primary lifecycle actions (kept in manage_gameobject) --- - case "create": - return CreateGameObject(@params); - case "modify": - return ModifyGameObject(@params, targetToken, searchMethod); - case "delete": - return DeleteGameObject(targetToken, searchMethod); - case "duplicate": - return DuplicateGameObject(@params, targetToken, searchMethod); - case "move_relative": - return MoveRelativeToObject(@params, targetToken, searchMethod); - - default: - return new ErrorResponse($"Unknown action: '{action}'."); - } - } - catch (Exception e) - { - McpLog.Error($"[ManageGameObject] Action '{action}' failed: {e}"); - return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); - } - } - - // --- Action Implementations --- - - private static object CreateGameObject(JObject @params) - { - string name = @params["name"]?.ToString(); - if (string.IsNullOrEmpty(name)) - { - return new ErrorResponse("'name' parameter is required for 'create' action."); - } - - // Get prefab creation parameters - bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; - string prefabPath = @params["prefabPath"]?.ToString(); - string tag = @params["tag"]?.ToString(); // Get tag for creation - string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check - GameObject newGo = null; // Initialize as null - - // --- Try Instantiating Prefab First --- - string originalPrefabPath = prefabPath; // Keep original for messages - if (!string.IsNullOrEmpty(prefabPath)) - { - // If no extension, search for the prefab by name - if ( - !prefabPath.Contains("/") - && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) - ) - { - string prefabNameOnly = prefabPath; - McpLog.Info( - $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" - ); - string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); - if (guids.Length == 0) - { - return new ErrorResponse( - $"Prefab named '{prefabNameOnly}' not found anywhere in the project." - ); - } - else if (guids.Length > 1) - { - string foundPaths = string.Join( - ", ", - guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) - ); - return new ErrorResponse( - $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." - ); - } - else // Exactly one found - { - prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path - McpLog.Info( - $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" - ); - } - } - else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. - McpLog.Warn( - $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." - ); - prefabPath += ".prefab"; - // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. - } - // The logic above now handles finding or assuming the .prefab extension. - - GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); - if (prefabAsset != null) - { - try - { - // Instantiate the prefab, initially place it at the root - // Parent will be set later if specified - newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; - - if (newGo == null) - { - // This might happen if the asset exists but isn't a valid GameObject prefab somehow - McpLog.Error( - $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." - ); - return new ErrorResponse( - $"Failed to instantiate prefab at '{prefabPath}'." - ); - } - // Name the instance based on the 'name' parameter, not the prefab's default name - if (!string.IsNullOrEmpty(name)) - { - newGo.name = name; - } - // Register Undo for prefab instantiation - Undo.RegisterCreatedObjectUndo( - newGo, - $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" - ); - McpLog.Info( - $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." - ); - } - catch (Exception e) - { - return new ErrorResponse( - $"Error instantiating prefab '{prefabPath}': {e.Message}" - ); - } - } - else - { - // Only return error if prefabPath was specified but not found. - // If prefabPath was empty/null, we proceed to create primitive/empty. - McpLog.Warn( - $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." - ); - // Do not return error here, allow fallback to primitive/empty creation - } - } - - // --- Fallback: Create Primitive or Empty GameObject --- - bool createdNewObject = false; // Flag to track if we created (not instantiated) - if (newGo == null) // Only proceed if prefab instantiation didn't happen - { - if (!string.IsNullOrEmpty(primitiveType)) - { - try - { - PrimitiveType type = (PrimitiveType) - Enum.Parse(typeof(PrimitiveType), primitiveType, true); - newGo = GameObject.CreatePrimitive(type); - // Set name *after* creation for primitives - if (!string.IsNullOrEmpty(name)) - { - newGo.name = name; - } - else - { - UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak - return new ErrorResponse( - "'name' parameter is required when creating a primitive." - ); - } - createdNewObject = true; - } - catch (ArgumentException) - { - return new ErrorResponse( - $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" - ); - } - catch (Exception e) - { - return new ErrorResponse( - $"Failed to create primitive '{primitiveType}': {e.Message}" - ); - } - } - else // Create empty GameObject - { - if (string.IsNullOrEmpty(name)) - { - return new ErrorResponse( - "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." - ); - } - newGo = new GameObject(name); - createdNewObject = true; - } - // Record creation for Undo *only* if we created a new object - if (createdNewObject) - { - Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); - } - } - // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- - if (newGo == null) - { - // Should theoretically not happen if logic above is correct, but safety check. - return new ErrorResponse("Failed to create or instantiate the GameObject."); - } - - // Record potential changes to the existing prefab instance or the new GO - // Record transform separately in case parent changes affect it - Undo.RecordObject(newGo.transform, "Set GameObject Transform"); - Undo.RecordObject(newGo, "Set GameObject Properties"); - - // Set Parent - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding - if (parentGo == null) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object - return new ErrorResponse($"Parent specified ('{parentToken}') but not found."); - } - newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true - } - - // Set Transform - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue) - newGo.transform.localPosition = position.Value; - if (rotation.HasValue) - newGo.transform.localEulerAngles = rotation.Value; - if (scale.HasValue) - newGo.transform.localScale = scale.Value; - - // Set Tag (added for create action) - if (!string.IsNullOrEmpty(tag)) - { - // Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning) - if (tag != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tag)) - { - McpLog.Info($"[ManageGameObject.Create] Tag '{tag}' not found. Creating it."); - try - { - InternalEditorUtility.AddTag(tag); - } - catch (Exception ex) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return new ErrorResponse($"Failed to create tag '{tag}': {ex.Message}."); - } - } - - try - { - newGo.tag = tag; - } - catch (Exception ex) - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return new ErrorResponse($"Failed to set tag to '{tag}' during creation: {ex.Message}."); - } - } - - // Set Layer (new for create action) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId != -1) - { - newGo.layer = layerId; - } - else - { - McpLog.Warn( - $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." - ); - } - } - - // Add Components - if (@params["componentsToAdd"] is JArray componentsToAddArray) - { - foreach (var compToken in componentsToAddArray) - { - string typeName = null; - JObject properties = null; - - if (compToken.Type == JTokenType.String) - { - typeName = compToken.ToString(); - } - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(newGo, typeName, properties); - if (addResult != null) // Check if AddComponentInternal returned an error object - { - UnityEngine.Object.DestroyImmediate(newGo); // Clean up - return addResult; // Return the error response - } - } - else - { - McpLog.Warn( - $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" - ); - } - } - } - - // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true - GameObject finalInstance = newGo; // Use this for selection and return data - if (createdNewObject && saveAsPrefab) - { - string finalPrefabPath = prefabPath; // Use a separate variable for saving path - // This check should now happen *before* attempting to save - if (string.IsNullOrEmpty(finalPrefabPath)) - { - // Clean up the created object before returning error - UnityEngine.Object.DestroyImmediate(newGo); - return new ErrorResponse( - "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." - ); - } - // Ensure the *saving* path ends with .prefab - if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) - { - McpLog.Info( - $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" - ); - finalPrefabPath += ".prefab"; - } - - try - { - // Ensure directory exists using the final saving path - string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); - if ( - !string.IsNullOrEmpty(directoryPath) - && !System.IO.Directory.Exists(directoryPath) - ) - { - System.IO.Directory.CreateDirectory(directoryPath); - AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport); // Refresh asset database to recognize the new folder - McpLog.Info( - $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" - ); - } - // Use SaveAsPrefabAssetAndConnect with the final saving path - finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( - newGo, - finalPrefabPath, - InteractionMode.UserAction - ); - - if (finalInstance == null) - { - // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) - UnityEngine.Object.DestroyImmediate(newGo); - return new ErrorResponse( - $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." - ); - } - McpLog.Info( - $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." - ); - // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. - // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect - } - catch (Exception e) - { - // Clean up the instance if prefab saving fails - UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt - return new ErrorResponse($"Error saving prefab '{finalPrefabPath}': {e.Message}"); - } - } - - // Select the instance in the scene (either prefab instance or newly created/saved one) - Selection.activeGameObject = finalInstance; - - // Determine appropriate success message using the potentially updated or original path - string messagePrefabPath = - finalInstance == null - ? originalPrefabPath - : AssetDatabase.GetAssetPath( - PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) - ?? (UnityEngine.Object)finalInstance - ); - string successMessage; - if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab - { - successMessage = - $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; - } - else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab - { - successMessage = - $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; - } - else // Created new primitive or empty GO, didn't save as prefab - { - successMessage = - $"GameObject '{finalInstance.name}' created successfully in scene."; - } - - // Use the new serializer helper - //return new SuccessResponse(successMessage, GetGameObjectData(finalInstance)); - return new SuccessResponse(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); - } - - private static object ModifyGameObject( - JObject @params, - JToken targetToken, - string searchMethod - ) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return new ErrorResponse( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - // Record state for Undo *before* modifications - Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); - Undo.RecordObject(targetGo, "Modify GameObject Properties"); - - bool modified = false; - - // Rename (using consolidated 'name' parameter) - string name = @params["name"]?.ToString(); - if (!string.IsNullOrEmpty(name) && targetGo.name != name) - { - targetGo.name = name; - modified = true; - } - - // Change Parent (using consolidated 'parent' parameter) - JToken parentToken = @params["parent"]; - if (parentToken != null) - { - GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); - // Check for hierarchy loops - if ( - newParentGo == null - && !( - parentToken.Type == JTokenType.Null - || ( - parentToken.Type == JTokenType.String - && string.IsNullOrEmpty(parentToken.ToString()) - ) - ) - ) - { - return new ErrorResponse($"New parent ('{parentToken}') not found."); - } - if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) - { - return new ErrorResponse( - $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." - ); - } - if (targetGo.transform.parent != (newParentGo?.transform)) - { - targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true - modified = true; - } - } - - // Set Active State - bool? setActive = @params["setActive"]?.ToObject(); - if (setActive.HasValue && targetGo.activeSelf != setActive.Value) - { - targetGo.SetActive(setActive.Value); - modified = true; - } - - // Change Tag (using consolidated 'tag' parameter) - string tag = @params["tag"]?.ToString(); - // Only attempt to change tag if a non-null tag is provided and it's different from the current one. - // Allow setting an empty string to remove the tag (Unity uses "Untagged"). - if (tag != null && targetGo.tag != tag) - { - // Ensure the tag is not empty, if empty, it means "Untagged" implicitly - string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; - - // Check if tag exists first (Unity doesn't throw exceptions for undefined tags, just logs a warning) - if (tagToSet != "Untagged" && !System.Linq.Enumerable.Contains(InternalEditorUtility.tags, tagToSet)) - { - McpLog.Info($"[ManageGameObject] Tag '{tagToSet}' not found. Creating it."); - try - { - InternalEditorUtility.AddTag(tagToSet); - } - catch (Exception ex) - { - return new ErrorResponse($"Failed to create tag '{tagToSet}': {ex.Message}."); - } - } - - try - { - targetGo.tag = tagToSet; - modified = true; - } - catch (Exception ex) - { - return new ErrorResponse($"Failed to set tag to '{tagToSet}': {ex.Message}."); - } - } - - // Change Layer (using consolidated 'layer' parameter) - string layerName = @params["layer"]?.ToString(); - if (!string.IsNullOrEmpty(layerName)) - { - int layerId = LayerMask.NameToLayer(layerName); - if (layerId == -1 && layerName != "Default") - { - return new ErrorResponse( - $"Invalid layer specified: '{layerName}'. Use a valid layer name." - ); - } - if (layerId != -1 && targetGo.layer != layerId) - { - targetGo.layer = layerId; - modified = true; - } - } - - // Transform Modifications - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? rotation = ParseVector3(@params["rotation"] as JArray); - Vector3? scale = ParseVector3(@params["scale"] as JArray); - - if (position.HasValue && targetGo.transform.localPosition != position.Value) - { - targetGo.transform.localPosition = position.Value; - modified = true; - } - if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) - { - targetGo.transform.localEulerAngles = rotation.Value; - modified = true; - } - if (scale.HasValue && targetGo.transform.localScale != scale.Value) - { - targetGo.transform.localScale = scale.Value; - modified = true; - } - - // --- Component Modifications --- - // Note: These might need more specific Undo recording per component - - // Remove Components - if (@params["componentsToRemove"] is JArray componentsToRemoveArray) - { - foreach (var compToken in componentsToRemoveArray) - { - // ... (parsing logic as in CreateGameObject) ... - string typeName = compToken.ToString(); - if (!string.IsNullOrEmpty(typeName)) - { - var removeResult = RemoveComponentInternal(targetGo, typeName); - if (removeResult != null) - return removeResult; // Return error if removal failed - modified = true; - } - } - } - - // Add Components (similar to create) - if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) - { - foreach (var compToken in componentsToAddArrayModify) - { - string typeName = null; - JObject properties = null; - if (compToken.Type == JTokenType.String) - typeName = compToken.ToString(); - else if (compToken is JObject compObj) - { - typeName = compObj["typeName"]?.ToString(); - properties = compObj["properties"] as JObject; - } - - if (!string.IsNullOrEmpty(typeName)) - { - var addResult = AddComponentInternal(targetGo, typeName, properties); - if (addResult != null) - return addResult; - modified = true; - } - } - } - - // Set Component Properties - var componentErrors = new List(); - if (@params["componentProperties"] is JObject componentPropertiesObj) - { - foreach (var prop in componentPropertiesObj.Properties()) - { - string compName = prop.Name; - JObject propertiesToSet = prop.Value as JObject; - if (propertiesToSet != null) - { - var setResult = SetComponentPropertiesInternal( - targetGo, - compName, - propertiesToSet - ); - if (setResult != null) - { - componentErrors.Add(setResult); - } - else - { - modified = true; - } - } - } - } - - // Return component errors if any occurred (after processing all components) - if (componentErrors.Count > 0) - { - // Aggregate flattened error strings to make tests/API assertions simpler - var aggregatedErrors = new System.Collections.Generic.List(); - foreach (var errorObj in componentErrors) - { - try - { - var dataProp = errorObj?.GetType().GetProperty("data"); - var dataVal = dataProp?.GetValue(errorObj); - if (dataVal != null) - { - var errorsProp = dataVal.GetType().GetProperty("errors"); - var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; - if (errorsEnum != null) - { - foreach (var item in errorsEnum) - { - var s = item?.ToString(); - if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); - } - } - } - } - catch { } - } - - return new ErrorResponse( - $"One or more component property operations failed on '{targetGo.name}'.", - new { componentErrors = componentErrors, errors = aggregatedErrors } - ); - } - - if (!modified) - { - // Use the new serializer helper - // return new SuccessResponse( - // $"No modifications applied to GameObject '{targetGo.name}'.", - // GetGameObjectData(targetGo)); - - return new SuccessResponse( - $"No modifications applied to GameObject '{targetGo.name}'.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - } - - EditorUtility.SetDirty(targetGo); // Mark scene as dirty - // Use the new serializer helper - return new SuccessResponse( - $"GameObject '{targetGo.name}' modified successfully.", - Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - ); - // return new SuccessResponse( - // $"GameObject '{targetGo.name}' modified successfully.", - // GetGameObjectData(targetGo)); - - } - - /// - /// Duplicates a GameObject with all its properties, components, and children. - /// - private static object DuplicateGameObject(JObject @params, JToken targetToken, string searchMethod) - { - GameObject sourceGo = FindObjectInternal(targetToken, searchMethod); - if (sourceGo == null) - { - return new ErrorResponse( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - // Optional parameters - string newName = @params["new_name"]?.ToString(); - Vector3? position = ParseVector3(@params["position"] as JArray); - Vector3? offset = ParseVector3(@params["offset"] as JArray); - JToken parentToken = @params["parent"]; - - // Duplicate the object - GameObject duplicatedGo = UnityEngine.Object.Instantiate(sourceGo); - Undo.RegisterCreatedObjectUndo(duplicatedGo, $"Duplicate {sourceGo.name}"); - - // Set name (default: SourceName_Copy or SourceName (1)) - if (!string.IsNullOrEmpty(newName)) - { - duplicatedGo.name = newName; - } - else - { - // Remove "(Clone)" suffix added by Instantiate and add "_Copy" - duplicatedGo.name = sourceGo.name.Replace("(Clone)", "").Trim() + "_Copy"; - } - - // Handle positioning - if (position.HasValue) - { - // Absolute position specified - duplicatedGo.transform.position = position.Value; - } - else if (offset.HasValue) - { - // Offset from original - duplicatedGo.transform.position = sourceGo.transform.position + offset.Value; - } - // else: keeps the same position as the original (default Instantiate behavior) - - // Handle parent - if (parentToken != null) - { - if (parentToken.Type == JTokenType.Null || - (parentToken.Type == JTokenType.String && string.IsNullOrEmpty(parentToken.ToString()))) - { - // Explicit null parent - move to root - duplicatedGo.transform.SetParent(null); - } - else - { - GameObject newParent = FindObjectInternal(parentToken, "by_id_or_name_or_path"); - if (newParent != null) - { - duplicatedGo.transform.SetParent(newParent.transform, true); - } - else - { - McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Keeping original parent."); - } - } - } - else - { - // Default: same parent as source - duplicatedGo.transform.SetParent(sourceGo.transform.parent, true); - } - - // Mark scene dirty - EditorUtility.SetDirty(duplicatedGo); - EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); - - Selection.activeGameObject = duplicatedGo; - - return new SuccessResponse( - $"Duplicated '{sourceGo.name}' as '{duplicatedGo.name}'.", - new - { - originalName = sourceGo.name, - originalId = sourceGo.GetInstanceID(), - duplicatedObject = Helpers.GameObjectSerializer.GetGameObjectData(duplicatedGo) - } - ); - } - - /// - /// Moves a GameObject relative to another reference object. - /// Supports directional offsets (left, right, up, down, forward, back) and distance. - /// - private static object MoveRelativeToObject(JObject @params, JToken targetToken, string searchMethod) - { - GameObject targetGo = FindObjectInternal(targetToken, searchMethod); - if (targetGo == null) - { - return new ErrorResponse( - $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - // Get reference object (required for relative movement) - JToken referenceToken = @params["reference_object"]; - if (referenceToken == null) - { - return new ErrorResponse("'reference_object' parameter is required for 'move_relative' action."); - } - - GameObject referenceGo = FindObjectInternal(referenceToken, "by_id_or_name_or_path"); - if (referenceGo == null) - { - return new ErrorResponse($"Reference object '{referenceToken}' not found."); - } - - // Get movement parameters - string direction = @params["direction"]?.ToString()?.ToLower(); - float distance = @params["distance"]?.ToObject() ?? 1f; - Vector3? customOffset = ParseVector3(@params["offset"] as JArray); - bool useWorldSpace = @params["world_space"]?.ToObject() ?? true; - - // Record for undo - Undo.RecordObject(targetGo.transform, $"Move {targetGo.name} relative to {referenceGo.name}"); - - Vector3 newPosition; - - if (customOffset.HasValue) - { - // Custom offset vector provided - if (useWorldSpace) - { - newPosition = referenceGo.transform.position + customOffset.Value; - } - else - { - // Offset in reference object's local space - newPosition = referenceGo.transform.TransformPoint(customOffset.Value); - } - } - else if (!string.IsNullOrEmpty(direction)) - { - // Directional movement - Vector3 directionVector = GetDirectionVector(direction, referenceGo.transform, useWorldSpace); - newPosition = referenceGo.transform.position + directionVector * distance; - } - else - { - return new ErrorResponse("Either 'direction' or 'offset' parameter is required for 'move_relative' action."); - } - - targetGo.transform.position = newPosition; - - // Mark scene dirty - EditorUtility.SetDirty(targetGo); - EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); - - return new SuccessResponse( - $"Moved '{targetGo.name}' relative to '{referenceGo.name}'.", - new - { - movedObject = targetGo.name, - referenceObject = referenceGo.name, - newPosition = new[] { targetGo.transform.position.x, targetGo.transform.position.y, targetGo.transform.position.z }, - direction = direction, - distance = distance, - gameObject = Helpers.GameObjectSerializer.GetGameObjectData(targetGo) - } - ); - } - - /// - /// Converts a direction string to a Vector3. - /// - private static Vector3 GetDirectionVector(string direction, Transform referenceTransform, bool useWorldSpace) - { - if (useWorldSpace) - { - // World space directions - switch (direction) - { - case "right": return Vector3.right; - case "left": return Vector3.left; - case "up": return Vector3.up; - case "down": return Vector3.down; - case "forward": case "front": return Vector3.forward; - case "back": case "backward": case "behind": return Vector3.back; - default: - McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); - return Vector3.forward; - } - } - else - { - // Reference object's local space directions - switch (direction) - { - case "right": return referenceTransform.right; - case "left": return -referenceTransform.right; - case "up": return referenceTransform.up; - case "down": return -referenceTransform.up; - case "forward": case "front": return referenceTransform.forward; - case "back": case "backward": case "behind": return -referenceTransform.forward; - default: - McpLog.Warn($"[ManageGameObject.MoveRelative] Unknown direction '{direction}', defaulting to forward."); - return referenceTransform.forward; - } - } - } - - private static object DeleteGameObject(JToken targetToken, string searchMethod) - { - // Find potentially multiple objects if name/tag search is used without find_all=false implicitly - List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety - - if (targets.Count == 0) - { - return new ErrorResponse( - $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." - ); - } - - List deletedObjects = new List(); - foreach (var targetGo in targets) - { - if (targetGo != null) - { - string goName = targetGo.name; - int goId = targetGo.GetInstanceID(); - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(targetGo); - deletedObjects.Add(new { name = goName, instanceID = goId }); - } - } - - if (deletedObjects.Count > 0) - { - string message = - targets.Count == 1 - ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." - : $"{deletedObjects.Count} GameObjects deleted successfully."; - return new SuccessResponse(message, deletedObjects); - } - else - { - // Should not happen if targets.Count > 0 initially, but defensive check - return new ErrorResponse("Failed to delete target GameObject(s)."); - } - } - - // --- Internal Helpers --- - - /// - /// Parses a JArray like [x, y, z] into a Vector3. - /// - private static Vector3? ParseVector3(JArray array) - { - if (array != null && array.Count == 3) - { - try - { - return new Vector3( - array[0].ToObject(), - array[1].ToObject(), - array[2].ToObject() - ); - } - catch (Exception ex) - { - McpLog.Warn($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); - } - } - return null; - } - - /// - /// Finds a single GameObject based on token (ID, name, path) and search method. - /// - private static GameObject FindObjectInternal( - JToken targetToken, - string searchMethod, - JObject findParams = null - ) - { - // If find_all is not explicitly false, we still want only one for most single-target operations. - bool findAll = findParams?["findAll"]?.ToObject() ?? false; - // If a specific target ID is given, always find just that one. - if ( - targetToken?.Type == JTokenType.Integer - || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) - ) - { - findAll = false; - } - List results = FindObjectsInternal( - targetToken, - searchMethod, - findAll, - findParams - ); - return results.Count > 0 ? results[0] : null; - } - - /// - /// Core logic for finding GameObjects based on various criteria. - /// - private static List FindObjectsInternal( - JToken targetToken, - string searchMethod, - bool findAll, - JObject findParams = null - ) - { - List results = new List(); - string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself - bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; - bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? false; - - // Default search method if not specified - if (string.IsNullOrEmpty(searchMethod)) - { - if (targetToken?.Type == JTokenType.Integer) - searchMethod = "by_id"; - else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) - searchMethod = "by_path"; - else - searchMethod = "by_name"; // Default fallback - } - - GameObject rootSearchObject = null; - // If searching in children, find the initial target first - if (searchInChildren && targetToken != null) - { - rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search - if (rootSearchObject == null) - { - McpLog.Warn( - $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." - ); - return results; // Return empty if root not found - } - } - - switch (searchMethod) - { - case "by_id": - if (int.TryParse(searchTerm, out int instanceId)) - { - // EditorUtility.InstanceIDToObject is slow, iterate manually if possible - // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; - var allObjects = GetAllSceneObjects(searchInactive); // More efficient - GameObject obj = allObjects.FirstOrDefault(go => - go.GetInstanceID() == instanceId - ); - if (obj != null) - results.Add(obj); - } - break; - case "by_name": - var searchPoolName = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolName.Where(go => go.name == searchTerm)); - break; - case "by_path": - // Path is relative to scene root or rootSearchObject - Transform foundTransform = rootSearchObject - ? rootSearchObject.transform.Find(searchTerm) - : GameObject.Find(searchTerm)?.transform; - if (foundTransform != null) - results.Add(foundTransform.gameObject); - break; - case "by_tag": - var searchPoolTag = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - results.AddRange(searchPoolTag.Where(go => go.CompareTag(searchTerm))); - break; - case "by_layer": - var searchPoolLayer = rootSearchObject - ? rootSearchObject - .GetComponentsInChildren(searchInactive) - .Select(t => t.gameObject) - : GetAllSceneObjects(searchInactive); - if (int.TryParse(searchTerm, out int layerIndex)) - { - results.AddRange(searchPoolLayer.Where(go => go.layer == layerIndex)); - } - else - { - int namedLayer = LayerMask.NameToLayer(searchTerm); - if (namedLayer != -1) - results.AddRange(searchPoolLayer.Where(go => go.layer == namedLayer)); - } - break; - case "by_component": - Type componentType = FindType(searchTerm); - if (componentType != null) - { - IEnumerable searchPoolComp; - if (rootSearchObject) - { - searchPoolComp = rootSearchObject - .GetComponentsInChildren(componentType, searchInactive) - .Select(c => (c as Component).gameObject); - } - else - { - // Use FindObjectsOfType overload that respects includeInactive - searchPoolComp = UnityEngine.Object.FindObjectsOfType(componentType, searchInactive) - .Cast() - .Select(c => c.gameObject); - } - results.AddRange(searchPoolComp.Where(go => go != null)); // Ensure GO is valid - } - else - { - McpLog.Warn( - $"[ManageGameObject.Find] Component type not found: {searchTerm}" - ); - } - break; - case "by_id_or_name_or_path": // Helper method used internally - if (int.TryParse(searchTerm, out int id)) - { - var allObjectsId = GetAllSceneObjects(true); // Search inactive for internal lookup - GameObject objById = allObjectsId.FirstOrDefault(go => - go.GetInstanceID() == id - ); - if (objById != null) - { - results.Add(objById); - break; - } - } - GameObject objByPath = GameObject.Find(searchTerm); - if (objByPath != null) - { - results.Add(objByPath); - break; - } - - var allObjectsName = GetAllSceneObjects(true); - results.AddRange(allObjectsName.Where(go => go.name == searchTerm)); - break; - default: - McpLog.Warn( - $"[ManageGameObject.Find] Unknown search method: {searchMethod}" - ); - break; - } - - // If only one result is needed, return just the first one found. - if (!findAll && results.Count > 1) - { - return new List { results[0] }; - } - - return results.Distinct().ToList(); // Ensure uniqueness - } - - // Helper to get all scene objects efficiently - private static IEnumerable GetAllSceneObjects(bool includeInactive) - { - // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() - var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); - var allObjects = new List(); - foreach (var root in rootObjects) - { - allObjects.AddRange( - root.GetComponentsInChildren(includeInactive) - .Select(t => t.gameObject) - ); - } - return allObjects; - } - - /// - /// Adds a component by type name and optionally sets properties. - /// Returns null on success, or an error response object on failure. - /// - private static object AddComponentInternal( - GameObject targetGo, - string typeName, - JObject properties - ) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return new ErrorResponse( - $"Component type '{typeName}' not found or is not a valid Component." - ); - } - if (!typeof(Component).IsAssignableFrom(componentType)) - { - return new ErrorResponse($"Type '{typeName}' is not a Component."); - } - - // Prevent adding Transform again - if (componentType == typeof(Transform)) - { - return new ErrorResponse("Cannot add another Transform component."); - } - - // Check for 2D/3D physics component conflicts - bool isAdding2DPhysics = - typeof(Rigidbody2D).IsAssignableFrom(componentType) - || typeof(Collider2D).IsAssignableFrom(componentType); - bool isAdding3DPhysics = - typeof(Rigidbody).IsAssignableFrom(componentType) - || typeof(Collider).IsAssignableFrom(componentType); - - if (isAdding2DPhysics) - { - // Check if the GameObject already has any 3D Rigidbody or Collider - if ( - targetGo.GetComponent() != null - || targetGo.GetComponent() != null - ) - { - return new ErrorResponse( - $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." - ); - } - } - else if (isAdding3DPhysics) - { - // Check if the GameObject already has any 2D Rigidbody or Collider - if ( - targetGo.GetComponent() != null - || targetGo.GetComponent() != null - ) - { - return new ErrorResponse( - $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." - ); - } - } - - try - { - // Use Undo.AddComponent for undo support - Component newComponent = Undo.AddComponent(targetGo, componentType); - if (newComponent == null) - { - return new ErrorResponse( - $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." - ); - } - - // Set default values for specific component types - if (newComponent is Light light) - { - // Default newly added lights to directional - light.type = LightType.Directional; - } - - // Set properties if provided - if (properties != null) - { - var setResult = SetComponentPropertiesInternal( - targetGo, - typeName, - properties, - newComponent - ); // Pass the new component instance - if (setResult != null) - { - // If setting properties failed, maybe remove the added component? - Undo.DestroyObjectImmediate(newComponent); - return setResult; // Return the error from setting properties - } - } - - return null; // Success - } - catch (Exception e) - { - return new ErrorResponse( - $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" - ); - } - } - - /// - /// Removes a component by type name. - /// Returns null on success, or an error response object on failure. - /// - private static object RemoveComponentInternal(GameObject targetGo, string typeName) - { - Type componentType = FindType(typeName); - if (componentType == null) - { - return new ErrorResponse($"Component type '{typeName}' not found for removal."); - } - - // Prevent removing essential components - if (componentType == typeof(Transform)) - { - return new ErrorResponse("Cannot remove the Transform component."); - } - - Component componentToRemove = targetGo.GetComponent(componentType); - if (componentToRemove == null) - { - return new ErrorResponse( - $"Component '{typeName}' not found on '{targetGo.name}' to remove." - ); - } - - try - { - // Use Undo.DestroyObjectImmediate for undo support - Undo.DestroyObjectImmediate(componentToRemove); - return null; // Success - } - catch (Exception e) - { - return new ErrorResponse( - $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" - ); - } - } - - /// - /// Sets properties on a component. - /// Returns null on success, or an error response object on failure. - /// - private static object SetComponentPropertiesInternal( - GameObject targetGo, - string compName, - JObject propertiesToSet, - Component targetComponentInstance = null - ) - { - Component targetComponent = targetComponentInstance; - if (targetComponent == null) - { - if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) - { - targetComponent = targetGo.GetComponent(compType); - } - else - { - targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup - } - } - if (targetComponent == null) - { - return new ErrorResponse( - $"Component '{compName}' not found on '{targetGo.name}' to set properties." - ); - } - - Undo.RecordObject(targetComponent, "Set Component Properties"); - - var failures = new List(); - foreach (var prop in propertiesToSet.Properties()) - { - string propName = prop.Name; - JToken propValue = prop.Value; - - try - { - bool setResult = SetProperty(targetComponent, propName, propValue); - if (!setResult) - { - var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); - var suggestions = ComponentResolver.GetFuzzyPropertySuggestions(propName, availableProperties); - var msg = suggestions.Any() - ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" - : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; - McpLog.Warn($"[ManageGameObject] {msg}"); - failures.Add(msg); - } - } - catch (Exception e) - { - McpLog.Error( - $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" - ); - failures.Add($"Error setting '{propName}': {e.Message}"); - } - } - EditorUtility.SetDirty(targetComponent); - return failures.Count == 0 - ? null - : new ErrorResponse($"One or more properties failed on '{compName}'.", new { errors = failures }); - } - - /// - /// Helper to set a property or field via reflection, handling basic types. - /// - private static bool SetProperty(object target, string memberName, JToken value) - { - Type type = target.GetType(); - BindingFlags flags = - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - // Normalize property name: "Use Gravity" → "useGravity", "is_kinematic" → "isKinematic" - string normalizedName = Helpers.ParamCoercion.NormalizePropertyName(memberName); - - // Use shared serializer to avoid per-call allocation - var inputSerializer = InputSerializer; - - try - { - // Handle special case for materials with dot notation (material.property) - // Examples: material.color, sharedMaterial.color, materials[0].color - if (memberName.Contains('.') || memberName.Contains('[')) - { - // Pass the inputSerializer down for nested conversions - return SetNestedProperty(target, memberName, value, inputSerializer); - } - - // Try both original and normalized names - PropertyInfo propInfo = type.GetProperty(memberName, flags) - ?? type.GetProperty(normalizedName, flags); - if (propInfo != null && propInfo.CanWrite) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null - { - propInfo.SetValue(target, convertedValue); - return true; - } - else - { - McpLog.Warn($"[SetProperty] Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - // Try both original and normalized names for fields - FieldInfo fieldInfo = type.GetField(memberName, flags) - ?? type.GetField(normalizedName, flags); - if (fieldInfo != null) // Check if !IsLiteral? - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null - { - fieldInfo.SetValue(target, convertedValue); - return true; - } - else - { - McpLog.Warn($"[SetProperty] Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - // Try NonPublic [SerializeField] fields (with both original and normalized names) - var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase) - ?? type.GetField(normalizedName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); - if (npField != null && npField.GetCustomAttribute() != null) - { - object convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - npField.SetValue(target, convertedValue); - return true; - } - } - } - } - } - catch (Exception ex) - { - McpLog.Error( - $"[SetProperty] Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}" - ); - } - return false; - } - - /// - /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") - /// - // Pass the input serializer for conversions - //Using the serializer helper - private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) - { - try - { - // Split the path into parts (handling both dot notation and array indexing) - string[] pathParts = SplitPropertyPath(path); - if (pathParts.Length == 0) - return false; - - object currentObject = target; - Type currentType = currentObject.GetType(); - BindingFlags flags = - BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; - - // Traverse the path until we reach the final property - for (int i = 0; i < pathParts.Length - 1; i++) - { - string part = pathParts[i]; - bool isArray = false; - int arrayIndex = -1; - - // Check if this part contains array indexing - if (part.Contains("[")) - { - int startBracket = part.IndexOf('['); - int endBracket = part.IndexOf(']'); - if (startBracket > 0 && endBracket > startBracket) - { - string indexStr = part.Substring( - startBracket + 1, - endBracket - startBracket - 1 - ); - if (int.TryParse(indexStr, out arrayIndex)) - { - isArray = true; - part = part.Substring(0, startBracket); - } - } - } - // Get the property/field - PropertyInfo propInfo = currentType.GetProperty(part, flags); - FieldInfo fieldInfo = null; - if (propInfo == null) - { - fieldInfo = currentType.GetField(part, flags); - if (fieldInfo == null) - { - McpLog.Warn( - $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" - ); - return false; - } - } - - // Get the value - currentObject = - propInfo != null - ? propInfo.GetValue(currentObject) - : fieldInfo.GetValue(currentObject); - //Need to stop if current property is null - if (currentObject == null) - { - McpLog.Warn( - $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." - ); - return false; - } - // If this part was an array or list, access the specific index - if (isArray) - { - if (currentObject is Material[]) - { - var materials = currentObject as Material[]; - if (arrayIndex < 0 || arrayIndex >= materials.Length) - { - McpLog.Warn( - $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" - ); - return false; - } - currentObject = materials[arrayIndex]; - } - else if (currentObject is System.Collections.IList) - { - var list = currentObject as System.Collections.IList; - if (arrayIndex < 0 || arrayIndex >= list.Count) - { - McpLog.Warn( - $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" - ); - return false; - } - currentObject = list[arrayIndex]; - } - else - { - McpLog.Warn( - $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." - ); - return false; - } - } - currentType = currentObject.GetType(); - } - - // Set the final property - string finalPart = pathParts[pathParts.Length - 1]; - - // Special handling for Material properties (shader properties) - if (currentObject is Material material && finalPart.StartsWith("_")) - { - return MaterialOps.TrySetShaderProperty(material, finalPart, value, inputSerializer); - } - - // For standard properties (not shader specific) - PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); - if (finalPropInfo != null && finalPropInfo.CanWrite) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - finalPropInfo.SetValue(currentObject, convertedValue); - return true; - } - else - { - McpLog.Warn($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); - if (finalFieldInfo != null) - { - // Use the inputSerializer for conversion - object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); - if (convertedValue != null || value.Type == JTokenType.Null) - { - finalFieldInfo.SetValue(currentObject, convertedValue); - return true; - } - else - { - McpLog.Warn($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); - } - } - else - { - McpLog.Warn( - $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" - ); - } - } - } - catch (Exception ex) - { - McpLog.Error( - $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" - ); - } - - return false; - } - - - /// - /// Split a property path into parts, handling both dot notation and array indexers - /// - private static string[] SplitPropertyPath(string path) - { - // Handle complex paths with both dots and array indexers - List parts = new List(); - int startIndex = 0; - bool inBrackets = false; - - for (int i = 0; i < path.Length; i++) - { - char c = path[i]; - - if (c == '[') - { - inBrackets = true; - } - else if (c == ']') - { - inBrackets = false; - } - else if (c == '.' && !inBrackets) - { - // Found a dot separator outside of brackets - parts.Add(path.Substring(startIndex, i - startIndex)); - startIndex = i + 1; - } - } - if (startIndex < path.Length) - { - parts.Add(path.Substring(startIndex)); - } - return parts.ToArray(); - } - - /// - /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. - /// - /// - /// Delegates to PropertyConversion.ConvertToType for unified type handling. - /// The inputSerializer parameter is kept for backwards compatibility but is ignored - /// as PropertyConversion uses UnityJsonSerializer.Instance internally. - /// - private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) - { - return PropertyConversion.ConvertToType(token, targetType); - } - - // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- - // Keep them temporarily for reference or if specific fallback logic is ever needed. - - private static Vector3 ParseJTokenToVector3(JToken token) - { - // ... (implementation - likely replaced by Vector3Converter) ... - // Consider removing these if the serializer handles them reliably. - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) - { - return new Vector3(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject()); - } - if (token is JArray arr && arr.Count >= 3) - { - return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); - } - McpLog.Warn($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); - return Vector3.zero; - - } - private static Vector2 ParseJTokenToVector2(JToken token) - { - // ... (implementation - likely replaced by Vector2Converter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) - { - return new Vector2(obj["x"].ToObject(), obj["y"].ToObject()); - } - if (token is JArray arr && arr.Count >= 2) - { - return new Vector2(arr[0].ToObject(), arr[1].ToObject()); - } - McpLog.Warn($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); - return Vector2.zero; - } - private static Quaternion ParseJTokenToQuaternion(JToken token) - { - // ... (implementation - likely replaced by QuaternionConverter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) - { - return new Quaternion(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject(), obj["w"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - McpLog.Warn($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); - return Quaternion.identity; - } - private static Color ParseJTokenToColor(JToken token) - { - // ... (implementation - likely replaced by ColorConverter) ... - if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) - { - return new Color(obj["r"].ToObject(), obj["g"].ToObject(), obj["b"].ToObject(), obj["a"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - McpLog.Warn($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); - return Color.white; - } - private static Rect ParseJTokenToRect(JToken token) - { - // ... (implementation - likely replaced by RectConverter) ... - if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) - { - return new Rect(obj["x"].ToObject(), obj["y"].ToObject(), obj["width"].ToObject(), obj["height"].ToObject()); - } - if (token is JArray arr && arr.Count >= 4) - { - return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); - } - McpLog.Warn($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); - return Rect.zero; - } - private static Bounds ParseJTokenToBounds(JToken token) - { - // ... (implementation - likely replaced by BoundsConverter) ... - if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) - { - // Requires Vector3 conversion, which should ideally use the serializer too - Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) - Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) - return new Bounds(center, size); - } - // Array fallback for Bounds is less intuitive, maybe remove? - // if (token is JArray arr && arr.Count >= 6) - // { - // return new Bounds(new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()), new Vector3(arr[3].ToObject(), arr[4].ToObject(), arr[5].ToObject())); - // } - McpLog.Warn($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); - return new Bounds(Vector3.zero, Vector3.zero); - } - // --- End Redundant Parse Helpers --- - - /// - /// Finds a specific UnityEngine.Object based on a find instruction JObject. - /// Primarily used by UnityEngineObjectConverter during deserialization. - /// - /// - /// This method now delegates to ObjectResolver.Resolve() for cleaner architecture. - /// Kept for backwards compatibility with existing code. - /// - public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) - { - return ObjectResolver.Resolve(instruction, targetType); - } - - - /// - /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. - /// Searches already-loaded assemblies, prioritizing runtime script assemblies. - /// - private static Type FindType(string typeName) - { - if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) - { - return resolvedType; - } - - // Log the resolver error if type wasn't found - if (!string.IsNullOrEmpty(error)) - { - McpLog.Warn($"[FindType] {error}"); - } - - return null; - } - } - - /// - /// Component resolver that delegates to UnityTypeResolver. - /// Kept for backwards compatibility. - /// - internal static class ComponentResolver - { - /// - /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. - /// Delegates to UnityTypeResolver.TryResolve with Component constraint. - /// - public static bool TryResolve(string nameOrFullName, out Type type, out string error) - { - return UnityTypeResolver.TryResolve(nameOrFullName, out type, out error, typeof(Component)); - } - - /// - /// Gets all accessible property and field names from a component type. - /// - public static List GetAllComponentProperties(Type componentType) - { - if (componentType == null) return new List(); - - var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(p => p.CanRead && p.CanWrite) - .Select(p => p.Name); - - var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) - .Where(f => !f.IsInitOnly && !f.IsLiteral) - .Select(f => f.Name); - - // Also include SerializeField private fields (common in Unity) - var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .Where(f => f.GetCustomAttribute() != null) - .Select(f => f.Name); - - return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); - } - - /// - /// Suggests the most likely property matches for a user's input using fuzzy matching. - /// Uses Levenshtein distance, substring matching, and common naming pattern heuristics. - /// - public static List GetFuzzyPropertySuggestions(string userInput, List availableProperties) - { - if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) - return new List(); - - // Simple caching to avoid repeated lookups for the same input - var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; - if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) - return cached; - - try - { - var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); - PropertySuggestionCache[cacheKey] = suggestions; - return suggestions; - } - catch (Exception ex) - { - McpLog.Warn($"[Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); - return new List(); - } - } - - private static readonly Dictionary> PropertySuggestionCache = new(); - - /// - /// Rule-based suggestions that mimic AI behavior for property matching. - /// This provides immediate value while we could add real AI integration later. - /// - private static List GetRuleBasedSuggestions(string userInput, List availableProperties) - { - var suggestions = new List(); - var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); - - foreach (var property in availableProperties) - { - var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); - - // Exact match after cleaning - if (cleanedProperty == cleanedInput) - { - suggestions.Add(property); - continue; - } - - // Check if property contains all words from input - var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); - if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) - { - suggestions.Add(property); - continue; - } - - // Levenshtein distance for close matches - if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) - { - suggestions.Add(property); - } - } - - // Prioritize exact matches, then by similarity - return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) - .Take(3) - .ToList(); - } - - /// - /// Calculates Levenshtein distance between two strings for similarity matching. - /// - private static int LevenshteinDistance(string s1, string s2) - { - if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; - if (string.IsNullOrEmpty(s2)) return s1.Length; - - var matrix = new int[s1.Length + 1, s2.Length + 1]; - - for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; - for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; - - for (int i = 1; i <= s1.Length; i++) - { - for (int j = 1; j <= s2.Length; j++) - { - int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; - matrix[i, j] = Math.Min(Math.Min( - matrix[i - 1, j] + 1, // deletion - matrix[i, j - 1] + 1), // insertion - matrix[i - 1, j - 1] + cost); // substitution - } - } - - return matrix[s1.Length, s2.Length]; - } - - // Removed duplicate ParseVector3 - using the one at line 1114 - - // Removed GetGameObjectData, GetComponentData, and related private helpers/caching/serializer setup. - // They are now in Helpers.GameObjectSerializer - } -} diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs index b52e6b143..c3758cee3 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; -using MCPForUnity.Editor.Helpers; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; +using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnity.Editor.Tools.Vfx { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs index ade0801ca..f3f3d273d 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/GameObjectAPIStressTests.cs @@ -6,6 +6,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; using MCPForUnity.Editor.Helpers; using MCPForUnity.Editor.Resources.Scene; using UnityEngine.TestTools; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs index d58c5a7f3..7521625ca 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MCPToolParameterTests.cs @@ -4,6 +4,7 @@ using UnityEditor; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; using System; using System.IO; using System.Text.RegularExpressions; diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs index b396f18a4..7785d8971 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectCreateTests.cs @@ -3,7 +3,7 @@ using UnityEngine; using UnityEditorInternal; using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs index 82aa94db5..f3afc7d35 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectDeleteTests.cs @@ -2,7 +2,7 @@ using NUnit.Framework; using UnityEngine; using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs index d671b6b0e..124c11a4b 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectModifyTests.cs @@ -3,7 +3,7 @@ using UnityEngine; using UnityEditorInternal; using Newtonsoft.Json.Linq; -using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs index 2d8b1848a..e42572ed9 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageGameObjectTests.cs @@ -6,6 +6,7 @@ using UnityEngine.TestTools; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnityTests.Editor.Tools { diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs index 329870afb..c78b928ba 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/MaterialParameterToolTests.cs @@ -5,6 +5,7 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Tools; +using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnityTests.Editor.Tools { From c0389964e5efb7dffaca889a72cd429c10bc70e6 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 13:48:16 -0400 Subject: [PATCH 08/32] Remove obsolete FindObjectByInstruction method We also update the namespace for ManageVFX --- .../Editor/Tools/GameObjects/ManageGameObject.cs | 15 --------------- MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs | 7 +++---- MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs | 4 ++-- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs index 6d406df28..6900234e9 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/ManageGameObject.cs @@ -111,20 +111,5 @@ public static object HandleCommand(JObject @params) return new ErrorResponse($"Internal error processing action '{action}': {e.Message}"); } } - - /// - /// Finds a specific UnityEngine.Object based on a find instruction JObject. - /// Primarily used by UnityEngineObjectConverter during deserialization. - /// - /// - /// This method now delegates to ObjectResolver.Resolve() for cleaner architecture. - /// Kept for backwards compatibility with existing code. - /// - public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) - { - return ObjectResolver.Resolve(instruction, targetType); - } - - } } diff --git a/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs index 768eb3409..2aa428a03 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ManageVFX.cs @@ -5,7 +5,6 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Tools.Vfx; using UnityEngine; using UnityEditor; @@ -13,7 +12,7 @@ using UnityEngine.VFX; #endif -namespace MCPForUnity.Editor.Tools +namespace MCPForUnity.Editor.Tools.Vfx { /// /// Tool for managing Unity VFX components: @@ -626,7 +625,7 @@ private static object VFXSetTexture(JObject @params) if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and texturePath required" }; var findInst = new JObject { ["find"] = path }; - Texture tex = ManageGameObject.FindObjectByInstruction(findInst, typeof(Texture)) as Texture; + Texture tex = ObjectResolver.Resolve(findInst, typeof(Texture)) as Texture; if (tex == null) return new { success = false, message = $"Texture not found: {path}" }; Undo.RecordObject(vfx, $"Set VFX Texture {param}"); @@ -646,7 +645,7 @@ private static object VFXSetMesh(JObject @params) if (string.IsNullOrEmpty(param) || string.IsNullOrEmpty(path)) return new { success = false, message = "Parameter and meshPath required" }; var findInst = new JObject { ["find"] = path }; - Mesh mesh = ManageGameObject.FindObjectByInstruction(findInst, typeof(Mesh)) as Mesh; + Mesh mesh = ObjectResolver.Resolve(findInst, typeof(Mesh)) as Mesh; if (mesh == null) return new { success = false, message = $"Mesh not found: {path}" }; Undo.RecordObject(vfx, $"Set VFX Mesh {param}"); diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs index c3758cee3..0ec7b4191 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs @@ -201,13 +201,13 @@ public static object SetRenderer(JObject @params) if (@params["materialPath"] != null) { var findInst = new JObject { ["find"] = @params["materialPath"].ToString() }; - Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; + Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material; if (mat != null) { renderer.sharedMaterial = mat; changes.Add("material"); } } if (@params["trailMaterialPath"] != null) { var findInst = new JObject { ["find"] = @params["trailMaterialPath"].ToString() }; - Material mat = ManageGameObject.FindObjectByInstruction(findInst, typeof(Material)) as Material; + Material mat = ObjectResolver.Resolve(findInst, typeof(Material)) as Material; if (mat != null) { renderer.trailMaterial = mat; changes.Add("trailMaterial"); } } From d83c439a7b431becb1120569930d7fa22ef6fcfe Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 14:47:20 -0400 Subject: [PATCH 09/32] refactor: Consolidate editor state resources into single canonical implementation Merged EditorStateV2 into EditorState, making get_editor_state the canonical resource. Updated Unity C# to use EditorStateCache directly. Enhanced Python implementation with advice/staleness enrichment, external changes detection, and instance ID inference. Removed duplicate EditorStateV2 resource and legacy fallback mapping. --- .../Editor/Resources/Editor/EditorState.cs | 19 +- .../Editor/Resources/Editor/EditorStateV2.cs | 27 -- .../Resources/Editor/EditorStateV2.cs.meta | 11 - README.md | 3 +- Server/src/services/resources/editor_state.py | 193 ++++++++++-- .../src/services/resources/editor_state_v2.py | 278 ------------------ Server/src/services/tools/preflight.py | 10 +- Server/src/services/tools/refresh_unity.py | 8 +- .../test_editor_state_v2_contract.py | 21 +- .../test_refresh_unity_retry_recovery.py | 4 +- 10 files changed, 188 insertions(+), 386 deletions(-) delete mode 100644 MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs delete mode 100644 MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta delete mode 100644 Server/src/services/resources/editor_state_v2.py diff --git a/MCPForUnity/Editor/Resources/Editor/EditorState.cs b/MCPForUnity/Editor/Resources/Editor/EditorState.cs index 57f70f745..b58c88800 100644 --- a/MCPForUnity/Editor/Resources/Editor/EditorState.cs +++ b/MCPForUnity/Editor/Resources/Editor/EditorState.cs @@ -1,8 +1,7 @@ using System; using MCPForUnity.Editor.Helpers; +using MCPForUnity.Editor.Services; using Newtonsoft.Json.Linq; -using UnityEditor; -using UnityEditor.SceneManagement; namespace MCPForUnity.Editor.Resources.Editor { @@ -16,20 +15,8 @@ public static object HandleCommand(JObject @params) { try { - var activeScene = EditorSceneManager.GetActiveScene(); - var state = new - { - isPlaying = EditorApplication.isPlaying, - isPaused = EditorApplication.isPaused, - isCompiling = EditorApplication.isCompiling, - isUpdating = EditorApplication.isUpdating, - timeSinceStartup = EditorApplication.timeSinceStartup, - activeSceneName = activeScene.name ?? "", - selectionCount = UnityEditor.Selection.count, - activeObjectName = UnityEditor.Selection.activeObject?.name - }; - - return new SuccessResponse("Retrieved editor state.", state); + var snapshot = EditorStateCache.GetSnapshot(); + return new SuccessResponse("Retrieved editor state.", snapshot); } catch (Exception e) { diff --git a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs deleted file mode 100644 index d131bdbf0..000000000 --- a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Services; -using Newtonsoft.Json.Linq; - -namespace MCPForUnity.Editor.Resources.Editor -{ - /// - /// Provides a cached, v2 readiness snapshot. This is designed to remain responsive even when Unity is busy. - /// - [McpForUnityResource("get_editor_state_v2")] - public static class EditorStateV2 - { - public static object HandleCommand(JObject @params) - { - try - { - var snapshot = EditorStateCache.GetSnapshot(); - return new SuccessResponse("Retrieved editor state (v2).", snapshot); - } - catch (Exception e) - { - return new ErrorResponse($"Error getting editor state (v2): {e.Message}"); - } - } - } -} diff --git a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta b/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta deleted file mode 100644 index e776994fb..000000000 --- a/MCPForUnity/Editor/Resources/Editor/EditorStateV2.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 5514ec4eb8a294a55892a13194e250e8 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/README.md b/README.md index 8c1442c82..1dfa2c8fc 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,7 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav * `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings. * `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode. * `editor_selection`: Detailed information about currently selected objects in the editor. -* `editor_state`: Current editor runtime state (play mode, compilation, active scene, selection). -* `editor_state_v2`: Canonical editor readiness snapshot with advice and staleness info. +* `editor_state`: Editor readiness snapshot with advice and staleness info. * `editor_windows`: All currently open editor windows with titles, types, positions, and focus state. * `project_info`: Static project information (root path, Unity version, platform). * `project_layers`: All layers defined in TagManager with their indices (0-31). diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 8faea4396..8a2cd0403 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -1,51 +1,184 @@ -from pydantic import BaseModel +import os +import time +from typing import Any + from fastmcp import Context from models import MCPResponse from services.registry import mcp_for_unity_resource from services.tools import get_unity_instance_from_context -from transport.unity_transport import send_with_unity_instance +from services.state.external_changes_scanner import external_changes_scanner +import transport.unity_transport as unity_transport from transport.legacy.unity_connection import async_send_command_with_retry -class EditorStateData(BaseModel): - """Editor state data fields.""" - isPlaying: bool = False - isPaused: bool = False - isCompiling: bool = False - isUpdating: bool = False - timeSinceStartup: float = 0.0 - activeSceneName: str = "" - selectionCount: int = 0 - activeObjectName: str | None = None +def _now_unix_ms() -> int: + return int(time.time() * 1000) + + +def _in_pytest() -> bool: + # Avoid instance-discovery side effects during the Python integration test suite. + return bool(os.environ.get("PYTEST_CURRENT_TEST")) + + +async def _infer_single_instance_id(ctx: Context) -> str | None: + """ + Best-effort: if exactly one Unity instance is connected, return its Name@hash id. + This makes editor_state outputs self-describing even when no explicit active instance is set. + """ + if _in_pytest(): + return None + + try: + transport = unity_transport._current_transport() + except Exception: + transport = None + + if transport == "http": + # HTTP/WebSocket transport: derive from PluginHub sessions. + try: + from transport.plugin_hub import PluginHub + + sessions_data = await PluginHub.get_sessions() + sessions = sessions_data.sessions if hasattr( + sessions_data, "sessions") else {} + if isinstance(sessions, dict) and len(sessions) == 1: + session = next(iter(sessions.values())) + project = getattr(session, "project", None) + project_hash = getattr(session, "hash", None) + if project and project_hash: + return f"{project}@{project_hash}" + except Exception: + return None + return None + + # Stdio/TCP transport: derive from connection pool discovery. + try: + from transport.legacy.unity_connection import get_unity_connection_pool + + pool = get_unity_connection_pool() + instances = pool.discover_all_instances(force_refresh=False) + if isinstance(instances, list) and len(instances) == 1: + inst = instances[0] + inst_id = getattr(inst, "id", None) + return str(inst_id) if inst_id else None + except Exception: + return None + return None + + +def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]: + now_ms = _now_unix_ms() + observed = state_v2.get("observed_at_unix_ms") + try: + observed_ms = int(observed) + except Exception: + observed_ms = now_ms + age_ms = max(0, now_ms - observed_ms) + # Conservative default: treat >2s as stale (covers common unfocused-editor throttling). + is_stale = age_ms > 2000 -class EditorStateResponse(MCPResponse): - """Dynamic editor state information that changes frequently.""" - data: EditorStateData = EditorStateData() + compilation = state_v2.get("compilation") or {} + tests = state_v2.get("tests") or {} + assets = state_v2.get("assets") or {} + refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {} + + blocking: list[str] = [] + if compilation.get("is_compiling") is True: + blocking.append("compiling") + if compilation.get("is_domain_reload_pending") is True: + blocking.append("domain_reload") + if tests.get("is_running") is True: + blocking.append("running_tests") + if refresh.get("is_refresh_in_progress") is True: + blocking.append("asset_refresh") + if is_stale: + blocking.append("stale_status") + + ready_for_tools = len(blocking) == 0 + + state_v2["advice"] = { + "ready_for_tools": ready_for_tools, + "blocking_reasons": blocking, + "recommended_retry_after_ms": 0 if ready_for_tools else 500, + "recommended_next_action": "none" if ready_for_tools else "retry_later", + } + state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale} + return state_v2 @mcp_for_unity_resource( uri="unity://editor/state", name="editor_state", - description="Current editor runtime state including play mode, compilation status, active scene, and selection summary. Refresh frequently for up-to-date information." + description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.", ) -async def get_editor_state(ctx: Context) -> EditorStateResponse | MCPResponse: - """Get current editor runtime state.""" +async def get_editor_state(ctx: Context) -> MCPResponse: unity_instance = get_unity_instance_from_context(ctx) - response = await send_with_unity_instance( + + response = await unity_transport.send_with_unity_instance( async_send_command_with_retry, unity_instance, "get_editor_state", - {} + {}, ) - # When Unity is reloading/unresponsive (often when unfocused), transports may return - # a retryable MCPResponse payload with success=false and no data. Do not attempt to - # coerce that into EditorStateResponse (it would fail validation); return it as-is. - if isinstance(response, dict): - if not response.get("success", True): - return MCPResponse(**response) - if response.get("data") is None: - return MCPResponse(success=False, error="Editor state missing 'data' payload", data=response) - return EditorStateResponse(**response) - return response + + # If Unity returns a structured retry hint or error, surface it directly. + if isinstance(response, dict) and not response.get("success", True): + return MCPResponse(**response) + + state_v2 = response.get("data") if isinstance( + response, dict) and isinstance(response.get("data"), dict) else {} + state_v2.setdefault("schema_version", "unity-mcp/editor_state@2") + state_v2.setdefault("observed_at_unix_ms", _now_unix_ms()) + state_v2.setdefault("sequence", 0) + + # Ensure the returned snapshot is clearly associated with the targeted instance. + unity_section = state_v2.get("unity") + if not isinstance(unity_section, dict): + unity_section = {} + state_v2["unity"] = unity_section + current_instance_id = unity_section.get("instance_id") + if current_instance_id in (None, ""): + if unity_instance: + unity_section["instance_id"] = unity_instance + else: + inferred = await _infer_single_instance_id(ctx) + if inferred: + unity_section["instance_id"] = inferred + + # External change detection (server-side): compute per instance based on project root path. + try: + instance_id = unity_section.get("instance_id") + if isinstance(instance_id, str) and instance_id.strip(): + from services.resources.project_info import get_project_info + + proj_resp = await get_project_info(ctx) + proj = proj_resp.model_dump() if hasattr( + proj_resp, "model_dump") else proj_resp + proj_data = proj.get("data") if isinstance(proj, dict) else None + project_root = proj_data.get("projectRoot") if isinstance( + proj_data, dict) else None + if isinstance(project_root, str) and project_root.strip(): + external_changes_scanner.set_project_root( + instance_id, project_root) + + ext = external_changes_scanner.update_and_get(instance_id) + + assets = state_v2.get("assets") + if not isinstance(assets, dict): + assets = {} + state_v2["assets"] = assets + assets["external_changes_dirty"] = bool( + ext.get("external_changes_dirty", False)) + assets["external_changes_last_seen_unix_ms"] = ext.get( + "external_changes_last_seen_unix_ms") + assets["external_changes_dirty_since_unix_ms"] = ext.get( + "dirty_since_unix_ms") + assets["external_changes_last_cleared_unix_ms"] = ext.get( + "last_cleared_unix_ms") + except Exception: + pass + + state_v2 = _enrich_advice_and_staleness(state_v2) + return MCPResponse(success=True, message="Retrieved editor state.", data=state_v2) diff --git a/Server/src/services/resources/editor_state_v2.py b/Server/src/services/resources/editor_state_v2.py deleted file mode 100644 index 371d2ca8e..000000000 --- a/Server/src/services/resources/editor_state_v2.py +++ /dev/null @@ -1,278 +0,0 @@ -import time -import os -from typing import Any - -from fastmcp import Context - -from models import MCPResponse -from services.registry import mcp_for_unity_resource -from services.tools import get_unity_instance_from_context -import transport.unity_transport as unity_transport -from transport.legacy.unity_connection import async_send_command_with_retry -from services.state.external_changes_scanner import external_changes_scanner - - -def _now_unix_ms() -> int: - return int(time.time() * 1000) - - -def _in_pytest() -> bool: - # Avoid instance-discovery side effects during the Python integration test suite. - return bool(os.environ.get("PYTEST_CURRENT_TEST")) - - -async def _infer_single_instance_id(ctx: Context) -> str | None: - """ - Best-effort: if exactly one Unity instance is connected, return its Name@hash id. - This makes editor_state outputs self-describing even when no explicit active instance is set. - """ - if _in_pytest(): - return None - - try: - transport = unity_transport._current_transport() - except Exception: - transport = None - - if transport == "http": - # HTTP/WebSocket transport: derive from PluginHub sessions. - try: - from transport.plugin_hub import PluginHub - - sessions_data = await PluginHub.get_sessions() - sessions = sessions_data.sessions if hasattr( - sessions_data, "sessions") else {} - if isinstance(sessions, dict) and len(sessions) == 1: - session = next(iter(sessions.values())) - project = getattr(session, "project", None) - project_hash = getattr(session, "hash", None) - if project and project_hash: - return f"{project}@{project_hash}" - except Exception: - return None - return None - - # Stdio/TCP transport: derive from connection pool discovery. - try: - from transport.legacy.unity_connection import get_unity_connection_pool - - pool = get_unity_connection_pool() - instances = pool.discover_all_instances(force_refresh=False) - if isinstance(instances, list) and len(instances) == 1: - inst = instances[0] - inst_id = getattr(inst, "id", None) - return str(inst_id) if inst_id else None - except Exception: - return None - return None - - -def _build_v2_from_legacy(legacy: dict[str, Any]) -> dict[str, Any]: - """ - Best-effort mapping from legacy get_editor_state payload into the v2 contract. - Legacy shape (Unity): {isPlaying,isPaused,isCompiling,isUpdating,timeSinceStartup,...} - """ - now_ms = _now_unix_ms() - # legacy may arrive already wrapped as MCPResponse-like {success,data:{...}} - state = legacy.get("data") if isinstance(legacy.get("data"), dict) else {} - - return { - "schema_version": "unity-mcp/editor_state@2", - "observed_at_unix_ms": now_ms, - "sequence": 0, - "unity": { - "instance_id": None, - "unity_version": None, - "project_id": None, - "platform": None, - "is_batch_mode": None, - }, - "editor": { - "is_focused": None, - "play_mode": { - "is_playing": bool(state.get("isPlaying", False)), - "is_paused": bool(state.get("isPaused", False)), - "is_changing": None, - }, - "active_scene": { - "path": None, - "guid": None, - "name": state.get("activeSceneName", "") or "", - }, - "selection": { - "count": int(state.get("selectionCount", 0) or 0), - "active_object_name": state.get("activeObjectName", None), - }, - }, - "activity": { - "phase": "unknown", - "since_unix_ms": now_ms, - "reasons": ["legacy_fallback"], - }, - "compilation": { - "is_compiling": bool(state.get("isCompiling", False)), - "is_domain_reload_pending": None, - "last_compile_started_unix_ms": None, - "last_compile_finished_unix_ms": None, - }, - "assets": { - "is_updating": bool(state.get("isUpdating", False)), - "external_changes_dirty": False, - "external_changes_last_seen_unix_ms": None, - "refresh": { - "is_refresh_in_progress": False, - "last_refresh_requested_unix_ms": None, - "last_refresh_finished_unix_ms": None, - }, - }, - "tests": { - "is_running": False, - "mode": None, - "started_unix_ms": None, - "started_by": "unknown", - "last_run": None, - }, - "transport": { - "unity_bridge_connected": None, - "last_message_unix_ms": None, - }, - } - - -def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]: - now_ms = _now_unix_ms() - observed = state_v2.get("observed_at_unix_ms") - try: - observed_ms = int(observed) - except Exception: - observed_ms = now_ms - - age_ms = max(0, now_ms - observed_ms) - # Conservative default: treat >2s as stale (covers common unfocused-editor throttling). - is_stale = age_ms > 2000 - - compilation = state_v2.get("compilation") or {} - tests = state_v2.get("tests") or {} - assets = state_v2.get("assets") or {} - refresh = (assets.get("refresh") or {}) if isinstance(assets, dict) else {} - - blocking: list[str] = [] - if compilation.get("is_compiling") is True: - blocking.append("compiling") - if compilation.get("is_domain_reload_pending") is True: - blocking.append("domain_reload") - if tests.get("is_running") is True: - blocking.append("running_tests") - if refresh.get("is_refresh_in_progress") is True: - blocking.append("asset_refresh") - if is_stale: - blocking.append("stale_status") - - ready_for_tools = len(blocking) == 0 - - state_v2["advice"] = { - "ready_for_tools": ready_for_tools, - "blocking_reasons": blocking, - "recommended_retry_after_ms": 0 if ready_for_tools else 500, - "recommended_next_action": "none" if ready_for_tools else "retry_later", - } - state_v2["staleness"] = {"age_ms": age_ms, "is_stale": is_stale} - return state_v2 - - -@mcp_for_unity_resource( - uri="unity://editor_state", - name="editor_state_v2", - description="Canonical editor readiness snapshot (v2). Includes advice and server-computed staleness.", -) -async def get_editor_state_v2(ctx: Context) -> MCPResponse: - unity_instance = get_unity_instance_from_context(ctx) - - # Try v2 snapshot first (Unity-side cache will make this fast once implemented). - response = await unity_transport.send_with_unity_instance( - async_send_command_with_retry, - unity_instance, - "get_editor_state_v2", - {}, - ) - - # If Unity returns a structured retry hint or error, surface it directly. - if isinstance(response, dict) and not response.get("success", True): - return MCPResponse(**response) - - # If v2 is unavailable (older plugin), fall back to legacy get_editor_state and map. - if not (isinstance(response, dict) and isinstance(response.get("data"), dict) and response["data"].get("schema_version")): - legacy = await unity_transport.send_with_unity_instance( - async_send_command_with_retry, - unity_instance, - "get_editor_state", - {}, - ) - if isinstance(legacy, dict) and not legacy.get("success", True): - return MCPResponse(**legacy) - state_v2 = _build_v2_from_legacy( - legacy if isinstance(legacy, dict) else {}) - else: - state_v2 = response.get("data") if isinstance( - response.get("data"), dict) else {} - # Ensure required v2 marker exists even if Unity returns partial. - state_v2.setdefault("schema_version", "unity-mcp/editor_state@2") - state_v2.setdefault("observed_at_unix_ms", _now_unix_ms()) - state_v2.setdefault("sequence", 0) - - # Ensure the returned snapshot is clearly associated with the targeted instance. - # (This matters when multiple Unity instances are connected and the client is polling readiness.) - unity_section = state_v2.get("unity") - if not isinstance(unity_section, dict): - unity_section = {} - state_v2["unity"] = unity_section - current_instance_id = unity_section.get("instance_id") - if current_instance_id in (None, ""): - if unity_instance: - unity_section["instance_id"] = unity_instance - else: - inferred = await _infer_single_instance_id(ctx) - if inferred: - unity_section["instance_id"] = inferred - - # External change detection (server-side): compute per instance based on project root path. - # This helps detect stale assets when external tools edit the filesystem. - try: - instance_id = unity_section.get("instance_id") - if isinstance(instance_id, str) and instance_id.strip(): - from services.resources.project_info import get_project_info - - # Cache the project root for this instance (best-effort). - proj_resp = await get_project_info(ctx) - proj = proj_resp.model_dump() if hasattr( - proj_resp, "model_dump") else proj_resp - proj_data = proj.get("data") if isinstance(proj, dict) else None - project_root = proj_data.get("projectRoot") if isinstance( - proj_data, dict) else None - if isinstance(project_root, str) and project_root.strip(): - external_changes_scanner.set_project_root( - instance_id, project_root) - - ext = external_changes_scanner.update_and_get(instance_id) - - assets = state_v2.get("assets") - if not isinstance(assets, dict): - assets = {} - state_v2["assets"] = assets - # IMPORTANT: Unity's cached snapshot may include placeholder defaults; the server scanner is authoritative - # for external changes (filesystem edits outside Unity). Always overwrite these fields from the scanner. - assets["external_changes_dirty"] = bool( - ext.get("external_changes_dirty", False)) - assets["external_changes_last_seen_unix_ms"] = ext.get( - "external_changes_last_seen_unix_ms") - # Extra bookkeeping fields (server-only) are safe to add under assets. - assets["external_changes_dirty_since_unix_ms"] = ext.get( - "dirty_since_unix_ms") - assets["external_changes_last_cleared_unix_ms"] = ext.get( - "last_cleared_unix_ms") - except Exception: - # Best-effort; do not fail readiness resource if filesystem scan can't run. - pass - - state_v2 = _enrich_advice_and_staleness(state_v2) - return MCPResponse(success=True, message="Retrieved editor state (v2).", data=state_v2) diff --git a/Server/src/services/tools/preflight.py b/Server/src/services/tools/preflight.py index b04e0de87..6817eb820 100644 --- a/Server/src/services/tools/preflight.py +++ b/Server/src/services/tools/preflight.py @@ -42,10 +42,10 @@ async def preflight( if _in_pytest(): return None - # Load canonical v2 state (server enriches advice + staleness). + # Load canonical editor state (server enriches advice + staleness). try: - from services.resources.editor_state_v2 import get_editor_state_v2 - state_resp = await get_editor_state_v2(ctx) + from services.resources.editor_state import get_editor_state + state_resp = await get_editor_state(ctx) state = state_resp.model_dump() if hasattr( state_resp, "model_dump") else state_resp except Exception: @@ -95,8 +95,8 @@ async def preflight( # Refresh state for the next loop iteration. try: - from services.resources.editor_state_v2 import get_editor_state_v2 - state_resp = await get_editor_state_v2(ctx) + from services.resources.editor_state import get_editor_state + state_resp = await get_editor_state(ctx) state = state_resp.model_dump() if hasattr( state_resp, "model_dump") else state_resp data = state.get("data") if isinstance(state, dict) else None diff --git a/Server/src/services/tools/refresh_unity.py b/Server/src/services/tools/refresh_unity.py index b596a181f..39a01239a 100644 --- a/Server/src/services/tools/refresh_unity.py +++ b/Server/src/services/tools/refresh_unity.py @@ -13,6 +13,7 @@ import transport.unity_transport as unity_transport from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason from services.state.external_changes_scanner import external_changes_scanner +from services.resources.editor_state import get_editor_state, _infer_single_instance_id @mcp_for_unity_tool( @@ -64,14 +65,13 @@ async def refresh_unity( recovered_from_disconnect = True # Optional server-side wait loop (defensive): if Unity tool doesn't wait or returns quickly, - # poll the canonical editor_state v2 resource until ready or timeout. + # poll the canonical editor_state resource until ready or timeout. if wait_for_ready: timeout_s = 60.0 start = time.monotonic() - from services.resources.editor_state_v2 import get_editor_state_v2 while time.monotonic() - start < timeout_s: - state_resp = await get_editor_state_v2(ctx) + state_resp = await get_editor_state(ctx) state = state_resp.model_dump() if hasattr( state_resp, "model_dump") else state_resp data = (state or {}).get("data") if isinstance( @@ -84,8 +84,6 @@ async def refresh_unity( # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly. try: - from services.resources.editor_state_v2 import _infer_single_instance_id - inst = unity_instance or await _infer_single_instance_id(ctx) if inst: external_changes_scanner.clear_dirty(inst) diff --git a/Server/tests/integration/test_editor_state_v2_contract.py b/Server/tests/integration/test_editor_state_v2_contract.py index 4d58ee940..644c82bfd 100644 --- a/Server/tests/integration/test_editor_state_v2_contract.py +++ b/Server/tests/integration/test_editor_state_v2_contract.py @@ -8,24 +8,25 @@ @pytest.mark.asyncio async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch): """ - Red test: we expect a canonical v2 resource `unity://editor_state` with required top-level fields. - - Today, only `unity://editor/state` exists and is minimal. + Canonical editor state resource should be `unity://editor/state` and conform to v2 contract fields. """ - # Import the v2 module to ensure it registers its decorator without disturbing global registry state. - import services.resources.editor_state_v2 # noqa: F401 + # Import module to ensure it registers its decorator without disturbing global registry state. + import services.resources.editor_state # noqa: F401 resources = get_registered_resources() - v2 = next((r for r in resources if r.get("uri") == "unity://editor_state"), None) - assert v2 is not None, ( - "Expected canonical readiness resource `unity://editor_state` to be registered. " + state_res = next( + (r for r in resources if r.get("uri") == "unity://editor/state"), + None, + ) + assert state_res is not None, ( + "Expected canonical editor state resource `unity://editor/state` to be registered. " "This is required so clients can poll readiness/staleness and avoid tool loops." ) async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): # Minimal stub payload for v2 resource tests. The server layer should enrich with staleness/advice. - assert command_type in {"get_editor_state_v2", "get_editor_state"} + assert command_type == "get_editor_state" return { "success": True, "data": { @@ -41,7 +42,7 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p import transport.unity_transport as unity_transport monkeypatch.setattr(unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) - result = await v2["func"](DummyContext()) + result = await state_res["func"](DummyContext()) payload = result.model_dump() if hasattr(result, "model_dump") else result assert isinstance(payload, dict) diff --git a/Server/tests/integration/test_refresh_unity_retry_recovery.py b/Server/tests/integration/test_refresh_unity_retry_recovery.py index 27a702230..b487bc10e 100644 --- a/Server/tests/integration/test_refresh_unity_retry_recovery.py +++ b/Server/tests/integration/test_refresh_unity_retry_recovery.py @@ -32,8 +32,8 @@ async def fake_get_editor_state_v2(_ctx): import services.tools.refresh_unity as refresh_mod monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) - import services.resources.editor_state_v2 as esv2_mod - monkeypatch.setattr(esv2_mod, "get_editor_state_v2", fake_get_editor_state_v2) + import services.resources.editor_state as es_mod + monkeypatch.setattr(es_mod, "get_editor_state", fake_get_editor_state_v2) resp = await refresh_unity(ctx, wait_for_ready=True) payload = resp.model_dump() if hasattr(resp, "model_dump") else resp From 0a9556330face1516a03c48f18ad79ebb564ca17 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 15:16:47 -0400 Subject: [PATCH 10/32] Validate editor state with Pydantic models in both C# and Python Added strongly-typed Pydantic models for EditorStateV2 schema in Python and corresponding C# classes with JsonProperty attributes. Updated C# to serialize using typed classes instead of anonymous objects. Python now validates the editor state payload before returning it, catching schema mismatches early. --- .../Editor/Services/EditorStateCache.cs | 301 ++++++++++++++---- Server/src/services/resources/editor_state.py | 123 ++++++- 2 files changed, 368 insertions(+), 56 deletions(-) diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs index 97557841e..bc014f87f 100644 --- a/MCPForUnity/Editor/Services/EditorStateCache.cs +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -1,5 +1,6 @@ using System; using MCPForUnity.Editor.Helpers; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEditorInternal; @@ -32,6 +33,195 @@ internal static class EditorStateCache private static JObject _cached; + private sealed class EditorStateV2Snapshot + { + [JsonProperty("schema_version")] + public string SchemaVersion { get; set; } + + [JsonProperty("observed_at_unix_ms")] + public long ObservedAtUnixMs { get; set; } + + [JsonProperty("sequence")] + public long Sequence { get; set; } + + [JsonProperty("unity")] + public EditorStateV2Unity Unity { get; set; } + + [JsonProperty("editor")] + public EditorStateV2Editor Editor { get; set; } + + [JsonProperty("activity")] + public EditorStateV2Activity Activity { get; set; } + + [JsonProperty("compilation")] + public EditorStateV2Compilation Compilation { get; set; } + + [JsonProperty("assets")] + public EditorStateV2Assets Assets { get; set; } + + [JsonProperty("tests")] + public EditorStateV2Tests Tests { get; set; } + + [JsonProperty("transport")] + public EditorStateV2Transport Transport { get; set; } + } + + private sealed class EditorStateV2Unity + { + [JsonProperty("instance_id")] + public string InstanceId { get; set; } + + [JsonProperty("unity_version")] + public string UnityVersion { get; set; } + + [JsonProperty("project_id")] + public string ProjectId { get; set; } + + [JsonProperty("platform")] + public string Platform { get; set; } + + [JsonProperty("is_batch_mode")] + public bool? IsBatchMode { get; set; } + } + + private sealed class EditorStateV2Editor + { + [JsonProperty("is_focused")] + public bool? IsFocused { get; set; } + + [JsonProperty("play_mode")] + public EditorStateV2PlayMode PlayMode { get; set; } + + [JsonProperty("active_scene")] + public EditorStateV2ActiveScene ActiveScene { get; set; } + } + + private sealed class EditorStateV2PlayMode + { + [JsonProperty("is_playing")] + public bool? IsPlaying { get; set; } + + [JsonProperty("is_paused")] + public bool? IsPaused { get; set; } + + [JsonProperty("is_changing")] + public bool? IsChanging { get; set; } + } + + private sealed class EditorStateV2ActiveScene + { + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("guid")] + public string Guid { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + } + + private sealed class EditorStateV2Activity + { + [JsonProperty("phase")] + public string Phase { get; set; } + + [JsonProperty("since_unix_ms")] + public long SinceUnixMs { get; set; } + + [JsonProperty("reasons")] + public string[] Reasons { get; set; } + } + + private sealed class EditorStateV2Compilation + { + [JsonProperty("is_compiling")] + public bool? IsCompiling { get; set; } + + [JsonProperty("is_domain_reload_pending")] + public bool? IsDomainReloadPending { get; set; } + + [JsonProperty("last_compile_started_unix_ms")] + public long? LastCompileStartedUnixMs { get; set; } + + [JsonProperty("last_compile_finished_unix_ms")] + public long? LastCompileFinishedUnixMs { get; set; } + + [JsonProperty("last_domain_reload_before_unix_ms")] + public long? LastDomainReloadBeforeUnixMs { get; set; } + + [JsonProperty("last_domain_reload_after_unix_ms")] + public long? LastDomainReloadAfterUnixMs { get; set; } + } + + private sealed class EditorStateV2Assets + { + [JsonProperty("is_updating")] + public bool? IsUpdating { get; set; } + + [JsonProperty("external_changes_dirty")] + public bool? ExternalChangesDirty { get; set; } + + [JsonProperty("external_changes_last_seen_unix_ms")] + public long? ExternalChangesLastSeenUnixMs { get; set; } + + [JsonProperty("refresh")] + public EditorStateV2Refresh Refresh { get; set; } + } + + private sealed class EditorStateV2Refresh + { + [JsonProperty("is_refresh_in_progress")] + public bool? IsRefreshInProgress { get; set; } + + [JsonProperty("last_refresh_requested_unix_ms")] + public long? LastRefreshRequestedUnixMs { get; set; } + + [JsonProperty("last_refresh_finished_unix_ms")] + public long? LastRefreshFinishedUnixMs { get; set; } + } + + private sealed class EditorStateV2Tests + { + [JsonProperty("is_running")] + public bool? IsRunning { get; set; } + + [JsonProperty("mode")] + public string Mode { get; set; } + + [JsonProperty("current_job_id")] + public string CurrentJobId { get; set; } + + [JsonProperty("started_unix_ms")] + public long? StartedUnixMs { get; set; } + + [JsonProperty("started_by")] + public string StartedBy { get; set; } + + [JsonProperty("last_run")] + public EditorStateV2LastRun LastRun { get; set; } + } + + private sealed class EditorStateV2LastRun + { + [JsonProperty("finished_unix_ms")] + public long? FinishedUnixMs { get; set; } + + [JsonProperty("result")] + public string Result { get; set; } + + [JsonProperty("counts")] + public object Counts { get; set; } + } + + private sealed class EditorStateV2Transport + { + [JsonProperty("unity_bridge_connected")] + public bool? UnityBridgeConnected { get; set; } + + [JsonProperty("last_message_unix_ms")] + public long? LastMessageUnixMs { get; set; } + } + static EditorStateCache() { try @@ -135,85 +325,86 @@ private static JObject BuildSnapshot(string reason) activityPhase = "playmode_transition"; } - // Keep this as a plain JSON object for minimal friction with transports. - return JObject.FromObject(new + var snapshot = new EditorStateV2Snapshot { - schema_version = "unity-mcp/editor_state@2", - observed_at_unix_ms = _observedUnixMs, - sequence = _sequence, - unity = new + SchemaVersion = "unity-mcp/editor_state@2", + ObservedAtUnixMs = _observedUnixMs, + Sequence = _sequence, + Unity = new EditorStateV2Unity { - instance_id = (string)null, - unity_version = Application.unityVersion, - project_id = (string)null, - platform = Application.platform.ToString(), - is_batch_mode = Application.isBatchMode + InstanceId = null, + UnityVersion = Application.unityVersion, + ProjectId = null, + Platform = Application.platform.ToString(), + IsBatchMode = Application.isBatchMode }, - editor = new + Editor = new EditorStateV2Editor { - is_focused = isFocused, - play_mode = new + IsFocused = isFocused, + PlayMode = new EditorStateV2PlayMode { - is_playing = EditorApplication.isPlaying, - is_paused = EditorApplication.isPaused, - is_changing = EditorApplication.isPlayingOrWillChangePlaymode + IsPlaying = EditorApplication.isPlaying, + IsPaused = EditorApplication.isPaused, + IsChanging = EditorApplication.isPlayingOrWillChangePlaymode }, - active_scene = new + ActiveScene = new EditorStateV2ActiveScene { - path = scenePath, - guid = sceneGuid, - name = scene.name ?? string.Empty + Path = scenePath, + Guid = sceneGuid, + Name = scene.name ?? string.Empty } }, - activity = new + Activity = new EditorStateV2Activity { - phase = activityPhase, - since_unix_ms = _observedUnixMs, - reasons = new[] { reason } + Phase = activityPhase, + SinceUnixMs = _observedUnixMs, + Reasons = new[] { reason } }, - compilation = new + Compilation = new EditorStateV2Compilation { - is_compiling = isCompiling, - is_domain_reload_pending = _domainReloadPending, - last_compile_started_unix_ms = _lastCompileStartedUnixMs, - last_compile_finished_unix_ms = _lastCompileFinishedUnixMs, - last_domain_reload_before_unix_ms = _domainReloadBeforeUnixMs, - last_domain_reload_after_unix_ms = _domainReloadAfterUnixMs + IsCompiling = isCompiling, + IsDomainReloadPending = _domainReloadPending, + LastCompileStartedUnixMs = _lastCompileStartedUnixMs, + LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs, + LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs, + LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs }, - assets = new + Assets = new EditorStateV2Assets { - is_updating = EditorApplication.isUpdating, - external_changes_dirty = false, - external_changes_last_seen_unix_ms = (long?)null, - refresh = new + IsUpdating = EditorApplication.isUpdating, + ExternalChangesDirty = false, + ExternalChangesLastSeenUnixMs = null, + Refresh = new EditorStateV2Refresh { - is_refresh_in_progress = false, - last_refresh_requested_unix_ms = (long?)null, - last_refresh_finished_unix_ms = (long?)null + IsRefreshInProgress = false, + LastRefreshRequestedUnixMs = null, + LastRefreshFinishedUnixMs = null } }, - tests = new + Tests = new EditorStateV2Tests { - is_running = testsRunning, - mode = testsMode, - current_job_id = string.IsNullOrEmpty(currentJobId) ? null : currentJobId, - started_unix_ms = TestRunStatus.StartedUnixMs, - started_by = "unknown", - last_run = TestRunStatus.FinishedUnixMs.HasValue - ? new + IsRunning = testsRunning, + Mode = testsMode, + CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId, + StartedUnixMs = TestRunStatus.StartedUnixMs, + StartedBy = "unknown", + LastRun = TestRunStatus.FinishedUnixMs.HasValue + ? new EditorStateV2LastRun { - finished_unix_ms = TestRunStatus.FinishedUnixMs, - result = "unknown", - counts = (object)null + FinishedUnixMs = TestRunStatus.FinishedUnixMs, + Result = "unknown", + Counts = null } : null }, - transport = new + Transport = new EditorStateV2Transport { - unity_bridge_connected = (bool?)null, - last_message_unix_ms = (long?)null + UnityBridgeConnected = null, + LastMessageUnixMs = null } - }); + }; + + return JObject.FromObject(snapshot); } public static JObject GetSnapshot() diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 8a2cd0403..9b176ef0f 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -3,6 +3,7 @@ from typing import Any from fastmcp import Context +from pydantic import BaseModel from models import MCPResponse from services.registry import mcp_for_unity_resource @@ -12,6 +13,109 @@ from transport.legacy.unity_connection import async_send_command_with_retry +class EditorStateV2Unity(BaseModel): + instance_id: str | None = None + unity_version: str | None = None + project_id: str | None = None + platform: str | None = None + is_batch_mode: bool | None = None + + +class EditorStateV2PlayMode(BaseModel): + is_playing: bool | None = None + is_paused: bool | None = None + is_changing: bool | None = None + + +class EditorStateV2ActiveScene(BaseModel): + path: str | None = None + guid: str | None = None + name: str | None = None + + +class EditorStateV2Editor(BaseModel): + is_focused: bool | None = None + play_mode: EditorStateV2PlayMode | None = None + active_scene: EditorStateV2ActiveScene | None = None + + +class EditorStateV2Activity(BaseModel): + phase: str | None = None + since_unix_ms: int | None = None + reasons: list[str] | None = None + + +class EditorStateV2Compilation(BaseModel): + is_compiling: bool | None = None + is_domain_reload_pending: bool | None = None + last_compile_started_unix_ms: int | None = None + last_compile_finished_unix_ms: int | None = None + last_domain_reload_before_unix_ms: int | None = None + last_domain_reload_after_unix_ms: int | None = None + + +class EditorStateV2Refresh(BaseModel): + is_refresh_in_progress: bool | None = None + last_refresh_requested_unix_ms: int | None = None + last_refresh_finished_unix_ms: int | None = None + + +class EditorStateV2Assets(BaseModel): + is_updating: bool | None = None + external_changes_dirty: bool | None = None + external_changes_last_seen_unix_ms: int | None = None + external_changes_dirty_since_unix_ms: int | None = None + external_changes_last_cleared_unix_ms: int | None = None + refresh: EditorStateV2Refresh | None = None + + +class EditorStateV2LastRun(BaseModel): + finished_unix_ms: int | None = None + result: str | None = None + counts: Any | None = None + + +class EditorStateV2Tests(BaseModel): + is_running: bool | None = None + mode: str | None = None + current_job_id: str | None = None + started_unix_ms: int | None = None + started_by: str | None = None + last_run: EditorStateV2LastRun | None = None + + +class EditorStateV2Transport(BaseModel): + unity_bridge_connected: bool | None = None + last_message_unix_ms: int | None = None + + +class EditorStateV2Advice(BaseModel): + ready_for_tools: bool | None = None + blocking_reasons: list[str] | None = None + recommended_retry_after_ms: int | None = None + recommended_next_action: str | None = None + + +class EditorStateV2Staleness(BaseModel): + age_ms: int | None = None + is_stale: bool | None = None + + +class EditorStateV2Data(BaseModel): + schema_version: str + observed_at_unix_ms: int + sequence: int + unity: EditorStateV2Unity | None = None + editor: EditorStateV2Editor | None = None + activity: EditorStateV2Activity | None = None + compilation: EditorStateV2Compilation | None = None + assets: EditorStateV2Assets | None = None + tests: EditorStateV2Tests | None = None + transport: EditorStateV2Transport | None = None + advice: EditorStateV2Advice | None = None + staleness: EditorStateV2Staleness | None = None + + def _now_unix_ms() -> int: return int(time.time() * 1000) @@ -181,4 +285,21 @@ async def get_editor_state(ctx: Context) -> MCPResponse: pass state_v2 = _enrich_advice_and_staleness(state_v2) - return MCPResponse(success=True, message="Retrieved editor state.", data=state_v2) + + try: + if hasattr(EditorStateV2Data, "model_validate"): + validated = EditorStateV2Data.model_validate(state_v2) + else: + validated = EditorStateV2Data.parse_obj( + state_v2) # type: ignore[attr-defined] + data = validated.model_dump() if hasattr( + validated, "model_dump") else validated.dict() + except Exception as e: + return MCPResponse( + success=False, + error="invalid_editor_state", + message=f"Editor state payload failed validation: {e}", + data={"raw": state_v2}, + ) + + return MCPResponse(success=True, message="Retrieved editor state.", data=data) From bcbba0539f61f4e5e360b373a6786da2296149ea Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:17:15 -0400 Subject: [PATCH 11/32] Consolidate run_tests and run_tests_async into single async implementation Merged run_tests_async into run_tests, making async job-based execution the default behavior. Removed synchronous blocking test execution. Updated RunTests.cs to start test jobs immediately and return job_id for polling. Changed TestJobManager methods to internal visibility. Updated README to reflect single run_tests_async tool. Python implementation now uses async job pattern exclusively. --- MCPForUnity/Editor/Services/TestJobManager.cs | 4 +- MCPForUnity/Editor/Tools/RunTests.cs | 185 +++++++----------- MCPForUnity/Editor/Tools/RunTests.cs.meta | 2 +- MCPForUnity/Editor/Tools/RunTestsAsync.cs | 126 ------------ .../Editor/Tools/RunTestsAsync.cs.meta | 13 -- README.md | 3 +- Server/src/services/tools/run_tests.py | 129 ++++++------ Server/src/services/tools/test_jobs.py | 114 ----------- Server/src/transport/plugin_hub.py | 10 +- ..._jobs_async.py => test_run_tests_async.py} | 20 +- .../test_run_tests_busy_semantics.py | 36 ---- .../Tests/EditMode/Tools/RunTestsTests.cs | 83 ++++---- 12 files changed, 183 insertions(+), 542 deletions(-) delete mode 100644 MCPForUnity/Editor/Tools/RunTestsAsync.cs delete mode 100644 MCPForUnity/Editor/Tools/RunTestsAsync.cs.meta delete mode 100644 Server/src/services/tools/test_jobs.py rename Server/tests/integration/{test_test_jobs_async.py => test_run_tests_async.py} (73%) delete mode 100644 Server/tests/integration/test_run_tests_busy_semantics.py diff --git a/MCPForUnity/Editor/Services/TestJobManager.cs b/MCPForUnity/Editor/Services/TestJobManager.cs index d5399cf5e..f22e7d9d1 100644 --- a/MCPForUnity/Editor/Services/TestJobManager.cs +++ b/MCPForUnity/Editor/Services/TestJobManager.cs @@ -416,7 +416,7 @@ public static void OnRunFinished() PersistToSessionState(force: true); } - public static TestJob GetJob(string jobId) + internal static TestJob GetJob(string jobId) { if (string.IsNullOrWhiteSpace(jobId)) { @@ -428,7 +428,7 @@ public static TestJob GetJob(string jobId) } } - public static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests) + internal static object ToSerializable(TestJob job, bool includeDetails, bool includeFailedTests) { if (job == null) { diff --git a/MCPForUnity/Editor/Tools/RunTests.cs b/MCPForUnity/Editor/Tools/RunTests.cs index 710b8b2dd..ccd0d085a 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs +++ b/MCPForUnity/Editor/Tools/RunTests.cs @@ -5,107 +5,110 @@ using MCPForUnity.Editor.Resources.Tests; using MCPForUnity.Editor.Services; using Newtonsoft.Json.Linq; +using UnityEditor.TestTools.TestRunner.Api; namespace MCPForUnity.Editor.Tools { /// - /// Executes Unity tests for a specified mode and returns detailed results. + /// Starts a Unity Test Runner run asynchronously and returns a job id immediately. + /// Use get_test_job(job_id) to poll status/results. /// [McpForUnityTool("run_tests", AutoRegister = false)] public static class RunTests { - private const int DefaultTimeoutSeconds = 600; // 10 minutes - - public static async Task HandleCommand(JObject @params) + public static Task HandleCommand(JObject @params) { - string modeStr = @params?["mode"]?.ToString(); - if (string.IsNullOrWhiteSpace(modeStr)) - { - modeStr = "EditMode"; - } - - if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) - { - return new ErrorResponse(parseError); - } - - int timeoutSeconds = DefaultTimeoutSeconds; try { - var timeoutToken = @params?["timeoutSeconds"]; - if (timeoutToken != null && int.TryParse(timeoutToken.ToString(), out var parsedTimeout) && parsedTimeout > 0) + string modeStr = @params?["mode"]?.ToString(); + if (string.IsNullOrWhiteSpace(modeStr)) { - timeoutSeconds = parsedTimeout; + modeStr = "EditMode"; } - } - catch - { - // Preserve default timeout if parsing fails - } - bool includeDetails = false; - bool includeFailedTests = false; - try - { - var includeDetailsToken = @params?["includeDetails"]; - if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails)) + if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) { - includeDetails = parsedIncludeDetails; + return Task.FromResult(new ErrorResponse(parseError)); } - var includeFailedTestsToken = @params?["includeFailedTests"]; - if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests)) + bool includeDetails = false; + bool includeFailedTests = false; + try { - includeFailedTests = parsedIncludeFailedTests; + var includeDetailsToken = @params?["includeDetails"]; + if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails)) + { + includeDetails = parsedIncludeDetails; + } + + var includeFailedTestsToken = @params?["includeFailedTests"]; + if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests)) + { + includeFailedTests = parsedIncludeFailedTests; + } + } + catch + { + // ignore parse failures } - } - catch - { - // Preserve defaults if parsing fails - } - var filterOptions = ParseFilterOptions(@params); + var filterOptions = GetFilterOptions(@params); + string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions); - var testService = MCPServiceLocator.Tests; - Task runTask; - try - { - runTask = testService.RunTestsAsync(parsedMode.Value, filterOptions); + return Task.FromResult(new SuccessResponse("Test job started.", new + { + job_id = jobId, + status = "running", + mode = parsedMode.Value.ToString(), + include_details = includeDetails, + include_failed_tests = includeFailedTests + })); } catch (Exception ex) { - return new ErrorResponse($"Failed to start test run: {ex.Message}"); - } - - var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds)); - var completed = await Task.WhenAny(runTask, timeoutTask).ConfigureAwait(true); - - if (completed != runTask) - { - return new ErrorResponse($"Test run timed out after {timeoutSeconds} seconds"); + // Normalize the already-running case to a stable error token. + if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0) + { + return Task.FromResult(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 })); + } + return Task.FromResult(new ErrorResponse($"Failed to start test job: {ex.Message}")); } - - var result = await runTask.ConfigureAwait(true); - - string message = FormatTestResultMessage(parsedMode.Value.ToString(), result); - - var data = result.ToSerializable(parsedMode.Value.ToString(), includeDetails, includeFailedTests); - return new SuccessResponse(message, data); } - private static TestFilterOptions ParseFilterOptions(JObject @params) + private static TestFilterOptions GetFilterOptions(JObject @params) { if (@params == null) { return null; } - var testNames = ParseStringArray(@params, "testNames"); - var groupNames = ParseStringArray(@params, "groupNames"); - var categoryNames = ParseStringArray(@params, "categoryNames"); - var assemblyNames = ParseStringArray(@params, "assemblyNames"); + string[] ParseStringArray(string key) + { + var token = @params[key]; + if (token == null) return null; + if (token.Type == JTokenType.String) + { + var value = token.ToString(); + return string.IsNullOrWhiteSpace(value) ? null : new[] { value }; + } + if (token.Type == JTokenType.Array) + { + var array = token as JArray; + if (array == null || array.Count == 0) return null; + var values = array + .Values() + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + return values.Length > 0 ? values : null; + } + return null; + } + + var testNames = ParseStringArray("testNames"); + var groupNames = ParseStringArray("groupNames"); + var categoryNames = ParseStringArray("categoryNames"); + var assemblyNames = ParseStringArray("assemblyNames"); - // Return null if no filters specified if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null) { return null; @@ -119,53 +122,5 @@ private static TestFilterOptions ParseFilterOptions(JObject @params) AssemblyNames = assemblyNames }; } - - internal static string FormatTestResultMessage(string mode, TestRunResult result) - { - string message = - $"{mode} tests completed: {result.Passed}/{result.Total} passed, {result.Failed} failed, {result.Skipped} skipped"; - - // Add warning when no tests matched the filter criteria - if (result.Total == 0) - { - message += " (No tests matched the specified filters)"; - } - - return message; - } - - private static string[] ParseStringArray(JObject @params, string key) - { - var token = @params[key]; - if (token == null) - { - return null; - } - - if (token.Type == JTokenType.String) - { - var value = token.ToString(); - return string.IsNullOrWhiteSpace(value) ? null : new[] { value }; - } - - if (token.Type == JTokenType.Array) - { - var array = token as JArray; - if (array == null || array.Count == 0) - { - return null; - } - - var values = array - .Where(t => t.Type == JTokenType.String) - .Select(t => t.ToString()) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToArray(); - - return values.Length > 0 ? values : null; - } - - return null; - } } } diff --git a/MCPForUnity/Editor/Tools/RunTests.cs.meta b/MCPForUnity/Editor/Tools/RunTests.cs.meta index 85d66e081..fea1fdf6f 100644 --- a/MCPForUnity/Editor/Tools/RunTests.cs.meta +++ b/MCPForUnity/Editor/Tools/RunTests.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b177f204e300948f7ae07fb45d4c7ca9 +guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Tools/RunTestsAsync.cs b/MCPForUnity/Editor/Tools/RunTestsAsync.cs deleted file mode 100644 index 3353cb8be..000000000 --- a/MCPForUnity/Editor/Tools/RunTestsAsync.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.Linq; -using System.Threading.Tasks; -using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Resources.Tests; -using MCPForUnity.Editor.Services; -using Newtonsoft.Json.Linq; -using UnityEditor.TestTools.TestRunner.Api; - -namespace MCPForUnity.Editor.Tools -{ - /// - /// Starts a Unity Test Runner run asynchronously and returns a job id immediately. - /// Use get_test_job(job_id) to poll status/results. - /// - [McpForUnityTool("run_tests_async", AutoRegister = false)] - public static class RunTestsAsync - { - public static Task HandleCommand(JObject @params) - { - try - { - string modeStr = @params?["mode"]?.ToString(); - if (string.IsNullOrWhiteSpace(modeStr)) - { - modeStr = "EditMode"; - } - - if (!ModeParser.TryParse(modeStr, out var parsedMode, out var parseError)) - { - return Task.FromResult(new ErrorResponse(parseError)); - } - - bool includeDetails = false; - bool includeFailedTests = false; - try - { - var includeDetailsToken = @params?["includeDetails"]; - if (includeDetailsToken != null && bool.TryParse(includeDetailsToken.ToString(), out var parsedIncludeDetails)) - { - includeDetails = parsedIncludeDetails; - } - - var includeFailedTestsToken = @params?["includeFailedTests"]; - if (includeFailedTestsToken != null && bool.TryParse(includeFailedTestsToken.ToString(), out var parsedIncludeFailedTests)) - { - includeFailedTests = parsedIncludeFailedTests; - } - } - catch - { - // ignore parse failures - } - - var filterOptions = GetFilterOptions(@params); - string jobId = TestJobManager.StartJob(parsedMode.Value, filterOptions); - - return Task.FromResult(new SuccessResponse("Test job started.", new - { - job_id = jobId, - status = "running", - mode = parsedMode.Value.ToString(), - include_details = includeDetails, - include_failed_tests = includeFailedTests - })); - } - catch (Exception ex) - { - // Normalize the already-running case to a stable error token. - if (ex.Message != null && ex.Message.IndexOf("already in progress", StringComparison.OrdinalIgnoreCase) >= 0) - { - return Task.FromResult(new ErrorResponse("tests_running", new { reason = "tests_running", retry_after_ms = 5000 })); - } - return Task.FromResult(new ErrorResponse($"Failed to start test job: {ex.Message}")); - } - } - - private static TestFilterOptions GetFilterOptions(JObject @params) - { - if (@params == null) - { - return null; - } - - string[] ParseStringArray(string key) - { - var token = @params[key]; - if (token == null) return null; - if (token.Type == JTokenType.String) - { - var value = token.ToString(); - return string.IsNullOrWhiteSpace(value) ? null : new[] { value }; - } - if (token.Type == JTokenType.Array) - { - var array = token as JArray; - if (array == null || array.Count == 0) return null; - var values = array - .Values() - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToArray(); - return values.Length > 0 ? values : null; - } - return null; - } - - var testNames = ParseStringArray("testNames"); - var groupNames = ParseStringArray("groupNames"); - var categoryNames = ParseStringArray("categoryNames"); - var assemblyNames = ParseStringArray("assemblyNames"); - - if (testNames == null && groupNames == null && categoryNames == null && assemblyNames == null) - { - return null; - } - - return new TestFilterOptions - { - TestNames = testNames, - GroupNames = groupNames, - CategoryNames = categoryNames, - AssemblyNames = assemblyNames - }; - } - } -} diff --git a/MCPForUnity/Editor/Tools/RunTestsAsync.cs.meta b/MCPForUnity/Editor/Tools/RunTestsAsync.cs.meta deleted file mode 100644 index 8b23e7f4b..000000000 --- a/MCPForUnity/Editor/Tools/RunTestsAsync.cs.meta +++ /dev/null @@ -1,13 +0,0 @@ -fileFormatVersion: 2 -guid: 5cc0c41b1a8b4e0e9d0f1f8b1d7d2a9c -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: - - diff --git a/README.md b/README.md index 1dfa2c8fc..9773645ee 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,8 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav * `find_gameobjects`: Search for GameObjects by name, tag, layer, component, path, or ID (paginated). * `read_console`: Gets messages from or clears the Unity console. * `refresh_unity`: Request asset database refresh and optional compilation. -* `run_tests_async`: Starts tests asynchronously, returns job_id for polling (preferred). +* `run_tests`: Starts tests asynchronously, returns job_id for polling. * `get_test_job`: Polls an async test job for progress and results. -* `run_tests`: Runs tests synchronously (blocks until complete). * `execute_custom_tool`: Execute project-scoped custom tools registered by Unity. * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). * `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`. diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 0a2ab0ed4..8a1a937b9 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -1,50 +1,21 @@ -"""Tool for executing Unity Test Runner suites.""" -from typing import Annotated, Literal, Any +"""Async Unity Test Runner jobs: start + poll.""" +from __future__ import annotations + +from typing import Annotated, Any, Literal from fastmcp import Context from mcp.types import ToolAnnotations -from pydantic import BaseModel, Field from models import MCPResponse from services.registry import mcp_for_unity_tool from services.tools import get_unity_instance_from_context -from services.tools.utils import coerce_int -from transport.unity_transport import send_with_unity_instance -from transport.legacy.unity_connection import async_send_command_with_retry from services.tools.preflight import preflight - - -class RunTestsSummary(BaseModel): - total: int - passed: int - failed: int - skipped: int - durationSeconds: float - resultState: str - - -class RunTestsTestResult(BaseModel): - name: str - fullName: str - state: str - durationSeconds: float - message: str | None = None - stackTrace: str | None = None - output: str | None = None - - -class RunTestsResult(BaseModel): - mode: str - summary: RunTestsSummary - results: list[RunTestsTestResult] | None = None - - -class RunTestsResponse(MCPResponse): - data: RunTestsResult | None = None +import transport.unity_transport as unity_transport +from transport.legacy.unity_connection import async_send_command_with_retry @mcp_for_unity_tool( - description="Runs Unity tests synchronously (blocks until complete). Prefer run_tests_async for non-blocking execution with progress polling.", + description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.", annotations=ToolAnnotations( title="Run Tests", destructiveHint=True, @@ -54,28 +25,25 @@ async def run_tests( ctx: Context, mode: Annotated[Literal["EditMode", "PlayMode"], "Unity test mode to run"] = "EditMode", - timeout_seconds: Annotated[int | str, - "Optional timeout in seconds for the test run"] | None = None, test_names: Annotated[list[str] | str, - "Full names of specific tests to run (e.g., 'MyNamespace.MyTests.TestMethod')"] | None = None, + "Full names of specific tests to run"] | None = None, group_names: Annotated[list[str] | str, "Same as test_names, except it allows for Regex"] | None = None, category_names: Annotated[list[str] | str, - "NUnit category names to filter by (tests marked with [Category] attribute)"] | None = None, + "NUnit category names to filter by"] | None = None, assembly_names: Annotated[list[str] | str, "Assembly names to filter tests by"] | None = None, include_failed_tests: Annotated[bool, "Include details for failed/skipped tests only (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, -) -> RunTestsResponse | MCPResponse: +) -> dict[str, Any] | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) if isinstance(gate, MCPResponse): return gate - # Coerce string or list to list of strings def _coerce_string_list(value) -> list[str] | None: if value is None: return None @@ -87,47 +55,60 @@ def _coerce_string_list(value) -> list[str] | None: return None params: dict[str, Any] = {"mode": mode} - ts = coerce_int(timeout_seconds) - if ts is not None: - params["timeoutSeconds"] = ts + if (t := _coerce_string_list(test_names)): + params["testNames"] = t + if (g := _coerce_string_list(group_names)): + params["groupNames"] = g + if (c := _coerce_string_list(category_names)): + params["categoryNames"] = c + if (a := _coerce_string_list(assembly_names)): + params["assemblyNames"] = a + if include_failed_tests: + params["includeFailedTests"] = True + if include_details: + params["includeDetails"] = True - # Add filter parameters if provided - test_names_list = _coerce_string_list(test_names) - if test_names_list: - params["testNames"] = test_names_list + response = await unity_transport.send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "run_tests", + params, + ) - group_names_list = _coerce_string_list(group_names) - if group_names_list: - params["groupNames"] = group_names_list + if isinstance(response, dict) and not response.get("success", True): + return MCPResponse(**response) + return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() - category_names_list = _coerce_string_list(category_names) - if category_names_list: - params["categoryNames"] = category_names_list - assembly_names_list = _coerce_string_list(assembly_names) - if assembly_names_list: - params["assemblyNames"] = assembly_names_list +@mcp_for_unity_tool( + description="Polls an async Unity test job by job_id.", + annotations=ToolAnnotations( + title="Get Test Job", + readOnlyHint=True, + ), +) +async def get_test_job( + ctx: Context, + job_id: Annotated[str, "Job id returned by run_tests"], + include_failed_tests: Annotated[bool, + "Include details for failed/skipped tests only (default: false)"] = False, + include_details: Annotated[bool, + "Include details for all tests (default: false)"] = False, +) -> dict[str, Any] | MCPResponse: + unity_instance = get_unity_instance_from_context(ctx) - # Add verbosity parameters + params: dict[str, Any] = {"job_id": job_id} if include_failed_tests: params["includeFailedTests"] = True if include_details: params["includeDetails"] = True - response = await send_with_unity_instance(async_send_command_with_retry, unity_instance, "run_tests", params) - - # If Unity indicates a run is already active, return a structured "busy" response rather than - # letting clients interpret this as a generic failure (avoids #503 retry loops). + response = await unity_transport.send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_test_job", + params, + ) if isinstance(response, dict) and not response.get("success", True): - err = (response.get("error") or response.get("message") or "").strip() - if "test run is already in progress" in err.lower(): - return MCPResponse( - success=False, - error="tests_running", - message=err, - hint="retry", - data={"reason": "tests_running", "retry_after_ms": 5000}, - ) return MCPResponse(**response) - - return RunTestsResponse(**response) if isinstance(response, dict) else response + return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() diff --git a/Server/src/services/tools/test_jobs.py b/Server/src/services/tools/test_jobs.py deleted file mode 100644 index 0800e3a17..000000000 --- a/Server/src/services/tools/test_jobs.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Async Unity Test Runner jobs: start + poll.""" -from __future__ import annotations - -from typing import Annotated, Any, Literal - -from fastmcp import Context -from mcp.types import ToolAnnotations - -from models import MCPResponse -from services.registry import mcp_for_unity_tool -from services.tools import get_unity_instance_from_context -from services.tools.preflight import preflight -import transport.unity_transport as unity_transport -from transport.legacy.unity_connection import async_send_command_with_retry - - -@mcp_for_unity_tool( - description="Starts a Unity test run asynchronously and returns a job_id immediately. Preferred over run_tests for long-running suites. Poll with get_test_job for progress.", - annotations=ToolAnnotations( - title="Run Tests Async", - destructiveHint=True, - ), -) -async def run_tests_async( - ctx: Context, - mode: Annotated[Literal["EditMode", "PlayMode"], - "Unity test mode to run"] = "EditMode", - test_names: Annotated[list[str] | str, - "Full names of specific tests to run"] | None = None, - group_names: Annotated[list[str] | str, - "Same as test_names, except it allows for Regex"] | None = None, - category_names: Annotated[list[str] | str, - "NUnit category names to filter by"] | None = None, - assembly_names: Annotated[list[str] | str, - "Assembly names to filter tests by"] | None = None, - include_failed_tests: Annotated[bool, - "Include details for failed/skipped tests only (default: false)"] = False, - include_details: Annotated[bool, - "Include details for all tests (default: false)"] = False, -) -> dict[str, Any] | MCPResponse: - unity_instance = get_unity_instance_from_context(ctx) - - gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) - if isinstance(gate, MCPResponse): - return gate - - def _coerce_string_list(value) -> list[str] | None: - if value is None: - return None - if isinstance(value, str): - return [value] if value.strip() else None - if isinstance(value, list): - result = [str(v).strip() for v in value if v and str(v).strip()] - return result if result else None - return None - - params: dict[str, Any] = {"mode": mode} - if (t := _coerce_string_list(test_names)): - params["testNames"] = t - if (g := _coerce_string_list(group_names)): - params["groupNames"] = g - if (c := _coerce_string_list(category_names)): - params["categoryNames"] = c - if (a := _coerce_string_list(assembly_names)): - params["assemblyNames"] = a - if include_failed_tests: - params["includeFailedTests"] = True - if include_details: - params["includeDetails"] = True - - response = await unity_transport.send_with_unity_instance( - async_send_command_with_retry, - unity_instance, - "run_tests_async", - params, - ) - - if isinstance(response, dict) and not response.get("success", True): - return MCPResponse(**response) - return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() - - -@mcp_for_unity_tool( - description="Polls an async Unity test job by job_id.", - annotations=ToolAnnotations( - title="Get Test Job", - readOnlyHint=True, - ), -) -async def get_test_job( - ctx: Context, - job_id: Annotated[str, "Job id returned by run_tests_async"], - include_failed_tests: Annotated[bool, - "Include details for failed/skipped tests only (default: false)"] = False, - include_details: Annotated[bool, - "Include details for all tests (default: false)"] = False, -) -> dict[str, Any] | MCPResponse: - unity_instance = get_unity_instance_from_context(ctx) - - params: dict[str, Any] = {"job_id": job_id} - if include_failed_tests: - params["includeFailedTests"] = True - if include_details: - params["includeDetails"] = True - - response = await unity_transport.send_with_unity_instance( - async_send_command_with_retry, - unity_instance, - "get_test_job", - params, - ) - if isinstance(response, dict) and not response.get("success", True): - return MCPResponse(**response) - return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 4cff37cc5..a700a974d 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -142,19 +142,15 @@ async def send_command(cls, session_id: str, command_type: str, params: dict[str future: asyncio.Future = asyncio.get_running_loop().create_future() # Compute a per-command timeout: # - fast-path commands: short timeout (encourage retry) - # - long-running commands (e.g., run_tests): allow caller to request a longer timeout via params + # - long-running commands: allow caller to request a longer timeout via params unity_timeout_s = float(cls.COMMAND_TIMEOUT) server_wait_s = float(cls.COMMAND_TIMEOUT) if command_type in cls._FAST_FAIL_COMMANDS: - try: - fast_timeout = float(os.environ.get( - "UNITY_MCP_FAST_COMMAND_TIMEOUT", "3")) - except Exception: - fast_timeout = 3.0 + fast_timeout = float(cls.FAST_FAIL_TIMEOUT) unity_timeout_s = fast_timeout server_wait_s = fast_timeout else: - # Common tools pass a requested timeout in seconds (e.g., run_tests(timeout_seconds=900)). + # Common tools pass a requested timeout in seconds (e.g., timeout_seconds=900). requested = None try: if isinstance(params, dict): diff --git a/Server/tests/integration/test_test_jobs_async.py b/Server/tests/integration/test_run_tests_async.py similarity index 73% rename from Server/tests/integration/test_test_jobs_async.py rename to Server/tests/integration/test_run_tests_async.py index a27a572cb..4c8df1dce 100644 --- a/Server/tests/integration/test_test_jobs_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -5,7 +5,7 @@ @pytest.mark.asyncio async def test_run_tests_async_forwards_params(monkeypatch): - from services.tools.test_jobs import run_tests_async + from services.tools.run_tests import run_tests captured = {} @@ -14,16 +14,17 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p captured["params"] = params return {"success": True, "data": {"job_id": "abc123", "status": "running"}} - import services.tools.test_jobs as mod - monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) - resp = await run_tests_async( + resp = await run_tests( DummyContext(), mode="EditMode", test_names="MyNamespace.MyTests.TestA", include_details=True, ) - assert captured["command_type"] == "run_tests_async" + assert captured["command_type"] == "run_tests" assert captured["params"]["mode"] == "EditMode" assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"] assert captured["params"]["includeDetails"] is True @@ -32,7 +33,7 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p @pytest.mark.asyncio async def test_get_test_job_forwards_job_id(monkeypatch): - from services.tools.test_jobs import get_test_job + from services.tools.run_tests import get_test_job captured = {} @@ -41,12 +42,11 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p captured["params"] = params return {"success": True, "data": {"job_id": params["job_id"], "status": "running"}} - import services.tools.test_jobs as mod - monkeypatch.setattr(mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) + import services.tools.run_tests as mod + monkeypatch.setattr( + mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) resp = await get_test_job(DummyContext(), job_id="job-1") assert captured["command_type"] == "get_test_job" assert captured["params"]["job_id"] == "job-1" assert resp["data"]["job_id"] == "job-1" - - diff --git a/Server/tests/integration/test_run_tests_busy_semantics.py b/Server/tests/integration/test_run_tests_busy_semantics.py deleted file mode 100644 index ca72b3911..000000000 --- a/Server/tests/integration/test_run_tests_busy_semantics.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest - -from .test_helpers import DummyContext - - -@pytest.mark.asyncio -async def test_run_tests_returns_busy_when_unity_reports_already_in_progress(monkeypatch): - """ - Red test (#503): if Unity reports a test run is already in progress, the tool should return a - structured Busy response quickly (retry hint + retry_after_ms) rather than looking like a generic failure. - """ - import services.tools.run_tests as run_tests_mod - - async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): - assert command_type == "run_tests" - # This mirrors the Unity-side exception message thrown by TestRunnerService today. - return { - "success": False, - "error": "A Unity test run is already in progress.", - } - - monkeypatch.setattr(run_tests_mod, "send_with_unity_instance", fake_send_with_unity_instance) - - result = await run_tests_mod.run_tests(ctx=DummyContext(), mode="EditMode") - payload = result.model_dump() if hasattr(result, "model_dump") else result - - assert payload.get("success") is False - # Desired new behavior: provide an explicit retry hint + suggested backoff. - assert payload.get("hint") == "retry" - data = payload.get("data") or {} - assert isinstance(data, dict) - assert data.get("reason") in {"tests_running", "busy"} - assert isinstance(data.get("retry_after_ms"), int) - assert data.get("retry_after_ms") >= 500 - - diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs index 6f2c036cd..1a0af4c4a 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/RunTestsTests.cs @@ -1,66 +1,65 @@ using System; +using System.Reflection; using Newtonsoft.Json.Linq; using NUnit.Framework; -using MCPForUnity.Editor.Services; +using MCPForUnity.Editor.Helpers; namespace MCPForUnityTests.Editor.Tools { /// /// Tests for RunTests tool functionality. /// Note: We cannot easily test the full HandleCommand because it would create - /// recursive test runner calls. Instead, we test the message formatting logic. + /// recursive test runner calls. /// public class RunTestsTests { [Test] - public void FormatResultMessage_WithNoTests_IncludesWarning() + public void HandleCommand_WhenTestsAlreadyRunning_ReturnsBusyError() { - // Arrange - var summary = new TestRunSummary( - total: 0, - passed: 0, - failed: 0, - skipped: 0, - durationSeconds: 0.0, - resultState: "Passed" - ); - var result = new TestRunResult(summary, new TestRunTestResult[0]); + // Arrange: Force TestJobManager into a "busy" state without starting a real run. + // We do this via reflection because TestJobManager is internal. + var asm = typeof(MCPForUnity.Editor.Services.MCPServiceLocator).Assembly; + var testJobManagerType = asm.GetType("MCPForUnity.Editor.Services.TestJobManager"); + Assert.NotNull(testJobManagerType, "Could not locate TestJobManager type via reflection"); - // Act - string message = MCPForUnity.Editor.Tools.RunTests.FormatTestResultMessage("EditMode", result); + var currentJobIdField = testJobManagerType.GetField("_currentJobId", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(currentJobIdField, "Could not locate TestJobManager._currentJobId field"); - // Assert - THIS IS THE NEW FEATURE - Assert.IsTrue( - message.Contains("No tests matched"), - $"Expected warning when total=0, but got: '{message}'" - ); + var originalJobId = currentJobIdField.GetValue(null) as string; + currentJobIdField.SetValue(null, "busy-test-job-id"); + + try + { + var resultObj = MCPForUnity.Editor.Tools.RunTests.HandleCommand(new JObject()).GetAwaiter().GetResult(); + + Assert.IsInstanceOf(resultObj); + var err = (ErrorResponse)resultObj; + Assert.AreEqual(false, err.Success); + Assert.AreEqual("tests_running", err.Code); + + var data = err.Data != null ? JObject.FromObject(err.Data) : null; + Assert.NotNull(data, "Expected data payload on tests_running error"); + Assert.AreEqual("tests_running", data["reason"]?.ToString()); + Assert.GreaterOrEqual(data["retry_after_ms"]?.Value() ?? 0, 500); + } + finally + { + currentJobIdField.SetValue(null, originalJobId); + } } [Test] - public void FormatResultMessage_WithTests_NoWarning() + public void HandleCommand_WithInvalidMode_ReturnsError() { - // Arrange - var summary = new TestRunSummary( - total: 5, - passed: 4, - failed: 1, - skipped: 0, - durationSeconds: 1.5, - resultState: "Failed" - ); - var result = new TestRunResult(summary, new TestRunTestResult[0]); + var resultObj = MCPForUnity.Editor.Tools.RunTests.HandleCommand(new JObject + { + ["mode"] = "NotARealMode" + }).GetAwaiter().GetResult(); - // Act - string message = MCPForUnity.Editor.Tools.RunTests.FormatTestResultMessage("EditMode", result); - - // Assert - Assert.IsFalse( - message.Contains("No tests matched"), - $"Should not have warning when tests exist, but got: '{message}'" - ); - Assert.IsTrue(message.Contains("4/5 passed"), "Should contain pass ratio"); + Assert.IsInstanceOf(resultObj); + var err = (ErrorResponse)resultObj; + Assert.AreEqual(false, err.Success); + Assert.IsTrue(err.Error.Contains("Unknown test mode", StringComparison.OrdinalIgnoreCase)); } - - // Use MCPForUnity.Editor.Tools.RunTests.FormatTestResultMessage directly. } } From 93f10845c2452f8bb042bb89868324d029c96c1e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:21:03 -0400 Subject: [PATCH 12/32] Validate test job responses with Pydantic models in Python --- Server/src/services/tools/run_tests.py | 93 +++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 8a1a937b9..bd498432b 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -5,6 +5,7 @@ from fastmcp import Context from mcp.types import ToolAnnotations +from pydantic import BaseModel from models import MCPResponse from services.registry import mcp_for_unity_tool @@ -14,6 +15,78 @@ from transport.legacy.unity_connection import async_send_command_with_retry +class RunTestsSummary(BaseModel): + total: int + passed: int + failed: int + skipped: int + durationSeconds: float + resultState: str + + +class RunTestsTestResult(BaseModel): + name: str + fullName: str + state: str + durationSeconds: float + message: str | None = None + stackTrace: str | None = None + output: str | None = None + + +class RunTestsResult(BaseModel): + mode: str + summary: RunTestsSummary + results: list[RunTestsTestResult] | None = None + + +class RunTestsStartData(BaseModel): + job_id: str + status: str + mode: str + include_details: bool | None = None + include_failed_tests: bool | None = None + + +class RunTestsStartResponse(MCPResponse): + data: RunTestsStartData | None = None + + +class TestJobFailure(BaseModel): + full_name: str | None = None + message: str | None = None + + +class TestJobProgress(BaseModel): + completed: int | None = None + total: int | None = None + current_test_full_name: str | None = None + current_test_started_unix_ms: int | None = None + last_finished_test_full_name: str | None = None + last_finished_unix_ms: int | None = None + stuck_suspected: bool | None = None + editor_is_focused: bool | None = None + blocked_reason: str | None = None + failures_so_far: list[TestJobFailure] | None = None + failures_capped: bool | None = None + + +class GetTestJobData(BaseModel): + job_id: str + status: str + mode: str | None = None + started_unix_ms: int | None = None + finished_unix_ms: int | None = None + last_update_unix_ms: int | None = None + progress: TestJobProgress | None = None + error: str | None = None + result: RunTestsResult | None = None + + +class GetTestJobResponse(MCPResponse): + data: GetTestJobData | None = None + + @mcp_for_unity_tool( description="Starts a Unity test run asynchronously and returns a job_id immediately. Poll with get_test_job for progress.", annotations=ToolAnnotations( @@ -37,7 +110,7 @@ async def run_tests( "Include details for failed/skipped tests only (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, -) -> dict[str, Any] | MCPResponse: +) -> RunTestsStartResponse | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) gate = await preflight(ctx, requires_no_tests=True, wait_for_no_compile=True, refresh_if_dirty=True) @@ -75,9 +148,11 @@ def _coerce_string_list(value) -> list[str] | None: params, ) - if isinstance(response, dict) and not response.get("success", True): - return MCPResponse(**response) - return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() + if isinstance(response, dict): + if not response.get("success", True): + return MCPResponse(**response) + return RunTestsStartResponse(**response) + return MCPResponse(success=False, error=str(response)) @mcp_for_unity_tool( @@ -94,7 +169,7 @@ async def get_test_job( "Include details for failed/skipped tests only (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, -) -> dict[str, Any] | MCPResponse: +) -> GetTestJobResponse | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) params: dict[str, Any] = {"job_id": job_id} @@ -109,6 +184,8 @@ async def get_test_job( "get_test_job", params, ) - if isinstance(response, dict) and not response.get("success", True): - return MCPResponse(**response) - return response if isinstance(response, dict) else MCPResponse(success=False, error=str(response)).model_dump() + if isinstance(response, dict): + if not response.get("success", True): + return MCPResponse(**response) + return GetTestJobResponse(**response) + return MCPResponse(success=False, error=str(response)) From 88ed90a89aca6bbafaa3f35eb29edebb2b7ff40d Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:37:16 -0400 Subject: [PATCH 13/32] Change resources URI from unity:// to mcpforunity:// It should reduce conflicts with other Unity MCPs that users try, and to comply with Unity's requests regarding use of their company and product name --- .github/scripts/mark_skipped.py | 2 +- Server/src/main.py | 4 +-- .../services/registry/resource_registry.py | 2 +- Server/src/services/resources/active_tool.py | 2 +- Server/src/services/resources/custom_tools.py | 4 +-- Server/src/services/resources/editor_state.py | 2 +- Server/src/services/resources/gameobject.py | 26 +++++++++---------- Server/src/services/resources/layers.py | 2 +- Server/src/services/resources/menu_items.py | 2 +- Server/src/services/resources/prefab_stage.py | 2 +- Server/src/services/resources/project_info.py | 2 +- Server/src/services/resources/selection.py | 2 +- Server/src/services/resources/tags.py | 2 +- Server/src/services/resources/tests.py | 4 +-- .../src/services/resources/unity_instances.py | 2 +- Server/src/services/resources/windows.py | 2 +- .../src/services/tools/execute_custom_tool.py | 2 +- Server/src/services/tools/find_gameobjects.py | 8 +++--- Server/src/services/tools/find_in_file.py | 6 ++--- .../src/services/tools/manage_components.py | 2 +- Server/src/services/tools/manage_script.py | 14 +++++----- .../src/services/tools/script_apply_edits.py | 6 ++--- .../src/services/tools/set_active_instance.py | 8 +++--- .../src/transport/legacy/unity_connection.py | 2 +- Server/src/transport/plugin_hub.py | 4 +-- .../test_edit_normalization_and_noop.py | 8 +++--- .../test_edit_strict_and_warnings.py | 4 +-- .../test_editor_state_v2_contract.py | 6 ++--- .../integration/test_gameobject_resources.py | 6 ++--- Server/tests/integration/test_get_sha.py | 4 +-- .../integration/test_manage_script_uri.py | 2 +- Server/tests/integration/test_script_tools.py | 10 +++---- .../test_validate_script_summary.py | 2 +- 33 files changed, 78 insertions(+), 78 deletions(-) diff --git a/.github/scripts/mark_skipped.py b/.github/scripts/mark_skipped.py index 22999c491..481185058 100755 --- a/.github/scripts/mark_skipped.py +++ b/.github/scripts/mark_skipped.py @@ -24,7 +24,7 @@ r"^MCP resources list is empty$", r"No MCP resources detected", r"aggregator.*returned\s*\[\s*\]", - r"Unknown resource:\s*unity://", + r"Unknown resource:\s*mcpforunity://", r"Input should be a valid dictionary.*ctx", r"validation error .* ctx", ] diff --git a/Server/src/main.py b/Server/src/main.py index 2e03cd829..c46a4765c 100644 --- a/Server/src/main.py +++ b/Server/src/main.py @@ -235,10 +235,10 @@ def _emit_startup(): instructions=""" This server provides tools to interact with the Unity Game Engine Editor. -I have a dynamic tool system. Always check the unity://custom-tools resource first to see what special capabilities are available for the current project. +I have a dynamic tool system. Always check the mcpforunity://custom-tools resource first to see what special capabilities are available for the current project. Targeting Unity instances: -- Use the resource unity://instances to list active Unity sessions (Name@hash). +- Use the resource mcpforunity://instances to list active Unity sessions (Name@hash). - When multiple instances are connected, call set_active_instance with the exact Name@hash before using tools/resources. The server will error if multiple are connected and no active instance is set. Important Workflows: diff --git a/Server/src/services/registry/resource_registry.py b/Server/src/services/registry/resource_registry.py index 5c8e42607..661e1e5c6 100644 --- a/Server/src/services/registry/resource_registry.py +++ b/Server/src/services/registry/resource_registry.py @@ -24,7 +24,7 @@ def mcp_for_unity_resource( **kwargs: Additional arguments passed to @mcp.resource() Example: - @mcp_for_unity_resource("mcpforunity://resource", description="Gets something interesting") + @mcp_for_unity_resource("mcpformcpforunity://resource", description="Gets something interesting") async def my_custom_resource(ctx: Context, ...): pass """ diff --git a/Server/src/services/resources/active_tool.py b/Server/src/services/resources/active_tool.py index d5af3f681..e20fcbb85 100644 --- a/Server/src/services/resources/active_tool.py +++ b/Server/src/services/resources/active_tool.py @@ -31,7 +31,7 @@ class ActiveToolResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://editor/active-tool", + uri="mcpforunity://editor/active-tool", name="editor_active_tool", description="Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings." ) diff --git a/Server/src/services/resources/custom_tools.py b/Server/src/services/resources/custom_tools.py index 5860b75dd..cd9359316 100644 --- a/Server/src/services/resources/custom_tools.py +++ b/Server/src/services/resources/custom_tools.py @@ -22,7 +22,7 @@ class CustomToolsResourceResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://custom-tools", + uri="mcpforunity://custom-tools", name="custom_tools", description="Lists custom tools available for the active Unity project.", ) @@ -31,7 +31,7 @@ async def get_custom_tools(ctx: Context) -> CustomToolsResourceResponse | MCPRes if not unity_instance: return MCPResponse( success=False, - message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.", + message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.", ) project_id = resolve_project_id_for_unity_instance(unity_instance) diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 9b176ef0f..b4c0acab1 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -213,7 +213,7 @@ def _enrich_advice_and_staleness(state_v2: dict[str, Any]) -> dict[str, Any]: @mcp_for_unity_resource( - uri="unity://editor/state", + uri="mcpforunity://editor/state", name="editor_state", description="Canonical editor readiness snapshot. Includes advice and server-computed staleness.", ) diff --git a/Server/src/services/resources/gameobject.py b/Server/src/services/resources/gameobject.py index 6cf0b46a5..a84111e6e 100644 --- a/Server/src/services/resources/gameobject.py +++ b/Server/src/services/resources/gameobject.py @@ -2,9 +2,9 @@ MCP Resources for reading GameObject data from Unity scenes. These resources provide read-only access to: -- Single GameObject data (unity://scene/gameobject/{id}) -- All components on a GameObject (unity://scene/gameobject/{id}/components) -- Single component on a GameObject (unity://scene/gameobject/{id}/component/{name}) +- Single GameObject data (mcpforunity://scene/gameobject/{id}) +- All components on a GameObject (mcpforunity://scene/gameobject/{id}/components) +- Single component on a GameObject (mcpforunity://scene/gameobject/{id}/component/{name}) """ from typing import Any from pydantic import BaseModel @@ -40,7 +40,7 @@ def _validate_instance_id(instance_id: str) -> tuple[int | None, MCPResponse | N # ============================================================================= @mcp_for_unity_resource( - uri="unity://scene/gameobject-api", + uri="mcpforunity://scene/gameobject-api", name="gameobject_api", description="Documentation for GameObject resources. Use find_gameobjects tool to get instance IDs, then access resources below." ) @@ -63,23 +63,23 @@ async def get_gameobject_api_docs(_ctx: Context) -> MCPResponse: "Example: Adding components to 3 objects → 1 batch_execute with 3 manage_components commands" ], "resources": { - "unity://scene/gameobject/{instance_id}": { + "mcpforunity://scene/gameobject/{instance_id}": { "description": "Get basic GameObject data (name, tag, layer, transform, component type list)", - "example": "unity://scene/gameobject/-81840", + "example": "mcpforunity://scene/gameobject/-81840", "returns": ["instanceID", "name", "tag", "layer", "transform", "componentTypes", "path", "parent", "children"] }, - "unity://scene/gameobject/{instance_id}/components": { + "mcpforunity://scene/gameobject/{instance_id}/components": { "description": "Get all components with full property serialization (paginated)", - "example": "unity://scene/gameobject/-81840/components", + "example": "mcpforunity://scene/gameobject/-81840/components", "parameters": { "page_size": "Number of components per page (default: 25)", "cursor": "Pagination offset (default: 0)", "include_properties": "Include full property data (default: true)" } }, - "unity://scene/gameobject/{instance_id}/component/{component_name}": { + "mcpforunity://scene/gameobject/{instance_id}/component/{component_name}": { "description": "Get a single component by type name with full properties", - "example": "unity://scene/gameobject/-81840/component/Camera", + "example": "mcpforunity://scene/gameobject/-81840/component/Camera", "note": "Use the component type name (e.g., 'Camera', 'Rigidbody', 'Transform')" } }, @@ -127,7 +127,7 @@ class GameObjectResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://scene/gameobject/{instance_id}", + uri="mcpforunity://scene/gameobject/{instance_id}", name="gameobject", description="Get detailed information about a single GameObject by instance ID. Returns name, tag, layer, active state, transform data, parent/children IDs, and component type list (no full component properties)." ) @@ -168,7 +168,7 @@ class ComponentsResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://scene/gameobject/{instance_id}/components", + uri="mcpforunity://scene/gameobject/{instance_id}/components", name="gameobject_components", description="Get all components on a GameObject with full property serialization. Supports pagination with pageSize and cursor parameters." ) @@ -214,7 +214,7 @@ class SingleComponentResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://scene/gameobject/{instance_id}/component/{component_name}", + uri="mcpforunity://scene/gameobject/{instance_id}/component/{component_name}", name="gameobject_component", description="Get a specific component on a GameObject by type name. Returns the fully serialized component with all properties." ) diff --git a/Server/src/services/resources/layers.py b/Server/src/services/resources/layers.py index 96287bd56..9586d493a 100644 --- a/Server/src/services/resources/layers.py +++ b/Server/src/services/resources/layers.py @@ -13,7 +13,7 @@ class LayersResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://project/layers", + uri="mcpforunity://project/layers", name="project_layers", description="All layers defined in the project's TagManager with their indices (0-31). Read this before using add_layer or remove_layer tools." ) diff --git a/Server/src/services/resources/menu_items.py b/Server/src/services/resources/menu_items.py index 2ac820756..eb5c48c5c 100644 --- a/Server/src/services/resources/menu_items.py +++ b/Server/src/services/resources/menu_items.py @@ -12,7 +12,7 @@ class GetMenuItemsResponse(MCPResponse): @mcp_for_unity_resource( - uri="mcpforunity://menu-items", + uri="mcpformcpforunity://menu-items", name="menu_items", description="Provides a list of all menu items." ) diff --git a/Server/src/services/resources/prefab_stage.py b/Server/src/services/resources/prefab_stage.py index a936b4ea6..0325da1e3 100644 --- a/Server/src/services/resources/prefab_stage.py +++ b/Server/src/services/resources/prefab_stage.py @@ -23,7 +23,7 @@ class PrefabStageResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://editor/prefab-stage", + uri="mcpforunity://editor/prefab-stage", name="editor_prefab_stage", description="Current prefab editing context if a prefab is open in isolation mode. Returns isOpen=false if no prefab is being edited." ) diff --git a/Server/src/services/resources/project_info.py b/Server/src/services/resources/project_info.py index 3bec93776..16f19b3c8 100644 --- a/Server/src/services/resources/project_info.py +++ b/Server/src/services/resources/project_info.py @@ -23,7 +23,7 @@ class ProjectInfoResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://project/info", + uri="mcpforunity://project/info", name="project_info", description="Static project information including root path, Unity version, and platform. This data rarely changes." ) diff --git a/Server/src/services/resources/selection.py b/Server/src/services/resources/selection.py index 0dc7a9d89..454d01d80 100644 --- a/Server/src/services/resources/selection.py +++ b/Server/src/services/resources/selection.py @@ -39,7 +39,7 @@ class SelectionResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://editor/selection", + uri="mcpforunity://editor/selection", name="editor_selection", description="Detailed information about currently selected objects in the editor, including GameObjects, assets, and their properties." ) diff --git a/Server/src/services/resources/tags.py b/Server/src/services/resources/tags.py index 6d6359f1f..87d82c3d9 100644 --- a/Server/src/services/resources/tags.py +++ b/Server/src/services/resources/tags.py @@ -14,7 +14,7 @@ class TagsResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://project/tags", + uri="mcpforunity://project/tags", name="project_tags", description="All tags defined in the project's TagManager. Read this before using add_tag or remove_tag tools." ) diff --git a/Server/src/services/resources/tests.py b/Server/src/services/resources/tests.py index d0a71c237..75ec78ad7 100644 --- a/Server/src/services/resources/tests.py +++ b/Server/src/services/resources/tests.py @@ -21,7 +21,7 @@ class GetTestsResponse(MCPResponse): data: list[TestItem] = [] -@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.") +@mcp_for_unity_resource(uri="mcpformcpforunity://tests", name="get_tests", description="Provides a list of all tests.") async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse: """Provides a list of all tests. """ @@ -35,7 +35,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse: return GetTestsResponse(**response) if isinstance(response, dict) else response -@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.") +@mcp_for_unity_resource(uri="mcpformcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.") async def get_tests_for_mode( ctx: Context, mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")], diff --git a/Server/src/services/resources/unity_instances.py b/Server/src/services/resources/unity_instances.py index 4176ffeb0..73b839ccd 100644 --- a/Server/src/services/resources/unity_instances.py +++ b/Server/src/services/resources/unity_instances.py @@ -11,7 +11,7 @@ @mcp_for_unity_resource( - uri="unity://instances", + uri="mcpforunity://instances", name="unity_instances", description="Lists all running Unity Editor instances with their details." ) diff --git a/Server/src/services/resources/windows.py b/Server/src/services/resources/windows.py index d8d6e25c6..e6eb07651 100644 --- a/Server/src/services/resources/windows.py +++ b/Server/src/services/resources/windows.py @@ -31,7 +31,7 @@ class WindowsResponse(MCPResponse): @mcp_for_unity_resource( - uri="unity://editor/windows", + uri="mcpforunity://editor/windows", name="editor_windows", description="All currently open editor windows with their titles, types, positions, and focus state." ) diff --git a/Server/src/services/tools/execute_custom_tool.py b/Server/src/services/tools/execute_custom_tool.py index ad68beae0..1aef2adc4 100644 --- a/Server/src/services/tools/execute_custom_tool.py +++ b/Server/src/services/tools/execute_custom_tool.py @@ -23,7 +23,7 @@ async def execute_custom_tool(ctx: Context, tool_name: str, parameters: dict | N if not unity_instance: return MCPResponse( success=False, - message="No active Unity instance. Call set_active_instance with Name@hash from unity://instances.", + message="No active Unity instance. Call set_active_instance with Name@hash from mcpforunity://instances.", ) project_id = resolve_project_id_for_unity_instance(unity_instance) diff --git a/Server/src/services/tools/find_gameobjects.py b/Server/src/services/tools/find_gameobjects.py index 8ca8fc927..011612a3f 100644 --- a/Server/src/services/tools/find_gameobjects.py +++ b/Server/src/services/tools/find_gameobjects.py @@ -14,7 +14,7 @@ @mcp_for_unity_tool( - description="Search for GameObjects in the scene. Returns instance IDs only (paginated) for efficient lookups. Use unity://scene/gameobject/{id} resource to get full GameObject data." + description="Search for GameObjects in the scene. Returns instance IDs only (paginated) for efficient lookups. Use mcpforunity://scene/gameobject/{id} resource to get full GameObject data." ) async def find_gameobjects( ctx: Context, @@ -38,9 +38,9 @@ async def find_gameobjects( It returns only instance IDs to minimize payload size. For detailed GameObject information, use the returned IDs with: - - unity://scene/gameobject/{id} - Get full GameObject data - - unity://scene/gameobject/{id}/components - Get all components - - unity://scene/gameobject/{id}/component/{name} - Get specific component + - mcpforunity://scene/gameobject/{id} - Get full GameObject data + - mcpforunity://scene/gameobject/{id}/components - Get all components + - mcpforunity://scene/gameobject/{id}/component/{name} - Get specific component """ unity_instance = get_unity_instance_from_context(ctx) diff --git a/Server/src/services/tools/find_in_file.py b/Server/src/services/tools/find_in_file.py index a6ae32140..56372d2b9 100644 --- a/Server/src/services/tools/find_in_file.py +++ b/Server/src/services/tools/find_in_file.py @@ -17,7 +17,7 @@ def _split_uri(uri: str) -> tuple[str, str]: """Split an incoming URI or path into (name, directory) suitable for Unity. Rules: - - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize) - file://... → percent-decode, normalize, strip host and leading slashes, then, if any 'Assets' segment exists, return path relative to that 'Assets' root. Otherwise, fall back to original name/dir behavior. @@ -25,8 +25,8 @@ def _split_uri(uri: str) -> tuple[str, str]: return relative to 'Assets'. """ raw_path: str - if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/"):] + if uri.startswith("mcpforunity://path/"): + raw_path = uri[len("mcpforunity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) host = (parsed.netloc or "").strip() diff --git a/Server/src/services/tools/manage_components.py b/Server/src/services/tools/manage_components.py index d9a1d91c2..ad0187cbf 100644 --- a/Server/src/services/tools/manage_components.py +++ b/Server/src/services/tools/manage_components.py @@ -14,7 +14,7 @@ @mcp_for_unity_tool( - description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the unity://scene/gameobject/{id}/components resource." + description="Manages components on GameObjects (add, remove, set_property). For reading component data, use the mcpforunity://scene/gameobject/{id}/components resource." ) async def manage_components( ctx: Context, diff --git a/Server/src/services/tools/manage_script.py b/Server/src/services/tools/manage_script.py index b8b2c1ab8..8ed6d5a2d 100644 --- a/Server/src/services/tools/manage_script.py +++ b/Server/src/services/tools/manage_script.py @@ -16,7 +16,7 @@ def _split_uri(uri: str) -> tuple[str, str]: """Split an incoming URI or path into (name, directory) suitable for Unity. Rules: - - unity://path/Assets/... → keep as Assets-relative (after decode/normalize) + - mcpforunity://path/Assets/... → keep as Assets-relative (after decode/normalize) - file://... → percent-decode, normalize, strip host and leading slashes, then, if any 'Assets' segment exists, return path relative to that 'Assets' root. Otherwise, fall back to original name/dir behavior. @@ -24,8 +24,8 @@ def _split_uri(uri: str) -> tuple[str, str]: return relative to 'Assets'. """ raw_path: str - if uri.startswith("unity://path/"): - raw_path = uri[len("unity://path/"):] + if uri.startswith("mcpforunity://path/"): + raw_path = uri[len("mcpforunity://path/"):] elif uri.startswith("file://"): parsed = urlparse(uri) host = (parsed.netloc or "").strip() @@ -86,7 +86,7 @@ def _split_uri(uri: str) -> tuple[str, str]: ) async def apply_text_edits( ctx: Context, - uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + uri: Annotated[str, "URI of the script to edit under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."], edits: Annotated[list[dict[str, Any]], "List of edits to apply to the script, i.e. a list of {startLine,startCol,endLine,endCol,newText} (1-indexed!)"], precondition_sha256: Annotated[str, "Optional SHA256 of the script to edit, used to prevent concurrent edits"] | None = None, @@ -434,7 +434,7 @@ async def create_script( ) async def delete_script( ctx: Context, - uri: Annotated[str, "URI of the script to delete under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + uri: Annotated[str, "URI of the script to delete under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."], ) -> dict[str, Any]: """Delete a C# script by URI.""" unity_instance = get_unity_instance_from_context(ctx) @@ -462,7 +462,7 @@ async def delete_script( ) async def validate_script( ctx: Context, - uri: Annotated[str, "URI of the script to validate under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + uri: Annotated[str, "URI of the script to validate under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."], level: Annotated[Literal['basic', 'standard'], "Validation level"] = "basic", include_diagnostics: Annotated[bool, @@ -621,7 +621,7 @@ async def manage_script_capabilities(ctx: Context) -> dict[str, Any]: ) async def get_sha( ctx: Context, - uri: Annotated[str, "URI of the script to edit under Assets/ directory, unity://path/Assets/... or file://... or Assets/..."], + uri: Annotated[str, "URI of the script to edit under Assets/ directory, mcpforunity://path/Assets/... or file://... or Assets/..."], ) -> dict[str, Any]: unity_instance = get_unity_instance_from_context(ctx) await ctx.info( diff --git a/Server/src/services/tools/script_apply_edits.py b/Server/src/services/tools/script_apply_edits.py index 605ba3e4b..2ada06137 100644 --- a/Server/src/services/tools/script_apply_edits.py +++ b/Server/src/services/tools/script_apply_edits.py @@ -229,7 +229,7 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: - name = "SmartReach.cs", path = "Assets/Scripts/Interaction" - name = "Assets/Scripts/Interaction/SmartReach.cs", path = "" - path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty) - - name or path using uri prefixes: unity://path/..., file://... + - name or path using uri prefixes: mcpforunity://path/..., file://... - accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs" Returns (name_without_extension, directory_path_under_Assets). @@ -238,8 +238,8 @@ def _normalize_script_locator(name: str, path: str) -> tuple[str, str]: p = (path or "").strip() def strip_prefix(s: str) -> str: - if s.startswith("unity://path/"): - return s[len("unity://path/"):] + if s.startswith("mcpforunity://path/"): + return s[len("mcpforunity://path/"):] if s.startswith("file://"): return s[len("file://"):] return s diff --git a/Server/src/services/tools/set_active_instance.py b/Server/src/services/tools/set_active_instance.py index 71809f463..53780eab5 100644 --- a/Server/src/services/tools/set_active_instance.py +++ b/Server/src/services/tools/set_active_instance.py @@ -56,7 +56,7 @@ async def set_active_instance( return { "success": False, "error": "Instance identifier is required. " - "Use unity://instances to copy a Name@hash or provide a hash prefix." + "Use mcpforunity://instances to copy a Name@hash or provide a hash prefix." } resolved = None if "@" in value: @@ -65,7 +65,7 @@ async def set_active_instance( return { "success": False, "error": f"Instance '{value}' not found. " - "Use unity://instances to copy an exact Name@hash." + "Use mcpforunity://instances to copy an exact Name@hash." } else: lookup = value.lower() @@ -80,7 +80,7 @@ async def set_active_instance( return { "success": False, "error": f"Instance hash '{value}' does not match any running Unity editors. " - "Use unity://instances to confirm the available hashes." + "Use mcpforunity://instances to confirm the available hashes." } if len(matches) > 1: matching_ids = ", ".join( @@ -89,7 +89,7 @@ async def set_active_instance( return { "success": False, "error": f"Instance hash '{value}' is ambiguous ({matching_ids}). " - "Provide the full Name@hash from unity://instances." + "Provide the full Name@hash from mcpforunity://instances." } resolved = matches[0] diff --git a/Server/src/transport/legacy/unity_connection.py b/Server/src/transport/legacy/unity_connection.py index 9a501dba0..7e40c89d6 100644 --- a/Server/src/transport/legacy/unity_connection.py +++ b/Server/src/transport/legacy/unity_connection.py @@ -584,7 +584,7 @@ def _resolve_instance_id(self, instance_identifier: str | None, instances: list[ raise ConnectionError( f"Unity instance '{identifier}' not found. " f"Available instances: {available_ids}. " - f"Check unity://instances resource for all instances." + f"Check mcpforunity://instances resource for all instances." ) def get_connection(self, instance_identifier: str | None = None) -> UnityConnection: diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index a700a974d..189010bc3 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -417,7 +417,7 @@ async def _try_once() -> tuple[str | None, int]: if not target_hash and session_count > 1: raise RuntimeError( "Multiple Unity instances are connected. " - "Call set_active_instance with Name@hash from unity://instances." + "Call set_active_instance with Name@hash from mcpforunity://instances." ) if wait_started is None: wait_started = time.monotonic() @@ -438,7 +438,7 @@ async def _try_once() -> tuple[str | None, int]: if session_id is None and not target_hash and session_count > 1: raise RuntimeError( "Multiple Unity instances are connected. " - "Call set_active_instance with Name@hash from unity://instances." + "Call set_active_instance with Name@hash from mcpforunity://instances." ) if session_id is None: diff --git a/Server/tests/integration/test_edit_normalization_and_noop.py b/Server/tests/integration/test_edit_normalization_and_noop.py index 959d85215..2eee7f33d 100644 --- a/Server/tests/integration/test_edit_normalization_and_noop.py +++ b/Server/tests/integration/test_edit_normalization_and_noop.py @@ -52,7 +52,7 @@ async def fake_send(cmd, params, **kwargs): }] await apply( DummyContext(), - uri="unity://path/Assets/Scripts/F.cs", + uri="mcpforunity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x", ) @@ -79,7 +79,7 @@ async def fake_read(cmd, params, **kwargs): ) await apply( DummyContext(), - uri="unity://path/Assets/Scripts/F.cs", + uri="mcpforunity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="x", ) @@ -108,7 +108,7 @@ async def fake_send(cmd, params, **kwargs): resp = await apply( DummyContext(), - uri="unity://path/Assets/Scripts/F.cs", + uri="mcpforunity://path/Assets/Scripts/F.cs", edits=[ {"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": ""} ], @@ -160,7 +160,7 @@ async def fake_send(cmd, params, **kwargs): ] resp = await apply_text( DummyContext(), - uri="unity://path/Assets/Scripts/C.cs", + uri="mcpforunity://path/Assets/Scripts/C.cs", edits=edits, precondition_sha256="sha", options={"validate": "relaxed", "applyMode": "atomic"}, diff --git a/Server/tests/integration/test_edit_strict_and_warnings.py b/Server/tests/integration/test_edit_strict_and_warnings.py index 1ae3c03a3..21084de53 100644 --- a/Server/tests/integration/test_edit_strict_and_warnings.py +++ b/Server/tests/integration/test_edit_strict_and_warnings.py @@ -44,7 +44,7 @@ async def fake_send(cmd, params, **kwargs): "endLine": 0, "endCol": 0, "newText": "//x"}] resp = await apply_edits( DummyContext(), - uri="unity://path/Assets/Scripts/F.cs", + uri="mcpforunity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", ) @@ -77,7 +77,7 @@ async def fake_send(cmd, params, **kwargs): "endLine": 0, "endCol": 0, "newText": "//x"}] resp = await apply_edits( DummyContext(), - uri="unity://path/Assets/Scripts/F.cs", + uri="mcpforunity://path/Assets/Scripts/F.cs", edits=edits, precondition_sha256="sha", strict=True, diff --git a/Server/tests/integration/test_editor_state_v2_contract.py b/Server/tests/integration/test_editor_state_v2_contract.py index 644c82bfd..f6a06cde2 100644 --- a/Server/tests/integration/test_editor_state_v2_contract.py +++ b/Server/tests/integration/test_editor_state_v2_contract.py @@ -8,7 +8,7 @@ @pytest.mark.asyncio async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch): """ - Canonical editor state resource should be `unity://editor/state` and conform to v2 contract fields. + Canonical editor state resource should be `mcpforunity://editor/state` and conform to v2 contract fields. """ # Import module to ensure it registers its decorator without disturbing global registry state. import services.resources.editor_state # noqa: F401 @@ -16,11 +16,11 @@ async def test_editor_state_v2_is_registered_and_has_contract_fields(monkeypatch resources = get_registered_resources() state_res = next( - (r for r in resources if r.get("uri") == "unity://editor/state"), + (r for r in resources if r.get("uri") == "mcpforunity://editor/state"), None, ) assert state_res is not None, ( - "Expected canonical editor state resource `unity://editor/state` to be registered. " + "Expected canonical editor state resource `mcpforunity://editor/state` to be registered. " "This is required so clients can poll readiness/staleness and avoid tool loops." ) diff --git a/Server/tests/integration/test_gameobject_resources.py b/Server/tests/integration/test_gameobject_resources.py index decca0812..3f2ea1417 100644 --- a/Server/tests/integration/test_gameobject_resources.py +++ b/Server/tests/integration/test_gameobject_resources.py @@ -2,9 +2,9 @@ Tests for the GameObject resources. Resources: -- unity://scene/gameobject/{instance_id} -- unity://scene/gameobject/{instance_id}/components -- unity://scene/gameobject/{instance_id}/component/{component_name} +- mcpforunity://scene/gameobject/{instance_id} +- mcpforunity://scene/gameobject/{instance_id}/components +- mcpforunity://scene/gameobject/{instance_id}/component/{component_name} """ import pytest diff --git a/Server/tests/integration/test_get_sha.py b/Server/tests/integration/test_get_sha.py index cb109bd5f..d8632b07f 100644 --- a/Server/tests/integration/test_get_sha.py +++ b/Server/tests/integration/test_get_sha.py @@ -39,7 +39,7 @@ async def test_get_sha_param_shape_and_routing(monkeypatch): async def fake_send(cmd, params, **kwargs): captured["cmd"] = cmd captured["params"] = params - return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "unity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}} + return {"success": True, "data": {"sha256": "abc", "lengthBytes": 1, "lastModifiedUtc": "2020-01-01T00:00:00Z", "uri": "mcpforunity://path/Assets/Scripts/A.cs", "path": "Assets/Scripts/A.cs"}} # Patch the send_command_with_retry function at the module level where it's imported import transport.legacy.unity_connection @@ -50,7 +50,7 @@ async def fake_send(cmd, params, **kwargs): ) # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - resp = await get_sha(DummyContext(), uri="unity://path/Assets/Scripts/A.cs") + resp = await get_sha(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs") assert captured["cmd"] == "manage_script" assert captured["params"]["action"] == "get_sha" assert captured["params"]["name"] == "A" diff --git a/Server/tests/integration/test_manage_script_uri.py b/Server/tests/integration/test_manage_script_uri.py index 306f57eff..ec2b2fb8b 100644 --- a/Server/tests/integration/test_manage_script_uri.py +++ b/Server/tests/integration/test_manage_script_uri.py @@ -49,7 +49,7 @@ async def fake_send(cmd, params, **kwargs): # capture params and return success # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry fn = test_tools['apply_text_edits'] - uri = "unity://path/Assets/Scripts/MyScript.cs" + uri = "mcpforunity://path/Assets/Scripts/MyScript.cs" await fn(DummyContext(), uri=uri, edits=[], precondition_sha256=None) assert captured['cmd'] == 'manage_script' diff --git a/Server/tests/integration/test_script_tools.py b/Server/tests/integration/test_script_tools.py index 1f6a73086..7b0671649 100644 --- a/Server/tests/integration/test_script_tools.py +++ b/Server/tests/integration/test_script_tools.py @@ -68,7 +68,7 @@ async def fake_send(cmd, params, **kwargs): edit = {"startLine": 1005, "startCol": 0, "endLine": 1005, "endCol": 5, "newText": "Hello"} ctx = DummyContext() - resp = await apply_edits(ctx, "unity://path/Assets/Scripts/LongFile.cs", [edit]) + resp = await apply_edits(ctx, "mcpforunity://path/Assets/Scripts/LongFile.cs", [edit]) assert captured["cmd"] == "manage_script" assert captured["params"]["action"] == "apply_text_edits" assert captured["params"]["edits"][0]["startLine"] == 1005 @@ -96,12 +96,12 @@ async def fake_send(cmd, params, **kwargs): edit1 = {"startLine": 1, "startCol": 0, "endLine": 1, "endCol": 0, "newText": "//header\n"} - resp1 = await apply_edits(DummyContext(), "unity://path/Assets/Scripts/File.cs", [edit1]) + resp1 = await apply_edits(DummyContext(), "mcpforunity://path/Assets/Scripts/File.cs", [edit1]) edit2 = {"startLine": 2, "startCol": 0, "endLine": 2, "endCol": 0, "newText": "//second\n"} resp2 = await apply_edits( DummyContext(), - "unity://path/Assets/Scripts/File.cs", + "mcpforunity://path/Assets/Scripts/File.cs", [edit2], precondition_sha256=resp1["sha256"], ) @@ -132,7 +132,7 @@ async def fake_send(cmd, params, **kwargs): opts = {"validate": "relaxed", "applyMode": "atomic", "refresh": "immediate"} await apply_edits( DummyContext(), - "unity://path/Assets/Scripts/File.cs", + "mcpforunity://path/Assets/Scripts/File.cs", [{"startLine": 1, "startCol": 1, "endLine": 1, "endCol": 1, "newText": "x"}], options=opts, ) @@ -165,7 +165,7 @@ async def fake_send(cmd, params, **kwargs): ] await apply_edits( DummyContext(), - "unity://path/Assets/Scripts/File.cs", + "mcpforunity://path/Assets/Scripts/File.cs", edits, precondition_sha256="x", ) diff --git a/Server/tests/integration/test_validate_script_summary.py b/Server/tests/integration/test_validate_script_summary.py index d1b161ae0..fc9944b6f 100644 --- a/Server/tests/integration/test_validate_script_summary.py +++ b/Server/tests/integration/test_validate_script_summary.py @@ -52,5 +52,5 @@ async def fake_send(cmd, params, **kwargs): "async_send_command_with_retry", fake_send) # No need to patch tools.manage_script; it now calls unity_connection.send_command_with_retry - resp = await validate_script(DummyContext(), uri="unity://path/Assets/Scripts/A.cs") + resp = await validate_script(DummyContext(), uri="mcpforunity://path/Assets/Scripts/A.cs") assert resp == {"success": True, "data": {"warnings": 1, "errors": 2}} From 07d1d9d1a2b67cff1ef69ceb39e8b6ec5ca6ca26 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:46:34 -0400 Subject: [PATCH 14/32] Update README with all tools + better listing for resources --- README.md | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9773645ee..c3cc87ac4 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,12 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav * `manage_vfx`: VFX effect operations, including line/trail renderer, particle system, and VisualEffectGraph (in development). * `batch_execute`: ⚡ **RECOMMENDED** - Executes multiple commands in one batch for 10-100x better performance. Use this for any repetitive operations. * `find_gameobjects`: Search for GameObjects by name, tag, layer, component, path, or ID (paginated). +* `find_in_file`: Search a C# script with a regex pattern and return matching line numbers and excerpts. * `read_console`: Gets messages from or clears the Unity console. * `refresh_unity`: Request asset database refresh and optional compilation. * `run_tests`: Starts tests asynchronously, returns job_id for polling. * `get_test_job`: Polls an async test job for progress and results. +* `debug_request_context`: Return the current request context details (client_id, session_id, and meta dump). * `execute_custom_tool`: Execute project-scoped custom tools registered by Unity. * `execute_menu_item`: Executes Unity Editor menu items (e.g., "File/Save Project"). * `set_active_instance`: Routes tool calls to a specific Unity instance. Requires `Name@hash` from `unity_instances`. @@ -76,22 +78,23 @@ MCP for Unity acts as a bridge, allowing AI assistants (Claude, Cursor, Antigrav Your LLM can retrieve the following resources: -* `custom_tools`: Lists custom tools available for the active Unity project. -* `unity_instances`: Lists all running Unity Editor instances with details (name, path, hash, status, session). -* `menu_items`: All available menu items in the Unity Editor. -* `tests`: All available tests (EditMode, PlayMode) in the Unity Editor. -* `gameobject_api`: Documentation for GameObject resources and how to use `find_gameobjects` tool. -* `unity://scene/gameobject/{instanceID}`: Read-only access to GameObject data (name, tag, transform, components, children). -* `unity://scene/gameobject/{instanceID}/components`: Read-only access to all components on a GameObject with full property serialization. -* `unity://scene/gameobject/{instanceID}/component/{componentName}`: Read-only access to a specific component's properties. -* `editor_active_tool`: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings. -* `editor_prefab_stage`: Current prefab editing context if a prefab is open in isolation mode. -* `editor_selection`: Detailed information about currently selected objects in the editor. -* `editor_state`: Editor readiness snapshot with advice and staleness info. -* `editor_windows`: All currently open editor windows with titles, types, positions, and focus state. -* `project_info`: Static project information (root path, Unity version, platform). -* `project_layers`: All layers defined in TagManager with their indices (0-31). -* `project_tags`: All tags defined in TagManager. +* `custom_tools` [`mcpforunity://custom-tools`]: Lists custom tools available for the active Unity project. +* `unity_instances` [`mcpforunity://instances`]: Lists all running Unity Editor instances with details (name, path, hash, status, session). +* `menu_items` [`mcpforunity://menu-items`]: All available menu items in the Unity Editor. +* `get_tests` [`mcpforunity://tests`]: All available tests (EditMode + PlayMode) in the Unity Editor. +* `get_tests_for_mode` [`mcpforunity://tests/{mode}`]: All available tests for a specific mode (EditMode or PlayMode). +* `gameobject_api` [`mcpforunity://scene/gameobject-api`]: Documentation for GameObject resources and how to use `find_gameobjects` tool. +* `gameobject` [`mcpforunity://scene/gameobject/{instance_id}`]: Read-only access to GameObject data (name, tag, transform, components, children). +* `gameobject_components` [`mcpforunity://scene/gameobject/{instance_id}/components`]: Read-only access to all components on a GameObject with full property serialization. +* `gameobject_component` [`mcpforunity://scene/gameobject/{instance_id}/component/{component_name}`]: Read-only access to a specific component's properties. +* `editor_active_tool` [`mcpforunity://editor/active-tool`]: Currently active editor tool (Move, Rotate, Scale, etc.) and transform handle settings. +* `editor_prefab_stage` [`mcpforunity://editor/prefab-stage`]: Current prefab editing context if a prefab is open in isolation mode. +* `editor_selection` [`mcpforunity://editor/selection`]: Detailed information about currently selected objects in the editor. +* `editor_state` [`mcpforunity://editor/state`]: Editor readiness snapshot with advice and staleness info. +* `editor_windows` [`mcpforunity://editor/windows`]: All currently open editor windows with titles, types, positions, and focus state. +* `project_info` [`mcpforunity://project/info`]: Static project information (root path, Unity version, platform). +* `project_layers` [`mcpforunity://project/layers`]: All layers defined in TagManager with their indices (0-31). +* `project_tags` [`mcpforunity://project/tags`]: All tags defined in TagManager. --- From c23412c430be7ffb566b8bff1ff963698d270283 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:47:51 -0400 Subject: [PATCH 15/32] Update other references to resources --- .claude/prompts/nl-gameobject-suite.md | 12 ++++++------ .claude/prompts/nl-unity-suite-nl.md | 4 ++-- .claude/prompts/nl-unity-suite-t.md | 6 +++--- MCPForUnity/Editor/Tools/ManageScript.cs | 20 ++++++++++---------- docs/README-DEV-zh.md | 2 +- docs/v8_NEW_NETWORKING_SETUP.md | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.claude/prompts/nl-gameobject-suite.md b/.claude/prompts/nl-gameobject-suite.md index 1ff16de47..6a43ec0bf 100644 --- a/.claude/prompts/nl-gameobject-suite.md +++ b/.claude/prompts/nl-gameobject-suite.md @@ -51,7 +51,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec **Goal**: Test reading a single GameObject via resource **Actions**: - Use the instance ID from GO-1 -- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID +- Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}")` replacing {instanceID} with the actual ID - Verify response includes: instanceID, name, tag, layer, transform, path - **Pass criteria**: All expected fields present @@ -59,7 +59,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec **Goal**: Test reading components via resource **Actions**: - Use the instance ID from GO-1 -- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID +- Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}/components")` replacing {instanceID} with the actual ID - Verify response includes paginated component list in `data.items` - Verify at least one component has typeName and instanceID - **Pass criteria**: Components list returned with proper pagination @@ -94,7 +94,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec **Goal**: Test reading a single component via resource **Actions**: - Get instance ID of GO_Test_Object from GO-5 -- Call `mcp__UnityMCP__read_resource(uri="unity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID} +- Call `mcp__UnityMCP__read_resource(uri="mcpforunity://scene/gameobject/{instanceID}/component/Rigidbody")` replacing {instanceID} - Verify response includes component data with typeName="Rigidbody" - Verify mass property is 5.0 (set in GO-4) - **Pass criteria**: Component data returned with correct properties @@ -131,9 +131,9 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__manage_gameobjec - `manage_components(action, target, component_type?, properties?)` - Add/remove/set_property/get_all/get_single ### New Resources -- `unity://scene/gameobject/{instanceID}` - Single GameObject data -- `unity://scene/gameobject/{instanceID}/components` - All components (paginated) -- `unity://scene/gameobject/{instanceID}/component/{componentName}` - Single component +- `mcpforunity://scene/gameobject/{instanceID}` - Single GameObject data +- `mcpforunity://scene/gameobject/{instanceID}/components` - All components (paginated) +- `mcpforunity://scene/gameobject/{instanceID}/component/{componentName}` - Single component ### Updated Resources - `manage_scene(action="get_hierarchy")` - Now includes `componentTypes` array in each item diff --git a/.claude/prompts/nl-unity-suite-nl.md b/.claude/prompts/nl-unity-suite-nl.md index 58e138eb9..9e08b3ba2 100644 --- a/.claude/prompts/nl-unity-suite-nl.md +++ b/.claude/prompts/nl-unity-suite-nl.md @@ -9,7 +9,7 @@ AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_ ## Mission 1) Pick target file (prefer): - - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + - `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 2) Execute NL tests NL-0..NL-4 in order using minimal, precise edits that build on each other. 3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`. 4) **Report**: write one `` XML fragment per test to `reports/_results.xml`. Do **not** read or edit `$JUNIT_OUT`. @@ -38,7 +38,7 @@ AllowedTools: Write,mcp__UnityMCP__apply_text_edits,mcp__UnityMCP__script_apply_ ## Environment & Paths (CI) - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - **Canonical URIs only**: - - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) + - Primary: `mcpforunity://path/Assets/...` (never embed `project_root` in the URI) - Relative (when supported): `Assets/...` CI provides: diff --git a/.claude/prompts/nl-unity-suite-t.md b/.claude/prompts/nl-unity-suite-t.md index 37b57bd94..16c280106 100644 --- a/.claude/prompts/nl-unity-suite-t.md +++ b/.claude/prompts/nl-unity-suite-t.md @@ -8,7 +8,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,m ## Mission 1) Pick target file (prefer): - - `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` + - `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` 2) Execute T tests T-A..T-J in order using minimal, precise edits that build on the NL pass state. 3) Validate each edit with `mcp__UnityMCP__validate_script(level:"standard")`. 4) **Report**: write one `` XML fragment per test to `reports/_results.xml`. Do **not** read or edit `$JUNIT_OUT`. @@ -37,7 +37,7 @@ AllowedTools: Write,mcp__UnityMCP__manage_editor,mcp__UnityMCP__list_resources,m ## Environment & Paths (CI) - Always pass: `project_root: "TestProjects/UnityMCPTests"` and `ctx: {}` on list/read/edit/validate. - **Canonical URIs only**: - - Primary: `unity://path/Assets/...` (never embed `project_root` in the URI) + - Primary: `mcpforunity://path/Assets/...` (never embed `project_root` in the URI) - Relative (when supported): `Assets/...` CI provides: @@ -151,7 +151,7 @@ STRICT OP GUARDRAILS ### T-G. Path Normalization Test (No State Change) **Goal**: Verify URI forms work equivalently on modified file **Actions**: -- Make identical edit using `unity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` +- Make identical edit using `mcpforunity://path/Assets/Scripts/LongUnityScriptClaudeTest.cs` - Then using `Assets/Scripts/LongUnityScriptClaudeTest.cs` - Second should return `stale_file`, retry with updated SHA - Verify both URI forms target same file diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs index e86890ff8..1b7028168 100644 --- a/MCPForUnity/Editor/Tools/ManageScript.cs +++ b/MCPForUnity/Editor/Tools/ManageScript.cs @@ -282,7 +282,7 @@ public static object HandleCommand(JObject @params) catch { lengthBytes = fi.Exists ? fi.Length : 0; } var data = new { - uri = $"unity://path/{relativePath}", + uri = $"mcpforunity://path/{relativePath}", path = relativePath, sha256 = sha, lengthBytes, @@ -372,7 +372,7 @@ string namespaceName try { File.Delete(tmp); } catch { } } - var uri = $"unity://path/{relativePath}"; + var uri = $"mcpforunity://path/{relativePath}"; var ok = new SuccessResponse( $"Script '{name}.cs' created successfully at '{relativePath}'.", new { uri, scheduledRefresh = false } @@ -401,7 +401,7 @@ private static object ReadScript(string fullPath, string relativePath) // Return both normal and encoded contents for larger files bool isLarge = contents.Length > 10000; // If content is large, include encoded version - var uri = $"unity://path/{relativePath}"; + var uri = $"mcpforunity://path/{relativePath}"; var responseData = new { uri, @@ -481,7 +481,7 @@ string contents } // Prepare success response BEFORE any operation that can trigger a domain reload - var uri = $"unity://path/{relativePath}"; + var uri = $"mcpforunity://path/{relativePath}"; var ok = new SuccessResponse( $"Script '{name}.cs' updated successfully at '{relativePath}'.", new { uri, path = relativePath, scheduledRefresh = true } @@ -704,7 +704,7 @@ private static object ApplyTextEdits( $"No-op: contents unchanged for '{relativePath}'.", new { - uri = $"unity://path/{relativePath}", + uri = $"mcpforunity://path/{relativePath}", path = relativePath, editsApplied = 0, no_op = true, @@ -805,7 +805,7 @@ private static object ApplyTextEdits( $"Applied {spans.Count} text edit(s) to '{relativePath}'.", new { - uri = $"unity://path/{relativePath}", + uri = $"mcpforunity://path/{relativePath}", path = relativePath, editsApplied = spans.Count, sha256 = newSha, @@ -1375,7 +1375,7 @@ private static object EditScript( new { path = relativePath, - uri = $"unity://path/{relativePath}", + uri = $"mcpforunity://path/{relativePath}", editsApplied = 0, no_op = true, sha256 = sameSha, @@ -1441,7 +1441,7 @@ private static object EditScript( new { path = relativePath, - uri = $"unity://path/{relativePath}", + uri = $"mcpforunity://path/{relativePath}", editsApplied = appliedCount, scheduledRefresh = !immediate, sha256 = newSha @@ -2637,8 +2637,8 @@ public static string SanitizeAssetsPath(string p) { if (string.IsNullOrEmpty(p)) return p; p = p.Replace('\\', '/').Trim(); - if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) - p = p.Substring("unity://path/".Length); + if (p.StartsWith("mcpforunity://path/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("mcpforunity://path/".Length); while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) p = p.Substring("Assets/".Length); if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) diff --git a/docs/README-DEV-zh.md b/docs/README-DEV-zh.md index 1cd10870a..6c549b4e9 100644 --- a/docs/README-DEV-zh.md +++ b/docs/README-DEV-zh.md @@ -37,7 +37,7 @@ python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity- ## 多 Unity 实例路由 -- 使用资源 `unity://instances` 查看所有已连接实例,复制资源返回的精确 `Name@hash`。 +- 使用资源 `mcpforunity://instances` 查看所有已连接实例,复制资源返回的精确 `Name@hash`。 - 当存在多个实例时,在调用任何工具/资源前先用 `set_active_instance(Name@hash)` 选择目标。 - 如果未选择且连接了多个实例,服务器会返回错误并要求你先选择。 diff --git a/docs/v8_NEW_NETWORKING_SETUP.md b/docs/v8_NEW_NETWORKING_SETUP.md index 38de0f80c..38daf1977 100644 --- a/docs/v8_NEW_NETWORKING_SETUP.md +++ b/docs/v8_NEW_NETWORKING_SETUP.md @@ -186,7 +186,7 @@ In the `plugin_hub`'s `on_receive` handler, we look out for the `register_tools` That requirement of keeping tools local to the projeect made this implementation a bit trickier. We have the requirement because in this project, we can run multiple Unity instances at the same time. So it doesn't make sense to make every tool globally available to all connected projects. -To make tools local to the project, we add a `unity://custom-tools` resource which lists all tools mapped to a session (which is retrieve from FastMCP's context). And then we add a `execute_custom_tool` function tool which can call the tools the user added. This worked surprisingly well, but required some tweaks: +To make tools local to the project, we add a `mcpforunity://custom-tools` resource which lists all tools mapped to a session (which is retrieve from FastMCP's context). And then we add a `execute_custom_tool` function tool which can call the tools the user added. This worked surprisingly well, but required some tweaks: - We removed the fallback for session IDs in the server. If there's more than one Unity instance connected to the server, the MCP client MUST call `set_active_instance` so the mapping between session IDs and Unity instances will be correct. - We removed the `read_resources` tool. It simply did not work, and LLMs would go in circles for a long time before actually reading the resource directly. This only works because MCP resources have up to date information and gives the MCP clients the right context to call the tools. From 9569de6a318c4907d337917393f81b5d3185608b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:55:03 -0400 Subject: [PATCH 16/32] Updated translated doc - unfortunately I cannot verify --- README-zh.md | 127 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 79 insertions(+), 48 deletions(-) diff --git a/README-zh.md b/README-zh.md index 703ef6beb..6f4c1cae7 100644 --- a/README-zh.md +++ b/README-zh.md @@ -8,6 +8,7 @@ [![Discord](https://img.shields.io/badge/discord-join-red.svg?logo=discord&logoColor=white)](https://discord.gg/y4p8KfzrN4) [![](https://img.shields.io/badge/Website-Visit-purple)](https://www.coplay.dev/?ref=unity-mcp) [![](https://img.shields.io/badge/Unity-000000?style=flat&logo=unity&logoColor=blue 'Unity')](https://unity.com/releases/editor/archive) +[![Unity Asset Store](https://img.shields.io/badge/Unity%20Asset%20Store-Get%20Package-FF6A00?style=flat&logo=unity&logoColor=white)](https://assetstore.unity.com/packages/tools/generative-ai/mcp-for-unity-ai-driven-development-329908) [![python](https://img.shields.io/badge/Python-3.10+-3776AB.svg?style=flat&logo=python&logoColor=white)](https://www.python.org) [![](https://badge.mcpx.dev?status=on 'MCP Enabled')](https://modelcontextprotocol.io/introduction) ![GitHub commit activity](https://img.shields.io/github/commit-activity/w/CoplayDev/unity-mcp) @@ -18,7 +19,7 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本地 **MCP(模型上下文协议)客户端** 直接与您的 Unity 编辑器交互。为您的大语言模型提供管理资源、控制场景、编辑脚本和自动化 Unity 任务的工具。 -MCP for Unity screenshot +MCP for Unity building a scene ### 💬 加入我们的 [Discord](https://discord.gg/y4p8KfzrN4) @@ -39,25 +40,34 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 您的大语言模型可以使用以下功能: -* `execute_custom_tool`: 执行由 Unity 注册的项目范围自定义工具。 -* `execute_menu_item`: 执行 Unity 编辑器菜单项(例如,"File/Save Project")。 -* `manage_asset`: 执行资源操作(导入、创建、修改、删除等)。 -* `manage_editor`: 控制和查询编辑器的状态和设置。 -* `manage_gameobject`: 管理游戏对象:创建、修改、删除、查找和组件操作。 -* `manage_material`: 管理材质:创建、设置属性、分配给渲染器以及查询材质信息。 -* `manage_prefabs`: 执行预制件操作(创建、修改、删除等)。 -* `manage_scene`: 管理场景(加载、保存、创建、获取层次结构等)。 -* `manage_script`: 传统脚本操作的兼容性路由器(创建、读取、删除)。建议使用 `apply_text_edits` 或 `script_apply_edits` 进行编辑。 -* `manage_shader`: 执行着色器 CRUD 操作(创建、读取、修改、删除)。 -* `read_console`: 获取控制台消息或清除控制台。 -* `run_tests`: 在 Unity 编辑器中运行测试。 -* `set_active_instance`: 将后续工具调用路由到特定的 Unity 实例(当运行多个实例时)。 -* `apply_text_edits`: 具有前置条件哈希和原子多编辑批次的精确文本编辑。 -* `script_apply_edits`: 结构化 C# 方法/类编辑(插入/替换/删除),具有更安全的边界。 -* `validate_script`: 快速验证(基本/标准)以在写入前后捕获语法/结构问题。 -* `create_script`: 在给定的项目路径创建新的 C# 脚本。 +* `manage_asset`: 执行资源操作(导入、创建、修改、删除、搜索等)。 +* `manage_editor`: 控制编辑器状态(播放模式、活动工具、标签、层)。 +* `manage_gameobject`: 管理 GameObject(创建、修改、删除、查找、复制、移动)。 +* `manage_components`: 管理 GameObject 上的组件(添加、移除、设置属性)。 +* `manage_material`: 管理材质(创建、设置属性/颜色、分配给渲染器)。 +* `manage_prefabs`: 预制体操作(打开/关闭 Stage、保存、从 GameObject 创建)。 +* `manage_scene`: 场景管理(加载、保存、创建、获取层级、截图)。 +* `manage_script`: 传统脚本操作(创建、读取、删除)。编辑建议使用 `apply_text_edits` 或 `script_apply_edits`。 +* `manage_scriptable_object`: 创建并修改 ScriptableObject 资产。 +* `manage_shader`: Shader CRUD(创建、读取、更新、删除)。 +* `manage_vfx`: VFX 操作(ParticleSystem / LineRenderer / TrailRenderer / VisualEffectGraph 等)。 +* `batch_execute`: ⚡ **推荐** - 批量执行多条命令(10-100x 性能提升)。 +* `find_gameobjects`: 按 name/tag/layer/component/path/id 搜索 GameObject(分页)。 +* `find_in_file`: 使用正则搜索 C# 脚本并返回匹配的行号与片段。 +* `read_console`: 获取或清除 Unity Console 日志。 +* `refresh_unity`: 请求刷新资产数据库,并可选触发编译。 +* `run_tests`: 异步启动测试,返回 job_id。 +* `get_test_job`: 轮询异步测试任务的进度和结果。 +* `debug_request_context`: 返回当前请求上下文(client_id、session_id、meta)。 +* `execute_custom_tool`: 执行由 Unity 注册的项目级自定义工具。 +* `execute_menu_item`: 执行 Unity 编辑器菜单项(例如 "File/Save Project")。 +* `set_active_instance`: 将后续工具调用路由到特定 Unity 实例(从 `unity_instances` 获取 `Name@hash`)。 +* `apply_text_edits`: 使用行/列范围进行精确文本编辑(支持前置条件哈希)。 +* `script_apply_edits`: 结构化 C# 方法/类编辑(insert/replace/delete),边界更安全。 +* `validate_script`: 快速验证(basic/standard),用于捕获语法/结构问题。 +* `create_script`: 在指定项目路径创建新的 C# 脚本。 * `delete_script`: 通过 URI 或 Assets 相对路径删除 C# 脚本。 -* `get_sha`: 获取 Unity C# 脚本的 SHA256 和基本元数据,而不返回文件内容。 +* `get_sha`: 获取 Unity C# 脚本的 SHA256 与元数据(不返回内容)。 @@ -66,18 +76,23 @@ MCP for Unity 作为桥梁,允许 AI 助手(如 Claude、Cursor)通过本 您的大语言模型可以检索以下资源: -* `custom_tools`: 列出活动 Unity 项目可用的自定义工具。 -* `unity_instances`: 列出所有正在运行的 Unity 编辑器实例及其详细信息(名称、路径、端口、状态)。 -* `menu_items`: 检索 Unity 编辑器中所有可用的菜单项。 -* `tests`: 检索 Unity 编辑器中所有可用的测试。可以选择特定类型的测试(例如,"EditMode"、"PlayMode")。 -* `editor_active_tool`: 当前活动的编辑器工具(移动、旋转、缩放等)和变换手柄设置。 -* `editor_prefab_stage`: 如果预制件在隔离模式下打开,则为当前预制件编辑上下文。 -* `editor_selection`: 有关编辑器中当前选定对象的详细信息。 -* `editor_state`: 当前编辑器运行时状态,包括播放模式、编译状态、活动场景和选择摘要。 -* `editor_windows`: 所有当前打开的编辑器窗口及其标题、类型、位置和焦点状态。 -* `project_info`: 静态项目信息,包括根路径、Unity 版本和平台。 -* `project_layers`: 项目 TagManager 中定义的所有层及其索引(0-31)。 -* `project_tags`: 项目 TagManager 中定义的所有标签。 +* `custom_tools` [`mcpforunity://custom-tools`]: 列出活动 Unity 项目可用的自定义工具。 +* `unity_instances` [`mcpforunity://instances`]: 列出所有正在运行的 Unity 编辑器实例及其详细信息。 +* `menu_items` [`mcpforunity://menu-items`]: Unity 编辑器中所有可用菜单项。 +* `get_tests` [`mcpforunity://tests`]: Unity 编辑器中所有可用测试(EditMode + PlayMode)。 +* `get_tests_for_mode` [`mcpforunity://tests/{mode}`]: 指定模式(EditMode 或 PlayMode)的测试列表。 +* `gameobject_api` [`mcpforunity://scene/gameobject-api`]: GameObject 资源用法说明(先用 `find_gameobjects` 获取 instance ID)。 +* `gameobject` [`mcpforunity://scene/gameobject/{instance_id}`]: 读取单个 GameObject 信息(不含完整组件序列化)。 +* `gameobject_components` [`mcpforunity://scene/gameobject/{instance_id}/components`]: 读取某 GameObject 的全部组件(支持分页,可选包含属性)。 +* `gameobject_component` [`mcpforunity://scene/gameobject/{instance_id}/component/{component_name}`]: 读取某 GameObject 上指定组件的完整属性。 +* `editor_active_tool` [`mcpforunity://editor/active-tool`]: 当前活动工具(Move/Rotate/Scale 等)与变换手柄设置。 +* `editor_prefab_stage` [`mcpforunity://editor/prefab-stage`]: 当前 Prefab Stage 上下文(若未打开则 isOpen=false)。 +* `editor_selection` [`mcpforunity://editor/selection`]: 编辑器当前选中对象的详细信息。 +* `editor_state` [`mcpforunity://editor/state`]: 编辑器就绪状态快照(包含建议与 staleness)。 +* `editor_windows` [`mcpforunity://editor/windows`]: 当前打开的编辑器窗口列表(标题、类型、位置、焦点)。 +* `project_info` [`mcpforunity://project/info`]: 静态项目信息(根路径、Unity 版本、平台)。 +* `project_layers` [`mcpforunity://project/layers`]: 项目层(0-31)及名称。 +* `project_tags` [`mcpforunity://project/tags`]: 项目 Tag 列表。 --- @@ -177,9 +192,9 @@ https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v8.6.0 HTTP 传输默认启用。Unity 窗口可以为您启动 FastMCP 服务器: 1. 打开 `Window > MCP for Unity`。 -2. 确保**传输**下拉菜单设置为 `HTTP`(默认),并且 **HTTP URL** 是您想要的(默认为 `http://localhost:8080`)。 -3. 点击**启动本地 HTTP 服务器**。Unity 会生成一个新的操作系统终端,运行 `uv ... server.py --transport http`。 -4. 在您工作时保持该终端窗口打开;关闭它会停止服务器。如果您需要干净地关闭它,请使用 Unity 窗口中的**停止会话**按钮。 +2. 确保 **Transport** 下拉菜单设置为 `HTTP Local`(默认),并将 **HTTP URL** 设置为你想要的地址(默认为 `http://localhost:8080`)。 +3. 点击 **Start Server**。Unity 会生成一个新的系统终端窗口,运行 `uv ... server.py --transport http`。 +4. 在你工作时保持该终端窗口打开;关闭它会停止服务器。如果你需要干净地关闭它,请使用 Unity 窗口中的 **Stop Session** 按钮。 > 更喜欢 stdio?将传输下拉菜单更改为 `Stdio`,Unity 将回退到嵌入式 TCP 桥接器,而不是启动 HTTP 服务器。 @@ -188,25 +203,29 @@ HTTP 传输默认启用。Unity 窗口可以为您启动 FastMCP 服务器: 您也可以从终端自己启动服务器——对 CI 或当您想查看原始日志时很有用: ```bash -uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.1.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080 +uvx --from "git+https://github.com/CoplayDev/unity-mcp@v8.6.0#subdirectory=Server" mcp-for-unity --transport http --http-url http://localhost:8080 ``` 在客户端连接时保持进程运行。 ### 🛠️ 步骤 3:配置您的 MCP 客户端 -将您的 MCP 客户端(Claude、Cursor 等)连接到步骤 2(自动)的 HTTP 服务器或通过手动配置(如下)。 +将你的 MCP 客户端(Claude、Cursor 等)连接到步骤 2 启动的 HTTP 服务器(自动)或使用下方的手动配置。 -**选项 A:自动设置(推荐用于 Claude/Cursor/VSC Copilot)** +对于 **Claude Desktop** 用户,可以尝试下载并上传 `claude_skill_unity.zip`(Unity_Skills),参见这个链接:https://www.claude.com/blog/skills + +**选项 A:配置按钮(推荐用于 Claude/Cursor/VSC Copilot)** 1. 在 Unity 中,前往 `Window > MCP for Unity`。 -2. 点击 `Auto-Setup`。 -3. 寻找绿色状态指示器 🟢 和"Connected ✓"。*(这会写入指向您在步骤 2 中启动的服务器的 HTTP `url`)。* +2. 从下拉菜单选择你的 Client/IDE。 +3. 点击 `Configure` 按钮。(或点击 `Configure All Detected Clients` 自动尝试配置所有检测到的客户端,但会更慢。) +4. 寻找绿色状态指示器 🟢 和 "Connected ✓"。*(这会写入指向你在步骤 2 中启动的服务器的 HTTP `url`)。*
客户端特定故障排除 - **VSCode**:使用 `Code/User/mcp.json` 和顶级 `servers.unityMCP`、`"type": "http"` 以及步骤 2 中的 URL。在 Windows 上,当您切换回 stdio 时,MCP for Unity 仍然偏好绝对 `uv.exe` 路径。 - **Cursor / Windsurf** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/1.-Fix-Unity-MCP-and-Cursor,-VSCode-&-Windsurf):如果缺少 `uv`,MCP for Unity 窗口会显示"uv Not Found"和快速 [HELP] 链接以及"Choose `uv` Install Location"按钮。 - - **Claude Code** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code):如果找不到 `claude`,窗口会显示"Claude Not Found"和 [HELP] 以及"Choose Claude Location"按钮。注销现在会立即更新 UI。
+ - **Claude Code** [(**帮助链接**)](https://github.com/CoplayDev/unity-mcp/wiki/2.-Fix-Unity-MCP-and-Claude-Code):如果找不到 `claude`,窗口会显示"Claude Not Found"和 [HELP] 以及"Choose Claude Location"按钮。注销现在会立即更新 UI。 + **选项 B:手动配置** @@ -254,7 +273,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso ```json { "mcpServers": { - "UnityMCP": { + "unityMCP": { "url": "http://localhost:8080/mcp" } } @@ -293,7 +312,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso ```json { "mcpServers": { - "UnityMCP": { + "unityMCP": { "command": "uv", "args": [ "run", @@ -313,7 +332,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso ```json { "mcpServers": { - "UnityMCP": { + "unityMCP": { "command": "C:/Users/YOUR_USERNAME/AppData/Local/Microsoft/WinGet/Links/uv.exe", "args": [ "run", @@ -336,7 +355,7 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso ## 使用方法 ▶️ -1. **打开您的 Unity 项目** 并验证 HTTP 服务器正在运行(Window > MCP for Unity > Start Local HTTP Server)。一旦服务器启动,指示器应显示"Session Active"。 +1. **打开你的 Unity 项目** 并确认 HTTP 服务器正在运行(Window > MCP for Unity > Start Server)。服务器启动后,指示器应显示 "Session Active"。 2. **启动您的 MCP 客户端**(Claude、Cursor 等)。它连接到步骤 3 中配置的 HTTP 端点——客户端不会生成额外的终端。 @@ -344,15 +363,27 @@ claude mcp add --scope user UnityMCP -- "C:/Users/USERNAME/AppData/Local/Microso 示例提示:`创建一个 3D 玩家控制器`,`创建一个 3D 井字游戏`,`创建一个酷炫的着色器并应用到立方体上`。 +### 💡 性能提示:使用 `batch_execute` + +当你需要执行多个操作时,请使用 `batch_execute` 而不是逐个调用工具。这可以显著降低延迟和 token 成本(单次最多 25 条命令): + +```text +❌ 慢:创建 5 个立方体 → 5 次 manage_gameobject 调用 +✅ 快:创建 5 个立方体 → 1 次 batch_execute(包含 5 条 manage_gameobject 命令) + +❌ 慢:先查找对象,再逐个加组件 → N+M 次调用 +✅ 快:查找 + 批量加组件 → 1 次 find + 1 次 batch_execute(包含 M 条 manage_components 命令) +``` + ### 使用多个 Unity 实例 MCP for Unity 同时支持多个 Unity 编辑器实例。每个实例在每个 MCP 客户端会话中是隔离的。 **要将工具调用定向到特定实例:** -1. 列出可用实例:要求您的大语言模型检查 `unity_instances` 资源 -2. 设置活动实例:使用 `set_active_instance` 与实例名称(例如,`MyProject@abc123`) -3. 所有后续工具路由到该实例,直到更改 +1. 列出可用实例:要求你的大语言模型检查 `unity_instances` 资源 +2. 设置活动实例:使用 `set_active_instance`,并传入 `unity_instances` 返回的精确 `Name@hash`(例如 `MyProject@abc123`) +3. 后续所有工具都会路由到该实例,直到你再次更改。如果存在多个实例且未设置活动实例,服务器会报错并提示选择实例。 **示例:** ``` @@ -412,7 +443,7 @@ MCP for Unity 包含**注重隐私的匿名遥测**来帮助我们改进产品 - 检查状态窗口:Window > MCP for Unity。 - 重启 Unity。 - **MCP 客户端未连接/服务器未启动:** - - 确保本地 HTTP 服务器正在运行(Window > MCP for Unity > Start Local HTTP Server)。保持生成的终端窗口打开。 + - 确保本地 HTTP 服务器正在运行(Window > MCP for Unity > Start Server)。保持生成的终端窗口打开。 - **验证服务器路径:** 双重检查您的 MCP 客户端 JSON 配置中的 --directory 路径。它必须完全匹配安装位置: - **Windows:** `%USERPROFILE%\AppData\Local\UnityMCP\UnityMcpServer\src` - **macOS:** `~/Library/AppSupport/UnityMCP/UnityMcpServer\src` From 21db63a57cf9202fd284571d1af2bbd627fa2fd3 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 16:59:07 -0400 Subject: [PATCH 17/32] Update the Chinese translation of the dev docks --- docs/README-DEV-zh.md | 365 +++++++++++++++++++++++++++++------------- 1 file changed, 252 insertions(+), 113 deletions(-) diff --git a/docs/README-DEV-zh.md b/docs/README-DEV-zh.md index 6c549b4e9..cc839d40c 100644 --- a/docs/README-DEV-zh.md +++ b/docs/README-DEV-zh.md @@ -5,120 +5,233 @@ 欢迎来到 MCP for Unity 开发环境!此目录包含简化 MCP for Unity 核心开发的工具和实用程序。 -## 🚀 可用开发功能 +## 🛠️ 开发环境搭建 + +### 安装开发依赖 + +如果你想贡献代码或运行测试,需要使用 `uv` 安装开发依赖: + +```bash +# 进入 server 源码目录 +cd Server + +# 以 editable 模式安装,并包含 dev 依赖 +uv pip install -e ".[dev]" +``` + +这会安装: + +- **运行时依赖**:`httpx`, `fastmcp`, `mcp`, `pydantic`, `tomli` +- **开发依赖**:`pytest`, `pytest-asyncio` + +### 运行测试 + +```bash +# 在 server 目录下 +cd Server +uv run pytest tests/ -v +``` + +或者从仓库根目录执行: + +```bash +# 使用 server 目录中的 uv +cd Server && uv run pytest tests/ -v +``` + +只运行集成测试: + +```bash +uv run pytest tests/ -v -m integration +``` + +只运行单元测试: + +```bash +uv run pytest tests/ -v -m unit +``` + +## 🚀 可用的开发特性 ### ✅ 开发部署脚本 -用于 MCP for Unity 核心更改的快速部署和测试工具。 + +用于快速部署与测试 MCP for Unity 核心更改的工具。 + +**Development Mode Toggle**:内置 Unity 编辑器开发特性(现在作为 Advanced Setting 提供) + +**Hot Reload System**:无需重启 Unity 的实时更新(Roslyn Runtime_Compilation Custom Tools) + +**Plugin Development Kit**:用于创建 MCP for Unity 扩展的工具(Custom Tools) ### 🔄 即将推出 -- **开发模式切换**:内置 Unity 编辑器开发功能 -- **热重载系统**:无需重启 Unity 的实时代码更新 -- **插件开发工具包**:用于创建自定义 MCP for Unity 扩展的工具 -- **自动化测试套件**:用于贡献的综合测试框架 -- **调试面板**:高级调试和监控工具 + +- **自动化测试套件**:为贡献提供更完整的测试框架 +- **调试面板**:更高级的调试与监控工具 --- +## Advanced Settings(编辑器窗口) + +使用 MCP for Unity 编辑器窗口(Window > MCP for Unity),在 Settings 选项卡内打开 **Advanced Settings**,可以在开发期间覆盖工具路径、切换 server 源、并将本地包部署到项目中。 + +![Advanced Settings](./images/advanced-setting.png) + +- **UV/UVX Path Override**:当系统 PATH 解析不正确时,可在 UI 中指定 `uv`/`uvx` 可执行文件路径(例如使用自定义安装)。清空后会回退到自动发现。 +- **Server Source Override**:为 Python server(`uvx --from mcp-for-unity`)设置本地文件夹或 git URL。清空后使用默认打包版本。 +- **Dev Mode(强制全新安装 server)**:启用后,生成的 `uvx` 命令会在启动前添加 `--no-cache --refresh`。会更慢,但可避免在迭代 `Server/` 时误用旧缓存构建。 +- **Local Package Deployment**:选择本地 `MCPForUnity` 文件夹(必须包含 `Editor/` 与 `Runtime/`),点击 **Deploy to Project** 后会将其复制到当前已安装的 package 路径(来自 `Packages/manifest.json` / Package Manager)。会在 `Library/MCPForUnityDeployBackups` 下保存带时间戳的备份,点击 **Restore Last Backup** 可回滚最近一次部署。 + +提示: + +- 部署/回滚后,Unity 会自动刷新脚本;若不确定,可重新打开 MCP window 并在 Advanced Settings 里确认目标路径标签。 +- 保持 source 与 target 不要混用(不要把 source 指向已经安装的 `PackageCache` 文件夹)。 +- 推荐使用被 gitignore 的工作目录进行快速迭代;部署流程只会复制 `Editor` 与 `Runtime`。 + ## 快速切换 MCP 包源 -从 unity-mcp 仓库运行,而不是从游戏的根目录。使用 `mcp_source.py` 在不同的 MCP for Unity 包源之间快速切换: +从 unity-mcp 仓库运行,而不是从游戏的根目录。使用 `mcp_source.py` 可以在不同的 MCP for Unity 包源之间快速切换: + +**用法:** -**用法:** ```bash python mcp_source.py [--manifest /path/to/manifest.json] [--repo /path/to/unity-mcp] [--choice 1|2|3] ``` -**选项:** -- **1** 上游主分支 (CoplayDev/unity-mcp) -- **2** 远程当前分支 (origin + branch) -- **3** 本地工作区 (file: MCPForUnity) - -切换后,打开包管理器并刷新以重新解析包。 +**选项:** -## 多 Unity 实例路由 +- **1** 上游 main(CoplayDev/unity-mcp) +- **2** 远程当前分支(origin + branch) +- **3** 本地工作区(file: MCPForUnity) -- 使用资源 `mcpforunity://instances` 查看所有已连接实例,复制资源返回的精确 `Name@hash`。 -- 当存在多个实例时,在调用任何工具/资源前先用 `set_active_instance(Name@hash)` 选择目标。 -- 如果未选择且连接了多个实例,服务器会返回错误并要求你先选择。 +切换后,打开 Package Manager 并 Refresh 以重新解析依赖。 -## 开发部署脚本 +## Development Deployment Scripts -这些部署脚本帮助您快速测试 MCP for Unity 核心代码的更改。 +这些部署脚本帮助你快速测试 MCP for Unity 核心代码的更改。 -## 脚本 +## Scripts ### `deploy-dev.bat` -将您的开发代码部署到实际安装位置进行测试。 -**作用:** -1. 将原始文件备份到带时间戳的文件夹 -2. 将 Unity Bridge 代码复制到 Unity 的包缓存 -3. 将 Python 服务器代码复制到 MCP 安装文件夹 +将你的开发代码部署到实际安装位置以便测试。 + +**它会做什么:** + +1. 将原始文件备份到一个带时间戳的文件夹 +2. 将 Unity Bridge 代码复制到 Unity 的 package cache +3. 将 Python Server 代码复制到 MCP 安装目录 + +**用法:** -**用法:** 1. 运行 `deploy-dev.bat` -2. 输入 Unity 包缓存路径(提供示例) -3. 输入服务器路径(或使用默认:`%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`) +2. 输入 Unity package cache 路径(脚本会给出示例) +3. 输入 server 路径(或使用默认:`%LOCALAPPDATA%\Programs\UnityMCP\UnityMcpServer\src`) 4. 输入备份位置(或使用默认:`%USERPROFILE%\Desktop\unity-mcp-backup`) -**注意:** 开发部署跳过 `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`;减少变动并避免复制虚拟环境。 +**注意:** Dev deploy 会跳过 `.venv`, `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.git`;减少变动并避免复制虚拟环境。 ### `restore-dev.bat` + 从备份恢复原始文件。 -**作用:** -1. 列出可用的带时间戳的备份 -2. 允许您选择要恢复的备份 -3. 恢复 Unity Bridge 和 Python 服务器文件 +**它会做什么:** + +1. 列出所有带时间戳的备份 +2. 让你选择要恢复的备份 +3. 同时恢复 Unity Bridge 与 Python Server 文件 ### `prune_tool_results.py` -将对话 JSON 中的大型 `tool_result` 块压缩为简洁的单行摘要。 -**用法:** +将对话 JSON 中体积很大的 `tool_result` 内容压缩为简洁的一行摘要。 + +**用法:** + ```bash python3 prune_tool_results.py < reports/claude-execution-output.json > reports/claude-execution-output.pruned.json ``` -脚本从 `stdin` 读取对话并将修剪版本写入 `stdout`,使日志更容易检查或存档。 +脚本从 `stdin` 读取对话并将裁剪版本输出到 `stdout`,使日志更容易检查或存档。 -这些默认设置在不影响基本信息的情况下大幅减少了令牌使用量。 +这些默认策略可以显著降低 token 使用量,同时保留关键的信息。 -## 查找 Unity 包缓存路径 +## 查找 Unity Package Cache 路径 + +Unity 会把 Git 包存储在一个“版本号或哈希”的文件夹下,例如: -Unity 将 Git 包存储在版本或哈希文件夹下。期望类似于: ``` X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@ ``` + 示例(哈希): + ``` X:\UnityProject\Library\PackageCache\com.coplaydev.unity-mcp@272123cfd97e ``` -可靠找到它: -1. 打开 Unity 包管理器 -2. 选择"MCP for Unity"包 -3. 右键单击包并选择"在资源管理器中显示" -4. 这将打开 Unity 为您的项目使用的确切缓存文件夹 +可靠的查找方式: + +1. 打开 Unity Package Manager +2. 选择 “MCP for Unity” package +3. 右键 package 并选择 “Show in Explorer” +4. Unity 会打开该项目实际使用的 cache 文件夹 + +注意:在近期版本中,Python server 的源码也会打包在该 package 内的 `Server` 目录下。这对本地测试或让 MCP client 直接指向打包 server 很有用。 -注意:在最新版本中,Python 服务器源代码也打包在包内的 `Server` 下。这对于本地测试或将 MCP 客户端直接指向打包服务器很方便。 +## Payload 大小与分页默认值(推荐) + +某些 Unity 工具调用可能返回*非常大的* JSON payload(例如深层级场景、带完整序列化属性的组件)。为避免 MCP 响应过大、以及 Unity 卡死/崩溃,建议优先使用 **分页 + 先摘要后细节** 的读法,仅在需要时再拉取完整属性。 + +### `manage_scene(action="get_hierarchy")` + +- **默认行为**:返回根 GameObject(无 `parent`)或指定 `parent` 的直接子节点的 **分页摘要**。不会内联完整递归子树。 +- **分页参数**: + - **`page_size`**:默认 **50**,限制 **1..500** + - **`cursor`**:默认 **0** + - **`next_cursor`**:当还有更多结果时返回 **字符串**;完成时为 `null` +- **其他安全阀**: + - **`max_nodes`**:默认 **1000**,限制 **1..5000** + - **`include_transform`**:默认 **false** + +### `manage_gameobject(action="get_components")` + +- **默认行为**:仅返回 **分页的组件元数据**(`typeName`, `instanceID`)。 +- **分页参数**: + - **`page_size`**:默认 **25**,限制 **1..200** + - **`cursor`**:默认 **0** + - **`max_components`**:默认 **50**,限制 **1..500** + - **`next_cursor`**:当还有更多结果时返回 **字符串**;完成时为 `null` +- **按需读取属性**: + - **`include_properties`** 默认 **false** + - 当 `include_properties=true` 时,会启用保守的响应大小预算(约 **~250KB** JSON 文本),返回条数可能少于 `page_size`;使用 `next_cursor` 继续。 + +### 实用默认值(我们在 prompts/tests 中的推荐) + +- **Hierarchy roots**:从 `page_size=50` 开始,根据 `next_cursor` 继续(大场景通常 1–2 次调用)。 +- **Children**:按 `parent` 分页,`page_size=10..50`(根据预期广度)。 +- **Components**: + - 先用 `include_properties=false` 且 `page_size=10..25` + - 需要完整属性时,用 `include_properties=true` 且保持较小 `page_size`(例如 **3..10**)来控制峰值 payload。 ## MCP Bridge 压力测试 -按需压力测试实用程序通过多个并发客户端测试 MCP bridge,同时通过立即脚本编辑触发真实脚本重载(无需菜单调用)。 +一个按需的压力测试工具会用多个并发客户端测试 MCP bridge,同时通过“立即脚本编辑”触发真实的脚本 reload(无需菜单调用)。 ### 脚本 + - `tools/stress_mcp.py` -### 作用 +### 它做什么 + - 对 MCP for Unity bridge 启动 N 个 TCP 客户端(默认端口从 `~/.unity-mcp/unity-mcp-status-*.json` 自动发现)。 -- 发送轻量级帧 `ping` 保活以维持并发。 -- 并行地,使用 `manage_script.apply_text_edits` 向目标 C# 文件追加唯一标记注释: - - `options.refresh = "immediate"` 立即强制导入/编译(触发域重载),以及 - - 从当前文件内容计算的 `precondition_sha256` 以避免漂移。 -- 使用 EOF 插入避免头部/`using` 保护编辑。 +- 发送轻量 framed `ping` 维持并发。 +- 同时,使用 `manage_script.apply_text_edits` 对目标 C# 文件在 EOF 追加唯一标记注释,并设置: + - `options.refresh = "immediate"` 来立即触发 import/compile(会引发 domain reload),以及 + - 从当前文件内容计算 `precondition_sha256` 来避免漂移。 +- 使用 EOF 插入避免头部/`using` guard 的编辑。 ### 用法(本地) + ```bash # 推荐:使用测试项目中包含的大型脚本 python3 tools/stress_mcp.py \ @@ -127,95 +240,121 @@ python3 tools/stress_mcp.py \ --unity-file "TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs" ``` -标志: -- `--project` Unity 项目路径(默认自动检测到包含的测试项目) +### Flags + +- `--project` Unity 项目路径(默认自动检测到仓库自带的测试项目) - `--unity-file` 要编辑的 C# 文件(默认为长测试脚本) - `--clients` 并发客户端数量(默认 10) - `--duration` 运行秒数(默认 60) ### 预期结果 -- 重载过程中 Unity 编辑器不崩溃 -- 每次应用编辑后立即重载(无 `Assets/Refresh` 菜单调用) -- 域重载期间可能发生一些暂时断开连接或少数失败调用;工具会重试并继续 -- 最后打印 JSON 摘要,例如: + +- Unity Editor 在 reload churn 下不崩溃 +- 每次应用编辑后立即 reload(无需 `Assets/Refresh` 菜单调用) +- 在 domain reload 期间可能会有少量短暂断线或失败调用;工具会重试并继续 +- 最后输出 JSON 汇总,例如: - `{"port": 6400, "stats": {"pings": 28566, "applies": 69, "disconnects": 0, "errors": 0}}` -### 注意事项和故障排除 -- 立即 vs 防抖: - - 工具设置 `options.refresh = "immediate"` 使更改立即编译。如果您只需要变动(不需要每次编辑确认),切换到防抖以减少重载中失败。 -- 需要前置条件: - - `apply_text_edits` 在较大文件上需要 `precondition_sha256`。工具首先读取文件以计算 SHA。 +### 说明与排障 + +- Immediate vs debounced: + - 工具设置 `options.refresh = "immediate"` 让每次改动都立刻编译。如果你只想测试 churn(不关心每次确认),可以改成 debounced 来减少中途失败。 +- 需要 precondition: + - 对较大文件,`apply_text_edits` 需要 `precondition_sha256`。工具会先读文件计算 SHA。 - 编辑位置: - - 为避免头部保护或复杂范围,工具在每个周期的 EOF 处追加单行标记。 -- 读取 API: - - bridge 当前支持 `manage_script.read` 进行文件读取。您可能看到弃用警告;对于此内部工具无害。 -- 暂时失败: - - 偶尔的 `apply_errors` 通常表示连接在回复过程中重载。编辑通常仍会应用;循环在下次迭代时继续。 + - 为避免头部 guards 或复杂范围,工具每轮都在 EOF 追加一行 marker。 +- Read API: + - bridge 当前支持 `manage_script.read` 用于读文件。可能会看到弃用提示;对该内部工具无影响。 +- 瞬时失败: + - 偶尔出现 `apply_errors` 往往意味着连接在回包时发生 reload。通常编辑仍然已应用;循环会继续下一轮。 ### CI 指导 -- 由于 Unity/编辑器要求和运行时变化,将此排除在默认 PR CI 之外。 -- 可选择在具有 Unity 功能的运行器上作为手动工作流或夜间作业运行。 + +- 由于 Unity/editor 依赖与运行时波动,不建议把它放进默认 PR CI。 +- 可选择作为手动 workflow 或 nightly job 在支持 Unity 的 runner 上运行。 ## CI 测试工作流(GitHub Actions) -我们提供 CI 作业来对 Unity 测试项目运行自然语言编辑套件。它启动无头 Unity 容器并通过 MCP bridge 连接。要从您的 fork 运行,您需要以下 GitHub "secrets":`ANTHROPIC_API_KEY` 和 Unity 凭据(通常是 `UNITY_EMAIL` + `UNITY_PASSWORD` 或 `UNITY_LICENSE` / `UNITY_SERIAL`)。这些在日志中被编辑所以永远不可见。 +我们提供 CI 作业来对 Unity 测试项目运行自然语言编辑套件:它会启动 headless Unity 容器并通过 MCP bridge 连接。要在 fork 上运行,你需要以下 GitHub Secrets:`ANTHROPIC_API_KEY` 以及 Unity 凭据(通常为 `UNITY_EMAIL` + `UNITY_PASSWORD` 或 `UNITY_LICENSE` / `UNITY_SERIAL`)。这些会在日志中被脱敏,因此不会泄露。 + +***如何运行*** -***运行方法*** - - 触发:在仓库的 GitHub "Actions" 中,触发 `workflow dispatch`(`Claude NL/T Full Suite (Unity live)`)。 - - 镜像:`UNITY_IMAGE`(UnityCI)按标签拉取;作业在运行时解析摘要。日志已清理。 - - 执行:单次通过,立即按测试片段发射(严格的每个文件单个 ``)。如果任何片段是裸 ID,占位符保护会快速失败。暂存(`reports/_staging`)被提升到 `reports/` 以减少部分写入。 - - 报告:JUnit 在 `reports/junit-nl-suite.xml`,Markdown 在 `reports/junit-nl-suite.md`。 - - 发布:JUnit 规范化为 `reports/junit-for-actions.xml` 并发布;工件上传 `reports/` 下的所有文件。 +- 触发:在 GitHub Actions 中手动触发 `workflow dispatch`(`Claude NL/T Full Suite (Unity live)`)。 +- 镜像:`UNITY_IMAGE`(UnityCI)使用 tag 拉取;作业会在运行时解析 digest。日志会被清理。 +- 执行:单次执行,每个测试生成一个片段(严格:每个文件只允许一个 ``)。若任何片段只是裸 ID,会被占位符 guard 快速判失败。暂存目录(`reports/_staging`)会被提升到 `reports/` 以减少部分写入。 +- 报告:JUnit 输出到 `reports/junit-nl-suite.xml`,Markdown 输出到 `reports/junit-nl-suite.md`。 +- 发布:JUnit 会被规范化为 `reports/junit-for-actions.xml` 并发布;Artifacts 会上传 `reports/` 下的全部文件。 ### 测试目标脚本 -- 仓库包含一个长的独立 C# 脚本,用于练习较大的编辑和窗口: + +- 仓库包含一个很长且独立的 C# 脚本,用于验证大文件编辑与窗口读取: - `TestProjects/UnityMCPTests/Assets/Scripts/LongUnityScriptClaudeTest.cs` - 在本地和 CI 中使用此文件来验证多编辑批次、锚插入和大型脚本上的窗口读取。 + 本地与 CI 都建议用它来测试多编辑批次、anchor insert、windowed read 等。 + +### 调整 tests / prompts -### 调整测试/提示 -- 编辑 `.claude/prompts/nl-unity-suite-t.md` 来修改 NL/T 步骤。遵循约定:在 `reports/_results.xml` 下为每个测试发射一个 XML 片段,每个包含恰好一个以测试 ID 开头的 `name` 的 ``。无序言/尾声或代码围栏。 -- 保持编辑最小且可逆;包含简洁证据。 +- 修改 `.claude/prompts/nl-unity-suite-t.md` 来调整 NL/T 步骤。遵循约定:每个测试在 `reports/_results.xml` 下生成一个 XML 片段,且每个片段恰好包含一个 ``,其 `name` 必须以 test ID 开头。不要包含 prologue/epilogue 或代码围栏。 +- 保持改动最小、可回滚,并给出简洁证据。 ### 运行套件 -1) 推送您的分支,然后从 Actions 标签手动运行工作流。 -2) 作业将报告写入 `reports/` 并上传工件。 -3) "JUnit Test Report" 检查总结结果;打开作业摘要查看完整 markdown。 + +1) 推送你的分支,然后在 Actions 标签页手动运行 workflow。 +2) 作业把 reports 写入 `reports/` 并上传 artifacts。 +3) “JUnit Test Report” check 会汇总结果;打开 Job Summary 查看完整 Markdown。 ### 查看结果 -- 作业摘要:GitHub Actions 标签中运行的内联 markdown 摘要 -- 检查:PR/提交上的"JUnit Test Report"。 -- 工件:`claude-nl-suite-artifacts` 包含 XML 和 MD。 + +- Job Summary:Actions 中的内联 Markdown 汇总 +- Check:“JUnit Test Report” +- Artifacts:`claude-nl-suite-artifacts`,包含 XML 与 MD ### MCP 连接调试 -- *在 MCP for Unity 窗口(编辑器内)启用调试日志* 以查看连接状态、自动设置结果和 MCP 客户端路径。它显示: - - bridge 启动/端口、客户端连接、严格帧协商和解析的帧 - - 自动配置路径检测(Windows/macOS/Linux)、uv/claude 解析和显示的错误 -- 在 CI 中,如果启动失败,作业会尾随 Unity 日志(序列号/许可证/密码/令牌已编辑)并打印套接字/状态 JSON 诊断。 -## 工作流程 +- 在 MCP for Unity 窗口(Editor 内)*启用 debug logs*,可以看到连接状态、auto-setup 结果与 MCP client 路径,包括: + - bridge 启动/端口、client 连接、strict framing 协商、解析后的 frame + - auto-config 路径检测(Windows/macOS/Linux)、uv/claude 解析与错误提示 +- CI 中如启动失败,作业会 tail Unity 日志(serial/license/password/token 已脱敏),并打印 socket/status JSON 诊断。 + +## Workflow -1. **进行更改** 到此目录中的源代码 -2. **部署** 使用 `deploy-dev.bat` -3. **测试** 在 Unity 中(首先重启 Unity 编辑器) -4. **迭代** - 根据需要重复步骤 1-3 -5. **恢复** 完成后使用 `restore-dev.bat` 恢复原始文件 +1. **修改** 此目录中的源码 +2. **Deploy** 使用 `deploy-dev.bat` +3. **在 Unity 中测试**(先重启 Unity Editor) +4. **迭代** - 按需重复 1-3 +5. **Restore** 完成后用 `restore-dev.bat` 恢复原始文件 -## 故障排除 +## Troubleshooting -### 运行 .bat 文件时出现"路径未找到"错误 -- 验证 Unity 包缓存路径是否正确 -- 检查是否实际安装了 MCP for Unity 包 -- 确保通过 MCP 客户端安装了服务器 +### 运行 .bat 时出现 "Path not found" -### "权限被拒绝"错误 -- 以管理员身份运行 cmd -- 部署前关闭 Unity 编辑器 -- 部署前关闭任何 MCP 客户端 +- 确认 Unity package cache 路径正确 +- 确认 MCP for Unity package 已安装 +- 确认 server 已通过 MCP client 安装 -### "备份未找到"错误 -- 首先运行 `deploy-dev.bat` 创建初始备份 +### 出现 "Permission denied" + +- 用管理员权限运行 cmd +- 部署前关闭 Unity Editor +- 部署前关闭所有 MCP client + +### 出现 "Backup not found" + +- 先运行 `deploy-dev.bat` 生成初始备份 - 检查备份目录权限 -- 验证备份目录路径是否正确 +- 确认备份路径正确 ### Windows uv 路径问题 -- 在 Windows 上测试 GUI 客户端时,优先选择 WinGet Links `uv.exe`;如果存在多个 `uv.exe`,使用"Choose `uv` Install Location"来固定 Links shim。 + +- 在 Windows 上测试 GUI client 时,优先使用 WinGet Links 下的 `uv.exe`;若存在多个 `uv.exe`,可用 “Choose `uv` Install Location” 固定 Links shim。 + +### Unity 退到后台时 Domain Reload Tests 卡住 + +在测试过程中触发脚本编译(例如 `DomainReloadResilienceTests`)时,如果 Unity 不是前台窗口,测试可能会卡住。这是操作系统层面的限制——macOS 会限制后台应用的主线程,从而阻止编译完成。 + +**Workarounds:** + +- 运行 domain reload tests 时保持 Unity 在前台 +- 在测试套件最开始运行它们(在 Unity 被切到后台之前) +- 使用 `[Explicit]` 属性将其从默认运行中排除 + +**注意:** MCP workflow 本身不受影响——socket 消息会给 Unity 提供外部刺激,使其即使在后台也保持响应。该限制主要影响 Unity 内部测试协程的等待。 From 7e38e319f6c14a62bf355635711f5d37d7221e1e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:04:47 -0400 Subject: [PATCH 18/32] Change menu item from Setup Window to Local Setup Window We now differentiate whether it's HTTP local or remote --- MCPForUnity/Editor/Dependencies/DependencyManager.cs | 2 +- MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs | 2 +- tools/prepare_unity_asset_store_release.py | 7 ------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs b/MCPForUnity/Editor/Dependencies/DependencyManager.cs index 3f7b15455..c3802c475 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs @@ -136,7 +136,7 @@ private static void GenerateRecommendations(DependencyCheckResult result, IPlatf if (result.GetMissingRequired().Count > 0) { - result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Setup Window) for guided installation."); + result.RecommendedActions.Add("Use the Setup Window (Window > MCP for Unity > Local Setup Window) for guided installation."); } } } diff --git a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs index 14a6e7b52..c280d9559 100644 --- a/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs +++ b/MCPForUnity/Editor/MenuItems/MCPForUnityMenu.cs @@ -20,7 +20,7 @@ public static void ToggleMCPWindow() } } - [MenuItem("Window/MCP For Unity/Setup Window", priority = 2)] + [MenuItem("Window/MCP For Unity/Local Setup Window", priority = 2)] public static void ShowSetupWindow() { SetupWindowService.ShowSetupWindow(); diff --git a/tools/prepare_unity_asset_store_release.py b/tools/prepare_unity_asset_store_release.py index 577201ca2..b75b2b025 100644 --- a/tools/prepare_unity_asset_store_release.py +++ b/tools/prepare_unity_asset_store_release.py @@ -125,13 +125,6 @@ def main() -> int: # Remove auto-popup setup window for Asset Store packaging remove_line_exact(setup_service, "[InitializeOnLoad]") - # Make the menu entry explicitly local-only - replace_once( - menu_file, - r'(\[MenuItem\()("Window/MCP For Unity/Setup Window")', - r'\1"Window/MCP For Unity/Local Setup Window"', - ) - # Set default base URL to the hosted endpoint replace_once( http_util, From e155470cf552bd31bb7fb8f1babf6e8bc5e906f2 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:07:25 -0400 Subject: [PATCH 19/32] Fix URIs for menu items and tests --- Server/src/services/registry/resource_registry.py | 2 +- Server/src/services/resources/menu_items.py | 2 +- Server/src/services/resources/tests.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Server/src/services/registry/resource_registry.py b/Server/src/services/registry/resource_registry.py index 661e1e5c6..5c8e42607 100644 --- a/Server/src/services/registry/resource_registry.py +++ b/Server/src/services/registry/resource_registry.py @@ -24,7 +24,7 @@ def mcp_for_unity_resource( **kwargs: Additional arguments passed to @mcp.resource() Example: - @mcp_for_unity_resource("mcpformcpforunity://resource", description="Gets something interesting") + @mcp_for_unity_resource("mcpforunity://resource", description="Gets something interesting") async def my_custom_resource(ctx: Context, ...): pass """ diff --git a/Server/src/services/resources/menu_items.py b/Server/src/services/resources/menu_items.py index eb5c48c5c..2ac820756 100644 --- a/Server/src/services/resources/menu_items.py +++ b/Server/src/services/resources/menu_items.py @@ -12,7 +12,7 @@ class GetMenuItemsResponse(MCPResponse): @mcp_for_unity_resource( - uri="mcpformcpforunity://menu-items", + uri="mcpforunity://menu-items", name="menu_items", description="Provides a list of all menu items." ) diff --git a/Server/src/services/resources/tests.py b/Server/src/services/resources/tests.py index 75ec78ad7..d0a71c237 100644 --- a/Server/src/services/resources/tests.py +++ b/Server/src/services/resources/tests.py @@ -21,7 +21,7 @@ class GetTestsResponse(MCPResponse): data: list[TestItem] = [] -@mcp_for_unity_resource(uri="mcpformcpforunity://tests", name="get_tests", description="Provides a list of all tests.") +@mcp_for_unity_resource(uri="mcpforunity://tests", name="get_tests", description="Provides a list of all tests.") async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse: """Provides a list of all tests. """ @@ -35,7 +35,7 @@ async def get_tests(ctx: Context) -> GetTestsResponse | MCPResponse: return GetTestsResponse(**response) if isinstance(response, dict) else response -@mcp_for_unity_resource(uri="mcpformcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.") +@mcp_for_unity_resource(uri="mcpforunity://tests/{mode}", name="get_tests_for_mode", description="Provides a list of tests for a specific mode.") async def get_tests_for_mode( ctx: Context, mode: Annotated[Literal["EditMode", "PlayMode"], Field(description="The mode to filter tests by.")], From e03df44ded9c50fc364d9185bca68599f79859f5 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:16:23 -0400 Subject: [PATCH 20/32] Shouldn't have removed it --- Server/src/transport/plugin_hub.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Server/src/transport/plugin_hub.py b/Server/src/transport/plugin_hub.py index 189010bc3..aa158de87 100644 --- a/Server/src/transport/plugin_hub.py +++ b/Server/src/transport/plugin_hub.py @@ -45,6 +45,9 @@ class PluginHub(WebSocketEndpoint): KEEP_ALIVE_INTERVAL = 15 SERVER_TIMEOUT = 30 COMMAND_TIMEOUT = 30 + # Timeout (seconds) for fast-fail commands like ping/read_console/get_editor_state. + # Keep short so MCP clients aren't blocked during Unity compilation/reload/unfocused throttling. + FAST_FAIL_TIMEOUT = 2.0 # Fast-path commands should never block the client for long; return a retry hint instead. # This helps avoid the Cursor-side ~30s tool-call timeout when Unity is compiling/reloading # or is throttled while unfocused. From f12adda2aed7806c14943f205cf190514d47e076 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:33:33 -0400 Subject: [PATCH 21/32] Minor edits from CodeRabbit feedback --- MCPForUnity/Editor/Services/EditorStateCache.cs | 8 ++++++++ .../Editor/Tools/GameObjects/GameObjectDuplicate.cs | 2 +- MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs | 5 ++++- MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs | 1 + MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs | 1 - 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs index bc014f87f..9f693a4a9 100644 --- a/MCPForUnity/Editor/Services/EditorStateCache.cs +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -164,6 +164,12 @@ private sealed class EditorStateV2Assets [JsonProperty("external_changes_last_seen_unix_ms")] public long? ExternalChangesLastSeenUnixMs { get; set; } + [JsonProperty("external_changes_dirty_since_unix_ms")] + public long? ExternalChangesDirtySinceUnixMs { get; set; } + + [JsonProperty("external_changes_last_cleared_unix_ms")] + public long? ExternalChangesLastClearedUnixMs { get; set; } + [JsonProperty("refresh")] public EditorStateV2Refresh Refresh { get; set; } } @@ -374,6 +380,8 @@ private static JObject BuildSnapshot(string reason) IsUpdating = EditorApplication.isUpdating, ExternalChangesDirty = false, ExternalChangesLastSeenUnixMs = null, + ExternalChangesDirtySinceUnixMs = null, + ExternalChangesLastClearedUnixMs = null, Refresh = new EditorStateV2Refresh { IsRefreshInProgress = false, diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs index 39c5737e3..6faeb2ad4 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDuplicate.cs @@ -58,7 +58,7 @@ internal static object Handle(JObject @params, JToken targetToken, string search } else { - McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Keeping original parent."); + McpLog.Warn($"[ManageGameObject.Duplicate] Parent '{parentToken}' not found. Object will remain at root level."); } } } diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index fab9ed7d2..1e0f71f00 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -210,7 +210,10 @@ internal static object Handle(JObject @params, JToken targetToken, string search } } } - catch { } + catch (Exception ex) + { + McpLog.Warn($"[GameObjectModify] Error aggregating component errors: {ex.Message}"); + } } return new ErrorResponse( diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs index cab4dca84..74753d4ce 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs @@ -51,6 +51,7 @@ public static object Control(JObject @params, string action) case "pause": ps.Pause(withChildren); break; case "restart": ps.Stop(withChildren, ParticleSystemStopBehavior.StopEmittingAndClear); ps.Play(withChildren); break; case "clear": ps.Clear(withChildren); break; + default: return new { success = false, message = $"Unknown action: {action}" }; } return new { success = true, message = $"ParticleSystem {action}" }; diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs index 0ec7b4191..19629c870 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleWrite.cs @@ -4,7 +4,6 @@ using UnityEditor; using UnityEngine; using MCPForUnity.Editor.Helpers; -using MCPForUnity.Editor.Tools.GameObjects; namespace MCPForUnity.Editor.Tools.Vfx { From 39c6234e3bf42cc8613bc58731592148a44d1d42 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:35:16 -0400 Subject: [PATCH 22/32] Don't use reflection which takes longer --- MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs index d4516166e..b48a1b7b5 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectDelete.cs @@ -34,7 +34,7 @@ internal static object Handle(JToken targetToken, string searchMethod) { string message = targets.Count == 1 - ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." + ? $"GameObject '{((dynamic)deletedObjects[0]).name}' deleted successfully." : $"{deletedObjects.Count} GameObjects deleted successfully."; return new SuccessResponse(message, deletedObjects); } From 580af95312be35d484eaf727184148324eb54f8e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:35:40 -0400 Subject: [PATCH 23/32] Fix failing python tests --- Server/src/services/tools/refresh_unity.py | 6 +++--- Server/src/services/tools/run_tests.py | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Server/src/services/tools/refresh_unity.py b/Server/src/services/tools/refresh_unity.py index 39a01239a..0f80a3f9e 100644 --- a/Server/src/services/tools/refresh_unity.py +++ b/Server/src/services/tools/refresh_unity.py @@ -13,7 +13,7 @@ import transport.unity_transport as unity_transport from transport.legacy.unity_connection import async_send_command_with_retry, _extract_response_reason from services.state.external_changes_scanner import external_changes_scanner -from services.resources.editor_state import get_editor_state, _infer_single_instance_id +import services.resources.editor_state as editor_state @mcp_for_unity_tool( @@ -71,7 +71,7 @@ async def refresh_unity( start = time.monotonic() while time.monotonic() - start < timeout_s: - state_resp = await get_editor_state(ctx) + state_resp = await editor_state.get_editor_state(ctx) state = state_resp.model_dump() if hasattr( state_resp, "model_dump") else state_resp data = (state or {}).get("data") if isinstance( @@ -84,7 +84,7 @@ async def refresh_unity( # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly. try: - inst = unity_instance or await _infer_single_instance_id(ctx) + inst = unity_instance or await editor_state._infer_single_instance_id(ctx) if inst: external_changes_scanner.clear_dirty(inst) except Exception: diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index bd498432b..818cb8d3a 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -43,7 +43,7 @@ class RunTestsResult(BaseModel): class RunTestsStartData(BaseModel): job_id: str status: str - mode: str + mode: str | None = None include_details: bool | None = None include_failed_tests: bool | None = None @@ -151,7 +151,10 @@ def _coerce_string_list(value) -> list[str] | None: if isinstance(response, dict): if not response.get("success", True): return MCPResponse(**response) - return RunTestsStartResponse(**response) + # Most tools in this codebase return raw dict payloads; keep consistency for callers/tests. + # We still validate that the payload matches expected shape (best-effort) but return dict. + RunTestsStartResponse(**response) + return response return MCPResponse(success=False, error=str(response)) @@ -187,5 +190,6 @@ async def get_test_job( if isinstance(response, dict): if not response.get("success", True): return MCPResponse(**response) - return GetTestJobResponse(**response) + GetTestJobResponse(**response) + return response return MCPResponse(success=False, error=str(response)) From 9122e1e585f0fd20034c626bde8e84835853eb1e Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:37:36 -0400 Subject: [PATCH 24/32] Add serialization helpers for ParticleSystem curves and MinMaxCurve types Added SerializeAnimationCurve and SerializeMinMaxCurve helper methods to properly serialize Unity's curve types. Updated GetInfo to use these helpers for startLifetime, startSpeed, startSize, gravityModifier, and rateOverTime instead of only reading constant values. --- MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs | 88 ++++++++++++++++++-- 1 file changed, 83 insertions(+), 5 deletions(-) diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs index 46506dbe9..65afcd4c0 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleRead.cs @@ -6,6 +6,84 @@ namespace MCPForUnity.Editor.Tools.Vfx { internal static class ParticleRead { + private static object SerializeAnimationCurve(AnimationCurve curve) + { + if (curve == null) + { + return null; + } + + return new + { + keys = curve.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray() + }; + } + + private static object SerializeMinMaxCurve(ParticleSystem.MinMaxCurve curve) + { + switch (curve.mode) + { + case ParticleSystemCurveMode.Constant: + return new + { + mode = "constant", + value = curve.constant + }; + + case ParticleSystemCurveMode.TwoConstants: + return new + { + mode = "two_constants", + min = curve.constantMin, + max = curve.constantMax + }; + + case ParticleSystemCurveMode.Curve: + return new + { + mode = "curve", + multiplier = curve.curveMultiplier, + keys = curve.curve.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray() + }; + + case ParticleSystemCurveMode.TwoCurves: + return new + { + mode = "curve", + multiplier = curve.curveMultiplier, + keys = curve.curveMax.keys.Select(k => new + { + time = k.time, + value = k.value, + inTangent = k.inTangent, + outTangent = k.outTangent + }).ToArray(), + originalMode = "two_curves", + curveMin = SerializeAnimationCurve(curve.curveMin), + curveMax = SerializeAnimationCurve(curve.curveMax) + }; + + default: + return new + { + mode = "constant", + value = curve.constant + }; + } + } + public static object GetInfo(JObject @params) { ParticleSystem ps = ParticleCommon.FindParticleSystem(@params); @@ -32,17 +110,17 @@ public static object GetInfo(JObject @params) { duration = main.duration, looping = main.loop, - startLifetime = main.startLifetime.constant, - startSpeed = main.startSpeed.constant, - startSize = main.startSize.constant, - gravityModifier = main.gravityModifier.constant, + startLifetime = SerializeMinMaxCurve(main.startLifetime), + startSpeed = SerializeMinMaxCurve(main.startSpeed), + startSize = SerializeMinMaxCurve(main.startSize), + gravityModifier = SerializeMinMaxCurve(main.gravityModifier), simulationSpace = main.simulationSpace.ToString(), maxParticles = main.maxParticles }, emission = new { enabled = emission.enabled, - rateOverTime = emission.rateOverTime.constant, + rateOverTime = SerializeMinMaxCurve(emission.rateOverTime), burstCount = emission.burstCount }, shape = new From 98a261ad620ceee315a5ff68ad17478c58cef51b Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:41:41 -0400 Subject: [PATCH 25/32] Use ctx param --- Server/src/services/resources/editor_state.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index b4c0acab1..832be0c49 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -130,6 +130,7 @@ async def _infer_single_instance_id(ctx: Context) -> str | None: Best-effort: if exactly one Unity instance is connected, return its Name@hash id. This makes editor_state outputs self-describing even when no explicit active instance is set. """ + ctx.info("If exactly one Unity instance is connected, return its Name@hash id.") if _in_pytest(): return None From 93a6434249b16dbf0b8f5eaf6f2ac598dfaaa7fd Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:47:31 -0400 Subject: [PATCH 26/32] Update Server/src/services/tools/run_tests.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Server/src/services/tools/run_tests.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 818cb8d3a..f5964be9f 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -151,10 +151,7 @@ def _coerce_string_list(value) -> list[str] | None: if isinstance(response, dict): if not response.get("success", True): return MCPResponse(**response) - # Most tools in this codebase return raw dict payloads; keep consistency for callers/tests. - # We still validate that the payload matches expected shape (best-effort) but return dict. - RunTestsStartResponse(**response) - return response + return RunTestsStartResponse(**response) return MCPResponse(success=False, error=str(response)) From 89ea989fc52b1215837589e6391b9b0b5142e327 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:50:21 -0400 Subject: [PATCH 27/32] Minor fixes --- MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs | 2 +- MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs index 1e0f71f00..b995bbc2d 100644 --- a/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs +++ b/MCPForUnity/Editor/Tools/GameObjects/GameObjectModify.cs @@ -95,7 +95,7 @@ internal static object Handle(JObject @params, JToken targetToken, string search if (!string.IsNullOrEmpty(layerName)) { int layerId = LayerMask.NameToLayer(layerName); - if (layerId == -1 && layerName != "Default") + if (layerId == -1) { return new ErrorResponse($"Invalid layer specified: '{layerName}'. Use a valid layer name."); } diff --git a/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs index 74753d4ce..583fefc15 100644 --- a/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs +++ b/MCPForUnity/Editor/Tools/Vfx/ParticleControl.cs @@ -66,8 +66,10 @@ public static object AddBurst(JObject @params) var emission = ps.emission; float time = @params["time"]?.ToObject() ?? 0f; - short minCount = (short)(@params["minCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); - short maxCount = (short)(@params["maxCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30); + int minCountRaw = @params["minCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30; + int maxCountRaw = @params["maxCount"]?.ToObject() ?? @params["count"]?.ToObject() ?? 30; + short minCount = (short)Math.Clamp(minCountRaw, 0, short.MaxValue); + short maxCount = (short)Math.Clamp(maxCountRaw, 0, short.MaxValue); int cycles = @params["cycles"]?.ToObject() ?? 1; float interval = @params["interval"]?.ToObject() ?? 0.01f; From 0de9d2cd39dc8f3bf1ad461d89941ee52ea0b466 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:51:11 -0400 Subject: [PATCH 28/32] Rename anything EditorStateV2 to just EditorState It's the default, there's no old version --- .../Editor/Services/EditorStateCache.cs | 70 +++++++++---------- Server/src/services/resources/editor_state.py | 60 ++++++++-------- 2 files changed, 65 insertions(+), 65 deletions(-) diff --git a/MCPForUnity/Editor/Services/EditorStateCache.cs b/MCPForUnity/Editor/Services/EditorStateCache.cs index 9f693a4a9..c29c769c9 100644 --- a/MCPForUnity/Editor/Services/EditorStateCache.cs +++ b/MCPForUnity/Editor/Services/EditorStateCache.cs @@ -33,7 +33,7 @@ internal static class EditorStateCache private static JObject _cached; - private sealed class EditorStateV2Snapshot + private sealed class EditorStateSnapshot { [JsonProperty("schema_version")] public string SchemaVersion { get; set; } @@ -45,28 +45,28 @@ private sealed class EditorStateV2Snapshot public long Sequence { get; set; } [JsonProperty("unity")] - public EditorStateV2Unity Unity { get; set; } + public EditorStateUnity Unity { get; set; } [JsonProperty("editor")] - public EditorStateV2Editor Editor { get; set; } + public EditorStateEditor Editor { get; set; } [JsonProperty("activity")] - public EditorStateV2Activity Activity { get; set; } + public EditorStateActivity Activity { get; set; } [JsonProperty("compilation")] - public EditorStateV2Compilation Compilation { get; set; } + public EditorStateCompilation Compilation { get; set; } [JsonProperty("assets")] - public EditorStateV2Assets Assets { get; set; } + public EditorStateAssets Assets { get; set; } [JsonProperty("tests")] - public EditorStateV2Tests Tests { get; set; } + public EditorStateTests Tests { get; set; } [JsonProperty("transport")] - public EditorStateV2Transport Transport { get; set; } + public EditorStateTransport Transport { get; set; } } - private sealed class EditorStateV2Unity + private sealed class EditorStateUnity { [JsonProperty("instance_id")] public string InstanceId { get; set; } @@ -84,19 +84,19 @@ private sealed class EditorStateV2Unity public bool? IsBatchMode { get; set; } } - private sealed class EditorStateV2Editor + private sealed class EditorStateEditor { [JsonProperty("is_focused")] public bool? IsFocused { get; set; } [JsonProperty("play_mode")] - public EditorStateV2PlayMode PlayMode { get; set; } + public EditorStatePlayMode PlayMode { get; set; } [JsonProperty("active_scene")] - public EditorStateV2ActiveScene ActiveScene { get; set; } + public EditorStateActiveScene ActiveScene { get; set; } } - private sealed class EditorStateV2PlayMode + private sealed class EditorStatePlayMode { [JsonProperty("is_playing")] public bool? IsPlaying { get; set; } @@ -108,7 +108,7 @@ private sealed class EditorStateV2PlayMode public bool? IsChanging { get; set; } } - private sealed class EditorStateV2ActiveScene + private sealed class EditorStateActiveScene { [JsonProperty("path")] public string Path { get; set; } @@ -120,7 +120,7 @@ private sealed class EditorStateV2ActiveScene public string Name { get; set; } } - private sealed class EditorStateV2Activity + private sealed class EditorStateActivity { [JsonProperty("phase")] public string Phase { get; set; } @@ -132,7 +132,7 @@ private sealed class EditorStateV2Activity public string[] Reasons { get; set; } } - private sealed class EditorStateV2Compilation + private sealed class EditorStateCompilation { [JsonProperty("is_compiling")] public bool? IsCompiling { get; set; } @@ -153,7 +153,7 @@ private sealed class EditorStateV2Compilation public long? LastDomainReloadAfterUnixMs { get; set; } } - private sealed class EditorStateV2Assets + private sealed class EditorStateAssets { [JsonProperty("is_updating")] public bool? IsUpdating { get; set; } @@ -171,10 +171,10 @@ private sealed class EditorStateV2Assets public long? ExternalChangesLastClearedUnixMs { get; set; } [JsonProperty("refresh")] - public EditorStateV2Refresh Refresh { get; set; } + public EditorStateRefresh Refresh { get; set; } } - private sealed class EditorStateV2Refresh + private sealed class EditorStateRefresh { [JsonProperty("is_refresh_in_progress")] public bool? IsRefreshInProgress { get; set; } @@ -186,7 +186,7 @@ private sealed class EditorStateV2Refresh public long? LastRefreshFinishedUnixMs { get; set; } } - private sealed class EditorStateV2Tests + private sealed class EditorStateTests { [JsonProperty("is_running")] public bool? IsRunning { get; set; } @@ -204,10 +204,10 @@ private sealed class EditorStateV2Tests public string StartedBy { get; set; } [JsonProperty("last_run")] - public EditorStateV2LastRun LastRun { get; set; } + public EditorStateLastRun LastRun { get; set; } } - private sealed class EditorStateV2LastRun + private sealed class EditorStateLastRun { [JsonProperty("finished_unix_ms")] public long? FinishedUnixMs { get; set; } @@ -219,7 +219,7 @@ private sealed class EditorStateV2LastRun public object Counts { get; set; } } - private sealed class EditorStateV2Transport + private sealed class EditorStateTransport { [JsonProperty("unity_bridge_connected")] public bool? UnityBridgeConnected { get; set; } @@ -331,12 +331,12 @@ private static JObject BuildSnapshot(string reason) activityPhase = "playmode_transition"; } - var snapshot = new EditorStateV2Snapshot + var snapshot = new EditorStateSnapshot { SchemaVersion = "unity-mcp/editor_state@2", ObservedAtUnixMs = _observedUnixMs, Sequence = _sequence, - Unity = new EditorStateV2Unity + Unity = new EditorStateUnity { InstanceId = null, UnityVersion = Application.unityVersion, @@ -344,29 +344,29 @@ private static JObject BuildSnapshot(string reason) Platform = Application.platform.ToString(), IsBatchMode = Application.isBatchMode }, - Editor = new EditorStateV2Editor + Editor = new EditorStateEditor { IsFocused = isFocused, - PlayMode = new EditorStateV2PlayMode + PlayMode = new EditorStatePlayMode { IsPlaying = EditorApplication.isPlaying, IsPaused = EditorApplication.isPaused, IsChanging = EditorApplication.isPlayingOrWillChangePlaymode }, - ActiveScene = new EditorStateV2ActiveScene + ActiveScene = new EditorStateActiveScene { Path = scenePath, Guid = sceneGuid, Name = scene.name ?? string.Empty } }, - Activity = new EditorStateV2Activity + Activity = new EditorStateActivity { Phase = activityPhase, SinceUnixMs = _observedUnixMs, Reasons = new[] { reason } }, - Compilation = new EditorStateV2Compilation + Compilation = new EditorStateCompilation { IsCompiling = isCompiling, IsDomainReloadPending = _domainReloadPending, @@ -375,21 +375,21 @@ private static JObject BuildSnapshot(string reason) LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs, LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs }, - Assets = new EditorStateV2Assets + Assets = new EditorStateAssets { IsUpdating = EditorApplication.isUpdating, ExternalChangesDirty = false, ExternalChangesLastSeenUnixMs = null, ExternalChangesDirtySinceUnixMs = null, ExternalChangesLastClearedUnixMs = null, - Refresh = new EditorStateV2Refresh + Refresh = new EditorStateRefresh { IsRefreshInProgress = false, LastRefreshRequestedUnixMs = null, LastRefreshFinishedUnixMs = null } }, - Tests = new EditorStateV2Tests + Tests = new EditorStateTests { IsRunning = testsRunning, Mode = testsMode, @@ -397,7 +397,7 @@ private static JObject BuildSnapshot(string reason) StartedUnixMs = TestRunStatus.StartedUnixMs, StartedBy = "unknown", LastRun = TestRunStatus.FinishedUnixMs.HasValue - ? new EditorStateV2LastRun + ? new EditorStateLastRun { FinishedUnixMs = TestRunStatus.FinishedUnixMs, Result = "unknown", @@ -405,7 +405,7 @@ private static JObject BuildSnapshot(string reason) } : null }, - Transport = new EditorStateV2Transport + Transport = new EditorStateTransport { UnityBridgeConnected = null, LastMessageUnixMs = null diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 832be0c49..977496c68 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -13,7 +13,7 @@ from transport.legacy.unity_connection import async_send_command_with_retry -class EditorStateV2Unity(BaseModel): +class EditorStateUnity(BaseModel): instance_id: str | None = None unity_version: str | None = None project_id: str | None = None @@ -21,31 +21,31 @@ class EditorStateV2Unity(BaseModel): is_batch_mode: bool | None = None -class EditorStateV2PlayMode(BaseModel): +class EditorStatePlayMode(BaseModel): is_playing: bool | None = None is_paused: bool | None = None is_changing: bool | None = None -class EditorStateV2ActiveScene(BaseModel): +class EditorStateActiveScene(BaseModel): path: str | None = None guid: str | None = None name: str | None = None -class EditorStateV2Editor(BaseModel): +class EditorStateEditor(BaseModel): is_focused: bool | None = None - play_mode: EditorStateV2PlayMode | None = None - active_scene: EditorStateV2ActiveScene | None = None + play_mode: EditorStatePlayMode | None = None + active_scene: EditorStateActiveScene | None = None -class EditorStateV2Activity(BaseModel): +class EditorStateActivity(BaseModel): phase: str | None = None since_unix_ms: int | None = None reasons: list[str] | None = None -class EditorStateV2Compilation(BaseModel): +class EditorStateCompilation(BaseModel): is_compiling: bool | None = None is_domain_reload_pending: bool | None = None last_compile_started_unix_ms: int | None = None @@ -54,66 +54,66 @@ class EditorStateV2Compilation(BaseModel): last_domain_reload_after_unix_ms: int | None = None -class EditorStateV2Refresh(BaseModel): +class EditorStateRefresh(BaseModel): is_refresh_in_progress: bool | None = None last_refresh_requested_unix_ms: int | None = None last_refresh_finished_unix_ms: int | None = None -class EditorStateV2Assets(BaseModel): +class EditorStateAssets(BaseModel): is_updating: bool | None = None external_changes_dirty: bool | None = None external_changes_last_seen_unix_ms: int | None = None external_changes_dirty_since_unix_ms: int | None = None external_changes_last_cleared_unix_ms: int | None = None - refresh: EditorStateV2Refresh | None = None + refresh: EditorStateRefresh | None = None -class EditorStateV2LastRun(BaseModel): +class EditorStateLastRun(BaseModel): finished_unix_ms: int | None = None result: str | None = None counts: Any | None = None -class EditorStateV2Tests(BaseModel): +class EditorStateTests(BaseModel): is_running: bool | None = None mode: str | None = None current_job_id: str | None = None started_unix_ms: int | None = None started_by: str | None = None - last_run: EditorStateV2LastRun | None = None + last_run: EditorStateLastRun | None = None -class EditorStateV2Transport(BaseModel): +class EditorStateTransport(BaseModel): unity_bridge_connected: bool | None = None last_message_unix_ms: int | None = None -class EditorStateV2Advice(BaseModel): +class EditorStateAdvice(BaseModel): ready_for_tools: bool | None = None blocking_reasons: list[str] | None = None recommended_retry_after_ms: int | None = None recommended_next_action: str | None = None -class EditorStateV2Staleness(BaseModel): +class EditorStateStaleness(BaseModel): age_ms: int | None = None is_stale: bool | None = None -class EditorStateV2Data(BaseModel): +class EditorStateData(BaseModel): schema_version: str observed_at_unix_ms: int sequence: int - unity: EditorStateV2Unity | None = None - editor: EditorStateV2Editor | None = None - activity: EditorStateV2Activity | None = None - compilation: EditorStateV2Compilation | None = None - assets: EditorStateV2Assets | None = None - tests: EditorStateV2Tests | None = None - transport: EditorStateV2Transport | None = None - advice: EditorStateV2Advice | None = None - staleness: EditorStateV2Staleness | None = None + unity: EditorStateUnity | None = None + editor: EditorStateEditor | None = None + activity: EditorStateActivity | None = None + compilation: EditorStateCompilation | None = None + assets: EditorStateAssets | None = None + tests: EditorStateTests | None = None + transport: EditorStateTransport | None = None + advice: EditorStateAdvice | None = None + staleness: EditorStateStaleness | None = None def _now_unix_ms() -> int: @@ -288,10 +288,10 @@ async def get_editor_state(ctx: Context) -> MCPResponse: state_v2 = _enrich_advice_and_staleness(state_v2) try: - if hasattr(EditorStateV2Data, "model_validate"): - validated = EditorStateV2Data.model_validate(state_v2) + if hasattr(EditorStateData, "model_validate"): + validated = EditorStateData.model_validate(state_v2) else: - validated = EditorStateV2Data.parse_obj( + validated = EditorStateData.parse_obj( state_v2) # type: ignore[attr-defined] data = validated.model_dump() if hasattr( validated, "model_dump") else validated.dict() From b3319a26f2c3e87da19d489c3c318d84d744aaba Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 17:59:05 -0400 Subject: [PATCH 29/32] Make infer_single_instance_id public by removing underscore prefix --- Server/src/services/resources/editor_state.py | 4 ++-- Server/src/services/tools/refresh_unity.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 977496c68..7d1c1f148 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -125,7 +125,7 @@ def _in_pytest() -> bool: return bool(os.environ.get("PYTEST_CURRENT_TEST")) -async def _infer_single_instance_id(ctx: Context) -> str | None: +async def infer_single_instance_id(ctx: Context) -> str | None: """ Best-effort: if exactly one Unity instance is connected, return its Name@hash id. This makes editor_state outputs self-describing even when no explicit active instance is set. @@ -248,7 +248,7 @@ async def get_editor_state(ctx: Context) -> MCPResponse: if unity_instance: unity_section["instance_id"] = unity_instance else: - inferred = await _infer_single_instance_id(ctx) + inferred = await infer_single_instance_id(ctx) if inferred: unity_section["instance_id"] = inferred diff --git a/Server/src/services/tools/refresh_unity.py b/Server/src/services/tools/refresh_unity.py index 0f80a3f9e..927c090ff 100644 --- a/Server/src/services/tools/refresh_unity.py +++ b/Server/src/services/tools/refresh_unity.py @@ -84,7 +84,7 @@ async def refresh_unity( # After readiness is restored, clear any external-dirty flag for this instance so future tools can proceed cleanly. try: - inst = unity_instance or await editor_state._infer_single_instance_id(ctx) + inst = unity_instance or await editor_state.infer_single_instance_id(ctx) if inst: external_changes_scanner.clear_dirty(inst) except Exception: From f270bee288006be0357d900e475f62c102d2d1e8 Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 18:09:35 -0400 Subject: [PATCH 30/32] Fix Python tests, again --- Server/src/services/resources/editor_state.py | 4 +--- Server/src/services/tools/run_tests.py | 3 +-- Server/tests/integration/test_run_tests_async.py | 8 ++++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Server/src/services/resources/editor_state.py b/Server/src/services/resources/editor_state.py index 7d1c1f148..7a3a24278 100644 --- a/Server/src/services/resources/editor_state.py +++ b/Server/src/services/resources/editor_state.py @@ -130,9 +130,7 @@ async def infer_single_instance_id(ctx: Context) -> str | None: Best-effort: if exactly one Unity instance is connected, return its Name@hash id. This makes editor_state outputs self-describing even when no explicit active instance is set. """ - ctx.info("If exactly one Unity instance is connected, return its Name@hash id.") - if _in_pytest(): - return None + await ctx.info("If exactly one Unity instance is connected, return its Name@hash id.") try: transport = unity_transport._current_transport() diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index f5964be9f..6c4a6d168 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -187,6 +187,5 @@ async def get_test_job( if isinstance(response, dict): if not response.get("success", True): return MCPResponse(**response) - GetTestJobResponse(**response) - return response + return GetTestJobResponse(**response) return MCPResponse(success=False, error=str(response)) diff --git a/Server/tests/integration/test_run_tests_async.py b/Server/tests/integration/test_run_tests_async.py index 4c8df1dce..86013f2c4 100644 --- a/Server/tests/integration/test_run_tests_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -28,7 +28,9 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p assert captured["params"]["mode"] == "EditMode" assert captured["params"]["testNames"] == ["MyNamespace.MyTests.TestA"] assert captured["params"]["includeDetails"] is True - assert resp["success"] is True + assert resp.success is True + assert resp.data is not None + assert resp.data.job_id == "abc123" @pytest.mark.asyncio @@ -49,4 +51,6 @@ async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, p resp = await get_test_job(DummyContext(), job_id="job-1") assert captured["command_type"] == "get_test_job" assert captured["params"]["job_id"] == "job-1" - assert resp["data"]["job_id"] == "job-1" + assert resp.success is True + assert resp.data is not None + assert resp.data.job_id == "job-1" From 07558e11d650b874b19bfb993b2fc0cbce5213cc Mon Sep 17 00:00:00 2001 From: Marcus Sanatan Date: Wed, 7 Jan 2026 18:18:48 -0400 Subject: [PATCH 31/32] Replace AI generated .meta files with actual Unity ones --- MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta | 2 +- .../Editor/Dependencies/Models/DependencyCheckResult.cs.meta | 2 +- MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta | 2 +- .../Dependencies/PlatformDetectors/IPlatformDetector.cs.meta | 2 +- .../PlatformDetectors/LinuxPlatformDetector.cs.meta | 2 +- .../PlatformDetectors/MacOSPlatformDetector.cs.meta | 2 +- .../PlatformDetectors/WindowsPlatformDetector.cs.meta | 2 +- MCPForUnity/Editor/Setup/SetupWindowService.cs.meta | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta index 78d723208..fc192c2b4 100644 --- a/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta +++ b/MCPForUnity/Editor/Dependencies/DependencyManager.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: f6789012345678901234abcdef012345 +guid: 4a6d2236d370b4f1db4d0e3d3ce0dcac MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta index b97fd1ddd..4d70a111d 100644 --- a/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyCheckResult.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 789012345678901234abcdef01234567 +guid: f6df82faa423f4e9ebb13a3dcee8ba19 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta index d536bcbf7..7e93ff493 100644 --- a/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta +++ b/MCPForUnity/Editor/Dependencies/Models/DependencyStatus.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 6789012345678901234abcdef0123456 +guid: ddeeeca2f876f4083a84417404175199 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta index 976e3b1ed..1ec32f330 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/IPlatformDetector.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 9012345678901234abcdef0123456789 +guid: 67d73d0e8caef4e60942f4419c6b76bf MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta index 50092a830..96e1a43dd 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/LinuxPlatformDetector.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 2345678901234abcdef0123456789abc +guid: b682b492eb80d4ed6834b76f72c9f0f3 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta index 7120ff54d..57b516bae 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/MacOSPlatformDetector.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 12345678901234abcdef0123456789ab +guid: c6f602b0a8ca848859197f9a949a7a5d MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta index 42c85eb4f..59cf1d9f7 100644 --- a/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta +++ b/MCPForUnity/Editor/Dependencies/PlatformDetectors/WindowsPlatformDetector.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 012345678901234abcdef0123456789a +guid: 1aedc29caa5704c07b487d20a27e9334 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta b/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta index 67d8bac59..ab1d46abb 100644 --- a/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta +++ b/MCPForUnity/Editor/Setup/SetupWindowService.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 345678901234abcdef0123456789abcd +guid: d1bf468667bb649989e3ef53dafddea6 MonoImporter: externalObjects: {} serializedVersion: 2 From ce1fbc6a930c51a4802dd00d72a0a8f58ec068de Mon Sep 17 00:00:00 2001 From: dsarno Date: Wed, 7 Jan 2026 14:47:01 -0800 Subject: [PATCH 32/32] ## Pre-Launch Enhancements: Testing Infrastructure & Tool Improvements (#8) * Add local test harness for fast developer iteration Scripts for running the NL/T/GO test suites locally against a GUI Unity Editor, complementing the CI workflows in .github/workflows/. Benefits: - 10-100x faster than CI (no Docker startup) - Real-time Unity console debugging - Single test execution for rapid iteration - Auto-detects HTTP vs stdio transport Usage: ./scripts/local-test/setup.sh # One-time setup ./scripts/local-test/quick-test.sh NL-0 # Run single test ./scripts/local-test/run-nl-suite-local.sh # Full suite See scripts/local-test/README.md for details. Also updated .gitignore to: - Allow scripts/local-test/ to be tracked - Ignore generated artifacts (reports/*.xml, .claude/local/, .unity-mcp/) * Fix issue #525: Save dirty scenes for all test modes Move SaveDirtyScenesIfNeeded() call outside the PlayMode conditional so EditMode tests don't get blocked by Unity's "Save Scene" modal dialog. This prevents MCP from timing out when running EditMode tests with unsaved scene changes. * fix: add missing FAST_FAIL_TIMEOUT constant in PluginHub The FAST_FAIL_TIMEOUT class attribute was referenced on line 149 but never defined, causing AttributeError on every ping attempt. This error was silently caught by the broad 'except Exception' handler, causing all fast-fail commands (read_console, get_editor_state, ping) to fail after 6 seconds of retries with 'ping not answered' error. Added FAST_FAIL_TIMEOUT = 10 to define a 10-second timeout for fast-fail commands, matching the intent of the existing fast-fail infrastructure. * feat(ScriptableObject): enhance dry-run validation for AnimationCurve and Quaternion Dry-run validation now validates value formats, not just property existence: - AnimationCurve: Validates structure ({keys:[...]} or direct array), checks each keyframe is an object, validates numeric fields (time, value, inSlope, outSlope, inWeight, outWeight) and integer fields (weightedMode) - Quaternion: Validates array length (3 for Euler, 4 for raw) or object structure ({x,y,z,w} or {euler:[x,y,z]}), ensures all components are numeric Refactored shared validation helpers into appropriate locations: - ParamCoercion: IsNumericToken, ValidateNumericField, ValidateIntegerField - VectorParsing: ValidateAnimationCurveFormat, ValidateQuaternionFormat Added comprehensive XML documentation clarifying keyframe field defaults (all default to 0 except as noted). Added 5 new dry-run validation tests covering valid and invalid formats for both AnimationCurve and Quaternion properties. * test: fix integration tests after merge - test_refresh_unity_retry_recovery: Mock now handles both refresh_unity and get_editor_state commands (refresh_unity internally calls get_editor_state when wait_for_ready=True) - test_run_tests_async_forwards_params: Mock response now includes required 'mode' field for RunTestsStartResponse Pydantic validation - test_get_test_job_forwards_job_id: Updated to handle GetTestJobResponse as Pydantic model instead of dict (use model_dump() for assertions) * Update warning message to apply to all test modes Follow-up to PR #527: Since SaveDirtyScenesIfNeeded() now runs for all test modes, update the warning message to say 'tests' instead of 'PlayMode tests'. * feat(run_tests): add wait_timeout to get_test_job to avoid client loop detection When polling for test completion, MCP clients like Cursor can detect the repeated get_test_job calls as 'looping' and terminate the agent. Added wait_timeout parameter that makes the server wait internally for tests to complete (polling Unity every 2s) before returning. This dramatically reduces client-side tool calls from 10-20 down to 1-2, avoiding loop detection. Usage: get_test_job(job_id='xxx', wait_timeout=30) - Returns immediately if tests complete within timeout - Returns current status if timeout expires (client can call again) - Recommended: 30-60 seconds * fix: use Pydantic attribute access in test_run_tests_async for merge compatibility * revert: remove local test harness - will be submitted in separate PR --------- Co-authored-by: Scott Jennings --- MCPForUnity/Editor/Helpers/ParamCoercion.cs | 59 ++++ MCPForUnity/Editor/Helpers/VectorParsing.cs | 209 ++++++++++++- .../Editor/Services/TestRunnerService.cs | 9 +- .../Editor/Tools/ManageScriptableObject.cs | 107 +++++-- Server/src/services/tools/run_tests.py | 50 +++- .../test_refresh_unity_retry_recovery.py | 13 +- .../tests/integration/test_run_tests_async.py | 4 +- .../ManageScriptableObjectStressTests.cs | 279 ++++++++++++++++++ 8 files changed, 687 insertions(+), 43 deletions(-) diff --git a/MCPForUnity/Editor/Helpers/ParamCoercion.cs b/MCPForUnity/Editor/Helpers/ParamCoercion.cs index 1b4b67cb8..a6c333118 100644 --- a/MCPForUnity/Editor/Helpers/ParamCoercion.cs +++ b/MCPForUnity/Editor/Helpers/ParamCoercion.cs @@ -156,6 +156,65 @@ public static T CoerceEnum(JToken token, T defaultValue) where T : struct, En return defaultValue; } + /// + /// Checks if a JToken represents a numeric value (integer or float). + /// Useful for validating JSON values before parsing. + /// + /// The JSON token to check + /// True if the token is an integer or float, false otherwise + public static bool IsNumericToken(JToken token) + { + return token != null && (token.Type == JTokenType.Integer || token.Type == JTokenType.Float); + } + + /// + /// Validates that an optional field in a JObject is numeric if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or numeric; false if present but non-numeric + public static bool ValidateNumericField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid (will use default) + } + if (!IsNumericToken(token)) + { + error = $"must be a number, got {token.Type}"; + return false; + } + return true; + } + + /// + /// Validates that an optional field in a JObject is an integer if present. + /// Used for dry-run validation of complex type formats. + /// + /// The JSON object containing the field + /// The name of the field to validate + /// Output error message if validation fails + /// True if the field is absent, null, or integer; false if present but non-integer + public static bool ValidateIntegerField(JObject obj, string fieldName, out string error) + { + error = null; + var token = obj[fieldName]; + if (token == null || token.Type == JTokenType.Null) + { + return true; // Field not present, valid + } + if (token.Type != JTokenType.Integer) + { + error = $"must be an integer, got {token.Type}"; + return false; + } + return true; + } + /// /// Normalizes a property name by removing separators and converting to camelCase. /// Handles common naming variations from LLMs and humans. diff --git a/MCPForUnity/Editor/Helpers/VectorParsing.cs b/MCPForUnity/Editor/Helpers/VectorParsing.cs index 1d621d222..0e81cca88 100644 --- a/MCPForUnity/Editor/Helpers/VectorParsing.cs +++ b/MCPForUnity/Editor/Helpers/VectorParsing.cs @@ -391,11 +391,24 @@ public static Gradient ParseGradientOrDefault(JToken token) /// /// Parses a JToken into an AnimationCurve. - /// Supports formats: - /// - Constant: 1.0 (number) - /// - Simple: {start: 0.0, end: 1.0} - /// - Full: {keys: [{time: 0.0, value: 1.0, inTangent: 0.0, outTangent: 0.0}, ...]} - /// Added for ManageVFX refactoring. + /// + /// Supported formats: + /// + /// Constant: 1.0 (number) - Creates constant curve at that value + /// Simple: {start: 0.0, end: 1.0} or {startValue: 0.0, endValue: 1.0} + /// Full: {keys: [{time: 0, value: 1, inTangent: 0, outTangent: 0}, ...]} + /// + /// + /// Keyframe field defaults (for Full format): + /// + /// time (float): Default: 0 + /// value (float): Default: 1 (note: differs from ManageScriptableObject which uses 0) + /// inTangent (float): Default: 0 + /// outTangent (float): Default: 0 + /// + /// + /// Note: This method is used by ManageVFX. For ScriptableObject patching, + /// see which has slightly different defaults. /// /// The JSON token to parse /// The parsed AnimationCurve or null if parsing fails @@ -459,6 +472,192 @@ public static AnimationCurve ParseAnimationCurveOrDefault(JToken token, float de { return ParseAnimationCurve(token) ?? AnimationCurve.Constant(0f, 1f, defaultValue); } + + /// + /// Validates AnimationCurve JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Wrapped: { "keys": [ { "time": 0, "value": 1.0 }, ... ] } + /// Direct array: [ { "time": 0, "value": 1.0 }, ... ] + /// Null/empty: Valid (will set empty curve) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateAnimationCurveFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set empty curve)."; + return true; + } + + JArray keysArray = null; + + if (valueToken is JObject curveObj) + { + keysArray = curveObj["keys"] as JArray; + if (keysArray == null) + { + message = "AnimationCurve object requires 'keys' array. Expected: { \"keys\": [ { \"time\": 0, \"value\": 0 }, ... ] }"; + return false; + } + } + else if (valueToken is JArray directArray) + { + keysArray = directArray; + } + else + { + message = "AnimationCurve requires object with 'keys' or array of keyframes. " + + "Expected: { \"keys\": [ { \"time\": 0, \"value\": 0, \"inSlope\": 0, \"outSlope\": 0 }, ... ] }"; + return false; + } + + // Validate each keyframe + for (int i = 0; i < keysArray.Count; i++) + { + var keyToken = keysArray[i]; + if (keyToken is not JObject keyObj) + { + message = $"Keyframe at index {i} must be an object with 'time' and 'value'."; + return false; + } + + // Validate numeric fields if present + string[] numericFields = { "time", "value", "inSlope", "outSlope", "inTangent", "outTangent", "inWeight", "outWeight" }; + foreach (var field in numericFields) + { + if (!ParamCoercion.ValidateNumericField(keyObj, field, out var fieldError)) + { + message = $"Keyframe[{i}].{field}: {fieldError}"; + return false; + } + } + + if (!ParamCoercion.ValidateIntegerField(keyObj, "weightedMode", out var weightedModeError)) + { + message = $"Keyframe[{i}].weightedMode: {weightedModeError}"; + return false; + } + } + + message = $"Value format valid (AnimationCurve with {keysArray.Count} keyframes). " + + "Note: Missing keyframe fields default to 0 (time, value, inSlope, outSlope, inWeight, outWeight)."; + return true; + } + + /// + /// Validates Quaternion JSON format without parsing it. + /// Used by dry-run validation to provide early feedback on format errors. + /// + /// Validated formats: + /// + /// Euler array: [x, y, z] - 3 numeric elements + /// Raw quaternion: [x, y, z, w] - 4 numeric elements + /// Object: { "x": 0, "y": 0, "z": 0, "w": 1 } + /// Explicit euler: { "euler": [x, y, z] } + /// Null/empty: Valid (will set identity) + /// + /// + /// The JSON value to validate + /// Output message describing validation result or error + /// True if format is valid, false otherwise + public static bool ValidateQuaternionFormat(JToken valueToken, out string message) + { + message = null; + + if (valueToken == null || valueToken.Type == JTokenType.Null) + { + message = "Value format valid (will set identity quaternion)."; + return true; + } + + if (valueToken is JArray arr) + { + if (arr.Count == 3) + { + // Validate Euler angles [x, y, z] + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from Euler angles [x, y, z])."; + return true; + } + else if (arr.Count == 4) + { + // Validate raw quaternion [x, y, z, w] + for (int i = 0; i < 4; i++) + { + if (!ParamCoercion.IsNumericToken(arr[i])) + { + message = $"Quaternion component at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from [x, y, z, w])."; + return true; + } + else + { + message = "Quaternion array must have 3 elements (Euler angles) or 4 elements (x, y, z, w)."; + return false; + } + } + else if (valueToken is JObject obj) + { + // Check for explicit euler property + if (obj["euler"] is JArray eulerArr) + { + if (eulerArr.Count != 3) + { + message = "Quaternion euler array must have exactly 3 elements [x, y, z]."; + return false; + } + for (int i = 0; i < 3; i++) + { + if (!ParamCoercion.IsNumericToken(eulerArr[i])) + { + message = $"Euler angle at index {i} must be a number."; + return false; + } + } + message = "Value format valid (Quaternion from { euler: [x, y, z] })."; + return true; + } + + // Object format { x, y, z, w } + if (obj["x"] != null && obj["y"] != null && obj["z"] != null && obj["w"] != null) + { + if (!ParamCoercion.IsNumericToken(obj["x"]) || !ParamCoercion.IsNumericToken(obj["y"]) || + !ParamCoercion.IsNumericToken(obj["z"]) || !ParamCoercion.IsNumericToken(obj["w"])) + { + message = "Quaternion { x, y, z, w } fields must all be numbers."; + return false; + } + message = "Value format valid (Quaternion from { x, y, z, w })."; + return true; + } + + message = "Quaternion object must have { x, y, z, w } or { euler: [x, y, z] }."; + return false; + } + else + { + message = "Quaternion requires array [x,y,z] (Euler), [x,y,z,w] (raw), or object { x, y, z, w }."; + return false; + } + } /// /// Parses a JToken into a Rect. diff --git a/MCPForUnity/Editor/Services/TestRunnerService.cs b/MCPForUnity/Editor/Services/TestRunnerService.cs index baa89577b..47f864d27 100644 --- a/MCPForUnity/Editor/Services/TestRunnerService.cs +++ b/MCPForUnity/Editor/Services/TestRunnerService.cs @@ -106,10 +106,9 @@ public async Task RunTestsAsync(TestMode mode, TestFilterOptions }; var settings = new ExecutionSettings(filter); - if (mode == TestMode.PlayMode) - { - SaveDirtyScenesIfNeeded(); - } + // Save dirty scenes for all test modes to prevent modal dialogs blocking MCP + // (Issue #525: EditMode tests were blocked by save dialog) + SaveDirtyScenesIfNeeded(); _testRunnerApi.Execute(settings); @@ -331,7 +330,7 @@ private static void SaveDirtyScenesIfNeeded() { if (string.IsNullOrEmpty(scene.path)) { - McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running PlayMode tests."); + McpLog.Warn($"[TestRunnerService] Skipping unsaved scene '{scene.name}': save it manually before running tests."); continue; } try diff --git a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs index 81d4f8f1e..7c98317d5 100644 --- a/MCPForUnity/Editor/Tools/ManageScriptableObject.cs +++ b/MCPForUnity/Editor/Tools/ManageScriptableObject.cs @@ -388,16 +388,50 @@ private static List ValidatePatches(UnityEngine.Object target, JArray pa if (prop != null) { - // Property exists - report its type for validation - results.Add(new { - index = i, - propertyPath = normalizedPath, - op, - ok = true, - message = "Property found.", - propertyType = prop.propertyType.ToString(), - isArray = prop.isArray - }); + // Property exists - validate value format for supported complex types + var valueToken = patchObj["value"]; + string valueValidationMsg = null; + bool valueFormatOk = true; + + // Enhanced dry-run: validate value format for AnimationCurve and Quaternion + // Uses shared validators from VectorParsing + if (valueToken != null && valueToken.Type != JTokenType.Null) + { + switch (prop.propertyType) + { + case SerializedPropertyType.AnimationCurve: + valueFormatOk = VectorParsing.ValidateAnimationCurveFormat(valueToken, out valueValidationMsg); + break; + case SerializedPropertyType.Quaternion: + valueFormatOk = VectorParsing.ValidateQuaternionFormat(valueToken, out valueValidationMsg); + break; + } + } + + if (valueFormatOk) + { + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = true, + message = valueValidationMsg ?? "Property found.", + propertyType = prop.propertyType.ToString(), + isArray = prop.isArray + }); + } + else + { + results.Add(new { + index = i, + propertyPath = normalizedPath, + op, + ok = false, + message = valueValidationMsg, + propertyType = prop.propertyType.ToString(), + isArray = prop.isArray + }); + } } } @@ -1057,9 +1091,32 @@ private static bool TrySetEnum(SerializedProperty prop, JToken valueToken, out s /// /// Sets an AnimationCurve property from a JSON structure. - /// Expected format: { "keys": [ { "time": 0, "value": 0, "inSlope": 0, "outSlope": 2 }, ... ] } - /// or a simple array: [ { "time": 0, "value": 0 }, ... ] + /// + /// Supported formats: + /// + /// Wrapped: { "keys": [ { "time": 0, "value": 1.0 }, ... ] } + /// Direct array: [ { "time": 0, "value": 1.0 }, ... ] + /// Null/empty: Sets an empty AnimationCurve + /// + /// + /// Keyframe fields: + /// + /// time (float): Keyframe time position. Default: 0 + /// value (float): Keyframe value. Default: 0 + /// inSlope or inTangent (float): Incoming tangent slope. Default: 0 + /// outSlope or outTangent (float): Outgoing tangent slope. Default: 0 + /// weightedMode (int): Weighted mode enum (0=None, 1=In, 2=Out, 3=Both). Default: 0 (None) + /// inWeight (float): Incoming tangent weight. Default: 0 + /// outWeight (float): Outgoing tangent weight. Default: 0 + /// + /// + /// Note: All keyframe fields are optional. Missing fields gracefully default to 0, + /// which produces linear interpolation when both tangents are 0. /// + /// The SerializedProperty of type AnimationCurve to set + /// JSON token containing the curve data + /// Output message describing the result + /// True if successful, false if the format is invalid private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueToken, out string message) { message = null; @@ -1144,12 +1201,28 @@ private static bool TrySetAnimationCurve(SerializedProperty prop, JToken valueTo /// /// Sets a Quaternion property from JSON. - /// Accepts: - /// - [x, y, z] as Euler angles (degrees) - /// - [x, y, z, w] as raw quaternion components - /// - { "x": 0, "y": 0, "z": 0, "w": 1 } as raw quaternion - /// - { "euler": [x, y, z] } for explicit euler + /// + /// Supported formats: + /// + /// Euler array: [x, y, z] - Euler angles in degrees + /// Raw quaternion array: [x, y, z, w] - Direct quaternion components + /// Object format: { "x": 0, "y": 0, "z": 0, "w": 1 } - Direct components + /// Explicit euler: { "euler": [x, y, z] } - Euler angles in degrees + /// Null/empty: Sets Quaternion.identity (no rotation) + /// + /// + /// Format detection: + /// + /// 3-element array → Interpreted as Euler angles (degrees) + /// 4-element array → Interpreted as raw quaternion [x, y, z, w] + /// Object with euler → Uses euler array for rotation + /// Object with x, y, z, w → Uses raw quaternion components + /// /// + /// The SerializedProperty of type Quaternion to set + /// JSON token containing the quaternion data + /// Output message describing the result + /// True if successful, false if the format is invalid private static bool TrySetQuaternion(SerializedProperty prop, JToken valueToken, out string message) { message = null; diff --git a/Server/src/services/tools/run_tests.py b/Server/src/services/tools/run_tests.py index 6c4a6d168..d5aa4f830 100644 --- a/Server/src/services/tools/run_tests.py +++ b/Server/src/services/tools/run_tests.py @@ -1,6 +1,7 @@ """Async Unity Test Runner jobs: start + poll.""" from __future__ import annotations +import asyncio from typing import Annotated, Any, Literal from fastmcp import Context @@ -169,6 +170,10 @@ async def get_test_job( "Include details for failed/skipped tests only (default: false)"] = False, include_details: Annotated[bool, "Include details for all tests (default: false)"] = False, + wait_timeout: Annotated[int | None, + "If set, wait up to this many seconds for tests to complete before returning. " + "Reduces polling frequency and avoids client-side loop detection. " + "Recommended: 30-60 seconds. Returns immediately if tests complete sooner."] = None, ) -> GetTestJobResponse | MCPResponse: unity_instance = get_unity_instance_from_context(ctx) @@ -178,12 +183,45 @@ async def get_test_job( if include_details: params["includeDetails"] = True - response = await unity_transport.send_with_unity_instance( - async_send_command_with_retry, - unity_instance, - "get_test_job", - params, - ) + async def _fetch_status() -> dict[str, Any]: + return await unity_transport.send_with_unity_instance( + async_send_command_with_retry, + unity_instance, + "get_test_job", + params, + ) + + # If wait_timeout is specified, poll server-side until complete or timeout + if wait_timeout and wait_timeout > 0: + deadline = asyncio.get_event_loop().time() + wait_timeout + poll_interval = 2.0 # Poll Unity every 2 seconds + + while True: + response = await _fetch_status() + + if not isinstance(response, dict): + return MCPResponse(success=False, error=str(response)) + + if not response.get("success", True): + return MCPResponse(**response) + + # Check if tests are done + data = response.get("data", {}) + status = data.get("status", "") + if status in ("succeeded", "failed", "cancelled"): + return GetTestJobResponse(**response) + + # Check timeout + remaining = deadline - asyncio.get_event_loop().time() + if remaining <= 0: + # Timeout reached, return current status + return GetTestJobResponse(**response) + + # Wait before next poll (but don't exceed remaining time) + await asyncio.sleep(min(poll_interval, remaining)) + + # No wait_timeout - return immediately (original behavior) + response = await _fetch_status() if isinstance(response, dict): if not response.get("success", True): return MCPResponse(**response) diff --git a/Server/tests/integration/test_refresh_unity_retry_recovery.py b/Server/tests/integration/test_refresh_unity_retry_recovery.py index b487bc10e..01916e2f8 100644 --- a/Server/tests/integration/test_refresh_unity_retry_recovery.py +++ b/Server/tests/integration/test_refresh_unity_retry_recovery.py @@ -23,18 +23,15 @@ async def test_refresh_unity_recovers_from_retry_disconnect(monkeypatch): external_changes_scanner._states[inst] = ExternalChangesState(dirty=True, dirty_since_unix_ms=1) async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): - assert command_type == "refresh_unity" - return {"success": False, "error": "disconnected", "hint": "retry"} - - async def fake_get_editor_state_v2(_ctx): - return MCPResponse(success=True, data={"advice": {"ready_for_tools": True}}) + if command_type == "refresh_unity": + return {"success": False, "error": "disconnected", "hint": "retry"} + elif command_type == "get_editor_state": + return {"success": True, "data": {"advice": {"ready_for_tools": True}}} + raise ValueError(f"Unexpected command: {command_type}") import services.tools.refresh_unity as refresh_mod monkeypatch.setattr(refresh_mod.unity_transport, "send_with_unity_instance", fake_send_with_unity_instance) - import services.resources.editor_state as es_mod - monkeypatch.setattr(es_mod, "get_editor_state", fake_get_editor_state_v2) - resp = await refresh_unity(ctx, wait_for_ready=True) payload = resp.model_dump() if hasattr(resp, "model_dump") else resp assert payload["success"] is True diff --git a/Server/tests/integration/test_run_tests_async.py b/Server/tests/integration/test_run_tests_async.py index 86013f2c4..5c005a6ff 100644 --- a/Server/tests/integration/test_run_tests_async.py +++ b/Server/tests/integration/test_run_tests_async.py @@ -12,7 +12,7 @@ async def test_run_tests_async_forwards_params(monkeypatch): async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): captured["command_type"] = command_type captured["params"] = params - return {"success": True, "data": {"job_id": "abc123", "status": "running"}} + return {"success": True, "data": {"job_id": "abc123", "status": "running", "mode": "EditMode"}} import services.tools.run_tests as mod monkeypatch.setattr( @@ -42,7 +42,7 @@ async def test_get_test_job_forwards_job_id(monkeypatch): async def fake_send_with_unity_instance(send_fn, unity_instance, command_type, params, **kwargs): captured["command_type"] = command_type captured["params"] = params - return {"success": True, "data": {"job_id": params["job_id"], "status": "running"}} + return {"success": True, "data": {"job_id": params["job_id"], "status": "running", "mode": "EditMode"}} import services.tools.run_tests as mod monkeypatch.setattr( diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs index 6f4126841..d3b129fac 100644 --- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs +++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptableObjectStressTests.cs @@ -731,6 +731,285 @@ public void DryRun_ValidatePatchesWithoutApplying() Debug.Log("[DryRun] Successfully validated patches without applying"); } + /// + /// Test: Dry-run validates AnimationCurve format and provides early feedback. + /// + [Test] + public void DryRun_AnimationCurve_ValidFormat_PassesValidation() + { + // Create a test asset first + var createResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "create", + ["typeName"] = "ComplexStressSO", + ["folderPath"] = _runRoot, + ["assetName"] = "DryRunAnimCurveValid", + ["overwrite"] = true + })); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var path = createResult["data"]?["path"]?.ToString(); + var guid = createResult["data"]?["guid"]?.ToString(); + _createdAssets.Add(path); + + // Dry-run with valid AnimationCurve format + var dryRunResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "modify", + ["target"] = new JObject { ["guid"] = guid }, + ["dryRun"] = true, + ["patches"] = new JArray + { + new JObject + { + ["propertyPath"] = "animCurve", + ["op"] = "set", + ["value"] = new JObject + { + ["keys"] = new JArray + { + new JObject { ["time"] = 0f, ["value"] = 0f }, + new JObject { ["time"] = 1f, ["value"] = 1f, ["inSlope"] = 0f, ["outSlope"] = 0f } + } + } + } + } + })); + + Assert.IsTrue(dryRunResult.Value("success"), $"Dry-run should succeed: {dryRunResult}"); + + var data = dryRunResult["data"] as JObject; + var validationResults = data["validationResults"] as JArray; + Assert.IsNotNull(validationResults, "Should have validation results"); + Assert.AreEqual(1, validationResults.Count); + + // Should pass validation with informative message + Assert.IsTrue(validationResults[0].Value("ok"), $"Valid AnimationCurve format should pass: {validationResults[0]}"); + var message = validationResults[0].Value("message"); + Assert.IsTrue(message.Contains("AnimationCurve") && message.Contains("2 keyframes"), + $"Message should describe curve: {message}"); + + Debug.Log($"[DryRun_AnimationCurve] Valid format passed: {message}"); + } + + /// + /// Test: Dry-run catches invalid AnimationCurve format early. + /// + [Test] + public void DryRun_AnimationCurve_InvalidFormat_FailsWithClearError() + { + // Create a test asset first + var createResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "create", + ["typeName"] = "ComplexStressSO", + ["folderPath"] = _runRoot, + ["assetName"] = "DryRunAnimCurveInvalid", + ["overwrite"] = true + })); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var path = createResult["data"]?["path"]?.ToString(); + var guid = createResult["data"]?["guid"]?.ToString(); + _createdAssets.Add(path); + + // Dry-run with INVALID AnimationCurve format (non-numeric time) + var dryRunResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "modify", + ["target"] = new JObject { ["guid"] = guid }, + ["dryRun"] = true, + ["patches"] = new JArray + { + new JObject + { + ["propertyPath"] = "animCurve", + ["op"] = "set", + ["value"] = new JObject + { + ["keys"] = new JArray + { + new JObject { ["time"] = "not-a-number", ["value"] = 0f } // Invalid! + } + } + } + } + })); + + Assert.IsTrue(dryRunResult.Value("success"), $"Dry-run call should succeed: {dryRunResult}"); + + var data = dryRunResult["data"] as JObject; + var validationResults = data["validationResults"] as JArray; + Assert.IsNotNull(validationResults); + + // Validation should FAIL with clear error message + Assert.IsFalse(validationResults[0].Value("ok"), $"Invalid AnimationCurve format should fail validation: {validationResults[0]}"); + var message = validationResults[0].Value("message"); + Assert.IsTrue(message.Contains("Keyframe") && message.Contains("time") && message.Contains("number"), + $"Error message should identify the problem: {message}"); + + Debug.Log($"[DryRun_AnimationCurve] Invalid format caught early: {message}"); + } + + /// + /// Test: Dry-run validates Quaternion format and provides early feedback. + /// + [Test] + public void DryRun_Quaternion_ValidFormat_PassesValidation() + { + // Create a test asset first + var createResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "create", + ["typeName"] = "ComplexStressSO", + ["folderPath"] = _runRoot, + ["assetName"] = "DryRunQuatValid", + ["overwrite"] = true + })); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var path = createResult["data"]?["path"]?.ToString(); + var guid = createResult["data"]?["guid"]?.ToString(); + _createdAssets.Add(path); + + // Dry-run with valid Quaternion format (Euler angles) + var dryRunResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "modify", + ["target"] = new JObject { ["guid"] = guid }, + ["dryRun"] = true, + ["patches"] = new JArray + { + new JObject + { + ["propertyPath"] = "rotation", + ["op"] = "set", + ["value"] = new JArray { 45f, 90f, 0f } // Valid Euler angles + } + } + })); + + Assert.IsTrue(dryRunResult.Value("success"), $"Dry-run should succeed: {dryRunResult}"); + + var data = dryRunResult["data"] as JObject; + var validationResults = data["validationResults"] as JArray; + Assert.IsNotNull(validationResults); + + // Should pass validation with informative message + Assert.IsTrue(validationResults[0].Value("ok"), $"Valid Quaternion format should pass: {validationResults[0]}"); + var message = validationResults[0].Value("message"); + Assert.IsTrue(message.Contains("Quaternion") && message.Contains("Euler"), + $"Message should describe format: {message}"); + + Debug.Log($"[DryRun_Quaternion] Valid Euler format passed: {message}"); + } + + /// + /// Test: Dry-run catches invalid Quaternion format (wrong array length) early. + /// + [Test] + public void DryRun_Quaternion_WrongArrayLength_FailsWithClearError() + { + // Create a test asset first + var createResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "create", + ["typeName"] = "ComplexStressSO", + ["folderPath"] = _runRoot, + ["assetName"] = "DryRunQuatWrongLength", + ["overwrite"] = true + })); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var path = createResult["data"]?["path"]?.ToString(); + var guid = createResult["data"]?["guid"]?.ToString(); + _createdAssets.Add(path); + + // Dry-run with INVALID Quaternion format (wrong array length) + var dryRunResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "modify", + ["target"] = new JObject { ["guid"] = guid }, + ["dryRun"] = true, + ["patches"] = new JArray + { + new JObject + { + ["propertyPath"] = "rotation", + ["op"] = "set", + ["value"] = new JArray { 1f, 2f } // Invalid! Must be 3 or 4 elements + } + } + })); + + Assert.IsTrue(dryRunResult.Value("success"), $"Dry-run call should succeed: {dryRunResult}"); + + var data = dryRunResult["data"] as JObject; + var validationResults = data["validationResults"] as JArray; + Assert.IsNotNull(validationResults); + + // Validation should FAIL with clear error message + Assert.IsFalse(validationResults[0].Value("ok"), $"Wrong array length should fail validation: {validationResults[0]}"); + var message = validationResults[0].Value("message"); + Assert.IsTrue(message.Contains("3 elements") || message.Contains("4 elements"), + $"Error message should explain valid lengths: {message}"); + + Debug.Log($"[DryRun_Quaternion] Wrong array length caught early: {message}"); + } + + /// + /// Test: Dry-run catches invalid Quaternion format (non-numeric values) early. + /// + [Test] + public void DryRun_Quaternion_NonNumericValue_FailsWithClearError() + { + // Create a test asset first + var createResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "create", + ["typeName"] = "ComplexStressSO", + ["folderPath"] = _runRoot, + ["assetName"] = "DryRunQuatNonNumeric", + ["overwrite"] = true + })); + Assert.IsTrue(createResult.Value("success"), createResult.ToString()); + + var path = createResult["data"]?["path"]?.ToString(); + var guid = createResult["data"]?["guid"]?.ToString(); + _createdAssets.Add(path); + + // Dry-run with INVALID Quaternion format (non-numeric value) + var dryRunResult = ToJObject(ManageScriptableObject.HandleCommand(new JObject + { + ["action"] = "modify", + ["target"] = new JObject { ["guid"] = guid }, + ["dryRun"] = true, + ["patches"] = new JArray + { + new JObject + { + ["propertyPath"] = "rotation", + ["op"] = "set", + ["value"] = new JArray { 45f, "ninety", 0f } // Invalid! Non-numeric + } + } + })); + + Assert.IsTrue(dryRunResult.Value("success"), $"Dry-run call should succeed: {dryRunResult}"); + + var data = dryRunResult["data"] as JObject; + var validationResults = data["validationResults"] as JArray; + Assert.IsNotNull(validationResults); + + // Validation should FAIL with clear error message + Assert.IsFalse(validationResults[0].Value("ok"), $"Non-numeric value should fail validation: {validationResults[0]}"); + var message = validationResults[0].Value("message"); + Assert.IsTrue(message.Contains("number") || message.Contains("numeric"), + $"Error message should mention number requirement: {message}"); + + Debug.Log($"[DryRun_Quaternion] Non-numeric value caught early: {message}"); + } + #endregion #region Phase 6: Extended Type Support Tests