diff --git a/bookstack_mcp/config.py b/bookstack_mcp/config.py index b91ec2e..e673fe3 100644 --- a/bookstack_mcp/config.py +++ b/bookstack_mcp/config.py @@ -59,6 +59,8 @@ class Settings: oidc_verify_id_token: bool = False oidc_groups_claim: str = "groups" 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) --- 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_groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), 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. allowed_users=[ u.lower() for u in (_list("MCP_ALLOWED_USERS") or _list("GITHUB_ALLOWED_USERS")) diff --git a/bookstack_mcp/server.py b/bookstack_mcp/server.py index 8e45ef0..ea0ab4b 100644 --- a/bookstack_mcp/server.py +++ b/bookstack_mcp/server.py @@ -389,17 +389,33 @@ def _build_auth(settings: Settings): if settings.required_groups: 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( config_url=settings.oidc_config_url, + extra_authorize_params={"scope": " ".join(scopes)}, client_id=settings.oidc_client_id, client_secret=settings.oidc_client_secret, base_url=settings.public_url, redirect_path="/auth/callback", - required_scopes=scopes, + required_scopes=required, # 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, + # 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: diff --git a/deploy/authelia/configuration.snippet.yml b/deploy/authelia/configuration.snippet.yml index 18db792..a4f52cd 100644 --- a/deploy/authelia/configuration.snippet.yml +++ b/deploy/authelia/configuration.snippet.yml @@ -75,6 +75,16 @@ identity_providers: redirect_uris: - '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 # connector dies when the access token expires. scopes: diff --git a/deploy/lxc/bookstack-mcp.service b/deploy/lxc/bookstack-mcp.service index 2e44645..58b4376 100644 --- a/deploy/lxc/bookstack-mcp.service +++ b/deploy/lxc/bookstack-mcp.service @@ -13,6 +13,19 @@ EnvironmentFile=/etc/bookstack-mcp/env WorkingDirectory=/opt/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 RestartSec=5s TimeoutStopSec=20s diff --git a/deploy/lxc/install.sh b/deploy/lxc/install.sh index bb0103e..2adf1c5 100755 --- a/deploy/lxc/install.sh +++ b/deploy/lxc/install.sh @@ -98,12 +98,24 @@ log "Installing Python dependencies (this takes a minute)" chown -R "$SVC_USER:$SVC_USER" "$APP_DIR" # --- 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" -if [[ ! -f "$CONF_DIR/env" ]]; then - log "Creating $CONF_DIR/env from the template" +# An empty file counts as "not configured" -- a previous failed run can leave +# 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 - # way a shell does, so strip comments and blank lines out of the example. - grep -vE '^\s*(#|$)' "$SRC_DIR/.env.example" > "$CONF_DIR/env" + # way a shell does, so strip comments and blank lines out of the source. + # 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 else log "Keeping existing $CONF_DIR/env" diff --git a/test_live.py b/test_live.py new file mode 100644 index 0000000..599a24a --- /dev/null +++ b/test_live.py @@ -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()))