mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 08:17:09 +00:00
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:
1 parent
985d2fa50d
commit
243656a7b6
11 files changed
+98
-7
No files matched your search
@@ -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}}」吗?这将同时移除所有相关授权。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "我的凭证",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user