Browse Source

fix(mcp-server): 修复启用开关触发 toggle/refresh 请求死循环

- 前端 toggleMcpServer 补发 { active } 请求体,与后端 @RequestBody 契约一致(此前 active 拼在 query、无 body,每次点开关必 400)
- 后端 toggleActive 兼容 query 兜底,避免旧调用方仍 400
- axios 拦截器加重试上限(_retry),刷新改用裸 axios 发起,切断 401→refresh→重试 自激递归
- MCP 开关失败回滚 _active 状态并加 _toggling 防连点
Spring-AI-1.1.2
wanghanlin 1 week ago
parent
commit
459cabb1bc
  1. 2
      frontend/src/api/mcp-server.ts
  2. 16
      frontend/src/api/request.ts
  3. 9
      frontend/src/views/McpServerManager.vue
  4. 11
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java

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

@ -11,7 +11,7 @@ export function getMcpServerDetail(id: string): Promise<ApiResponse> { return re
export function createMcpServer(data: any): Promise<ApiResponse> { return request.post('/mcp-server', data).then(r => r.data) }
export function updateMcpServer(id: string, data: any): Promise<ApiResponse> { return request.put(`/mcp-server/${id}`, data).then(r => r.data) }
export function deleteMcpServer(id: string): Promise<ApiResponse> { return request.delete(`/mcp-server/${id}`).then(r => r.data) }
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) }
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle`, { 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) }

16
frontend/src/api/request.ts

@ -42,6 +42,15 @@ request.interceptors.response.use(
const url: string = config?.url || ''
if (response?.status === 401 && !url.includes('/auth/login') && !url.includes('/auth/refresh')) {
// 重试上限:已经过「401 → refresh → 重试」一次的请求若仍 401,不再二次刷新,
// 直接失败,斩断 401→refresh→401 的自激死循环(配合 _retry 标记,见下方挂起分支)
if ((config as any)._retry) {
return Promise.reject(new Error(response?.data?.message || error.message || '登录已过期,请重新登录'))
}
// 标记本次请求已进入刷新流程(含挂起等待者),确保每个请求至多重试一次
const retriedConfig = config as any
retriedConfig._retry = true
// 已有刷新在进行中,挂起当前请求,等待新 token 后重试
if (isRefreshing) {
return new Promise((resolve, reject) => {
@ -55,17 +64,18 @@ request.interceptors.response.use(
})
}
// 发起刷新(request 的 url 为 /auth/refresh,其 401 会被上方判断跳过,不会递归)
// 发起刷新(用「裸 axios」而不走当前实例,避免 refresh 自身再次进入本拦截器造成递归;
// 其 401 也会被上方 url 判断跳过,不会二次刷新)
isRefreshing = true
try {
const res = await request.post('/auth/refresh')
const res = await axios.post('/auth/refresh', null, { withCredentials: true })
const newToken = res.data?.data?.accessToken
if (newToken) {
setToken(newToken)
// 放行所有挂起请求(请求拦截器会自动附加新 token)
pendingQueue.forEach((cb) => cb(newToken))
pendingQueue = []
// 重试当前请求
// 重试当前请求(config._retry 已为 true,若仍 401 会直接失败而不再刷新)
return request(config)
}
throw new Error('刷新令牌失败')

9
frontend/src/views/McpServerManager.vue

@ -13,7 +13,7 @@
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template>
<template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" @change="toggleActive(row)" /></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" :disabled="!!row._toggling" @change="toggleActive(row)" /></template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]">{{ testLoading[row.id]?'测试中...':'测试' }}</t-button>
@ -108,8 +108,11 @@ async function save(){
async function remove(id:string,name:string){if(!await confirm('确定删除「'+(name||id)+'」?'))return
try{const r=await api.deleteMcpServer(id);if(r.success){toast('删除成功','success');load(currentPage.value)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function toggleActive(s:any){const na=!s.is_active
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function toggleActive(s:any){
if(s._toggling)return // :
const na=!s.is_active
s._toggling=true
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else{s._active=s.is_active;toast(r.message||'操作失败','error')}}catch(e:any){s._active=s.is_active;toast('操作失败:'+e.message,'error')}finally{s._toggling=false}}
async function testConnection(s:any){testLoading.value={...testLoading.value,[s.id]:true}
try{const r=await api.testMcpServer(s.id);showResult(r)}catch(e:any){showResult({success:false,message:e.message})}finally{testLoading.value={...testLoading.value,[s.id]:false}}}

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

@ -254,16 +254,21 @@ public class McpServerConfigController {
* 切换配置启用/禁用状态
*
* @param id 配置ID
* @param body 请求体{active: true/false}
* @param body 请求体可选{active: true/false}
* @param active query 兜底参数可选兼容不带 body 的旧调用方active query string
* @return 操作结果
*/
@PutMapping("/mcp-server/{id}/toggle")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> toggleActive(
@PathVariable("id") Long id,
@RequestBody Map<String, Boolean> body) {
@RequestBody(required = false) Map<String, Boolean> body,
@RequestParam(value = "active", required = false) Boolean activeParam) {
try {
Boolean active = body.get("active");
Boolean active = (body != null) ? body.get("active") : null;
if (active == null) {
active = activeParam;
}
if (active == null) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,

Loading…
Cancel
Save