mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
parent
46d9742950
commit
ff6dc33291
30 changed files with 1198 additions and 589 deletions
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue