This commit is contained in:
2026-08-16 22:21:19 -07:00
parent 4ad9a62f0f
commit b3212235db
5 changed files with 474 additions and 43 deletions
+115 -17
View File
@@ -32,11 +32,38 @@ Write tools appear only when `BOOKSTACK_READ_ONLY=false`: `create_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.
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. Log in as that user → profile menu → **Edit Profile****API Tokens****Create Token**.
4. Copy the **Token ID** and **Token Secret**.
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`).
@@ -56,25 +83,85 @@ Generate a static token for Claude Code:
openssl rand -hex 32 # paste into MCP_STATIC_TOKENS
```
## 3. Set up GitHub OAuth (needed for claude.ai)
## 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. 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.
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. 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`.
4. Set `MCP_AUTH_MODE=github+token` plus `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.
With `MCP_AUTH_MODE=github+token`, both paths work at once: claude.ai does the OAuth
dance, Claude Code sends a static bearer token.
### Who gets in
> 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.
`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
@@ -231,8 +318,13 @@ npx @modelcontextprotocol/inspector
- 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.
- 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.
@@ -245,5 +337,11 @@ npx @modelcontextprotocol/inspector
| 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 |
+143
View File
@@ -0,0 +1,143 @@
"""Configuration, loaded from environment variables."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _list(name: str) -> list[str]:
raw = os.environ.get(name, "")
return [item.strip() for item in raw.split(",") if item.strip()]
def _require(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
@dataclass(frozen=True)
class Settings:
# --- BookStack ---
bookstack_url: str
bookstack_token_id: str
bookstack_token_secret: str
bookstack_verify_tls: bool = True
request_timeout: float = 30.0
# --- Behaviour ---
read_only: bool = True
allow_delete: bool = False
max_content_chars: int = 40_000
# --- Transport ---
host: str = "0.0.0.0"
port: int = 8080
mcp_path: str = "/mcp"
# --- Auth (client -> this MCP server) ---
auth_mode: str = "none" # none | token | github | github+token
static_tokens: list[str] = field(default_factory=list)
public_url: str = ""
github_client_id: str = ""
github_client_secret: str = ""
allowed_hosts: list[str] = field(default_factory=list)
# --- OIDC (your own IdP: Authentik, Keycloak, Authelia, Zitadel, ...) ---
oidc_config_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_verify_id_token: bool = False
oidc_groups_claim: str = "groups"
oidc_scopes: list[str] = field(default_factory=list)
# --- Who may use the server (applies to both GitHub and OIDC) ---
allowed_users: list[str] = field(default_factory=list)
required_groups: list[str] = field(default_factory=list)
@classmethod
def from_env(cls) -> "Settings":
auth_mode = os.environ.get("MCP_AUTH_MODE", "none").strip().lower()
valid_modes = {
"none", "token",
"github", "github+token",
"oidc", "oidc+token",
}
if auth_mode not in valid_modes:
raise RuntimeError(
f"MCP_AUTH_MODE must be one of {'|'.join(sorted(valid_modes))}, got {auth_mode!r}"
)
settings = cls(
bookstack_url=_require("BOOKSTACK_URL").rstrip("/"),
bookstack_token_id=_require("BOOKSTACK_TOKEN_ID"),
bookstack_token_secret=_require("BOOKSTACK_TOKEN_SECRET"),
bookstack_verify_tls=_bool("BOOKSTACK_VERIFY_TLS", True),
request_timeout=float(os.environ.get("BOOKSTACK_TIMEOUT", "30")),
read_only=_bool("BOOKSTACK_READ_ONLY", True),
allow_delete=_bool("BOOKSTACK_ALLOW_DELETE", False),
max_content_chars=int(os.environ.get("MCP_MAX_CONTENT_CHARS", "40000")),
host=os.environ.get("MCP_HOST", "0.0.0.0"),
port=int(os.environ.get("MCP_PORT", "8080")),
mcp_path=os.environ.get("MCP_PATH", "/mcp"),
auth_mode=auth_mode,
static_tokens=_list("MCP_STATIC_TOKENS"),
public_url=os.environ.get("MCP_PUBLIC_URL", "").rstrip("/"),
github_client_id=os.environ.get("GITHUB_CLIENT_ID", ""),
github_client_secret=os.environ.get("GITHUB_CLIENT_SECRET", ""),
allowed_hosts=_list("MCP_ALLOWED_HOSTS"),
oidc_config_url=os.environ.get("OIDC_CONFIG_URL", "").strip(),
oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""),
oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False),
oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"),
oidc_scopes=_list("OIDC_SCOPES"),
# GITHUB_ALLOWED_USERS is the old name, still honoured.
allowed_users=[
u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS"))
],
required_groups=[g.lower() for g in _list("MCP_REQUIRED_GROUPS")],
)
settings.validate()
return settings
def validate(self) -> None:
if "token" in self.auth_mode and not self.static_tokens:
raise RuntimeError("MCP_AUTH_MODE includes 'token' but MCP_STATIC_TOKENS is empty")
if "github" in self.auth_mode:
if not (self.github_client_id and self.github_client_secret):
raise RuntimeError(
"MCP_AUTH_MODE includes 'github' but GITHUB_CLIENT_ID / "
"GITHUB_CLIENT_SECRET are not set"
)
if "oidc" in self.auth_mode:
if not self.oidc_config_url:
raise RuntimeError(
"MCP_AUTH_MODE includes 'oidc' but OIDC_CONFIG_URL is not set. "
"This is your provider's discovery document, e.g. "
"https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration"
)
if not (self.oidc_client_id and self.oidc_client_secret):
raise RuntimeError(
"MCP_AUTH_MODE includes 'oidc' but OIDC_CLIENT_ID / "
"OIDC_CLIENT_SECRET are not set"
)
if ("github" in self.auth_mode or "oidc" in self.auth_mode) and not self.public_url:
raise RuntimeError(
"MCP_PUBLIC_URL is required for OAuth (e.g. https://mcp.example.com)"
)
if not self.mcp_path.startswith("/"):
raise RuntimeError("MCP_PATH must start with '/'")
@property
def api_base(self) -> str:
return f"{self.bookstack_url}/api"
+116
View File
@@ -0,0 +1,116 @@
# Authelia configuration for the BookStack MCP server.
#
# Merge these blocks into your existing configuration.yml. Tested against
# Authelia 4.39+, where ID Token claims changed (see the claims_policies note).
#
# Generate the client id and secret first:
#
# docker run --rm authelia/authelia:latest \
# authelia crypto rand --length 72 --charset rfc3986
#
# docker run --rm authelia/authelia:latest \
# authelia crypto hash generate pbkdf2 --variant sha512 \
# --random --random.length 72 --random.charset rfc3986
#
# The second command prints BOTH a "Random Password" and a "Digest".
# Digest -> client_secret below
# Random Password -> OIDC_CLIENT_SECRET in /etc/bookstack-mcp/env
# Do not put the digest in the MCP server's env file.
identity_providers:
oidc:
# -----------------------------------------------------------------------
# 1. Claims policy
#
# Authelia 4.39 stopped putting non-standard claims in the ID Token by
# default. The MCP server reads identity from the ID Token (because
# Authelia issues opaque access tokens), so groups and preferred_username
# have to be put back explicitly. Without this, MCP_REQUIRED_GROUPS will
# never match and every tool call gets denied.
# -----------------------------------------------------------------------
claims_policies:
mcp_claims:
id_token:
- 'groups'
- 'preferred_username'
- 'email'
- 'email_verified'
- 'name'
# -----------------------------------------------------------------------
# 2. Lifespan
#
# Authelia's default refresh_token lifespan is 90m. A claude.ai connector
# that sits idle longer than that has to be re-authorised by hand, which
# gets old fast. A long refresh token with a short access token is the
# right shape for a background integration.
# -----------------------------------------------------------------------
lifespans:
custom:
mcp:
access_token: '1h'
id_token: '1h'
refresh_token: '30d'
clients:
- client_id: 'REPLACE_WITH_GENERATED_ID'
client_name: 'BookStack MCP'
client_secret: 'REPLACE_WITH_PBKDF2_DIGEST'
public: false
# one_factor if you don't want to be prompted for 2FA on reconnect.
authorization_policy: 'two_factor'
claims_policy: 'mcp_claims'
lifespan: 'mcp'
# Claude re-authorises on token refresh. Explicit consent every time is
# painful; pre-configured remembers the grant for the given duration.
consent_mode: 'pre-configured'
pre_configured_consent_duration: '1 month'
require_pkce: true
pkce_challenge_method: 'S256'
redirect_uris:
- 'https://mcp.example.com/auth/callback'
# offline_access is what gets you a refresh token. Without it the
# connector dies when the access token expires.
scopes:
- 'openid'
- 'profile'
- 'email'
- 'groups'
- 'offline_access'
grant_types:
- 'authorization_code'
- 'refresh_token'
response_types:
- 'code'
token_endpoint_auth_method: 'client_secret_post'
# Leaving these as 'none' keeps access tokens opaque, which is why the
# MCP server needs OIDC_VERIFY_ID_TOKEN=true.
access_token_signed_response_alg: 'none'
userinfo_signed_response_alg: 'none'
# ---------------------------------------------------------------------------
# 3. Access control bypass <-- the one everybody misses
#
# If mcp.example.com is behind Authelia's forward-auth on your reverse proxy,
# Claude will be served an HTML login page instead of the MCP endpoint and the
# connector will fail with a parse error or a redirect loop. Claude cannot do
# cookie-based forward auth; the MCP server runs its own OAuth flow against
# Authelia instead. So the host must bypass forward-auth entirely.
#
# This is not a security hole: the MCP server refuses every unauthenticated
# request itself, and Authelia is still the thing deciding who gets a token.
# ---------------------------------------------------------------------------
access_control:
rules:
- domain: 'mcp.example.com'
policy: 'bypass'
+43 -9
View File
@@ -41,23 +41,57 @@ MCP_PATH=/mcp
# 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
# oidc your own IdP -- Authentik, Keycloak, Authelia, Zitadel, ...
# github GitHub OAuth (use if you have no IdP)
# oidc+token OIDC for claude.ai + static token for Claude Code <-- recommended
# github+token same, with GitHub as the IdP
# ---------------------------------------------------------------------------
MCP_AUTH_MODE=github+token
MCP_AUTH_MODE=oidc+token
# Public HTTPS URL of THIS server. Required for GitHub OAuth.
# Public HTTPS URL of THIS server. Required for any OAuth mode.
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:
# --- Option A: your own OIDC provider -------------------------------------
# The discovery document URL. Examples:
# Authentik https://auth.example.com/application/o/bookstack-mcp/.well-known/openid-configuration
# Keycloak https://auth.example.com/realms/main/.well-known/openid-configuration
# Authelia https://auth.example.com/.well-known/openid-configuration
# Zitadel https://auth.example.com/.well-known/openid-configuration
OIDC_CONFIG_URL=
# Register a NEW confidential client in your IdP for this server -- do not
# reuse the BookStack client. Redirect URI must be exactly:
# https://mcp.example.com/auth/callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
# Set true if your IdP issues opaque (non-JWT) access tokens; the ID token is
# then verified instead. Authelia and Okta commonly need this. Keycloak and
# Authentik issue JWTs, so leave it false.
OIDC_VERIFY_ID_TOKEN=false
# Claim holding the user's groups. Supports dot-notation for nested claims,
# e.g. Keycloak's resource_access.bookstack-mcp.roles
OIDC_GROUPS_CLAIM=groups
# Override the requested scopes entirely, if your provider needs something
# specific. Default: openid, profile, email, offline_access (+ groups claim
# when MCP_REQUIRED_GROUPS is set).
OIDC_SCOPES=
# --- Option B: GitHub instead of your own IdP -----------------------------
# https://github.com/settings/developers
# Callback URL: 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
# --- Who may use the server (applies to whichever provider you chose) ------
# Matched against login / preferred_username / email / sub. Case-insensitive.
MCP_ALLOWED_USERS=
# And/or require membership of at least one of these groups.
# Leave BOTH empty and anyone who can log in to your IdP gets in.
MCP_REQUIRED_GROUPS=wiki-users
+48 -8
View File
@@ -81,10 +81,21 @@ def create_server(settings: Settings) -> FastMCP:
lifespan=lifespan,
)
if settings.github_allowed_users:
from .authz import GitHubAllowlistMiddleware
if settings.allowed_users or settings.required_groups:
from .authz import IdentityAllowlistMiddleware
mcp.add_middleware(GitHubAllowlistMiddleware(settings.github_allowed_users))
mcp.add_middleware(
IdentityAllowlistMiddleware(
allowed_users=settings.allowed_users,
required_groups=settings.required_groups,
groups_claim=settings.oidc_groups_claim,
)
)
elif settings.auth_mode not in {"none", "token"}:
logger.warning(
"OAuth is enabled but MCP_ALLOWED_USERS and MCP_REQUIRED_GROUPS are both "
"empty: anyone who can log in to your identity provider can use this server."
)
# ------------------------------------------------------------------ read
@@ -351,12 +362,12 @@ def _build_auth(settings: Settings):
}
)
if "github" not in mode:
return verifier
oauth = None
if "github" in mode:
from fastmcp.server.auth.providers.github import GitHubProvider
github = GitHubProvider(
oauth = GitHubProvider(
client_id=settings.github_client_id,
client_secret=settings.github_client_secret,
base_url=settings.public_url,
@@ -364,9 +375,38 @@ def _build_auth(settings: Settings):
required_scopes=["read:user"],
)
elif "oidc" in mode:
from fastmcp.server.auth.oidc_proxy import OIDCProxy
# OIDC_SCOPES overrides this entirely when a provider needs something
# specific. Otherwise: identify the user, ask for groups only when group
# checks are configured (some providers reject unknown scopes), and
# request offline_access so the connection survives token expiry.
if settings.oidc_scopes:
scopes = settings.oidc_scopes
else:
scopes = ["openid", "profile", "email", "offline_access"]
if settings.required_groups:
scopes.insert(3, settings.oidc_groups_claim)
oauth = OIDCProxy(
config_url=settings.oidc_config_url,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
base_url=settings.public_url,
redirect_path="/auth/callback",
required_scopes=scopes,
# Some providers (Authelia, Okta) issue opaque access tokens that
# can't be validated as JWTs. Setting OIDC_VERIFY_ID_TOKEN=true
# verifies the ID token instead, which is always a signed JWT.
verify_id_token=settings.oidc_verify_id_token,
)
if oauth is None:
return verifier
if verifier is None:
return github
return oauth
from fastmcp.server.auth import MultiAuth
return MultiAuth(server=github, verifiers=[verifier], base_url=settings.public_url)
return MultiAuth(server=oauth, verifiers=[verifier], base_url=settings.public_url)