Merge pull request #680 from cbcoutinho/docs/adr-024-dynaconf-config-management
docs: ADR-025 dynaconf configuration management
This commit is contained in:
@@ -5,6 +5,10 @@ __pycache__/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Dynaconf (ADR-024)
|
||||
.secrets.toml
|
||||
settings.local.toml
|
||||
|
||||
# Git
|
||||
worktrees/
|
||||
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 [<mode>] section ← mode-specific overrides
|
||||
3. .secrets.toml [default] section ← base secrets
|
||||
4. .secrets.toml [<mode>] 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
|
||||
+185
-104
@@ -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 [<mode>] 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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
+131
@@ -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 [<mode>] section (via MCP_DEPLOYMENT_MODE)
|
||||
# 3. .secrets.toml [default] section
|
||||
# 4. .secrets.toml [<mode>] 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]
|
||||
@@ -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()
|
||||
+102
-10
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user