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
dsarno 2025-10-22 18:42:46 -07:00 committed by GitHub
parent 9ccf70bd7b
commit c866e0625b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 331 additions and 183 deletions

View File

@ -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",
] ]

View File

@ -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

View File

@ -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

View File

@ -1 +1 @@
6.2.1 6.2.3

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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":

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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}')

View File

@ -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

View File

@ -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",

1
tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file makes tests a package so test modules can import from each other

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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