diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
index 3eb80d3f587..e6cd9eb54a5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/PassthroughAuthorizeSection.tsx
@@ -3,9 +3,11 @@ import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/shared/PasswordInput";
+import { Input } from "@/components/ui/input";
+import { MultiSelect } from "@/components/shared/MultiSelect";
import DcrBridgeToggle from "./DcrBridgeToggle";
import { MountedFormField } from "@/components/common_components/MountedFormField";
-import { textControl } from "./mcpFieldRules";
+import { tagsControl, textControl } from "./mcpFieldRules";
import { credentialAuthClass, isClientForwardedTokenMode } from "@/components/mcp_tools/types";
interface PassthroughOAuthFlow {
@@ -69,6 +71,7 @@ export default function PassthroughAuthorizeSection({
const clientSecretPlaceholder = classUnchanged
? "Leave blank to keep the currently saved secret (if any)"
: "Leave blank for public clients / PKCE";
+ const fieldClassName = "rounded-lg border-border focus:border-info focus:ring-ring";
const clientIdExtra = classUnchanged
? "Set this to make everyone authorize through a specific app; required for upstreams without dynamic client registration (e.g. a pre-registered Slack app)."
: "Switching the auth type discards the previously saved app; enter a client ID here or leave blank to use dynamic client registration.";
@@ -113,6 +116,39 @@ export default function PassthroughAuthorizeSection({
/>
)}
+ Authorization URL (optional)}
+ name="authorization_url"
+ help="Set this when the upstream publishes no OAuth metadata, for example an internal server behind Microsoft Entra. Leave blank to discover it from the upstream."
+ >
+ {(control) => (
+
+ )}
+
+ Token URL (optional)}
+ name="token_url"
+ help="Token endpoint of the same authorization server. Leave blank to discover it from the upstream."
+ >
+ {(control) => (
+
+ )}
+
+ Scopes (optional)}
+ name={["credentials", "scopes"]}
+ help="Scopes requested from the upstream when a user authorizes. Press enter or comma between scopes."
+ >
+ {(control) => }
+
{isEditing && onRemoveStoredAppChange && (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts
index 4573069ff07..3ae846b0308 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/createServerPayload.test.ts
@@ -201,6 +201,22 @@ describe("buildCreateServerPayload credentials", () => {
},
);
+ it.each([["true_passthrough"], ["oauth_delegate"]])(
+ "carries the admin-entered authorization URL, token URL and scopes for %s",
+ (authType) => {
+ const entraServer = {
+ auth_type: authType,
+ authorization_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
+ token_url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
+ credentials: { client_id: "org-app", scopes: ["api://mcp/.default", "offline_access", ""] },
+ };
+ const payload = payloadOf(build(entraServer));
+ expect(payload.authorization_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize");
+ expect(payload.token_url).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/token");
+ expect(payload.credentials).toEqual({ client_id: "org-app", scopes: ["api://mcp/.default", "offline_access"] });
+ },
+ );
+
it("merges the DCR-minted client into an oauth2 payload", () => {
const payload = payloadOf(
build(
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts
index 7db2d0dda36..4219113918b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts
@@ -295,11 +295,6 @@ export const CASES: readonly DifferentialCase[] = [
ui: {},
},
{ label: "credentials absent entirely", values: { ...ROOT, auth_type: "oauth2" }, ui: {} },
- {
- label: "removeStoredApp forces an explicit-null app write",
- values: { ...ROOT, auth_type: "true_passthrough", credentials: { ...CREDS } },
- ui: { removeStoredApp: true, mcpServer: { ...SERVER, auth_type: "true_passthrough" } },
- },
{
label: "removeStoredApp ignored outside a client-forwarded mode",
values: { ...ROOT, auth_type: "api_key", credentials: { ...CREDS } },
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
index 8a9bac582d6..ee5cce2d0d2 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.test.ts
@@ -398,3 +398,61 @@ it.each([
expect(result.payload.credentials?.client_secret).toBe(useDcr ? null : "configured-secret");
expect(result.payload.credentials?.dcr_issuer).toBe(useDcr ? "https://new.example" : undefined);
});
+
+it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
+ "keeps the endpoints typed for %s when the server was saved as oauth2",
+ (authType) => {
+ const authorizationUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize";
+ const tokenUrl = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token";
+ const values: EditServerFormValues = {
+ auth_type: authType,
+ transport: "http",
+ authorization_url: authorizationUrl,
+ token_url: tokenUrl,
+ };
+ const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.OAUTH2 } };
+ const result = buildEditServerPayload(values, ui);
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
+ expect(result.payload.authorization_url).toBe(authorizationUrl);
+ expect(result.payload.token_url).toBe(tokenUrl);
+ expect(result.payload.issuer).toBeNull();
+ expect(result.payload.registration_url).toBeNull();
+ },
+);
+
+it.each([AUTH_TYPE.OAUTH_DELEGATE, AUTH_TYPE.TRUE_PASSTHROUGH])(
+ "writes an explicit null when the admin removes every scope on a %s server",
+ (authType) => {
+ const values: EditServerFormValues = {
+ auth_type: authType,
+ transport: "http",
+ credentials: { client_id: "org-app", scopes: [""] },
+ };
+ const ui: EditServerUiState = { ...baseUi, mcpServer: { ...baseUi.mcpServer, auth_type: authType } };
+ const result = buildEditServerPayload(values, ui);
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
+ expect(result.payload.credentials).toEqual({ client_id: "org-app", scopes: null });
+ },
+);
+
+it.each([
+ [["read"], ["read"]],
+ [[""], null],
+])("keeps the scope edit %j when the saved app is removed on a client-forwarded server", (typed, expected) => {
+ const values: EditServerFormValues = {
+ auth_type: AUTH_TYPE.TRUE_PASSTHROUGH,
+ transport: "http",
+ credentials: { client_id: "cid", client_secret: "csec", scopes: typed },
+ };
+ const ui: EditServerUiState = {
+ ...baseUi,
+ removeStoredApp: true,
+ mcpServer: { ...baseUi.mcpServer, auth_type: AUTH_TYPE.TRUE_PASSTHROUGH },
+ };
+ const result = buildEditServerPayload(values, ui);
+ expect(result.kind).toBe("ok");
+ if (result.kind !== "ok") throw new Error("Expected valid MCP server payload");
+ expect(result.payload.credentials).toEqual({ scopes: expected, client_id: null, client_secret: null });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts
index f166fa20d1f..081063854c0 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.ts
@@ -214,6 +214,21 @@ const buildCredentials = (credentialValues: unknown): Readonly
+ isClientForwardedTokenMode(authType) ? {} : { authorization_url: null, token_url: null };
+
+const scopesCleared = (credentialValues: unknown): boolean => {
+ if (!credentialValues || typeof credentialValues !== "object") return false;
+ const scopes: unknown = (credentialValues as { scopes?: unknown }).scopes;
+ return Array.isArray(scopes) && scopes.every((scope: unknown) => scope == null || scope === "");
+};
+
+const withClearedScopes = (
+ credentials: Readonly> | undefined,
+ credentialValues: unknown,
+): Readonly> | undefined =>
+ scopesCleared(credentialValues) ? { ...(credentials ?? {}), scopes: null } : credentials;
+
interface CredentialsEntryInput {
readonly dcrClient?: McpDcrCredentials | null;
readonly oauthFlow?: string;
@@ -239,7 +254,7 @@ const resolveCredentialsEntry = ({
return { credentials: { ...credentials, ...dcrClient } };
}
if (removeStoredApp && isClientForwardedTokenMode(authType)) {
- return { credentials: { client_id: null, client_secret: null } };
+ return { credentials: { ...(credentials ?? {}), client_id: null, client_secret: null } };
}
if (includeCredentials && credentials && Object.keys(credentials).length > 0) {
return { credentials };
@@ -334,7 +349,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
// Client-forwarded rows persist ONLY the declared app; strip any token material lingering in the
// form (e.g. from a prior oauth2 authorize this session) so it can never reach the row.
const submitCredentials = isClientForwardedTokenMode(restValues.auth_type)
- ? preservedAdminCredentials(credentialsPayload)
+ ? withClearedScopes(preservedAdminCredentials(credentialsPayload), credentialValues)
: credentialsPayload;
const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type);
@@ -355,7 +370,7 @@ export const buildEditServerPayload = (values: EditServerFormValues, ui: EditSer
stdio_config: undefined,
env_json: undefined,
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
- ? { issuer: null, authorization_url: null, token_url: null, registration_url: null }
+ ? { issuer: null, ...unmountedOauth2Endpoints(restValues.auth_type), registration_url: null }
: {}),
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx
index 7aed6488b54..9f91c06e9e5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.integration.test.tsx
@@ -264,6 +264,8 @@ const CASES: readonly Case[] = [
...EXPECTED_BASE,
auth_type: "true_passthrough",
credentials: { client_id: "cid", client_secret: "csec" },
+ authorization_url: undefined,
+ token_url: undefined,
},
},
{
@@ -273,6 +275,8 @@ const CASES: readonly Case[] = [
...EXPECTED_BASE,
auth_type: "oauth_delegate",
credentials: { client_id: "cid", client_secret: "csec" },
+ authorization_url: undefined,
+ token_url: undefined,
},
},
{
@@ -283,7 +287,13 @@ const CASES: readonly Case[] = [
{
label: "dcr_bridge stays true for a client-forwarded mode",
server: { ...BASE, auth_type: "true_passthrough", dcr_bridge: true },
- expected: { ...EXPECTED_BASE, auth_type: "true_passthrough", dcr_bridge: true },
+ expected: {
+ ...EXPECTED_BASE,
+ auth_type: "true_passthrough",
+ dcr_bridge: true,
+ authorization_url: undefined,
+ token_url: undefined,
+ },
},
{
label: "an existing allowed_tools list enforces the allowlist and round-trips",
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx
index 10a1bb4b669..f555fc1efa9 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.test.tsx
@@ -333,6 +333,14 @@ describe("preservedAdminCredentials vs preservedDeclaredAppCredentials", () => {
expect(preservedAdminCredentials(credentials)).toEqual({ client_id: "cid", upstream_resource: "auto" });
});
+ it("preserves admin-typed scopes and drops blank ones", () => {
+ expect(preservedAdminCredentials({ client_id: "cid", scopes: ["api://mcp/.default", ""] })).toEqual({
+ client_id: "cid",
+ scopes: ["api://mcp/.default"],
+ });
+ expect(preservedAdminCredentials({ client_id: "cid", scopes: [] })).toEqual({ client_id: "cid" });
+ });
+
// The two helpers answer different questions and must not be collapsed: "has the admin declared an
// OAuth app" gates the app-may-not-match-upstream warning, so a resource-only server must read as
// having no declared app.
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
index 2a6b3f2793b..22fd64f10d8 100644
--- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
+++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx
@@ -181,10 +181,23 @@ export const preservedDeclaredAppCredentials = (
// Everything the admin typed into `credentials` and nothing minted: the declared app plus the config
// keys. This is what must survive the invalidation reset and what a client-forwarded row may persist,
// so dropping a key from here silently discards admin input on an unrelated edit.
+const adminScopes = (value: unknown): string[] | undefined => {
+ if (!Array.isArray(value)) return undefined;
+ const kept = value.filter((scope): scope is string => typeof scope === "string" && scope !== "");
+ return kept.length > 0 ? kept : undefined;
+};
+
export const preservedAdminCredentials = (
credentials: Record | null | undefined,
-): Record | undefined =>
- pickStringCredentials(credentials, [...DECLARED_APP_CREDENTIAL_KEYS, ...ADMIN_CONFIG_CREDENTIAL_KEYS]);
+): Record | undefined => {
+ const strings = pickStringCredentials(credentials, [
+ ...DECLARED_APP_CREDENTIAL_KEYS,
+ ...ADMIN_CONFIG_CREDENTIAL_KEYS,
+ ]);
+ const scopes = adminScopes(credentials?.scopes);
+ if (scopes === undefined) return strings;
+ return { ...(strings ?? {}), scopes };
+};
// Drop minted token keys, keeping everything else (the declared app plus any non-token config).
export const withoutMintedTokenCredentials = (