This commit is contained in:
2026-08-16 22:47:26 -07:00
parent b3212235db
commit ca0f89a671
12 changed files with 1431 additions and 0 deletions
View File
+48
View File
@@ -0,0 +1,48 @@
"""Entrypoint: python -m bookstack_mcp"""
from __future__ import annotations
import logging
import sys
from .config import Settings
from .server import create_server
def build_app():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
settings = Settings.from_env()
mcp = create_server(settings)
kwargs = {}
if settings.allowed_hosts:
kwargs["allowed_hosts"] = settings.allowed_hosts
kwargs["host_origin_protection"] = "auto"
return mcp.http_app(path=settings.mcp_path, **kwargs), settings
def main() -> None:
import uvicorn
try:
app, settings = build_app()
except RuntimeError as exc:
print(f"Configuration error: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
logging.getLogger("bookstack-mcp").info(
"Serving BookStack MCP on http://%s:%s%s (auth=%s, read_only=%s)",
settings.host,
settings.port,
settings.mcp_path,
settings.auth_mode,
settings.read_only,
)
uvicorn.run(app, host=settings.host, port=settings.port, proxy_headers=True,
forwarded_allow_ips="*", access_log=False)
if __name__ == "__main__":
main()
+97
View File
@@ -0,0 +1,97 @@
"""Restrict access to specific users or groups from the identity provider."""
from __future__ import annotations
import logging
from typing import Any
from fastmcp.exceptions import ToolError
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.middleware import Middleware, MiddlewareContext
logger = logging.getLogger("bookstack-mcp.authz")
# Claims that different providers use for the human-readable username.
# GitHub -> login; Keycloak/Authentik/Authelia/Zitadel -> preferred_username.
USERNAME_CLAIMS = ("login", "preferred_username", "username", "email", "sub")
def _dig(claims: dict[str, Any], path: str) -> Any:
"""Look up a possibly nested claim, e.g. 'resource_access.mcp.roles'."""
value: Any = claims
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
return None
value = value[part]
return value
class IdentityAllowlistMiddleware(Middleware):
"""Reject requests from identities outside the configured allowlist.
Two independent checks, both optional:
* allowed_users -- username must appear in this list
* required_groups -- at least one group/role claim must match
Static machine tokens carry no identity claims. Those are already trusted
because the operator minted them by hand, so they pass through untouched.
"""
def __init__(
self,
allowed_users: list[str] | None = None,
required_groups: list[str] | None = None,
groups_claim: str = "groups",
) -> None:
self.allowed_users = {u.lower() for u in (allowed_users or [])}
self.required_groups = {g.lower() for g in (required_groups or [])}
self.groups_claim = groups_claim
@staticmethod
def _username(claims: dict[str, Any]) -> str | None:
for claim in USERNAME_CLAIMS:
value = claims.get(claim)
if isinstance(value, str) and value:
return value
return None
def _groups(self, claims: dict[str, Any]) -> set[str]:
raw = _dig(claims, self.groups_claim)
if isinstance(raw, str):
raw = [raw]
if not isinstance(raw, list):
return set()
return {str(g).lower() for g in raw}
def _check(self) -> None:
token = get_access_token()
if token is None:
return
claims = token.claims or {}
username = self._username(claims)
if username is None:
return # static token, no identity to check
if self.allowed_users and username.lower() not in self.allowed_users:
logger.warning("Denied BookStack MCP access to user %r (not in allowlist)", username)
raise ToolError(f"User '{username}' is not authorised to use this BookStack server.")
if self.required_groups:
groups = self._groups(claims)
if not (groups & self.required_groups):
logger.warning(
"Denied BookStack MCP access to user %r (groups %s, need one of %s)",
username, sorted(groups), sorted(self.required_groups),
)
raise ToolError(
f"User '{username}' is not in a group permitted to use this BookStack server."
)
async def on_call_tool(self, context: MiddlewareContext, call_next):
self._check()
return await call_next(context)
async def on_list_tools(self, context: MiddlewareContext, call_next):
self._check()
return await call_next(context)
+121
View File
@@ -0,0 +1,121 @@
"""Thin async wrapper around the BookStack REST API."""
from __future__ import annotations
import re
from html import unescape
from typing import Any
import httpx
from .config import Settings
class BookStackError(RuntimeError):
"""Raised when BookStack returns an error response."""
_TAG_RE = re.compile(r"<[^>]+>")
def strip_html(value: str | None) -> str:
"""Turn BookStack's preview HTML into plain text."""
if not value:
return ""
text = _TAG_RE.sub(" ", value)
return re.sub(r"\s+", " ", unescape(text)).strip()
class BookStackClient:
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = httpx.AsyncClient(
base_url=settings.api_base,
timeout=settings.request_timeout,
verify=settings.bookstack_verify_tls,
headers={
"Authorization": (
f"Token {settings.bookstack_token_id}:{settings.bookstack_token_secret}"
),
"Accept": "application/json",
"User-Agent": "bookstack-mcp/1.0",
},
follow_redirects=True,
)
async def aclose(self) -> None:
await self._client.aclose()
async def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
raw: bool = False,
) -> Any:
try:
response = await self._client.request(
method, path, params=params, json=json
)
except httpx.HTTPError as exc: # network/DNS/TLS failures
raise BookStackError(f"Could not reach BookStack at {self._settings.bookstack_url}: {exc}") from exc
if response.status_code == 401:
raise BookStackError(
"BookStack rejected the API token (401). Check BOOKSTACK_TOKEN_ID / "
"BOOKSTACK_TOKEN_SECRET and that the token's user has the "
"'Access System API' permission."
)
if response.status_code == 403:
raise BookStackError(
"BookStack denied access (403). The token's user lacks permission for "
"this content or action."
)
if response.status_code == 404:
raise BookStackError("Not found in BookStack (404).")
if response.status_code == 429:
raise BookStackError(
"BookStack rate limit hit (429). Default is 180 requests/minute per user."
)
if response.status_code >= 400:
detail = response.text[:500]
raise BookStackError(f"BookStack returned HTTP {response.status_code}: {detail}")
if raw:
return response.text
if not response.content:
return {}
return response.json()
# ---------- listing helpers ----------
async def list_endpoint(
self,
endpoint: str,
*,
count: int = 25,
offset: int = 0,
sort: str | None = None,
filters: dict[str, str] | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {"count": min(max(count, 1), 500), "offset": max(offset, 0)}
if sort:
params["sort"] = sort
for key, value in (filters or {}).items():
params[f"filter[{key}]"] = value
return await self.request("GET", endpoint, params=params)
async def search(self, query: str, *, count: int, page: int) -> dict[str, Any]:
return await self.request(
"GET",
"/search",
params={"query": query, "count": min(max(count, 1), 100), "page": max(page, 1)},
)
async def export(self, kind: str, item_id: int, fmt: str) -> str:
return await self.request("GET", f"/{kind}/{item_id}/export/{fmt}", raw=True)
def web_url(self, path: str) -> str:
return f"{self._settings.bookstack_url}/{path.lstrip('/')}"
+143
View File
@@ -0,0 +1,143 @@
"""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"
+412
View File
@@ -0,0 +1,412 @@
"""BookStack MCP server (streamable HTTP)."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated, Any, Literal
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from .client import BookStackClient, BookStackError, strip_html
from .config import Settings
logger = logging.getLogger("bookstack-mcp")
INSTRUCTIONS = """\
Tools for reading and writing a BookStack wiki.
BookStack organises content as: shelves > books > chapters > pages. Pages are the
only items that hold text.
Start with `search_content` for almost any question. It accepts BookStack search
syntax, e.g.:
firewall rules plain terms
{type:page} vpn restrict to pages
[server=prod] filter by tag name/value
"exact phrase" exact match
{updated_by:me} pages the API user last touched
`search_content` returns ids; pass a page id to `get_page` to read the full text.
"""
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return (
text[:limit]
+ f"\n\n[... truncated: {len(text) - limit} more characters. "
"Read the page in BookStack for the full content.]"
)
def _tags(raw: list[dict[str, Any]] | None) -> list[str]:
out = []
for tag in raw or []:
name, value = tag.get("name"), tag.get("value")
out.append(f"{name}={value}" if value else str(name))
return out
def _tag_payload(tags: list[str] | None) -> list[dict[str, str]] | None:
if not tags:
return None
payload = []
for tag in tags:
name, _, value = tag.partition("=")
payload.append({"name": name.strip(), "value": value.strip()})
return payload
def create_server(settings: Settings) -> FastMCP:
client = BookStackClient(settings)
@asynccontextmanager
async def lifespan(_server: FastMCP):
try:
yield
finally:
await client.aclose()
auth = _build_auth(settings)
mcp = FastMCP(
name="bookstack",
instructions=INSTRUCTIONS,
version="1.0.0",
auth=auth,
lifespan=lifespan,
)
if settings.allowed_users or settings.required_groups:
from .authz import IdentityAllowlistMiddleware
mcp.add_middleware(
IdentityAllowlistMiddleware(
allowed_users=settings.allowed_users,
required_groups=settings.required_groups,
groups_claim=settings.oidc_groups_claim,
)
)
elif settings.auth_mode not in {"none", "token"}:
logger.warning(
"OAuth is enabled but MCP_ALLOWED_USERS and MCP_REQUIRED_GROUPS are both "
"empty: anyone who can log in to your identity provider can use this server."
)
# ------------------------------------------------------------------ read
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def search_content(
query: Annotated[str, Field(description="BookStack search query. Supports filters like {type:page} and [tag=value].")],
count: Annotated[int, Field(description="Results per page (1-100).", ge=1, le=100)] = 15,
page: Annotated[int, Field(description="1-based page number.", ge=1)] = 1,
) -> dict[str, Any]:
"""Search across all BookStack content the API user can see.
This is the main entry point: use it to locate books, chapters, and pages
before reading them with get_page / get_book / get_chapter.
"""
data = await _guard(client.search(query, count=count, page=page))
results = []
for item in data.get("data", []):
preview = item.get("preview_html") or {}
results.append(
{
"type": item.get("type"),
"id": item.get("id"),
"name": strip_html(preview.get("name")) or item.get("name"),
"excerpt": strip_html(preview.get("content"))[:400],
"url": item.get("url"),
"book_id": item.get("book_id"),
"chapter_id": item.get("chapter_id"),
"tags": _tags(item.get("tags")),
"updated_at": item.get("updated_at"),
}
)
return {"total": data.get("total", len(results)), "page": page, "results": results}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_page(
page_id: Annotated[int, Field(description="Numeric page id, from search_content or get_book.")],
format: Annotated[Literal["markdown", "plaintext", "html"], Field(description="Content format to return.")] = "markdown",
) -> dict[str, Any]:
"""Read the full content of a single page."""
meta = await _guard(client.request("GET", f"/pages/{page_id}"))
try:
content = await client.export("pages", page_id, format)
except BookStackError:
content = meta.get("markdown") or meta.get("html") or ""
return {
"id": meta.get("id"),
"name": meta.get("name"),
"book_id": meta.get("book_id"),
"chapter_id": meta.get("chapter_id"),
"slug": meta.get("slug"),
"url": client.web_url(f"books/{meta.get('book_slug', '')}/page/{meta.get('slug', '')}"),
"tags": _tags(meta.get("tags")),
"updated_at": meta.get("updated_at"),
"format": format,
"content": _truncate(content, settings.max_content_chars),
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_books(
count: Annotated[int, Field(description="How many books to return.", ge=1, le=100)] = 50,
offset: Annotated[int, Field(description="Skip this many books.", ge=0)] = 0,
sort: Annotated[str, Field(description="Sort field, e.g. name, -updated_at, -created_at.")] = "name",
) -> dict[str, Any]:
"""List the books in the wiki. Useful for getting oriented."""
data = await _guard(client.list_endpoint("/books", count=count, offset=offset, sort=sort))
return {
"total": data.get("total"),
"books": [
{
"id": b.get("id"),
"name": b.get("name"),
"description": (b.get("description") or "")[:300],
"updated_at": b.get("updated_at"),
}
for b in data.get("data", [])
],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_book(
book_id: Annotated[int, Field(description="Numeric book id.")],
) -> dict[str, Any]:
"""Get a book with its full table of contents (chapters and pages)."""
data = await _guard(client.request("GET", f"/books/{book_id}"))
contents = []
for node in data.get("contents", []):
entry = {
"type": node.get("type"),
"id": node.get("id"),
"name": node.get("name"),
}
if node.get("type") == "chapter":
entry["pages"] = [
{"id": p.get("id"), "name": p.get("name")} for p in node.get("pages", [])
]
contents.append(entry)
return {
"id": data.get("id"),
"name": data.get("name"),
"description": data.get("description"),
"tags": _tags(data.get("tags")),
"updated_at": data.get("updated_at"),
"contents": contents,
}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_chapter(
chapter_id: Annotated[int, Field(description="Numeric chapter id.")],
) -> dict[str, Any]:
"""Get a chapter and the list of pages inside it."""
data = await _guard(client.request("GET", f"/chapters/{chapter_id}"))
return {
"id": data.get("id"),
"name": data.get("name"),
"book_id": data.get("book_id"),
"description": data.get("description"),
"tags": _tags(data.get("tags")),
"pages": [{"id": p.get("id"), "name": p.get("name")} for p in data.get("pages", [])],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_shelves(
count: Annotated[int, Field(description="How many shelves to return.", ge=1, le=100)] = 50,
) -> dict[str, Any]:
"""List bookshelves, the top level of the BookStack hierarchy."""
data = await _guard(client.list_endpoint("/shelves", count=count, sort="name"))
return {
"total": data.get("total"),
"shelves": [
{"id": s.get("id"), "name": s.get("name"), "description": (s.get("description") or "")[:300]}
for s in data.get("data", [])
],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_recent_pages(
count: Annotated[int, Field(description="How many pages to return.", ge=1, le=100)] = 25,
) -> dict[str, Any]:
"""List the most recently updated pages. Good for 'what changed lately'."""
data = await _guard(client.list_endpoint("/pages", count=count, sort="-updated_at"))
return {
"pages": [
{
"id": p.get("id"),
"name": p.get("name"),
"book_id": p.get("book_id"),
"chapter_id": p.get("chapter_id"),
"updated_at": p.get("updated_at"),
}
for p in data.get("data", [])
]
}
# ----------------------------------------------------------------- write
if not settings.read_only:
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_page(
name: Annotated[str, Field(description="Title of the new page.")],
markdown: Annotated[str, Field(description="Page body in Markdown.")],
book_id: Annotated[int | None, Field(description="Book to create the page in. Provide this or chapter_id.")] = None,
chapter_id: Annotated[int | None, Field(description="Chapter to create the page in. Provide this or book_id.")] = None,
tags: Annotated[list[str] | None, Field(description="Tags as 'name' or 'name=value' strings.")] = None,
) -> dict[str, Any]:
"""Create a new page in a book or chapter."""
if not book_id and not chapter_id:
raise ToolError("Provide either book_id or chapter_id.")
payload: dict[str, Any] = {"name": name, "markdown": markdown}
if chapter_id:
payload["chapter_id"] = chapter_id
else:
payload["book_id"] = book_id
if tag_payload := _tag_payload(tags):
payload["tags"] = tag_payload
data = await _guard(client.request("POST", "/pages", json=payload))
return {"id": data.get("id"), "name": data.get("name"), "slug": data.get("slug")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
async def update_page(
page_id: Annotated[int, Field(description="Page to update.")],
name: Annotated[str | None, Field(description="New title. Omit to leave unchanged.")] = None,
markdown: Annotated[str | None, Field(description="New body in Markdown. This REPLACES the whole page body.")] = None,
tags: Annotated[list[str] | None, Field(description="Replacement tag list. Omit to leave unchanged.")] = None,
) -> dict[str, Any]:
"""Update an existing page. Supplying markdown replaces the entire body."""
payload: dict[str, Any] = {}
if name is not None:
payload["name"] = name
if markdown is not None:
payload["markdown"] = markdown
if tag_payload := _tag_payload(tags):
payload["tags"] = tag_payload
if not payload:
raise ToolError("Nothing to update: provide name, markdown, or tags.")
data = await _guard(client.request("PUT", f"/pages/{page_id}", json=payload))
return {"id": data.get("id"), "name": data.get("name"), "updated_at": data.get("updated_at")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_book(
name: Annotated[str, Field(description="Title of the new book.")],
description: Annotated[str, Field(description="Short description.")] = "",
) -> dict[str, Any]:
"""Create a new book."""
data = await _guard(
client.request("POST", "/books", json={"name": name, "description": description})
)
return {"id": data.get("id"), "name": data.get("name")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_chapter(
book_id: Annotated[int, Field(description="Book that will contain the chapter.")],
name: Annotated[str, Field(description="Title of the new chapter.")],
description: Annotated[str, Field(description="Short description.")] = "",
) -> dict[str, Any]:
"""Create a new chapter inside a book."""
data = await _guard(
client.request(
"POST",
"/chapters",
json={"book_id": book_id, "name": name, "description": description},
)
)
return {"id": data.get("id"), "name": data.get("name")}
if settings.allow_delete:
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True})
async def delete_page(
page_id: Annotated[int, Field(description="Page to send to the recycle bin.")],
) -> dict[str, Any]:
"""Move a page to the BookStack recycle bin (recoverable)."""
await _guard(client.request("DELETE", f"/pages/{page_id}"))
return {"deleted": page_id, "note": "Moved to the recycle bin; recoverable in BookStack."}
return mcp
async def _guard(awaitable):
"""Convert BookStack API errors into clean MCP tool errors."""
try:
return await awaitable
except BookStackError as exc:
raise ToolError(str(exc)) from exc
def _build_auth(settings: Settings):
mode = settings.auth_mode
if mode == "none":
logger.warning(
"MCP_AUTH_MODE=none: this server is unauthenticated. Only run it this way "
"on a trusted network or behind another authenticating proxy."
)
return None
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
verifier = None
if "token" in mode:
verifier = StaticTokenVerifier(
tokens={
token: {"client_id": f"static-{i}", "scopes": ["bookstack"]}
for i, token in enumerate(settings.static_tokens)
}
)
oauth = None
if "github" in mode:
from fastmcp.server.auth.providers.github import GitHubProvider
oauth = GitHubProvider(
client_id=settings.github_client_id,
client_secret=settings.github_client_secret,
base_url=settings.public_url,
redirect_path="/auth/callback",
required_scopes=["read:user"],
)
elif "oidc" in mode:
from fastmcp.server.auth.oidc_proxy import OIDCProxy
# OIDC_SCOPES overrides this entirely when a provider needs something
# specific. Otherwise: identify the user, ask for groups only when group
# checks are configured (some providers reject unknown scopes), and
# request offline_access so the connection survives token expiry.
if settings.oidc_scopes:
scopes = settings.oidc_scopes
else:
scopes = ["openid", "profile", "email", "offline_access"]
if settings.required_groups:
scopes.insert(3, settings.oidc_groups_claim)
oauth = OIDCProxy(
config_url=settings.oidc_config_url,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
base_url=settings.public_url,
redirect_path="/auth/callback",
required_scopes=scopes,
# Some providers (Authelia, Okta) issue opaque access tokens that
# can't be validated as JWTs. Setting OIDC_VERIFY_ID_TOKEN=true
# verifies the ID token instead, which is always a signed JWT.
verify_id_token=settings.oidc_verify_id_token,
)
if oauth is None:
return verifier
if verifier is None:
return oauth
from fastmcp.server.auth import MultiAuth
return MultiAuth(server=oauth, verifiers=[verifier], base_url=settings.public_url)