diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx
index af81f2713ae..314b45fff6c 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx
@@ -11,13 +11,14 @@ interface PassthroughOAuthFlow {
/**
* Browser-only Authorize & Fetch for the client-forwarded token modes
- * (true_passthrough / oauth_delegate). LiteLLM never stores upstream
- * credentials for these modes, so the token obtained here lives in this
- * browser session only: it is forwarded per-server for the tools preview and
- * allowlist configuration, and is never written to the server row or the
- * per-user credential store. The optional client credentials cover IdPs
- * without dynamic client registration (e.g. a pre-registered Slack app) and
- * ride the temporary authorize session only.
+ * (true_passthrough / oauth_delegate). Tokens are never stored: the token
+ * obtained here lives in this browser session only, forwarded per-server for
+ * the tools preview and allowlist configuration, and is never written to the
+ * server row or the per-user credential store. The optional OAuth client
+ * credentials cover IdPs without dynamic client registration (e.g. a
+ * pre-registered Slack app); unlike the token they ARE saved onto the server
+ * as declared config, so internal users' Authorize relays through the org's
+ * app instead of dead-ending on upstreams that cannot mint clients.
*/
export default function PassthroughAuthorizeSection({
authType,
@@ -35,14 +36,15 @@ export default function PassthroughAuthorizeSection({
return (
- Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview
- tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser
- session only and is never saved to LiteLLM.
+ Callers bring their own upstream token for this auth type, so LiteLLM never stores tokens. To preview tools and
+ configure the tool allowlist, authorize against the upstream here: the token stays in this browser session only
+ and is never saved to LiteLLM. An OAuth app configured below IS saved with the server, so internal users who
+ authorize from the Tools page go through it.
OAuth Client ID (optional, not saved)}
+ label={OAuth Client ID (optional, saved)}
name={["credentials", "client_id"]}
- extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only."
+ extra="Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)."
>
OAuth Client Secret (optional, not saved)}
+ label={OAuth Client Secret (optional, saved)}
name={["credentials", "client_secret"]}
>
{oauthFlow.error}}
{oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && (
- Token held for this browser session. Tools can now be previewed and configured; nothing was saved to LiteLLM.
+ Token held for this browser session. Tools can now be previewed and configured; the token was not saved to
+ LiteLLM.
)}
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 02374a3ffa4..0a3128c6e7f 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
@@ -202,8 +202,8 @@ describe("CreateMCPServer", () => {
await waitFor(() => {
expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument();
});
- expect(screen.getByText("OAuth Client ID (optional, not saved)")).toBeInTheDocument();
- expect(screen.getByText("OAuth Client Secret (optional, not saved)")).toBeInTheDocument();
+ expect(screen.getByText("OAuth Client ID (optional, saved)")).toBeInTheDocument();
+ expect(screen.getByText("OAuth Client Secret (optional, saved)")).toBeInTheDocument();
},
);
@@ -436,6 +436,73 @@ describe("CreateMCPServer", () => {
);
});
+ it.each([
+ ["true_passthrough", "True Passthrough (no LiteLLM auth)"],
+ ["oauth_delegate", "OAuth Delegate (client-supplied upstream token)"],
+ ])(
+ "persists admin-entered OAuth app credentials on create for %s while the token stays browser-held",
+ async (_authType, optionLabel) => {
+ oauthHook.tokenResponse = { access_token: "upstream-tok", token_type: "Bearer" };
+ await selectHttpTransport();
+
+ const user = userEvent.setup({ delay: null });
+ await user.type(getServerNameInput(), "CF_App_Server");
+ await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp");
+
+ await selectAntOption("Authentication", optionLabel);
+
+ // Admin declares the org's pre-registered upstream app; unlike the browser-authorized
+ // token, this is config and must survive onto the server row so internal users'
+ // Tools-page Authorize relays through it (required for non-DCR upstreams like Slack).
+ await user.type(
+ screen.getByPlaceholderText("Leave blank to use dynamic client registration"),
+ "org-app-client-id",
+ );
+ await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret");
+
+ await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy());
+ await act(async () => {
+ oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined);
+ });
+
+ const createdServer = {
+ server_id: "new-cf-app-server",
+ server_name: "CF_App_Server",
+ alias: "CF_App_Server",
+ url: "https://example.com/mcp",
+ transport: "http",
+ auth_type: _authType,
+ created_at: "2024-01-01T00:00:00Z",
+ created_by: "user-1",
+ updated_at: "2024-01-01T00:00:00Z",
+ updated_by: "user-1",
+ };
+ vi.mocked(networking.createMCPServer).mockResolvedValue(createdServer);
+
+ 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];
+
+ // The declared app persists; the browser-authorized token still appears nowhere in the
+ // payload and no per-user DB credential is written.
+ expect(payload.credentials).toEqual({
+ client_id: "org-app-client-id",
+ client_secret: "org-app-secret",
+ });
+ expect(JSON.stringify(payload)).not.toContain("upstream-tok");
+ expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled();
+ expect(setToken).toHaveBeenCalledWith(
+ "new-cf-app-server",
+ expect.objectContaining({ access_token: "upstream-tok" }),
+ undefined,
+ );
+ },
+ );
+
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 eb48fd02474..79db031008e 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
@@ -60,6 +60,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.AWS_SIGV4,
+ AUTH_TYPE.TRUE_PASSTHROUGH,
+ AUTH_TYPE.OAUTH_DELEGATE,
];
const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
@@ -209,7 +211,7 @@ const CreateMCPServer: React.FC = ({
// edit form's onTokenReceived early return.
setAuthorizedIdentity(getOAuthAuthorizationIdentity(form.getFieldsValue(true)));
NotificationsManager.success(
- "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.",
+ "Token held for this browser session. Tools can now be previewed and configured; the token is not saved to LiteLLM.",
);
return;
}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
index adb3e161da5..a1bad0307b0 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import MCPServerEdit from "./mcp_server_edit";
import * as networking from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
@@ -1377,6 +1378,51 @@ describe("MCPServerEdit (OAuth token persistence on save)", () => {
},
);
+ it.each([["true_passthrough"], ["oauth_delegate"]])(
+ "persists admin-entered OAuth app credentials in the update payload for the %s mode",
+ async (authType) => {
+ mockOauth.tokenResponse = { access_token: "cf-tok", expires_in: 1800, token_type: "bearer" };
+ vi.mocked(networking.updateMCPServer).mockResolvedValue({
+ ...interactiveOAuthServer,
+ auth_type: authType,
+ });
+
+ render(
+ ,
+ );
+
+ const user = userEvent.setup({ delay: null });
+ await user.type(
+ screen.getByPlaceholderText("Leave blank to use dynamic client registration"),
+ "org-app-client-id",
+ );
+ await user.type(screen.getByPlaceholderText("Leave blank for public clients / PKCE"), "org-app-secret");
+
+ await act(async () => {
+ fireEvent.click(screen.getAllByRole("button", { name: "Save Changes" })[0]);
+ });
+
+ await waitFor(() => expect(networking.updateMCPServer).toHaveBeenCalledTimes(1));
+ const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
+
+ // The declared app is config and persists onto the row; the browser-held token still never
+ // reaches the payload or the per-user credential store.
+ expect(payload.credentials).toMatchObject({
+ client_id: "org-app-client-id",
+ client_secret: "org-app-secret",
+ });
+ expect(JSON.stringify(payload)).not.toContain("cf-tok");
+ expect(networking.storeMCPOAuthUserCredential).not.toHaveBeenCalled();
+ },
+ );
+
it("forwards a newly authorized browser-held token for tool loading before the form is saved", async () => {
// Regression: fetchTools keyed the browser-held decision off the saved mcpServer.auth_type, so
// after switching the form to true_passthrough and authorizing, the fresh token was not sent as
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
index 7446c96c40e..9b2c5af861f 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx
@@ -56,6 +56,8 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.AWS_SIGV4,
+ AUTH_TYPE.TRUE_PASSTHROUGH,
+ AUTH_TYPE.OAUTH_DELEGATE,
];
export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
@@ -202,7 +204,7 @@ const MCPServerEdit: React.FC = ({
};
setToken(mcpServer.server_id, browserHeldToken, userID);
NotificationsManager.success(
- "Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.",
+ "Token held for this browser session. Tools can now be loaded and configured; the token is not saved to LiteLLM.",
);
return;
}