diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 00b0ccd96..506c1dd89 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -264,6 +264,48 @@ function saveListExpansionState(state: SidebarListExpansionState) { // Maximum number of entity sub-items visible before "More" toggle const MAX_VISIBLE_ITEMS = 5; +const MCP_REFRESH_POLL_INTERVAL_MS = 1000; +const MCP_REFRESH_TIMEOUT_MS = 60000; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForMCPRefreshTask(taskId: number) { + const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS; + + while (Date.now() < deadline) { + const task = await httpClient.getAsyncTask(taskId); + if (task.runtime.done) return task; + await sleep(MCP_REFRESH_POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for MCP refresh task ${taskId}`); +} + +async function refreshEnabledMCPConnections() { + const resp = await httpClient.getMCPServers(); + const enabledServers = resp.servers.filter((server) => server.enable); + if (enabledServers.length === 0) return; + + const taskResults = await Promise.allSettled( + enabledServers.map((server) => httpClient.testMCPServer(server.name, {})), + ); + const taskIds: number[] = []; + + for (const result of taskResults) { + if ( + result.status === 'fulfilled' && + typeof result.value.task_id === 'number' + ) { + taskIds.push(result.value.task_id); + } else if (result.status === 'rejected') { + console.error('Failed to start MCP refresh task:', result.reason); + } + } + + await Promise.allSettled(taskIds.map(waitForMCPRefreshTask)); +} // Sort entity items by updatedAt descending (most recent first), items without updatedAt go last function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] { @@ -352,11 +394,19 @@ function NavItems({ if (extRefreshing) return; setExtRefreshing(true); try { - await Promise.all([ + const results = await Promise.allSettled([ sidebarData.refreshPlugins(), - sidebarData.refreshMCPServers(), sidebarData.refreshSkills(), + refreshEnabledMCPConnections(), ]); + const mcpRefreshResult = results[2]; + if (mcpRefreshResult.status === 'rejected') { + console.error( + 'Failed to refresh MCP connections:', + mcpRefreshResult.reason, + ); + } + await sidebarData.refreshMCPServers(); } finally { setExtRefreshing(false); } diff --git a/web/src/app/home/mcp/MCPDetailContent.tsx b/web/src/app/home/mcp/MCPDetailContent.tsx index 473676abd..91a0801f6 100644 --- a/web/src/app/home/mcp/MCPDetailContent.tsx +++ b/web/src/app/home/mcp/MCPDetailContent.tsx @@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) { navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`); } + const handlePersistedTestComplete = useCallback(async () => { + await refreshMCPServers(); + }, [refreshMCPServers]); + function confirmDelete() { httpClient .deleteMCPServer(id) @@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) { onRuntimeInfoChange={(runtimeInfo) => setDetailRuntimeStatus(runtimeInfo?.status ?? null) } + onPersistedTestComplete={handlePersistedTestComplete} /> diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index ac28c9a2f..753e7a73c 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -488,6 +488,7 @@ interface MCPFormProps { onDirtyChange?: (dirty: boolean) => void; onTestingChange?: (testing: boolean) => void; onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void; + onPersistedTestComplete?: (serverName: string) => void | Promise; /** Reported when the form cannot be saved because the current mode is * ``stdio`` and the Box sandbox is disabled/unavailable. Parents that * render the Save button outside this component should disable it. */ @@ -512,6 +513,7 @@ const MCPForm = forwardRef(function MCPForm( onDirtyChange, onTestingChange, onRuntimeInfoChange, + onPersistedTestComplete, onSaveBlockedChange, layout = 'stacked', sideHeader, @@ -823,6 +825,8 @@ const MCPForm = forwardRef(function MCPForm( // are always current. const serverName = isEditMode && initServerName ? initServerName : form.getValues('name'); + const shouldTestPersistedServer = + isEditMode && !!initServerName && !form.formState.isDirty; const formExtraArgs = form.getValues('extra_args') ?? []; const formStdioArgs = form.getValues('args') ?? []; let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio; @@ -845,12 +849,20 @@ const MCPForm = forwardRef(function MCPForm( }; } - const { task_id } = await httpClient.testMCPServer('_', { - name: serverName, - mode, - enable: true, - extra_args: extraArgsData, - } as MCPServer); + const testTarget = shouldTestPersistedServer ? serverName : '_'; + const testPayload = shouldTestPersistedServer + ? {} + : ({ + name: serverName, + mode, + enable: true, + extra_args: extraArgsData, + } as MCPServer); + + const { task_id } = await httpClient.testMCPServer( + testTarget, + testPayload, + ); if (!task_id) { throw new Error(t('mcp.noTaskId')); @@ -876,14 +888,18 @@ const MCPForm = forwardRef(function MCPForm( resource_count: 0, resources: [], }); + if (shouldTestPersistedServer) { + await onPersistedTestComplete?.(serverName); + } } else { - if (isEditMode) { + if (shouldTestPersistedServer) { await loadServerForEdit(serverName); + await onPersistedTestComplete?.(serverName); } else { - // Create mode has no persisted server to reload tools from. + // Transient tests have no persisted server to reload tools from. // The backend stashes the discovered runtime info (status + - // tools) in the test task's metadata before tearing the - // transient session down — surface it so a successful test + // tools) in the task metadata before tearing the transient + // session down — surface it so a successful test // shows the tool list instead of "no tools found". const runtimeInfoFromTest = taskResp.task_context?.metadata ?.runtime_info as MCPServerRuntimeInfo | undefined;