From dbf36f0e00c57eace09fbb7aef76c5f80c5c737d Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Tue, 3 Nov 2026 12:06:45 +0000 Subject: [PATCH 1/4] feat: add shared OAuth connection validation Signed-off-by: Vishu Bhatnagar --- server/test/oauth-callback.test.ts | 20 ++ src/api/oauth.ts | 9 +- src/api/servers.test.ts | 64 ++++++- src/api/servers.ts | 32 +++- .../OAuthConnectionValidationPanel.test.tsx | 102 ++++++++++ .../OAuthConnectionValidationPanel.tsx | 106 +++++++++++ .../useOAuthConnectionValidation.test.ts | 174 ++++++++++++++++++ src/hooks/useOAuthConnectionValidation.ts | 105 +++++++++++ src/i18n/locales/en-US/mcpServer.json | 49 ++++- src/i18n/locales/es-ES/mcpServer.json | 49 ++++- src/i18n/locales/pt-BR/mcpServer.json | 49 ++++- src/lib/oauthConnectionValidation.ts | 77 ++++++++ 12 files changed, 829 insertions(+), 7 deletions(-) create mode 100644 src/components/servers/OAuthConnectionValidationPanel.test.tsx create mode 100644 src/components/servers/OAuthConnectionValidationPanel.tsx create mode 100644 src/hooks/useOAuthConnectionValidation.test.ts create mode 100644 src/hooks/useOAuthConnectionValidation.ts create mode 100644 src/lib/oauthConnectionValidation.ts diff --git a/server/test/oauth-callback.test.ts b/server/test/oauth-callback.test.ts index ca75adc6..8355b9b9 100644 --- a/server/test/oauth-callback.test.ts +++ b/server/test/oauth-callback.test.ts @@ -39,6 +39,14 @@ beforeAll(async () => { return; } + if (req.url?.startsWith("/oauth/callback?structured-error")) { + res.writeHead(400, { "content-type": "text/html" }); + res.end( + '', + ); + return; + } + // Mirrors mcpgateway's oauth_callback popup branch: an HTML page whose // inline script posts the result to window.opener and closes itself. res.writeHead(200, { @@ -65,6 +73,18 @@ async function buildApp() { } describe("GET /oauth/callback", () => { + it("preserves safe structured backend callback errors", async () => { + const app = await buildApp(); + const response = await app.fastify.inject({ + method: "GET", + url: "/oauth/callback?structured-error=1&state=popup.xyz", + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain('"errorCode":"token_url_unreachable"'); + expect(response.body).toContain('"failedStage":"token_exchange"'); + expect(response.body).toContain('"reauthorizationRequired":false'); + }); + it("proxies with no session cookie required", async () => { const app = await buildApp(); diff --git a/src/api/oauth.ts b/src/api/oauth.ts index 375b3373..edca0e67 100644 --- a/src/api/oauth.ts +++ b/src/api/oauth.ts @@ -106,4 +106,11 @@ export async function getOAuthStatuses( export type OAuthAttempt = | { state: "pending" } | { state: "succeeded" } - | { state: "failed"; reason: string; dialogOpen: boolean }; + | { + state: "failed"; + reason: string; + dialogOpen: boolean; + errorCode?: string; + failedStage?: string; + reauthorizationRequired?: boolean; + }; diff --git a/src/api/servers.test.ts b/src/api/servers.test.ts index cfbb5688..d7c4e40d 100644 --- a/src/api/servers.test.ts +++ b/src/api/servers.test.ts @@ -1,6 +1,6 @@ import { waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { serversApi } from "./servers"; +import { OAuthAuthorizationError, serversApi } from "./servers"; import { setCsrfToken } from "./client"; import type { GatewayTestRequest, GatewayHandshakeRequest } from "@/generated/types"; @@ -33,6 +33,68 @@ describe("serversApi", () => { vi.restoreAllMocks(); }); + describe("post-authorization connection validation", () => { + it("sends only the saved gateway identity, URL and strict mode", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ success: false, latencyMs: 1, errorCode: "insufficient_scope" }), + { status: 200 }, + ), + ); + const controller = new AbortController(); + const result = await serversApi.testOAuthConnection( + { gatewayId: "gw-1", baseUrl: "https://saved.example/mcp" }, + controller.signal, + ); + expect(result?.errorCode).toBe("insufficient_scope"); + expect(mockFetch).toHaveBeenCalledExactlyOnceWith( + expect.stringContaining("/v1/mcp-servers/test-handshake"), + expect.objectContaining({ + method: "POST", + signal: controller.signal, + body: JSON.stringify({ + baseUrl: "https://saved.example/mcp", + gatewayId: "gw-1", + validationMode: "post_authorization", + }), + }), + ); + }); + + it("retains structured callback fields and remains compatible with legacy errors", async () => { + const authWindow = mockAuthPopup(); + vi.spyOn(window, "open").mockReturnValue(authWindow); + mockFetch.mockResolvedValueOnce(nonceResponse()); + const promise = serversApi.triggerOAuthAuthorization("gw-1"); + const failure = expect(promise).rejects.toMatchObject({ + code: "oauth_error", + errorCode: "token_url_unreachable", + failedStage: "token_exchange", + reauthorizationRequired: false, + }); + window.dispatchEvent( + new MessageEvent("message", { + source: authWindow, + data: { + type: "oauth_callback", + status: "error", + error: "oauth_error", + errorCode: "token_url_unreachable", + failedStage: "token_exchange", + reauthorizationRequired: false, + }, + }), + ); + await failure; + expect(new OAuthAuthorizationError("access_denied").errorCode).toBe("access_denied"); + expect( + new OAuthAuthorizationError("unknown", "text", { + reauthorizationRequired: "yes" as unknown as boolean, + }).reauthorizationRequired, + ).toBe(false); + }); + }); + describe("toggleEnabled", () => { it("should activate a server with CSRF token", async () => { const mockResponse = new Response( diff --git a/src/api/servers.ts b/src/api/servers.ts index 2c53caed..8b9d827a 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -14,6 +14,10 @@ import type { GatewayTestRequest, GatewayTestResponse, } from "@/generated/types"; +import type { + OAuthConnectionValidationRequest, + OAuthConnectionValidationResponse, +} from "./contracts/oauthConnectionValidation"; import { validateServerId } from "@/utils/serverId"; const serverByIdRequestCache = new Map>(); @@ -33,14 +37,20 @@ function providerErrorText(value: unknown): string | undefined { export class OAuthAuthorizationError extends Error { readonly code?: string; readonly description?: string; + readonly errorCode?: string; + readonly failedStage?: string; + readonly reauthorizationRequired: boolean; - constructor(code?: unknown, description?: unknown) { + constructor(code?: unknown, description?: unknown, details?: Partial) { const safeCode = providerErrorText(code); const safeDescription = providerErrorText(description); super(safeDescription || safeCode || "OAuth authorization failed"); this.name = "OAuthAuthorizationError"; this.code = safeCode; this.description = safeDescription; + this.errorCode = providerErrorText(details?.errorCode) ?? safeCode; + this.failedStage = providerErrorText(details?.failedStage); + this.reauthorizationRequired = details?.reauthorizationRequired === true; } } @@ -204,6 +214,21 @@ export const serversApi = { return api.post("/v1/mcp-servers/test-handshake", request, { signal }); }, + testOAuthConnection: ( + { gatewayId, baseUrl }: { gatewayId: string; baseUrl: string }, + signal?: AbortSignal, + ): Promise => { + return api.post( + "/v1/mcp-servers/test-handshake", + { + baseUrl, + gatewayId: validateServerId(gatewayId), + validationMode: "post_authorization", + } satisfies OAuthConnectionValidationRequest, + { signal }, + ); + }, + /** * Toggle the enabled state of an MCP server (activate/deactivate) */ @@ -297,7 +322,7 @@ export const serversApi = { if (data.status === "success") { resolve(data); } else { - reject(new OAuthAuthorizationError(data.error, data.errorDescription)); + reject(new OAuthAuthorizationError(data.error, data.errorDescription, data)); } }; @@ -343,4 +368,7 @@ export interface OAuthCallbackResult { gatewayName?: string; error?: string; errorDescription?: string; + errorCode?: string; + failedStage?: string; + reauthorizationRequired?: boolean; } diff --git a/src/components/servers/OAuthConnectionValidationPanel.test.tsx b/src/components/servers/OAuthConnectionValidationPanel.test.tsx new file mode 100644 index 00000000..91f3be4c --- /dev/null +++ b/src/components/servers/OAuthConnectionValidationPanel.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render } from "@/test/test-utils"; +import { OAuthConnectionValidationPanel } from "./OAuthConnectionValidationPanel"; +import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation"; + +const props = { name: "Example", onTest: vi.fn(), onCancel: vi.fn(), onAuthorize: vi.fn() }; +const success: OAuthConnectionValidationState = { + state: "success", + testedAt: Date.now(), + result: { + success: true, + latencyMs: 2, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, + }, +}; + +describe("OAuthConnectionValidationPanel", () => { + it("waits for a click before testing", async () => { + render(); + expect(screen.queryByRole("status", { busy: true })).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Test connection" })); + expect(props.onTest).toHaveBeenCalledOnce(); + }); + + it("renders zero tools, stage text, test time and Retest", () => { + render(); + expect(screen.getByText("0 tools")).toBeVisible(); + expect(screen.getByText("Token: Passed")).toBeVisible(); + expect(screen.getByText("MCP negotiation: Passed")).toBeVisible(); + expect(screen.getByText("Tools access: Passed")).toBeVisible(); + expect(screen.getByText(/Tested at/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Retest" })).toBeEnabled(); + }); + + it("preserves first-page lower bounds", () => { + render( + , + ); + expect(screen.getByText("2+ tools")).toBeVisible(); + }); + + it("announces a cancellable running test", async () => { + render(); + expect(screen.getByRole("status", { busy: true })).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(props.onCancel).toHaveBeenCalled(); + }); + + it.each(["token", "negotiation", "tools_list"] as const)( + "shows a failed %s stage and reauthorization", + async (stage) => { + render( + , + ); + expect(screen.getByText(/stored token lacks the required scope/)).toBeVisible(); + expect(screen.getByText(/: Failed$/)).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Authorize again" })); + expect(props.onAuthorize).toHaveBeenCalled(); + }, + ); + + it("uses generic guidance for unknown errors without displaying upstream secrets", () => { + render( + , + ); + expect(screen.getByText(/Check the configuration and try again/)).toBeVisible(); + expect(screen.queryByText(/unsafe token|secret-provider-code/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/servers/OAuthConnectionValidationPanel.tsx b/src/components/servers/OAuthConnectionValidationPanel.tsx new file mode 100644 index 00000000..b265d68f --- /dev/null +++ b/src/components/servers/OAuthConnectionValidationPanel.tsx @@ -0,0 +1,106 @@ +import { CheckCircle2, CircleMinus, CircleX, Loader2 } from "lucide-react"; +import { useIntl } from "react-intl"; +import { Button } from "@/components/ui/button"; +import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation"; +import { oauthErrorMessage, VALIDATION_STAGES } from "@/lib/oauthConnectionValidation"; + +export interface OAuthConnectionValidationPanelProps { + name: string; + entry?: OAuthConnectionValidationState; + onTest: () => void; + onCancel: () => void; + onAuthorize?: () => void; + actionsOnly?: boolean; + resultOnly?: boolean; +} + +export function OAuthConnectionValidationPanel({ + name, + entry, + onTest, + onCancel, + onAuthorize, + actionsOnly = false, + resultOnly = false, +}: OAuthConnectionValidationPanelProps) { + const intl = useIntl(); + const message = (key: string) => intl.formatMessage({ id: `mcpServer.oauthValidation.${key}` }); + const running = entry?.state === "running"; + const result = entry?.result; + return ( +
+ {!resultOnly && ( +
+ + {running && ( + + )} + {entry?.state === "failure" && result?.reauthorizationRequired && onAuthorize && ( + + )} +
+ )} + {!actionsOnly && entry && ( +
+

+ {message(running ? "testing" : entry.state === "success" ? "success" : "failure")} +

+ {result?.stages && ( +
    + {VALIDATION_STAGES.map((stage) => { + const status = result.stages?.[stage] ?? "skipped"; + const Icon = + status === "ok" ? CheckCircle2 : status === "failed" ? CircleX : CircleMinus; + return ( +
  • +
  • + ); + })} +
+ )} + {entry.state === "failure" &&

{oauthErrorMessage(intl, entry.errorCode)}

} + {entry.state === "success" && result?.componentCounts?.tools !== undefined && ( +

+ {intl.formatMessage( + { + id: `mcpServer.testConnection.${result.countsPartial ? "countsPartial" : "counts"}.tools`, + }, + { count: result.componentCounts.tools }, + )} +

+ )} + {entry.testedAt && ( +

+ {intl.formatMessage( + { id: "mcpServer.oauthValidation.testedAt" }, + { time: intl.formatTime(entry.testedAt) }, + )} +

+ )} +
+ )} +
+ ); +} diff --git a/src/hooks/useOAuthConnectionValidation.test.ts b/src/hooks/useOAuthConnectionValidation.test.ts new file mode 100644 index 00000000..36fbf80d --- /dev/null +++ b/src/hooks/useOAuthConnectionValidation.test.ts @@ -0,0 +1,174 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; +import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthConnectionValidation"; +import { useOAuthConnectionValidation } from "./useOAuthConnectionValidation"; + +vi.mock("@/api/servers", () => ({ serversApi: { testOAuthConnection: vi.fn() } })); +const success: OAuthConnectionValidationResponse = { + success: true, + latencyMs: 5, + stages: { token: "ok", negotiation: "ok", tools_list: "ok" }, + componentCounts: { tools: 0 }, +}; +const target = { gatewayId: "gw-1", baseUrl: "https://saved.example/mcp" }; +const mockTest = vi.mocked(serversApi.testOAuthConnection); + +beforeEach(() => { + mockTest.mockReset(); +}); + +describe("useOAuthConnectionValidation", () => { + it("only runs on demand and accepts zero tools without any mutation calls", async () => { + mockTest.mockResolvedValue(success); + const { result } = renderHook(() => useOAuthConnectionValidation()); + expect(mockTest).not.toHaveBeenCalled(); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledExactlyOnceWith(target, expect.any(AbortSignal)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "success", + result: success, + testedAt: expect.any(Number), + }); + }); + + it.each([ + { success: true, latencyMs: 1 }, + { ...success, stages: { token: "ok", negotiation: "ok" } }, + null, + ] as OAuthConnectionValidationResponse[])( + "rejects responses without complete stage evidence: %j", + async (response) => { + mockTest.mockResolvedValue(response); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + errorCode: "incompatible", + }); + }, + ); + + it.each(["failed", "skipped", "unsupported"] as const)( + "does not report success for a %s listing", + async (status) => { + mockTest.mockResolvedValue({ + ...success!, + stages: { token: "ok", negotiation: "ok", tools_list: status }, + }); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"].state).toBe("failure"); + }, + ); + + it("preserves HTTP-200 validation failures and reauthorization guidance", async () => { + const response: OAuthConnectionValidationResponse = { + ...success!, + success: false, + stages: { token: "ok", negotiation: "ok", tools_list: "failed" }, + errorCode: "insufficient_scope", + failedStage: "tools_list", + reauthorizationRequired: true, + }; + mockTest.mockResolvedValue(response); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + result: response, + errorCode: "insufficient_scope", + }); + }); + + it.each([ + [403, "permission_denied"], + [422, "request_invalid"], + [429, "rate_limited"], + [500, "generic"], + ])("maps HTTP %s without automatic retry", async (status, code) => { + mockTest.mockRejectedValue(new ApiError(status as number, null, "unsafe upstream body")); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ state: "failure", errorCode: code }); + expect(mockTest).toHaveBeenCalledTimes(1); + }); + + it("deduplicates, cancels and ignores late responses after a replacement test", async () => { + let finish!: (response: OAuthConnectionValidationResponse) => void; + mockTest.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useOAuthConnectionValidation()); + let first!: Promise; + act(() => { + first = result.current.test("gw-1", target); + }); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledTimes(1); + const signal = mockTest.mock.calls[0][1]!; + act(() => result.current.reset("gw-1")); + expect(signal.aborted).toBe(true); + mockTest.mockResolvedValue(success); + await act(() => result.current.test("gw-1", target)); + await act(async () => { + finish(null); + await first; + }); + expect(result.current.entries["gw-1"].state).toBe("success"); + }); + + it("resets on configuration change and aborts on permission loss and unmount", async () => { + mockTest.mockImplementation(() => new Promise(() => {})); + const { result, rerender, unmount } = renderHook( + ({ enabled, key }) => useOAuthConnectionValidation(enabled, { "gw-1": key }), + { initialProps: { enabled: true, key: "old" } }, + ); + act(() => { + void result.current.test("gw-1", target); + }); + rerender({ enabled: true, key: "new" }); + expect(mockTest.mock.calls[0][1]?.aborted).toBe(true); + expect(result.current.entries).toEqual({}); + act(() => { + void result.current.test("gw-1", target); + }); + rerender({ enabled: false, key: "new" }); + expect(mockTest.mock.calls[1][1]?.aborted).toBe(true); + expect(result.current.entries).toEqual({}); + await act(() => result.current.test("gw-1", target)); + expect(mockTest).toHaveBeenCalledTimes(2); + rerender({ enabled: true, key: "new" }); + act(() => { + void result.current.test("gw-1", target); + }); + unmount(); + expect(mockTest.mock.calls[2][1]?.aborted).toBe(true); + }); + + it("cancels while resolving the saved gateway before any handshake request", async () => { + let finish!: (value: typeof target) => void; + const resolver = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const { result } = renderHook(() => useOAuthConnectionValidation()); + let request!: Promise; + act(() => { + request = result.current.test("gw-1", resolver); + }); + act(() => result.current.reset("gw-1")); + await act(async () => { + finish(target); + await request; + }); + expect(mockTest).not.toHaveBeenCalled(); + expect(result.current.entries).toEqual({}); + }); +}); diff --git a/src/hooks/useOAuthConnectionValidation.ts b/src/hooks/useOAuthConnectionValidation.ts new file mode 100644 index 00000000..8b378972 --- /dev/null +++ b/src/hooks/useOAuthConnectionValidation.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; +import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthConnectionValidation"; +import { hasValidationStages, validationSucceeded } from "@/lib/oauthConnectionValidation"; + +export interface OAuthConnectionValidationState { + state: "running" | "success" | "failure"; + result?: OAuthConnectionValidationResponse; + errorCode?: string; + testedAt?: number; +} + +type Target = { gatewayId: string; baseUrl: string }; + +export function useOAuthConnectionValidation(enabled = true, targets?: Record) { + const [entries, setEntries] = useState>({}); + const requests = useRef(new Map()); + + const reset = useCallback((id: string) => { + requests.current.get(id)?.abort(); + requests.current.delete(id); + setEntries((previous) => { + if (!previous[id]) return previous; + const next = { ...previous }; + delete next[id]; + return next; + }); + }, []); + + const previousTargets = useRef>({}); + const targetsKey = JSON.stringify(targets); + useEffect(() => { + if (!targetsKey) return; + const current = JSON.parse(targetsKey) as Record; + Object.entries(previousTargets.current).forEach(([id, key]) => { + if (current[id] !== key) reset(id); + }); + previousTargets.current = current; + }, [targetsKey, reset]); + + const abortAll = useCallback(() => { + requests.current.forEach((controller) => controller.abort()); + requests.current.clear(); + }, []); + + useEffect(() => abortAll, [abortAll]); + useEffect(() => { + if (!enabled) { + abortAll(); + setEntries({}); + } + }, [enabled, abortAll]); + + const test = useCallback( + async (id: string, target: Target | ((signal: AbortSignal) => Promise)) => { + if (!enabled || requests.current.has(id)) return; + const controller = new AbortController(); + requests.current.set(id, controller); + const isCurrent = () => !controller.signal.aborted && requests.current.get(id) === controller; + setEntries((previous) => ({ ...previous, [id]: { state: "running" } })); + try { + const resolved = typeof target === "function" ? await target(controller.signal) : target; + if (!isCurrent()) return; + if (resolved.gatewayId !== id) throw new Error("Gateway mismatch"); + const result = await serversApi.testOAuthConnection(resolved, controller.signal); + if (!isCurrent()) return; + setEntries((previous) => ({ + ...previous, + [id]: { + state: validationSucceeded(result) ? "success" : "failure", + result, + errorCode: hasValidationStages(result) + ? (result?.errorCode ?? undefined) + : "incompatible", + testedAt: Date.now(), + }, + })); + } catch (error) { + if (!isCurrent()) return; + const errorCode = + error instanceof ApiError + ? (( + { + 401: "unauthenticated", + 403: "permission_denied", + 422: "request_invalid", + 429: "rate_limited", + 404: "gateway_unavailable", + } as Record + )[error.status] ?? "generic") + : "network"; + setEntries((previous) => ({ + ...previous, + [id]: { state: "failure", errorCode, testedAt: Date.now() }, + })); + } finally { + if (requests.current.get(id) === controller) requests.current.delete(id); + } + }, + [enabled], + ); + + return { entries, test, reset }; +} diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index d1b91be7..215e0969 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -390,5 +390,52 @@ "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." + "mcpServer.components.readError": "Tools, resources, or prompts could not be loaded.", + "mcpServer.oauthValidation.label": "OAuth connection test for {name}", + "mcpServer.oauthValidation.test": "Test connection", + "mcpServer.oauthValidation.retest": "Retest", + "mcpServer.oauthValidation.cancel": "Cancel", + "mcpServer.oauthValidation.authorize": "Authorize again", + "mcpServer.oauthValidation.testing": "Testing stored authorization…", + "mcpServer.oauthValidation.success": "Stored authorization verified", + "mcpServer.oauthValidation.failure": "Connection validation failed", + "mcpServer.oauthValidation.testedAt": "Tested at {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "MCP negotiation", + "mcpServer.oauthValidation.stage.tools_list": "Tools access", + "mcpServer.oauthValidation.outcome.ok": "Passed", + "mcpServer.oauthValidation.outcome.failed": "Failed", + "mcpServer.oauthValidation.outcome.skipped": "Skipped", + "mcpServer.oauthValidation.outcome.unsupported": "Unsupported", + "mcpServer.oauthValidation.error.generic": "OAuth connection failed. Check the configuration and try again.", + "mcpServer.oauthValidation.error.invalid_scope": "The provider rejected the requested scopes. Review the scopes and authorize again.", + "mcpServer.oauthValidation.error.insufficient_scope": "The stored token lacks the required scope. Review the scopes and authorize again.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "The provider rejected the redirect URI. Check the registered callback URL.", + "mcpServer.oauthValidation.error.token_url_unreachable": "The token endpoint is unreachable. Check its URL and network access.", + "mcpServer.oauthValidation.error.authorization_missing": "Authorize this server before testing the connection.", + "mcpServer.oauthValidation.error.token_expired": "The stored token has expired. Authorize this server again.", + "mcpServer.oauthValidation.error.invalid_token": "The stored token is invalid. Authorize this server again.", + "mcpServer.oauthValidation.error.invalid_client": "The provider rejected the OAuth client. Check the client configuration.", + "mcpServer.oauthValidation.error.invalid_request": "The provider rejected the authorization request. Check the OAuth configuration.", + "mcpServer.oauthValidation.error.unauthorized_client": "This client cannot use the requested authorization method. Check the provider configuration.", + "mcpServer.oauthValidation.error.unsupported_response_type": "The provider does not support this response type. Check the OAuth configuration.", + "mcpServer.oauthValidation.error.server_error": "The authorization server encountered an error. Try again later.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "The authorization server is temporarily unavailable. Try again later.", + "mcpServer.oauthValidation.error.token_exchange_failed": "OAuth token exchange failed. Check the configuration and authorize again.", + "mcpServer.oauthValidation.error.access_denied": "The provider denied authorization. Authorize this server again.", + "mcpServer.oauthValidation.error.issuer_mismatch": "The authorization response issuer does not match. Check the issuer configuration.", + "mcpServer.oauthValidation.error.gateway_unavailable": "The saved server is unavailable. Reload the page and check its status.", + "mcpServer.oauthValidation.error.permission_denied": "You need server read and update permissions to validate this connection.", + "mcpServer.oauthValidation.error.unsupported_auth": "This test requires an OAuth authorization-code server.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "Stored authorization cannot be read. Try again later.", + "mcpServer.oauthValidation.error.tools_unsupported": "This server does not advertise tools, so tools access cannot be validated.", + "mcpServer.oauthValidation.error.tools_list_failed": "The tools request failed. Check upstream access and protocol support.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "MCP negotiation failed. Check the server transport and configuration.", + "mcpServer.oauthValidation.error.timeout": "Validation exceeded its ten-second time limit. Try again.", + "mcpServer.oauthValidation.error.blocked": "The target is blocked by the connection-test policy. Check the server URL.", + "mcpServer.oauthValidation.error.incompatible": "This backend did not return post-authorization validation stages. Update the backend before testing again.", + "mcpServer.oauthValidation.error.request_invalid": "The saved server is not eligible for this test or the request is invalid. Reload and check its configuration.", + "mcpServer.oauthValidation.error.rate_limited": "Too many connection tests. Wait before testing again.", + "mcpServer.oauthValidation.error.network": "The connection test could not reach the backend. Check your connection and try again.", + "mcpServer.oauthValidation.error.unauthenticated": "Your session has expired. Sign in again." } diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index edd1a2f3..26e1fac2 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -390,5 +390,52 @@ "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." + "mcpServer.components.readError": "No se pudieron cargar las herramientas, los recursos o los prompts.", + "mcpServer.oauthValidation.label": "Prueba de conexión OAuth para {name}", + "mcpServer.oauthValidation.test": "Probar conexión", + "mcpServer.oauthValidation.retest": "Volver a probar", + "mcpServer.oauthValidation.cancel": "Cancelar", + "mcpServer.oauthValidation.authorize": "Autorizar de nuevo", + "mcpServer.oauthValidation.testing": "Probando la autorización guardada…", + "mcpServer.oauthValidation.success": "Autorización guardada verificada", + "mcpServer.oauthValidation.failure": "Falló la validación de conexión", + "mcpServer.oauthValidation.testedAt": "Probado a las {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "Negociación MCP", + "mcpServer.oauthValidation.stage.tools_list": "Acceso a herramientas", + "mcpServer.oauthValidation.outcome.ok": "Correcto", + "mcpServer.oauthValidation.outcome.failed": "Falló", + "mcpServer.oauthValidation.outcome.skipped": "Omitido", + "mcpServer.oauthValidation.outcome.unsupported": "No compatible", + "mcpServer.oauthValidation.error.generic": "Falló la conexión OAuth. Revisa la configuración e inténtalo de nuevo.", + "mcpServer.oauthValidation.error.invalid_scope": "El proveedor rechazó los permisos solicitados. Revísalos y autoriza de nuevo.", + "mcpServer.oauthValidation.error.insufficient_scope": "El token guardado no tiene los permisos necesarios. Revísalos y autoriza de nuevo.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "El proveedor rechazó la URI de redirección. Revisa la URL de retorno registrada.", + "mcpServer.oauthValidation.error.token_url_unreachable": "No se puede acceder al endpoint de tokens. Revisa su URL y el acceso a la red.", + "mcpServer.oauthValidation.error.authorization_missing": "Autoriza este servidor antes de probar la conexión.", + "mcpServer.oauthValidation.error.token_expired": "El token guardado ha caducado. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.invalid_token": "El token guardado no es válido. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.invalid_client": "El proveedor rechazó el cliente OAuth. Revisa su configuración.", + "mcpServer.oauthValidation.error.invalid_request": "El proveedor rechazó la solicitud de autorización. Revisa la configuración OAuth.", + "mcpServer.oauthValidation.error.unauthorized_client": "Este cliente no puede usar el método de autorización solicitado. Revisa la configuración del proveedor.", + "mcpServer.oauthValidation.error.unsupported_response_type": "El proveedor no admite este tipo de respuesta. Revisa la configuración OAuth.", + "mcpServer.oauthValidation.error.server_error": "El servidor de autorización encontró un error. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "El servidor de autorización no está disponible temporalmente. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.token_exchange_failed": "Falló el intercambio de tokens OAuth. Revisa la configuración y autoriza de nuevo.", + "mcpServer.oauthValidation.error.access_denied": "El proveedor denegó la autorización. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.issuer_mismatch": "El emisor de la respuesta de autorización no coincide. Revisa su configuración.", + "mcpServer.oauthValidation.error.gateway_unavailable": "El servidor guardado no está disponible. Recarga la página y revisa su estado.", + "mcpServer.oauthValidation.error.permission_denied": "Necesitas permisos de lectura y actualización del servidor para validar esta conexión.", + "mcpServer.oauthValidation.error.unsupported_auth": "Esta prueba requiere un servidor OAuth con código de autorización.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "No se puede leer la autorización guardada. Inténtalo más tarde.", + "mcpServer.oauthValidation.error.tools_unsupported": "Este servidor no anuncia herramientas, por lo que no se puede validar su acceso.", + "mcpServer.oauthValidation.error.tools_list_failed": "Falló la solicitud de herramientas. Revisa el acceso al servidor y la compatibilidad del protocolo.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "Falló la negociación MCP. Revisa el transporte y la configuración del servidor.", + "mcpServer.oauthValidation.error.timeout": "La validación superó el límite de diez segundos. Inténtalo de nuevo.", + "mcpServer.oauthValidation.error.blocked": "La política de pruebas de conexión bloquea el destino. Revisa la URL del servidor.", + "mcpServer.oauthValidation.error.incompatible": "Este backend no devolvió las etapas de validación posteriores a la autorización. Actualízalo antes de volver a probar.", + "mcpServer.oauthValidation.error.request_invalid": "El servidor guardado no es apto para esta prueba o la solicitud no es válida. Recarga y revisa su configuración.", + "mcpServer.oauthValidation.error.rate_limited": "Demasiadas pruebas de conexión. Espera antes de volver a probar.", + "mcpServer.oauthValidation.error.network": "La prueba no pudo acceder al backend. Revisa tu conexión e inténtalo de nuevo.", + "mcpServer.oauthValidation.error.unauthenticated": "Tu sesión ha caducado. Inicia sesión de nuevo." } diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 785e6858..23b3d2af 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -390,5 +390,52 @@ "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." + "mcpServer.components.readError": "Não foi possível carregar as ferramentas, os recursos ou os prompts.", + "mcpServer.oauthValidation.label": "Teste de conexão OAuth para {name}", + "mcpServer.oauthValidation.test": "Testar conexão", + "mcpServer.oauthValidation.retest": "Testar novamente", + "mcpServer.oauthValidation.cancel": "Cancelar", + "mcpServer.oauthValidation.authorize": "Autorizar novamente", + "mcpServer.oauthValidation.testing": "Testando a autorização armazenada…", + "mcpServer.oauthValidation.success": "Autorização armazenada verificada", + "mcpServer.oauthValidation.failure": "Falha na validação da conexão", + "mcpServer.oauthValidation.testedAt": "Testado às {time}", + "mcpServer.oauthValidation.stage.token": "Token", + "mcpServer.oauthValidation.stage.negotiation": "Negociação MCP", + "mcpServer.oauthValidation.stage.tools_list": "Acesso às ferramentas", + "mcpServer.oauthValidation.outcome.ok": "Aprovado", + "mcpServer.oauthValidation.outcome.failed": "Falhou", + "mcpServer.oauthValidation.outcome.skipped": "Ignorado", + "mcpServer.oauthValidation.outcome.unsupported": "Não compatível", + "mcpServer.oauthValidation.error.generic": "Falha na conexão OAuth. Verifique a configuração e tente novamente.", + "mcpServer.oauthValidation.error.invalid_scope": "O provedor rejeitou os escopos solicitados. Revise os escopos e autorize novamente.", + "mcpServer.oauthValidation.error.insufficient_scope": "O token armazenado não tem o escopo necessário. Revise os escopos e autorize novamente.", + "mcpServer.oauthValidation.error.redirect_uri_mismatch": "O provedor rejeitou a URI de redirecionamento. Verifique a URL de retorno registrada.", + "mcpServer.oauthValidation.error.token_url_unreachable": "O endpoint de tokens está inacessível. Verifique a URL e o acesso à rede.", + "mcpServer.oauthValidation.error.authorization_missing": "Autorize este servidor antes de testar a conexão.", + "mcpServer.oauthValidation.error.token_expired": "O token armazenado expirou. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.invalid_token": "O token armazenado é inválido. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.invalid_client": "O provedor rejeitou o cliente OAuth. Verifique a configuração do cliente.", + "mcpServer.oauthValidation.error.invalid_request": "O provedor rejeitou a solicitação de autorização. Verifique a configuração OAuth.", + "mcpServer.oauthValidation.error.unauthorized_client": "Este cliente não pode usar o método de autorização solicitado. Verifique a configuração do provedor.", + "mcpServer.oauthValidation.error.unsupported_response_type": "O provedor não oferece suporte a este tipo de resposta. Verifique a configuração OAuth.", + "mcpServer.oauthValidation.error.server_error": "O servidor de autorização encontrou um erro. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.temporarily_unavailable": "O servidor de autorização está temporariamente indisponível. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.token_exchange_failed": "Falha na troca de tokens OAuth. Verifique a configuração e autorize novamente.", + "mcpServer.oauthValidation.error.access_denied": "O provedor negou a autorização. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.issuer_mismatch": "O emissor da resposta de autorização não corresponde. Verifique a configuração do emissor.", + "mcpServer.oauthValidation.error.gateway_unavailable": "O servidor salvo está indisponível. Recarregue a página e verifique o status.", + "mcpServer.oauthValidation.error.permission_denied": "Você precisa de permissões de leitura e atualização do servidor para validar esta conexão.", + "mcpServer.oauthValidation.error.unsupported_auth": "Este teste requer um servidor OAuth com código de autorização.", + "mcpServer.oauthValidation.error.token_storage_unavailable": "Não foi possível ler a autorização armazenada. Tente novamente mais tarde.", + "mcpServer.oauthValidation.error.tools_unsupported": "Este servidor não anuncia ferramentas, portanto o acesso não pode ser validado.", + "mcpServer.oauthValidation.error.tools_list_failed": "Falha na solicitação de ferramentas. Verifique o acesso ao servidor e o suporte ao protocolo.", + "mcpServer.oauthValidation.error.mcp_handshake_failed": "Falha na negociação MCP. Verifique o transporte e a configuração do servidor.", + "mcpServer.oauthValidation.error.timeout": "A validação excedeu o limite de dez segundos. Tente novamente.", + "mcpServer.oauthValidation.error.blocked": "O destino está bloqueado pela política de testes de conexão. Verifique a URL do servidor.", + "mcpServer.oauthValidation.error.incompatible": "Este backend não retornou as etapas de validação após a autorização. Atualize o backend antes de testar novamente.", + "mcpServer.oauthValidation.error.request_invalid": "O servidor salvo não é elegível para este teste ou a solicitação é inválida. Recarregue e verifique a configuração.", + "mcpServer.oauthValidation.error.rate_limited": "Muitos testes de conexão. Aguarde antes de testar novamente.", + "mcpServer.oauthValidation.error.network": "O teste não conseguiu acessar o backend. Verifique sua conexão e tente novamente.", + "mcpServer.oauthValidation.error.unauthenticated": "Sua sessão expirou. Entre novamente." } diff --git a/src/lib/oauthConnectionValidation.ts b/src/lib/oauthConnectionValidation.ts new file mode 100644 index 00000000..00135562 --- /dev/null +++ b/src/lib/oauthConnectionValidation.ts @@ -0,0 +1,77 @@ +import type { IntlShape } from "react-intl"; +import type { OAuthStatusEntry } from "@/api/oauth"; +import type { MCPServer } from "@/types/server"; +import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthConnectionValidation"; + +export const VALIDATION_STAGES = ["token", "negotiation", "tools_list"] as const; + +const ERROR_CODES = new Set([ + "invalid_scope", + "insufficient_scope", + "redirect_uri_mismatch", + "token_url_unreachable", + "authorization_missing", + "token_expired", + "invalid_token", + "invalid_client", + "invalid_request", + "unauthorized_client", + "unsupported_response_type", + "server_error", + "temporarily_unavailable", + "token_exchange_failed", + "access_denied", + "issuer_mismatch", + "gateway_unavailable", + "permission_denied", + "unsupported_auth", + "token_storage_unavailable", + "tools_unsupported", + "tools_list_failed", + "mcp_handshake_failed", + "timeout", + "blocked", + "incompatible", + "request_invalid", + "rate_limited", + "network", + "unauthenticated", +]); + +export function oauthErrorMessage(intl: IntlShape, code?: string): string { + return intl.formatMessage({ + id: `mcpServer.oauthValidation.error.${code && ERROR_CODES.has(code) ? code : "generic"}`, + }); +} + +export function hasValidationStages(result: OAuthConnectionValidationResponse): boolean { + return VALIDATION_STAGES.every((stage) => + ["ok", "failed", "skipped", "unsupported"].includes(result?.stages?.[stage] ?? ""), + ); +} + +export function validationSucceeded(result: OAuthConnectionValidationResponse): boolean { + return ( + result?.success === true && VALIDATION_STAGES.every((stage) => result.stages?.[stage] === "ok") + ); +} + +export function canValidateOAuthConnection(server: MCPServer, status?: OAuthStatusEntry): boolean { + return ( + server.enabled && + server.authType?.toLowerCase() === "oauth" && + server.oauthConfig?.grant_type === "authorization_code" && + status?.state === "ready" && + (status.tokenStatus === "valid" || status.tokenStatus === "near_expiry") + ); +} + +export function oauthConfigurationKey(server: MCPServer): string { + return JSON.stringify([ + server.url, + server.transport, + server.enabled, + server.authType, + server.oauthConfig, + ]); +} From ba9e153c41229221e2b69ba6f4c3cfa9033cdba4 Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Wed, 4 Nov 2026 11:11:59 +0000 Subject: [PATCH 2/4] fix: address shared OAuth validation review feedback Signed-off-by: Vishu Bhatnagar --- server/test/oauth-callback.test.ts | 2 +- .../OAuthConnectionValidationPanel.test.tsx | 42 +++++++++++++++ .../OAuthConnectionValidationPanel.tsx | 54 ++++++++++++++----- .../useOAuthConnectionValidation.test.ts | 33 +++++++++++- src/hooks/useOAuthConnectionValidation.ts | 7 +-- src/lib/oauthConnectionValidation.test.ts | 40 ++++++++++++++ src/lib/oauthConnectionValidation.ts | 15 +++++- 7 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 src/lib/oauthConnectionValidation.test.ts diff --git a/server/test/oauth-callback.test.ts b/server/test/oauth-callback.test.ts index 8355b9b9..79edf6d5 100644 --- a/server/test/oauth-callback.test.ts +++ b/server/test/oauth-callback.test.ts @@ -73,7 +73,7 @@ async function buildApp() { } describe("GET /oauth/callback", () => { - it("preserves safe structured backend callback errors", async () => { + it("forwards structured backend callback error fields", async () => { const app = await buildApp(); const response = await app.fastify.inject({ method: "GET", diff --git a/src/components/servers/OAuthConnectionValidationPanel.test.tsx b/src/components/servers/OAuthConnectionValidationPanel.test.tsx index 91f3be4c..5a55b083 100644 --- a/src/components/servers/OAuthConnectionValidationPanel.test.tsx +++ b/src/components/servers/OAuthConnectionValidationPanel.test.tsx @@ -25,6 +25,48 @@ describe("OAuthConnectionValidationPanel", () => { expect(props.onTest).toHaveBeenCalledOnce(); }); + it("keeps Test focused while running and returns focus from Cancel on completion", async () => { + const { rerender } = render(); + const button = screen.getByRole("button", { name: "Test connection" }); + button.focus(); + rerender(); + expect(button).toHaveFocus(); + expect(button).toHaveAttribute("aria-disabled", "true"); + const before = props.onTest.mock.calls.length; + await userEvent.click(button); + expect(props.onTest).toHaveBeenCalledTimes(before); + screen.getByRole("button", { name: "Cancel" }).focus(); + rerender(); + expect(screen.getByRole("button", { name: "Retest" })).toHaveFocus(); + }); + + it("returns focus from Cancel when validation is reset", () => { + const { rerender } = render( + , + ); + screen.getByRole("button", { name: "Cancel" }).focus(); + rerender(); + expect(screen.getByRole("button", { name: "Test connection" })).toHaveFocus(); + }); + + it("hides unknown stage values instead of constructing missing translation keys", () => { + render( + , + ); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + expect(screen.queryByText(/outcome.unexpected/)).not.toBeInTheDocument(); + }); + it("renders zero tools, stage text, test time and Retest", () => { render(); expect(screen.getByText("0 tools")).toBeVisible(); diff --git a/src/components/servers/OAuthConnectionValidationPanel.tsx b/src/components/servers/OAuthConnectionValidationPanel.tsx index b265d68f..a943b340 100644 --- a/src/components/servers/OAuthConnectionValidationPanel.tsx +++ b/src/components/servers/OAuthConnectionValidationPanel.tsx @@ -1,8 +1,14 @@ -import { CheckCircle2, CircleMinus, CircleX, Loader2 } from "lucide-react"; +import { Loader2 } from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; +import { STATUS_ICON, STATUS_TONE_CLASS } from "@/lib/status"; import { useIntl } from "react-intl"; import { Button } from "@/components/ui/button"; import type { OAuthConnectionValidationState } from "@/hooks/useOAuthConnectionValidation"; -import { oauthErrorMessage, VALIDATION_STAGES } from "@/lib/oauthConnectionValidation"; +import { + hasValidationStages, + oauthErrorMessage, + VALIDATION_STAGES, +} from "@/lib/oauthConnectionValidation"; export interface OAuthConnectionValidationPanelProps { name: string; @@ -27,6 +33,14 @@ export function OAuthConnectionValidationPanel({ const message = (key: string) => intl.formatMessage({ id: `mcpServer.oauthValidation.${key}` }); const running = entry?.state === "running"; const result = entry?.result; + const testButton = useRef(null); + const cancelFocused = useRef(false); + useLayoutEffect(() => { + if (!running && cancelFocused.current) { + testButton.current?.focus(); + cancelFocused.current = false; + } + }, [running]); return (
{!resultOnly && (
- {running && ( - )} @@ -62,18 +94,16 @@ export function OAuthConnectionValidationPanel({

{message(running ? "testing" : entry.state === "success" ? "success" : "failure")}

- {result?.stages && ( + {hasValidationStages(result ?? null) && (
    {VALIDATION_STAGES.map((stage) => { - const status = result.stages?.[stage] ?? "skipped"; - const Icon = - status === "ok" ? CheckCircle2 : status === "failed" ? CircleX : CircleMinus; + const status = result?.stages?.[stage] ?? "skipped"; + const severity = + status === "ok" ? "success" : status === "failed" ? "error" : "info"; + const Icon = STATUS_ICON[severity]; return (
  • -
  • ); diff --git a/src/hooks/useOAuthConnectionValidation.test.ts b/src/hooks/useOAuthConnectionValidation.test.ts index 36fbf80d..beccb44d 100644 --- a/src/hooks/useOAuthConnectionValidation.test.ts +++ b/src/hooks/useOAuthConnectionValidation.test.ts @@ -54,7 +54,7 @@ describe("useOAuthConnectionValidation", () => { "does not report success for a %s listing", async (status) => { mockTest.mockResolvedValue({ - ...success!, + ...success, stages: { token: "ok", negotiation: "ok", tools_list: status }, }); const { result } = renderHook(() => useOAuthConnectionValidation()); @@ -65,7 +65,7 @@ describe("useOAuthConnectionValidation", () => { it("preserves HTTP-200 validation failures and reauthorization guidance", async () => { const response: OAuthConnectionValidationResponse = { - ...success!, + ...success, success: false, stages: { token: "ok", negotiation: "ok", tools_list: "failed" }, errorCode: "insufficient_scope", @@ -150,6 +150,35 @@ describe("useOAuthConnectionValidation", () => { expect(mockTest.mock.calls[2][1]?.aborted).toBe(true); }); + it("classifies gateway identity mismatches without sending a test", async () => { + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", { ...target, gatewayId: "other" })); + expect(result.current.entries["gw-1"].errorCode).toBe("request_invalid"); + expect(mockTest).not.toHaveBeenCalled(); + }); + + it("explains unsupported tools without inventing a scope failure", async () => { + mockTest.mockResolvedValue({ + ...success, + stages: { token: "ok", negotiation: "ok", tools_list: "unsupported" }, + }); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"].errorCode).toBe("tools_unsupported"); + }); + + it("retains the previous result and time during a retest", async () => { + mockTest.mockResolvedValueOnce(success).mockImplementationOnce(() => new Promise(() => {})); + const { result, unmount } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + const completed = result.current.entries["gw-1"]; + act(() => { + void result.current.test("gw-1", target); + }); + expect(result.current.entries["gw-1"]).toEqual({ ...completed, state: "running" }); + unmount(); + }); + it("cancels while resolving the saved gateway before any handshake request", async () => { let finish!: (value: typeof target) => void; const resolver = vi.fn( diff --git a/src/hooks/useOAuthConnectionValidation.ts b/src/hooks/useOAuthConnectionValidation.ts index 8b378972..c6ee8671 100644 --- a/src/hooks/useOAuthConnectionValidation.ts +++ b/src/hooks/useOAuthConnectionValidation.ts @@ -58,11 +58,11 @@ export function useOAuthConnectionValidation(enabled = true, targets?: Record !controller.signal.aborted && requests.current.get(id) === controller; - setEntries((previous) => ({ ...previous, [id]: { state: "running" } })); + setEntries((previous) => ({ ...previous, [id]: { ...previous[id], state: "running" } })); try { const resolved = typeof target === "function" ? await target(controller.signal) : target; if (!isCurrent()) return; - if (resolved.gatewayId !== id) throw new Error("Gateway mismatch"); + if (resolved.gatewayId !== id) throw new ApiError(422, null, "Gateway mismatch"); const result = await serversApi.testOAuthConnection(resolved, controller.signal); if (!isCurrent()) return; setEntries((previous) => ({ @@ -71,7 +71,8 @@ export function useOAuthConnectionValidation(enabled = true, targets?: Record { + it("ignores object key order but detects saved changes", () => { + expect(oauthConfigurationKey(gateway)).toBe( + oauthConfigurationKey({ + ...gateway, + oauthConfig: { nested: { b: 2, a: 1 }, grant_type: "authorization_code" }, + }), + ); + expect(oauthConfigurationKey(gateway)).not.toBe( + oauthConfigurationKey({ ...gateway, url: "https://changed.example/mcp" }), + ); + }); + it("requires an enabled authorization-code gateway and usable authorization", () => { + const status = { + state: "ready", + tokenStatus: "valid", + status: { oauth_enabled: true }, + } as const; + expect(canValidateOAuthConnection(gateway, status)).toBe(true); + expect(canValidateOAuthConnection({ ...gateway, enabled: false }, status)).toBe(false); + expect( + canValidateOAuthConnection( + { ...gateway, oauthConfig: { grant_type: "client_credentials" } }, + status, + ), + ).toBe(false); + expect(canValidateOAuthConnection(gateway)).toBe(false); + }); +}); diff --git a/src/lib/oauthConnectionValidation.ts b/src/lib/oauthConnectionValidation.ts index 00135562..41ade4d9 100644 --- a/src/lib/oauthConnectionValidation.ts +++ b/src/lib/oauthConnectionValidation.ts @@ -66,12 +66,25 @@ export function canValidateOAuthConnection(server: MCPServer, status?: OAuthStat ); } +// Sort nested object keys while preserving array order (for example, scope lists). +function canonicalConfiguration(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalConfiguration); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, canonicalConfiguration(item)]), + ); + } + return value; +} + export function oauthConfigurationKey(server: MCPServer): string { return JSON.stringify([ server.url, server.transport, server.enabled, server.authType, - server.oauthConfig, + canonicalConfiguration(server.oauthConfig), ]); } From 8c250b652c8b2a911e3d48193677dd17c9a9ddb0 Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Wed, 4 Nov 2026 14:08:19 +0000 Subject: [PATCH 3/4] fix: complete OAuth validation error guidance Signed-off-by: Vishu Bhatnagar --- .../useOAuthConnectionValidation.test.ts | 58 +++++++++++++++++++ src/hooks/useOAuthConnectionValidation.ts | 26 +++++++-- src/i18n/locales/en-US/mcpServer.json | 12 ++++ src/i18n/locales/es-ES/mcpServer.json | 12 ++++ src/i18n/locales/pt-BR/mcpServer.json | 12 ++++ src/lib/oauthConnectionValidation.test.ts | 47 ++++++++++++++- src/lib/oauthConnectionValidation.ts | 12 ++++ 7 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/hooks/useOAuthConnectionValidation.test.ts b/src/hooks/useOAuthConnectionValidation.test.ts index beccb44d..685384cc 100644 --- a/src/hooks/useOAuthConnectionValidation.test.ts +++ b/src/hooks/useOAuthConnectionValidation.test.ts @@ -167,6 +167,64 @@ describe("useOAuthConnectionValidation", () => { expect(result.current.entries["gw-1"].errorCode).toBe("tools_unsupported"); }); + it.each(["token", "negotiation", "tools_list"] as const)( + "derives guidance for every incomplete %s outcome without a backend error code", + async (stage) => { + const expected = { + token: "token_validation_failed", + negotiation: "mcp_handshake_failed", + tools_list: "tools_list_failed", + }; + const { result } = renderHook(() => useOAuthConnectionValidation()); + for (const outcome of ["failed", "skipped", "unsupported"] as const) { + mockTest.mockResolvedValue({ + ...success, + stages: { ...success?.stages, [stage]: outcome }, + }); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + errorCode: + stage === "tools_list" && outcome === "unsupported" + ? "tools_unsupported" + : expected[stage], + }); + } + }, + ); + + it("reports resolver exceptions as gateway failures without exposing their messages", async () => { + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => + result.current.test("gw-1", async () => { + throw new Error("unsafe resolver details"); + }), + ); + expect(result.current.entries["gw-1"]).toMatchObject({ + state: "failure", + errorCode: "gateway_unavailable", + }); + expect(mockTest).not.toHaveBeenCalled(); + }); + + it("preserves HTTP permission errors from a gateway resolver", async () => { + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => + result.current.test("gw-1", async () => { + throw new ApiError(403, null, "unsafe resolver details"); + }), + ); + expect(result.current.entries["gw-1"].errorCode).toBe("permission_denied"); + expect(mockTest).not.toHaveBeenCalled(); + }); + + it("still reports transport failures during validation as network errors", async () => { + mockTest.mockRejectedValue(new TypeError("Failed to fetch")); + const { result } = renderHook(() => useOAuthConnectionValidation()); + await act(() => result.current.test("gw-1", target)); + expect(result.current.entries["gw-1"].errorCode).toBe("network"); + }); + it("retains the previous result and time during a retest", async () => { mockTest.mockResolvedValueOnce(success).mockImplementationOnce(() => new Promise(() => {})); const { result, unmount } = renderHook(() => useOAuthConnectionValidation()); diff --git a/src/hooks/useOAuthConnectionValidation.ts b/src/hooks/useOAuthConnectionValidation.ts index c6ee8671..b61db8bb 100644 --- a/src/hooks/useOAuthConnectionValidation.ts +++ b/src/hooks/useOAuthConnectionValidation.ts @@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError } from "@/api/client"; import { serversApi } from "@/api/servers"; import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthConnectionValidation"; -import { hasValidationStages, validationSucceeded } from "@/lib/oauthConnectionValidation"; +import { + hasValidationStages, + validationSucceeded, + VALIDATION_STAGES, +} from "@/lib/oauthConnectionValidation"; export interface OAuthConnectionValidationState { state: "running" | "success" | "failure"; @@ -59,20 +63,32 @@ export function useOAuthConnectionValidation(enabled = true, targets?: Record !controller.signal.aborted && requests.current.get(id) === controller; setEntries((previous) => ({ ...previous, [id]: { ...previous[id], state: "running" } })); + let resolvingTarget = true; try { const resolved = typeof target === "function" ? await target(controller.signal) : target; if (!isCurrent()) return; if (resolved.gatewayId !== id) throw new ApiError(422, null, "Gateway mismatch"); + resolvingTarget = false; const result = await serversApi.testOAuthConnection(resolved, controller.signal); if (!isCurrent()) return; + const incompleteStage = VALIDATION_STAGES.find((stage) => result?.stages?.[stage] !== "ok"); + const stageErrorCode = incompleteStage + ? { + token: "token_validation_failed", + negotiation: "mcp_handshake_failed", + tools_list: + result?.stages?.tools_list === "unsupported" + ? "tools_unsupported" + : "tools_list_failed", + }[incompleteStage] + : undefined; setEntries((previous) => ({ ...previous, [id]: { state: validationSucceeded(result) ? "success" : "failure", result, errorCode: hasValidationStages(result) - ? (result?.errorCode ?? - (result?.stages?.tools_list === "unsupported" ? "tools_unsupported" : undefined)) + ? result?.errorCode || stageErrorCode : "incompatible", testedAt: Date.now(), }, @@ -90,7 +106,9 @@ export function useOAuthConnectionValidation(enabled = true, targets?: Record )[error.status] ?? "generic") - : "network"; + : resolvingTarget + ? "gateway_unavailable" + : "network"; setEntries((previous) => ({ ...previous, [id]: { state: "failure", errorCode, testedAt: Date.now() }, diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 215e0969..0f6ac806 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -408,6 +408,18 @@ "mcpServer.oauthValidation.outcome.skipped": "Skipped", "mcpServer.oauthValidation.outcome.unsupported": "Unsupported", "mcpServer.oauthValidation.error.generic": "OAuth connection failed. Check the configuration and try again.", + "mcpServer.oauthValidation.error.invalid_grant": "The authorization grant is invalid or expired. Authorize this server again.", + "mcpServer.oauthValidation.error.unsupported_grant_type": "The provider does not support this grant type. Check the OAuth configuration.", + "mcpServer.oauthValidation.error.login_required": "The provider requires you to sign in. Authorize this server again.", + "mcpServer.oauthValidation.error.consent_required": "The provider requires your consent. Authorize this server again and review the requested permissions.", + "mcpServer.oauthValidation.error.interaction_required": "The provider requires interaction to complete authorization. Authorize this server again.", + "mcpServer.oauthValidation.error.account_selection_required": "The provider requires you to select an account. Authorize this server again.", + "mcpServer.oauthValidation.error.invalid_request_uri": "The provider rejected the request URI. Check the OAuth request configuration.", + "mcpServer.oauthValidation.error.invalid_request_object": "The provider rejected the request object. Check the OAuth request configuration.", + "mcpServer.oauthValidation.error.request_not_supported": "The provider does not support request objects. Check the OAuth request configuration.", + "mcpServer.oauthValidation.error.request_uri_not_supported": "The provider does not support request URIs. Check the OAuth request configuration.", + "mcpServer.oauthValidation.error.registration_not_supported": "The provider does not support this registration request. Check the OAuth client registration.", + "mcpServer.oauthValidation.error.token_validation_failed": "Stored token validation did not complete successfully. Check the authorization and try again.", "mcpServer.oauthValidation.error.invalid_scope": "The provider rejected the requested scopes. Review the scopes and authorize again.", "mcpServer.oauthValidation.error.insufficient_scope": "The stored token lacks the required scope. Review the scopes and authorize again.", "mcpServer.oauthValidation.error.redirect_uri_mismatch": "The provider rejected the redirect URI. Check the registered callback URL.", diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 26e1fac2..ed087163 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -408,6 +408,18 @@ "mcpServer.oauthValidation.outcome.skipped": "Omitido", "mcpServer.oauthValidation.outcome.unsupported": "No compatible", "mcpServer.oauthValidation.error.generic": "Falló la conexión OAuth. Revisa la configuración e inténtalo de nuevo.", + "mcpServer.oauthValidation.error.invalid_grant": "La concesión de autorización no es válida o ha caducado. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.unsupported_grant_type": "El proveedor no admite este tipo de concesión. Revisa la configuración OAuth.", + "mcpServer.oauthValidation.error.login_required": "El proveedor requiere que inicies sesión. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.consent_required": "El proveedor requiere tu consentimiento. Autoriza este servidor de nuevo y revisa los permisos solicitados.", + "mcpServer.oauthValidation.error.interaction_required": "El proveedor requiere interacción para completar la autorización. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.account_selection_required": "El proveedor requiere que selecciones una cuenta. Autoriza este servidor de nuevo.", + "mcpServer.oauthValidation.error.invalid_request_uri": "El proveedor rechazó la URI de la solicitud. Revisa la configuración de la solicitud OAuth.", + "mcpServer.oauthValidation.error.invalid_request_object": "El proveedor rechazó el objeto de la solicitud. Revisa la configuración de la solicitud OAuth.", + "mcpServer.oauthValidation.error.request_not_supported": "El proveedor no admite objetos de solicitud. Revisa la configuración de la solicitud OAuth.", + "mcpServer.oauthValidation.error.request_uri_not_supported": "El proveedor no admite URI de solicitud. Revisa la configuración de la solicitud OAuth.", + "mcpServer.oauthValidation.error.registration_not_supported": "El proveedor no admite esta solicitud de registro. Revisa el registro del cliente OAuth.", + "mcpServer.oauthValidation.error.token_validation_failed": "La validación del token guardado no se completó correctamente. Revisa la autorización e inténtalo de nuevo.", "mcpServer.oauthValidation.error.invalid_scope": "El proveedor rechazó los permisos solicitados. Revísalos y autoriza de nuevo.", "mcpServer.oauthValidation.error.insufficient_scope": "El token guardado no tiene los permisos necesarios. Revísalos y autoriza de nuevo.", "mcpServer.oauthValidation.error.redirect_uri_mismatch": "El proveedor rechazó la URI de redirección. Revisa la URL de retorno registrada.", diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 23b3d2af..f6f81a67 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -408,6 +408,18 @@ "mcpServer.oauthValidation.outcome.skipped": "Ignorado", "mcpServer.oauthValidation.outcome.unsupported": "Não compatível", "mcpServer.oauthValidation.error.generic": "Falha na conexão OAuth. Verifique a configuração e tente novamente.", + "mcpServer.oauthValidation.error.invalid_grant": "A concessão de autorização é inválida ou expirou. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.unsupported_grant_type": "O provedor não oferece suporte a este tipo de concessão. Verifique a configuração OAuth.", + "mcpServer.oauthValidation.error.login_required": "O provedor exige que você entre na sua conta. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.consent_required": "O provedor exige seu consentimento. Autorize este servidor novamente e revise as permissões solicitadas.", + "mcpServer.oauthValidation.error.interaction_required": "O provedor exige interação para concluir a autorização. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.account_selection_required": "O provedor exige que você selecione uma conta. Autorize este servidor novamente.", + "mcpServer.oauthValidation.error.invalid_request_uri": "O provedor rejeitou a URI da solicitação. Verifique a configuração da solicitação OAuth.", + "mcpServer.oauthValidation.error.invalid_request_object": "O provedor rejeitou o objeto da solicitação. Verifique a configuração da solicitação OAuth.", + "mcpServer.oauthValidation.error.request_not_supported": "O provedor não oferece suporte a objetos de solicitação. Verifique a configuração da solicitação OAuth.", + "mcpServer.oauthValidation.error.request_uri_not_supported": "O provedor não oferece suporte a URIs de solicitação. Verifique a configuração da solicitação OAuth.", + "mcpServer.oauthValidation.error.registration_not_supported": "O provedor não oferece suporte a esta solicitação de registro. Verifique o registro do cliente OAuth.", + "mcpServer.oauthValidation.error.token_validation_failed": "A validação do token armazenado não foi concluída com sucesso. Verifique a autorização e tente novamente.", "mcpServer.oauthValidation.error.invalid_scope": "O provedor rejeitou os escopos solicitados. Revise os escopos e autorize novamente.", "mcpServer.oauthValidation.error.insufficient_scope": "O token armazenado não tem o escopo necessário. Revise os escopos e autorize novamente.", "mcpServer.oauthValidation.error.redirect_uri_mismatch": "O provedor rejeitou a URI de redirecionamento. Verifique a URL de retorno registrada.", diff --git a/src/lib/oauthConnectionValidation.test.ts b/src/lib/oauthConnectionValidation.test.ts index dfd91786..ee54aef9 100644 --- a/src/lib/oauthConnectionValidation.test.ts +++ b/src/lib/oauthConnectionValidation.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect } from "vitest"; import type { MCPServer } from "@/types/server"; -import { canValidateOAuthConnection, oauthConfigurationKey } from "./oauthConnectionValidation"; +import { createIntl, createIntlCache } from "react-intl"; +import en from "@/i18n/locales/en-US/mcpServer.json"; +import es from "@/i18n/locales/es-ES/mcpServer.json"; +import pt from "@/i18n/locales/pt-BR/mcpServer.json"; +import { + canValidateOAuthConnection, + oauthConfigurationKey, + oauthErrorMessage, +} from "./oauthConnectionValidation"; const gateway = { url: "https://saved.example/mcp", @@ -38,3 +46,40 @@ describe("OAuth validation eligibility and configuration", () => { expect(canValidateOAuthConnection(gateway)).toBe(false); }); }); + +describe("OAuth error guidance", () => { + it.each([ + ["en-US", en], + ["es-ES", es], + ["pt-BR", pt], + ] as const)( + "localizes supported provider and token validation errors in %s", + (locale, messages) => { + const intl = createIntl({ locale, messages }, createIntlCache()); + for (const code of [ + "invalid_grant", + "unsupported_grant_type", + "login_required", + "consent_required", + "interaction_required", + "account_selection_required", + "invalid_request_uri", + "invalid_request_object", + "request_not_supported", + "request_uri_not_supported", + "registration_not_supported", + "token_validation_failed", + ]) { + const key = `mcpServer.oauthValidation.error.${code}` as keyof typeof messages; + expect(messages[key]).toBeTruthy(); + expect(oauthErrorMessage(intl, code)).toBe(messages[key]); + expect(oauthErrorMessage(intl, code)).not.toBe( + messages["mcpServer.oauthValidation.error.generic"], + ); + } + expect(oauthErrorMessage(intl, "unsafe upstream details")).toBe( + messages["mcpServer.oauthValidation.error.generic"], + ); + }, + ); +}); diff --git a/src/lib/oauthConnectionValidation.ts b/src/lib/oauthConnectionValidation.ts index 41ade4d9..ba56ab3e 100644 --- a/src/lib/oauthConnectionValidation.ts +++ b/src/lib/oauthConnectionValidation.ts @@ -6,6 +6,18 @@ import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthCon export const VALIDATION_STAGES = ["token", "negotiation", "tools_list"] as const; const ERROR_CODES = new Set([ + "invalid_grant", + "unsupported_grant_type", + "login_required", + "consent_required", + "interaction_required", + "account_selection_required", + "invalid_request_uri", + "invalid_request_object", + "request_not_supported", + "request_uri_not_supported", + "registration_not_supported", + "token_validation_failed", "invalid_scope", "insufficient_scope", "redirect_uri_mismatch", From 6cf2f1f03caed75983cc633feba61690f1765783 Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Thu, 5 Nov 2026 13:06:54 +0000 Subject: [PATCH 4/4] fix: classify OAuth validation request and gateway errors Signed-off-by: Vishu Bhatnagar --- .../useOAuthConnectionValidation.test.ts | 19 +++++++++++++++++++ src/hooks/useOAuthConnectionValidation.ts | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/src/hooks/useOAuthConnectionValidation.test.ts b/src/hooks/useOAuthConnectionValidation.test.ts index 685384cc..f0dffcfe 100644 --- a/src/hooks/useOAuthConnectionValidation.test.ts +++ b/src/hooks/useOAuthConnectionValidation.test.ts @@ -87,6 +87,9 @@ describe("useOAuthConnectionValidation", () => { [422, "request_invalid"], [429, "rate_limited"], [500, "generic"], + [502, "gateway_unavailable"], + [503, "gateway_unavailable"], + [504, "timeout"], ])("maps HTTP %s without automatic retry", async (status, code) => { mockTest.mockRejectedValue(new ApiError(status as number, null, "unsafe upstream body")); const { result } = renderHook(() => useOAuthConnectionValidation()); @@ -157,6 +160,22 @@ describe("useOAuthConnectionValidation", () => { expect(mockTest).not.toHaveBeenCalled(); }); + it.each(["", "invalid/id", "invalid id", "../gateway"])( + "rejects invalid gateway ID %j before sending a request", + async (gatewayId) => { + const { result } = renderHook(() => useOAuthConnectionValidation()); + const invalidTarget = { ...target, gatewayId }; + for (const input of [invalidTarget, async () => invalidTarget]) { + await act(() => result.current.test(gatewayId, input)); + expect(result.current.entries[gatewayId]).toMatchObject({ + state: "failure", + errorCode: "request_invalid", + }); + expect(mockTest).not.toHaveBeenCalled(); + } + }, + ); + it("explains unsupported tools without inventing a scope failure", async () => { mockTest.mockResolvedValue({ ...success, diff --git a/src/hooks/useOAuthConnectionValidation.ts b/src/hooks/useOAuthConnectionValidation.ts index b61db8bb..24621859 100644 --- a/src/hooks/useOAuthConnectionValidation.ts +++ b/src/hooks/useOAuthConnectionValidation.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError } from "@/api/client"; import { serversApi } from "@/api/servers"; +import { validateServerId } from "@/utils/serverId"; import type { OAuthConnectionValidationResponse } from "@/api/contracts/oauthConnectionValidation"; import { hasValidationStages, @@ -68,6 +69,11 @@ export function useOAuthConnectionValidation(enabled = true, targets?: Record )[error.status] ?? "generic") : resolvingTarget