Upload files to "/"

This commit is contained in:
2026-08-16 21:57:01 -07:00
parent 35841d819a
commit 4ad9a62f0f
5 changed files with 839 additions and 1 deletions
+248 -1
View File
@@ -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 <your-repo> /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 `<mcp-container-ip>: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 |
+43
View File
@@ -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
+63
View File
@@ -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
+113
View File
@@ -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 <<EOF
Next steps:
1. Edit the config and fill in your BookStack details:
nano $CONF_DIR/env
At minimum: BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, BOOKSTACK_TOKEN_SECRET.
Generate a Claude Code token with: openssl rand -hex 32
2. Start it:
systemctl start bookstack-mcp
systemctl status bookstack-mcp
journalctl -u bookstack-mcp -f
3. Verify locally (401 with a WWW-Authenticate header is the correct answer
when auth is enabled):
curl -i http://127.0.0.1:8080/mcp
EOF
else
echo " systemctl restart bookstack-mcp"
fi
+372
View File
@@ -0,0 +1,372 @@
"""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.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)