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
+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()))