update
This commit is contained in:
+179
@@ -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 & 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())
|
||||
Reference in New Issue
Block a user