fix: resolve dynaconf settings.toml not found in non-editable installs

The root_path for dynaconf resolved to site-packages instead of the
application root when installed non-editable (Docker). This caused all
settings without env var overrides to be None, crashing on startup with
a TypeError in chunk size validation.

Fix root_path to fall back to CWD when settings.toml isn't at the
source-tree path, and refactor get_settings() to only pass values
dynaconf actually has — letting Settings dataclass defaults apply for
unconfigured keys. Mount settings.toml into all docker-compose MCP
services as a read-only volume.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-07 14:17:34 +02:00
co-authored by Claude Opus 4.6
parent c8e4cbe825
commit b8b1616897
2 changed files with 93 additions and 53 deletions
+4
View File
@@ -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
+89 -53
View File
@@ -9,6 +9,16 @@ 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(
@@ -17,7 +27,7 @@ _dynaconf = Dynaconf(
envvar_prefix=False,
env_switcher="MCP_DEPLOYMENT_MODE",
ignore_unknown_envvars=True,
root_path=str(Path(__file__).parent.parent),
root_path=str(_config_root),
load_dotenv=False,
validators=[
# Port ranges
@@ -518,6 +528,16 @@ 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 dynaconf configuration.
@@ -528,6 +548,10 @@ def get_settings() -> Settings:
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
"""
@@ -535,72 +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=_dynaconf.get("MCP_DEPLOYMENT_MODE"),
"deployment_mode": "MCP_DEPLOYMENT_MODE",
# OAuth/OIDC settings
oidc_discovery_url=_dynaconf.get("OIDC_DISCOVERY_URL"),
oidc_client_id=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_ID"),
oidc_client_secret=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_SECRET"),
oidc_issuer=_dynaconf.get("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=_dynaconf.get("NEXTCLOUD_HOST"),
nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"),
nextcloud_password=_dynaconf.get("NEXTCLOUD_PASSWORD"),
nextcloud_app_password=_dynaconf.get("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=_dynaconf.get("NEXTCLOUD_VERIFY_SSL"),
nextcloud_ca_bundle=_dynaconf.get("NEXTCLOUD_CA_BUNDLE"),
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
# ADR-005: Token Audience Validation
nextcloud_mcp_server_url=_dynaconf.get("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=_dynaconf.get("NEXTCLOUD_RESOURCE_URI"),
"nextcloud_mcp_server_url": "NEXTCLOUD_MCP_SERVER_URL",
"nextcloud_resource_uri": "NEXTCLOUD_RESOURCE_URI",
# Token verification endpoints
jwks_uri=_dynaconf.get("JWKS_URI"),
introspection_uri=_dynaconf.get("INTROSPECTION_URI"),
userinfo_uri=_dynaconf.get("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=_dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH"),
"enable_multi_user_basic_auth": "ENABLE_MULTI_USER_BASIC_AUTH",
# Login Flow v2 settings (ADR-022)
enable_login_flow=_dynaconf.get("ENABLE_LOGIN_FLOW"),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
token_encryption_key=_dynaconf.get("TOKEN_ENCRYPTION_KEY"),
token_storage_db=_dynaconf.get("TOKEN_STORAGE_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=_dynaconf.get("VECTOR_SYNC_SCAN_INTERVAL"),
vector_sync_processor_workers=_dynaconf.get("VECTOR_SYNC_PROCESSOR_WORKERS"),
vector_sync_queue_max_size=_dynaconf.get("VECTOR_SYNC_QUEUE_MAX_SIZE"),
vector_sync_user_poll_interval=_dynaconf.get("VECTOR_SYNC_USER_POLL_INTERVAL"),
"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=_dynaconf.get("QDRANT_URL"),
qdrant_location=_dynaconf.get("QDRANT_LOCATION"),
qdrant_api_key=_dynaconf.get("QDRANT_API_KEY"),
qdrant_collection=_dynaconf.get("QDRANT_COLLECTION"),
"qdrant_url": "QDRANT_URL",
"qdrant_location": "QDRANT_LOCATION",
"qdrant_api_key": "QDRANT_API_KEY",
"qdrant_collection": "QDRANT_COLLECTION",
# Ollama settings
ollama_base_url=_dynaconf.get("OLLAMA_BASE_URL"),
ollama_embedding_model=_dynaconf.get("OLLAMA_EMBEDDING_MODEL"),
ollama_verify_ssl=_dynaconf.get("OLLAMA_VERIFY_SSL"),
"ollama_base_url": "OLLAMA_BASE_URL",
"ollama_embedding_model": "OLLAMA_EMBEDDING_MODEL",
"ollama_verify_ssl": "OLLAMA_VERIFY_SSL",
# OpenAI settings
openai_api_key=_dynaconf.get("OPENAI_API_KEY"),
openai_base_url=_dynaconf.get("OPENAI_BASE_URL"),
openai_embedding_model=_dynaconf.get("OPENAI_EMBEDDING_MODEL"),
"openai_api_key": "OPENAI_API_KEY",
"openai_base_url": "OPENAI_BASE_URL",
"openai_embedding_model": "OPENAI_EMBEDDING_MODEL",
# Document chunking settings
document_chunk_size=_dynaconf.get("DOCUMENT_CHUNK_SIZE"),
document_chunk_overlap=_dynaconf.get("DOCUMENT_CHUNK_OVERLAP"),
"document_chunk_size": "DOCUMENT_CHUNK_SIZE",
"document_chunk_overlap": "DOCUMENT_CHUNK_OVERLAP",
# Observability settings
metrics_enabled=_dynaconf.get("METRICS_ENABLED"),
metrics_port=_dynaconf.get("METRICS_PORT"),
otel_exporter_otlp_endpoint=_dynaconf.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_verify_ssl=_dynaconf.get("OTEL_EXPORTER_VERIFY_SSL"),
otel_service_name=_dynaconf.get("OTEL_SERVICE_NAME"),
otel_traces_sampler=_dynaconf.get("OTEL_TRACES_SAMPLER"),
otel_traces_sampler_arg=_dynaconf.get("OTEL_TRACES_SAMPLER_ARG"),
log_format=_dynaconf.get("LOG_FORMAT"),
log_level=_dynaconf.get("LOG_LEVEL"),
log_include_trace_context=_dynaconf.get("LOG_INCLUDE_TRACE_CONTEXT"),
)
"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: