Browse Source

feat(role): 客服角色 MCP 工具授权

- 修正 allowedMcpTools 空列表语义:仅无角色(null)回退为全部,空列表=严格禁止
- 三个对话入口区分「无角色」与「角色无授权」,无角色向后兼容允许全部
- 工具注册名引入 configId 命名空间(mcp_<configId>_<name>),避免跨 Server 同名工具冲突
- 新增 GET /mcp-server/tools 接口,返回按 Server 分组的可用工具清单
- 更新角色 MCP 授权后清理 AssistantApp ChatClient 缓存,改动即时生效
- listRoles 解析 allowed_mcp_tools JSONB 为 string[],避免 PGobject 原样返回
- 前端 RoleManager 新增「MCP 工具授权」区块,接入保存/脏检测/全部授权
TDesign-Vue-Next-1.20.6
wanghanlin 1 week ago
parent
commit
6590ac762d
  1. 1
      frontend/src/api/mcp-server.ts
  2. 3
      frontend/src/api/role.ts
  3. 2
      frontend/src/types/models.ts
  4. 109
      frontend/src/views/RoleManager.vue
  5. 4
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  6. 2
      src/main/java/com/wok/supportbot/app/ChatContext.java
  7. 4
      src/main/java/com/wok/supportbot/controller/AiController.java
  8. 5
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  9. 49
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  10. 3
      src/main/java/com/wok/supportbot/controller/OpenApiController.java
  11. 24
      src/main/java/com/wok/supportbot/mcp/McpToolCallbackAdapter.java
  12. 3
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

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

109
frontend/src/views/RoleManager.vue

@ -115,6 +115,40 @@
</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">
<t-checkbox-group
v-if="!allToolsSelected && mcpServers.length"
v-model="form.allowedMcpTools"
class="mcp-tools-list"
>
<div v-for="s in mcpServers" :key="s.configId" class="mcp-server-group">
<div class="mcp-server-name">{{ s.serverName }}</div>
<t-checkbox
v-for="t in s.tools"
:key="t.name"
:value="t.name"
:disabled="saving"
>
<span class="mcp-tool-label">{{ t.rawName }}</span>
<span v-if="t.description" class="mcp-tool-desc"> {{ t.description }}</span>
</t-checkbox>
</div>
</t-checkbox-group>
<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 +164,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 +177,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 +189,7 @@ const form = reactive({
prompt: '',
enabled: true,
categoryIds: [] as string[],
allowedMcpTools: [] as string[],
})
// ---- ref ----
@ -164,6 +202,7 @@ function takeSnapshot(): string {
prompt: form.prompt,
enabled: form.enabled,
categoryIds: [...form.categoryIds].sort(),
allowedMcpTools: [...form.allowedMcpTools].sort(),
})
}
@ -173,6 +212,7 @@ function resetForm(): void {
form.prompt = ''
form.enabled = true
form.categoryIds = []
form.allowedMcpTools = []
snapshot.value = takeSnapshot()
}
@ -182,6 +222,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 +230,17 @@ const isDirty = computed(() => takeSnapshot() !== snapshot.value)
// ---- ----
const selectedRole = computed(() => roles.value.find(r => r.id === selectedId.value) ?? null)
// ["*"]
const allToolsSelected = computed<boolean>({
get: () => form.allowedMcpTools.includes('*'),
set: (val) => { form.allowedMcpTools = val ? ['*'] : [] },
})
// ---- ----
onMounted(() => {
reload()
categoryStore.loadCategories()
loadMcpTools()
})
// ---- selectedRole 退 ----
@ -233,6 +280,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 +390,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')
}
@ -517,6 +586,44 @@ async function handleDelete(): Promise<void> {
border-top: 1px solid var(--td-border-level-1-color);
}
/* ===== MCP 工具授权 ===== */
.mcp-tools-box {
width: 100%;
}
.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-name {
font-size: 12px;
font-weight: 600;
color: var(--td-text-color-secondary);
margin-bottom: 2px;
}
.mcp-tool-label {
font-weight: 500;
}
.mcp-tool-desc {
font-size: 12px;
color: var(--td-text-color-placeholder);
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.role-sidebar {

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=普通对话

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