fix(mcp): refresh agent cache on MCP connection change + non-blocking connect (#289)

Closes #289 — after an MCP server (re)connects, chat queries kept replying
"from memory" instead of calling MCP tools.

Root cause: agents snapshot their tool set at build time and are cached in
AgentService.agentInstances, but MCP server lifecycle changes never
invalidated that cache (unlike model-config / tool-guard changes which do).
A stale, tool-less agent graph survived until process restart.

Changes:
- Add McpServerChangedEvent; McpServerService publishes it on connect /
  disconnect / reconnect / delete / (re)connect-failure / batch refresh /
  startup init. AgentService listens and calls refreshAllAgents(), so the
  next turn rebuilds against the live MCP tool set. Also closes the boot
  race where the web server accepts requests before the @Order(200) MCP
  init runner finishes.
- Make create/update/toggle connect asynchronously on a dedicated pool
  ("mcp-connect") so a slow/unreachable server can no longer freeze the
  admin request; status returns immediately as "connecting".
- UI: render the new "connecting" status (pulsing amber dot), show a
  friendly "connecting in background" toast, and poll until the status
  settles (window widened to ~40s to outlast the default connect timeout).
- UI: MCP config modal no longer closes on outside/backdrop click — only
  the × and Cancel buttons close it, so an accidental click can't discard
  unsaved config.

Verified E2E: ckjia-shopping (参考价) MCP server connected at runtime with
no backend restart; the cached 通用助手 agent immediately enabled and called
ckjia_shopping_recommend, returning real product cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
matevip 2026-06-09 14:10:40 +08:00
parent 846c1c31ca
commit 64b8e167d5
8 changed files with 183 additions and 8 deletions

View File

@ -505,6 +505,19 @@ public class AgentService {
log.info("Agent caches refreshed after tool guard config change (denied tools may have changed)");
}
/**
* Issue #289: an MCP server connecting / disconnecting / reconnecting
* changes the live tool set, but cached agents snapshot their tools at
* build time. Clear the cache so the next turn rebuilds against the
* current MCP tools instead of replying "from memory" with a stale,
* tool-less graph.
*/
@EventListener
public void onMcpServerChanged(vip.mate.tool.mcp.event.McpServerChangedEvent event) {
refreshAllAgents();
log.info("Agent caches refreshed after MCP server change: {}", event.reason());
}
// ==================== Lifecycle helpers ====================
/**

View File

@ -0,0 +1,24 @@
package vip.mate.tool.mcp.event;
/**
* Published whenever an MCP server's <em>connection state</em> changes
* connected, disconnected, reconnected, removed, or a (re)connect attempt
* failed. The set of tools available to agents shifts on every one of these
* transitions.
*
* <p>{@code AgentService} listens for this event and clears its agent-instance
* cache, so the next chat turn rebuilds the agent graph against the current
* live MCP tool set. Without this, an agent built before a server connected
* (or while it was down) keeps a stale, tool-less snapshot until the process
* restarts see issue #289.
*
* <p>Mirrors the existing {@code ModelConfigChangedEvent} /
* {@code ToolGuardConfigChangedEvent} pattern: the publisher (McpServerService)
* depends only on {@code ApplicationEventPublisher}, avoiding a circular
* dependency on AgentService.
*
* @param reason short human-readable cause, for logs only
* @author MateClaw Team
*/
public record McpServerChangedEvent(String reason) {
}

View File

@ -4,10 +4,13 @@ import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.exception.MateClawException;
import vip.mate.tool.mcp.event.McpServerChangedEvent;
import vip.mate.tool.mcp.model.McpServerEntity;
import vip.mate.tool.mcp.repository.McpServerMapper;
import vip.mate.tool.mcp.runtime.McpClientManager;
@ -17,6 +20,8 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
/**
@ -33,9 +38,38 @@ public class McpServerService {
private final McpServerMapper mcpServerMapper;
private final McpClientManager mcpClientManager;
private final ApplicationEventPublisher eventPublisher;
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\-. ]{1,128}$");
/**
* Connecting to an MCP server blocks on network / subprocess I/O and can
* take up to connectTimeout + readTimeout seconds (or hang on an
* unreachable endpoint). Running it on the request thread freezes the
* admin UI's create/toggle/update call. We offload it to this small pool
* so the API returns immediately with status {@code connecting}; the UI
* then polls for the final {@code connected}/{@code error} state.
*/
private final ExecutorService connectExecutor = Executors.newFixedThreadPool(2, r -> {
Thread t = new Thread(r, "mcp-connect");
t.setDaemon(true);
return t;
});
@PreDestroy
public void shutdownConnectExecutor() {
connectExecutor.shutdownNow();
}
/** Publish a connection-state change so AgentService rebuilds its agent cache (issue #289). */
private void publishChanged(String reason) {
try {
eventPublisher.publishEvent(new McpServerChangedEvent(reason));
} catch (Exception e) {
log.warn("Failed to publish MCP server change event ({}): {}", reason, e.getMessage());
}
}
// ==================== CRUD ====================
public List<McpServerEntity> listAll() {
@ -76,9 +110,11 @@ public class McpServerService {
mcpServerMapper.insert(entity);
log.info("MCP server created: name={}, transport={}, id={}", entity.getName(), entity.getTransport(), entity.getId());
// Auto-connect if enabled
// Auto-connect if enabled done asynchronously so a slow / unreachable
// server can't freeze the create request (issue: 配置 MCP 卡死).
if (Boolean.TRUE.equals(entity.getEnabled())) {
connectSync(entity);
connectAsync(entity);
entity.setLastStatus("connecting");
}
return entity;
@ -113,12 +149,16 @@ public class McpServerService {
log.info("MCP server updated: name={}, id={}", existing.getName(), id);
// Reconnect if enabled, disconnect if disabled
// Reconnect if enabled, disconnect if disabled. Reconnect runs
// asynchronously so a slow / unreachable server can't freeze the
// update request (issue: 配置 MCP 卡死).
if (Boolean.TRUE.equals(existing.getEnabled())) {
reconnectSync(existing);
reconnectAsync(existing);
existing.setLastStatus("connecting");
} else {
mcpClientManager.remove(id);
updateStatus(id, "disconnected", null, 0);
publishChanged("server-disabled");
}
return existing;
@ -133,6 +173,7 @@ public class McpServerService {
// Disconnect first
mcpClientManager.remove(id);
mcpServerMapper.deleteById(id);
publishChanged("server-deleted");
log.info("MCP server deleted: name={}, id={}", entity.getName(), id);
}
@ -142,10 +183,14 @@ public class McpServerService {
mcpServerMapper.updateById(entity);
if (enabled) {
connectSync(entity);
// Connect asynchronously so toggling on a slow / unreachable
// server can't freeze the request (issue: 配置 MCP 卡死).
connectAsync(entity);
entity.setLastStatus("connecting");
} else {
mcpClientManager.remove(id);
updateStatus(id, "disconnected", null, 0);
publishChanged("server-disabled");
}
log.info("MCP server toggled: name={}, enabled={}", entity.getName(), enabled);
@ -224,6 +269,9 @@ public class McpServerService {
}
log.info("MCP servers refresh complete: {} enabled, {} connected",
enabled.size(), mcpClientManager.getActiveCount());
// closeAll() above dropped every client; even if all reconnects failed,
// cached agents must drop the now-removed tools.
publishChanged("servers-refreshed");
}
/**
@ -253,6 +301,11 @@ public class McpServerService {
}
log.info("MCP servers initialization complete: {} connected / {} total",
mcpClientManager.getActiveCount(), enabled.size());
// The embedded web server starts accepting chat requests before this
// @Order(200) runner finishes, so an agent may have been cached during
// the boot window with no MCP tools. Drop those stale snapshots now
// that connections are established (issue #289).
publishChanged("servers-initialized");
}
// ==================== Sanitization ====================
@ -297,8 +350,23 @@ public class McpServerService {
// ==================== Internal ====================
/**
* Mark the server {@code connecting} (so the UI reflects it immediately)
* and run the blocking {@link #connectSync} on the background pool. The
* caller's request thread returns at once.
*/
private void connectAsync(McpServerEntity server) {
updateStatus(server.getId(), "connecting", null, 0);
connectExecutor.submit(() -> connectSync(server));
}
/** Async counterpart of {@link #reconnectSync}. See {@link #connectAsync}. */
private void reconnectAsync(McpServerEntity server) {
updateStatus(server.getId(), "connecting", null, 0);
connectExecutor.submit(() -> reconnectSync(server));
}
private void connectSync(McpServerEntity server) {
// 同步连接阻塞调用线程后续可改为 @Async + 线程池实现真异步
try {
ConnectionResult result = mcpClientManager.connect(server);
if (result.success()) {
@ -306,11 +374,13 @@ public class McpServerService {
} else {
mcpClientManager.remove(server.getId());
updateStatus(server.getId(), "error", result.message(), 0);
publishChanged("connect-failed");
}
} catch (Exception e) {
log.warn("Failed to connect MCP server '{}': {}", server.getName(), e.getMessage());
mcpClientManager.remove(server.getId());
updateStatus(server.getId(), "error", e.getMessage(), 0);
publishChanged("connect-error");
}
}
@ -322,11 +392,13 @@ public class McpServerService {
} else {
mcpClientManager.remove(server.getId());
updateStatus(server.getId(), "error", result.message(), 0);
publishChanged("reconnect-failed");
}
} catch (Exception e) {
log.warn("Failed to reconnect MCP server '{}': {}", server.getName(), e.getMessage());
mcpClientManager.remove(server.getId());
updateStatus(server.getId(), "error", e.getMessage(), 0);
publishChanged("reconnect-error");
}
}
@ -344,6 +416,9 @@ public class McpServerService {
List<McpSchema.Tool> tools = mcpClientManager.getServerTools(serverId);
String cacheJson = serializeToolsCache(tools);
updateStatusWithCache(serverId, "connected", null, tools.size(), cacheJson);
// Tools just became available rebuild agent graphs so the next turn
// can actually call them (issue #289).
publishChanged("server-connected");
}
private void updateStatus(Long id, String status, String error, int toolCount) {

View File

@ -64,6 +64,35 @@ export function useMcpServers() {
return list.slice(start, start + size)
}
// Connecting to an MCP server now runs asynchronously on the backend: the
// create/toggle/update call returns immediately with status "connecting".
// Poll a few times so the card updates to connected/error without the user
// hitting "refresh" manually. Stops early once nothing is still connecting.
// Poll long enough to outlast a slow/failing connect: the SSE/HTTP connect
// can take up to connectTimeout + readTimeout (default 30s+30s) before it
// resolves to error, so a short window would leave the card stuck on
// "connecting" until a manual refresh. ~40s with a gentle 2s cadence covers
// the common case; it also early-exits as soon as nothing is connecting.
let pollTimer: ReturnType<typeof setTimeout> | null = null
function pollConnectingStatus(rounds = 20, intervalMs = 2000) {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
let n = 0
const tick = async () => {
n += 1
await reload()
const stillConnecting = installed.value.some(s => s.lastStatus === 'connecting')
if (stillConnecting && n < rounds) {
pollTimer = setTimeout(tick, intervalMs)
} else {
pollTimer = null
}
}
pollTimer = setTimeout(tick, intervalMs)
}
const pagedInstalled = computed(() =>
paginate(filteredInstalled.value, installedPage.value, pageSize.value),
)
@ -110,6 +139,12 @@ export function useMcpServers() {
mcToast.success(t('mcp.messages.createSuccess'))
}
await reload()
// Enabled servers connect in the background — tell the user and poll for
// the result instead of leaving the card stuck on "connecting".
if (form.enabled) {
mcToast.info(t('mcp.messages.connecting'))
pollConnectingStatus()
}
return true
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
@ -130,10 +165,16 @@ export function useMcpServers() {
}
async function toggleServer(server: McpServer) {
const enabling = !server.enabled
try {
await mcpApi.toggle(server.id, !server.enabled)
await mcpApi.toggle(server.id, enabling)
mcToast.success(t('mcp.messages.toggleSuccess'))
await reload()
// Enabling connects in the background — poll until it settles.
if (enabling) {
mcToast.info(t('mcp.messages.connecting'))
pollConnectingStatus()
}
} catch (e: any) {
mcToast.error(e?.message || t('mcp.messages.saveFailed'))
}

View File

@ -1788,6 +1788,7 @@ export default {
connected: 'Connected',
disconnected: 'Disconnected',
error: 'Error',
connecting: 'Connecting…',
},
transport: {
stdio: 'Stdio',
@ -1840,6 +1841,7 @@ export default {
deleteConfirm: 'Delete MCP connection "{name}"?',
toggleSuccess: 'Status updated',
refreshSuccess: 'All connections refreshed',
connecting: 'Connecting in the background — status updates automatically',
saveFailed: 'Failed to save',
tierFailed: 'Failed to change disclosure tier',
empty: 'No MCP connections',

View File

@ -1680,6 +1680,7 @@ export default {
connected: '已连接',
disconnected: '未连接',
error: '连接失败',
connecting: '连接中…',
},
transport: {
stdio: 'Stdio',
@ -1732,6 +1733,7 @@ export default {
deleteConfirm: '确认删除 MCP 连接 "{name}" 吗?',
toggleSuccess: '状态已更新',
refreshSuccess: '全量刷新完成',
connecting: '正在后台连接,状态会自动更新',
saveFailed: '保存失败',
tierFailed: '调整披露分级失败',
empty: '暂无 MCP 连接',

View File

@ -183,6 +183,7 @@ const statusClass = computed(() => {
const s = props.server?.lastStatus
if (s === 'connected') return 'mcp-status-dot--ok'
if (s === 'error') return 'mcp-status-dot--err'
if (s === 'connecting') return 'mcp-status-dot--connecting'
return 'mcp-status-dot--off'
})
@ -190,8 +191,13 @@ const hasError = computed(
() => !isCatalog.value && props.server!.lastStatus === 'error' && !!props.server!.lastError,
)
const isConnecting = computed(
() => !isCatalog.value && props.server!.lastStatus === 'connecting',
)
const descriptionText = computed(() => {
if (isCatalog.value) return props.catalogEntry!.description
if (isConnecting.value) return t('mcp.status.connecting')
if (hasError.value) {
const err = props.server!.lastError
return err.length > 60 ? err.slice(0, 60) + '…' : err
@ -299,6 +305,15 @@ function onPrimaryAction() {
box-shadow: 0 0 4px rgba(239, 68, 68, 0.4);
}
.mcp-status-dot--off { background: var(--mc-text-tertiary); opacity: 0.4; }
.mcp-status-dot--connecting {
background: #f59e0b;
box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);
animation: mcp-dot-pulse 1s ease-in-out infinite;
}
@keyframes mcp-dot-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.78); }
}
.mcp-transport-pill {
display: inline-flex;

View File

@ -1,5 +1,8 @@
<template>
<div v-if="modelValue" class="modal-overlay" @click.self="close">
<!-- Intentionally NOT closing on overlay/outside click: the form holds
unsaved config and an accidental click outside used to discard it.
Only the × button and Cancel close the modal. -->
<div v-if="modelValue" class="modal-overlay">
<div class="modal modal-wide">
<div class="modal-header">
<h2>