feat: 新增支持动态配置mcp功能

This commit is contained in:
hfdy2019 2026-01-14 00:34:24 +08:00 committed by Wenjie Zhang
parent 59b7193762
commit c360252e56
9 changed files with 2132 additions and 26 deletions

View File

@ -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/*

View 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))

View File

@ -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()

View File

@ -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}'")

View File

@ -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
View 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

View 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>

View 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">
管理 MCPModel 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>

View File

@ -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: {