fix(config): derive mode flags in Settings.__post_init__; address review round 2

The integration jobs for `mcp-multi-user-basic` and `mcp-login-flow`
were failing with HTTP 500s. Root cause: `get_settings()` builds a
fresh Settings on every call (not cached). Commits 3 and 4 set the
derived `enable_login_flow` / `enable_multi_user_basic_auth` flags as
a side effect of `detect_auth_mode`. detect_auth_mode runs once at
startup, against the Settings instance owned by `validate_configuration`.
Every per-request call site that does `settings = get_settings()` got
a fresh Settings with both flags at their default `False` (since the
env-var aliases were dropped), causing the multi-user dispatcher in
`context.py` to take the wrong branch and crash.

Fix: move the derivation into `Settings.__post_init__`. Every Settings
instance now carries correct flags from the moment it's constructed —
no caching needed, no mutation-after-construction race. detect_auth_mode
becomes a pure reader of the already-derived state.

The legacy env-var deprecation check moves with it. It also picks up
the reviewer's truthy-string fix: previously `os.getenv(legacy)` fired
for the literal string "false" (a non-empty Python string is truthy),
which would have errored on any user with a leftover
`ENABLE_LOGIN_FLOW=false` in their `.env`. The check now only fires
when the value lowercases to one of {"1", "true", "yes", "on"}.

- nextcloud_mcp_server/config.py: extend Settings.__post_init__ with
  the legacy-deprecation block and the derived-flag derivation
  (resolve mode from deployment_mode + username/password, set flags).
- nextcloud_mcp_server/config_validators.py: drop the
  `_sync_derived_flags` helper (superseded by __post_init__). Drop the
  legacy-env-var deprecation block (moved). `detect_auth_mode` is now
  pure — no mutation. Drop the now-unused `import os`.
- tests/unit/test_config_validators.py: legacy-env-var tests now
  expect `ValueError` at `Settings(...)` construction (via `get_settings()`),
  not at `detect_auth_mode` call. Added two new tests:
    * `test_legacy_env_var_check_ignores_falsy_strings` — pins the
      truthy-string fix (reviewer round 2 finding).
    * `test_derived_flags_stable_across_get_settings_calls` — regression
      test pinning the integration-test fix (two consecutive
      `get_settings()` calls return Settings instances with the same
      derived flags).
  Also reworked `test_login_flow_mode_auto_derives_enable_login_flow_flag`
  to assert at-construction derivation (not the old mutation pattern).
- docs/configuration-migration-v2.md: dropped the duplicate
  `MCP_DEPLOYMENT_MODE=multi_user_basic` line (review round 2 nit — a
  sed artifact from commit 4).
- docs/ADR-021-configuration-consolidation.md: sed-replaced the in-body
  `MCP_DEPLOYMENT_MODE=oauth_single_audience` examples with `login_flow`
  (review round 2 nit — only the status header was updated in commit 4).
- tests/conftest.py: docstring comment for the multi-user-basic fixture
  switched from `ENABLE_MULTI_USER_BASIC_AUTH=true` to
  `MCP_DEPLOYMENT_MODE=multi_user_basic` (review round 2 nit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-12 20:44:17 +02:00
co-authored by Claude Opus 4.7
parent 282c245da1
commit 6e7c821761
6 changed files with 137 additions and 68 deletions
+5 -5
View File
@@ -86,7 +86,7 @@ Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
```bash
# Optional: Explicitly declare deployment mode
MCP_DEPLOYMENT_MODE=oauth_single_audience
MCP_DEPLOYMENT_MODE=login_flow
# Valid values: single_user_basic, multi_user_basic,
# oauth_single_audience, oauth_token_exchange
@@ -114,7 +114,7 @@ TOKEN_STORAGE_DB=/path/to/tokens.db
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
MCP_DEPLOYMENT_MODE=login_flow # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
@@ -271,7 +271,7 @@ def enable_semantic_search(self) -> bool:
- `ENABLE_SEMANTIC_SEARCH=false``enable_background_operations=false` (unless explicitly set)
**Mode selection tests**:
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
- `MCP_DEPLOYMENT_MODE=login_flow` → mode correctly detected
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
@@ -363,7 +363,7 @@ QDRANT_URL=http://qdrant:6333
**After** (simplified):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
MCP_DEPLOYMENT_MODE=login_flow # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
@@ -384,7 +384,7 @@ TOKEN_STORAGE_DB=/path/to/tokens.db
**After** (optional migration):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
MCP_DEPLOYMENT_MODE=login_flow
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
-3
View File
@@ -207,9 +207,6 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=secret
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=multi_user_basic
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=multi_user_basic
# One variable handles both!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
+43
View File
@@ -607,6 +607,49 @@ class Settings:
f"Smaller chunks may lose context. Consider using at least 1024 characters."
)
# --- ADR-022 follow-up: deployment mode is the single source of truth ---
# The ENABLE_MULTI_USER_BASIC_AUTH and ENABLE_LOGIN_FLOW env vars were
# removed in favour of MCP_DEPLOYMENT_MODE. We do TWO things here:
#
# 1. Loud-fail if a user still has either legacy env var set to a
# truthy value (silent removal would have flipped them into the
# wrong runtime mode). Only fires for truthy strings, so an
# explicit `ENABLE_LOGIN_FLOW=false` in a leftover .env passes
# through harmlessly.
# 2. Derive `enable_login_flow` and `enable_multi_user_basic_auth`
# from the resolved deployment mode here, in __post_init__, so
# every Settings instance carries correct flags. (`get_settings()`
# builds a fresh Settings on each call — without this, the
# mutation that used to live in detect_auth_mode would only stick
# on the startup Settings instance, leaving per-request handlers
# with default False values.)
_truthy = {"1", "true", "yes", "on"}
for _legacy, _replacement in (
("ENABLE_MULTI_USER_BASIC_AUTH", "multi_user_basic"),
("ENABLE_LOGIN_FLOW", "login_flow"),
):
if os.environ.get(_legacy, "").strip().lower() in _truthy:
raise ValueError(
f"{_legacy} is no longer read from the environment. "
f"Set MCP_DEPLOYMENT_MODE={_replacement} instead "
"(ADR-022). The deployment mode is the single source "
"of truth for selecting an auth flow."
)
resolved_mode = (self.deployment_mode or "").strip().lower()
if not resolved_mode:
if self.nextcloud_username and self.nextcloud_password:
resolved_mode = "single_user_basic"
else:
# Default multi-user mode is Login Flow v2 (browser-based
# app-password acquisition); the un-augmented OAuth bearer
# pass-through it replaced needed unmerged Nextcloud
# user_oidc patches and is no longer supported.
resolved_mode = "login_flow"
self.enable_multi_user_basic_auth = resolved_mode == "multi_user_basic"
self.enable_login_flow = resolved_mode == "login_flow"
def get_embedding_model_name(self) -> str:
"""
Get the active embedding model name based on provider priority.
+14 -45
View File
@@ -9,7 +9,6 @@ See ADR-020 for detailed architecture and deployment mode documentation.
"""
import logging
import os
from dataclasses import dataclass
from enum import Enum
@@ -163,11 +162,16 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Mode detection priority (ADR-021, updated for ADR-022):
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
1. Multi-user BasicAuth
2. Single-user BasicAuth
0. Explicit MCP_DEPLOYMENT_MODE (if set) NEW in ADR-021
1. Multi-user BasicAuth (only via explicit mode after ADR-022 follow-up)
2. Single-user BasicAuth (auto-detected from credentials)
3. Login Flow v2 (default — was OAuth single-audience pre-ADR-022)
Pure function — the legacy-env-var deprecation and the derivation of
`enable_login_flow` / `enable_multi_user_basic_auth` now happen in
`Settings.__post_init__` so every Settings instance carries correct
flags regardless of how it was constructed.
Args:
settings: Application settings
@@ -175,31 +179,15 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
Detected AuthMode
Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
ValueError: If explicit deployment_mode is unrecognised.
"""
logger = logging.getLogger(__name__)
# ADR-022 follow-up: fail loudly if a caller is still relying on the
# removed env-var aliases. Bypass dynaconf and read os.environ directly
# so the check survives even though the aliases are gone.
for legacy, replacement in (
("ENABLE_MULTI_USER_BASIC_AUTH", "multi_user_basic"),
("ENABLE_LOGIN_FLOW", "login_flow"),
):
if os.getenv(legacy):
raise ValueError(
f"{legacy} is no longer read from the environment. "
f"Set MCP_DEPLOYMENT_MODE={replacement} instead "
"(ADR-022). The deployment mode is the single source of "
"truth for selecting an auth flow."
)
# ADR-021: Check for explicit deployment mode first
# ADR-021: explicit deployment mode wins
if settings.deployment_mode:
mode_str = settings.deployment_mode.lower().strip()
# Map string to AuthMode enum
mode_map = {
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
@@ -215,41 +203,22 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
explicit_mode = mode_map[mode_str]
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
_sync_derived_flags(settings, explicit_mode)
return explicit_mode
# Auto-detection (no explicit deployment_mode).
# MULTI_USER_BASIC is no longer auto-detectable — the ENABLE_MULTI_USER_BASIC_AUTH
# env-var alias was dropped in the ADR-022 follow-up, so the only way to
# opt into that mode is `MCP_DEPLOYMENT_MODE=multi_user_basic` (handled
# above). The legacy env var fails loudly at the top of this function.
# env-var alias was dropped in the ADR-022 follow-up, so the only way
# to opt into that mode is `MCP_DEPLOYMENT_MODE=multi_user_basic`
# (handled above). The legacy env var fails loudly in
# `Settings.__post_init__`.
# Check for single-user BasicAuth (explicit credentials)
if settings.nextcloud_username and settings.nextcloud_password:
_sync_derived_flags(settings, AuthMode.SINGLE_USER_BASIC)
return AuthMode.SINGLE_USER_BASIC
# Default: Login Flow v2 multi-user mode (browser-based app-password flow).
# The un-augmented OAuth bearer pass-through it replaced required unmerged
# Nextcloud user_oidc patches (see ADR-022).
_sync_derived_flags(settings, AuthMode.LOGIN_FLOW)
return AuthMode.LOGIN_FLOW
def _sync_derived_flags(settings: Settings, mode: AuthMode) -> None:
"""Derive internal feature flags from the resolved deployment mode.
Some runtime call sites (app.py, context.py, auth/scope_authorization.py)
still read individual boolean flags rather than passing the mode around.
Keep those flags in sync with the mode here so the mode is the single
source of truth and users don't have to set redundant env vars. The
ENABLE_LOGIN_FLOW and ENABLE_MULTI_USER_BASIC_AUTH env-var aliases were
removed in the ADR-022 follow-up (PR #787).
"""
settings.enable_login_flow = mode == AuthMode.LOGIN_FLOW
settings.enable_multi_user_basic_auth = mode == AuthMode.MULTI_USER_BASIC
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
+1 -1
View File
@@ -265,7 +265,7 @@ async def nc_mcp_basic_auth_client(
) -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session with BasicAuth credentials.
Connects to the multi-user BasicAuth MCP server on port 8003 with ENABLE_MULTI_USER_BASIC_AUTH=true.
Connects to the multi-user BasicAuth MCP server on port 8003 with MCP_DEPLOYMENT_MODE=multi_user_basic.
Uses BasicAuth credentials for multi-user pass-through mode (ADR-020).
Credentials are passed in Authorization header and forwarded to Nextcloud APIs.
+74 -14
View File
@@ -370,27 +370,28 @@ class TestLoginFlowValidation:
assert any("token_encryption_key" in err.lower() for err in errors)
def test_login_flow_mode_auto_derives_enable_login_flow_flag(self):
"""ADR-022 follow-up: setting MCP_DEPLOYMENT_MODE=login_flow auto-derives the flag.
"""ADR-022 follow-up: deployment_mode=login_flow auto-derives the flag.
Users no longer need to set ENABLE_LOGIN_FLOW=true (env var was removed);
detect_auth_mode populates settings.enable_login_flow from the resolved mode.
Users no longer need to set ENABLE_LOGIN_FLOW=true (env var was
removed); Settings.__post_init__ populates settings.enable_login_flow
from the resolved mode at construction time, so every Settings
instance carries correct flags regardless of how it was built.
"""
# Default-fallback case: no auth env vars → LOGIN_FLOW.
settings = Settings(nextcloud_host="http://localhost")
assert settings.enable_login_flow is False # default before detection
mode = detect_auth_mode(settings)
assert mode == AuthMode.LOGIN_FLOW
assert settings.enable_login_flow is True
assert settings.enable_multi_user_basic_auth is False
assert detect_auth_mode(settings) == AuthMode.LOGIN_FLOW
# Non-LOGIN_FLOW mode should leave the flag False.
# Single-user BasicAuth (credentials set) → neither derived flag.
basic_settings = Settings(
nextcloud_host="http://localhost",
nextcloud_username="alice",
nextcloud_password="hunter2",
)
basic_mode = detect_auth_mode(basic_settings)
assert basic_mode == AuthMode.SINGLE_USER_BASIC
assert basic_settings.enable_login_flow is False
assert basic_settings.enable_multi_user_basic_auth is False
assert detect_auth_mode(basic_settings) == AuthMode.SINGLE_USER_BASIC
def test_vector_sync_auto_enables_background_ops_in_login_flow_mode(self):
"""Test vector sync automatically enables background operations in Login Flow v2 mode (ADR-021)."""
@@ -850,6 +851,8 @@ class TestExplicitModeSelection:
The env-var alias was removed; users must migrate to
`MCP_DEPLOYMENT_MODE=multi_user_basic`. Silent removal would have
switched users to LOGIN_FLOW (the default) — wrong runtime mode.
The check lives in Settings.__post_init__ so it fires at config
load time, not only when detect_auth_mode is reached.
"""
with patch.dict(
os.environ,
@@ -862,10 +865,9 @@ class TestExplicitModeSelection:
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
with pytest.raises(ValueError) as exc:
detect_auth_mode(settings)
get_settings()
assert "ENABLE_MULTI_USER_BASIC_AUTH" in str(exc.value)
assert "multi_user_basic" in str(exc.value)
@@ -874,7 +876,7 @@ class TestExplicitModeSelection:
"""ADR-022 follow-up: ENABLE_LOGIN_FLOW=true must fail loudly.
Mirrors the ENABLE_MULTI_USER_BASIC_AUTH check — both legacy aliases
now error with a one-line migration message.
now error with a one-line migration message at Settings construction.
"""
with patch.dict(
os.environ,
@@ -887,10 +889,68 @@ class TestExplicitModeSelection:
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
with pytest.raises(ValueError) as exc:
detect_auth_mode(settings)
get_settings()
assert "ENABLE_LOGIN_FLOW" in str(exc.value)
assert "login_flow" in str(exc.value)
def test_legacy_env_var_check_ignores_falsy_strings(self):
"""ADR-022 follow-up: a leftover ENABLE_LOGIN_FLOW=false must NOT error.
Reviewer round 2 found that the legacy check used `os.getenv(legacy)`
which is truthy for the literal string 'false'. A user who had
explicitly set the flag to false (meaning 'I don't want this') would
get a startup ValueError after upgrading, which is wrong. The fix
only fires for explicitly-truthy values.
"""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"ENABLE_LOGIN_FLOW": "false",
"ENABLE_MULTI_USER_BASIC_AUTH": "0",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings() # Must not raise.
# Falls through to LOGIN_FLOW (the auto-detect default) since
# no credentials are set.
assert detect_auth_mode(settings) == AuthMode.LOGIN_FLOW
def test_derived_flags_stable_across_get_settings_calls(self):
"""Regression: derived flags must persist across get_settings() calls.
`get_settings()` builds a fresh Settings on each invocation. Earlier
commits set the derived flags as a side effect of detect_auth_mode,
which meant the *next* get_settings() call started with default
False — breaking per-request handlers in the integration tests
for `mcp-multi-user-basic` and `mcp-login-flow`. The fix moves the
derivation into Settings.__post_init__ so every instance carries
correct flags.
"""
with patch.dict(
os.environ,
{
"NEXTCLOUD_HOST": "http://localhost:8080",
"MCP_DEPLOYMENT_MODE": "multi_user_basic",
},
clear=True,
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
s1 = get_settings()
s2 = get_settings()
assert s1 is not s2 # fresh instance each call
assert s1.enable_multi_user_basic_auth is True
assert s2.enable_multi_user_basic_auth is True
assert s1.enable_login_flow is False
assert s2.enable_login_flow is False