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.
- 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.
- Settings → Roles → (that user's role) → enable Access System API.
- 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.
- 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
cp .env.example .env
$EDITOR .env
Fill in BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, BOOKSTACK_TOKEN_SECRET.
Generate a static token for Claude Code:
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_codeandrefresh_token - PKCE with S256
- Scopes:
openid profile email offline_access, plus your groups scope
Then in .env:
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.
Three things are easy to miss and each one produces a confusing failure:
- 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 anaccess_controlrule withpolicy: 'bypass'. - Add a claims policy. Since 4.39, Authelia leaves non-standard claims out
of the ID Token by default, so
groupswon't be there and every tool call gets denied. Putgroupsandpreferred_usernameback viaclaims_policies. - 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)
- https://github.com/settings/developers → New OAuth App
- Homepage URL:
https://mcp.example.com - Authorization callback URL:
https://mcp.example.com/auth/callback(must match exactly) - Set
MCP_AUTH_MODE=github+tokenplusGITHUB_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:
docker compose up -d --build
docker compose logs -f bookstack-mcp
Caddy handles TLS automatically. Verify:
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:
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:
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.
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.comat<mcp-container-ip>:8080. Disable response buffering — streamable HTTP holds a long-lived GET stream open. In Nginx Proxy Manager that means addingproxy_buffering off;andproxy_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'sCaddyfileinto/etc/caddy/Caddyfile(changebookstack-mcp:8080to127.0.0.1:8080), thensystemctl 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
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:
{
"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
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:
npx @modelcontextprotocol/inspector
Security notes
- The BookStack token's permissions are the real security boundary. A read-only
BookStack user plus
BOOKSTACK_READ_ONLY=truemeans 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_USERSorMCP_REQUIRED_GROUPSpopulated. 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_GROUPSgates 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=noneis 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 |