"""MCP elicitation helpers for Login Flow v2. Provides a unified way to present login URLs to users, using MCP elicitation when the client supports it, or falling back to returning the URL in a message. """ import logging from typing import Any from mcp.server.fastmcp import Context from pydantic import BaseModel, Field from nextcloud_mcp_server.config import get_settings logger = logging.getLogger(__name__) # Path of the Astrolabe Nextcloud app's settings UI. The full URL is # reconstructed at elicitation time from settings.nextcloud_public_issuer_url # / settings.nextcloud_host so the user gets a browser-reachable link without # needing a separate config knob. If the Astrolabe app is not installed this # path will 404, and the user falls back to the nc_auth_provision_access tool # path mentioned in the same message. ASTROLABE_SETTINGS_PATH = "/index.php/apps/astrolabe/settings" class LoginFlowConfirmation(BaseModel): """Schema for Login Flow v2 confirmation elicitation.""" acknowledged: bool = Field( default=False, description="Check this box after completing login at the provided URL", ) class ProvisioningRequiredConfirmation(BaseModel): """Schema for the 'app password not provisioned' elicitation.""" acknowledged: bool = Field( default=False, description="Check this box after enabling Nextcloud access", ) def _astrolabe_settings_url() -> str | None: """Construct the Astrolabe settings page URL from settings. Prefers ``nextcloud_public_issuer_url`` (the browser-reachable public URL) over ``nextcloud_host`` (which may be an internal hostname in Docker deployments). Returns None if neither is set (or set to the empty string), or if the configured base URL is missing an http:// or https:// scheme — in the latter case the caller renders the tool-only fallback message instead of a broken link. """ settings = get_settings() base = ( settings.nextcloud_public_issuer_url or settings.nextcloud_host or "" ).strip() if not base: return None if not base.startswith(("http://", "https://")): # Bare hostname (e.g. "internal:8080") would silently produce a # non-clickable URL. Surface the misconfiguration instead. logger.warning( "Cannot build Astrolabe settings URL: configured Nextcloud base URL " "%r is missing an http:// or https:// scheme. Falling back to the " "tool-only provisioning message.", base, ) return None return f"{base.rstrip('/')}{ASTROLABE_SETTINGS_PATH}" async def _run_elicit( ctx: Context, message: str, schema: type[BaseModel], *, log_label: str, ) -> tuple[str, Any]: """Shared elicit-or-fallback flow used by all elicitation prompts. Returns ``(outcome, result)`` where ``outcome`` is one of ``"accepted"`` / ``"declined"`` / ``"cancelled"`` / ``"message_only"``. ``result`` is the underlying ``ctx.elicit()`` return value when the elicitation actually ran (any of the first three outcomes), else None. Callers needing post-accept inspection (e.g. the data-acknowledged warning in :func:`present_login_url`) read it from ``result``. """ if not hasattr(ctx, "elicit"): logger.debug( "Elicitation not available on context — message_only fallback (%s)", log_label, ) return "message_only", None try: result = await ctx.elicit(message=message, schema=schema) except NotImplementedError: logger.debug( "Elicitation not supported by client — message_only fallback (%s)", log_label, ) return "message_only", None except Exception as e: logger.warning( "Elicitation failed unexpectedly for %s (%s: %s), " "falling back to message_only", log_label, type(e).__name__, e, ) return "message_only", None if result.action == "accept": logger.info("User acknowledged %s", log_label) return "accepted", result if result.action == "decline": logger.info("User declined %s", log_label) return "declined", result logger.info("User cancelled %s", log_label) return "cancelled", result async def present_login_url( ctx: Context, login_url: str, message: str | None = None, ) -> str: """Present a login URL to the user via MCP elicitation or message. Tries MCP elicitation first (ctx.elicit) for interactive clients. Falls back to returning the URL as a plain message. Args: ctx: MCP context login_url: URL the user should open in their browser message: Optional custom message (defaults to standard Login Flow prompt) Returns: "accepted" if user acknowledged via elicitation, "declined" if user declined, "message_only" if elicitation not supported (URL returned in message) """ if message is None: message = ( f"Please log in to Nextcloud to grant access:\n\n" f"{login_url}\n\n" f"Open this URL in your browser, log in, and grant the requested permissions. " f"Then check the box below and click OK." ) outcome, result = await _run_elicit( ctx, message, LoginFlowConfirmation, log_label="login flow completion", ) if ( outcome == "accepted" and result is not None and hasattr(result, "data") and not result.data.acknowledged ): # User clicked OK without ticking the box — login completion is still # verified via the LFv2 poller, so we proceed but flag it. logger.warning( "User accepted login flow without checking the acknowledged box — " "login completion will be verified via polling" ) return outcome async def present_provisioning_required(ctx: Context) -> str: """Elicit a provisioning prompt when a tool is called without an app password. Used by the ``@require_scopes`` decorator (Login Flow v2 path) to give the user a clickable Astrolabe settings URL — or a fallback instruction to call the ``nc_auth_provision_access`` MCP tool — instead of just raising a plain ``ProvisioningRequiredError`` text message that an LLM has to translate. The Astrolabe settings URL is reconstructed from ``settings.nextcloud_public_issuer_url`` / ``settings.nextcloud_host``; if Astrolabe is not installed the link 404s and the user falls back to the tool path suggested in the same message. Returns: Same string contract as :func:`present_login_url`: ``"accepted"`` / ``"declined"`` / ``"cancelled"`` / ``"message_only"``. """ settings_url = _astrolabe_settings_url() if settings_url: message = ( "Nextcloud access is not yet provisioned for this user.\n\n" f"Open this URL to enable it via the Astrolabe app:\n\n{settings_url}\n\n" "If the Astrolabe app is not installed, ask your MCP client to call " "the `nc_auth_provision_access` tool instead — it will return a " "Login Flow v2 URL you can open in your browser.\n\n" "Then check the box below and retry the original request." ) else: message = ( "Nextcloud access is not yet provisioned for this user.\n\n" "Ask your MCP client to call the `nc_auth_provision_access` tool — " "it will return a Login Flow v2 URL you can open in your browser to " "grant access.\n\n" "Then check the box below and retry the original request." ) outcome, _ = await _run_elicit( ctx, message, ProvisioningRequiredConfirmation, log_label="provisioning-required prompt", ) return outcome