Files
simbarag/tests/unit/test_bookstack_mcp.py
ryan 0b8fe7b2df Add admin-only BookStack MCP connector
Load an allowlisted subset of bookstack-mcp tools (search/read plus
create/update books, chapters, pages and comments; no deletes) over stdio
and add them to the admin agent alongside Homebox. Extract the shared
allowlist + is_admin wrapping into utils/mcp_tools.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:54:01 -04:00

104 lines
3.5 KiB
Python

"""Tests for the admin-only BookStack MCP tool wrapping."""
import os
from unittest.mock import patch
from langchain_core.tools import StructuredTool
from utils.bookstack_mcp import (
ADMIN_ONLY_MESSAGE,
BOOKSTACK_TOOL_ALLOWLIST,
load_bookstack_tools,
select_bookstack_tools,
)
ADMIN = {"configurable": {"is_admin": True}}
NON_ADMIN = {"configurable": {"is_admin": False}}
def _mcp_tool(name: str, error: Exception | None = None):
"""Build a stub shaped like a langchain-mcp-adapters tool."""
calls = []
async def call(runtime=None, **arguments):
calls.append(arguments)
if error:
raise error
return ([{"type": "text", "text": "ok"}], None)
tool = StructuredTool(
name=name,
description=f"{name} description",
args_schema={"type": "object", "properties": {"query": {"type": "string"}}},
coroutine=call,
response_format="content_and_artifact",
)
return tool, calls
class TestSelectBookstackTools:
def test_filters_to_allowlist_and_prefixes(self):
stubs = [
_mcp_tool(n)[0] for n in ("search_content", "delete_page", "update_page")
]
tools = select_bookstack_tools(stubs)
assert [t.name for t in tools] == [
"bookstack_search_content",
"bookstack_update_page",
]
def test_never_exposes_destructive_tools(self):
for name in BOOKSTACK_TOOL_ALLOWLIST:
assert not name.startswith("delete_")
assert name not in ("permanently_delete", "restore_deleted")
def test_allows_reads_and_writes(self):
assert "get_page" in BOOKSTACK_TOOL_ALLOWLIST
assert "create_page" in BOOKSTACK_TOOL_ALLOWLIST
assert "update_page" in BOOKSTACK_TOOL_ALLOWLIST
class TestAdminGuard:
async def test_admin_call_delegates(self):
stub, calls = _mcp_tool("search_content")
[tool] = select_bookstack_tools([stub])
assert await tool.ainvoke({"query": "wifi"}, config=ADMIN) == "ok"
assert calls == [{"query": "wifi"}]
async def test_non_admin_is_refused_without_calling_server(self):
stub, calls = _mcp_tool("search_content")
[tool] = select_bookstack_tools([stub])
assert (
await tool.ainvoke({"query": "wifi"}, config=NON_ADMIN)
== ADMIN_ONLY_MESSAGE
)
assert ADMIN_ONLY_MESSAGE == "BookStack access is restricted to admin users."
assert calls == []
async def test_server_errors_are_returned_as_text(self):
stub, _ = _mcp_tool("search_content", error=RuntimeError("401 Unauthorized"))
[tool] = select_bookstack_tools([stub])
out = await tool.ainvoke({"query": "wifi"}, config=ADMIN)
assert out == "BookStack error: 401 Unauthorized"
class TestLoadBookstackTools:
async def test_returns_empty_when_unconfigured(self):
env = {
"BOOKSTACK_BASE_URL": "https://wiki",
"BOOKSTACK_TOKEN_ID": "id",
"BOOKSTACK_TOKEN_SECRET": "",
}
with patch.dict(os.environ, env):
assert await load_bookstack_tools() == []
async def test_returns_empty_when_server_fails(self):
env = {
"BOOKSTACK_BASE_URL": "https://wiki",
"BOOKSTACK_TOKEN_ID": "id",
"BOOKSTACK_TOKEN_SECRET": "secret",
"BOOKSTACK_MCP_COMMAND": "/nonexistent/bookstack-mcp",
}
with patch.dict(os.environ, env):
assert await load_bookstack_tools() == []