diff --git a/python/setup.cfg b/python/setup.cfg index cb634962..d05314e1 100644 --- a/python/setup.cfg +++ b/python/setup.cfg @@ -31,12 +31,15 @@ sql = libsql-client>=0.3.1 strands-agents = strands-agents>=0.1.6 +mcp = + mcp>=1.0.0 all = anthropic>=0.40.0 openai>=1.55.3 boto3>=1.36.18 libsql-client>=0.3.1 + mcp>=1.0.0 [options.packages.find] where = src diff --git a/python/src/agent_squad/__init__.py b/python/src/agent_squad/__init__.py index 94b579f9..223a20c7 100644 --- a/python/src/agent_squad/__init__.py +++ b/python/src/agent_squad/__init__.py @@ -1,3 +1,9 @@ from .shared import user_agent -user_agent.inject_user_agent() \ No newline at end of file +user_agent.inject_user_agent() + +try: + from .tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + __all__ = ["MCPToolProvider", "MCPServerConfig"] +except ImportError: + pass \ No newline at end of file diff --git a/python/src/agent_squad/tools/__init__.py b/python/src/agent_squad/tools/__init__.py new file mode 100644 index 00000000..0aac7499 --- /dev/null +++ b/python/src/agent_squad/tools/__init__.py @@ -0,0 +1,4 @@ +"""Optional tool providers for agent-squad.""" +from .mcp_tool_provider import MCPToolProvider, MCPServerConfig + +__all__ = ["MCPToolProvider", "MCPServerConfig"] diff --git a/python/src/agent_squad/tools/mcp_tool_provider.py b/python/src/agent_squad/tools/mcp_tool_provider.py new file mode 100644 index 00000000..004129bc --- /dev/null +++ b/python/src/agent_squad/tools/mcp_tool_provider.py @@ -0,0 +1,324 @@ +""" +MCPToolProvider — drop-in AgentTools subclass that exposes MCP server tools. + +Usage example:: + + from agent_squad.tools import MCPToolProvider, MCPServerConfig + from agent_squad.agents import BedrockLLMAgent, BedrockLLMAgentOptions + + agent = BedrockLLMAgent(BedrockLLMAgentOptions( + name="my-agent", + description="An agent with MCP tools", + tool_config={ + "tool": MCPToolProvider([ + MCPServerConfig(type="stdio", command="uvx", args=["my-mcp-server"]), + MCPServerConfig(type="sse", url="http://localhost:3000/sse"), + ]) + } + )) + +Requires the ``mcp`` extra:: + + pip install agent-squad[mcp] +""" + +from __future__ import annotations + +try: + from mcp import ClientSession + from mcp.client.stdio import stdio_client, StdioServerParameters + from mcp.client.sse import sse_client +except ImportError as exc: + raise ImportError( + "MCPToolProvider requires the 'mcp' package. " + "Install it with: pip install agent-squad[mcp]" + ) from exc + +from dataclasses import dataclass, field +from typing import Any, Optional + +from agent_squad.types import AgentProviderType, ConversationMessage, ParticipantRole +from agent_squad.utils.tool import AgentTools, AgentToolCallbacks + + +@dataclass +class MCPServerConfig: + """Configuration for a single MCP server. + + For stdio transport set ``type="stdio"`` and provide ``command`` / ``args`` / ``env``. + For SSE/HTTP transport set ``type="sse"`` and provide ``url`` / ``headers``. + + Attributes: + type: Transport type — ``"stdio"`` or ``"sse"``. + command: Executable to launch (stdio only). + args: Command-line arguments (stdio only). + env: Environment variables to pass to the subprocess (stdio only). + url: SSE endpoint URL (sse only). + headers: HTTP headers to send with the SSE connection (sse only). + """ + + type: str # "stdio" or "sse" + command: Optional[str] = None + args: list[str] = field(default_factory=list) + env: Optional[dict[str, str]] = None + url: Optional[str] = None + headers: Optional[dict[str, str]] = None + + +class MCPToolProvider(AgentTools): + """AgentTools subclass that proxies tools from one or more MCP servers. + + Pass an instance directly as the ``tool`` value inside ``tool_config`` for + any ``BedrockLLMAgent``, ``AnthropicAgent``, or agent that accepts + ``AgentTools``:: + + tool_config={ + "tool": MCPToolProvider([ + MCPServerConfig(type="stdio", command="uvx", args=["my-server"]), + MCPServerConfig(type="sse", url="http://localhost:3000/sse"), + ]) + } + + Connections are established lazily on the first call that requires tool + metadata or tool execution. All servers are initialised once and then + reused for the lifetime of the provider instance. + + Args: + servers: List of :class:`MCPServerConfig` describing the MCP servers to + connect to. + callbacks: Optional :class:`~agent_squad.utils.tool.AgentToolCallbacks` + instance for lifecycle hooks. + """ + + def __init__( + self, + servers: list[MCPServerConfig], + callbacks: Optional[AgentToolCallbacks] = None, + ) -> None: + # Intentionally bypass AgentTools.__init__ — we have no static AgentTool list + self.tools: list[Any] = [] # unused; tools come from MCP at runtime + self.callbacks = callbacks or AgentToolCallbacks() + + self._servers = servers + # Maps tool_name → (ClientSession, mcp_tool) + self._tool_map: dict[str, tuple[Any, Any]] = {} + self._connected = False + # Keep hold of context-manager stacks so we can exit cleanly if needed + self._cm_stack: list[Any] = [] + + # ------------------------------------------------------------------ + # Internal connection management + # ------------------------------------------------------------------ + + async def _ensure_connected(self) -> None: + """Lazily connect to all configured servers and cache their tools.""" + if self._connected: + return + + for server_cfg in self._servers: + if server_cfg.type == "stdio": + if not server_cfg.command: + raise ValueError("MCPServerConfig with type='stdio' requires a 'command'") + params = StdioServerParameters( + command=server_cfg.command, + args=server_cfg.args or [], + env=server_cfg.env, + ) + cm = stdio_client(params) + elif server_cfg.type == "sse": + if not server_cfg.url: + raise ValueError("MCPServerConfig with type='sse' requires a 'url'") + cm = sse_client(server_cfg.url, headers=server_cfg.headers or {}) + else: + raise ValueError( + f"Unsupported MCPServerConfig type: '{server_cfg.type}'. " + "Use 'stdio' or 'sse'." + ) + + read, write = await cm.__aenter__() + self._cm_stack.append(cm) + + session = ClientSession(read, write) + await session.__aenter__() + await session.initialize() + + tools_result = await session.list_tools() + for mcp_tool in tools_result.tools: + self._tool_map[mcp_tool.name] = (session, mcp_tool) + + self._connected = True + + # ------------------------------------------------------------------ + # AgentTools interface + # ------------------------------------------------------------------ + + async def tool_handler( + self, + provider_type: str, + response: Any, + _conversation: list[dict[str, Any]], + agent_info: Optional[dict[str, Any]] = None, + ) -> Any: + """Execute tool calls found in *response* and return results. + + Compatible with Bedrock, Anthropic, and OpenAI response shapes. + """ + await self._ensure_connected() + + if not response.content: + raise ValueError("No content blocks in response") + + tool_results = [] + + for block in response.content: + tool_use_block = self._get_tool_use_block(provider_type, block) + if not tool_use_block: + continue + + if provider_type == AgentProviderType.BEDROCK.value: + tool_name = tool_use_block.get("name") + tool_id = tool_use_block.get("toolUseId") + input_data = tool_use_block.get("input", {}) + else: + # Anthropic object-style block + tool_name = tool_use_block.name + tool_id = tool_use_block.id + input_data = tool_use_block.input + + await self.callbacks.on_tool_start( + tool_name, input_data, metadata={"agent_info": agent_info} + ) + + result = await self._call_mcp_tool(tool_name, input_data) + + await self.callbacks.on_tool_end( + tool_name, input_data, result, metadata={"agent_info": agent_info} + ) + + if provider_type == AgentProviderType.BEDROCK.value: + tool_results.append( + { + "toolResult": { + "toolUseId": tool_id, + "content": [{"text": result}], + } + } + ) + else: + tool_results.append( + { + "type": "tool_result", + "tool_use_id": tool_id, + "content": result, + } + ) + + if provider_type == AgentProviderType.BEDROCK.value: + return ConversationMessage( + role=ParticipantRole.USER.value, content=tool_results + ) + return {"role": ParticipantRole.USER.value, "content": tool_results} + + async def _call_mcp_tool(self, tool_name: str, input_data: dict) -> str: + """Call a tool on the appropriate MCP server and return a string result.""" + if tool_name not in self._tool_map: + return f"Tool '{tool_name}' not found in any connected MCP server" + + session, _ = self._tool_map[tool_name] + try: + call_result = await session.call_tool(tool_name, input_data) + except Exception as exc: # noqa: BLE001 + return f"Error calling tool '{tool_name}': {exc}" + + if getattr(call_result, "isError", False): + # Surface the error text back to the model so it can react + parts = [ + getattr(item, "text", str(item)) + for item in (call_result.content or []) + ] + return f"Tool error: {' '.join(parts)}" if parts else "Tool returned an error" + + # Collect text content from the result + parts = [ + getattr(item, "text", str(item)) + for item in (call_result.content or []) + ] + return "\n".join(parts) if parts else "" + + # ------------------------------------------------------------------ + # Format conversion helpers + # ------------------------------------------------------------------ + + def to_bedrock_format(self) -> list[dict[str, Any]]: + """Return MCP tools in the Bedrock ``toolSpec`` format. + + .. note:: + This is a *synchronous* method. If the provider has not yet been + connected you must call ``await provider._ensure_connected()`` + before using this method, or use the agent's async flow which will + connect lazily via :meth:`tool_handler`. + """ + result = [] + for tool_name, (_session, mcp_tool) in self._tool_map.items(): + input_schema = ( + mcp_tool.inputSchema + if isinstance(mcp_tool.inputSchema, dict) + else {"type": "object", "properties": {}} + ) + result.append( + { + "toolSpec": { + "name": tool_name, + "description": mcp_tool.description or "", + "inputSchema": {"json": input_schema}, + } + } + ) + return result + + def to_claude_format(self) -> list[dict[str, Any]]: + """Return MCP tools in the Anthropic / Claude ``input_schema`` format.""" + result = [] + for tool_name, (_session, mcp_tool) in self._tool_map.items(): + input_schema = ( + mcp_tool.inputSchema + if isinstance(mcp_tool.inputSchema, dict) + else {"type": "object", "properties": {}} + ) + result.append( + { + "name": tool_name, + "description": mcp_tool.description or "", + "input_schema": input_schema, + } + ) + return result + + def to_anthropic_format(self) -> list[dict[str, Any]]: + """Alias for :meth:`to_claude_format` (same wire format).""" + return self.to_claude_format() + + def to_openai_format(self) -> list[dict[str, Any]]: + """Return MCP tools in the OpenAI function-calling format.""" + result = [] + for tool_name, (_session, mcp_tool) in self._tool_map.items(): + input_schema = ( + mcp_tool.inputSchema + if isinstance(mcp_tool.inputSchema, dict) + else {"type": "object", "properties": {}} + ) + # Ensure required field is present for strict mode compatibility + parameters = {**input_schema} + if "additionalProperties" not in parameters: + parameters["additionalProperties"] = False + result.append( + { + "type": "function", + "function": { + "name": tool_name, + "description": mcp_tool.description or "", + "parameters": parameters, + }, + } + ) + return result diff --git a/python/src/tests/tools/__init__.py b/python/src/tests/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/src/tests/tools/test_mcp_tool_provider.py b/python/src/tests/tools/test_mcp_tool_provider.py new file mode 100644 index 00000000..6e266535 --- /dev/null +++ b/python/src/tests/tools/test_mcp_tool_provider.py @@ -0,0 +1,464 @@ +"""Unit tests for MCPToolProvider. + +All MCP transport and session interactions are mocked — no real server required. +""" + +from __future__ import annotations + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from types import SimpleNamespace + +from agent_squad.types import AgentProviderType, ConversationMessage, ParticipantRole + + +# --------------------------------------------------------------------------- +# Helpers to build mock MCP objects +# --------------------------------------------------------------------------- + +def _make_mcp_tool(name: str, description: str = "", properties: dict | None = None, required: list | None = None): + """Return a mock object shaped like an mcp Tool.""" + input_schema = { + "type": "object", + "properties": properties or {"query": {"type": "string", "description": "search query"}}, + "required": required or ["query"], + } + return SimpleNamespace(name=name, description=description, inputSchema=input_schema) + + +def _make_call_result(text: str, is_error: bool = False): + """Return a mock object shaped like an mcp CallToolResult.""" + content_item = SimpleNamespace(text=text) + return SimpleNamespace(isError=is_error, content=[content_item]) + + +def _make_list_tools_result(tools): + return SimpleNamespace(tools=tools) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def mock_mcp_modules(): + """Patch mcp imports so tests don't need the real mcp package.""" + mock_client_session_cls = MagicMock() + mock_stdio_client = MagicMock() + mock_sse_client = MagicMock() + mock_stdio_params_cls = MagicMock() + + with ( + patch("agent_squad.tools.mcp_tool_provider.ClientSession", mock_client_session_cls), + patch("agent_squad.tools.mcp_tool_provider.stdio_client", mock_stdio_client), + patch("agent_squad.tools.mcp_tool_provider.sse_client", mock_sse_client), + patch("agent_squad.tools.mcp_tool_provider.StdioServerParameters", mock_stdio_params_cls), + ): + yield { + "ClientSession": mock_client_session_cls, + "stdio_client": mock_stdio_client, + "sse_client": mock_sse_client, + "StdioServerParameters": mock_stdio_params_cls, + } + + +def _build_provider(mocks, tools: list, server_type: str = "stdio"): + """Helper that returns an MCPToolProvider pre-wired with mock internals.""" + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + if server_type == "stdio": + cfg = MCPServerConfig(type="stdio", command="uvx", args=["my-server"]) + else: + cfg = MCPServerConfig(type="sse", url="http://localhost:3000/sse") + + provider = MCPToolProvider([cfg]) + + # Pre-populate the tool map so we skip the real async connect path + mock_session = AsyncMock() + mock_session.call_tool = AsyncMock() + for t in tools: + provider._tool_map[t.name] = (mock_session, t) + provider._connected = True + + return provider, mock_session + + +# --------------------------------------------------------------------------- +# to_bedrock_format +# --------------------------------------------------------------------------- + +def test_to_bedrock_format(mock_mcp_modules): + tool = _make_mcp_tool("search", "Search the web", {"q": {"type": "string"}}, ["q"]) + provider, _ = _build_provider(mock_mcp_modules, [tool]) + + result = provider.to_bedrock_format() + + assert len(result) == 1 + spec = result[0]["toolSpec"] + assert spec["name"] == "search" + assert spec["description"] == "Search the web" + assert spec["inputSchema"]["json"]["properties"] == {"q": {"type": "string"}} + assert spec["inputSchema"]["json"]["required"] == ["q"] + + +# --------------------------------------------------------------------------- +# to_claude_format / to_anthropic_format +# --------------------------------------------------------------------------- + +def test_to_claude_format(mock_mcp_modules): + tool = _make_mcp_tool("calculator", "Do math") + provider, _ = _build_provider(mock_mcp_modules, [tool]) + + result = provider.to_claude_format() + + assert len(result) == 1 + assert result[0]["name"] == "calculator" + assert result[0]["description"] == "Do math" + assert "input_schema" in result[0] + + +def test_to_anthropic_format_alias(mock_mcp_modules): + tool = _make_mcp_tool("calculator", "Do math") + provider, _ = _build_provider(mock_mcp_modules, [tool]) + + assert provider.to_anthropic_format() == provider.to_claude_format() + + +# --------------------------------------------------------------------------- +# to_openai_format +# --------------------------------------------------------------------------- + +def test_to_openai_format(mock_mcp_modules): + tool = _make_mcp_tool("fetch_url", "Fetch a URL", {"url": {"type": "string"}}, ["url"]) + provider, _ = _build_provider(mock_mcp_modules, [tool]) + + result = provider.to_openai_format() + + assert len(result) == 1 + fn = result[0] + assert fn["type"] == "function" + assert fn["function"]["name"] == "fetch_url" + assert fn["function"]["description"] == "Fetch a URL" + params = fn["function"]["parameters"] + assert params["properties"] == {"url": {"type": "string"}} + assert params["additionalProperties"] is False + + +# --------------------------------------------------------------------------- +# tool_handler — Bedrock provider +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tool_handler_bedrock(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + tool = _make_mcp_tool("weather", "Get weather") + provider, mock_session = _build_provider(mock_mcp_modules, [tool]) + + mock_session.call_tool.return_value = _make_call_result("Sunny, 25°C") + + # Bedrock-style response content block + bedrock_response = SimpleNamespace( + content=[ + { + "toolUse": { + "name": "weather", + "toolUseId": "tool-id-001", + "input": {"query": "London"}, + } + } + ] + ) + + result = await provider.tool_handler( + AgentProviderType.BEDROCK.value, bedrock_response, [] + ) + + assert isinstance(result, ConversationMessage) + assert result.role == ParticipantRole.USER.value + assert result.content[0]["toolResult"]["toolUseId"] == "tool-id-001" + assert result.content[0]["toolResult"]["content"][0]["text"] == "Sunny, 25°C" + mock_session.call_tool.assert_called_once_with("weather", {"query": "London"}) + + +# --------------------------------------------------------------------------- +# tool_handler — Anthropic provider +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tool_handler_anthropic(mock_mcp_modules): + tool = _make_mcp_tool("weather", "Get weather") + provider, mock_session = _build_provider(mock_mcp_modules, [tool]) + + mock_session.call_tool.return_value = _make_call_result("Rainy, 10°C") + + # Anthropic-style response content block (object with .type attribute) + tool_use_block = SimpleNamespace( + type="tool_use", name="weather", id="tool-id-002", input={"query": "Paris"} + ) + anthropic_response = SimpleNamespace(content=[tool_use_block]) + + result = await provider.tool_handler( + AgentProviderType.ANTHROPIC.value, anthropic_response, [] + ) + + assert isinstance(result, dict) + assert result["role"] == ParticipantRole.USER.value + tool_result = result["content"][0] + assert tool_result["type"] == "tool_result" + assert tool_result["tool_use_id"] == "tool-id-002" + assert tool_result["content"] == "Rainy, 10°C" + + +# --------------------------------------------------------------------------- +# Error handling — isError from MCP +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tool_handler_mcp_error(mock_mcp_modules): + tool = _make_mcp_tool("broken_tool", "A broken tool") + provider, mock_session = _build_provider(mock_mcp_modules, [tool]) + + mock_session.call_tool.return_value = _make_call_result( + "Something went wrong", is_error=True + ) + + bedrock_response = SimpleNamespace( + content=[ + { + "toolUse": { + "name": "broken_tool", + "toolUseId": "tool-id-err", + "input": {}, + } + } + ] + ) + + result = await provider.tool_handler( + AgentProviderType.BEDROCK.value, bedrock_response, [] + ) + + text_result = result.content[0]["toolResult"]["content"][0]["text"] + assert "Tool error" in text_result + assert "Something went wrong" in text_result + + +# --------------------------------------------------------------------------- +# Error handling — unknown tool name +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tool_handler_unknown_tool(mock_mcp_modules): + provider, _ = _build_provider(mock_mcp_modules, []) # no tools registered + + bedrock_response = SimpleNamespace( + content=[ + { + "toolUse": { + "name": "nonexistent", + "toolUseId": "tool-id-x", + "input": {}, + } + } + ] + ) + + result = await provider.tool_handler( + AgentProviderType.BEDROCK.value, bedrock_response, [] + ) + + text = result.content[0]["toolResult"]["content"][0]["text"] + assert "not found" in text + + +# --------------------------------------------------------------------------- +# Error handling — call_tool raises exception +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_tool_handler_call_exception(mock_mcp_modules): + tool = _make_mcp_tool("flaky", "Flaky tool") + provider, mock_session = _build_provider(mock_mcp_modules, [tool]) + + mock_session.call_tool.side_effect = RuntimeError("connection lost") + + bedrock_response = SimpleNamespace( + content=[ + { + "toolUse": { + "name": "flaky", + "toolUseId": "tool-id-f", + "input": {}, + } + } + ] + ) + + result = await provider.tool_handler( + AgentProviderType.BEDROCK.value, bedrock_response, [] + ) + + text = result.content[0]["toolResult"]["content"][0]["text"] + assert "Error calling tool" in text + assert "connection lost" in text + + +# --------------------------------------------------------------------------- +# Lazy connection — _ensure_connected +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_lazy_connection_stdio(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + tool = _make_mcp_tool("ping", "Ping") + + # Build a fake context manager that returns (read, write) streams + read_mock = MagicMock() + write_mock = MagicMock() + fake_cm = AsyncMock() + fake_cm.__aenter__ = AsyncMock(return_value=(read_mock, write_mock)) + fake_cm.__aexit__ = AsyncMock(return_value=False) + + mock_mcp_modules["stdio_client"].return_value = fake_cm + + # Build a fake ClientSession + mock_session_instance = AsyncMock() + mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) + mock_session_instance.__aexit__ = AsyncMock(return_value=False) + mock_session_instance.initialize = AsyncMock() + mock_session_instance.list_tools = AsyncMock( + return_value=_make_list_tools_result([tool]) + ) + mock_mcp_modules["ClientSession"].return_value = mock_session_instance + + provider = MCPToolProvider( + [MCPServerConfig(type="stdio", command="echo", args=["hello"])] + ) + + assert not provider._connected + assert len(provider._tool_map) == 0 + + await provider._ensure_connected() + + assert provider._connected + assert "ping" in provider._tool_map + mock_session_instance.initialize.assert_called_once() + mock_session_instance.list_tools.assert_called_once() + + +@pytest.mark.asyncio +async def test_lazy_connection_sse(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + tool = _make_mcp_tool("search", "Search") + + read_mock = MagicMock() + write_mock = MagicMock() + fake_cm = AsyncMock() + fake_cm.__aenter__ = AsyncMock(return_value=(read_mock, write_mock)) + fake_cm.__aexit__ = AsyncMock(return_value=False) + + mock_mcp_modules["sse_client"].return_value = fake_cm + + mock_session_instance = AsyncMock() + mock_session_instance.__aenter__ = AsyncMock(return_value=mock_session_instance) + mock_session_instance.__aexit__ = AsyncMock(return_value=False) + mock_session_instance.initialize = AsyncMock() + mock_session_instance.list_tools = AsyncMock( + return_value=_make_list_tools_result([tool]) + ) + mock_mcp_modules["ClientSession"].return_value = mock_session_instance + + provider = MCPToolProvider( + [MCPServerConfig(type="sse", url="http://localhost:9000/sse")] + ) + + await provider._ensure_connected() + + assert provider._connected + assert "search" in provider._tool_map + mock_mcp_modules["sse_client"].assert_called_once_with( + "http://localhost:9000/sse", headers={} + ) + + +@pytest.mark.asyncio +async def test_ensure_connected_idempotent(mock_mcp_modules): + """Calling _ensure_connected twice should not reconnect.""" + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + provider = MCPToolProvider( + [MCPServerConfig(type="stdio", command="echo", args=[])] + ) + provider._connected = True # pretend already connected + + await provider._ensure_connected() + + # stdio_client should never have been called + mock_mcp_modules["stdio_client"].assert_not_called() + + +# --------------------------------------------------------------------------- +# Config validation errors +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_stdio_missing_command(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + provider = MCPToolProvider([MCPServerConfig(type="stdio")]) # no command + + with pytest.raises(ValueError, match="command"): + await provider._ensure_connected() + + +@pytest.mark.asyncio +async def test_sse_missing_url(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + provider = MCPToolProvider([MCPServerConfig(type="sse")]) # no url + + with pytest.raises(ValueError, match="url"): + await provider._ensure_connected() + + +@pytest.mark.asyncio +async def test_unknown_transport_type(mock_mcp_modules): + from agent_squad.tools.mcp_tool_provider import MCPToolProvider, MCPServerConfig + + provider = MCPToolProvider([MCPServerConfig(type="grpc", url="grpc://localhost")]) + + with pytest.raises(ValueError, match="Unsupported"): + await provider._ensure_connected() + + +# --------------------------------------------------------------------------- +# Multiple tools from a single server +# --------------------------------------------------------------------------- + +def test_multiple_tools_format(mock_mcp_modules): + tools = [ + _make_mcp_tool("tool_a", "Tool A"), + _make_mcp_tool("tool_b", "Tool B"), + _make_mcp_tool("tool_c", "Tool C"), + ] + provider, _ = _build_provider(mock_mcp_modules, tools) + + bedrock_result = provider.to_bedrock_format() + claude_result = provider.to_claude_format() + openai_result = provider.to_openai_format() + + assert len(bedrock_result) == 3 + assert len(claude_result) == 3 + assert len(openai_result) == 3 + + names_bedrock = {r["toolSpec"]["name"] for r in bedrock_result} + assert names_bedrock == {"tool_a", "tool_b", "tool_c"} + + names_claude = {r["name"] for r in claude_result} + assert names_claude == {"tool_a", "tool_b", "tool_c"} + + names_openai = {r["function"]["name"] for r in openai_result} + assert names_openai == {"tool_a", "tool_b", "tool_c"}