27 changed files with 3161 additions and 42 deletions
-
BINio/modelcontextprotocol/client/McpSyncClient.class
-
6pom.xml
-
223src/main/java/com/wok/supportbot/app/AssistantApp.java
-
16src/main/java/com/wok/supportbot/config/ChatModelFactory.java
-
70src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
660src/main/java/com/wok/supportbot/config/McpClientManager.java
-
24src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java
-
24src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java
-
16src/main/java/com/wok/supportbot/controller/AiController.java
-
29src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
-
348src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
-
118src/main/java/com/wok/supportbot/converter/MessageConverter.java
-
12src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java
-
104src/main/java/com/wok/supportbot/entity/McpServerConfig.java
-
265src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
-
111src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
-
73src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
-
76src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
-
320src/main/java/com/wok/supportbot/service/McpServerConfigService.java
-
4src/main/resources/init-database.sql
-
41src/main/resources/static/components/ChatPanel.js
-
524src/main/resources/static/components/McpServerManager.js
-
9src/main/resources/static/css/main.css
-
58src/main/resources/static/js/api.js
-
7src/main/resources/static/js/app.js
-
4src/main/resources/static/js/store.js
-
61src/main/resources/static/js/utils.js
@ -0,0 +1,660 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import com.wok.supportbot.service.McpServerConfigService; |
|||
import io.modelcontextprotocol.client.McpSyncClient; |
|||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; |
|||
import io.modelcontextprotocol.client.transport.ServerParameters; |
|||
import io.modelcontextprotocol.client.transport.StdioClientTransport; |
|||
import io.modelcontextprotocol.spec.McpSchema; |
|||
import jakarta.annotation.PostConstruct; |
|||
import jakarta.annotation.PreDestroy; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.model.ModelOptionsUtils; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.scheduling.annotation.Scheduled; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.time.Duration; |
|||
import java.time.Instant; |
|||
import java.util.*; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
|
|||
/** |
|||
* MCP 客户端生命周期管理器 |
|||
* 负责 MCP Client 的创建、缓存、刷新和销毁。 |
|||
* <p> |
|||
* 支持两种传输模式: |
|||
* - SSE(Server-Sent Events):通过 HttpClientSseClientTransport 连接远程 MCP Server |
|||
* - stdio(标准输入输出):通过 StdioClientTransport 启动本地 MCP Server 进程 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpClientManager { |
|||
|
|||
@Autowired |
|||
private McpServerConfigService mcpServerConfigService; |
|||
|
|||
/** |
|||
* 客户端缓存:key = 配置ID 的字符串形式,value = MCP Client 实例 |
|||
* volatile 保证 refreshAll() 双缓冲切换时的可见性 |
|||
*/ |
|||
private volatile ConcurrentHashMap<String, McpSyncClient> clientCache = new ConcurrentHashMap<>(); |
|||
|
|||
/** |
|||
* 不可用配置集合:记录已知不存在或未启用的配置ID,避免重复查询 DB |
|||
* 配置被启用或新建时需同步移除此集合中的对应条目 |
|||
*/ |
|||
private final Set<String> unavailableConfigs = ConcurrentHashMap.newKeySet(); |
|||
|
|||
/** |
|||
* 健康状态缓存:key = 配置ID 的字符串形式,value = 健康检查结果 |
|||
*/ |
|||
private final ConcurrentHashMap<String, HealthStatus> healthCache = new ConcurrentHashMap<>(); |
|||
|
|||
/** |
|||
* MCP Server 健康状态记录 |
|||
*/ |
|||
public record HealthStatus( |
|||
/** 状态:ONLINE / OFFLINE / UNKNOWN */ |
|||
String status, |
|||
/** 响应延迟(毫秒) */ |
|||
long latencyMs, |
|||
/** 最后检查时间 */ |
|||
Instant lastCheckTime, |
|||
/** 错误信息(健康时为 null) */ |
|||
String errorMessage |
|||
) { |
|||
public static HealthStatus online(long latencyMs) { |
|||
return new HealthStatus("ONLINE", latencyMs, Instant.now(), null); |
|||
} |
|||
|
|||
public static HealthStatus offline(long latencyMs, String errorMessage) { |
|||
return new HealthStatus("OFFLINE", latencyMs, Instant.now(), errorMessage); |
|||
} |
|||
|
|||
public static HealthStatus unknown(String errorMessage) { |
|||
return new HealthStatus("UNKNOWN", 0, Instant.now(), errorMessage); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* MCP 客户端请求超时时间,支持 Duration 格式(如 30s、PT30S、60s) |
|||
*/ |
|||
@Value("${mcp.client.request-timeout:30s}") |
|||
private String requestTimeoutStr; |
|||
|
|||
/** |
|||
* MCP 客户端初始化超时时间,支持 Duration 格式(如 15s、PT15S、30s) |
|||
*/ |
|||
@Value("${mcp.client.init-timeout:15s}") |
|||
private String initTimeoutStr; |
|||
|
|||
/** |
|||
* 应用启动时自动加载所有已启用的 MCP Server 配置并建立连接 |
|||
* 确保第一次对话请求时 clientCache 已就绪,MCP 工具可以被注册到 ChatClient |
|||
*/ |
|||
@PostConstruct |
|||
public void init() { |
|||
log.info("McpClientManager 初始化,开始加载已启用的 MCP Server 配置..."); |
|||
refreshAll(); |
|||
} |
|||
|
|||
/** |
|||
* 刷新所有 MCP 客户端连接(双缓冲策略) |
|||
* 先在新 Map 中构建所有客户端,再原子切换引用,最后关闭旧客户端。 |
|||
* 避免清空与重建之间的请求全部失败。 |
|||
*/ |
|||
public void refreshAll() { |
|||
log.info("开始刷新所有 MCP 客户端连接..."); |
|||
|
|||
// 获取所有启用的配置(一次查询代替两次按类型过滤) |
|||
List<McpServerConfig> allActiveConfigs = mcpServerConfigService.listAllActiveConfigs(); |
|||
|
|||
// 第一步:在全新 Map 中构建所有客户端(不影响现有缓存) |
|||
ConcurrentHashMap<String, McpSyncClient> newCache = new ConcurrentHashMap<>(); |
|||
for (McpServerConfig config : allActiveConfigs) { |
|||
try { |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
newCache.put(config.getId().toString(), client); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("创建客户端失败: id={}, name={}, transportType={}, error={}", |
|||
config.getId(), config.getName(), config.getTransportType(), e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
// 第二步:原子切换引用(volatile 保证其他线程立即可见) |
|||
ConcurrentHashMap<String, McpSyncClient> oldCache = clientCache; |
|||
clientCache = newCache; |
|||
unavailableConfigs.clear(); |
|||
|
|||
// 第三步:关闭旧缓存中的客户端(不影响新请求) |
|||
for (Map.Entry<String, McpSyncClient> entry : oldCache.entrySet()) { |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
if (client != null) { |
|||
client.close(); |
|||
log.debug("已关闭旧 MCP 客户端: configId={}", entry.getKey()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("关闭旧 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
log.info("MCP 客户端刷新完成,当前缓存数量: {}", clientCache.size()); |
|||
} |
|||
|
|||
/** |
|||
* 获取指定配置的客户端(懒加载) |
|||
* 使用 computeIfAbsent 保证同一配置只创建一次客户端,避免并发竞态。 |
|||
* 如果缓存中不存在且不在不可用集合中,从 DB 读取配置后原子创建并缓存。 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return MCP Client 实例,不存在或未启用返回 null |
|||
*/ |
|||
public McpSyncClient getClient(Long configId) { |
|||
String key = configId.toString(); |
|||
|
|||
// 快速路径:缓存命中 |
|||
McpSyncClient cached = clientCache.get(key); |
|||
if (cached != null) { |
|||
return cached; |
|||
} |
|||
|
|||
// 快速路径:已知不可用的配置,直接跳过 |
|||
if (unavailableConfigs.contains(key)) { |
|||
return null; |
|||
} |
|||
|
|||
// 缓存未命中,使用 computeIfAbsent 保证同一 key 只创建一次客户端 |
|||
// ConcurrentHashMap.computeIfAbsent 对同一 key 加锁,避免并发线程重复创建 |
|||
// 注意:mapping function 不能返回 null(会抛 NPE),因此不可用的情况记录到 unavailableConfigs |
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
log.warn("MCP 配置不存在: id={}", configId); |
|||
unavailableConfigs.add(key); |
|||
return null; |
|||
} |
|||
if (!Boolean.TRUE.equals(config.getIsActive())) { |
|||
log.warn("MCP 配置未启用,跳过创建客户端: id={}, name={}", configId, config.getName()); |
|||
unavailableConfigs.add(key); |
|||
return null; |
|||
} |
|||
|
|||
// 配置存在且启用,通过 computeIfAbsent 原子创建(防止并发重复创建) |
|||
return clientCache.computeIfAbsent(key, k -> createClientDirectly(config)); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有已启用的 MCP 工具描述 |
|||
* 遍历所有已缓存的客户端,调用 listTools() 获取真实的工具列表 |
|||
* |
|||
* @return 工具描述列表,每个元素包含 config_id、name、transport_type、description、tools |
|||
*/ |
|||
public List<Map<String, Object>> listAvailableTools() { |
|||
List<Map<String, Object>> tools = new ArrayList<>(); |
|||
|
|||
log.info("🔍 listAvailableTools: clientCache 大小 = {}", clientCache.size()); |
|||
|
|||
if (clientCache.isEmpty()) { |
|||
log.warn("⚠️ clientCache 为空!MCP Server 可能未配置或连接失败。"); |
|||
log.warn(" 请检查:1) 是否添加了 MCP Server 配置 2) 配置是否已启用 3) 启动日志是否有连接错误"); |
|||
return tools; |
|||
} |
|||
|
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
Long configId; |
|||
try { |
|||
configId = Long.parseLong(entry.getKey()); |
|||
} catch (NumberFormatException e) { |
|||
log.warn("缓存 key 格式异常: {}", entry.getKey()); |
|||
continue; |
|||
} |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) { |
|||
continue; |
|||
} |
|||
|
|||
Map<String, Object> toolInfo = new LinkedHashMap<>(); |
|||
toolInfo.put("config_id", config.getId().toString()); |
|||
toolInfo.put("name", config.getName()); |
|||
toolInfo.put("transport_type", config.getTransportType()); |
|||
toolInfo.put("description", config.getDescription()); |
|||
|
|||
// 从 MCP Client 获取真实的工具列表 |
|||
List<Map<String, Object>> toolList = new ArrayList<>(); |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
McpSchema.ListToolsResult listToolsResult = client.listTools(); |
|||
if (listToolsResult != null && listToolsResult.tools() != null) { |
|||
log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size()); |
|||
for (McpSchema.Tool tool : listToolsResult.tools()) { |
|||
Map<String, Object> toolMeta = new LinkedHashMap<>(); |
|||
toolMeta.put("name", tool.name()); |
|||
toolMeta.put("description", tool.description()); |
|||
// JsonSchema 对象序列化为 JSON 字符串,供 McpToolCallback 构建 ToolDefinition 使用 |
|||
if (tool.inputSchema() != null) { |
|||
toolMeta.put("inputSchema", ModelOptionsUtils.toJsonString(tool.inputSchema())); |
|||
} else { |
|||
toolMeta.put("inputSchema", "{}"); |
|||
} |
|||
toolList.add(toolMeta); |
|||
} |
|||
} |
|||
log.info(" 📦 MCP Server [{}] 获取到 {} 个工具", config.getName(), toolList.size()); |
|||
if (toolList.isEmpty()) { |
|||
log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("获取 MCP 工具列表失败: serverId={}, name={}, error={}", |
|||
configId, config.getName(), e.getMessage()); |
|||
} |
|||
|
|||
toolInfo.put("tools", toolList); |
|||
tools.add(toolInfo); |
|||
} |
|||
|
|||
return tools; |
|||
} |
|||
|
|||
/** |
|||
* 定时健康检查:每 5 分钟执行一次 |
|||
* 遍历所有缓存的客户端,尝试 listTools() 探测连通性,记录状态到 healthCache |
|||
*/ |
|||
@Scheduled(fixedDelay = 300000) |
|||
public void healthCheck() { |
|||
log.info("开始 MCP Server 定时健康检查,当前缓存数量: {}", clientCache.size()); |
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
String key = entry.getKey(); |
|||
checkSingleClient(key, entry.getValue()); |
|||
} |
|||
log.info("MCP Server 定时健康检查完成"); |
|||
} |
|||
|
|||
/** |
|||
* 手动触发单个配置的健康检查 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 该配置的最新健康状态 |
|||
*/ |
|||
public HealthStatus checkHealthForConfig(Long configId) { |
|||
String key = configId.toString(); |
|||
McpSyncClient client = clientCache.get(key); |
|||
if (client == null) { |
|||
HealthStatus status = HealthStatus.unknown("客户端未缓存或配置不存在"); |
|||
healthCache.put(key, status); |
|||
return status; |
|||
} |
|||
return checkSingleClient(key, client); |
|||
} |
|||
|
|||
/** |
|||
* 对单个客户端执行健康探测 |
|||
* 通过调用 listTools() 判断服务端是否存活 |
|||
* |
|||
* @param key 缓存 key(配置ID 字符串) |
|||
* @param client MCP 客户端实例 |
|||
* @return 健康状态 |
|||
*/ |
|||
private HealthStatus checkSingleClient(String key, McpSyncClient client) { |
|||
long startTime = System.currentTimeMillis(); |
|||
try { |
|||
McpSchema.ListToolsResult result = client.listTools(); |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
int toolsCount = (result != null && result.tools() != null) ? result.tools().size() : 0; |
|||
HealthStatus status = HealthStatus.online(latencyMs); |
|||
healthCache.put(key, status); |
|||
log.debug("MCP Server 健康检查通过: configId={}, latencyMs={}, toolsCount={}", key, latencyMs, toolsCount); |
|||
return status; |
|||
} catch (Exception e) { |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage()); |
|||
healthCache.put(key, status); |
|||
log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage()); |
|||
return status; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定配置的健康状态 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 健康状态,未检查过返回 UNKNOWN |
|||
*/ |
|||
public HealthStatus getHealthStatus(Long configId) { |
|||
HealthStatus status = healthCache.get(configId.toString()); |
|||
if (status == null) { |
|||
return HealthStatus.unknown("尚未执行健康检查"); |
|||
} |
|||
return status; |
|||
} |
|||
|
|||
/** |
|||
* 获取所有已缓存配置的健康状态 |
|||
* |
|||
* @return Map:key = 配置ID 字符串,value = 健康状态 |
|||
*/ |
|||
public Map<String, HealthStatus> getAllHealthStatus() { |
|||
Map<String, HealthStatus> result = new LinkedHashMap<>(); |
|||
// 遍历所有缓存的客户端,未在 healthCache 中的标记为 UNKNOWN |
|||
for (String key : clientCache.keySet()) { |
|||
HealthStatus status = healthCache.get(key); |
|||
if (status == null) { |
|||
status = HealthStatus.unknown("尚未执行健康检查"); |
|||
} |
|||
result.put(key, status); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
/** |
|||
* 关闭所有 MCP 客户端连接 |
|||
* 在应用关闭或手动刷新时调用 |
|||
*/ |
|||
@PreDestroy |
|||
public void shutdown() { |
|||
log.info("关闭所有 MCP 客户端连接,当前数量: {}", clientCache.size()); |
|||
for (Map.Entry<String, McpSyncClient> entry : clientCache.entrySet()) { |
|||
try { |
|||
McpSyncClient client = entry.getValue(); |
|||
if (client != null) { |
|||
client.close(); |
|||
log.debug("已关闭 MCP 客户端: configId={}", entry.getKey()); |
|||
} |
|||
} catch (Exception e) { |
|||
log.error("关闭 MCP 客户端失败: configId={}, error={}", entry.getKey(), e.getMessage()); |
|||
} |
|||
} |
|||
clientCache.clear(); |
|||
unavailableConfigs.clear(); |
|||
healthCache.clear(); |
|||
log.info("所有 MCP 客户端连接已关闭"); |
|||
} |
|||
|
|||
/** |
|||
* 根据配置创建 MCP 客户端(不负责缓存,由调用方决定是否缓存) |
|||
* <p> |
|||
* SSE 模式:使用 HttpClientSseClientTransport 连接远程 MCP Server |
|||
* stdio 模式:使用 StdioClientTransport 启动本地 MCP Server 进程 |
|||
* |
|||
* @param config MCP Server 配置 |
|||
* @return 创建并初始化后的客户端实例,失败返回 null |
|||
*/ |
|||
private McpSyncClient createClientDirectly(McpServerConfig config) { |
|||
try { |
|||
McpSyncClient client; |
|||
|
|||
if ("sse".equals(config.getTransportType())) { |
|||
// 创建 SSE 传输层:连接远程 MCP Server |
|||
// HttpClientSseClientTransport 会自动将 /sse 作为 SSE 端点附加到 baseUri |
|||
String serverUrl = config.getServerUrl().trim(); |
|||
// 如果 URL 以 /sse 结尾,去掉后缀作为 baseUri(transport 层会自动附加) |
|||
String baseUri = serverUrl.replaceAll("/sse/?$", ""); |
|||
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUri).build(); |
|||
|
|||
client = buildSyncClient(transport); |
|||
log.info("正在初始化 SSE MCP 客户端: id={}, url={}", config.getId(), baseUri); |
|||
|
|||
} else if ("stdio".equals(config.getTransportType())) { |
|||
// 创建 stdio 传输层:启动本地 MCP Server 进程 |
|||
String command = config.getCommand().trim(); |
|||
|
|||
// 解析参数列表(逗号分隔) |
|||
List<String> argsList = new ArrayList<>(); |
|||
if (config.getArgs() != null && !config.getArgs().trim().isEmpty()) { |
|||
argsList = Arrays.asList(config.getArgs().split("\\s*,\\s*")); |
|||
} |
|||
|
|||
// 构建 ServerParameters |
|||
ServerParameters.Builder paramsBuilder = ServerParameters.builder(command); |
|||
if (!argsList.isEmpty()) { |
|||
paramsBuilder.args(argsList); |
|||
} |
|||
|
|||
// 环境变量(Map<String, Object> -> Map<String, String>) |
|||
if (config.getEnvVars() != null && !config.getEnvVars().isEmpty()) { |
|||
Map<String, String> envStr = new HashMap<>(); |
|||
config.getEnvVars().forEach((k, v) -> envStr.put(k, v != null ? v.toString() : "")); |
|||
paramsBuilder.env(envStr); |
|||
} |
|||
|
|||
StdioClientTransport transport = new StdioClientTransport(paramsBuilder.build()); |
|||
client = buildSyncClient(transport); |
|||
log.info("正在初始化 stdio MCP 客户端: id={}, command={}", config.getId(), command); |
|||
|
|||
} else { |
|||
log.error("不支持的传输类型: {}", config.getTransportType()); |
|||
return null; |
|||
} |
|||
|
|||
// 初始化连接(握手协议) |
|||
client.initialize(); |
|||
log.info("MCP 客户端初始化成功: id={}, name={}, transportType={}", |
|||
config.getId(), config.getName(), config.getTransportType()); |
|||
|
|||
return client; |
|||
|
|||
} catch (Exception e) { |
|||
log.error("创建 MCP 客户端失败: id={}, name={}, transportType={}, error={}", |
|||
config.getId(), config.getName(), config.getTransportType(), e.getMessage(), e); |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 构建 McpSyncClient 实例 |
|||
* 统一设置客户端信息和超时参数,超时时间从配置文件读取 |
|||
* |
|||
* @param transport 传输层实例(SSE 或 stdio) |
|||
* @return 构建的 McpSyncClient |
|||
*/ |
|||
private McpSyncClient buildSyncClient(io.modelcontextprotocol.spec.McpClientTransport transport) { |
|||
Duration requestTimeout = parseDuration(requestTimeoutStr, Duration.ofSeconds(30)); |
|||
Duration initTimeout = parseDuration(initTimeoutStr, Duration.ofSeconds(15)); |
|||
|
|||
return io.modelcontextprotocol.client.McpClient.sync(transport) |
|||
.clientInfo(new McpSchema.Implementation("supportbot-mcp-client", "1.0.0")) |
|||
.requestTimeout(requestTimeout) |
|||
.initializationTimeout(initTimeout) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 解析 Duration 字符串,兼容 ISO-8601 格式(PT30S)和简化格式(30s、60s) |
|||
* 解析失败时返回默认值 |
|||
* |
|||
* @param durationStr Duration 字符串 |
|||
* @param defaultValue 解析失败时的默认值 |
|||
* @return 解析后的 Duration |
|||
*/ |
|||
private Duration parseDuration(String durationStr, Duration defaultValue) { |
|||
if (durationStr == null || durationStr.isBlank()) { |
|||
return defaultValue; |
|||
} |
|||
try { |
|||
// 先尝试 ISO-8601 格式(如 PT30S、PT1M30S) |
|||
return Duration.parse(durationStr.trim()); |
|||
} catch (Exception e) { |
|||
// 尝试简化格式:纯数字秒(如 "30")或带 "s" 后缀(如 "30s") |
|||
String trimmed = durationStr.trim().toLowerCase(); |
|||
String numStr = trimmed.replaceAll("[^0-9.]+$", ""); |
|||
if (!numStr.isEmpty()) { |
|||
try { |
|||
long seconds = Long.parseLong(numStr); |
|||
return Duration.ofSeconds(seconds); |
|||
} catch (NumberFormatException ignored) { |
|||
// 继续尝试小数 |
|||
} |
|||
} |
|||
log.warn("无法解析 Duration 配置 '{}',使用默认值: {}", durationStr, defaultValue); |
|||
return defaultValue; |
|||
} |
|||
} |
|||
|
|||
// ==================== 增量缓存管理(供 Controller 层 CRUD 操作使用) ==================== |
|||
|
|||
/** |
|||
* 新增配置时:创建客户端并加入缓存 |
|||
* 仅对新建的单个配置创建连接,不影响其他已有连接。 |
|||
* |
|||
* @param configId 新建配置的ID |
|||
*/ |
|||
public void addClient(Long configId) { |
|||
String key = configId.toString(); |
|||
// 从不可用集合中移除(新建的配置可能之前被标记为不可用) |
|||
unavailableConfigs.remove(key); |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null || !Boolean.TRUE.equals(config.getIsActive())) { |
|||
return; |
|||
} |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
clientCache.put(key, client); |
|||
log.info("增量添加 MCP 客户端: id={}, name={}", configId, config.getName()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新配置时:关闭旧客户端,重建新客户端 |
|||
* 仅对更新的单个配置重建连接,不影响其他已有连接。 |
|||
* |
|||
* @param configId 更新的配置ID |
|||
*/ |
|||
public void rebuildClient(Long configId) { |
|||
String key = configId.toString(); |
|||
// 先移除旧客户端 |
|||
removeClient(configId); |
|||
|
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
unavailableConfigs.add(key); |
|||
return; |
|||
} |
|||
if (!Boolean.TRUE.equals(config.getIsActive())) { |
|||
unavailableConfigs.add(key); |
|||
return; |
|||
} |
|||
|
|||
unavailableConfigs.remove(key); |
|||
McpSyncClient client = createClientDirectly(config); |
|||
if (client != null) { |
|||
clientCache.put(key, client); |
|||
log.info("增量重建 MCP 客户端: id={}, name={}", configId, config.getName()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 禁用配置时:关闭并移除客户端,标记为不可用 |
|||
* |
|||
* @param configId 禁用的配置ID |
|||
*/ |
|||
public void disableClient(Long configId) { |
|||
removeClient(configId); |
|||
unavailableConfigs.add(configId.toString()); |
|||
} |
|||
|
|||
/** |
|||
* 移除指定配置的客户端缓存 |
|||
* 配置删除或禁用时调用 |
|||
* |
|||
* @param configId 配置ID |
|||
*/ |
|||
public void removeClient(Long configId) { |
|||
String key = configId.toString(); |
|||
McpSyncClient removed = clientCache.remove(key); |
|||
if (removed != null) { |
|||
try { |
|||
removed.close(); |
|||
} catch (Exception e) { |
|||
log.error("关闭被移除的 MCP 客户端失败: configId={}, error={}", configId, e.getMessage()); |
|||
} |
|||
log.info("已移除并关闭 MCP 客户端缓存: configId={}", configId); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 测试指定配置的 MCP Server 连接 |
|||
* 创建临时客户端 -> initialize -> listTools -> close,不影响正常缓存 |
|||
* |
|||
* @param configId 配置ID |
|||
* @return 测试结果 Map:success / latencyMs / toolsCount / errorMessage |
|||
*/ |
|||
public Map<String, Object> testConnection(Long configId) { |
|||
McpServerConfig config = mcpServerConfigService.getConfigById(configId); |
|||
if (config == null) { |
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", 0); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", "配置不存在: id=" + configId); |
|||
return result; |
|||
} |
|||
|
|||
McpSyncClient tempClient = null; |
|||
long startTime = System.currentTimeMillis(); |
|||
try { |
|||
// 创建临时客户端(不加入缓存),复用 createClientDirectly 的传输层构建逻辑 |
|||
tempClient = createClientDirectly(config); |
|||
if (tempClient == null) { |
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", System.currentTimeMillis() - startTime); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", "客户端创建失败,请检查配置"); |
|||
return result; |
|||
} |
|||
|
|||
// 获取工具列表 |
|||
McpSchema.ListToolsResult listToolsResult = tempClient.listTools(); |
|||
int toolsCount = (listToolsResult != null && listToolsResult.tools() != null) |
|||
? listToolsResult.tools().size() : 0; |
|||
|
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
log.info("MCP 连接测试成功: id={}, name={}, latencyMs={}, toolsCount={}", |
|||
configId, config.getName(), latencyMs, toolsCount); |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", true); |
|||
result.put("latencyMs", latencyMs); |
|||
result.put("toolsCount", toolsCount); |
|||
result.put("errorMessage", null); |
|||
return result; |
|||
|
|||
} catch (Exception e) { |
|||
long latencyMs = System.currentTimeMillis() - startTime; |
|||
log.warn("MCP 连接测试失败: id={}, name={}, latencyMs={}, error={}", |
|||
configId, config.getName(), latencyMs, e.getMessage()); |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("success", false); |
|||
result.put("latencyMs", latencyMs); |
|||
result.put("toolsCount", 0); |
|||
result.put("errorMessage", e.getMessage()); |
|||
return result; |
|||
|
|||
} finally { |
|||
// 确保关闭临时客户端 |
|||
if (tempClient != null) { |
|||
try { |
|||
tempClient.close(); |
|||
} catch (Exception e) { |
|||
log.debug("关闭临时测试客户端失败: configId={}, error={}", configId, e.getMessage()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取当前缓存的客户端数量 |
|||
* |
|||
* @return 缓存数量 |
|||
*/ |
|||
public int getCacheSize() { |
|||
return clientCache.size(); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.mcp.McpToolCallback; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.web.servlet.HandlerInterceptor; |
|||
|
|||
/** |
|||
* MCP 请求拦截器 |
|||
* 在每个 HTTP 请求开始时重置 McpToolCallback 的 ThreadLocal 调用轮次计数器, |
|||
* 防止 Tomcat 线程池复用导致计数器累积,避免内存泄漏和限流失效。 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpRequestInterceptor implements HandlerInterceptor { |
|||
|
|||
@Override |
|||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { |
|||
McpToolCallback.resetCallRounds(); |
|||
return true; |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; |
|||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
|||
|
|||
/** |
|||
* MCP 拦截器注册配置 |
|||
* 将 McpRequestInterceptor 注册到所有请求路径, |
|||
* 确保每次请求开始时重置 MCP 工具调用轮次计数器。 |
|||
*/ |
|||
@Configuration |
|||
public class McpWebMvcConfig implements WebMvcConfigurer { |
|||
|
|||
@Autowired |
|||
private McpRequestInterceptor mcpRequestInterceptor; |
|||
|
|||
@Override |
|||
public void addInterceptors(InterceptorRegistry registry) { |
|||
registry.addInterceptor(mcpRequestInterceptor) |
|||
.addPathPatterns("/**"); |
|||
} |
|||
} |
|||
@ -0,0 +1,348 @@ |
|||
package com.wok.supportbot.controller; |
|||
|
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import com.wok.supportbot.service.McpServerConfigService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Server 配置管理控制器 |
|||
* 提供 MCP Server 配置的增删改查、启用/禁用、连接测试、缓存刷新等 API |
|||
*/ |
|||
@RestController |
|||
@Slf4j |
|||
public class McpServerConfigController { |
|||
|
|||
@Autowired |
|||
private McpServerConfigService mcpServerConfigService; |
|||
|
|||
@Autowired |
|||
private McpClientManager mcpClientManager; |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 获取 MCP Server 配置列表(分页) |
|||
* |
|||
* @param page 页码(默认1) |
|||
* @param size 每页大小(默认10) |
|||
* @return 分页配置列表 |
|||
*/ |
|||
@GetMapping("/mcp-server/list") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> listConfigs( |
|||
@RequestParam(defaultValue = "1") int page, |
|||
@RequestParam(defaultValue = "10") int size) { |
|||
try { |
|||
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size); |
|||
Map<String, Object> data = new LinkedHashMap<>(); |
|||
data.put("success", true); |
|||
data.put("data", result.get("records")); |
|||
data.put("total", result.get("total")); |
|||
data.put("page", result.get("page")); |
|||
data.put("size", result.get("size")); |
|||
data.put("pages", result.get("pages")); |
|||
return ResponseEntity.ok(data); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 单条详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条 MCP Server 配置详情 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 配置详情 |
|||
*/ |
|||
@GetMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) { |
|||
try { |
|||
Map<String, Object> config = mcpServerConfigService.getConfigDetail(id); |
|||
if (config == null) { |
|||
return ResponseEntity.status(404).body(Map.of( |
|||
"success", false, |
|||
"message", "配置不存在" |
|||
)); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", config |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建 MCP Server 配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 创建结果 |
|||
*/ |
|||
@PostMapping("/mcp-server") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> createConfig(@RequestBody McpServerConfig config) { |
|||
try { |
|||
Map<String, Object> created = mcpServerConfigService.createConfig(config); |
|||
// 增量操作:仅为新建配置创建客户端,不影响其他已有连接 |
|||
Object idObj = created.get("id"); |
|||
if (idObj != null) { |
|||
try { |
|||
mcpClientManager.addClient(Long.parseLong(idObj.toString())); |
|||
} catch (Exception e) { |
|||
log.warn("新建配置后创建客户端失败(不影响配置保存): {}", e.getMessage()); |
|||
} |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", created, |
|||
"message", "配置创建成功" |
|||
)); |
|||
} catch (IllegalArgumentException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "创建失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新 MCP Server 配置 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新结果 |
|||
*/ |
|||
@PutMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> updateConfig( |
|||
@PathVariable("id") Long id, |
|||
@RequestBody McpServerConfig config) { |
|||
try { |
|||
Map<String, Object> updated = mcpServerConfigService.updateConfig(id, config); |
|||
// 增量操作:仅重建更新的配置客户端,不影响其他已有连接 |
|||
mcpClientManager.rebuildClient(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", updated, |
|||
"message", "配置更新成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "更新失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 删除 MCP Server 配置(逻辑删除) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 删除结果 |
|||
*/ |
|||
@DeleteMapping("/mcp-server/{id}") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) { |
|||
try { |
|||
mcpServerConfigService.deleteConfig(id); |
|||
// 增量操作:仅移除被删除的配置客户端,不影响其他已有连接 |
|||
mcpClientManager.removeClient(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "配置删除成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "删除失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ==================== |
|||
|
|||
/** |
|||
* 切换配置启用/禁用状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @param body 请求体:{active: true/false} |
|||
* @return 操作结果 |
|||
*/ |
|||
@PutMapping("/mcp-server/{id}/toggle") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> toggleActive( |
|||
@PathVariable("id") Long id, |
|||
@RequestBody Map<String, Boolean> body) { |
|||
try { |
|||
Boolean active = body.get("active"); |
|||
if (active == null) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "active 参数不能为空" |
|||
)); |
|||
} |
|||
mcpServerConfigService.toggleActive(id, active); |
|||
// 增量操作:启用时重建客户端,禁用时移除客户端 |
|||
if (active) { |
|||
mcpClientManager.rebuildClient(id); |
|||
} else { |
|||
mcpClientManager.disableClient(id); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", active ? "配置已启用" : "配置已禁用" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "操作失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接 ==================== |
|||
|
|||
/** |
|||
* 测试 MCP Server 连接 |
|||
* 创建临时客户端进行握手 + 工具列表查询,不影响正常缓存 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 测试结果(success / latencyMs / toolsCount / errorMessage) |
|||
*/ |
|||
@PostMapping("/mcp-server/{id}/test") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> testConnection(@PathVariable("id") Long id) { |
|||
try { |
|||
Map<String, Object> result = mcpClientManager.testConnection(id); |
|||
return ResponseEntity.ok(result); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "测试失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 刷新连接 ==================== |
|||
|
|||
/** |
|||
* 刷新所有 MCP 客户端连接 |
|||
* 清空现有缓存并重新建立所有已启用配置的客户端连接 |
|||
* |
|||
* @return 刷新结果 |
|||
*/ |
|||
@PostMapping("/mcp-server/refresh") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> refreshAll() { |
|||
try { |
|||
mcpClientManager.refreshAll(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "MCP 客户端连接已刷新" |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "刷新失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 健康检查 ==================== |
|||
|
|||
/** |
|||
* 获取所有 MCP Server 的健康状态 |
|||
* 返回各配置的在线/离线状态、延迟、最后检查时间等信息 |
|||
* |
|||
* @return 所有 MCP Server 的健康状态 |
|||
*/ |
|||
@GetMapping("/mcp-server/health") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> getAllHealthStatus() { |
|||
try { |
|||
Map<String, McpClientManager.HealthStatus> healthMap = mcpClientManager.getAllHealthStatus(); |
|||
Map<String, Object> data = new LinkedHashMap<>(); |
|||
data.put("success", true); |
|||
data.put("data", healthMap); |
|||
return ResponseEntity.ok(data); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "获取健康状态失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 手动触发单个 MCP Server 的健康检查 |
|||
* 对指定配置的客户端执行 listTools 探测,返回最新健康状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 该配置的健康状态 |
|||
*/ |
|||
@PostMapping("/mcp-server/{id}/health-check") |
|||
@PreAuthorize("hasRole('admin')") |
|||
public ResponseEntity<Map<String, Object>> checkSingleHealth(@PathVariable("id") Long id) { |
|||
try { |
|||
McpClientManager.HealthStatus status = mcpClientManager.checkHealthForConfig(id); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", Map.of( |
|||
"configId", id.toString(), |
|||
"status", status.status(), |
|||
"latencyMs", status.latencyMs(), |
|||
"lastCheckTime", status.lastCheckTime().toString(), |
|||
"errorMessage", status.errorMessage() != null ? status.errorMessage() : "" |
|||
) |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "健康检查失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.wok.supportbot.dao; |
|||
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import org.apache.ibatis.annotations.Mapper; |
|||
|
|||
/** |
|||
* MCP Server 配置 Mapper - 继承 MyBatis Plus BaseMapper,自带 CRUD |
|||
*/ |
|||
@Mapper |
|||
public interface McpServerConfigMapper extends BaseMapper<McpServerConfig> { |
|||
} |
|||
@ -0,0 +1,104 @@ |
|||
package com.wok.supportbot.entity; |
|||
|
|||
import com.baomidou.mybatisplus.annotation.*; |
|||
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
|||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; |
|||
import com.wok.supportbot.handler.PostgresJsonTypeHandler; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.io.Serial; |
|||
import java.io.Serializable; |
|||
import java.util.Date; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Server 配置表 - 管理外部 MCP Server 连接,支持 stdio 和 sse 两种传输模式 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
@TableName(value = "mcp_server_config", autoResultMap = true) |
|||
public class McpServerConfig implements Serializable { |
|||
|
|||
@Serial |
|||
@TableField(exist = false) |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** |
|||
* 主键ID(雪花算法) |
|||
*/ |
|||
@TableId(value = "id", type = IdType.ASSIGN_ID) |
|||
@JsonSerialize(using = ToStringSerializer.class) |
|||
private Long id; |
|||
|
|||
/** |
|||
* 配置名称(如"天气查询服务") |
|||
*/ |
|||
@TableField("name") |
|||
private String name; |
|||
|
|||
/** |
|||
* 传输类型: stdio(标准输入输出)/ sse(Server-Sent Events) |
|||
*/ |
|||
@TableField("transport_type") |
|||
private String transportType; |
|||
|
|||
/** |
|||
* SSE 模式的 MCP Server URL |
|||
*/ |
|||
@TableField("server_url") |
|||
private String serverUrl; |
|||
|
|||
/** |
|||
* stdio 模式的启动命令 |
|||
*/ |
|||
@TableField("command") |
|||
private String command; |
|||
|
|||
/** |
|||
* stdio 模式的命令参数(多个用逗号分隔) |
|||
*/ |
|||
@TableField("args") |
|||
private String args; |
|||
|
|||
/** |
|||
* 环境变量(JSONB,键值对形式) |
|||
*/ |
|||
@TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class) |
|||
private Map<String, Object> envVars; |
|||
|
|||
/** |
|||
* 描述说明 |
|||
*/ |
|||
@TableField("description") |
|||
private String description; |
|||
|
|||
/** |
|||
* 是否启用 |
|||
*/ |
|||
@TableField("is_active") |
|||
private Boolean isActive; |
|||
|
|||
/** |
|||
* 创建时间 |
|||
*/ |
|||
@TableField(value = "create_time", fill = FieldFill.INSERT) |
|||
private Date createTime; |
|||
|
|||
/** |
|||
* 更新时间 |
|||
*/ |
|||
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) |
|||
private Date updateTime; |
|||
|
|||
/** |
|||
* 删除标志 - false:未删除, true:已删除(逻辑删除) |
|||
*/ |
|||
@TableField("is_delete") |
|||
@TableLogic |
|||
private boolean isDelete; |
|||
} |
|||
@ -0,0 +1,265 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import io.modelcontextprotocol.client.McpSyncClient; |
|||
import io.modelcontextprotocol.spec.McpSchema; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.model.ModelOptionsUtils; |
|||
import org.springframework.ai.tool.ToolCallback; |
|||
import org.springframework.ai.tool.definition.DefaultToolDefinition; |
|||
import org.springframework.ai.tool.definition.ToolDefinition; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP Tool -> Spring AI ToolCallback 适配器 |
|||
* 将单个 MCP Server 暴露的工具适配为 Spring AI 可调用的 ToolCallback。 |
|||
* <p> |
|||
* 工作流程: |
|||
* 1. ChatClient 在对话时通过 getToolDefinition() 获取工具元数据(名称、描述、参数 Schema) |
|||
* 2. 当 AI 模型决定调用该工具时,Spring AI 框架自动调用 call() 方法 |
|||
* 3. call() 内部通过 McpClientManager 获取对应的 MCP Client,转发调用到远程 MCP Server |
|||
* 4. 工具调用事件通过 ThreadLocal 收集器记录,供 SSE 流式输出使用 |
|||
*/ |
|||
@Slf4j |
|||
public class McpToolCallback implements ToolCallback { |
|||
|
|||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
|||
|
|||
// ==================== 工具调用事件收集 ==================== |
|||
|
|||
/** |
|||
* 工具调用事件记录 |
|||
*/ |
|||
public record ToolCallEvent(String tool, String input, String result, long latencyMs, boolean error) {} |
|||
|
|||
/** |
|||
* 线程级事件收集器:在同一请求线程中收集所有工具调用事件 |
|||
* SSE 流式输出完成后,从这里取出事件发送给前端 |
|||
*/ |
|||
private static final ThreadLocal<List<ToolCallEvent>> EVENTS = ThreadLocal.withInitial(ArrayList::new); |
|||
|
|||
/** |
|||
* 获取当前线程收集的所有工具调用事件,并清空收集器 |
|||
*/ |
|||
public static List<ToolCallEvent> drainEvents() { |
|||
List<ToolCallEvent> events = EVENTS.get(); |
|||
List<ToolCallEvent> copy = new ArrayList<>(events); |
|||
events.clear(); |
|||
return copy; |
|||
} |
|||
|
|||
/** |
|||
* 重置事件收集器(每个新请求开始时调用) |
|||
*/ |
|||
public static void resetEvents() { |
|||
EVENTS.remove(); |
|||
} |
|||
|
|||
/** |
|||
* MCP Server 配置 ID(用于路由调用到正确的 MCP Client) |
|||
*/ |
|||
private final Long mcpServerConfigId; |
|||
|
|||
/** |
|||
* MCP Client Manager(用于获取实际的 MCP Client 实例) |
|||
*/ |
|||
private final McpClientManager mcpClientManager; |
|||
|
|||
/** |
|||
* 工具名称(已加 mcp_ 前缀,避免与内置工具冲突) |
|||
*/ |
|||
private final String toolName; |
|||
|
|||
/** |
|||
* MCP Server 上的原始工具名称(无前缀,用于发送给 MCP Server 调用) |
|||
*/ |
|||
private final String originalToolName; |
|||
|
|||
/** |
|||
* 工具描述(供 AI 模型理解工具用途) |
|||
*/ |
|||
private final String toolDescription; |
|||
|
|||
/** |
|||
* 工具输入参数的 JSON Schema(供 AI 模型生成合法的调用参数) |
|||
*/ |
|||
private final String inputSchema; |
|||
|
|||
/** |
|||
* 单次对话请求中最大允许的调用轮次,防止无限循环调用 |
|||
*/ |
|||
private final int maxCallRounds; |
|||
|
|||
/** |
|||
* 线程级调用轮次计数器,每个请求线程独立计数 |
|||
*/ |
|||
private static final ThreadLocal<Integer> CALL_ROUNDS = ThreadLocal.withInitial(() -> 0); |
|||
|
|||
/** |
|||
* 构造 MCP 工具适配器(含最大调用轮次限制) |
|||
* |
|||
* @param mcpServerConfigId MCP Server 配置 ID |
|||
* @param toolName 工具名称(含 mcp_ 前缀,用于 Spring AI 注册) |
|||
* @param originalToolName MCP Server 上的原始工具名称(无前缀,用于实际调用) |
|||
* @param toolDescription 工具描述 |
|||
* @param inputSchema 输入参数的 JSON Schema |
|||
* @param mcpClientManager MCP 客户端管理器 |
|||
* @param maxCallRounds 单次请求中最大允许的调用轮次 |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName, |
|||
String toolDescription, String inputSchema, McpClientManager mcpClientManager, |
|||
int maxCallRounds) { |
|||
this.mcpServerConfigId = mcpServerConfigId; |
|||
this.toolName = toolName; |
|||
this.originalToolName = originalToolName; |
|||
this.toolDescription = toolDescription; |
|||
this.inputSchema = inputSchema; |
|||
this.mcpClientManager = mcpClientManager; |
|||
this.maxCallRounds = maxCallRounds; |
|||
} |
|||
|
|||
/** |
|||
* 构造 MCP 工具适配器(使用默认最大调用轮次 5) |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String originalToolName, |
|||
String toolDescription, String inputSchema, McpClientManager mcpClientManager) { |
|||
this(mcpServerConfigId, toolName, originalToolName, toolDescription, inputSchema, mcpClientManager, 5); |
|||
} |
|||
|
|||
/** |
|||
* 兼容旧构造方式(toolName 直接用于调用,不加前缀区分) |
|||
*/ |
|||
public McpToolCallback(Long mcpServerConfigId, String toolName, String toolDescription, |
|||
String inputSchema, McpClientManager mcpClientManager) { |
|||
this(mcpServerConfigId, toolName, toolName, toolDescription, inputSchema, mcpClientManager, 5); |
|||
} |
|||
|
|||
/** |
|||
* 返回工具定义,供 Spring AI 框架注册到 ChatClient |
|||
* AI 模型通过此定义了解工具的名称、用途和参数格式 |
|||
*/ |
|||
@Override |
|||
public ToolDefinition getToolDefinition() { |
|||
return DefaultToolDefinition.builder() |
|||
.name(toolName) |
|||
.description(toolDescription) |
|||
.inputSchema(inputSchema) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 执行工具调用 |
|||
* 当 AI 模型决定调用此工具时,Spring AI 框架自动调用此方法。 |
|||
* 内部通过 McpClientManager 获取对应的 MCP Client 实例, |
|||
* 将调用转发到远程 MCP Server 并返回结果。 |
|||
* <p> |
|||
* 包含调用轮次限制:同一请求线程中,所有 MCP 工具的累计调用次数 |
|||
* 超过 maxCallRounds 时,返回错误 JSON 防止无限循环。 |
|||
* |
|||
* @param toolInput JSON 格式的工具输入参数 |
|||
* @return 工具执行结果(JSON 字符串形式) |
|||
*/ |
|||
@Override |
|||
public String call(String toolInput) { |
|||
// 检查调用轮次是否超限 |
|||
int currentRound = CALL_ROUNDS.get(); |
|||
if (currentRound >= maxCallRounds) { |
|||
log.warn("MCP 工具调用轮次超限: tool={}, currentRound={}, maxRounds={}", |
|||
originalToolName, currentRound, maxCallRounds); |
|||
return "{\"error\": \"工具调用轮次已达上限 (" + maxCallRounds + " 次),已终止调用以防止无限循环。请优化提示词减少工具调用次数。\"}"; |
|||
} |
|||
CALL_ROUNDS.set(currentRound + 1); |
|||
|
|||
log.info("MCP 工具调用: serverId={}, tool={}, input={}, round={}/{}", |
|||
mcpServerConfigId, originalToolName, toolInput, currentRound + 1, maxCallRounds); |
|||
long startTime = System.currentTimeMillis(); |
|||
|
|||
try { |
|||
// 获取 MCP Client 实例 |
|||
McpSyncClient client = mcpClientManager.getClient(mcpServerConfigId); |
|||
if (client == null) { |
|||
log.error("MCP 客户端不可用: serverId={}, tool={}", mcpServerConfigId, originalToolName); |
|||
return "{\"error\": \"MCP Server (id=" + mcpServerConfigId + ") 客户端未就绪,请检查配置\"}"; |
|||
} |
|||
|
|||
// 解析工具输入参数:JSON 字符串 -> Map |
|||
Map<String, Object> arguments = OBJECT_MAPPER.readValue(toolInput, new TypeReference<>() {}); |
|||
|
|||
// 构建 MCP 调用请求并执行 |
|||
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(originalToolName, arguments); |
|||
McpSchema.CallToolResult result = client.callTool(request); |
|||
|
|||
long latency = System.currentTimeMillis() - startTime; |
|||
log.info("MCP 工具调用完成: tool={}, latency={}ms, isError={}", |
|||
originalToolName, latency, result.isError()); |
|||
|
|||
// 收集工具调用事件(供 SSE 流式输出使用) |
|||
String resultStr = result.content() != null ? String.valueOf(result.content()) : ""; |
|||
boolean isError = result.isError() != null && result.isError(); |
|||
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, resultStr, latency, isError)); |
|||
|
|||
// 检查是否为错误结果 |
|||
if (result.isError() != null && result.isError()) { |
|||
log.error("MCP 工具返回错误: tool={}, content={}", originalToolName, result.content()); |
|||
return "{\"error\": \"工具执行返回错误: " + |
|||
escapeJson(String.valueOf(result.content())) + "\"}"; |
|||
} |
|||
|
|||
// 将 MCP Content 列表序列化为 JSON 字符串返回给 AI 模型 |
|||
// 与官方 SyncMcpToolCallback 保持一致,使用 ModelOptionsUtils 序列化 |
|||
String resultJson = ModelOptionsUtils.toJsonString(result.content()); |
|||
log.debug("MCP 工具调用结果: tool={}, result={}", originalToolName, resultJson); |
|||
return resultJson; |
|||
|
|||
} catch (Exception e) { |
|||
long latency = System.currentTimeMillis() - startTime; |
|||
log.error("MCP 工具调用失败: tool={}, latency={}ms, error={}", originalToolName, latency, e.getMessage()); |
|||
// 记录失败事件 |
|||
EVENTS.get().add(new ToolCallEvent(originalToolName, toolInput, e.getMessage(), latency, true)); |
|||
return "{\"error\": \"" + escapeJson(e.getMessage()) + "\"}"; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 转义字符串中的特殊字符,避免破坏 JSON 格式 |
|||
*/ |
|||
private String escapeJson(String text) { |
|||
if (text == null) return "未知错误"; |
|||
return text.replace("\\", "\\\\").replace("\"", "'").replace("\n", "\\n").replace("\r", "\\r"); |
|||
} |
|||
|
|||
// ==================== Getter 方法 ==================== |
|||
|
|||
public Long getMcpServerConfigId() { |
|||
return mcpServerConfigId; |
|||
} |
|||
|
|||
public String getToolName() { |
|||
return toolName; |
|||
} |
|||
|
|||
public String getOriginalToolName() { |
|||
return originalToolName; |
|||
} |
|||
|
|||
public String getToolDescription() { |
|||
return toolDescription; |
|||
} |
|||
|
|||
public String getInputSchema() { |
|||
return inputSchema; |
|||
} |
|||
|
|||
/** |
|||
* 重置当前线程的调用轮次计数器 |
|||
* 应在每次新的对话请求开始时调用,确保轮次计数从零开始 |
|||
*/ |
|||
public static void resetCallRounds() { |
|||
CALL_ROUNDS.remove(); |
|||
} |
|||
} |
|||
@ -0,0 +1,111 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import com.wok.supportbot.config.McpClientManager; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.tool.ToolCallback; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* MCP 工具批量适配器 |
|||
* 从 McpClientManager 获取所有已启用的 MCP 工具描述, |
|||
* 批量转换为 Spring AI ToolCallback 列表,供 ChatClient 注册。 |
|||
* <p> |
|||
* 使用方式: |
|||
* <pre> |
|||
* // 注入后在 ChatClient 构建时注册所有 MCP 工具 |
|||
* ChatClient.builder(chatModel) |
|||
* .defaultTools(mcpToolCallbackAdapter.getAllToolCallbacksAsArray()) |
|||
* .defaultAdvisors(...) |
|||
* .build(); |
|||
* </pre> |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class McpToolCallbackAdapter { |
|||
|
|||
private final McpClientManager mcpClientManager; |
|||
|
|||
/** |
|||
* 单次对话请求中最大允许的 MCP 工具调用轮次,防止无限循环调用 |
|||
*/ |
|||
@Value("${mcp.tools.max-call-rounds:5}") |
|||
private int maxCallRounds; |
|||
|
|||
public McpToolCallbackAdapter(McpClientManager mcpClientManager) { |
|||
this.mcpClientManager = mcpClientManager; |
|||
} |
|||
|
|||
/** |
|||
* 获取所有 MCP 工具的 ToolCallback 列表 |
|||
* 遍历所有已启用的 MCP Server,将其暴露的工具转换为 ToolCallback |
|||
* |
|||
* @return ToolCallback 列表(可能为空,但不会为 null) |
|||
*/ |
|||
public List<ToolCallback> getAllToolCallbacks() { |
|||
List<ToolCallback> callbacks = new ArrayList<>(); |
|||
List<Map<String, Object>> tools = mcpClientManager.listAvailableTools(); |
|||
|
|||
for (Map<String, Object> toolInfo : tools) { |
|||
String configId = (String) toolInfo.get("config_id"); |
|||
String serverName = (String) toolInfo.get("name"); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
List<Map<String, Object>> toolList = (List<Map<String, Object>>) toolInfo.get("tools"); |
|||
if (toolList == null || toolList.isEmpty()) { |
|||
log.debug("MCP Server [{}] 暂无可用工具", serverName); |
|||
continue; |
|||
} |
|||
|
|||
for (Map<String, Object> tool : toolList) { |
|||
String toolName = (String) tool.get("name"); |
|||
String description = (String) tool.get("description"); |
|||
String inputSchema = (String) tool.get("inputSchema"); |
|||
|
|||
// 工具名加 mcp_ 前缀,避免与内置工具冲突 |
|||
String prefixedName = "mcp_" + toolName; |
|||
McpToolCallback callback = new McpToolCallback( |
|||
Long.parseLong(configId), prefixedName, toolName, description, inputSchema, |
|||
mcpClientManager, maxCallRounds); |
|||
callbacks.add(callback); |
|||
log.debug("注册 MCP 工具: server={}, tool={}", serverName, prefixedName); |
|||
} |
|||
} |
|||
|
|||
log.info("MCP 工具适配完成,共注册 {} 个工具", callbacks.size()); |
|||
return callbacks; |
|||
} |
|||
|
|||
/** |
|||
* 获取 MCP 工具的 ToolCallback 数组(供 ChatClient.defaultTools() 使用) |
|||
* ChatClient.defaultTools() 接受 ToolCallback[] 参数,此方法提供便捷转换 |
|||
* |
|||
* @return ToolCallback 数组 |
|||
*/ |
|||
public ToolCallback[] getAllToolCallbacksAsArray() { |
|||
return getAllToolCallbacks().toArray(new ToolCallback[0]); |
|||
} |
|||
|
|||
/** |
|||
* 获取指定工具名列表的 ToolCallback 数组(按角色权限过滤 MCP 工具) |
|||
* |
|||
* @param allowedTools 允许的工具名列表;["*"] 表示全部允许,其他为具体工具名列表 |
|||
* @return 过滤后的 ToolCallback 数组(可能为空数组) |
|||
*/ |
|||
public ToolCallback[] getFilteredToolCallbacks(List<String> allowedTools) { |
|||
if (allowedTools == null || allowedTools.isEmpty()) { |
|||
return new ToolCallback[0]; |
|||
} |
|||
List<ToolCallback> all = getAllToolCallbacks(); |
|||
if (allowedTools.contains("*")) { |
|||
return all.toArray(new ToolCallback[0]); |
|||
} |
|||
return all.stream() |
|||
.filter(cb -> allowedTools.contains(cb.getToolDefinition().name())) |
|||
.toArray(ToolCallback[]::new); |
|||
} |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.wok.supportbot.mcp; |
|||
|
|||
import org.springframework.http.codec.ServerSentEvent; |
|||
|
|||
/** |
|||
* SSE 事件构建器 |
|||
* 用于在 MCP 工具调用流程中构建标准 SSE 事件, |
|||
* 让前端能区分文本内容与工具调用状态。 |
|||
* |
|||
* 事件类型: |
|||
* - message: 普通文本内容 |
|||
* - tool_call_start: 工具调用开始 |
|||
* - tool_call_result: 工具调用结果返回 |
|||
* - error: 错误信息 |
|||
*/ |
|||
public class SseEventBuilder { |
|||
|
|||
/** |
|||
* 构建普通文本消息事件 |
|||
*/ |
|||
public static ServerSentEvent<String> messageEvent(String data) { |
|||
return ServerSentEvent.<String>builder() |
|||
.event("message") |
|||
.data(data) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建工具调用开始事件 |
|||
*/ |
|||
public static ServerSentEvent<String> toolCallStartEvent(String toolName, String input) { |
|||
String json = String.format("{\"tool\":\"%s\",\"input\":\"%s\"}", |
|||
escapeJson(toolName), escapeJson(input)); |
|||
return ServerSentEvent.<String>builder() |
|||
.event("tool_call_start") |
|||
.data(json) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建工具调用结果事件 |
|||
*/ |
|||
public static ServerSentEvent<String> toolCallResultEvent(String toolName, String result, long latencyMs) { |
|||
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}", |
|||
escapeJson(toolName), escapeJson(result), latencyMs); |
|||
return ServerSentEvent.<String>builder() |
|||
.event("tool_call_result") |
|||
.data(json) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 构建错误事件 |
|||
*/ |
|||
public static ServerSentEvent<String> errorEvent(String message) { |
|||
return ServerSentEvent.<String>builder() |
|||
.event("error") |
|||
.data("{\"message\":\"" + escapeJson(message) + "\"}") |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* JSON 字符串转义 |
|||
*/ |
|||
private static String escapeJson(String s) { |
|||
if (s == null) return ""; |
|||
return s.replace("\\", "\\\\") |
|||
.replace("\"", "\\\"") |
|||
.replace("\n", "\\n") |
|||
.replace("\r", "\\r") |
|||
.replace("\t", "\\t"); |
|||
} |
|||
} |
|||
@ -0,0 +1,320 @@ |
|||
package com.wok.supportbot.service; |
|||
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.wok.supportbot.dao.McpServerConfigMapper; |
|||
import com.wok.supportbot.entity.McpServerConfig; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.*; |
|||
|
|||
/** |
|||
* MCP Server 配置管理服务 |
|||
* 提供 CRUD、启用/禁用、JSONB 字段显式持久化等功能 |
|||
*/ |
|||
@Service |
|||
@Slf4j |
|||
public class McpServerConfigService { |
|||
|
|||
@Autowired |
|||
private McpServerConfigMapper mcpServerConfigMapper; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
/** 复用的 JSON 序列化器,避免每次持久化都新建实例 */ |
|||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 分页查询 MCP Server 配置列表 |
|||
* |
|||
* @param page 页码(从1开始) |
|||
* @param size 每页大小 |
|||
* @return 分页结果(records/total/page/size/pages) |
|||
*/ |
|||
public Map<String, Object> listConfigs(int page, int size) { |
|||
// 查询总数 |
|||
LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>(); |
|||
Long total = mcpServerConfigMapper.selectCount(countWrapper); |
|||
if (total == null) total = 0L; |
|||
|
|||
// 查询列表(带排序和分页) |
|||
LambdaQueryWrapper<McpServerConfig> listWrapper = new LambdaQueryWrapper<>(); |
|||
listWrapper.orderByDesc(McpServerConfig::getCreateTime); |
|||
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size)); |
|||
List<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper); |
|||
|
|||
// 格式化返回结果 |
|||
List<Map<String, Object>> formattedRecords = new ArrayList<>(); |
|||
for (McpServerConfig record : records) { |
|||
formattedRecords.add(formatConfig(record)); |
|||
} |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("records", formattedRecords); |
|||
result.put("total", total); |
|||
result.put("page", page); |
|||
result.put("size", size); |
|||
result.put("pages", (total + size - 1) / size); |
|||
return result; |
|||
} |
|||
|
|||
// ==================== 详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条配置详情 |
|||
* |
|||
* @param id 配置ID |
|||
* @return 格式化的配置详情,不存在返回 null |
|||
*/ |
|||
public Map<String, Object> getConfigDetail(Long id) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
return null; |
|||
} |
|||
return formatConfig(config); |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建 MCP Server 配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 保存后的配置(格式化) |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> createConfig(McpServerConfig config) { |
|||
// 校验必填字段 |
|||
validateRequired(config); |
|||
|
|||
mcpServerConfigMapper.insert(config); |
|||
// JSONB 字段显式持久化,避免 MyBatis Plus typeHandler 不触发的问题 |
|||
persistEnvVars(config.getId(), config.getEnvVars()); |
|||
|
|||
log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType()); |
|||
return formatConfig(mcpServerConfigMapper.selectById(config.getId())); |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新 MCP Server 配置 |
|||
* 使用 LambdaUpdateWrapper 显式设置所有字段(含 null), |
|||
* 避免 updateById 默认跳过 null 字段导致无法清空可选字段。 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新后的配置(格式化) |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public Map<String, Object> updateConfig(Long id, McpServerConfig config) { |
|||
McpServerConfig existing = mcpServerConfigMapper.selectById(id); |
|||
if (existing == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
|
|||
// 使用 LambdaUpdateWrapper 显式设置所有字段,null 也会被写入(清空语义) |
|||
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(McpServerConfig::getId, id) |
|||
.set(McpServerConfig::getName, config.getName()) |
|||
.set(McpServerConfig::getTransportType, config.getTransportType()) |
|||
.set(McpServerConfig::getServerUrl, config.getServerUrl()) |
|||
.set(McpServerConfig::getCommand, config.getCommand()) |
|||
.set(McpServerConfig::getArgs, config.getArgs()) |
|||
.set(McpServerConfig::getDescription, config.getDescription()); |
|||
// isActive 仅在前端显式传入时更新,null 表示不修改 |
|||
if (config.getIsActive() != null) { |
|||
updateWrapper.set(McpServerConfig::getIsActive, config.getIsActive()); |
|||
} |
|||
mcpServerConfigMapper.update(null, updateWrapper); |
|||
|
|||
// JSONB 字段显式持久化(LambdaUpdateWrapper 不支持 JSONB 类型) |
|||
// null 或空 Map 均执行持久化(清空语义),与 LambdaUpdateWrapper 行为一致 |
|||
persistEnvVars(id, config.getEnvVars()); |
|||
|
|||
log.info("更新 MCP Server 配置: id={}", id); |
|||
return formatConfig(mcpServerConfigMapper.selectById(id)); |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 逻辑删除 MCP Server 配置 |
|||
* |
|||
* @param id 配置ID |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void deleteConfig(Long id) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
mcpServerConfigMapper.deleteById(id); |
|||
log.info("逻辑删除 MCP Server 配置: id={}, name={}", id, config.getName()); |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ==================== |
|||
|
|||
/** |
|||
* 切换配置启用状态 |
|||
* |
|||
* @param id 配置ID |
|||
* @param active 是否启用 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void toggleActive(Long id, boolean active) { |
|||
McpServerConfig config = mcpServerConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在: id=" + id); |
|||
} |
|||
|
|||
LambdaUpdateWrapper<McpServerConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(McpServerConfig::getId, id) |
|||
.set(McpServerConfig::getIsActive, active); |
|||
mcpServerConfigMapper.update(null, updateWrapper); |
|||
|
|||
log.info("切换 MCP Server 配置状态: id={}, name={}, active={}", id, config.getName(), active); |
|||
} |
|||
|
|||
// ==================== 活跃配置查询 ==================== |
|||
|
|||
/** |
|||
* 获取所有启用的 SSE 类型配置 |
|||
* 供 McpClientManager 建立 SSE 连接使用 |
|||
* |
|||
* @return SSE 类型的活跃配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listActiveSseConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true) |
|||
.eq(McpServerConfig::getTransportType, "sse"); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有启用的 stdio 类型配置 |
|||
* 供 McpClientManager 启动 stdio 进程使用 |
|||
* |
|||
* @return stdio 类型的活跃配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listActiveStdioConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true) |
|||
.eq(McpServerConfig::getTransportType, "stdio"); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
/** |
|||
* 根据 ID 获取配置(含完整字段,仅供内部使用) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 原始配置对象,不存在返回 null |
|||
*/ |
|||
public McpServerConfig getConfigById(Long id) { |
|||
return mcpServerConfigMapper.selectById(id); |
|||
} |
|||
|
|||
/** |
|||
* 获取所有启用的配置(不区分传输类型) |
|||
* 供 McpClientManager.refreshAll() 一次查询获取所有活跃配置 |
|||
* |
|||
* @return 所有启用的配置列表 |
|||
*/ |
|||
public List<McpServerConfig> listAllActiveConfigs() { |
|||
LambdaQueryWrapper<McpServerConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(McpServerConfig::getIsActive, true); |
|||
return mcpServerConfigMapper.selectList(wrapper); |
|||
} |
|||
|
|||
// ==================== 工具方法 ==================== |
|||
|
|||
/** |
|||
* 校验必填字段 |
|||
* |
|||
* @param config 配置对象 |
|||
*/ |
|||
private void validateRequired(McpServerConfig config) { |
|||
if (config.getName() == null || config.getName().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("配置名称不能为空"); |
|||
} |
|||
if (config.getTransportType() == null || config.getTransportType().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("传输类型不能为空"); |
|||
} |
|||
// 根据传输类型校验对应必填字段 |
|||
switch (config.getTransportType()) { |
|||
case "sse": |
|||
if (config.getServerUrl() == null || config.getServerUrl().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("SSE 模式下服务地址不能为空"); |
|||
} |
|||
break; |
|||
case "stdio": |
|||
if (config.getCommand() == null || config.getCommand().trim().isEmpty()) { |
|||
throw new IllegalArgumentException("stdio 模式下启动命令不能为空"); |
|||
} |
|||
break; |
|||
default: |
|||
throw new IllegalArgumentException("不支持的传输类型: " + config.getTransportType()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 格式化配置为前端返回格式 |
|||
* 雪花 ID 转字符串、时间格式化 |
|||
* |
|||
* @param config 原始配置 |
|||
* @return 格式化后的 Map |
|||
*/ |
|||
private Map<String, Object> formatConfig(McpServerConfig config) { |
|||
Map<String, Object> formatted = new LinkedHashMap<>(); |
|||
// 雪花 ID 必须转为字符串,避免 JS 精度丢失 |
|||
formatted.put("id", config.getId().toString()); |
|||
formatted.put("name", config.getName()); |
|||
formatted.put("transport_type", config.getTransportType()); |
|||
formatted.put("server_url", config.getServerUrl()); |
|||
formatted.put("command", config.getCommand()); |
|||
formatted.put("args", config.getArgs()); |
|||
formatted.put("env_vars", config.getEnvVars()); |
|||
formatted.put("description", config.getDescription()); |
|||
formatted.put("is_active", config.getIsActive()); |
|||
formatted.put("create_time", config.getCreateTime()); |
|||
formatted.put("update_time", config.getUpdateTime()); |
|||
return formatted; |
|||
} |
|||
|
|||
/** |
|||
* 显式持久化 envVars JSONB 字段 |
|||
* MyBatis Plus 的 insert/updateById 对带 typeHandler 的 JSONB 字段可能不触发写入, |
|||
* 使用 JdbcTemplate 显式更新保证数据可靠落库。 |
|||
* |
|||
* @param id 配置ID |
|||
* @param envVars 环境变量 Map |
|||
*/ |
|||
private void persistEnvVars(Long id, Map<String, Object> envVars) { |
|||
if (id == null) { |
|||
return; |
|||
} |
|||
try { |
|||
String json; |
|||
if (envVars == null || envVars.isEmpty()) { |
|||
json = "{}"; |
|||
} else { |
|||
json = OBJECT_MAPPER.writeValueAsString(envVars); |
|||
} |
|||
jdbcTemplate.update( |
|||
"UPDATE mcp_server_config SET env_vars = ?::jsonb WHERE id = ?", |
|||
json, id); |
|||
log.debug("持久化 envVars: id={}, envVars={}", id, json); |
|||
} catch (Exception e) { |
|||
log.error("持久化 envVars 失败: id={}, error={}", id, e.getMessage()); |
|||
throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,524 @@ |
|||
/** |
|||
* MCP 服务管理组件 |
|||
* 支持 SSE / stdio 两种传输类型的 MCP Server 配置管理 |
|||
* 功能:列表展示、新建/编辑/删除、启用/禁用切换、测试连接、刷新连接、分页 |
|||
*/ |
|||
import { ref, computed, onMounted, onUnmounted } from 'vue' |
|||
import * as api from '../js/api.js' |
|||
import { toast, formatDate } from '../js/utils.js' |
|||
|
|||
// 传输类型选项
|
|||
const TRANSPORT_OPTIONS = [ |
|||
{ value: 'sse', label: 'SSE(HTTP 服务)' }, |
|||
{ value: 'stdio', label: 'Stdio(本地进程)' } |
|||
] |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>MCP 服务管理</h2> |
|||
<p style="font-size:13px;color:var(--sub);margin:-4px 0 16px;"> |
|||
管理 MCP (Model Context Protocol) 服务端配置,支持 SSE 和 Stdio 两种传输方式 |
|||
</p> |
|||
|
|||
<!-- 操作栏 --> |
|||
<div class="input-row" style="flex-wrap:wrap;margin-bottom:16px;"> |
|||
<button class="btn btn-primary btn-sm" @click="openAddModal">新建配置</button> |
|||
<button class="btn btn-outline btn-sm" @click="refreshConnections" :disabled="refreshLoading"> |
|||
{{ refreshLoading ? '刷新中...' : '刷新连接' }} |
|||
</button> |
|||
<button class="btn btn-outline btn-sm" @click="load(currentPage)">刷新列表</button> |
|||
<span style="margin-left:auto;font-size:12px;color:var(--sub);"> |
|||
共 {{ total }} 条配置 |
|||
</span> |
|||
</div> |
|||
|
|||
<!-- 列表表格 --> |
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>名称</th> |
|||
<th>传输类型</th> |
|||
<th>URL / 命令</th> |
|||
<th>启用状态</th> |
|||
<th>描述</th> |
|||
<th>创建时间</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="servers.length === 0"> |
|||
<td colspan="7" style="text-align:center;color:var(--sub);">暂无 MCP 服务配置</td> |
|||
</tr> |
|||
<tr v-for="s in servers" :key="s.id"> |
|||
<td><strong>{{ s.name || '-' }}</strong></td> |
|||
<td> |
|||
<span class="badge" :class="s.transport_type === 'sse' ? 'badge-get' : ''"> |
|||
{{ s.transport_type === 'sse' ? 'SSE' : 'Stdio' }} |
|||
</span> |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" |
|||
:title="getDisplayUrl(s)"> |
|||
{{ getDisplayUrl(s) || '-' }} |
|||
</td> |
|||
<td> |
|||
<label class="toggle-switch" :title="s.is_active ? '已启用' : '已禁用'"> |
|||
<input type="checkbox" :checked="s.is_active" @change="toggleActive(s)"> |
|||
<span class="toggle-slider"></span> |
|||
</label> |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" |
|||
:title="s.description"> |
|||
{{ s.description || '-' }} |
|||
</td> |
|||
<td style="font-size:12px;color:#6b7280;">{{ formatDate(s.create_time) }}</td> |
|||
<td style="white-space:nowrap;"> |
|||
<button class="btn btn-sm btn-outline" @click="testConnection(s)" :disabled="testLoading[s.id]" title="测试连接"> |
|||
{{ testLoading[s.id] ? '测试中...' : '测试' }} |
|||
</button> |
|||
<button class="btn btn-sm btn-outline" @click="openEditModal(s)" title="编辑">编辑</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(s.id, s.name)" title="删除">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div class="pagination" v-if="totalPages > 1"> |
|||
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button> |
|||
<template v-for="i in totalPages" :key="i"> |
|||
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)" |
|||
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button> |
|||
</div> |
|||
|
|||
<!-- 测试结果提示 --> |
|||
<div v-if="testResult.visible" class="test-result-toast" |
|||
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', |
|||
background: testResult.success ? '#16a34a' : '#dc2626' }"> |
|||
<div style="font-weight:600;">{{ testResult.success ? '连接成功' : '连接失败' }}</div> |
|||
<div v-if="testResult.message" style="font-size:12px;margin-top:4px;opacity:0.9;">{{ testResult.message }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 新建/编辑弹窗 --> |
|||
<div class="modal-overlay" :class="{ active: showModal }"> |
|||
<div class="modal-box" style="max-width:600px;"> |
|||
<button class="modal-close" @click="closeModal">×</button> |
|||
<h2 style="margin-bottom:16px;">{{ modalMode === 'add' ? '新建 MCP 服务配置' : '编辑 MCP 服务配置' }}</h2> |
|||
|
|||
<div style="display:flex;flex-direction:column;gap:16px;"> |
|||
<!-- 名称 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
服务名称 <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.name" placeholder="如:天气查询服务"> |
|||
</div> |
|||
|
|||
<!-- 传输类型 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;"> |
|||
传输类型 <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<div style="display:flex;gap:8px;"> |
|||
<button v-for="opt in transportOptions" :key="opt.value" |
|||
type="button" |
|||
@click="form.transportType = opt.value" |
|||
style="padding:6px 16px;border-radius:6px;font-size:13px;cursor:pointer;transition:all 0.15s;" |
|||
:style="form.transportType === opt.value |
|||
? 'background:#2563eb;color:white;border:2px solid #2563eb;font-weight:600;' |
|||
: 'background:white;color:#374151;border:2px solid #e5e7eb;font-weight:400;'"> |
|||
{{ opt.label }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- SSE 模式:URL --> |
|||
<div v-if="form.transportType === 'sse'"> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Server URL <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.url" placeholder="如:http://localhost:3001/sse"> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
MCP 服务的 SSE 端点地址 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Stdio 模式:Command + Args --> |
|||
<template v-if="form.transportType === 'stdio'"> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Command <span style="color:#dc2626;">*</span> |
|||
</label> |
|||
<input type="text" class="input" v-model.trim="form.command" placeholder="如:npx, python, node"> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
启动 MCP 服务的可执行命令 |
|||
</div> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
Args(命令参数,每行一个) |
|||
</label> |
|||
<textarea class="input" v-model="form.argsText" rows="3" |
|||
placeholder="-m mcp-server-fetch --verbose"></textarea> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
每行一个参数,留空则不传参 |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<!-- 环境变量 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;"> |
|||
环境变量(可选) |
|||
</label> |
|||
<textarea class="input" v-model="form.envText" rows="2" |
|||
placeholder="KEY1=value1 KEY2=value2"></textarea> |
|||
<div style="font-size:11px;color:#6b7280;margin-top:4px;"> |
|||
格式:KEY=value,每行一组,留空则不设置 |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 描述 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label> |
|||
<textarea class="input" v-model.trim="form.description" rows="2" placeholder="可选,填写服务用途说明"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 按钮 --> |
|||
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;"> |
|||
<button class="btn btn-outline" @click="closeModal">取消</button> |
|||
<button class="btn btn-primary" @click="save" :disabled="saving"> |
|||
{{ saving ? '保存中...' : '保存' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
|
|||
setup() { |
|||
// ==================== 列表状态 ====================
|
|||
const servers = ref([]) |
|||
const currentPage = ref(1) |
|||
const totalPages = ref(1) |
|||
const total = ref(0) |
|||
const pageSize = 10 |
|||
|
|||
// ==================== 弹窗状态 ====================
|
|||
const showModal = ref(false) |
|||
const modalMode = ref('add') // 'add' | 'edit'
|
|||
const editId = ref(null) |
|||
const saving = ref(false) |
|||
|
|||
// 表单数据
|
|||
const form = ref(createEmptyForm()) |
|||
|
|||
function createEmptyForm() { |
|||
return { |
|||
name: '', |
|||
transportType: 'sse', |
|||
url: '', |
|||
command: '', |
|||
argsText: '', |
|||
envText: '', |
|||
description: '' |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接状态 ====================
|
|||
const testLoading = ref({}) |
|||
const testResult = ref({ visible: false, success: false, message: '' }) |
|||
let testResultTimer = null |
|||
|
|||
// ==================== 刷新连接状态 ====================
|
|||
const refreshLoading = ref(false) |
|||
|
|||
// ==================== 传输类型选项 ====================
|
|||
const transportOptions = TRANSPORT_OPTIONS |
|||
|
|||
// ==================== 数据加载 ====================
|
|||
|
|||
async function load(p = 1) { |
|||
currentPage.value = p |
|||
try { |
|||
const json = await api.listMcpServers(p, pageSize) |
|||
if (json.success) { |
|||
servers.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
totalPages.value = json.pages || 1 |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载列表失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 弹窗操作 ====================
|
|||
|
|||
function openAddModal() { |
|||
modalMode.value = 'add' |
|||
editId.value = null |
|||
form.value = createEmptyForm() |
|||
showModal.value = true |
|||
} |
|||
|
|||
function openEditModal(server) { |
|||
modalMode.value = 'edit' |
|||
editId.value = server.id |
|||
|
|||
// 解析 args 数组为换行文本
|
|||
let argsText = '' |
|||
if (server.args && Array.isArray(server.args)) { |
|||
argsText = server.args.join('\n') |
|||
} else if (typeof server.args === 'string' && server.args) { |
|||
try { |
|||
const parsed = JSON.parse(server.args) |
|||
if (Array.isArray(parsed)) argsText = parsed.join('\n') |
|||
else argsText = server.args |
|||
} catch (e) { |
|||
argsText = server.args |
|||
} |
|||
} |
|||
|
|||
// 解析环境变量对象为换行文本
|
|||
let envText = '' |
|||
if (server.env_vars && typeof server.env_vars === 'object') { |
|||
envText = Object.entries(server.env_vars).map(([k, v]) => k + '=' + v).join('\n') |
|||
} else if (typeof server.env_vars === 'string' && server.env_vars) { |
|||
try { |
|||
const parsed = JSON.parse(server.env_vars) |
|||
if (typeof parsed === 'object' && parsed !== null) { |
|||
envText = Object.entries(parsed).map(([k, v]) => k + '=' + v).join('\n') |
|||
} else { |
|||
envText = server.env_vars |
|||
} |
|||
} catch (e) { |
|||
envText = server.env_vars |
|||
} |
|||
} |
|||
|
|||
form.value = { |
|||
name: server.name || '', |
|||
transportType: server.transport_type || 'sse', |
|||
url: server.server_url || '', |
|||
command: server.command || '', |
|||
argsText: argsText, |
|||
envText: envText, |
|||
description: server.description || '' |
|||
} |
|||
showModal.value = true |
|||
} |
|||
|
|||
function closeModal() { |
|||
showModal.value = false |
|||
} |
|||
|
|||
// ==================== 保存 ====================
|
|||
|
|||
function buildSaveData() { |
|||
const f = form.value |
|||
const data = { |
|||
name: f.name, |
|||
transportType: f.transportType, |
|||
description: f.description |
|||
} |
|||
|
|||
if (f.transportType === 'sse') { |
|||
data.serverUrl = f.url |
|||
} else { |
|||
data.command = f.command |
|||
// 解析 args:按换行拆分,过滤空行
|
|||
if (f.argsText && f.argsText.trim()) { |
|||
data.args = f.argsText.split('\n').map(s => s.trim()).filter(Boolean) |
|||
} else { |
|||
data.args = [] |
|||
} |
|||
} |
|||
|
|||
// 解析环境变量:按换行拆分,解析 KEY=value
|
|||
if (f.envText && f.envText.trim()) { |
|||
const envObj = {} |
|||
f.envText.split('\n').forEach(line => { |
|||
const trimmed = line.trim() |
|||
if (!trimmed) return |
|||
const eqIdx = trimmed.indexOf('=') |
|||
if (eqIdx > 0) { |
|||
envObj[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1) |
|||
} |
|||
}) |
|||
data.envVars = envObj |
|||
} |
|||
|
|||
return data |
|||
} |
|||
|
|||
async function save() { |
|||
const f = form.value |
|||
|
|||
// 校验
|
|||
if (!f.name || !f.name.trim()) { |
|||
toast('请填写服务名称', 'error') |
|||
return |
|||
} |
|||
if (f.transportType === 'sse') { |
|||
if (!f.url || !f.url.trim()) { |
|||
toast('请填写 Server URL', 'error') |
|||
return |
|||
} |
|||
} else { |
|||
if (!f.command || !f.command.trim()) { |
|||
toast('请填写 Command', 'error') |
|||
return |
|||
} |
|||
} |
|||
|
|||
saving.value = true |
|||
try { |
|||
const data = buildSaveData() |
|||
let json |
|||
if (modalMode.value === 'add') { |
|||
json = await api.createMcpServer(data) |
|||
} else { |
|||
json = await api.updateMcpServer(editId.value, data) |
|||
} |
|||
|
|||
if (json.success) { |
|||
toast(modalMode.value === 'add' ? '创建成功' : '更新成功', 'success') |
|||
closeModal() |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('保存失败:' + e.message, 'error') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除 ====================
|
|||
|
|||
async function remove(id, name) { |
|||
if (!confirm('确定删除配置「' + (name || id) + '」?删除后不可恢复。')) return |
|||
try { |
|||
const json = await api.deleteMcpServer(id) |
|||
if (json.success) { |
|||
toast('删除成功', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 启用/禁用 ====================
|
|||
|
|||
async function toggleActive(server) { |
|||
const newActive = !server.is_active |
|||
try { |
|||
const json = await api.toggleMcpServer(server.id, newActive) |
|||
if (json.success) { |
|||
toast(newActive ? '已启用' : '已禁用', 'success') |
|||
// 乐观更新本地状态,避免刷新列表
|
|||
server.is_active = newActive |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('操作失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 测试连接 ====================
|
|||
|
|||
async function testConnection(server) { |
|||
testLoading.value = { ...testLoading.value, [server.id]: true } |
|||
try { |
|||
const json = await api.testMcpServer(server.id) |
|||
showTestResult(json) |
|||
} catch (e) { |
|||
showTestResult({ success: false, message: e.message }) |
|||
} finally { |
|||
testLoading.value = { ...testLoading.value, [server.id]: false } |
|||
} |
|||
} |
|||
|
|||
function showTestResult(result) { |
|||
if (testResultTimer) clearTimeout(testResultTimer) |
|||
testResult.value = { |
|||
visible: true, |
|||
success: result.success, |
|||
message: result.message || '' |
|||
} |
|||
testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) |
|||
} |
|||
|
|||
// ==================== 刷新连接 ====================
|
|||
|
|||
async function refreshConnections() { |
|||
refreshLoading.value = true |
|||
try { |
|||
const json = await api.refreshMcpServers() |
|||
if (json.success) { |
|||
toast('连接已刷新', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '刷新失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('刷新失败:' + e.message, 'error') |
|||
} finally { |
|||
refreshLoading.value = false |
|||
} |
|||
} |
|||
|
|||
// ==================== 工具函数 ====================
|
|||
|
|||
function getDisplayUrl(server) { |
|||
if (server.transport_type === 'sse') { |
|||
return server.server_url || '-' |
|||
} |
|||
// stdio 模式:显示 command + args
|
|||
let display = server.command || '' |
|||
if (server.args) { |
|||
// args 可能是字符串或数组
|
|||
const argsStr = Array.isArray(server.args) ? server.args.join(' ') : server.args |
|||
display += ' ' + argsStr |
|||
} |
|||
return display || '-' |
|||
} |
|||
|
|||
// ==================== 生命周期 ====================
|
|||
|
|||
onMounted(() => { |
|||
load() |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
if (testResultTimer) clearTimeout(testResultTimer) |
|||
}) |
|||
|
|||
return { |
|||
// 列表
|
|||
servers, currentPage, totalPages, total, |
|||
// 弹窗
|
|||
showModal, modalMode, form, saving, transportOptions, |
|||
// 测试
|
|||
testLoading, testResult, |
|||
// 刷新
|
|||
refreshLoading, |
|||
// 方法
|
|||
load, openAddModal, openEditModal, closeModal, save, remove, |
|||
toggleActive, testConnection, refreshConnections, getDisplayUrl, formatDate |
|||
} |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue