148 lines
5.9 KiB
Python
148 lines
5.9 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)
|
|
oidc_forward_resource: bool = True
|
|
oidc_required_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"),
|
|
oidc_forward_resource=_bool("OIDC_FORWARD_RESOURCE", True),
|
|
oidc_required_scopes=_list("OIDC_REQUIRED_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"
|