Three review items from the third-round review on PR #757:
- scope_authorization: split the combined logger.warning(error_msg) in
the require_scopes decorator's missing-app-password branch into two
lazy %-style logger calls (one per branch), keeping the f-string
error_msg for the exception only. The else branch also logs the
elicit_result for diagnostics. Bypassing lazy %-interpolation in
security-sensitive code formatted the message regardless of log level
and matched the repo-wide lazy-logging preference; the new code now
conforms.
- config + browser_oauth_routes: wire COOKIE_SECURE through Settings
(cookie_secure: bool | None = None) so _should_use_secure_cookies()
reads it via get_settings() rather than os.getenv. Completes the
consolidation pass that touched this file in commit 7464340 and
removes the last raw os.getenv from browser_oauth_routes.py
(import os dropped). Dynaconf auto-coerces "true"/"false" → bool;
"1"/"0" arrive as int and are normalised by an explicit bool() at
the consumer.
- elicitation: clarify the _astrolabe_settings_url docstring to call
out that the empty-string case is also a None-return path (matches
the existing `if not base:` guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.8 KiB
Python
221 lines
7.8 KiB
Python
"""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
|