diff --git a/python/packages/autogen-core/docs/src/reference/index.md b/python/packages/autogen-core/docs/src/reference/index.md index fdaf598c0029..762e9f07ba35 100644 --- a/python/packages/autogen-core/docs/src/reference/index.md +++ b/python/packages/autogen-core/docs/src/reference/index.md @@ -54,6 +54,7 @@ python/autogen_ext.models.replay python/autogen_ext.models.azure python/autogen_ext.models.semantic_kernel python/autogen_ext.tools.langchain +python/autogen_ext.tools.mcp python/autogen_ext.tools.graphrag python/autogen_ext.tools.code_execution python/autogen_ext.tools.semantic_kernel diff --git a/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.mcp.rst b/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.mcp.rst new file mode 100644 index 000000000000..3c7ffd9085a5 --- /dev/null +++ b/python/packages/autogen-core/docs/src/reference/python/autogen_ext.tools.mcp.rst @@ -0,0 +1,8 @@ +autogen\_ext.tools.mcp +====================== + + +.. automodule:: autogen_ext.tools.mcp + :members: + :undoc-members: + :show-inheritance: diff --git a/python/packages/autogen-ext/pyproject.toml b/python/packages/autogen-ext/pyproject.toml index a2ffda98a019..f3e49b6ce992 100644 --- a/python/packages/autogen-ext/pyproject.toml +++ b/python/packages/autogen-ext/pyproject.toml @@ -112,6 +112,11 @@ semantic-kernel-all = [ rich = ["rich>=13.9.4"] +mcp = [ + "mcp>=1.1.3", + "json-schema-to-pydantic>=0.2.2" +] + [tool.hatch.build.targets.wheel] packages = ["src/autogen_ext"] diff --git a/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py b/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py index 07f3fe74802c..26e2092dc430 100644 --- a/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py +++ b/python/packages/autogen-ext/src/autogen_ext/models/semantic_kernel/_sk_chat_completion_adapter.py @@ -1,6 +1,6 @@ import json -from typing import Any, Literal, Mapping, Optional, Sequence import warnings +from typing import Any, Literal, Mapping, Optional, Sequence from autogen_core import FunctionCall from autogen_core._cancellation_token import CancellationToken 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..83d76fcad502 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/__init__.py @@ -0,0 +1,13 @@ +from ._config import McpServerParams, SseServerParams, StdioServerParams +from ._factory import mcp_server_tools +from ._sse import SseMcpToolAdapter +from ._stdio import StdioMcpToolAdapter + +__all__ = [ + "StdioMcpToolAdapter", + "StdioServerParams", + "SseMcpToolAdapter", + "SseServerParams", + "McpServerParams", + "mcp_server_tools", +] diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py new file mode 100644 index 000000000000..cf905800d6b2 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py @@ -0,0 +1,101 @@ +from abc import ABC +from typing import Any, Generic, Type, TypeVar + +from autogen_core import CancellationToken +from autogen_core.tools import BaseTool +from json_schema_to_pydantic import create_model +from mcp import Tool +from pydantic import BaseModel + +from ._config import McpServerParams +from ._session import create_mcp_server_session + +TServerParams = TypeVar("TServerParams", bound=McpServerParams) + + +class McpToolAdapter(BaseTool[BaseModel, Any], ABC, Generic[TServerParams]): + """ + Base adapter class for MCP tools to make them compatible with AutoGen. + + Args: + server_params (TServerParams): Parameters for the MCP server connection. + tool (Tool): The MCP tool to wrap. + """ + + component_type = "tool" + + def __init__(self, server_params: TServerParams, tool: Tool) -> None: + self._tool = tool + self._server_params = server_params + + # Extract name and description + name = tool.name + description = tool.description or "" + + # 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: + """ + Run the MCP tool with the provided arguments. + + Args: + args (BaseModel): The arguments to pass to the tool. + cancellation_token (CancellationToken): Token to signal cancellation. + + Returns: + Any: The result of the tool execution. + + Raises: + Exception: If the operation is cancelled or the tool execution fails. + """ + kwargs = args.model_dump() + + try: + async with create_mcp_server_session(self._server_params) as session: + await session.initialize() + + if cancellation_token.is_cancelled(): + raise Exception("Operation cancelled") + + result = await session.call_tool(self._tool.name, kwargs) # type: ignore + + 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 + + @classmethod + async def from_server_params(cls, server_params: TServerParams, tool_name: str) -> "McpToolAdapter[TServerParams]": + """ + Create an instance of McpToolAdapter from server parameters and tool name. + + Args: + server_params (TServerParams): Parameters for the MCP server connection. + tool_name (str): The name of the tool to wrap. + + Returns: + McpToolAdapter[TServerParams]: An instance of McpToolAdapter. + + Raises: + ValueError: If the tool with the specified name is not found. + """ + async with create_mcp_server_session(server_params) as session: + await session.initialize() + + tools_response = await session.list_tools() + matching_tool = next((t for t in tools_response.tools if t.name == tool_name), None) + + if matching_tool is None: + raise ValueError( + f"Tool '{tool_name}' not found, available tools: {', '.join([t.name for t in tools_response.tools])}" + ) + + return cls(server_params=server_params, tool=matching_tool) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_config.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_config.py new file mode 100644 index 000000000000..3f140587d30e --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_config.py @@ -0,0 +1,22 @@ +from typing import Any, TypeAlias + +from mcp import StdioServerParameters +from pydantic import BaseModel + + +class StdioServerParams(StdioServerParameters): + """Parameters for connecting to an MCP server over STDIO.""" + + pass + + +class SseServerParams(BaseModel): + """Parameters for connecting to an MCP server over SSE.""" + + url: str + headers: dict[str, Any] | None = None + timeout: float = 5 + sse_read_timeout: float = 60 * 5 + + +McpServerParams: TypeAlias = StdioServerParams | SseServerParams diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_factory.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_factory.py new file mode 100644 index 000000000000..fcb29e3c4a99 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_factory.py @@ -0,0 +1,134 @@ +from ._config import McpServerParams, SseServerParams, StdioServerParams +from ._session import create_mcp_server_session +from ._sse import SseMcpToolAdapter +from ._stdio import StdioMcpToolAdapter + + +async def mcp_server_tools( + server_params: McpServerParams, +) -> list[StdioMcpToolAdapter | SseMcpToolAdapter]: + """Creates a list of MCP tool adapters that can be used with AutoGen agents. + + This factory function connects to an MCP server and returns adapters for all available tools. + The adapters can be directly assigned to an AutoGen agent's tools list. + + Args: + server_params (McpServerParams): Connection parameters for the MCP server. + Can be either StdioServerParams for command-line tools or + SseServerParams for HTTP/SSE services. + + Returns: + list[StdioMcpToolAdapter | SseMcpToolAdapter]: A list of tool adapters ready to use + with AutoGen agents. + + Examples: + + **Local file system MCP service over standard I/O example:** + + Install the filesystem server package from npm (requires Node.js 16+ and npm). + + .. code-block:: bash + + npm install -g @modelcontextprotocol/server-filesystem + + Create an agent that can use all tools from the local filesystem MCP server. + + .. code-block:: python + + import asyncio + from pathlib import Path + from autogen_ext.models.openai import OpenAIChatCompletionClient + from autogen_ext.tools.mcp import StdioServerParams, mcp_server_tools + from autogen_agentchat.agents import AssistantAgent + from autogen_core import CancellationToken + + + async def main() -> None: + # Setup server params for local filesystem access + desktop = str(Path.home() / "Desktop") + server_params = StdioServerParams( + command="npx.cmd", args=["-y", "@modelcontextprotocol/server-filesystem", desktop] + ) + + # Get all available tools from the server + tools = await mcp_server_tools(server_params) + + # Create an agent that can use all the tools + agent = AssistantAgent( + name="file_manager", + model_client=OpenAIChatCompletionClient(model="gpt-4"), + tools=tools, # type: ignore + ) + + # The agent can now use any of the filesystem tools + await agent.run(task="Create a file called test.txt with some content", cancellation_token=CancellationToken()) + + + if __name__ == "__main__": + asyncio.run(main()) + + **Local fetch MCP service over standard I/O example:** + + Install the `mcp-server-fetch` package. + + .. code-block:: bash + + pip install mcp-server-fetch + + Create an agent that can use the `fetch` tool from the local MCP server. + + .. code-block:: python + + import asyncio + + from autogen_agentchat.agents import AssistantAgent + from autogen_ext.models.openai import OpenAIChatCompletionClient + from autogen_ext.tools.mcp import StdioServerParams, mcp_server_tools + + + async def main() -> None: + # Get the fetch tool from mcp-server-fetch. + fetch_mcp_server = StdioServerParams(command="uvx", args=["mcp-server-fetch"]) + tools = await mcp_server_tools(fetch_mcp_server) + + # Create an agent that can use the fetch tool. + model_client = OpenAIChatCompletionClient(model="gpt-4o") + agent = AssistantAgent(name="fetcher", model_client=model_client, tools=tools, reflect_on_tool_use=True) # type: ignore + + # Let the agent fetch the content of a URL and summarize it. + result = await agent.run(task="Summarize the content of https://en.wikipedia.org/wiki/Seattle") + print(result.messages[-1].content) + + + asyncio.run(main()) + + + **Remote MCP service over SSE example:** + + .. code-block:: python + + from autogen_ext.tools.mcp import SseServerParams, mcp_server_tools + + + async def main() -> None: + # Setup server params for remote service + server_params = SseServerParams(url="https://api.example.com/mcp", headers={"Authorization": "Bearer token"}) + + # Get all available tools + tools = await mcp_server_tools(server_params) + + # Create an agent with all tools + agent = AssistantAgent(name="tool_user", model_client=OpenAIChatCompletionClient(model="gpt-4"), tools=tools) # type: ignore + + For more examples and detailed usage, see the samples directory in the package repository. + """ + async with create_mcp_server_session(server_params) as session: + await session.initialize() + + tools = await session.list_tools() + + if isinstance(server_params, StdioServerParams): + return [StdioMcpToolAdapter(server_params=server_params, tool=tool) for tool in tools.tools] + elif isinstance(server_params, SseServerParams): + return [SseMcpToolAdapter(server_params=server_params, tool=tool) for tool in tools.tools] + raise ValueError(f"Unsupported server params type: {type(server_params)}") diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_session.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_session.py new file mode 100644 index 000000000000..964a5c9f77cf --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_session.py @@ -0,0 +1,23 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client + +from ._config import McpServerParams, SseServerParams, StdioServerParams + + +@asynccontextmanager +async def create_mcp_server_session( + server_params: McpServerParams, +) -> AsyncGenerator[ClientSession, None]: + """Create an MCP client session for the given server parameters.""" + if isinstance(server_params, StdioServerParams): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read_stream=read, write_stream=write) as session: + yield session + elif isinstance(server_params, SseServerParams): + async with sse_client(**server_params.model_dump()) as (read, write): + async with ClientSession(read_stream=read, write_stream=write) as session: + yield session diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_sse.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_sse.py new file mode 100644 index 000000000000..7b875649a4f4 --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_sse.py @@ -0,0 +1,104 @@ +from autogen_core import Component +from mcp import Tool +from pydantic import BaseModel +from typing_extensions import Self + +from ._base import McpToolAdapter +from ._config import SseServerParams + + +class SseMcpToolAdapterConfig(BaseModel): + """Configuration for the MCP tool adapter.""" + + server_params: SseServerParams + tool: Tool + + +class SseMcpToolAdapter( + McpToolAdapter[SseServerParams], + Component[SseMcpToolAdapterConfig], +): + """ + Allows you to wrap an MCP tool running over Server-Sent Events (SSE) and make it available to AutoGen. + + This adapter enables using MCP-compatible tools that communicate over HTTP with SSE + with AutoGen agents. Common use cases include integrating with remote MCP services, + cloud-based tools, and web APIs that implement the Model Context Protocol (MCP). + + Args: + server_params (SseServerParameters): Parameters for the MCP server connection, + including URL, headers, and timeouts + tool (Tool): The MCP tool to wrap + + Examples: + Use a remote translation service that implements MCP over SSE to create tools + that allow AutoGen agents to perform translations: + + .. code-block:: python + + import asyncio + from autogen_ext.models.openai import OpenAIChatCompletionClient + from autogen_ext.tools.mcp import SseMcpToolAdapter, SseServerParams + from autogen_agentchat.agents import AssistantAgent + from autogen_agentchat.ui import Console + from autogen_core import CancellationToken + + + async def main() -> None: + # Create server params for the remote MCP service + server_params = SseServerParams( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer your-api-key", "Content-Type": "application/json"}, + timeout=30, # Connection timeout in seconds + ) + + # Get the translation tool from the server + adapter = await SseMcpToolAdapter.from_server_params(server_params, "translate") + + # Create an agent that can use the translation tool + model_client = OpenAIChatCompletionClient(model="gpt-4") + agent = AssistantAgent( + name="translator", + model_client=model_client, + tools=[adapter], + system_message="You are a helpful translation assistant.", + ) + + # Let the agent translate some text + await Console( + agent.run_stream(task="Translate 'Hello, how are you?' to Spanish", cancellation_token=CancellationToken()) + ) + + + if __name__ == "__main__": + asyncio.run(main()) + + """ + + component_config_schema = SseMcpToolAdapterConfig + component_provider_override = "autogen_ext.tools.mcp.SseMcpToolAdapter" + + def __init__(self, server_params: SseServerParams, tool: Tool) -> None: + super().__init__(server_params=server_params, tool=tool) + + def _to_config(self) -> SseMcpToolAdapterConfig: + """ + Convert the adapter to its configuration representation. + + Returns: + SseMcpToolAdapterConfig: The configuration of the adapter. + """ + return SseMcpToolAdapterConfig(server_params=self._server_params, tool=self._tool) + + @classmethod + def _from_config(cls, config: SseMcpToolAdapterConfig) -> Self: + """ + Create an instance of SseMcpToolAdapter from its configuration. + + Args: + config (SseMcpToolAdapterConfig): The configuration of the adapter. + + Returns: + SseMcpToolAdapter: An instance of SseMcpToolAdapter. + """ + return cls(server_params=config.server_params, tool=config.tool) diff --git a/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_stdio.py b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_stdio.py new file mode 100644 index 000000000000..629b1a46ec1a --- /dev/null +++ b/python/packages/autogen-ext/src/autogen_ext/tools/mcp/_stdio.py @@ -0,0 +1,61 @@ +from autogen_core import Component +from mcp import Tool +from pydantic import BaseModel +from typing_extensions import Self + +from ._base import McpToolAdapter +from ._config import StdioServerParams + + +class StdioMcpToolAdapterConfig(BaseModel): + """Configuration for the MCP tool adapter.""" + + server_params: StdioServerParams + tool: Tool + + +class StdioMcpToolAdapter( + McpToolAdapter[StdioServerParams], + Component[StdioMcpToolAdapterConfig], +): + """Allows you to wrap an MCP tool running over STDIO and make it available to AutoGen. + + This adapter enables using MCP-compatible tools that communicate over standard input/output + with AutoGen agents. Common use cases include wrapping command-line tools and local services + that implement the Model Context Protocol (MCP). + + Args: + server_params (StdioServerParams): Parameters for the MCP server connection, + including command to run and its arguments + tool (Tool): The MCP tool to wrap + + See :func:`~autogen_ext.tools.mcp.mcp_server_tools` for examples. + """ + + component_config_schema = StdioMcpToolAdapterConfig + component_provider_override = "autogen_ext.tools.mcp.StdioMcpToolAdapter" + + def __init__(self, server_params: StdioServerParams, tool: Tool) -> None: + super().__init__(server_params=server_params, tool=tool) + + def _to_config(self) -> StdioMcpToolAdapterConfig: + """ + Convert the adapter to its configuration representation. + + Returns: + StdioMcpToolAdapterConfig: The configuration of the adapter. + """ + return StdioMcpToolAdapterConfig(server_params=self._server_params, tool=self._tool) + + @classmethod + def _from_config(cls, config: StdioMcpToolAdapterConfig) -> Self: + """ + Create an instance of StdioMcpToolAdapter from its configuration. + + Args: + config (StdioMcpToolAdapterConfig): The configuration of the adapter. + + Returns: + StdioMcpToolAdapter: An instance of StdioMcpToolAdapter. + """ + return cls(server_params=config.server_params, tool=config.tool) diff --git a/python/packages/autogen-ext/tests/tools/test_mcp_tools.py b/python/packages/autogen-ext/tests/tools/test_mcp_tools.py new file mode 100644 index 000000000000..7af9933d3a94 --- /dev/null +++ b/python/packages/autogen-ext/tests/tools/test_mcp_tools.py @@ -0,0 +1,271 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from autogen_core import CancellationToken +from autogen_ext.tools.mcp import ( + SseMcpToolAdapter, + SseServerParams, + StdioMcpToolAdapter, + StdioServerParams, +) +from json_schema_to_pydantic import create_model +from mcp import ClientSession, Tool + + +@pytest.fixture +def sample_tool() -> Tool: + return Tool( + name="test_tool", + description="A test tool", + inputSchema={ + "type": "object", + "properties": {"test_param": {"type": "string"}}, + "required": ["test_param"], + }, + ) + + +@pytest.fixture +def sample_server_params() -> StdioServerParams: + return StdioServerParams(command="echo", args=["test"]) + + +@pytest.fixture +def sample_sse_tool() -> Tool: + return Tool( + name="test_sse_tool", + description="A test SSE tool", + inputSchema={ + "type": "object", + "properties": {"test_param": {"type": "string"}}, + "required": ["test_param"], + }, + ) + + +@pytest.fixture +def mock_sse_session() -> AsyncMock: + session = AsyncMock(spec=ClientSession) + session.initialize = AsyncMock() + session.call_tool = AsyncMock() + session.list_tools = AsyncMock() + return session + + +@pytest.fixture +def mock_session() -> AsyncMock: + session = AsyncMock(spec=ClientSession) + session.initialize = AsyncMock() + session.call_tool = AsyncMock() + session.list_tools = AsyncMock() + return session + + +@pytest.fixture +def mock_tool_response() -> MagicMock: + response = MagicMock() + response.isError = False + response.content = {"result": "test_output"} + return response + + +@pytest.fixture +def cancellation_token() -> CancellationToken: + return CancellationToken() + + +def test_adapter_config_serialization(sample_tool: Tool, sample_server_params: StdioServerParams) -> None: + """Test that adapter can be saved to and loaded from config.""" + original_adapter = StdioMcpToolAdapter(server_params=sample_server_params, tool=sample_tool) + config = original_adapter.dump_component() + loaded_adapter = StdioMcpToolAdapter.load_component(config) + + # Test that the loaded adapter has the same properties + assert loaded_adapter.name == "test_tool" + assert loaded_adapter.description == "A test tool" + + # Verify schema structure + schema = loaded_adapter.schema + assert "parameters" in schema, "Schema must have parameters" + params_schema = schema["parameters"] + assert isinstance(params_schema, dict), "Parameters must be a dict" + assert "type" in params_schema, "Parameters must have type" + assert "required" in params_schema, "Parameters must have required fields" + assert "properties" in params_schema, "Parameters must have properties" + + # Compare schema content + assert params_schema["type"] == sample_tool.inputSchema["type"] + assert params_schema["required"] == sample_tool.inputSchema["required"] + assert ( + params_schema["properties"]["test_param"]["type"] == sample_tool.inputSchema["properties"]["test_param"]["type"] + ) + + +@pytest.mark.asyncio +async def test_mcp_tool_execution( + sample_tool: Tool, + sample_server_params: StdioServerParams, + mock_session: AsyncMock, + mock_tool_response: MagicMock, + cancellation_token: CancellationToken, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that adapter properly executes tools through ClientSession.""" + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_session + monkeypatch.setattr( + "autogen_ext.tools.mcp._base.create_mcp_server_session", + lambda *args, **kwargs: mock_context, # type: ignore + ) + + mock_session.call_tool.return_value = mock_tool_response + + adapter = StdioMcpToolAdapter(server_params=sample_server_params, tool=sample_tool) + result = await adapter.run( + args=create_model(sample_tool.inputSchema)(**{"test_param": "test"}), + cancellation_token=cancellation_token, + ) + + assert result == mock_tool_response.content + mock_session.initialize.assert_called_once() + mock_session.call_tool.assert_called_once() + + +@pytest.mark.asyncio +async def test_adapter_from_server_params( + sample_tool: Tool, + sample_server_params: StdioServerParams, + mock_session: AsyncMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that adapter can be created from server parameters.""" + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_session + monkeypatch.setattr( + "autogen_ext.tools.mcp._base.create_mcp_server_session", + lambda *args, **kwargs: mock_context, # type: ignore + ) + + mock_session.list_tools.return_value.tools = [sample_tool] + + adapter = await StdioMcpToolAdapter.from_server_params(sample_server_params, "test_tool") + + assert isinstance(adapter, StdioMcpToolAdapter) + assert adapter.name == "test_tool" + assert adapter.description == "A test tool" + + # Verify schema structure + schema = adapter.schema + assert "parameters" in schema, "Schema must have parameters" + params_schema = schema["parameters"] + assert isinstance(params_schema, dict), "Parameters must be a dict" + assert "type" in params_schema, "Parameters must have type" + assert "required" in params_schema, "Parameters must have required fields" + assert "properties" in params_schema, "Parameters must have properties" + + # Compare schema content + assert params_schema["type"] == sample_tool.inputSchema["type"] + assert params_schema["required"] == sample_tool.inputSchema["required"] + assert ( + params_schema["properties"]["test_param"]["type"] == sample_tool.inputSchema["properties"]["test_param"]["type"] + ) + + +@pytest.mark.asyncio +async def test_sse_adapter_config_serialization(sample_sse_tool: Tool) -> None: + """Test that SSE adapter can be saved to and loaded from config.""" + params = SseServerParams(url="http://test-url") + original_adapter = SseMcpToolAdapter(server_params=params, tool=sample_sse_tool) + config = original_adapter.dump_component() + loaded_adapter = SseMcpToolAdapter.load_component(config) + + # Test that the loaded adapter has the same properties + assert loaded_adapter.name == "test_sse_tool" + assert loaded_adapter.description == "A test SSE tool" + + # Verify schema structure + schema = loaded_adapter.schema + assert "parameters" in schema, "Schema must have parameters" + params_schema = schema["parameters"] + assert isinstance(params_schema, dict), "Parameters must be a dict" + assert "type" in params_schema, "Parameters must have type" + assert "required" in params_schema, "Parameters must have required fields" + assert "properties" in params_schema, "Parameters must have properties" + + # Compare schema content + assert params_schema["type"] == sample_sse_tool.inputSchema["type"] + assert params_schema["required"] == sample_sse_tool.inputSchema["required"] + assert ( + params_schema["properties"]["test_param"]["type"] + == sample_sse_tool.inputSchema["properties"]["test_param"]["type"] + ) + + +@pytest.mark.asyncio +async def test_sse_tool_execution( + sample_sse_tool: Tool, + mock_sse_session: AsyncMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that SSE adapter properly executes tools through ClientSession.""" + params = SseServerParams(url="http://test-url") + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_sse_session + + mock_sse_session.call_tool.return_value = MagicMock(isError=False, content={"result": "test_output"}) + + monkeypatch.setattr( + "autogen_ext.tools.mcp._base.create_mcp_server_session", + lambda *args, **kwargs: mock_context, # type: ignore + ) + + adapter = SseMcpToolAdapter(server_params=params, tool=sample_sse_tool) + result = await adapter.run( + args=create_model(sample_sse_tool.inputSchema)(**{"test_param": "test"}), + cancellation_token=CancellationToken(), + ) + + assert result == mock_sse_session.call_tool.return_value.content + mock_sse_session.initialize.assert_called_once() + mock_sse_session.call_tool.assert_called_once() + + +@pytest.mark.asyncio +async def test_sse_adapter_from_server_params( + sample_sse_tool: Tool, + mock_sse_session: AsyncMock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that SSE adapter can be created from server parameters.""" + params = SseServerParams(url="http://test-url") + mock_context = AsyncMock() + mock_context.__aenter__.return_value = mock_sse_session + monkeypatch.setattr( + "autogen_ext.tools.mcp._base.create_mcp_server_session", + lambda *args, **kwargs: mock_context, # type: ignore + ) + + mock_sse_session.list_tools.return_value.tools = [sample_sse_tool] + + adapter = await SseMcpToolAdapter.from_server_params(params, "test_sse_tool") + + assert isinstance(adapter, SseMcpToolAdapter) + assert adapter.name == "test_sse_tool" + assert adapter.description == "A test SSE tool" + + # Verify schema structure + schema = adapter.schema + assert "parameters" in schema, "Schema must have parameters" + params_schema = schema["parameters"] + assert isinstance(params_schema, dict), "Parameters must be a dict" + assert "type" in params_schema, "Parameters must have type" + assert "required" in params_schema, "Parameters must have required fields" + assert "properties" in params_schema, "Parameters must have properties" + + # Compare schema content + assert params_schema["type"] == sample_sse_tool.inputSchema["type"] + assert params_schema["required"] == sample_sse_tool.inputSchema["required"] + assert ( + params_schema["properties"]["test_param"]["type"] + == sample_sse_tool.inputSchema["properties"]["test_param"]["type"] + ) diff --git a/python/uv.lock b/python/uv.lock index eab21f4ab3cd..b4b1e170df60 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2,6 +2,7 @@ version = 1 requires-python = ">=3.10, <3.13" resolution-markers = [ "python_full_version >= '3.12.4' and sys_platform == 'darwin'", + "python_version < '0'", "python_full_version >= '3.12' and python_full_version < '3.12.4' and sys_platform == 'darwin'", "python_full_version >= '3.12.4' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and python_full_version < '3.12.4' and platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -611,6 +612,10 @@ magentic-one = [ { name = "pillow" }, { name = "playwright" }, ] +mcp = [ + { name = "json-schema-to-pydantic" }, + { name = "mcp" }, +] openai = [ { name = "aiofiles" }, { name = "openai" }, @@ -694,10 +699,12 @@ requires-dist = [ { name = "graphrag", marker = "extra == 'graphrag'", specifier = ">=1.0.1" }, { name = "grpcio", marker = "extra == 'grpc'", specifier = "~=1.70.0" }, { name = "ipykernel", marker = "extra == 'jupyter-executor'", specifier = ">=6.29.5" }, + { name = "json-schema-to-pydantic", marker = "extra == 'mcp'", specifier = ">=0.2.2" }, { 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'" }, @@ -2813,6 +2820,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" @@ -3854,6 +3873,23 @@ 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.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "pydantic" }, + { name = "sse-starlette" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/60/66ebfd280b197f9a9d074c9e46cb1ac3186a32d12e6bd0425c24fe7cf7e8/mcp-1.1.3.tar.gz", hash = "sha256:af11018b8e9153cdd25f3722ec639fe7a462c00213a330fd6f593968341a9883", size = 57903 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/08/cfcfa13e41f8d27503c51a8cbf1939d720073ace92469d08655bb5de1b24/mcp-1.1.3-py3-none-any.whl", hash = "sha256:71462d6cd7c06c14689dfcf110ff22286ba1b608cfc3515c0a5cbe33d131731a", size = 36997 }, +] + [[package]] name = "mdit-py-plugins" version = "0.4.2" @@ -4308,6 +4344,7 @@ name = "nvidia-cublas-cu12" version = "12.4.5.8" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/7f/7fbae15a3982dc9595e49ce0f19332423b260045d0a6afe93cdbe2f1f624/nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0f8aa1706812e00b9f19dfe0cdb3999b092ccb8ca168c0db5b8ea712456fd9b3", size = 363333771 }, { url = "https://files.pythonhosted.org/packages/ae/71/1c91302526c45ab494c23f61c7a84aa568b8c1f9d196efa5993957faf906/nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2fc8da60df463fdefa81e323eef2e36489e1c94335b5358bcb38360adf75ac9b", size = 363438805 }, ] @@ -4316,6 +4353,7 @@ name = "nvidia-cuda-cupti-cu12" version = "12.4.127" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/93/b5/9fb3d00386d3361b03874246190dfec7b206fd74e6e287b26a8fcb359d95/nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_aarch64.whl", hash = "sha256:79279b35cf6f91da114182a5ce1864997fd52294a87a16179ce275773799458a", size = 12354556 }, { url = "https://files.pythonhosted.org/packages/67/42/f4f60238e8194a3106d06a058d494b18e006c10bb2b915655bd9f6ea4cb1/nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9dec60f5ac126f7bb551c055072b69d85392b13311fcc1bcda2202d172df30fb", size = 13813957 }, ] @@ -4324,6 +4362,7 @@ name = "nvidia-cuda-nvrtc-cu12" version = "12.4.127" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/083b01c427e963ad0b314040565ea396f914349914c298556484f799e61b/nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0eedf14185e04b76aa05b1fea04133e59f465b6f960c0cbf4e37c3cb6b0ea198", size = 24133372 }, { url = "https://files.pythonhosted.org/packages/2c/14/91ae57cd4db3f9ef7aa99f4019cfa8d54cb4caa7e00975df6467e9725a9f/nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a178759ebb095827bd30ef56598ec182b85547f1508941a3d560eb7ea1fbf338", size = 24640306 }, ] @@ -4332,6 +4371,7 @@ name = "nvidia-cuda-runtime-cu12" version = "12.4.127" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/aa/b656d755f474e2084971e9a297def515938d56b466ab39624012070cb773/nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_aarch64.whl", hash = "sha256:961fe0e2e716a2a1d967aab7caee97512f71767f852f67432d572e36cb3a11f3", size = 894177 }, { url = "https://files.pythonhosted.org/packages/ea/27/1795d86fe88ef397885f2e580ac37628ed058a92ed2c39dc8eac3adf0619/nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:64403288fa2136ee8e467cdc9c9427e0434110899d07c779f25b5c068934faa5", size = 883737 }, ] @@ -4354,6 +4394,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/8a/0e728f749baca3fbeffad762738276e5df60851958be7783af121a7221e7/nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:5dad8008fc7f92f5ddfa2101430917ce2ffacd86824914c82e28990ad7f00399", size = 211422548 }, { url = "https://files.pythonhosted.org/packages/27/94/3266821f65b92b3138631e9c8e7fe1fb513804ac934485a8d05776e1dd43/nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f083fc24912aa410be21fa16d157fed2055dab1cc4b6934a0e03cba69eb242b9", size = 211459117 }, ] @@ -4362,6 +4403,7 @@ name = "nvidia-curand-cu12" version = "10.3.5.147" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/80/9c/a79180e4d70995fdf030c6946991d0171555c6edf95c265c6b2bf7011112/nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1f173f09e3e3c76ab084aba0de819c49e56614feae5c12f69883f4ae9bb5fad9", size = 56314811 }, { url = "https://files.pythonhosted.org/packages/8a/6d/44ad094874c6f1b9c654f8ed939590bdc408349f137f9b98a3a23ccec411/nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a88f583d4e0bb643c49743469964103aa59f7f708d862c3ddb0fc07f851e3b8b", size = 56305206 }, ] @@ -4375,6 +4417,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/46/6b/a5c33cf16af09166845345275c34ad2190944bcc6026797a39f8e0a282e0/nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d338f155f174f90724bbde3758b7ac375a70ce8e706d70b018dd3375545fc84e", size = 127634111 }, { url = "https://files.pythonhosted.org/packages/3a/e1/5b9089a4b2a4790dfdea8b3a006052cfecff58139d5a4e34cb1a51df8d6f/nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:19e33fa442bcfd085b3086c4ebf7e8debc07cfe01e11513cc6d332fd918ac260", size = 127936057 }, ] @@ -4386,6 +4429,7 @@ dependencies = [ { name = "nvidia-nvjitlink-cu12", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ + { url = "https://files.pythonhosted.org/packages/96/a9/c0d2f83a53d40a4a41be14cea6a0bf9e668ffcf8b004bd65633f433050c0/nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_aarch64.whl", hash = "sha256:9d32f62896231ebe0480efd8a7f702e143c98cfaa0e8a76df3386c1ba2b54df3", size = 207381987 }, { url = "https://files.pythonhosted.org/packages/db/f7/97a9ea26ed4bbbfc2d470994b8b4f338ef663be97b8f677519ac195e113d/nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ea4f11a2904e2a8dc4b1833cc1b5181cde564edd0d5cd33e3c168eff2d1863f1", size = 207454763 }, ] @@ -4402,6 +4446,7 @@ name = "nvidia-nvjitlink-cu12" version = "12.4.127" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/02/45/239d52c05074898a80a900f49b1615d81c07fceadd5ad6c4f86a987c0bc4/nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4abe7fef64914ccfa909bc2ba39739670ecc9e820c83ccc7a6ed414122599b83", size = 20552510 }, { url = "https://files.pythonhosted.org/packages/ff/ff/847841bacfbefc97a00036e0fce5a0f086b640756dc38caea5e1bb002655/nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:06b3b9b25bf3f8af351d664978ca26a16d2c5127dbd53c0497e28d1fb9611d57", size = 21066810 }, ] @@ -4410,6 +4455,7 @@ name = "nvidia-nvtx-cu12" version = "12.4.127" source = { registry = "https://pypi.org/simple" } wheels = [ + { url = "https://files.pythonhosted.org/packages/06/39/471f581edbb7804b39e8063d92fc8305bdc7a80ae5c07dbe6ea5c50d14a5/nvidia_nvtx_cu12-12.4.127-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7959ad635db13edf4fc65c06a6e9f9e55fc2f92596db928d169c0bb031e88ef3", size = 100417 }, { url = "https://files.pythonhosted.org/packages/87/20/199b8713428322a2f22b722c62b8cc278cc53dffa9705d744484b5035ee9/nvidia_nvtx_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:781e950d9b9f60d8241ccea575b32f5105a5baf4c2351cab5256a24869f12a1a", size = 99144 }, ] @@ -6785,6 +6831,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"