Files
bookstack-mcp/bookstack_mcp/config.py
T
2026-08-16 22:47:26 -07:00

144 lines
5.7 KiB
Python

"""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"