0b8fe7b2df
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>
104 lines
3.5 KiB
Python
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() == []
|