feat:接入mcp,为模型提供图表信息
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""物料字段 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)
|
||||
Reference in New Issue
Block a user