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.graph_router import graph
|
||||||
from server.routers.knowledge_router import knowledge
|
from server.routers.knowledge_router import knowledge
|
||||||
from server.routers.evaluation_router import evaluation
|
from server.routers.evaluation_router import evaluation
|
||||||
|
from server.routers.mcp_router import mcp
|
||||||
from server.routers.mindmap_router import mindmap
|
from server.routers.mindmap_router import mindmap
|
||||||
from server.routers.system_router import system
|
from server.routers.system_router import system
|
||||||
from server.routers.task_router import tasks
|
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(mindmap) # /api/mindmap/*
|
||||||
router.include_router(graph) # /api/graph/*
|
router.include_router(graph) # /api/graph/*
|
||||||
router.include_router(tasks) # /api/tasks/*
|
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 fastapi import FastAPI
|
||||||
|
|
||||||
from server.services import tasker
|
from server.services import tasker
|
||||||
|
from src.agents.common.mcp import init_mcp_servers
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await tasker.start()
|
|
||||||
"""FastAPI lifespan事件管理器"""
|
"""FastAPI lifespan事件管理器"""
|
||||||
|
# 初始化 MCP 服务器配置
|
||||||
|
await init_mcp_servers()
|
||||||
|
|
||||||
|
await tasker.start()
|
||||||
yield
|
yield
|
||||||
await tasker.shutdown()
|
await tasker.shutdown()
|
||||||
|
|
||||||
|
|||||||
@ -11,30 +11,106 @@ from src.utils import logger
|
|||||||
# Global MCP tools cache
|
# Global MCP tools cache
|
||||||
_mcp_tools_cache: dict[str, list[Callable[..., Any]]] = {}
|
_mcp_tools_cache: dict[str, list[Callable[..., Any]]] = {}
|
||||||
|
|
||||||
# MCP Server configurations
|
# MCP Server configurations(运行时缓存,从数据库加载)
|
||||||
MCP_SERVERS = {
|
MCP_SERVERS: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# 默认 MCP 服务器配置(首次启动时导入数据库)
|
||||||
|
_DEFAULT_MCP_SERVERS = {
|
||||||
"sequentialthinking": {
|
"sequentialthinking": {
|
||||||
"url": "https://remote.mcpservers.org/sequentialthinking/mcp",
|
"url": "https://remote.mcpservers.org/sequentialthinking/mcp",
|
||||||
"transport": "streamable_http",
|
"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(
|
async def get_mcp_client(
|
||||||
server_configs: dict[str, Any] | None = None,
|
server_configs: dict[str, Any] | None = None,
|
||||||
@ -49,8 +125,19 @@ async def get_mcp_client(
|
|||||||
return None
|
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]]:
|
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
|
global _mcp_tools_cache
|
||||||
|
|
||||||
# Return cached tools if available
|
# 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:
|
if client is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Get all tools and filter by server (if tools have server metadata)
|
# Get all tools
|
||||||
all_tools = await client.get_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
|
# 渲染 ID 规则: mcp__[camelCaseServer]__[camelCaseTool]
|
||||||
logger.info(f"Loaded {len(tools)} tools from MCP server '{server_name}'")
|
server_cc = to_camel_case(server_name)
|
||||||
return tools
|
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:
|
except AssertionError as e:
|
||||||
logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}")
|
logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}")
|
||||||
return []
|
return []
|
||||||
@ -100,3 +204,10 @@ def clear_mcp_cache() -> None:
|
|||||||
"""Clear the MCP tools cache (useful for testing)."""
|
"""Clear the MCP tools cache (useful for testing)."""
|
||||||
global _mcp_tools_cache
|
global _mcp_tools_cache
|
||||||
_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,
|
"reason": self.reason,
|
||||||
"created_at": format_utc_datetime(self.created_at),
|
"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" />
|
<UserOutlined class="icon" />
|
||||||
<span>用户管理</span>
|
<span>用户管理</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="sider-item"
|
||||||
|
:class="{ activesec: activeTab === 'mcp' }"
|
||||||
|
@click="activeTab = 'mcp'"
|
||||||
|
v-if="userStore.isAdmin"
|
||||||
|
>
|
||||||
|
<ApiOutlined class="icon" />
|
||||||
|
<span>MCP 管理</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 顶部导航 (Mobile) -->
|
<!-- 顶部导航 (Mobile) -->
|
||||||
@ -68,6 +77,14 @@
|
|||||||
>
|
>
|
||||||
用户管理
|
用户管理
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeTab === 'mcp' }"
|
||||||
|
@click="activeTab = 'mcp'"
|
||||||
|
v-if="userStore.isAdmin"
|
||||||
|
>
|
||||||
|
MCP 管理
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域 -->
|
<!-- 内容区域 -->
|
||||||
@ -84,6 +101,10 @@
|
|||||||
<div v-show="activeTab === 'user'" v-if="userStore.isAdmin">
|
<div v-show="activeTab === 'user'" v-if="userStore.isAdmin">
|
||||||
<UserManagementComponent />
|
<UserManagementComponent />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === 'mcp'" v-if="userStore.isAdmin">
|
||||||
|
<McpServersComponent />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -96,11 +117,13 @@ import { useUserStore } from '@/stores/user'
|
|||||||
import {
|
import {
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
CodeOutlined,
|
CodeOutlined,
|
||||||
UserOutlined
|
UserOutlined,
|
||||||
|
ApiOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
import BasicSettingsSection from '@/components/BasicSettingsSection.vue'
|
||||||
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue'
|
||||||
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
import UserManagementComponent from '@/components/UserManagementComponent.vue'
|
||||||
|
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user