test: Cleanup testing fixtures regarding canceled scopes

This commit is contained in:
Chris Coutinho
2025-10-18 20:11:07 +02:00
parent 371d0c93a5
commit c3ff92a8c1
2 changed files with 425 additions and 68 deletions
+108 -68
View File
@@ -66,10 +66,14 @@ async def create_mcp_client_session(
"""
Factory function to create an MCP client session with proper lifecycle management.
Uses native async context managers to ensure correct LIFO cleanup order,
eliminating the need for exception suppression. Python's context manager protocol
guarantees that cleanup happens in reverse order of entry.
Consolidates the common pattern used by all MCP client fixtures:
- Creates streamable HTTP client with optional OAuth token
- Initializes MCP ClientSession
- Handles cleanup with proper exception handling
- Ensures proper cleanup without suppressing errors
Args:
url: MCP server URL (e.g., "http://127.0.0.1:8000/mcp")
@@ -78,48 +82,32 @@ async def create_mcp_client_session(
Yields:
Initialized MCP ClientSession
Note:
This implementation uses native async context managers instead of manually
calling __aenter__/__aexit__. This ensures that anyio's structured concurrency
requirements are met, as Python guarantees LIFO cleanup order for nested
context managers. See: https://github.com/modelcontextprotocol/python-sdk/issues/577
"""
logger.info(f"Creating Streamable HTTP client for {client_name}")
# Prepare headers with OAuth token if provided
headers = {"Authorization": f"Bearer {token}"} if token else None
streamable_context = streamablehttp_client(url, headers=headers)
session_context = None
try:
read_stream, write_stream, _ = await streamable_context.__aenter__()
session_context = ClientSession(read_stream, write_stream)
session = await session_context.__aenter__()
await session.initialize()
logger.info(f"{client_name} client session initialized successfully")
# Use native async with - Python ensures LIFO cleanup
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
async with streamablehttp_client(url, headers=headers) as (
read_stream,
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.info(f"{client_name} client session initialized successfully")
yield session
yield session
finally:
# Clean up in reverse order, ignoring task scope issues
# See: https://github.com/modelcontextprotocol/python-sdk/issues/577
if session_context is not None:
try:
await session_context.__aexit__(None, None, None)
except RuntimeError as e:
if "cancel scope" in str(e):
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
else:
logger.warning(f"Error closing {client_name} session: {e}")
except Exception as e:
logger.warning(f"Error closing {client_name} session: {e}")
try:
await streamable_context.__aexit__(None, None, None)
except RuntimeError as e:
if "cancel scope" in str(e):
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
else:
logger.warning(
f"Error closing {client_name} streamable HTTP client: {e}"
)
except Exception as e:
logger.warning(f"Error closing {client_name} streamable HTTP client: {e}")
# Cleanup happens automatically in LIFO order - no exception suppression needed
logger.debug(f"{client_name} client session cleaned up successfully")
@pytest.fixture(scope="session")
@@ -161,11 +149,28 @@ async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session for integration tests using streamable-http.
Note: This fixture uses a workaround for pytest-asyncio + anyio incompatibility.
pytest-asyncio runs fixture teardown in a new asyncio task, which violates anyio's
requirement that cancel scopes must be entered/exited in the same task. We catch
and ignore these expected teardown errors while allowing real errors to propagate.
See: https://github.com/modelcontextprotocol/python-sdk/issues/577
"""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8000/mcp", client_name="Basic MCP"
):
yield session
except RuntimeError as e:
# Expected error during pytest-asyncio fixture teardown
# pytest-asyncio creates a new task for teardown, causing:
# "Attempted to exit cancel scope in a different task than it was entered in"
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
# Unexpected RuntimeError - re-raise
raise
@pytest.fixture(scope="session")
@@ -177,13 +182,22 @@ async def nc_mcp_oauth_client(
Connects to the OAuth-enabled MCP server on port 8001 with OAuth authentication.
Uses headless browser automation suitable for CI/CD.
Note: Includes workaround for pytest-asyncio + anyio incompatibility.
See nc_mcp_client fixture for details.
"""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=playwright_oauth_token,
client_name="OAuth MCP (Playwright)",
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=playwright_oauth_token,
client_name="OAuth MCP (Playwright)",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
@pytest.fixture
@@ -1186,21 +1200,35 @@ async def alice_mcp_client(
alice_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as alice (owner role)."""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=alice_oauth_token,
client_name="Alice MCP",
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=alice_oauth_token,
client_name="Alice MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
@pytest.fixture(scope="session")
async def bob_mcp_client(bob_oauth_token: str) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as bob (viewer role)."""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp", token=bob_oauth_token, client_name="Bob MCP"
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=bob_oauth_token,
client_name="Bob MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
@pytest.fixture(scope="session")
@@ -1208,12 +1236,18 @@ async def charlie_mcp_client(
charlie_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=charlie_oauth_token,
client_name="Charlie MCP",
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=charlie_oauth_token,
client_name="Charlie MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
@pytest.fixture(scope="session")
@@ -1221,12 +1255,18 @@ async def diana_mcp_client(
diana_oauth_token: str,
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client authenticated as diana (no-access role)."""
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=diana_oauth_token,
client_name="Diana MCP",
):
yield session
try:
async for session in create_mcp_client_session(
url="http://127.0.0.1:8001/mcp",
token=diana_oauth_token,
client_name="Diana MCP",
):
yield session
except RuntimeError as e:
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
raise
# Test user/group fixtures for clean test isolation