feat:架构重构
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""按 mcp_servers.json 连接 MCP 服务,把所有服务的工具交给 agent
|
||||
|
||||
每一项是一个服务:键为服务名,transport / url / headers 原样交给 MultiServerMCPClient;
|
||||
填写 module 的服务只能在本机,由应用以子进程拉起,不填表示服务由外部部署
|
||||
各环境不同的值和密钥写成 ${变量名},加载时从环境变量展开
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
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
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
CONFIG_PATH = PROJECT_ROOT / "mcp_servers.json"
|
||||
# 物料服务返回的目录版本须与主进程一致,否则模型查到的字段和校验依据不同
|
||||
MATERIAL_SERVER = "materials"
|
||||
LOCAL_HOSTS = {"localhost", "127.0.0.1"}
|
||||
|
||||
|
||||
class MCPServiceError(RuntimeError):
|
||||
"""MCP 配置错误、服务不可用,或物料目录版本不一致"""
|
||||
|
||||
|
||||
def load_mcp_config(path: Path = CONFIG_PATH) -> dict[str, dict]:
|
||||
"""读取服务列表并展开环境变量;文件缺失或格式不对时报错"""
|
||||
if not path.exists():
|
||||
raise MCPServiceError(f"未找到 MCP 服务配置: {path}")
|
||||
try:
|
||||
config = json.loads(os.path.expandvars(path.read_text(encoding="utf-8")))
|
||||
except json.JSONDecodeError as error:
|
||||
raise MCPServiceError(f"MCP 服务配置不是合法 JSON: {error}") from error
|
||||
if not isinstance(config, dict) or not all(
|
||||
isinstance(item, dict) and item.get("transport") and item.get("url")
|
||||
for item in config.values()
|
||||
):
|
||||
raise MCPServiceError("MCP 服务配置的每一项都需要 transport 和 url")
|
||||
return config
|
||||
|
||||
|
||||
async def _limit_time(request: MCPToolCallRequest, handler):
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
handler(request), timeout=settings.mcp_tool_timeout
|
||||
)
|
||||
except TimeoutError as error:
|
||||
raise MCPServiceError(
|
||||
f"MCP 工具调用超时: {request.server_name}.{request.name}"
|
||||
) from error
|
||||
|
||||
|
||||
async def _check_catalog_version(request: MCPToolCallRequest, handler):
|
||||
result = await handler(request)
|
||||
if request.server_name != MATERIAL_SERVER:
|
||||
return result
|
||||
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 MCPServiceError(
|
||||
f"物料服务的目录版本 {served} 与主服务 {expected} 不一致,请重启服务"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_tools: list[BaseTool] | None = None
|
||||
|
||||
|
||||
async def get_mcp_tools() -> list[BaseTool]:
|
||||
"""首次使用时拉取所有服务的工具并缓存;此前失败时重新拉取"""
|
||||
global _tools
|
||||
if _tools is None:
|
||||
config = load_mcp_config()
|
||||
client = MultiServerMCPClient(
|
||||
{
|
||||
name: {key: value for key, value in item.items() if key != "module"}
|
||||
for name, item in config.items()
|
||||
},
|
||||
tool_interceptors=[_limit_time, _check_catalog_version],
|
||||
# 参数不对等查询错误返回给模型自行修正;连接失败等仍直接抛出
|
||||
handle_tool_errors=True,
|
||||
)
|
||||
try:
|
||||
tools = await asyncio.wait_for(
|
||||
client.get_tools(), timeout=settings.mcp_tool_timeout
|
||||
)
|
||||
except Exception as error:
|
||||
raise MCPServiceError(f"MCP 服务不可用: {', '.join(config)}") from error
|
||||
# 不加服务名前缀,skill 里写的工具名保持不变,因此不同服务的工具不能重名
|
||||
if duplicated := [
|
||||
name for name, count in Counter(t.name for t in tools).items() if count > 1
|
||||
]:
|
||||
raise MCPServiceError(f"不同 MCP 服务的工具重名: {duplicated}")
|
||||
_tools = tools
|
||||
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(name: str, item: dict) -> subprocess.Popen | None:
|
||||
"""本机地址且尚未运行时拉起服务;已运行则直接复用"""
|
||||
target = urlsplit(item["url"])
|
||||
if target.hostname not in LOCAL_HOSTS:
|
||||
raise MCPServiceError(f"{name} 由应用拉起,只能使用本机地址")
|
||||
port = target.port or 80
|
||||
if _port_open(port):
|
||||
return None
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-X", "utf8", "-m", item["module"]],
|
||||
cwd=PROJECT_ROOT,
|
||||
env={**os.environ, "PORT": str(port)},
|
||||
)
|
||||
for _ in range(100):
|
||||
if process.poll() is not None:
|
||||
raise MCPServiceError(f"{name} 启动失败,请查看终端日志")
|
||||
if _port_open(port):
|
||||
logger.info("MCP 服务 {} 已启动: {}", name, item["url"])
|
||||
return process
|
||||
await asyncio.sleep(0.1)
|
||||
await stop_mcp_servers([process])
|
||||
raise MCPServiceError(f"{name} 启动超时")
|
||||
|
||||
|
||||
async def start_mcp_servers() -> list[subprocess.Popen]:
|
||||
"""拉起配置了 module 的服务,返回进程供退出时关闭;单个服务失败只记录日志"""
|
||||
processes = []
|
||||
for name, item in load_mcp_config().items():
|
||||
if not item.get("module"):
|
||||
continue
|
||||
try:
|
||||
if (process := await _start(name, item)) is not None:
|
||||
processes.append(process)
|
||||
except MCPServiceError as error:
|
||||
logger.error("{},用到它的请求将无法执行", error)
|
||||
return processes
|
||||
|
||||
|
||||
async def stop_mcp_servers(processes: list[subprocess.Popen]) -> None:
|
||||
for process in processes:
|
||||
if process.poll() is not None:
|
||||
continue
|
||||
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