feat: 新增支持动态配置mcp功能
This commit is contained in:
parent
59b7193762
commit
c360252e56
@ -6,6 +6,7 @@ from server.routers.dashboard_router import dashboard
|
||||
from server.routers.graph_router import graph
|
||||
from server.routers.knowledge_router import knowledge
|
||||
from server.routers.evaluation_router import evaluation
|
||||
from server.routers.mcp_router import mcp
|
||||
from server.routers.mindmap_router import mindmap
|
||||
from server.routers.system_router import system
|
||||
from server.routers.task_router import tasks
|
||||
@ -22,3 +23,5 @@ router.include_router(evaluation) # /api/evaluation/*
|
||||
router.include_router(mindmap) # /api/mindmap/*
|
||||
router.include_router(graph) # /api/graph/*
|
||||
router.include_router(tasks) # /api/tasks/*
|
||||
router.include_router(mcp) # /api/system/mcp-servers/*
|
||||
|
||||
|
||||
420
server/routers/mcp_router.py
Normal file
420
server/routers/mcp_router.py
Normal file
@ -0,0 +1,420 @@
|
||||
"""MCP 服务器管理路由"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.agents.common.mcp import (
|
||||
clear_mcp_server_tools_cache,
|
||||
get_mcp_tools,
|
||||
sync_mcp_server_to_cache,
|
||||
)
|
||||
from src.storage.db.models import MCPServer, User
|
||||
from src.utils import logger
|
||||
from server.utils.auth_middleware import get_admin_user, get_db
|
||||
|
||||
mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 服务器 CRUD ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.get("")
|
||||
async def get_mcp_servers(
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取所有 MCP 服务器配置"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer))
|
||||
servers = result.scalars().all()
|
||||
return {"success": True, "data": [s.to_dict() for s in servers]}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP servers: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.post("")
|
||||
async def create_mcp_server(
|
||||
name: str = Body(..., description="服务器名称"),
|
||||
transport: str = Body(..., description="传输类型:sse/streamable_http"),
|
||||
url: str = Body(..., description="服务器 URL"),
|
||||
description: str = Body(None, description="描述"),
|
||||
headers: dict = Body(None, description="HTTP 请求头"),
|
||||
timeout: int = Body(None, description="HTTP 超时时间(秒)"),
|
||||
sse_read_timeout: int = Body(None, description="SSE 读取超时(秒)"),
|
||||
tags: list = Body(None, description="标签数组"),
|
||||
icon: str = Body(None, description="图标(emoji)"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建新的 MCP 服务器"""
|
||||
# 校验传输类型
|
||||
if transport not in ("sse", "streamable_http"):
|
||||
raise HTTPException(status_code=400, detail="传输类型必须是 sse 或 streamable_http")
|
||||
|
||||
try:
|
||||
# 检查名称是否已存在
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"服务器名称 '{name}' 已存在")
|
||||
|
||||
server = MCPServer(
|
||||
name=name,
|
||||
description=description,
|
||||
transport=transport,
|
||||
url=url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
sse_read_timeout=sse_read_timeout,
|
||||
tags=tags,
|
||||
icon=icon,
|
||||
enabled=1,
|
||||
created_by=current_user.username,
|
||||
updated_by=current_user.username,
|
||||
)
|
||||
db.add(server)
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
# 同步到缓存
|
||||
sync_mcp_server_to_cache(name, server.to_mcp_config())
|
||||
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.get("/{name}")
|
||||
async def get_mcp_server(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取单个 MCP 服务器配置"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{name}")
|
||||
async def update_mcp_server(
|
||||
name: str,
|
||||
description: str = Body(None, description="描述"),
|
||||
transport: str = Body(None, description="传输类型"),
|
||||
url: str = Body(None, description="服务器 URL"),
|
||||
headers: dict = Body(None, description="HTTP 请求头"),
|
||||
timeout: int = Body(None, description="HTTP 超时时间(秒)"),
|
||||
sse_read_timeout: int = Body(None, description="SSE 读取超时(秒)"),
|
||||
tags: list = Body(None, description="标签数组"),
|
||||
icon: str = Body(None, description="图标(emoji)"),
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 MCP 服务器配置"""
|
||||
# 校验传输类型
|
||||
if transport is not None and transport not in ("sse", "streamable_http"):
|
||||
raise HTTPException(status_code=400, detail="传输类型必须是 sse 或 streamable_http")
|
||||
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
# 更新字段
|
||||
if description is not None:
|
||||
server.description = description
|
||||
if transport is not None:
|
||||
server.transport = transport
|
||||
if url is not None:
|
||||
server.url = url
|
||||
if headers is not None:
|
||||
server.headers = headers
|
||||
if timeout is not None:
|
||||
server.timeout = timeout
|
||||
if sse_read_timeout is not None:
|
||||
server.sse_read_timeout = sse_read_timeout
|
||||
if tags is not None:
|
||||
server.tags = tags
|
||||
if icon is not None:
|
||||
server.icon = icon
|
||||
|
||||
server.updated_by = current_user.username
|
||||
await db.commit()
|
||||
await db.refresh(server)
|
||||
|
||||
# 同步到缓存(如果启用)
|
||||
if server.enabled:
|
||||
sync_mcp_server_to_cache(name, server.to_mcp_config())
|
||||
|
||||
return {"success": True, "data": server.to_dict()}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.delete("/{name}")
|
||||
async def delete_mcp_server(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除 MCP 服务器"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
await db.delete(server)
|
||||
await db.commit()
|
||||
|
||||
# 从缓存中删除
|
||||
sync_mcp_server_to_cache(name, None)
|
||||
|
||||
return {"success": True, "message": f"服务器 '{name}' 已删除"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 服务器操作 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.post("/{name}/test")
|
||||
async def test_mcp_server(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""测试 MCP 服务器连接"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
# 获取配置用于测试
|
||||
config = server.to_mcp_config()
|
||||
|
||||
try:
|
||||
tools = await get_mcp_tools(name, {name: config})
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功,共发现 {len(tools)} 个工具",
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
except Exception as test_error:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接失败: {str(test_error)}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{name}/toggle")
|
||||
async def toggle_mcp_server(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换 MCP 服务器启用状态"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
# 切换状态
|
||||
server.enabled = 0 if server.enabled else 1
|
||||
server.updated_by = current_user.username
|
||||
await db.commit()
|
||||
|
||||
# 获取更新后的状态
|
||||
is_enabled = bool(server.enabled)
|
||||
server_config = server.to_mcp_config() if is_enabled else None
|
||||
|
||||
# 同步到缓存
|
||||
sync_mcp_server_to_cache(name, server_config)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"enabled": is_enabled,
|
||||
"message": f"服务器 '{name}' 已{'启用' if is_enabled else '禁用'}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle MCP server: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# === MCP 工具管理 ===
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.get("/{name}/tools")
|
||||
async def get_mcp_server_tools(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 MCP 服务器的工具列表"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
# 获取配置
|
||||
config = server.to_mcp_config()
|
||||
disabled_tools = server.disabled_tools or []
|
||||
|
||||
try:
|
||||
tools = await get_mcp_tools(name, {name: config})
|
||||
tool_list = []
|
||||
|
||||
for tool in tools:
|
||||
original_name = tool.name
|
||||
unique_id = tool.metadata.get("id") if tool.metadata else original_name
|
||||
|
||||
tool_info = {
|
||||
"name": original_name,
|
||||
"id": unique_id,
|
||||
"description": getattr(tool, "description", ""),
|
||||
"enabled": original_name not in disabled_tools,
|
||||
}
|
||||
# 提取参数信息
|
||||
if hasattr(tool, "args_schema") and tool.args_schema:
|
||||
schema = tool.args_schema.schema() if hasattr(tool.args_schema, "schema") else {}
|
||||
tool_info["parameters"] = schema.get("properties", {})
|
||||
tool_info["required"] = schema.get("required", [])
|
||||
else:
|
||||
tool_info["parameters"] = {}
|
||||
tool_info["required"] = []
|
||||
tool_list.append(tool_info)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": tool_list,
|
||||
"total": len(tool_list),
|
||||
}
|
||||
except Exception as tool_error:
|
||||
logger.error(f"Failed to get tools from MCP server '{name}': {tool_error}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"获取工具失败: {str(tool_error)}",
|
||||
"data": [],
|
||||
"total": 0,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MCP server tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.post("/{name}/tools/refresh")
|
||||
async def refresh_mcp_server_tools(
|
||||
name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""刷新 MCP 服务器的工具列表(清除缓存重新获取)"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
# 清除该服务器的工具缓存
|
||||
clear_mcp_server_tools_cache(name)
|
||||
|
||||
# 获取配置
|
||||
config = server.to_mcp_config()
|
||||
|
||||
try:
|
||||
tools = await get_mcp_tools(name, {name: config})
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"工具列表已刷新,共发现 {len(tools)} 个工具",
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
except Exception as tool_error:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"刷新失败: {str(tool_error)}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh MCP server tools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@mcp.put("/{name}/tools/{tool_name}/toggle")
|
||||
async def toggle_mcp_server_tool(
|
||||
name: str,
|
||||
tool_name: str,
|
||||
current_user: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""切换单个工具的启用状态"""
|
||||
try:
|
||||
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
|
||||
server = result.scalar_one_or_none()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail=f"服务器 '{name}' 不存在")
|
||||
|
||||
disabled_tools = list(server.disabled_tools or [])
|
||||
|
||||
if tool_name in disabled_tools:
|
||||
# 当前禁用,改为启用
|
||||
disabled_tools.remove(tool_name)
|
||||
enabled = True
|
||||
else:
|
||||
# 当前启用,改为禁用
|
||||
disabled_tools.append(tool_name)
|
||||
enabled = False
|
||||
|
||||
server.disabled_tools = disabled_tools
|
||||
server.updated_by = current_user.username
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tool_name": tool_name,
|
||||
"enabled": enabled,
|
||||
"message": f"工具 '{tool_name}' 已{'启用' if enabled else '禁用'}",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle MCP server tool: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@ -3,11 +3,16 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
from server.services import tasker
|
||||
from src.agents.common.mcp import init_mcp_servers
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await tasker.start()
|
||||
"""FastAPI lifespan事件管理器"""
|
||||
# 初始化 MCP 服务器配置
|
||||
await init_mcp_servers()
|
||||
|
||||
await tasker.start()
|
||||
yield
|
||||
await tasker.shutdown()
|
||||
|
||||
|
||||
@ -11,30 +11,106 @@ from src.utils import logger
|
||||
# Global MCP tools cache
|
||||
_mcp_tools_cache: dict[str, list[Callable[..., Any]]] = {}
|
||||
|
||||
# MCP Server configurations
|
||||
MCP_SERVERS = {
|
||||
# MCP Server configurations(运行时缓存,从数据库加载)
|
||||
MCP_SERVERS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# 默认 MCP 服务器配置(首次启动时导入数据库)
|
||||
_DEFAULT_MCP_SERVERS = {
|
||||
"sequentialthinking": {
|
||||
"url": "https://remote.mcpservers.org/sequentialthinking/mcp",
|
||||
"transport": "streamable_http",
|
||||
"description": "顺序思考工具,帮助 AI 将复杂问题分解为多个步骤",
|
||||
"icon": "🧠",
|
||||
"tags": ["工具", "AI"],
|
||||
},
|
||||
# "zhipu-web-search-sse": {
|
||||
# "url": f"https://open.bigmodel.cn/api/mcp/web_search/sse?Authorization={os.getenv('ZHIPUAI_API_KEY')}",
|
||||
# "transport": "streamable_http",
|
||||
# },
|
||||
# 这些 stdio 的 MCP server 需要在本地启动,启动的时候需要安装对应的包,需要时间
|
||||
# "time": {
|
||||
# "command": "uvx",
|
||||
# "args": ["mcp-server-time"],
|
||||
# "transport": "stdio",
|
||||
# },
|
||||
# "mcp_server_chart": {
|
||||
# "command": "npx",
|
||||
# "args": ["-y", "@antv/mcp-server-chart"],
|
||||
# "transport": "stdio"
|
||||
# },
|
||||
# 更多用法参考:https://xerrors.github.io/Yuxi-Know/latest/advanced/agents-config.html#内置工具与-mcp-集成
|
||||
}
|
||||
|
||||
async def load_mcp_servers_from_db() -> None:
|
||||
"""从数据库加载所有启用的 MCP 服务器配置到 MCP_SERVERS 缓存"""
|
||||
global MCP_SERVERS
|
||||
|
||||
# 延迟导入以避免循环引用
|
||||
from sqlalchemy import select
|
||||
from src.storage.db.manager import db_manager
|
||||
from src.storage.db.models import MCPServer
|
||||
|
||||
try:
|
||||
async with db_manager.get_async_session_context() as session:
|
||||
result = await session.execute(select(MCPServer).filter(MCPServer.enabled == 1))
|
||||
servers = result.scalars().all()
|
||||
MCP_SERVERS.clear()
|
||||
for server in servers:
|
||||
MCP_SERVERS[server.name] = server.to_mcp_config()
|
||||
logger.info(f"Loaded {len(MCP_SERVERS)} MCP servers from database: {list(MCP_SERVERS.keys())}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load MCP servers from database: {e}")
|
||||
|
||||
|
||||
def sync_mcp_server_to_cache(name: str, config: dict[str, Any] | None) -> None:
|
||||
"""同步单个 MCP 服务器配置到缓存
|
||||
|
||||
Args:
|
||||
name: 服务器名称
|
||||
config: 服务器配置,如果为 None 则从缓存中删除
|
||||
"""
|
||||
global MCP_SERVERS
|
||||
|
||||
if config is None:
|
||||
MCP_SERVERS.pop(name, None)
|
||||
logger.info(f"Removed MCP server '{name}' from cache")
|
||||
else:
|
||||
MCP_SERVERS[name] = config
|
||||
logger.info(f"Synced MCP server '{name}' to cache")
|
||||
|
||||
# 清除该服务器的工具缓存
|
||||
_mcp_tools_cache.pop(name, None)
|
||||
|
||||
|
||||
async def init_mcp_servers() -> None:
|
||||
"""初始化 MCP 服务器配置
|
||||
|
||||
首次启动时,如果数据库为空,将默认配置导入数据库
|
||||
然后从数据库加载配置到 MCP_SERVERS 缓存
|
||||
"""
|
||||
# 延迟导入以避免循环引用
|
||||
from sqlalchemy import func, select
|
||||
from src.storage.db.manager import db_manager
|
||||
from src.storage.db.models import MCPServer
|
||||
|
||||
try:
|
||||
async with db_manager.get_async_session_context() as session:
|
||||
# 检查数据库是否有 MCP 配置
|
||||
result = await session.execute(select(func.count(MCPServer.name)))
|
||||
count = result.scalar()
|
||||
|
||||
if count == 0:
|
||||
# 数据库为空,导入默认配置
|
||||
logger.info("No MCP servers in database, importing default configurations...")
|
||||
for name, config in _DEFAULT_MCP_SERVERS.items():
|
||||
server = MCPServer(
|
||||
name=name,
|
||||
description=config.get("description"),
|
||||
transport=config["transport"],
|
||||
url=config["url"],
|
||||
headers=config.get("headers"),
|
||||
timeout=config.get("timeout"),
|
||||
sse_read_timeout=config.get("sse_read_timeout"),
|
||||
tags=config.get("tags"),
|
||||
icon=config.get("icon"),
|
||||
enabled=1,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
session.add(server)
|
||||
await session.commit()
|
||||
logger.info(f"Imported {len(_DEFAULT_MCP_SERVERS)} default MCP servers to database")
|
||||
|
||||
# 从数据库加载配置到缓存
|
||||
await load_mcp_servers_from_db()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize MCP servers: {e}, traceback: {traceback.format_exc()}")
|
||||
|
||||
|
||||
async def get_mcp_client(
|
||||
server_configs: dict[str, Any] | None = None,
|
||||
@ -49,8 +125,19 @@ async def get_mcp_client(
|
||||
return None
|
||||
|
||||
|
||||
def to_camel_case(s: str) -> str:
|
||||
"""将字符串转换为小驼峰格式"""
|
||||
import re
|
||||
# 处理 - 和 _
|
||||
s = re.sub(r'[-_]+(.)', lambda m: m.group(1).upper(), s)
|
||||
# 首字母小写
|
||||
if len(s) > 0:
|
||||
s = s[0].lower() + s[1:]
|
||||
return s
|
||||
|
||||
|
||||
async def get_mcp_tools(server_name: str, additional_servers: dict[str, dict] = None) -> list[Callable[..., Any]]:
|
||||
"""Get MCP tools for a specific server, initializing client if needed."""
|
||||
"""Get MCP tools for a specific server, initializing client if needed and rendering unique IDs."""
|
||||
global _mcp_tools_cache
|
||||
|
||||
# Return cached tools if available
|
||||
@ -65,13 +152,30 @@ async def get_mcp_tools(server_name: str, additional_servers: dict[str, dict] =
|
||||
if client is None:
|
||||
return []
|
||||
|
||||
# Get all tools and filter by server (if tools have server metadata)
|
||||
# Get all tools
|
||||
all_tools = await client.get_tools()
|
||||
tools = cast(list[Callable[..., Any]], all_tools)
|
||||
raw_tools = cast(list[Any], all_tools)
|
||||
|
||||
_mcp_tools_cache[server_name] = tools
|
||||
logger.info(f"Loaded {len(tools)} tools from MCP server '{server_name}'")
|
||||
return tools
|
||||
# 渲染 ID 规则: mcp__[camelCaseServer]__[camelCaseTool]
|
||||
server_cc = to_camel_case(server_name)
|
||||
processed_tools = []
|
||||
|
||||
for tool in raw_tools:
|
||||
# 渲染唯一 ID 规则: mcp__[camelCaseServer]__[camelCaseTool]
|
||||
original_name = tool.name
|
||||
tool_cc = to_camel_case(original_name)
|
||||
unique_id = f"mcp__{server_cc}__{tool_cc}"
|
||||
|
||||
# 使用 metadata 存储,这是 LangChain 工具扩展属性的标准做法
|
||||
if tool.metadata is None:
|
||||
tool.metadata = {}
|
||||
tool.metadata["id"] = unique_id
|
||||
|
||||
processed_tools.append(tool)
|
||||
|
||||
_mcp_tools_cache[server_name] = processed_tools
|
||||
logger.info(f"Loaded {len(processed_tools)} tools from MCP server '{server_name}' with extra tool IDs")
|
||||
return processed_tools
|
||||
except AssertionError as e:
|
||||
logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}")
|
||||
return []
|
||||
@ -100,3 +204,10 @@ def clear_mcp_cache() -> None:
|
||||
"""Clear the MCP tools cache (useful for testing)."""
|
||||
global _mcp_tools_cache
|
||||
_mcp_tools_cache = {}
|
||||
|
||||
|
||||
def clear_mcp_server_tools_cache(server_name: str) -> None:
|
||||
"""Clear the tools cache for a specific MCP server."""
|
||||
global _mcp_tools_cache
|
||||
_mcp_tools_cache.pop(server_name, None)
|
||||
logger.info(f"Cleared tools cache for MCP server '{server_name}'")
|
||||
|
||||
@ -348,3 +348,76 @@ class MessageFeedback(Base):
|
||||
"reason": self.reason,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
}
|
||||
|
||||
|
||||
class MCPServer(Base):
|
||||
"""MCP 服务器配置模型"""
|
||||
|
||||
__tablename__ = "mcp_servers"
|
||||
|
||||
# 核心字段 - name 作为主键
|
||||
name = Column(String(100), primary_key=True, comment="服务器名称(唯一标识)")
|
||||
description = Column(String(500), nullable=True, comment="描述")
|
||||
|
||||
# 连接配置
|
||||
transport = Column(String(20), nullable=False, comment="传输类型:sse/streamable_http")
|
||||
url = Column(String(500), nullable=False, comment="服务器 URL")
|
||||
headers = Column(JSON, nullable=True, comment="HTTP 请求头")
|
||||
timeout = Column(Integer, nullable=True, comment="HTTP 超时时间(秒)")
|
||||
sse_read_timeout = Column(Integer, nullable=True, comment="SSE 读取超时(秒)")
|
||||
|
||||
# UI 增强字段
|
||||
tags = Column(JSON, nullable=True, comment="标签数组")
|
||||
icon = Column(String(50), nullable=True, comment="图标(emoji)")
|
||||
|
||||
# 状态字段
|
||||
enabled = Column(Integer, nullable=False, default=1, comment="是否启用:1=是,0=否")
|
||||
disabled_tools = Column(JSON, nullable=True, comment="禁用的工具名称列表")
|
||||
|
||||
# 用户追踪
|
||||
created_by = Column(String(100), nullable=False, comment="创建人用户名")
|
||||
updated_by = Column(String(100), nullable=False, comment="修改人用户名")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=utc_now, comment="创建时间")
|
||||
updated_at = Column(DateTime, default=utc_now, onupdate=utc_now, comment="更新时间")
|
||||
|
||||
def to_dict(self):
|
||||
def format_utc_datetime(dt_value):
|
||||
if dt_value is None:
|
||||
return None
|
||||
if dt_value.tzinfo is None:
|
||||
dt_value = dt_value.replace(tzinfo=dt.UTC)
|
||||
return utc_isoformat(dt_value)
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"transport": self.transport,
|
||||
"url": self.url,
|
||||
"headers": self.headers or {},
|
||||
"timeout": self.timeout,
|
||||
"sse_read_timeout": self.sse_read_timeout,
|
||||
"tags": self.tags or [],
|
||||
"icon": self.icon,
|
||||
"enabled": bool(self.enabled),
|
||||
"disabled_tools": self.disabled_tools or [],
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
"updated_at": format_utc_datetime(self.updated_at),
|
||||
}
|
||||
|
||||
def to_mcp_config(self) -> dict:
|
||||
"""转换为 MCP 配置格式(用于加载到 MCP_SERVERS 缓存)"""
|
||||
config = {
|
||||
"transport": self.transport,
|
||||
"url": self.url,
|
||||
}
|
||||
if self.headers:
|
||||
config["headers"] = self.headers
|
||||
if self.timeout is not None:
|
||||
config["timeout"] = self.timeout
|
||||
if self.sse_read_timeout is not None:
|
||||
config["sse_read_timeout"] = self.sse_read_timeout
|
||||
return config
|
||||
|
||||
133
web/src/apis/mcp_api.js
Normal file
133
web/src/apis/mcp_api.js
Normal file
@ -0,0 +1,133 @@
|
||||
import { apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
|
||||
|
||||
/**
|
||||
* MCP 服务器管理 API 模块
|
||||
* 包含 MCP 服务器的增删改查和工具管理功能
|
||||
*/
|
||||
|
||||
const BASE_URL = '/api/system/mcp-servers'
|
||||
|
||||
// =============================================================================
|
||||
// === MCP 服务器 CRUD ===
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 获取所有 MCP 服务器配置
|
||||
* @returns {Promise} - 服务器列表
|
||||
*/
|
||||
export const getMcpServers = async () => {
|
||||
return apiAdminGet(BASE_URL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 MCP 服务器配置
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 服务器配置
|
||||
*/
|
||||
export const getMcpServer = async (name) => {
|
||||
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的 MCP 服务器
|
||||
* @param {Object} data - 服务器配置数据
|
||||
* @returns {Promise} - 创建结果
|
||||
*/
|
||||
export const createMcpServer = async (data) => {
|
||||
return apiAdminPost(BASE_URL, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 MCP 服务器配置
|
||||
* @param {string} name - 服务器名称
|
||||
* @param {Object} data - 更新数据
|
||||
* @returns {Promise} - 更新结果
|
||||
*/
|
||||
export const updateMcpServer = async (name, data) => {
|
||||
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(name)}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 MCP 服务器
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 删除结果
|
||||
*/
|
||||
export const deleteMcpServer = async (name) => {
|
||||
return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// === MCP 服务器操作 ===
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 测试 MCP 服务器连接
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 测试结果
|
||||
*/
|
||||
export const testMcpServer = async (name) => {
|
||||
return apiAdminPost(`${BASE_URL}/${encodeURIComponent(name)}/test`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 MCP 服务器启用状态
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 切换结果
|
||||
*/
|
||||
export const toggleMcpServer = async (name) => {
|
||||
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(name)}/toggle`, {})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// === MCP 工具管理 ===
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 获取 MCP 服务器的工具列表
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 工具列表
|
||||
*/
|
||||
export const getMcpServerTools = async (name) => {
|
||||
return apiAdminGet(`${BASE_URL}/${encodeURIComponent(name)}/tools`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 MCP 服务器的工具列表(清除缓存重新获取)
|
||||
* @param {string} name - 服务器名称
|
||||
* @returns {Promise} - 刷新结果
|
||||
*/
|
||||
export const refreshMcpServerTools = async (name) => {
|
||||
return apiAdminPost(`${BASE_URL}/${encodeURIComponent(name)}/tools/refresh`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换单个工具的启用状态
|
||||
* @param {string} serverName - 服务器名称
|
||||
* @param {string} toolName - 工具名称
|
||||
* @returns {Promise} - 切换结果
|
||||
*/
|
||||
export const toggleMcpServerTool = async (serverName, toolName) => {
|
||||
return apiAdminPut(
|
||||
`${BASE_URL}/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// === 导出为对象形式(兼容现有代码风格)===
|
||||
// =============================================================================
|
||||
|
||||
export const mcpApi = {
|
||||
getMcpServers,
|
||||
getMcpServer,
|
||||
createMcpServer,
|
||||
updateMcpServer,
|
||||
deleteMcpServer,
|
||||
testMcpServer,
|
||||
toggleMcpServer,
|
||||
getMcpServerTools,
|
||||
refreshMcpServerTools,
|
||||
toggleMcpServerTool,
|
||||
}
|
||||
|
||||
export default mcpApi
|
||||
604
web/src/components/McpServerDetailModal.vue
Normal file
604
web/src/components/McpServerDetailModal.vue
Normal file
@ -0,0 +1,604 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="server?.name || 'MCP 服务器详情'"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
@cancel="handleClose"
|
||||
class="mcp-detail-modal"
|
||||
>
|
||||
<div class="detail-container" v-if="server">
|
||||
<!-- 头部状态 -->
|
||||
<div class="detail-header">
|
||||
<div class="server-info">
|
||||
<span class="server-icon">{{ server.icon || '🔌' }}</span>
|
||||
<div class="server-meta">
|
||||
<h3 class="server-name">{{ server.name }}</h3>
|
||||
<span class="server-status" :class="{ enabled: server.enabled }">
|
||||
{{ server.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button @click="handleTestConnection" :loading="testLoading">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
测试连接
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 导航 -->
|
||||
<a-tabs v-model:activeKey="activeTab" class="detail-tabs">
|
||||
<a-tab-pane key="general" tab="通用">
|
||||
<div class="tab-content">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>传输类型</label>
|
||||
<span>
|
||||
<a-tag :color="server.transport === 'sse' ? 'orange' : 'blue'">
|
||||
{{ server.transport }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>服务器 URL</label>
|
||||
<span class="url-text">{{ server.url }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.description">
|
||||
<label>描述</label>
|
||||
<span>{{ server.description }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.timeout">
|
||||
<label>HTTP 超时</label>
|
||||
<span>{{ server.timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.sse_read_timeout">
|
||||
<label>SSE 读取超时</label>
|
||||
<span>{{ server.sse_read_timeout }} 秒</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.headers && Object.keys(server.headers).length > 0">
|
||||
<label>请求头</label>
|
||||
<pre class="headers-pre">{{ JSON.stringify(server.headers, null, 2) }}</pre>
|
||||
</div>
|
||||
<div class="info-item" v-if="server.tags && server.tags.length > 0">
|
||||
<label>标签</label>
|
||||
<span>
|
||||
<a-tag v-for="tag in server.tags" :key="tag">{{ tag }}</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建时间</label>
|
||||
<span>{{ formatTime(server.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>更新时间</label>
|
||||
<span>{{ formatTime(server.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>创建人</label>
|
||||
<span>{{ server.created_by }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tools" :tab="`工具 (${tools.length})`">
|
||||
<div class="tab-content tools-tab">
|
||||
<!-- 工具栏 -->
|
||||
<div class="tools-toolbar">
|
||||
<a-input-search
|
||||
v-model:value="toolSearchText"
|
||||
placeholder="搜索工具..."
|
||||
style="width: 240px"
|
||||
allowClear
|
||||
/>
|
||||
<a-button @click="handleRefreshTools" :loading="toolsLoading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新工具
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 工具列表 -->
|
||||
<a-spin :spinning="toolsLoading">
|
||||
<div v-if="filteredTools.length === 0" class="empty-tools">
|
||||
<a-empty :description="toolsError || '暂无工具'" />
|
||||
</div>
|
||||
<div v-else class="tools-list">
|
||||
<div
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.name"
|
||||
class="tool-card"
|
||||
:class="{ disabled: !tool.enabled }"
|
||||
>
|
||||
<div class="tool-header">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<a-tooltip :title="`ID: ${tool.id}`">
|
||||
<InfoCircleOutlined class="info-icon" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div class="tool-actions">
|
||||
<a-switch
|
||||
:checked="tool.enabled"
|
||||
@change="handleToggleTool(tool)"
|
||||
:loading="toggleToolLoading === tool.name"
|
||||
size="small"
|
||||
/>
|
||||
<a-tooltip title="复制工具名称">
|
||||
<a-button type="text" size="small" @click="copyToolName(tool.name)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-description" v-if="tool.description">
|
||||
{{ tool.description }}
|
||||
</div>
|
||||
<a-collapse v-if="tool.parameters && Object.keys(tool.parameters).length > 0" ghost>
|
||||
<a-collapse-panel key="params" header="参数">
|
||||
<div class="params-list">
|
||||
<div
|
||||
v-for="(param, paramName) in tool.parameters"
|
||||
:key="paramName"
|
||||
class="param-item"
|
||||
>
|
||||
<div class="param-header">
|
||||
<span class="param-name">{{ paramName }}</span>
|
||||
<span class="param-required" v-if="tool.required?.includes(paramName)">必填</span>
|
||||
<span class="param-type">{{ param.type || 'any' }}</span>
|
||||
</div>
|
||||
<div class="param-desc" v-if="param.description">
|
||||
{{ param.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="prompts" tab="提示">
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="提示功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📝</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="resources" tab="资源">
|
||||
<div class="tab-content empty-tab">
|
||||
<a-empty description="资源功能即将推出">
|
||||
<template #image>
|
||||
<span style="font-size: 48px">📦</span>
|
||||
</template>
|
||||
</a-empty>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { notification } from 'ant-design-vue'
|
||||
import {
|
||||
ApiOutlined,
|
||||
ReloadOutlined,
|
||||
InfoCircleOutlined,
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import { formatDateTime } from '@/utils/time'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
server: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'update'])
|
||||
|
||||
// 状态
|
||||
const activeTab = ref('general')
|
||||
const tools = ref([])
|
||||
const toolsLoading = ref(false)
|
||||
const toolsError = ref(null)
|
||||
const toolSearchText = ref('')
|
||||
const testLoading = ref(false)
|
||||
const toggleToolLoading = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const modalVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
})
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
if (!toolSearchText.value) return tools.value
|
||||
const search = toolSearchText.value.toLowerCase()
|
||||
return tools.value.filter(t =>
|
||||
t.name.toLowerCase().includes(search) ||
|
||||
(t.description && t.description.toLowerCase().includes(search))
|
||||
)
|
||||
})
|
||||
|
||||
// 监听服务器变化,加载工具列表
|
||||
watch(() => props.server, (newServer) => {
|
||||
if (newServer) {
|
||||
activeTab.value = 'general'
|
||||
fetchTools()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 获取工具列表
|
||||
const fetchTools = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toolsLoading.value = true
|
||||
toolsError.value = null
|
||||
const result = await mcpApi.getMcpServerTools(props.server.name)
|
||||
if (result.success) {
|
||||
tools.value = result.data || []
|
||||
} else {
|
||||
toolsError.value = result.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取工具列表失败:', err)
|
||||
toolsError.value = err.message || '获取工具列表失败'
|
||||
tools.value = []
|
||||
} finally {
|
||||
toolsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新工具列表
|
||||
const handleRefreshTools = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toolsLoading.value = true
|
||||
const result = await mcpApi.refreshMcpServerTools(props.server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
await fetchTools()
|
||||
} else {
|
||||
notification.error({ message: result.message || '刷新失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('刷新工具列表失败:', err)
|
||||
notification.error({ message: err.message || '刷新失败' })
|
||||
} finally {
|
||||
toolsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const handleTestConnection = async () => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
testLoading.value = true
|
||||
const result = await mcpApi.testMcpServer(props.server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
} else {
|
||||
notification.warning({ message: result.message || '连接失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('测试连接失败:', err)
|
||||
notification.error({ message: err.message || '测试失败' })
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换工具启用状态
|
||||
const handleToggleTool = async (tool) => {
|
||||
if (!props.server) return
|
||||
|
||||
try {
|
||||
toggleToolLoading.value = tool.name
|
||||
const result = await mcpApi.toggleMcpServerTool(props.server.name, tool.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
// 更新本地状态
|
||||
const targetTool = tools.value.find(t => t.name === tool.name)
|
||||
if (targetTool) {
|
||||
targetTool.enabled = result.enabled
|
||||
}
|
||||
emit('update')
|
||||
} else {
|
||||
notification.error({ message: result.message || '操作失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('切换工具状态失败:', err)
|
||||
notification.error({ message: err.message || '操作失败' })
|
||||
} finally {
|
||||
toggleToolLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 复制工具名称
|
||||
const copyToolName = async (name) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name)
|
||||
notification.success({ message: '已复制到剪贴板' })
|
||||
} catch {
|
||||
notification.error({ message: '复制失败' })
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => formatDateTime(timeStr)
|
||||
|
||||
// 关闭弹框
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mcp-detail-modal {
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
background: var(--gray-25);
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.server-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.server-meta {
|
||||
.server-name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.server-status {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
padding: 2px 8px;
|
||||
background: var(--gray-100);
|
||||
border-radius: 4px;
|
||||
|
||||
&.enabled {
|
||||
background: var(--color-success-50);
|
||||
color: var(--color-success-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
:deep(.ant-tabs-nav) {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px 24px;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.url-text {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.headers-pre {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--gray-50);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-tab {
|
||||
.tools-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-tools {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.tools-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.tool-card {
|
||||
background: var(--gray-0);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--gray-200);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tool-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tool-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--gray-400);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-collapse) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.param-item {
|
||||
background: var(--gray-50);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
.param-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.param-name {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--gray-900);
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.param-required {
|
||||
font-size: 11px;
|
||||
color: var(--color-error-500);
|
||||
background: var(--color-error-50);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
734
web/src/components/McpServersComponent.vue
Normal file
734
web/src/components/McpServersComponent.vue
Normal file
@ -0,0 +1,734 @@
|
||||
<template>
|
||||
<div class="mcp-servers">
|
||||
<!-- 头部区域 -->
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<h3 class="title">MCP 服务器管理</h3>
|
||||
<p class="description">
|
||||
管理 MCP(Model Context Protocol)服务器配置。添加、编辑或删除 MCP 服务器以扩展 AI 的能力。
|
||||
</p>
|
||||
</div>
|
||||
<a-button type="primary" @click="showAddModal" class="add-btn">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加服务器
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section" v-if="servers.length > 0">
|
||||
<span class="stats-text">
|
||||
已配置 {{ servers.length }} 个 MCP 服务器:
|
||||
HTTP: {{ httpCount }} · SSE: {{ sseCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<div class="content-section">
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="error" class="error-message">
|
||||
<a-alert type="error" :message="error" show-icon />
|
||||
</div>
|
||||
|
||||
<div class="cards-container">
|
||||
<div v-if="servers.length === 0" class="empty-state">
|
||||
<a-empty description="暂无 MCP 服务器配置">
|
||||
<a-button type="primary" @click="showAddModal">添加服务器</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
<div v-else class="server-cards-grid">
|
||||
<div
|
||||
v-for="server in servers"
|
||||
:key="server.name"
|
||||
class="server-card"
|
||||
:class="{ disabled: !server.enabled }"
|
||||
>
|
||||
<div class="card-header">
|
||||
<div class="server-info">
|
||||
<span class="server-icon">{{ server.icon || '🔌' }}</span>
|
||||
<div class="server-basic-info">
|
||||
<h4 class="server-name">{{ server.name }}</h4>
|
||||
<div class="server-transport">
|
||||
<a-tag :color="getTransportColor(server.transport)" size="small">
|
||||
{{ server.transport }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="server.enabled"
|
||||
@change="handleToggleServer(server)"
|
||||
:loading="toggleLoading === server.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="server-description" v-if="server.description">
|
||||
{{ server.description }}
|
||||
</div>
|
||||
<div class="server-url">
|
||||
<span class="url-label">URL:</span>
|
||||
<span class="url-value">{{ truncateUrl(server.url) }}</span>
|
||||
</div>
|
||||
<div class="server-tags" v-if="server.tags && server.tags.length > 0">
|
||||
<a-tag v-for="tag in server.tags" :key="tag" size="small">{{ tag }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<a-tooltip title="查看详情">
|
||||
<a-button type="text" size="small" @click="showDetailModal(server)" class="action-btn">
|
||||
<EyeOutlined />
|
||||
<span>详情</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="测试连接">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleTestServer(server)"
|
||||
class="action-btn"
|
||||
:loading="testLoading === server.name"
|
||||
>
|
||||
<ApiOutlined />
|
||||
<span>测试</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="编辑配置">
|
||||
<a-button type="text" size="small" @click="showEditModal(server)" class="action-btn">
|
||||
<EditOutlined />
|
||||
<span>编辑</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="删除服务器">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
@click="confirmDeleteServer(server)"
|
||||
class="action-btn"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<span>删除</span>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑服务器模态框 -->
|
||||
<a-modal
|
||||
v-model:open="formModalVisible"
|
||||
:title="editMode ? '编辑 MCP 服务器' : '添加 MCP 服务器'"
|
||||
@ok="handleFormSubmit"
|
||||
:confirmLoading="formLoading"
|
||||
@cancel="formModalVisible = false"
|
||||
:maskClosable="false"
|
||||
width="560px"
|
||||
class="server-modal"
|
||||
>
|
||||
<!-- 模式切换 -->
|
||||
<div class="mode-switch">
|
||||
<a-radio-group v-model:value="formMode" button-style="solid" size="small">
|
||||
<a-radio-button value="form">表单模式</a-radio-button>
|
||||
<a-radio-button value="json">JSON 模式</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 表单模式 -->
|
||||
<a-form v-if="formMode === 'form'" layout="vertical" class="server-form">
|
||||
<a-form-item label="服务器名称" required class="form-item">
|
||||
<a-input
|
||||
v-model:value="form.name"
|
||||
placeholder="请输入服务器名称(唯一标识)"
|
||||
:disabled="editMode"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" class="form-item">
|
||||
<a-input
|
||||
v-model:value="form.description"
|
||||
placeholder="请输入服务器描述"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="传输类型" required class="form-item">
|
||||
<a-select v-model:value="form.transport">
|
||||
<a-select-option value="streamable_http">streamable_http</a-select-option>
|
||||
<a-select-option value="sse">sse</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="图标" class="form-item">
|
||||
<a-input v-model:value="form.icon" placeholder="输入 emoji,如 🧠" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="服务器 URL" required class="form-item">
|
||||
<a-input
|
||||
v-model:value="form.url"
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="HTTP 请求头" class="form-item">
|
||||
<a-textarea
|
||||
v-model:value="form.headersText"
|
||||
placeholder='JSON 格式,如:{"Authorization": "Bearer xxx"}'
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="HTTP 超时(秒)" class="form-item">
|
||||
<a-input-number v-model:value="form.timeout" :min="1" :max="300" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="SSE 读取超时(秒)" class="form-item">
|
||||
<a-input-number v-model:value="form.sse_read_timeout" :min="1" :max="300" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="标签" class="form-item">
|
||||
<a-select
|
||||
v-model:value="form.tags"
|
||||
mode="tags"
|
||||
placeholder="输入标签后回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- JSON 模式 -->
|
||||
<div v-else class="json-mode">
|
||||
<a-textarea
|
||||
v-model:value="jsonContent"
|
||||
:rows="15"
|
||||
placeholder='请输入 JSON 配置,格式如:
|
||||
{
|
||||
"name": "my-server",
|
||||
"transport": "streamable_http",
|
||||
"url": "https://example.com/mcp",
|
||||
"description": "服务器描述",
|
||||
"headers": {"Authorization": "Bearer xxx"},
|
||||
"tags": ["工具", "AI"]
|
||||
}'
|
||||
class="json-textarea"
|
||||
/>
|
||||
<div class="json-actions">
|
||||
<a-button size="small" @click="formatJson">格式化</a-button>
|
||||
<a-button size="small" @click="parseJsonToForm">解析到表单</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 服务器详情模态框 -->
|
||||
<McpServerDetailModal
|
||||
v-model:visible="detailModalVisible"
|
||||
:server="selectedServer"
|
||||
@update="handleServerUpdate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { notification, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { mcpApi } from '@/apis/mcp_api'
|
||||
import McpServerDetailModal from './McpServerDetailModal.vue'
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const servers = ref([])
|
||||
const toggleLoading = ref(null)
|
||||
const testLoading = ref(null)
|
||||
|
||||
// 表单相关
|
||||
const formModalVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formMode = ref('form')
|
||||
const editMode = ref(false)
|
||||
const jsonContent = ref('')
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
transport: 'streamable_http',
|
||||
url: '',
|
||||
headersText: '',
|
||||
timeout: null,
|
||||
sse_read_timeout: null,
|
||||
tags: [],
|
||||
icon: '',
|
||||
})
|
||||
|
||||
// 详情模态框
|
||||
const detailModalVisible = ref(false)
|
||||
const selectedServer = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const httpCount = computed(() => servers.value.filter(s => s.transport === 'streamable_http').length)
|
||||
const sseCount = computed(() => servers.value.filter(s => s.transport === 'sse').length)
|
||||
|
||||
// 获取服务器列表
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const result = await mcpApi.getMcpServers()
|
||||
if (result.success) {
|
||||
servers.value = result.data || []
|
||||
} else {
|
||||
error.value = result.message || '获取服务器列表失败'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取服务器列表失败:', err)
|
||||
error.value = err.message || '获取服务器列表失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
editMode.value = false
|
||||
formMode.value = 'form'
|
||||
Object.assign(form, {
|
||||
name: '',
|
||||
description: '',
|
||||
transport: 'streamable_http',
|
||||
url: '',
|
||||
headersText: '',
|
||||
timeout: null,
|
||||
sse_read_timeout: null,
|
||||
tags: [],
|
||||
icon: '',
|
||||
})
|
||||
jsonContent.value = ''
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示编辑模态框
|
||||
const showEditModal = (server) => {
|
||||
editMode.value = true
|
||||
formMode.value = 'form'
|
||||
Object.assign(form, {
|
||||
name: server.name,
|
||||
description: server.description || '',
|
||||
transport: server.transport,
|
||||
url: server.url,
|
||||
headersText: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||
timeout: server.timeout,
|
||||
sse_read_timeout: server.sse_read_timeout,
|
||||
tags: server.tags || [],
|
||||
icon: server.icon || '',
|
||||
})
|
||||
formModalVisible.value = true
|
||||
}
|
||||
|
||||
// 显示详情模态框
|
||||
const showDetailModal = (server) => {
|
||||
selectedServer.value = server
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
formLoading.value = true
|
||||
|
||||
let data
|
||||
if (formMode.value === 'json') {
|
||||
try {
|
||||
data = JSON.parse(jsonContent.value)
|
||||
} catch {
|
||||
notification.error({ message: 'JSON 格式错误' })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 解析 headers
|
||||
let headers = null
|
||||
if (form.headersText.trim()) {
|
||||
try {
|
||||
headers = JSON.parse(form.headersText)
|
||||
} catch {
|
||||
notification.error({ message: '请求头 JSON 格式错误' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data = {
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
transport: form.transport,
|
||||
url: form.url,
|
||||
headers,
|
||||
timeout: form.timeout || null,
|
||||
sse_read_timeout: form.sse_read_timeout || null,
|
||||
tags: form.tags.length > 0 ? form.tags : null,
|
||||
icon: form.icon || null,
|
||||
}
|
||||
}
|
||||
|
||||
// 校验必填字段
|
||||
if (!data.name?.trim()) {
|
||||
notification.error({ message: '服务器名称不能为空' })
|
||||
return
|
||||
}
|
||||
if (!data.url?.trim()) {
|
||||
notification.error({ message: '服务器 URL 不能为空' })
|
||||
return
|
||||
}
|
||||
if (!data.transport) {
|
||||
notification.error({ message: '请选择传输类型' })
|
||||
return
|
||||
}
|
||||
|
||||
if (editMode.value) {
|
||||
const result = await mcpApi.updateMcpServer(data.name, data)
|
||||
if (result.success) {
|
||||
notification.success({ message: '服务器更新成功' })
|
||||
} else {
|
||||
notification.error({ message: result.message || '更新失败' })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const result = await mcpApi.createMcpServer(data)
|
||||
if (result.success) {
|
||||
notification.success({ message: '服务器创建成功' })
|
||||
} else {
|
||||
notification.error({ message: result.message || '创建失败' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
formModalVisible.value = false
|
||||
await fetchServers()
|
||||
} catch (err) {
|
||||
console.error('操作失败:', err)
|
||||
notification.error({ message: err.message || '操作失败' })
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换服务器启用状态
|
||||
const handleToggleServer = async (server) => {
|
||||
try {
|
||||
toggleLoading.value = server.name
|
||||
const result = await mcpApi.toggleMcpServer(server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
await fetchServers()
|
||||
} else {
|
||||
notification.error({ message: result.message || '操作失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('切换状态失败:', err)
|
||||
notification.error({ message: err.message || '操作失败' })
|
||||
} finally {
|
||||
toggleLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 测试服务器连接
|
||||
const handleTestServer = async (server) => {
|
||||
try {
|
||||
testLoading.value = server.name
|
||||
const result = await mcpApi.testMcpServer(server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: result.message })
|
||||
} else {
|
||||
notification.warning({ message: result.message || '连接失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('测试连接失败:', err)
|
||||
notification.error({ message: err.message || '测试失败' })
|
||||
} finally {
|
||||
testLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除服务器
|
||||
const confirmDeleteServer = (server) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除服务器',
|
||||
content: `确定要删除服务器 "${server.name}" 吗?此操作不可撤销。`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
try {
|
||||
const result = await mcpApi.deleteMcpServer(server.name)
|
||||
if (result.success) {
|
||||
notification.success({ message: '服务器删除成功' })
|
||||
await fetchServers()
|
||||
} else {
|
||||
notification.error({ message: result.message || '删除失败' })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err)
|
||||
notification.error({ message: err.message || '删除失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理服务器更新(来自详情模态框)
|
||||
const handleServerUpdate = () => {
|
||||
fetchServers()
|
||||
}
|
||||
|
||||
// 格式化 JSON
|
||||
const formatJson = () => {
|
||||
try {
|
||||
const obj = JSON.parse(jsonContent.value)
|
||||
jsonContent.value = JSON.stringify(obj, null, 2)
|
||||
} catch {
|
||||
notification.error({ message: 'JSON 格式错误,无法格式化' })
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 JSON 到表单
|
||||
const parseJsonToForm = () => {
|
||||
try {
|
||||
const obj = JSON.parse(jsonContent.value)
|
||||
Object.assign(form, {
|
||||
name: obj.name || '',
|
||||
description: obj.description || '',
|
||||
transport: obj.transport || 'streamable_http',
|
||||
url: obj.url || '',
|
||||
headersText: obj.headers ? JSON.stringify(obj.headers, null, 2) : '',
|
||||
timeout: obj.timeout || null,
|
||||
sse_read_timeout: obj.sse_read_timeout || null,
|
||||
tags: obj.tags || [],
|
||||
icon: obj.icon || '',
|
||||
})
|
||||
formMode.value = 'form'
|
||||
notification.success({ message: '已解析到表单' })
|
||||
} catch {
|
||||
notification.error({ message: 'JSON 格式错误' })
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const getTransportColor = (transport) => {
|
||||
return transport === 'sse' ? 'orange' : 'blue'
|
||||
}
|
||||
|
||||
const truncateUrl = (url) => {
|
||||
if (!url) return '-'
|
||||
return url.length > 40 ? url.substring(0, 40) + '...' : url
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchServers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.mcp-servers {
|
||||
margin-top: 12px;
|
||||
min-height: 50vh;
|
||||
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
color: var(--gray-600);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.stats-text {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
.content-section {
|
||||
overflow: hidden;
|
||||
|
||||
.error-message {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.cards-container {
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.server-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.server-card {
|
||||
background: var(--gray-0);
|
||||
border: 1px solid var(--gray-150);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--gray-200);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.server-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.server-basic-info {
|
||||
.server-name {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.server-description {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.server-url {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.url-label {
|
||||
color: var(--gray-500);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.url-value {
|
||||
color: var(--gray-700);
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.server-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--gray-25);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 12px;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--gray-25);
|
||||
}
|
||||
|
||||
&.ant-btn-dangerous:hover {
|
||||
background: var(--gray-25);
|
||||
border-color: var(--color-error-500);
|
||||
color: var(--color-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.server-modal {
|
||||
.mode-switch {
|
||||
margin-bottom: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.server-form {
|
||||
.form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.json-mode {
|
||||
.json-textarea {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.json-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -40,6 +40,15 @@
|
||||
<UserOutlined class="icon" />
|
||||
<span>用户管理</span>
|
||||
</div>
|
||||
<div
|
||||
class="sider-item"
|
||||
:class="{ activesec: activeTab === 'mcp' }"
|
||||
@click="activeTab = 'mcp'"
|
||||
v-if="userStore.isAdmin"
|
||||
>
|
||||
<ApiOutlined class="icon" />
|
||||
<span>MCP 管理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部导航 (Mobile) -->
|
||||
@ -68,6 +77,14 @@
|
||||
>
|
||||
用户管理
|
||||
</div>
|
||||
<div
|
||||
class="nav-item"
|
||||
:class="{ active: activeTab === 'mcp' }"
|
||||
@click="activeTab = 'mcp'"
|
||||
v-if="userStore.isAdmin"
|
||||
>
|
||||
MCP 管理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
@ -84,6 +101,10 @@
|
||||
<div v-show="activeTab === 'user'" v-if="userStore.isAdmin">
|
||||
<UserManagementComponent />
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 'mcp'" v-if="userStore.isAdmin">
|
||||
<McpServersComponent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -96,11 +117,13 @@ import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
SettingOutlined,
|
||||
CodeOutlined,
|
||||
UserOutlined
|
||||
UserOutlined,
|
||||
ApiOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
||||
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
||||
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user