Skip to content

Commit f933afe

Browse files
authored
Merge pull request #731 from whatevertogo/feat/tools-support-json-input
fix: accept JSON strings for list parameters in manage_gameobject and manage_texture
2 parents fedf42a + 254fc93 commit f933afe

3 files changed

Lines changed: 79 additions & 7 deletions

File tree

‎Server/src/services/tools/manage_gameobject.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from services.tools import get_unity_instance_from_context
88
from transport.unity_transport import send_with_unity_instance
99
from transport.legacy.unity_connection import async_send_command_with_retry
10-
from services.tools.utils import coerce_bool, parse_json_payload, normalize_vector3
10+
from services.tools.utils import coerce_bool, parse_json_payload, normalize_vector3, normalize_string_list
1111
from services.tools.preflight import preflight
1212

1313

@@ -74,7 +74,7 @@ async def manage_gameobject(
7474
"Rotation as [x, y, z] euler angles array, {x, y, z} object, or JSON string"] | None = None,
7575
scale: Annotated[list[float] | dict[str, float] | str,
7676
"Scale as [x, y, z] array, {x, y, z} object, or JSON string"] | None = None,
77-
components_to_add: Annotated[list[str],
77+
components_to_add: Annotated[list[str] | str,
7878
"List of component names to add during 'create' or 'modify'"] | None = None,
7979
primitive_type: Annotated[str,
8080
"Primitive type for 'create' action"] | None = None,
@@ -87,7 +87,7 @@ async def manage_gameobject(
8787
set_active: Annotated[bool | str,
8888
"If True, sets the GameObject active (accepts true/false or 'true'/'false')"] | None = None,
8989
layer: Annotated[str, "Layer name"] | None = None,
90-
components_to_remove: Annotated[list[str],
90+
components_to_remove: Annotated[list[str] | str,
9191
"List of component names to remove"] | None = None,
9292
component_properties: Annotated[dict[str, dict[str, Any]],
9393
"""Dictionary of component names to their properties to set. For example:
@@ -149,6 +149,15 @@ async def manage_gameobject(
149149
if comp_props_error:
150150
return {"success": False, "message": comp_props_error}
151151

152+
# --- Normalize components_to_add and components_to_remove ---
153+
components_to_add, add_error = normalize_string_list(components_to_add, "components_to_add")
154+
if add_error:
155+
return {"success": False, "message": add_error}
156+
157+
components_to_remove, remove_error = normalize_string_list(components_to_remove, "components_to_remove")
158+
if remove_error:
159+
return {"success": False, "message": remove_error}
160+
152161
try:
153162
# Prepare parameters, removing None values
154163
params = {

‎Server/src/services/tools/manage_texture.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,26 @@ def _normalize_palette(value: Any) -> tuple[list[list[int]] | None, str | None]:
5353
if isinstance(value, str):
5454
if value in ("[object Object]", "undefined", "null", ""):
5555
return None, f"palette received invalid value: '{value}'"
56-
value = parse_json_payload(value)
56+
parsed = parse_json_payload(value)
57+
# If parsing succeeded and result is a list, normalize and return
58+
if isinstance(parsed, list):
59+
value = parsed
60+
# If parsing returned the original string (invalid JSON), treat as error
61+
elif parsed == value:
62+
return None, f"palette must be a list of colors, got invalid string: '{value}'"
63+
else:
64+
return None, f"palette must be a list of colors (list), got string that parsed to {type(parsed).__name__}"
5765

66+
# Validate and normalize each color in the palette
5867
if not isinstance(value, list):
5968
return None, f"palette must be a list of colors, got {type(value).__name__}"
6069

6170
normalized = []
6271
for i, color in enumerate(value):
63-
parsed, error = _normalize_color_int(color)
72+
color_normalized, error = _normalize_color_int(color)
6473
if error:
6574
return None, f"palette[{i}]: {error}"
66-
normalized.append(parsed)
75+
normalized.append(color_normalized)
6776

6877
return normalized, None
6978

@@ -405,7 +414,7 @@ async def manage_texture(
405414
"dots", "grid", "brick"
406415
], "Pattern type for apply_pattern action"] | None = None,
407416

408-
palette: Annotated[list[list[int | float]],
417+
palette: Annotated[list[list[int | float]] | str,
409418
"Color palette as [[r,g,b,a], ...]. Accepts both 0-255 range or 0.0-1.0 normalized range"] | None = None,
410419

411420
pattern_size: Annotated[int,

‎Server/src/services/tools/utils.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,60 @@ def normalize_vector3(value: Any, param_name: str = "vector") -> tuple[list[floa
212212
return None, f"{param_name} must be a list, dict, or string, got {type(value).__name__}"
213213

214214

215+
def normalize_string_list(value: Any, param_name: str = "list") -> tuple[list[str] | None, str | None]:
216+
"""
217+
Normalize a string list parameter that might be a JSON string or plain string.
218+
219+
Handles various input formats from MCP clients/LLMs:
220+
- None -> (None, None)
221+
- list/tuple of strings -> (list, None)
222+
- JSON string '["a", "b", "c"]' -> parsed and normalized
223+
- Plain non-JSON string "foo" -> treated as ["foo"]
224+
225+
Returns:
226+
Tuple of (parsed_list, error_message). If error_message is set, parsed_list is None.
227+
"""
228+
if value is None:
229+
return None, None
230+
231+
# Already a list/tuple - validate and return
232+
if isinstance(value, (list, tuple)):
233+
# Ensure all elements are strings
234+
if all(isinstance(item, str) for item in value):
235+
return list(value), None
236+
return None, f"{param_name} must contain only strings, got mixed types"
237+
238+
# Try parsing as JSON string (immediate parsing for string input)
239+
if isinstance(value, str):
240+
val_trimmed = value.strip()
241+
# Check for obviously invalid values
242+
if val_trimmed in ("[object Object]", "undefined", "null", ""):
243+
return None, f"{param_name} received invalid value: '{value}'. Expected a JSON array like [\"item1\", \"item2\"]"
244+
245+
# Check if it looks like a JSON array but will fail to parse
246+
looks_like_json_array = (val_trimmed.startswith("[") and val_trimmed.endswith("]"))
247+
248+
parsed = parse_json_payload(value)
249+
# If parsing succeeded and result is a list, validate and return
250+
if isinstance(parsed, list):
251+
# Validate all elements are strings
252+
if all(isinstance(item, str) for item in parsed):
253+
return parsed, None
254+
return None, f"{param_name} must contain only strings, got: {parsed}"
255+
# If parsing returned the original string but it looked like a JSON array,
256+
# it's malformed JSON - return error instead of treating as single item
257+
if parsed == value and looks_like_json_array:
258+
return None, f"{param_name} has invalid JSON syntax: '{value}'. Expected a valid JSON array like [\"item1\", \"item2\"]"
259+
# If parsing returned the original string (plain non-JSON), treat as single item
260+
if parsed == value:
261+
# Treat as single-element list
262+
return [value], None
263+
264+
return None, f"{param_name} must be a JSON array (list), got string that parsed to {type(parsed).__name__}"
265+
266+
return None, f"{param_name} must be a list or JSON string, got {type(value).__name__}"
267+
268+
215269
def normalize_color(value: Any, output_range: str = "float") -> tuple[list[float] | None, str | None]:
216270
"""
217271
Normalize a color parameter to [r, g, b, a] format.

0 commit comments

Comments
 (0)