fix: enable uvx/PyPI deployments without Docker assumptions
Two bugs made `uvx --from . nextcloud-mcp-server run` (and any pip install) unusable outside Docker: 1. Dynaconf was configured with ignore_unknown_envvars=True and relied on settings.toml to declare the key schema. With no settings.toml in a wheel install, every env var (NEXTCLOUD_HOST, MCP_DEPLOYMENT_MODE, ...) was silently dropped. Moved the schema into a Python _DEFAULTS dict passed directly to Dynaconf, kept settings.toml as an optional external override (renamed to settings.toml.example, gitignored), and pointed docker-compose at the example file. 2. Token SQLite DB defaulted to /app/data/tokens.db in multiple places (auth/storage.py, migrations.py, alembic/env.py, cli.py db subcommands), which blew up at uvicorn startup with FileNotFoundError on non-Docker hosts. Replaced with a new config.get_token_db_path() helper that resolves TOKEN_STORAGE_DB if explicitly set, otherwise allocates a per-process tempfile cleaned up at interpreter exit via atexit — mirroring the "ephemeral by default" pattern used for QDRANT_LOCATION=:memory:. Containers are unaffected: docker-compose services now explicitly set TOKEN_STORAGE_DB=/app/data/tokens.db (the fourth service that was missing this pin has been brought in line with the other three). Verified end-to-end in an isolated /tmp venv: env-var-only startup, Alembic migrations run against the tempfile, Application startup complete, /health/live returns 200, tempfile deleted on SIGTERM. Unit tests (464) + ruff + ty pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
fd1846de03
commit
146b622ebf
@@ -7,6 +7,7 @@ __pycache__/
|
|||||||
|
|
||||||
# Dynaconf (ADR-024)
|
# Dynaconf (ADR-024)
|
||||||
.secrets.toml
|
.secrets.toml
|
||||||
|
settings.toml
|
||||||
settings.local.toml
|
settings.local.toml
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
|
|||||||
+6
-5
@@ -40,11 +40,12 @@ path_separator = os
|
|||||||
# Default: utf-8
|
# Default: utf-8
|
||||||
# output_encoding = utf-8
|
# output_encoding = utf-8
|
||||||
|
|
||||||
# Database URL - can be overridden by:
|
# Database URL - placeholder only.
|
||||||
# 1. Passing -x database_url=... to alembic commands
|
# Runtime code (migrations.get_alembic_config / alembic/env.get_database_url)
|
||||||
# 2. Setting in environment via get_database_url() in env.py
|
# overrides this with config.get_token_db_path() which defaults to an
|
||||||
# Default: sqlite:///app/data/tokens.db
|
# ephemeral per-process tempfile unless TOKEN_STORAGE_DB is set.
|
||||||
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
|
# This value only matters for manual `alembic` invocations from the repo root.
|
||||||
|
sqlalchemy.url = sqlite+aiosqlite:///./tokens.db
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
# Post-write hooks allow you to run scripts after generating migration files
|
# Post-write hooks allow you to run scripts after generating migration files
|
||||||
|
|||||||
+8
-4
@@ -86,13 +86,17 @@ services:
|
|||||||
- 127.0.0.1:9090:9090
|
- 127.0.0.1:9090:9090
|
||||||
volumes:
|
volumes:
|
||||||
- mcp-data:/app/data
|
- mcp-data:/app/data
|
||||||
- ./settings.toml:/app/settings.toml:ro
|
- ./settings.toml.example:/app/settings.toml:ro
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_USERNAME=admin
|
- NEXTCLOUD_USERNAME=admin
|
||||||
- NEXTCLOUD_PASSWORD=admin
|
- NEXTCLOUD_PASSWORD=admin
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Pin the token DB inside the mcp-data volume so the runtime default
|
||||||
|
# (ephemeral tempfile) doesn't silently apply inside containers.
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
# Semantic search configuration (ADR-007, ADR-021)
|
# Semantic search configuration (ADR-007, ADR-021)
|
||||||
- ENABLE_SEMANTIC_SEARCH=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=5
|
- VECTOR_SYNC_SCAN_INTERVAL=5
|
||||||
@@ -167,7 +171,7 @@ services:
|
|||||||
# NO admin credentials - credentials come from client Authorization header
|
# NO admin credentials - credentials come from client Authorization header
|
||||||
volumes:
|
volumes:
|
||||||
- multi-user-basic-data:/app/data
|
- multi-user-basic-data:/app/data
|
||||||
- ./settings.toml:/app/settings.toml:ro
|
- ./settings.toml.example:/app/settings.toml:ro
|
||||||
profiles:
|
profiles:
|
||||||
- multi-user-basic
|
- multi-user-basic
|
||||||
|
|
||||||
@@ -243,7 +247,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- keycloak-tokens:/app/data
|
- keycloak-tokens:/app/data
|
||||||
- keycloak-oauth-storage:/app/.oauth
|
- keycloak-oauth-storage:/app/.oauth
|
||||||
- ./settings.toml:/app/settings.toml:ro
|
- ./settings.toml.example:/app/settings.toml:ro
|
||||||
profiles:
|
profiles:
|
||||||
- keycloak
|
- keycloak
|
||||||
|
|
||||||
@@ -282,7 +286,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- login-flow-data:/app/data
|
- login-flow-data:/app/data
|
||||||
- login-flow-oauth-storage:/app/.oauth
|
- login-flow-oauth-storage:/app/.oauth
|
||||||
- ./settings.toml:/app/settings.toml:ro
|
- ./settings.toml.example:/app/settings.toml:ro
|
||||||
profiles:
|
profiles:
|
||||||
- login-flow
|
- login-flow
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ def get_database_url() -> str:
|
|||||||
url = config.get_main_option("sqlalchemy.url")
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
# Default to /app/data/tokens.db for Docker deployments
|
# Fall back to the same resolver the runtime uses (ephemeral tempfile
|
||||||
db_path = Path("/app/data/tokens.db")
|
# unless TOKEN_STORAGE_DB is set). Imported lazily to avoid pulling
|
||||||
|
# the full config module into offline alembic invocations.
|
||||||
|
from nextcloud_mcp_server.config import ( # noqa: PLC0415
|
||||||
|
get_token_db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_path = Path(get_token_db_path())
|
||||||
url = f"sqlite+aiosqlite:///{db_path}"
|
url = f"sqlite+aiosqlite:///{db_path}"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No database URL configured, using default: {url}. "
|
f"No database URL configured, using default: {url}. "
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import httpx
|
|||||||
from anyio import to_thread
|
from anyio import to_thread
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_token_db_path, is_ephemeral_token_db
|
||||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||||
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||||
|
|
||||||
@@ -82,7 +83,10 @@ class RefreshTokenStorage:
|
|||||||
Create storage instance from environment variables.
|
Create storage instance from environment variables.
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
|
TOKEN_STORAGE_DB: Path to database file. If unset, a per-process
|
||||||
|
tempfile is allocated and deleted at interpreter exit —
|
||||||
|
tokens are ephemeral and wiped on restart. Set this to a
|
||||||
|
filesystem path to persist tokens across restarts.
|
||||||
TOKEN_ENCRYPTION_KEY: Optional base64-encoded Fernet key (required for token storage)
|
TOKEN_ENCRYPTION_KEY: Optional base64-encoded Fernet key (required for token storage)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -92,7 +96,13 @@ class RefreshTokenStorage:
|
|||||||
If TOKEN_ENCRYPTION_KEY is not set, token storage operations will fail,
|
If TOKEN_ENCRYPTION_KEY is not set, token storage operations will fail,
|
||||||
but webhook tracking will still work.
|
but webhook tracking will still work.
|
||||||
"""
|
"""
|
||||||
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
|
db_path = get_token_db_path()
|
||||||
|
if is_ephemeral_token_db(db_path):
|
||||||
|
logger.info(
|
||||||
|
"Using ephemeral token storage at %s "
|
||||||
|
"(set TOKEN_STORAGE_DB to persist tokens across restarts)",
|
||||||
|
db_path,
|
||||||
|
)
|
||||||
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
|
|
||||||
encryption_key = None
|
encryption_key = None
|
||||||
|
|||||||
+31
-12
@@ -6,6 +6,8 @@ import uvicorn
|
|||||||
|
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
get_settings,
|
get_settings,
|
||||||
|
get_token_db_path,
|
||||||
|
is_ephemeral_token_db,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.migrations import (
|
from nextcloud_mcp_server.migrations import (
|
||||||
create_migration,
|
create_migration,
|
||||||
@@ -285,13 +287,25 @@ def db():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_if_ephemeral(database_path: str) -> None:
|
||||||
|
if is_ephemeral_token_db(database_path):
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
f"⚠ Using ephemeral tempfile {database_path}; changes "
|
||||||
|
"will be lost on exit. Pass --database-path or set "
|
||||||
|
"TOKEN_STORAGE_DB to operate on a persistent database.",
|
||||||
|
fg="yellow",
|
||||||
|
),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@db.command()
|
@db.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--database-path",
|
"--database-path",
|
||||||
"-d",
|
"-d",
|
||||||
envvar="TOKEN_STORAGE_DB",
|
envvar="TOKEN_STORAGE_DB",
|
||||||
default="/app/data/tokens.db",
|
default=None,
|
||||||
show_default=True,
|
|
||||||
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
|
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -301,7 +315,7 @@ def db():
|
|||||||
show_default=True,
|
show_default=True,
|
||||||
help="Target revision (default: head for latest)",
|
help="Target revision (default: head for latest)",
|
||||||
)
|
)
|
||||||
def upgrade(database_path: str, revision: str):
|
def upgrade(database_path: str | None, revision: str):
|
||||||
"""Upgrade database to a specific revision.
|
"""Upgrade database to a specific revision.
|
||||||
|
|
||||||
\b
|
\b
|
||||||
@@ -315,6 +329,8 @@ def upgrade(database_path: str, revision: str):
|
|||||||
# Use custom database path
|
# Use custom database path
|
||||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
"""
|
"""
|
||||||
|
database_path = database_path or get_token_db_path()
|
||||||
|
_warn_if_ephemeral(database_path)
|
||||||
try:
|
try:
|
||||||
click.echo(f"Upgrading database to revision: {revision}")
|
click.echo(f"Upgrading database to revision: {revision}")
|
||||||
upgrade_database(database_path, revision)
|
upgrade_database(database_path, revision)
|
||||||
@@ -329,8 +345,7 @@ def upgrade(database_path: str, revision: str):
|
|||||||
"--database-path",
|
"--database-path",
|
||||||
"-d",
|
"-d",
|
||||||
envvar="TOKEN_STORAGE_DB",
|
envvar="TOKEN_STORAGE_DB",
|
||||||
default="/app/data/tokens.db",
|
default=None,
|
||||||
show_default=True,
|
|
||||||
help="Path to token storage database",
|
help="Path to token storage database",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
@@ -343,7 +358,7 @@ def upgrade(database_path: str, revision: str):
|
|||||||
@click.confirmation_option(
|
@click.confirmation_option(
|
||||||
prompt="Are you sure you want to downgrade the database? This may result in data loss."
|
prompt="Are you sure you want to downgrade the database? This may result in data loss."
|
||||||
)
|
)
|
||||||
def downgrade(database_path: str, revision: str):
|
def downgrade(database_path: str | None, revision: str):
|
||||||
"""Downgrade database to a specific revision.
|
"""Downgrade database to a specific revision.
|
||||||
|
|
||||||
WARNING: This may result in data loss! Use with caution.
|
WARNING: This may result in data loss! Use with caution.
|
||||||
@@ -359,6 +374,8 @@ def downgrade(database_path: str, revision: str):
|
|||||||
# Downgrade to base (empty database)
|
# Downgrade to base (empty database)
|
||||||
$ nextcloud-mcp-server db downgrade --revision base
|
$ nextcloud-mcp-server db downgrade --revision base
|
||||||
"""
|
"""
|
||||||
|
database_path = database_path or get_token_db_path()
|
||||||
|
_warn_if_ephemeral(database_path)
|
||||||
try:
|
try:
|
||||||
click.echo(f"Downgrading database to revision: {revision}")
|
click.echo(f"Downgrading database to revision: {revision}")
|
||||||
downgrade_database(database_path, revision)
|
downgrade_database(database_path, revision)
|
||||||
@@ -373,17 +390,18 @@ def downgrade(database_path: str, revision: str):
|
|||||||
"--database-path",
|
"--database-path",
|
||||||
"-d",
|
"-d",
|
||||||
envvar="TOKEN_STORAGE_DB",
|
envvar="TOKEN_STORAGE_DB",
|
||||||
default="/app/data/tokens.db",
|
default=None,
|
||||||
show_default=True,
|
|
||||||
help="Path to token storage database",
|
help="Path to token storage database",
|
||||||
)
|
)
|
||||||
def current(database_path: str):
|
def current(database_path: str | None):
|
||||||
"""Show current database revision.
|
"""Show current database revision.
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db current
|
$ nextcloud-mcp-server db current
|
||||||
"""
|
"""
|
||||||
|
database_path = database_path or get_token_db_path()
|
||||||
|
_warn_if_ephemeral(database_path)
|
||||||
try:
|
try:
|
||||||
revision = get_current_revision(database_path)
|
revision = get_current_revision(database_path)
|
||||||
if revision:
|
if revision:
|
||||||
@@ -406,17 +424,18 @@ def current(database_path: str):
|
|||||||
"--database-path",
|
"--database-path",
|
||||||
"-d",
|
"-d",
|
||||||
envvar="TOKEN_STORAGE_DB",
|
envvar="TOKEN_STORAGE_DB",
|
||||||
default="/app/data/tokens.db",
|
default=None,
|
||||||
show_default=True,
|
|
||||||
help="Path to token storage database",
|
help="Path to token storage database",
|
||||||
)
|
)
|
||||||
def history(database_path: str):
|
def history(database_path: str | None):
|
||||||
"""Show migration history.
|
"""Show migration history.
|
||||||
|
|
||||||
\b
|
\b
|
||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db history
|
$ nextcloud-mcp-server db history
|
||||||
"""
|
"""
|
||||||
|
database_path = database_path or get_token_db_path()
|
||||||
|
_warn_if_ephemeral(database_path)
|
||||||
try:
|
try:
|
||||||
click.echo("Migration history:")
|
click.echo("Migration history:")
|
||||||
show_migration_history(database_path)
|
show_migration_history(database_path)
|
||||||
|
|||||||
+170
-11
@@ -1,34 +1,152 @@
|
|||||||
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from dynaconf import Dynaconf, Validator
|
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".
|
# Sentinel for "key not in dynaconf at all" vs "explicitly set to None".
|
||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
|
|
||||||
# Dynaconf instance — loads settings.toml + .secrets.toml + env vars.
|
# Built-in defaults — declared in Python so env vars work without any settings
|
||||||
# Env vars always win (12-factor). See ADR-024 for architecture.
|
# file being present (e.g., `uvx` / `pip install` deployments). Mirrors the
|
||||||
|
# [default] section that used to live in settings.toml. Keys set here are
|
||||||
|
# "known" to dynaconf, which is required because we run with
|
||||||
|
# ignore_unknown_envvars=True. See ADR-024/025.
|
||||||
|
_DEFAULTS: dict[str, Any] = {
|
||||||
|
# Deployment mode (ADR-021)
|
||||||
|
"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,
|
||||||
|
"oidc_resource_server_id": None,
|
||||||
|
# Mode flags
|
||||||
|
"enable_multi_user_basic_auth": False,
|
||||||
|
"enable_login_flow": False,
|
||||||
|
"enable_semantic_search": False,
|
||||||
|
"enable_background_operations": False,
|
||||||
|
"vector_sync_enabled": False,
|
||||||
|
"enable_offline_access": False,
|
||||||
|
"enable_token_exchange": False,
|
||||||
|
# Token storage
|
||||||
|
"token_encryption_key": None,
|
||||||
|
# None = ephemeral per-process tempfile (see get_token_db_path()).
|
||||||
|
# Set TOKEN_STORAGE_DB to persist tokens across restarts.
|
||||||
|
"token_storage_db": None,
|
||||||
|
# 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
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_settings_files() -> list[str]:
|
||||||
|
"""Find optional external settings files.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. NEXTCLOUD_MCP_SETTINGS_FILE env var (absolute or relative path)
|
||||||
|
2. ./settings.toml in cwd (for docker / dev workflows)
|
||||||
|
3. .secrets.toml alongside whichever settings.toml was found
|
||||||
|
|
||||||
|
Returns an empty list if no files are present — that's fine, defaults and
|
||||||
|
env vars still apply.
|
||||||
|
"""
|
||||||
|
files: list[str] = []
|
||||||
|
explicit = os.environ.get("NEXTCLOUD_MCP_SETTINGS_FILE")
|
||||||
|
if explicit:
|
||||||
|
p = Path(explicit)
|
||||||
|
if p.exists():
|
||||||
|
files.append(str(p))
|
||||||
|
else:
|
||||||
|
cwd_settings = Path.cwd() / "settings.toml"
|
||||||
|
if cwd_settings.exists():
|
||||||
|
files.append(str(cwd_settings))
|
||||||
|
cwd_secrets = Path.cwd() / ".secrets.toml"
|
||||||
|
if cwd_secrets.exists():
|
||||||
|
files.append(str(cwd_secrets))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# Dynaconf instance — env vars always win (12-factor). Settings files are
|
||||||
|
# optional; when absent the defaults above provide the full key schema so
|
||||||
|
# env vars still override correctly. See ADR-024/025 for architecture.
|
||||||
_dynaconf = Dynaconf(
|
_dynaconf = Dynaconf(
|
||||||
settings_files=["settings.toml", ".secrets.toml"],
|
settings_files=_resolve_settings_files(),
|
||||||
environments=True,
|
environments=True,
|
||||||
envvar_prefix=False,
|
envvar_prefix=False,
|
||||||
env_switcher="MCP_DEPLOYMENT_MODE",
|
env_switcher="MCP_DEPLOYMENT_MODE",
|
||||||
ignore_unknown_envvars=True,
|
ignore_unknown_envvars=True,
|
||||||
root_path=str(_config_root),
|
|
||||||
load_dotenv=False,
|
load_dotenv=False,
|
||||||
|
**_DEFAULTS,
|
||||||
validators=[
|
validators=[
|
||||||
# Port ranges
|
# Port ranges
|
||||||
Validator("METRICS_PORT", gte=1, lte=65535),
|
Validator("METRICS_PORT", gte=1, lte=65535),
|
||||||
@@ -73,6 +191,47 @@ def _reload_config():
|
|||||||
_dynaconf.validators.validate_all()
|
_dynaconf.validators.validate_all()
|
||||||
|
|
||||||
|
|
||||||
|
_ephemeral_db_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_db_path() -> str:
|
||||||
|
"""Resolve the token SQLite database path.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. TOKEN_STORAGE_DB env var / dynaconf setting if explicitly set —
|
||||||
|
docker-compose pins /app/data/tokens.db this way.
|
||||||
|
2. Otherwise a per-process tempfile under tempfile.gettempdir(),
|
||||||
|
allocated lazily and deleted at interpreter exit via atexit.
|
||||||
|
Ephemeral: tokens are wiped on restart, matching the Qdrant
|
||||||
|
":memory:" default pattern used elsewhere in this project.
|
||||||
|
"""
|
||||||
|
explicit = os.environ.get("TOKEN_STORAGE_DB") or _dynaconf.get("TOKEN_STORAGE_DB")
|
||||||
|
if explicit:
|
||||||
|
return str(explicit)
|
||||||
|
global _ephemeral_db_path
|
||||||
|
if _ephemeral_db_path is None:
|
||||||
|
fd, path = tempfile.mkstemp(
|
||||||
|
prefix=f"nextcloud-mcp-tokens-{os.getpid()}-", suffix=".db"
|
||||||
|
)
|
||||||
|
os.close(fd)
|
||||||
|
_ephemeral_db_path = path
|
||||||
|
|
||||||
|
def _cleanup(p: str = path) -> None:
|
||||||
|
try:
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
atexit.register(_cleanup)
|
||||||
|
return _ephemeral_db_path
|
||||||
|
|
||||||
|
|
||||||
|
def is_ephemeral_token_db(path: str) -> bool:
|
||||||
|
"""Return True if the given path is the process-local ephemeral tempfile."""
|
||||||
|
return path == _ephemeral_db_path
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG = {
|
LOGGING_CONFIG = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from alembic.config import Config
|
|||||||
|
|
||||||
import nextcloud_mcp_server.alembic as alembic_package
|
import nextcloud_mcp_server.alembic as alembic_package
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from nextcloud_mcp_server.config import get_token_db_path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
|||||||
package location instead of alembic.ini file.
|
package location instead of alembic.ini file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
database_path: Path to SQLite database file. If None, uses default
|
database_path: Path to SQLite database file. If None, resolves via
|
||||||
(/app/data/tokens.db for Docker)
|
config.get_token_db_path() (ephemeral tempfile unless
|
||||||
|
TOKEN_STORAGE_DB is set).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Alembic Config object configured for the specified database
|
Alembic Config object configured for the specified database
|
||||||
@@ -45,7 +47,7 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
|||||||
if database_path:
|
if database_path:
|
||||||
db_path = Path(database_path).resolve()
|
db_path = Path(database_path).resolve()
|
||||||
else:
|
else:
|
||||||
db_path = Path("/app/data/tokens.db") # Default for Docker
|
db_path = Path(get_token_db_path()).resolve()
|
||||||
|
|
||||||
url = f"sqlite+aiosqlite:///{db_path}"
|
url = f"sqlite+aiosqlite:///{db_path}"
|
||||||
config.set_main_option("sqlalchemy.url", url)
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
@@ -100,7 +102,7 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if database_path is None:
|
if database_path is None:
|
||||||
database_path = "/app/data/tokens.db"
|
database_path = get_token_db_path()
|
||||||
|
|
||||||
db_path = Path(database_path).resolve()
|
db_path = Path(database_path).resolve()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user