test: Cleanup testing fixtures regarding canceled scopes
This commit is contained in:
+108
-68
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user