Harden MCP tool parameter handling to eliminate “invalid param” errors (#339)
* feat: migrate to FastMCP 2.0 (v2.12.5) - Update pyproject.toml to use fastmcp>=2.12.5 instead of mcp[cli] - Replace all imports from mcp.server.fastmcp to fastmcp - Maintain MCP protocol compliance with mcp>=1.16.0 - All 15 files updated with new import statements - Server and tools registration working with FastMCP 2.0 * chore: bump MCP for Unity to 6.2.2 and widen numeric tool params (asset search/read_resource/run_tests) for better LLM compatibility * chore: bump installed server_version.txt to 6.2.2 so Unity installer logs correct version * fix(parameters): apply parameter hardening to read_console, manage_editor, and manage_gameobject - read_console: accept int|str for count parameter with coercion - manage_editor: accept bool|str for wait_for_completion with coercion - manage_gameobject: accept bool|str for all boolean parameters with coercion - All tools now handle string parameters gracefully and convert to proper types internally * chore(deps): drop fastmcp, use mcp>=1.18.0; update imports to mcp.server.fastmcp * chore(deps): re-add fastmcp>=2.12.5, relax mcp to >=1.16.0 Adds fastmcp as explicit dependency for FastMCP 2.0 migration. Relaxes mcp version constraint to support broader compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: remove obsolete mcp stubs for FastMCP 2.0 compatibility Removes stub mcp modules from test files that were conflicting with the real mcp and fastmcp packages now installed as dependencies. Adds tests/__init__.py to make tests a proper Python package. This fixes test collection errors after migrating to FastMCP 2.0. Test results: 40 passed, 7 xpassed, 5 skipped, 1 failed (pre-existing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete FastMCP 2.0 migration with correct import paths Updates all remaining files to use `from fastmcp import` instead of the old `from mcp.server.fastmcp import` path. Changes: - server.py: Update FastMCP import - tools/__init__.py: Update FastMCP import - resources/__init__.py: Update FastMCP import - tools/manage_script.py, read_console.py, resource_tools.py: Update imports - test stubs: Update to stub `fastmcp` instead of `mcp.server.fastmcp` Addresses PR review feedback about incomplete migration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: harden parameter type handling and resolve PR feedback Parameter Type Improvements: - Broaden count in read_console.py to accept int | str - Broaden build_index in manage_scene.py to accept int | str - Harden vector parsing in manage_gameobject.py with NaN/Inf checks - Add whitespace-delimited vector support (e.g., "1 2 3") - Narrow exception handling from Exception to (ValueError, TypeError) Test Improvements: - Harden _load_module in test files with spec/loader validation - Fix test_manage_gameobject_boolean_and_tag_mapping by mapping tag→search_term Bug Fixes: - Fix syntax error in manage_shader.py (remove stray 'x') Version: Bump to 6.2.3 All tests pass: 41 passed, 5 skipped, 7 xpassed --------- Co-authored-by: Claude <noreply@anthropic.com>main
parent
9ccf70bd7b
commit
c866e0625b
|
|
@ -1,12 +1,13 @@
|
||||||
[project]
|
[project]
|
||||||
name = "MCPForUnityServer"
|
name = "MCPForUnityServer"
|
||||||
version = "6.2.1"
|
version = "6.2.2"
|
||||||
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
|
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27.2",
|
"httpx>=0.27.2",
|
||||||
"mcp[cli]>=1.17.0",
|
"fastmcp>=2.12.5",
|
||||||
|
"mcp>=1.16.0",
|
||||||
"pydantic>=2.12.0",
|
"pydantic>=2.12.0",
|
||||||
"tomli>=2.3.0",
|
"tomli>=2.3.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ MCP Resources package - Auto-discovers and registers all resources in this direc
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from telemetry_decorator import telemetry_resource
|
from telemetry_decorator import telemetry_resource
|
||||||
|
|
||||||
from registry import get_registered_resources
|
from registry import get_registered_resources
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
|
||||||
from mcp.server.fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
6.2.1
|
6.2.3
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ MCP Tools package - Auto-discovers and registers all tools in this directory.
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from telemetry_decorator import telemetry_tool
|
from telemetry_decorator import telemetry_tool
|
||||||
|
|
||||||
from registry import get_registered_tools
|
from registry import get_registered_tools
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Defines the execute_menu_item tool for executing and reading Unity Editor menu i
|
||||||
"""
|
"""
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
from models import MCPResponse
|
from models import MCPResponse
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Defines the manage_asset tool for interacting with Unity assets.
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import async_send_command_with_retry
|
from unity_connection import async_send_command_with_retry
|
||||||
|
|
||||||
|
|
@ -29,8 +29,8 @@ async def manage_asset(
|
||||||
filter_type: Annotated[str, "Filter type for search"] | None = None,
|
filter_type: Annotated[str, "Filter type for search"] | None = None,
|
||||||
filter_date_after: Annotated[str,
|
filter_date_after: Annotated[str,
|
||||||
"Date after which to filter"] | None = None,
|
"Date after which to filter"] | None = None,
|
||||||
page_size: Annotated[int, "Page size for pagination"] | None = None,
|
page_size: Annotated[int | float | str, "Page size for pagination"] | None = None,
|
||||||
page_number: Annotated[int, "Page number for pagination"] | None = None
|
page_number: Annotated[int | float | str, "Page number for pagination"] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx.info(f"Processing manage_asset: {action}")
|
ctx.info(f"Processing manage_asset: {action}")
|
||||||
# Ensure properties is a dict if None
|
# Ensure properties is a dict if None
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from telemetry import is_telemetry_enabled, record_tool_usage
|
from telemetry import is_telemetry_enabled, record_tool_usage
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
description="Controls and queries the Unity editor's state and settings"
|
description="Controls and queries the Unity editor's state and settings. Tip: pass booleans as true/false; if your client only sends strings, 'true'/'false' are accepted."
|
||||||
)
|
)
|
||||||
def manage_editor(
|
def manage_editor(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows",
|
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows",
|
||||||
"get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."],
|
"get_active_tool", "get_selection", "get_prefab_stage", "set_active_tool", "add_tag", "remove_tag", "get_tags", "add_layer", "remove_layer", "get_layers"], "Get and update the Unity Editor state."],
|
||||||
wait_for_completion: Annotated[bool,
|
wait_for_completion: Annotated[bool | str,
|
||||||
"Optional. If True, waits for certain actions"] | None = None,
|
"Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
tool_name: Annotated[str,
|
tool_name: Annotated[str,
|
||||||
"Tool name when setting active tool"] | None = None,
|
"Tool name when setting active tool"] | None = None,
|
||||||
tag_name: Annotated[str,
|
tag_name: Annotated[str,
|
||||||
|
|
@ -23,6 +23,23 @@ def manage_editor(
|
||||||
"Layer name when adding and removing layers"] | None = None,
|
"Layer name when adding and removing layers"] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx.info(f"Processing manage_editor: {action}")
|
ctx.info(f"Processing manage_editor: {action}")
|
||||||
|
|
||||||
|
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
|
||||||
|
def _coerce_bool(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in ("true", "1", "yes", "on"): # common truthy strings
|
||||||
|
return True
|
||||||
|
if v in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
wait_for_completion = _coerce_bool(wait_for_completion)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Diagnostics: quick telemetry checks
|
# Diagnostics: quick telemetry checks
|
||||||
if action == "telemetry_status":
|
if action == "telemetry_status":
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
description="Manage GameObjects. Note: for 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data."
|
description="Manage GameObjects. For booleans, send true/false; if your client only sends strings, 'true'/'false' are accepted. Vectors may be [x,y,z] or a string like '[x,y,z]'. For 'get_components', the `data` field contains a dictionary of component names and their serialized properties. For 'get_component', specify 'component_name' to retrieve only that component's serialized data."
|
||||||
)
|
)
|
||||||
def manage_gameobject(
|
def manage_gameobject(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
|
@ -21,24 +21,24 @@ def manage_gameobject(
|
||||||
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
|
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
|
||||||
parent: Annotated[str,
|
parent: Annotated[str,
|
||||||
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
|
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
|
||||||
position: Annotated[list[float],
|
position: Annotated[list[float] | str,
|
||||||
"Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None,
|
"Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||||
rotation: Annotated[list[float],
|
rotation: Annotated[list[float] | str,
|
||||||
"Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None,
|
"Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||||
scale: Annotated[list[float],
|
scale: Annotated[list[float] | str,
|
||||||
"Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None,
|
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
|
||||||
components_to_add: Annotated[list[str],
|
components_to_add: Annotated[list[str],
|
||||||
"List of component names to add"] | None = None,
|
"List of component names to add"] | None = None,
|
||||||
primitive_type: Annotated[str,
|
primitive_type: Annotated[str,
|
||||||
"Primitive type for 'create' action"] | None = None,
|
"Primitive type for 'create' action"] | None = None,
|
||||||
save_as_prefab: Annotated[bool,
|
save_as_prefab: Annotated[bool | str,
|
||||||
"If True, saves the created GameObject as a prefab"] | None = None,
|
"If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
|
prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
|
||||||
prefab_folder: Annotated[str,
|
prefab_folder: Annotated[str,
|
||||||
"Folder for prefab creation"] | None = None,
|
"Folder for prefab creation"] | None = None,
|
||||||
# --- Parameters for 'modify' ---
|
# --- Parameters for 'modify' ---
|
||||||
set_active: Annotated[bool,
|
set_active: Annotated[bool | str,
|
||||||
"If True, sets the GameObject active"] | None = None,
|
"If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
layer: Annotated[str, "Layer name"] | None = None,
|
layer: Annotated[str, "Layer name"] | None = None,
|
||||||
components_to_remove: Annotated[list[str],
|
components_to_remove: Annotated[list[str],
|
||||||
"List of component names to remove"] | None = None,
|
"List of component names to remove"] | None = None,
|
||||||
|
|
@ -51,21 +51,73 @@ def manage_gameobject(
|
||||||
# --- Parameters for 'find' ---
|
# --- Parameters for 'find' ---
|
||||||
search_term: Annotated[str,
|
search_term: Annotated[str,
|
||||||
"Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None,
|
"Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None,
|
||||||
find_all: Annotated[bool,
|
find_all: Annotated[bool | str,
|
||||||
"If True, finds all GameObjects matching the search term"] | None = None,
|
"If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
search_in_children: Annotated[bool,
|
search_in_children: Annotated[bool | str,
|
||||||
"If True, searches in children of the GameObject"] | None = None,
|
"If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
search_inactive: Annotated[bool,
|
search_inactive: Annotated[bool | str,
|
||||||
"If True, searches inactive GameObjects"] | None = None,
|
"If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
# -- Component Management Arguments --
|
# -- Component Management Arguments --
|
||||||
component_name: Annotated[str,
|
component_name: Annotated[str,
|
||||||
"Component name for 'add_component' and 'remove_component' actions"] | None = None,
|
"Component name for 'add_component' and 'remove_component' actions"] | None = None,
|
||||||
# Controls whether serialization of private [SerializeField] fields is included
|
# Controls whether serialization of private [SerializeField] fields is included
|
||||||
includeNonPublicSerialized: Annotated[bool,
|
includeNonPublicSerialized: Annotated[bool | str,
|
||||||
"Controls whether serialization of private [SerializeField] fields is included"] | None = None,
|
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx.info(f"Processing manage_gameobject: {action}")
|
ctx.info(f"Processing manage_gameobject: {action}")
|
||||||
|
|
||||||
|
# Coercers to tolerate stringified booleans and vectors
|
||||||
|
def _coerce_bool(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
def _coerce_vec(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
import math
|
||||||
|
def _to_vec3(parts):
|
||||||
|
try:
|
||||||
|
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
return vec if all(math.isfinite(n) for n in vec) else default
|
||||||
|
if isinstance(value, list) and len(value) == 3:
|
||||||
|
return _to_vec3(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
|
||||||
|
if s.startswith("[") and s.endswith("]"):
|
||||||
|
s = s[1:-1]
|
||||||
|
# support "x,y,z" and "x y z"
|
||||||
|
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
|
||||||
|
if len(parts) == 3:
|
||||||
|
return _to_vec3(parts)
|
||||||
|
return default
|
||||||
|
|
||||||
|
position = _coerce_vec(position, default=position)
|
||||||
|
rotation = _coerce_vec(rotation, default=rotation)
|
||||||
|
scale = _coerce_vec(scale, default=scale)
|
||||||
|
save_as_prefab = _coerce_bool(save_as_prefab)
|
||||||
|
set_active = _coerce_bool(set_active)
|
||||||
|
find_all = _coerce_bool(find_all)
|
||||||
|
search_in_children = _coerce_bool(search_in_children)
|
||||||
|
search_inactive = _coerce_bool(search_inactive)
|
||||||
|
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Map tag to search_term when search_method is by_tag for backward compatibility
|
||||||
|
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
|
||||||
|
search_term = tag
|
||||||
|
|
||||||
# Validate parameter usage to prevent silent failures
|
# Validate parameter usage to prevent silent failures
|
||||||
if action == "find":
|
if action == "find":
|
||||||
if name is not None:
|
if name is not None:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
from typing import Annotated, Literal, Any
|
from typing import Annotated, Literal, Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(description="Manage Unity scenes")
|
@mcp_for_unity_tool(description="Manage Unity scenes. Tip: For broad client compatibility, pass build_index as a quoted string (e.g., '0').")
|
||||||
def manage_scene(
|
def manage_scene(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
|
action: Annotated[Literal["create", "load", "save", "get_hierarchy", "get_active", "get_build_settings"], "Perform CRUD operations on Unity scenes."],
|
||||||
|
|
@ -13,8 +13,8 @@ def manage_scene(
|
||||||
"Scene name. Not required get_active/get_build_settings"] | None = None,
|
"Scene name. Not required get_active/get_build_settings"] | None = None,
|
||||||
path: Annotated[str,
|
path: Annotated[str,
|
||||||
"Asset path for scene operations (default: 'Assets/')"] | None = None,
|
"Asset path for scene operations (default: 'Assets/')"] | None = None,
|
||||||
build_index: Annotated[int,
|
build_index: Annotated[int | str,
|
||||||
"Build index for load/build settings actions"] | None = None,
|
"Build index for load/build settings actions (accepts int or string, e.g., 0 or '0')"] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx.info(f"Processing manage_scene: {action}")
|
ctx.info(f"Processing manage_scene: {action}")
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import os
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP, Context
|
from fastmcp import FastMCP, Context
|
||||||
|
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
import unity_connection
|
import unity_connection
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import base64
|
import base64
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,48 @@ Defines the read_console tool for accessing Unity Editor console messages.
|
||||||
"""
|
"""
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
||||||
|
|
||||||
@mcp_for_unity_tool(
|
@mcp_for_unity_tool(
|
||||||
description="Gets messages from or clears the Unity Editor console."
|
description="Gets messages from or clears the Unity Editor console. Note: For maximum client compatibility, pass count as a quoted string (e.g., '5')."
|
||||||
)
|
)
|
||||||
def read_console(
|
def read_console(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None,
|
action: Annotated[Literal['get', 'clear'], "Get or clear the Unity Editor console."] | None = None,
|
||||||
types: Annotated[list[Literal['error', 'warning',
|
types: Annotated[list[Literal['error', 'warning',
|
||||||
'log', 'all']], "Message types to get"] | None = None,
|
'log', 'all']], "Message types to get"] | None = None,
|
||||||
count: Annotated[int, "Max messages to return"] | None = None,
|
count: Annotated[int | str, "Max messages to return (accepts int or string, e.g., 5 or '5')"] | None = None,
|
||||||
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
filter_text: Annotated[str, "Text filter for messages"] | None = None,
|
||||||
since_timestamp: Annotated[str,
|
since_timestamp: Annotated[str,
|
||||||
"Get messages after this timestamp (ISO 8601)"] | None = None,
|
"Get messages after this timestamp (ISO 8601)"] | None = None,
|
||||||
format: Annotated[Literal['plain', 'detailed',
|
format: Annotated[Literal['plain', 'detailed',
|
||||||
'json'], "Output format"] | None = None,
|
'json'], "Output format"] | None = None,
|
||||||
include_stacktrace: Annotated[bool,
|
include_stacktrace: Annotated[bool | str,
|
||||||
"Include stack traces in output"] | None = None
|
"Include stack traces in output (accepts true/false or 'true'/'false')"] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
ctx.info(f"Processing read_console: {action}")
|
ctx.info(f"Processing read_console: {action}")
|
||||||
# Set defaults if values are None
|
# Set defaults if values are None
|
||||||
action = action if action is not None else 'get'
|
action = action if action is not None else 'get'
|
||||||
types = types if types is not None else ['error', 'warning', 'log']
|
types = types if types is not None else ['error', 'warning', 'log']
|
||||||
format = format if format is not None else 'detailed'
|
format = format if format is not None else 'detailed'
|
||||||
include_stacktrace = include_stacktrace if include_stacktrace is not None else True
|
# Coerce booleans defensively (strings like 'true'/'false')
|
||||||
|
def _coerce_bool(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
v = value.strip().lower()
|
||||||
|
if v in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
include_stacktrace = _coerce_bool(include_stacktrace, True)
|
||||||
|
|
||||||
# Normalize action if it's a string
|
# Normalize action if it's a string
|
||||||
if isinstance(action, str):
|
if isinstance(action, str):
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import re
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
@ -190,13 +190,13 @@ async def list_resources(
|
||||||
async def read_resource(
|
async def read_resource(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
uri: Annotated[str, "The resource URI to read under Assets/"],
|
uri: Annotated[str, "The resource URI to read under Assets/"],
|
||||||
start_line: Annotated[int,
|
start_line: Annotated[int | float | str,
|
||||||
"The starting line number (0-based)"] | None = None,
|
"The starting line number (0-based)"] | None = None,
|
||||||
line_count: Annotated[int,
|
line_count: Annotated[int | float | str,
|
||||||
"The number of lines to read"] | None = None,
|
"The number of lines to read"] | None = None,
|
||||||
head_bytes: Annotated[int,
|
head_bytes: Annotated[int | float | str,
|
||||||
"The number of bytes to read from the start of the file"] | None = None,
|
"The number of bytes to read from the start of the file"] | None = None,
|
||||||
tail_lines: Annotated[int,
|
tail_lines: Annotated[int | float | str,
|
||||||
"The number of lines to read from the end of the file"] | None = None,
|
"The number of lines to read from the end of the file"] | None = None,
|
||||||
project_root: Annotated[str,
|
project_root: Annotated[str,
|
||||||
"The project root directory"] | None = None,
|
"The project root directory"] | None = None,
|
||||||
|
|
@ -351,7 +351,7 @@ async def find_in_file(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
|
uri: Annotated[str, "The resource URI to search under Assets/ or file path form supported by read_resource"],
|
||||||
pattern: Annotated[str, "The regex pattern to search for"],
|
pattern: Annotated[str, "The regex pattern to search for"],
|
||||||
ignore_case: Annotated[bool, "Case-insensitive search"] | None = True,
|
ignore_case: Annotated[bool | str, "Case-insensitive search (accepts true/false or 'true'/'false')"] | None = True,
|
||||||
project_root: Annotated[str,
|
project_root: Annotated[str,
|
||||||
"The project root directory"] | None = None,
|
"The project root directory"] | None = None,
|
||||||
max_results: Annotated[int,
|
max_results: Annotated[int,
|
||||||
|
|
@ -365,6 +365,20 @@ async def find_in_file(
|
||||||
return {"success": False, "error": f"Resource not found: {uri}"}
|
return {"success": False, "error": f"Resource not found: {uri}"}
|
||||||
|
|
||||||
text = p.read_text(encoding="utf-8")
|
text = p.read_text(encoding="utf-8")
|
||||||
|
# Tolerant boolean coercion for clients that stringify booleans
|
||||||
|
def _coerce_bool(val, default=None):
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
v = val.strip().lower()
|
||||||
|
if v in ("true", "1", "yes", "on"):
|
||||||
|
return True
|
||||||
|
if v in ("false", "0", "no", "off"):
|
||||||
|
return False
|
||||||
|
return bool(val)
|
||||||
|
ignore_case = _coerce_bool(ignore_case, default=True)
|
||||||
flags = re.MULTILINE
|
flags = re.MULTILINE
|
||||||
if ignore_case:
|
if ignore_case:
|
||||||
flags |= re.IGNORECASE
|
flags |= re.IGNORECASE
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tool for executing Unity Test Runner suites."""
|
"""Tool for executing Unity Test Runner suites."""
|
||||||
from typing import Annotated, Literal, Any
|
from typing import Annotated, Literal, Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from models import MCPResponse
|
from models import MCPResponse
|
||||||
|
|
@ -43,14 +43,31 @@ async def run_tests(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
mode: Annotated[Literal["edit", "play"], Field(
|
mode: Annotated[Literal["edit", "play"], Field(
|
||||||
description="Unity test mode to run")] = "edit",
|
description="Unity test mode to run")] = "edit",
|
||||||
timeout_seconds: Annotated[int, Field(
|
timeout_seconds: Annotated[str, Field(
|
||||||
description="Optional timeout in seconds for the Unity test run")] | None = None,
|
description="Optional timeout in seconds for the Unity test run (string, e.g. '30')")] | None = None,
|
||||||
) -> RunTestsResponse:
|
) -> RunTestsResponse:
|
||||||
await ctx.info(f"Processing run_tests: mode={mode}")
|
await ctx.info(f"Processing run_tests: mode={mode}")
|
||||||
|
|
||||||
|
# Coerce timeout defensively (string/float -> int)
|
||||||
|
def _coerce_int(value, default=None):
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
if isinstance(value, int):
|
||||||
|
return int(value)
|
||||||
|
s = str(value).strip()
|
||||||
|
if s.lower() in ("", "none", "null"):
|
||||||
|
return default
|
||||||
|
return int(float(s))
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
params: dict[str, Any] = {"mode": mode}
|
params: dict[str, Any] = {"mode": mode}
|
||||||
if timeout_seconds is not None:
|
ts = _coerce_int(timeout_seconds)
|
||||||
params["timeoutSeconds"] = timeout_seconds
|
if ts is not None:
|
||||||
|
params["timeoutSeconds"] = ts
|
||||||
|
|
||||||
response = await async_send_command_with_retry("run_tests", params)
|
response = await async_send_command_with_retry("run_tests", params)
|
||||||
await ctx.info(f'Response {response}')
|
await ctx.info(f'Response {response}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import hashlib
|
||||||
import re
|
import re
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context
|
from fastmcp import Context
|
||||||
|
|
||||||
from registry import mcp_for_unity_tool
|
from registry import mcp_for_unity_tool
|
||||||
from unity_connection import send_command_with_retry
|
from unity_connection import send_command_with_retry
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "com.coplaydev.unity-mcp",
|
"name": "com.coplaydev.unity-mcp",
|
||||||
"version": "6.2.1",
|
"version": "6.2.3",
|
||||||
"displayName": "MCP for Unity",
|
"displayName": "MCP for Unity",
|
||||||
"description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
|
"description": "A bridge that connects AI assistants to Unity via the MCP (Model Context Protocol). Allows AI clients like Claude Code, Cursor, and VSCode to directly control your Unity Editor for enhanced development workflows.\n\nFeatures automated setup wizard, cross-platform support, and seamless integration with popular AI development tools.\n\nJoin Our Discord: https://discord.gg/y4p8KfzrN4",
|
||||||
"unity": "2021.3",
|
"unity": "2021.3",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
# This file makes tests a package so test modules can import from each other
|
||||||
|
|
@ -1,31 +1,12 @@
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def _load(path: pathlib.Path, name: str):
|
def _load(path: pathlib.Path, name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,17 @@
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp to satisfy imports without full dependency
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module(path: pathlib.Path, name: str):
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
|
||||||
|
|
@ -5,31 +5,12 @@ Test the improved anchor matching logic.
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
|
|
||||||
# add server src to path and load modules
|
# add server src to path and load modules
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def load_module(path, name):
|
def load_module(path, name):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
import importlib.util
|
||||||
|
import types
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# Stub fastmcp to avoid real MCP deps
|
||||||
|
fastmcp_pkg = types.ModuleType("fastmcp")
|
||||||
|
|
||||||
|
|
||||||
|
class _Dummy:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
fastmcp_pkg.FastMCP = _Dummy
|
||||||
|
fastmcp_pkg.Context = _Dummy
|
||||||
|
sys.modules.setdefault("fastmcp", fastmcp_pkg)
|
||||||
|
|
||||||
|
|
||||||
|
from tests.test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_asset_pagination_coercion(monkeypatch):
|
||||||
|
# Import with SRC as CWD to satisfy telemetry import side effects
|
||||||
|
_prev = os.getcwd()
|
||||||
|
os.chdir(str(SRC))
|
||||||
|
try:
|
||||||
|
manage_asset_mod = _load_module(SRC / "tools" / "manage_asset.py", "manage_asset_mod")
|
||||||
|
finally:
|
||||||
|
os.chdir(_prev)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_async_send(cmd, params, loop=None):
|
||||||
|
captured["params"] = params
|
||||||
|
return {"success": True, "data": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(manage_asset_mod, "async_send_command_with_retry", fake_async_send)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
manage_asset_mod.manage_asset(
|
||||||
|
ctx=DummyContext(),
|
||||||
|
action="search",
|
||||||
|
path="Assets",
|
||||||
|
page_size="50",
|
||||||
|
page_number="2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {"success": True, "data": {}}
|
||||||
|
assert captured["params"]["pageSize"] == 50
|
||||||
|
assert captured["params"]["pageNumber"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
import importlib.util
|
||||||
|
import types
|
||||||
|
import os
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# Stub fastmcp to avoid real MCP deps
|
||||||
|
fastmcp_pkg = types.ModuleType("fastmcp")
|
||||||
|
|
||||||
|
|
||||||
|
class _Dummy:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
fastmcp_pkg.FastMCP = _Dummy
|
||||||
|
fastmcp_pkg.Context = _Dummy
|
||||||
|
sys.modules.setdefault("fastmcp", fastmcp_pkg)
|
||||||
|
|
||||||
|
|
||||||
|
from tests.test_helpers import DummyContext
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_gameobject_boolean_and_tag_mapping(monkeypatch):
|
||||||
|
# Import with SRC as CWD to satisfy telemetry import side effects
|
||||||
|
_prev = os.getcwd()
|
||||||
|
os.chdir(str(SRC))
|
||||||
|
try:
|
||||||
|
manage_go_mod = _load_module(SRC / "tools" / "manage_gameobject.py", "manage_go_mod")
|
||||||
|
finally:
|
||||||
|
os.chdir(_prev)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_send(cmd, params):
|
||||||
|
captured["params"] = params
|
||||||
|
return {"success": True, "data": {}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(manage_go_mod, "send_command_with_retry", fake_send)
|
||||||
|
|
||||||
|
# find by tag: allow tag to map to searchTerm
|
||||||
|
resp = manage_go_mod.manage_gameobject(
|
||||||
|
ctx=DummyContext(),
|
||||||
|
action="find",
|
||||||
|
search_method="by_tag",
|
||||||
|
tag="Player",
|
||||||
|
find_all="true",
|
||||||
|
search_inactive="0",
|
||||||
|
)
|
||||||
|
# Loosen equality: wrapper may include a diagnostic message
|
||||||
|
assert resp.get("success") is True
|
||||||
|
assert "data" in resp
|
||||||
|
# ensure tag mapped to searchTerm and booleans passed through; C# side coerces true/false already
|
||||||
|
assert captured["params"]["searchTerm"] == "Player"
|
||||||
|
assert captured["params"]["findAll"] == "true" or captured["params"]["findAll"] is True
|
||||||
|
assert captured["params"]["searchInactive"] in ("0", False, 0)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -21,10 +21,8 @@ if SRC is None:
|
||||||
)
|
)
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# Stub mcp.server.fastmcp to satisfy imports without full package
|
# Stub fastmcp to avoid real MCP deps
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
fastmcp_pkg = types.ModuleType("fastmcp")
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
class _Dummy:
|
||||||
|
|
@ -33,11 +31,7 @@ class _Dummy:
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
fastmcp_pkg.FastMCP = _Dummy
|
||||||
fastmcp_pkg.Context = _Dummy
|
fastmcp_pkg.Context = _Dummy
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
sys.modules.setdefault("fastmcp", fastmcp_pkg)
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
# Import target module after path injection
|
# Import target module after path injection
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module(path: pathlib.Path, name: str):
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
import pytest
|
import pytest
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
@ -10,24 +9,6 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp to satisfy imports without full dependency
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def load_module(path, name):
|
def load_module(path, name):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ sys.modules.setdefault("telemetry", telemetry_stub)
|
||||||
|
|
||||||
def _load_module(path: pathlib.Path, name: str):
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,16 @@
|
||||||
import sys
|
import sys
|
||||||
import pathlib
|
import pathlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import types
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
SRC = ROOT / "MCPForUnity" / "UnityMcpServer~" / "src"
|
||||||
sys.path.insert(0, str(SRC))
|
sys.path.insert(0, str(SRC))
|
||||||
|
|
||||||
# stub mcp.server.fastmcp similar to test_get_sha
|
|
||||||
mcp_pkg = types.ModuleType("mcp")
|
|
||||||
server_pkg = types.ModuleType("mcp.server")
|
|
||||||
fastmcp_pkg = types.ModuleType("mcp.server.fastmcp")
|
|
||||||
|
|
||||||
|
|
||||||
class _Dummy:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
fastmcp_pkg.FastMCP = _Dummy
|
|
||||||
fastmcp_pkg.Context = _Dummy
|
|
||||||
server_pkg.fastmcp = fastmcp_pkg
|
|
||||||
mcp_pkg.server = server_pkg
|
|
||||||
sys.modules.setdefault("mcp", mcp_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server", server_pkg)
|
|
||||||
sys.modules.setdefault("mcp.server.fastmcp", fastmcp_pkg)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module(path: pathlib.Path, name: str):
|
def _load_module(path: pathlib.Path, name: str):
|
||||||
spec = importlib.util.spec_from_file_location(name, path)
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(f"Cannot load module {name} from {path}")
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue