diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index a44ca342d82..1d73b36a361 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -1246,6 +1246,17 @@ async def get_user_idp_grant( return _decode_idp_grant_payload(row.credential_b64) +async def list_user_idp_grants(prisma_client: PrismaClient, user_id: str) -> list[dict[str, Any]]: + """Return the user's IdP-grant payloads (for the consent panel), each tagged with its ``idp_key``. + + Payloads still carry the access/refresh tokens; the caller must strip those before returning them + to a client (the consent panel only needs the IdP identity, connected-at, and expiry). + """ + rows = await MCPUserCredentialsRepository(prisma_client).table.find_many(where={"user_id": user_id}) + decoded = ((row.server_id, _decode_idp_grant_payload(row.credential_b64)) for row in rows) + return [{**payload, "idp_key": idp_key} for idp_key, payload in decoded if payload is not None] + + def _decrypted_credential_field(creds: Dict[str, object], field: str) -> object: """Return one credential field decrypted with the global salt key; non-string and legacy plaintext values come back unchanged (decrypt_value_helper returns the original on failure).""" diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/idp_oauth_config.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/idp_oauth_config.py index db4453716f6..f88d4508b65 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/idp_oauth_config.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/idp_oauth_config.py @@ -36,6 +36,7 @@ class IdpOAuthProvider(BaseModel): client_id: str client_secret: SecretStr scopes: tuple[str, ...] = ("openid", "offline_access") + label: str | None = None @property def grant_key(self) -> str: @@ -59,6 +60,11 @@ class IdpOAuthProviderRegistry: def get_by_token_url(self, token_url: str) -> IdpOAuthProvider | None: return self._by_key.get(idp_grant_key(token_url)) + def all(self) -> tuple[IdpOAuthProvider, ...]: + """Every configured provider (for listing what a user can connect); carries no secrets when + the caller serializes only the public fields.""" + return tuple(self._by_key.values()) + def __len__(self) -> int: return len(self._by_key) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 2d38acfc184..dfb017d8c94 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1860,6 +1860,48 @@ if MCP_AVAILABLE: ) return JSONResponse({"status": "connected", "offline_access": grant.refresh_token is not None}) + @router.get("/idp/providers", include_in_schema=False) + async def mcp_idp_providers(user_api_key_dict: UserAPIKeyAuth = _idp_consent_auth_dep) -> JSONResponse: + """List the configured IdP providers a user can connect for delegated OBO. Public fields only + (no client secret): the token endpoint that identifies the provider, its display label, and the + grant key a connected grant is stored under (for the panel to match connected status).""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.idp_oauth_config import ( # noqa: PLC0415 # lazy: avoids cycle + get_idp_oauth_registry, + ) + + providers = [ + { + "token_url": provider.token_url, + "label": provider.label or provider.token_url, + "grant_key": provider.grant_key, + } + for provider in get_idp_oauth_registry().all() + ] + return JSONResponse({"providers": providers}) + + @router.get("/idp/grants", include_in_schema=False) + async def mcp_idp_grants(user_api_key_dict: UserAPIKeyAuth = _idp_consent_auth_dep) -> JSONResponse: + """The signed-in user's connected IdP grants, for the consent panel. Returns only the grant's + identity and lifecycle (never the stored access/refresh tokens).""" + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # lazy: avoids cycle + list_user_idp_grants, + ) + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # lazy: runtime global + + user_id = user_api_key_dict.user_id + if not user_id or prisma_client is None: + return JSONResponse({"grants": []}) + grants = [ + { + "grant_key": grant["idp_key"], + "connected_at": grant.get("connected_at"), + "expires_at": grant.get("expires_at"), + "offline_access": bool(grant.get("refresh_token")), + } + for grant in await list_user_idp_grants(prisma_client, user_id) + ] + return JSONResponse({"grants": grants}) + @router.post( "/server/oauth/{server_id}/register", include_in_schema=False, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index c795b5f31bd..e980261db31 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.db import ( get_user_idp_grant, get_user_oauth_credential, is_oauth_credential_expired, + list_user_idp_grants, list_user_oauth_credentials, resolve_valid_user_oauth_token, rotate_mcp_user_credentials_master_key, @@ -595,6 +596,32 @@ async def test_idp_grant_is_not_surfaced_as_an_oauth2_credential(): assert await list_user_oauth_credentials(prisma, "alice") == [] +@pytest.mark.asyncio +async def test_list_user_idp_grants_returns_only_idp_rows_tagged_by_key(): + # The consent panel lists a user's connected IdPs; the same table also holds oauth2 (connected + # server) rows, so the listing must return only the idp_grant rows, each tagged with its idp_key, + # and never surface an oauth2 row as a phantom IdP connection. + prisma = _make_prisma_with_existing(row=None) + await store_user_idp_grant(prisma, "alice", "idp::https://idp/token", "idp-at", refresh_token="idp-rt") + idp_blob = _stored_value(prisma) + await store_user_oauth_credential(prisma, "alice", "server-1", "oauth-at") + oauth_blob = _stored_value(prisma) + + idp_row = MagicMock() + idp_row.server_id = "idp::https://idp/token" + idp_row.credential_b64 = idp_blob + oauth_row = MagicMock() + oauth_row.server_id = "server-1" + oauth_row.credential_b64 = oauth_blob + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[oauth_row, idp_row]) + + grants = await list_user_idp_grants(prisma, "alice") + assert len(grants) == 1 + assert grants[0]["idp_key"] == "idp::https://idp/token" + assert grants[0]["access_token"] == "idp-at" + assert grants[0]["type"] == "idp_grant" + + # ── _decode_user_credential helper ──────────────────────────────────────────── diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdpDelegationPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdpDelegationPanel.tsx new file mode 100644 index 00000000000..55e87afff98 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdpDelegationPanel.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Button, Card, List, Space, Tag, Typography } from "antd"; +import { CheckCircle2, Link as LinkIcon, RefreshCw } from "lucide-react"; +import { getMcpIdpGrantsCall, getMcpIdpProvidersCall, getProxyBaseUrl } from "@/components/networking"; + +const { Title, Text } = Typography; + +interface IdpProvider { + token_url: string; + label: string; + grant_key: string; +} + +interface IdpGrant { + grant_key: string; + connected_at: string | null; + expires_at: string | null; + offline_access: boolean; +} + +interface IdpDelegationPanelProps { + accessToken: string; +} + +const describeGrant = (grant: IdpGrant): string => { + const parts: string[] = []; + if (grant.connected_at) { + parts.push(`connected ${new Date(grant.connected_at).toLocaleString()}`); + } + if (grant.expires_at) { + parts.push(`access token expires ${new Date(grant.expires_at).toLocaleString()}`); + } + return parts.length > 0 ? parts.join("; ") : "connected"; +}; + +const IdpDelegationPanel: React.FC = ({ accessToken }) => { + const proxyBaseUrl = getProxyBaseUrl(); + const [providers, setProviders] = useState(null); + const [grantsByKey, setGrantsByKey] = useState>({}); + + const loadGrants = useCallback(async () => { + try { + const res = await getMcpIdpGrantsCall(accessToken); + const grants: IdpGrant[] = res?.grants ?? []; + setGrantsByKey(Object.fromEntries(grants.map((grant) => [grant.grant_key, grant]))); + } catch (error) { + console.error("Failed to refresh MCP IdP grants:", error); + } + }, [accessToken]); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const res = await getMcpIdpProvidersCall(accessToken); + if (!cancelled) { + setProviders(res?.providers ?? []); + } + await loadGrants(); + } catch (error) { + console.error("Failed to load MCP IdP providers:", error); + if (!cancelled) { + setProviders([]); + } + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [accessToken, loadGrants]); + + useEffect(() => { + const onFocus = () => { + void loadGrants(); + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [loadGrants]); + + if (!providers || providers.length === 0) { + return null; + } + + const connect = (provider: IdpProvider) => { + const url = `${proxyBaseUrl}/v1/mcp/idp/authorize?token_url=${encodeURIComponent(provider.token_url)}`; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + return ( + + +
+
+ + Delegated access + + + Connect your identity provider so agents you have authorized can act on your behalf when they call MCP + tools + +
+ +
+ { + const grant = grantsByKey[provider.grant_key]; + const connected = Boolean(grant); + return ( + } + onClick={() => connect(provider)} + > + {connected ? "Reconnect" : "Connect"} + , + ]} + > + + {provider.label} + {connected && ( + }> + Connected + + )} + {connected && !grant.offline_access && no refresh token} +
+ } + description={connected ? describeGrant(grant) : "not connected"} + /> + + ); + }} + /> + +
+ ); +}; + +export default IdpDelegationPanel; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx index 7bdfd9c6b8f..0ba4f2fdf01 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_connect.tsx @@ -6,6 +6,7 @@ import { TabPanel, TabPanels, TabGroup, TabList, Tab, Title as TremorTitle, Text import { CopyIcon, Code, Terminal, Globe, CheckIcon, ExternalLinkIcon, KeyIcon, ServerIcon, Zap } from "lucide-react"; import { getProxyBaseUrl } from "@/components/networking"; import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; +import IdpDelegationPanel from "./IdpDelegationPanel"; const { Title, Text } = Typography; const { Panel } = Collapse; @@ -112,9 +113,10 @@ const FeatureCard: React.FC = ({ interface MCPConnectProps { currentServerAccessGroups?: string[]; + accessToken?: string | null; } -const MCPConnect: React.FC = ({ currentServerAccessGroups = [] }) => { +const MCPConnect: React.FC = ({ currentServerAccessGroups = [], accessToken }) => { const proxyBaseUrl = getProxyBaseUrl(); const [copiedStates, setCopiedStates] = useState>({}); const [serverHeaders, setServerHeaders] = useState>({ @@ -532,6 +534,7 @@ const MCPConnect: React.FC = ({ currentServerAccessGroups = [] + {accessToken && } ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx index f186fef22da..13bfd7682dc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_servers.tsx @@ -655,7 +655,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) - + {isAdminRole(userRole) && ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index d44a491b840..986d41429e1 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -18,6 +18,30 @@ export const getCallbackConfigsCall = async (accessToken: string) => { } }; +export const getMcpIdpProvidersCall = async (accessToken: string) => { + /** + * List the IdP providers a user can connect for delegated (agent-on-behalf-of) MCP access + */ + try { + return await apiClient.get(`/v1/mcp/idp/providers`, { accessToken }); + } catch (error) { + console.error("Failed to get MCP IdP providers:", error); + throw error; + } +}; + +export const getMcpIdpGrantsCall = async (accessToken: string) => { + /** + * The signed-in user's connected IdP grants (identity + lifecycle only, never the tokens) + */ + try { + return await apiClient.get(`/v1/mcp/idp/grants`, { accessToken }); + } catch (error) { + console.error("Failed to get MCP IdP grants:", error); + throw error; + } +}; + /** * Helper file for calls being made to proxy */