From 80a8ea097ab7ce4cafad2dcbb6b5124e31037a63 Mon Sep 17 00:00:00 2001 From: Chewbaccalakis Date: Mon, 21 Sep 2026 19:19:18 -0700 Subject: [PATCH] duplicates --- bookstack-mcp.service | 43 ---------- config.py | 143 --------------------------------- configuration.snippet.yml | 116 --------------------------- deploy/lxc/install.sh | 162 -------------------------------------- install.sh | 113 -------------------------- 5 files changed, 577 deletions(-) delete mode 100644 bookstack-mcp.service delete mode 100644 config.py delete mode 100644 configuration.snippet.yml delete mode 100755 deploy/lxc/install.sh delete mode 100644 install.sh diff --git a/bookstack-mcp.service b/bookstack-mcp.service deleted file mode 100644 index 2e44645..0000000 --- a/bookstack-mcp.service +++ /dev/null @@ -1,43 +0,0 @@ -[Unit] -Description=BookStack MCP Server -Documentation=https://github.com/your/bookstack-mcp -After=network-online.target -Wants=network-online.target - -[Service] -Type=exec -User=bookstack-mcp -Group=bookstack-mcp - -EnvironmentFile=/etc/bookstack-mcp/env -WorkingDirectory=/opt/bookstack-mcp -ExecStart=/opt/bookstack-mcp/venv/bin/python -m bookstack_mcp - -Restart=on-failure -RestartSec=5s -TimeoutStopSec=20s - -# --- Hardening --- -# These all work in an unprivileged Proxmox/LXD container. If the service fails -# with a 226/NAMESPACE or 227/SECCOMP status on an older kernel, comment out the -# block below and it will start fine -- it only reduces defence in depth. -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ProtectControlGroups=yes -RestrictSUIDSGID=yes -RemoveIPC=yes -LockPersonality=yes -MemoryDenyWriteExecute=yes -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX -CapabilityBoundingSet= -AmbientCapabilities= - -# Journald captures stdout/stderr: journalctl -u bookstack-mcp -f -StandardOutput=journal -StandardError=journal -SyslogIdentifier=bookstack-mcp - -[Install] -WantedBy=multi-user.target diff --git a/config.py b/config.py deleted file mode 100644 index b91ec2e..0000000 --- a/config.py +++ /dev/null @@ -1,143 +0,0 @@ -"""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" diff --git a/configuration.snippet.yml b/configuration.snippet.yml deleted file mode 100644 index b2a9013..0000000 --- a/configuration.snippet.yml +++ /dev/null @@ -1,116 +0,0 @@ -# Authelia configuration for the BookStack MCP server. -# -# Merge these blocks into your existing configuration.yml. Tested against -# Authelia 4.39+, where ID Token claims changed (see the claims_policies note). -# -# Generate the client id and secret first: -# -# docker run --rm authelia/authelia:latest \ -# authelia crypto rand --length 72 --charset rfc3986 -# -# docker run --rm authelia/authelia:latest \ -# authelia crypto hash generate pbkdf2 --variant sha512 \ -# --random --random.length 72 --random.charset rfc3986 -# -# The second command prints BOTH a "Random Password" and a "Digest". -# Digest -> client_secret below -# Random Password -> OIDC_CLIENT_SECRET in /etc/bookstack-mcp/env -# Do not put the digest in the MCP server's env file. - -identity_providers: - oidc: - # ----------------------------------------------------------------------- - # 1. Claims policy - # - # Authelia 4.39 stopped putting non-standard claims in the ID Token by - # default. The MCP server reads identity from the ID Token (because - # Authelia issues opaque access tokens), so groups and preferred_username - # have to be put back explicitly. Without this, MCP_REQUIRED_GROUPS will - # never match and every tool call gets denied. - # ----------------------------------------------------------------------- - claims_policies: - mcp_claims: - id_token: - - 'groups' - - 'preferred_username' - - 'email' - - 'email_verified' - - 'name' - - # ----------------------------------------------------------------------- - # 2. Lifespan - # - # Authelia's default refresh_token lifespan is 90m. A claude.ai connector - # that sits idle longer than that has to be re-authorised by hand, which - # gets old fast. A long refresh token with a short access token is the - # right shape for a background integration. - # ----------------------------------------------------------------------- - lifespans: - custom: - mcp: - access_token: '1h' - id_token: '1h' - refresh_token: '30d' - - clients: - - client_id: 'REPLACE_WITH_GENERATED_ID' - client_name: 'BookStack MCP' - client_secret: 'REPLACE_WITH_PBKDF2_DIGEST' - public: false - - # one_factor if you don't want to be prompted for 2FA on reconnect. - authorization_policy: 'two_factor' - - claims_policy: 'mcp_claims' - lifespan: 'mcp' - - # Claude re-authorises on token refresh. Explicit consent every time is - # painful; pre-configured remembers the grant for the given duration. - consent_mode: 'pre-configured' - pre_configured_consent_duration: '1 month' - - require_pkce: true - pkce_challenge_method: 'S256' - - redirect_uris: - - 'https://mcp.example.com/auth/callback' - - # offline_access is what gets you a refresh token. Without it the - # connector dies when the access token expires. - scopes: - - 'openid' - - 'profile' - - 'email' - - 'groups' - - 'offline_access' - - grant_types: - - 'authorization_code' - - 'refresh_token' - - response_types: - - 'code' - - token_endpoint_auth_method: 'client_secret_post' - - # Leaving these as 'none' keeps access tokens opaque, which is why the - # MCP server needs OIDC_VERIFY_ID_TOKEN=true. - access_token_signed_response_alg: 'none' - userinfo_signed_response_alg: 'none' - -# --------------------------------------------------------------------------- -# 3. Access control bypass <-- the one everybody misses -# -# If mcp.example.com is behind Authelia's forward-auth on your reverse proxy, -# Claude will be served an HTML login page instead of the MCP endpoint and the -# connector will fail with a parse error or a redirect loop. Claude cannot do -# cookie-based forward auth; the MCP server runs its own OAuth flow against -# Authelia instead. So the host must bypass forward-auth entirely. -# -# This is not a security hole: the MCP server refuses every unauthenticated -# request itself, and Authelia is still the thing deciding who gets a token. -# --------------------------------------------------------------------------- -access_control: - rules: - - domain: 'mcp.example.com' - policy: 'bypass' diff --git a/deploy/lxc/install.sh b/deploy/lxc/install.sh deleted file mode 100755 index 2adf1c5..0000000 --- a/deploy/lxc/install.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -# -# Install the BookStack MCP server into a Debian/Ubuntu LXC container. -# Run as root, from inside the container, in the directory containing pyproject.toml: -# -# bash deploy/lxc/install.sh -# -set -euo pipefail - -APP_DIR=/opt/bookstack-mcp -CONF_DIR=/etc/bookstack-mcp -SVC_USER=bookstack-mcp -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } -die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } - -# Find the project root. Normally this script lives at deploy/lxc/install.sh so -# the root is two levels up, but people often copy the tree around flat, so -# check the obvious candidates rather than assuming. -find_src() { - local candidate - for candidate in "$SCRIPT_DIR/../.." "$SCRIPT_DIR/.." "$SCRIPT_DIR" "$PWD"; do - if [[ -f "$candidate/pyproject.toml" && -d "$candidate/bookstack_mcp" ]]; then - (cd "$candidate" && pwd) - return 0 - fi - done - return 1 -} - -if ! SRC_DIR="$(find_src)"; then - die "Can't find the project source (needs pyproject.toml and a bookstack_mcp/ directory). - Looked in: $SCRIPT_DIR/../.. , $SCRIPT_DIR/.. , $SCRIPT_DIR , $PWD - - Get the whole project into the container, then run the script from its root: - git clone /opt/src/bookstack-mcp - cd /opt/src/bookstack-mcp - bash deploy/lxc/install.sh" -fi - -[[ $EUID -eq 0 ]] || die "Run this as root (or with sudo)." - -# The script copies SRC_DIR into APP_DIR, so they must not be the same place. -if [[ "$SRC_DIR" == "$APP_DIR" ]]; then - die "The source is $APP_DIR, which is also the install target. - Keep the source somewhere else and let the installer copy it in: - mkdir -p /opt/src && mv $APP_DIR /opt/src/bookstack-mcp - cd /opt/src/bookstack-mcp && bash deploy/lxc/install.sh" -fi - -log "Installing from $SRC_DIR" - -# --- Python version check ------------------------------------------------- -# Needs 3.11+. Debian 12/13 and Ubuntu 24.04 are fine; Debian 11 and -# Ubuntu 22.04 ship something older and need a newer python installed first. -log "Checking Python version" -if ! command -v python3 >/dev/null; then - die "python3 not installed. Try: apt install -y python3 python3-venv" -fi -PY_OK=$(python3 -c 'import sys; print(1 if sys.version_info >= (3,11) else 0)') -if [[ "$PY_OK" != "1" ]]; then - die "Python $(python3 -V | cut -d' ' -f2) found, but 3.11+ is required. - Debian 12+/Ubuntu 24.04+ work out of the box. On older releases install a - newer python3 first (e.g. via deadsnakes) and re-run." -fi - -# --- Dependencies --------------------------------------------------------- -log "Installing system packages" -export DEBIAN_FRONTEND=noninteractive -# A single broken third-party repo shouldn't abort the whole install, so the -# update is advisory; only the install itself is allowed to fail the script. -apt-get update -qq || log "apt update reported errors, continuing" -apt-get install -y -qq python3-venv python3-pip ca-certificates >/dev/null - -# --- Service user --------------------------------------------------------- -if ! id -u "$SVC_USER" >/dev/null 2>&1; then - log "Creating service user $SVC_USER" - useradd --system --home-dir "$APP_DIR" --shell /usr/sbin/nologin "$SVC_USER" -fi - -# --- Application ---------------------------------------------------------- -log "Installing application to $APP_DIR" -mkdir -p "$APP_DIR" -rm -rf "$APP_DIR/bookstack_mcp" -cp -r "$SRC_DIR/bookstack_mcp" "$APP_DIR/" -cp "$SRC_DIR/pyproject.toml" "$APP_DIR/" - -if [[ ! -d "$APP_DIR/venv" ]]; then - log "Creating virtualenv" - python3 -m venv "$APP_DIR/venv" -fi - -log "Installing Python dependencies (this takes a minute)" -"$APP_DIR/venv/bin/pip" install --quiet --upgrade pip -"$APP_DIR/venv/bin/pip" install --quiet "$APP_DIR" - -chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" - -# --- Configuration -------------------------------------------------------- -# --- Configuration -------------------------------------------------------- -# Prefer a filled-in .env if one exists, otherwise fall back to the template. -ENV_SRC="" -for candidate in "$SRC_DIR/.env" "$SRC_DIR/.env.example"; do - [[ -f "$candidate" ]] && { ENV_SRC="$candidate"; break; } -done -[[ -n "$ENV_SRC" ]] || die "Can't find $SRC_DIR/.env or $SRC_DIR/.env.example to seed the config from." - -mkdir -p "$CONF_DIR" -# An empty file counts as "not configured" -- a previous failed run can leave -# a zero-byte config behind, and silently keeping it is worse than replacing it. -if [[ ! -s "$CONF_DIR/env" ]]; then - log "Creating $CONF_DIR/env from $(basename "$ENV_SRC")" - # systemd EnvironmentFile does not understand quotes or inline comments the - # way a shell does, so strip comments and blank lines out of the source. - # Write to a temp file first so a failure here can't leave an empty config. - grep -vE '^\s*(#|$)' "$ENV_SRC" > "$CONF_DIR/env.tmp" - mv "$CONF_DIR/env.tmp" "$CONF_DIR/env" - NEW_CONFIG=1 -else - log "Keeping existing $CONF_DIR/env" - NEW_CONFIG=0 -fi -chown root:"$SVC_USER" "$CONF_DIR/env" -chmod 640 "$CONF_DIR/env" - -# --- systemd -------------------------------------------------------------- -log "Installing systemd unit" -UNIT_SRC="" -for candidate in "$SRC_DIR/deploy/lxc/bookstack-mcp.service" "$SCRIPT_DIR/bookstack-mcp.service"; do - [[ -f "$candidate" ]] && { UNIT_SRC="$candidate"; break; } -done -[[ -n "$UNIT_SRC" ]] || die "Can't find bookstack-mcp.service" -install -m 644 "$UNIT_SRC" /etc/systemd/system/bookstack-mcp.service -systemctl daemon-reload -systemctl enable bookstack-mcp >/dev/null - -echo -log "Install complete." -echo -if [[ "$NEW_CONFIG" == "1" ]]; then - cat <\033[0m %s\n' "$*"; } -die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } - -[[ $EUID -eq 0 ]] || die "Run this as root (or with sudo)." -[[ -f "$SRC_DIR/pyproject.toml" ]] || die "Can't find pyproject.toml next to this script." - -# --- Python version check ------------------------------------------------- -# Needs 3.11+. Debian 12/13 and Ubuntu 24.04 are fine; Debian 11 and -# Ubuntu 22.04 ship something older and need a newer python installed first. -log "Checking Python version" -if ! command -v python3 >/dev/null; then - die "python3 not installed. Try: apt install -y python3 python3-venv" -fi -PY_OK=$(python3 -c 'import sys; print(1 if sys.version_info >= (3,11) else 0)') -if [[ "$PY_OK" != "1" ]]; then - die "Python $(python3 -V | cut -d' ' -f2) found, but 3.11+ is required. - Debian 12+/Ubuntu 24.04+ work out of the box. On older releases install a - newer python3 first (e.g. via deadsnakes) and re-run." -fi - -# --- Dependencies --------------------------------------------------------- -log "Installing system packages" -export DEBIAN_FRONTEND=noninteractive -# A single broken third-party repo shouldn't abort the whole install, so the -# update is advisory; only the install itself is allowed to fail the script. -apt-get update -qq || log "apt update reported errors, continuing" -apt-get install -y -qq python3-venv python3-pip ca-certificates >/dev/null - -# --- Service user --------------------------------------------------------- -if ! id -u "$SVC_USER" >/dev/null 2>&1; then - log "Creating service user $SVC_USER" - useradd --system --home-dir "$APP_DIR" --shell /usr/sbin/nologin "$SVC_USER" -fi - -# --- Application ---------------------------------------------------------- -log "Installing application to $APP_DIR" -mkdir -p "$APP_DIR" -rm -rf "$APP_DIR/bookstack_mcp" -cp -r "$SRC_DIR/bookstack_mcp" "$APP_DIR/" -cp "$SRC_DIR/pyproject.toml" "$APP_DIR/" - -if [[ ! -d "$APP_DIR/venv" ]]; then - log "Creating virtualenv" - python3 -m venv "$APP_DIR/venv" -fi - -log "Installing Python dependencies (this takes a minute)" -"$APP_DIR/venv/bin/pip" install --quiet --upgrade pip -"$APP_DIR/venv/bin/pip" install --quiet "$APP_DIR" - -chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" - -# --- Configuration -------------------------------------------------------- -mkdir -p "$CONF_DIR" -if [[ ! -f "$CONF_DIR/env" ]]; then - log "Creating $CONF_DIR/env from the template" - # systemd EnvironmentFile does not understand quotes or inline comments the - # way a shell does, so strip comments and blank lines out of the example. - grep -vE '^\s*(#|$)' "$SRC_DIR/.env.example" > "$CONF_DIR/env" - NEW_CONFIG=1 -else - log "Keeping existing $CONF_DIR/env" - NEW_CONFIG=0 -fi -chown root:"$SVC_USER" "$CONF_DIR/env" -chmod 640 "$CONF_DIR/env" - -# --- systemd -------------------------------------------------------------- -log "Installing systemd unit" -install -m 644 "$SRC_DIR/deploy/lxc/bookstack-mcp.service" \ - /etc/systemd/system/bookstack-mcp.service -systemctl daemon-reload -systemctl enable bookstack-mcp >/dev/null - -echo -log "Install complete." -echo -if [[ "$NEW_CONFIG" == "1" ]]; then - cat <