This commit is contained in:
2026-08-16 22:47:26 -07:00
parent b3212235db
commit ca0f89a671
12 changed files with 1431 additions and 0 deletions
+179
View File
@@ -0,0 +1,179 @@
"""Smoke test: run the MCP server against a fake BookStack API."""
import asyncio
import os
import threading
import time
import uvicorn
from starlette.applications import Starlette
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Route
# ---------------- fake BookStack ----------------
EXPECTED_AUTH = "Token tok-id:tok-secret"
def check(request):
assert request.headers.get("authorization") == EXPECTED_AUTH, "bad auth header"
async def search(request):
check(request)
q = request.query_params.get("query")
return JSONResponse({
"data": [{
"id": 42, "name": "Firewall Rules", "type": "page", "book_id": 3,
"chapter_id": 0, "url": "https://wiki.test/books/net/page/firewall-rules",
"preview_html": {"name": "<strong>Firewall</strong> Rules",
"content": "Inbound <em>443</em> allowed &amp; logged"},
"tags": [{"name": "env", "value": "prod"}],
"updated_at": "2026-08-01T10:00:00Z",
}],
"total": 1, "query": q,
})
async def page_detail(request):
check(request)
return JSONResponse({
"id": 42, "name": "Firewall Rules", "book_id": 3, "chapter_id": 0,
"slug": "firewall-rules", "book_slug": "net",
"tags": [{"name": "env", "value": "prod"}],
"updated_at": "2026-08-01T10:00:00Z", "html": "<p>hi</p>",
})
async def page_export(request):
check(request)
return PlainTextResponse("# Firewall Rules\n\nInbound 443 allowed.\n")
async def books(request):
check(request)
return JSONResponse({"data": [{"id": 3, "name": "Networking",
"description": "net docs",
"updated_at": "2026-08-01T10:00:00Z"}], "total": 1})
async def book_detail(request):
check(request)
return JSONResponse({
"id": 3, "name": "Networking", "description": "net docs", "tags": [],
"updated_at": "2026-08-01T10:00:00Z",
"contents": [
{"type": "chapter", "id": 7, "name": "Edge",
"pages": [{"id": 42, "name": "Firewall Rules"}]},
{"type": "page", "id": 43, "name": "Loose Page"},
],
})
async def create_page(request):
check(request)
body = await request.json()
assert body["markdown"], "missing markdown"
return JSONResponse({"id": 99, "name": body["name"], "slug": "new-page"})
async def forbidden(request):
return JSONResponse({"error": "nope"}, status_code=403)
fake = Starlette(routes=[
Route("/api/search", search),
Route("/api/pages/42", page_detail),
Route("/api/pages/42/export/markdown", page_export),
Route("/api/pages", books, methods=["GET"]),
Route("/api/pages", create_page, methods=["POST"]),
Route("/api/books", books, methods=["GET"]),
Route("/api/books/3", book_detail),
Route("/api/shelves", forbidden),
])
def serve(app, port):
uvicorn.run(app, host="127.0.0.1", port=port, log_level="error")
async def main():
threading.Thread(target=serve, args=(fake, 9911), daemon=True).start()
os.environ.update({
"BOOKSTACK_URL": "http://127.0.0.1:9911",
"BOOKSTACK_TOKEN_ID": "tok-id",
"BOOKSTACK_TOKEN_SECRET": "tok-secret",
"BOOKSTACK_READ_ONLY": "false",
"MCP_AUTH_MODE": "token",
"MCP_STATIC_TOKENS": "secret-abc,secret-def",
"MCP_PORT": "9912",
})
from bookstack_mcp.__main__ import build_app
app, settings = build_app()
threading.Thread(target=serve, args=(app, 9912), daemon=True).start()
time.sleep(2.5)
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
url = "http://127.0.0.1:9912/mcp"
# 1. unauthenticated must fail
try:
async with Client(url) as c:
await c.list_tools()
print("FAIL: unauthenticated access was allowed")
except Exception as exc:
print(f"PASS auth required -> {type(exc).__name__}")
# 2. authenticated
async with Client(url, auth=BearerAuth("secret-abc")) as c:
tools = sorted(t.name for t in await c.list_tools())
print("PASS tools:", tools)
r = await c.call_tool("search_content", {"query": "{type:page} firewall"})
hit = r.data["results"][0]
assert hit["name"] == "Firewall Rules", hit
assert hit["excerpt"] == "Inbound 443 allowed & logged", hit["excerpt"]
assert hit["tags"] == ["env=prod"], hit["tags"]
print("PASS search_content ->", hit["name"], "|", hit["excerpt"], "|", hit["tags"])
r = await c.call_tool("get_page", {"page_id": 42})
assert r.data["content"].startswith("# Firewall Rules"), r.data
print("PASS get_page ->", repr(r.data["content"][:30]), "url:", r.data["url"])
r = await c.call_tool("get_book", {"book_id": 3})
assert r.data["contents"][0]["pages"][0]["id"] == 42
print("PASS get_book ->", r.data["contents"])
r = await c.call_tool("create_page", {
"name": "New", "markdown": "# hi", "book_id": 3, "tags": ["env=dev", "draft"]})
assert r.data["id"] == 99
print("PASS create_page ->", r.data)
# error surfacing: shelves endpoint returns 403
try:
await c.call_tool("list_shelves", {})
print("FAIL: 403 was not surfaced")
except Exception as exc:
print("PASS 403 surfaced ->", str(exc)[:80])
# validation
try:
await c.call_tool("create_page", {"name": "x", "markdown": "y"})
print("FAIL: missing parent not caught")
except Exception as exc:
print("PASS missing parent ->", str(exc)[:60])
# 3. wrong token rejected
try:
async with Client(url, auth=BearerAuth("wrong")) as c:
await c.list_tools()
print("FAIL: bad token accepted")
except Exception as exc:
print(f"PASS bad token rejected -> {type(exc).__name__}")
asyncio.run(main())