fix(tests): convert create_mcp_client_session to asynccontextmanager
The multi-user-basic integration job was consistently failing with `CancelledError: Cancelled via cancel scope ... by <async_generator_athrow>` followed by a cascade of `anyio.ClosedResourceError` in every subsequent test. Root cause: `create_mcp_client_session` was declared as an async generator driven by `async for session in ...:`, so Python's generator finalizer (`aclose`) ran under pytest-asyncio's cleanup task instead of the task that owned the nested `streamablehttp_client` cancel scope. anyio then raised when the inner task group saw its scope being exited from a foreign task, leaving the memory object streams half-closed and poisoning the rest of the session. Switching to `@asynccontextmanager` + `async with ... as session:` makes `__aenter__`/`__aexit__` run in the frame that owns the context manager, satisfying anyio's structured concurrency requirements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9b6b554155
commit
3935f45be8
+30
-28
@@ -8,8 +8,9 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator, AsyncIterator
|
||||||
from urllib.parse import parse_qs, quote, urlparse
|
from urllib.parse import parse_qs, quote, urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
@@ -118,6 +119,7 @@ async def wait_for_nextcloud(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
async def create_mcp_client_session(
|
async def create_mcp_client_session(
|
||||||
url: str,
|
url: str,
|
||||||
token: str | None = None,
|
token: str | None = None,
|
||||||
@@ -125,7 +127,7 @@ async def create_mcp_client_session(
|
|||||||
elicitation_callback: Any = None,
|
elicitation_callback: Any = None,
|
||||||
sampling_callback: Any = None,
|
sampling_callback: Any = None,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncIterator[ClientSession]:
|
||||||
"""
|
"""
|
||||||
Factory function to create an MCP client session with proper lifecycle management.
|
Factory function to create an MCP client session with proper lifecycle management.
|
||||||
|
|
||||||
@@ -227,10 +229,10 @@ async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
|
|||||||
|
|
||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8000/mcp",
|
url="http://localhost:8000/mcp",
|
||||||
client_name="Basic MCP (HTTP)",
|
client_name="Basic MCP (HTTP)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -246,11 +248,11 @@ async def nc_mcp_oauth_client(
|
|||||||
Uses headless browser automation suitable for CI/CD.
|
Uses headless browser automation suitable for CI/CD.
|
||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token,
|
token=playwright_oauth_token,
|
||||||
client_name="OAuth MCP (Playwright)",
|
client_name="OAuth MCP (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -271,11 +273,11 @@ async def nc_mcp_basic_auth_client(
|
|||||||
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
||||||
auth_header = f"Basic {credentials}"
|
auth_header = f"Basic {credentials}"
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8003/mcp",
|
url="http://localhost:8003/mcp",
|
||||||
headers={"Authorization": auth_header},
|
headers={"Authorization": auth_header},
|
||||||
client_name="BasicAuth MCP (Multi-User)",
|
client_name="BasicAuth MCP (Multi-User)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -296,11 +298,11 @@ async def nc_mcp_oauth_jwt_client(
|
|||||||
Uses headless browser automation suitable for CI/CD.
|
Uses headless browser automation suitable for CI/CD.
|
||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_jwt,
|
token=playwright_oauth_token_jwt,
|
||||||
client_name="OAuth JWT MCP (Playwright)",
|
client_name="OAuth JWT MCP (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -456,12 +458,12 @@ async def nc_mcp_oauth_client_with_elicitation(
|
|||||||
await page.close()
|
await page.close()
|
||||||
|
|
||||||
# Create client session with elicitation callback
|
# Create client session with elicitation callback
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token,
|
token=playwright_oauth_token,
|
||||||
client_name="OAuth MCP with Elicitation",
|
client_name="OAuth MCP with Elicitation",
|
||||||
elicitation_callback=elicitation_callback,
|
elicitation_callback=elicitation_callback,
|
||||||
):
|
) as session:
|
||||||
# Attach elicitation metadata for test validation
|
# Attach elicitation metadata for test validation
|
||||||
session.elicitation_triggered = elicitation_triggered
|
session.elicitation_triggered = elicitation_triggered
|
||||||
yield session
|
yield session
|
||||||
@@ -482,11 +484,11 @@ async def nc_mcp_oauth_client_read_only(
|
|||||||
Uses JWT tokens because they embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based tool filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_read_only,
|
token=playwright_oauth_token_read_only,
|
||||||
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -505,11 +507,11 @@ async def nc_mcp_oauth_client_write_only(
|
|||||||
Uses JWT tokens because they embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based tool filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_write_only,
|
token=playwright_oauth_token_write_only,
|
||||||
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -527,11 +529,11 @@ async def nc_mcp_oauth_client_full_access(
|
|||||||
Uses JWT tokens because they embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based tool filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_full_access,
|
token=playwright_oauth_token_full_access,
|
||||||
client_name="OAuth JWT MCP Full Access (Playwright)",
|
client_name="OAuth JWT MCP Full Access (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -552,11 +554,11 @@ async def nc_mcp_oauth_client_no_custom_scopes(
|
|||||||
Uses JWT tokens because they embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based tool filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_no_custom_scopes,
|
token=playwright_oauth_token_no_custom_scopes,
|
||||||
client_name="OAuth JWT MCP No Custom Scopes (Playwright)",
|
client_name="OAuth JWT MCP No Custom Scopes (Playwright)",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -2726,11 +2728,11 @@ async def alice_mcp_client(
|
|||||||
alice_oauth_token: str,
|
alice_oauth_token: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as alice (owner role)."""
|
"""MCP client authenticated as alice (owner role)."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=alice_oauth_token,
|
token=alice_oauth_token,
|
||||||
client_name="Alice MCP",
|
client_name="Alice MCP",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -2739,11 +2741,11 @@ async def bob_mcp_client(
|
|||||||
anyio_backend, bob_oauth_token: str
|
anyio_backend, bob_oauth_token: str
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as bob (viewer role)."""
|
"""MCP client authenticated as bob (viewer role)."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=bob_oauth_token,
|
token=bob_oauth_token,
|
||||||
client_name="Bob MCP",
|
client_name="Bob MCP",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -2753,11 +2755,11 @@ async def charlie_mcp_client(
|
|||||||
charlie_oauth_token: str,
|
charlie_oauth_token: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
"""MCP client authenticated as charlie (editor role, in 'editors' group)."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=charlie_oauth_token,
|
token=charlie_oauth_token,
|
||||||
client_name="Charlie MCP",
|
client_name="Charlie MCP",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -2767,11 +2769,11 @@ async def diana_mcp_client(
|
|||||||
diana_oauth_token: str,
|
diana_oauth_token: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client authenticated as diana (no-access role)."""
|
"""MCP client authenticated as diana (no-access role)."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8001/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=diana_oauth_token,
|
token=diana_oauth_token,
|
||||||
client_name="Diana MCP",
|
client_name="Diana MCP",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ def create_sampling_callback(provider: Provider):
|
|||||||
if provider.supports_generation:
|
if provider.supports_generation:
|
||||||
callback = create_sampling_callback(provider)
|
callback = create_sampling_callback(provider)
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8000/mcp",
|
url="http://localhost:8000/mcp",
|
||||||
sampling_callback=callback,
|
sampling_callback=callback,
|
||||||
):
|
) as session:
|
||||||
# Session now supports sampling
|
# Session now supports sampling
|
||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -145,11 +145,11 @@ async def test_astrolabe_plotly_visualization_with_basic_auth(
|
|||||||
logger.info(f"Authorization result: {auth_result}")
|
logger.info(f"Authorization result: {auth_result}")
|
||||||
|
|
||||||
# Create MCP client session as alice - all MCP operations inside this block
|
# Create MCP client session as alice - all MCP operations inside this block
|
||||||
async for alice_mcp_client in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8003/mcp",
|
url="http://localhost:8003/mcp",
|
||||||
headers={"Authorization": auth_header},
|
headers={"Authorization": auth_header},
|
||||||
client_name="Alice BasicAuth MCP",
|
client_name="Alice BasicAuth MCP",
|
||||||
):
|
) as alice_mcp_client:
|
||||||
# Phase 3: Get initial indexed count
|
# Phase 3: Get initial indexed count
|
||||||
initial_sync = await alice_mcp_client.call_tool(
|
initial_sync = await alice_mcp_client.call_tool(
|
||||||
"nc_get_vector_sync_status", {}
|
"nc_get_vector_sync_status", {}
|
||||||
@@ -355,11 +355,11 @@ The visualization should show this document as a point in PCA-reduced space.
|
|||||||
# Cleanup note if not already cleaned (create new client for cleanup)
|
# Cleanup note if not already cleaned (create new client for cleanup)
|
||||||
if note_id:
|
if note_id:
|
||||||
try:
|
try:
|
||||||
async for cleanup_client in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8003/mcp",
|
url="http://localhost:8003/mcp",
|
||||||
headers={"Authorization": auth_header},
|
headers={"Authorization": auth_header},
|
||||||
client_name="Cleanup MCP",
|
client_name="Cleanup MCP",
|
||||||
):
|
) as cleanup_client:
|
||||||
delete_response = await cleanup_client.call_tool(
|
delete_response = await cleanup_client.call_tool(
|
||||||
"nc_notes_delete_note", {"note_id": note_id}
|
"nc_notes_delete_note", {"note_id": note_id}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,11 +227,11 @@ async def nc_mcp_client_with_sampling(
|
|||||||
"""
|
"""
|
||||||
sampling_callback = create_sampling_callback(generation_provider)
|
sampling_callback = create_sampling_callback(generation_provider)
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url="http://localhost:8000/mcp",
|
url="http://localhost:8000/mcp",
|
||||||
client_name=f"Sampling MCP ({provider_name})",
|
client_name=f"Sampling MCP ({provider_name})",
|
||||||
sampling_callback=sampling_callback,
|
sampling_callback=sampling_callback,
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -367,12 +367,12 @@ async def nc_mcp_login_flow_client(
|
|||||||
content={"acknowledged": True},
|
content={"acknowledged": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=login_flow_oauth_token,
|
token=login_flow_oauth_token,
|
||||||
client_name="Login Flow MCP",
|
client_name="Login Flow MCP",
|
||||||
elicitation_callback=elicitation_callback,
|
elicitation_callback=elicitation_callback,
|
||||||
):
|
) as session:
|
||||||
# Step 1: Provision access via Login Flow v2
|
# Step 1: Provision access via Login Flow v2
|
||||||
logger.info("Starting Login Flow v2 provisioning...")
|
logger.info("Starting Login Flow v2 provisioning...")
|
||||||
provision_result = await session.call_tool(
|
provision_result = await session.call_tool(
|
||||||
@@ -499,11 +499,11 @@ async def nc_mcp_login_flow_client_read_only(
|
|||||||
anyio_backend, login_flow_read_only_token: str
|
anyio_backend, login_flow_read_only_token: str
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with read-only scopes on the login-flow server."""
|
"""MCP client with read-only scopes on the login-flow server."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=login_flow_read_only_token,
|
token=login_flow_read_only_token,
|
||||||
client_name="Login Flow MCP Read-Only",
|
client_name="Login Flow MCP Read-Only",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -512,11 +512,11 @@ async def nc_mcp_login_flow_client_write_only(
|
|||||||
anyio_backend, login_flow_write_only_token: str
|
anyio_backend, login_flow_write_only_token: str
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with write-only scopes on the login-flow server."""
|
"""MCP client with write-only scopes on the login-flow server."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=login_flow_write_only_token,
|
token=login_flow_write_only_token,
|
||||||
client_name="Login Flow MCP Write-Only",
|
client_name="Login Flow MCP Write-Only",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -525,11 +525,11 @@ async def nc_mcp_login_flow_client_full_access(
|
|||||||
anyio_backend, login_flow_full_access_token: str
|
anyio_backend, login_flow_full_access_token: str
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with full access scopes on the login-flow server."""
|
"""MCP client with full access scopes on the login-flow server."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=login_flow_full_access_token,
|
token=login_flow_full_access_token,
|
||||||
client_name="Login Flow MCP Full Access",
|
client_name="Login Flow MCP Full Access",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -538,11 +538,11 @@ async def nc_mcp_login_flow_client_no_custom_scopes(
|
|||||||
anyio_backend, login_flow_no_custom_scopes_token: str
|
anyio_backend, login_flow_no_custom_scopes_token: str
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with no custom scopes on the login-flow server."""
|
"""MCP client with no custom scopes on the login-flow server."""
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=login_flow_no_custom_scopes_token,
|
token=login_flow_no_custom_scopes_token,
|
||||||
client_name="Login Flow MCP No Custom Scopes",
|
client_name="Login Flow MCP No Custom Scopes",
|
||||||
):
|
) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@@ -724,12 +724,12 @@ async def _provision_login_flow_mcp_client(
|
|||||||
|
|
||||||
return ElicitResult(action="accept", content={"acknowledged": True})
|
return ElicitResult(action="accept", content={"acknowledged": True})
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async with create_mcp_client_session(
|
||||||
url=LOGIN_FLOW_MCP_URL,
|
url=LOGIN_FLOW_MCP_URL,
|
||||||
token=token,
|
token=token,
|
||||||
client_name=f"Login Flow MCP ({username})",
|
client_name=f"Login Flow MCP ({username})",
|
||||||
elicitation_callback=elicitation_callback,
|
elicitation_callback=elicitation_callback,
|
||||||
):
|
) as session:
|
||||||
# Provision access
|
# Provision access
|
||||||
provision_result = await session.call_tool(
|
provision_result = await session.call_tool(
|
||||||
"nc_auth_provision_access", {"scopes": None}
|
"nc_auth_provision_access", {"scopes": None}
|
||||||
|
|||||||
Reference in New Issue
Block a user