diff --git a/README.md b/README.md index f5bf45d..6267b0d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,249 @@ -# bookstack-mcp +# BookStack MCP Server +A self-hosted MCP server that gives Claude read (and optionally write) access to your +BookStack wiki. Speaks streamable HTTP, so the same deployment works with both +**Claude Code** and **claude.ai**. + +``` +Claude Code ──┐ + ├──► https://mcp.example.com/mcp ──► BookStack REST API +claude.ai ──┘ (this server) +``` + +## Tools + +Read-only (always available): + +| Tool | What it does | +|---|---| +| `search_content` | Full BookStack search, including `{type:page}` and `[tag=value]` syntax | +| `get_page` | Full page body as markdown, plaintext, or HTML | +| `get_book` | Book metadata plus its full table of contents | +| `get_chapter` | Chapter metadata plus its page list | +| `list_books` | Browse books | +| `list_shelves` | Browse shelves | +| `list_recent_pages` | Most recently updated pages | + +Write tools appear only when `BOOKSTACK_READ_ONLY=false`: `create_page`, +`update_page`, `create_book`, `create_chapter`, and — behind a second flag — +`delete_page`. + +--- + +## 1. Create the BookStack API token + +1. In BookStack, make a dedicated user for Claude. Its roles decide exactly what the + MCP server can see, so give it the narrowest access you're comfortable with. +2. Settings → Roles → (that user's role) → enable **Access System API**. +3. Log in as that user → profile menu → **Edit Profile** → **API Tokens** → **Create Token**. +4. Copy the **Token ID** and **Token Secret**. + +BookStack rate-limits the API to 180 requests/minute per user by default +(`API_REQUESTS_PER_MIN`). + +## 2. Configure + +```bash +cp .env.example .env +$EDITOR .env +``` + +Fill in `BOOKSTACK_URL`, `BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`. + +Generate a static token for Claude Code: + +```bash +openssl rand -hex 32 # paste into MCP_STATIC_TOKENS +``` + +## 3. Set up GitHub OAuth (needed for claude.ai) + +claude.ai will not send custom headers to a connector — it only supports OAuth or +no auth at all. Rather than hand-rolling an OAuth 2.1 server, this project uses +GitHub as the identity provider and restricts access to an allowlist of usernames. + +1. https://github.com/settings/developers → **New OAuth App** +2. Homepage URL: `https://mcp.example.com` +3. **Authorization callback URL: `https://mcp.example.com/auth/callback`** (must match exactly) +4. Generate a client secret. +5. Put the client ID and secret in `.env`, set `MCP_PUBLIC_URL=https://mcp.example.com`, + and list your GitHub username in `GITHUB_ALLOWED_USERS`. + +With `MCP_AUTH_MODE=github+token`, both paths work at once: claude.ai does the OAuth +dance, Claude Code sends a static bearer token. + +> Prefer a different IdP? FastMCP ships providers for Google, Auth0, Keycloak, Azure, +> WorkOS, Clerk, Descope, Supabase, and generic OIDC. Swapping `GitHubProvider` in +> `bookstack_mcp/server.py` is a few lines. + +## 4. Deploy + +Point a DNS A record for `mcp.example.com` at your host, edit the domain in +`Caddyfile`, then: + +```bash +docker compose up -d --build +docker compose logs -f bookstack-mcp +``` + +Caddy handles TLS automatically. Verify: + +```bash +curl -i https://mcp.example.com/mcp # expect 401 with a WWW-Authenticate header +curl -s https://mcp.example.com/.well-known/oauth-protected-resource/mcp | jq +``` + +**Give this server its own subdomain.** Claude discovers OAuth metadata at +`/.well-known/oauth-authorization-server`, which lives at the domain root — not +under `/mcp`. Sharing a domain with BookStack means those paths collide. + +The server must be reachable from the public internet; Claude connects from +Anthropic's infrastructure, not from your machine. If your BookStack sits on a +private network, that's fine — only this MCP server needs to be exposed, and it can +reach BookStack over the internal network. + +### Alternative: LXC container (Proxmox / LXD) + +If you already run BookStack in an LXC, this fits the same pattern. It's a single +Python process, so skip Docker entirely and run it as a systemd service — no +nesting, no `keyctl`, works in an **unprivileged** container. + +Create a container (1 core, 512 MB RAM, 4 GB disk is plenty) on **Debian 12+ or +Ubuntu 24.04+** — the server needs Python 3.11 or newer, and Debian 11 / Ubuntu +22.04 ship something older. On Proxmox: + +```bash +pct create 210 local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst \ + --hostname bookstack-mcp --cores 1 --memory 512 --rootfs local-lvm:4 \ + --net0 name=eth0,bridge=vmbr0,ip=dhcp --unprivileged 1 --features nesting=0 +pct start 210 && pct enter 210 +``` + +Then inside the container: + +```bash +apt update && apt install -y git +git clone /tmp/bookstack-mcp && cd /tmp/bookstack-mcp +bash deploy/lxc/install.sh +``` + +The installer creates a `bookstack-mcp` system user, builds a venv at +`/opt/bookstack-mcp/venv`, writes config to `/etc/bookstack-mcp/env` (mode 640, +readable only by root and the service user), and installs a hardened systemd unit. + +```bash +nano /etc/bookstack-mcp/env # fill in BookStack URL + token +systemctl start bookstack-mcp +systemctl status bookstack-mcp +journalctl -u bookstack-mcp -f + +curl -i http://127.0.0.1:8080/mcp # 401 + WWW-Authenticate = working correctly +``` + +To upgrade later, re-run `install.sh` from the updated source; it preserves your +existing `/etc/bookstack-mcp/env`. + +**Networking.** BookStack can stay entirely private. Point `BOOKSTACK_URL` at its +internal address (`http://10.0.0.42` or `http://bookstack.lan`) — only the MCP +container needs to be reachable from the internet, and only on `/mcp` plus the +`/.well-known/*` paths. + +For TLS you have two options: + +- **Existing reverse proxy** (NPM, Caddy, or Traefik in another container): point + `mcp.example.com` at `:8080`. Disable response buffering — + streamable HTTP holds a long-lived GET stream open. In Nginx Proxy Manager that + means adding `proxy_buffering off;` and `proxy_read_timeout 3600s;` to the + custom config for that host. +- **Caddy in the same container**: `apt install caddy`, drop the site block from + this repo's `Caddyfile` into `/etc/caddy/Caddyfile` (change `bookstack-mcp:8080` + to `127.0.0.1:8080`), then `systemctl reload caddy`. + +If you use a local reverse proxy, set `MCP_HOST=127.0.0.1` so the app isn't +directly reachable on the LAN. + +**If the service won't start** with status `226/NAMESPACE` or `227/SECCOMP`, your +kernel is older than the systemd hardening options expect. Comment out the +hardening block in `/etc/systemd/system/bookstack-mcp.service`, then +`systemctl daemon-reload && systemctl restart bookstack-mcp`. + +### Alternative: Cloudflare Tunnel + +If you'd rather not open ports, drop the `caddy` service and run +`cloudflared tunnel --url http://bookstack-mcp:8080` instead. Everything else is +unchanged. + +## 5. Connect Claude Code + +```bash +claude mcp add --transport http bookstack https://mcp.example.com/mcp \ + --header "Authorization: Bearer YOUR_STATIC_TOKEN" \ + --scope user +``` + +Or use OAuth instead — omit `--header`, then run `/mcp` inside Claude Code and +complete the browser login. + +Check it: `claude mcp list`, then `/mcp` in a session. + +To share with a team via a committable `.mcp.json` without leaking the token: + +```json +{ + "mcpServers": { + "bookstack": { + "type": "http", + "url": "https://mcp.example.com/mcp", + "headers": { "Authorization": "Bearer ${BOOKSTACK_MCP_TOKEN}" } + } + } +} +``` + +## 6. Connect claude.ai + +Settings → **Connectors** → **Add custom connector** → URL `https://mcp.example.com/mcp`. +Leave the Advanced Settings client ID/secret blank — this server supports Dynamic +Client Registration, so Claude registers itself. Click Connect and approve the +GitHub login. + +Custom connectors need a Pro, Max, Team, or Enterprise plan. On Team and Enterprise, +an Owner adds the connector via Organization Settings before members can enable it. + +## Testing locally + +```bash +python -m venv .venv && .venv/bin/pip install -e . starlette +.venv/bin/python test_smoke.py # runs against a fake BookStack, no real instance needed +``` + +To inspect the live server's tools: + +```bash +npx @modelcontextprotocol/inspector +``` + +## Security notes + +- The BookStack token's permissions are the real security boundary. A read-only + BookStack user plus `BOOKSTACK_READ_ONLY=true` means a prompt injection in a wiki + page can't cause damage. +- Every Claude user shares one BookStack identity. This server does not map GitHub + users to individual BookStack accounts, so per-user BookStack permissions don't + apply — everyone sees whatever the token user sees. +- Keep `GITHUB_ALLOWED_USERS` populated. An empty allowlist means anyone with a + GitHub account who finds your URL can complete the login. +- `MCP_AUTH_MODE=none` is for local testing only. Never expose it publicly. +- Wiki content is untrusted input. A page containing instructions aimed at an LLM is + a real prompt-injection vector — another reason to start read-only. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| 401 from BookStack | Token wrong, or its user lacks **Access System API** | +| 403 from BookStack | Token user's role can't see that content | +| claude.ai says "Disconnected" right after Connect | Callback URL mismatch, or `/.well-known/*` not proxied to this server | +| Claude Code reports a hard failure | An invalid static `Authorization` header does *not* fall back to OAuth — remove the header to let OAuth take over | +| Connection drops mid-stream | Reverse proxy buffering; keep `flush_interval -1` and disabled timeouts in the Caddyfile | +| 429 | BookStack's 180 req/min limit | diff --git a/bookstack-mcp.service b/bookstack-mcp.service new file mode 100644 index 0000000..2e44645 --- /dev/null +++ b/bookstack-mcp.service @@ -0,0 +1,43 @@ +[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/example.env b/example.env new file mode 100644 index 0000000..8974c4d --- /dev/null +++ b/example.env @@ -0,0 +1,63 @@ +# --------------------------------------------------------------------------- +# BookStack connection +# --------------------------------------------------------------------------- +# Root URL of your BookStack instance, no trailing slash and no /api suffix. +BOOKSTACK_URL=https://wiki.example.com + +# From BookStack: profile menu > Edit Profile > API Tokens > Create Token. +# The token user's roles decide exactly what this MCP server can see and do. +# Create a dedicated user with only the access you want to expose. +BOOKSTACK_TOKEN_ID= +BOOKSTACK_TOKEN_SECRET= + +# Set to false only if BookStack uses a self-signed certificate. +BOOKSTACK_VERIFY_TLS=true +BOOKSTACK_TIMEOUT=30 + +# --------------------------------------------------------------------------- +# Behaviour +# --------------------------------------------------------------------------- +# true = search/read tools only (recommended to start) +# false = also expose create_page, update_page, create_book, create_chapter +BOOKSTACK_READ_ONLY=true + +# Only relevant when BOOKSTACK_READ_ONLY=false. Adds delete_page (recycle bin). +BOOKSTACK_ALLOW_DELETE=false + +# Page bodies longer than this are truncated before being sent to Claude. +MCP_MAX_CONTENT_CHARS=40000 + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- +MCP_HOST=0.0.0.0 +MCP_PORT=8080 +MCP_PATH=/mcp + +# Optional DNS-rebinding protection. Set to your public hostname(s). +# MCP_ALLOWED_HOSTS=mcp.example.com + +# --------------------------------------------------------------------------- +# Auth: how Claude authenticates TO this server +# none no auth (local testing only) +# token static bearer tokens (Claude Code / CLI only) +# github GitHub OAuth (works with claude.ai AND Claude Code) +# github+token both at once <-- recommended +# --------------------------------------------------------------------------- +MCP_AUTH_MODE=github+token + +# Public HTTPS URL of THIS server. Required for GitHub OAuth. +MCP_PUBLIC_URL=https://mcp.example.com + +# Comma-separated. Generate with: openssl rand -hex 32 +MCP_STATIC_TOKENS= + +# GitHub OAuth App (https://github.com/settings/developers) +# Authorization callback URL must be exactly: +# https://mcp.example.com/auth/callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Only these GitHub usernames may use the server. Leave empty to allow anyone +# who can complete the GitHub login (almost never what you want). +GITHUB_ALLOWED_USERS=your-github-username diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..4bc15c6 --- /dev/null +++ b/install.sh @@ -0,0 +1,113 @@ +#!/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 +SRC_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; } + +[[ $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 < 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.github_allowed_users: + from .authz import GitHubAllowlistMiddleware + + mcp.add_middleware(GitHubAllowlistMiddleware(settings.github_allowed_users)) + + # ------------------------------------------------------------------ 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) + } + ) + + if "github" not in mode: + return verifier + + from fastmcp.server.auth.providers.github import GitHubProvider + + github = 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"], + ) + + if verifier is None: + return github + + from fastmcp.server.auth import MultiAuth + + return MultiAuth(server=github, verifiers=[verifier], base_url=settings.public_url)