1+ import json
12from typing import Annotated , Any , Literal
23
34from fastmcp import Context
4- from mcp .types import ToolAnnotations
5+ from fastmcp .server .server import ToolResult
6+ from mcp .types import ToolAnnotations , TextContent , ImageContent
57
68from services .registry import mcp_for_unity_tool
79from services .tools import get_unity_instance_from_context
10+ from services .tools .utils import coerce_int , coerce_bool , normalize_vector3
811from transport .unity_transport import send_with_unity_instance
912from transport .legacy .unity_connection import async_send_command_with_retry
1013
2427 "set_blend" , "force_camera" , "release_override" , "list_cameras" ,
2528]
2629
27- ALL_ACTIONS = SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + EXTENSION_ACTIONS + CONTROL_ACTIONS
30+ CAPTURE_ACTIONS = ["screenshot" , "screenshot_multiview" ]
31+
32+ ALL_ACTIONS = SETUP_ACTIONS + CREATION_ACTIONS + CONFIGURATION_ACTIONS + EXTENSION_ACTIONS + CONTROL_ACTIONS + CAPTURE_ACTIONS
33+
34+
35+ def _extract_images (response : dict [str , Any ], action : str ) -> ToolResult | None :
36+ """If the Unity response contains inline base64 images, return a ToolResult
37+ with TextContent + ImageContent blocks. Returns None for normal text-only responses."""
38+ if not isinstance (response , dict ) or not response .get ("success" ):
39+ return None
40+
41+ data = response .get ("data" )
42+ if not isinstance (data , dict ):
43+ return None
44+
45+ # Batch images (surround/orbit mode) — multiple screenshots in one response
46+ screenshots = data .get ("screenshots" )
47+ if screenshots and isinstance (screenshots , list ):
48+ blocks : list [TextContent | ImageContent ] = []
49+ summary_screenshots = []
50+ for s in screenshots :
51+ summary_screenshots .append ({k : v for k , v in s .items () if k != "imageBase64" })
52+ text_result = {
53+ "success" : True ,
54+ "message" : response .get ("message" , "" ),
55+ "data" : {
56+ "sceneCenter" : data .get ("sceneCenter" ),
57+ "sceneRadius" : data .get ("sceneRadius" ),
58+ "screenshots" : summary_screenshots ,
59+ },
60+ }
61+ blocks .append (TextContent (type = "text" , text = json .dumps (text_result )))
62+ for s in screenshots :
63+ b64 = s .get ("imageBase64" )
64+ if b64 :
65+ blocks .append (TextContent (type = "text" , text = f"[Angle: { s .get ('angle' , '?' )} ]" ))
66+ blocks .append (ImageContent (type = "image" , data = b64 , mimeType = "image/png" ))
67+ return ToolResult (content = blocks )
68+
69+ # Single image (include_image or positioned capture) or contact sheet
70+ image_b64 = data .get ("imageBase64" )
71+ if not image_b64 :
72+ return None
73+ text_data = {k : v for k , v in data .items () if k != "imageBase64" }
74+ text_result = {"success" : True , "message" : response .get ("message" , "" ), "data" : text_data }
75+ return ToolResult (
76+ content = [
77+ TextContent (type = "text" , text = json .dumps (text_result )),
78+ ImageContent (type = "image" , data = image_b64 , mimeType = "image/png" ),
79+ ],
80+ )
2881
2982
3083@mcp_for_unity_tool (
55108 "- set_blend: Configure default blend (style: Cut/EaseInOut/Linear/etc., duration)\n "
56109 "- force_camera: Override Brain to use specific camera\n "
57110 "- release_override: Release camera override\n "
58- "- list_cameras: List all cameras with status\n "
111+ "- list_cameras: List all cameras with status\n \n "
112+ "CAPTURE:\n "
113+ "- screenshot: Capture from a camera. Supports include_image=true for inline base64 PNG, "
114+ "batch='surround' for 6-angle contact sheet, batch='orbit' for configurable grid, "
115+ "look_at/view_position for positioned capture.\n "
116+ "- screenshot_multiview: Shorthand for screenshot with batch='surround' and include_image=true."
59117 ),
60118 annotations = ToolAnnotations (
61119 title = "Manage Camera" ,
@@ -74,7 +132,34 @@ async def manage_camera(
74132 dict [str , Any ] | str | None ,
75133 "Action-specific parameters (dict or JSON string)." ,
76134 ] = None ,
77- ) -> dict [str , Any ]:
135+ # --- screenshot params ---
136+ screenshot_file_name : Annotated [str | None ,
137+ "Screenshot file name (optional). Defaults to timestamp." ] = None ,
138+ screenshot_super_size : Annotated [int | str | None ,
139+ "Screenshot supersize multiplier (integer >= 1)." ] = None ,
140+ camera : Annotated [str | None ,
141+ "Camera to capture from (name, path, or instance ID). Defaults to Camera.main." ] = None ,
142+ include_image : Annotated [bool | str | None ,
143+ "If true, return screenshot as inline base64 PNG. Default false." ] = None ,
144+ max_resolution : Annotated [int | str | None ,
145+ "Max resolution (longest edge px) for inline image. Default 640." ] = None ,
146+ batch : Annotated [str | None ,
147+ "Batch capture mode: 'surround' (6 angles) or 'orbit' (configurable grid)." ] = None ,
148+ look_at : Annotated [str | int | list [float ] | None ,
149+ "Target to aim camera at. GameObject name/path/ID or [x,y,z]." ] = None ,
150+ view_position : Annotated [list [float ] | str | None ,
151+ "World position [x,y,z] to place camera for positioned capture." ] = None ,
152+ view_rotation : Annotated [list [float ] | str | None ,
153+ "Euler rotation [x,y,z] for camera. Overrides look_at if both provided." ] = None ,
154+ orbit_angles : Annotated [int | str | None ,
155+ "Number of azimuth samples for batch='orbit' (default 8, max 36)." ] = None ,
156+ orbit_elevations : Annotated [list [float ] | str | None ,
157+ "Elevation angles in degrees for batch='orbit' (default [0, 30, -15])." ] = None ,
158+ orbit_distance : Annotated [float | str | None ,
159+ "Camera distance from target for batch='orbit' (default auto)." ] = None ,
160+ orbit_fov : Annotated [float | str | None ,
161+ "Camera FOV in degrees for batch='orbit' (default 60)." ] = None ,
162+ ) -> dict [str , Any ] | ToolResult :
78163 """Unified camera management tool (Unity Camera + Cinemachine)."""
79164
80165 action_normalized = action .lower ()
@@ -86,6 +171,7 @@ async def manage_camera(
86171 "Configuration" : CONFIGURATION_ACTIONS ,
87172 "Extensions" : EXTENSION_ACTIONS ,
88173 "Control" : CONTROL_ACTIONS ,
174+ "Capture" : CAPTURE_ACTIONS ,
89175 }
90176 category_list = "; " .join (
91177 f"{ cat } : { ', ' .join (actions )} " for cat , actions in categories .items ()
@@ -108,6 +194,64 @@ async def manage_camera(
108194 if search_method is not None :
109195 params_dict ["searchMethod" ] = search_method
110196
197+ # Screenshot params — only relevant for screenshot/screenshot_multiview actions
198+ if action_normalized in CAPTURE_ACTIONS :
199+ if screenshot_file_name :
200+ params_dict ["fileName" ] = screenshot_file_name
201+ coerced_super_size = coerce_int (screenshot_super_size , default = None )
202+ if coerced_super_size is not None :
203+ params_dict ["superSize" ] = coerced_super_size
204+ if camera :
205+ params_dict ["camera" ] = camera
206+ coerced_include_image = coerce_bool (include_image , default = None )
207+ if coerced_include_image is not None :
208+ params_dict ["includeImage" ] = coerced_include_image
209+ coerced_max_resolution = coerce_int (max_resolution , default = None )
210+ if coerced_max_resolution is not None :
211+ if coerced_max_resolution <= 0 :
212+ return {"success" : False , "message" : "max_resolution must be a positive integer." }
213+ params_dict ["maxResolution" ] = coerced_max_resolution
214+ if batch :
215+ params_dict ["batch" ] = batch
216+ if look_at is not None :
217+ params_dict ["lookAt" ] = look_at
218+
219+ # Orbit params
220+ coerced_orbit_angles = coerce_int (orbit_angles , default = None )
221+ if coerced_orbit_angles is not None :
222+ params_dict ["orbitAngles" ] = coerced_orbit_angles
223+ if orbit_elevations is not None :
224+ if isinstance (orbit_elevations , str ):
225+ try :
226+ orbit_elevations = json .loads (orbit_elevations )
227+ except (ValueError , TypeError ):
228+ return {"success" : False , "message" : "orbit_elevations must be a JSON array of floats." }
229+ if not isinstance (orbit_elevations , list ) or not all (
230+ isinstance (v , (int , float )) for v in orbit_elevations
231+ ):
232+ return {"success" : False , "message" : "orbit_elevations must be a list of numbers." }
233+ params_dict ["orbitElevations" ] = orbit_elevations
234+ if orbit_distance is not None :
235+ try :
236+ params_dict ["orbitDistance" ] = float (orbit_distance )
237+ except (ValueError , TypeError ):
238+ return {"success" : False , "message" : "orbit_distance must be a number." }
239+ if orbit_fov is not None :
240+ try :
241+ params_dict ["orbitFov" ] = float (orbit_fov )
242+ except (ValueError , TypeError ):
243+ return {"success" : False , "message" : "orbit_fov must be a number." }
244+ if view_position is not None :
245+ vec , err = normalize_vector3 (view_position , "view_position" )
246+ if err :
247+ return {"success" : False , "message" : err }
248+ params_dict ["viewPosition" ] = vec
249+ if view_rotation is not None :
250+ vec , err = normalize_vector3 (view_rotation , "view_rotation" )
251+ if err :
252+ return {"success" : False , "message" : err }
253+ params_dict ["viewRotation" ] = vec
254+
111255 params_dict = {k : v for k , v in params_dict .items () if v is not None }
112256
113257 result = await send_with_unity_instance (
@@ -117,4 +261,13 @@ async def manage_camera(
117261 params_dict ,
118262 )
119263
120- return result if isinstance (result , dict ) else {"success" : False , "message" : str (result )}
264+ if not isinstance (result , dict ):
265+ return {"success" : False , "message" : str (result )}
266+
267+ # For capture actions, check for inline images to return as ImageContent
268+ if action_normalized in CAPTURE_ACTIONS :
269+ image_result = _extract_images (result , "screenshot" )
270+ if image_result is not None :
271+ return image_result
272+
273+ return result
0 commit comments