Files
simbarag/utils/mcp_tools.py
T
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

112 lines
3.7 KiB
Python

"""Shared helpers for loading admin-only agent tools from stdio MCP servers.
Each integration (Homebox, BookStack, ...) picks an allowlist of server tools;
they are renamed ``<prefix>_<name>``, get trimmed descriptions, and re-check
``is_admin`` in the run config on every call as a second line of defense
(the tools are only compiled into the admin agent in the first place).
"""
import logging
import os
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool, StructuredTool
logger = logging.getLogger(__name__)
DESCRIPTION_MAX_CHARS = 300
def content_to_text(content) -> str:
"""Flatten MCP content blocks into plain text for the LLM and persistence."""
if isinstance(content, str):
return content
if isinstance(content, (list, tuple)):
parts = []
for block in content:
if isinstance(block, dict):
parts.append(block.get("text") or f"[{block.get('type', 'content')}]")
else:
parts.append(str(block))
return "\n".join(parts)
return str(content)
def short_description(description: str) -> str:
description = " ".join((description or "").split())
if len(description) > DESCRIPTION_MAX_CHARS:
description = description[: DESCRIPTION_MAX_CHARS - 3].rstrip() + "..."
return description
def admin_only_message(label: str) -> str:
return f"{label} access is restricted to admin users."
def wrap_admin_only(inner: BaseTool, prefix: str, label: str) -> StructuredTool:
"""Rename an MCP tool to ``<prefix>_<name>`` and gate it on ``is_admin``."""
async def call(config: RunnableConfig, **kwargs) -> str:
if not (config or {}).get("configurable", {}).get("is_admin"):
return admin_only_message(label)
try:
result = await inner.coroutine(**kwargs)
except Exception as e:
return f"{label} error: {e}"
content = result[0] if isinstance(result, tuple) else result
return content_to_text(content)
return StructuredTool(
name=f"{prefix}_{inner.name}",
description=short_description(inner.description),
args_schema=inner.args_schema,
coroutine=call,
metadata=inner.metadata,
)
def select_tools(
mcp_tools: list[BaseTool], allowlist: tuple[str, ...], prefix: str, label: str
) -> list[StructuredTool]:
"""Keep allowlisted tools (in allowlist order) and wrap them admin-only."""
by_name = {t.name: t for t in mcp_tools}
missing = [name for name in allowlist if name not in by_name]
if missing:
logger.warning(f"{label} MCP: server is missing tools {missing}")
return [
wrap_admin_only(by_name[name], prefix, label)
for name in allowlist
if name in by_name
]
async def load_stdio_tools(
server: str,
command: str,
env: dict[str, str],
allowlist: tuple[str, ...],
prefix: str,
label: str,
) -> list[StructuredTool]:
"""Start a stdio MCP server and return its wrapped tools, or [] on failure."""
try:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient(
{
server: {
"transport": "stdio",
"command": command,
"args": [],
"env": {**env, "PATH": os.environ.get("PATH", "")},
}
}
)
tools = select_tools(await client.get_tools(), allowlist, prefix, label)
except Exception:
logger.exception(f"{label} MCP failed to load; continuing without it")
return []
logger.info(f"{label} MCP: loaded {len(tools)} tools")
return tools