4 Commits

Author SHA1 Message Date
wanghanlin 27d17e2af8 chore: 忽略 frontend/.claude 目录 1 week ago
wanghanlin a8cb53b171 fix(mcp): MCP 客户端服务端重启后自动重连 1 week ago
wanghanlin 7222893120 feat(role): 客服角色管理页布局优化并支持 MCP 工具全选 1 week ago
wanghanlin 6590ac762d feat(role): 客服角色 MCP 工具授权 1 week ago
  1. 1
      .gitignore
  2. 1
      frontend/src/api/mcp-server.ts
  3. 3
      frontend/src/api/role.ts
  4. 2
      frontend/src/types/models.ts
  5. 273
      frontend/src/views/RoleManager.vue
  6. 4
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  7. 2
      src/main/java/com/wok/supportbot/app/ChatContext.java
  8. 80
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  9. 4
      src/main/java/com/wok/supportbot/controller/AiController.java
  10. 5
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  11. 49
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  12. 3
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  13. 24
      src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
  14. 3
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

1
.gitignore

@ -47,3 +47,4 @@ build/
/logs/
/node/
/src/main/resources/static/sdk/
/frontend/.claude/

1
frontend/src/api/mcp-server.ts

@ -9,3 +9,4 @@ export function deleteMcpServer(id: string): Promise<ApiResponse> { return reque
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) }
export function testMcpServer(id: string): Promise<ApiResponse> { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) }
export function refreshMcpServers(): Promise<ApiResponse> { return request.post('/mcp-server/refresh', {}).then(r => r.data) }
export function listAvailableMcpTools(): Promise<ApiResponse> { return request.get('/mcp-server/tools').then(r => r.data) }

3
frontend/src/api/role.ts

@ -9,3 +9,6 @@ export function deleteRole(roleId: string): Promise<ApiResponse> { return reques
export function updateRoleCategories(roleId: string, categoryIds: string[]): Promise<ApiResponse> {
return request.put(`/role/${roleId}/categories`, { categoryIds }).then(r => r.data)
}
export function updateRoleMcpTools(roleId: string, allowedMcpTools: string[]): Promise<ApiResponse> {
return request.put(`/role/${roleId}/mcp-tools`, { allowedMcpTools }).then(r => r.data)
}

2
frontend/src/types/models.ts

@ -150,8 +150,10 @@ export interface SupportRole {
id: string
name: string
description?: string
prompt?: string
systemPrompt?: string
categoryIds?: string[]
allowedMcpTools?: string[]
enabled: boolean
createTime?: string
updateTime?: string

273
frontend/src/views/RoleManager.vue

@ -1,5 +1,5 @@
<template>
<t-card title="🎭 客服角色管理" :bordered="false">
<t-card title="🎭 客服角色管理" :bordered="false" class="role-page-card">
<p class="desc-text">为角色设定人设并授权可检索的知识库分类对话页只能选择这里配置好的角色检索范围由服务端按角色强制限定</p>
<div class="role-shell">
@ -77,20 +77,20 @@
<t-form label-align="top" class="detail-form">
<t-row :gutter="16">
<t-col :span="8">
<t-col :span="6" :xs="12">
<t-form-item label="角色名称 *">
<t-input v-model="form.name" :disabled="saving" placeholder="请输入角色名称" />
</t-form-item>
</t-col>
<t-col :span="4">
<t-form-item label="状态">
<t-switch v-model="form.enabled" :disabled="saving" :label="['启用', '停用']" />
<t-col :span="6" :xs="12">
<t-form-item label="职责描述">
<t-input v-model="form.description" :disabled="saving" placeholder="职责描述(可选)" />
</t-form-item>
</t-col>
</t-row>
<t-form-item label="职责描述">
<t-input v-model="form.description" :disabled="saving" placeholder="职责描述(可选)" />
<t-form-item label="状态">
<t-switch v-model="form.enabled" :disabled="saving" :label="['启用', '停用']" />
</t-form-item>
<t-form-item label="人设 / 风格提示词">
@ -102,6 +102,8 @@
/>
</t-form-item>
<div class="form-section-title">授权配置</div>
<t-form-item label="可检索知识库授权">
<t-checkbox-group v-if="categoryStore.categories.length" v-model="form.categoryIds">
<t-checkbox
@ -115,6 +117,59 @@
</t-checkbox-group>
<span v-else class="desc-text">暂无知识库分类请先在知识库管理中创建分类</span>
</t-form-item>
<t-form-item label="MCP 工具授权">
<div class="mcp-tools-box">
<t-checkbox
v-model="allToolsSelected"
:disabled="saving"
>
全部授权允许调用所有已接入的 MCP 工具
</t-checkbox>
<t-loading size="small" :loading="mcpLoading">
<template v-if="!allToolsSelected && mcpServers.length">
<div class="mcp-tools-toolbar">
<t-checkbox
:checked="allToolsChecked"
:indeterminate="allToolsIndeterminate"
:disabled="saving"
@change="toggleAllTools"
>
全选工具
</t-checkbox>
</div>
<div class="mcp-tools-list">
<div v-for="s in mcpServers" :key="s.configId" class="mcp-server-group">
<div class="mcp-server-header">
<span class="mcp-server-name">{{ s.serverName }}</span>
<t-checkbox
:checked="isGroupAllChecked(s)"
:indeterminate="isGroupIndeterminate(s)"
:disabled="saving"
@change="(val) => toggleGroupAll(s, val)"
>
全选
</t-checkbox>
</div>
<t-checkbox
v-for="t in s.tools"
:key="t.name"
:checked="form.allowedMcpTools.includes(t.name)"
:disabled="saving"
@change="(val) => toggleTool(t.name, val)"
>
<span class="mcp-tool-label">{{ t.rawName }}</span>
<span v-if="t.description" class="mcp-tool-desc"> {{ t.description }}</span>
</t-checkbox>
</div>
</div>
</template>
<span v-else-if="!allToolsSelected && !mcpServers.length && !mcpLoading" class="desc-text">
暂无已接入的 MCP 工具请先在MCP 服务管理中配置并启用 Server
</span>
</t-loading>
</div>
</t-form-item>
</t-form>
<div class="detail-footer">
@ -130,7 +185,8 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories, updateRoleMcpTools } from '@/api/role'
import { listAvailableMcpTools } from '@/api/mcp-server'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
@ -142,6 +198,8 @@ const roles = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const mcpServers = ref<any[]>([])
const mcpLoading = ref(false)
const mode = ref<'view' | 'create' | 'edit'>('view')
const selectedId = ref<string | null>(null)
@ -152,6 +210,7 @@ const form = reactive({
prompt: '',
enabled: true,
categoryIds: [] as string[],
allowedMcpTools: [] as string[],
})
// ---- ref ----
@ -164,6 +223,7 @@ function takeSnapshot(): string {
prompt: form.prompt,
enabled: form.enabled,
categoryIds: [...form.categoryIds].sort(),
allowedMcpTools: [...form.allowedMcpTools].sort(),
})
}
@ -173,6 +233,7 @@ function resetForm(): void {
form.prompt = ''
form.enabled = true
form.categoryIds = []
form.allowedMcpTools = []
snapshot.value = takeSnapshot()
}
@ -182,6 +243,7 @@ function setFormFromRole(role: any): void {
form.prompt = role.prompt || ''
form.enabled = role.enabled !== false
form.categoryIds = [...(role.categoryIds || [])]
form.allowedMcpTools = [...(role.allowedMcpTools || [])]
snapshot.value = takeSnapshot()
}
@ -189,11 +251,93 @@ const isDirty = computed(() => takeSnapshot() !== snapshot.value)
// ---- ----
const selectedRole = computed(() => roles.value.find(r => r.id === selectedId.value) ?? null)
//
let lastToolsBeforeAll: string[] = []
// ["*"]
const allToolsSelected = computed<boolean>({
get: () => form.allowedMcpTools.includes('*'),
set: (val) => {
if (val) {
lastToolsBeforeAll = form.allowedMcpTools.filter(t => t !== '*')
form.allowedMcpTools = ['*']
} else {
form.allowedMcpTools = [...lastToolsBeforeAll]
}
},
})
// ---- MCP ----
//
const allToolNames = computed<string[]>(() => {
const names = new Set<string>()
for (const s of mcpServers.value) {
for (const t of (s.tools || [])) {
names.add(t.name)
}
}
return [...names]
})
//
const allToolsChecked = computed<boolean>(() => {
const names = allToolNames.value
return names.length > 0 && names.every(n => form.allowedMcpTools.includes(n))
})
//
const allToolsIndeterminate = computed<boolean>(() => {
const names = allToolNames.value
const selected = names.filter(n => form.allowedMcpTools.includes(n)).length
return selected > 0 && selected < names.length
})
function toggleAllTools(val: boolean): void {
if (val) {
form.allowedMcpTools = [...new Set([...form.allowedMcpTools, ...allToolNames.value])]
} else {
const nameSet = new Set(allToolNames.value)
form.allowedMcpTools = form.allowedMcpTools.filter(n => !nameSet.has(n))
}
}
// Server
function isGroupAllChecked(group: any): boolean {
const tools = group.tools || []
return tools.length > 0 && tools.every((t: any) => form.allowedMcpTools.includes(t.name))
}
// Server
function isGroupIndeterminate(group: any): boolean {
const tools = group.tools || []
const selected = tools.filter((t: any) => form.allowedMcpTools.includes(t.name)).length
return selected > 0 && selected < tools.length
}
function toggleGroupAll(group: any, val: boolean): void {
const names = (group.tools || []).map((t: any) => t.name)
if (val) {
form.allowedMcpTools = [...new Set([...form.allowedMcpTools, ...names])]
} else {
const nameSet = new Set(names)
form.allowedMcpTools = form.allowedMcpTools.filter(n => !nameSet.has(n))
}
}
// /
function toggleTool(name: string, val: boolean): void {
if (val) {
form.allowedMcpTools = [...new Set([...form.allowedMcpTools, name])]
} else {
form.allowedMcpTools = form.allowedMcpTools.filter(n => n !== name)
}
}
// ---- ----
onMounted(() => {
reload()
categoryStore.loadCategories()
loadMcpTools()
})
// ---- selectedRole 退 ----
@ -233,6 +377,23 @@ async function reload(): Promise<void> {
}
}
// ---- MCP ----
async function loadMcpTools(): Promise<void> {
mcpLoading.value = true
try {
const r = await listAvailableMcpTools()
if (r.success) {
mcpServers.value = r.data || []
} else {
toast('加载 MCP 工具列表失败:' + (r.message || '未知错误'), 'error')
}
} catch (e: any) {
toast('加载 MCP 工具列表失败:' + e.message, 'error')
} finally {
mcpLoading.value = false
}
}
// ---- ----
async function handleRefresh(): Promise<void> {
if (!(await guardDirty('刷新'))) return
@ -326,6 +487,11 @@ async function handleSubmit(): Promise<void> {
if (!catRes.success) {
toast(catRes.message || '保存成功,但知识库授权失败', 'error')
}
// MCP
const mcpRes = await updateRoleMcpTools(roleId, [...form.allowedMcpTools])
if (!mcpRes.success) {
toast(mcpRes.message || '保存成功,但 MCP 工具授权失败', 'error')
}
} else {
toast('保存成功,但无法获取角色 ID,知识库授权未提交', 'warning')
}
@ -381,10 +547,16 @@ async function handleDelete(): Promise<void> {
</script>
<style scoped>
/* ===== 页级留白 ===== */
.role-page-card {
margin: 16px;
}
/* ===== 整体分栏 ===== */
.role-shell {
display: flex;
gap: 16px;
height: calc(100vh - 220px);
min-height: 520px;
}
@ -458,8 +630,13 @@ async function handleDelete(): Promise<void> {
}
.role-item-name {
flex: 1;
min-width: 0;
font-size: 13px;
color: var(--td-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.role-item-desc {
@ -479,13 +656,14 @@ async function handleDelete(): Promise<void> {
border: 1px solid var(--td-border-level-1-color);
border-radius: 10px;
padding: 20px 24px;
overflow-y: auto;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--td-border-level-1-color);
@ -506,17 +684,82 @@ async function handleDelete(): Promise<void> {
.detail-form {
flex: 1;
overflow-y: auto;
}
.detail-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid var(--td-border-level-1-color);
}
/* ===== 表单分区标题 ===== */
.form-section-title {
margin: 8px 0 12px;
padding-left: 8px;
font-size: 13px;
font-weight: 600;
color: var(--td-text-color-primary);
border-left: 3px solid var(--td-brand-color);
line-height: 1.4;
}
/* ===== MCP 工具授权 ===== */
.mcp-tools-box {
width: 100%;
}
.mcp-tools-toolbar {
display: flex;
align-items: center;
margin-top: 8px;
padding-bottom: 8px;
border-bottom: 1px dashed var(--td-border-level-1-color);
}
.mcp-tools-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.mcp-server-group {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
background: var(--td-bg-color-page);
border-radius: 8px;
}
.mcp-server-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.mcp-server-name {
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-secondary);
}
.mcp-tool-label {
font-weight: 500;
}
.mcp-tool-desc {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.role-sidebar {
@ -528,12 +771,22 @@ async function handleDelete(): Promise<void> {
}
@media (max-width: 768px) {
.role-page-card {
margin: 12px;
}
.role-shell {
flex-direction: column;
height: auto;
}
.role-sidebar {
width: auto;
max-height: 260px;
}
.role-detail {
overflow: visible;
}
.detail-form {
overflow: visible;
}
}
</style>

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

@ -120,9 +120,9 @@ public class AssistantApp {
* 获取 ChatClient支持按角色过滤 MCP 工具缓存上限 32 LRU 淘汰
*/
private ChatClient getChatClient(String appType, List<String> allowedMcpTools) {
// 空列表降级为 ["*"]允许所有工具与无参数版本行为一致
// null无角色降级为 ["*"]向后兼容允许全部空列表表示有角色但未授权保持空不注册任何工具
final List<String> effectiveTools;
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
if (enableMcpTools && allowedMcpTools == null) {
effectiveTools = List.of("*");
} else {
effectiveTools = allowedMcpTools;

2
src/main/java/com/wok/supportbot/app/ChatContext.java

@ -15,7 +15,7 @@ import java.util.List;
* @param chatId 会话 ID多轮记忆键
* @param appType ChatModel 应用类型CHAT / RAG_REWRITE 默认 CHAT
* @param systemPrompt 角色人设/系统提示词可为 null
* @param allowedMcpTools 允许的 MCP 工具名列表null/=允许所["*"]=全部
* @param allowedMcpTools 允许的 MCP 工具名列表null=无角色允许全部=角色但无授权禁止["*"]=全部
* @param categoryIds 知识库分类隔离范围可为空不限制
* @param rewriteStrategy RAG 查询重写策略REWRITE / TRANSLATION / COMPRESSION / MULTI_QUERY可为 null
* @param enableRag 是否启用 RAG 检索false=普通对话

80
src/main/java/com/wok/supportbot/config/McpClientManager.java

@ -177,9 +177,7 @@ public class McpClientManager {
return null;
}
// 缓存未命中使用 computeIfAbsent 保证同一 key 只创建一次客户端
// ConcurrentHashMap.computeIfAbsent 对同一 key 加锁避免并发线程重复创建
// 注意mapping function 不能返回 null会抛 NPE因此不可用的情况记录到 unavailableConfigs
// 缓存未命中 DB 读取配置后创建客户端创建逻辑见下方 double-check + putIfAbsent
McpServerConfig config = mcpServerConfigService.getConfigById(configId);
if (config == null) {
log.warn("MCP 配置不存在: id={}", configId);
@ -192,8 +190,29 @@ public class McpClientManager {
return null;
}
// 配置存在且启用通过 computeIfAbsent 原子创建防止并发重复创建
return clientCache.computeIfAbsent(key, k -> createClientDirectly(config));
// 配置存在且启用创建并缓存客户端
// 手动 double-check + putIfAbsent 代替 computeIfAbsentmapping function 返回 null
// computeIfAbsent 会抛 NPE导致服务端离线时重连无法优雅降级为返回 null
McpSyncClient existing = clientCache.get(key);
if (existing != null) {
return existing;
}
McpSyncClient created = createClientDirectly(config);
if (created == null) {
unavailableConfigs.add(key);
return null;
}
McpSyncClient prev = clientCache.putIfAbsent(key, created);
if (prev != null) {
// 并发下已有其他线程放入关闭本次多余创建的客户端避免连接泄漏
try {
created.close();
} catch (Exception closeEx) {
log.debug("关闭并发重复创建的 MCP 客户端失败: configId={}, error={}", key, closeEx.getMessage());
}
return prev;
}
return created;
}
/**
@ -233,15 +252,15 @@ public class McpClientManager {
toolInfo.put("transport_type", config.getTransportType());
toolInfo.put("description", config.getDescription());
// MCP Client 获取真实的工具列表
// MCP Client 获取真实的工具列表失败时自动重连并重试一次应对 MCP 服务端重启
List<Map<String, Object>> toolList = new ArrayList<>();
try {
McpSyncClient client = entry.getValue();
McpSchema.ListToolsResult listToolsResult = client.listTools();
McpSchema.ListToolsResult listToolsResult = listToolsWithRecovery(configId, client);
if (listToolsResult != null) {
// 调试日志输出 MCP 原始返回结果
log.info("🔧 [DEBUG] MCP listTools 原始结果 - configId={}, server={}, rawJson={}",
configId, config.getName(), ModelOptionsUtils.toJsonString(listToolsResult));
if (listToolsResult != null && listToolsResult.tools() != null) {
if (listToolsResult.tools() != null) {
log.info(" 📦 MCP Server [{}] 暴露了 {} 个工具", config.getName(), listToolsResult.tools().size());
for (McpSchema.Tool tool : listToolsResult.tools()) {
Map<String, Object> toolMeta = new LinkedHashMap<>();
@ -260,9 +279,10 @@ public class McpClientManager {
if (toolList.isEmpty()) {
log.warn(" ⚠️ MCP Server [{}] 未暴露任何工具,AI 模型将无法调用", config.getName());
}
} catch (Exception e) {
log.error("获取 MCP 工具列表失败: serverId={}, name={}, error={}",
configId, config.getName(), e.getMessage());
} else {
// 重连后仍失败toolList 保持空前端该 Server 分组将展示为无可选工具
log.error("获取 MCP 工具列表失败(重连后仍失败): serverId={}, name={}",
configId, config.getName());
}
toolInfo.put("tools", toolList);
@ -272,6 +292,36 @@ public class McpClientManager {
return tools;
}
/**
* 调用 listTools 获取工具列表失败时自动重连客户端并重试一次
* MCP 服务端重启后clientCache 中缓存的长连接会失效直接 listTools 会抛异常
* 通过 reconnect 重建连接后重试使工具授权页面与对话能自动恢复无需手动刷新连接
*
* @param configId 配置ID
* @param client 当前缓存的客户端可能已失效
* @return 工具列表结果重连后仍失败返回 null
*/
private McpSchema.ListToolsResult listToolsWithRecovery(Long configId, McpSyncClient client) {
try {
return client.listTools();
} catch (Exception firstEx) {
log.warn("MCP listTools 失败,尝试重连客户端后重试: configId={}, error={}",
configId, firstEx.getMessage());
}
try {
McpSyncClient reconnected = reconnect(configId);
if (reconnected == null) {
log.warn("MCP 客户端重连失败,无法获取工具列表: configId={}", configId);
return null;
}
return reconnected.listTools();
} catch (Exception retryEx) {
log.error("MCP 客户端重连并重试 listTools 失败: configId={}, error={}",
configId, retryEx.getMessage());
return null;
}
}
/**
* 定时健康检查 5 分钟执行一次
* 遍历所有缓存的客户端尝试 listTools() 探测连通性记录状态到 healthCache
@ -329,6 +379,12 @@ public class McpClientManager {
HealthStatus status = HealthStatus.offline(latencyMs, e.getMessage());
healthCache.put(key, status);
log.warn("MCP Server 健康检查失败: configId={}, latencyMs={}, error={}", key, latencyMs, e.getMessage());
// 探测失败说明连接已失效 MCP 服务端重启自动重连以恢复避免缓存长期保留失效客户端
try {
reconnect(Long.parseLong(key));
} catch (Exception reconnectEx) {
log.warn("健康检查自动重连失败: configId={}, error={}", key, reconnectEx.getMessage());
}
return status;
}
}

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

@ -148,7 +148,7 @@ public class AiController {
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return new ChatContext(message, chatId, "CHAT", resolveSystemPrompt(scope, systemPrompt),
scope.allowedMcpTools(), null, null, false, false);
scope.hasRole() ? scope.allowedMcpTools() : null, null, null, false, false);
}
/** 构造 RAG 对话的 ChatContext(含严格隔离判断:KbDenied 则 enableRag=false)。 */
@ -160,7 +160,7 @@ public class AiController {
String sys = resolveSystemPrompt(scope, systemPrompt);
boolean enableRag = !isKbDenied(scope);
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
return new ChatContext(message, chatId, "CHAT", sys, scope.allowedMcpTools(), cats,
return new ChatContext(message, chatId, "CHAT", sys, scope.hasRole() ? scope.allowedMcpTools() : null, cats,
normalizeStrategy(rewriteStrategy), enableRag, false);
}

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

@ -1,5 +1,6 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.rag.CategoryFilter;
import com.wok.supportbot.service.CustomerServiceRoleService;
import org.springframework.beans.factory.annotation.Autowired;
@ -25,6 +26,8 @@ public class CustomerServiceRoleController {
private CustomerServiceRoleService customerServiceRoleService;
@Autowired
private CategoryFilter categoryFilter;
@Autowired
private AssistantApp assistantApp;
@GetMapping("/role/list")
@PreAuthorize("hasRole('admin')")
@ -121,6 +124,8 @@ public class CustomerServiceRoleController {
allowedMcpTools = List.of();
}
customerServiceRoleService.updateRoleMcpTools(roleId, allowedMcpTools);
// 清理 ChatClient 缓存确保 MCP 工具授权改动即时生效
assistantApp.clearCache();
return ResponseEntity.ok(Map.of("success", true, "message", "MCP 工具权限更新成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新失败:" + e.getMessage()));

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

@ -2,6 +2,7 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.config.McpClientManager;
import com.wok.supportbot.entity.McpServerConfig;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import com.wok.supportbot.service.McpServerConfigService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -9,7 +10,9 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -89,6 +92,52 @@ public class McpServerConfigController {
}
}
// ==================== 可用工具列表 ====================
/**
* 获取所有已启用 MCP Server 暴露的工具清单 Server 分组
* 工具名为带 mcp_ 前缀的注册名与角色授权allowed_mcp_tools存储值一致客服角色管理授权 UI 使用
*
* @return [{ configId, serverName, transportType, tools: [{ name, rawName, description }] }]
*/
@GetMapping("/mcp-server/tools")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listAvailableTools() {
try {
List<Map<String, Object>> servers = mcpClientManager.listAvailableTools();
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> server : servers) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("configId", server.get("config_id"));
out.put("serverName", server.get("name"));
out.put("transportType", server.get("transport_type"));
List<Map<String, Object>> tools = new ArrayList<>();
@SuppressWarnings("unchecked")
List<Map<String, Object>> rawTools = (List<Map<String, Object>>) server.get("tools");
if (rawTools != null) {
String configId = (String) server.get("config_id");
for (Map<String, Object> tool : rawTools) {
String rawName = (String) tool.get("name");
Map<String, Object> toolOut = new LinkedHashMap<>();
toolOut.put("name", McpToolCallbackAdapter.buildToolName(configId, rawName));
toolOut.put("rawName", rawName);
toolOut.put("description", tool.get("description"));
tools.add(toolOut);
}
}
out.put("tools", tools);
result.add(out);
}
return ResponseEntity.ok(Map.of("success", true, "data", result));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "获取工具列表失败:" + e.getMessage()
));
}
}
// ==================== 新建配置 ====================
/**

3
src/main/java/com/wok/supportbot/controller/OpenApiController.java

@ -223,7 +223,8 @@ public class OpenApiController {
String strategy = (rewriteStrategy != null && !rewriteStrategy.isBlank())
? rewriteStrategy : "MULTI_QUERY";
return new ChatContext(message, chatId, "CHAT", systemPrompt, scope.allowedMcpTools(),
return new ChatContext(message, chatId, "CHAT", systemPrompt,
scope.hasRole() ? scope.allowedMcpTools() : null,
catIds, strategy, useRag, streaming);
}

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

@ -30,6 +30,12 @@ public class McpToolCallbackAdapter {
private final McpClientManager mcpClientManager;
/**
* MCP 工具注册名前缀用于避免与内置工具名冲突
* 角色授权allowed_mcp_tools中存储的工具名均带此前缀
*/
public static final String MCP_TOOL_PREFIX = "mcp_";
/**
* 单次对话请求中最大允许的 MCP 工具调用轮次防止无限循环调用
*/
@ -66,8 +72,8 @@ public class McpToolCallbackAdapter {
String description = (String) tool.get("description");
String inputSchema = (String) tool.get("inputSchema");
// 工具名加 mcp_ 前缀避免内置工具冲突
String prefixedName = "mcp_" + toolName;
// 工具名加 mcp_ 前缀 + configId 命名空间避免内置工具冲突及跨 Server 同名工具冲突
String prefixedName = buildToolName(configId, toolName);
McpToolCallback callback = new McpToolCallback(
Long.parseLong(configId), prefixedName, toolName, description, inputSchema,
mcpClientManager, maxCallRounds);
@ -108,4 +114,18 @@ public class McpToolCallbackAdapter {
.filter(cb -> allowedTools.contains(cb.getToolDefinition().name()))
.toArray(ToolCallback[]::new);
}
/**
* 生成 MCP 工具的唯一注册名 configId 命名空间
* 不同 MCP Server 可能暴露同名工具仅靠 "mcp_" + 原始名会冲突
* 因此引入 configId 区分形如 {@code mcp_1_get_weather}
* 该名称同时用于 Spring AI 工具注册与角色授权allowed_mcp_tools存储
*
* @param configId MCP Server 配置 ID字符串
* @param rawName MCP Server 上的原始工具名
* @return 唯一注册名
*/
public static String buildToolName(String configId, String rawName) {
return MCP_TOOL_PREFIX + configId + "_" + rawName;
}
}

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

@ -72,6 +72,9 @@ public class CustomerServiceRoleService {
String roleId = String.valueOf(role.get("id"));
role.put("categoryIds", categoryIdsByRole.getOrDefault(roleId, List.of()));
role.put("categories", categoriesByRole.getOrDefault(roleId, List.of()));
// 解析 JSONB 字段为 List<String>避免 PGobject 原样返回导致前端无法读取
Object rawMcp = role.remove("allowed_mcp_tools");
role.put("allowedMcpTools", parseAllowedMcpTools(rawMcp));
}
return roles;
}

Loading…
Cancel
Save