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 && (
+
+ )}
+
);
}