Files
OpenMesh/coworker/mcp/tools.py
zhaolei 6f402ffcee
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
2026-09-13 23:41:04 +08:00

111 lines
3.9 KiB
Python

"""Turn MCP tools into ToolRegistry-ready callables.
Each MCP tool becomes a sync callable (so it fits the registry's `execute` contract, which
the engine already runs via `asyncio.to_thread`). The callable bridges back to the live
async session on the server loop via `run_coroutine_threadsafe`. We attach `ToolMetadata`
(category="mcp", `requires_approval` per config) so the PermissionEngine gates it, and an
explicit OpenAI schema built straight from the MCP `inputSchema` for fidelity.
"""
from __future__ import annotations
import asyncio
import re
from typing import Any, Awaitable, Callable
import aisuite as ai
from .config import MCPServerDef
CallAsync = Callable[[str, dict[str, Any]], Awaitable[Any]]
_NAME_OK = re.compile(r"[^a-zA-Z0-9_-]")
_MAX_NAME = 64 # OpenAI function-name limit
def tool_name(server: str, tool: str) -> str:
"""`mcp__<server>__<tool>`, sanitized to OpenAI's `[A-Za-z0-9_-]{1,64}` rule."""
base = f"mcp__{_NAME_OK.sub('_', server)}__{_NAME_OK.sub('_', tool)}"
if len(base) > _MAX_NAME:
base = base[:_MAX_NAME]
return base
def _openai_schema(name: str, mcp_tool: Any) -> dict[str, Any]:
params = getattr(mcp_tool, "inputSchema", None) or {
"type": "object",
"properties": {},
}
description = (getattr(mcp_tool, "description", None) or "")[:1024]
return {
"type": "function",
"function": {"name": name, "description": description, "parameters": params},
}
def _filtered(mcp_tools: list[Any], server: MCPServerDef) -> list[Any]:
out = mcp_tools
if server.include_tools is not None:
allow = set(server.include_tools)
out = [t for t in out if t.name in allow]
if server.exclude_tools:
block = set(server.exclude_tools)
out = [t for t in out if t.name not in block]
return out
def build_callables(
server: MCPServerDef,
mcp_tools: list[Any],
call_async: CallAsync,
loop: asyncio.AbstractEventLoop,
*,
timeout: float = 120.0,
) -> list[Callable[..., Any]]:
"""Wrap a server's (filtered) MCP tools as registry-ready callables."""
callables: list[Callable[..., Any]] = []
for mcp_tool in _filtered(mcp_tools, server):
name = tool_name(server.name, mcp_tool.name)
remote = mcp_tool.name
def _invoke(_remote: str = remote, **kwargs: Any) -> Any:
future = asyncio.run_coroutine_threadsafe(call_async(_remote, kwargs), loop)
return future.result(timeout)
# We attach the schema + metadata explicitly (rather than via `ai.tool`, which would
# try to derive a schema from this `**kwargs` wrapper): the registry reads both attrs.
_invoke.__name__ = name
_invoke.__doc__ = (
getattr(mcp_tool, "description", None)
or f"MCP tool {remote} from {server.name}"
)
_invoke.__aisuite_tool_metadata__ = ai.ToolMetadata(
name=name,
category="mcp",
risk_level="medium",
capabilities=[server.name],
requires_approval=server.requires_approval,
)
_invoke.__coworker_schema__ = _openai_schema(name, mcp_tool)
# OPE-136 finding 4: where this call actually goes, for the approval card's
# scope chip. From the server DEF (user-authored config), never from anything
# the server itself claims. http → the remote host; stdio → a local process.
_invoke.__coworker_mcp_destination__ = {
"transport": server.transport,
"host": _server_host(server),
}
callables.append(_invoke)
return callables
def _server_host(server: MCPServerDef) -> str:
"""The hostname an HTTP server's calls reach (lowercased), "" for stdio/unparseable."""
if not server.url:
return ""
try:
from urllib.parse import urlparse
return (urlparse(server.url).hostname or "").lower()
except ValueError: # pragma: no cover - urlparse rarely raises, but fail to ""
return ""