diff --git a/e2e/mcp-oauth-outcomes.spec.ts b/e2e/mcp-oauth-outcomes.spec.ts
new file mode 100644
index 00000000..6e4def46
--- /dev/null
+++ b/e2e/mcp-oauth-outcomes.spec.ts
@@ -0,0 +1,164 @@
+import { test, expect } from "./fixtures/auth";
+import { APP } from "./utils/paths";
+
+for (const fromDetails of [false, true]) {
+ test(`MCP server OAuth from ${fromDetails ? "details" : "row"}: 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 panel = page.getByRole("region", { name: /MCP Server details: OAuth outcomes server/ });
+ if (fromDetails) {
+ await page.getByRole("button", { name: "Actions for OAuth outcomes server" }).click();
+ await page.getByRole("menuitem", { name: "View details" }).click();
+ await panel.getByRole("tab", { name: "Components" }).click();
+ }
+ const trigger = (fromDetails ? panel : page).getByRole("button", {
+ name: "Authorize OAuth outcomes server",
+ });
+ const firstPopupPromise = context.waitForEvent("page");
+ await trigger.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();
+ if (fromDetails) await expect(panel).toBeVisible();
+ else await expect(panel).not.toBeVisible();
+ await expect(trigger).toBeFocused();
+
+ const retryPopupPromise = context.waitForEvent("page");
+ await trigger.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(trigger).not.toBeVisible();
+ if (!fromDetails) {
+ await expect(panel).not.toBeVisible();
+ await page.getByRole("button", { name: "Actions for OAuth outcomes server" }).click();
+ await page.getByRole("menuitem", { name: "View details" }).click();
+ await panel.getByRole("tab", { name: "Components" }).click();
+ }
+ await expect(panel.getByText("Active", { exact: true })).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..6ea21c38 100644
--- a/src/components/servers/MCPServerDetailsPanel.tsx
+++ b/src/components/servers/MCPServerDetailsPanel.tsx
@@ -8,6 +8,7 @@ import {
MessageSquareCode,
PanelRightClose,
Search,
+ RefreshCw,
Server,
Wrench,
} from "lucide-react";
@@ -27,7 +28,8 @@ import { TruncatedMiddleText } from "@/components/ui/truncated-middle-text";
import { cn } from "@/lib/utils";
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,46 @@ 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";
// Filter components based on active tab and search query
const visibleComponents = useMemo((): ComponentWithType[] => {
@@ -387,6 +427,39 @@ export function MCPServerDetailsPanel({
+
+ {componentRefreshError && (
+ void onRetryDiscovery(),
+ }
+ : undefined
+ }
+ />
+ )}
+ {componentReadError && (
+
+ )}
+
+ {onRefresh && (
+
+ )}
-
+
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..917146c3 100644
--- a/src/i18n/locales/en-US/mcpServer.json
+++ b/src/i18n/locales/en-US/mcpServer.json
@@ -381,5 +381,13 @@
"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.components.refresh": "Refresh components",
+ "mcpServer.components.refreshing": "Refreshing components…",
+ "mcpServer.components.discoveryError": "Components could not be refreshed.",
+ "mcpServer.components.retryRefresh": "Retry refresh",
+ "mcpServer.components.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..7fe16912 100644
--- a/src/i18n/locales/es-ES/mcpServer.json
+++ b/src/i18n/locales/es-ES/mcpServer.json
@@ -381,5 +381,13 @@
"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.components.refresh": "Actualizar componentes",
+ "mcpServer.components.refreshing": "Actualizando componentes…",
+ "mcpServer.components.discoveryError": "No se pudieron actualizar los componentes.",
+ "mcpServer.components.retryRefresh": "Reintentar actualización",
+ "mcpServer.components.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..aa8e5e26 100644
--- a/src/i18n/locales/pt-BR/mcpServer.json
+++ b/src/i18n/locales/pt-BR/mcpServer.json
@@ -381,5 +381,13 @@
"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.components.refresh": "Atualizar componentes",
+ "mcpServer.components.refreshing": "Atualizando componentes…",
+ "mcpServer.components.discoveryError": "Não foi possível atualizar os componentes.",
+ "mcpServer.components.retryRefresh": "Tentar atualizar novamente",
+ "mcpServer.components.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..ad2f0448 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,526 @@ 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("authorizes from the row without opening details or reloading the table", async () => {
+ const user = userEvent.setup();
+ const { discover } = setupOAuth();
+ renderWithRouter();
+ const chip = await screen.findByRole("button", { name: "Authorize Test Server 0" });
+ const listCalls = vi
+ .mocked(api.get)
+ .mock.calls.filter(([path]) => path.startsWith("/v1/mcp-servers?")).length;
+ await user.click(chip);
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("button", { name: "Authorize Test Server 0" }),
+ ).not.toBeInTheDocument(),
+ );
+ expect(screen.queryByRole("region", { name: /Test Server 0/i })).not.toBeInTheDocument();
+ expect(screen.queryByText("OAuth authorization successful.")).not.toBeInTheDocument();
+ expect(discover).toHaveBeenCalledWith("server-0");
+ expect(
+ vi.mocked(api.get).mock.calls.filter(([path]) => path.startsWith("/v1/mcp-servers?"))
+ .length,
+ ).toBe(listCalls);
+ await user.click(screen.getByRole("button", { name: "Actions for Test Server 0" }));
+ await user.click(await screen.findByRole("menuitem", { name: "View details" }));
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ expect(await screen.findByText("Fresh tool")).toBeVisible();
+ expect(await screen.findByText("Fresh resource")).toBeVisible();
+ expect(await screen.findByText("Fresh prompt")).toBeVisible();
+ });
+
+ 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(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).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(within(panel).getAllByText("Authorization required")).toHaveLength(1);
+ await user.click(within(panel).getByRole("button", { name: "Authorize Test Server 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(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).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(
+ within(details).getByRole("button", { name: "Authorize Test 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" }),
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("button", { name: "Authorize Test Server 0" }),
+ ).not.toBeInTheDocument(),
+ );
+ 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(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).findByRole("button", { name: "Authorize Test Server 0" }),
+ );
+ expect(await within(details).findByText("Active")).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" }));
+ await waitFor(() =>
+ expect(
+ screen.queryByRole("button", { name: "Authorize Test Server 0" }),
+ ).not.toBeInTheDocument(),
+ );
+ 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.queryByRole("region", { name: /Test Server 0/i })).not.toBeInTheDocument();
+ 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(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).findByRole("button", { name: "Authorize Test Server 0" }),
+ );
+ const panel = screen.getByRole("region", { name: /Test Server 0/i });
+ expect(within(panel).getByRole("button", { name: "Authorize Test Server 0" })).toBeDisabled();
+ await user.click(within(panel).getByRole("button", { name: "Authorize Test Server 0" }));
+ 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());
+ const failure = await screen.findByRole("dialog", {
+ name: "OAuth authorization failed for Test Server 0",
+ });
+ expect(within(failure).getByText("Failure from server 0")).toBeVisible();
+ await user.click(within(failure).getByRole("button", { name: "Dismiss" }));
+ expect(screen.getByRole("region", { name: /Test Server 1/i })).toBeVisible();
+ });
+
+ it("keeps Refresh enabled and prevents duplicate authorization while OAuth is pending", async () => {
+ const user = userEvent.setup();
+ const { authorize } = setupOAuth();
+ let cancel!: () => void;
+ authorize.mockImplementationOnce(
+ () =>
+ new Promise((_, reject) => {
+ cancel = () => reject(new OAuthCancelledError());
+ }),
+ );
+ const refresh = vi.spyOn(serversApi, "refreshTools").mockResolvedValue({
+ success: true,
+ gatewayId: "server-0",
+ durationMs: 1,
+ refreshedAt: "2026-10-27T00:00:00Z",
+ });
+ renderWithRouter(, "/app/servers?selected=server-0");
+ const panel = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ const chip = await within(panel).findByRole("button", { name: "Authorize Test Server 0" });
+ expect(within(panel).getAllByRole("button", { name: /^Authorize/ })).toHaveLength(1);
+ await user.click(chip);
+ expect(chip).toBeDisabled();
+ await user.click(chip);
+ expect(authorize).toHaveBeenCalledTimes(1);
+ const refreshButton = within(panel).getByRole("button", { name: "Refresh components" });
+ expect(refreshButton).toBeEnabled();
+ await user.click(refreshButton);
+ expect(refresh).toHaveBeenCalledWith("server-0");
+ await act(async () => cancel());
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ expect(panel).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("restores focus to the row chip and preserves compact labels after failure", async () => {
+ const user = userEvent.setup();
+ const { authorize } = setupOAuth();
+ authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied"));
+ renderWithRouter();
+ const chip = await screen.findByRole("button", { name: "Authorize Test Server 0" });
+ await user.click(chip);
+ await user.click(
+ within(await screen.findByRole("dialog")).getByRole("button", { name: "Dismiss" }),
+ );
+ await waitFor(() => expect(chip).toHaveFocus());
+ expect(screen.queryByRole("region", { name: /Test Server 0/i })).not.toBeInTheDocument();
+ 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(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).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 status chip when retry changes authorization to Active", async () => {
+ const user = userEvent.setup();
+ const { authorize } = setupOAuth();
+ authorize.mockRejectedValueOnce(new OAuthAuthorizationError("access_denied"));
+ renderWithRouter(, "/app/servers?selected=server-0");
+ const details = await screen.findByRole("region", { name: /Test Server 0/i });
+ await user.click(await screen.findByRole("tab", { name: "Components" }));
+ await user.click(
+ await within(details).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(
+ within(details).getByRole("button", { name: /Test Server 0.*Active/ }),
+ ).toHaveFocus(),
+ );
+ });
+ });
});
diff --git a/src/pages/Servers.tsx b/src/pages/Servers.tsx
index 7c6c2a78..0ef48525 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,19 @@ export function Servers() {
const [loadingMore, setLoadingMore] = useState(false);
const [selectedServerIdForDetails, setSelectedServerIdForDetails] = useState(null);
const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = useState(false);
+ const [oauthAttempts, setOAuthAttempts] = useState>({});
+ const [oauthDialogServerId, setOAuthDialogServerId] = useState(null);
+ const authorizationTriggers = useRef(
+ new Map(),
+ );
+ const dialogAttempt = oauthDialogServerId ? oauthAttempts[oauthDialogServerId] : undefined;
+ 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 +110,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 +125,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 +258,100 @@ export function Servers() {
[patchServer],
);
+ 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 {
+ 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) {
+ 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);
+ if (
+ !document.activeElement?.closest('[role="dialog"]') ||
+ !authorizationTriggers.current.has(id)
+ ) {
+ authorizationTriggers.current.set(id, {
+ element: document.activeElement instanceof HTMLElement ? document.activeElement : null,
+ container: document.activeElement?.closest("[data-server-status]") ?? null,
+ name: allServers.find((server) => server.id === id)?.name ?? panelServer?.name ?? id,
+ });
+ }
+ setOAuthAttempts((prev) => ({ ...prev, [id]: { state: "pending" } }));
try {
+ // Keep this call before any await: window.open requires the click gesture.
await serversApi.triggerOAuthAuthorization(id);
} catch (err) {
- if (err instanceof OAuthCancelledError) return;
- toast.error(
- intl.formatMessage(
- { id: "mcpServer.status.authorizationError" },
- { error: sanitizeError(err) },
- ),
- );
+ if (!(err instanceof OAuthCancelledError)) setOAuthDialogServerId(id);
+ 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);
}
-
- 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));
- }
- }
-
+ setOAuthAttempts((prev) => ({ ...prev, [id]: { state: "succeeded" } }));
+ await Promise.allSettled([reloadOAuthStatuses([id]), discoverOAuthComponents(id)]);
try {
- await refetch();
+ serversApi.invalidateGetCache(id);
+ const updated = await serversApi.get(id);
+ patchServer(updated);
+ setDetailsServer((prev) => (prev?.id === id ? updated : prev));
} catch (err) {
- console.error("Failed to refresh servers after authorization:", sanitizeError(err));
+ console.error("Failed to refresh server after authorization:", sanitizeError(err));
}
},
- [canUpdateServer, intl, refetch, reloadOAuthStatuses],
+ [
+ canReadServers,
+ reloadOAuthStatuses,
+ discoverOAuthComponents,
+ allServers,
+ panelServer,
+ patchServer,
+ setDetailsServer,
+ ],
);
+ const dismissOAuthFailure = () => {
+ if (!oauthDialogServerId) return;
+ const id = oauthDialogServerId;
+ 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 +360,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 +444,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,7 +464,7 @@ export function Servers() {
});
}
},
- [refreshingServerIds, allServers, intl, patchServer],
+ [allServers, intl, patchServer],
);
const handleViewDetails = useCallback((id: string) => {
@@ -528,6 +622,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 +721,23 @@ 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 +748,45 @@ export function Servers() {
}
onRetryOAuthStatus={panelServer ? () => void retryOAuthStatus(panelServer.id) : undefined}
/>
+ {
+ if (!open) dismissOAuthFailure();
+ }}
+ title={intl.formatMessage(
+ { id: "mcpServer.oauth.failureTitle" },
+ {
+ name: oauthDialogServerId
+ ? (authorizationTriggers.current.get(oauthDialogServerId)?.name ?? "")
+ : "",
+ },
+ )}
+ description={
+
+ {dialogAttempt?.state === "failed" ? dialogAttempt.reason : ""}
+
+ }
+ confirmLabel={intl.formatMessage({ id: "mcpServer.oauth.tryAgain" })}
+ cancelLabel={intl.formatMessage({ id: "mcpServer.oauth.dismiss" })}
+ closeOnConfirm={false}
+ onConfirm={async () => {
+ if (oauthDialogServerId) await handleAuthorize(oauthDialogServerId);
+ }}
+ onCloseAutoFocus={(event) => {
+ const origin = oauthDialogServerId
+ ? authorizationTriggers.current.get(oauthDialogServerId)
+ : undefined;
+ const trigger = origin?.element;
+ const target =
+ trigger?.isConnected && !trigger.matches(":disabled")
+ ? trigger
+ : origin?.container?.querySelector("button:not(:disabled)");
+ if (target) {
+ event.preventDefault();
+ target.focus();
+ }
+ }}
+ />
);
}