Skip to content

Commit c866e06

Browse files
dsarnoclaude
andauthored
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>
1 parent 9ccf70b commit c866e06

30 files changed

+331
-183
lines changed

MCPForUnity/UnityMcpServer~/src/pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
[project]
22
name = "MCPForUnityServer"
3-
version = "6.2.1"
3+
version = "6.2.2"
44
description = "MCP for Unity Server: A Unity package for Unity Editor integration via the Model Context Protocol (MCP)."
55
readme = "README.md"
66
requires-python = ">=3.10"
77
dependencies = [
88
"httpx>=0.27.2",
9-
"mcp[cli]>=1.17.0",
9+
"fastmcp>=2.12.5",
10+
"mcp>=1.16.0",
1011
"pydantic>=2.12.0",
1112
"tomli>=2.3.0",
1213
]

MCPForUnity/UnityMcpServer~/src/resources/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from pathlib import Path
66

7-
from mcp.server.fastmcp import FastMCP
7+
from fastmcp import FastMCP
88
from telemetry_decorator import telemetry_resource
99

1010
from registry import get_registered_resources

MCPForUnity/UnityMcpServer~/src/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from telemetry import record_telemetry, record_milestone, RecordType, MilestoneType
2-
from mcp.server.fastmcp import FastMCP
2+
from fastmcp import FastMCP
33
import logging
44
from logging.handlers import RotatingFileHandler
55
import os
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
6.2.1
1+
6.2.3

MCPForUnity/UnityMcpServer~/src/tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from pathlib import Path
66

7-
from mcp.server.fastmcp import FastMCP
7+
from fastmcp import FastMCP
88
from telemetry_decorator import telemetry_tool
99

1010
from registry import get_registered_tools

MCPForUnity/UnityMcpServer~/src/tools/execute_menu_item.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from typing import Annotated, Any
55

6-
from mcp.server.fastmcp import Context
6+
from fastmcp import Context
77

88
from models import MCPResponse
99
from registry import mcp_for_unity_tool

MCPForUnity/UnityMcpServer~/src/tools/manage_asset.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import asyncio
55
from typing import Annotated, Any, Literal
66

7-
from mcp.server.fastmcp import Context
7+
from fastmcp import Context
88
from registry import mcp_for_unity_tool
99
from unity_connection import async_send_command_with_retry
1010

@@ -29,8 +29,8 @@ async def manage_asset(
2929
filter_type: Annotated[str, "Filter type for search"] | None = None,
3030
filter_date_after: Annotated[str,
3131
"Date after which to filter"] | None = None,
32-
page_size: Annotated[int, "Page size for pagination"] | None = None,
33-
page_number: Annotated[int, "Page number for pagination"] | None = None
32+
page_size: Annotated[int | float | str, "Page size for pagination"] | None = None,
33+
page_number: Annotated[int | float | str, "Page number for pagination"] | None = None
3434
) -> dict[str, Any]:
3535
ctx.info(f"Processing manage_asset: {action}")
3636
# Ensure properties is a dict if None

MCPForUnity/UnityMcpServer~/src/tools/manage_editor.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
from typing import Annotated, Any, Literal
22

3-
from mcp.server.fastmcp import Context
3+
from fastmcp import Context
44
from registry import mcp_for_unity_tool
55
from telemetry import is_telemetry_enabled, record_tool_usage
66
from unity_connection import send_command_with_retry
77

88

99
@mcp_for_unity_tool(
10-
description="Controls and queries the Unity editor's state and settings"
10+
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."
1111
)
1212
def manage_editor(
1313
ctx: Context,
1414
action: Annotated[Literal["telemetry_status", "telemetry_ping", "play", "pause", "stop", "get_state", "get_project_root", "get_windows",
1515
"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."],
16-
wait_for_completion: Annotated[bool,
17-
"Optional. If True, waits for certain actions"] | None = None,
16+
wait_for_completion: Annotated[bool | str,
17+
"Optional. If True, waits for certain actions (accepts true/false or 'true'/'false')"] | None = None,
1818
tool_name: Annotated[str,
1919
"Tool name when setting active tool"] | None = None,
2020
tag_name: Annotated[str,
@@ -23,6 +23,23 @@ def manage_editor(
2323
"Layer name when adding and removing layers"] | None = None,
2424
) -> dict[str, Any]:
2525
ctx.info(f"Processing manage_editor: {action}")
26+
27+
# Coerce boolean parameters defensively to tolerate 'true'/'false' strings
28+
def _coerce_bool(value, default=None):
29+
if value is None:
30+
return default
31+
if isinstance(value, bool):
32+
return value
33+
if isinstance(value, str):
34+
v = value.strip().lower()
35+
if v in ("true", "1", "yes", "on"): # common truthy strings
36+
return True
37+
if v in ("false", "0", "no", "off"):
38+
return False
39+
return bool(value)
40+
41+
wait_for_completion = _coerce_bool(wait_for_completion)
42+
2643
try:
2744
# Diagnostics: quick telemetry checks
2845
if action == "telemetry_status":

MCPForUnity/UnityMcpServer~/src/tools/manage_gameobject.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from typing import Annotated, Any, Literal
22

3-
from mcp.server.fastmcp import Context
3+
from fastmcp import Context
44
from registry import mcp_for_unity_tool
55
from unity_connection import send_command_with_retry
66

77

88
@mcp_for_unity_tool(
9-
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."
9+
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."
1010
)
1111
def manage_gameobject(
1212
ctx: Context,
@@ -21,24 +21,24 @@ def manage_gameobject(
2121
"Tag name - used for both 'create' (initial tag) and 'modify' (change tag)"] | None = None,
2222
parent: Annotated[str,
2323
"Parent GameObject reference - used for both 'create' (initial parent) and 'modify' (change parent)"] | None = None,
24-
position: Annotated[list[float],
25-
"Position - used for both 'create' (initial position) and 'modify' (change position)"] | None = None,
26-
rotation: Annotated[list[float],
27-
"Rotation - used for both 'create' (initial rotation) and 'modify' (change rotation)"] | None = None,
28-
scale: Annotated[list[float],
29-
"Scale - used for both 'create' (initial scale) and 'modify' (change scale)"] | None = None,
24+
position: Annotated[list[float] | str,
25+
"Position - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
26+
rotation: Annotated[list[float] | str,
27+
"Rotation - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
28+
scale: Annotated[list[float] | str,
29+
"Scale - [x,y,z] or string '[x,y,z]' for client compatibility"] | None = None,
3030
components_to_add: Annotated[list[str],
3131
"List of component names to add"] | None = None,
3232
primitive_type: Annotated[str,
3333
"Primitive type for 'create' action"] | None = None,
34-
save_as_prefab: Annotated[bool,
35-
"If True, saves the created GameObject as a prefab"] | None = None,
34+
save_as_prefab: Annotated[bool | str,
35+
"If True, saves the created GameObject as a prefab (accepts true/false or 'true'/'false')"] | None = None,
3636
prefab_path: Annotated[str, "Path for prefab creation"] | None = None,
3737
prefab_folder: Annotated[str,
3838
"Folder for prefab creation"] | None = None,
3939
# --- Parameters for 'modify' ---
40-
set_active: Annotated[bool,
41-
"If True, sets the GameObject active"] | None = None,
40+
set_active: Annotated[bool | str,
41+
"If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None,
4242
layer: Annotated[str, "Layer name"] | None = None,
4343
components_to_remove: Annotated[list[str],
4444
"List of component names to remove"] | None = None,
@@ -51,21 +51,73 @@ def manage_gameobject(
5151
# --- Parameters for 'find' ---
5252
search_term: Annotated[str,
5353
"Search term for 'find' action ONLY. Use this (not 'name') when searching for GameObjects."] | None = None,
54-
find_all: Annotated[bool,
55-
"If True, finds all GameObjects matching the search term"] | None = None,
56-
search_in_children: Annotated[bool,
57-
"If True, searches in children of the GameObject"] | None = None,
58-
search_inactive: Annotated[bool,
59-
"If True, searches inactive GameObjects"] | None = None,
54+
find_all: Annotated[bool | str,
55+
"If True, finds all GameObjects matching the search term (accepts true/false or 'true'/'false')"] | None = None,
56+
search_in_children: Annotated[bool | str,
57+
"If True, searches in children of the GameObject (accepts true/false or 'true'/'false')"] | None = None,
58+
search_inactive: Annotated[bool | str,
59+
"If True, searches inactive GameObjects (accepts true/false or 'true'/'false')"] | None = None,
6060
# -- Component Management Arguments --
6161
component_name: Annotated[str,
6262
"Component name for 'add_component' and 'remove_component' actions"] | None = None,
6363
# Controls whether serialization of private [SerializeField] fields is included
64-
includeNonPublicSerialized: Annotated[bool,
65-
"Controls whether serialization of private [SerializeField] fields is included"] | None = None,
64+
includeNonPublicSerialized: Annotated[bool | str,
65+
"Controls whether serialization of private [SerializeField] fields is included (accepts true/false or 'true'/'false')"] | None = None,
6666
) -> dict[str, Any]:
6767
ctx.info(f"Processing manage_gameobject: {action}")
68+
69+
# Coercers to tolerate stringified booleans and vectors
70+
def _coerce_bool(value, default=None):
71+
if value is None:
72+
return default
73+
if isinstance(value, bool):
74+
return value
75+
if isinstance(value, str):
76+
v = value.strip().lower()
77+
if v in ("true", "1", "yes", "on"):
78+
return True
79+
if v in ("false", "0", "no", "off"):
80+
return False
81+
return bool(value)
82+
83+
def _coerce_vec(value, default=None):
84+
if value is None:
85+
return default
86+
import math
87+
def _to_vec3(parts):
88+
try:
89+
vec = [float(parts[0]), float(parts[1]), float(parts[2])]
90+
except (ValueError, TypeError):
91+
return default
92+
return vec if all(math.isfinite(n) for n in vec) else default
93+
if isinstance(value, list) and len(value) == 3:
94+
return _to_vec3(value)
95+
if isinstance(value, str):
96+
s = value.strip()
97+
# minimal tolerant parse for "[x,y,z]" or "x,y,z"
98+
if s.startswith("[") and s.endswith("]"):
99+
s = s[1:-1]
100+
# support "x,y,z" and "x y z"
101+
parts = [p.strip() for p in (s.split(",") if "," in s else s.split())]
102+
if len(parts) == 3:
103+
return _to_vec3(parts)
104+
return default
105+
106+
position = _coerce_vec(position, default=position)
107+
rotation = _coerce_vec(rotation, default=rotation)
108+
scale = _coerce_vec(scale, default=scale)
109+
save_as_prefab = _coerce_bool(save_as_prefab)
110+
set_active = _coerce_bool(set_active)
111+
find_all = _coerce_bool(find_all)
112+
search_in_children = _coerce_bool(search_in_children)
113+
search_inactive = _coerce_bool(search_inactive)
114+
includeNonPublicSerialized = _coerce_bool(includeNonPublicSerialized)
115+
68116
try:
117+
# Map tag to search_term when search_method is by_tag for backward compatibility
118+
if action == "find" and search_method == "by_tag" and tag is not None and search_term is None:
119+
search_term = tag
120+
69121
# Validate parameter usage to prevent silent failures
70122
if action == "find":
71123
if name is not None:

MCPForUnity/UnityMcpServer~/src/tools/manage_prefabs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Annotated, Any, Literal
22

3-
from mcp.server.fastmcp import Context
3+
from fastmcp import Context
44
from registry import mcp_for_unity_tool
55
from unity_connection import send_command_with_retry
66

0 commit comments

Comments
 (0)