Files
my-v0-server/app/clients/material_mcp.py
T

119 lines
4.2 KiB
Python

"""物料字段 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)