fix(webui): surface MCP runtime connection failures (#5331)

This commit is contained in:
chengyongru
2026-08-11 23:52:02 +08:00
committed by GitHub
parent 1edfd268db
commit d45c893f68
31 changed files with 838 additions and 123 deletions
@@ -17,6 +17,7 @@ import {
Database,
ExternalLink,
Loader2,
MoreHorizontal,
PauseCircle,
PlayCircle,
Plus,
@@ -24,6 +25,7 @@ import {
Search,
Server,
SlidersHorizontal,
TriangleAlert,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -158,8 +160,8 @@ export function AppsCatalogSettings({
onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onMcpOAuthConnect: (name: string) => void;
onMcpAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
onMcpOAuthConnect: (name: string, reset?: boolean) => void;
onMcpOAuthCancel: () => void;
onMcpOAuthOpen: () => void;
onMcpOAuthCallbackUrlChange: (value: string) => void;
@@ -324,6 +326,7 @@ export function AppsCatalogSettings({
oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos}
showTypeBadge={filter !== "mcp"}
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect}
@@ -487,6 +490,7 @@ function McpAppsCatalogRow({
oauthCompleting,
oauthCallbackError,
showBrandLogos,
showTypeBadge,
onFieldChange,
onAction,
onOAuthConnect,
@@ -505,9 +509,10 @@ function McpAppsCatalogRow({
oauthCompleting: boolean;
oauthCallbackError: string | null;
showBrandLogos: boolean;
showTypeBadge: boolean;
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onOAuthConnect: (name: string) => void;
onAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
onOAuthConnect: (name: string, reset?: boolean) => void;
onOAuthCancel: () => void;
onOAuthOpen: () => void;
onOAuthCallbackUrlChange: (value: string) => void;
@@ -522,17 +527,29 @@ function McpAppsCatalogRow({
const disableBusy = actionKey === `disable:${preset.name}`;
const removeBusy = actionKey === `remove:${preset.name}`;
const testBusy = actionKey === `test:${preset.name}`;
const reconnectBusy = actionKey === `reconnect:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`;
const oauthBusy = actionKey === `oauth:${preset.name}`;
const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy;
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy || oauthBusy;
const busy = enableBusy || disableBusy || removeBusy || testBusy || reconnectBusy || toolsBusy || oauthBusy;
const agentPlugin = preset.source === "agent-plugin";
const toggleable = preset.enabled !== undefined;
const isOAuth = preset.auth === "oauth";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0;
const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
const configuredInstalled = preset.installed && preset.configured;
const readyInstalled = preset.enabled ?? configuredInstalled;
const runtimeConnected = !toggleable && preset.runtime_status === "connected";
const runtimeConnecting = !toggleable && preset.runtime_status === "connecting";
const runtimeFailed = !toggleable && preset.runtime_status === "failed";
const statusLabel = toggleable
? tx("settings.nanobotFeatures.enabled", "Enabled")
: runtimeConnected
? tx("settings.mcp.connected", "Connected.")
: mcpPresetStatusLabel(preset.status, tx);
const failureLabel = tx("settings.mcp.connectionFailed", "Connection failed.");
const reconnectLabel = tx("settings.mcp.reconnect", "Reconnect");
const canEnable =
preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -544,9 +561,6 @@ function McpAppsCatalogRow({
const detail = agentPlugin && preset.requires
? `${description} · ${preset.requires}`
: description || preset.requires;
const statusLabel = toggleable
? tx("settings.nanobotFeatures.enabled", "Enabled")
: mcpPresetStatusLabel(preset.status, tx);
const manualCallback =
oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
const callbackInputId = `mcp-oauth-callback-${preset.name}`;
@@ -583,33 +597,121 @@ function McpAppsCatalogRow({
return (
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
<div
className={cn(
"group min-w-0 px-3 py-3",
oauthFlow
? "grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 gap-y-2 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
: "flex items-center gap-3",
)}
>
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="min-w-[8rem] flex-[1_1_8rem]">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge>
{showTypeBadge ? (
<AppsTypeBadge>
{agentPlugin
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge>
) : null}
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
<p
className={cn(
"mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] leading-5 text-muted-foreground",
runtimeFailed && configuredInstalled && "font-medium text-destructive",
)}
>
{runtimeFailed && configuredInstalled ? (
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden />
) : null}
<span className="truncate">
{runtimeFailed && configuredInstalled ? failureLabel : detail}
</span>
</p>
</div>
<div
className={cn(
"flex shrink-0 items-center gap-1",
oauthFlow && "col-span-2 justify-self-end sm:col-span-1",
)}
>
{readyInstalled ? (
<div className="ml-auto flex shrink-0 items-center gap-1">
{oauthFlow ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.connectingAccount", {
name: preset.display_name,
defaultValue: "Connecting {{name}}",
})}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.actions.cancel", "Cancel")}
visibleLabel={tx("settings.actions.cancel", "Cancel")}
tone="danger"
onClick={onOAuthCancel}
/>
</>
) : runtimeConnecting && configuredInstalled ? (
<>
<AppsActionButton
ariaLabel={`${preset.display_name}: ${tx("settings.mcp.connectingLabel", "Connecting…")}`}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy}
disabled={busy && !removeBusy}
tone="danger"
onClick={() => onAction("remove", preset.name)}
>
<Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton>
</>
) : runtimeFailed && configuredInstalled ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.reconnectTitle", {
name: preset.display_name,
defaultValue: "Reconnect {{name}}",
})}
visibleLabel={reconnectLabel}
busy={isOAuth ? oauthBusy : reconnectBusy}
disabled={anotherOAuthBusy || (busy && !oauthBusy && !reconnectBusy)}
onClick={() => {
if (isOAuth) onOAuthConnect(preset.name, true);
else onAction("reconnect", preset.name);
}}
>
<RotateCcw className="h-4 w-4" aria-hidden />
</AppsActionButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<AppsActionButton
ariaLabel={t("settings.mcp.actionsTitle", {
name: preset.display_name,
defaultValue: "Actions for {{name}}",
})}
busy={testBusy || toolsBusy || removeBusy}
disabled={busy}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")}
</DropdownMenuItem>
{toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
tone="destructive"
disabled={busy}
onClick={() => onAction("remove", preset.name)}
>
<Trash2 aria-hidden />
{tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : readyInstalled ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -618,9 +720,13 @@ function McpAppsCatalogRow({
visibleLabel={statusLabel}
busy={testBusy || toolsBusy || disableBusy}
disabled={busy}
tone="installed"
tone={toggleable || runtimeConnected ? "installed" : "default"}
>
<Check className="h-4 w-4" aria-hidden />
{toggleable || runtimeConnected ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Server className="h-4 w-4" aria-hidden />
)}
</AppsActionButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -667,23 +773,6 @@ function McpAppsCatalogRow({
busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)}
/>
) : oauthFlow ? (
<>
<AppsActionButton
ariaLabel={t("settings.mcp.connectingAccount", {
name: preset.display_name,
defaultValue: "Connecting {{name}}",
})}
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
busy
/>
<AppsActionButton
ariaLabel={tx("settings.actions.cancel", "Cancel")}
visibleLabel={tx("settings.actions.cancel", "Cancel")}
tone="danger"
onClick={onOAuthCancel}
/>
</>
) : isOAuth && preset.install_supported ? (
<AppsActionButton
ariaLabel={t("settings.mcp.connectTitle", {
@@ -902,7 +991,7 @@ function McpAppsCatalogRow({
</div>
) : null}
{toolsOpen && readyInstalled && toolNames.length ? (
{toolsOpen && configuredInstalled && toolNames.length ? (
<div className="mx-3 mb-3 rounded-[14px] bg-background/55 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11.5px] font-medium text-muted-foreground">
@@ -966,47 +1055,56 @@ function AppsTypeBadge({ children }: { children: ReactNode }) {
);
}
export const AppsActionButton = forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<typeof Button> & {
type AppsActionButtonProps = Omit<
ComponentPropsWithoutRef<typeof Button>,
"aria-label" | "children" | "disabled" | "size" | "variant"
> & {
ariaLabel: string;
visibleLabel?: string;
busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger";
}>(function AppsActionButton({
ariaLabel,
visibleLabel,
busy,
disabled,
tone = "default",
className,
children,
...props
}, ref) {
return (
<Button
{...props}
ref={ref}
type="button"
size={visibleLabel ? "sm" : "icon"}
variant="ghost"
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
className={cn(
"rounded-full text-muted-foreground transition-colors",
visibleLabel
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
: "h-9 w-9",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
{visibleLabel ? <span>{visibleLabel}</span> : null}
</Button>
);
});
children?: ReactNode;
};
export const AppsActionButton = forwardRef<HTMLButtonElement, AppsActionButtonProps>(
function AppsActionButton({
ariaLabel,
visibleLabel,
busy,
disabled,
tone = "default",
children,
className,
...buttonProps
}, ref) {
return (
<Button
{...buttonProps}
ref={ref}
type="button"
size={visibleLabel ? "sm" : "icon"}
variant="ghost"
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
className={cn(
"rounded-full text-muted-foreground transition-colors",
visibleLabel
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
: "h-9 w-9",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
{visibleLabel ? <span>{visibleLabel}</span> : null}
</Button>
);
},
);
function appsTitle(item: AppsCatalogItem): string {
return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
@@ -1014,7 +1112,10 @@ function appsTitle(item: AppsCatalogItem): string {
function appsReady(item: AppsCatalogItem): boolean {
if (item.kind === "cli") return item.app.installed;
return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
if (item.preset.enabled !== undefined) return item.preset.enabled;
return item.preset.installed &&
item.preset.configured &&
item.preset.runtime_status === "connected";
}
function appsSearchText(item: AppsCatalogItem): string {
@@ -423,7 +423,7 @@ export function createSystemSettingsActions({
}
};
const handleMcpOAuthConnect = async (name: string) => {
const handleMcpOAuthConnect = async (name: string, reset = false) => {
openMcpOAuthPopup();
const key = `oauth:${name}`;
setMcpPresetAction(key);
@@ -433,7 +433,7 @@ export function createSystemSettingsActions({
setMcpOAuthCompleting(false);
setMcpOAuthCallbackError(null);
try {
const flow = await startMcpOAuth(client, name);
const flow = await startMcpOAuth(client, name, reset);
mcpOAuthFlowRef.current = flow;
setMcpOAuthFlow(flow);
navigateMcpOAuthPopup(flow);
@@ -521,7 +521,7 @@ export function createSystemSettingsActions({
};
const handleMcpPresetAction = async (
action: "enable" | "disable" | "remove" | "test",
action: "enable" | "disable" | "remove" | "test" | "reconnect",
name: string,
values: Record<string, string> = {},
) => {
@@ -21,6 +21,8 @@ interface SystemSettingsEffectsOptions {
pageVisible: boolean;
}
const MCP_RUNTIME_STATUS_REFRESH_MS = 1_000;
export function useSystemSettingsEffects({
state,
activeSection,
@@ -149,26 +151,36 @@ export function useSystemSettingsEffects({
}, [activeSection, getToken]);
useEffect(() => {
if (activeSection !== "apps") return;
if (activeSection !== "apps" || !pageVisible) return;
let cancelled = false;
setMcpPresetsLoading(true);
fetchMcpPresets(getToken())
.then((payload) => {
if (!cancelled) {
let retry: number | null = null;
const loadMcpPresets = (showLoading: boolean) => {
if (showLoading) setMcpPresetsLoading(true);
fetchMcpPresets(getToken())
.then((payload) => {
if (cancelled) return;
setMcpPresets(payload);
setMcpError(null);
}
})
.catch((err) => {
if (!cancelled) setMcpError((err as Error).message);
})
.finally(() => {
if (!cancelled) setMcpPresetsLoading(false);
});
if (payload.presets.some((preset) => preset.runtime_status === "connecting")) {
retry = window.setTimeout(() => {
retry = null;
loadMcpPresets(false);
}, MCP_RUNTIME_STATUS_REFRESH_MS);
}
})
.catch((err) => {
if (!cancelled) setMcpError((err as Error).message);
})
.finally(() => {
if (!cancelled && showLoading) setMcpPresetsLoading(false);
});
};
loadMcpPresets(true);
return () => {
cancelled = true;
if (retry !== null) window.clearTimeout(retry);
};
}, [activeSection, getToken]);
}, [activeSection, getToken, pageVisible]);
const refreshAutomations = useCallback(
async (showLoading = false) => {
+3
View File
@@ -354,7 +354,10 @@
"enabled": "Enabled",
"setup": "Connect",
"configure": "Connect",
"reconnect": "Reconnect",
"connectTitle": "Connect {{name}}",
"reconnectTitle": "Reconnect {{name}}",
"actionsTitle": "Actions for {{name}}",
"connectHint": "Add the key from your account settings.",
"saveAndEnable": "Save and enable",
"updateSetup": "Update setup",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Habilitado",
"setup": "Conectar",
"configure": "Conectar",
"reconnect": "Reconectar",
"connectTitle": "Conectar {{name}}",
"reconnectTitle": "Reconectar {{name}}",
"actionsTitle": "Acciones de {{name}}",
"connectHint": "Añade la clave desde la configuración de tu cuenta.",
"saveAndEnable": "Guardar y habilitar",
"updateSetup": "Actualizar configuración",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Activé",
"setup": "Connecter",
"configure": "Connecter",
"reconnect": "Reconnecter",
"connectTitle": "Connecter {{name}}",
"reconnectTitle": "Reconnecter {{name}}",
"actionsTitle": "Actions pour {{name}}",
"connectHint": "Ajoutez la clé depuis les paramètres de votre compte.",
"saveAndEnable": "Enregistrer et activer",
"updateSetup": "Mettre à jour la configuration",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Aktif",
"setup": "Hubungkan",
"configure": "Hubungkan",
"reconnect": "Hubungkan kembali",
"connectTitle": "Hubungkan {{name}}",
"reconnectTitle": "Hubungkan kembali {{name}}",
"actionsTitle": "Tindakan untuk {{name}}",
"connectHint": "Tambahkan kunci dari pengaturan akun Anda.",
"saveAndEnable": "Simpan dan aktifkan",
"updateSetup": "Perbarui konfigurasi",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "有効",
"setup": "接続",
"configure": "接続",
"reconnect": "再接続",
"connectTitle": "{{name}} に接続",
"reconnectTitle": "{{name}} に再接続",
"actionsTitle": "{{name}} の操作",
"connectHint": "アカウント設定からキーを追加します。",
"saveAndEnable": "保存して有効化",
"updateSetup": "設定を更新",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "활성화됨",
"setup": "연결",
"configure": "연결",
"reconnect": "다시 연결",
"connectTitle": "{{name}} 연결",
"reconnectTitle": "{{name}} 다시 연결",
"actionsTitle": "{{name}} 작업",
"connectHint": "계정 설정에서 키를 추가하세요.",
"saveAndEnable": "저장 후 활성화",
"updateSetup": "설정 업데이트",
+3
View File
@@ -354,7 +354,10 @@
"enabled": "Habilitado",
"setup": "Conectar",
"configure": "Conectar",
"reconnect": "Reconectar",
"connectTitle": "Conectar {{name}}",
"reconnectTitle": "Reconectar {{name}}",
"actionsTitle": "Ações para {{name}}",
"connectHint": "Adicione a chave a partir das configurações da sua conta.",
"saveAndEnable": "Salvar e habilitar",
"updateSetup": "Atualizar configuração",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "Đã bật",
"setup": "Kết nối",
"configure": "Kết nối",
"reconnect": "Kết nối lại",
"connectTitle": "Kết nối {{name}}",
"reconnectTitle": "Kết nối lại {{name}}",
"actionsTitle": "Thao tác cho {{name}}",
"connectHint": "Thêm khóa từ phần cài đặt tài khoản của bạn.",
"saveAndEnable": "Lưu và bật",
"updateSetup": "Cập nhật thiết lập",
+3
View File
@@ -354,7 +354,10 @@
"enabled": "已启用",
"setup": "连接",
"configure": "连接",
"reconnect": "重新连接",
"connectTitle": "连接 {{name}}",
"reconnectTitle": "重新连接 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "填入账户中的密钥。",
"saveAndEnable": "保存并启用",
"updateSetup": "更新配置",
+3
View File
@@ -539,7 +539,10 @@
"enabled": "已啟用",
"setup": "連線",
"configure": "連線",
"reconnect": "重新連線",
"connectTitle": "連線 {{name}}",
"reconnectTitle": "重新連線 {{name}}",
"actionsTitle": "{{name}} 操作",
"connectHint": "請從帳號設定新增金鑰。",
"saveAndEnable": "儲存並啟用",
"updateSetup": "更新設定",
+1 -1
View File
@@ -766,7 +766,7 @@ export async function fetchProviderModels(
export async function runMcpPresetAction(
transport: WebUIMutationTransport,
action: "enable" | "disable" | "remove" | "test",
action: "enable" | "disable" | "remove" | "test" | "reconnect",
name: string,
values: Record<string, string> = {},
): Promise<McpPresetsPayload> {
+1
View File
@@ -965,6 +965,7 @@ export interface McpPresetInfo {
enabled?: boolean;
available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
runtime_status?: "connecting" | "connected" | "failed" | string;
logo_url?: string | null;
brand_color?: string | null;
required_fields: McpPresetField[];
+176 -1
View File
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import {
installSettingsViewTestHooks,
jsonResponse,
@@ -140,6 +141,180 @@ describe("SettingsView Apps catalog", () => {
);
});
it("shows a real OAuth runtime failure and restarts authorization without a success check", async () => {
const failedPreset = {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
runtime_status: "failed",
connection_summary: "https://app.xmind.com/api/mcp",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [failedPreset], installed_count: 1 });
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("open", vi.fn(() => null));
requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion"));
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "Ready" }));
expect(await screen.findByText("No tools are ready yet.")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Xmind" })).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
const heading = await screen.findByRole("heading", { name: "Xmind" });
const row = heading.closest("article");
expect(row).not.toBeNull();
expect(row?.parentElement).toHaveClass("xl:grid-cols-2");
expect(within(row as HTMLElement).queryByText("MCP")).not.toBeInTheDocument();
const failed = within(row as HTMLElement).getByText("Connection failed.");
expect(failed.closest("button")).toBeNull();
expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull();
expect(row?.querySelector(".lucide-check")).toBeNull();
expect(within(row as HTMLElement).getByRole("button", { name: "Reconnect Xmind" })).toHaveTextContent(
"Reconnect",
);
const actions = within(row as HTMLElement).getByRole("button", { name: "Actions for Xmind" });
expect(actions).toHaveAttribute("aria-haspopup", "menu");
fireEvent.pointerDown(actions, { button: 0, ctrlKey: false });
expect(await screen.findByRole("menuitem", { name: "Test" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Remove" })).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await act(() => i18n.changeLanguage("zh-CN"));
expect(within(row as HTMLElement).getByText("连接失败。")).toBeInTheDocument();
expect(within(row as HTMLElement).getByRole("button", { name: "Xmind 操作" }))
.toBeInTheDocument();
const reconnect = screen.getByRole("button", { name: "重新连接 Xmind" });
expect(reconnect).toHaveTextContent("重新连接");
fireEvent.click(reconnect);
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.oauth_start",
{ name: "xmind", reset: true },
30_000,
));
await act(() => i18n.changeLanguage("en"));
});
it("refreshes a connecting MCP snapshot until the runtime attempt settles", async () => {
const connectingPreset = {
...xmindMcpPreset,
installed: true,
configured: true,
available: true,
status: "configured",
runtime_status: "connecting",
connection_summary: "https://app.xmind.com/api/mcp",
};
let mcpPresetRequests = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
mcpPresetRequests += 1;
return jsonResponse({
presets: [{
...connectingPreset,
runtime_status: mcpPresetRequests === 1 ? "connecting" : "connected",
}],
installed_count: 1,
});
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(await screen.findByRole("button", { name: "Xmind: Connecting…" }))
.toHaveTextContent("Connecting…");
expect(await screen.findByRole(
"button",
{ name: "Xmind: Connected." },
{ timeout: 2_500 },
)).toHaveTextContent("Connected.");
expect(mcpPresetRequests).toBe(2);
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument();
});
it("retries a failed custom MCP and only shows a success check after it connects", async () => {
const failedCustom = {
...xmindMcpPreset,
name: "team-docs",
display_name: "team-docs",
auth: null,
source: "custom",
installed: true,
configured: true,
available: true,
status: "configured",
runtime_status: "failed",
connection_summary: "https://mcp.example.com/mcp",
};
const connectedCustom = { ...failedCustom, runtime_status: "connected" };
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(settingsPayload());
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [failedCustom], installed_count: 1 });
}
return { ok: false, status: 404, text: async () => "Not found" } as Response;
});
vi.stubGlobal("fetch", fetchMock);
requestMutationMock.mockResolvedValueOnce({
presets: [connectedCustom],
installed_count: 1,
requires_restart: false,
hot_reload: {
ok: true,
message: "MCP connections refreshed without restarting nanobot.",
connected: ["team-docs"],
failed: [],
},
last_action: { ok: true, message: "Retried connection for MCP server team-docs." },
});
renderSettingsView({ initialSection: "apps" });
fireEvent.click(await screen.findByRole("button", { name: "MCP" }));
expect(await screen.findByText("Connection failed.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.reconnect",
{ name: "team-docs" },
20_000,
));
const connected = await screen.findByRole("button", { name: "team-docs: Connected." });
expect(connected).toHaveTextContent("Connected.");
expect(connected.querySelector(".lucide-check")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Ready" }));
const readyHeading = await screen.findByRole("heading", { name: "team-docs" });
expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP"))
.toBeInTheDocument();
});
it("configures OAuth for a custom remote MCP without importing JSON", async () => {
const customPreset = {
...xmindMcpPreset,