missingTables = new java.util.ArrayList<>();
@@ -430,6 +441,24 @@ public class DatabaseInitConfig {
log.warn("add conversation_session.external_account_id failed: {}", e.getMessage());
}
}
+
+ /**
+ * 为 customer_service_role 表添加 allowed_mcp_tools 列(角色 MCP 工具权限控制)
+ * 幂等:已有列则跳过
+ */
+ private void addRoleAllowedMcpToolsColumn() {
+ try {
+ String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'customer_service_role' AND column_name = 'allowed_mcp_tools'";
+ Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
+ if (count != null && count == 0) {
+ log.info("添加 customer_service_role.allowed_mcp_tools 列");
+ jdbcTemplate.execute("ALTER TABLE customer_service_role ADD COLUMN allowed_mcp_tools JSONB DEFAULT '[]'");
+ }
+ } catch (Exception e) {
+ log.error("添加 customer_service_role.allowed_mcp_tools 列失败", e);
+ }
+ }
+
private void syncDefaultCustomerServiceRoles() {
upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明",
"""
@@ -1017,6 +1046,27 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_webhook_enabled ON webhook_config (enabled) WHERE is_delete = false");
}
+ private void createMcpServerConfigTable() {
+ String sql = """
+ CREATE TABLE IF NOT EXISTS mcp_server_config (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ transport_type VARCHAR(20) NOT NULL DEFAULT 'sse',
+ server_url VARCHAR(500),
+ command VARCHAR(500),
+ args VARCHAR(1000),
+ env_vars JSONB DEFAULT '{}' NOT NULL,
+ description VARCHAR(500),
+ is_active BOOLEAN DEFAULT TRUE NOT NULL,
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ is_delete BOOLEAN NOT NULL DEFAULT FALSE
+ )
+ """;
+ jdbcTemplate.execute(sql);
+ jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE");
+ }
+
/**
* 为所有自动创建的表添加注释(COMMENT ON)。
* 所有语句均为幂等操作,可安全重复执行。
@@ -1027,7 +1077,7 @@ public class DatabaseInitConfig {
executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)");
executeComment("COLUMN chat_message.id", "主键(雪花算法生成)");
executeComment("COLUMN chat_message.conversation_id", "会话 ID(标识同一次对话)");
- executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复) / SYSTEM(系统消息)");
+ executeComment("COLUMN chat_message.message_type", "消息类型: USER(用户消息) / ASSISTANT(AI回复,可能含toolCalls) / SYSTEM(系统消息) / TOOL(MCP工具调用响应)");
executeComment("COLUMN chat_message.content", "消息内容(实际对话文本)");
executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)");
executeComment("COLUMN chat_message.create_time", "创建时间");
@@ -1077,6 +1127,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN customer_service_role.create_time", "创建时间");
executeComment("COLUMN customer_service_role.update_time", "更新时间");
executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
+ executeComment("COLUMN customer_service_role.allowed_mcp_tools", "允许使用的 MCP 工具列表(JSONB 数组,空=不允许,[\"*\"]=全部允许)");
// ===== customer_service_role_category =====
executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)");
@@ -1208,6 +1259,21 @@ public class DatabaseInitConfig {
// ===== webhook_config =====
executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)");
+ // ===== mcp_server_config =====
+ executeComment("TABLE mcp_server_config", "MCP Server 配置表(管理外部 MCP Server 连接配置)");
+ executeComment("COLUMN mcp_server_config.id", "主键(雪花算法生成)");
+ executeComment("COLUMN mcp_server_config.name", "配置名称");
+ executeComment("COLUMN mcp_server_config.transport_type", "传输类型: stdio(标准输入输出) / sse(Server-Sent Events)");
+ executeComment("COLUMN mcp_server_config.server_url", "SSE 模式的 MCP Server URL");
+ executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令");
+ executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)");
+ executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)");
+ executeComment("COLUMN mcp_server_config.description", "描述说明");
+ executeComment("COLUMN mcp_server_config.is_active", "是否启用");
+ executeComment("COLUMN mcp_server_config.create_time", "创建时间");
+ executeComment("COLUMN mcp_server_config.update_time", "更新时间");
+ executeComment("COLUMN mcp_server_config.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
+
// ===== ai_model_config =====
executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)");
executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)");
diff --git a/src/main/java/com/wok/supportbot/config/McpClientManager.java b/src/main/java/com/wok/supportbot/config/McpClientManager.java
new file mode 100644
index 0000000..7041d8a
--- /dev/null
+++ b/src/main/java/com/wok/supportbot/config/McpClientManager.java
@@ -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 的创建、缓存、刷新和销毁。
+ *
+ * 支持两种传输模式:
+ * - 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 clientCache = new ConcurrentHashMap<>();
+
+ /**
+ * 不可用配置集合:记录已知不存在或未启用的配置ID,避免重复查询 DB
+ * 配置被启用或新建时需同步移除此集合中的对应条目
+ */
+ private final Set unavailableConfigs = ConcurrentHashMap.newKeySet();
+
+ /**
+ * 健康状态缓存:key = 配置ID 的字符串形式,value = 健康检查结果
+ */
+ private final ConcurrentHashMap 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 allActiveConfigs = mcpServerConfigService.listAllActiveConfigs();
+
+ // 第一步:在全新 Map 中构建所有客户端(不影响现有缓存)
+ ConcurrentHashMap 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 oldCache = clientCache;
+ clientCache = newCache;
+ unavailableConfigs.clear();
+
+ // 第三步:关闭旧缓存中的客户端(不影响新请求)
+ for (Map.Entry 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