Skip to content

Commit 9266293

Browse files
Merge pull request #250 from askui/feat/tool-from-mcp-fastmcp
feat: wrap FastMCP tools as AskUI Tool via Tool.from_mcp_tool
2 parents a9847aa + 2954089 commit 9266293

1 file changed

Lines changed: 110 additions & 2 deletions

File tree

src/askui/models/shared/tools.py

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from abc import ABC, abstractmethod
66
from datetime import timedelta
77
from functools import wraps
8-
from typing import Any, Literal, Protocol, Type
8+
from typing import Any, Callable, Literal, Protocol, Type
99

1010
import jsonref
1111
import mcp
@@ -14,6 +14,8 @@
1414
from fastmcp.tools import Tool as FastMcpTool
1515
from fastmcp.utilities.types import Image as FastMcpImage
1616
from mcp import Tool as McpTool
17+
from mcp.types import ImageContent as McpImageContent
18+
from mcp.types import TextContent as McpTextContent
1719
from PIL import Image
1820
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
1921
from typing_extensions import Self
@@ -30,7 +32,7 @@
3032
)
3133
from askui.tools import AgentOs
3234
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
3436

3537
logger = logging.getLogger(__name__)
3638

@@ -127,6 +129,26 @@ def _convert_to_mcp_content(
127129
return result
128130

129131

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+
130152
PLAYWRIGHT_TOOL_PREFIX = "browser_"
131153

132154

@@ -238,6 +260,92 @@ def wrapped_tool_call(*args: Any, **kwargs: Any) -> Any:
238260
tags=tags,
239261
)
240262

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+
241349

242350
class ToolWithAgentOS(Tool):
243351
"""Tool base class that has an AgentOs available."""

0 commit comments

Comments
 (0)