refactor(config): consolidate NEXTCLOUD_PUBLIC_ISSUER_URL through Settings

Lift NEXTCLOUD_PUBLIC_ISSUER_URL out of raw os.getenv reads into
Settings.nextcloud_public_issuer_url across all 8 production call sites
(app.py x2, oauth_routes.py x2, browser_oauth_routes.py,
provision_routes.py, userinfo_routes.py, elicitation.py). cli.py
remains the env-write source so the existing config-by-flag pipeline
still works.

Also addresses remaining PR #757 review nits:
- elicitation.py: align URL-present/absent wording on "open in your
  browser" so users don't try clicking in the terminal
- test_scope_authorization_stored.py: lock in the deliberately-shared
  fall-through branch with explicit declined/cancelled decorator tests
- test_elicitation.py: switch from monkeypatch.setenv to
  patch(get_settings) since Settings is now the canonical surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 16:16:19 +02:00
co-authored by Claude Opus 4.7
parent 822a8fe2ed
commit f3256e515e
9 changed files with 174 additions and 61 deletions
@@ -22,6 +22,7 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_get_userinfo_endpoint,
_query_idp_userinfo,
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
@@ -167,7 +168,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
scopes += " offline_access"
# Replace internal Docker hostname with public URL
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
+17 -14
View File
@@ -5,19 +5,20 @@ when the client supports it, or falling back to returning the URL in a message.
"""
import logging
import os
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 NEXTCLOUD_PUBLIC_ISSUER_URL /
# 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.
# 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"
@@ -40,14 +41,15 @@ class ProvisioningRequiredConfirmation(BaseModel):
def _astrolabe_settings_url() -> str | None:
"""Construct the Astrolabe settings page URL from environment.
"""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
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.
"""
settings = get_settings()
base = (
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or os.getenv("NEXTCLOUD_HOST") or ""
settings.nextcloud_public_issuer_url or settings.nextcloud_host or ""
).strip()
if not base:
return None
@@ -132,9 +134,10 @@ async def present_provisioning_required(ctx: Context) -> str:
has to translate.
The Astrolabe settings URL is reconstructed from
``NEXTCLOUD_PUBLIC_ISSUER_URL`` / ``NEXTCLOUD_HOST``; if Astrolabe is not
installed the link 404s and the user falls back to the tool path
suggested in the same message.
``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`:
@@ -148,7 +151,7 @@ async def present_provisioning_required(ctx: Context) -> str:
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 directly.\n\n"
"Login Flow v2 URL you can open in your browser.\n\n"
"Then check the box below and retry the original request."
)
else:
+2 -2
View File
@@ -360,7 +360,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -507,7 +507,7 @@ async def oauth_authorize_nextcloud(
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -15,7 +15,6 @@ Flow:
import html
import logging
import os
import secrets
import time
from urllib.parse import urlparse
@@ -251,7 +250,7 @@ async def provision_page(
# LoginFlowV2Client) while login_url is rewritten to the public issuer
# URL here because the browser needs a publicly-reachable address.
login_url = init_response.login_url
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "")
public_issuer = settings.nextcloud_public_issuer_url or ""
if public_issuer and nextcloud_host:
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
+1 -1
View File
@@ -474,7 +474,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
# otherwise fall back to NEXTCLOUD_HOST from settings
settings = get_settings()
nextcloud_host_for_links = (
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
settings.nextcloud_public_issuer_url or settings.nextcloud_host
)
# Build host info HTML (BasicAuth only)