test_transport_integration.pyā¢13.8 kB
"""Transport integration tests for MCP server.
These tests verify that both stdio and streamable-http transports work
correctly with the Odoo MCP server in integration with pytest.
"""
import asyncio
import json
import subprocess
import sys
from typing import Any, Dict, List, Optional
import pytest
import requests
from tests.helpers.mcp_test_client import MCPTestClient
# Mark all tests in this module as integration tests requiring Odoo
pytestmark = [pytest.mark.integration, pytest.mark.odoo_required]
class HttpTransportTester:
"""Helper class for testing streamable-http transport."""
def __init__(self, base_url: str = "http://localhost:8002/mcp/"):
self.base_url = base_url.rstrip("/") + "/"
self.session = requests.Session()
self.session_id: Optional[str] = None
self.request_id = 0
self.server_process = None
def _next_id(self) -> int:
"""Get next request ID."""
self.request_id += 1
return self.request_id
@staticmethod
def _parse_sse_response(text: str) -> List[Dict[str, Any]]:
"""Parse SSE-formatted response."""
results = []
for line in text.strip().split("\n"):
if line.startswith("data:"):
try:
data = json.loads(line[5:].strip())
results.append(data)
except json.JSONDecodeError:
pass
return results
async def start_server(self, port: int = 8002, timeout: int = 10) -> bool:
"""Start HTTP server."""
try:
self.server_process = subprocess.Popen(
[
sys.executable,
"-m",
"mcp_server_odoo",
"--transport",
"streamable-http",
"--port",
str(port),
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# Wait for server to start
await asyncio.sleep(5)
# Test if server is responding
try:
requests.get(f"http://localhost:{port}/", timeout=3)
return True
except requests.exceptions.RequestException:
# Server might not have a root endpoint, try the MCP endpoint
try:
# Just check if we can connect
requests.post(
f"http://localhost:{port}/mcp/", json={"test": "connectivity"}, timeout=3
)
return True
except requests.exceptions.RequestException:
return self.server_process.poll() is None
except Exception as e:
print(f"Failed to start HTTP server: {e}")
return False
def stop_server(self):
"""Stop HTTP server."""
if self.server_process:
self.server_process.terminate()
try:
self.server_process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.server_process.kill()
self.server_process.wait()
self.server_process = None
async def _send_request(
self, method: str, params: Dict[str, Any], request_id: Optional[int] = None
) -> Optional[Dict[str, Any]]:
"""Send HTTP request and return response."""
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
if self.session_id:
headers["mcp-session-id"] = self.session_id
payload = {"jsonrpc": "2.0", "method": method, "params": params}
if request_id is not None:
payload["id"] = request_id
try:
response = self.session.post(self.base_url, json=payload, headers=headers, timeout=15)
# Update session ID if provided
if "mcp-session-id" in response.headers:
self.session_id = response.headers["mcp-session-id"]
# Parse SSE response
if response.status_code == 200:
events = self._parse_sse_response(response.text)
for event in events:
if request_id is not None and event.get("id") == request_id:
return event
elif request_id is None:
return {"status": response.status_code}
return {
"error": f"Request failed with status {response.status_code}",
"status": response.status_code,
}
except Exception as e:
return {"error": str(e)}
class TestTransportIntegration:
"""Integration tests for MCP transports."""
@pytest.mark.asyncio
async def test_stdio_transport_basic_flow(self, odoo_server_required):
"""Test stdio transport basic initialization and communication."""
client = MCPTestClient()
try:
async with client.connect():
# Test basic operations
tools = await client.list_tools()
assert len(tools) > 0, "Expected at least one tool"
await client.list_resources()
# Resources might be empty, that's ok for transport testing
# Test a basic tool call - list_models should work with proper auth
result = await client.call_tool("list_models", {})
assert result is not None, "Tool call should return a result"
# The result should be a proper MCP response
assert hasattr(result, "content"), "Tool result should have content"
except Exception as e:
# Log the actual error for debugging
import logging
logging.error(f"stdio transport test failed: {e}")
# Re-raise to fail the test
raise
@pytest.mark.asyncio
async def test_stdio_transport_multiple_requests(self, odoo_server_required):
"""Test stdio transport can handle multiple sequential requests."""
client = MCPTestClient()
try:
async with client.connect():
# Make multiple tool list requests
for i in range(3):
tools = await client.list_tools()
assert len(tools) > 0, f"Expected tools on request {i + 1}"
# Make multiple resource list requests
for i in range(3):
resources = await client.list_resources()
# Resources might be empty, that's ok - just testing transport stability
assert (
resources is not None
), f"Resource list should not be None on request {i + 1}"
except Exception as e:
# Log the actual error for debugging
import logging
logging.error(f"stdio multiple requests test failed: {e}")
raise
@pytest.mark.asyncio
async def test_http_transport_basic_flow(self, odoo_server_required):
"""Test streamable-http transport basic initialization and communication."""
tester = HttpTransportTester()
try:
# Start server
assert await tester.start_server(), "Failed to start HTTP server"
# Test initialization
init_params = {
"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
"clientInfo": {"name": "pytest-http", "version": "1.0.0"},
}
response = await tester._send_request("initialize", init_params, tester._next_id())
assert response is not None, "No response to initialize request"
assert "error" not in response, f"Error in initialize response: {response}"
assert "result" in response, f"Expected result in response, got: {response}"
assert tester.session_id is not None, "No session ID received"
# Send initialized notification
await tester._send_request("notifications/initialized", {})
# Test tools/list
response = await tester._send_request("tools/list", {}, tester._next_id())
assert response is not None, "No response to tools/list request"
assert "error" not in response, f"Error in tools/list response: {response}"
assert "result" in response, f"Expected result in tools/list response, got: {response}"
finally:
tester.stop_server()
@pytest.mark.asyncio
async def test_http_transport_session_persistence(self, odoo_server_required):
"""Test that HTTP transport maintains session across requests."""
tester = HttpTransportTester()
try:
# Start and initialize server
assert await tester.start_server(), "Failed to start HTTP server"
# Initialize
init_params = {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "pytest-http", "version": "1.0"},
}
response = await tester._send_request("initialize", init_params, tester._next_id())
assert response and "result" in response
original_session_id = tester.session_id
assert original_session_id is not None, "No session ID after initialize"
# Send initialized notification
await tester._send_request("notifications/initialized", {})
# Make multiple requests and verify session ID persists
for i in range(3):
response = await tester._send_request("tools/list", {}, tester._next_id())
assert response is not None, f"No response to request {i + 1}"
assert "error" not in response, f"Error in request {i + 1}: {response}"
assert (
tester.session_id == original_session_id
), f"Session ID changed on request {i + 1}"
finally:
tester.stop_server()
@pytest.mark.asyncio
async def test_http_transport_tool_call(self, odoo_server_required):
"""Test HTTP transport can execute tool calls."""
tester = HttpTransportTester()
try:
# Start and initialize server
assert await tester.start_server(), "Failed to start HTTP server"
# Initialize
init_params = {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "pytest-http", "version": "1.0"},
}
response = await tester._send_request("initialize", init_params, tester._next_id())
assert response and "result" in response
# Send initialized notification
await tester._send_request("notifications/initialized", {})
# Test list_models tool call
params = {"name": "list_models", "arguments": {}}
response = await tester._send_request("tools/call", params, tester._next_id())
assert response is not None, "No response to tool call"
# Note: The tool call might fail due to auth, but the transport should work
# Just check that we got some kind of response (transport working)
assert isinstance(response, dict), f"Expected dict response, got: {response}"
# Accept either successful result or any error that's not a transport error
has_result = "result" in response
has_error = "error" in response
if has_error:
error = response.get("error")
# If error is a dict with code, check it's not a transport error (-32600)
if isinstance(error, dict) and error.get("code") == -32600:
raise AssertionError(f"Transport error in tool call: {response}")
# If we get here, either we have a result or a non-transport error
assert has_result or has_error, f"Response should have result or error: {response}"
finally:
tester.stop_server()
@pytest.mark.integration
class TestTransportCompatibility:
"""Test transport compatibility and edge cases."""
@pytest.mark.asyncio
async def test_server_version_consistency(self, odoo_server_required):
"""Test that both transports can successfully connect and communicate."""
# Test stdio connection
stdio_client = MCPTestClient()
stdio_connected = False
try:
async with stdio_client.connect():
# Test basic operation to verify connection works
tools = await stdio_client.list_tools()
stdio_connected = len(tools) > 0
except Exception:
stdio_connected = False
# Test HTTP connection
http_tester = HttpTransportTester()
http_connected = False
try:
if await http_tester.start_server():
response = await http_tester._send_request(
"initialize",
{
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"},
},
1,
)
http_connected = response is not None and "result" in response
finally:
http_tester.stop_server()
# Both transports should successfully connect
assert stdio_connected, "Failed to connect via stdio transport"
assert http_connected, "Failed to connect via HTTP transport"