diff --git a/.gitignore b/.gitignore index 702fe2f1..d64fb5d5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ __pycache__/ .env.local .env.*.local +# Dynaconf (ADR-024) +.secrets.toml +settings.local.toml + # Git worktrees/ diff --git a/.secrets.toml.example b/.secrets.toml.example new file mode 100644 index 00000000..2e31cfe9 --- /dev/null +++ b/.secrets.toml.example @@ -0,0 +1,13 @@ +# Secrets configuration for Nextcloud MCP Server. +# Copy this file to .secrets.toml and fill in your values. +# .secrets.toml is gitignored — never commit real secrets. +# +# See docs/ADR-024-dynaconf-configuration-management.md for details. + +[default] +token_encryption_key = "" +nextcloud_password = "" +nextcloud_oidc_client_secret = "" +qdrant_api_key = "" +openai_api_key = "" +custom_processor_api_key = "" diff --git a/docker-compose.yml b/docker-compose.yml index 82f4c1fb..e3ffdbaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,7 @@ services: - 127.0.0.1:9090:9090 volumes: - mcp-data:/app/data + - ./settings.toml:/app/settings.toml:ro environment: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_USERNAME=admin @@ -166,6 +167,7 @@ services: # NO admin credentials - credentials come from client Authorization header volumes: - multi-user-basic-data:/app/data + - ./settings.toml:/app/settings.toml:ro profiles: - multi-user-basic @@ -241,6 +243,7 @@ services: volumes: - keycloak-tokens:/app/data - keycloak-oauth-storage:/app/.oauth + - ./settings.toml:/app/settings.toml:ro profiles: - keycloak @@ -279,6 +282,7 @@ services: volumes: - login-flow-data:/app/data - login-flow-oauth-storage:/app/.oauth + - ./settings.toml:/app/settings.toml:ro profiles: - login-flow diff --git a/docs/ADR-025-dynaconf-configuration-management.md b/docs/ADR-025-dynaconf-configuration-management.md new file mode 100644 index 00000000..fd95e419 --- /dev/null +++ b/docs/ADR-025-dynaconf-configuration-management.md @@ -0,0 +1,556 @@ +# ADR-024: Dynaconf Configuration Management + +**Status:** Proposed +**Date:** 2026-04-04 +**Deciders:** Development Team +**Related:** ADR-020 (Deployment Modes), ADR-021 (Configuration Consolidation), ADR-022 (Login Flow v2) + +## Context + +The nextcloud-mcp-server configuration system has grown to ~80+ environment variables across five deployment modes. All configuration is loaded via manual `os.getenv()` calls in `config.py` (~60 calls in `get_settings()` alone) and `providers/registry.py`. This creates several problems: + +### Problems Identified + +1. **No file-based configuration option**: Every deployment requires setting environment variables. For complex deployments with 20+ variables (e.g., Keycloak + semantic search + observability), this is unwieldy and error-prone. There is no way to ship a "configuration profile" as a file. + +2. **Configuration sprawl across multiple locations**: Environment variables are read in at least three places: + - `config.py:get_settings()` — Main settings (~60 vars) + - `config.py:get_document_processor_config()` — Document processing (~20 vars) + - `providers/registry.py:ProviderRegistry.create_provider()` — Embedding providers (~15 vars) + +3. **No configuration file for local development**: Developers must either maintain a `.env` file and `export $(grep -v '^#' .env | xargs)`, or rely solely on docker-compose environment blocks. A structured settings file with defaults per deployment mode would simplify onboarding. + +4. **Manual type coercion is repetitive and error-prone**: The codebase is littered with patterns like: + ```python + os.getenv("SOME_BOOL", "false").lower() == "true" + int(os.getenv("SOME_INT", "300")) + float(os.getenv("SOME_FLOAT", "1.0")) + ``` + Each is a potential `ValueError` if a user provides a non-numeric string for an integer field. + +5. **No structured validation at load time**: While `config_validators.py` validates mode requirements after loading, there is no validation of individual field types, ranges, or mutual exclusivity at parse time. Invalid values (e.g., `METRICS_PORT=abc`) only fail when first used. + +6. **Secrets mixed with configuration**: `TOKEN_ENCRYPTION_KEY`, `NEXTCLOUD_PASSWORD`, `OPENAI_API_KEY`, and other secrets are treated identically to non-sensitive configuration, with no separation mechanism. + +### Current Configuration Surface + +| Category | Approx. Vars | Example | +|----------|-------------|---------| +| Core Nextcloud | 6 | `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_VERIFY_SSL` | +| OAuth/OIDC | 12 | `OIDC_DISCOVERY_URL`, `NEXTCLOUD_OIDC_CLIENT_ID`, `JWKS_URI` | +| Mode Selection | 4 | `MCP_DEPLOYMENT_MODE`, `ENABLE_LOGIN_FLOW`, `ENABLE_TOKEN_EXCHANGE` | +| Token Storage | 3 | `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB` | +| Semantic Search | 6 | `ENABLE_SEMANTIC_SEARCH`, `VECTOR_SYNC_SCAN_INTERVAL` | +| Qdrant | 4 | `QDRANT_URL`, `QDRANT_LOCATION`, `QDRANT_API_KEY` | +| Embedding Providers | 15 | `OLLAMA_BASE_URL`, `OPENAI_API_KEY`, `BEDROCK_*` | +| Document Processing | 18 | `ENABLE_UNSTRUCTURED`, `TESSERACT_CMD`, `PYMUPDF_*` | +| Observability | 10 | `OTEL_EXPORTER_OTLP_ENDPOINT`, `LOG_FORMAT`, `METRICS_PORT` | +| Webhooks/Internal | 4 | `WEBHOOK_INTERNAL_URL`, `NEXTCLOUD_MCP_SERVICE_NAME` | +| **Total** | **~82** | | + +## Decision + +Adopt [dynaconf](https://www.dynaconf.com/) as the configuration management layer, enabling TOML file-based configuration alongside existing environment variable support. + +### Why Dynaconf + +| Criterion | Dynaconf | Pydantic Settings | python-dotenv | +|-----------|----------|-------------------|---------------| +| File-based config (TOML/YAML) | Yes (native) | No native TOML sections/env switching | `.env` only | +| Environment sections/profiles | Yes (`[default]`, `[production]`) | No | No | +| Env var override (12-factor) | Yes (built-in, highest priority) | Yes | Yes | +| Type coercion | Automatic (TOML parser) | Via type hints | No | +| Validators | Declarative + conditional | Via Pydantic | No | +| Secrets file separation | Yes (`.secrets.toml`) | No built-in | Separate `.env` | +| Local overrides | Yes (`settings.local.toml` auto-loaded) | No | No | +| Zero-prefix env vars | Yes (`envvar_prefix=False`) | Custom | N/A | +| Dependency | Pure Python, well-maintained | Pydantic (already in project for models) | Minimal | + +### Architecture + +#### 1. Dynaconf Instance Configuration + +```python +# nextcloud_mcp_server/config.py +from pathlib import Path +from dynaconf import Dynaconf, Validator + +_config_root = Path(__file__).parent.parent + +settings = Dynaconf( + settings_files=["settings.toml", ".secrets.toml"], + root_path=str(_config_root), + environments=True, + env_switcher="MCP_DEPLOYMENT_MODE", + envvar_prefix=False, + load_dotenv=False, + ignore_unknown_envvars=True, + validators=[...], # See Section 4 +) +``` + +Key choices: +- **`envvar_prefix=False`**: Existing env vars (`NEXTCLOUD_HOST`, `ENABLE_SEMANTIC_SEARCH`, etc.) work without any prefix. No renaming required. +- **`env_switcher="MCP_DEPLOYMENT_MODE"`**: Reuses the existing ADR-021 variable. Setting `MCP_DEPLOYMENT_MODE=single_user_basic` loads the `[single_user_basic]` TOML section on top of `[default]`. Note: dynaconf's `environments` feature is designed for lifecycle environments (dev/staging/prod), but custom environment names are a supported pattern — see `tests_functional/legacy/simple_ini_example/` in the dynaconf repo for a precedent using `environments=["ansible", "puppet"]`. **Legacy risk:** dynaconf docs flag `environments=True` as a legacy feature; if a future dynaconf major release removes it, we would need to migrate to the per-file approach (`settings.single_user_basic.toml`, etc.). This risk is acceptable given the alternative requires managing 5+ separate TOML files. +- **`ignore_unknown_envvars=True`**: Only env vars matching keys defined in `settings.toml` or defaults are loaded. System env vars (`HOME`, `PATH`, `LANG`) are ignored. **Important:** This means every env var the application reads must have a corresponding entry in `settings.toml`. See the `ignore_unknown_envvars` risk note under Consequences for mitigation. +- **`root_path=str(_config_root)`**: Anchors settings file lookup to the package's parent directory. Uses `str()` because dynaconf expects a string path. **Note on pip-installed packages:** When installed into a venv, `__file__` resolves to `site-packages/nextcloud_mcp_server/config.py` and `parent.parent` points inside site-packages — `settings.toml` will not be found there. This is intentional: pip-installed deployments are expected to use env vars (the primary configuration mechanism) or mount `settings.toml` into a location specified via `SETTINGS_FILE_FOR_DYNACONF`. The file-based config is a convenience for development and container deployments, not a requirement. +- **`load_dotenv=False`**: We don't auto-load `.env` files to avoid surprising behavior. Shell-level `.env` loading (e.g., `export $(grep -v '^#' .env | xargs)` as documented in CLAUDE.md) continues to work — env vars loaded into the shell before the process starts are picked up by dynaconf via its standard env var reading. Users who want automatic dotenv can use `direnv`. + +#### 2. Settings File Structure + +**`settings.toml`** — Shipped with the project, checked into git: + +```toml +[default] +# === Nextcloud Connection === +# nextcloud_host — Required, set via env var or .secrets.toml. No default. +nextcloud_verify_ssl = true +nextcloud_ca_bundle = "@none" + +# === Deployment Mode === +# Auto-detected if not set. Valid: single_user_basic, multi_user_basic, +# oauth_single_audience, login_flow, keycloak +# mcp_deployment_mode = "" + +# === Authentication Toggles === +enable_multi_user_basic_auth = false +enable_login_flow = false +enable_token_exchange = false + +# === Token Storage === +token_storage_db = "/tmp/tokens.db" + +# === Semantic Search === +enable_semantic_search = false +vector_sync_scan_interval = 300 +vector_sync_processor_workers = 3 +vector_sync_queue_max_size = 10000 +vector_sync_user_poll_interval = 60 + +# === Qdrant === +qdrant_location = ":memory:" +qdrant_collection = "nextcloud_content" + +# === Embedding Providers === +ollama_embedding_model = "nomic-embed-text" +ollama_verify_ssl = true +openai_embedding_model = "text-embedding-3-small" +simple_embedding_dimension = 384 + +# === Provider: Ollama === +# ollama_base_url — Set via env var or .secrets.toml +ollama_generation_model = "@none" + +# === Provider: Bedrock === +aws_region = "@none" +bedrock_embedding_model = "@none" +bedrock_generation_model = "@none" +# aws_access_key_id — Set via env var or .secrets.toml +# aws_secret_access_key — Set via env var or .secrets.toml + +# === Provider: Anthropic === +# anthropic_api_key — Set via env var or .secrets.toml + +# === Document Chunking === +document_chunk_size = 2048 +document_chunk_overlap = 200 + +# === Document Processing === +enable_document_processing = false +document_processor = "unstructured" +enable_pymupdf = true +pymupdf_extract_images = true +enable_unstructured = false +unstructured_api_url = "http://unstructured:8000" +unstructured_timeout = 120 +unstructured_strategy = "auto" +unstructured_languages = "eng,deu" +enable_tesseract = false +tesseract_lang = "eng" +enable_custom_processor = false +custom_processor_name = "custom" +custom_processor_types = "application/pdf" +custom_processor_timeout = 60 + +# === Observability === +metrics_enabled = true +metrics_port = 9090 +otel_service_name = "nextcloud-mcp-server" +otel_traces_sampler = "always_on" +otel_traces_sampler_arg = 1.0 +otel_exporter_verify_ssl = false +log_format = "text" +log_level = "INFO" +log_include_trace_context = true + +# === Webhooks === +nextcloud_mcp_service_name = "mcp" +nextcloud_mcp_port = 8000 + +# ───────────────────────────────────────────── +# Deployment Mode Overrides +# ───────────────────────────────────────────── + +[single_user_basic] +# Credentials provided via env vars or .secrets.toml +# nextcloud_username = "" (in .secrets.toml) +# nextcloud_password = "" (in .secrets.toml) + +[multi_user_basic] +enable_multi_user_basic_auth = true +token_storage_db = "/app/data/tokens.db" + +[login_flow] +enable_login_flow = true +token_storage_db = "/app/data/tokens.db" + +[keycloak] +enable_token_exchange = true +token_storage_db = "/app/data/tokens.db" +token_exchange_cache_ttl = 300 + +[oauth_single_audience] +token_storage_db = "/app/data/tokens.db" +``` + +**`.secrets.toml.example`** — Template, checked into git (actual `.secrets.toml` is gitignored): + +```toml +[default] +# token_encryption_key = "" + +[single_user_basic] +# nextcloud_username = "" +# nextcloud_password = "" +# nextcloud_app_password = "" + +[keycloak] +# nextcloud_oidc_client_id = "" +# nextcloud_oidc_client_secret = "" +# token_encryption_key = "" + +[login_flow] +# token_encryption_key = "" + +# Provider API keys (any deployment mode) +# ollama_base_url = "" +# anthropic_api_key = "" +# openai_api_key = "" +# aws_access_key_id = "" +# aws_secret_access_key = "" +# qdrant_api_key = "" +``` + +**`settings.local.toml`** — Personal overrides, gitignored, auto-loaded by dynaconf: + +```toml +# Example developer overrides +[default] +log_level = "DEBUG" +ollama_base_url = "http://localhost:11434" +``` + +#### 3. Configuration Loading Priority + +Dynaconf merges configuration in this order (last wins): + +``` +1. settings.toml [default] section ← base defaults +2. settings.toml [] section ← mode-specific overrides +3. .secrets.toml [default] section ← base secrets +4. .secrets.toml [] section ← mode-specific secrets +5. settings.local.toml (all sections) ← developer overrides +6. Environment variables ← highest priority (12-factor) +``` + +This means: +- **File-based config is optional** — env vars alone still work (they override everything) +- **Mode-specific defaults reduce boilerplate** — `[login_flow]` sets `enable_login_flow=true` and `token_storage_db=/app/data/tokens.db` so deployers don't need to +- **Secrets are separated** — `.secrets.toml` holds `TOKEN_ENCRYPTION_KEY`, passwords, API keys +- **Local dev overrides don't pollute** — `settings.local.toml` is gitignored + +#### 4. Dynaconf Validators + +Replace repetitive `__post_init__` checks with declarative validators: + +```python +validators = [ + # Required unconditionally — needed in all deployment modes + Validator("NEXTCLOUD_HOST", must_exist=True), + + # Deployment mode validation — catch typos at startup + Validator("MCP_DEPLOYMENT_MODE", is_in=[ + "single_user_basic", "multi_user_basic", "login_flow", + "keycloak", "oauth_single_audience", + ], when=Validator("MCP_DEPLOYMENT_MODE", must_exist=True)), + + # Type and range validation + Validator("METRICS_PORT", gte=1, lte=65535), + Validator("VECTOR_SYNC_SCAN_INTERVAL", gte=1), + Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1), + Validator("DOCUMENT_CHUNK_SIZE", gte=128), + Validator("DOCUMENT_CHUNK_OVERLAP", gte=0), + + # OTEL_TRACES_SAMPLER_ARG only validated for ratio-based samplers + Validator( + "OTEL_TRACES_SAMPLER_ARG", gte=0.0, lte=1.0, + when=Validator("OTEL_TRACES_SAMPLER", condition=lambda v: "ratio" in str(v)), + ), + + # Enum validation + Validator("LOG_FORMAT", is_in=["text", "json"]), + Validator("LOG_LEVEL", is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + Validator("OTEL_TRACES_SAMPLER", is_in=["always_on", "always_off", "parentbased_always_on", "parentbased_always_off", "traceidratio", "parentbased_traceidratio"]), + + # Mutual exclusivity: QDRANT_URL and non-default QDRANT_LOCATION cannot both be set. + # QDRANT_LOCATION defaults to ":memory:", so check for non-default values. + # Note: Validator does not support `ne=` as a constructor kwarg — use `condition=` instead. + Validator("QDRANT_URL", must_exist=False, when=Validator("QDRANT_LOCATION", condition=lambda v: v != ":memory:")), +] +``` + +#### 5. Backward Compatibility: Deprecation Handling + +Deprecated env var names (`VECTOR_SYNC_ENABLED`, `ENABLE_OFFLINE_ACCESS`) are mapped to their current equivalents. In **Phases 1-3**, this logic lives in the `get_settings()` adapter function, which already performs this mapping today via `_get_semantic_search_enabled()` and `_get_background_operations_enabled()`. Dynaconf simply replaces the `os.getenv()` calls that feed these helpers. + +In **Phase 4 (optional, future)**, these could be migrated to dynaconf `post_hooks` — constructor callbacks that run after all sources are loaded. Each hook receives a clone of the settings and returns a dict of values to merge: + +```python +# Phase 4 target (not implemented in Phases 1-3) +def handle_deprecations(settings): + """Map deprecated variable names to current names (ADR-021 compatibility).""" + overrides = {} + if settings.exists("VECTOR_SYNC_ENABLED") and not settings.exists("ENABLE_SEMANTIC_SEARCH"): + overrides["ENABLE_SEMANTIC_SEARCH"] = settings.VECTOR_SYNC_ENABLED + logger.warning("VECTOR_SYNC_ENABLED is deprecated. Use ENABLE_SEMANTIC_SEARCH instead.") + if settings.exists("ENABLE_OFFLINE_ACCESS") and not settings.exists("ENABLE_BACKGROUND_OPERATIONS"): + overrides["ENABLE_BACKGROUND_OPERATIONS"] = settings.ENABLE_OFFLINE_ACCESS + logger.warning("ENABLE_OFFLINE_ACCESS is deprecated. Use ENABLE_BACKGROUND_OPERATIONS instead.") + return overrides if overrides else None + +# Usage: Dynaconf(post_hooks=[handle_deprecations, resolve_dependencies], ...) +``` + +**Note on `post_hooks` API:** This is a supported `Dynaconf()` constructor parameter (defined on `DynaconfConfig`, with `post_hooks` declared in `base.py` since dynaconf 3.2.x). It is verified to work in dynaconf's functional test suite (`tests_functional/legacy/ignore_unknown_envvars/app.py`). However, since the existing Python helpers already implement this logic correctly, migrating to `post_hooks` is deferred to Phase 4 to avoid changing execution context and ordering relative to `config_validators.py`. + +#### 6. Smart Dependency Resolution + +The auto-enablement of `ENABLE_BACKGROUND_OPERATIONS` when semantic search is active in multi-user modes (existing behavior from ADR-021) is preserved. In **Phases 1-3**, the existing `_is_multi_user_mode()` and `_get_background_operations_enabled()` helpers continue to work, reading from dynaconf instead of `os.getenv()`. + +In **Phase 4**, this could migrate to a post-hook: + +```python +# Phase 4 target (not implemented in Phases 1-3) +def resolve_dependencies(settings): + """Auto-enable background operations for semantic search in multi-user modes.""" + is_multi_user = ( + settings.get("ENABLE_MULTI_USER_BASIC_AUTH", False) + or settings.get("ENABLE_TOKEN_EXCHANGE", False) + or (not settings.get("NEXTCLOUD_USERNAME") and not settings.get("NEXTCLOUD_PASSWORD")) + ) + if settings.get("ENABLE_SEMANTIC_SEARCH", False) and is_multi_user: + if not settings.get("ENABLE_BACKGROUND_OPERATIONS", False): + logger.info("Auto-enabled background operations for semantic search in multi-user mode.") + return {"ENABLE_BACKGROUND_OPERATIONS": True} + return None +``` + +#### 7. Adapter Layer (Migration Bridge) + +During migration, `get_settings()` continues to return the `Settings` dataclass, populated from dynaconf: + +```python +from dynaconf import Dynaconf + +_dynaconf = Dynaconf(...) # As configured above + +def get_settings() -> Settings: + """Get application settings — backed by dynaconf.""" + return Settings( + deployment_mode=_dynaconf.get("MCP_DEPLOYMENT_MODE"), + nextcloud_host=_dynaconf.get("NEXTCLOUD_HOST"), + nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"), + enable_token_exchange=_dynaconf.get("ENABLE_TOKEN_EXCHANGE", False), + # ... all fields populated from _dynaconf.get() instead of os.getenv() + ) +``` + +This is a zero-risk change: every consumer of `get_settings()` sees the same `Settings` type. **Every field on the `Settings` dataclass must have a corresponding `_dynaconf.get()` call** — omitting a field (e.g., `enable_token_exchange`) would silently regress functionality. The implementation should use a `_field_map` dict to make this exhaustive mapping auditable. The dataclass can be removed in a later phase once all consumers migrate to `_dynaconf` directly. + +#### 8. Mode Detection Preserved + +`config_validators.py` is unchanged in this phase. `detect_auth_mode()` and `validate_configuration()` continue to operate on the `Settings` dataclass. The business logic for mode detection, conditional requirements, and forbidden variables is too complex for declarative validators and benefits from remaining as explicit Python code. + +#### 9. Document Processor Config Integration + +`get_document_processor_config()` currently reads ~20 env vars independently. It will be migrated to read from the same dynaconf instance, with document processor settings nested under the `[default]` section alongside all other settings. + +#### 10. Provider Registry (Phase 6) + +`providers/registry.py:ProviderRegistry.create_provider()` reads ~15 env vars directly via `os.getenv()`. **Until Phase 6**, these calls remain unchanged — they are not broken by Phases 1-3 because `ignore_unknown_envvars` only affects dynaconf's own env var loading, not direct `os.getenv()` calls in other modules. However, all provider env vars must still be declared in `settings.toml` (see Section 2) so that dynaconf-based code can access them. In Phase 6, `ProviderRegistry` will be updated to accept a settings object or read from the dynaconf instance, consolidating all configuration into a single source. + +#### 11. Test Isolation + +Tests must not be affected by `settings.toml` or `.secrets.toml` being present in the repository. Dynaconf provides several test isolation patterns — we recommend the **fixture factory** approach as the primary strategy: + +**Primary: Fresh instance per test (best isolation)** + +```python +# conftest.py +import pytest +from dynaconf import Dynaconf + +@pytest.fixture +def test_settings(tmp_path): + """Create a fresh Dynaconf instance with no file-based config.""" + empty_toml = tmp_path / "settings.toml" + empty_toml.write_text("[default]\n") + return Dynaconf( + settings_files=[str(empty_toml)], + environments=True, + env_switcher="MCP_DEPLOYMENT_MODE", + envvar_prefix=False, + FORCE_ENV_FOR_DYNACONF="testing", + ) +``` + +**Alternative: DynaconfDict for simple mocking** + +```python +from dynaconf import DynaconfDict + +def test_something(): + """Use DynaconfDict when only a few values are needed.""" + mock_settings = DynaconfDict({ + "NEXTCLOUD_HOST": "https://test.example.com", + "ENABLE_SEMANTIC_SEARCH": False, + }) + result = some_function(mock_settings) +``` + +**Alternative: Module-level reload for integration tests** + +Dynaconf instances do support `reload()` (defined in `dynaconf/base.py`), which clears all loaded values and re-executes all loaders. This can be used for integration tests that need the full loading pipeline: + +```python +@pytest.fixture(autouse=True) +def isolated_settings(tmp_path, monkeypatch): + """Reset the module-level dynaconf instance for integration tests. + + Calls reload() + validate_all() in teardown to ensure the instance + is both reset and in a valid state for subsequent tests. + """ + monkeypatch.setenv("SETTINGS_FILE_FOR_DYNACONF", str(tmp_path / "empty.toml")) + (tmp_path / "empty.toml").write_text("[default]\n") + from nextcloud_mcp_server.config import _dynaconf + _dynaconf.reload() + yield + _dynaconf.reload() + # Re-validate after teardown to catch tests that leave invalid state +``` + +**Note on `_dynaconf` and `_reload_config`:** These are prefixed with `_` to signal internal use, but tests necessarily import them for isolation. This is an accepted trade-off. To prevent accidental production use, these names are intentionally excluded from `__all__` and carry docstrings noting they are test-accessible internals. + +The fixture factory approach is preferred because it avoids global state mutation and is compatible with parallel test execution. Tests that need specific configuration values continue to use `monkeypatch.setenv()` as today, which overrides any file-based defaults (env vars have highest priority in dynaconf). + +### Docker Compose Impact + +**Zero breaking changes.** All existing `environment:` blocks in `docker-compose.yml` continue to work because `envvar_prefix=False` means env vars map directly to setting keys. + +**Optional enhancement:** Users can mount settings files for cleaner configuration: + +```yaml +mcp: + volumes: + # Note: settings.toml is checked into git, so it exists on the host. + # If the host file is missing, Docker creates a directory instead — this + # would cause a startup error, not silent misconfiguration. + - ./settings.toml:/app/settings.toml:ro + - ./.secrets.toml:/app/.secrets.toml:ro + environment: + # Only override what differs from settings.toml + - MCP_DEPLOYMENT_MODE=single_user_basic + - LOG_LEVEL=DEBUG +``` + +## Migration Strategy + +### Phase 1: Add Dynaconf Foundation +- Add `dynaconf` dependency to `pyproject.toml` +- Create `settings.toml` with `[default]` values matching current defaults +- Create `.secrets.toml.example` template +- Add `.secrets.toml` and `settings.local.toml` to `.gitignore` (currently absent — existing `.gitignore` has `*.env` patterns but no dynaconf-specific entries) +- **Audit all `os.getenv()` calls** across the codebase (`config.py`, `providers/registry.py`, etc.) to ensure every env var has a corresponding `settings.toml` entry. This includes provider env vars (`AWS_REGION`, `BEDROCK_*`, `ANTHROPIC_API_KEY`, `OLLAMA_*`, `SIMPLE_EMBEDDING_DIMENSION`) which are critical because `ignore_unknown_envvars=True` silently drops unrecognized env vars. +- **Add CI lint check** (prerequisite for Phase 2): A script that extracts all `os.getenv()` keys and verifies each has a `settings.toml` entry. Phase 2 must not merge without this check passing in CI. +- Initialize `Dynaconf` instance in `config.py` + +### Phase 2: Wire Adapter +- Replace `os.getenv()` calls in `get_settings()` with `_dynaconf.get()` calls +- Replace `os.getenv()` calls in `get_document_processor_config()` similarly +- `Settings` dataclass and all consumers unchanged +- All tests pass without modification + +### Phase 3: Add Validators +- Add dynaconf `Validator` instances for type checking, range validation, and enum constraints +- Remove corresponding manual checks from `Settings.__post_init__` + +### Phase 4: Deprecation and Dependency Hooks (Optional, Future) +- Move `_get_semantic_search_enabled()`, `_get_background_operations_enabled()`, and `_is_multi_user_mode()` logic into dynaconf post-hooks +- Remove standalone helper functions +- **Risk note:** These functions contain nuanced multi-variable logic (e.g., the `ENABLE_SEMANTIC_SEARCH` + `VECTOR_SYNC_ENABLED` OR pattern, the username/password presence check for mode detection). Running them as dynaconf post-hooks changes their execution context and ordering guarantees relative to `config_validators.py`. This phase should only proceed after Phases 1-3 are stable and well-tested. + +### Phase 5: Direct Dynaconf Access (Optional, Future) +- Gradually replace `get_settings().field` with `settings.FIELD` in consumers +- Remove `Settings` dataclass once all consumers migrated +- This is a larger refactor touching ~30 files and can be deferred + +### Phase 6: Provider Registry Consolidation (Optional, Future) +- Update `ProviderRegistry.create_provider()` to read from dynaconf +- Eliminates the last pocket of direct `os.getenv()` calls + +## Consequences + +### Positive +- **File-based configuration** enables shipping deployment profiles, reducing per-deployment env var count from 15-25 to 1-3 overrides +- **Automatic type coercion** eliminates ~30 manual `int()`, `float()`, `.lower() == "true"` patterns and their potential `ValueError` exceptions +- **Declarative validation** catches invalid configuration at startup with clear error messages +- **Secret separation** via `.secrets.toml` provides a standard pattern for credential management +- **Local overrides** via `settings.local.toml` simplify developer workflows without polluting git +- **12-factor compliant** — env vars always win, files are optional +- **Zero breaking changes** in Phases 1-3. Phase 4 is optional and carries moderate risk due to complex multi-variable logic. + +### Negative +- **New dependency** — `dynaconf` is a runtime dependency (~50KB, pure Python, well-maintained) +- **Two configuration systems during migration** — Phases 1-3 run dynaconf alongside the existing `Settings` dataclass +- **Learning curve** — Contributors must understand dynaconf's merge semantics and environment sections +- **`envvar_prefix=False` risk** — Without a prefix, any env var matching a setting key is loaded. Mitigated by `ignore_unknown_envvars=True` which restricts to pre-defined keys only +- **`ignore_unknown_envvars=True` silent failure mode** — Env vars not declared in `settings.toml` are silently ignored. If a developer adds a new env var but forgets to add a corresponding entry in `settings.toml`, the value will silently be `None` at runtime instead of producing an error. This inverts the current failure mode (where `os.getenv()` returning `None` at least fails visibly at the point of use). **Mitigation (mandatory before Phase 2):** Phase 1 must complete a full audit of all `os.getenv()` calls across the codebase — including `config.py`, `providers/registry.py`, and any other modules — and add corresponding entries to `settings.toml`. A CI lint check (e.g., a script that greps for `os.getenv()` keys and verifies each has a `settings.toml` entry) must be added as part of Phase 1, not deferred. Until this CI check is in place, `ignore_unknown_envvars=True` should not be enabled. +- **`ValidationError` replaces `ValueError`** — Dynaconf validators raise `dynaconf.validator.ValidationError` instead of `ValueError`. Any external code catching `ValueError` from `Settings.__post_init__` (e.g., for `document_chunk_overlap < 0`) will need to be updated. This is a breaking change introduced in Phase 3 when validators replace manual checks. +- **`environments=True` is a legacy dynaconf feature** — The dynaconf docs recommend against it for new projects. If a future dynaconf major release removes it, we would need to migrate to per-file configuration (`settings.single_user_basic.toml`, etc.) or pinned TOML section names. This risk is accepted because the alternative requires managing 5+ separate files with duplicated defaults. + +### Neutral +- **`config_validators.py` unchanged** — Mode detection and conditional validation remain as Python business logic. Dynaconf validators handle structural checks only. +- **Docker Compose files unchanged** — Existing `environment:` blocks work as-is. File mounting is optional. +- **`environments=True` with custom deployment mode names** — When `MCP_DEPLOYMENT_MODE` is unset, only the `[default]` TOML section is loaded. This is the correct behavior: the existing auto-detection logic in `config_validators.py` still determines the deployment mode post-load based on which env vars are present. The TOML sections provide *defaults per mode*, not mode detection. Note: `env_switcher="MCP_DEPLOYMENT_MODE"` takes precedence over dynaconf's default `ENV_FOR_DYNACONF` variable. Contributors should not set `ENV_FOR_DYNACONF` directly, as it would shadow the `env_switcher` configuration and cause confusing behavior. + +## Alternatives Considered + +### 1. Pydantic Settings +Pydantic v2's `BaseSettings` provides type validation and env var loading. As of pydantic-settings 2.x, it supports TOML files via `PyprojectTomlConfigSettingsSource` and custom settings sources. However, it lacks native TOML section-based environment switching and automatic secrets file separation, which are the primary motivations for this change. While Pydantic v2 is already used in the project for response models (`nextcloud_mcp_server/models/`), Pydantic Settings would require significant custom code to replicate dynaconf's `[default]`/`[mode]` section merging and `.secrets.toml` auto-loading. + +### 2. python-decouple +Supports `.env` and `.ini` files with type casting. Lacks environment sections, validators, secrets separation, and TOML support. Too limited for our needs. + +### 3. Custom TOML Loader +Build a minimal TOML loader using `tomllib` (stdlib in Python 3.11+). This avoids a dependency but requires implementing validation, env var override, secrets separation, and environment switching from scratch — essentially rebuilding dynaconf. + +### 4. Status Quo (Env Vars Only) +Continue with `os.getenv()`. Acceptable for small projects, but with 80+ variables across 5 deployment modes, the lack of file-based configuration, validation, and defaults per mode is a growing maintenance burden. + +## References + +- [Dynaconf Documentation](https://www.dynaconf.com/) +- [12-Factor App: Config](https://12factor.net/config) +- ADR-020: Deployment Modes and Configuration Validation +- ADR-021: Configuration Consolidation and Simplification +- ADR-022: Login Flow v2 diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index cc4aa50c..dd2a3f6a 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -4,8 +4,75 @@ import os import socket import ssl from dataclasses import dataclass +from pathlib import Path from typing import Any +from dynaconf import Dynaconf, Validator + +# Resolve root_path for dynaconf settings files. +# Editable installs: parent.parent is the project root (has settings.toml). +# Non-editable installs (Docker): settings.toml is mounted at WORKDIR (/app/). +_config_root = Path(__file__).parent.parent +if not (_config_root / "settings.toml").exists(): + _config_root = Path.cwd() + +# Sentinel for "key not in dynaconf at all" vs "explicitly set to None". +_UNSET = object() + +# Dynaconf instance — loads settings.toml + .secrets.toml + env vars. +# Env vars always win (12-factor). See ADR-024 for architecture. +_dynaconf = Dynaconf( + settings_files=["settings.toml", ".secrets.toml"], + environments=True, + envvar_prefix=False, + env_switcher="MCP_DEPLOYMENT_MODE", + ignore_unknown_envvars=True, + root_path=str(_config_root), + load_dotenv=False, + validators=[ + # Port ranges + Validator("METRICS_PORT", gte=1, lte=65535), + # Positive integers + Validator("VECTOR_SYNC_SCAN_INTERVAL", gte=1), + Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1), + Validator("VECTOR_SYNC_QUEUE_MAX_SIZE", gte=1), + Validator("VECTOR_SYNC_USER_POLL_INTERVAL", gte=1), + Validator("DOCUMENT_CHUNK_SIZE", gte=1), + # Non-negative + Validator("DOCUMENT_CHUNK_OVERLAP", gte=0), + # Enum constraints + Validator("LOG_FORMAT", is_in=["text", "json"]), + Validator( + "LOG_LEVEL", + is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + ), + Validator( + "OTEL_TRACES_SAMPLER", + is_in=[ + "always_on", + "always_off", + "traceidratio", + "parentbased_always_on", + "parentbased_always_off", + "parentbased_traceidratio", + ], + ), + # Float ranges + Validator("OTEL_TRACES_SAMPLER_ARG", gte=0.0, lte=1.0), + ], +) + + +def _reload_config(): + """Reload dynaconf settings from files and environment. + + Call this in tests after modifying os.environ to refresh the cache. + Re-validates all validators since reload() only checks unchecked ones. + """ + _dynaconf.reload() + _dynaconf.validators.validate_all() + + LOGGING_CONFIG = { "version": 1, "disable_existing_loggers": False, @@ -63,7 +130,7 @@ def setup_logging(): def get_document_processor_config() -> dict[str, Any]: - """Get document processor configuration from environment. + """Get document processor configuration from dynaconf. Returns: Dict with processor configs: @@ -78,54 +145,54 @@ def get_document_processor_config() -> dict[str, Any]: } """ config: dict[str, Any] = { - "enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true", - "default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"), + "enabled": _dynaconf.get("ENABLE_DOCUMENT_PROCESSING"), + "default_processor": _dynaconf.get("DOCUMENT_PROCESSOR"), "processors": {}, } # Unstructured configuration - if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true": + if _dynaconf.get("ENABLE_UNSTRUCTURED"): + languages_str = _dynaconf.get("UNSTRUCTURED_LANGUAGES") config["processors"]["unstructured"] = { - "api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"), - "timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")), - "strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"), + "api_url": _dynaconf.get("UNSTRUCTURED_API_URL"), + "timeout": _dynaconf.get("UNSTRUCTURED_TIMEOUT"), + "strategy": _dynaconf.get("UNSTRUCTURED_STRATEGY"), "languages": [ - lang.strip() - for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",") - if lang.strip() + lang.strip() for lang in languages_str.split(",") if lang.strip() ], - "progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")), + "progress_interval": _dynaconf.get("PROGRESS_INTERVAL"), } # Tesseract configuration - if os.getenv("ENABLE_TESSERACT", "false").lower() == "true": + if _dynaconf.get("ENABLE_TESSERACT"): config["processors"]["tesseract"] = { - "tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect - "lang": os.getenv("TESSERACT_LANG", "eng"), + "tesseract_cmd": _dynaconf.get("TESSERACT_CMD"), # None = auto-detect + "lang": _dynaconf.get("TESSERACT_LANG"), } # PyMuPDF configuration (local PDF processing) - if os.getenv("ENABLE_PYMUPDF", "true").lower() == "true": # Enabled by default + if _dynaconf.get("ENABLE_PYMUPDF"): # Enabled by default config["processors"]["pymupdf"] = { - "extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower() - == "true", - "image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory + "extract_images": _dynaconf.get("PYMUPDF_EXTRACT_IMAGES"), + "image_dir": _dynaconf.get( + "PYMUPDF_IMAGE_DIR" + ), # None = use temp directory } # Custom processor (via HTTP API) - if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true": - custom_url = os.getenv("CUSTOM_PROCESSOR_URL") + if _dynaconf.get("ENABLE_CUSTOM_PROCESSOR"): + custom_url = _dynaconf.get("CUSTOM_PROCESSOR_URL") if custom_url: - supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf") + supported_types_str = _dynaconf.get("CUSTOM_PROCESSOR_TYPES") supported_types = { t.strip() for t in supported_types_str.split(",") if t.strip() } config["processors"]["custom"] = { - "name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"), + "name": _dynaconf.get("CUSTOM_PROCESSOR_NAME"), "api_url": custom_url, - "api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"), - "timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")), + "api_key": _dynaconf.get("CUSTOM_PROCESSOR_API_KEY"), + "timeout": _dynaconf.get("CUSTOM_PROCESSOR_TIMEOUT"), "supported_types": supported_types, } @@ -277,11 +344,6 @@ class Settings: f"Smaller chunks may lose context. Consider using at least 1024 characters." ) - if self.document_chunk_overlap < 0: - raise ValueError( - f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative." - ) - def get_embedding_model_name(self) -> str: """ Get the active embedding model name based on provider priority. @@ -371,8 +433,8 @@ def _get_semantic_search_enabled() -> bool: """ logger = logging.getLogger(__name__) - new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true" - old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true" + new_value = _dynaconf.get("ENABLE_SEMANTIC_SEARCH", False) + old_value = _dynaconf.get("VECTOR_SYNC_ENABLED", False) if new_value and old_value: logger.warning( @@ -405,16 +467,16 @@ def _is_multi_user_mode() -> bool: True if multi-user mode detected """ # Multi-user BasicAuth explicitly enabled - if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true": + if _dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH", False): return True # Token exchange implies OAuth multi-user - if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true": + if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False): return True # If both username and password are set, it's single-user BasicAuth - has_username = bool(os.getenv("NEXTCLOUD_USERNAME")) - has_password = bool(os.getenv("NEXTCLOUD_PASSWORD")) + has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME")) + has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD")) if has_username and has_password: return False @@ -436,8 +498,8 @@ def _get_background_operations_enabled() -> bool: logger = logging.getLogger(__name__) # Check new and old variable names - explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true" - legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true" + explicit = _dynaconf.get("ENABLE_BACKGROUND_OPERATIONS", False) + legacy = _dynaconf.get("ENABLE_OFFLINE_ACCESS", False) if explicit and legacy: logger.warning( @@ -466,8 +528,29 @@ def _get_background_operations_enabled() -> bool: return explicit or legacy or auto_enabled +def _dget(key): + """Get a value from dynaconf if configured, otherwise return _UNSET. + + Distinguishes "explicitly set to None" (via @none in TOML or env var) + from "not configured at all". When _UNSET is returned, callers should + let the Settings dataclass default apply. + """ + return _dynaconf[key] if key in _dynaconf else _UNSET + + def get_settings() -> Settings: - """Get application settings from environment variables. + """Get application settings from dynaconf configuration. + + Settings are loaded from (last wins): + 1. settings.toml [default] section + 2. settings.toml [] section (via MCP_DEPLOYMENT_MODE) + 3. .secrets.toml (if present) + 4. settings.local.toml (if present) + 5. Environment variables (highest priority) + + Values not found in any source are omitted, letting Settings dataclass + defaults apply. This ensures the server starts correctly even without + settings.toml (e.g., env-var-only deployments). Returns: Settings object with configuration values @@ -476,86 +559,84 @@ def get_settings() -> Settings: enable_semantic_search = _get_semantic_search_enabled() enable_background_operations = _get_background_operations_enabled() - return Settings( + # Mapping from Settings field name to dynaconf key + _field_map = { # Deployment mode (ADR-021) - deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"), + "deployment_mode": "MCP_DEPLOYMENT_MODE", # OAuth/OIDC settings - oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"), - oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"), - oidc_client_secret=os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET"), - oidc_issuer=os.getenv("OIDC_ISSUER"), + "oidc_discovery_url": "OIDC_DISCOVERY_URL", + "oidc_client_id": "NEXTCLOUD_OIDC_CLIENT_ID", + "oidc_client_secret": "NEXTCLOUD_OIDC_CLIENT_SECRET", + "oidc_issuer": "OIDC_ISSUER", # Nextcloud settings - nextcloud_host=os.getenv("NEXTCLOUD_HOST"), - nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"), - nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"), - nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"), + "nextcloud_host": "NEXTCLOUD_HOST", + "nextcloud_username": "NEXTCLOUD_USERNAME", + "nextcloud_password": "NEXTCLOUD_PASSWORD", + "nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD", # Nextcloud SSL/TLS settings - nextcloud_verify_ssl=( - os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true" - ), - nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"), + "nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL", + "nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE", # ADR-005: Token Audience Validation - nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"), - nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"), + "nextcloud_mcp_server_url": "NEXTCLOUD_MCP_SERVER_URL", + "nextcloud_resource_uri": "NEXTCLOUD_RESOURCE_URI", # Token verification endpoints - jwks_uri=os.getenv("JWKS_URI"), - introspection_uri=os.getenv("INTROSPECTION_URI"), - userinfo_uri=os.getenv("USERINFO_URI"), - # Progressive Consent settings (always enabled) - enable_offline_access=enable_background_operations, # Smart dependency resolution + "jwks_uri": "JWKS_URI", + "introspection_uri": "INTROSPECTION_URI", + "userinfo_uri": "USERINFO_URI", # Multi-user BasicAuth pass-through mode - enable_multi_user_basic_auth=( - os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true" - ), + "enable_multi_user_basic_auth": "ENABLE_MULTI_USER_BASIC_AUTH", # Login Flow v2 settings (ADR-022) - enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"), - # Token and webhook storage settings (encryption key optional for webhook-only usage) - token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"), - token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"), + "enable_login_flow": "ENABLE_LOGIN_FLOW", + # Token and webhook storage settings + "token_encryption_key": "TOKEN_ENCRYPTION_KEY", + "token_storage_db": "TOKEN_STORAGE_DB", # Vector sync settings (ADR-007) - vector_sync_enabled=enable_semantic_search, # Smart dependency resolution - vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")), - vector_sync_processor_workers=int( - os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3") - ), - vector_sync_queue_max_size=int( - os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000") - ), - vector_sync_user_poll_interval=int( - os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60") - ), + "vector_sync_scan_interval": "VECTOR_SYNC_SCAN_INTERVAL", + "vector_sync_processor_workers": "VECTOR_SYNC_PROCESSOR_WORKERS", + "vector_sync_queue_max_size": "VECTOR_SYNC_QUEUE_MAX_SIZE", + "vector_sync_user_poll_interval": "VECTOR_SYNC_USER_POLL_INTERVAL", # Qdrant settings - qdrant_url=os.getenv("QDRANT_URL"), - qdrant_location=os.getenv("QDRANT_LOCATION"), - qdrant_api_key=os.getenv("QDRANT_API_KEY"), - qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"), + "qdrant_url": "QDRANT_URL", + "qdrant_location": "QDRANT_LOCATION", + "qdrant_api_key": "QDRANT_API_KEY", + "qdrant_collection": "QDRANT_COLLECTION", # Ollama settings - ollama_base_url=os.getenv("OLLAMA_BASE_URL"), - ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"), - ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true", + "ollama_base_url": "OLLAMA_BASE_URL", + "ollama_embedding_model": "OLLAMA_EMBEDDING_MODEL", + "ollama_verify_ssl": "OLLAMA_VERIFY_SSL", # OpenAI settings - openai_api_key=os.getenv("OPENAI_API_KEY"), - openai_base_url=os.getenv("OPENAI_BASE_URL"), - openai_embedding_model=os.getenv( - "OPENAI_EMBEDDING_MODEL", "text-embedding-3-small" - ), + "openai_api_key": "OPENAI_API_KEY", + "openai_base_url": "OPENAI_BASE_URL", + "openai_embedding_model": "OPENAI_EMBEDDING_MODEL", # Document chunking settings - document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")), - document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")), + "document_chunk_size": "DOCUMENT_CHUNK_SIZE", + "document_chunk_overlap": "DOCUMENT_CHUNK_OVERLAP", # Observability settings - metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true", - metrics_port=int(os.getenv("METRICS_PORT", "9090")), - otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), - otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower() - == "true", - otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"), - otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"), - otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")), - log_format=os.getenv("LOG_FORMAT", "text"), - log_level=os.getenv("LOG_LEVEL", "INFO"), - log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower() - == "true", - ) + "metrics_enabled": "METRICS_ENABLED", + "metrics_port": "METRICS_PORT", + "otel_exporter_otlp_endpoint": "OTEL_EXPORTER_OTLP_ENDPOINT", + "otel_exporter_verify_ssl": "OTEL_EXPORTER_VERIFY_SSL", + "otel_service_name": "OTEL_SERVICE_NAME", + "otel_traces_sampler": "OTEL_TRACES_SAMPLER", + "otel_traces_sampler_arg": "OTEL_TRACES_SAMPLER_ARG", + "log_format": "LOG_FORMAT", + "log_level": "LOG_LEVEL", + "log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT", + } + + # Only pass values that dynaconf actually has; omit unset keys so + # the Settings dataclass defaults apply. + kwargs = { + field: val + for field, key in _field_map.items() + if (val := _dget(key)) is not _UNSET + } + + # Smart dependency overrides (always set, regardless of dynaconf) + kwargs["vector_sync_enabled"] = enable_semantic_search + kwargs["enable_offline_access"] = enable_background_operations + + return Settings(**kwargs) def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext: diff --git a/pyproject.toml b/pyproject.toml index 3845db6e..2e5740ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "pymupdf4llm>=0.2.2", "pymupdf-layout>=1.26.6", "openai>=2.8.1", + "dynaconf>=3.2.13,<4.0", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/settings.toml b/settings.toml new file mode 100644 index 00000000..b4cccac1 --- /dev/null +++ b/settings.toml @@ -0,0 +1,131 @@ +# Nextcloud MCP Server Configuration +# This file defines all configuration keys with their default values. +# Environment variables always override values from this file. +# +# Loading priority (last wins): +# 1. settings.toml [default] section +# 2. settings.toml [] section (via MCP_DEPLOYMENT_MODE) +# 3. .secrets.toml [default] section +# 4. .secrets.toml [] section +# 5. settings.local.toml (all sections, gitignored) +# 6. Environment variables (highest priority) +# +# See docs/ADR-024-dynaconf-configuration-management.md for details. + +[default] + +# --- Deployment mode (ADR-021) --- +# Valid: single_user_basic, multi_user_basic, login_flow, keycloak, oauth_single_audience +# If unset, mode is auto-detected from other settings. +mcp_deployment_mode = "@none" + +# --- Nextcloud core --- +nextcloud_host = "@none" +nextcloud_username = "@none" +nextcloud_password = "@none" +nextcloud_app_password = "@none" +nextcloud_verify_ssl = true +nextcloud_ca_bundle = "@none" +nextcloud_mcp_server_url = "@none" +nextcloud_resource_uri = "@none" + +# --- OAuth/OIDC --- +oidc_discovery_url = "@none" +nextcloud_oidc_client_id = "@none" +nextcloud_oidc_client_secret = "@none" +oidc_issuer = "@none" +jwks_uri = "@none" +introspection_uri = "@none" +userinfo_uri = "@none" + +# --- Mode flags --- +enable_multi_user_basic_auth = false +enable_login_flow = false +enable_semantic_search = false +enable_background_operations = false + +# Deprecated aliases (declared so env var overrides work) +vector_sync_enabled = false +enable_offline_access = false +enable_token_exchange = false + +# --- Token storage --- +token_encryption_key = "@none" +token_storage_db = "/tmp/tokens.db" + +# --- Vector sync --- +vector_sync_scan_interval = 300 +vector_sync_processor_workers = 3 +vector_sync_queue_max_size = 10000 +vector_sync_user_poll_interval = 60 + +# --- Qdrant --- +# No default for qdrant_location — conditional default (:memory:) is in Settings.__post_init__ +qdrant_url = "@none" +qdrant_location = "@none" +qdrant_api_key = "@none" +qdrant_collection = "nextcloud_content" + +# --- Ollama --- +ollama_base_url = "@none" +ollama_embedding_model = "nomic-embed-text" +ollama_verify_ssl = true + +# --- OpenAI --- +openai_api_key = "@none" +openai_base_url = "@none" +openai_embedding_model = "text-embedding-3-small" + +# --- Document chunking --- +document_chunk_size = 2048 +document_chunk_overlap = 200 + +# --- Observability --- +metrics_enabled = true +metrics_port = 9090 +otel_exporter_otlp_endpoint = "@none" +otel_exporter_verify_ssl = false +otel_service_name = "nextcloud-mcp-server" +otel_traces_sampler = "always_on" +otel_traces_sampler_arg = 1.0 +log_format = "text" +log_level = "INFO" +log_include_trace_context = true + +# --- Document processing --- +enable_document_processing = false +document_processor = "unstructured" +enable_unstructured = false +unstructured_api_url = "http://unstructured:8000" +unstructured_timeout = 120 +unstructured_strategy = "auto" +unstructured_languages = "eng,deu" +progress_interval = 10 +enable_tesseract = false +tesseract_cmd = "@none" +tesseract_lang = "eng" +enable_pymupdf = true +pymupdf_extract_images = true +pymupdf_image_dir = "@none" +enable_custom_processor = false +custom_processor_url = "@none" +custom_processor_types = "application/pdf" +custom_processor_name = "custom" +custom_processor_api_key = "@none" +custom_processor_timeout = 60 + + +# --- Deployment mode sections --- +# Keys here override [default] when MCP_DEPLOYMENT_MODE matches. + +[single_user_basic] + +[multi_user_basic] +enable_multi_user_basic_auth = true + +[login_flow] +enable_login_flow = true + +[keycloak] + +[oauth_single_audience] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..9eb01e72 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,21 @@ +"""Unit test configuration — shared fixtures for all unit tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def _reload_dynaconf_after_test(): + """Ensure dynaconf cache is clean between tests. + + Dynaconf caches env var values at load time. Tests that modify os.environ + must call _reload_config() to refresh the cache. This fixture reloads + after each test to prevent leaked state. + + Uses _dynaconf.reload() directly (without validate_all) since the + real env may have values that don't pass validators. Tests that need + validation should call _reload_config() explicitly. + """ + yield + from nextcloud_mcp_server.config import _dynaconf + + _dynaconf.reload() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 345107ce..2a73c1ae 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from nextcloud_mcp_server.config import Settings, get_settings +from nextcloud_mcp_server.config import Settings, _reload_config, get_settings class TestQdrantConfigValidation: @@ -74,6 +74,7 @@ class TestGetSettings: @patch.dict(os.environ, {}, clear=True) def test_get_settings_defaults_to_memory(self): """Test get_settings() defaults to :memory: when no env vars set.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == ":memory:" assert settings.qdrant_url is None @@ -88,6 +89,7 @@ class TestGetSettings: ) def test_get_settings_network_mode(self): """Test get_settings() with network mode env vars.""" + _reload_config() settings = get_settings() assert settings.qdrant_url == "http://qdrant:6333" assert settings.qdrant_api_key == "test-key" @@ -100,6 +102,7 @@ class TestGetSettings: ) def test_get_settings_persistent_mode(self): """Test get_settings() with persistent local mode env vars.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == "/app/data/qdrant" assert settings.qdrant_url is None @@ -111,6 +114,7 @@ class TestGetSettings: ) def test_get_settings_explicit_memory(self): """Test get_settings() with explicit :memory: env var.""" + _reload_config() settings = get_settings() assert settings.qdrant_location == ":memory:" assert settings.qdrant_url is None @@ -125,6 +129,7 @@ class TestGetSettings: ) def test_get_settings_mutual_exclusion_error(self): """Test get_settings() raises error when both URL and location set.""" + _reload_config() with pytest.raises( ValueError, match="Cannot set both QDRANT_URL and QDRANT_LOCATION", @@ -144,6 +149,7 @@ class TestGetSettings: ) def test_get_settings_vector_sync_config(self): """Test get_settings() with vector sync configuration.""" + _reload_config() settings = get_settings() assert settings.qdrant_collection == "test_collection" assert settings.vector_sync_enabled is True @@ -192,16 +198,17 @@ class TestChunkConfigValidation: document_chunk_overlap=300, ) + @patch.dict( + os.environ, + {"DOCUMENT_CHUNK_OVERLAP": "-10"}, + clear=True, + ) def test_negative_overlap_raises_error(self): - """Test that negative overlap raises ValueError.""" - with pytest.raises( - ValueError, - match="DOCUMENT_CHUNK_OVERLAP .* cannot be negative", - ): - Settings( - document_chunk_size=512, - document_chunk_overlap=-10, - ) + """Test that negative overlap raises ValidationError via dynaconf.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_OVERLAP"): + _reload_config() def test_small_chunk_size_warning(self, caplog): """Test that chunk size < 512 triggers warning.""" @@ -237,6 +244,7 @@ class TestChunkConfigValidation: ) def test_get_settings_chunk_config(self): """Test get_settings() with chunk configuration.""" + _reload_config() settings = get_settings() assert settings.document_chunk_size == 1024 assert settings.document_chunk_overlap == 102 @@ -251,6 +259,7 @@ class TestChunkConfigValidation: ) def test_get_settings_invalid_chunk_config_raises_error(self): """Test get_settings() raises error for invalid chunk config.""" + _reload_config() with pytest.raises( ValueError, match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE", @@ -294,6 +303,7 @@ class TestEmbeddingModelName: ) def test_get_settings_openai_model(self): """Test get_settings() loads OpenAI embedding model.""" + _reload_config() settings = get_settings() assert settings.openai_api_key == "test-openai-key" assert settings.openai_embedding_model == "openai/text-embedding-3-small" @@ -342,3 +352,85 @@ class TestCollectionNameWithProviders: openai_embedding_model="text-embedding-3-large", ) assert settings.get_collection_name() == "custom-collection" + + +class TestDynaconfValidators: + """Test dynaconf declarative validators (ADR-024 Phase 3).""" + + @patch.dict(os.environ, {"METRICS_PORT": "0"}, clear=True) + def test_metrics_port_too_low(self): + """Test METRICS_PORT below minimum raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="METRICS_PORT"): + _reload_config() + + @patch.dict(os.environ, {"METRICS_PORT": "99999"}, clear=True) + def test_metrics_port_too_high(self): + """Test METRICS_PORT above maximum raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="METRICS_PORT"): + _reload_config() + + @patch.dict(os.environ, {"LOG_FORMAT": "xml"}, clear=True) + def test_invalid_log_format(self): + """Test invalid LOG_FORMAT raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="LOG_FORMAT"): + _reload_config() + + @patch.dict(os.environ, {"LOG_LEVEL": "VERBOSE"}, clear=True) + def test_invalid_log_level(self): + """Test invalid LOG_LEVEL raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="LOG_LEVEL"): + _reload_config() + + @patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "random"}, clear=True) + def test_invalid_otel_sampler(self): + """Test invalid OTEL_TRACES_SAMPLER raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER"): + _reload_config() + + @patch.dict(os.environ, {"OTEL_TRACES_SAMPLER_ARG": "2.0"}, clear=True) + def test_sampler_arg_too_high(self): + """Test OTEL_TRACES_SAMPLER_ARG above 1.0 raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER_ARG"): + _reload_config() + + @patch.dict(os.environ, {"VECTOR_SYNC_SCAN_INTERVAL": "0"}, clear=True) + def test_vector_sync_interval_zero(self): + """Test zero VECTOR_SYNC_SCAN_INTERVAL raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="VECTOR_SYNC_SCAN_INTERVAL"): + _reload_config() + + @patch.dict(os.environ, {"DOCUMENT_CHUNK_SIZE": "0"}, clear=True) + def test_chunk_size_zero(self): + """Test zero DOCUMENT_CHUNK_SIZE raises ValidationError.""" + from dynaconf import ValidationError + + with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_SIZE"): + _reload_config() + + @patch.dict(os.environ, {"METRICS_PORT": "8080"}, clear=True) + def test_valid_metrics_port(self): + """Test valid METRICS_PORT passes validation.""" + _reload_config() + settings = get_settings() + assert settings.metrics_port == 8080 + + @patch.dict(os.environ, {"LOG_FORMAT": "json"}, clear=True) + def test_valid_log_format_json(self): + """Test valid LOG_FORMAT=json passes validation.""" + _reload_config() + settings = get_settings() + assert settings.log_format == "json" diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index ad242c14..3aad9eaa 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -10,7 +10,7 @@ Tests cover: import os from unittest.mock import patch -from nextcloud_mcp_server.config import Settings +from nextcloud_mcp_server.config import Settings, _reload_config from nextcloud_mcp_server.config_validators import ( AuthMode, detect_auth_mode, @@ -274,6 +274,7 @@ class TestMultiUserBasicValidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -372,6 +373,7 @@ class TestOAuthSingleAudienceValidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -459,6 +461,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True @@ -474,6 +477,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.vector_sync_enabled is True @@ -490,6 +494,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.enable_offline_access is True @@ -506,6 +511,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() assert settings.enable_offline_access is True @@ -525,6 +531,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search enabled @@ -549,6 +556,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search enabled @@ -572,6 +580,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Semantic search NOT enabled @@ -593,6 +602,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should use new name value (true) @@ -612,6 +622,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should use new name value (true) @@ -637,6 +648,7 @@ class TestConfigurationConsolidation: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode, errors = validate_configuration(settings) @@ -671,6 +683,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -688,6 +701,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -705,6 +719,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -722,6 +737,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() # Should raise ValueError with clear message @@ -747,6 +763,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -765,6 +782,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) @@ -782,6 +800,7 @@ class TestExplicitModeSelection: ): from nextcloud_mcp_server.config import get_settings + _reload_config() settings = get_settings() mode = detect_auth_mode(settings) diff --git a/tests/unit/test_document_processor_config.py b/tests/unit/test_document_processor_config.py index 6f55fbfd..29cc91be 100644 --- a/tests/unit/test_document_processor_config.py +++ b/tests/unit/test_document_processor_config.py @@ -4,6 +4,8 @@ import os import pytest +from nextcloud_mcp_server.config import _reload_config, get_document_processor_config + pytestmark = pytest.mark.unit @@ -12,18 +14,16 @@ class TestDocumentProcessorConfig: def test_config_disabled_by_default(self): """Test that document processing is disabled by default.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ.pop("ENABLE_DOCUMENT_PROCESSING", None) + _reload_config() config = get_document_processor_config() assert config["enabled"] is False def test_config_enabled(self): """Test enabling document processing.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" try: + _reload_config() config = get_document_processor_config() assert config["enabled"] is True finally: @@ -31,8 +31,6 @@ class TestDocumentProcessorConfig: def test_unstructured_processor_config(self): """Test Unstructured processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000" os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res" @@ -40,6 +38,7 @@ class TestDocumentProcessorConfig: os.environ["UNSTRUCTURED_TIMEOUT"] = "60" try: + _reload_config() config = get_document_processor_config() assert "unstructured" in config["processors"] unst_config = config["processors"]["unstructured"] @@ -56,13 +55,12 @@ class TestDocumentProcessorConfig: def test_tesseract_processor_config(self): """Test Tesseract processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_TESSERACT"] = "true" os.environ["TESSERACT_LANG"] = "eng+deu" os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract" try: + _reload_config() config = get_document_processor_config() assert "tesseract" in config["processors"] tess_config = config["processors"]["tesseract"] @@ -75,8 +73,6 @@ class TestDocumentProcessorConfig: def test_custom_processor_config(self): """Test custom processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true" os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr" os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process" @@ -85,6 +81,7 @@ class TestDocumentProcessorConfig: os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg" try: + _reload_config() config = get_document_processor_config() assert "custom" in config["processors"] custom_config = config["processors"]["custom"] @@ -104,13 +101,12 @@ class TestDocumentProcessorConfig: def test_multiple_processors(self): """Test configuration with multiple processors enabled.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true" os.environ["ENABLE_UNSTRUCTURED"] = "true" os.environ["ENABLE_TESSERACT"] = "true" try: + _reload_config() config = get_document_processor_config() assert config["enabled"] is True assert "unstructured" in config["processors"] @@ -122,14 +118,14 @@ class TestDocumentProcessorConfig: def test_default_processor_selection(self): """Test default processor configuration.""" - from nextcloud_mcp_server.config import get_document_processor_config - os.environ.pop("DOCUMENT_PROCESSOR", None) + _reload_config() config = get_document_processor_config() assert config["default_processor"] == "unstructured" os.environ["DOCUMENT_PROCESSOR"] = "tesseract" try: + _reload_config() config = get_document_processor_config() assert config["default_processor"] == "tesseract" finally: diff --git a/tests/unit/test_ssl_config.py b/tests/unit/test_ssl_config.py index a03d5c47..0aca6f70 100644 --- a/tests/unit/test_ssl_config.py +++ b/tests/unit/test_ssl_config.py @@ -9,7 +9,12 @@ import certifi import httpx import pytest -from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings +from nextcloud_mcp_server.config import ( + Settings, + _reload_config, + get_nextcloud_ssl_verify, + get_settings, +) from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport @@ -50,7 +55,7 @@ class TestGetNextcloudSSLVerify: "NEXTCLOUD_VERIFY_SSL": "true", } with patch.dict(os.environ, env, clear=False): - # Clear any cached settings + _reload_config() result = get_nextcloud_ssl_verify() assert result is True @@ -110,12 +115,14 @@ class TestGetSettingsSSLEnvVars: def test_verify_ssl_env_true(self): env = {"NEXTCLOUD_VERIFY_SSL": "true"} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is True def test_verify_ssl_env_false(self): env = {"NEXTCLOUD_VERIFY_SSL": "false"} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is False @@ -123,6 +130,7 @@ class TestGetSettingsSSLEnvVars: with patch.dict(os.environ, {}, clear=False): # Remove NEXTCLOUD_VERIFY_SSL if it exists os.environ.pop("NEXTCLOUD_VERIFY_SSL", None) + _reload_config() settings = get_settings() assert settings.nextcloud_verify_ssl is True @@ -133,6 +141,7 @@ class TestGetSettingsSSLEnvVars: ) env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)} with patch.dict(os.environ, env, clear=False): + _reload_config() settings = get_settings() assert settings.nextcloud_ca_bundle == str(ca_file) diff --git a/uv.lock b/uv.lock index eb1adc4a..d803ac48 100644 --- a/uv.lock +++ b/uv.lock @@ -760,6 +760,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "dynaconf" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/0e/05927cf459e73f8bf9a9277cbea6f2d5b7db8a5cc9dc1e20e7a5fbac1b90/dynaconf-3.2.13.tar.gz", hash = "sha256:d79e0189d97b3f226b8ebb1717e2ce05d1a05cdf6ea05de66d24625fdb5a0cbd", size = 283507, upload-time = "2026-03-17T19:38:47.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/43/11d6e5d2c00bf000b5329717c74563bf76a9193f4a41cb0c4ef277dde4fa/dynaconf-3.2.13-py2.py3-none-any.whl", hash = "sha256:4305527aef4834bdba3e39479b23c005186e83fb85f65bcaa4bcea58fa26759b", size = 238041, upload-time = "2026-03-17T19:38:45.337Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -2096,6 +2105,7 @@ dependencies = [ { name = "boto3" }, { name = "caldav" }, { name = "click" }, + { name = "dynaconf" }, { name = "fastembed" }, { name = "httpx" }, { name = "icalendar" }, @@ -2149,6 +2159,7 @@ requires-dist = [ { name = "boto3", specifier = ">=1.35.0" }, { name = "caldav", specifier = ">=3.0.1,<4.0" }, { name = "click", specifier = ">=8.1.8" }, + { name = "dynaconf", specifier = ">=3.2.13,<4.0" }, { name = "fastembed", specifier = ">=0.7.3" }, { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, { name = "icalendar", specifier = ">=7.0.2,<7.1.0" },