mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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:
parent
ceb8845679
commit
c7209892da
8 changed files with 263 additions and 2 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue