From 57cbb2e9dd39bc5faabdb7dd1548ac90ceace9b5 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Fri, 24 Jan 2025 15:33:25 +0000 Subject: [PATCH 1/9] initial commit for declarative-mcp-tools --- python/packages/autogen-ext/pyproject.toml | 5 + .../src/autogen_ext/tools/mcp/__init__.py | 3 + .../src/autogen_ext/tools/mcp/_mcp_tool.py | 174 ++++++++++++++++++ .../database/component_factory.py | 28 ++- .../autogenstudio/datamodel/types.py | 23 ++- python/packages/autogen-studio/pyproject.toml | 6 +- python/uv.lock | 89 ++++++++- 7 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py create mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 20579c99baec..793bdf9a654a 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -105,6 +105,11 @@ semantic-kernel-all = [ "semantic-kernel[google,hugging_face,mistralai,ollama,onnx,anthropic,usearch,pandas,aws,dapr]>=1.17.1", ] +mcp = [ + "mcp>=1.1.3", + "json-schema-to-pydantic>=0.2.0" +] + [tool.hatch.build.targets.wheel] packages = ["src/autogen_ext"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py new file mode 100644 index 000000000000..556a1019e68d --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py @@ -0,0 +1,3 @@ +from ._mcp_tool import SseMcpTool, SseMcpToolBuilder, SseServerParameters, StdioMcpTool, StdioMcpToolBuilder + +__all__ = ["StdioMcpTool", "StdioMcpToolBuilder", "SseMcpTool", "SseMcpToolBuilder", "SseServerParameters"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py new file mode 100644 index 000000000000..6d464d3586f6 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py @@ -0,0 +1,174 @@ +from abc import ABC, abstractmethod +from typing import Any, Type + +from autogen_core import CancellationToken +from autogen_core.tools import BaseTool +from json_schema_to_pydantic import create_model +from mcp import ClientSession, StdioServerParameters, Tool, stdio_client +from mcp.client.sse import sse_client +from pydantic import BaseModel + + +class StdioMcpTool(BaseTool[BaseModel, Any]): + """Adapter for MCP tools to make them compatible with AutoGen. + + Args: + server_params (StdioServerParameters): Parameters for the MCP server connection + tool (Tool): The MCP tool to wrap + """ + + def __init__(self, server_params: StdioServerParameters, tool: Tool) -> None: + self._tool = tool + self.server_params = server_params + + # Extract name and description + name = tool.name + description = tool.description or "" + + # Validate and extract schema information with detailed errors + if tool.inputSchema is None: + raise ValueError(f"Tool {name} has no input schema defined") + + if not isinstance(tool.inputSchema, dict): + raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") + + # Create the input model from the tool's schema + input_model = create_model(tool.inputSchema) + + # Use Any as return type since MCP tool returns can vary + return_type: Type[Any] = object + + super().__init__(input_model, return_type, name, description) + + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: + """Execute the MCP tool with the given arguments. + + Args: + args: The validated input arguments + cancellation_token: Token for cancelling the operation + + Returns: + The result from the MCP tool + + Raises: + Exception: If tool execution fails + """ + kwargs = args.model_dump() + + try: + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + if cancellation_token.is_cancelled(): + raise Exception("Operation cancelled") + + result = await session.call_tool(self._tool.name, kwargs) + + if result.isError: + raise Exception(f"MCP tool execution failed: {result.content}") + return result.content + except Exception as e: + raise Exception(str(e)) from e + +class StdioMcpToolBuilder: + def __init__(self, server_params: StdioServerParameters, tool_name: str) -> None: + self.server_params: StdioServerParameters = server_params + self.tool_name: str = tool_name + + async def build(self) -> StdioMcpTool: + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools_list = await session.list_tools() + for tool in tools_list.tools: + if tool.name == self.tool_name: + return StdioMcpTool(self.server_params, tool) + raise ValueError(f"Tool {self.tool_name} not found") + +class SseServerParameters(BaseModel): + url: str + headers: dict[str, Any] + + +class SseMcpTool(BaseTool[BaseModel, Any]): + """Adapter for MCP tools to make them compatible with AutoGen. + + Args: + server_params (SseServerParameters): Parameters for the MCP server connection + tool (Tool): The MCP tool to wrap + """ + + def __init__(self, server_params: SseServerParameters, tool: Tool) -> None: + self._tool = tool + self.server_params = server_params + + # Extract name and description + name = tool.name + description = tool.description or "" + + # Validate and extract schema information with detailed errors + if tool.inputSchema is None: + raise ValueError(f"Tool {name} has no input schema defined") + + if not isinstance(tool.inputSchema, dict): + raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") + + # Create the input model from the tool's schema + input_model = create_model(tool.inputSchema) + + # Use Any as return type since MCP tool returns can vary + return_type: Type[Any] = object + + super().__init__(input_model, return_type, name, description) + + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: + """Execute the MCP tool with the given arguments. + + Args: + args: The validated input arguments + cancellation_token: Token for cancelling the operation + + Returns: + The result from the MCP tool + + Raises: + Exception: If tool execution fails + """ + kwargs = args.model_dump() + + try: + async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + if cancellation_token.is_cancelled(): + raise Exception("Operation cancelled") + + result = await session.call_tool(self._tool.name, kwargs) + + if result.isError: + raise Exception(f"MCP tool execution failed: {result.content}") + return result.content + except Exception as e: + raise Exception(str(e)) from e + + +class SseMcpToolBuilder: + def __init__(self, server_params: SseServerParameters, tool_name: str) -> None: + self.server_params = server_params + self.tool_name = tool_name + + async def build(self) -> SseMcpTool: + async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools_list = await session.list_tools() + for tool in tools_list.tools: + if tool.name == self.tool_name: + return SseMcpTool(self.server_params, tool) + + raise ValueError(f"Tool {self.tool_name} not found") + pass \ No newline at end of file diff --git a/python/packages/autogen-studio/autogenstudio/database/component_factory.py b/python/packages/autogen-studio/autogenstudio/database/component_factory.py index b954b39c0f4b..782ed94dbd45 100644 --- a/python/packages/autogen-studio/autogenstudio/database/component_factory.py +++ b/python/packages/autogen-studio/autogenstudio/database/component_factory.py @@ -17,11 +17,13 @@ TimeoutTermination, TokenUsageTermination, ) +from mcp import StdioServerParameters from autogen_agentchat.teams import MagenticOneGroupChat, RoundRobinGroupChat, SelectorGroupChat from autogen_core.tools import FunctionTool from autogen_ext.agents.file_surfer import FileSurfer from autogen_ext.agents.magentic_one import MagenticOneCoderAgent from autogen_ext.agents.web_surfer import MultimodalWebSurfer +from autogen_ext.tools.mcp import StdioMcpTool, StdioMcpToolBuilder from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from ..datamodel.types import ( @@ -47,6 +49,9 @@ TerminationTypes, TextMentionTerminationConfig, ToolConfig, + PythonFunctionToolConfig, + StdioMcpToolConfig, + SseMcpToolConfig, ToolTypes, UserProxyAgentConfig, ) @@ -57,7 +62,7 @@ TeamComponent = Union[RoundRobinGroupChat, SelectorGroupChat, MagenticOneGroupChat] AgentComponent = Union[AssistantAgent, MultimodalWebSurfer, UserProxyAgent, FileSurfer, MagenticOneCoderAgent] ModelComponent = Union[OpenAIChatCompletionClient, AzureOpenAIChatCompletionClient] -ToolComponent = Union[FunctionTool] # Will grow with more tool types +ToolComponent = Union[FunctionTool, StdioMcpTool] # Will grow with more tool types TerminationComponent = Union[ MaxMessageTermination, StopMessageTermination, @@ -325,7 +330,8 @@ async def load_team(self, config: TeamConfig, input_func: Optional[Callable] = N async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = None) -> AgentComponent: """Create agent instance from configuration.""" - + logger.error(f"Loading agent {config.name}") + logger.error(f"Config: {config}") model_client = None system_message = None tools = [] @@ -335,6 +341,8 @@ async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = model_client = await self.load(config.model_client) if hasattr(config, "tools") and config.tools: for tool_config in config.tools: + logger.error(f"Loading tool {tool_config.name}") + logger.error(f"Config: {tool_config}") tool = await self.load(tool_config) tools.append(tool) @@ -424,11 +432,9 @@ async def load_model(self, config: ModelConfig) -> ModelComponent: async def load_tool(self, config: ToolConfig) -> ToolComponent: """Create tool instance from configuration.""" + logger.error(f"Loading tool {config.name}") + logger.error(f"Config: {config}") try: - # Validate required fields - if not all([config.name, config.description, config.content, config.tool_type]): - raise ValueError("Tool configuration missing required fields") - # Check cache first cache_key = str(config.model_dump()) if cache_key in self._tool_cache: @@ -436,11 +442,21 @@ async def load_tool(self, config: ToolConfig) -> ToolComponent: return self._tool_cache[cache_key] if config.tool_type == ToolTypes.PYTHON_FUNCTION: + # Validate required fields + if not all([config.name, config.description, config.content, config.tool_type]): + raise ValueError("Tool configuration missing required fields") tool = FunctionTool( name=config.name, description=config.description, func=self._func_from_string(config.content) ) self._tool_cache[cache_key] = tool return tool + elif config.tool_type == ToolTypes.MCP_STDIO_CLIENT: + cfg = StdioServerParameters( + command=config.command, + args=config.args, + ) + builder = StdioMcpToolBuilder(server_params=cfg, tool_name=config.name) + return await builder.build() else: raise ValueError(f"Unsupported tool type: {config.tool_type}") diff --git a/python/packages/autogen-studio/autogenstudio/datamodel/types.py b/python/packages/autogen-studio/autogenstudio/datamodel/types.py index eb02fb121ebe..682303e368f4 100644 --- a/python/packages/autogen-studio/autogenstudio/datamodel/types.py +++ b/python/packages/autogen-studio/autogenstudio/datamodel/types.py @@ -15,6 +15,8 @@ class ModelTypes(str, Enum): class ToolTypes(str, Enum): PYTHON_FUNCTION = "PythonFunction" + MCP_STDIO_CLIENT = "McpStdioClient" + MCP_SSE_CLIENT = "McpSseClient" class AgentTypes(str, Enum): @@ -84,14 +86,31 @@ class AzureOpenAIModelConfig(BaseModelConfig): ModelConfig = OpenAIModelConfig | AzureOpenAIModelConfig -class ToolConfig(BaseConfig): +class BaseToolConfig(BaseConfig): name: str description: str - content: str tool_type: ToolTypes component_type: ComponentTypes = ComponentTypes.TOOL +class PythonFunctionToolConfig(BaseToolConfig): + content: str + tool_type: ToolTypes = ToolTypes.PYTHON_FUNCTION + +class StdioMcpToolConfig(BaseToolConfig): + command: str + args: List[str] + tool_type: ToolTypes = ToolTypes.MCP_STDIO_CLIENT + + +class SseMcpToolConfig(BaseToolConfig): + url: str + headers: Dict[str, Any] + tool_type: ToolTypes = ToolTypes.MCP_SSE_CLIENT + +ToolConfig = PythonFunctionToolConfig | StdioMcpToolConfig | SseMcpToolConfig + + class BaseAgentConfig(BaseConfig): name: str agent_type: AgentTypes diff --git a/python/packages/autogen-studio/pyproject.toml b/python/packages/autogen-studio/pyproject.toml index a8d6911d3e26..c2d7b63a35c8 100644 --- a/python/packages/autogen-studio/pyproject.toml +++ b/python/packages/autogen-studio/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ "autogen-core>=0.4.2,<0.5", "autogen-agentchat>=0.4.2,<0.5", "autogen-ext[magentic-one, openai, azure]>=0.4.2,<0.5", - "azure-identity" + "azure-identity", + "mcp", ] optional-dependencies = {web = ["fastapi", "uvicorn"], database = ["psycopg"]} @@ -89,3 +90,6 @@ fmt = "ruff format" format.ref = "fmt" lint = "ruff check" test = "pytest -n 0 --cov=autogenstudio --cov-report=term-missing" + +[tool.uv.sources] +mcp = { path = "../../../../../modelcontextprotocol/python-sdk" } diff --git a/python/uv.lock b/python/uv.lock index e8a07804e93d..a809a3b24096 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -593,6 +593,10 @@ magentic-one = [ { name = "pillow" }, { name = "playwright" }, ] +mcp = [ + { name = "json-schema-to-pydantic" }, + { name = "mcp" }, +] openai = [ { name = "aiofiles" }, { name = "openai" }, @@ -670,10 +674,12 @@ requires-dist = [ { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.62.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, + { name = "json-schema-to-pydantic", marker = "extra == 'mcp'", specifier = ">=0.2.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = "~=0.3.3" }, { name = "markitdown", marker = "extra == 'file-surfer'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'magentic-one'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'web-surfer'", specifier = ">=0.0.1a2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.1.3" }, { name = "nbclient", marker = "extra == 'jupyter-executor'", specifier = ">=0.10.2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.52.2" }, { name = "openai-whisper", marker = "extra == 'video-surfer'" }, @@ -803,6 +809,7 @@ dependencies = [ { name = "azure-identity" }, { name = "fastapi" }, { name = "loguru" }, + { name = "mcp" }, { name = "numpy" }, { name = "psycopg" }, { name = "pydantic" }, @@ -835,6 +842,7 @@ requires-dist = [ { name = "fastapi" }, { name = "fastapi", marker = "extra == 'web'" }, { name = "loguru" }, + { name = "mcp", directory = "../../../modelcontextprotocol/python-sdk" }, { name = "numpy", specifier = "<2.0.0" }, { name = "psycopg" }, { name = "psycopg", marker = "extra == 'database'" }, @@ -1110,7 +1118,7 @@ wheels = [ [[package]] name = "chainlit" -version = "2.0.1" +version = "2.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -1136,9 +1144,9 @@ dependencies = [ { name = "uvicorn" }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/15/26dc5f957c6344813b2ae8c6f52cc820a7074088509ea947da0cf76ffc5f/chainlit-2.0.1.tar.gz", hash = "sha256:9fb7728aa5704e823c5b5d51f570dcfabafdcc97c23a73e6047f65eb72c938e7", size = 4637433 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/d8/7173caf3ca0d7480b3614e3126da9c592692d353764326fc0e1702b9eddd/chainlit-2.0.5.tar.gz", hash = "sha256:8af7746999d6641c69c33b67e5325e2d018432dd0b3306926d7435b862b0bfe2", size = 4646512 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/99/c63fa2e1d7b949c034b7fc838a0c00de22cd2cec30245e379c9dd15dedfd/chainlit-2.0.1-py3-none-any.whl", hash = "sha256:84982902c6f42a91ac341ea9b6d52e6b1348e53a60ee49b4ffe0e5e5be02f4ba", size = 4703745 }, + { url = "https://files.pythonhosted.org/packages/dc/01/8f02145330355e2802b95f835afb4cf11ea503b779cd6136892d4940abc5/chainlit-2.0.5-py3-none-any.whl", hash = "sha256:30cd2c39a9393de047b4e64b3dcf84ca4f691cb61445d59ae9a29f8e1f1af006", size = 4709971 }, ] [[package]] @@ -2735,6 +2743,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/2d/79a46330c4b97ee90dd403fb0d267da7b25b24d7db604c5294e5c57d5f7c/json_repair-0.30.3-py3-none-any.whl", hash = "sha256:63bb588162b0958ae93d85356ecbe54c06b8c33f8a4834f93fa2719ea669804e", size = 18951 }, ] +[[package]] +name = "json-schema-to-pydantic" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/87/af1870beea329744a759349b972b309f8c95ae21e986e387e19733b85cc9/json_schema_to_pydantic-0.2.2.tar.gz", hash = "sha256:685db8d93aa29ccd257b2803fcd9a956c527e5fb108a523cbfe8cac1239b3785", size = 34158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8d/3c738e4b4b041269c4a506544b90e9ba924bbd800c8d496ed3e5a6da0265/json_schema_to_pydantic-0.2.2-py3-none-any.whl", hash = "sha256:01b82d234f2b482a273e117e29d063b6b86021a250035873d6eec4b85b70e64d", size = 11396 }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -3156,7 +3176,7 @@ wheels = [ [[package]] name = "literalai" -version = "0.0.623" +version = "0.1.103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chevron" }, @@ -3164,7 +3184,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/af/07d943e62a1297a7b44777297c0dca8f4bfcd6ae18b9df7d3cd9c1970e29/literalai-0.0.623.tar.gz", hash = "sha256:d65c04dde6b1e99d585e4112a607e5fd574d282b70f600c55a671018340dfb0f", size = 57081 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/fc/628b39e31b368aacbca51721ba7a66a4d140e9be916a0c7396664fdaed7a/literalai-0.1.103.tar.gz", hash = "sha256:060e86e63c0f53041a737b2183354ac092ee8cd9faec817dc95df639bb263a7d", size = 62540 } [[package]] name = "llama-cloud" @@ -3768,6 +3788,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, ] +[[package]] +name = "mcp" +version = "1.3.0.dev0" +source = { directory = "../../../modelcontextprotocol/python-sdk" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx-sse", specifier = ">=0.4" }, + { name = "pydantic", specifier = ">=2.10.1,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.6.1" }, + { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, + { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "starlette", specifier = ">=0.27" }, + { name = "typer", marker = "extra == 'cli'", specifier = ">=0.12.4" }, + { name = "uvicorn", specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-flakefinder", specifier = ">=1.1.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.8.5" }, + { name = "trio", specifier = ">=0.26.2" }, +] + [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -6662,6 +6722,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -7365,16 +7438,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.25.0" +version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/54/0eb4441bf38c70f6ed1886dddb2e29d1650026041d19e49fc373e332fa60/uvicorn-0.25.0.tar.gz", hash = "sha256:6dddbad1d7ee0f5140aba5ec138ddc9612c5109399903828b4874c9937f009c2", size = 40724 } +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/59/fddd9df489fe27f492cc97626e03663fb3b9b6ef7ce8597a7cdc5f2cbbad/uvicorn-0.25.0-py3-none-any.whl", hash = "sha256:ce107f5d9bd02b4636001a77a4e74aab5e1e2b146868ebbad565237145af444c", size = 60303 }, + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, ] [[package]] From 9c4a00b533354e1729734c2771934ce24ea68ada Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 15:57:20 +0000 Subject: [PATCH 2/9] remove MCP and add more http options --- python/packages/autogen-ext/pyproject.toml | 9 +- .../src/autogen_ext/tools/http/_http_tool.py | 28 ++- .../src/autogen_ext/tools/mcp/__init__.py | 3 - .../src/autogen_ext/tools/mcp/_mcp_tool.py | 174 ------------------ python/uv.lock | 12 +- 5 files changed, 36 insertions(+), 190 deletions(-) delete mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py delete mode 100644 python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index 4eef314871f7..b914573fba82 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -106,14 +106,15 @@ semantic-kernel-dapr = [ "semantic-kernel[dapr]>=1.17.1", ] +http = [ + "httpx>=0.27.0", + "json-schema-to-pydantic>=0.2.0" +] + semantic-kernel-all = [ "semantic-kernel[google,hugging_face,mistralai,ollama,onnx,anthropic,usearch,pandas,aws,dapr]>=1.17.1", ] -mcp = [ - "mcp>=1.1.3", - "json-schema-to-pydantic>=0.2.0" -] rich = ["rich>=13.9.4"] [tool.hatch.build.targets.wheel] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 71967063fa0c..7dda63f42521 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,5 +1,4 @@ -import json -from typing import Any, Optional, Type +from typing import Any, Literal, Optional, Type import httpx from autogen_core import CancellationToken, Component @@ -21,6 +20,10 @@ class HttpToolConfig(BaseModel): """ The URL to send the request to. """ + method: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH"]] = "POST" + """ + The HTTP method to use, will default to POST if not provided. + """ headers: Optional[dict[str, Any]] """ A dictionary of headers to send with the request. @@ -54,6 +57,15 @@ def __init__(self, server_params: HttpToolConfig) -> None: super().__init__(input_model, return_type, name, description) + def _to_config(self) -> HttpToolConfig: + copied_config = self.server_params.copy() + return copied_config + + @classmethod + def _from_config(cls, config: HttpToolConfig): + copied_config = config.model_copy().model_dump(exclude_none=True) + return cls(**copied_config) + async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: """Execute the MCP tool with the given arguments. @@ -69,6 +81,16 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A """ async with httpx.AsyncClient() as client: - response = await client.post(self.server_params.url, json=args.model_dump()) + match self.server_params.method: + case "GET": + response = await client.get(self.server_params.url, params=args.model_dump()) + case "PUT": + response = await client.put(self.server_params.url, json=args.model_dump()) + case "DELETE": + response = await client.delete(self.server_params.url, params=args.model_dump()) + case "PATCH": + response = await client.patch(self.server_params.url, json=args.model_dump()) + case _: # Default case + response = await client.post(self.server_params.url, json=args.model_dump()) return response.json() diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py deleted file mode 100644 index 556a1019e68d..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._mcp_tool import SseMcpTool, SseMcpToolBuilder, SseServerParameters, StdioMcpTool, StdioMcpToolBuilder - -__all__ = ["StdioMcpTool", "StdioMcpToolBuilder", "SseMcpTool", "SseMcpToolBuilder", "SseServerParameters"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py deleted file mode 100644 index 6d464d3586f6..000000000000 --- a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_mcp_tool.py +++ /dev/null @@ -1,174 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Type - -from autogen_core import CancellationToken -from autogen_core.tools import BaseTool -from json_schema_to_pydantic import create_model -from mcp import ClientSession, StdioServerParameters, Tool, stdio_client -from mcp.client.sse import sse_client -from pydantic import BaseModel - - -class StdioMcpTool(BaseTool[BaseModel, Any]): - """Adapter for MCP tools to make them compatible with AutoGen. - - Args: - server_params (StdioServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap - """ - - def __init__(self, server_params: StdioServerParameters, tool: Tool) -> None: - self._tool = tool - self.server_params = server_params - - # Extract name and description - name = tool.name - description = tool.description or "" - - # Validate and extract schema information with detailed errors - if tool.inputSchema is None: - raise ValueError(f"Tool {name} has no input schema defined") - - if not isinstance(tool.inputSchema, dict): - raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") - - # Create the input model from the tool's schema - input_model = create_model(tool.inputSchema) - - # Use Any as return type since MCP tool returns can vary - return_type: Type[Any] = object - - super().__init__(input_model, return_type, name, description) - - async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. - - Args: - args: The validated input arguments - cancellation_token: Token for cancelling the operation - - Returns: - The result from the MCP tool - - Raises: - Exception: If tool execution fails - """ - kwargs = args.model_dump() - - try: - async with stdio_client(self.server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - if cancellation_token.is_cancelled(): - raise Exception("Operation cancelled") - - result = await session.call_tool(self._tool.name, kwargs) - - if result.isError: - raise Exception(f"MCP tool execution failed: {result.content}") - return result.content - except Exception as e: - raise Exception(str(e)) from e - -class StdioMcpToolBuilder: - def __init__(self, server_params: StdioServerParameters, tool_name: str) -> None: - self.server_params: StdioServerParameters = server_params - self.tool_name: str = tool_name - - async def build(self) -> StdioMcpTool: - async with stdio_client(self.server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools_list = await session.list_tools() - for tool in tools_list.tools: - if tool.name == self.tool_name: - return StdioMcpTool(self.server_params, tool) - raise ValueError(f"Tool {self.tool_name} not found") - -class SseServerParameters(BaseModel): - url: str - headers: dict[str, Any] - - -class SseMcpTool(BaseTool[BaseModel, Any]): - """Adapter for MCP tools to make them compatible with AutoGen. - - Args: - server_params (SseServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap - """ - - def __init__(self, server_params: SseServerParameters, tool: Tool) -> None: - self._tool = tool - self.server_params = server_params - - # Extract name and description - name = tool.name - description = tool.description or "" - - # Validate and extract schema information with detailed errors - if tool.inputSchema is None: - raise ValueError(f"Tool {name} has no input schema defined") - - if not isinstance(tool.inputSchema, dict): - raise ValueError(f"Invalid input schema for tool {name}: expected dictionary, got {type(tool.inputSchema)}") - - # Create the input model from the tool's schema - input_model = create_model(tool.inputSchema) - - # Use Any as return type since MCP tool returns can vary - return_type: Type[Any] = object - - super().__init__(input_model, return_type, name, description) - - async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. - - Args: - args: The validated input arguments - cancellation_token: Token for cancelling the operation - - Returns: - The result from the MCP tool - - Raises: - Exception: If tool execution fails - """ - kwargs = args.model_dump() - - try: - async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - if cancellation_token.is_cancelled(): - raise Exception("Operation cancelled") - - result = await session.call_tool(self._tool.name, kwargs) - - if result.isError: - raise Exception(f"MCP tool execution failed: {result.content}") - return result.content - except Exception as e: - raise Exception(str(e)) from e - - -class SseMcpToolBuilder: - def __init__(self, server_params: SseServerParameters, tool_name: str) -> None: - self.server_params = server_params - self.tool_name = tool_name - - async def build(self) -> SseMcpTool: - async with sse_client(self.server_params.url, headers=self.server_params.headers) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools_list = await session.list_tools() - for tool in tools_list.tools: - if tool.name == self.tool_name: - return SseMcpTool(self.server_params, tool) - - raise ValueError(f"Tool {self.tool_name} not found") - pass \ No newline at end of file diff --git a/python/uv.lock b/python/uv.lock index bf987fee5d8a..10714effc692 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -581,6 +581,10 @@ graphrag = [ grpc = [ { name = "grpcio" }, ] +http = [ + { name = "httpx" }, + { name = "json-schema-to-pydantic" }, +] jupyter-executor = [ { name = "ipykernel" }, { name = "nbclient" }, @@ -594,10 +598,6 @@ magentic-one = [ { name = "pillow" }, { name = "playwright" }, ] -mcp = [ - { name = "json-schema-to-pydantic" }, - { name = "mcp" }, -] openai = [ { name = "aiofiles" }, { name = "openai" }, @@ -678,13 +678,13 @@ requires-dist = [ { name = "ffmpeg-python", marker = "extra == 'video-surfer'" }, { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.62.0" }, + { name = "httpx", marker = "extra == 'http'", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, - { name = "json-schema-to-pydantic", marker = "extra == 'mcp'", specifier = ">=0.2.0" }, + { name = "json-schema-to-pydantic", marker = "extra == 'http'", specifier = ">=0.2.0" }, { name = "langchain-core", marker = "extra == 'langchain'", specifier = "~=0.3.3" }, { name = "markitdown", marker = "extra == 'file-surfer'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'magentic-one'", specifier = ">=0.0.1a2" }, { name = "markitdown", marker = "extra == 'web-surfer'", specifier = ">=0.0.1a2" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.1.3" }, { name = "nbclient", marker = "extra == 'jupyter-executor'", specifier = ">=0.10.2" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.52.2" }, { name = "openai-whisper", marker = "extra == 'video-surfer'" }, From 95fc65e2979580f7ff661e11a19e9af0ea33c05d Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 22:40:01 +0000 Subject: [PATCH 3/9] unit tests are working --- .../src/autogen_ext/tools/http/__init__.py | 4 +- .../src/autogen_ext/tools/http/_http_tool.py | 95 ++++++++++-- .../autogen-ext/tests/tools/http/conftest.py | 89 +++++++++++ .../tests/tools/http/test_http_tool.py | 144 ++++++++++++++++++ 4 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 python/packages/autogen-ext/tests/tools/http/conftest.py create mode 100644 python/packages/autogen-ext/tests/tools/http/test_http_tool.py diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py index 9aa4ee155551..6c276b625e3f 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/__init__.py @@ -1,3 +1,3 @@ -from ._http_tool import HttpTool, HttpToolConfig +from ._http_tool import HttpTool -__all__ = ["HttpTool", "HttpToolConfig"] +__all__ = ["HttpTool"] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 7dda63f42521..f9c690f6aeea 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -35,22 +35,91 @@ class HttpToolConfig(BaseModel): class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): - """Adapter for MCP tools to make them compatible with AutoGen. + """A wrapper for using an HTTP server as a tool. Args: - server_params (StdioServerParameters): Parameters for the MCP server connection - tool (Tool): The MCP tool to wrap + name (str): The name of the tool. + description (str, optional): A description of the tool. + url (str): The URL to send the request to. + method (str, optional): The HTTP method to use, will default to POST if not provided. + Must be one of "GET", "POST", "PUT", "DELETE", "PATCH". + headers (dict[str, Any], optional): A dictionary of headers to send with the request. + json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. + + Example: + Simple usage case:: + + import asyncio + from autogen_ext.tools.http import HttpTool + from autogen_agentchat.agents import AssistantAgent + from autogen_ext.models.openai import OpenAIChatCompletionClient + + # Define a JSON schema for a weather API + weather_schema = { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The city to get weather for"}, + "country": {"type": "string", "description": "The country code"} + }, + "required": ["city"] + } + + # Create an HTTP tool for the weather API + weather_tool = HttpTool( + name="get_weather", + description="Get the current weather for a city", + url="https://api.weatherapi.com/v1/current.json", + method="GET", + headers={"key": "your-api-key"}, + json_schema=weather_schema + ) + + async def main(): + # Create an assistant with the weather tool + model = OpenAIChatCompletionClient(model="gpt-4") + assistant = AssistantAgent( + "weather_assistant", + model_client=model, + tools=[weather_tool] + ) + + # The assistant can now use the weather tool to get weather data + response = await assistant.on_messages([ + TextMessage(content="What's the weather like in London?") + ]) + print(response.chat_message.content) + + asyncio.run(main()) """ - def __init__(self, server_params: HttpToolConfig) -> None: - self.server_params = server_params + component_type = "agent" + component_provider_override = "autogen_ext.tools.http.HttpTool" + component_config_schema = HttpToolConfig + + def __init__( + self, + name: str, + url: str, + json_schema: dict[str, Any], + headers: Optional[dict[str, Any]], + description: str = "HTTP tool", + method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "POST", + ) -> None: + self.server_params = HttpToolConfig( + name=name, + description=description, + url=url, + method=method, + headers=headers, + json_schema=json_schema, + ) # Extract name and description - name = server_params.name - description = server_params.description or "" + name = self.server_params.name + description = self.server_params.description or "" # Create the input model from the tool's schema - input_model = create_model(server_params.json_schema) + input_model = create_model(self.server_params.json_schema) # Use Any as return type since MCP tool returns can vary return_type: Type[Any] = object @@ -58,23 +127,23 @@ def __init__(self, server_params: HttpToolConfig) -> None: super().__init__(input_model, return_type, name, description) def _to_config(self) -> HttpToolConfig: - copied_config = self.server_params.copy() + copied_config = self.server_params.model_copy() return copied_config @classmethod def _from_config(cls, config: HttpToolConfig): - copied_config = config.model_copy().model_dump(exclude_none=True) + copied_config = config.model_copy().model_dump() return cls(**copied_config) async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the MCP tool with the given arguments. + """Execute the HTTO tool with the given arguments. Args: args: The validated input arguments cancellation_token: Token for cancelling the operation Returns: - The result from the MCP tool + The response body from the HTTP call in JSON format Raises: Exception: If tool execution fails @@ -90,7 +159,7 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A response = await client.delete(self.server_params.url, params=args.model_dump()) case "PATCH": response = await client.patch(self.server_params.url, json=args.model_dump()) - case _: # Default case + case _: # Default case response = await client.post(self.server_params.url, json=args.model_dump()) return response.json() diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py new file mode 100644 index 000000000000..43708b712541 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -0,0 +1,89 @@ +import asyncio +from typing import AsyncGenerator + +import pytest +import pytest_asyncio +import uvicorn +from autogen_core import CancellationToken, ComponentModel +from autogen_ext.tools.http import HttpTool +from fastapi import Body, FastAPI +from pydantic import BaseModel, Field + + +class TestArgs(BaseModel): + query: str = Field(description="The test query") + value: int = Field(description="A test value") + + +class TestResponse(BaseModel): + result: str = Field(description="The test result") + + +# Create a test FastAPI app +app = FastAPI() + + +@app.post("/test") +async def test_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@app.get("/test") +async def test_get_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + + +@app.put("/test") +async def test_put_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@app.delete("/test") +async def test_delete_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + + +@app.patch("/test") +async def test_patch_endpoint(body: TestArgs = Body(...)) -> TestResponse: + return TestResponse(result=f"Received: {body.query} with value {body.value}") + + +@pytest.fixture +def test_config() -> ComponentModel: + return ComponentModel( + provider="autogen_ext.tools.http.HttpTool", + config={ + "name": "TestHttpTool", + "description": "A test HTTP tool", + "url": "http://localhost:8000/test", + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "json_schema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The test query"}, + "value": {"type": "integer", "description": "A test value"}, + }, + "required": ["query", "value"], + }, + }, + ) + + +@pytest_asyncio.fixture +async def test_server() -> AsyncGenerator[None, None]: + # Start the test server + config = uvicorn.Config(app, host="127.0.0.1", port=8000, log_level="error") + server = uvicorn.Server(config) + + # Create a task for the server + server_task = asyncio.create_task(server.serve()) + + # Wait a bit for server to start + await asyncio.sleep(0.5) # Increased sleep time to ensure server is ready + + yield + + # Cleanup + server.should_exit = True + await server_task diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py new file mode 100644 index 000000000000..70e189f8c590 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -0,0 +1,144 @@ +import pytest +import httpx +from pydantic import ValidationError +from autogen_core import CancellationToken +from autogen_ext.tools.http import HttpTool +from autogen_core import Component, ComponentModel + + +def test_tool_schema_generation(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + schema = tool.schema + + assert schema["name"] == "TestHttpTool" + assert "description" in schema + assert schema["description"] == "A test HTTP tool" + assert "parameters" in schema + assert schema["parameters"]["type"] == "object" + assert "properties" in schema["parameters"] + assert schema["parameters"]["properties"]["query"]["description"] == "The test query" + assert schema["parameters"]["properties"]["query"]["type"] == "string" + assert schema["parameters"]["properties"]["value"]["description"] == "A test value" + assert schema["parameters"]["properties"]["value"]["type"] == "integer" + assert "required" in schema["parameters"] + assert set(schema["parameters"]["required"]) == {"query", "value"} + + +def test_tool_properties(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + + assert tool.name == "TestHttpTool" + assert tool.description == "A test HTTP tool" + assert tool.server_params.url == "http://localhost:8000/test" + assert tool.server_params.method == "POST" + + +def test_component_base_class(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + assert tool.dump_component() is not None + assert HttpTool.load_component(tool.dump_component(), HttpTool) is not None + assert isinstance(tool, Component) + + +@pytest.mark.asyncio +async def test_post_request(test_config: ComponentModel, test_server: None) -> None: + tool = HttpTool.load_component(test_config) + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_get_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for GET request + config = test_config.model_copy() + config.config["method"] = "GET" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_put_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for PUT request + config = test_config.model_copy() + config.config["method"] = "PUT" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_delete_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for DELETE request + config = test_config.model_copy() + config.config["method"] = "DELETE" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_patch_request(test_config: ComponentModel, test_server: None) -> None: + # Modify config for PATCH request + config = test_config.model_copy() + config.config["method"] = "PATCH" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + + +@pytest.mark.asyncio +async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> None: + # Create an invalid schema missing required properties + config: ComponentModel = test_config.model_copy() + config.config["url"] = True # Incorrect type + + with pytest.raises(ValidationError): + # Should fail when trying to create model from invalid schema + HttpTool.load_component(config) + + +@pytest.mark.asyncio +async def test_invalid_request(test_config: ComponentModel, test_server: None) -> None: + # Use an invalid URL + config = test_config.model_copy() + config.config["url"] = "http://fake:8000/nonexistent" + tool = HttpTool.load_component(config) + + with pytest.raises(httpx.ConnectError): + await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + +def test_config_serialization(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + config = tool._to_config() + + assert config.name == test_config.config["name"] + assert config.description == test_config.config["description"] + assert config.url == test_config.config["url"] + assert config.method == test_config.config["method"] + assert config.headers == test_config.config["headers"] + + +def test_config_deserialization(test_config: ComponentModel) -> None: + tool = HttpTool.load_component(test_config) + + assert tool.name == test_config.config["name"] + assert tool.description == test_config.config["description"] + assert tool.server_params.url == test_config.config["url"] + assert tool.server_params.method == test_config.config["method"] + assert tool.server_params.headers == test_config.config["headers"] From 7b644e2c3a98b14964e8b1e347d3c1eaefdb3655 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:27:00 +0000 Subject: [PATCH 4/9] works again with path params stuff --- .../src/autogen_ext/tools/http/_http_tool.py | 76 ++++++++++++++----- .../autogen-ext/tests/tools/http/conftest.py | 5 +- .../tests/tools/http/test_http_tool.py | 19 +++-- 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index f9c690f6aeea..0d19a8413437 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,10 +1,12 @@ +import re from typing import Any, Literal, Optional, Type +import urllib.parse import httpx from autogen_core import CancellationToken, Component from autogen_core.tools import BaseTool from json_schema_to_pydantic import create_model -from pydantic import BaseModel +from pydantic import BaseModel, Field class HttpToolConfig(BaseModel): @@ -16,10 +18,24 @@ class HttpToolConfig(BaseModel): """ A description of the tool. """ - url: str + scheme: Literal["http", "https"] = "http" + """ + The scheme to use for the request. + """ + host: str """ The URL to send the request to. """ + port: int + """ + The port to send the request to. + """ + path: str = Field(default="/") + """ + The path to send the request to. defaults to "/" + The path can accept parameters, e.g. "/{param1}/{param2}". + These parameters will be templated from the inputs args, any additional parameters will be added as query parameters or the body of the request. + """ method: Optional[Literal["GET", "POST", "PUT", "DELETE", "PATCH"]] = "POST" """ The HTTP method to use, will default to POST if not provided. @@ -31,6 +47,7 @@ class HttpToolConfig(BaseModel): json_schema: dict[str, Any] """ A JSON Schema object defining the expected parameters for the tool. + Path parameters MUST also be included in the json_schema. They must also MUST be set to string """ @@ -47,7 +64,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. Example: - Simple usage case:: + Simple use case:: import asyncio from autogen_ext.tools.http import HttpTool @@ -70,7 +87,7 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): description="Get the current weather for a city", url="https://api.weatherapi.com/v1/current.json", method="GET", - headers={"key": "your-api-key"}, + headers={"key": "your-api-key"}, # Replace with your API key json_schema=weather_schema ) @@ -99,29 +116,35 @@ async def main(): def __init__( self, name: str, - url: str, + host: str, + port: int, json_schema: dict[str, Any], headers: Optional[dict[str, Any]], description: str = "HTTP tool", + path: str = "/", + scheme: Literal["http", "https"] = "http", method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "POST", ) -> None: self.server_params = HttpToolConfig( name=name, description=description, - url=url, + host=host, + port=port, + path=path, + scheme=scheme, method=method, headers=headers, json_schema=json_schema, ) - # Extract name and description - name = self.server_params.name - description = self.server_params.description or "" + # Use regex to find all path parameters, we will need those later to template the path + path_params = {match.group(1) for match in re.finditer(r"{([^}]*)}", path)} + self._path_params = path_params - # Create the input model from the tool's schema - input_model = create_model(self.server_params.json_schema) + # Create the input model from the modified schema + input_model = create_model(json_schema) - # Use Any as return type since MCP tool returns can vary + # Use Any as return type since HTTP responses can vary return_type: Type[Any] = object super().__init__(input_model, return_type, name, description) @@ -136,7 +159,7 @@ def _from_config(cls, config: HttpToolConfig): return cls(**copied_config) async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> Any: - """Execute the HTTO tool with the given arguments. + """Execute the HTTP tool with the given arguments. Args: args: The validated input arguments @@ -149,17 +172,32 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A Exception: If tool execution fails """ + + model_dump = args.model_dump() + path_params = {k: v for k, v in model_dump.items() if k in self._path_params} + # Remove path params from the model dump + for k in self._path_params: + model_dump.pop(k) + + path = self.server_params.path.format(**path_params) + + url = httpx.URL( + scheme=self.server_params.scheme, + host=self.server_params.host, + port=self.server_params.port, + path=path, + ) async with httpx.AsyncClient() as client: match self.server_params.method: case "GET": - response = await client.get(self.server_params.url, params=args.model_dump()) + response = await client.get(url, params=model_dump) case "PUT": - response = await client.put(self.server_params.url, json=args.model_dump()) + response = await client.put(url, json=model_dump) case "DELETE": - response = await client.delete(self.server_params.url, params=args.model_dump()) + response = await client.delete(url, params=model_dump) case "PATCH": - response = await client.patch(self.server_params.url, json=args.model_dump()) - case _: # Default case - response = await client.post(self.server_params.url, json=args.model_dump()) + response = await client.patch(url, json=model_dump) + case _: # Default case POST + response = await client.post(url, json=model_dump) return response.json() diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 43708b712541..895e3f5dd817 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -55,7 +55,10 @@ def test_config() -> ComponentModel: config={ "name": "TestHttpTool", "description": "A test HTTP tool", - "url": "http://localhost:8000/test", + "scheme": "http", + "path": "/test", + "host": "localhost", + "port": 8000, "method": "POST", "headers": {"Content-Type": "application/json"}, "json_schema": { diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 70e189f8c590..0e6c9736b0f7 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -29,7 +29,10 @@ def test_tool_properties(test_config: ComponentModel) -> None: assert tool.name == "TestHttpTool" assert tool.description == "A test HTTP tool" - assert tool.server_params.url == "http://localhost:8000/test" + assert tool.server_params.host == "localhost" + assert tool.server_params.port == 8000 + assert tool.server_params.path == "/test" + assert tool.server_params.scheme == "http" assert tool.server_params.method == "POST" @@ -105,7 +108,7 @@ async def test_patch_request(test_config: ComponentModel, test_server: None) -> async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> None: # Create an invalid schema missing required properties config: ComponentModel = test_config.model_copy() - config.config["url"] = True # Incorrect type + config.config["host"] = True # Incorrect type with pytest.raises(ValidationError): # Should fail when trying to create model from invalid schema @@ -116,7 +119,7 @@ async def test_invalid_schema(test_config: ComponentModel, test_server: None) -> async def test_invalid_request(test_config: ComponentModel, test_server: None) -> None: # Use an invalid URL config = test_config.model_copy() - config.config["url"] = "http://fake:8000/nonexistent" + config.config["host"] = "fake" tool = HttpTool.load_component(config) with pytest.raises(httpx.ConnectError): @@ -129,7 +132,10 @@ def test_config_serialization(test_config: ComponentModel) -> None: assert config.name == test_config.config["name"] assert config.description == test_config.config["description"] - assert config.url == test_config.config["url"] + assert config.host == test_config.config["host"] + assert config.port == test_config.config["port"] + assert config.path == test_config.config["path"] + assert config.scheme == test_config.config["scheme"] assert config.method == test_config.config["method"] assert config.headers == test_config.config["headers"] @@ -139,6 +145,9 @@ def test_config_deserialization(test_config: ComponentModel) -> None: assert tool.name == test_config.config["name"] assert tool.description == test_config.config["description"] - assert tool.server_params.url == test_config.config["url"] + assert tool.server_params.host == test_config.config["host"] + assert tool.server_params.port == test_config.config["port"] + assert tool.server_params.path == test_config.config["path"] + assert tool.server_params.scheme == test_config.config["scheme"] assert tool.server_params.method == test_config.config["method"] assert tool.server_params.headers == test_config.config["headers"] From 873ac5c6bb4b8b069090c67c43c0056642bcb30a Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:33:06 +0000 Subject: [PATCH 5/9] add unit tests for the new path params stuff --- .../autogen-ext/tests/tools/http/conftest.py | 11 ++++++ .../tests/tools/http/test_http_tool.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/python/packages/autogen-ext/tests/tools/http/conftest.py b/python/packages/autogen-ext/tests/tools/http/conftest.py index 895e3f5dd817..fd3c5e4a537d 100644 --- a/python/packages/autogen-ext/tests/tools/http/conftest.py +++ b/python/packages/autogen-ext/tests/tools/http/conftest.py @@ -27,6 +27,17 @@ class TestResponse(BaseModel): async def test_endpoint(body: TestArgs = Body(...)) -> TestResponse: return TestResponse(result=f"Received: {body.query} with value {body.value}") +@app.post("/test/{query}/{value}") +async def test_path_params_endpoint(query: str, value: int) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value}") + +@app.put("/test/{query}/{value}") +async def test_path_params_and_body_endpoint( + query: str, + value: int, + body: dict = Body(...) +) -> TestResponse: + return TestResponse(result=f"Received: {query} with value {value} and extra {body.get("extra")}") @app.get("/test") async def test_get_endpoint(query: str, value: int) -> TestResponse: diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 0e6c9736b0f7..a64bb09fd067 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -77,6 +77,45 @@ async def test_put_request(test_config: ComponentModel, test_server: None) -> No assert isinstance(result, dict) assert result["result"] == "Received: test query with value 42" +@pytest.mark.asyncio +async def test_path_params(test_config: ComponentModel, test_server: None) -> None: + # Modify config to use path parameters + config = test_config.model_copy() + config.config["path"] = "/test/{query}/{value}" + tool = HttpTool.load_component(config) + + result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42" + +@pytest.mark.asyncio +async def test_path_params_and_body(test_config: ComponentModel, test_server: None) -> None: + # Modify config to use path parameters and include body parameters + config = test_config.model_copy() + config.config["method"] = "PUT" + config.config["path"] = "/test/{query}/{value}" + config.config["json_schema"] = { + "type": "object", + "properties": { + "query": {"type": "string", "description": "The test query"}, + "value": {"type": "integer", "description": "A test value"}, + "extra": {"type": "string", "description": "Extra body parameter"} + }, + "required": ["query", "value", "extra"] + } + tool = HttpTool.load_component(config) + + result = await tool.run_json({ + "query": "test query", + "value": 42, + "extra": "extra data" + }, CancellationToken()) + + assert isinstance(result, dict) + assert result["result"] == "Received: test query with value 42 and extra extra data" + + @pytest.mark.asyncio async def test_delete_request(test_config: ComponentModel, test_server: None) -> None: From dfb9b50a760f161cc7e8d9319e0a4287d0498a3f Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:51:58 +0000 Subject: [PATCH 6/9] unit tests now fully passing with params, example works with httpbin base64 decode --- .../src/autogen_ext/tools/http/_http_tool.py | 98 ++++++++++--------- .../tests/tools/http/test_http_tool.py | 30 +++--- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 0d19a8413437..9c2fe21c1301 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,4 +1,5 @@ import re +import json from typing import Any, Literal, Optional, Type import urllib.parse @@ -66,50 +67,54 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): Example: Simple use case:: - import asyncio - from autogen_ext.tools.http import HttpTool - from autogen_agentchat.agents import AssistantAgent - from autogen_ext.models.openai import OpenAIChatCompletionClient - - # Define a JSON schema for a weather API - weather_schema = { - "type": "object", - "properties": { - "city": {"type": "string", "description": "The city to get weather for"}, - "country": {"type": "string", "description": "The country code"} - }, - "required": ["city"] - } - - # Create an HTTP tool for the weather API - weather_tool = HttpTool( - name="get_weather", - description="Get the current weather for a city", - url="https://api.weatherapi.com/v1/current.json", - method="GET", - headers={"key": "your-api-key"}, # Replace with your API key - json_schema=weather_schema - ) - - async def main(): - # Create an assistant with the weather tool - model = OpenAIChatCompletionClient(model="gpt-4") - assistant = AssistantAgent( - "weather_assistant", - model_client=model, - tools=[weather_tool] - ) - - # The assistant can now use the weather tool to get weather data - response = await assistant.on_messages([ - TextMessage(content="What's the weather like in London?") - ]) - print(response.chat_message.content) - - asyncio.run(main()) - """ - - component_type = "agent" + import asyncio + + from autogen_agentchat.agents import AssistantAgent + from autogen_agentchat.messages import TextMessage + from autogen_core import CancellationToken + from autogen_ext.models.openai import OpenAIChatCompletionClient + from autogen_ext.tools.http import HttpTool + + # Define a JSON schema for a base64 decode tool + base64_schema = { + "type": "object", + "properties": { + "value": {"type": "string", "description": "The base64 value to decode"}, + }, + "required": ["value"] + } + + # Create an HTTP tool for the weather API + base64_tool = HttpTool( + name="base64_decode", + description="base64 decode a value", + scheme="https", + host="httpbin.org", + port=443, + path="/base64/{value}", + method="GET", + json_schema=base64_schema + ) + + async def main(): + # Create an assistant with the base64 tool + model = OpenAIChatCompletionClient(model="gpt-4") + assistant = AssistantAgent( + "base64_assistant", + model_client=model, + tools=[base64_tool] + ) + + # The assistant can now use the base64 tool to decode the string + response = await assistant.on_messages([ + TextMessage(content="Can you base64 decode the value 'YWJjZGU=', please?", source="user") + ], CancellationToken()) + print(response.chat_message.content) + + asyncio.run(main()) + """ + + component_type = "tool" component_provider_override = "autogen_ext.tools.http.HttpTool" component_config_schema = HttpToolConfig @@ -119,7 +124,7 @@ def __init__( host: str, port: int, json_schema: dict[str, Any], - headers: Optional[dict[str, Any]], + headers: Optional[dict[str, Any]] = None, description: str = "HTTP tool", path: str = "/", scheme: Literal["http", "https"] = "http", @@ -200,4 +205,5 @@ async def run(self, args: BaseModel, cancellation_token: CancellationToken) -> A case _: # Default case POST response = await client.post(url, json=model_dump) - return response.json() + # TODO: (EItanya): Think about adding the ability to parse the response as JSON, or check a schema + return response.text diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index a64bb09fd067..0742b6c87bde 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -1,3 +1,4 @@ +import json import pytest import httpx from pydantic import ValidationError @@ -48,8 +49,8 @@ async def test_post_request(test_config: ComponentModel, test_server: None) -> N tool = HttpTool.load_component(test_config) result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -61,8 +62,8 @@ async def test_get_request(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -74,8 +75,8 @@ async def test_put_request(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio async def test_path_params(test_config: ComponentModel, test_server: None) -> None: @@ -86,8 +87,8 @@ async def test_path_params(test_config: ComponentModel, test_server: None) -> No result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio async def test_path_params_and_body(test_config: ComponentModel, test_server: None) -> None: @@ -112,8 +113,9 @@ async def test_path_params_and_body(test_config: ComponentModel, test_server: No "extra": "extra data" }, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42 and extra extra data" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42 and extra extra data" + @@ -126,8 +128,8 @@ async def test_delete_request(test_config: ComponentModel, test_server: None) -> result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio @@ -139,8 +141,8 @@ async def test_patch_request(test_config: ComponentModel, test_server: None) -> result = await tool.run_json({"query": "test query", "value": 42}, CancellationToken()) - assert isinstance(result, dict) - assert result["result"] == "Received: test query with value 42" + assert isinstance(result, str) + assert json.loads(result)["result"] == "Received: test query with value 42" @pytest.mark.asyncio From 7395c0a351ce6541ada736a1214fb76c00c887dd Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:53:28 +0000 Subject: [PATCH 7/9] update docs for args --- .../autogen-ext/src/autogen_ext/tools/http/_http_tool.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index 9c2fe21c1301..a9ccada8521a 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -58,11 +58,16 @@ class HttpTool(BaseTool[BaseModel, Any], Component[HttpToolConfig]): Args: name (str): The name of the tool. description (str, optional): A description of the tool. - url (str): The URL to send the request to. + scheme (str): The scheme to use for the request. Must be either "http" or "https". + host (str): The host to send the request to. + port (int): The port to send the request to. + path (str, optional): The path to send the request to. Defaults to "/". + Can include path parameters like "/{param1}/{param2}" which will be templated from input args. method (str, optional): The HTTP method to use, will default to POST if not provided. Must be one of "GET", "POST", "PUT", "DELETE", "PATCH". headers (dict[str, Any], optional): A dictionary of headers to send with the request. json_schema (dict[str, Any]): A JSON Schema object defining the expected parameters for the tool. + Path parameters must also be included in the schema and must be strings. Example: Simple use case:: From 98b05190f80542c1a52a7903fedc566bc7accf4e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sat, 25 Jan 2025 23:54:45 +0000 Subject: [PATCH 8/9] revert autogen studio --- .../database/component_factory.py | 28 ++++--------------- .../autogenstudio/datamodel/types.py | 23 ++------------- .../autogen-studio/notebooks/team.json | 15 ++++------ python/packages/autogen-studio/pyproject.toml | 3 +- 4 files changed, 14 insertions(+), 55 deletions(-) diff --git a/python/packages/autogen-studio/autogenstudio/database/component_factory.py b/python/packages/autogen-studio/autogenstudio/database/component_factory.py index 782ed94dbd45..b954b39c0f4b 100644 --- a/python/packages/autogen-studio/autogenstudio/database/component_factory.py +++ b/python/packages/autogen-studio/autogenstudio/database/component_factory.py @@ -17,13 +17,11 @@ TimeoutTermination, TokenUsageTermination, ) -from mcp import StdioServerParameters from autogen_agentchat.teams import MagenticOneGroupChat, RoundRobinGroupChat, SelectorGroupChat from autogen_core.tools import FunctionTool from autogen_ext.agents.file_surfer import FileSurfer from autogen_ext.agents.magentic_one import MagenticOneCoderAgent from autogen_ext.agents.web_surfer import MultimodalWebSurfer -from autogen_ext.tools.mcp import StdioMcpTool, StdioMcpToolBuilder from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient from ..datamodel.types import ( @@ -49,9 +47,6 @@ TerminationTypes, TextMentionTerminationConfig, ToolConfig, - PythonFunctionToolConfig, - StdioMcpToolConfig, - SseMcpToolConfig, ToolTypes, UserProxyAgentConfig, ) @@ -62,7 +57,7 @@ TeamComponent = Union[RoundRobinGroupChat, SelectorGroupChat, MagenticOneGroupChat] AgentComponent = Union[AssistantAgent, MultimodalWebSurfer, UserProxyAgent, FileSurfer, MagenticOneCoderAgent] ModelComponent = Union[OpenAIChatCompletionClient, AzureOpenAIChatCompletionClient] -ToolComponent = Union[FunctionTool, StdioMcpTool] # Will grow with more tool types +ToolComponent = Union[FunctionTool] # Will grow with more tool types TerminationComponent = Union[ MaxMessageTermination, StopMessageTermination, @@ -330,8 +325,7 @@ async def load_team(self, config: TeamConfig, input_func: Optional[Callable] = N async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = None) -> AgentComponent: """Create agent instance from configuration.""" - logger.error(f"Loading agent {config.name}") - logger.error(f"Config: {config}") + model_client = None system_message = None tools = [] @@ -341,8 +335,6 @@ async def load_agent(self, config: AgentConfig, input_func: Optional[Callable] = model_client = await self.load(config.model_client) if hasattr(config, "tools") and config.tools: for tool_config in config.tools: - logger.error(f"Loading tool {tool_config.name}") - logger.error(f"Config: {tool_config}") tool = await self.load(tool_config) tools.append(tool) @@ -432,9 +424,11 @@ async def load_model(self, config: ModelConfig) -> ModelComponent: async def load_tool(self, config: ToolConfig) -> ToolComponent: """Create tool instance from configuration.""" - logger.error(f"Loading tool {config.name}") - logger.error(f"Config: {config}") try: + # Validate required fields + if not all([config.name, config.description, config.content, config.tool_type]): + raise ValueError("Tool configuration missing required fields") + # Check cache first cache_key = str(config.model_dump()) if cache_key in self._tool_cache: @@ -442,21 +436,11 @@ async def load_tool(self, config: ToolConfig) -> ToolComponent: return self._tool_cache[cache_key] if config.tool_type == ToolTypes.PYTHON_FUNCTION: - # Validate required fields - if not all([config.name, config.description, config.content, config.tool_type]): - raise ValueError("Tool configuration missing required fields") tool = FunctionTool( name=config.name, description=config.description, func=self._func_from_string(config.content) ) self._tool_cache[cache_key] = tool return tool - elif config.tool_type == ToolTypes.MCP_STDIO_CLIENT: - cfg = StdioServerParameters( - command=config.command, - args=config.args, - ) - builder = StdioMcpToolBuilder(server_params=cfg, tool_name=config.name) - return await builder.build() else: raise ValueError(f"Unsupported tool type: {config.tool_type}") diff --git a/python/packages/autogen-studio/autogenstudio/datamodel/types.py b/python/packages/autogen-studio/autogenstudio/datamodel/types.py index 682303e368f4..eb02fb121ebe 100644 --- a/python/packages/autogen-studio/autogenstudio/datamodel/types.py +++ b/python/packages/autogen-studio/autogenstudio/datamodel/types.py @@ -15,8 +15,6 @@ class ModelTypes(str, Enum): class ToolTypes(str, Enum): PYTHON_FUNCTION = "PythonFunction" - MCP_STDIO_CLIENT = "McpStdioClient" - MCP_SSE_CLIENT = "McpSseClient" class AgentTypes(str, Enum): @@ -86,31 +84,14 @@ class AzureOpenAIModelConfig(BaseModelConfig): ModelConfig = OpenAIModelConfig | AzureOpenAIModelConfig -class BaseToolConfig(BaseConfig): +class ToolConfig(BaseConfig): name: str description: str + content: str tool_type: ToolTypes component_type: ComponentTypes = ComponentTypes.TOOL -class PythonFunctionToolConfig(BaseToolConfig): - content: str - tool_type: ToolTypes = ToolTypes.PYTHON_FUNCTION - -class StdioMcpToolConfig(BaseToolConfig): - command: str - args: List[str] - tool_type: ToolTypes = ToolTypes.MCP_STDIO_CLIENT - - -class SseMcpToolConfig(BaseToolConfig): - url: str - headers: Dict[str, Any] - tool_type: ToolTypes = ToolTypes.MCP_SSE_CLIENT - -ToolConfig = PythonFunctionToolConfig | StdioMcpToolConfig | SseMcpToolConfig - - class BaseAgentConfig(BaseConfig): name: str agent_type: AgentTypes diff --git a/python/packages/autogen-studio/notebooks/team.json b/python/packages/autogen-studio/notebooks/team.json index cdbf22f87806..5fb43ea26cb2 100644 --- a/python/packages/autogen-studio/notebooks/team.json +++ b/python/packages/autogen-studio/notebooks/team.json @@ -1,9 +1,9 @@ { - "name": "filesystem_team", + "name": "weather_team", "component_type": "team", "participants": [ { - "name": "filesystem_agent", + "name": "writing_agent", "component_type": "agent", "model_client": { "model": "gpt-4o-2024-08-06", @@ -14,14 +14,9 @@ { "name": "get_weather", "description": "Get the weather for a city", - "tool_type": "McpStdioClient", - "component_type": "tool", - "command": "npx", - "args":[ - "-y", - "@modelcontextprotocol/server-filesystem", - "/home/eitanyarmush" - ] + "content": "async def get_weather(city: str) -> str:\n return f\"The weather in {city} is 73 degrees and Sunny.\"", + "tool_type": "PythonFunction", + "component_type": "tool" } ], "agent_type": "AssistantAgent" diff --git a/python/packages/autogen-studio/pyproject.toml b/python/packages/autogen-studio/pyproject.toml index 267ce6ff0c9f..a8d6911d3e26 100644 --- a/python/packages/autogen-studio/pyproject.toml +++ b/python/packages/autogen-studio/pyproject.toml @@ -36,8 +36,7 @@ dependencies = [ "autogen-core>=0.4.2,<0.5", "autogen-agentchat>=0.4.2,<0.5", "autogen-ext[magentic-one, openai, azure]>=0.4.2,<0.5", - "azure-identity", - "mcp", + "azure-identity" ] optional-dependencies = {web = ["fastapi", "uvicorn"], database = ["psycopg"]} From 4644bc819bad823a17654eb279d234487a27c273 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 26 Jan 2025 02:54:10 +0000 Subject: [PATCH 9/9] formatting --- .../autogen-ext/src/autogen_ext/tools/http/_http_tool.py | 2 -- .../autogen-ext/tests/tools/http/test_http_tool.py | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py index a9ccada8521a..8aa1d224d94e 100644 --- a/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py +++ b/python/packages/autogen-ext/src/autogen_ext/tools/http/_http_tool.py @@ -1,7 +1,5 @@ import re -import json from typing import Any, Literal, Optional, Type -import urllib.parse import httpx from autogen_core import CancellationToken, Component diff --git a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py index 0742b6c87bde..e8055b2bfbf1 100644 --- a/python/packages/autogen-ext/tests/tools/http/test_http_tool.py +++ b/python/packages/autogen-ext/tests/tools/http/test_http_tool.py @@ -1,10 +1,10 @@ import json -import pytest + import httpx -from pydantic import ValidationError -from autogen_core import CancellationToken +import pytest +from autogen_core import CancellationToken, Component, ComponentModel from autogen_ext.tools.http import HttpTool -from autogen_core import Component, ComponentModel +from pydantic import ValidationError def test_tool_schema_generation(test_config: ComponentModel) -> None: