Add admin-only Homebox MCP connector

Load an allowlisted subset of homebox-mcp tools (read + safe writes, no
deletes) over stdio and compile them into a separate admin agent, so
non-admins never see them. Each tool also re-checks is_admin per call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 11:01:19 -04:00
parent ccc5126ae0
commit 9858e06b73
15 changed files with 874 additions and 82 deletions
+122
View File
@@ -0,0 +1,122 @@
"""Tests for the admin-only Homebox MCP tool wrapping and agent routing."""
import os
from types import SimpleNamespace
from unittest.mock import patch
from langchain_core.tools import StructuredTool
from quart import Quart
from utils.homebox_mcp import (
ADMIN_ONLY_MESSAGE,
HOMEBOX_TOOL_ALLOWLIST,
load_homebox_tools,
select_homebox_tools,
)
ADMIN = {"configurable": {"is_admin": True}}
NON_ADMIN = {"configurable": {"is_admin": False}}
def _mcp_tool(name: str, result=None, 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 (
result if result is not None else ([{"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 TestSelectHomeboxTools:
def test_filters_to_allowlist_and_prefixes(self):
stubs = [_mcp_tool(n)[0] for n in ("search_items", "delete_item", "get_item")]
tools = select_homebox_tools(stubs)
assert [t.name for t in tools] == ["homebox_search_items", "homebox_get_item"]
def test_never_exposes_deletes(self):
assert not any(name.startswith("delete_") for name in HOMEBOX_TOOL_ALLOWLIST)
def test_truncates_long_descriptions(self):
stub, _ = _mcp_tool("search_items")
stub.description = "word " * 200
[tool] = select_homebox_tools([stub])
assert len(tool.description) <= 300
class TestAdminGuard:
async def test_admin_call_delegates_and_flattens_content(self):
stub, calls = _mcp_tool(
"search_items",
result=(
[{"type": "text", "text": "Drill"}, {"type": "text", "text": "Garage"}],
None,
),
)
[tool] = select_homebox_tools([stub])
out = await tool.ainvoke({"query": "drill"}, config=ADMIN)
assert out == "Drill\nGarage"
assert calls == [{"query": "drill"}]
async def test_non_admin_is_refused_without_calling_server(self):
stub, calls = _mcp_tool("search_items")
[tool] = select_homebox_tools([stub])
assert (
await tool.ainvoke({"query": "drill"}, config=NON_ADMIN)
== ADMIN_ONLY_MESSAGE
)
assert await tool.ainvoke({"query": "drill"}) == ADMIN_ONLY_MESSAGE
assert calls == []
async def test_server_errors_are_returned_as_text(self):
stub, _ = _mcp_tool("search_items", error=RuntimeError("cannot reach Homebox"))
[tool] = select_homebox_tools([stub])
out = await tool.ainvoke({"query": "drill"}, config=ADMIN)
assert out == "Homebox error: cannot reach Homebox"
def test_config_not_exposed_in_schema(self):
[tool] = select_homebox_tools([_mcp_tool("search_items")[0]])
assert list(tool.tool_call_schema["properties"]) == ["query"]
class TestLoadHomeboxTools:
async def test_returns_empty_when_unconfigured(self):
with patch.dict(os.environ, {"HOMEBOX_URL": "", "HOMEBOX_TOKEN": ""}):
assert await load_homebox_tools() == []
async def test_returns_empty_when_server_fails(self):
env = {
"HOMEBOX_URL": "http://homebox",
"HOMEBOX_TOKEN": "hb_x",
"HOMEBOX_MCP_COMMAND": "/nonexistent/homebox-mcp",
}
with patch.dict(os.environ, env):
assert await load_homebox_tools() == []
class TestGetAgentFor:
async def test_routes_admin_to_admin_agent(self):
from blueprints.conversation.logic import get_agent_for
app = Quart(__name__)
app.main_agent, app.admin_agent = "main", "admin"
admin = SimpleNamespace(is_admin=lambda: True)
user = SimpleNamespace(is_admin=lambda: False)
async with app.app_context():
assert get_agent_for(admin) == "admin"
assert get_agent_for(user) == "main"