From e29e24e628661e1faf75955e66d185386ddf5a4d Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 23:09:19 -0700 Subject: [PATCH] fix(ui): keep the browser-authorized token out of form.credentials for the pass-through modes The create form wrote the upstream token obtained by Authorize & Fetch into form.credentials for every mode, so for true_passthrough / oauth_delegate the browser-held token leaked into the OAuth flow's getCredentials (preview requests) and the redirect-persist cache, and was a step away from server-level credential persistence. onTokenReceived now early-returns for the client-forwarded modes, holding the token only in local state for preview (mirroring the edit form), instead of writing it into form.credentials. --- .../mcp_tools/create_mcp_server.test.tsx | 25 +++++++++++ .../mcp_tools/create_mcp_server.tsx | 45 ++++++++++++------- 2 files changed, 54 insertions(+), 16 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 ca94aae20e9..eecbc253b6b 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 @@ -30,6 +30,7 @@ const oauthHook = vi.hoisted(() => ({ onTokenReceived: null as | ((token: Record | null, registeredClient?: { clientId?: string; clientSecret?: string }) => void) | null, + getCredentials: null as (() => Record | undefined) | null, })); vi.mock("@/hooks/useMcpOAuthFlow", () => ({ useMcpOAuthFlow: (opts: { @@ -37,8 +38,10 @@ vi.mock("@/hooks/useMcpOAuthFlow", () => ({ token: Record | null, registeredClient?: { clientId?: string; clientSecret?: string }, ) => void; + getCredentials?: () => Record | undefined; }) => { oauthHook.onTokenReceived = opts.onTokenReceived; + oauthHook.getCredentials = opts.getCredentials ?? null; return { startOAuthFlow: vi.fn(), status: "idle", @@ -349,6 +352,28 @@ describe("CreateMCPServer", () => { expect(payload.credentials).toEqual({ auth_value: "my-secret-key" }); }); + it("does not write the browser-authorized token into form.credentials for true_passthrough", async () => { + await selectHttpTransport(); + + const user = userEvent.setup({ delay: null }); + await user.type(getServerNameInput(), "PT_Server"); + await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp"); + + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + // Simulate the browser Authorize & Fetch flow handing back an upstream token. + await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy()); + await act(async () => { + oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined); + }); + + // For a browser-only mode the token must never land in form.credentials, which the OAuth flow's + // getCredentials reads for preview requests and the redirect-persist cache serializes. Without + // the guard, onTokenReceived writes it here and this returns { access_token: "upstream-tok" }. + const credentials = oauthHook.getCredentials?.() ?? {}; + expect(credentials.access_token).toBeUndefined(); + }); + it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); 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 7d160407d02..00eda92ba25 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 @@ -200,23 +200,36 @@ const CreateMCPServer: React.FC = ({ onTokenReceived: (token, registeredClient) => { setOauthAccessToken(token?.access_token ?? null); - if (token?.access_token) { - const credentials = { - access_token: token.access_token, - ...(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.", - ); + if (!token?.access_token) { + return; } + + if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) { + // Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview + // and committed to sessionStorage on submit; it must never be written into form.credentials, + // which would persist it as server-level credentials on the created server row. Mirrors the + // edit form's onTokenReceived early return. + NotificationsManager.success( + "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", + ); + return; + } + + const credentials = { + access_token: token.access_token, + ...(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.", + ); }, onBeforeRedirect: persistCreateUiState, flowSource: "create",