"""MCP 加载器"""
import asyncio
import base64
import json
import logging
import random
import shutil
import asyncer
from anyio import Path
from sqids.sqids import Sqids
from typing import Any
from apps.common.lance import LanceDB
from apps.common.mongo import MongoDB
from apps.common.process_handler import ProcessHandler
from apps.common.singleton import SingletonMeta
from apps.constants import MCP_PATH
from apps.llm.embedding import Embedding
from apps.scheduler.pool.mcp.client import MCPClient
from apps.scheduler.pool.mcp.install import install_npx, install_uvx
from apps.schemas.mcp import (
MCPCollection,
MCPInstallStatus,
MCPServerConfig,
MCPServerSSEConfig,
MCPServerStdioConfig,
MCPTool,
MCPToolVector,
MCPType,
MCPVector,
)
logger = logging.getLogger(__name__)
sqids = Sqids(min_length=12)
class MCPLoader(metaclass=SingletonMeta):
"""
MCP加载模块
创建MCP Client,启动MCP进程,并将MCP基本信息(名称、描述、工具列表等)写入数据库
"""
@staticmethod
async def _check_dir() -> None:
"""
检查MCP目录是否存在
:return: 无
"""
if not await (MCP_PATH / "template").exists() or not await (MCP_PATH / "template").is_dir():
logger.warning("[MCPLoader] template目录不存在,创建中")
await (MCP_PATH / "template").unlink(missing_ok=True)
await (MCP_PATH / "template").mkdir(parents=True, exist_ok=True)
if not await (MCP_PATH / "users").exists() or not await (MCP_PATH / "users").is_dir():
logger.warning("[MCPLoader] users目录不存在,创建中")
await (MCP_PATH / "users").unlink(missing_ok=True)
await (MCP_PATH / "users").mkdir(parents=True, exist_ok=True)
@staticmethod
async def _load_config(config_path: Path) -> MCPServerConfig:
"""
加载 MCP 配置
:param Path config_path: MCP配置文件路径
:return: MCP配置
:raises FileNotFoundError: 如果配置文件不存在,则抛出异常
"""
if not await config_path.exists():
err = f"MCP配置文件不存在: {config_path}"
logger.error(err)
raise FileNotFoundError(err)
f = await config_path.open("r", encoding="utf-8")
f_content = json.loads(await f.read())
await f.aclose()
return MCPServerConfig.model_validate(f_content)
@staticmethod
async def _install_template_task(
mcp_id: str, config: MCPServerConfig,
) -> None:
"""
安装依赖;此函数在子进程中运行
:param str mcp_id: MCP模板ID
:param MCPServerConfig config: MCP配置
:return: 无
"""
try:
if not config.config.auto_install:
print(f"[Installer] MCP模板无需安装: {mcp_id}")
elif isinstance(config.config, MCPServerStdioConfig):
print(f"[Installer] Stdio方式的MCP模板,开始自动安装: {mcp_id}")
if "uv" in config.config.command:
new_config = await install_uvx(mcp_id, config.config)
elif "npx" in config.config.command:
new_config = await install_npx(mcp_id, config.config)
if new_config is None:
logger.error("[MCPLoader] MCP模板安装失败: %s", mcp_id)
await MCPLoader.update_template_status(mcp_id, MCPInstallStatus.FAILED)
return
config.config = new_config
template_config = MCP_PATH / "template" / mcp_id / "config.json"
f = await template_config.open("w+", encoding="utf-8")
config_data = config.model_dump(by_alias=True, exclude_none=True)
await f.write(json.dumps(config_data, indent=4, ensure_ascii=False))
await f.aclose()
else:
logger.info(f"[Installer] SSE/StreamableHTTP方式的MCP模板,无需安装: {mcp_id}")
config.config.auto_install = False
await MCPLoader._insert_template_tool(mcp_id, config)
await MCPLoader.update_template_status(mcp_id, MCPInstallStatus.READY)
logger.info(f"[Installer] MCP模板安装成功: {mcp_id}")
except Exception as e:
logger.error("[MCPLoader] MCP模板安装失败: %s, 错误: %s", mcp_id, e)
await MCPLoader.update_template_status(mcp_id, MCPInstallStatus.FAILED)
raise
@staticmethod
async def clear_ready_or_failed_mcp_installation() -> None:
"""
清除状态为ready或failed的MCP安装任务
"""
mcp_collection = MongoDB().get_collection("mcp")
mcp_ids = ProcessHandler.get_all_task_ids()
db_service_list = await mcp_collection.find(
{"_id": {"$in": mcp_ids}, "status": {"$in": [MCPInstallStatus.READY, MCPInstallStatus.FAILED]}},
).to_list(None)
for db_service in db_service_list:
try:
item = MCPCollection.model_validate(db_service)
except Exception as e:
logger.error("[MCPLoader] MCP模板数据验证失败: %s, 错误: %s", db_service["_id"], e)
continue
ProcessHandler.remove_task(item.id)
logger.info("[MCPLoader] 删除已完成或失败的MCP安装进程: %s", item.id)
@staticmethod
async def init_one_template(mcp_id: str, config: MCPServerConfig) -> None:
"""
初始化单个MCP模板
:param str mcp_id: MCP模板ID
:param MCPServerConfig config: MCP配置
:return: 无
"""
await MCPLoader.clear_ready_or_failed_mcp_installation()
template_path = MCP_PATH / "template" / mcp_id
await Path.mkdir(template_path, parents=True, exist_ok=True)
ProcessHandler.remove_task(mcp_id)
if not ProcessHandler.add_task(mcp_id, MCPLoader._install_template_task, mcp_id, config):
err = f"安装任务无法执行,请稍后重试: {mcp_id}"
logger.error(err)
raise RuntimeError(err)
@staticmethod
async def cancel_all_installing_task() -> None:
"""
取消正在安装的MCP模板任务
"""
template_path = MCP_PATH / "template"
logger.info("[MCPLoader] 初始化所有MCP模板: %s", template_path)
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
mcp_ids = []
async for mcp_dir in template_path.iterdir():
if not await mcp_dir.is_dir():
logger.warning("[MCPLoader] 跳过非目录: %s", mcp_dir.as_posix())
continue
mcp_ids.append(mcp_dir.name)
await mcp_collection.update_many(
{"_id": {"$in": mcp_ids}, "status": MCPInstallStatus.INSTALLING},
{"$set": {"status": MCPInstallStatus.CANCELLED}},
)
@staticmethod
async def _get_template_tool(
mcp_id: str,
config: MCPServerConfig,
user_sub: str | None = None,
) -> list[MCPTool]:
"""
获取MCP模板的工具列表
:param str mcp_id: MCP模板ID
:param MCPServerConfig config: MCP配置
:param str | None user_sub: 用户ID,默认为None
:return: 工具列表
:rtype: list[str]
"""
if (
(config.type == MCPType.STDIO and isinstance(config.config, MCPServerStdioConfig))
or (config.type == MCPType.SSE and isinstance(config.config, MCPServerSSEConfig))
):
client = MCPClient()
else:
err = f"MCP {mcp_id}:未知的MCP服务类型“{config.type}”"
logger.error(err)
raise ValueError(err)
await client.init(user_sub, mcp_id, config.config)
tool_list = []
for item in client.tools:
tool_list += [MCPTool(
id=sqids.encode([random.randint(0, 1000000) for _ in range(5)])[:6],
name=item.name,
mcp_id=mcp_id,
description=item.description or "",
input_schema=item.inputSchema,
)]
await client.stop()
return tool_list
@staticmethod
async def _insert_template_db(mcp_id: str, config: MCPServerConfig) -> None:
"""插入单个MCP Server模板信息到数据库"""
mcp_collection = MongoDB().get_collection("mcp")
await mcp_collection.update_one(
{"_id": mcp_id},
{
"$set": MCPCollection(
_id=mcp_id,
name=config.name,
description=config.description,
type=config.type,
author=config.author,
).model_dump(by_alias=True, exclude_none=True),
},
upsert=True,
)
@staticmethod
async def _insert_template_tool(mcp_id: str, config: MCPServerConfig) -> None:
"""
插入单个MCP Server模板信息到数据库
:param str mcp_id: MCP模板ID
:param MCPServerSSEConfig | MCPServerStdioConfig config: MCP配置
:return: 无
"""
tool_list = await MCPLoader._get_template_tool(mcp_id, config)
mcp_collection = MongoDB().get_collection("mcp")
await mcp_collection.update_one(
{"_id": mcp_id},
{"$set": {"tools": []}},
upsert=True,
)
await mcp_collection.update_one(
{"_id": mcp_id},
{
"$set": {
"tools": [tool.model_dump(by_alias=True, exclude_none=True) for tool in tool_list],
},
},
upsert=True,
)
embedding = await Embedding.get_embedding([config.description])
while True:
try:
mcp_table = await LanceDB().get_table("mcp")
await mcp_table.merge_insert("id").when_matched_update_all().when_not_matched_insert_all().execute([
MCPVector(
id=mcp_id,
embedding=embedding[0],
),
])
break
except Exception as e:
if "Commit conflict" in str(e):
logger.error("[MCPLoader] LanceDB插入mcp冲突,重试中...")
await asyncio.sleep(0.01)
else:
raise
tool_desc_list = [tool.description for tool in tool_list]
tool_embedding = await Embedding.get_embedding(tool_desc_list)
for tool, embedding in zip(tool_list, tool_embedding, strict=True):
while True:
try:
mcp_tool_table = await LanceDB().get_table("mcp_tool")
await mcp_tool_table.merge_insert(
"id",
).when_matched_update_all().when_not_matched_insert_all().execute([
MCPToolVector(
id=tool.id,
mcp_id=mcp_id,
embedding=embedding,
),
])
break
except Exception as e:
if "Commit conflict" in str(e):
logger.error("[MCPLoader] LanceDB插入mcp_tool冲突,重试中...")
await asyncio.sleep(0.01)
else:
raise
await LanceDB().create_index("mcp_tool")
@staticmethod
async def save_one(mcp_id: str, config: MCPServerConfig) -> None:
"""
保存单个MCP模板的配置文件(``config.json``文件和``icon.png``文件)
:param str mcp_id: MCP模板ID
:param str icon: MCP模板图标
:param MCPConfig config: MCP配置
:return: 无
"""
config_path = MCP_PATH / "template" / mcp_id / "config.json"
await Path.mkdir(config_path.parent, parents=True, exist_ok=True)
f = await config_path.open("w+", encoding="utf-8")
config_dict = config.model_dump(by_alias=True, exclude_none=True)
await f.write(json.dumps(config_dict, indent=4, ensure_ascii=False))
await f.aclose()
@staticmethod
async def get_icon(mcp_id: str) -> str:
"""
获取MCP模板的图标
:param str mcp_id: MCP模板ID
:return: 图标
:rtype: str
"""
icon_path = MCP_PATH / "template" / mcp_id / "icon" / f"{mcp_id}.png"
if not await icon_path.exists():
logger.warning("[MCPLoader] MCP模板图标不存在: %s", mcp_id)
return ""
f = await icon_path.open("rb")
icon = await f.read()
await f.aclose()
header = "data:image/png;base64,"
return header + base64.b64encode(icon).decode("utf-8")
@staticmethod
async def get_config(mcp_id: str) -> MCPServerConfig:
"""
获取MCP服务配置
:param mcp_id: str: MCP服务ID
:return: MCP服务配置
"""
config_path = MCP_PATH / "template" / mcp_id / "config.json"
if not await config_path.exists():
err = f"MCP模板配置文件不存在: {mcp_id}"
logger.error(err)
raise FileNotFoundError(err)
f = await config_path.open("r", encoding="utf-8")
config = json.loads(await f.read())
await f.aclose()
return MCPServerConfig.model_validate(config)
@staticmethod
async def update_template_status(mcp_id: str, status: MCPInstallStatus) -> None:
"""
更新数据库中MCP模板状态
:param str mcp_id: MCP模板ID
:param MCPStatus status: MCP模板status
:return: 无
"""
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
logger.info("[MCPLoader] 更新MCP模板状态: %s -> %s", mcp_id, status)
await mcp_collection.update_one(
{"_id": mcp_id},
{"$set": {"status": status}},
upsert=True,
)
@staticmethod
async def user_active_template(user_sub: str, mcp_id: str, mcp_env: dict[str, Any] | None = None) -> None:
"""
用户激活MCP模板
激活MCP模板时,将已安装的环境拷贝一份到用户目录,并更新数据库
:param str user_sub: 用户ID
:param str mcp_id: MCP模板ID
:return: 无
:raises FileExistsError: MCP模板已存在或有同名文件,无法激活
"""
template_path = MCP_PATH / "template" / mcp_id
user_path = MCP_PATH / "users" / user_sub / mcp_id
if await user_path.exists():
err = f"MCP模板“{mcp_id}”已存在或有同名文件,无法激活"
raise FileExistsError(err)
mcp_config = await MCPLoader.get_config(mcp_id)
await asyncer.asyncify(shutil.copytree)(
template_path.as_posix(),
user_path.as_posix(),
dirs_exist_ok=True,
symlinks=True,
)
if mcp_env is not None:
if mcp_config.type == MCPType.STDIO:
mcp_config.config.env.update(mcp_env)
else:
mcp_config.config.headers.update(mcp_env)
if mcp_config.type == MCPType.STDIO:
index = None
for i in range(len(mcp_config.config.args)):
if mcp_config.config.args[i] == "--directory":
index = i + 1
break
if index is not None:
if index < len(mcp_config.config.args):
mcp_config.config.args[index] = str(user_path)+'/project'
else:
mcp_config.config.args.append(str(user_path)+'/project')
else:
mcp_config.config.args = ["--directory", str(user_path)+'/project'] + mcp_config.config.args
user_config_path = user_path / "config.json"
f = await user_config_path.open("w", encoding="utf-8", errors="ignore")
await f.write(
json.dumps(
mcp_config.model_dump(by_alias=True, exclude_none=True),
indent=4,
ensure_ascii=False,
)
)
await f.aclose()
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
await mcp_collection.update_one(
{"_id": mcp_id},
{"$addToSet": {"activated": user_sub}},
)
@staticmethod
async def user_deactive_template(user_sub: str, mcp_id: str) -> None:
"""
取消激活MCP模板
取消激活MCP模板时,删除用户目录下对应的MCP环境文件夹,并更新数据库
:param str user_sub: 用户ID
:param str mcp_id: MCP模板ID
:return: 无
"""
user_path = MCP_PATH / "users" / user_sub / mcp_id
await asyncer.asyncify(shutil.rmtree)(user_path.as_posix(), ignore_errors=True)
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
await mcp_collection.update_one(
{"_id": mcp_id},
{"$pull": {"activated": user_sub}},
)
@staticmethod
async def _find_deleted_mcp() -> list[str]:
"""
查找在文件系统中被修改和被删除的MCP
:return: 被修改的MCP列表和被删除的MCP列表
:rtype: tuple[list[str], list[str]]
"""
deleted_mcp_list = []
mcp_collection = MongoDB().get_collection("mcp")
mcp_list = await mcp_collection.find({}, {"_id": 1}).to_list(None)
for db_item in mcp_list:
mcp_path: Path = MCP_PATH / "template" / db_item["_id"]
if not await mcp_path.exists():
deleted_mcp_list.append(db_item["_id"])
logger.info("[MCPLoader] 这些MCP在文件系统中被删除: %s", deleted_mcp_list)
return deleted_mcp_list
@staticmethod
async def cancel_installing_task(cancel_mcp_list: list[str]) -> None:
"""
取消正在安装的MCP模板任务
:param list[str] cancel_mcp_list: 需要取消的MCP列表
:return: 无
"""
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
cancel_mcp_list = await mcp_collection.distinct("_id", {"_id": {"$in": cancel_mcp_list}, "status": MCPInstallStatus.INSTALLING})
await mcp_collection.update_many(
{"_id": {"$in": cancel_mcp_list}, "status": MCPInstallStatus.INSTALLING},
{"$set": {"status": MCPInstallStatus.CANCELLED}},
)
for mcp_id in cancel_mcp_list:
ProcessHandler.remove_task(mcp_id)
logger.info("[MCPLoader] 取消这些正在安装的MCP模板任务: %s", cancel_mcp_list)
@staticmethod
async def remove_deleted_mcp(deleted_mcp_list: list[str]) -> None:
"""
删除无效的MCP在数据库中的记录
:param list[str] deleted_mcp_list: 被删除的MCP列表
:return: 无
"""
mcp_collection = MongoDB().get_collection("mcp")
mcp_service_list = await mcp_collection.find(
{"_id": {"$in": deleted_mcp_list}},
).to_list(None)
for mcp_service in mcp_service_list:
item = MCPCollection.model_validate(mcp_service)
ProcessHandler.remove_task(item.id)
for user_sub in item.activated:
await MCPLoader.user_deactive_template(user_sub=user_sub, mcp_id=item.id)
await mcp_collection.delete_many({"_id": {"$in": deleted_mcp_list}})
logger.info("[MCPLoader] 清除数据库中无效的MCP")
for mcp_id in deleted_mcp_list:
while True:
try:
mcp_table = await LanceDB().get_table("mcp")
await mcp_table.delete(f"id == '{mcp_id}'")
break
except Exception as e:
if "Commit conflict" in str(e):
logger.error("[MCPLoader] LanceDB删除mcp冲突,重试中...")
await asyncio.sleep(0.01)
else:
raise
logger.info("[MCPLoader] 清除LanceDB中无效的MCP")
@staticmethod
async def delete_mcp(mcp_id: str) -> None:
"""
删除MCP
:param str mcp_id: 被删除的MCP ID
:return: 无
"""
await MCPLoader.remove_deleted_mcp([mcp_id])
template_path = MCP_PATH / "template" / mcp_id
if await template_path.exists():
await asyncer.asyncify(shutil.rmtree)(template_path.as_posix(), ignore_errors=True)
@staticmethod
async def _load_user_mcp() -> None:
"""
加载用户MCP
:return: 用户MCP列表
:rtype: dict[str, list[str]]
"""
user_path = MCP_PATH / "users"
if not await user_path.exists():
logger.warning("[MCPLoader] users目录不存在,跳过加载用户MCP")
return
mongo = MongoDB()
mcp_collection = mongo.get_collection("mcp")
mcp_list = {}
async for user_dir in user_path.iterdir():
if not await user_dir.is_dir():
continue
async for mcp_dir in user_dir.iterdir():
mcp_item = await mcp_collection.find_one({"_id": mcp_dir.name})
if not mcp_item:
await asyncer.asyncify(shutil.rmtree)(mcp_dir.as_posix(), ignore_errors=True)
if mcp_dir.name not in mcp_list:
mcp_list[mcp_dir.name] = []
mcp_list[mcp_dir.name].append(user_dir.name)
for mcp_id, user_list in mcp_list.items():
await mcp_collection.update_one(
{"_id": mcp_id},
{"$set": {"activated": user_list}},
)
@staticmethod
async def init() -> None:
"""
初始化MCP加载器
:return: 无
"""
deleted_mcp_list = await MCPLoader._find_deleted_mcp()
await MCPLoader.remove_deleted_mcp(deleted_mcp_list)
await MCPLoader._check_dir()
await MCPLoader.cancel_all_installing_task()
await MCPLoader._load_user_mcp()