feat(mcp): support oauth2_token_exchange auth type via REST API and dashboard (#31772)

* feat(mcp): support oauth2_token_exchange auth type via REST API and dashboard

OAuth 2.0 Token Exchange (RFC 8693, a.k.a. OBO) for MCP servers could previously only be
configured through config.yaml; the create/update REST API and the dashboard had no way to
express it. This wires token_exchange_endpoint, audience, and subject_token_type end to end.

These three are persisted as dedicated columns on LiteLLM_MCPServerTable, mirroring how token_url
and oauth2_flow are stored, so the edit form prefills them and they are unaffected by the
credentials-blob clearing on auth_type change. build_mcp_server_from_table reads the columns first
and falls back to the credentials blob so servers persisted before the columns existed still load.
client_id and client_secret continue to ride the existing encrypted credentials path.

On the dashboard, "OAuth Token Exchange (OBO)" is a distinct auth-type option with its own field
section. The McpOAuthMode classifier gains a token_exchange arm keyed off auth_type; the previous
catch-all oauth2 mode was renamed from "obo" to "authorization_code" so the two on-behalf-of
mechanisms are no longer conflated. The token-exchange IdP endpoint and audience are scrubbed from
non-admin and virtual-key responses, matching how token_url is treated.

* fix(ui): gate the MCP list-401 re-auth on authorization_code, not token_exchange

The renamed 401 gate keyed off isTokenExchange, but that condition exists for authorization_code:
when a stored per-user credential is present yet the tools/list 401s (the backend's refresh could
not mint a token), the user must re-authorize via the browser flow. token_exchange has no
gateway-side authorize step, so the Authorize gate never applied to it. The prior isObo flag was
undefined (a compile error) and, per this file's convention and its tests, meant authorization_code;
renaming it to isTokenExchange changed the behavior and broke the mcp_tools auth-gate test for an
authorization_code server whose token expired with no usable refresh. Gate on isAuthorizationCode
instead and drop the now-unused isTokenExchange

* fix(mcp): clear flow-scoped endpoint config when a server's auth_type changes

Switching an existing oauth2 server to oauth2_token_exchange left the old flow's
token_url on the row. The OBO resolver treats token_exchange_endpoint or token_url
as the configured exchange endpoint, so the stale value both suppressed the RFC
9728/8414 discovery this PR adds and sent the exchange grant (client credentials
plus the user's subject token) to the previous flow's token endpoint

update_mcp_server now mirrors its existing stale-credentials rule for the
flow-scoped columns (authorization_url, token_url, registration_url, oauth2_flow,
token_exchange_endpoint, audience, subject_token_type): when auth_type changes,
each one is cleared unless the same request explicitly provides it, so a
deliberate override in the switch request still wins. Updates that keep the
auth_type never touch these columns, which keeps legacy OBO rows that use
token_url as their exchange endpoint working

The edit form sends explicit nulls for the previous flow's fields on an auth type
switch; antd preserves unmounted field values by default, so without this the old
token_url would be re-sent verbatim and read as an explicit override. Transitions
are detected against the persisted auth_type, so saves that keep the auth type
send nothing extra

Reported by Cursor Bugbot on the PR

* fix(mcp): lift legacy blob token-exchange settings into their columns on every write

The three token-exchange settings live in dedicated columns but also exist on
MCPCredentials as the pre-column REST shape. Writes now lift incoming blob
values into the columns (an explicit top-level value wins, including an
explicit null) and strip them from the stored blob; the same-auth credentials
merge migrates legacy rows the same way. The read-time column-or-blob fallback
then only ever serves rows current code has never written, so clearing a column
to re-enable RFC 9728/8414 discovery can no longer be silently undone by a
stale blob copy.

Also asserts the auth-switch clearing fires on the external fields_set path
(PUT /v1/mcp/server).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mcp): single source for the RFC 8693 default subject_token_type

The default was applied at four egress build sites plus two model defaults,
each with its own copy of the literal. All sites now share
DEFAULT_SUBJECT_TOKEN_TYPE from litellm.types.mcp. A DB-level DEFAULT is
deliberately not used: Prisma writes explicit values on insert, so a column
default would rarely apply, and NULL-means-RFC-default keeps existing rows
correct.

Also documents two review decisions in place: the audience column keeps the
RFC 8693 parameter name (RFC 8707 resource indicators are already a separate
concept named resource in the v2 egress types), and the migration's
out-of-order timestamp is safe under prisma migrate deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: fix import sort order in outbound_credentials/types.py (I001 strict budget)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): purge legacy blob copies when a token-exchange column is written without credentials

The migrate-on-write in the credentials merge lifts blob values into null
columns, which is correct for legacy rows but could repopulate a column an
admin had cleared in an earlier no-credentials update (that path never touched
the blob, so the stale copy survived to be lifted later). An explicit
token-exchange column write (set or clear) now migrates the row even when the
update carries no credentials: untouched null columns are lifted, every blob
copy is stripped, and unrelated blob keys stay as-is. A cleared column can then
never be resurrected, because no write path leaves a blob copy behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(mcp): state the blob-to-column lift contract on the legacy credential keys

The three token-exchange keys on MCPCredentials are the pre-column REST shape
(the only REST shape from 2026-05 until this PR). Document on both the blob
type and the request models that the dedicated columns are authoritative and
that writes lift blob values into them and strip the stored copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): scrub subject_token_type in the non-admin and virtual-key sanitizers

The other two token-exchange fields were cleared while subject_token_type was
left visible. It is a public RFC 8693 URN with no disclosure value, but the
sanitizers' rule is that these views receive no token-exchange config at all —
cleared for uniformity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tin-berri 2026-07-07 15:26:12 -07:00 committed by GitHub
parent 46d9742950
commit ff6dc33291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1198 additions and 589 deletions

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@ from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import (
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
@ -35,8 +36,6 @@ if TYPE_CHECKING:
# 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.

View file

@ -46,6 +46,33 @@ from litellm.types.mcp import MCPCredentials
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
@ -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"])
@ -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.
@ -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
data_dict["credentials"] = safe_dumps(merged)
# Add audit fields

View file

@ -115,7 +115,7 @@ from litellm.proxy.common_utils.user_api_key_cache import get_management_object_
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,
@ -972,7 +972,7 @@ class MCPServerManager:
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)),
@ -1283,7 +1283,8 @@ class MCPServerManager:
(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
or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None),
mcp_server.token_url,
)
)
@ -1349,11 +1350,14 @@ class MCPServerManager:
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),
@ -4630,6 +4634,9 @@ class MCPServerManager:
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,
@ -4734,6 +4741,9 @@ class MCPServerManager:
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,

View file

@ -28,7 +28,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
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
@ -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,

View file

@ -39,6 +39,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
Ok,
Result,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
class AuthSpecKind(str, Enum):
@ -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

View file

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

View file

@ -537,6 +537,9 @@ if MCP_AVAILABLE:
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
@ -578,6 +581,9 @@ if MCP_AVAILABLE:
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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict
from litellm.types.mcp import (
DEFAULT_SUBJECT_TOKEN_TYPE,
MCPAuth,
MCPAuthType,
MCPTokenEndpointAuthMethod,
@ -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"

View file

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

View file

@ -1556,6 +1556,9 @@ async def test_add_update_server_with_alias():
mock_mcp_server.registration_url = None
mock_mcp_server.token_url = None
mock_mcp_server.oauth2_flow = None
mock_mcp_server.token_exchange_endpoint = None
mock_mcp_server.audience = None
mock_mcp_server.subject_token_type = None
# Additional fields used by build_mcp_server_from_table
mock_mcp_server.extra_headers = None
mock_mcp_server.allow_all_keys = False
@ -1615,6 +1618,9 @@ async def test_add_update_server_without_alias():
mock_mcp_server.registration_url = None
mock_mcp_server.token_url = None
mock_mcp_server.oauth2_flow = None
mock_mcp_server.token_exchange_endpoint = None
mock_mcp_server.audience = None
mock_mcp_server.subject_token_type = None
# Additional fields used by build_mcp_server_from_table
mock_mcp_server.extra_headers = None
mock_mcp_server.allow_all_keys = False
@ -1674,6 +1680,9 @@ async def test_add_update_server_fallback_to_server_id():
mock_mcp_server.registration_url = None
mock_mcp_server.token_url = None
mock_mcp_server.oauth2_flow = None
mock_mcp_server.token_exchange_endpoint = None
mock_mcp_server.audience = None
mock_mcp_server.subject_token_type = None
# Additional fields used by build_mcp_server_from_table - set explicitly
# to avoid MagicMock objects being passed to Pydantic MCPServer constructor
mock_mcp_server.extra_headers = None

View file

@ -17,6 +17,7 @@ import pytest
from litellm.proxy._experimental.mcp_server.db import (
_decode_user_credential,
_prepare_mcp_server_data,
get_user_credential,
get_user_oauth_credential,
is_oauth_credential_expired,
@ -27,10 +28,12 @@ from litellm.proxy._experimental.mcp_server.db import (
store_user_credential,
store_user_oauth_credential,
)
from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.types.mcp import MCPAuth, MCPTransport
SALT_KEY = "test-salt-key-for-byok-credential-tests-1234"
@ -722,3 +725,46 @@ async def test_refresh_user_oauth_token_defaults_to_client_secret_post(monkeypat
assert "Authorization" not in kwargs["headers"]
assert kwargs["data"]["client_id"] == "cid"
assert kwargs["data"]["client_secret"] == "sec"
def test_prepare_mcp_server_data_create_carries_token_exchange_columns():
"""The create path (POST /v1/mcp/server) must emit token_exchange_endpoint/audience/
subject_token_type as top-level column values so an auth_type=oauth2_token_exchange server
persists via the REST API, not only via config.yaml. Dropping the fields from the request
model would leave them out of the prepared column data."""
request = NewMCPServerRequest(
server_name="te_write",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
token_exchange_endpoint="https://idp.example.com/oauth2/token",
audience="https://upstream.example.com",
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
credentials={"client_id": "te-client", "client_secret": "te-secret"},
)
data = _prepare_mcp_server_data(request)
assert data["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
assert data["audience"] == "https://upstream.example.com"
assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
def test_prepare_mcp_server_data_update_carries_token_exchange_columns():
"""The partial-update path (PUT /v1/mcp/server, exclude_unset) must carry the three
token-exchange columns when the caller provides them."""
request = UpdateMCPServerRequest(
server_id="te-update",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
token_exchange_endpoint="https://idp.example.com/oauth2/token",
audience="https://upstream.example.com",
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
)
data = _prepare_mcp_server_data(request, exclude_unset=True)
assert data["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
assert data["audience"] == "https://upstream.example.com"
assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"

View file

@ -7,6 +7,7 @@ Omitting a field must NOT reset it to its Pydantic schema default (e.g.
would silently overwrite the existing DB row.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -134,14 +135,10 @@ async def test_partial_update_writes_explicitly_provided_fields():
@pytest.mark.asyncio
async def test_partial_update_can_explicitly_reset_allow_all_keys():
"""Caller can still reset a field to its default by sending it explicitly."""
enabled = await _run_update(
UpdateMCPServerRequest(server_id="s", allow_all_keys=True)
)
enabled = await _run_update(UpdateMCPServerRequest(server_id="s", allow_all_keys=True))
assert enabled["allow_all_keys"] is True
disabled = await _run_update(
UpdateMCPServerRequest(server_id="s", allow_all_keys=False)
)
disabled = await _run_update(UpdateMCPServerRequest(server_id="s", allow_all_keys=False))
assert disabled["allow_all_keys"] is False
@ -178,6 +175,92 @@ async def test_partial_update_can_explicitly_clear_alias():
assert data_dict["alias"] is None
async def _run_update_with_existing(data: UpdateMCPServerRequest, existing_auth_type: str) -> dict:
mock_prisma = _mock_prisma()
existing = MagicMock()
existing.auth_type = existing_auth_type
existing.credentials = None
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
await update_mcp_server(mock_prisma, data, "test-user")
return mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
@pytest.mark.asyncio
async def test_auth_type_switch_clears_stale_flow_scoped_fields():
"""
Switching oauth2 -> oauth2_token_exchange must clear the previous flow's
endpoint config: a stale token_url would otherwise be picked up as the
token-exchange endpoint and suppress RFC 9728/8414 discovery.
"""
data = UpdateMCPServerRequest(server_id="my-test-server", auth_type="oauth2_token_exchange")
data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2")
for stale_field in (
"authorization_url",
"token_url",
"registration_url",
"oauth2_flow",
"token_exchange_endpoint",
"audience",
"subject_token_type",
):
assert data_dict[stale_field] is None, f"{stale_field} must be cleared on auth_type switch"
assert data_dict["credentials"] is None
@pytest.mark.asyncio
async def test_auth_type_switch_keeps_explicitly_provided_flow_fields():
"""Fields explicitly provided alongside the auth_type switch must survive it."""
data = UpdateMCPServerRequest(
server_id="my-test-server",
auth_type="oauth2_token_exchange",
token_exchange_endpoint="https://idp.example.com/oauth2/token",
)
data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2")
assert data_dict["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
assert data_dict["token_url"] is None
@pytest.mark.asyncio
async def test_auth_type_switch_back_to_oauth2_clears_token_exchange_fields():
"""The reverse switch must not leave token-exchange settings behind to
silently reactivate if the server is later switched back."""
data = UpdateMCPServerRequest(server_id="my-test-server", auth_type="oauth2")
data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2_token_exchange")
assert data_dict["token_exchange_endpoint"] is None
assert data_dict["audience"] is None
assert data_dict["subject_token_type"] is None
@pytest.mark.asyncio
async def test_unchanged_auth_type_does_not_clear_flow_fields():
"""An update that keeps the auth_type must not touch flow-scoped fields, so a
legacy OBO server using token_url as its exchange endpoint keeps working."""
data = UpdateMCPServerRequest(
server_id="my-test-server",
auth_type="oauth2_token_exchange",
allowed_tools=["foo"],
)
data_dict = await _run_update_with_existing(data, existing_auth_type="oauth2_token_exchange")
for flow_field in (
"authorization_url",
"token_url",
"registration_url",
"oauth2_flow",
"token_exchange_endpoint",
"audience",
"subject_token_type",
):
assert flow_field not in data_dict
@pytest.mark.asyncio
async def test_create_still_writes_defaults():
"""
@ -203,3 +286,250 @@ async def test_create_still_writes_defaults():
# audit fields set by create_mcp_server.
assert data_dict["created_by"] == "test-user"
assert data_dict["updated_by"] == "test-user"
# ── token-exchange blob → column normalization ────────────────────────────────
#
# token_exchange_endpoint / audience / subject_token_type have dedicated columns;
# their MCPCredentials copies are a legacy shape. Writes must lift blob values
# into the columns and strip them from the stored blob so the read-time
# ``column or blob`` fallback can never resurrect a stale blob value after the
# column is cleared.
@pytest.fixture(autouse=True)
def _salt_key(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
def _existing_row(auth_type: str, credentials: dict | None = None):
existing = MagicMock()
existing.auth_type = auth_type
existing.credentials = json.dumps(credentials) if credentials is not None else None
existing.token_exchange_endpoint = None
existing.audience = None
existing.subject_token_type = None
return existing
@pytest.mark.asyncio
async def test_create_lifts_blob_token_exchange_settings_into_columns():
"""The legacy REST shape (TE settings inside ``credentials``) must land in
the dedicated columns, and the stored blob must not keep a copy."""
mock_prisma = _mock_prisma()
data = NewMCPServerRequest(
server_id="te-server",
url="https://example.com/mcp",
transport="http",
auth_type="oauth2_token_exchange",
credentials={
"client_id": "cid",
"client_secret": "sec",
"token_exchange_endpoint": "https://idp.example.com/oauth2/token",
"audience": "api://upstream",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
},
)
await create_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.create.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] == "https://idp.example.com/oauth2/token"
assert data_dict["audience"] == "api://upstream"
assert data_dict["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt"
stored_blob = json.loads(data_dict["credentials"])
for te_field in ("token_exchange_endpoint", "audience", "subject_token_type"):
assert te_field not in stored_blob
assert "client_id" in stored_blob
@pytest.mark.asyncio
async def test_create_explicit_column_wins_over_blob_copy():
mock_prisma = _mock_prisma()
data = NewMCPServerRequest(
server_id="te-server",
url="https://example.com/mcp",
transport="http",
auth_type="oauth2_token_exchange",
token_exchange_endpoint="https://top-level.example.com/token",
credentials={"client_id": "cid", "token_exchange_endpoint": "https://blob.example.com/token"},
)
await create_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.create.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] == "https://top-level.example.com/token"
assert "token_exchange_endpoint" not in json.loads(data_dict["credentials"])
@pytest.mark.asyncio
async def test_credentials_merge_migrates_legacy_blob_te_settings():
"""A same-auth credentials update on a legacy row (TE settings in the blob,
columns null) must move the settings to the columns and drop them from the
merged blob."""
mock_prisma = _mock_prisma()
existing = _existing_row(
"oauth2_token_exchange",
credentials={
"client_id": "enc-old-cid",
"token_exchange_endpoint": "https://legacy-idp.example.com/token",
"audience": "api://legacy",
},
)
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(
server_id="te-server",
auth_type="oauth2_token_exchange",
credentials={"client_id": "new-cid"},
)
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] == "https://legacy-idp.example.com/token"
assert data_dict["audience"] == "api://legacy"
merged_blob = json.loads(data_dict["credentials"])
for te_field in ("token_exchange_endpoint", "audience", "subject_token_type"):
assert te_field not in merged_blob
@pytest.mark.asyncio
async def test_cleared_column_is_not_resurrected_by_legacy_blob_value():
"""The Greptile scenario: explicitly clearing the column (to re-enable
RFC 9728/8414 discovery) while the legacy blob still holds an endpoint must
NOT resurrect the blob value the explicit null wins and the blob copy is
stripped."""
mock_prisma = _mock_prisma()
existing = _existing_row(
"oauth2_token_exchange",
credentials={"client_id": "enc-old-cid", "token_exchange_endpoint": "https://dead-idp.example.com/token"},
)
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(
server_id="te-server",
auth_type="oauth2_token_exchange",
token_exchange_endpoint=None,
credentials={"client_id": "new-cid"},
)
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] is None
assert "token_exchange_endpoint" not in json.loads(data_dict["credentials"])
@pytest.mark.asyncio
async def test_merge_strips_blob_te_copy_when_column_already_set():
"""When the row already has a column value, the blob copy is shadowed at
read time anyway the merge must strip it rather than carry it forward."""
mock_prisma = _mock_prisma()
existing = _existing_row(
"oauth2_token_exchange",
credentials={"client_id": "enc-old-cid", "token_exchange_endpoint": "https://blob-copy.example.com/token"},
)
existing.token_exchange_endpoint = "https://column.example.com/token"
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(
server_id="te-server",
auth_type="oauth2_token_exchange",
credentials={"client_id": "new-cid"},
)
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
# Column untouched by this update (not in payload), blob copy gone.
assert "token_exchange_endpoint" not in data_dict
assert "token_exchange_endpoint" not in json.loads(data_dict["credentials"])
@pytest.mark.asyncio
async def test_auth_type_switch_clears_flow_fields_with_external_fields_set():
"""The management endpoint passes ``fields_set`` explicitly (PUT
/v1/mcp/server). The auth-switch clearing must fire on that path too it is
gated on ``data.auth_type``/the existing row, not on how fields_set arrives."""
mock_prisma = _mock_prisma()
existing = _existing_row("oauth2")
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(server_id="te-server", auth_type="oauth2_token_exchange")
await update_mcp_server(mock_prisma, data, "test-user", fields_set=set(data.fields_set()))
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
for stale_field in (
"authorization_url",
"token_url",
"registration_url",
"oauth2_flow",
"token_exchange_endpoint",
"audience",
"subject_token_type",
):
assert data_dict[stale_field] is None, f"{stale_field} must be cleared via the fields_set path"
@pytest.mark.asyncio
async def test_explicit_clear_without_credentials_purges_legacy_blob_copy():
"""Clearing a column in an update that does not touch credentials must strip
the legacy blob copy too otherwise the next credentials update's
migrate-on-write would repopulate the column the admin just cleared."""
mock_prisma = _mock_prisma()
existing = _existing_row(
"oauth2_token_exchange",
credentials={"client_id": "enc-old-cid", "token_exchange_endpoint": "https://dead-idp.example.com/token"},
)
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(server_id="te-server", token_exchange_endpoint=None)
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] is None
stored_blob = json.loads(data_dict["credentials"])
assert "token_exchange_endpoint" not in stored_blob
# Unrelated blob keys (encrypted secrets) survive untouched.
assert stored_blob["client_id"] == "enc-old-cid"
@pytest.mark.asyncio
async def test_explicit_te_write_without_credentials_migrates_other_legacy_fields():
"""A no-credentials update that writes one token-exchange column migrates the
whole row: untouched null columns are lifted from the blob, and every blob
copy is stripped."""
mock_prisma = _mock_prisma()
existing = _existing_row(
"oauth2_token_exchange",
credentials={
"client_id": "enc-old-cid",
"token_exchange_endpoint": "https://legacy-idp.example.com/token",
"audience": "api://legacy",
},
)
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(server_id="te-server", audience="api://new")
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert data_dict["audience"] == "api://new"
assert data_dict["token_exchange_endpoint"] == "https://legacy-idp.example.com/token"
stored_blob = json.loads(data_dict["credentials"])
for te_field in ("token_exchange_endpoint", "audience", "subject_token_type"):
assert te_field not in stored_blob
@pytest.mark.asyncio
async def test_te_update_without_blob_te_keys_leaves_credentials_untouched():
"""A no-credentials column write on a row whose blob has no legacy copies
must not rewrite the credentials blob at all."""
mock_prisma = _mock_prisma()
existing = _existing_row("oauth2_token_exchange", credentials={"client_id": "enc-old-cid"})
mock_prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=existing)
data = UpdateMCPServerRequest(server_id="te-server", token_exchange_endpoint="https://new.example.com/token")
await update_mcp_server(mock_prisma, data, "test-user")
data_dict = mock_prisma.db.litellm_mcpservertable.update.call_args[1]["data"]
assert data_dict["token_exchange_endpoint"] == "https://new.example.com/token"
assert "credentials" not in data_dict

View file

@ -5184,6 +5184,9 @@ async def test_list_tools_with_legacy_db_m2m_server_resolves_oauth2_flow():
legacy_server.server_id = "legacy-m2m-id"
legacy_server.auth_type = MCPAuth.oauth2
legacy_server.oauth2_flow = None # Legacy: field not set in DB
legacy_server.token_exchange_endpoint = None
legacy_server.audience = None
legacy_server.subject_token_type = None
legacy_server.token_url = "https://oauth.example.com/token"
legacy_server.authorization_url = None
legacy_server.client_id = "client-id"

View file

@ -4387,6 +4387,102 @@ class TestMCPServerTimestamps:
assert "0.01s" in exc_info.value.detail["message"]
class TestMCPServerTokenExchangeColumns:
"""Token-exchange (RFC 8693) config persists through the dedicated columns added for the
create/update REST + DB path, mirroring how ``token_url`` is stored. The credentials JSON
blob is kept as a read-fallback so servers persisted before the columns existed still load."""
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_reads_token_exchange_columns(self):
"""The DB->runtime loader must read the three fields from the dedicated columns. Before the
columns existed it only read the credentials blob, so column values would be dropped."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="te-cols",
server_name="te_cols",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
token_exchange_endpoint="https://idp.example.com/oauth2/token",
audience="https://upstream.example.com",
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert mcp_server.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
assert mcp_server.audience == "https://upstream.example.com"
assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:jwt"
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_falls_back_to_credentials_blob(self):
"""Backwards compatibility: a server whose token-exchange config lives only in the
credentials blob (no columns) must still load with those values."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="te-blob",
server_name="te_blob",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
credentials={
"token_exchange_endpoint": "https://idp.example.com/legacy/token",
"audience": "legacy-audience",
"subject_token_type": "urn:ietf:params:oauth:token-type:saml2",
},
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert mcp_server.token_exchange_endpoint == "https://idp.example.com/legacy/token"
assert mcp_server.audience == "legacy-audience"
assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:saml2"
@pytest.mark.asyncio
async def test_build_mcp_server_from_table_subject_token_type_defaults(self):
"""subject_token_type falls back to the RFC 8693 access_token URN when unset."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="te-default",
server_name="te_default",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
token_exchange_endpoint="https://idp.example.com/oauth2/token",
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
assert mcp_server.subject_token_type == "urn:ietf:params:oauth:token-type:access_token"
@pytest.mark.asyncio
async def test_round_trip_token_exchange_columns_preserved(self):
"""The three fields survive LiteLLM_MCPServerTable -> MCPServer -> LiteLLM_MCPServerTable.
Before the table builder wrote them back, a registry round-trip dropped them."""
manager = MCPServerManager()
table_record = LiteLLM_MCPServerTable(
server_id="te-rt",
server_name="te_rt",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
token_exchange_endpoint="https://idp.example.com/oauth2/token",
audience="https://upstream.example.com",
subject_token_type="urn:ietf:params:oauth:token-type:jwt",
)
mcp_server = await manager.build_mcp_server_from_table(table_record)
rebuilt_table = manager._build_mcp_server_table(mcp_server)
assert rebuilt_table.token_exchange_endpoint == "https://idp.example.com/oauth2/token"
assert rebuilt_table.audience == "https://upstream.example.com"
assert rebuilt_table.subject_token_type == "urn:ietf:params:oauth:token-type:jwt"
class TestInternalDelegatePkceWarningLog:
@pytest.mark.asyncio
async def test_build_mcp_server_logs_on_internal_delegate_interactive(self, caplog):

View file

@ -856,6 +856,9 @@ class TestSigV4BuildFromTable:
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
table_record.token_exchange_endpoint = None
table_record.audience = None
table_record.subject_token_type = None
table_record.instructions = None
table_record.source_url = None
@ -915,6 +918,9 @@ class TestSigV4BuildFromTable:
table_record.tool_name_to_description = None
table_record.byok_api_key_help_url = None
table_record.oauth2_flow = None
table_record.token_exchange_endpoint = None
table_record.audience = None
table_record.subject_token_type = None
table_record.instructions = None
table_record.source_url = None

View file

@ -0,0 +1,92 @@
import React from "react";
import { Form, Input, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
interface TokenExchangeFormFieldsProps {
isEditing?: boolean;
}
const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
<span className="text-sm font-medium text-gray-700 flex items-center">
{label}
<Tooltip title={tooltip}>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
);
const TokenExchangeFormFields: React.FC<TokenExchangeFormFieldsProps> = ({ isEditing = false }) => {
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
return (
<>
<Form.Item
label={
<FieldLabel
label="Token Exchange Endpoint (optional)"
tooltip="RFC 8693 token endpoint. The proxy exchanges the user's incoming token here for a scoped token used to call the upstream MCP server. Leave blank to auto-discover it from the upstream's protected-resource metadata (RFC 9728 then RFC 8414)."
/>
}
name="token_exchange_endpoint"
>
<Input placeholder="https://idp.example.com/oauth2/token" className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Client ID"
tooltip="OAuth2 client ID used to authenticate to the token exchange endpoint."
/>
}
name={["credentials", "client_id"]}
rules={[{ required: !isEditing, message: "Client ID is required for token exchange" }]}
>
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Client Secret"
tooltip="OAuth2 client secret used to authenticate to the token exchange endpoint."
/>
}
name={["credentials", "client_secret"]}
rules={[{ required: !isEditing, message: "Client Secret is required for token exchange" }]}
>
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Audience (optional)"
tooltip="Target audience for the exchanged token (RFC 8693 audience). Identifies the upstream MCP server the token is for."
/>
}
name="audience"
>
<Input placeholder="https://upstream.example.com" className={fieldClassName} />
</Form.Item>
<Form.Item
label={
<FieldLabel
label="Subject Token Type (optional)"
tooltip="Type of the user's incoming token (RFC 8693 subject_token_type). Defaults to urn:ietf:params:oauth:token-type:access_token."
/>
}
name="subject_token_type"
>
<Input placeholder="urn:ietf:params:oauth:token-type:access_token" className={fieldClassName} />
</Form.Item>
<Form.Item
label={<FieldLabel label="Scopes (optional)" tooltip="Optional scopes to request during the token exchange." />}
name={["credentials", "scopes"]}
>
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
</Form.Item>
</>
);
};
export default TokenExchangeFormFields;

View file

@ -350,6 +350,61 @@ describe("CreateMCPServer", () => {
expect(payload.credentials).toBeUndefined();
});
it("routes OAuth Token Exchange (OBO) config to the backend payload", async () => {
await selectHttpTransport();
const user = userEvent.setup({ delay: null });
const nameInput = getServerNameInput();
await user.type(nameInput, "TE_Server");
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
await user.type(urlInput, "https://upstream.example.com/mcp");
await selectAntOption("Authentication", "OAuth Token Exchange (OBO)");
await waitFor(() => {
expect(screen.getByPlaceholderText("https://idp.example.com/oauth2/token")).toBeInTheDocument();
});
await user.type(
screen.getByPlaceholderText("https://idp.example.com/oauth2/token"),
"https://idp.example.com/oauth2/token",
);
await user.type(screen.getByPlaceholderText("Enter OAuth client ID"), "te-client-id");
await user.type(screen.getByPlaceholderText("Enter OAuth client secret"), "te-client-secret");
vi.mocked(networking.createMCPServer).mockResolvedValue({
server_id: "new-server-te",
server_name: "TE_Server",
alias: "TE_Server",
url: "https://upstream.example.com/mcp",
transport: "http",
auth_type: "oauth2_token_exchange",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
});
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
await act(async () => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("oauth2_token_exchange");
expect(payload.token_exchange_endpoint).toBe("https://idp.example.com/oauth2/token");
expect(payload.credentials).toMatchObject({
client_id: "te-client-id",
client_secret: "te-client-secret",
});
});
it("enforces the allowlist when the user explicitly deselects every tool", async () => {
await selectHttpTransport();

View file

@ -16,6 +16,7 @@ import {
MCP_OAUTH2_FLOW_INTERACTIVE,
} from "./types";
import OAuthFormFields from "./OAuthFormFields";
import TokenExchangeFormFields from "./TokenExchangeFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
import MCPToolConfiguration from "./mcp_tool_configuration";
@ -48,7 +49,12 @@ interface CreateMCPServerProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2, AUTH_TYPE.AWS_SIGV4];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.AWS_SIGV4,
];
const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state";
const reduceStaticHeaders = (list: unknown): Record<string, string> => {
@ -112,6 +118,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
const authType = formValues.auth_type as string | undefined;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
@ -485,7 +492,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
oauth2_flow: values.oauth_flow_type === OAUTH_FLOW.M2M ? MCP_OAUTH2_FLOW_M2M : null,
delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw),
});
if (oauthMode === "obo") {
if (oauthMode === "authorization_code") {
const scope = oauthTokenResponse.scope;
await storeMCPOAuthUserCredential(accessToken, response.server_id, {
access_token: oauthTokenResponse.access_token,
@ -935,6 +942,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
</Select>
</Form.Item>
@ -980,6 +988,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}}
/>
)}
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
</>
),
},

View file

@ -307,6 +307,81 @@ describe("MCPServerEdit (delegate auth)", () => {
});
});
describe("MCPServerEdit (auth type switch)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("clears stale oauth2 endpoint overrides when switching to token exchange", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({
...interactiveOAuthServer,
auth_type: "oauth2_token_exchange",
});
render(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
token_url: "https://old-idp.example.com/oauth/token",
authorization_url: "https://old-idp.example.com/oauth/authorize",
registration_url: "https://old-idp.example.com/oauth/register",
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await selectAntOption("Authentication", "OAuth Token Exchange (OBO)");
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
await act(async () => {
fireEvent.click(saveButtons[0]);
});
await waitFor(() => {
expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("oauth2_token_exchange");
expect(payload.token_url).toBeNull();
expect(payload.authorization_url).toBeNull();
expect(payload.registration_url).toBeNull();
});
it("keeps oauth2 endpoint overrides when the auth type is unchanged", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...interactiveOAuthServer });
render(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
token_url: "https://idp.example.com/oauth/token",
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
await act(async () => {
fireEvent.click(saveButtons[0]);
});
await waitFor(() => {
expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
});
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
expect(payload.auth_type).toBe("oauth2");
expect(payload.token_url).toBe("https://idp.example.com/oauth/token");
});
});
describe("MCPServerEdit (tool allowlist)", () => {
beforeEach(() => {
vi.clearAllMocks();

View file

@ -20,6 +20,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import TokenExchangeFormFields from "./TokenExchangeFormFields";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
@ -44,7 +45,12 @@ interface MCPServerEditProps {
}
const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOKEN, AUTH_TYPE.TOKEN, AUTH_TYPE.BASIC];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2, AUTH_TYPE.AWS_SIGV4];
const AUTH_TYPES_REQUIRING_CREDENTIALS = [
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
AUTH_TYPE.OAUTH2,
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
AUTH_TYPE.AWS_SIGV4,
];
export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
@ -75,6 +81,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
@ -639,6 +646,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
// Remove UI-only fields
stdio_config: undefined,
env_json: undefined,
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
? { authorization_url: null, token_url: null, registration_url: null }
: {}),
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
? { token_exchange_endpoint: null, audience: null, subject_token_type: null }
: {}),
server_id: mcpServer.server_id,
mcp_info: {
...(mcpServer.mcp_info ?? {}),
@ -717,7 +731,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream),
});
try {
if (oauthMode === "obo") {
if (oauthMode === "authorization_code") {
const scope = oauthTokenResponse.scope;
await storeMCPOAuthUserCredential(accessToken, mcpServer.server_id, {
access_token: oauthTokenResponse.access_token,
@ -848,6 +862,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
<Select.Option value="token">Token</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
<Select.Option value="oauth2">OAuth</Select.Option>
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
</Select>
</Form.Item>
@ -1140,6 +1155,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</>
)}
{!isStdioTransport && isTokenExchangeAuthType && <TokenExchangeFormFields isEditing />}
{!isStdioTransport && isAwsSigV4AuthType && (
<>
<p className="text-sm text-gray-500 mb-2">

View file

@ -36,13 +36,13 @@ const MCPToolsViewer = ({
const [showHeaderInput, setShowHeaderInput] = useState(false);
// PKCE passthrough holds a browser-side session token (sessionStorage) and
// gates tool listing behind it. OBO uses a backend-stored per-user token that
// the user must establish once via an interactive login; we gate on whether
// that DB credential exists. M2M uses the backend's own service token and
// needs no gate.
// gates tool listing behind it. authorization_code uses a backend-stored
// per-user token that the user must establish once via an interactive login;
// we gate on whether that DB credential exists. M2M uses the backend's own
// service token and needs no gate.
const oauthMode = getMcpOAuthMode({ auth_type, oauth2_flow, delegate_auth_to_upstream });
const isPassthrough = oauthMode === "passthrough";
const isObo = oauthMode === "obo";
const isAuthorizationCode = oauthMode === "authorization_code";
const [oauthToken, setOauthToken] = useState<string | null>(() =>
isPassthrough && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null,
);
@ -68,18 +68,18 @@ const MCPToolsViewer = ({
onSuccess: setOauthToken,
});
// OBO servers list tools using a per-user token the backend stores in the DB;
// authorization_code servers list tools using a per-user token the backend stores in the DB;
// check whether the current user has a valid one so we can prompt them to
// authorize when they don't (otherwise the backend silently returns no tools).
const {
data: oboCredStatus,
isLoading: isLoadingOboCred,
isError: isOboCredError,
refetch: refetchOboCred,
data: authorizationCodeCredStatus,
isLoading: isLoadingAuthorizationCodeCred,
isError: isAuthorizationCodeCredError,
refetch: refetchAuthorizationCodeCred,
} = useQuery({
queryKey: ["mcpOauthUserCredStatus", serverId, userID],
queryFn: () => getMCPOAuthUserCredentialStatus(accessToken ?? "", serverId),
enabled: !!accessToken && isObo,
enabled: !!accessToken && isAuthorizationCode,
staleTime: 30000,
});
@ -89,9 +89,12 @@ const MCPToolsViewer = ({
// the status check itself fails we can't confirm a credential, so surface the
// Authorize gate rather than a silent empty tool list; re-authorizing only
// overwrites the user's own row, so it is safe when a credential did exist.
const hasOboCred = !!oboCredStatus?.has_credential;
const oboNeedsAuth = isObo && !isLoadingOboCred && (isOboCredError || (!!oboCredStatus && !hasOboCred));
const oboStatusLoading = isObo && isLoadingOboCred;
const hasAuthorizationCodeCred = !!authorizationCodeCredStatus?.has_credential;
const authorizationCodeNeedsAuth =
isAuthorizationCode &&
!isLoadingAuthorizationCodeCred &&
(isAuthorizationCodeCredError || (!!authorizationCodeCredStatus && !hasAuthorizationCodeCred));
const authorizationCodeStatusLoading = isAuthorizationCode && isLoadingAuthorizationCodeCred;
// Check if this server has extra headers configured
const hasExtraHeaders = extraHeaders && extraHeaders.length > 0;
@ -105,7 +108,7 @@ const MCPToolsViewer = ({
// The backend's _get_mcp_server_auth_headers_from_headers() picks up the
// x-mcp-{alias}-{header} pattern and forwards it to the upstream MCP server.
// When no alias is available, fall back to x-mcp-auth (legacy but still supported).
// Passthrough only: OBO/M2M tokens are attached server-side, not from the browser.
// Passthrough only: authorization_code/token_exchange/M2M tokens are attached server-side, not from the browser.
if (isPassthrough && oauthToken) {
Object.assign(customHeaders, buildMcpPassthroughAuthHeader(serverAlias, oauthToken));
}
@ -158,9 +161,10 @@ const MCPToolsViewer = ({
}
return result;
},
// Passthrough blocks until a browser session token exists; OBO blocks until
// Passthrough blocks until a browser session token exists; authorization_code blocks until
// the user has a valid DB credential (else the backend returns no tools).
enabled: !!accessToken && (isPassthrough ? oauthToken !== null : isObo ? hasOboCred : true),
enabled:
!!accessToken && (isPassthrough ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true),
staleTime: 30000, // Consider data fresh for 30 seconds
retry: (failureCount, error: any) => {
// Don't retry on 401 — token is invalid, user must re-authenticate
@ -169,12 +173,12 @@ const MCPToolsViewer = ({
},
});
// OBO authorize: same redirect+exchange flow as the admin "Authorize & Fetch"
// authorization_code authorize: same redirect+exchange flow as the admin "Authorize & Fetch"
// and the chat "Connect" button, but persists the token to the per-user DB.
const onOboAuthSuccess = useCallback(() => {
refetchOboCred();
const onAuthorizationCodeAuthSuccess = useCallback(() => {
refetchAuthorizationCodeCred();
refetchTools();
}, [refetchOboCred, refetchTools]);
}, [refetchAuthorizationCodeCred, refetchTools]);
const {
startOAuthFlow: startDbOAuthFlow,
@ -184,12 +188,12 @@ const MCPToolsViewer = ({
accessToken: accessToken ?? "",
serverId,
serverAlias,
onSuccess: onOboAuthSuccess,
onSuccess: onAuthorizationCodeAuthSuccess,
});
// Stash which server started the redirect so the MCP Servers page can reopen
// this Tools tab on return and let the flow resume to persist the credential.
const startOboAuthorize = useCallback(() => {
const startAuthorizationCodeAuthorize = useCallback(() => {
try {
setSecureItem(TOOLS_OAUTH_UI_STATE_KEY, JSON.stringify({ serverId }));
} catch (_) {}
@ -238,17 +242,21 @@ const MCPToolsViewer = ({
const toolsData = mcpToolsResponse?.tools || [];
const oboToolsError = mcpToolsError as (Error & { status?: number; response?: { status?: number } }) | null;
const oboTokenRejected = isObo && (oboToolsError?.status ?? oboToolsError?.response?.status) === 401;
const toolsError = mcpToolsError as (Error & { status?: number; response?: { status?: number } }) | null;
// authorization_code only: a 401 from the list call means the stored credential is unusable and
// the backend's refresh could not mint a token, so the user must re-authorize (the browser flow).
// token_exchange has no gateway-side authorize step, so it is not gated here.
const authorizationCodeTokenRejected =
isAuthorizationCode && (toolsError?.status ?? toolsError?.response?.status) === 401;
// An auth gate replaces the tool list when the user must authenticate first:
// passthrough needs a browser token; OBO needs a stored DB credential or a
// passthrough needs a browser token; authorization_code needs a stored DB credential or a
// still-valid one — a 401 from the list call means the backend has none even
// after attempting a refresh, so re-authorization is required.
const authGateActive = (isPassthrough && !oauthToken) || oboNeedsAuth || oboTokenRejected;
// Treat OBO credential-status loading as "tools loading" so the empty state
const authGateActive = (isPassthrough && !oauthToken) || authorizationCodeNeedsAuth || authorizationCodeTokenRejected;
// Treat authorization_code credential-status loading as "tools loading" so the empty state
// doesn't flash before we know whether the user needs to authorize.
const toolsAreaLoading = isLoadingTools || oboStatusLoading;
const toolsAreaLoading = isLoadingTools || authorizationCodeStatusLoading;
// Filter tools based on search term
const filteredTools = toolsData.filter((tool: MCPTool) => {
@ -369,12 +377,12 @@ const MCPToolsViewer = ({
</div>
)}
{/* OBO auth gate shown when there is no credential row for this
user, or when the list call returns 401 (no valid token and the
{/* Auth gate (authorization_code or token_exchange) shown when there is no credential
row for this user, or when the list call returns 401 (no valid token and the
server-side refresh could not mint one, e.g. an expired token
with no usable refresh token). A refreshable token is refreshed
on the list call and never trips this gate. */}
{(oboNeedsAuth || oboTokenRejected) && (
{(authorizationCodeNeedsAuth || authorizationCodeTokenRejected) && (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<LockOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">Authentication required</p>
@ -385,7 +393,7 @@ const MCPToolsViewer = ({
size="small"
type="primary"
loading={dbOAuthStatus === "authorizing" || dbOAuthStatus === "exchanging"}
onClick={startOboAuthorize}
onClick={startAuthorizationCodeAuthorize}
disabled={!accessToken}
>
Authorize

View file

@ -82,6 +82,17 @@ describe("getMcpOAuthMode", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: MCP_OAUTH2_FLOW_M2M })).toBe("m2m");
});
it("classifies oauth2_token_exchange as token_exchange regardless of the oauth2 secondary fields", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE })).toBe("token_exchange");
expect(
getMcpOAuthMode({
auth_type: AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
oauth2_flow: MCP_OAUTH2_FLOW_M2M,
delegate_auth_to_upstream: true,
}),
).toBe("token_exchange");
});
it("treats m2m as m2m even when delegate_auth_to_upstream is true", () => {
expect(
getMcpOAuthMode({
@ -98,14 +109,14 @@ describe("getMcpOAuthMode", () => {
);
});
it("classifies an interactive server without delegation as obo", () => {
it("classifies an interactive server without delegation as authorization_code", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: null, delegate_auth_to_upstream: false })).toBe(
"obo",
"authorization_code",
);
});
it("defaults to obo when delegate_auth_to_upstream is undefined", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2 })).toBe("obo");
it("defaults to authorization_code when delegate_auth_to_upstream is undefined", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2 })).toBe("authorization_code");
});
it("treats explicit authorization_code as interactive, not m2m", () => {
@ -115,7 +126,7 @@ describe("getMcpOAuthMode", () => {
oauth2_flow: "authorization_code",
delegate_auth_to_upstream: false,
}),
).toBe("obo");
).toBe("authorization_code");
});
// Regression: the old heuristic labeled any OAuth2 server with a token endpoint
@ -123,7 +134,7 @@ describe("getMcpOAuthMode", () => {
// legitimately carries one is classified by oauth2_flow + delegate, never M2M.
it("does not treat an interactive server with a token endpoint as m2m", () => {
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2, oauth2_flow: null, delegate_auth_to_upstream: false })).toBe(
"obo",
"authorization_code",
);
});
});

View file

@ -39,6 +39,7 @@ export const AUTH_TYPE = {
TOKEN: "token",
BASIC: "basic",
OAUTH2: "oauth2",
OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange",
AWS_SIGV4: "aws_sigv4",
};
@ -53,22 +54,28 @@ export const MCP_OAUTH2_FLOW_M2M = "client_credentials";
export const MCP_OAUTH2_FLOW_INTERACTIVE = "authorization_code";
export type McpOAuthMode = "m2m" | "passthrough" | "obo";
export type McpOAuthMode = "m2m" | "passthrough" | "authorization_code" | "token_exchange";
// Classify an OAuth2 MCP server into the mode that decides how the tool list is
// authenticated: M2M (backend service token), PKCE passthrough (browser-held
// session token), or OBO (backend-stored per-user token). `token_url` is
// intentionally not consulted: every OAuth2 grant that exchanges for a token
// carries one (interactive PKCE and client_credentials alike), so it cannot
// distinguish the modes; `oauth2_flow` is the authoritative M2M signal.
// Classify an OAuth MCP server into the mode that decides how the tool list is
// authenticated. token_exchange (RFC 8693 / OBO) is its own auth_type
// (`oauth2_token_exchange`), so it is keyed off auth_type directly; the other
// three all share auth_type `oauth2` and are told apart by secondary fields:
// M2M (backend service token via the client_credentials grant), PKCE passthrough
// (browser-held session token), or authorization_code (per-user token obtained
// via the interactive authorization_code/PKCE grant and stored by the backend).
// `token_url` is intentionally not consulted for the oauth2 modes: every OAuth2
// grant that exchanges for a token carries one (interactive PKCE and
// client_credentials alike), so it cannot distinguish the modes; `oauth2_flow`
// is the authoritative M2M signal.
export function getMcpOAuthMode(s: {
auth_type?: string | null;
oauth2_flow?: string | null;
delegate_auth_to_upstream?: boolean | null;
}): McpOAuthMode | null {
if (s.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) return "token_exchange";
if (s.auth_type !== AUTH_TYPE.OAUTH2) return null;
if (s.oauth2_flow === MCP_OAUTH2_FLOW_M2M) return "m2m";
return s.delegate_auth_to_upstream ? "passthrough" : "obo";
return s.delegate_auth_to_upstream ? "passthrough" : "authorization_code";
}
// Map a server's stored `oauth2_flow` (the API value: client_credentials /
@ -231,6 +238,9 @@ export interface MCPServer {
authorization_url?: string | null;
token_url?: string | null;
registration_url?: string | null;
token_exchange_endpoint?: string | null;
audience?: string | null;
subject_token_type?: string | null;
mcp_info?: MCPInfo | null;
created_at: string;
created_by: string;

File diff suppressed because one or more lines are too long