diff --git a/atomic-agents/atomic_agents/connectors/mcp/mcp_factory.py b/atomic-agents/atomic_agents/connectors/mcp/mcp_factory.py index e43b6a8d..0d4c08ee 100644 --- a/atomic-agents/atomic_agents/connectors/mcp/mcp_factory.py +++ b/atomic-agents/atomic_agents/connectors/mcp/mcp_factory.py @@ -239,6 +239,15 @@ async def _call_with_persistent_session(): # Legacy behaviour – open a fresh connection per invocation. tool_result = await _connect_and_call() + # An MCP tool error is not successful content, even if it matches the output schema. + is_error = ( + tool_result.get("isError", False) + if isinstance(tool_result, dict) + else getattr(tool_result, "isError", False) + ) + if is_error: + raise RuntimeError(f"MCP tool '{bound_tool_name}' reported an error: {tool_result}") + # Process the result based on whether we have a typed output schema. # Extraction precedence for typed schemas: # 1. structuredContent attribute (MCP spec primary path) diff --git a/atomic-agents/tests/connectors/mcp/test_mcp_factory.py b/atomic-agents/tests/connectors/mcp/test_mcp_factory.py index 1b158187..8213473e 100644 --- a/atomic-agents/tests/connectors/mcp/test_mcp_factory.py +++ b/atomic-agents/tests/connectors/mcp/test_mcp_factory.py @@ -25,6 +25,42 @@ class DummySession: pass +@pytest.mark.asyncio +@pytest.mark.parametrize("typed_output", [False, True]) +@pytest.mark.parametrize("dict_result", [False, True]) +async def test_tool_error_is_rejected_before_output_conversion(typed_output, dict_result): + """Error content must not become a valid generic or typed tool result.""" + from mcp.types import CallToolResult, TextContent + + error = CallToolResult( + isError=True, + content=[TextContent(type="text", text="Rate limit exceeded")], + structuredContent={"results": []}, + ) + + class ErrorSession: + async def call_tool(self, name, arguments): + assert name == "Search" + return error.model_dump() if dict_result else error + + factory = MCPFactory(client_session=ErrorSession(), event_loop=asyncio.get_running_loop()) + output_schema = { + "type": "object", + "properties": {"results": {"type": "array", "items": {"type": "string"}}}, + "required": ["results"], + } + definition = MCPToolDefinition( + name="Search", + description="Search the web", + input_schema={"type": "object", "properties": {}}, + output_schema=output_schema if typed_output else None, + ) + Tool = factory._create_tool_classes([definition])[0] + + with pytest.raises(RuntimeError, match="reported an error.*Rate limit exceeded"): + await Tool().arun(Tool.input_schema(tool_name="Search")) + + def test_fetch_mcp_tools_no_endpoint_raises(): with pytest.raises(ValueError): fetch_mcp_tools() diff --git a/docs/examples/mcp_agent.md b/docs/examples/mcp_agent.md index 8477e14a..b98e384f 100644 --- a/docs/examples/mcp_agent.md +++ b/docs/examples/mcp_agent.md @@ -130,6 +130,90 @@ async def setup_tools(): return tools ``` +## Remote web search with Parallel + +The local examples above use a server base URL. To connect to a remote server +with an exact endpoint and custom headers, open an MCP SDK session and pass it +to `fetch_mcp_tools_async`. The generated tools reuse that session. + +This example uses [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +over Streamable HTTP at `https://search.parallel.ai/mcp`. Anonymous access needs +no Parallel account or API key and is rate limited. No local MCP server or LLM +key is needed for these direct tool calls. + +Run `uv sync` in the repository root, save the following as `parallel_search.py`, +then run `uv run python parallel_search.py`: + +```python +import asyncio +from importlib.metadata import version +from uuid import uuid4 + +import httpx +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +from atomic_agents.connectors.mcp import MCPTransportType, fetch_mcp_tools_async + + +async def main() -> None: + # Identify Atomic Agents for aggregate free MCP usage measurement. + # Keep this project-wide, without user or installation identifiers. + headers = { + "User-Agent": f"atomic-agents/{version('atomic-agents')} python-httpx/{httpx.__version__}", + } + async with streamablehttp_client("https://search.parallel.ai/mcp", headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + tools = await fetch_mcp_tools_async( + transport_type=MCPTransportType.HTTP_STREAM, + client_session=session, + ) + tools_by_name = {tool.mcp_tool_name: tool for tool in tools} + print("Available tools:", ", ".join(tools_by_name)) + + # Search and fetch belong to one task, independently of the HTTP session. + task_session_id = uuid4().hex + Search = tools_by_name["web_search"] + search_result = await Search().arun( + Search.input_schema( + tool_name="web_search", + objective="Find the official Parallel Search MCP setup instructions", + search_queries=["Parallel Search MCP setup"], + session_id=task_session_id, + ) + ) + print(search_result.model_dump_json(indent=2)) + + Fetch = tools_by_name["web_fetch"] + fetch_result = await Fetch().arun( + Fetch.input_schema( + tool_name="web_fetch", + urls=["https://docs.parallel.ai/integrations/mcp/search-mcp"], + objective="Explain anonymous access and the available tools", + full_content=False, + session_id=task_session_id, + ) + ) + print(fetch_result.model_dump_json(indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Running this example sends queries, requested URLs, objectives, and the supplied +task identifier to Parallel. Its project `User-Agent` identifies Atomic Agents +and its installed version for aggregate usage measurement. See Parallel's +[Customer Terms](https://parallel.ai/customer-terms) and +[Privacy Policy](https://parallel.ai/privacy-policy). + +The tools retain their discovered input and output schemas, including result +URLs, excerpts, warnings, and per-URL fetch errors. `full_content=False` requests +excerpts; fetching does not use a signed-in browser or browser cookies. Keep +retrieved page text separate from trusted instructions. If you give these tools +to an agent, it can invoke them as part of answering a question. Use them only +while the session context is open and call `arun` from the same event loop. + ## MCP Transport Methods The example implements three distinct transport methods via the `MCPTransportType` enum, each with its own advantages: