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 @@
-