Upload files to "/"
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user