Browse Source

支持mcp服务加入对话链

dev-mcp
wanghanlin 3 weeks ago
parent
commit
747a9ab558
  1. BIN
      io/modelcontextprotocol/client/McpSyncClient.class
  2. 6
      pom.xml
  3. 223
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  4. 16
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  5. 70
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  6. 660
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  7. 24
      src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java
  8. 24
      src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java
  9. 16
      src/main/java/com/wok/supportbot/controller/AiController.java
  10. 29
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  11. 348
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  12. 118
      src/main/java/com/wok/supportbot/converter/MessageConverter.java
  13. 12
      src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java
  14. 104
      src/main/java/com/wok/supportbot/entity/McpServerConfig.java
  15. 265
      src/main/java/com/wok/supportbot/mcp/McpToolCallback.java
  16. 111
      src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
  17. 73
      src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java
  18. 76
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  19. 320
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  20. 4
      src/main/resources/init-database.sql
  21. 37
      src/main/resources/static/components/ChatPanel.js
  22. 524
      src/main/resources/static/components/McpServerManager.js
  23. 9
      src/main/resources/static/css/main.css
  24. 58
      src/main/resources/static/js/api.js
  25. 7
      src/main/resources/static/js/app.js
  26. 4
      src/main/resources/static/js/store.js
  27. 61
      src/main/resources/static/js/utils.js

BIN
io/modelcontextprotocol/client/McpSyncClient.class

6
pom.xml

@ -92,6 +92,12 @@
<artifactId>spring-ai-openai</artifactId> <artifactId>spring-ai-openai</artifactId>
</dependency> </dependency>
<!-- Spring AI MCP Client(Model Context Protocol 客户端) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!--Hutool 工具库--> <!--Hutool 工具库-->
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>

223
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -10,6 +10,8 @@ import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter; import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter; import com.wok.supportbot.rag.preretrieval.TranslationQueryRewriter;
import com.wok.supportbot.service.FaqMatchEngine; import com.wok.supportbot.service.FaqMatchEngine;
import com.wok.supportbot.mcp.McpToolCallback;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.ChatClient;
@ -23,10 +25,12 @@ import org.springframework.ai.document.Document;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor; import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter; import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever; import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.filter.Filter; import org.springframework.ai.vectorstore.filter.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@ -62,6 +66,13 @@ public class AssistantApp {
@Resource @Resource
private FaqMatchEngine faqMatchEngine; private FaqMatchEngine faqMatchEngine;
@Resource
private McpToolCallbackAdapter mcpToolCallbackAdapter;
/** MCP 工具开关,默认启用,可通过 application.yml 的 chat.mcp.enabled 关闭 */
@Value("${chat.mcp.enabled:true}")
private boolean enableMcpTools;
private final ChatModelFactory chatModelFactory; private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory; private final DatabaseChatMemory chatMemory;
@ -81,13 +92,51 @@ public class AssistantApp {
} }
private ChatClient getChatClient(String appType) { private ChatClient getChatClient(String appType) {
return chatClientCache.computeIfAbsent(appType, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(type);
return getChatClient(appType, enableMcpTools ? java.util.List.of("*") : null);
}
/**
* 获取 ChatClient支持按角色过滤 MCP 工具
* 缓存 key 包含工具权限信息不同角色权限缓存不同的 ChatClient 实例
*
* @param appType 应用类型CHAT / RAG_REWRITE
* @param allowedMcpTools 允许的 MCP 工具名列表null 或空 = 允许所有默认行为["*"] = 全部
*/
private ChatClient getChatClient(String appType, List<String> allowedMcpTools) {
// 空列表降级为 ["*"]允许所有工具与无参数版本行为一致
final List<String> effectiveTools;
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
effectiveTools = java.util.List.of("*");
} else {
effectiveTools = allowedMcpTools;
}
String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools));
return chatClientCache.computeIfAbsent(cacheKey, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(appType);
var builder = ChatClient.builder(chatModel); var builder = ChatClient.builder(chatModel);
// 仅在系统提示词非空时设置避免 Spring AI Assert.hasText 校验抛异常 // 仅在系统提示词非空时设置避免 Spring AI Assert.hasText 校验抛异常
if (StringUtils.hasText(SYSTEM_PROMPT)) { if (StringUtils.hasText(SYSTEM_PROMPT)) {
builder.defaultSystem(SYSTEM_PROMPT); builder.defaultSystem(SYSTEM_PROMPT);
} }
// 注册 MCP 工具按角色权限过滤
if (enableMcpTools && effectiveTools != null && !effectiveTools.isEmpty()) {
ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools);
if (mcpTools.length > 0) {
builder.defaultToolCallbacks(mcpTools);
log.info("✅ 已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
for (ToolCallback tc : mcpTools) {
log.info(" 🔧 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
}
} else {
log.warn("⚠️ MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?");
log.warn(" 2. 配置是否已启用(is_active=true)?");
log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?");
log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?");
}
} else {
log.info("ℹ️ MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
}
return builder return builder
.defaultAdvisors( .defaultAdvisors(
contentSafetyAdvisor, contentSafetyAdvisor,
@ -123,7 +172,20 @@ public class AssistantApp {
* @return AI 回答 * @return AI 回答
*/ */
public String doChat(String message, String chatId, String systemPrompt) { public String doChat(String message, String chatId, String systemPrompt) {
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT")
return doChat(message, chatId, systemPrompt, null);
}
/**
* AI 基础对话支持多轮对话记忆 + 角色系统提示词 + MCP 工具权限
*
* @param message 用户消息
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return AI 回答
*/
public String doChat(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.user(message) .user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId)); .advisors(s -> s.param(CONVERSATION_ID, chatId));
@ -175,6 +237,64 @@ public class AssistantApp {
return spec.stream().content(); return spec.stream().content();
} }
/**
* AI 基础对话多轮记忆 + 角色系统提示词 + MCP 工具权限SSE 流式传输
*
* @param message 用户消息
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return 流式回答
*/
public Flux<String> doChatByStream(String message, String chatId, String systemPrompt, List<String> allowedMcpTools) {
// 重置 MCP 工具调用状态事件收集器 + 轮次计数器
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return appendMcpToolEvents(spec.stream().content());
}
/**
* SSE 文本流末尾追加 MCP 工具调用事件
* 前端通过解析 "event: tool_call_start" / "event: tool_call_result" 行来展示工具调用过程
* 事件格式遵循 SSE 标准 readSSEStreamWithEvents 的解析逻辑匹配
*/
private Flux<String> appendMcpToolEvents(Flux<String> contentFlux) {
return contentFlux.concatMap(chunk -> Flux.just(chunk))
.concatWith(Flux.defer(() -> {
java.util.List<McpToolCallback.ToolCallEvent> events = McpToolCallback.drainEvents();
if (events.isEmpty()) {
return Flux.empty();
}
log.info("本次对话触发了 {} 个 MCP 工具调用", events.size());
List<String> sseLines = new ArrayList<>();
for (McpToolCallback.ToolCallEvent event : events) {
sseLines.add("event:tool_call_result");
String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}",
escapeSse(event.tool()),
escapeSse(event.result()),
event.latencyMs());
sseLines.add("data:" + json);
sseLines.add(""); // SSE 事件分隔空行
}
return Flux.fromIterable(sseLines);
}));
}
/** 转义 SSE 数据中的特殊字符 */
private static String escapeSse(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r");
}
// ==================== FAQ 优先匹配 ==================== // ==================== FAQ 优先匹配 ====================
/** /**
@ -278,6 +398,44 @@ public class AssistantApp {
return chatResponse.getResult().getOutput().getText(); return chatResponse.getResult().getOutput().getText();
} }
/**
* RAG 知识库对话支持查询重写策略 + MCP 工具权限
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return AI 回答
*/
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
// FAQ 优先匹配
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中,直接返回标准答案: chatId={}", chatId);
return faqAnswer.get();
}
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
}
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(rewrittenMessage)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
ChatResponse chatResponse = spec.call().chatResponse();
return chatResponse.getResult().getOutput().getText();
}
/** /**
* 根据策略对查询做预检索改写MULTI_QUERY 不走此方法 * 根据策略对查询做预检索改写MULTI_QUERY 不走此方法
*/ */
@ -344,6 +502,49 @@ public class AssistantApp {
return spec.stream().content(); return spec.stream().content();
} }
/**
* RAG 知识库进行对话支持查询重写策略 + MCP 工具权限SSE 流式传输
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @param allowedMcpTools 允许的 MCP 工具列表null=不注册工具
* @return 流式回答
*/
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
List<Long> categoryIds, String systemPrompt,
List<String> allowedMcpTools) {
// 重置 MCP 工具调用状态
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
// FAQ 优先匹配命中则直接以流式形式返回标准答案
Optional<String> faqAnswer = tryFaqMatch(message);
if (faqAnswer.isPresent()) {
log.info("FAQ 命中(流式),直接返回标准答案: chatId={}", chatId);
return Flux.just(faqAnswer.get());
}
// 对于 MULTI_QUERY 策略需要先手动检索合并再流式生成
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, allowedMcpTools);
}
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT", allowedMcpTools)
.prompt()
.user(rewrittenMessage)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return appendMcpToolEvents(spec.stream().content());
}
/** /**
* 使用多路查询扩展的 RAG 对话 * 使用多路查询扩展的 RAG 对话
* 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并 * 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并
@ -356,8 +557,13 @@ public class AssistantApp {
* @return AI 回答 * @return AI 回答
*/ */
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) { private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt, null);
}
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
ChatResponse chatResponse = getChatClient("CHAT")
ChatResponse chatResponse = getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.system(ragSystem) .system(ragSystem)
.user(message) .user(message)
@ -368,14 +574,19 @@ public class AssistantApp {
} }
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) { private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt, null);
}
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds,
String systemPrompt, List<String> allowedMcpTools) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
return getChatClient("CHAT")
return appendMcpToolEvents(getChatClient("CHAT", allowedMcpTools)
.prompt() .prompt()
.system(ragSystem) .system(ragSystem)
.user(message) .user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId)) .advisors(s -> s.param(CONVERSATION_ID, chatId))
.stream() .stream()
.content();
.content());
} }
/** /**

16
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -354,6 +354,22 @@ public class ChatModelFactory {
// ==================== F6: Fallback 故障自动切换 ==================== // ==================== F6: Fallback 故障自动切换 ====================
/**
* 判断指定提供商是否支持 Tool Calling
* DashScope: 支持通义千问系列
* DeepSeek: 支持
* OpenAI: 支持
* 其他: 默认支持 API 端点自行返回错误
*
* @param provider 提供商标识 dashscopedeepseekopenai
* @return 是否支持 Tool Calling
*/
public boolean supportsToolCalling(String provider) {
if (provider == null) return true;
// 当前所有已支持的提供商都支持 tool calling
return true;
}
/** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */ /** 共享熔断器:连续 3 次失败触发,5 分钟自动恢复 */
private final SimpleCircuitBreaker circuitBreaker = private final SimpleCircuitBreaker circuitBreaker =
new SimpleCircuitBreaker(3, 5 * 60 * 1000L, ""); new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "");

70
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -86,6 +86,8 @@ public class DatabaseInitConfig {
}); });
safeInit("迁移 conversation_session.external_account_id 列", this::addConversationSessionExternalAccountIdColumn); safeInit("迁移 conversation_session.external_account_id 列", this::addConversationSessionExternalAccountIdColumn);
safeInit("迁移 customer_service_role.allowed_mcp_tools 列", this::addRoleAllowedMcpToolsColumn);
safeInit("同步默认客服角色", this::syncDefaultCustomerServiceRoles); safeInit("同步默认客服角色", this::syncDefaultCustomerServiceRoles);
safeInit("同步默认客服账号", this::syncDefaultCustomerAccounts); safeInit("同步默认客服账号", this::syncDefaultCustomerAccounts);
@ -182,6 +184,14 @@ public class DatabaseInitConfig {
} }
}); });
// ==================== MCP Server 配置 ====================
safeInit("创建 MCP Server 配置表 mcp_server_config", () -> {
if (!checkTableExists("mcp_server_config")) {
createMcpServerConfigTable();
}
});
// P0-001: 混合检索 - vector_store 添加全文检索列 // P0-001: 混合检索 - vector_store 添加全文检索列
safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch); safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch);
@ -220,7 +230,8 @@ public class DatabaseInitConfig {
"knowledge_faq", "faq_embedding", "knowledge_faq", "faq_embedding",
"sys_user", "sys_role", "sys_permission", "sys_user_role", "sys_user", "sys_role", "sys_permission", "sys_user_role",
"rag_hit_log", "dashboard_snapshot", "rag_hit_log", "dashboard_snapshot",
"api_key", "webhook_config"
"api_key", "webhook_config",
"mcp_server_config"
}; };
java.util.List<String> missingTables = new java.util.ArrayList<>(); java.util.List<String> missingTables = new java.util.ArrayList<>();
@ -430,6 +441,24 @@ public class DatabaseInitConfig {
log.warn("add conversation_session.external_account_id failed: {}", e.getMessage()); 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() { private void syncDefaultCustomerServiceRoles() {
upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明", 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"); 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 * 为所有自动创建的表添加注释COMMENT ON
* 所有语句均为幂等操作可安全重复执行 * 所有语句均为幂等操作可安全重复执行
@ -1027,7 +1077,7 @@ public class DatabaseInitConfig {
executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)"); executeComment("TABLE chat_message", "聊天消息表(存储用户与 AI 助手的对话历史)");
executeComment("COLUMN chat_message.id", "主键(雪花算法生成)"); executeComment("COLUMN chat_message.id", "主键(雪花算法生成)");
executeComment("COLUMN chat_message.conversation_id", "会话 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.content", "消息内容(实际对话文本)");
executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)"); executeComment("COLUMN chat_message.metadata", "元数据(JSON 格式,存储额外信息)");
executeComment("COLUMN chat_message.create_time", "创建时间"); 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.create_time", "创建时间");
executeComment("COLUMN customer_service_role.update_time", "更新时间"); executeComment("COLUMN customer_service_role.update_time", "更新时间");
executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除"); executeComment("COLUMN customer_service_role.is_delete", "逻辑删除: FALSE=正常 TRUE=已删除");
executeComment("COLUMN customer_service_role.allowed_mcp_tools", "允许使用的 MCP 工具列表(JSONB 数组,空=不允许,[\"*\"]=全部允许)");
// ===== customer_service_role_category ===== // ===== customer_service_role_category =====
executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)"); executeComment("TABLE customer_service_role_category", "客服角色知识库关联表(角色与知识库分类的多对多关系)");
@ -1208,6 +1259,21 @@ public class DatabaseInitConfig {
// ===== webhook_config ===== // ===== webhook_config =====
executeComment("TABLE webhook_config", "Webhook 配置表(事件推送订阅)"); 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 ===== // ===== ai_model_config =====
executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)"); executeComment("TABLE ai_model_config", "AI 大模型配置表(管理多套模型配置,按应用类型绑定)");
executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)"); executeComment("COLUMN ai_model_config.id", "主键(雪花算法生成)");

660
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 的创建缓存刷新和销毁
* <p>
* 支持两种传输模式
* - SSEServer-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 格式 30sPT30S60s
*/
@Value("${mcp.client.request-timeout:30s}")
private String requestTimeoutStr;
/**
* MCP 客户端初始化超时时间支持 Duration 格式 15sPT15S30s
*/
@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_idnametransport_typedescriptiontools
*/
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 Mapkey = 配置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 结尾去掉后缀作为 baseUritransport 层会自动附加
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和简化格式30s60s
* 解析失败时返回默认值
*
* @param durationStr Duration 字符串
* @param defaultValue 解析失败时的默认值
* @return 解析后的 Duration
*/
private Duration parseDuration(String durationStr, Duration defaultValue) {
if (durationStr == null || durationStr.isBlank()) {
return defaultValue;
}
try {
// 先尝试 ISO-8601 格式 PT30SPT1M30S
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 测试结果 Mapsuccess / 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();
}
}

24
src/main/java/com/wok/supportbot/config/McpRequestInterceptor.java

@ -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;
}
}

24
src/main/java/com/wok/supportbot/config/McpWebMvcConfig.java

@ -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("/**");
}
}

16
src/main/java/com/wok/supportbot/controller/AiController.java

@ -61,7 +61,7 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt));
return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools());
} }
/** /**
@ -77,7 +77,7 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt));
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools());
} }
/** /**
@ -93,7 +93,7 @@ public class AiController {
AccountRoleContext context = resolveAccountRole(accountId, roleId); AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context); bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId()); RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools())
.map(chunk -> ServerSentEvent.<String>builder() .map(chunk -> ServerSentEvent.<String>builder()
.data(chunk) .data(chunk)
.build()); .build());
@ -115,7 +115,7 @@ public class AiController {
// 创建一个超时时间较长的 SseEmitter // 创建一个超时时间较长的 SseEmitter
SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时 SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时
// 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter // 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter
assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt), scope.allowedMcpTools())
.subscribe(chunk -> { .subscribe(chunk -> {
try { try {
sseEmitter.send(chunk); sseEmitter.send(chunk);
@ -147,12 +147,12 @@ public class AiController {
String sys = resolveSystemPrompt(scope, systemPrompt); String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通对话绝不检索 KB // 严格隔离未授权任何知识库的角色退回普通对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChat(message, chatId, sys);
return assistantApp.doChat(message, chatId, sys, scope.allowedMcpTools());
} }
try { try {
return assistantApp.doChatWithRagStrategy( return assistantApp.doChatWithRagStrategy(
message, chatId, normalizeStrategy(rewriteStrategy), message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
resolveCategoryIds(scope, categoryId, categoryIds), sys, scope.allowedMcpTools());
} catch (Exception e) { } catch (Exception e) {
log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e); log.error("RAG 对话失败 [strategy={}, chatId={}]: {}", rewriteStrategy, chatId, e.getMessage(), e);
return "抱歉,知识库检索出现异常,请稍后重试。"; return "抱歉,知识库检索出现异常,请稍后重试。";
@ -179,11 +179,11 @@ public class AiController {
String sys = resolveSystemPrompt(scope, systemPrompt); String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通流式对话绝不检索 KB // 严格隔离未授权任何知识库的角色退回普通流式对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) { if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChatByStream(message, chatId, sys);
return assistantApp.doChatByStream(message, chatId, sys, scope.allowedMcpTools());
} }
return assistantApp.doChatWithRagStrategyByStream( return assistantApp.doChatWithRagStrategyByStream(
message, chatId, normalizeStrategy(rewriteStrategy), message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
resolveCategoryIds(scope, categoryId, categoryIds), sys, scope.allowedMcpTools());
} }
/** /**

29
src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java

@ -93,6 +93,35 @@ public class CustomerServiceRoleController {
)); ));
} }
/**
* 更新角色的 MCP 工具权限
* body: { "allowedMcpTools": ["*"] } { "allowedMcpTools": ["mcp_get_weather", "mcp_query_db"] }
*/
@SuppressWarnings("unchecked")
@PutMapping("/role/{id}/mcp-tools")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> updateRoleMcpTools(
@PathVariable("id") Long roleId,
@RequestBody Map<String, Object> body) {
try {
Object raw = body.get("allowedMcpTools");
List<String> allowedMcpTools;
if (raw instanceof List<?> list) {
allowedMcpTools = list.stream()
.filter(Objects::nonNull)
.map(Objects::toString)
.distinct()
.toList();
} else {
allowedMcpTools = List.of();
}
customerServiceRoleService.updateRoleMcpTools(roleId, allowedMcpTools);
return ResponseEntity.ok(Map.of("success", true, "message", "MCP 工具权限更新成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新失败:" + e.getMessage()));
}
}
private List<Long> parseCategoryIds(Object rawCategoryIds) { private List<Long> parseCategoryIds(Object rawCategoryIds) {
if (!(rawCategoryIds instanceof List<?> list)) { if (!(rawCategoryIds instanceof List<?> list)) {
return List.of(); return List.of();

348
src/main/java/com/wok/supportbot/controller/McpServerConfigController.java

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

118
src/main/java/com/wok/supportbot/converter/MessageConverter.java

@ -3,42 +3,136 @@ package com.wok.supportbot.converter;
import com.wok.supportbot.entity.ChatMessage; import com.wok.supportbot.entity.ChatMessage;
import org.springframework.ai.chat.messages.*; import org.springframework.ai.chat.messages.*;
import java.util.List;
import java.util.Map;
import java.util.*;
/** /**
* @Classname MessageConverter
* @Description
* @Version 1.0.0
* @Date 2025/06/28 13:30
* @Author lyx
* 消息转换器Message <-> ChatMessage
* 支持 USER / ASSISTANT / SYSTEM / TOOL 四种消息类型
* 其中 ASSISTANT toolCalls TOOL responses 通过 metadata JSONB 字段持久化
*/ */
public class MessageConverter { public class MessageConverter {
/** metadata 中存储 toolCalls 的内部键名 */
private static final String META_KEY_TOOL_CALLS = "_toolCalls";
/** metadata 中存储 toolResponses 的内部键名 */
private static final String META_KEY_TOOL_RESPONSES = "_toolResponses";
/** /**
* Message 转换为 ChatMessage
* Spring AI Message 转换为数据库实体 ChatMessage
* 对于 ASSISTANT 消息如果包含 toolCalls 则序列化到 metadata
* 对于 TOOL 消息 responses 序列化到 metadata
*/ */
public static ChatMessage toChatMessage(Message message, String conversationId) { public static ChatMessage toChatMessage(Message message, String conversationId) {
Map<String, Object> metadata = message.getMetadata() != null
? new HashMap<>(message.getMetadata())
: new HashMap<>();
// 处理 AssistantMessage toolCalls
if (message instanceof AssistantMessage assistant && assistant.hasToolCalls()) {
List<Map<String, String>> toolCallsJson = new ArrayList<>();
for (AssistantMessage.ToolCall tc : assistant.getToolCalls()) {
Map<String, String> tcMap = new LinkedHashMap<>();
tcMap.put("id", tc.id());
tcMap.put("type", tc.type());
tcMap.put("name", tc.name());
tcMap.put("arguments", tc.arguments());
toolCallsJson.add(tcMap);
}
metadata.put(META_KEY_TOOL_CALLS, toolCallsJson);
}
// 处理 ToolResponseMessage responses
if (message instanceof ToolResponseMessage trm) {
List<Map<String, String>> responsesJson = new ArrayList<>();
for (ToolResponseMessage.ToolResponse tr : trm.getResponses()) {
Map<String, String> trMap = new LinkedHashMap<>();
trMap.put("id", tr.id());
trMap.put("name", tr.name());
trMap.put("responseData", tr.responseData());
responsesJson.add(trMap);
}
metadata.put(META_KEY_TOOL_RESPONSES, responsesJson);
}
return ChatMessage.builder() return ChatMessage.builder()
.conversationId(conversationId) .conversationId(conversationId)
.messageType(message.getMessageType()) .messageType(message.getMessageType())
.content(message.getText()) .content(message.getText())
.metadata(message.getMetadata())
.metadata(metadata)
.build(); .build();
} }
/** /**
* ChatMessage 转换为 Message
* 将数据库实体 ChatMessage 转换为 Spring AI Message
* 根据 messageType 分发到具体子类反序列化 toolCalls / responses
*/ */
@SuppressWarnings("unchecked")
public static Message toMessage(ChatMessage chatMessage) { public static Message toMessage(ChatMessage chatMessage) {
MessageType messageType = chatMessage.getMessageType(); MessageType messageType = chatMessage.getMessageType();
String text = chatMessage.getContent(); String text = chatMessage.getContent();
Map<String, Object> metadata = chatMessage.getMetadata(); Map<String, Object> metadata = chatMessage.getMetadata();
// 构建不含内部键的干净 metadata Message 对象使用
Map<String, Object> cleanMetadata = metadata != null
? new HashMap<>(metadata)
: new HashMap<>();
return switch (messageType) { return switch (messageType) {
case USER -> new UserMessage(text); case USER -> new UserMessage(text);
case ASSISTANT -> new AssistantMessage(text, metadata);
case ASSISTANT -> {
List<AssistantMessage.ToolCall> toolCalls = extractToolCalls(cleanMetadata);
yield new AssistantMessage(text, cleanMetadata, toolCalls);
}
case SYSTEM -> new SystemMessage(text); case SYSTEM -> new SystemMessage(text);
case TOOL -> new ToolResponseMessage(List.of(), metadata);
case TOOL -> {
List<ToolResponseMessage.ToolResponse> responses = extractToolResponses(cleanMetadata);
yield new ToolResponseMessage(responses, cleanMetadata);
}
}; };
} }
/**
* metadata 中提取并移除 _toolCalls 反序列化为 ToolCall 列表
*/
@SuppressWarnings("unchecked")
private static List<AssistantMessage.ToolCall> extractToolCalls(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_TOOL_CALLS);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<AssistantMessage.ToolCall> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<String, Object> map = (Map<String, Object>) item;
String id = String.valueOf(map.getOrDefault("id", ""));
String type = String.valueOf(map.getOrDefault("type", "function"));
String name = String.valueOf(map.getOrDefault("name", ""));
String arguments = String.valueOf(map.getOrDefault("arguments", "{}"));
result.add(new AssistantMessage.ToolCall(id, type, name, arguments));
}
}
return result;
}
/**
* metadata 中提取并移除 _toolResponses 反序列化为 ToolResponse 列表
*/
@SuppressWarnings("unchecked")
private static List<ToolResponseMessage.ToolResponse> extractToolResponses(Map<String, Object> metadata) {
Object raw = metadata.remove(META_KEY_TOOL_RESPONSES);
if (!(raw instanceof List<?> list) || list.isEmpty()) {
return List.of();
}
List<ToolResponseMessage.ToolResponse> result = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<String, Object> map = (Map<String, Object>) item;
String id = String.valueOf(map.getOrDefault("id", ""));
String name = String.valueOf(map.getOrDefault("name", ""));
String responseData = String.valueOf(map.getOrDefault("responseData", ""));
result.add(new ToolResponseMessage.ToolResponse(id, name, responseData));
}
}
return result;
}
} }

12
src/main/java/com/wok/supportbot/dao/McpServerConfigMapper.java

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

104
src/main/java/com/wok/supportbot/entity/McpServerConfig.java

@ -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标准输入输出/ sseServer-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;
}

265
src/main/java/com/wok/supportbot/mcp/McpToolCallback.java

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

111
src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java

@ -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);
}
}

73
src/main/java/com/wok/supportbot/mcp/SseEventBuilder.java

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

76
src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

@ -1,5 +1,8 @@
package com.wok.supportbot.service; package com.wok.supportbot.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -12,11 +15,15 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
@Service @Service
@Slf4j
public class CustomerServiceRoleService { public class CustomerServiceRoleService {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@Autowired
private ObjectMapper objectMapper;
public List<Map<String, Object>> listRoles() { public List<Map<String, Object>> listRoles() {
return listRoles(false); return listRoles(false);
} }
@ -25,7 +32,7 @@ public class CustomerServiceRoleService {
* @param includeDisabled true=包含已停用角色管理页用false=仅启用角色对话页用 * @param includeDisabled true=包含已停用角色管理页用false=仅启用角色对话页用
*/ */
public List<Map<String, Object>> listRoles(boolean includeDisabled) { public List<Map<String, Object>> listRoles(boolean includeDisabled) {
String roleSql = "SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled "
String roleSql = "SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled, allowed_mcp_tools "
+ "FROM customer_service_role WHERE is_delete = false " + "FROM customer_service_role WHERE is_delete = false "
+ (includeDisabled ? "" : "AND enabled = true ") + (includeDisabled ? "" : "AND enabled = true ")
+ "ORDER BY sort_order ASC, id ASC"; + "ORDER BY sort_order ASC, id ASC";
@ -137,6 +144,28 @@ public class CustomerServiceRoleService {
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId); jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
} }
/**
* 更新角色的 MCP 工具权限
*
* @param roleId 角色ID
* @param allowedMcpTools 允许的 MCP 工具名列表["*"]=全部允许/null=不允许任何工具
*/
@Transactional(rollbackFor = Exception.class)
public void updateRoleMcpTools(Long roleId, List<String> allowedMcpTools) {
if (roleId == null || roleId <= 0) {
throw new IllegalArgumentException("角色ID无效");
}
String json;
try {
json = objectMapper.writeValueAsString(allowedMcpTools != null ? allowedMcpTools : List.of());
} catch (Exception e) {
json = "[]";
}
jdbcTemplate.update(
"UPDATE customer_service_role SET allowed_mcp_tools = ?::jsonb, update_time = CURRENT_TIMESTAMP WHERE id = ? AND is_delete = false",
json, roleId);
}
/** /**
* 服务端解析角色的知识库范围与人设 * 服务端解析角色的知识库范围与人设
* 用于对话时强制约束角色只能检索其绑定分类下的内容由后端决定客户端无法越权跨域 * 用于对话时强制约束角色只能检索其绑定分类下的内容由后端决定客户端无法越权跨域
@ -149,7 +178,7 @@ public class CustomerServiceRoleService {
return RoleScope.empty(); return RoleScope.empty();
} }
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT name, prompt FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
"SELECT name, prompt, allowed_mcp_tools FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
roleId); roleId);
if (rows.isEmpty()) { if (rows.isEmpty()) {
return RoleScope.empty(); return RoleScope.empty();
@ -159,22 +188,57 @@ public class CustomerServiceRoleService {
List<Long> categoryIds = jdbcTemplate.queryForList( List<Long> categoryIds = jdbcTemplate.queryForList(
"SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false", "SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false",
Long.class, roleId); Long.class, roleId);
return new RoleScope(true, name, prompt, categoryIds);
// 解析 allowed_mcp_tools JSONB 字段
List<String> allowedMcpTools = parseAllowedMcpTools(rows.get(0).get("allowed_mcp_tools"));
return new RoleScope(true, name, prompt, categoryIds, allowedMcpTools);
} }
/** /**
* 角色范围是否命中角色角色人设可检索的知识库分类
* 解析 allowed_mcp_tools JSONB 字段为 List<String>
* 兼容 PostgreSQL JSONBJava ListString 等多种输入类型
*/ */
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds) {
private List<String> parseAllowedMcpTools(Object raw) {
if (raw == null) {
return List.of();
}
try {
if (raw instanceof List<?> list) {
return list.stream().map(Objects::toString).toList();
}
String json = raw.toString().trim();
if (json.isEmpty() || "[]".equals(json) || "null".equals(json)) {
return List.of();
}
return objectMapper.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
log.warn("解析 allowed_mcp_tools 失败: {}", raw, e);
return List.of();
}
}
/**
* 角色范围是否命中角色角色人设可检索的知识库分类MCP 工具权限
*/
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds, List<String> allowedMcpTools) {
public static RoleScope empty() { public static RoleScope empty() {
return new RoleScope(false, "", "", List.of());
return new RoleScope(false, "", "", List.of(), List.of());
} }
public boolean hasRole() { public boolean hasRole() {
return present; return present;
} }
/** 是否允许使用 MCP 工具(非空即允许) */
public boolean isMcpToolsAllowed() {
return allowedMcpTools != null && !allowedMcpTools.isEmpty();
}
/** 是否允许使用所有 MCP 工具(["*"] 表示全部允许) */
public boolean isAllMcpToolsAllowed() {
return allowedMcpTools != null && allowedMcpTools.contains("*");
}
/** /**
* 组合角色系统提示词当前客服角色 + 人设都为空时返回 null退回基础提示词 * 组合角色系统提示词当前客服角色 + 人设都为空时返回 null退回基础提示词
*/ */

320
src/main/java/com/wok/supportbot/service/McpServerConfigService.java

@ -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);
}
}
}

4
src/main/resources/init-database.sql

@ -141,7 +141,8 @@ CREATE TABLE IF NOT EXISTS customer_service_role (
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_delete BOOLEAN NOT NULL DEFAULT FALSE
is_delete BOOLEAN NOT NULL DEFAULT FALSE,
allowed_mcp_tools JSONB NOT NULL DEFAULT '[]'
); );
CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled, sort_order); CREATE INDEX IF NOT EXISTS idx_customer_service_role_enabled ON customer_service_role (enabled, sort_order);
@ -157,6 +158,7 @@ COMMENT ON COLUMN customer_service_role.enabled IS '是否启用';
COMMENT ON COLUMN customer_service_role.create_time IS '创建时间'; COMMENT ON COLUMN customer_service_role.create_time IS '创建时间';
COMMENT ON COLUMN customer_service_role.update_time IS '更新时间'; COMMENT ON COLUMN customer_service_role.update_time IS '更新时间';
COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除'; COMMENT ON COLUMN customer_service_role.is_delete IS '逻辑删除';
COMMENT ON COLUMN customer_service_role.allowed_mcp_tools IS '允许使用的 MCP 工具列表(JSONB 数组,空=不允许,["*"]=全部允许)';
-- 默认角色种子数据 -- 默认角色种子数据
INSERT INTO customer_service_role (role_key, name, description, sort_order, enabled) INSERT INTO customer_service_role (role_key, name, description, sort_order, enabled)

37
src/main/resources/static/components/ChatPanel.js

@ -3,7 +3,7 @@
*/ */
import { ref, computed, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js'
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js'
import { toast, readSSEStream, readSSEStreamWithEvents, renderMarkdown } from '../js/utils.js'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import MessageSources from './MessageSources.js' import MessageSources from './MessageSources.js'
@ -108,6 +108,16 @@ export default {
</div> </div>
</template> </template>
<template v-else> <template v-else>
<!-- MCP 工具调用展示 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div> <div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div>
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div> <div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<div v-else class="msg-bubble">{{ m.content }}</div> <div v-else class="msg-bubble">{{ m.content }}</div>
@ -310,7 +320,7 @@ export default {
isSending.value = true isSending.value = true
messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() }) messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() })
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [] }
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [], toolCalls: [] }
messages.value.push(assistantMsg) messages.value.push(assistantMsg)
await scrollToBottom() await scrollToBottom()
@ -325,10 +335,29 @@ export default {
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) { } else if (isRagMode.value) {
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStream(url, async (chunk) => {
await readSSEStreamWithEvents(url, {
onMessage: async (chunk) => {
assistantMsg.content += chunk assistantMsg.content += chunk
await scrollToBottom() await scrollToBottom()
}, () => {})
},
onToolCallStart: (data) => {
assistantMsg.toolCalls.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data) => {
const tc = assistantMsg.toolCalls.find(t => t.tool === data.tool && t.status === 'running')
if (tc) {
tc.status = 'done'
tc.result = data.result
tc.latencyMs = data.latencyMs
}
scrollToBottom()
},
onError: (data) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
},
onDone: () => {}
})
} else if (mode.value === 'sync') { } else if (mode.value === 'sync') {
assistantMsg.content = await chatSync(text, cid, roleId) assistantMsg.content = await chatSync(text, cid, roleId)
} else { } else {

524
src/main/resources/static/components/McpServerManager.js

@ -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">&times;</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&#10;mcp-server-fetch&#10;--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&#10;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
}
}
}

9
src/main/resources/static/css/main.css

@ -252,6 +252,15 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; } .msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; }
.msg-tools button:hover { background:#f4f4f5; color:var(--text); } .msg-tools button:hover { background:#f4f4f5; color:var(--text); }
.msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); } .msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); }
/* ==================== MCP 工具调用展示 ==================== */
.mcp-tool-calls { margin-bottom:8px; border-left:3px solid var(--primary, #4f46e5); padding-left:10px; }
.mcp-tool-item { padding:4px 0; }
.mcp-tool-header { display:flex; align-items:center; gap:6px; font-size:12px; color:#6b7280; }
.mcp-tool-icon { font-size:14px; }
.mcp-tool-name { font-family:monospace; background:#f3f4f6; padding:1px 6px; border-radius:4px; font-size:11px; }
.mcp-tool-latency { color:#9ca3af; font-size:11px; }
.chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; } .chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; }
.composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; } .composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; }
.composer-box:focus-within { border-color:var(--text); } .composer-box:focus-within { border-color:var(--text); }

58
src/main/resources/static/js/api.js

@ -1012,3 +1012,61 @@ export function deleteWebhook(id) {
export function testWebhook(id) { export function testWebhook(id) {
return postJSON(`/webhook/${id}/test`, {}) return postJSON(`/webhook/${id}/test`, {})
} }
// ==================== MCP 服务管理 ====================
/**
* MCP Server 配置列表分页
*/
export function listMcpServers(page = 1, size = 10) {
return getJSON(`/mcp-server/list?page=${page}&size=${size}`)
}
/**
* MCP Server 配置详情
*/
export function getMcpServerDetail(id) {
return getJSON(`/mcp-server/${id}`)
}
/**
* 新建 MCP Server 配置
*/
export function createMcpServer(data) {
return postJSON('/mcp-server', data)
}
/**
* 更新 MCP Server 配置
*/
export function updateMcpServer(id, data) {
return putJSONWithBody(`/mcp-server/${id}`, data)
}
/**
* 删除 MCP Server 配置
*/
export function deleteMcpServer(id) {
return deleteJSON(`/mcp-server/${id}`)
}
/**
* 启用/禁用 MCP Server 配置
*/
export function toggleMcpServer(id, active) {
return putJSONWithBody(`/mcp-server/${id}/toggle`, { active })
}
/**
* 测试 MCP Server 连接
*/
export function testMcpServer(id) {
return postJSON(`/mcp-server/${id}/test`, {})
}
/**
* 刷新所有 MCP 客户端连接
*/
export function refreshMcpServers() {
return postJSON('/mcp-server/refresh', {})
}

7
src/main/resources/static/js/app.js

@ -27,6 +27,7 @@ import UserManager from '../components/UserManager.js'
import DashboardPanel from '../components/DashboardPanel.js' import DashboardPanel from '../components/DashboardPanel.js'
import ApiKeyManager from '../components/ApiKeyManager.js' import ApiKeyManager from '../components/ApiKeyManager.js'
import WebhookManager from '../components/WebhookManager.js' import WebhookManager from '../components/WebhookManager.js'
import McpServerManager from '../components/McpServerManager.js'
const app = createApp({ const app = createApp({
setup() { setup() {
@ -279,6 +280,11 @@ const app = createApp({
<webhook-manager></webhook-manager> <webhook-manager></webhook-manager>
</div> </div>
<!-- 系统设置 - MCP 服务管理 -->
<div v-if="store.activePage === 'mcp-server'" class="page-pane" style="animation: fadeIn .3s ease;">
<mcp-server-manager></mcp-server-manager>
</div>
<!-- 文档详情弹窗 --> <!-- 文档详情弹窗 -->
<doc-detail></doc-detail> <doc-detail></doc-detail>
</main> </main>
@ -309,5 +315,6 @@ app.component('user-manager', UserManager)
app.component('dashboard-panel', DashboardPanel) app.component('dashboard-panel', DashboardPanel)
app.component('api-key-manager', ApiKeyManager) app.component('api-key-manager', ApiKeyManager)
app.component('webhook-manager', WebhookManager) app.component('webhook-manager', WebhookManager)
app.component('mcp-server-manager', McpServerManager)
app.mount('#app') app.mount('#app')

4
src/main/resources/static/js/store.js

@ -47,7 +47,8 @@ export const MENU_ITEMS = [
{ id: 'audit-log', label: '审计日志', icon: '📋' }, { id: 'audit-log', label: '审计日志', icon: '📋' },
{ id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] }, { id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] },
{ id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] }, { id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] },
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] }
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] },
{ id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', roles: ['admin'] }
] ]
} }
] ]
@ -243,6 +244,7 @@ export const store = reactive({
case 'user-manage': case 'user-manage':
case 'api-key': case 'api-key':
case 'webhook': case 'webhook':
case 'mcp-server':
// 新页面无额外数据预加载 // 新页面无额外数据预加载
break break
} }

61
src/main/resources/static/js/utils.js

@ -152,6 +152,67 @@ export async function readSSEStream(url, onChunk, onDone, headers) {
if (onDone) onDone() if (onDone) onDone()
} }
/**
* 增强版 SSE 流式读取支持事件类型分发
* 解析 SSE 标准的 event: 字段将不同类型事件分发到对应回调
*
* @param {string} url 请求地址
* @param {object} handlers 回调对象
* - onMessage(chunk): 普通文本内容event: message 或无 event data
* - onToolCallStart(data): 工具调用开始event: tool_call_start
* - onToolCallResult(data): 工具调用结果event: tool_call_result
* - onError(data): 错误事件event: error
* - onDone(): 流结束
* @param {object} [headers] 额外请求头
*/
export async function readSSEStreamWithEvents(url, handlers, headers) {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers
const res = await fetch(url, { headers: headers || {} })
if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = 'message' // 默认事件类型
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) {
const data = line.slice(5).trim()
if (!data || data === '[DONE]') continue
switch (currentEvent) {
case 'tool_call_start':
if (onToolCallStart) onToolCallStart(JSON.parse(data))
break
case 'tool_call_result':
if (onToolCallResult) onToolCallResult(JSON.parse(data))
break
case 'error':
if (onError) onError(JSON.parse(data))
break
case 'message':
default:
if (onMessage) onMessage(data)
break
}
currentEvent = 'message' // 重置为默认
} else if (line.trim() && !line.startsWith(':')) {
// Flux<String> 模式,非 SSE 标准格式
if (onMessage) onMessage(line)
}
}
}
if (onDone) onDone()
}
// ==================== Markdown 渲染 ==================== // ==================== Markdown 渲染 ====================
/** /**

Loading…
Cancel
Save