diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index e7b9c159..217cff21 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -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 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java new file mode 100644 index 00000000..277dc4ad --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/event/McpServerChangedEvent.java @@ -0,0 +1,24 @@ +package vip.mate.tool.mcp.event; + +/** + * Published whenever an MCP server's connection state 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. + * + *

{@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. + * + *

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) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java index 5ef6d87f..382be817 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/service/McpServerService.java @@ -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 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 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) { diff --git a/mateclaw-ui/src/composables/useMcpServers.ts b/mateclaw-ui/src/composables/useMcpServers.ts index 1b6047ea..78d8b264 100644 --- a/mateclaw-ui/src/composables/useMcpServers.ts +++ b/mateclaw-ui/src/composables/useMcpServers.ts @@ -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 | 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')) } diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 78bbea79..e6a4e48a 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 2047accd..cab06d6a 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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 连接', diff --git a/mateclaw-ui/src/views/mcp/McpCard.vue b/mateclaw-ui/src/views/mcp/McpCard.vue index e79443f8..7ff5b011 100644 --- a/mateclaw-ui/src/views/mcp/McpCard.vue +++ b/mateclaw-ui/src/views/mcp/McpCard.vue @@ -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; diff --git a/mateclaw-ui/src/views/mcp/McpFormModal.vue b/mateclaw-ui/src/views/mcp/McpFormModal.vue index 7a02d898..34923cd0 100644 --- a/mateclaw-ui/src/views/mcp/McpFormModal.vue +++ b/mateclaw-ui/src/views/mcp/McpFormModal.vue @@ -1,5 +1,8 @@