"""物料字段 MCP 客户端:把字段查询工具交给模型使用 服务只监听本机,由应用启动时拉起;不可用时直接报错,不退回其他查询方式 """ import asyncio import os import socket import subprocess import sys from pathlib import Path from urllib.parse import urlsplit from langchain_core.tools import BaseTool from langchain_mcp_adapters.client import MultiServerMCPClient from langchain_mcp_adapters.interceptors import MCPToolCallRequest from loguru import logger from mcp.types import CallToolResult from app.config import settings from app.resources.component import get_catalog_cache SERVER_NAME = "materials" PROJECT_ROOT = Path(__file__).resolve().parents[2] class MaterialFieldServiceError(RuntimeError): """物料字段服务不可用,或与主进程的物料目录版本不一致""" async def _check_version(request: MCPToolCallRequest, handler): """限制单次查询耗时;返回的目录版本须与主进程一致,否则模型查到的字段和校验依据不同""" try: result = await asyncio.wait_for( handler(request), timeout=settings.mcp_tool_timeout ) except TimeoutError as error: raise MaterialFieldServiceError(f"物料字段查询超时: {request.name}") from error if isinstance(result, CallToolResult) and result.structuredContent: served = result.structuredContent.get("catalog_version") catalog = get_catalog_cache() expected = catalog.version if catalog is not None else None if served != expected: raise MaterialFieldServiceError( f"物料字段服务的目录版本 {served} 与主服务 {expected} 不一致,请重启服务" ) return result _client = MultiServerMCPClient( { SERVER_NAME: { "transport": "streamable_http", "url": settings.mcp_material_url, } }, tool_interceptors=[_check_version], # 未知组件类型、分组等查询错误返回给模型自行修正;连接失败等仍直接抛出 handle_tool_errors=True, ) _tools: list[BaseTool] | None = None async def get_material_tools() -> list[BaseTool]: """字段查询工具;首次使用或此前连接失败时向服务拉取工具清单""" global _tools if _tools is None: try: _tools = await asyncio.wait_for( _client.get_tools(server_name=SERVER_NAME), timeout=settings.mcp_tool_timeout, ) except Exception as error: raise MaterialFieldServiceError( f"物料字段服务不可用: {settings.mcp_material_url}" ) from error return _tools def _port_open(port: int) -> bool: with socket.socket() as probe: probe.settimeout(0.2) return probe.connect_ex(("127.0.0.1", port)) == 0 async def start_material_server() -> subprocess.Popen | None: """本机地址且尚未运行时拉起服务,返回进程供退出时关闭;已运行则直接复用""" target = urlsplit(settings.mcp_material_url) if target.hostname not in {"localhost", "127.0.0.1"}: raise MaterialFieldServiceError("物料字段服务只能部署在本机,不对外暴露") port = target.port or 80 if _port_open(port): return None process = subprocess.Popen( [sys.executable, "-X", "utf8", "-m", "mcp_servers.material_server"], cwd=PROJECT_ROOT, env={**os.environ, "PORT": str(port)}, ) for _ in range(100): if process.poll() is not None: raise MaterialFieldServiceError("物料字段服务启动失败,请查看终端日志") if _port_open(port): logger.info("物料字段服务已启动: {}", settings.mcp_material_url) return process await asyncio.sleep(0.1) await stop_material_server(process) raise MaterialFieldServiceError("物料字段服务启动超时") async def stop_material_server(process: subprocess.Popen | None) -> None: if process is None or process.poll() is not None: return process.terminate() try: await asyncio.to_thread(process.wait, timeout=5) except subprocess.TimeoutExpired: process.kill() await asyncio.to_thread(process.wait)