Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Timestamp sorts before some already-applied migrations; this is safe: the
-- runner is `prisma migrate deploy`, which applies every pending migration
-- regardless of name order (utils.py has an informational check for exactly
-- this), and IF NOT EXISTS keeps a re-apply idempotent.
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "token_exchange_endpoint" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "audience" TEXT;
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "subject_token_type" TEXT;
5 changes: 5 additions & 0 deletions litellm-proxy-extras/litellm_proxy_extras/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
Expand Down
8 changes: 8 additions & 0 deletions litellm/models/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. ``audience`` is named for the RFC's
# request parameter (token-exchange only); RFC 8707 resource indicators are a
# separate concept named ``resource`` in the v2 egress types. A null
# ``subject_token_type`` means DEFAULT_SUBJECT_TOKEN_TYPE (litellm.types.mcp),
# applied at the egress build sites.
token_exchange_endpoint: Optional[str] = None
Comment thread
veria-ai[bot] marked this conversation as resolved.
audience: Optional[str] = None
subject_token_type: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,14 @@
build_token_endpoint_client_auth,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE

if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer

# RFC 8693 grant type constant
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"

DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"


class TokenExchangeHandler:
"""Handles OAuth 2.0 Token Exchange (RFC 8693) for MCP servers.
Expand Down
86 changes: 78 additions & 8 deletions litellm/proxy/_experimental/mcp_server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@
if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer

_AUTH_FLOW_SCOPED_FIELDS: frozenset = frozenset(
{
"authorization_url",
"token_url",
"registration_url",
"oauth2_flow",
"token_exchange_endpoint",
"audience",
"subject_token_type",
}
)

# Token-exchange settings with dedicated columns that also exist on
# ``MCPCredentials`` as a legacy shape (rows and REST callers that predate the
# columns). Every write lifts blob values into the columns and strips them from
# the stored blob, so the read-time ``column or blob`` fallback only serves rows
# the current code has never written — a cleared column can then never be
# silently resurrected by a stale blob copy. These keys are stored plaintext
# (endpoints/identifiers, not secrets), so values lift as-is.
_TOKEN_EXCHANGE_COLUMN_FIELDS: frozenset = frozenset(
{
"token_exchange_endpoint",
"audience",
"subject_token_type",
}
)


def _is_global_env_var_scope(scope: Any) -> bool:
"""``scope="user"`` entries are placeholders the user fills in; everything
Expand Down Expand Up @@ -241,6 +268,14 @@ def _prepare_mcp_server_data(
# Handle credentials serialization
credentials = data_dict.get("credentials")
if credentials is not None:
# Lift legacy blob-shaped token-exchange settings into their dedicated
# columns (an explicit top-level value wins, including an explicit
# null) and strip them from the blob so it never seeds the read-time
# fallback for rows written by current code.
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
blob_value = credentials.pop(te_field, None)
if blob_value is not None and te_field not in data_dict:
data_dict[te_field] = blob_value
data_dict["credentials"] = encrypt_credentials(credentials=credentials, encryption_key=_get_salt_key())
data_dict["credentials"] = safe_dumps(data_dict["credentials"])

Expand Down Expand Up @@ -603,19 +638,41 @@ async def update_mcp_server(
# Pre-fetch existing record once if we need it for auth_type or credential logic
existing = None
has_credentials = "credentials" in data_dict and data_dict["credentials"] is not None
if data.auth_type or has_credentials:
# An explicit token-exchange column write (set or clear) also migrates the
# legacy blob copies below, so the existing row is needed for those updates.
explicit_te_write = bool(_TOKEN_EXCHANGE_COLUMN_FIELDS & data_dict.keys())
if data.auth_type or has_credentials or explicit_te_write:
existing = await MCPServerRepository(prisma_client).table.find_unique(where={"server_id": data.server_id})

auth_type_changed = bool(
data.auth_type and existing and existing.auth_type is not None and existing.auth_type != data.auth_type
)

# Clear stale credentials when auth_type changes but no new credentials provided
if (
data.auth_type
and "credentials" not in data_dict
and existing
and existing.auth_type is not None
and existing.auth_type != data.auth_type
):
if auth_type_changed and "credentials" not in data_dict:
data_dict["credentials"] = None

if auth_type_changed:
data_dict.update({field: None for field in _AUTH_FLOW_SCOPED_FIELDS if field not in data_dict})

# An explicit column write that does not touch credentials must still migrate
# the row's legacy blob copies: lift values for columns the caller left
# untouched, strip every copy from the blob. Without this, clearing a column
# (e.g. to re-enable RFC 9728/8414 discovery) would leave the blob copy in
# place, and the next credentials update's migrate-on-write would silently
# repopulate the column the admin just cleared. (When credentials ARE in the
# update, the merge below performs the same migration.)
if explicit_te_write and "credentials" not in data_dict and existing is not None and existing.credentials:
existing_creds = (
json.loads(existing.credentials) if isinstance(existing.credentials, str) else dict(existing.credentials)
)
if _TOKEN_EXCHANGE_COLUMN_FIELDS & existing_creds.keys():
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
legacy_value = existing_creds.pop(te_field, None)
if legacy_value is not None and te_field not in data_dict and getattr(existing, te_field, None) is None:
data_dict[te_field] = legacy_value
data_dict["credentials"] = safe_dumps(existing_creds)

# Merge credentials: preserve existing fields not present in the update.
# Without this, a partial credential update (e.g. changing only region)
# would wipe encrypted secrets that the UI cannot display back.
Expand All @@ -638,6 +695,19 @@ async def update_mcp_server(
)
# New values override existing; existing keys not in update are preserved
merged = {**existing_creds, **new_creds}
# Migrate-on-write for legacy rows: token-exchange settings the
# old blob shape carried move to their dedicated columns (unless
# the caller set the column this update, or the row already has
# one) and are never re-persisted in the blob. Stored plaintext,
# so the merged value lifts as-is.
for te_field in _TOKEN_EXCHANGE_COLUMN_FIELDS:
legacy_value = merged.pop(te_field, None)
if (
legacy_value is not None
and te_field not in data_dict
and getattr(existing, te_field, None) is None
):
data_dict[te_field] = legacy_value
Comment thread
tin-berri marked this conversation as resolved.
Comment thread
tin-berri marked this conversation as resolved.
data_dict["credentials"] = safe_dumps(merged)

# Add audit fields
Expand Down
26 changes: 18 additions & 8 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
from litellm.proxy.utils import ProxyLogging, get_server_root_path
from litellm.repositories.table_repositories import MCPServerRepository
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPAuth, MCPStdioConfig
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth, MCPStdioConfig
from litellm.types.mcp_server.mcp_server_manager import (
MCPInfo,
MCPOAuthMetadata,
Expand Down Expand Up @@ -972,7 +972,7 @@ async def load_servers_from_config(
audience=server_config.get("audience", None),
subject_token_type=server_config.get(
"subject_token_type",
"urn:ietf:params:oauth:token-type:access_token",
DEFAULT_SUBJECT_TOKEN_TYPE,
),
token_exchange_profile=server_config.get("token_exchange_profile", "rfc8693"),
allow_sampling=bool(server_config.get("allow_sampling", False)),
Expand Down Expand Up @@ -1283,7 +1283,8 @@ async def build_mcp_server_from_table(
(auth_type == MCPAuth.oauth2 and not mcp_server.authorization_url)
or self._obo_needs_endpoint_discovery(
auth_type,
credentials_dict.get("token_exchange_endpoint") if credentials_dict else None,
mcp_server.token_exchange_endpoint
Comment thread
tin-berri marked this conversation as resolved.
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
mcp_server.token_url,
)
)
Expand Down Expand Up @@ -1349,11 +1350,14 @@ async def build_mcp_server_from_table(
aws_role_name=aws_creds.get("aws_role_name"),
aws_session_name=aws_creds.get("aws_session_name"),
instructions=mcp_server.instructions,
# Token Exchange (OBO) fields — read from credentials JSON blob
token_exchange_endpoint=(credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
audience=(credentials_dict.get("audience") if credentials_dict else None),
subject_token_type=(credentials_dict.get("subject_token_type") if credentials_dict else None)
or "urn:ietf:params:oauth:token-type:access_token",
# Token exchange (OBO) fields: dedicated columns, with the credentials blob as a
# back-compat fallback for servers persisted before the columns existed.
token_exchange_endpoint=mcp_server.token_exchange_endpoint
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
audience=mcp_server.audience or (credentials_dict.get("audience") if credentials_dict else None),
subject_token_type=mcp_server.subject_token_type
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
or DEFAULT_SUBJECT_TOKEN_TYPE,
token_exchange_profile=(credentials_dict.get("token_exchange_profile") if credentials_dict else None)
or "rfc8693",
timeout=getattr(mcp_server, "timeout", None),
Expand Down Expand Up @@ -4630,6 +4634,9 @@ async def _noop(session):
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=server.oauth2_flow,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,
subject_token_type=server.subject_token_type,
allow_all_keys=server.allow_all_keys,
instructions=server.instructions,
timeout=server.timeout,
Comment thread
tin-berri marked this conversation as resolved.
Expand Down Expand Up @@ -4734,6 +4741,9 @@ def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable:
token_url=server.token_url,
registration_url=server.registration_url,
oauth2_flow=server.oauth2_flow,
token_exchange_endpoint=server.token_exchange_endpoint,
audience=server.audience,
subject_token_type=server.subject_token_type,
allow_all_keys=server.allow_all_keys,
available_on_public_internet=server.available_on_public_internet,
delegate_auth_to_upstream=server.delegate_auth_to_upstream,
Comment thread
tin-berri marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
Subject,
TokenExchangeConfig,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE, MCPAuth

if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
Expand Down Expand Up @@ -124,7 +124,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> Optional[ServerSpe
resource=resource,
config=TokenExchangeConfig(
profile=profile,
subject_token_type=server.subject_token_type or "urn:ietf:params:oauth:token-type:access_token",
subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE,
token_exchange_endpoint=endpoint,
audience=server.audience,
client_id=server.client_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
Ok,
Result,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE


class AuthSpecKind(str, Enum):
Expand Down Expand Up @@ -215,7 +216,7 @@ class TokenExchangeConfig(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.token_exchange] = AuthSpecKind.token_exchange
profile: Literal["rfc8693", "entra_obo"] = "rfc8693"
subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token"
subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE
token_exchange_endpoint: str | None = None
audience: str | None = None
client_id: str | None = None
Expand Down
14 changes: 14 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,13 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. These top-level fields are the
# canonical shape; the same keys inside ``credentials`` are the legacy
# pre-column REST shape and are lifted into these columns on write (an
# explicit top-level value wins) and stripped from the stored blob.
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
Expand Down Expand Up @@ -1341,6 +1348,13 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
token_url: Optional[str] = None
registration_url: Optional[str] = None
oauth2_flow: Optional[Literal["client_credentials", "authorization_code"]] = None
# Token Exchange (OBO) fields — RFC 8693. These top-level fields are the
# canonical shape; the same keys inside ``credentials`` are the legacy
# pre-column REST shape and are lifted into these columns on write (an
# explicit top-level value wins) and stripped from the stored blob.
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: Optional[str] = None
allow_all_keys: bool = False
available_on_public_internet: bool = True
delegate_auth_to_upstream: bool = False
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,9 @@ def _sanitize_mcp_server_for_non_admin(
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
sanitized.token_exchange_endpoint = None
sanitized.audience = None
sanitized.subject_token_type = None
# Drop env vars entirely rather than only blanking global values: the
# names alone (DB_PASSWORD, GITHUB_API_KEY, ...) leak what secrets the
# admin configured. Non-admins get the per-user vars they must fill in
Expand Down Expand Up @@ -578,6 +581,9 @@ def _sanitize_mcp_server_for_virtual_key(
sanitized.authorization_url = None
sanitized.token_url = None
sanitized.registration_url = None
sanitized.token_exchange_endpoint = None
sanitized.audience = None
sanitized.subject_token_type = None

sanitized.health_check_error = None
sanitized.last_health_check = None
Expand Down
5 changes: 5 additions & 0 deletions litellm/proxy/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ model LiteLLM_MCPServerTable {
token_url String?
registration_url String?
oauth2_flow String?
token_exchange_endpoint String?
// Named for the RFC 8693 "audience" token-exchange request parameter (that flow only).
// RFC 8707 resource indicators are a separate concept, named "resource" in the v2 egress types.
audience String?
subject_token_type String?
allow_all_keys Boolean @default(false)
available_on_public_internet Boolean @default(true)
delegate_auth_to_upstream Boolean @default(false)
Expand Down
25 changes: 22 additions & 3 deletions litellm/types/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ class MCPAuth(str, enum.Enum):
oauth2_token_exchange = "oauth2_token_exchange"


# RFC 8693 default subject_token_type. A NULL column / omitted config key means
# "use this default"; it is applied at every egress build site via this single
# constant rather than a DB-level DEFAULT (Prisma writes explicit values on
# insert, so a column default would rarely apply anyway).
DEFAULT_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"

# MCP Literals
MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http, MCPTransport.stdio]
MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025, MCPSpecVersion.jun_2025]
Expand Down Expand Up @@ -122,18 +128,31 @@ class MCPCredentials(TypedDict, total=False):

audience: Optional[str]
"""
Target audience for OAuth 2.0 Token Exchange (RFC 8693)
Target audience for OAuth 2.0 Token Exchange (RFC 8693).

Legacy input shape: this setting has a dedicated ``audience`` column, which is
authoritative. A value sent here is accepted for back-compat (the pre-column
REST shape, released since 2026-05), lifted into the column on write, and
stripped from the stored blob. Prefer the top-level request field.
"""

token_exchange_endpoint: Optional[str]
"""
IDP token endpoint for OAuth 2.0 Token Exchange (RFC 8693)
IDP token endpoint for OAuth 2.0 Token Exchange (RFC 8693).

Legacy input shape: lifted into the dedicated ``token_exchange_endpoint``
column on write and stripped from the stored blob; the column is
authoritative. Prefer the top-level request field.
"""

subject_token_type: Optional[str]
"""
Subject token type for OAuth 2.0 Token Exchange (RFC 8693).
Default: urn:ietf:params:oauth:token-type:access_token
Default: DEFAULT_SUBJECT_TOKEN_TYPE (urn:ietf:params:oauth:token-type:access_token).

Legacy input shape: lifted into the dedicated ``subject_token_type`` column on
write and stripped from the stored blob; the column is authoritative. Prefer
the top-level request field.
"""

token_endpoint_auth_method: Optional[MCPTokenEndpointAuthMethod]
Expand Down
3 changes: 2 additions & 1 deletion litellm/types/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import BaseModel, ConfigDict

from litellm.types.mcp import (
DEFAULT_SUBJECT_TOKEN_TYPE,
MCPAuth,
MCPAuthType,
MCPTokenEndpointAuthMethod,
Expand Down Expand Up @@ -68,7 +69,7 @@ class MCPServer(BaseModel):
# Token Exchange (OBO) fields
token_exchange_endpoint: Optional[str] = None
audience: Optional[str] = None
subject_token_type: str = "urn:ietf:params:oauth:token-type:access_token"
subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE
# Wire dialect: "rfc8693" (standard token-exchange grant) or "entra_obo" (Microsoft Entra
# On-Behalf-Of, the RFC 7523 jwt-bearer grant + requested_token_use extension)
token_exchange_profile: str = "rfc8693"
Expand Down
Loading
Loading