"""Dynamic MCP tool wrapper for LangChain/LangGraph. Fetches available MCP tools from the MCP server and exposes them as LangChain BaseTool instances that call the MCP `/execute` endpoint at runtime. """ from typing import Any, Dict, List, Optional, Type import json import requests from pydantic import BaseModel, Field, PrivateAttr from langchain.tools import BaseTool from th_agenter.core.config import get_settings from loguru import logger import os # Map MCP parameter types to Python type hints _TYPE_MAP: Dict[str, Any] = { "string": str, "integer": int, "float": float, "boolean": bool, "array": List[Any], "object": Dict[str, Any], } def _build_args_schema(params: List[Dict[str, Any]]) -> Type[BaseModel]: """Build a Pydantic BaseModel class dynamically from MCP tool params.""" annotations: Dict[str, Any] = {} fields: Dict[str, Any] = {} for p in params: name = p.get("name") ptype = p.get("type", "string") required = p.get("required", True) default = p.get("default", None) description = p.get("description", "") enum = p.get("enum") py_type = _TYPE_MAP.get(ptype, Any) annotations[name] = py_type if enum is not None and default is None: # if enum present without default, keep required unless specified field_default = ... if required else None else: field_default = ... if required and default is None else default fields[name] = Field( default=field_default, description=description, ) # Create model class namespace = {"__annotations__": annotations} namespace.update(fields) return type("MCPToolArgs", (BaseModel,), namespace) class MCPDynamicTool(BaseTool): """LangChain BaseTool wrapper that executes MCP tools via HTTP.""" name: str description: str args_schema: Type[BaseModel] _mcp_base_url: str = PrivateAttr() _tool_name: str = PrivateAttr() def __init__(self, mcp_base_url: str, tool_info: Dict[str, Any]): # Initialize BaseTool with dynamic metadata super().__init__( name=tool_info.get("name", "tool"), description=tool_info.get("description", ""), args_schema=_build_args_schema(tool_info.get("parameters", [])), ) # set private attrs after BaseTool init to avoid pydantic stripping self._mcp_base_url = mcp_base_url.rstrip("/") self._tool_name = tool_info["name"] def _execute(self, params: Dict[str, Any]) -> Dict[str, Any]: url = f"{self._mcp_base_url}/execute" payload = { "tool_name": self._tool_name, "parameters": params, } logger.info(f"调用 MCP 工具: {self._tool_name} 参数: {params}") try: resp = requests.post(url, json=payload, timeout=30) resp.raise_for_status() data = resp.json() return data except Exception as e: logger.error(f"MCP 工具调用失败: {e}") return { "success": False, "error": str(e), "result": None, "tool_name": self._tool_name, } def _run(self, **kwargs: Any) -> str: """Synchronous execution for LangChain tools.""" data = self._execute(kwargs) if not isinstance(data, dict): return json.dumps({"success": False, "error": "Invalid MCP response"}, ensure_ascii=False) # Return string content; LangChain expects textual content for ToolMessage if data.get("success"): return json.dumps(data.get("result", {}), ensure_ascii=False) return json.dumps({"error": data.get("error")}, ensure_ascii=False) async def _arun(self, **kwargs: Any) -> str: # LangChain will call async version when available; we simply delegate to sync for now. return self._run(**kwargs) def load_mcp_tools(include: Optional[List[str]] = None) -> List[MCPDynamicTool]: """Load MCP tools from the MCP server and construct dynamic tools. include: optional list of tool names to include (e.g., ["weather", "search"]). """ settings = get_settings() # Try settings.tool.mcp_server_url, fallback to default mcp_base_url = getattr(settings.tool, "mcp_server_url", None) or os.getenv("MCP_SERVER_URL") or "http://127.0.0.1:8001" url = f"{mcp_base_url.rstrip('/')}/tools" try: resp = requests.get(url, timeout=15) resp.raise_for_status() tools_info = resp.json() except Exception as e: logger.error(f"获取 MCP 工具列表失败: {e}") return [] dynamic_tools: List[MCPDynamicTool] = [] for tool in tools_info: name = tool.get("name") if include and name not in include: continue try: dynamic_tools.append(MCPDynamicTool(mcp_base_url=mcp_base_url, tool_info=tool)) except Exception as e: logger.warning(f"构建 MCP 工具'{name}'失败: {e}") logger.info(f"已加载 MCP 工具: {[t.name for t in dynamic_tools]}") return dynamic_tools