feat(mcp): add reconnect button to evict stale MCP connections

When MCP servers update their tools externally, the gateway keeps
stale cached connections. Add POST /v1/mcp/servers/{id}/reconnect
endpoint that evicts the pooled connection, forcing re-discovery
on next agent use. Exposed via reconnect button on both web and
desktop MCP management pages.
This commit is contained in:
viettranx committed 2026-03-29 07:15:03 +07:00
1 parent 985d2fa50d
commit 243656a7b6
11 files changed
+98 -7

No files matched your search

+28
View File
@@ -61,6 +61,9 @@ func (h *MCPHandler) RegisterRoutes(mux *http.ServeMux) {
// Test connection (no save)
mux.HandleFunc("POST /v1/mcp/servers/test", h.auth(h.handleTestConnection))
// Reconnect (evict pooled connection so next use re-discovers tools)
mux.HandleFunc("POST /v1/mcp/servers/{id}/reconnect", h.auth(h.handleReconnectServer))
// Server tools (runtime-discovered)
mux.HandleFunc("GET /v1/mcp/servers/{id}/tools", h.auth(h.handleListServerTools))
@@ -231,3 +234,28 @@ func (h *MCPHandler) handleDeleteServer(w http.ResponseWriter, r *http.Request)
emitAudit(h.msgBus, r, "mcp_server.deleted", "mcp_server", id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *MCPHandler) handleReconnectServer(w http.ResponseWriter, r *http.Request) {
locale := store.LocaleFromContext(r.Context())
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "server")})
return
}
srv, err := h.store.GetServer(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "server", id.String())})
return
}
if h.poolEvictor != nil {
tid := store.TenantIDFromContext(r.Context())
h.poolEvictor.Evict(tid, srv.Name)
}
h.emitCacheInvalidate()
emitAudit(h.msgBus, r, "mcp_server.reconnected", "mcp_server", id.String())
slog.Info("mcp.server.reconnect_requested", "server", srv.Name, "id", id)
writeJSON(w, http.StatusOK, map[string]string{"status": "reconnected"})
}
@@ -14,7 +14,7 @@ export function McpServerList() {
const {
servers, loading, atLimit,
fetchServers, createServer, updateServer, deleteServer,
testConnection, listServerTools,
testConnection, reconnectServer, listServerTools,
listGrants, grantAgent, revokeAgent,
} = useMcpServers()
const { agents } = useAgentCrud()
@@ -24,6 +24,7 @@ export function McpServerList() {
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null)
const [grantsServer, setGrantsServer] = useState<MCPServerData | null>(null)
const [toolsServer, setToolsServer] = useState<MCPServerData | null>(null)
const [reconnectingId, setReconnectingId] = useState<string | null>(null)
function openCreate() {
setEditServer(null)
@@ -163,6 +164,19 @@ export function McpServerList() {
{/* Actions */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
disabled={reconnectingId === s.id}
onClick={async () => {
setReconnectingId(s.id)
try { await reconnectServer(s.id) } finally { setReconnectingId(null) }
}}
className="p-1 text-text-muted hover:text-text-primary transition-colors disabled:opacity-50"
title={t('reconnect')}
>
<svg className={`h-3.5 w-3.5${reconnectingId === s.id ? ' animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
</svg>
</button>
<button onClick={() => openEdit(s)} className="p-1 text-text-muted hover:text-text-primary transition-colors" title="Edit">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
@@ -68,6 +68,16 @@ export function useMcpServers() {
return getApiClient().post<MCPTestResult>('/v1/mcp/servers/test', data)
}, [])
const reconnectServer = useCallback(async (id: string) => {
try {
await getApiClient().post(`/v1/mcp/servers/${id}/reconnect`, {})
toast.success('Connection reset')
} catch (err) {
toast.error('Failed to reconnect', (err as Error).message)
throw err
}
}, [])
const listServerTools = useCallback(async (serverId: string) => {
const res = await getApiClient().get<{ tools: MCPToolInfo[] | null }>(`/v1/mcp/servers/${serverId}/tools`)
return res.tools ?? []
@@ -93,7 +103,7 @@ export function useMcpServers() {
return {
servers, loading, atLimit,
fetchServers, createServer, updateServer, deleteServer,
testConnection, listServerTools,
testConnection, reconnectServer, listServerTools,
listGrants, grantAgent, revokeAgent,
}
}
@@ -18,6 +18,7 @@
},
"viewTools": "View tools",
"manageGrants": "Manage grants",
"reconnect": "Reconnect",
"delete": {
"title": "Delete MCP Server",
"description": "Are you sure you want to delete \"{{name}}\"? This will also remove all associated grants.",
@@ -18,6 +18,7 @@
},
"viewTools": "Xem công cụ",
"manageGrants": "Quản lý quyền",
"reconnect": "Kết nối lại",
"delete": {
"title": "Xóa MCP Server",
"description": "Bạn có chắc muốn xóa \"{{name}}\"? Tất cả quyền liên quan cũng sẽ bị xóa.",
@@ -18,6 +18,7 @@
},
"viewTools": "查看工具",
"manageGrants": "管理授权",
"reconnect": "重新连接",
"delete": {
"title": "删除 MCP 服务器",
"description": "确定要删除「{{name}}」吗?这将同时移除所有相关授权。",
+4 -1
View File
@@ -18,6 +18,7 @@
},
"viewTools": "View tools",
"manageGrants": "Manage grants",
"reconnect": "Reconnect",
"delete": {
"title": "Delete MCP Server",
"description": "Are you sure you want to delete \"{{name}}\"? This will also remove all associated grants.",
@@ -98,7 +99,9 @@
"deleted": "MCP server deleted",
"failedCreate": "Failed to create MCP server",
"failedUpdate": "Failed to update MCP server",
"failedDelete": "Failed to delete MCP server"
"failedDelete": "Failed to delete MCP server",
"reconnected": "MCP server connection reset",
"failedReconnect": "Failed to reconnect MCP server"
},
"userCredentials": {
"title": "My Credentials",
+4 -1
View File
@@ -18,6 +18,7 @@
},
"viewTools": "Xem công cụ",
"manageGrants": "Quản lý quyền",
"reconnect": "Kết nối lại",
"delete": {
"title": "Xóa MCP Server",
"description": "Bạn có chắc muốn xóa \"{{name}}\"? Tất cả quyền liên quan cũng sẽ bị xóa.",
@@ -98,7 +99,9 @@
"deleted": "Đã xóa MCP server",
"failedCreate": "Không thể tạo MCP server",
"failedUpdate": "Không thể cập nhật MCP server",
"failedDelete": "Không thể xóa MCP server"
"failedDelete": "Không thể xóa MCP server",
"reconnected": "Đã reset kết nối MCP server",
"failedReconnect": "Không thể kết nối lại MCP server"
},
"userCredentials": {
"title": "Thông tin xác thực",
+4 -1
View File
@@ -18,6 +18,7 @@
},
"viewTools": "查看工具",
"manageGrants": "管理授权",
"reconnect": "重新连接",
"delete": {
"title": "删除 MCP 服务器",
"description": "确定要删除「{{name}}」吗?这将同时移除所有相关授权。",
@@ -98,7 +99,9 @@
"deleted": "MCP 服务器已删除",
"failedCreate": "创建 MCP 服务器失败",
"failedUpdate": "更新 MCP 服务器失败",
"failedDelete": "删除 MCP 服务器失败"
"failedDelete": "删除 MCP 服务器失败",
"reconnected": "MCP 服务器连接已重置",
"failedReconnect": "重新连接 MCP 服务器失败"
},
"userCredentials": {
"title": "我的凭证",
+14
View File
@@ -109,6 +109,19 @@ export function useMCP() {
[http],
);
const reconnectServer = useCallback(
async (id: string) => {
try {
await http.post(`/v1/mcp/servers/${id}/reconnect`, {});
toast.success(i18next.t("mcp:toast.reconnected"));
} catch (err) {
toast.error(i18next.t("mcp:toast.failedReconnect"), err instanceof Error ? err.message : "");
throw err;
}
},
[http],
);
const listServerTools = useCallback(
async (serverId: string) => {
const res = await http.get<{ tools: MCPToolInfo[] }>(`/v1/mcp/servers/${serverId}/tools`);
@@ -153,6 +166,7 @@ export function useMCP() {
revokeAgent,
listGrantsByAgent,
testConnection,
reconnectServer,
listServerTools,
getUserCredentials,
setUserCredentials,
+15 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Plug, Plus, RefreshCw, Pencil, Trash2, Users, Wrench, KeyRound } from "lucide-react";
import { Plug, Plus, RefreshCw, RotateCcw, Pencil, Trash2, Users, Wrench, KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
@@ -27,7 +27,7 @@ const transportBadge: Record<string, string> = {
export function MCPPage() {
const { t } = useTranslation("mcp");
const { t: tc } = useTranslation("common");
const { servers, loading, refresh, createServer, updateServer, deleteServer, grantAgent, revokeAgent, listAgentGrants, testConnection, listServerTools, getUserCredentials, setUserCredentials, deleteUserCredentials } = useMCP();
const { servers, loading, refresh, createServer, updateServer, deleteServer, grantAgent, revokeAgent, listAgentGrants, testConnection, reconnectServer, listServerTools, getUserCredentials, setUserCredentials, deleteUserCredentials } = useMCP();
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && servers.length === 0);
const [search, setSearch] = useState("");
@@ -38,6 +38,7 @@ export function MCPPage() {
const [deleteTarget, setDeleteTarget] = useState<MCPServerData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [credentialsServer, setCredentialsServer] = useState<MCPServerData | null>(null);
const [reconnectingId, setReconnectingId] = useState<string | null>(null);
const filtered = servers.filter(
(s) =>
@@ -163,6 +164,18 @@ export function MCPPage() {
<td className="px-4 py-3 text-muted-foreground">{srv.created_by || "-"}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
disabled={reconnectingId === srv.id}
onClick={async () => {
setReconnectingId(srv.id);
try { await reconnectServer(srv.id); } finally { setReconnectingId(null); }
}}
title={t("reconnect")}
>
<RotateCcw className={"h-3.5 w-3.5" + (reconnectingId === srv.id ? " animate-spin" : "")} />
</Button>
<Button
variant="ghost"
size="sm"