Files
my-v0-server/app/clients/mcp.py
T
2026-10-02 02:36:11 -07:00

186 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按 mcp_servers.json 连接 MCP 服务,把所有服务的工具交给 agent
每一项是一个服务:键为服务名,transport / url / headers 原样交给 MultiServerMCPClient;
填写 module 的服务只能在本机,由应用以子进程拉起,不填表示服务由外部部署
各环境不同的值和密钥写成 ${变量名},加载时从环境变量展开
"""
import asyncio
import json
import os
import re
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"}
# 配置中引用环境变量的写法
ENV_REFERENCE = re.compile(r"\$\{(\w+)\}")
class MCPServiceError(RuntimeError):
"""MCP 配置错误、服务不可用,或物料目录版本不一致"""
def _expand_env(value):
"""只展开字符串值里的 ${NAME};变量未设置时报错,不把字面量当成地址或密钥"""
if isinstance(value, dict):
return {key: _expand_env(item) for key, item in value.items()}
if isinstance(value, list):
return [_expand_env(item) for item in value]
if not isinstance(value, str):
return value
def lookup(match: re.Match) -> str:
if (found := os.environ.get(match[1])) is None:
raise MCPServiceError(f"MCP 服务配置引用的环境变量 {match[1]} 未设置")
return found
return ENV_REFERENCE.sub(lookup, value)
def load_mcp_config(path: Path = CONFIG_PATH) -> dict[str, dict]:
"""读取服务列表并展开环境变量;文件缺失或格式不对时报错"""
if not path.exists():
raise MCPServiceError(f"未找到 MCP 服务配置: {path}")
try:
config = _expand_env(json.loads(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)