update
This commit is contained in:
@@ -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"))
|
||||
|
||||
+17
-1
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-4
@@ -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"
|
||||
|
||||
+139
@@ -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()))
|
||||
Reference in New Issue
Block a user