From a3c1ece4c4698ed8eea4d2e8e1f89151491c68e2 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 3 Jul 2026 12:15:33 -0700 Subject: [PATCH] fix(mcp): persist DCR client_id from on-create MCP OAuth Authorize & Fetch (#31920) * fix(mcp): persist DCR client_id so interactive OAuth token refresh works Interactive authorization_code MCP servers register an OAuth client via Dynamic Client Registration (RFC 7591) during the authorize flow, but the minted client_id and the discovered token_url were returned to the caller and never written to the server row. The autonomous refresh_token grant reads client_id, client_secret and token_url off the server, so an expired access token could not be refreshed; the user was bounced back to re-authorize and tools/list returned zero tools Persist the DCR client_id (plus client_secret and token_endpoint_auth_method when the registration returns them) and the discovered token_url onto the server row, reusing the encrypt_credentials write that client_credentials and token exchange already use, then refresh the in-memory registry so the value is live at refresh time. Both the v1 refresher and the v2 AuthorizationCodeRefresher read those same fields, so egress needs no change * fix: reuse persisted MCP DCR clients * fix(ui): persist DCR client_id from on-create MCP OAuth "Authorize & Fetch" The interactive "Authorize & Fetch" flow on the create form registers an OAuth client (RFC 7591) against a temporary server that has no DB row, then creates the real server afterward. useMcpOAuthFlow captured the DCR client_id and client_secret but passed only the token to onTokenReceived, so the create request dropped the client identity and the created server could not refresh its access token; its row had credentials={} and the refresh_token grant 401d at the upstream token endpoint Forward the registered client to onTokenReceived and write client_id (and client_secret when present) into the create form credentials, so the create request carries them and the backend persists them through its existing encrypt_credentials path. token_url is omitted because it is re-discovered on load (RFC 9728 then 8414); token_endpoint_auth_method is unused because this flow only ever registers as client_secret_post or none, never client_secret_basic * fix(ui): prevent stale MCP OAuth credentials * fix(ui): reset MCP OAuth authorization state --------- Co-authored-by: Cursor Agent (cherry picked from commit 3235f4a4998fe2674f1aafd4fdf6bff72d1238f5) --- .../mcp_tools/create_mcp_server.test.tsx | 182 +++++++++++++++++- .../mcp_tools/create_mcp_server.tsx | 74 ++++++- .../src/hooks/useMcpOAuthFlow.test.tsx | 118 +++++++++++- .../src/hooks/useMcpOAuthFlow.tsx | 34 +++- 4 files changed, 386 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index e70548d6a96..8173a9fef46 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -7,6 +7,7 @@ import CreateMCPServer from "./create_mcp_server"; vi.mock("../networking", () => ({ createMCPServer: vi.fn(), + fetchOpenAPIRegistry: vi.fn().mockResolvedValue({ apis: [] }), registerMCPServer: vi.fn(), storeMCPOAuthUserCredential: vi.fn().mockResolvedValue({}), testMCPToolsListRequest: vi.fn().mockResolvedValue({ tools: [], error: null }), @@ -16,15 +17,26 @@ vi.mock("@/utils/mcpTokenStore", () => ({ setToken: vi.fn(), })); +vi.mock("./OpenAPIQuickPicker", () => ({ + default: () => null, +})); + // Mutable holder so individual tests can simulate "Authorize & Fetch" having // produced a token before submit, and inspect the reset wiring. const oauthHook = vi.hoisted(() => ({ tokenResponse: null as Record | null, reset: vi.fn(), - onTokenReceived: null as ((token: Record | null) => void) | null, + onTokenReceived: null as + | ((token: Record | null, registeredClient?: { clientId?: string; clientSecret?: string }) => void) + | null, })); vi.mock("@/hooks/useMcpOAuthFlow", () => ({ - useMcpOAuthFlow: (opts: { onTokenReceived: (token: Record | null) => void }) => { + useMcpOAuthFlow: (opts: { + onTokenReceived: ( + token: Record | null, + registeredClient?: { clientId?: string; clientSecret?: string }, + ) => void; + }) => { oauthHook.onTokenReceived = opts.onTokenReceived; return { startOAuthFlow: vi.fn(), @@ -495,6 +507,170 @@ describe("CreateMCPServer", () => { expect(payload.token_validation).toEqual({ organization: "my-org", "team.id": "42" }); }); + it("invalidates the DCR client and OAuth flow when the MCP URL changes after Authorize & Fetch", async () => { + await setupOAuthInteractive(); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Url_Change_Server" } }); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://a.example.com/mcp" } }); + }); + + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://b.example.com/mcp" } }); + }); + + await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled()); + + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-server-oauth", + server_name: "Url_Change_Server", + alias: "Url_Change_Server", + url: "https://b.example.com/mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.credentials?.client_id).toBeUndefined(); + expect(payload.credentials?.client_secret).toBeUndefined(); + }); + + it("invalidates the DCR client and OAuth flow when the OpenAPI spec URL changes after Authorize & Fetch", async () => { + render(); + await selectAntOption("Transport Type", "OpenAPI Spec"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json")).toBeInTheDocument(); + }); + await selectAntOption("Authentication", "OAuth"); + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "OpenAPI_Server" } }); + }); + const specInput = screen.getByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json"); + await act(async () => { + fireEvent.change(specInput, { target: { value: "https://a.example.com/openapi.json" } }); + }); + + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + await act(async () => { + fireEvent.change(specInput, { target: { value: "https://b.example.com/openapi.json" } }); + }); + + await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled()); + + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-openapi-server", + server_name: "OpenAPI_Server", + alias: "OpenAPI_Server", + url: "https://b.example.com/openapi.json", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.spec_path).toBe("https://b.example.com/openapi.json"); + expect(payload.credentials?.client_id).toBeUndefined(); + expect(payload.credentials?.client_secret).toBeUndefined(); + }); + + it("invalidates the DCR client and OAuth flow when the transport changes after Authorize & Fetch", async () => { + render(); + await selectAntOption("Transport Type", "OpenAPI Spec"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json")).toBeInTheDocument(); + }); + await selectAntOption("Authentication", "OAuth"); + await waitFor(() => { + expect(screen.getByText("OAuth Flow Type")).toBeInTheDocument(); + }); + + const nameInput = document.getElementById("server_name") as HTMLInputElement; + await act(async () => { + fireEvent.change(nameInput, { target: { value: "Transport_Change_Server" } }); + }); + const specInput = screen.getByPlaceholderText("https://petstore3.swagger.io/api/v3/openapi.json"); + await act(async () => { + fireEvent.change(specInput, { target: { value: "https://same.example.com/spec-or-mcp" } }); + }); + + act(() => { + oauthHook.onTokenReceived?.({ access_token: "tok-a" }, { clientId: "client-a", clientSecret: "secret-a" }); + }); + oauthHook.reset.mockClear(); + + await selectAntOption("Transport Type", "Streamable HTTP"); + await waitFor(() => { + expect(screen.getByPlaceholderText("https://your-mcp-server.com")).toBeInTheDocument(); + }); + const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + await act(async () => { + fireEvent.change(urlInput, { target: { value: "https://same.example.com/spec-or-mcp" } }); + }); + + await waitFor(() => expect(oauthHook.reset).toHaveBeenCalled()); + + vi.mocked(networking.createMCPServer).mockResolvedValue({ + server_id: "new-transport-server", + server_name: "Transport_Change_Server", + alias: "Transport_Change_Server", + url: "https://same.example.com/spec-or-mcp", + transport: "http", + auth_type: "oauth2", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + }); + + const submitButton = screen.getByRole("button", { name: "Add MCP Server" }); + await act(async () => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(networking.createMCPServer).toHaveBeenCalledTimes(1)); + const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0]; + expect(payload.url).toBe("https://same.example.com/spec-or-mcp"); + expect(payload.credentials?.client_id).toBeUndefined(); + expect(payload.credentials?.client_secret).toBeUndefined(); + }); + it("omits token_validation from payload when token_validation_json is empty", async () => { vi.mocked(networking.createMCPServer).mockResolvedValue({ server_id: "new-server-oauth", @@ -667,11 +843,13 @@ describe("CreateMCPServer", () => { // Reopen for a brand-new server and enter a different URL without re-authorizing. rerender(); const reopenedUrlInput = screen.getByPlaceholderText("https://your-mcp-server.com"); + oauthHook.reset.mockClear(); await act(async () => { fireEvent.change(reopenedUrlInput, { target: { value: "https://server-b.example.com/mcp" } }); }); // The previous server's token must never be replayed for the new session. + expect(oauthHook.reset).not.toHaveBeenCalled(); expect(usedToken("stale-token-A")).toBe(false); }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index b45c902b9d7..ae7fb2eb5f3 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -89,6 +89,7 @@ const CreateMCPServer: React.FC = ({ const [oauthAccessToken, setOauthAccessToken] = useState(null); const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); + const [authorizedUrl, setAuthorizedUrl] = useState(undefined); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = @@ -105,6 +106,12 @@ const CreateMCPServer: React.FC = ({ const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; + const getOAuthAuthorizationTarget = (values: Record): string | undefined => { + const transport = values.transport || transportType; + const target = transport === TRANSPORT.OPENAPI ? values.spec_path : values.url; + return typeof target === "string" ? target : undefined; + }; + const persistCreateUiState = () => { if (typeof window === "undefined") { return; @@ -171,7 +178,7 @@ const CreateMCPServer: React.FC = ({ env: values.env, }; }, - onTokenReceived: (token) => { + onTokenReceived: (token, registeredClient) => { setOauthAccessToken(token?.access_token ?? null); if (token?.access_token) { @@ -180,9 +187,12 @@ const CreateMCPServer: React.FC = ({ ...(token.refresh_token && { refresh_token: token.refresh_token }), ...(token.expires_in && { expires_in: token.expires_in }), ...(token.scope && { scope: token.scope }), + ...(registeredClient?.clientId && { client_id: registeredClient.clientId }), + ...(registeredClient?.clientSecret && { client_secret: registeredClient.clientSecret }), }; form.setFieldsValue({ credentials }); + setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true))); NotificationsManager.success( "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.", @@ -193,6 +203,15 @@ const CreateMCPServer: React.FC = ({ flowSource: "create", }); + const clearAuthorizedOAuthState = (values: Record) => { + form.resetFields(["credentials", "authorization_url", "token_url", "registration_url"]); + form.setFieldsValue(values); + setOauthAccessToken(null); + clearTools(); + resetOAuthFlow(); + setAuthorizedUrl(undefined); + }; + React.useEffect(() => { if (typeof window === "undefined") { return; @@ -506,12 +525,28 @@ const CreateMCPServer: React.FC = ({ const handleTransportChange = (value: string) => { setTransportType(value); // Clear fields that are not relevant for the selected transport - if (value === "stdio") { - form.setFieldsValue({ url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined }); - } else if (value === TRANSPORT.OPENAPI) { - form.setFieldsValue({ url: undefined, command: undefined, args: undefined, env: undefined }); + const transportValues = + value === "stdio" + ? { url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined } + : value === TRANSPORT.OPENAPI + ? { url: undefined, command: undefined, args: undefined, env: undefined } + : { spec_path: undefined, command: undefined, args: undefined, env: undefined }; + + const nextValues = + authorizedUrl === undefined + ? transportValues + : { + ...transportValues, + credentials: undefined, + authorization_url: undefined, + token_url: undefined, + registration_url: undefined, + }; + + if (authorizedUrl !== undefined) { + clearAuthorizedOAuthState(nextValues); } else { - form.setFieldsValue({ spec_path: undefined, command: undefined, args: undefined, env: undefined }); + form.setFieldsValue(nextValues); } }; @@ -567,11 +602,32 @@ const CreateMCPServer: React.FC = ({ setOauthAccessToken(null); clearTools(); resetOAuthFlow(); + setAuthorizedUrl(undefined); } }, [isModalVisible, form, clearTools, resetOAuthFlow]); const isAdmin = isAdminRole(userRole); + const handleFormValuesChange = (changedValues: Record, allValues: Record) => { + const changedAuthorizationTarget = "url" in changedValues || "spec_path" in changedValues; + if ( + changedAuthorizationTarget && + authorizedUrl !== undefined && + getOAuthAuthorizationTarget(allValues) !== authorizedUrl + ) { + const invalidated = { + credentials: undefined, + authorization_url: changedValues.authorization_url, + token_url: changedValues.token_url, + registration_url: changedValues.registration_url, + }; + clearAuthorizedOAuthState(invalidated); + setFormValues({ ...allValues, ...invalidated }); + return; + } + setFormValues(allValues); + }; + // rendering return ( = ({
setFormValues(allValues)} + onValuesChange={handleFormValuesChange} layout="vertical" className="space-y-6" > @@ -736,7 +792,9 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates }))} + onValuesChange={(updates) => + handleFormValuesChange(updates, { ...form.getFieldsValue(true), ...updates }) + } onKeyToolsChange={setKeyTools} onLogoUrlChange={setLogoUrl} onOAuthDocsUrlChange={setOauthDocsUrl} diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx index d8dce3c29e9..71a6881cd03 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.test.tsx @@ -77,7 +77,7 @@ describe("useMcpOAuthFlow reset", () => { await waitFor(() => expect(result.current.status).toBe("success")); expect(result.current.tokenResponse).toEqual(token); - expect(onTokenReceived).toHaveBeenCalledWith(token); + expect(onTokenReceived).toHaveBeenCalledWith(token, expect.objectContaining({ clientId: "client-1" })); act(() => { result.current.reset(); @@ -88,6 +88,34 @@ describe("useMcpOAuthFlow reset", () => { expect(result.current.error).toBeNull(); }); + it("ignores an in-flight exchange result after reset", async () => { + const token = { access_token: "stale-token" }; + let resolveExchange: (value: typeof token) => void = () => undefined; + const exchangePromise = new Promise((resolve) => { + resolveExchange = resolve; + }); + vi.mocked(networking.exchangeMcpOAuthToken).mockReturnValueOnce(exchangePromise); + seedCompletedRedirect(); + + const onTokenReceived = vi.fn(); + const { result } = renderFlow(onTokenReceived); + + await waitFor(() => expect(result.current.status).toBe("exchanging")); + + act(() => { + result.current.reset(); + }); + + await act(async () => { + resolveExchange(token); + await exchangePromise; + }); + + expect(onTokenReceived).not.toHaveBeenCalled(); + expect(result.current.status).toBe("idle"); + expect(result.current.tokenResponse).toBeNull(); + }); + it("clears the in-flight guard so a callback after a mid-exchange close is not swallowed", async () => { // First exchange hangs, mimicking the modal being closed while the token // endpoint is still in flight. processingRef is left true at that point. @@ -112,6 +140,92 @@ describe("useMcpOAuthFlow reset", () => { const onTokenReceived2 = vi.fn(); rerender({ onTokenReceived: onTokenReceived2 }); - await waitFor(() => expect(onTokenReceived2).toHaveBeenCalledWith(token)); + await waitFor(() => + expect(onTokenReceived2).toHaveBeenCalledWith(token, expect.objectContaining({ clientId: "client-1" })), + ); + }); + + it("passes the DCR-registered client_id and client_secret to onTokenReceived so the created server persists them", async () => { + const token = { access_token: "tok-xyz", refresh_token: "ref-xyz", expires_in: 3600 }; + vi.mocked(networking.exchangeMcpOAuthToken).mockResolvedValue(token); + setSecureItem(RESULT_KEY, JSON.stringify({ state: "state-1", code: "code-1" })); + setSecureItem( + FLOW_STATE_KEY, + JSON.stringify({ + state: "state-1", + codeVerifier: "verifier-1", + serverId: "server-1", + clientId: "dcr-client-xyz", + clientSecret: "dcr-secret-abc", + redirectUri: "https://app.example.com/ui/mcp/oauth/callback", + flowSource: "create", + }), + ); + + const onTokenReceived = vi.fn(); + const { result } = renderFlow(onTokenReceived); + + await waitFor(() => expect(result.current.status).toBe("success")); + expect(onTokenReceived).toHaveBeenCalledWith(token, { + clientId: "dcr-client-xyz", + clientSecret: "dcr-secret-abc", + }); + }); + + it("reuses an existing client_id and does not register a new client (second Authorize & Fetch, same server)", async () => { + vi.mocked(networking.cacheTemporaryMcpServer).mockResolvedValue({ server_id: "server-1" }); + vi.mocked(networking.buildMcpOAuthAuthorizeUrl).mockReturnValue("https://idp.example.com/authorize"); + + const { result } = renderHook(() => + useMcpOAuthFlow({ + accessToken: "admin-token", + getCredentials: () => ({ client_id: "existing-client" }), + getTemporaryPayload: () => ({ + url: "https://server-1.example.com/mcp", + transport: "http", + credentials: { client_id: "existing-client" }, + }), + onTokenReceived: vi.fn(), + flowSource: "create", + }), + ); + + await act(async () => { + await result.current.startOAuthFlow(); + }); + + expect(networking.registerMcpOAuthClient).not.toHaveBeenCalled(); + expect(networking.buildMcpOAuthAuthorizeUrl).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "existing-client" }), + ); + }); + + it("registers a fresh client when no client_id is present (new URL after the derived client is cleared)", async () => { + vi.mocked(networking.cacheTemporaryMcpServer).mockResolvedValue({ server_id: "server-2" }); + vi.mocked(networking.registerMcpOAuthClient).mockResolvedValue({ client_id: "fresh-client" }); + vi.mocked(networking.buildMcpOAuthAuthorizeUrl).mockReturnValue("https://idp.example.com/authorize"); + + const { result } = renderHook(() => + useMcpOAuthFlow({ + accessToken: "admin-token", + getCredentials: () => ({}), + getTemporaryPayload: () => ({ + url: "https://server-2.example.com/mcp", + transport: "http", + credentials: {}, + }), + onTokenReceived: vi.fn(), + flowSource: "create", + }), + ); + + await act(async () => { + await result.current.startOAuthFlow(); + }); + + expect(networking.registerMcpOAuthClient).toHaveBeenCalledTimes(1); + expect(networking.buildMcpOAuthAuthorizeUrl).toHaveBeenCalledWith( + expect.objectContaining({ clientId: "fresh-client" }), + ); }); }); diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index ee253577d9d..da4fd1f5eb4 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -26,7 +26,10 @@ interface UseMcpOAuthFlowOptions { } | undefined; getTemporaryPayload: () => Record | null; - onTokenReceived: (tokenResponse: Record) => void; + onTokenReceived: ( + tokenResponse: Record, + registeredClient?: { clientId?: string; clientSecret?: string }, + ) => void; onBeforeRedirect?: () => void; // Distinguishes which form started the flow (e.g. "create" vs "edit"). Both forms // mount this hook with shared storage keys, so the return handler only processes a @@ -55,6 +58,7 @@ export const useMcpOAuthFlow = ({ const [error, setError] = useState(null); const [tokenResponse, setTokenResponse] = useState | null>(null); const processingRef = useRef(false); + const resetVersionRef = useRef(0); const FLOW_STATE_KEY = "litellm-mcp-oauth-flow-state"; const RESULT_KEY = "litellm-mcp-oauth-result"; @@ -144,9 +148,7 @@ export const useMcpOAuthFlow = ({ } let registeredClient: { clientId?: string; clientSecret?: string } = {}; - const hasPreconfiguredCredentials = Boolean( - temporaryPayload.credentials?.client_id && temporaryPayload.credentials?.client_secret, - ); + const hasPreconfiguredCredentials = Boolean(temporaryPayload.credentials?.client_id); if (!hasPreconfiguredCredentials) { const registration = await registerMcpOAuthClient(accessToken, serverId, { @@ -286,6 +288,8 @@ export const useMcpOAuthFlow = ({ } } + const resetVersion = resetVersionRef.current; + try { if (!flowState || !flowState.state || !flowState.codeVerifier || !flowState.serverId) { throw new Error( @@ -314,22 +318,31 @@ export const useMcpOAuthFlow = ({ accessToken, }); - onTokenReceived(token); + if (resetVersion !== resetVersionRef.current) { + return; + } + + onTokenReceived(token, { clientId: flowState.clientId, clientSecret: flowState.clientSecret }); setTokenResponse(token); setStatus("success"); setError(null); NotificationsManager.success("OAuth token retrieved successfully"); } catch (err) { + if (resetVersion !== resetVersionRef.current) { + return; + } const message = extractErrorMessage(err); setError(message); setStatus("error"); NotificationsManager.error(message); } finally { - clearStoredFlow(); - // Reset processing flag after a delay to allow UI updates - setTimeout(() => { - processingRef.current = false; - }, 1000); + if (resetVersion === resetVersionRef.current) { + clearStoredFlow(); + // Reset processing flag after a delay to allow UI updates + setTimeout(() => { + processingRef.current = false; + }, 1000); + } } }, [onTokenReceived]); @@ -338,6 +351,7 @@ export const useMcpOAuthFlow = ({ }, [resumeOAuthFlow]); const reset = useCallback(() => { + resetVersionRef.current += 1; setStatus("idle"); setError(null); setTokenResponse(null);