update
This commit is contained in:
@@ -32,11 +32,38 @@ Write tools appear only when `BOOKSTACK_READ_ONLY=false`: `create_page`,
|
|||||||
|
|
||||||
## 1. Create the BookStack API token
|
## 1. Create the BookStack API token
|
||||||
|
|
||||||
1. In BookStack, make a dedicated user for Claude. Its roles decide exactly what the
|
BookStack's API is authenticated **separately from web login**. API tokens work
|
||||||
MCP server can see, so give it the narrowest access you're comfortable with.
|
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**.
|
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**.
|
3. Get a token for that user:
|
||||||
4. Copy the **Token ID** and **Token Secret**.
|
- **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
|
BookStack rate-limits the API to 180 requests/minute per user by default
|
||||||
(`API_REQUESTS_PER_MIN`).
|
(`API_REQUESTS_PER_MIN`).
|
||||||
@@ -56,25 +83,85 @@ Generate a static token for Claude Code:
|
|||||||
openssl rand -hex 32 # paste into MCP_STATIC_TOKENS
|
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
|
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
|
no auth at all. So the server needs an OAuth layer. Rather than hand-rolling an
|
||||||
GitHub as the identity provider and restricts access to an allowlist of usernames.
|
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**
|
1. https://github.com/settings/developers → **New OAuth App**
|
||||||
2. Homepage URL: `https://mcp.example.com`
|
2. Homepage URL: `https://mcp.example.com`
|
||||||
3. **Authorization callback URL: `https://mcp.example.com/auth/callback`** (must match exactly)
|
3. **Authorization callback URL: `https://mcp.example.com/auth/callback`** (must match exactly)
|
||||||
4. Generate a client secret.
|
4. Set `MCP_AUTH_MODE=github+token` plus `GITHUB_CLIENT_ID` / `GITHUB_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
|
### Who gets in
|
||||||
dance, Claude Code sends a static bearer token.
|
|
||||||
|
|
||||||
> Prefer a different IdP? FastMCP ships providers for Google, Auth0, Keycloak, Azure,
|
`MCP_ALLOWED_USERS` matches on `login` / `preferred_username` / `email` / `sub`,
|
||||||
> WorkOS, Clerk, Descope, Supabase, and generic OIDC. Swapping `GitHubProvider` in
|
case-insensitively. `MCP_REQUIRED_GROUPS` requires membership of at least one
|
||||||
> `bookstack_mcp/server.py` is a few lines.
|
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
|
## 4. Deploy
|
||||||
|
|
||||||
@@ -231,8 +318,13 @@ npx @modelcontextprotocol/inspector
|
|||||||
- Every Claude user shares one BookStack identity. This server does not map GitHub
|
- 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
|
users to individual BookStack accounts, so per-user BookStack permissions don't
|
||||||
apply — everyone sees whatever the token user sees.
|
apply — everyone sees whatever the token user sees.
|
||||||
- Keep `GITHUB_ALLOWED_USERS` populated. An empty allowlist means anyone with a
|
- Keep `MCP_ALLOWED_USERS` or `MCP_REQUIRED_GROUPS` populated. Empty means anyone
|
||||||
GitHub account who finds your URL can complete the login.
|
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.
|
- `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
|
- 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.
|
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 |
|
| 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.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 |
|
| 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 |
|
| Connection drops mid-stream | Reverse proxy buffering; keep `flush_interval -1` and disabled timeouts in the Caddyfile |
|
||||||
| 429 | BookStack's 180 req/min limit |
|
| 429 | BookStack's 180 req/min limit |
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
@@ -41,23 +41,57 @@ MCP_PATH=/mcp
|
|||||||
# Auth: how Claude authenticates TO this server
|
# Auth: how Claude authenticates TO this server
|
||||||
# none no auth (local testing only)
|
# none no auth (local testing only)
|
||||||
# token static bearer tokens (Claude Code / CLI only)
|
# token static bearer tokens (Claude Code / CLI only)
|
||||||
# github GitHub OAuth (works with claude.ai AND Claude Code)
|
# oidc your own IdP -- Authentik, Keycloak, Authelia, Zitadel, ...
|
||||||
# github+token both at once <-- recommended
|
# 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
|
MCP_PUBLIC_URL=https://mcp.example.com
|
||||||
|
|
||||||
# Comma-separated. Generate with: openssl rand -hex 32
|
# Comma-separated. Generate with: openssl rand -hex 32
|
||||||
MCP_STATIC_TOKENS=
|
MCP_STATIC_TOKENS=
|
||||||
|
|
||||||
# GitHub OAuth App (https://github.com/settings/developers)
|
# --- Option A: your own OIDC provider -------------------------------------
|
||||||
# Authorization callback URL must be exactly:
|
# 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
|
# 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_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
# Only these GitHub usernames may use the server. Leave empty to allow anyone
|
# --- Who may use the server (applies to whichever provider you chose) ------
|
||||||
# who can complete the GitHub login (almost never what you want).
|
# Matched against login / preferred_username / email / sub. Case-insensitive.
|
||||||
GITHUB_ALLOWED_USERS=your-github-username
|
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
|
||||||
|
|||||||
@@ -81,10 +81,21 @@ def create_server(settings: Settings) -> FastMCP:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
if settings.github_allowed_users:
|
if settings.allowed_users or settings.required_groups:
|
||||||
from .authz import GitHubAllowlistMiddleware
|
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
|
# ------------------------------------------------------------------ read
|
||||||
|
|
||||||
@@ -351,22 +362,51 @@ def _build_auth(settings: Settings):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if "github" not in mode:
|
oauth = None
|
||||||
|
|
||||||
|
if "github" in mode:
|
||||||
|
from fastmcp.server.auth.providers.github import GitHubProvider
|
||||||
|
|
||||||
|
oauth = 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"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
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:
|
if verifier is None:
|
||||||
return github
|
return oauth
|
||||||
|
|
||||||
from fastmcp.server.auth import MultiAuth
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user