diff --git a/e2e/mcp-oauth-outcomes.spec.ts b/e2e/mcp-oauth-outcomes.spec.ts new file mode 100644 index 00000000..f2b0acd2 --- /dev/null +++ b/e2e/mcp-oauth-outcomes.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from "./fixtures/auth"; +import { APP } from "./utils/paths"; + +test("MCP server OAuth failure → dismissal → retry → fresh components", async ({ + page, + context, + apiMock, +}) => { + await apiMock.mockPermissions(); + const server = { + id: "oauth-outcomes", + name: "OAuth outcomes server", + url: "http://localhost:9000/mcp", + transport: "STREAMABLEHTTP", + enabled: true, + reachable: true, + visibility: "public", + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + createdAt: "2026-10-21T00:00:00Z", + updatedAt: "2026-10-21T00:00:00Z", + }; + let authorized = false; + let discovered = false; + await page.route("**/v1/mcp-servers?*", (route) => + route.fulfill({ json: { gateways: [server], nextCursor: null } }), + ); + await page.route(`**/v1/mcp-servers/${server.id}`, (route) => route.fulfill({ json: server })); + await page.route("**/oauth/status?*", (route) => + route.fulfill({ + json: { + [server.id]: { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { + status: authorized ? "valid" : "missing", + authorized, + }, + }, + }, + }), + ); + await page.route("**/oauth/authorize-nonce", (route) => + route.fulfill({ json: { nonce: "test-nonce" } }), + ); + await context.route("**/oauth/authorize/**", (route) => + route.fulfill({ + contentType: "text/html", + body: "Provider", + }), + ); + await page.route(`**/oauth/fetch-tools/${server.id}`, (route) => { + discovered = true; + return route.fulfill({ json: { success: true, message: "Fetched components" } }); + }); + await page.route("**/tools?*", (route) => + route.fulfill({ + json: { + tools: discovered + ? [ + { + id: "fresh-tool", + name: "fresh_tool", + originalName: "fresh_tool", + title: "Fresh tool", + }, + ] + : [], + }, + }), + ); + await page.route("**/resources?*", (route) => + route.fulfill({ + json: { + resources: discovered + ? [ + { + id: "fresh-resource", + name: "fresh_resource", + uri: "file:///fresh", + title: "Fresh resource", + }, + ] + : [], + }, + }), + ); + await page.route("**/prompts?*", (route) => + route.fulfill({ + json: { + prompts: discovered + ? [ + { + id: "fresh-prompt", + name: "fresh_prompt", + originalName: "fresh_prompt", + title: "Fresh prompt", + }, + ] + : [], + }, + }), + ); + + await page.goto(APP.SERVERS); + const firstPopupPromise = context.waitForEvent("page"); + await page.getByRole("button", { name: "Authorize OAuth outcomes server" }).click(); + const firstPopup = await firstPopupPromise; + await firstPopup.waitForURL(/\/oauth\/authorize\//); + await firstPopup.evaluate(() => { + window.opener.postMessage( + { + type: "oauth_callback", + status: "error", + error: "access_denied", + errorDescription: "Provider denied the requested scope", + }, + "*", + ); + window.close(); + }); + const dialog = page.getByRole("dialog", { + name: "OAuth authorization failed for OAuth outcomes server", + }); + await expect(dialog.getByText("Provider denied the requested scope")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + const panel = page.getByRole("region", { name: /MCP Server details: OAuth outcomes server/ }); + await expect(panel.getByText("Authorization required")).toBeVisible(); + await expect(page.locator(`#oauth-authorize-${server.id}`)).toBeFocused(); + + const retryPopupPromise = context.waitForEvent("page"); + await panel.getByRole("button", { name: "Authorize", exact: true }).last().click(); + const retryPopup = await retryPopupPromise; + await retryPopup.waitForURL(/\/oauth\/authorize\//); + authorized = true; + await retryPopup.evaluate(() => { + window.opener.postMessage({ type: "oauth_callback", status: "success" }, "*"); + window.close(); + }); + await expect(panel.getByText("OAuth authorization successful.")).toBeVisible(); + await expect(panel.getByText("Fresh tool")).toBeVisible(); + await expect(panel.getByText("Fresh resource")).toBeVisible(); + await expect(panel.getByText("Fresh prompt")).toBeVisible(); + await expect(panel.getByText("Authorization required")).not.toBeVisible(); +}); diff --git a/src/api/oauth.ts b/src/api/oauth.ts index 831849f5..375b3373 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -101,3 +101,9 @@ export async function getOAuthStatuses( return result; } + +/** Transient UI outcome, separate from the caller's authoritative token status. */ +export type OAuthAttempt = + | { state: "pending" } + | { state: "succeeded" } + | { state: "failed"; reason: string; dialogOpen: boolean }; diff --git a/src/api/servers.test.ts b/src/api/servers.test.ts index dee7741b..cfbb5688 100644 --- a/src/api/servers.test.ts +++ b/src/api/servers.test.ts @@ -249,7 +249,11 @@ describe("serversApi", () => { Object.defineProperty(event, "source", { value: mockAuthWindow, writable: false }); window.dispatchEvent(event); - await expect(promise).rejects.toThrow("User denied access"); + await expect(promise).rejects.toMatchObject({ + name: "OAuthAuthorizationError", + code: "access_denied", + description: "User denied access", + }); }); it("falls back to the error code when errorDescription is absent", async () => { @@ -284,6 +288,37 @@ describe("serversApi", () => { await expect(promise).rejects.toThrow("OAuth authorization failed"); }); + it.each([ + { + error: "access_denied", + errorDescription: "x".repeat(2000), + expected: `${"x".repeat(999)}…`, + }, + { + error: { unsafe: "object" }, + errorDescription: ["not text"], + expected: "OAuth authorization failed", + }, + { error: "access_denied", errorDescription: " ", expected: "access_denied" }, + ])( + "normalizes and bounds provider error fields: $expected", + async ({ error, errorDescription, expected }) => { + const popup = mockAuthPopup(); + vi.spyOn(window, "open").mockReturnValue(popup); + mockFetch.mockResolvedValueOnce(nonceResponse()); + const promise = serversApi.triggerOAuthAuthorization("server-123"); + const event = new MessageEvent("message", { + data: { type: "oauth_callback", status: "error", error, errorDescription }, + }); + Object.defineProperty(event, "source", { value: popup }); + window.dispatchEvent(event); + await expect(promise).rejects.toMatchObject({ + name: "OAuthAuthorizationError", + message: expected, + }); + }, + ); + it("ignores postMessages from other sources", async () => { vi.useFakeTimers(); const mockAuthWindow = mockAuthPopup(); diff --git a/src/api/servers.ts b/src/api/servers.ts index 8c8a4012..912cf4cb 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -18,6 +18,32 @@ import { validateServerId } from "@/utils/serverId"; const serverByIdRequestCache = new Map>(); +export const MAX_OAUTH_ERROR_LENGTH = 1000; + +function providerErrorText(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const text = value.trim(); + if (!text) return undefined; + return text.length > MAX_OAUTH_ERROR_LENGTH + ? `${text.slice(0, MAX_OAUTH_ERROR_LENGTH - 1)}…` + : text; +} + +/** Provider callback failure; render only bounded plain-text fields. */ +export class OAuthAuthorizationError extends Error { + readonly code?: string; + readonly description?: string; + + constructor(code?: unknown, description?: unknown) { + const safeCode = providerErrorText(code); + const safeDescription = providerErrorText(description); + super(safeDescription || safeCode || "OAuth authorization failed"); + this.name = "OAuthAuthorizationError"; + this.code = safeCode; + this.description = safeDescription; + } +} + /** The user closed the OAuth popup. Typed so callers can stay quiet about it. */ export class OAuthCancelledError extends Error { constructor() { @@ -265,7 +291,7 @@ export const serversApi = { if (data.status === "success") { resolve(data); } else { - reject(new Error(data.errorDescription ?? data.error ?? "OAuth authorization failed")); + reject(new OAuthAuthorizationError(data.error, data.errorDescription)); } }; diff --git a/src/components/servers/MCPServerDetailsPanel.tsx b/src/components/servers/MCPServerDetailsPanel.tsx index 7ac4ed51..e959835c 100644 --- a/src/components/servers/MCPServerDetailsPanel.tsx +++ b/src/components/servers/MCPServerDetailsPanel.tsx @@ -25,9 +25,11 @@ import { Input } from "@/components/ui/input"; import { TruncatedText } from "@/components/ui/truncated-text"; import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text"; import { cn } from "@/lib/utils"; +import { getServerAvailability, needsOAuthAuthorization } from "@/lib/serverStatus"; import type { MCPServer as BaseMCPServer, VirtualServerTag } from "@/types/server"; import { useQuery } from "@/hooks/useQuery"; -import type { OAuthStatusEntry } from "@/api/oauth"; +import type { OAuthAttempt, OAuthStatusEntry } from "@/api/oauth"; +import { InlineNotification } from "@/components/ui/inline-notification"; import { TestConnectionPanel } from "./TestConnectionPanel"; import { ServerStatusIndicator } from "./ServerStatusIndicator"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -139,6 +141,12 @@ export function MCPServerDetailsPanel({ oauthStatus, onAuthorize, onRetryOAuthStatus, + oauthAttempt, + componentRefreshRevision = 0, + componentRefreshError, + isRefreshing = false, + onRefresh, + onRetryDiscovery, }: { server: MCPServer | null; error: { message: string } | null; @@ -155,6 +163,12 @@ export function MCPServerDetailsPanel({ oauthStatus?: OAuthStatusEntry; onAuthorize?: () => Promise; onRetryOAuthStatus?: () => void; + oauthAttempt?: OAuthAttempt; + componentRefreshRevision?: number; + componentRefreshError?: { reason?: string }; + isRefreshing?: boolean; + onRefresh?: () => Promise; + onRetryDiscovery?: () => Promise; }) { const [topTab, setTopTab] = useState(initialTab); const [activeTab, setActiveTab] = useState("all"); @@ -197,7 +211,12 @@ export function MCPServerDetailsPanel({ useEffect(() => { if (!open) return; const onKey = (e: globalThis.KeyboardEvent) => { - if (e.key === "Escape" && !e.defaultPrevented) onClose(); + if ( + e.key === "Escape" && + !e.defaultPrevented && + !document.querySelector('[role="dialog"][data-state="open"]') + ) + onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); @@ -224,25 +243,49 @@ export function MCPServerDetailsPanel({ // Fetch components data - only when panel is open and server exists const fetchEnabled = open && Boolean(server?.id); - const { data: toolsData, isLoading: toolsLoading } = useQuery<{ tools: Tool[] }>(toolsPath, { + const { + data: toolsData, + isLoading: toolsLoading, + error: toolsError, + refetch: refetchTools, + } = useQuery<{ tools: Tool[] }>(toolsPath, { enabled: fetchEnabled, }); - const { data: resourcesData, isLoading: resourcesLoading } = useQuery<{ resources: Resource[] }>( - resourcesPath, - { - enabled: fetchEnabled, - }, - ); + const { + data: resourcesData, + isLoading: resourcesLoading, + error: resourcesError, + refetch: refetchResources, + } = useQuery<{ resources: Resource[] }>(resourcesPath, { + enabled: fetchEnabled, + }); - const { data: promptsData, isLoading: promptsLoading } = useQuery<{ prompts: Prompt[] }>( - promptsPath, - { - enabled: fetchEnabled, - }, - ); + const { + data: promptsData, + isLoading: promptsLoading, + error: promptsError, + refetch: refetchPrompts, + } = useQuery<{ prompts: Prompt[] }>(promptsPath, { + enabled: fetchEnabled, + }); + + const retryComponentReads = useCallback(() => { + void Promise.allSettled([refetchTools(), refetchResources(), refetchPrompts()]); + }, [refetchTools, refetchResources, refetchPrompts]); + const previousRevision = useRef(componentRefreshRevision); + useEffect(() => { + if (previousRevision.current === componentRefreshRevision) return; + previousRevision.current = componentRefreshRevision; + if (fetchEnabled) retryComponentReads(); + }, [componentRefreshRevision, fetchEnabled, retryComponentReads]); const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; + const componentReadError = toolsError || resourcesError || promptsError; + const isAuthorizing = oauthAttempt?.state === "pending"; + const needsAuthorization = server + ? needsOAuthAuthorization(getServerAvailability(server, oauthStatus)) + : false; // Filter components based on active tab and search query const visibleComponents = useMemo((): ComponentWithType[] => { @@ -387,6 +430,74 @@ export function MCPServerDetailsPanel({ +
+
+ {onAuthorize && needsAuthorization && ( + + )} + {onRefresh && ( + + )} +
+ {oauthAttempt?.state === "succeeded" && ( + + )} + {componentRefreshError && ( + void onRetryDiscovery(), + } + : undefined + } + /> + )} + {componentReadError && ( + + )} +
+ )} +
diff --git a/src/components/servers/ServerStatusIndicator.tsx b/src/components/servers/ServerStatusIndicator.tsx index 03c1cc5b..ff04faac 100644 --- a/src/components/servers/ServerStatusIndicator.tsx +++ b/src/components/servers/ServerStatusIndicator.tsx @@ -20,6 +20,7 @@ interface ServerStatusIndicatorProps { server: ServerAvailabilityInput & { name: string; lastError?: string | null }; oauthStatus?: OAuthStatusEntry; compact?: boolean; + authorizing?: boolean; /** Disable popover/authorization interaction when nested inside another interactive control. */ interactive?: boolean; onAuthorize?: () => Promise; @@ -32,6 +33,7 @@ export function ServerStatusIndicator({ server, oauthStatus, compact = false, + authorizing = false, interactive = true, onAuthorize, onRetry, @@ -39,7 +41,8 @@ export function ServerStatusIndicator({ className, }: ServerStatusIndicatorProps) { const intl = useIntl(); - const [isAuthorizing, setIsAuthorizing] = useState(false); + const [localAuthorizing, setIsAuthorizing] = useState(false); + const isAuthorizing = authorizing || localAuthorizing; const availability = getServerAvailability(server, oauthStatus); const presentation = getAvailabilityPresentation(availability); const StatusIcon = presentation.Icon; diff --git a/src/components/servers/ServersTable.tsx b/src/components/servers/ServersTable.tsx index a16f8bd0..12c787f5 100644 --- a/src/components/servers/ServersTable.tsx +++ b/src/components/servers/ServersTable.tsx @@ -55,6 +55,7 @@ interface ServersTableProps { onToggleEnabled?: (id: string, enabled: boolean) => void; onRefresh?: (id: string) => void; refreshingServerIds?: Set; + authorizingServerIds?: Set; oauthStatuses?: Record; onAuthorize?: (id: string) => Promise; onRetryOAuthStatus?: (id: string) => void; @@ -70,6 +71,7 @@ export function ServersTable({ onRefresh, refreshingServerIds, oauthStatuses, + authorizingServerIds, onAuthorize, onRetryOAuthStatus, }: ServersTableProps) { @@ -190,6 +192,7 @@ export function ServersTable({ onAuthorize(server.id))} onRetry={onRetryOAuthStatus && (() => onRetryOAuthStatus(server.id))} compact diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 06c5234f..eb8c19ec 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -381,5 +381,15 @@ "mcpServer.testConnection.virtualServer.failureCopy.protocol": "The server responded, but MCP protocol negotiation failed. This can happen when the virtual server is disabled or a federated source returned an unexpected response.", "mcpServer.testConnection.virtualServer.failureCopy.auth": "Authentication failed. Check the credentials or headers being sent.", "mcpServer.testConnection.virtualServer.failureCopy.invalidResponse": "The server returned a response that couldn't be parsed as a valid MCP handshake.", - "mcpServer.testConnection.virtualServer.failureCopy.default": "Check the credentials being sent and that the virtual server is enabled." + "mcpServer.testConnection.virtualServer.failureCopy.default": "Check the credentials being sent and that the virtual server is enabled.", + "mcpServer.oauth.failureTitle": "OAuth authorization failed for {name}", + "mcpServer.oauth.tryAgain": "Try again", + "mcpServer.oauth.dismiss": "Dismiss", + "mcpServer.oauth.authorize": "Authorize", + "mcpServer.oauth.refresh": "Refresh components", + "mcpServer.oauth.refreshing": "Refreshing components…", + "mcpServer.oauth.success": "OAuth authorization successful.", + "mcpServer.oauth.discoveryError": "Components could not be refreshed.", + "mcpServer.oauth.retryRefresh": "Retry refresh", + "mcpServer.oauth.readError": "Tools, resources, or prompts could not be loaded." } diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 6f3080c8..06e94629 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -381,5 +381,15 @@ "mcpServer.testConnection.virtualServer.failureCopy.protocol": "El servidor respondió, pero la negociación del protocolo MCP falló. Esto puede ocurrir si el servidor virtual está deshabilitado o una fuente federada devolvió una respuesta inesperada.", "mcpServer.testConnection.virtualServer.failureCopy.auth": "Fallo de autenticación. Verifica las credenciales o los encabezados enviados.", "mcpServer.testConnection.virtualServer.failureCopy.invalidResponse": "El servidor devolvió una respuesta que no se pudo interpretar como un handshake MCP válido.", - "mcpServer.testConnection.virtualServer.failureCopy.default": "Verifica las credenciales enviadas y que el servidor virtual esté habilitado." + "mcpServer.testConnection.virtualServer.failureCopy.default": "Verifica las credenciales enviadas y que el servidor virtual esté habilitado.", + "mcpServer.oauth.failureTitle": "Error de autorización OAuth para {name}", + "mcpServer.oauth.tryAgain": "Intentar de nuevo", + "mcpServer.oauth.dismiss": "Descartar", + "mcpServer.oauth.authorize": "Autorizar", + "mcpServer.oauth.refresh": "Actualizar componentes", + "mcpServer.oauth.refreshing": "Actualizando componentes…", + "mcpServer.oauth.success": "Autorización OAuth completada.", + "mcpServer.oauth.discoveryError": "No se pudieron actualizar los componentes.", + "mcpServer.oauth.retryRefresh": "Reintentar actualización", + "mcpServer.oauth.readError": "No se pudieron cargar las herramientas, los recursos o los prompts." } diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index d53d55cd..a730e3ef 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -381,5 +381,15 @@ "mcpServer.testConnection.virtualServer.failureCopy.protocol": "O servidor respondeu, mas a negociação do protocolo MCP falhou. Isso pode acontecer quando o servidor virtual está desabilitado ou uma fonte federada retornou uma resposta inesperada.", "mcpServer.testConnection.virtualServer.failureCopy.auth": "Falha na autenticação. Verifique as credenciais ou os cabeçalhos enviados.", "mcpServer.testConnection.virtualServer.failureCopy.invalidResponse": "O servidor retornou uma resposta que não pôde ser interpretada como um handshake MCP válido.", - "mcpServer.testConnection.virtualServer.failureCopy.default": "Verifique as credenciais enviadas e se o servidor virtual está habilitado." + "mcpServer.testConnection.virtualServer.failureCopy.default": "Verifique as credenciais enviadas e se o servidor virtual está habilitado.", + "mcpServer.oauth.failureTitle": "Falha na autorização OAuth de {name}", + "mcpServer.oauth.tryAgain": "Tentar novamente", + "mcpServer.oauth.dismiss": "Dispensar", + "mcpServer.oauth.authorize": "Autorizar", + "mcpServer.oauth.refresh": "Atualizar componentes", + "mcpServer.oauth.refreshing": "Atualizando componentes…", + "mcpServer.oauth.success": "Autorização OAuth concluída.", + "mcpServer.oauth.discoveryError": "Não foi possível atualizar os componentes.", + "mcpServer.oauth.retryRefresh": "Tentar atualizar novamente", + "mcpServer.oauth.readError": "Não foi possível carregar as ferramentas, os recursos ou os prompts." } diff --git a/src/pages/Servers.test.tsx b/src/pages/Servers.test.tsx index f67534ea..bf1d7efe 100644 --- a/src/pages/Servers.test.tsx +++ b/src/pages/Servers.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { screen, waitFor, within } from "@testing-library/react"; +import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { render } from "@testing-library/react"; import { toast } from "sonner"; @@ -45,7 +45,7 @@ vi.mock("@/auth/useAuth", () => ({ })); import { api, ApiError } from "@/api/client"; -import { serversApi } from "@/api/servers"; +import { OAuthAuthorizationError, OAuthCancelledError, serversApi } from "@/api/servers"; const mockToastSuccess = vi.mocked(toast.success); const mockToastError = vi.mocked(toast.error); @@ -581,7 +581,7 @@ describe("Servers", () => { await user.click(authorize); await waitFor(() => { - expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getAllByText("Active").length).toBeGreaterThan(0); }); expect( screen.queryByRole("button", { name: /authorize test server 0/i }), @@ -1459,4 +1459,443 @@ describe("Servers", () => { expect(pendingItem).toHaveAttribute("aria-disabled", "true"); }); }); + + describe("OAuth outcomes on the MCP Servers page", () => { + function setupOAuth({ token = "missing", statusUnavailable = false, emptyList = false } = {}) { + let tokenStatus = token; + let discovered = false; + const first = { + ...mockServerDetails, + authType: "oauth", + oauthConfig: { grant_type: "authorization_code" }, + }; + const second = { ...first, id: "server-1", name: "Test Server 1" }; + vi.mocked(api.get).mockImplementation((path: string) => { + if (path.startsWith("/oauth/status")) { + if (statusUnavailable) return Promise.reject(new Error("Service down")); + return Promise.resolve( + Object.fromEntries( + [first, second].map((server) => [ + server.id, + { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: tokenStatus, authorized: tokenStatus === "valid" }, + }, + ]), + ) as never, + ); + } + if (path.startsWith("/v1/mcp-servers?")) + return Promise.resolve({ + gateways: emptyList ? [] : [first, second], + nextCursor: null, + } as never); + if (path === "/v1/mcp-servers/server-0") return Promise.resolve(first as never); + if (path === "/v1/mcp-servers/server-1") return Promise.resolve(second as never); + if (path.startsWith("/tools?")) + return Promise.resolve({ + tools: discovered + ? [ + { + id: "new-tool", + name: "new_tool", + originalName: "new_tool", + title: "Fresh tool", + }, + ] + : [], + } as never); + if (path.startsWith("/resources?")) + return Promise.resolve({ + resources: discovered + ? [ + { + id: "new-resource", + name: "new_resource", + uri: "file:///fresh", + title: "Fresh resource", + }, + ] + : [], + } as never); + if (path.startsWith("/prompts?")) + return Promise.resolve({ + prompts: discovered + ? [ + { + id: "new-prompt", + name: "new_prompt", + originalName: "new_prompt", + title: "Fresh prompt", + }, + ] + : [], + } as never); + return Promise.resolve({} as never); + }); + const authorize = vi + .spyOn(serversApi, "triggerOAuthAuthorization") + .mockImplementation(async () => { + tokenStatus = "valid"; + return { type: "oauth_callback", status: "success" }; + }); + const discover = vi.spyOn(serversApi, "fetchToolsAfterOAuth").mockImplementation(async () => { + discovered = true; + return { success: true, message: "Fetched components" }; + }); + return { + authorize, + discover, + markDiscovered: () => { + discovered = true; + }, + }; + } + + it("opens Components and refetches tools, resources, and prompts after authorization", async () => { + const user = userEvent.setup(); + const { discover } = setupOAuth(); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + expect(await screen.findByText("OAuth authorization successful.")).toBeVisible(); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(await screen.findByText("Fresh resource")).toBeVisible(); + expect(await screen.findByText("Fresh prompt")).toBeVisible(); + expect(discover).toHaveBeenCalledWith("server-0"); + expect(screen.getByRole("tab", { name: "Components" })).toHaveAttribute( + "data-state", + "active", + ); + }); + + it("shows the provider reason, dismisses without closing details, and retries successfully", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce( + new OAuthAuthorizationError("access_denied", "Provider denied the requested scope"), + ); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + const dialog = await screen.findByRole("dialog", { + name: "OAuth authorization failed for Test Server 0", + }); + expect(within(dialog).getByText("Provider denied the requested scope")).toBeVisible(); + await user.click(within(dialog).getByRole("button", { name: "Dismiss" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + expect(within(panel).getByText("Authorization required")).toBeVisible(); + expect(screen.getAllByText("Authorization required")).toHaveLength(1); + await user.click(within(panel).getAllByRole("button", { name: "Authorize" })[0]); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(screen.queryByText("Authorization required")).not.toBeInTheDocument(); + expect(authorize).toHaveBeenCalledTimes(2); + }); + + it("dismisses only the failure dialog with Escape and restores focus to Authorize", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await screen.findByRole("dialog"); + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(screen.getByRole("region", { name: /Test Server 0/i })).toBeVisible(); + await waitFor(() => + expect(document.getElementById("oauth-authorize-server-0")).toHaveFocus(), + ); + }); + + it("retries directly from the failure dialog", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Try again" }), + ); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not offer authorization when the token is already valid", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth({ token: "valid" }); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + expect(await within(panel).findByText("Active")).toBeVisible(); + expect(within(panel).queryByRole("button", { name: /^Authorize/ })).not.toBeInTheDocument(); + expect(authorize).not.toHaveBeenCalled(); + }); + + it("keeps OAuth success when discovery returns success:false and retries discovery independently", async () => { + const user = userEvent.setup(); + const { authorize, discover } = setupOAuth(); + discover.mockResolvedValueOnce({ success: false, message: "Unavailable" }); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + expect(await screen.findByText("OAuth authorization successful.")).toBeVisible(); + await user.click(await screen.findByRole("button", { name: "Retry refresh" })); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(authorize).toHaveBeenCalledTimes(1); + expect(discover).toHaveBeenCalledTimes(2); + }); + + it("allows authorization without component discovery for read-only users", async () => { + const user = userEvent.setup(); + const { discover } = setupOAuth(); + mockHasPermission.mockImplementation((permission) => permission === "gateways.read"); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + expect(await screen.findByText("OAuth authorization successful.")).toBeVisible(); + expect(discover).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Refresh components" })).not.toBeInTheDocument(); + }); + + it("treats popup closure as cancellation without a failure dialog", async () => { + const user = userEvent.setup(); + const { authorize, discover } = setupOAuth(); + authorize.mockRejectedValueOnce(new OAuthCancelledError()); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Authorizing..." })).not.toBeInTheDocument(), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(discover).not.toHaveBeenCalled(); + expect( + screen.getAllByRole("button", { name: "Authorize Test Server 0" }).length, + ).toBeGreaterThan(0); + }); + + it("shows generic popup failures in a retryable dialog", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce(new Error("Failed to open OAuth authorization window")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByRole("button", { name: "Try again" })).toBeVisible(); + expect(within(dialog).getByText("An error occurred. Please try again.")).toBeVisible(); + }); + + it("loads OAuth status for a deep-linked server outside the current list", async () => { + setupOAuth({ emptyList: true }); + renderWithRouter(, "/app/servers?selected=server-0"); + await waitFor(() => + expect(api.get).toHaveBeenCalledWith( + expect.stringContaining("gateway_ids=server-0"), + undefined, + expect.any(AbortSignal), + ), + ); + expect(await screen.findByRole("button", { name: "Authorize Test Server 0" })).toBeVisible(); + }); + + it("keeps unavailable OAuth status distinguishable from missing authorization", async () => { + setupOAuth({ statusUnavailable: true }); + renderWithRouter(, "/app/servers?selected=server-0"); + const panel = await screen.findByRole("region", { name: /Test Server 0/i }); + expect(await within(panel).findByText("Authorization status unavailable")).toBeVisible(); + expect(within(panel).queryByText("Authorization required")).not.toBeInTheDocument(); + }); + + it("prevents duplicate attempts and does not reopen closed details when a callback arrives", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + let finish!: () => void; + authorize.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve({ type: "oauth_callback", status: "success" }); + }), + ); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + expect(within(panel).getByRole("button", { name: "Authorizing..." })).toBeDisabled(); + await user.click(within(panel).getByRole("button", { name: "Authorizing..." })); + expect(authorize).toHaveBeenCalledTimes(1); + await user.click(within(panel).getByRole("button", { name: "Close MCP server details" })); + await act(async () => finish()); + expect(screen.queryByRole("region", { name: /Test Server 0/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not apply a late failure to another selected server", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + let fail!: () => void; + authorize.mockImplementationOnce( + () => + new Promise((_, reject) => { + fail = () => + reject(new OAuthAuthorizationError("access_denied", "Failure from server 0")); + }), + ); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await user.click(screen.getByRole("button", { name: "Actions for Test Server 1" })); + await user.click(await screen.findByRole("menuitem", { name: "View details" })); + expect(await screen.findByRole("region", { name: /Test Server 1/i })).toBeVisible(); + await act(async () => fail()); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.queryByText("Failure from server 0")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: /Test Server 1/i })).toBeVisible(); + }); + + it("refreshes an open component list independently of authorization", async () => { + const user = userEvent.setup(); + const { authorize, markDiscovered } = setupOAuth({ token: "valid" }); + const refresh = vi.spyOn(serversApi, "refreshTools").mockImplementation(async () => { + markDiscovered(); + return { + success: true, + gatewayId: "server-0", + durationMs: 1, + refreshedAt: "2026-10-21T00:00:00Z", + }; + }); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + expect(screen.queryByText("Fresh tool")).not.toBeInTheDocument(); + await user.click(await screen.findByRole("button", { name: "Refresh components" })); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(refresh).toHaveBeenCalledWith("server-0"); + expect(authorize).not.toHaveBeenCalled(); + }); + + it("retries component-list read failures without restarting OAuth", async () => { + const user = userEvent.setup(); + const { authorize, markDiscovered } = setupOAuth({ token: "valid" }); + const get = vi.mocked(api.get).getMockImplementation()!; + let failReads = true; + vi.mocked(api.get).mockImplementation((...args) => { + if (args[0].startsWith("/tools?") && failReads) + return Promise.reject(new Error("Service down")); + return get(...args); + }); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + expect( + await screen.findByText("Tools, resources, or prompts could not be loaded."), + ).toBeVisible(); + failReads = false; + markDiscovered(); + await user.click(screen.getByRole("button", { name: "Retry refresh" })); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + expect(authorize).not.toHaveBeenCalled(); + }); + + it("uses the shared status explanation and preserves compact table labels after failure", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Dismiss" }), + ); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + await user.click(within(panel).getByRole("button", { name: /Authorization required/ })); + expect( + await screen.findByText( + "You have not authorized this server. Authorization is per person.", + ), + ).toBeVisible(); + expect(within(screen.getByRole("table")).getAllByText("Auth")).toHaveLength(2); + }); + + it("keeps expired-token wording after dismissing an authorization failure", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth({ token: "expired" }); + authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Dismiss" }), + ); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + expect(within(panel).getByText("Authorization expired")).toBeVisible(); + expect(within(panel).queryByText("Authorization required")).not.toBeInTheDocument(); + }); + + it("does not offer authorization while the token status loads", async () => { + const user = userEvent.setup(); + setupOAuth(); + const get = vi.mocked(api.get).getMockImplementation()!; + vi.mocked(api.get).mockImplementation((...args) => + args[0].startsWith("/oauth/status") ? new Promise(() => {}) : get(...args), + ); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + const panel = screen.getByRole("region", { name: /Test Server 0/i }); + expect(within(panel).queryByRole("button", { name: /^Authorize/ })).not.toBeInTheDocument(); + }); + + it("shows one inline sentence and no toast when a panel refresh fails without a reason", async () => { + const user = userEvent.setup(); + setupOAuth({ token: "valid" }); + vi.spyOn(serversApi, "refreshTools").mockResolvedValue({ + success: false, + gatewayId: "server-0", + durationMs: 1, + refreshedAt: "2026-10-22T00:00:00Z", + }); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + await user.click(screen.getByRole("button", { name: "Refresh components" })); + expect( + await screen.findByText("Components could not be refreshed.", { exact: true }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Retry refresh" })).toBeVisible(); + expect(mockToastError).not.toHaveBeenCalled(); + }); + + it("uses only a toast for table refresh failures", async () => { + const user = userEvent.setup(); + setupOAuth(); + vi.spyOn(serversApi, "refreshTools").mockRejectedValue( + new ApiError(500, {}, "Server unavailable"), + ); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + await user.click(screen.getByRole("button", { name: "Actions for Test Server 0" })); + await user.click(await screen.findByRole("menuitem", { name: /^Refresh$/i })); + await waitFor(() => expect(mockToastError).toHaveBeenCalledOnce()); + expect(screen.queryByRole("button", { name: "Retry refresh" })).not.toBeInTheDocument(); + }); + + it("does not offer an inline retry for an in-progress refresh conflict", async () => { + const user = userEvent.setup(); + setupOAuth({ token: "valid" }); + vi.spyOn(serversApi, "refreshTools").mockRejectedValue( + new ApiError(409, {}, "Refresh in progress"), + ); + renderWithRouter(, "/app/servers?selected=server-0"); + await user.click(await screen.findByRole("tab", { name: "Components" })); + await user.click(screen.getByRole("button", { name: "Refresh components" })); + await waitFor(() => expect(mockToastError).toHaveBeenCalledOnce()); + expect(screen.queryByRole("button", { name: "Retry refresh" })).not.toBeInTheDocument(); + expect(screen.queryByText(/Components could not be refreshed/)).not.toBeInTheDocument(); + }); + + it("restores focus to the panel close control when a successful retry removes Authorize", async () => { + const user = userEvent.setup(); + const { authorize } = setupOAuth(); + authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied")); + renderWithRouter(); + await user.click(await screen.findByRole("button", { name: "Authorize Test Server 0" })); + await user.click( + within(await screen.findByRole("dialog")).getByRole("button", { name: "Try again" }), + ); + expect(await screen.findByText("Fresh tool")).toBeVisible(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Close MCP server details" })).toHaveFocus(), + ); + }); + }); }); diff --git a/src/pages/Servers.tsx b/src/pages/Servers.tsx index 7c6c2a78..4a78b4ab 100644 --- a/src/pages/Servers.tsx +++ b/src/pages/Servers.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { Plus } from "lucide-react"; import { toast } from "sonner"; import { MCPIcon } from "@/components/icons/MCPIcon"; @@ -12,19 +12,24 @@ import { useAuth } from "@/auth/useAuth"; import { useQuery } from "@/hooks/useQuery"; import { useLocalSearch } from "@/hooks/useLocalSearch"; import { ApiError, api } from "@/api/client"; -import { OAuthCancelledError, serversApi } from "@/api/servers"; +import { OAuthAuthorizationError, OAuthCancelledError, serversApi } from "@/api/servers"; import { useRouter } from "@/router"; import { extractApiErrorDetail, sanitizeError } from "@/utils/errors"; import type { MCPServer, ServersResponse } from "@/types/server"; import { Loading } from "@/components/ui/loading"; import { InlineNotification } from "@/components/ui/inline-notification"; import { useIntl } from "react-intl"; +import type { OAuthAttempt } from "@/api/oauth"; import { useOAuthStatuses } from "@/hooks/useOAuthStatuses"; import { isOAuthServer } from "@/lib/serverStatus"; // Pagination constants const DEFAULT_PAGE_SIZE = 10; +function oauthFailureReason(error: unknown): string { + return error instanceof OAuthAuthorizationError ? error.message : sanitizeError(error); +} + export function Servers() { const intl = useIntl(); const { path } = useRouter(); @@ -44,6 +49,15 @@ export function Servers() { const [loadingMore, setLoadingMore] = useState(false); const [selectedServerIdForDetails, setSelectedServerIdForDetails] = useState(null); const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = useState(false); + const [detailsInitialTab, setDetailsInitialTab] = useState<"tryit" | "components">("tryit"); + const [oauthAttempts, setOAuthAttempts] = useState>({}); + const pendingAuthorizationIds = useRef(new Set()); + const pendingRefreshIds = useRef(new Set()); + const refreshKinds = useRef(new Map()); + const [componentRevisions, setComponentRevisions] = useState>({}); + const [componentRefreshErrors, setComponentRefreshErrors] = useState< + Record + >({}); const selectedSearchServerId = useMemo(() => { const queryString = path.split("?")[1] ?? ""; return new URLSearchParams(queryString).get("selected")?.trim() || null; @@ -92,7 +106,9 @@ export function Servers() { () => allServers.find((s) => s.id === selectedServerIdForDetails) ?? null, [allServers, selectedServerIdForDetails], ); - const panelServer = detailsServer ?? selectedRow; + const panelServer = + detailsServer?.id === selectedServerIdForDetails ? detailsServer : selectedRow; + const panelAttempt = panelServer ? oauthAttempts[panelServer.id] : undefined; // Update servers on initial load useEffect(() => { @@ -105,8 +121,13 @@ export function Servers() { // Derive servers from accumulated list const servers = allServers; const oauthServerIds = useMemo( - () => servers.filter(isOAuthServer).map((server) => server.id), - [servers], + () => [ + ...new Set([ + ...servers.filter(isOAuthServer).map((server) => server.id), + ...(panelServer && isOAuthServer(panelServer) ? [panelServer.id] : []), + ]), + ], + [servers, panelServer], ); const { entries: oauthStatuses, @@ -233,46 +254,76 @@ export function Servers() { [patchServer], ); - const handleAuthorize = useCallback( + const discoverOAuthComponents = useCallback( async (id: string) => { + if (!canUpdateServer || pendingRefreshIds.current.has(id)) return; + pendingRefreshIds.current.add(id); + refreshKinds.current.set(id, "oauth"); + setRefreshingServerIds((prev) => new Set(prev).add(id)); + setComponentRefreshErrors((prev) => ({ ...prev, [id]: undefined })); try { - await serversApi.triggerOAuthAuthorization(id); + const result = await serversApi.fetchToolsAfterOAuth(id); + if (result.success === false) throw new Error(result.message); + setComponentRevisions((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); } catch (err) { - if (err instanceof OAuthCancelledError) return; - toast.error( - intl.formatMessage( - { id: "mcpServer.status.authorizationError" }, - { error: sanitizeError(err) }, - ), - ); - return; - } - - await reloadOAuthStatuses([id]); - - if (canUpdateServer) { - try { - await serversApi.fetchToolsAfterOAuth(id); - } catch (err) { - toast.warning(intl.formatMessage({ id: "mcpServer.status.componentRefreshError" }), { - description: sanitizeError(err), - }); - console.error("Failed to fetch components after authorization:", sanitizeError(err)); - } + setComponentRefreshErrors((prev) => ({ ...prev, [id]: { reason: sanitizeError(err) } })); + } finally { + pendingRefreshIds.current.delete(id); + setRefreshingServerIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); } + }, + [canUpdateServer], + ); + const handleAuthorize = useCallback( + async (id: string) => { + if (!canReadServers || pendingAuthorizationIds.current.has(id)) return; + pendingAuthorizationIds.current.add(id); + setSelectedServerIdForDetails(id); + setDetailsInitialTab("components"); + setIsDetailsDrawerOpen(true); + setOAuthAttempts((prev) => ({ ...prev, [id]: { state: "pending" } })); try { - await refetch(); + // Keep this call before any await: window.open requires the click gesture. + await serversApi.triggerOAuthAuthorization(id); } catch (err) { - console.error("Failed to refresh servers after authorization:", sanitizeError(err)); + setOAuthAttempts((prev) => { + const next = { ...prev }; + if (err instanceof OAuthCancelledError) delete next[id]; + else next[id] = { state: "failed", reason: oauthFailureReason(err), dialogOpen: true }; + return next; + }); + return; + } finally { + pendingAuthorizationIds.current.delete(id); } + setOAuthAttempts((prev) => ({ ...prev, [id]: { state: "succeeded" } })); + await Promise.allSettled([reloadOAuthStatuses([id]), discoverOAuthComponents(id), refetch()]); }, - [canUpdateServer, intl, refetch, reloadOAuthStatuses], + [canReadServers, refetch, reloadOAuthStatuses, discoverOAuthComponents], ); + const dismissOAuthFailure = () => { + if (!panelServer) return; + const id = panelServer.id; + setOAuthAttempts((prev) => { + const attempt = prev[id]; + return attempt?.state === "failed" + ? { ...prev, [id]: { ...attempt, dialogOpen: false } } + : prev; + }); + }; + const handleRefresh = useCallback( - async (id: string) => { - if (refreshingServerIds.has(id)) return; + async (id: string, source: "table" | "panel" = "table") => { + if (pendingRefreshIds.current.has(id)) return; + pendingRefreshIds.current.add(id); + refreshKinds.current.set(id, "manual"); + setComponentRefreshErrors((prev) => ({ ...prev, [id]: undefined })); setRefreshingServerIds((prev) => new Set(prev).add(id)); const name = allServers.find((s) => s.id === id)?.name ?? id; @@ -281,12 +332,21 @@ export function Servers() { const result = await serversApi.refreshTools(id); if (result && result.success === false) { - toast.error(intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), { - description: result.error ?? intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), - }); + if (source === "panel") { + setComponentRefreshErrors((prev) => ({ + ...prev, + [id]: { reason: result.error ?? undefined }, + })); + } else { + toast.error(intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), { + description: + result.error ?? intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), + }); + } return; } + setComponentRevisions((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 })); const toolsAdded = result?.toolsAdded ?? 0; const toolsUpdated = result?.toolsUpdated ?? 0; const toolsRemoved = result?.toolsRemoved ?? 0; @@ -356,13 +416,19 @@ export function Servers() { return; } const detail = err instanceof ApiError ? extractApiErrorDetail(err.body) : null; - toast.error(intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), { - description: intl.formatMessage( - { id: "mcpServer.refresh.error" }, - { name, detail: detail ?? sanitizeError(err) }, - ), - }); + const reason = detail ?? sanitizeError(err); + if (source === "panel") { + setComponentRefreshErrors((prev) => ({ ...prev, [id]: { reason } })); + } else { + toast.error(intl.formatMessage({ id: "mcpServer.refresh.errorTitle" }), { + description: intl.formatMessage( + { id: "mcpServer.refresh.error" }, + { name, detail: reason }, + ), + }); + } } finally { + pendingRefreshIds.current.delete(id); setRefreshingServerIds((prev) => { const next = new Set(prev); next.delete(id); @@ -370,10 +436,11 @@ export function Servers() { }); } }, - [refreshingServerIds, allServers, intl, patchServer], + [allServers, intl, patchServer], ); const handleViewDetails = useCallback((id: string) => { + setDetailsInitialTab("tryit"); setSelectedServerIdForDetails(id); setIsDetailsDrawerOpen(true); }, []); @@ -528,6 +595,13 @@ export function Servers() { onRefresh={canUpdateServer ? handleRefresh : undefined} refreshingServerIds={refreshingServerIds} oauthStatuses={oauthStatuses} + authorizingServerIds={ + new Set( + Object.keys(oauthAttempts).filter( + (id) => oauthAttempts[id].state === "pending", + ), + ) + } onAuthorize={canReadServers ? handleAuthorize : undefined} onRetryOAuthStatus={(id) => void retryOAuthStatus(id)} /> @@ -620,7 +694,24 @@ export function Servers() { /> handleRefresh(panelServer.id, "panel") : undefined + } + onRetryDiscovery={ + canUpdateServer && panelServer + ? () => + refreshKinds.current.get(panelServer.id) === "manual" + ? handleRefresh(panelServer.id, "panel") + : discoverOAuthComponents(panelServer.id) + : undefined + } error={detailsError} open={isDetailsDrawerOpen} onClose={() => handleCloseDetails(false)} @@ -631,6 +722,40 @@ export function Servers() { } onRetryOAuthStatus={panelServer ? () => void retryOAuthStatus(panelServer.id) : undefined} /> + { + if (!open) dismissOAuthFailure(); + }} + title={intl.formatMessage( + { id: "mcpServer.oauth.failureTitle" }, + { name: panelServer?.name ?? "" }, + )} + description={ +

+ {panelAttempt?.state === "failed" ? panelAttempt.reason : ""} +

+ } + confirmLabel={intl.formatMessage({ id: "mcpServer.oauth.tryAgain" })} + cancelLabel={intl.formatMessage({ id: "mcpServer.oauth.dismiss" })} + closeOnConfirm={false} + onConfirm={async () => { + if (panelServer) await handleAuthorize(panelServer.id); + }} + onCloseAutoFocus={(event) => { + const authorize = document.getElementById( + `oauth-authorize-${panelServer?.id}`, + ) as HTMLButtonElement | null; + const target = + authorize && !authorize.disabled + ? authorize + : document.getElementById(`mcp-server-close-${panelServer?.id}`); + if (target) { + event.preventDefault(); + target.focus(); + } + }} + />
); }