feat: add real elicitation integration test with python-sdk MCP client

This commit adds proper integration testing of the login elicitation flow
(ADR-006) using python-sdk's MCP client with actual elicitation callback
support, and fixes user_id extraction to support both JWT and opaque tokens.

## Changes

### 1. Enhanced create_mcp_client_session helper (tests/conftest.py)
- Added `elicitation_callback` parameter to function signature
- Pass callback to ClientSession constructor
- Added necessary imports: RequestContext, ElicitRequestParams,
  ElicitResult, ErrorData from mcp package
- Allows fixtures to provide custom elicitation handlers

### 2. New fixture: nc_mcp_oauth_client_with_elicitation (tests/conftest.py)
- Creates MCP client with Playwright-based elicitation callback
- Callback implementation:
  - Extracts OAuth URL from elicitation message using regex
  - Uses Playwright browser to complete OAuth flow automatically
  - Handles Nextcloud login form (username/password)
  - Handles consent screen if present
  - Waits for OAuth callback completion
  - Returns ElicitResult(action="accept") on success
- Function-scoped to allow independent test state
- Tracks elicitation invocations via session.elicitation_triggered

### 3. Fixed user_id extraction for opaque tokens (oauth_tools.py)
- Created extract_user_id_from_token() helper to handle both JWT and
  opaque tokens by calling userinfo endpoint when needed
- Fixed check_logged_in to use helper instead of broken ctx.authorization
- Fixed revoke_nextcloud_access to use helper instead of ctx.context.get()
- Both tools now properly extract user_id from access tokens

### 4. Enhanced integration tests (test_elicitation_integration.py)
- Updated tests to revoke refresh tokens via MCP tool
- All 4 tests now pass:
  - test_check_logged_in_with_real_elicitation_callback: Complete flow
  - test_elicitation_callback_url_extraction: URL extraction validation
  - test_elicitation_stores_refresh_token: Token persistence verification
  - test_second_check_logged_in_does_not_elicit: No redundant elicitations

### 5. Added diagnostic logging (oauth_routes.py)
- Track user_id extraction from ID tokens during OAuth callbacks
- Log refresh token storage with user_id and flow_type

## Test Results
 4/4 tests pass

The test suite successfully validates:
- Elicitation callback is triggered when no refresh token exists
- Playwright automation completes OAuth flow
- Refresh token is stored after OAuth with correct user_id
- Tool returns "yes" after successful login
- Already-logged-in users don't get redundant elicitations

## Why This Matters
Previous tests (test_login_elicitation.py) only validated response
formats and acknowledged they couldn't test real elicitation protocol.

This test exercises the REAL MCP elicitation protocol end-to-end:
1. MCP server calls ctx.elicit()
2. python-sdk ClientSession invokes custom callback
3. Callback completes OAuth via Playwright
4. Client returns acceptance to server
5. Tool proceeds with authenticated state

This proves the python-sdk MCP client can handle elicitation in
production environments with both JWT and opaque tokens.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-07 22:55:49 +01:00
co-authored by Claude
parent 11cdab475f
commit 71326384da
4 changed files with 464 additions and 24 deletions
+134 -1
View File
@@ -8,7 +8,9 @@ import httpx
import pytest
from httpx import HTTPStatusError
from mcp import ClientSession
from mcp.client.session import RequestContext
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
from nextcloud_mcp_server.client import NextcloudClient
@@ -110,6 +112,7 @@ async def create_mcp_client_session(
url: str,
token: str | None = None,
client_name: str = "MCP",
elicitation_callback: Any = None,
) -> AsyncGenerator[ClientSession, Any]:
"""
Factory function to create an MCP client session with proper lifecycle management.
@@ -127,6 +130,8 @@ async def create_mcp_client_session(
url: MCP server URL (e.g., "http://localhost:8000/mcp")
token: Optional OAuth access token for Bearer authentication
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
elicitation_callback: Optional callback for handling elicitation requests.
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
Yields:
Initialized MCP ClientSession
@@ -149,7 +154,9 @@ async def create_mcp_client_session(
write_stream,
_,
):
async with ClientSession(read_stream, write_stream) as session:
async with ClientSession(
read_stream, write_stream, elicitation_callback=elicitation_callback
) as session:
await session.initialize()
logger.info(f"{client_name} client session initialized successfully")
yield session
@@ -251,6 +258,132 @@ async def nc_mcp_oauth_jwt_client(
yield session
@pytest.fixture
async def nc_mcp_oauth_client_with_elicitation(
anyio_backend,
playwright_oauth_token: str,
browser,
) -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session with elicitation callback support.
This fixture enables REAL elicitation testing by providing a callback that:
1. Extracts OAuth URL from elicitation message
2. Uses Playwright to complete OAuth flow automatically
3. Returns acceptance to confirm completion
This allows testing the complete login elicitation flow (ADR-006) end-to-end,
verifying that:
- The check_logged_in tool triggers elicitation for unauthenticated users
- The OAuth flow completes successfully via automated browser
- Refresh token is stored after OAuth completion
- The tool returns "yes" after successful login
Uses function scope to allow each test to have independent elicitation state.
"""
# Get credentials from environment
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
if not all([username, password]):
pytest.skip(
"Elicitation test requires NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD"
)
# Track whether elicitation was triggered (for test validation)
elicitation_triggered = {"count": 0}
async def elicitation_callback(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
) -> ElicitResult | ErrorData:
"""Handle elicitation by completing OAuth flow with Playwright."""
elicitation_triggered["count"] += 1
logger.info("🎯 Elicitation callback invoked!")
logger.info(f" Message: {params.message[:100]}...")
logger.info(f" Schema: {params.schema}")
# Extract OAuth URL from elicitation message
import re
url_pattern = r"https?://[^\s]+"
urls = re.findall(url_pattern, params.message)
if not urls:
error_msg = "No URL found in elicitation message"
logger.error(f"{error_msg}")
return ErrorData(code=-32602, message=error_msg)
oauth_url = urls[0]
logger.info(f" Extracted URL: {oauth_url}")
# Complete OAuth flow with Playwright
page = await browser.new_page()
try:
logger.info("🌐 Navigating to OAuth URL...")
await page.goto(oauth_url, timeout=60000)
current_url = page.url
logger.info(f" Current URL after navigation: {current_url}")
# Handle login form if present
if "/login" in current_url or "/index.php/login" in current_url:
logger.info("🔐 Login page detected, filling credentials...")
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000)
logger.info(" ✓ Login completed")
# Handle consent screen if present
try:
consent_handled = await _handle_oauth_consent_screen(page, username)
if consent_handled:
logger.info(" ✓ Consent granted")
except Exception as e:
logger.debug(f" No consent screen: {e}")
# Wait for OAuth callback completion (redirect to success page or callback URL)
# The MCP server's callback endpoint will handle token exchange
await page.wait_for_load_state("networkidle", timeout=30000)
final_url = page.url
logger.info(f" Final URL: {final_url}")
# Return success - user "accepted" the elicitation
logger.info("✅ OAuth flow completed, returning accept")
return ElicitResult(action="accept", content={"acknowledged": True})
except Exception as e:
logger.error(f"❌ Elicitation OAuth flow failed: {e}")
# Take screenshot for debugging
try:
screenshot_path = f"/tmp/elicitation_oauth_failure_{uuid.uuid4()}.png"
await page.screenshot(path=screenshot_path)
logger.error(f" Screenshot saved: {screenshot_path}")
except Exception:
pass
return ErrorData(
code=-32603, message=f"Failed to complete OAuth flow: {str(e)}"
)
finally:
await page.close()
# Create client session with elicitation callback
async for session in create_mcp_client_session(
url="http://localhost:8001/mcp",
token=playwright_oauth_token,
client_name="OAuth MCP with Elicitation",
elicitation_callback=elicitation_callback,
):
# Attach elicitation metadata for test validation
session.elicitation_triggered = elicitation_triggered
yield session
@pytest.fixture(scope="session")
async def nc_mcp_oauth_client_read_only(
anyio_backend,