"""Helper functions for accessing context in MCP tools.""" import logging from httpx import BasicAuth from mcp.server.fastmcp import Context from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import ( DeploymentMode, get_deployment_mode, get_settings, ) logger = logging.getLogger(__name__) async def get_client(ctx: Context) -> NextcloudClient: """ Get the appropriate Nextcloud client based on authentication mode. ADR-016 compliant implementation supporting three deployment modes: 1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true): Create client from session configuration (nextcloud_url, username, app_password) No persistent state - client created per-request from Smithery session config. 2. BasicAuth mode: Returns shared client from lifespan context 3. OAuth mode: a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default): Token already contains both MCP and Nextcloud audiences - use directly b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true): Exchange MCP token for Nextcloud token via RFC 8693 SECURITY: Token passthrough has been REMOVED. All OAuth modes validate proper token audiences per MCP Security Best Practices specification. Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced by the MCP server via @require_scopes decorator, not by the IdP. This function automatically detects the authentication mode by checking the deployment mode and type of the lifespan context. Args: ctx: MCP request context Returns: NextcloudClient configured for the current authentication mode Raises: AttributeError: If context doesn't contain expected data ValueError: If Smithery mode but session config is missing required fields Example: ```python @mcp.tool() async def my_tool(ctx: Context): client = await get_client(ctx) return await client.capabilities() ``` """ deployment_mode = get_deployment_mode() # ADR-016: Smithery stateless mode - create client from session config if deployment_mode == DeploymentMode.SMITHERY_STATELESS: return _get_client_from_session_config(ctx) settings = get_settings() # Multi-user BasicAuth pass-through mode - extract credentials from request if settings.enable_multi_user_basic_auth: return _get_client_from_basic_auth(ctx) lifespan_ctx = ctx.request_context.lifespan_context # BasicAuth mode - use shared client (no token exchange) if hasattr(lifespan_ctx, "client"): return lifespan_ctx.client # OAuth mode (has 'nextcloud_host' attribute) if hasattr(lifespan_ctx, "nextcloud_host"): from nextcloud_mcp_server.auth.context_helper import ( get_client_from_context, get_session_client_from_context, ) if settings.enable_token_exchange: # Mode 2: Exchange MCP token for Nextcloud token # Token was validated to have MCP audience in UnifiedTokenVerifier # Now exchange it for Nextcloud audience return await get_session_client_from_context( ctx, lifespan_ctx.nextcloud_host ) else: # Mode 1: Multi-audience token - use directly # Token was validated to have MCP audience in UnifiedTokenVerifier # Nextcloud will independently validate its own audience when receiving API calls return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) # Unknown context type raise AttributeError( f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. " f"Type: {type(lifespan_ctx)}" ) def _get_client_from_session_config(ctx: Context) -> NextcloudClient: """ Create NextcloudClient from Smithery session configuration. ADR-016: In Smithery stateless mode, each request includes session config with the user's Nextcloud credentials. This function creates a fresh client for each request - no state is persisted between requests. For container runtime, config is extracted from URL query parameters by SmitheryConfigMiddleware and stored in a context variable. Expected session config fields (from Smithery configSchema): - nextcloud_url: str - Nextcloud instance URL (required) - username: str - Nextcloud username (required) - app_password: str - Nextcloud app password (required) Args: ctx: MCP request context (not used directly for Smithery config) Returns: NextcloudClient configured with session credentials Raises: ValueError: If required session config fields are missing """ # ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware) from nextcloud_mcp_server.app import get_smithery_session_config session_config = get_smithery_session_config() if session_config is None: raise ValueError( "Session configuration required in Smithery mode. " "Ensure nextcloud_url, username, and app_password are provided as URL query parameters." ) # Extract required fields - config is always a dict from SmitheryConfigMiddleware nextcloud_url = session_config.get("nextcloud_url") username = session_config.get("username") app_password = session_config.get("app_password") # Validate required fields missing_fields = [] if not nextcloud_url: missing_fields.append("nextcloud_url") if not username: missing_fields.append("username") if not app_password: missing_fields.append("app_password") if missing_fields: raise ValueError( f"Missing required session config fields: {', '.join(missing_fields)}. " f"Configure these in the Smithery connection settings." ) # Type assertions after validation (for type checker) # These are guaranteed to be str after the missing_fields check above assert nextcloud_url is not None assert username is not None assert app_password is not None # Validate URL format if not nextcloud_url.startswith(("http://", "https://")): raise ValueError( f"Invalid nextcloud_url: {nextcloud_url}. " f"Must start with http:// or https://" ) logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}") # Create client with session credentials using BasicAuth return NextcloudClient( base_url=nextcloud_url, username=username, auth=BasicAuth(username, app_password), ) def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient: """ Create NextcloudClient from BasicAuth credentials in request headers. For multi-user BasicAuth pass-through mode, this function extracts username/password from the Authorization: Basic header (stored by BasicAuthMiddleware) and creates a client that passes these credentials through to Nextcloud APIs. The credentials are NOT stored persistently - they exist only for the duration of this request (stateless). Args: ctx: MCP request context with basic_auth in request state Returns: NextcloudClient configured with BasicAuth credentials Raises: ValueError: If BasicAuth credentials not found in request or if NEXTCLOUD_HOST is not configured """ settings = get_settings() # Validate that NEXTCLOUD_HOST is configured if not settings.nextcloud_host: raise ValueError( "NEXTCLOUD_HOST environment variable must be set for multi-user BasicAuth mode" ) # Extract BasicAuth credentials from request state (set by BasicAuthMiddleware) # Access scope through the request object scope = getattr(ctx.request_context.request, "scope", None) if scope is None: raise ValueError("Request scope not available in context") request_state = scope.get("state", {}) basic_auth = request_state.get("basic_auth") if not basic_auth: raise ValueError( "BasicAuth credentials not found in request. " "Ensure Authorization: Basic header is provided with valid credentials." ) username = basic_auth.get("username") password = basic_auth.get("password") if not username or not password: raise ValueError("Invalid BasicAuth credentials - missing username or password") logger.debug( f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}" ) # Create client that passes BasicAuth credentials through to Nextcloud # settings.nextcloud_host is guaranteed to be str after the check above return NextcloudClient( base_url=settings.nextcloud_host, username=username, auth=BasicAuth(username, password), )