feat(mcp): end-user consent panel for delegated-OBO IdP connections

Adds the read side of the delegated-OBO consent surface on top of the capture
flow: GET /v1/mcp/idp/providers lists the IdP providers a user can connect
(public fields only), and GET /v1/mcp/idp/grants returns the signed-in user's
connected grants reduced to identity and lifecycle (never the stored tokens).
list_user_idp_grants reads the user's idp_grant rows, mirroring
list_user_oauth_credentials. A self-hiding Delegated access panel on the MCP
Servers Connect tab lists each provider with its connected status and a Connect
button that opens the existing /v1/mcp/idp/authorize flow.
This commit is contained in:
Tin Chi Lo 2026-07-21 20:59:17 -07:00
parent ceb8845679
commit c7209892da
8 changed files with 263 additions and 2 deletions

View file

@ -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)."""

View file

@ -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)

View file

@ -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,

View file

@ -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 ────────────────────────────────────────────

View file

@ -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<IdpDelegationPanelProps> = ({ accessToken }) => {
const proxyBaseUrl = getProxyBaseUrl();
const [providers, setProviders] = useState<IdpProvider[] | null>(null);
const [grantsByKey, setGrantsByKey] = useState<Record<string, IdpGrant>>({});
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 (
<Card className="mt-6 border border-gray-200">
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div className="flex items-start justify-between">
<div>
<Title level={5} style={{ marginBottom: 4 }}>
Delegated access
</Title>
<Text type="secondary">
Connect your identity provider so agents you have authorized can act on your behalf when they call MCP
tools
</Text>
</div>
<Button icon={<RefreshCw size={14} />} onClick={() => void loadGrants()}>
Refresh
</Button>
</div>
<List
dataSource={providers}
renderItem={(provider) => {
const grant = grantsByKey[provider.grant_key];
const connected = Boolean(grant);
return (
<List.Item
actions={[
<Button
key="connect"
type={connected ? "default" : "primary"}
icon={<LinkIcon size={14} />}
onClick={() => connect(provider)}
>
{connected ? "Reconnect" : "Connect"}
</Button>,
]}
>
<List.Item.Meta
title={
<Space>
<span>{provider.label}</span>
{connected && (
<Tag color="green" icon={<CheckCircle2 size={12} className="inline align-text-bottom" />}>
Connected
</Tag>
)}
{connected && !grant.offline_access && <Tag color="orange">no refresh token</Tag>}
</Space>
}
description={connected ? describeGrant(grant) : "not connected"}
/>
</List.Item>
);
}}
/>
</Space>
</Card>
);
};
export default IdpDelegationPanel;

View file

@ -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<FeatureCardProps> = ({
interface MCPConnectProps {
currentServerAccessGroups?: string[];
accessToken?: string | null;
}
const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = [] }) => {
const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = [], accessToken }) => {
const proxyBaseUrl = getProxyBaseUrl();
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [serverHeaders, setServerHeaders] = useState<Record<string, string[]>>({
@ -532,6 +534,7 @@ const MCPConnect: React.FC<MCPConnectProps> = ({ currentServerAccessGroups = []
</TabPanel>
</TabPanels>
</TabGroup>
{accessToken && <IdpDelegationPanel accessToken={accessToken} />}
</Space>
</div>
);

View file

@ -655,7 +655,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<MCPToolsetsTab accessToken={accessToken} userRole={userRole} />
</TabPanel>
<TabPanel>
<MCPConnect />
<MCPConnect accessToken={accessToken} />
</TabPanel>
{isAdminRole(userRole) && (
<TabPanel>

View file

@ -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
*/