|
5 | 5 | from abc import ABC, abstractmethod |
6 | 6 | from datetime import timedelta |
7 | 7 | from functools import wraps |
8 | | -from typing import Any, Literal, Protocol, Type |
| 8 | +from typing import Any, Callable, Literal, Protocol, Type |
9 | 9 |
|
10 | 10 | import jsonref |
11 | 11 | import mcp |
|
14 | 14 | from fastmcp.tools import Tool as FastMcpTool |
15 | 15 | from fastmcp.utilities.types import Image as FastMcpImage |
16 | 16 | from mcp import Tool as McpTool |
| 17 | +from mcp.types import ImageContent as McpImageContent |
| 18 | +from mcp.types import TextContent as McpTextContent |
17 | 19 | from PIL import Image |
18 | 20 | from pydantic import BaseModel, ConfigDict, Field, PrivateAttr |
19 | 21 | from typing_extensions import Self |
|
30 | 32 | ) |
31 | 33 | from askui.tools import AgentOs |
32 | 34 | from askui.tools.android.agent_os import AndroidAgentOs |
33 | | -from askui.utils.image_utils import ImageSource |
| 35 | +from askui.utils.image_utils import ImageSource, base64_to_image |
34 | 36 |
|
35 | 37 | logger = logging.getLogger(__name__) |
36 | 38 |
|
@@ -127,6 +129,26 @@ def _convert_to_mcp_content( |
127 | 129 | return result |
128 | 130 |
|
129 | 131 |
|
| 132 | +def _convert_from_mcp_tool_call_result( |
| 133 | + tool_name: str, |
| 134 | + result: Any, |
| 135 | +) -> PrimitiveToolCallResult: |
| 136 | + if isinstance(result, str): |
| 137 | + return result |
| 138 | + if not isinstance(result, (McpTextContent, McpImageContent)): |
| 139 | + unexpected_type = type(result).__name__ |
| 140 | + msg = ( |
| 141 | + f"MCP tool returned unexpected content type: {unexpected_type}. " |
| 142 | + "Expected McpTextContent or McpImageContent." |
| 143 | + ) |
| 144 | + raise McpToolAdapterException(tool_name, msg) |
| 145 | + |
| 146 | + if isinstance(result, McpImageContent): |
| 147 | + return base64_to_image(result.data) |
| 148 | + |
| 149 | + return result.text |
| 150 | + |
| 151 | + |
130 | 152 | PLAYWRIGHT_TOOL_PREFIX = "browser_" |
131 | 153 |
|
132 | 154 |
|
@@ -238,6 +260,92 @@ def wrapped_tool_call(*args: Any, **kwargs: Any) -> Any: |
238 | 260 | tags=tags, |
239 | 261 | ) |
240 | 262 |
|
| 263 | + @staticmethod |
| 264 | + def from_mcp_tool( |
| 265 | + mcp_tool: FastMcpTool, |
| 266 | + name_prefix: str | None = None, |
| 267 | + ) -> "Tool": |
| 268 | + """Wrap a FastMCP tool as an AskUI `Tool`. |
| 269 | +
|
| 270 | + Delegates execution and reuses the MCP tool's name, description, and |
| 271 | + input schema for `ToolCollection` and related flows. |
| 272 | +
|
| 273 | + Args: |
| 274 | + mcp_tool (FastMcpTool): The MCP tool to wrap. |
| 275 | + name_prefix (str | None, optional): If set, the AskUI tool name is |
| 276 | + `{name_prefix}{mcp_tool.name}`. |
| 277 | +
|
| 278 | + Returns: |
| 279 | + Tool: An AskUI tool whose `__call__` returns a `ToolCallResult`. |
| 280 | +
|
| 281 | + Notes: |
| 282 | + The underlying callable must return values this adapter can turn |
| 283 | + into text or image: `str`, `McpTextContent`, or `McpImageContent` |
| 284 | + (or a `list` / `tuple` of those). |
| 285 | + Any other types raise `McpToolAdapterException`. |
| 286 | +
|
| 287 | + Example: |
| 288 | + ```python |
| 289 | + from fastmcp.tools import Tool as FastMcpTool |
| 290 | + from askui.models.shared.tools import Tool |
| 291 | +
|
| 292 | + def my_tool(x: int) -> str: |
| 293 | + return str(x) |
| 294 | +
|
| 295 | + mcp_tool = FastMcpTool.from_function( |
| 296 | + my_tool, name="my_tool", description="Returns string of x" |
| 297 | + ) |
| 298 | + askui_tool = Tool.from_mcp_tool(mcp_tool) |
| 299 | + ``` |
| 300 | + """ |
| 301 | + return _McpToolAdapter(mcp_tool=mcp_tool, name_prefix=name_prefix) |
| 302 | + |
| 303 | + |
| 304 | +class McpToolAdapterException(Exception): |
| 305 | + """ |
| 306 | + Exception raised when the MCP tool adapter fails. |
| 307 | + """ |
| 308 | + |
| 309 | + def __init__(self, tool_name: str, reason: str): |
| 310 | + self.tool_name = tool_name |
| 311 | + super().__init__( |
| 312 | + f"Failed to convert MCP to AskUI tool {tool_name}: Reason {reason}" |
| 313 | + ) |
| 314 | + |
| 315 | + |
| 316 | +class _McpToolAdapter(Tool): |
| 317 | + """Concrete Tool that delegates to a FastMCP tool.""" |
| 318 | + |
| 319 | + def __init__( |
| 320 | + self, |
| 321 | + mcp_tool: FastMcpTool, |
| 322 | + name_prefix: str | None = None, |
| 323 | + ) -> None: |
| 324 | + name = mcp_tool.name |
| 325 | + if name_prefix is not None: |
| 326 | + name = f"{name_prefix}{name}" |
| 327 | + super().__init__( |
| 328 | + name=name, |
| 329 | + description=mcp_tool.description or "", |
| 330 | + input_schema=mcp_tool.parameters, |
| 331 | + ) |
| 332 | + self._function: Callable[[Any], Any] = mcp_tool.fn # type: ignore[attr-defined] |
| 333 | + if not callable(self._function): |
| 334 | + msg = "MCP tool has no callable (fn or __call__)" |
| 335 | + raise McpToolAdapterException(self.name, msg) |
| 336 | + |
| 337 | + def __call__(self, *args: Any, **kwargs: Any) -> ToolCallResult: |
| 338 | + result = self._function(*args, **kwargs) |
| 339 | + if isinstance(result, list): |
| 340 | + return [ |
| 341 | + _convert_from_mcp_tool_call_result(self.name, item) for item in result |
| 342 | + ] |
| 343 | + if isinstance(result, tuple): |
| 344 | + return tuple( |
| 345 | + _convert_from_mcp_tool_call_result(self.name, item) for item in result |
| 346 | + ) |
| 347 | + return _convert_from_mcp_tool_call_result(self.name, result) |
| 348 | + |
241 | 349 |
|
242 | 350 | class ToolWithAgentOS(Tool): |
243 | 351 | """Tool base class that has an AgentOs available.""" |
|
0 commit comments