Files
bookstack-mcp/bookstack_mcp/server.py
T
2026-08-16 22:47:26 -07:00

413 lines
17 KiB
Python

"""BookStack MCP server (streamable HTTP)."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated, Any, Literal
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
from pydantic import Field
from .client import BookStackClient, BookStackError, strip_html
from .config import Settings
logger = logging.getLogger("bookstack-mcp")
INSTRUCTIONS = """\
Tools for reading and writing a BookStack wiki.
BookStack organises content as: shelves > books > chapters > pages. Pages are the
only items that hold text.
Start with `search_content` for almost any question. It accepts BookStack search
syntax, e.g.:
firewall rules plain terms
{type:page} vpn restrict to pages
[server=prod] filter by tag name/value
"exact phrase" exact match
{updated_by:me} pages the API user last touched
`search_content` returns ids; pass a page id to `get_page` to read the full text.
"""
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return (
text[:limit]
+ f"\n\n[... truncated: {len(text) - limit} more characters. "
"Read the page in BookStack for the full content.]"
)
def _tags(raw: list[dict[str, Any]] | None) -> list[str]:
out = []
for tag in raw or []:
name, value = tag.get("name"), tag.get("value")
out.append(f"{name}={value}" if value else str(name))
return out
def _tag_payload(tags: list[str] | None) -> list[dict[str, str]] | None:
if not tags:
return None
payload = []
for tag in tags:
name, _, value = tag.partition("=")
payload.append({"name": name.strip(), "value": value.strip()})
return payload
def create_server(settings: Settings) -> FastMCP:
client = BookStackClient(settings)
@asynccontextmanager
async def lifespan(_server: FastMCP):
try:
yield
finally:
await client.aclose()
auth = _build_auth(settings)
mcp = FastMCP(
name="bookstack",
instructions=INSTRUCTIONS,
version="1.0.0",
auth=auth,
lifespan=lifespan,
)
if settings.allowed_users or settings.required_groups:
from .authz import IdentityAllowlistMiddleware
mcp.add_middleware(
IdentityAllowlistMiddleware(
allowed_users=settings.allowed_users,
required_groups=settings.required_groups,
groups_claim=settings.oidc_groups_claim,
)
)
elif settings.auth_mode not in {"none", "token"}:
logger.warning(
"OAuth is enabled but MCP_ALLOWED_USERS and MCP_REQUIRED_GROUPS are both "
"empty: anyone who can log in to your identity provider can use this server."
)
# ------------------------------------------------------------------ read
@mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True})
async def search_content(
query: Annotated[str, Field(description="BookStack search query. Supports filters like {type:page} and [tag=value].")],
count: Annotated[int, Field(description="Results per page (1-100).", ge=1, le=100)] = 15,
page: Annotated[int, Field(description="1-based page number.", ge=1)] = 1,
) -> dict[str, Any]:
"""Search across all BookStack content the API user can see.
This is the main entry point: use it to locate books, chapters, and pages
before reading them with get_page / get_book / get_chapter.
"""
data = await _guard(client.search(query, count=count, page=page))
results = []
for item in data.get("data", []):
preview = item.get("preview_html") or {}
results.append(
{
"type": item.get("type"),
"id": item.get("id"),
"name": strip_html(preview.get("name")) or item.get("name"),
"excerpt": strip_html(preview.get("content"))[:400],
"url": item.get("url"),
"book_id": item.get("book_id"),
"chapter_id": item.get("chapter_id"),
"tags": _tags(item.get("tags")),
"updated_at": item.get("updated_at"),
}
)
return {"total": data.get("total", len(results)), "page": page, "results": results}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_page(
page_id: Annotated[int, Field(description="Numeric page id, from search_content or get_book.")],
format: Annotated[Literal["markdown", "plaintext", "html"], Field(description="Content format to return.")] = "markdown",
) -> dict[str, Any]:
"""Read the full content of a single page."""
meta = await _guard(client.request("GET", f"/pages/{page_id}"))
try:
content = await client.export("pages", page_id, format)
except BookStackError:
content = meta.get("markdown") or meta.get("html") or ""
return {
"id": meta.get("id"),
"name": meta.get("name"),
"book_id": meta.get("book_id"),
"chapter_id": meta.get("chapter_id"),
"slug": meta.get("slug"),
"url": client.web_url(f"books/{meta.get('book_slug', '')}/page/{meta.get('slug', '')}"),
"tags": _tags(meta.get("tags")),
"updated_at": meta.get("updated_at"),
"format": format,
"content": _truncate(content, settings.max_content_chars),
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_books(
count: Annotated[int, Field(description="How many books to return.", ge=1, le=100)] = 50,
offset: Annotated[int, Field(description="Skip this many books.", ge=0)] = 0,
sort: Annotated[str, Field(description="Sort field, e.g. name, -updated_at, -created_at.")] = "name",
) -> dict[str, Any]:
"""List the books in the wiki. Useful for getting oriented."""
data = await _guard(client.list_endpoint("/books", count=count, offset=offset, sort=sort))
return {
"total": data.get("total"),
"books": [
{
"id": b.get("id"),
"name": b.get("name"),
"description": (b.get("description") or "")[:300],
"updated_at": b.get("updated_at"),
}
for b in data.get("data", [])
],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_book(
book_id: Annotated[int, Field(description="Numeric book id.")],
) -> dict[str, Any]:
"""Get a book with its full table of contents (chapters and pages)."""
data = await _guard(client.request("GET", f"/books/{book_id}"))
contents = []
for node in data.get("contents", []):
entry = {
"type": node.get("type"),
"id": node.get("id"),
"name": node.get("name"),
}
if node.get("type") == "chapter":
entry["pages"] = [
{"id": p.get("id"), "name": p.get("name")} for p in node.get("pages", [])
]
contents.append(entry)
return {
"id": data.get("id"),
"name": data.get("name"),
"description": data.get("description"),
"tags": _tags(data.get("tags")),
"updated_at": data.get("updated_at"),
"contents": contents,
}
@mcp.tool(annotations={"readOnlyHint": True})
async def get_chapter(
chapter_id: Annotated[int, Field(description="Numeric chapter id.")],
) -> dict[str, Any]:
"""Get a chapter and the list of pages inside it."""
data = await _guard(client.request("GET", f"/chapters/{chapter_id}"))
return {
"id": data.get("id"),
"name": data.get("name"),
"book_id": data.get("book_id"),
"description": data.get("description"),
"tags": _tags(data.get("tags")),
"pages": [{"id": p.get("id"), "name": p.get("name")} for p in data.get("pages", [])],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_shelves(
count: Annotated[int, Field(description="How many shelves to return.", ge=1, le=100)] = 50,
) -> dict[str, Any]:
"""List bookshelves, the top level of the BookStack hierarchy."""
data = await _guard(client.list_endpoint("/shelves", count=count, sort="name"))
return {
"total": data.get("total"),
"shelves": [
{"id": s.get("id"), "name": s.get("name"), "description": (s.get("description") or "")[:300]}
for s in data.get("data", [])
],
}
@mcp.tool(annotations={"readOnlyHint": True})
async def list_recent_pages(
count: Annotated[int, Field(description="How many pages to return.", ge=1, le=100)] = 25,
) -> dict[str, Any]:
"""List the most recently updated pages. Good for 'what changed lately'."""
data = await _guard(client.list_endpoint("/pages", count=count, sort="-updated_at"))
return {
"pages": [
{
"id": p.get("id"),
"name": p.get("name"),
"book_id": p.get("book_id"),
"chapter_id": p.get("chapter_id"),
"updated_at": p.get("updated_at"),
}
for p in data.get("data", [])
]
}
# ----------------------------------------------------------------- write
if not settings.read_only:
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_page(
name: Annotated[str, Field(description="Title of the new page.")],
markdown: Annotated[str, Field(description="Page body in Markdown.")],
book_id: Annotated[int | None, Field(description="Book to create the page in. Provide this or chapter_id.")] = None,
chapter_id: Annotated[int | None, Field(description="Chapter to create the page in. Provide this or book_id.")] = None,
tags: Annotated[list[str] | None, Field(description="Tags as 'name' or 'name=value' strings.")] = None,
) -> dict[str, Any]:
"""Create a new page in a book or chapter."""
if not book_id and not chapter_id:
raise ToolError("Provide either book_id or chapter_id.")
payload: dict[str, Any] = {"name": name, "markdown": markdown}
if chapter_id:
payload["chapter_id"] = chapter_id
else:
payload["book_id"] = book_id
if tag_payload := _tag_payload(tags):
payload["tags"] = tag_payload
data = await _guard(client.request("POST", "/pages", json=payload))
return {"id": data.get("id"), "name": data.get("name"), "slug": data.get("slug")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": True})
async def update_page(
page_id: Annotated[int, Field(description="Page to update.")],
name: Annotated[str | None, Field(description="New title. Omit to leave unchanged.")] = None,
markdown: Annotated[str | None, Field(description="New body in Markdown. This REPLACES the whole page body.")] = None,
tags: Annotated[list[str] | None, Field(description="Replacement tag list. Omit to leave unchanged.")] = None,
) -> dict[str, Any]:
"""Update an existing page. Supplying markdown replaces the entire body."""
payload: dict[str, Any] = {}
if name is not None:
payload["name"] = name
if markdown is not None:
payload["markdown"] = markdown
if tag_payload := _tag_payload(tags):
payload["tags"] = tag_payload
if not payload:
raise ToolError("Nothing to update: provide name, markdown, or tags.")
data = await _guard(client.request("PUT", f"/pages/{page_id}", json=payload))
return {"id": data.get("id"), "name": data.get("name"), "updated_at": data.get("updated_at")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_book(
name: Annotated[str, Field(description="Title of the new book.")],
description: Annotated[str, Field(description="Short description.")] = "",
) -> dict[str, Any]:
"""Create a new book."""
data = await _guard(
client.request("POST", "/books", json={"name": name, "description": description})
)
return {"id": data.get("id"), "name": data.get("name")}
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": False})
async def create_chapter(
book_id: Annotated[int, Field(description="Book that will contain the chapter.")],
name: Annotated[str, Field(description="Title of the new chapter.")],
description: Annotated[str, Field(description="Short description.")] = "",
) -> dict[str, Any]:
"""Create a new chapter inside a book."""
data = await _guard(
client.request(
"POST",
"/chapters",
json={"book_id": book_id, "name": name, "description": description},
)
)
return {"id": data.get("id"), "name": data.get("name")}
if settings.allow_delete:
@mcp.tool(annotations={"readOnlyHint": False, "destructiveHint": True})
async def delete_page(
page_id: Annotated[int, Field(description="Page to send to the recycle bin.")],
) -> dict[str, Any]:
"""Move a page to the BookStack recycle bin (recoverable)."""
await _guard(client.request("DELETE", f"/pages/{page_id}"))
return {"deleted": page_id, "note": "Moved to the recycle bin; recoverable in BookStack."}
return mcp
async def _guard(awaitable):
"""Convert BookStack API errors into clean MCP tool errors."""
try:
return await awaitable
except BookStackError as exc:
raise ToolError(str(exc)) from exc
def _build_auth(settings: Settings):
mode = settings.auth_mode
if mode == "none":
logger.warning(
"MCP_AUTH_MODE=none: this server is unauthenticated. Only run it this way "
"on a trusted network or behind another authenticating proxy."
)
return None
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
verifier = None
if "token" in mode:
verifier = StaticTokenVerifier(
tokens={
token: {"client_id": f"static-{i}", "scopes": ["bookstack"]}
for i, token in enumerate(settings.static_tokens)
}
)
oauth = None
if "github" in mode:
from fastmcp.server.auth.providers.github import GitHubProvider
oauth = GitHubProvider(
client_id=settings.github_client_id,
client_secret=settings.github_client_secret,
base_url=settings.public_url,
redirect_path="/auth/callback",
required_scopes=["read:user"],
)
elif "oidc" in mode:
from fastmcp.server.auth.oidc_proxy import OIDCProxy
# OIDC_SCOPES overrides this entirely when a provider needs something
# specific. Otherwise: identify the user, ask for groups only when group
# checks are configured (some providers reject unknown scopes), and
# request offline_access so the connection survives token expiry.
if settings.oidc_scopes:
scopes = settings.oidc_scopes
else:
scopes = ["openid", "profile", "email", "offline_access"]
if settings.required_groups:
scopes.insert(3, settings.oidc_groups_claim)
oauth = OIDCProxy(
config_url=settings.oidc_config_url,
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
base_url=settings.public_url,
redirect_path="/auth/callback",
required_scopes=scopes,
# 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,
)
if oauth is None:
return verifier
if verifier is None:
return oauth
from fastmcp.server.auth import MultiAuth
return MultiAuth(server=oauth, verifiers=[verifier], base_url=settings.public_url)