From e9b996abb05c2b0fc3cb88ccf919f61b599eae7c Mon Sep 17 00:00:00 2001 From: Zamil Majdy Date: Sun, 8 Feb 2026 13:49:44 +0400 Subject: [PATCH] feat(backend/blocks): Add integration tests and trusted_origins support - Add a test MCP server (test_server.py) for integration testing - Add 14 integration tests that hit a real local MCP server over HTTP - Add trusted_origins support to MCPClient for localhost/internal servers - MCPToolBlock now trusts the user-configured server URL by default - Add local conftest.py to avoid SpinTestServer overhead for MCP tests Test results: 34 unit tests + 14 integration tests = 48 total, all passing --- .../backend/backend/blocks/mcp/block.py | 7 +- .../backend/backend/blocks/mcp/client.py | 20 +- .../backend/backend/blocks/mcp/conftest.py | 21 + .../backend/blocks/mcp/test_integration.py | 374 ++++++++++++++++++ .../backend/backend/blocks/mcp/test_server.py | 163 ++++++++ 5 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 autogpt_platform/backend/backend/blocks/mcp/conftest.py create mode 100644 autogpt_platform/backend/backend/blocks/mcp/test_integration.py create mode 100644 autogpt_platform/backend/backend/blocks/mcp/test_server.py diff --git a/autogpt_platform/backend/backend/blocks/mcp/block.py b/autogpt_platform/backend/backend/blocks/mcp/block.py index 18e6576b8e..e7ac707758 100644 --- a/autogpt_platform/backend/backend/blocks/mcp/block.py +++ b/autogpt_platform/backend/backend/blocks/mcp/block.py @@ -172,7 +172,12 @@ class MCPToolBlock(Block): auth_token: str | None = None, ) -> Any: """Call a tool on the MCP server. Extracted for easy mocking in tests.""" - client = MCPClient(server_url, auth_token=auth_token) + # Trust the user-configured server URL to allow internal/localhost servers + client = MCPClient( + server_url, + auth_token=auth_token, + trusted_origins=[server_url], + ) await client.initialize() result = await client.call_tool(tool_name, arguments) diff --git a/autogpt_platform/backend/backend/blocks/mcp/client.py b/autogpt_platform/backend/backend/blocks/mcp/client.py index 664c9b8b7a..9953fe013e 100644 --- a/autogpt_platform/backend/backend/blocks/mcp/client.py +++ b/autogpt_platform/backend/backend/blocks/mcp/client.py @@ -47,9 +47,15 @@ class MCPClient: Supports optional Bearer token authentication. """ - def __init__(self, server_url: str, auth_token: str | None = None): + def __init__( + self, + server_url: str, + auth_token: str | None = None, + trusted_origins: list[str] | None = None, + ): self.server_url = server_url.rstrip("/") self.auth_token = auth_token + self.trusted_origins = trusted_origins or [] self._request_id = 0 def _next_id(self) -> int: @@ -84,7 +90,11 @@ class MCPClient: payload = self._build_jsonrpc_request(method, params) headers = self._build_headers() - requests = Requests(raise_for_status=True, extra_headers=headers) + requests = Requests( + raise_for_status=True, + extra_headers=headers, + trusted_origins=self.trusted_origins, + ) response = await requests.post(self.server_url, json=payload) body = response.json() @@ -102,7 +112,11 @@ class MCPClient: """Send a JSON-RPC notification (no id, no response expected).""" headers = self._build_headers() notification = {"jsonrpc": "2.0", "method": method} - requests = Requests(raise_for_status=False, extra_headers=headers) + requests = Requests( + raise_for_status=False, + extra_headers=headers, + trusted_origins=self.trusted_origins, + ) await requests.post(self.server_url, json=notification) async def initialize(self) -> dict[str, Any]: diff --git a/autogpt_platform/backend/backend/blocks/mcp/conftest.py b/autogpt_platform/backend/backend/blocks/mcp/conftest.py new file mode 100644 index 0000000000..80a4628354 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/mcp/conftest.py @@ -0,0 +1,21 @@ +""" +Conftest for MCP block tests. + +Override the session-scoped server and graph_cleanup fixtures from +backend/conftest.py so that MCP integration tests don't spin up the +full SpinTestServer infrastructure. +""" + +import pytest + + +@pytest.fixture(scope="session") +def server(): + """No-op override — MCP tests don't need the full platform server.""" + yield None + + +@pytest.fixture(scope="session", autouse=True) +def graph_cleanup(server): + """No-op override — MCP tests don't create graphs.""" + yield diff --git a/autogpt_platform/backend/backend/blocks/mcp/test_integration.py b/autogpt_platform/backend/backend/blocks/mcp/test_integration.py new file mode 100644 index 0000000000..7ba6a8d08f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/mcp/test_integration.py @@ -0,0 +1,374 @@ +""" +Integration tests for MCP client and MCPToolBlock against a real HTTP server. + +These tests spin up a local MCP test server and run the full client/block flow +against it — no mocking, real HTTP requests. +""" + +import asyncio +import json +import threading + +import pytest +from aiohttp import web +from pydantic import SecretStr + +from backend.blocks.mcp.block import MCPToolBlock +from backend.blocks.mcp.client import MCPClient +from backend.blocks.mcp.test_server import create_test_mcp_app +from backend.data.model import APIKeyCredentials + + +class _MCPTestServer: + """ + Run an MCP test server in a background thread with its own event loop. + This avoids event loop conflicts with pytest-asyncio. + """ + + def __init__(self, auth_token: str | None = None): + self.auth_token = auth_token + self.url: str = "" + self._runner: web.AppRunner | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._started = threading.Event() + + def _run(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._start()) + self._started.set() + self._loop.run_forever() + + async def _start(self): + app = create_test_mcp_app(auth_token=self.auth_token) + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + self.url = f"http://127.0.0.1:{port}/mcp" + + def start(self): + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + self._started.wait(timeout=5) + return self + + def stop(self): + if self._loop and self._runner: + asyncio.run_coroutine_threadsafe( + self._runner.cleanup(), self._loop + ).result(timeout=5) + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread: + self._thread.join(timeout=5) + + +@pytest.fixture(scope="module") +def mcp_server(): + """Start a local MCP test server in a background thread.""" + server = _MCPTestServer() + server.start() + yield server.url + server.stop() + + +@pytest.fixture(scope="module") +def mcp_server_with_auth(): + """Start a local MCP test server with auth in a background thread.""" + server = _MCPTestServer(auth_token="test-secret-token") + server.start() + yield server.url, "test-secret-token" + server.stop() + + +def _make_client(url: str, auth_token: str | None = None) -> MCPClient: + """Create an MCPClient with localhost trusted for integration tests.""" + return MCPClient(url, auth_token=auth_token, trusted_origins=[url]) + + +def _make_fake_creds(api_key: str = "FAKE_API_KEY") -> APIKeyCredentials: + return APIKeyCredentials( + id="test-integration", + provider="mcp", + api_key=SecretStr(api_key), + title="test", + ) + + +# ── MCPClient integration tests ────────────────────────────────────── + + +class TestMCPClientIntegration: + """Test MCPClient against a real local MCP server.""" + + @pytest.mark.asyncio + async def test_initialize(self, mcp_server): + client = _make_client(mcp_server) + result = await client.initialize() + + assert result["protocolVersion"] == "2025-03-26" + assert result["serverInfo"]["name"] == "test-mcp-server" + assert "tools" in result["capabilities"] + + @pytest.mark.asyncio + async def test_list_tools(self, mcp_server): + client = _make_client(mcp_server) + await client.initialize() + tools = await client.list_tools() + + assert len(tools) == 3 + + tool_names = {t.name for t in tools} + assert tool_names == {"get_weather", "add_numbers", "echo"} + + # Check get_weather schema + weather = next(t for t in tools if t.name == "get_weather") + assert weather.description == "Get current weather for a city" + assert "city" in weather.input_schema["properties"] + assert weather.input_schema["required"] == ["city"] + + # Check add_numbers schema + add = next(t for t in tools if t.name == "add_numbers") + assert "a" in add.input_schema["properties"] + assert "b" in add.input_schema["properties"] + + @pytest.mark.asyncio + async def test_call_tool_get_weather(self, mcp_server): + client = _make_client(mcp_server) + await client.initialize() + result = await client.call_tool("get_weather", {"city": "London"}) + + assert not result.is_error + assert len(result.content) == 1 + assert result.content[0]["type"] == "text" + + data = json.loads(result.content[0]["text"]) + assert data["city"] == "London" + assert data["temperature"] == 22 + assert data["condition"] == "sunny" + + @pytest.mark.asyncio + async def test_call_tool_add_numbers(self, mcp_server): + client = _make_client(mcp_server) + await client.initialize() + result = await client.call_tool("add_numbers", {"a": 3, "b": 7}) + + assert not result.is_error + data = json.loads(result.content[0]["text"]) + assert data["result"] == 10 + + @pytest.mark.asyncio + async def test_call_tool_echo(self, mcp_server): + client = _make_client(mcp_server) + await client.initialize() + result = await client.call_tool("echo", {"message": "Hello MCP!"}) + + assert not result.is_error + assert result.content[0]["text"] == "Hello MCP!" + + @pytest.mark.asyncio + async def test_call_unknown_tool(self, mcp_server): + client = _make_client(mcp_server) + await client.initialize() + result = await client.call_tool("nonexistent_tool", {}) + + assert result.is_error + assert "Unknown tool" in result.content[0]["text"] + + @pytest.mark.asyncio + async def test_auth_success(self, mcp_server_with_auth): + url, token = mcp_server_with_auth + client = _make_client(url, auth_token=token) + result = await client.initialize() + + assert result["protocolVersion"] == "2025-03-26" + + tools = await client.list_tools() + assert len(tools) == 3 + + @pytest.mark.asyncio + async def test_auth_failure(self, mcp_server_with_auth): + url, _ = mcp_server_with_auth + client = _make_client(url, auth_token="wrong-token") + + with pytest.raises(Exception): + await client.initialize() + + @pytest.mark.asyncio + async def test_auth_missing(self, mcp_server_with_auth): + url, _ = mcp_server_with_auth + client = _make_client(url) + + with pytest.raises(Exception): + await client.initialize() + + +# ── MCPToolBlock integration tests ─────────────────────────────────── + + +class TestMCPToolBlockIntegration: + """Test MCPToolBlock end-to-end against a real local MCP server.""" + + @pytest.mark.asyncio + async def test_full_flow_get_weather(self, mcp_server): + """Full flow: discover tools, select one, execute it.""" + # Step 1: Discover tools (simulating what the frontend/API would do) + client = _make_client(mcp_server) + await client.initialize() + tools = await client.list_tools() + assert len(tools) == 3 + + # Step 2: User selects "get_weather" and we get its schema + weather_tool = next(t for t in tools if t.name == "get_weather") + + # Step 3: Execute the block with the selected tool + block = MCPToolBlock() + input_data = MCPToolBlock.Input( + server_url=mcp_server, + selected_tool="get_weather", + tool_input_schema=weather_tool.input_schema, + tool_arguments={"city": "Paris"}, + credentials={ # type: ignore + "provider": "mcp", + "id": "test", + "type": "api_key", + "title": "test", + }, + ) + + outputs = [] + async for name, data in block.run( + input_data, credentials=_make_fake_creds() + ): + outputs.append((name, data)) + + assert len(outputs) == 1 + assert outputs[0][0] == "result" + result = outputs[0][1] + assert result["city"] == "Paris" + assert result["temperature"] == 22 + assert result["condition"] == "sunny" + + @pytest.mark.asyncio + async def test_full_flow_add_numbers(self, mcp_server): + """Full flow for add_numbers tool.""" + client = _make_client(mcp_server) + await client.initialize() + tools = await client.list_tools() + add_tool = next(t for t in tools if t.name == "add_numbers") + + block = MCPToolBlock() + input_data = MCPToolBlock.Input( + server_url=mcp_server, + selected_tool="add_numbers", + tool_input_schema=add_tool.input_schema, + tool_arguments={"a": 42, "b": 58}, + credentials={ # type: ignore + "provider": "mcp", + "id": "test", + "type": "api_key", + "title": "test", + }, + ) + + outputs = [] + async for name, data in block.run( + input_data, credentials=_make_fake_creds() + ): + outputs.append((name, data)) + + assert len(outputs) == 1 + assert outputs[0][0] == "result" + assert outputs[0][1]["result"] == 100 + + @pytest.mark.asyncio + async def test_full_flow_echo_plain_text(self, mcp_server): + """Verify plain text (non-JSON) responses work.""" + block = MCPToolBlock() + input_data = MCPToolBlock.Input( + server_url=mcp_server, + selected_tool="echo", + tool_input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + tool_arguments={"message": "Hello from AutoGPT!"}, + credentials={ # type: ignore + "provider": "mcp", + "id": "test", + "type": "api_key", + "title": "test", + }, + ) + + outputs = [] + async for name, data in block.run( + input_data, credentials=_make_fake_creds() + ): + outputs.append((name, data)) + + assert len(outputs) == 1 + assert outputs[0][0] == "result" + assert outputs[0][1] == "Hello from AutoGPT!" + + @pytest.mark.asyncio + async def test_full_flow_unknown_tool_yields_error(self, mcp_server): + """Calling an unknown tool should yield an error output.""" + block = MCPToolBlock() + input_data = MCPToolBlock.Input( + server_url=mcp_server, + selected_tool="nonexistent_tool", + tool_arguments={}, + credentials={ # type: ignore + "provider": "mcp", + "id": "test", + "type": "api_key", + "title": "test", + }, + ) + + outputs = [] + async for name, data in block.run( + input_data, credentials=_make_fake_creds() + ): + outputs.append((name, data)) + + assert len(outputs) == 1 + assert outputs[0][0] == "error" + assert "returned an error" in outputs[0][1] + + @pytest.mark.asyncio + async def test_full_flow_with_auth(self, mcp_server_with_auth): + """Full flow with authentication.""" + url, token = mcp_server_with_auth + + block = MCPToolBlock() + input_data = MCPToolBlock.Input( + server_url=url, + selected_tool="echo", + tool_input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + tool_arguments={"message": "Authenticated!"}, + credentials={ # type: ignore + "provider": "mcp", + "id": "test", + "type": "api_key", + "title": "test", + }, + ) + + outputs = [] + async for name, data in block.run( + input_data, credentials=_make_fake_creds(api_key=token) + ): + outputs.append((name, data)) + + assert len(outputs) == 1 + assert outputs[0][0] == "result" + assert outputs[0][1] == "Authenticated!" diff --git a/autogpt_platform/backend/backend/blocks/mcp/test_server.py b/autogpt_platform/backend/backend/blocks/mcp/test_server.py new file mode 100644 index 0000000000..a6861681ec --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/mcp/test_server.py @@ -0,0 +1,163 @@ +""" +Minimal MCP server for integration testing. + +Implements the MCP Streamable HTTP transport (JSON-RPC 2.0 over HTTP POST) +with a few sample tools. Runs on localhost with a random available port. +""" + +import json +import logging +from aiohttp import web + +logger = logging.getLogger(__name__) + +# Sample tools this test server exposes +TEST_TOOLS = [ + { + "name": "get_weather", + "description": "Get current weather for a city", + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "City name", + }, + }, + "required": ["city"], + }, + }, + { + "name": "add_numbers", + "description": "Add two numbers together", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number", "description": "First number"}, + "b": {"type": "number", "description": "Second number"}, + }, + "required": ["a", "b"], + }, + }, + { + "name": "echo", + "description": "Echo back the input message", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string", "description": "Message to echo"}, + }, + "required": ["message"], + }, + }, +] + + +def _handle_initialize(params: dict) -> dict: + return { + "protocolVersion": "2025-03-26", + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "test-mcp-server", "version": "1.0.0"}, + } + + +def _handle_tools_list(params: dict) -> dict: + return {"tools": TEST_TOOLS} + + +def _handle_tools_call(params: dict) -> dict: + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + + if tool_name == "get_weather": + city = arguments.get("city", "Unknown") + return { + "content": [ + { + "type": "text", + "text": json.dumps( + {"city": city, "temperature": 22, "condition": "sunny"} + ), + } + ], + } + + elif tool_name == "add_numbers": + a = arguments.get("a", 0) + b = arguments.get("b", 0) + return { + "content": [{"type": "text", "text": json.dumps({"result": a + b})}], + } + + elif tool_name == "echo": + message = arguments.get("message", "") + return { + "content": [{"type": "text", "text": message}], + } + + else: + return { + "content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], + "isError": True, + } + + +HANDLERS = { + "initialize": _handle_initialize, + "tools/list": _handle_tools_list, + "tools/call": _handle_tools_call, +} + + +async def handle_mcp_request(request: web.Request) -> web.Response: + """Handle incoming MCP JSON-RPC 2.0 requests.""" + # Check auth if configured + expected_token = request.app.get("auth_token") + if expected_token: + auth_header = request.headers.get("Authorization", "") + if auth_header != f"Bearer {expected_token}": + return web.json_response( + { + "jsonrpc": "2.0", + "error": {"code": -32001, "message": "Unauthorized"}, + "id": None, + }, + status=401, + ) + + body = await request.json() + + # Handle notifications (no id field) — just acknowledge + if "id" not in body: + return web.Response(status=202) + + method = body.get("method", "") + params = body.get("params", {}) + request_id = body.get("id") + + handler = HANDLERS.get(method) + if not handler: + return web.json_response( + { + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": f"Method not found: {method}", + }, + "id": request_id, + } + ) + + result = handler(params) + return web.json_response( + {"jsonrpc": "2.0", "result": result, "id": request_id} + ) + + +def create_test_mcp_app(auth_token: str | None = None) -> web.Application: + """Create an aiohttp app that acts as an MCP server.""" + app = web.Application() + app.router.add_post("/mcp", handle_mcp_request) + if auth_token: + app["auth_token"] = auth_token + return app