"""Configuration, loaded from environment variables.""" from __future__ import annotations import os from dataclasses import dataclass, field def _bool(name: str, default: bool = False) -> bool: raw = os.environ.get(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} def _list(name: str) -> list[str]: raw = os.environ.get(name, "") return [item.strip() for item in raw.split(",") if item.strip()] def _require(name: str) -> str: value = os.environ.get(name, "").strip() if not value: raise RuntimeError(f"Missing required environment variable: {name}") return value @dataclass(frozen=True) class Settings: # --- BookStack --- bookstack_url: str bookstack_token_id: str bookstack_token_secret: str bookstack_verify_tls: bool = True request_timeout: float = 30.0 # --- Behaviour --- read_only: bool = True allow_delete: bool = False max_content_chars: int = 40_000 # --- Transport --- host: str = "0.0.0.0" port: int = 8080 mcp_path: str = "/mcp" # --- Auth (client -> this MCP server) --- auth_mode: str = "none" # none | token | github | github+token static_tokens: list[str] = field(default_factory=list) public_url: str = "" github_client_id: str = "" github_client_secret: str = "" allowed_hosts: list[str] = field(default_factory=list) # --- OIDC (your own IdP: Authentik, Keycloak, Authelia, Zitadel, ...) --- oidc_config_url: str = "" oidc_client_id: str = "" oidc_client_secret: str = "" oidc_verify_id_token: bool = False oidc_groups_claim: str = "groups" oidc_scopes: list[str] = field(default_factory=list) # --- Who may use the server (applies to both GitHub and OIDC) --- allowed_users: list[str] = field(default_factory=list) required_groups: list[str] = field(default_factory=list) @classmethod def from_env(cls) -> "Settings": auth_mode = os.environ.get("MCP_AUTH_MODE", "none").strip().lower() valid_modes = { "none", "token", "github", "github+token", "oidc", "oidc+token", } if auth_mode not in valid_modes: raise RuntimeError( f"MCP_AUTH_MODE must be one of {'|'.join(sorted(valid_modes))}, got {auth_mode!r}" ) settings = cls( bookstack_url=_require("BOOKSTACK_URL").rstrip("/"), bookstack_token_id=_require("BOOKSTACK_TOKEN_ID"), bookstack_token_secret=_require("BOOKSTACK_TOKEN_SECRET"), bookstack_verify_tls=_bool("BOOKSTACK_VERIFY_TLS", True), request_timeout=float(os.environ.get("BOOKSTACK_TIMEOUT", "30")), read_only=_bool("BOOKSTACK_READ_ONLY", True), allow_delete=_bool("BOOKSTACK_ALLOW_DELETE", False), max_content_chars=int(os.environ.get("MCP_MAX_CONTENT_CHARS", "40000")), host=os.environ.get("MCP_HOST", "0.0.0.0"), port=int(os.environ.get("MCP_PORT", "8080")), mcp_path=os.environ.get("MCP_PATH", "/mcp"), auth_mode=auth_mode, static_tokens=_list("MCP_STATIC_TOKENS"), public_url=os.environ.get("MCP_PUBLIC_URL", "").rstrip("/"), github_client_id=os.environ.get("GITHUB_CLIENT_ID", ""), github_client_secret=os.environ.get("GITHUB_CLIENT_SECRET", ""), allowed_hosts=_list("MCP_ALLOWED_HOSTS"), oidc_config_url=os.environ.get("OIDC_CONFIG_URL", "").strip(), oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""), oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""), oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False), oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), oidc_scopes=_list("OIDC_SCOPES"), # GITHUB_ALLOWED_USERS is the old name, still honoured. allowed_users=[ u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS")) ], required_groups=[g.lower() for g in _list("MCP_REQUIRED_GROUPS")], ) settings.validate() return settings def validate(self) -> None: if "token" in self.auth_mode and not self.static_tokens: raise RuntimeError("MCP_AUTH_MODE includes 'token' but MCP_STATIC_TOKENS is empty") if "github" in self.auth_mode: if not (self.github_client_id and self.github_client_secret): raise RuntimeError( "MCP_AUTH_MODE includes 'github' but GITHUB_CLIENT_ID / " "GITHUB_CLIENT_SECRET are not set" ) if "oidc" in self.auth_mode: if not self.oidc_config_url: raise RuntimeError( "MCP_AUTH_MODE includes 'oidc' but OIDC_CONFIG_URL is not set. " "This is your provider's discovery document, e.g. " "https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration" ) if not (self.oidc_client_id and self.oidc_client_secret): raise RuntimeError( "MCP_AUTH_MODE includes 'oidc' but OIDC_CLIENT_ID / " "OIDC_CLIENT_SECRET are not set" ) if ("github" in self.auth_mode or "oidc" in self.auth_mode) and not self.public_url: raise RuntimeError( "MCP_PUBLIC_URL is required for OAuth (e.g. https://mcp.example.com)" ) if not self.mcp_path.startswith("/"): raise RuntimeError("MCP_PATH must start with '/'") @property def api_base(self) -> str: return f"{self.bookstack_url}/api"