2026-08-17 11:19:05 -07:00
2026-08-17 11:19:05 -07:00
2026-08-17 11:19:05 -07:00
2026-08-16 21:57:01 -07:00
2026-08-16 22:21:19 -07:00
2026-08-16 22:21:19 -07:00
2026-08-16 22:21:19 -07:00
2026-08-16 21:57:01 -07:00
2026-08-16 22:47:26 -07:00
2026-08-16 22:47:26 -07:00
2026-08-16 22:21:19 -07:00
2026-08-16 22:21:19 -07:00
2026-08-17 11:19:05 -07:00
2026-08-16 22:47:26 -07:00

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 ProfileAPI TokensCreate 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

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.

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:

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:

  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/developersNew 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:

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.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

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 → ConnectorsAdd 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=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
S
Description
No description provided
Readme
86 KiB
Languages
Python 86.8%
Shell 13.2%