This commit is contained in:
2026-08-17 11:19:05 -07:00
parent ca0f89a671
commit cf87fdba1a
6 changed files with 199 additions and 5 deletions
+4
View File
@@ -59,6 +59,8 @@ class Settings:
oidc_verify_id_token: bool = False oidc_verify_id_token: bool = False
oidc_groups_claim: str = "groups" oidc_groups_claim: str = "groups"
oidc_scopes: list[str] = field(default_factory=list) oidc_scopes: list[str] = field(default_factory=list)
oidc_forward_resource: bool = True
oidc_required_scopes: list[str] = field(default_factory=list)
# --- Who may use the server (applies to both GitHub and OIDC) --- # --- Who may use the server (applies to both GitHub and OIDC) ---
allowed_users: list[str] = field(default_factory=list) allowed_users: list[str] = field(default_factory=list)
@@ -101,6 +103,8 @@ class Settings:
oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False), oidc_verify_id_token=_bool("OIDC_VERIFY_ID_TOKEN", False),
oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), oidc_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"),
oidc_scopes=_list("OIDC_SCOPES"), oidc_scopes=_list("OIDC_SCOPES"),
oidc_forward_resource=_bool("OIDC_FORWARD_RESOURCE", True),
oidc_required_scopes=_list("OIDC_REQUIRED_SCOPES"),
# GITHUB_ALLOWED_USERS is the old name, still honoured. # GITHUB_ALLOWED_USERS is the old name, still honoured.
allowed_users=[ allowed_users=[
u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS")) u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS"))
+17 -1
View File
@@ -389,17 +389,33 @@ def _build_auth(settings: Settings):
if settings.required_groups: if settings.required_groups:
scopes.insert(3, settings.oidc_groups_claim) scopes.insert(3, settings.oidc_groups_claim)
# Two different things that are easy to conflate:
# scopes -> what we ASK the IdP for
# required_scopes -> what must be present on every incoming token
# FastMCP's issued token only carries the scopes the IdP echoes back in
# its token response. If a requested scope isn't echoed (Authelia does
# not always echo offline_access), enforcing the full request list
# rejects every call with invalid_token, and the client loops
# refreshing. So validation defaults to nothing unless explicitly set.
required = settings.oidc_required_scopes or []
oauth = OIDCProxy( oauth = OIDCProxy(
config_url=settings.oidc_config_url, config_url=settings.oidc_config_url,
extra_authorize_params={"scope": " ".join(scopes)},
client_id=settings.oidc_client_id, client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret, client_secret=settings.oidc_client_secret,
base_url=settings.public_url, base_url=settings.public_url,
redirect_path="/auth/callback", redirect_path="/auth/callback",
required_scopes=scopes, required_scopes=required,
# Some providers (Authelia, Okta) issue opaque access tokens that # Some providers (Authelia, Okta) issue opaque access tokens that
# can't be validated as JWTs. Setting OIDC_VERIFY_ID_TOKEN=true # can't be validated as JWTs. Setting OIDC_VERIFY_ID_TOKEN=true
# verifies the ID token instead, which is always a signed JWT. # verifies the ID token instead, which is always a signed JWT.
verify_id_token=settings.oidc_verify_id_token, verify_id_token=settings.oidc_verify_id_token,
# RFC 8707 resource indicators. Authelia validates the forwarded
# 'resource' against the client's audience whitelist and returns
# invalid_target if it isn't listed. Either whitelist it there or
# set OIDC_FORWARD_RESOURCE=false to stop sending it.
forward_resource=settings.oidc_forward_resource,
) )
if oauth is None: if oauth is None:
+10
View File
@@ -75,6 +75,16 @@ identity_providers:
redirect_uris: redirect_uris:
- 'https://mcp.example.com/auth/callback' - 'https://mcp.example.com/auth/callback'
# RFC 8707 resource indicators. The MCP server sends a 'resource'
# parameter identifying itself; Authelia validates it against this
# whitelist and returns 'invalid_target' if it isn't listed.
# Alternative: set OIDC_FORWARD_RESOURCE=false on the MCP server to
# stop sending it at all.
audience:
- 'https://mcp.example.com'
- 'https://mcp.example.com/mcp'
requested_audience_mode: 'implicit'
# offline_access is what gets you a refresh token. Without it the # offline_access is what gets you a refresh token. Without it the
# connector dies when the access token expires. # connector dies when the access token expires.
scopes: scopes:
+13
View File
@@ -13,6 +13,19 @@ EnvironmentFile=/etc/bookstack-mcp/env
WorkingDirectory=/opt/bookstack-mcp WorkingDirectory=/opt/bookstack-mcp
ExecStart=/opt/bookstack-mcp/venv/bin/python -m bookstack_mcp ExecStart=/opt/bookstack-mcp/venv/bin/python -m bookstack_mcp
# ProtectSystem=strict makes everything read-only, but the MCP library writes
# to a data directory under $HOME (OAuth client registrations, caches). The
# service user's home is /opt/bookstack-mcp, which is part of the read-only
# tree -- so point HOME and the XDG dirs at directories systemd creates and
# grants write access to. Without this the service dies with
# "OSError: [Errno 30] Read-only file system: '/opt/bookstack-mcp/.local'".
StateDirectory=bookstack-mcp
CacheDirectory=bookstack-mcp
Environment=HOME=/var/lib/bookstack-mcp
Environment=XDG_DATA_HOME=/var/lib/bookstack-mcp
Environment=XDG_CONFIG_HOME=/var/lib/bookstack-mcp
Environment=XDG_CACHE_HOME=/var/cache/bookstack-mcp
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
TimeoutStopSec=20s TimeoutStopSec=20s
+16 -4
View File
@@ -98,12 +98,24 @@ log "Installing Python dependencies (this takes a minute)"
chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR"
# --- Configuration -------------------------------------------------------- # --- Configuration --------------------------------------------------------
# --- Configuration --------------------------------------------------------
# Prefer a filled-in .env if one exists, otherwise fall back to the template.
ENV_SRC=""
for candidate in "$SRC_DIR/.env" "$SRC_DIR/.env.example"; do
[[ -f "$candidate" ]] && { ENV_SRC="$candidate"; break; }
done
[[ -n "$ENV_SRC" ]] || die "Can't find $SRC_DIR/.env or $SRC_DIR/.env.example to seed the config from."
mkdir -p "$CONF_DIR" mkdir -p "$CONF_DIR"
if [[ ! -f "$CONF_DIR/env" ]]; then # An empty file counts as "not configured" -- a previous failed run can leave
log "Creating $CONF_DIR/env from the template" # a zero-byte config behind, and silently keeping it is worse than replacing it.
if [[ ! -s "$CONF_DIR/env" ]]; then
log "Creating $CONF_DIR/env from $(basename "$ENV_SRC")"
# systemd EnvironmentFile does not understand quotes or inline comments the # systemd EnvironmentFile does not understand quotes or inline comments the
# way a shell does, so strip comments and blank lines out of the example. # way a shell does, so strip comments and blank lines out of the source.
grep -vE '^\s*(#|$)' "$SRC_DIR/.env.example" > "$CONF_DIR/env" # Write to a temp file first so a failure here can't leave an empty config.
grep -vE '^\s*(#|$)' "$ENV_SRC" > "$CONF_DIR/env.tmp"
mv "$CONF_DIR/env.tmp" "$CONF_DIR/env"
NEW_CONFIG=1 NEW_CONFIG=1
else else
log "Keeping existing $CONF_DIR/env" log "Keeping existing $CONF_DIR/env"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Test a RUNNING BookStack MCP server end to end.
Run it with the venv the installer created, so no extra install is needed:
/opt/bookstack-mcp/venv/bin/python test_live.py \
--url http://127.0.0.1:8080/mcp --token YOUR_STATIC_TOKEN
Add --search "some term" to also run a real query against your wiki.
"""
from __future__ import annotations
import argparse
import asyncio
import sys
GREEN, RED, YELLOW, DIM, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m"
def ok(msg: str) -> None:
print(f"{GREEN} PASS{RESET} {msg}")
def fail(msg: str, hint: str = "") -> None:
print(f"{RED} FAIL{RESET} {msg}")
if hint:
print(f"{DIM} {hint}{RESET}")
def info(msg: str) -> None:
print(f"{DIM} {msg}{RESET}")
async def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="http://127.0.0.1:8080/mcp")
parser.add_argument("--token", help="A value from MCP_STATIC_TOKENS")
parser.add_argument("--search", help="Optional: run a real search query")
args = parser.parse_args()
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
print(f"\nTesting {args.url}\n")
auth = BearerAuth(args.token) if args.token else None
failures = 0
# 1. Connect and list tools
try:
async with Client(args.url, auth=auth) as client:
tools = sorted(t.name for t in await client.list_tools())
ok(f"connected, {len(tools)} tools available")
info(", ".join(tools))
writable = [t for t in tools if t.startswith(("create_", "update_", "delete_"))]
if writable:
print(f"{YELLOW} NOTE{RESET} write tools are exposed: {', '.join(writable)}")
info("set BOOKSTACK_READ_ONLY=true if you didn't intend this")
# 2. Reach BookStack itself
try:
result = await client.call_tool("list_books", {"count": 5})
books = result.data.get("books", [])
ok(f"BookStack API reachable, {result.data.get('total', len(books))} books visible")
for b in books[:5]:
info(f"[{b['id']}] {b['name']}")
if not books:
print(f"{YELLOW} NOTE{RESET} no books visible to the token's user")
info("check that user's role permissions in BookStack")
except Exception as exc:
failures += 1
fail("could not list books", str(exc)[:200])
# 3. Optional real search
if args.search:
try:
result = await client.call_tool(
"search_content", {"query": args.search, "count": 5}
)
hits = result.data.get("results", [])
ok(f"search for {args.search!r} returned {len(hits)} result(s)")
for h in hits:
info(f"{h['type']} [{h['id']}] {h['name']}")
except Exception as exc:
failures += 1
fail("search failed", str(exc)[:200])
except Exception as exc:
text = str(exc)
failures += 1
fail("could not connect", text[:200])
if "401" in text or "Unauthorized" in text:
info("Token rejected. Check it matches a value in MCP_STATIC_TOKENS,")
info("or omit --token if MCP_AUTH_MODE=none.")
elif "Connection" in text or "refused" in text:
info("Nothing listening. Check: systemctl status bookstack-mcp")
return 1
# 4. OAuth discovery, only meaningful for public URLs
if args.url.startswith("https://"):
import httpx
base = args.url.rsplit("/", 1)[0]
path = args.url.rsplit("/", 1)[1]
for name, endpoint in [
("protected resource metadata", f"{base}/.well-known/oauth-protected-resource/{path}"),
("authorization server metadata", f"{base}/.well-known/oauth-authorization-server"),
]:
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as http:
response = await http.get(endpoint)
ctype = response.headers.get("content-type", "")
if response.status_code == 200 and "json" in ctype:
ok(f"{name} served")
elif "html" in ctype:
failures += 1
fail(f"{name} returned HTML, not JSON")
info("Your reverse proxy is serving a login page here.")
info("Add an Authelia access_control bypass rule for this host.")
else:
failures += 1
fail(f"{name} returned HTTP {response.status_code}")
info("Proxy the whole hostname, not just the /mcp path.")
except Exception as exc:
failures += 1
fail(f"{name} unreachable", str(exc)[:150])
print()
if failures:
print(f"{RED}{failures} check(s) failed.{RESET}\n")
return 1
print(f"{GREEN}All checks passed.{RESET}\n")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))