348 lines
15 KiB
Markdown
348 lines
15 KiB
Markdown
# 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
|
|
|
|
BookStack's API is authenticated **separately from web login**. API tokens work
|
|
the same whether your instance uses standard, LDAP, SAML, or OIDC authentication —
|
|
BookStack cannot accept an OIDC access token for API calls, only its own tokens.
|
|
So an OIDC-backed instance changes nothing about this step except how you get the
|
|
token into existence.
|
|
|
|
1. 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. Get a token for that user:
|
|
- **Standard auth:** log in as them → profile menu → **Edit Profile** →
|
|
**API Tokens** → **Create Token**.
|
|
- **OIDC auth:** the service user never needs to log in at all. As an admin,
|
|
go to Settings → Users → (the user) and use the **API Tokens** section at the
|
|
bottom of the edit view. An admin with both *Manage Users* and *Access System
|
|
API* can mint tokens on another user's behalf.
|
|
4. Copy the **Token ID** and **Token Secret**. The secret is shown once.
|
|
|
|
### If you use OIDC group sync
|
|
|
|
`OIDC_REMOVE_FROM_GROUPS=true` strips any BookStack role that doesn't match a
|
|
group from the ID token, on every login. That will silently revoke your service
|
|
user's API access if you assigned its role by hand. Two ways around it:
|
|
|
|
- **Never log the service user in.** Create it in the admin UI and mint its token
|
|
as an admin (step 3 above). Group sync only runs at login, so a user that never
|
|
authenticates through the IdP never gets re-synced. Simplest option.
|
|
- **Or model it in the IdP.** Create a group like `bookstack-api`, map it to a
|
|
BookStack role that has *Access System API*, and put the service user in it.
|
|
Survives logins, and access is managed where the rest of your access lives.
|
|
|
|
Either way, a 401 that appears weeks later "for no reason" is almost always this.
|
|
|
|
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 OAuth (needed for claude.ai)
|
|
|
|
claude.ai will not send custom headers to a connector — it only supports OAuth or
|
|
no auth at all. So the server needs an OAuth layer. Rather than hand-rolling an
|
|
OAuth 2.1 authorization server, it proxies an existing identity provider.
|
|
|
|
### Option A: your own OIDC provider (recommended if you have one)
|
|
|
|
If BookStack already uses OIDC — Authelia, Authentik, Keycloak, Zitadel — point
|
|
this server at the same IdP. Same login, same groups, no second identity system.
|
|
|
|
**Register a new confidential client** for the MCP server. Do not reuse the
|
|
BookStack client; the redirect URI differs and they are separate relying parties.
|
|
|
|
- Redirect URI: `https://mcp.example.com/auth/callback`
|
|
- Grant types: `authorization_code` and `refresh_token`
|
|
- PKCE with S256
|
|
- Scopes: `openid profile email offline_access`, plus your groups scope
|
|
|
|
Then in `.env`:
|
|
|
|
```ini
|
|
MCP_AUTH_MODE=oidc+token
|
|
MCP_PUBLIC_URL=https://mcp.example.com
|
|
OIDC_CONFIG_URL=https://auth.example.com/.well-known/openid-configuration
|
|
OIDC_CLIENT_ID=...
|
|
OIDC_CLIENT_SECRET=...
|
|
MCP_REQUIRED_GROUPS=wiki-users
|
|
```
|
|
|
|
Discovery URLs by provider:
|
|
|
|
| Provider | `OIDC_CONFIG_URL` |
|
|
|---|---|
|
|
| Authelia | `https://auth.example.com/.well-known/openid-configuration` |
|
|
| Authentik | `https://auth.example.com/application/o/<app-slug>/.well-known/openid-configuration` |
|
|
| Keycloak | `https://auth.example.com/realms/<realm>/.well-known/openid-configuration` |
|
|
| Zitadel | `https://auth.example.com/.well-known/openid-configuration` |
|
|
|
|
**Authelia users:** a ready-made config snippet is in
|
|
[`deploy/authelia/configuration.snippet.yml`](deploy/authelia/configuration.snippet.yml).
|
|
Three things are easy to miss and each one produces a confusing failure:
|
|
|
|
1. **Bypass forward-auth for `mcp.example.com`.** If the host sits behind
|
|
Authelia's forward-auth, Claude gets an HTML login page instead of the MCP
|
|
endpoint. Claude can't hold session cookies — the MCP server runs its own
|
|
OAuth flow instead. Add an `access_control` rule with `policy: 'bypass'`.
|
|
2. **Add a claims policy.** Since 4.39, Authelia leaves non-standard claims out
|
|
of the ID Token by default, so `groups` won't be there and every tool call
|
|
gets denied. Put `groups` and `preferred_username` back via
|
|
`claims_policies`.
|
|
3. **Set `OIDC_VERIFY_ID_TOKEN=true`.** Authelia issues opaque access tokens,
|
|
which can't be verified as JWTs. The ID Token always can.
|
|
|
|
Also worth setting a custom `lifespan` with a long `refresh_token` — Authelia's
|
|
default is 90 minutes, after which an idle claude.ai connector needs manual
|
|
re-authorisation.
|
|
|
|
For nested group claims, `OIDC_GROUPS_CLAIM` takes dot-notation, e.g. Keycloak's
|
|
`resource_access.bookstack-mcp.roles`. If your provider rejects any of the scopes
|
|
above, override the whole list with `OIDC_SCOPES`.
|
|
|
|
### Option B: GitHub (if you have no IdP)
|
|
|
|
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. Set `MCP_AUTH_MODE=github+token` plus `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.
|
|
|
|
### Who gets in
|
|
|
|
`MCP_ALLOWED_USERS` matches on `login` / `preferred_username` / `email` / `sub`,
|
|
case-insensitively. `MCP_REQUIRED_GROUPS` requires membership of at least one
|
|
listed group. Set at least one of them — leaving both empty means anyone who can
|
|
authenticate to your IdP can read your wiki through Claude. The server logs a
|
|
warning at startup if you do.
|
|
|
|
With `oidc+token` (or `github+token`), both paths work at once: claude.ai does the
|
|
OAuth dance, Claude Code sends a static bearer token that bypasses OAuth entirely.
|
|
|
|
## 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 `MCP_ALLOWED_USERS` or `MCP_REQUIRED_GROUPS` populated. Empty means anyone
|
|
who can authenticate to your IdP gets in — and with a public GitHub app, that is
|
|
everyone on GitHub.
|
|
- Everyone who connects shares one BookStack identity, so per-user BookStack
|
|
permissions do not apply. `MCP_REQUIRED_GROUPS` gates *who may connect*; it does
|
|
not scope *what they see*. If different people need different wiki visibility,
|
|
run one instance per group with its own BookStack service user.
|
|
- `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 |
|
|
| Worked for weeks, now 401 from BookStack | OIDC group sync stripped the service user's role on its last login — see the group sync note in step 1 |
|
|
| OAuth completes but every tool call is denied | Username or group claim doesn't match `MCP_ALLOWED_USERS` / `MCP_REQUIRED_GROUPS`; check `journalctl -u bookstack-mcp` for the exact value seen |
|
|
| `invalid_scope` from your IdP | The groups scope isn't defined on that client; add it, or drop `MCP_REQUIRED_GROUPS` |
|
|
| Token verification fails with an opaque token | Set `OIDC_VERIFY_ID_TOKEN=true` (Authelia, Okta) |
|
|
| Claude shows a login page or redirect loop instead of connecting | The MCP host is behind forward-auth; add an Authelia `bypass` rule for it |
|
|
| Connector drops after ~90 minutes idle | Authelia's default refresh token lifespan; set a custom `lifespan` on the client |
|
|
| Connection drops mid-stream | Reverse proxy buffering; keep `flush_interval -1` and disabled timeouts in the Caddyfile |
|
|
| 429 | BookStack's 180 req/min limit |
|