mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
Merge pull request #49167 from BerriAI/litellm_backport_rc_1_108_0_eddb61
fix(mcp)!: backport #48939 to rc/1.108.0, restore upstream-token admission for legacy delegate servers
This commit is contained in:
commit
839bc9fda2
12 changed files with 1180 additions and 198 deletions
|
|
@ -68,8 +68,11 @@ module materially harder to understand.
|
|||
behind a single generic branch unless tests prove every mode still behaves
|
||||
correctly.
|
||||
- Be especially careful with legacy `delegate_auth_to_upstream: true`. `auth_type: oauth2`
|
||||
with `delegate_auth_to_upstream: true` is deprecated: LiteLLM admission is required
|
||||
for matching MCP routes. Use `auth_type: oauth_delegate` for client-forwarded OAuth.
|
||||
with `delegate_auth_to_upstream: true` is deprecated but still supported. A bare
|
||||
`Authorization` that is a LiteLLM credential is validated and never forwarded. Any other
|
||||
bearer is the upstream token: it is forwarded, and the caller is admitted as the JWT or
|
||||
custom-auth user it also proves when that user is granted the server, else anonymously.
|
||||
New servers should use `auth_type: true_passthrough` or `auth_type: oauth_delegate`.
|
||||
OAuth discovery endpoints stay public so clients can start the RFC 9728 flow.
|
||||
- Keep database-backed fields in sync across migrations, typed models under
|
||||
`litellm/types/mcp.py` or `litellm/types/mcp_server/`, config loading, this
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import functools
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr, TypeAdapter
|
||||
from starlette.datastructures import Headers
|
||||
|
|
@ -64,9 +65,11 @@ from litellm.proxy.agent_endpoints.auth.agent_access_groups import (
|
|||
from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth
|
||||
from litellm.proxy.agent_endpoints.auth.managed_authorization import managed_agent_policy
|
||||
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
|
||||
from litellm.proxy.auth.oauth2_check import OAuth2TokenValidationError
|
||||
from litellm.proxy.auth.user_api_key_auth import ( # noqa: F401 # legacy module exports
|
||||
_get_bearer_token_or_received_api_key, # pyright: ignore[reportPrivateUsage] # shared x-litellm-api-key parser lives with user_api_key_auth
|
||||
_run_centralized_common_checks, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
oauth2_introspection_applies,
|
||||
run_centralized_common_checks,
|
||||
user_api_key_auth,
|
||||
)
|
||||
|
|
@ -93,6 +96,9 @@ if TYPE_CHECKING:
|
|||
|
||||
_EMPTY_TOOLSET_GRANTS: Final[Mapping[str, Sequence[str]]] = MappingProxyType({})
|
||||
OPTIONAL_STRING_ADAPTER: Final[TypeAdapter[str | None]] = TypeAdapter(str | None)
|
||||
_PROXY_SETTINGS_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_CAUSE_CHAIN_MAX_DEPTH: Final = 16
|
||||
_IdentityCheck: TypeAlias = Literal["oauth2", "oauth2_proxy", "jwt"]
|
||||
|
||||
|
||||
def _normalize_caller_admission_credential(value: str) -> str:
|
||||
|
|
@ -225,6 +231,136 @@ def _is_legacy_delegate_cold_start(mcp_servers: list[str] | None, client_ip: str
|
|||
return True
|
||||
|
||||
|
||||
def _cause_chain(exc: BaseException) -> Iterator[BaseException]:
|
||||
link: BaseException | None = exc # rebind-ok: advances one link of the cause chain per iteration
|
||||
for _ in range(_CAUSE_CHAIN_MAX_DEPTH):
|
||||
if link is None:
|
||||
return
|
||||
yield link
|
||||
link = link.__cause__ or link.__context__
|
||||
|
||||
|
||||
def _caused_by(exc: BaseException, error_type: type[BaseException]) -> bool:
|
||||
return any(isinstance(link, error_type) for link in _cause_chain(exc))
|
||||
|
||||
|
||||
def _signed_with(token: str, secret: str) -> bool:
|
||||
"""True when ``token`` is a JWT whose HS256 signature verifies with ``secret``, whatever its claims say."""
|
||||
try:
|
||||
jwt.decode(
|
||||
token,
|
||||
secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False, "verify_nbf": False, "verify_iat": False, "verify_aud": False},
|
||||
)
|
||||
except jwt.PyJWTError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _bearer_is_litellm_credential(token: str) -> bool:
|
||||
"""True when ``token`` is, or may be, a credential LiteLLM issued: the master key or a JWT it signed
|
||||
(dashboard sessions, BYOK sessions), a sealed gateway bearer, or a virtual key the key table holds (a
|
||||
lookup fault counts as held). Only ``sk-`` tokens are looked up, because no other token can be a
|
||||
virtual key."""
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
is_sealed_gateway_bearer,
|
||||
virtual_key_may_exist,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
LITELLM_VIRTUAL_KEY_PREFIX,
|
||||
)
|
||||
from litellm.proxy.proxy_server import master_key # noqa: PLC0415 # startup owns the master key
|
||||
|
||||
if master_key and (secrets.compare_digest(token.encode(), master_key.encode()) or _signed_with(token, master_key)):
|
||||
return True
|
||||
if is_sealed_gateway_bearer(token):
|
||||
return True
|
||||
return token.startswith(LITELLM_VIRTUAL_KEY_PREFIX) and await virtual_key_may_exist(token)
|
||||
|
||||
|
||||
def _identity_check(token: str, route: str, settings: Mapping[str, object]) -> _IdentityCheck | None:
|
||||
"""The identity check LiteLLM's own auth would run on ``token`` for ``route``, in its order: OAuth2
|
||||
introspection, then trusted reverse-proxy identity headers, then a JWT whose issuer may be LiteLLM's."""
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
jwt_issuer_may_be_litellm,
|
||||
)
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
jwt_handler,
|
||||
premium_user,
|
||||
)
|
||||
|
||||
enable_jwt_auth: Final = settings.get("enable_jwt_auth") is True
|
||||
if oauth2_introspection_applies(
|
||||
token,
|
||||
route,
|
||||
enable_oauth2_auth=settings.get("enable_oauth2_auth") is True,
|
||||
enable_jwt_auth=enable_jwt_auth,
|
||||
jwt_handler=jwt_handler,
|
||||
):
|
||||
return "oauth2" if premium_user is True else None
|
||||
if settings.get("enable_oauth2_proxy_auth") is True:
|
||||
return "oauth2_proxy"
|
||||
if enable_jwt_auth and premium_user is True and JWTHandler.is_jwt(token=token) and jwt_issuer_may_be_litellm(token):
|
||||
return "jwt"
|
||||
return None
|
||||
|
||||
|
||||
async def _verify_identity(check: Literal["oauth2_proxy", "jwt"], token: str, request: Request) -> None:
|
||||
from litellm.proxy.auth.oauth2_proxy_hook import ( # noqa: PLC0415 # proxy import cycle
|
||||
handle_oauth2_proxy_request,
|
||||
)
|
||||
from litellm.proxy.proxy_server import jwt_handler # noqa: PLC0415 # startup owns the active auth configuration
|
||||
|
||||
match check:
|
||||
case "oauth2_proxy":
|
||||
await handle_oauth2_proxy_request(request=request)
|
||||
case "jwt":
|
||||
await jwt_handler.auth_jwt(token=token) # pyright: ignore[reportUnknownMemberType] # returns a bare dict; only whether it raises matters
|
||||
case _: # pragma: no cover - exhaustiveness guard, unreachable while the Literal holds
|
||||
assert_never(check) # pragma: no cover
|
||||
|
||||
|
||||
async def _upstream_bearer_identity(token: str, litellm_api_key: str, request: Request) -> UserAPIKeyAuth | None:
|
||||
"""The LiteLLM user an upstream bearer also proves, or ``None`` when it proves none.
|
||||
|
||||
Custom auth decides alone, so any rejection is ``None``. Otherwise the identity check LiteLLM would run
|
||||
must vouch for the caller. Reverse-proxy headers and a JWT are verified first, and OAuth2 introspection
|
||||
runs once inside the full check, where a provider that does not vouch is ``None``. Past that, only a
|
||||
user LiteLLM does not know is ``None``; every other refusal (budget, blocked team, disallowed route)
|
||||
propagates."""
|
||||
from litellm.proxy._experimental.mcp_server.bridge_token_flow import ( # noqa: PLC0415 # bridge flow imports this module
|
||||
custom_auth_configured,
|
||||
)
|
||||
from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
general_settings, # pyright: ignore[reportUnknownVariableType] # legacy global is declared as a bare dict
|
||||
)
|
||||
|
||||
if custom_auth_configured():
|
||||
try:
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
except (HTTPException, ProxyException):
|
||||
return None
|
||||
check: Final = _identity_check(
|
||||
token, get_request_route(request), _PROXY_SETTINGS_ADAPTER.validate_python(general_settings)
|
||||
)
|
||||
if check is None:
|
||||
return None
|
||||
if check != "oauth2":
|
||||
try:
|
||||
await _verify_identity(check, token, request)
|
||||
except Exception: # noqa: BLE001 # any verification failure means LiteLLM does not vouch for this bearer
|
||||
return None
|
||||
try:
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
except (HTTPException, ProxyException, OAuth2TokenValidationError) as exc:
|
||||
if _caused_by(exc, UserNotFoundError) or _caused_by(exc, OAuth2TokenValidationError):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _is_litellm_auth_admission_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, HTTPException):
|
||||
return exc.status_code == 401
|
||||
|
|
@ -675,6 +811,19 @@ class MCPRequestHandler:
|
|||
route=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
)
|
||||
elif oauth2_headers and (
|
||||
legacy_delegate_targets := MCPRequestHandler._legacy_delegate_targets(
|
||||
path=request_route,
|
||||
mcp_servers=mcp_servers,
|
||||
client_ip=IPAddressUtils.get_mcp_client_ip(request),
|
||||
)
|
||||
):
|
||||
validated_user_api_key_auth = await MCPRequestHandler._admit_legacy_delegate_bearer( # rebind-ok: one assignment per admission arm, like its siblings
|
||||
authorization_value=oauth2_headers["Authorization"],
|
||||
litellm_api_key=litellm_api_key,
|
||||
targets=legacy_delegate_targets,
|
||||
request=request,
|
||||
)
|
||||
elif oauth2_headers:
|
||||
# Authorization on a non-delegated server: the bearer must be a real
|
||||
# LiteLLM credential, so a failed validation is a genuine 401/403 and
|
||||
|
|
@ -712,10 +861,14 @@ class MCPRequestHandler:
|
|||
custom_key_header_name: Final = OPTIONAL_STRING_ADAPTER.validate_python(
|
||||
general_settings.get("litellm_key_header_name")
|
||||
)
|
||||
admitted_credential: Final = MCPRequestHandler.caller_admission_credential(
|
||||
headers,
|
||||
validated_user_api_key_auth,
|
||||
custom_key_header_name=custom_key_header_name,
|
||||
admitted_credential: Final = (
|
||||
None
|
||||
if validated_user_api_key_auth.mcp_forwards_caller_authorization
|
||||
else MCPRequestHandler.caller_admission_credential(
|
||||
headers,
|
||||
validated_user_api_key_auth,
|
||||
custom_key_header_name=custom_key_header_name,
|
||||
)
|
||||
)
|
||||
(
|
||||
oauth2_headers,
|
||||
|
|
@ -905,6 +1058,53 @@ class MCPRequestHandler:
|
|||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _legacy_delegate_targets(
|
||||
path: str, mcp_servers: Sequence[str] | None, client_ip: str | None
|
||||
) -> tuple[MCPServer, ...]:
|
||||
"""Every server the request targets when ALL of them are legacy ``delegate_auth_to_upstream``
|
||||
servers, else ``()``. Names resolve the way downstream routing resolves them, so a permissive
|
||||
``x-mcp-servers`` header cannot open the gate for a stricter path target."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
global_mcp_server_manager,
|
||||
is_legacy_upstream_delegate,
|
||||
)
|
||||
|
||||
servers: Final = tuple(
|
||||
global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
|
||||
for name in MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
|
||||
)
|
||||
delegates: Final = tuple(
|
||||
server for server in servers if server is not None and is_legacy_upstream_delegate(server)
|
||||
)
|
||||
return delegates if servers and len(delegates) == len(servers) else ()
|
||||
|
||||
@staticmethod
|
||||
async def _admit_legacy_delegate_bearer(
|
||||
authorization_value: str,
|
||||
litellm_api_key: str,
|
||||
targets: tuple[MCPServer, ...],
|
||||
request: Request,
|
||||
) -> UserAPIKeyAuth:
|
||||
"""Admit a bare ``Authorization`` bearer sent to legacy ``delegate_auth_to_upstream`` servers.
|
||||
|
||||
A LiteLLM credential runs normal admission, and its errors propagate. Any other bearer is the
|
||||
upstream token and is marked for forwarding: a user it also proves who is granted every target is
|
||||
admitted as that user, and every other caller is admitted anonymously."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # manager imports this module
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
token: Final = _normalize_caller_admission_credential(authorization_value)
|
||||
if await _bearer_is_litellm_credential(token):
|
||||
return await user_api_key_auth(api_key=litellm_api_key, request=request)
|
||||
identity: Final = await _upstream_bearer_identity(token, litellm_api_key, request)
|
||||
if identity is not None:
|
||||
allowed: Final = frozenset(await global_mcp_server_manager.get_allowed_mcp_servers(identity))
|
||||
if all(server.server_id in allowed for server in targets):
|
||||
return identity.model_copy(update={"mcp_forwards_caller_authorization": True})
|
||||
return UserAPIKeyAuth().model_copy(update={"mcp_forwards_caller_authorization": True})
|
||||
|
||||
@staticmethod
|
||||
def _single_dcr_bridge_delegate_target(
|
||||
path: str, mcp_servers: list[str] | None, client_ip: str | None
|
||||
|
|
@ -1519,7 +1719,7 @@ class MCPRequestHandler:
|
|||
return expiry >= datetime.now(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_server_names(path: str, mcp_servers_header: list[str] | None) -> list[str]:
|
||||
def _resolve_target_server_names(path: str, mcp_servers_header: Sequence[str] | None) -> Sequence[str]:
|
||||
"""
|
||||
Resolve the target MCP server names exactly as downstream routing
|
||||
does (``server.py::extract_mcp_auth_context``).
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
jwt_handler,
|
||||
master_key,
|
||||
user_custom_auth,
|
||||
)
|
||||
|
||||
if "x-litellm-api-key" in request.headers:
|
||||
|
|
@ -71,10 +70,26 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
return "authorization" in request.headers
|
||||
if token.startswith("sk-") or (master_key and secrets.compare_digest(token.encode(), master_key.encode())):
|
||||
return True
|
||||
if user_custom_auth is not None or jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
|
||||
if custom_auth_configured() or jwt_handler.litellm_jwtauth.oidc_userinfo_enabled:
|
||||
return True
|
||||
if not JWTHandler.is_jwt(token):
|
||||
return await _opaque_bearer_is_gateway_credential(token)
|
||||
return is_sealed_gateway_bearer(token) or await virtual_key_may_exist(token)
|
||||
return jwt_issuer_may_be_litellm(token)
|
||||
|
||||
|
||||
def custom_auth_configured() -> bool:
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the active auth configuration
|
||||
user_custom_auth,
|
||||
)
|
||||
|
||||
return user_custom_auth is not None
|
||||
|
||||
|
||||
def jwt_issuer_may_be_litellm(token: str) -> bool:
|
||||
"""False only when the JWT names an issuer LiteLLM's JWT auth is not configured to accept."""
|
||||
from litellm.proxy.auth.handle_jwt import JWTHandler # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import jwt_handler # noqa: PLC0415 # startup owns the active auth configuration
|
||||
|
||||
claims: Final = JWTHandler.get_unverified_claims(token)
|
||||
issuer: Final = claims.get("iss") if claims is not None else None
|
||||
global_issuer: Final = os.getenv("JWT_ISSUER")
|
||||
|
|
@ -86,13 +101,27 @@ async def oauth_authorization_uses_gateway_credential(request: Request) -> bool:
|
|||
)
|
||||
|
||||
|
||||
async def _opaque_bearer_is_gateway_credential(token: str) -> bool:
|
||||
def is_sealed_gateway_bearer(token: str) -> bool:
|
||||
"""True for a bearer only the gateway issues: a bridge or refresh envelope, a v2 GCM blob, or a
|
||||
dashboard UI token. A UI token that cannot be read still counts, so a lookup fault never reads as foreign."""
|
||||
from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # envelope imports bridge types
|
||||
is_envelope,
|
||||
is_refresh_envelope,
|
||||
)
|
||||
from litellm.proxy._types import hash_token # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken # noqa: PLC0415 # proxy import cycle
|
||||
|
||||
if is_envelope(token) or is_refresh_envelope(token) or token.startswith(V2_GCM_PREFIX):
|
||||
return True
|
||||
try:
|
||||
return ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None
|
||||
except Exception as exc: # noqa: BLE001 # an identity lookup fault must not permit cookie fallback
|
||||
verbose_logger.debug("OAuth bearer ownership could not be checked (%s)", type(exc).__name__)
|
||||
return True
|
||||
|
||||
|
||||
async def virtual_key_may_exist(token: str) -> bool:
|
||||
"""False only when the key table answers that ``token`` is not a virtual key; a lookup fault counts as held."""
|
||||
from litellm.proxy._types import hash_token # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.auth.resolvers.store import IdentityStore # noqa: PLC0415 # proxy import cycle
|
||||
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # startup owns the identity store dependencies
|
||||
|
|
@ -100,11 +129,7 @@ async def _opaque_bearer_is_gateway_credential(token: str) -> bool:
|
|||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if is_envelope(token) or is_refresh_envelope(token) or token.startswith(V2_GCM_PREFIX):
|
||||
return True
|
||||
try:
|
||||
if ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) is not None:
|
||||
return True
|
||||
await IdentityStore(prisma_client, user_api_key_cache).resolve(hashed_token=hash_token(token))
|
||||
except KeyNotFoundError:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1189,14 +1189,23 @@ def should_strip_caller_authorization(
|
|||
pass-through cold-start case (RFC 9728) the bearer in
|
||||
``Authorization`` is the upstream OAuth token and must be
|
||||
forwarded, so we keep it.
|
||||
- **Delegated OAuth servers**: admission always runs and there is no
|
||||
anonymous path, so the caller's separate ``Authorization`` is
|
||||
- **Legacy delegate servers**: forward when MCP admission marked the
|
||||
caller's ``Authorization`` as the upstream token
|
||||
(``mcp_forwards_caller_authorization``); otherwise follow the
|
||||
delegated OAuth rule below.
|
||||
- **Delegated OAuth servers**: the caller's separate ``Authorization`` is
|
||||
forwarded only when a distinct ``x-litellm-api-key`` carried
|
||||
admission. Without that header the ``Authorization`` *was* the
|
||||
admission credential — a virtual key, an IdP JWT, or an SSO / OIDC /
|
||||
session token whose ``api_key`` is ``None`` — and must never reach
|
||||
the upstream, so it is stripped regardless of the ``api_key`` value.
|
||||
"""
|
||||
if (
|
||||
user_api_key_auth is not None
|
||||
and user_api_key_auth.mcp_forwards_caller_authorization
|
||||
and is_legacy_upstream_delegate(mcp_server)
|
||||
):
|
||||
return False
|
||||
if _authorization_is_admission_jwt(raw_headers, user_api_key_auth):
|
||||
return True
|
||||
if mcp_server.auth_type == MCPAuth.oauth2_token_exchange:
|
||||
|
|
@ -1511,6 +1520,18 @@ async def _materialize_auth_headers(auth: httpx2.Auth | None) -> dict[str, str]
|
|||
return {header_name: header_value} if header_value else None
|
||||
|
||||
|
||||
def is_legacy_upstream_delegate(server: MCPServer) -> bool:
|
||||
"""True for an interactive ``auth_type=oauth2`` server with ``delegate_auth_to_upstream=true``, whose
|
||||
caller ``Authorization`` is the upstream token. The flow is resolved, not read from the column, so an
|
||||
unstamped M2M-shaped row stays excluded: its stored client credentials drive egress, and admitting it
|
||||
anonymously would let any caller act as LiteLLM's service account."""
|
||||
return (
|
||||
server.auth_type == MCPAuth.oauth2
|
||||
and server.delegate_auth_to_upstream is True
|
||||
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
|
||||
)
|
||||
|
||||
|
||||
def _consumes_caller_authorization(server: MCPServer) -> bool:
|
||||
"""True when this server's egress forwards the caller's request-wide ``Authorization`` upstream:
|
||||
the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated
|
||||
|
|
@ -1662,18 +1683,15 @@ def warn_on_shared_identifier_prefixes(servers: Iterable[MCPServer]) -> None:
|
|||
|
||||
|
||||
def _warn_legacy_delegate_auth_if_applicable(server: MCPServer, *, source: str) -> None:
|
||||
"""Direct legacy delegated OAuth configurations to the admitted replacement."""
|
||||
if server.auth_type != MCPAuth.oauth2:
|
||||
return
|
||||
if getattr(server, "delegate_auth_to_upstream", False) is not True:
|
||||
return
|
||||
if server.has_client_credentials:
|
||||
"""Point legacy delegated OAuth configurations at the client-forwarded replacements."""
|
||||
if not is_legacy_upstream_delegate(server):
|
||||
return
|
||||
label: Final = get_server_prefix(server)
|
||||
verbose_logger.warning(
|
||||
"MCP server %r (id=%s, source=%s) uses deprecated auth_type=oauth2 with "
|
||||
"delegate_auth_to_upstream=true. LiteLLM admission is now required; migrate to "
|
||||
"auth_type=oauth_delegate for client-forwarded OAuth.",
|
||||
"delegate_auth_to_upstream=true. It still works: a caller without a LiteLLM credential is "
|
||||
"admitted anonymously and its upstream token is forwarded. Migrate to auth_type=true_passthrough "
|
||||
"or auth_type=oauth_delegate.",
|
||||
label,
|
||||
server.server_id,
|
||||
source,
|
||||
|
|
@ -3723,12 +3741,12 @@ class MCPServerManager:
|
|||
)
|
||||
)
|
||||
if is_anonymous:
|
||||
passthrough_server_ids: Final = [
|
||||
anonymous_server_ids: Final = [
|
||||
server.server_id
|
||||
for server in self.get_registry().values()
|
||||
if server.auth_type == MCPAuth.true_passthrough
|
||||
if server.auth_type == MCPAuth.true_passthrough or is_legacy_upstream_delegate(server)
|
||||
]
|
||||
combined_servers.update(passthrough_server_ids)
|
||||
combined_servers.update(anonymous_server_ids)
|
||||
|
||||
restrict_allow_all: Final = (
|
||||
resolved_general_settings.get("mcp_allow_all_keys_respects_mcp_scope", False)
|
||||
|
|
|
|||
|
|
@ -578,6 +578,7 @@ if MCP_AVAILABLE:
|
|||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
MCPServerManager,
|
||||
global_mcp_server_manager,
|
||||
is_legacy_upstream_delegate,
|
||||
)
|
||||
|
||||
######################################################
|
||||
|
|
@ -2152,8 +2153,9 @@ if MCP_AVAILABLE:
|
|||
oauth2_headers: Mapping[str, str] | None,
|
||||
mcp_server_auth_headers: dict[str, dict[str, str]] | None,
|
||||
raw_headers: Mapping[str, str] | None,
|
||||
requested_servers: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Probe pass-through upstream servers in parallel before the MCP session starts.
|
||||
"""Probe pass-through and legacy delegate upstream servers in parallel before the MCP session starts.
|
||||
|
||||
Only servers the caller's key is already authorized to reach are probed —
|
||||
the list is derived from _get_allowed_mcp_servers so that a user cannot
|
||||
|
|
@ -2176,6 +2178,18 @@ if MCP_AVAILABLE:
|
|||
mcp_servers=mcp_servers,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
# A legacy delegate is probed only when the single requested name resolves to it the way admission
|
||||
# resolves it, so neither a multi-server connect nor an access group can be failed by one rejected
|
||||
# token; its challenge echoes the requested name, matching the tokenless challenge.
|
||||
requested_scope: Final = requested_servers if requested_servers is not None else mcp_servers
|
||||
single_requested_name: Final = (
|
||||
requested_scope[0] if requested_scope is not None and len(requested_scope) == 1 else None
|
||||
)
|
||||
requested_server: Final = (
|
||||
operations.global_mcp_server_manager.get_mcp_server_by_name(single_requested_name, client_ip=client_ip)
|
||||
if single_requested_name is not None
|
||||
else None
|
||||
)
|
||||
prepared_headers: Final = tuple(
|
||||
(
|
||||
srv,
|
||||
|
|
@ -2191,6 +2205,11 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
for srv in allowed_servers
|
||||
if srv.is_oauth_passthrough
|
||||
or (
|
||||
requested_server is not None
|
||||
and srv.server_id == requested_server.server_id
|
||||
and is_legacy_upstream_delegate(srv)
|
||||
)
|
||||
)
|
||||
probe_headers: Final = tuple(
|
||||
(
|
||||
|
|
@ -2203,7 +2222,7 @@ if MCP_AVAILABLE:
|
|||
for srv, (auth, extra) in prepared_headers
|
||||
)
|
||||
probe_targets: Final[tuple[tuple[MCPServer, str, str], ...]] = tuple(
|
||||
(srv, auth_header, srv.name)
|
||||
(srv, auth_header, srv.name if srv.is_oauth_passthrough else single_requested_name or srv.name)
|
||||
for srv, headers in probe_headers
|
||||
if (
|
||||
auth_header := next(
|
||||
|
|
@ -2301,6 +2320,7 @@ if MCP_AVAILABLE:
|
|||
if context.mcp_server_auth_headers is not None
|
||||
else None,
|
||||
raw_headers=context.raw_headers,
|
||||
requested_servers=context.mcp_servers,
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
|
||||
class OAuth2TokenValidationError(ValueError):
|
||||
"""The token-info or introspection endpoint did not vouch for the token: it rejected it, reported it
|
||||
inactive, or could not be reached."""
|
||||
|
||||
|
||||
class Oauth2Handler:
|
||||
"""
|
||||
Handles OAuth2 token validation.
|
||||
|
|
@ -207,7 +212,7 @@ class Oauth2Handler:
|
|||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
# This will catch any 4xx or 5xx errors
|
||||
raise ValueError(f"Oauth 2.0 Token validation failed: {e}")
|
||||
raise OAuth2TokenValidationError(f"Oauth 2.0 Token validation failed: {e}")
|
||||
except Exception as e:
|
||||
# This will catch any other errors (like network issues)
|
||||
raise ValueError(f"An error occurred during token validation: {e}")
|
||||
raise OAuth2TokenValidationError(f"An error occurred during token validation: {e}")
|
||||
|
|
|
|||
|
|
@ -1098,6 +1098,24 @@ def _matches_routing_override(token_claims: dict, override: "JWTRoutingOverride"
|
|||
)
|
||||
|
||||
|
||||
def oauth2_introspection_applies(
|
||||
api_key: str, route: str, *, enable_oauth2_auth: bool, enable_jwt_auth: bool, jwt_handler: JWTHandler
|
||||
) -> bool:
|
||||
"""Whether LiteLLM validates ``api_key`` on ``route`` by OAuth2 token introspection, on LLM API, info and
|
||||
lens routes only: a non-JWT token when OAuth2 auth is on globally, or a JWT matched by a routing override."""
|
||||
is_jwt: Final = enable_jwt_auth and jwt_handler.is_jwt(token=api_key)
|
||||
# Routing uses unverified JWT claims only to choose auth path.
|
||||
# Final authentication is enforced by the selected validator.
|
||||
route_jwt_to_oauth2: Final = is_jwt and _should_route_jwt_to_oauth2_override(token=api_key, jwt_handler=jwt_handler)
|
||||
covered_route: Final = (
|
||||
RouteChecks.is_llm_api_route(route=route)
|
||||
or RouteChecks.is_info_route(route=route)
|
||||
or route == "/lens"
|
||||
or route.startswith("/lens/")
|
||||
)
|
||||
return covered_route and ((enable_oauth2_auth and not is_jwt) or route_jwt_to_oauth2)
|
||||
|
||||
|
||||
def _should_route_jwt_to_oauth2_override(token: str, jwt_handler: JWTHandler) -> bool:
|
||||
routing_overrides: Final = jwt_handler.litellm_jwtauth.routing_overrides
|
||||
if not routing_overrides:
|
||||
|
|
@ -2194,23 +2212,13 @@ async def user_api_key_auth_builder(
|
|||
|
||||
enable_oauth2_auth: Final = general_settings.get("enable_oauth2_auth", False) is True
|
||||
enable_jwt_auth: Final = general_settings.get("enable_jwt_auth", False) is True
|
||||
is_jwt = jwt_handler.is_jwt(token=api_key) if enable_jwt_auth else False
|
||||
|
||||
# Routing uses unverified JWT claims only to choose auth path.
|
||||
# Final authentication is enforced by the selected validator.
|
||||
route_jwt_to_oauth2 = is_jwt and _should_route_jwt_to_oauth2_override(token=api_key, jwt_handler=jwt_handler)
|
||||
|
||||
# OAuth2 applies for:
|
||||
# 1) when global OAuth2 auth is enabled on LLM + info routes
|
||||
# 2) JWT tokens that explicitly match routing_overrides on LLM + info routes
|
||||
route_is_lens: Final = route == "/lens" or route.startswith("/lens/")
|
||||
should_apply_override_oauth2: Final = route_jwt_to_oauth2 and (
|
||||
RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route) or route_is_lens
|
||||
)
|
||||
should_apply_global_oauth2: Final = enable_oauth2_auth and (
|
||||
RouteChecks.is_llm_api_route(route=route) or RouteChecks.is_info_route(route=route) or route_is_lens
|
||||
)
|
||||
if (should_apply_global_oauth2 and not is_jwt) or should_apply_override_oauth2:
|
||||
if oauth2_introspection_applies(
|
||||
api_key,
|
||||
route,
|
||||
enable_oauth2_auth=enable_oauth2_auth,
|
||||
enable_jwt_auth=enable_jwt_auth,
|
||||
jwt_handler=jwt_handler,
|
||||
):
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
if premium_user is not True:
|
||||
|
|
|
|||
|
|
@ -204,9 +204,11 @@ class MCPServer(LiteLLMBaseModel):
|
|||
access_groups: list[str] | None = None
|
||||
allow_all_keys: bool = False
|
||||
available_on_public_internet: bool = True
|
||||
# Legacy opt-in to upstream-delegated authentication for ``oauth2``
|
||||
# servers. LiteLLM admission still applies; use ``oauth_delegate`` for the
|
||||
# supported client-forwarded OAuth flow.
|
||||
# Deprecated opt-in to upstream-delegated authentication for ``oauth2``
|
||||
# servers. A bare ``Authorization`` that is not a LiteLLM credential is the
|
||||
# upstream token and is forwarded; see
|
||||
# ``MCPRequestHandler._admit_legacy_delegate_bearer``. New servers should use
|
||||
# ``true_passthrough`` or ``oauth_delegate``.
|
||||
#
|
||||
# Honored only for ``auth_type == oauth2``; ignored for any other
|
||||
# ``auth_type``. OAuth pass-through for non-oauth2 servers
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
# above; a forged value could at most narrow, but the stripping keeps the field's provenance
|
||||
# single-owner so its meaning stays trustworthy.
|
||||
mcp_session_resource_server_ids: tuple[str, ...] | None = Field(default=None, exclude=True)
|
||||
# Set only by MCP admission when a legacy delegate_auth_to_upstream caller's Authorization is the
|
||||
# upstream token rather than a LiteLLM credential, so egress forwards it. Stripped from validated
|
||||
# input like the markers above: a forged value would forward a LiteLLM credential upstream.
|
||||
mcp_forwards_caller_authorization: bool = Field(default=False, exclude=True)
|
||||
mcp_toolset_id: str | None = Field(default=None, exclude=True)
|
||||
authenticated_by_custom_auth: bool = Field(default=False, exclude=True)
|
||||
via_virtual_key: bool = Field(
|
||||
|
|
@ -230,6 +234,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob
|
|||
values.pop("mcp_explicit_grants_only", None)
|
||||
values.pop("mcp_source_team_rpm_limits", None)
|
||||
values.pop("mcp_session_resource_server_ids", None)
|
||||
values.pop("mcp_forwards_caller_authorization", None)
|
||||
values.pop("mcp_toolset_id", None)
|
||||
values.pop("via_virtual_key", None)
|
||||
values.pop("authenticated_by_custom_auth", None)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from collections.abc import Set as AbstractSet
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -1598,9 +1598,7 @@ class TestMCPRequestHandler:
|
|||
team_id="test-team-id",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth"
|
||||
) as mock_user_api_key_auth:
|
||||
with patch("litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth") as mock_user_api_key_auth:
|
||||
mock_user_api_key_auth.return_value = mock_auth_result
|
||||
|
||||
# Call the method
|
||||
|
|
@ -1662,10 +1660,10 @@ class TestMCPOAuth2AuthFlow:
|
|||
as LiteLLM API keys, causing auth failures and empty tool listings.
|
||||
"""
|
||||
|
||||
async def test_oauth2_token_in_authorization_header_requires_litellm_admission(self):
|
||||
async def test_oauth2_upstream_token_on_legacy_delegate_is_admitted_anonymously(self) -> None:
|
||||
"""
|
||||
A bare Authorization token on the legacy delegated mode must establish
|
||||
a LiteLLM principal rather than entering anonymously.
|
||||
A bare upstream OAuth token on the legacy delegated mode is admitted
|
||||
anonymously without LiteLLM key validation and kept for egress.
|
||||
"""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
|
|
@ -1701,10 +1699,11 @@ class TestMCPOAuth2AuthFlow:
|
|||
raw_headers,
|
||||
) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
assert auth_result.user_id == "admitted-user"
|
||||
mock_auth.assert_awaited_once()
|
||||
assert oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-access-token-xyz"
|
||||
assert auth_result.user_id is None
|
||||
assert auth_result.api_key is None
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
mock_auth.assert_not_awaited()
|
||||
assert oauth2_headers == {"Authorization": "Bearer atlassian-oauth2-access-token-xyz"}
|
||||
|
||||
async def test_explicit_litellm_key_with_oauth2_authorization(self):
|
||||
"""
|
||||
|
|
@ -2544,9 +2543,10 @@ class TestMCPDelegateAuthToUpstream:
|
|||
"""
|
||||
Tests for the ``delegate_auth_to_upstream`` per-server flag.
|
||||
|
||||
The legacy flag no longer bypasses LiteLLM admission. OAuth discovery may
|
||||
still use the anonymous cold-start challenge, but a presented bearer must
|
||||
authenticate to LiteLLM unless a separate admission credential is supplied.
|
||||
A bare bearer that is a LiteLLM credential takes normal admission and is
|
||||
never forwarded. Any other bearer is the upstream token: it is forwarded,
|
||||
and the caller is admitted as the JWT or custom-auth user it also proves
|
||||
when that user is granted the server, else anonymously.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -2651,37 +2651,497 @@ class TestMCPDelegateAuthToUpstream:
|
|||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
mock_auth.assert_awaited_once()
|
||||
|
||||
async def test_delegate_with_only_upstream_token_requires_litellm_auth(self):
|
||||
"""
|
||||
An upstream token cannot establish a LiteLLM principal and must not
|
||||
reopen anonymous admission.
|
||||
"""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
scope = {
|
||||
@staticmethod
|
||||
def _bearer_scope(token: str) -> dict[str, object]:
|
||||
return {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/delegated_oauth_server",
|
||||
"headers": [(b"authorization", b"Bearer upstream-pkce-token")],
|
||||
"headers": [(b"authorization", f"Bearer {token}".encode())],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _jwt(issuer: str = "https://idp.example") -> str:
|
||||
import jwt
|
||||
|
||||
return jwt.encode({"iss": issuer, "sub": "jwt-user"}, "test-signing-secret", algorithm="HS256")
|
||||
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def _delegate_admission(
|
||||
admission: AsyncMock, allowed_server_ids: Sequence[str] = ("test-server-id",)
|
||||
) -> Iterator[None]:
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=HTTPException(status_code=401, detail="Invalid API key"),
|
||||
) as mock_auth,
|
||||
patch("litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", admission),
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server(
|
||||
auth_type=MCPAuth.oauth2,
|
||||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
mock_mgr.get_allowed_mcp_servers = AsyncMock(return_value=list(allowed_server_ids))
|
||||
yield
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
mock_auth.assert_awaited_once()
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def _jwt_auth_enabled(auth_jwt: AsyncMock) -> Iterator[None]:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_JWTAuth
|
||||
|
||||
with (
|
||||
patch.dict(proxy_server.general_settings, {"enable_jwt_auth": True}),
|
||||
patch.object(proxy_server, "premium_user", True),
|
||||
patch.object(proxy_server, "user_custom_auth", None),
|
||||
patch.object(proxy_server.jwt_handler, "auth_jwt", auth_jwt),
|
||||
patch.object(proxy_server.jwt_handler, "litellm_jwtauth", LiteLLM_JWTAuth(), create=True),
|
||||
patch.dict(os.environ),
|
||||
):
|
||||
os.environ.pop("JWT_ISSUER", None)
|
||||
yield
|
||||
|
||||
async def test_delegate_forwards_unknown_sk_token_after_key_table_miss(self) -> None:
|
||||
"""An sk- token the key table does not hold, such as a provider API key, belongs to the upstream:
|
||||
it is forwarded anonymously rather than rejected for its prefix."""
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError
|
||||
|
||||
admission: Final = AsyncMock()
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission),
|
||||
patch(
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore.resolve",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=KeyNotFoundError("hashed-token"),
|
||||
),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("sk-proj-upstream-provider-key")
|
||||
)
|
||||
|
||||
assert auth_result.api_key is None
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": "Bearer sk-proj-upstream-provider-key"}
|
||||
admission.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lookup_effect",
|
||||
[None, RuntimeError("key table unreachable")],
|
||||
ids=["key-held", "lookup-fault"],
|
||||
)
|
||||
async def test_delegate_litellm_key_refusal_propagates(self, lookup_effect: Exception | None) -> None:
|
||||
"""A key the table holds, or one the lookup could not check, takes normal admission, so its
|
||||
refusal reaches the caller instead of becoming an anonymous request that forwards the key."""
|
||||
refusal: Final = ProxyException(
|
||||
message="Budget has been exceeded", type="budget_exceeded", param=None, code=400
|
||||
)
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(AsyncMock(side_effect=refusal)),
|
||||
patch(
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore.resolve",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=lookup_effect,
|
||||
),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(TestMCPDelegateAuthToUpstream._bearer_scope("sk-held-key"))
|
||||
|
||||
assert exc_info.value is refusal
|
||||
|
||||
async def test_delegate_master_key_is_admitted_and_never_forwarded(self) -> None:
|
||||
"""The master key is not in the key table, so only recognizing it directly stops it from being
|
||||
treated as the upstream's token and sent to the upstream server."""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError
|
||||
|
||||
admission: Final = AsyncMock(return_value=UserAPIKeyAuth(api_key="sk-master-secret", user_role="proxy_admin"))
|
||||
with (
|
||||
patch.object(proxy_server, "master_key", "sk-master-secret"),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission),
|
||||
patch(
|
||||
"litellm.proxy.auth.resolvers.store.IdentityStore.resolve",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=KeyNotFoundError("hashed-master-key"),
|
||||
),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("sk-master-secret")
|
||||
)
|
||||
|
||||
admission.assert_awaited_once()
|
||||
assert auth_result.mcp_forwards_caller_authorization is False
|
||||
assert oauth2_headers is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signing_secret, expires_in, forwarded",
|
||||
[
|
||||
("sk-master-secret", timedelta(hours=1), False),
|
||||
("sk-master-secret", timedelta(hours=-1), False),
|
||||
("upstream-idp-signing-secret", timedelta(hours=1), True),
|
||||
],
|
||||
ids=["master-key-signed", "master-key-signed-expired", "signed-elsewhere"],
|
||||
)
|
||||
async def test_delegate_master_key_signed_jwt_is_never_forwarded(
|
||||
self, signing_secret: str, expires_in: timedelta, forwarded: bool
|
||||
) -> None:
|
||||
"""LiteLLM signs dashboard and OAuth tokens with the master key, so such a JWT is LiteLLM's own even
|
||||
once expired: it takes normal admission and never reaches the upstream. A JWT signed by anyone
|
||||
else is the upstream's token."""
|
||||
import jwt
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
token: Final = jwt.encode(
|
||||
{"sub": "dashboard-user", "exp": datetime.now(timezone.utc) + expires_in}, signing_secret, algorithm="HS256"
|
||||
)
|
||||
admission: Final = AsyncMock(return_value=UserAPIKeyAuth(user_id="dashboard-user"))
|
||||
with (
|
||||
patch.object(proxy_server, "master_key", "sk-master-secret"),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission),
|
||||
):
|
||||
auth_result, *_ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope(token)
|
||||
)
|
||||
|
||||
assert auth_result.mcp_forwards_caller_authorization is forwarded
|
||||
assert admission.await_count == (0 if forwarded else 1)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token, ui_token_lookup",
|
||||
[
|
||||
("llm_env_sealed-bridge-envelope", None),
|
||||
("llm_refresh_sealed-refresh-envelope", None),
|
||||
("v2:gcm:sealed-credential-blob", None),
|
||||
("dashboard-login-token", lambda: MagicMock(return_value=UserAPIKeyAuth(user_id="dashboard-user"))),
|
||||
("unreadable-dashboard-token", lambda: MagicMock(side_effect=ValueError("Invalid hash key"))),
|
||||
],
|
||||
ids=["bridge-envelope", "refresh-envelope", "gcm-blob", "dashboard-token", "unreadable-dashboard-token"],
|
||||
)
|
||||
async def test_delegate_sealed_litellm_token_takes_normal_admission(
|
||||
self, token: str, ui_token_lookup: Callable[[], MagicMock] | None
|
||||
) -> None:
|
||||
"""A token only LiteLLM issues is LiteLLM's own, even one it fails to read, so it goes through
|
||||
normal admission and is never marked for forwarding upstream."""
|
||||
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
|
||||
|
||||
admission: Final = AsyncMock(return_value=UserAPIKeyAuth(user_id="litellm-user"))
|
||||
ui_lookup: Final = (
|
||||
patch.object(ExperimentalUIJWTToken, "get_key_object_from_ui_hash_key", ui_token_lookup())
|
||||
if ui_token_lookup is not None
|
||||
else contextlib.nullcontext()
|
||||
)
|
||||
with TestMCPDelegateAuthToUpstream._delegate_admission(admission), ui_lookup:
|
||||
auth_result, *_ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope(token)
|
||||
)
|
||||
|
||||
admission.assert_awaited_once()
|
||||
assert auth_result.mcp_forwards_caller_authorization is False
|
||||
|
||||
@staticmethod
|
||||
def _unknown_user_refusal() -> ProxyException:
|
||||
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
|
||||
|
||||
refusal: Final = ProxyException(message="User doesn't exist in db", type="auth_error", param=None, code=401)
|
||||
refusal.__cause__ = UserNotFoundError(user_id="jwt-user")
|
||||
return refusal
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"auth_jwt_effect, admission, allowed_server_ids, expected_user_id",
|
||||
[
|
||||
(
|
||||
None,
|
||||
lambda: AsyncMock(return_value=UserAPIKeyAuth(user_id="jwt-user", jwt_claims={"sub": "jwt-user"})),
|
||||
["test-server-id"],
|
||||
"jwt-user",
|
||||
),
|
||||
(
|
||||
None,
|
||||
lambda: AsyncMock(return_value=UserAPIKeyAuth(user_id="jwt-user", jwt_claims={"sub": "jwt-user"})),
|
||||
[],
|
||||
None,
|
||||
),
|
||||
(
|
||||
None,
|
||||
lambda: AsyncMock(side_effect=TestMCPDelegateAuthToUpstream._unknown_user_refusal()),
|
||||
["test-server-id"],
|
||||
None,
|
||||
),
|
||||
(
|
||||
ValueError("Signature verification failed"),
|
||||
lambda: AsyncMock(return_value=UserAPIKeyAuth(user_id="jwt-user")),
|
||||
["test-server-id"],
|
||||
None,
|
||||
),
|
||||
],
|
||||
ids=["granted-user", "user-not-granted", "user-unknown-to-litellm", "signature-rejected"],
|
||||
)
|
||||
async def test_delegate_jwt_is_forwarded_as_granted_user_or_anonymously(
|
||||
self,
|
||||
auth_jwt_effect: Exception | None,
|
||||
admission: Callable[[], AsyncMock],
|
||||
allowed_server_ids: Sequence[str],
|
||||
expected_user_id: str | None,
|
||||
) -> None:
|
||||
"""A JWT LiteLLM also accepts admits that user when granted the server; a user who is not
|
||||
granted, unknown to LiteLLM, or whose signature fails is admitted anonymously. The JWT is always
|
||||
forwarded, because on this server it is the upstream's token."""
|
||||
token: Final = TestMCPDelegateAuthToUpstream._jwt()
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._jwt_auth_enabled(AsyncMock(side_effect=auth_jwt_effect)),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission(), allowed_server_ids),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope(token)
|
||||
)
|
||||
|
||||
assert auth_result.user_id == expected_user_id
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"refusal",
|
||||
[
|
||||
ProxyException(message="Budget has been exceeded", type="budget_exceeded", param=None, code=400),
|
||||
ProxyException(message="Team=t1 is blocked", type="auth_error", param=None, code=401),
|
||||
],
|
||||
ids=["over-budget", "blocked-team"],
|
||||
)
|
||||
async def test_delegate_jwt_refusal_after_valid_signature_propagates(self, refusal: ProxyException) -> None:
|
||||
"""Once the signature verifies, LiteLLM knows who the caller is, so a budget or an explicit
|
||||
disallowment is enforced and the request fails instead of continuing anonymously."""
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._jwt_auth_enabled(AsyncMock(return_value={"sub": "jwt-user"})),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(AsyncMock(side_effect=refusal)),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope(TestMCPDelegateAuthToUpstream._jwt())
|
||||
)
|
||||
|
||||
assert exc_info.value is refusal
|
||||
|
||||
@staticmethod
|
||||
@contextlib.contextmanager
|
||||
def _idp_auth_enabled(
|
||||
mechanism: Literal["oauth2", "oauth2_proxy"], verify: AsyncMock, premium: bool = True
|
||||
) -> Iterator[None]:
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.auth.oauth2_check import Oauth2Handler
|
||||
|
||||
settings: Final = {"enable_oauth2_auth": True} if mechanism == "oauth2" else {"enable_oauth2_proxy_auth": True}
|
||||
verifier: Final = (
|
||||
patch.object(Oauth2Handler, "check_oauth2_token", verify)
|
||||
if mechanism == "oauth2"
|
||||
else patch("litellm.proxy.auth.oauth2_proxy_hook.handle_oauth2_proxy_request", verify)
|
||||
)
|
||||
with (
|
||||
patch.dict(proxy_server.general_settings, settings),
|
||||
patch.object(proxy_server, "premium_user", premium),
|
||||
patch.object(proxy_server, "user_custom_auth", None),
|
||||
verifier,
|
||||
):
|
||||
yield
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"verify_effect, allowed_server_ids, expected_user_id",
|
||||
[
|
||||
(None, ["test-server-id"], "idp-user"),
|
||||
(None, [], None),
|
||||
(ValueError("Missing trusted proxy header"), ["test-server-id"], None),
|
||||
],
|
||||
ids=["granted-user", "user-not-granted", "identity-rejected"],
|
||||
)
|
||||
async def test_delegate_proxy_header_identity_follows_the_jwt_rules(
|
||||
self,
|
||||
verify_effect: Exception | None,
|
||||
allowed_server_ids: Sequence[str],
|
||||
expected_user_id: str | None,
|
||||
) -> None:
|
||||
"""Trusted reverse-proxy headers admit a granted user by name, and a user who is not granted or not
|
||||
vouched for anonymously. The bearer is forwarded either way."""
|
||||
admission: Final = AsyncMock(return_value=UserAPIKeyAuth(user_id="idp-user"))
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._idp_auth_enabled("oauth2_proxy", AsyncMock(side_effect=verify_effect)),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission, allowed_server_ids),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("opaque-idp-token")
|
||||
)
|
||||
|
||||
assert auth_result.user_id == expected_user_id
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": "Bearer opaque-idp-token"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_status, active, allowed_server_ids, expected_user_id",
|
||||
[
|
||||
(200, True, ["test-server-id"], "idp-user"),
|
||||
(200, True, [], None),
|
||||
(200, False, ["test-server-id"], None),
|
||||
(401, True, ["test-server-id"], None),
|
||||
],
|
||||
ids=["granted-user", "user-not-granted", "token-inactive", "provider-rejects-token"],
|
||||
)
|
||||
async def test_delegate_oauth2_introspects_once_and_follows_the_jwt_rules(
|
||||
self, provider_status: int, active: bool, allowed_server_ids: Sequence[str], expected_user_id: str | None
|
||||
) -> None:
|
||||
"""The provider is asked about the token once per request. A token it vouches for admits a granted
|
||||
user by name, and one it reports inactive or rejects outright is forwarded anonymously instead of
|
||||
failing the request."""
|
||||
import httpx
|
||||
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
async def introspect(outbound: httpx.Request, **kwargs: object) -> httpx.Response:
|
||||
return httpx.Response(provider_status, request=outbound, json={"active": active, "sub": "idp-user"})
|
||||
|
||||
with (
|
||||
patch.dict(proxy_server.general_settings, {"enable_oauth2_auth": True}),
|
||||
patch.object(proxy_server, "premium_user", True),
|
||||
patch.object(proxy_server, "user_custom_auth", None),
|
||||
patch.object(proxy_server, "master_key", "sk-fixture-master"),
|
||||
patch.object(proxy_server, "prisma_client", None),
|
||||
patch.object(proxy_server, "user_api_key_cache", DualCache()),
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OAUTH_TOKEN_INFO_ENDPOINT": "https://idp.fixture.test/introspect",
|
||||
"OAUTH_CLIENT_ID": "fixture-client",
|
||||
"OAUTH_CLIENT_SECRET": "fixture-client-secret",
|
||||
},
|
||||
),
|
||||
patch("httpx.AsyncClient.send", side_effect=introspect) as network,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server(
|
||||
auth_type=MCPAuth.oauth2, delegate_auth_to_upstream=True
|
||||
)
|
||||
mock_mgr.get_allowed_mcp_servers = AsyncMock(return_value=list(allowed_server_ids))
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("opaque-idp-token")
|
||||
)
|
||||
|
||||
assert network.call_count == 1
|
||||
assert auth_result.user_id == expected_user_id
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": "Bearer opaque-idp-token"}
|
||||
|
||||
async def test_delegate_oauth2_rejection_admits_anonymously_whether_or_not_auth_wraps_it(self) -> None:
|
||||
"""A provider rejection is the provider declining to vouch, so the bearer is forwarded anonymously
|
||||
even when it reaches admission as the bare validation error rather than a wrapped auth error."""
|
||||
from litellm.proxy.auth.oauth2_check import OAuth2TokenValidationError
|
||||
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._idp_auth_enabled("oauth2", AsyncMock()),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(
|
||||
AsyncMock(side_effect=OAuth2TokenValidationError("Token is not active"))
|
||||
),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("opaque-idp-token")
|
||||
)
|
||||
|
||||
assert auth_result.user_id is None
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": "Bearer opaque-idp-token"}
|
||||
|
||||
@pytest.mark.parametrize("mechanism", ["oauth2", "oauth2_proxy"])
|
||||
async def test_delegate_idp_user_refusal_propagates(self, mechanism: Literal["oauth2", "oauth2_proxy"]) -> None:
|
||||
"""Once the provider vouches for the caller, LiteLLM's budget and permission refusals apply."""
|
||||
refusal: Final = ProxyException(
|
||||
message="Budget has been exceeded", type="budget_exceeded", param=None, code=400
|
||||
)
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._idp_auth_enabled(mechanism, AsyncMock()),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(AsyncMock(side_effect=refusal)),
|
||||
):
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("opaque-idp-token")
|
||||
)
|
||||
|
||||
assert exc_info.value is refusal
|
||||
|
||||
async def test_delegate_oauth2_without_premium_never_calls_the_provider(self) -> None:
|
||||
"""OAuth2 introspection is enterprise-only, so without a license the bearer is the upstream's alone."""
|
||||
introspect: Final = AsyncMock()
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._idp_auth_enabled("oauth2", introspect, premium=False),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(
|
||||
AsyncMock(return_value=UserAPIKeyAuth(user_id="idp-user"))
|
||||
),
|
||||
):
|
||||
auth_result, *_ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("opaque-idp-token")
|
||||
)
|
||||
|
||||
assert auth_result.user_id is None
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
introspect.assert_not_awaited()
|
||||
|
||||
async def test_delegate_jwt_from_unconfigured_issuer_is_forwarded_without_jwt_auth(self) -> None:
|
||||
"""A JWT from an issuer LiteLLM's JWT auth is not configured for never reaches JWT auth."""
|
||||
auth_jwt: Final = AsyncMock(return_value={"sub": "jwt-user"})
|
||||
admission: Final = AsyncMock(return_value=UserAPIKeyAuth(user_id="jwt-user"))
|
||||
with (
|
||||
TestMCPDelegateAuthToUpstream._jwt_auth_enabled(auth_jwt),
|
||||
patch.dict(os.environ, {"JWT_ISSUER": "https://litellm-idp.example"}),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission),
|
||||
):
|
||||
auth_result, *_ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope(
|
||||
TestMCPDelegateAuthToUpstream._jwt(issuer="https://upstream-idp.example")
|
||||
)
|
||||
)
|
||||
|
||||
assert auth_result.user_id is None
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
auth_jwt.assert_not_awaited()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"admission, expected_user_id",
|
||||
[
|
||||
(
|
||||
lambda: AsyncMock(return_value=UserAPIKeyAuth(user_id="custom-user", api_key="upstream-custom-token")),
|
||||
"custom-user",
|
||||
),
|
||||
(
|
||||
lambda: AsyncMock(
|
||||
side_effect=ProxyException(message="custom auth rejected", type="auth_error", param=None, code=401)
|
||||
),
|
||||
None,
|
||||
),
|
||||
],
|
||||
ids=["accepted", "rejected"],
|
||||
)
|
||||
async def test_delegate_custom_auth_decides_alone(
|
||||
self, admission: Callable[[], AsyncMock], expected_user_id: str | None
|
||||
) -> None:
|
||||
"""With custom auth configured, its answer alone decides: accepted admits that user, any failure
|
||||
admits anonymously. The bearer is forwarded either way."""
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
with (
|
||||
patch.object(proxy_server, "user_custom_auth", AsyncMock()),
|
||||
TestMCPDelegateAuthToUpstream._delegate_admission(admission()),
|
||||
):
|
||||
auth_result, _, _, _, oauth2_headers, _ = await MCPRequestHandler.process_mcp_request(
|
||||
TestMCPDelegateAuthToUpstream._bearer_scope("upstream-custom-token")
|
||||
)
|
||||
|
||||
assert auth_result.user_id == expected_user_id
|
||||
assert auth_result.mcp_forwards_caller_authorization is True
|
||||
assert oauth2_headers == {"Authorization": "Bearer upstream-custom-token"}
|
||||
|
||||
def test_forwarding_marker_cannot_be_forged_from_input(self) -> None:
|
||||
"""Only admission may mark a caller's Authorization for forwarding; caller-shaped input never can."""
|
||||
assert UserAPIKeyAuth(mcp_forwards_caller_authorization=True).mcp_forwards_caller_authorization is False
|
||||
assert (
|
||||
UserAPIKeyAuth.model_validate({"mcp_forwards_caller_authorization": True}).mcp_forwards_caller_authorization
|
||||
is False
|
||||
)
|
||||
|
||||
async def test_delegate_off_still_requires_litellm_auth(self):
|
||||
"""
|
||||
|
|
@ -2752,7 +3212,12 @@ class TestMCPDelegateAuthToUpstream:
|
|||
await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
async def test_delegate_mixed_targets_fail_closed(self):
|
||||
@pytest.mark.parametrize(
|
||||
"bearer_headers",
|
||||
[[], [(b"authorization", b"Bearer upstream-pkce-token")]],
|
||||
ids=["no-bearer", "upstream-bearer"],
|
||||
)
|
||||
async def test_delegate_mixed_targets_fail_closed(self, bearer_headers: list[tuple[bytes, bytes]]) -> None:
|
||||
"""
|
||||
x-mcp-servers can list multiple targets. If ANY of them does not opt in
|
||||
to delegate_auth_to_upstream, the bypass must NOT fire — otherwise an
|
||||
|
|
@ -2768,6 +3233,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
"path": "/mcp",
|
||||
"headers": [
|
||||
(b"x-mcp-servers", b"delegated_oauth,plain_oauth"),
|
||||
*bearer_headers,
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -2862,9 +3328,8 @@ class TestMCPDelegateAuthToUpstream:
|
|||
|
||||
async def test_authorization_bearer_on_delegate_server_establishes_litellm_principal(self):
|
||||
"""
|
||||
A bare Authorization bearer now follows normal LiteLLM admission. A
|
||||
separate x-litellm-api-key is required when Authorization is intended
|
||||
for the upstream server.
|
||||
A bare sk- bearer the key table may hold takes normal LiteLLM admission
|
||||
and is never marked for forwarding upstream.
|
||||
"""
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
|
|
@ -2897,6 +3362,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
) = await MCPRequestHandler.process_mcp_request(scope)
|
||||
assert isinstance(auth_result, UserAPIKeyAuth)
|
||||
assert auth_result.user_id == "real-user"
|
||||
assert auth_result.mcp_forwards_caller_authorization is False
|
||||
assert oauth2_headers.get("Authorization") == "Bearer sk-9876"
|
||||
mock_auth.assert_awaited_once()
|
||||
|
||||
|
|
@ -3078,8 +3544,9 @@ class TestMCPDelegateAuthToUpstream:
|
|||
mock_auth.assert_awaited_once()
|
||||
assert auth.api_key is None
|
||||
|
||||
async def test_get_allowed_servers_excludes_legacy_delegates(self):
|
||||
"""Legacy delegated servers are never added to anonymous access."""
|
||||
async def test_get_allowed_servers_includes_interactive_legacy_delegates_for_anonymous(self) -> None:
|
||||
"""Anonymous callers reach interactive legacy delegates, never an M2M server whose stored
|
||||
client credentials would let them act as LiteLLM's service account."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
MCPServerManager,
|
||||
)
|
||||
|
|
@ -3117,7 +3584,7 @@ class TestMCPDelegateAuthToUpstream:
|
|||
):
|
||||
result = await manager.get_allowed_mcp_servers(None)
|
||||
|
||||
assert result == []
|
||||
assert result == ["pkce-server"]
|
||||
|
||||
async def test_true_passthrough_skips_litellm_auth_anonymously(self):
|
||||
"""auth_type=true_passthrough performs no admission auth: the caller's Authorization is an
|
||||
|
|
@ -7643,9 +8110,7 @@ class TestMCPDcrBridgeDualCredential:
|
|||
new_callable=AsyncMock,
|
||||
return_value=UserAPIKeyAuth(user_id=presented_user, api_key="any-hash"),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = self._DELEGATE._bridge_delegate_server()
|
||||
|
|
@ -7882,9 +8347,7 @@ class TestAggregateGatewayDcrChallenge:
|
|||
):
|
||||
with (
|
||||
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = server
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -7930,9 +8393,7 @@ class TestAggregateGatewayDcrChallenge:
|
|||
with (
|
||||
patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}),
|
||||
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = server
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
|
|
@ -7963,9 +8424,7 @@ class TestAggregateGatewayDcrChallenge:
|
|||
):
|
||||
with (
|
||||
patch(self._AUTH_PATCH_TARGET, side_effect=self._auth_401()),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr,
|
||||
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
|
||||
):
|
||||
mock_mgr.get_mcp_server_by_name.return_value = resolved
|
||||
mock_mgr.get_mcp_server_by_identifier.return_value = resolved
|
||||
|
|
@ -8010,9 +8469,7 @@ class TestAggregateGatewayDcrChallenge:
|
|||
(None, None),
|
||||
]
|
||||
for resolved, expected in cases:
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
|
||||
) as mock_mgr:
|
||||
with patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr:
|
||||
mock_mgr.get_mcp_server_by_identifier.return_value = resolved
|
||||
assert _gateway_dcr_challenge_target("/mcp/srv", None, None) == expected, resolved
|
||||
assert _gateway_dcr_challenge_target("/mcp/a,b", None, None) is None
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import contextlib
|
|||
import contextvars
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Coroutine, Iterator, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Final, Literal
|
||||
|
|
@ -664,6 +665,75 @@ def test_prepare_mcp_server_headers_legacy_delegate_strips_repeated_admission_ke
|
|||
assert extra_headers is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"caller_fields",
|
||||
[{}, {"user_id": "jwt-user", "api_key": "upstream-jwt", "jwt_claims": {"sub": "jwt-user"}}],
|
||||
ids=["anonymous", "admitted-jwt-user"],
|
||||
)
|
||||
def test_prepare_mcp_server_headers_legacy_delegate_forwards_marked_upstream_authorization(
|
||||
caller_fields: dict[str, object],
|
||||
) -> None:
|
||||
"""When admission marked the caller's Authorization as the upstream token, a legacy delegate forwards
|
||||
it, including a JWT that also admitted the caller and would otherwise be stripped as an admission JWT."""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_prepare_mcp_server_headers,
|
||||
)
|
||||
|
||||
server = MCPServer(
|
||||
server_id="legacy-delegate-marked",
|
||||
name="legacy-delegate",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
delegate_auth_to_upstream=True,
|
||||
)
|
||||
caller: Final = UserAPIKeyAuth.model_validate(caller_fields).model_copy(
|
||||
update={"mcp_forwards_caller_authorization": True}
|
||||
)
|
||||
|
||||
server_auth_header, extra_headers = _prepare_mcp_server_headers(
|
||||
server=server,
|
||||
mcp_server_auth_headers=None,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers={"Authorization": "Bearer upstream-jwt"},
|
||||
raw_headers={"authorization": "Bearer upstream-jwt"},
|
||||
user_api_key_auth=caller,
|
||||
)
|
||||
|
||||
assert server_auth_header is None
|
||||
assert extra_headers == {"Authorization": "Bearer upstream-jwt"}
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_forwarding_marker_is_ignored_off_legacy_delegates() -> None:
|
||||
"""The forwarding marker only applies to legacy delegates, so it can never carry a LiteLLM key to an
|
||||
oauth_delegate upstream."""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_prepare_mcp_server_headers,
|
||||
)
|
||||
|
||||
server = MCPServer(
|
||||
server_id="oauth-delegate-marked",
|
||||
name="oauth-delegate",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth_delegate,
|
||||
)
|
||||
caller: Final = UserAPIKeyAuth(api_key="sk-litellm-key").model_copy(
|
||||
update={"mcp_forwards_caller_authorization": True}
|
||||
)
|
||||
|
||||
_, extra_headers = _prepare_mcp_server_headers(
|
||||
server=server,
|
||||
mcp_server_auth_headers=None,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers={"Authorization": "Bearer sk-litellm-key"},
|
||||
raw_headers={"authorization": "Bearer sk-litellm-key"},
|
||||
user_api_key_auth=caller,
|
||||
)
|
||||
|
||||
assert extra_headers is None
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_m2m_skips_authorization_from_raw_extra_headers():
|
||||
"""M2M must not merge caller Authorization from raw_headers when extra_headers lists it."""
|
||||
try:
|
||||
|
|
@ -7173,15 +7243,19 @@ async def test_legacy_delegate_bare_token_is_not_probed_upstream(): # test-qual
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_delegate_dual_credentials_are_not_probed_upstream(): # test-quality-ok: this removed security-sensitive egress has no return value; non-invocation is the contract
|
||||
"""A separate upstream bearer never triggers the removed legacy probe."""
|
||||
@pytest.mark.usefixtures("httpx_transport")
|
||||
async def test_legacy_delegate_dual_credentials_probe_only_the_upstream_token() -> None:
|
||||
"""With a separate x-litellm-api-key, the preflight probes the upstream with its own token and never
|
||||
with the LiteLLM key."""
|
||||
import respx
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
server = _delegate_auth_mcp_server()
|
||||
scope = _delegate_scope(
|
||||
server: Final = _delegate_auth_mcp_server()
|
||||
scope: Final = _delegate_scope(
|
||||
[
|
||||
(b"x-litellm-api-key", b"sk-litellm-proxy-key"),
|
||||
(b"authorization", b"Bearer upstream-token"),
|
||||
|
|
@ -7189,15 +7263,14 @@ async def test_legacy_delegate_dual_credentials_are_not_probed_upstream(): # te
|
|||
)
|
||||
|
||||
with (
|
||||
_patch_delegate_resolver(server, "delegate_test"),
|
||||
patch( # test-quality-ok: isolate authorized-server resolution so this test targets the preflight boundary
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
),
|
||||
patch( # test-quality-ok: the removed probe call is the security regression under test
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(),
|
||||
) as probe,
|
||||
respx.mock as upstream,
|
||||
):
|
||||
probe: Final = upstream.post(server.url).respond(200)
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=scope,
|
||||
user_api_key_auth=UserAPIKeyAuth(user_id="admitted-user"),
|
||||
|
|
@ -7208,7 +7281,160 @@ async def test_legacy_delegate_dual_credentials_are_not_probed_upstream(): # te
|
|||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
assert probe.call_count == 1
|
||||
sent_headers: Final = probe.calls[0].request.headers
|
||||
assert sent_headers["Authorization"] == "Bearer upstream-token"
|
||||
assert all("sk-litellm-proxy-key" not in value for value in sent_headers.values())
|
||||
|
||||
|
||||
def _expired_delegate_token_preflight(mcp_servers: Sequence[str]) -> Coroutine[object, object, None]:
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
|
||||
return _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope([(b"authorization", b"Bearer expired-upstream-token")]),
|
||||
user_api_key_auth=UserAPIKeyAuth().model_copy(update={"mcp_forwards_caller_authorization": True}),
|
||||
mcp_servers=list(mcp_servers),
|
||||
client_ip=None,
|
||||
oauth2_headers={"Authorization": "Bearer expired-upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"authorization": "Bearer expired-upstream-token"},
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _rejecting_delegate_upstream() -> Iterator[None]:
|
||||
server: Final = _delegate_auth_mcp_server()
|
||||
with (
|
||||
_patch_delegate_resolver(server, "delegate_test"),
|
||||
patch( # test-quality-ok: isolate authorized-server resolution so this test targets the preflight boundary
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
),
|
||||
patch( # test-quality-ok: stands in for the upstream rejecting an expired token
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, 'Bearer error="invalid_token"')),
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_delegate_expired_upstream_token_gets_reauthorization_challenge() -> None:
|
||||
"""An expired upstream token on a single-server route gets the RFC 9728 re-authorization challenge
|
||||
for the requested name, so the client signs in again instead of seeing an empty tool list."""
|
||||
with _rejecting_delegate_upstream(), pytest.raises(HTTPException) as exc_info:
|
||||
await _expired_delegate_token_preflight(["delegate_test"])
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
challenge: Final = exc_info.value.headers["www-authenticate"]
|
||||
assert 'error="invalid_token"' in challenge
|
||||
assert (
|
||||
'resource_metadata="http://localhost:4000/.well-known/oauth-protected-resource/mcp/delegate_test"' in challenge
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_delegate_multi_server_scope_is_not_failed_by_one_upstream_token() -> None:
|
||||
"""A multi-server scope is not probed, so one rejected upstream token cannot fail the whole connect."""
|
||||
with _rejecting_delegate_upstream():
|
||||
assert await _expired_delegate_token_preflight(["delegate_test", "other_server"]) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("httpx_transport")
|
||||
async def test_legacy_delegate_probe_checks_the_callers_token_not_a_static_authorization() -> None:
|
||||
"""The probe is what checks a delegate caller's own token, so a static Authorization the server sends on
|
||||
tool calls cannot stand in for it: a junk token still gets the re-authorization challenge."""
|
||||
import respx
|
||||
|
||||
server: Final = _delegate_auth_mcp_server().model_copy(
|
||||
update={"static_headers": {"Authorization": "Bearer operator-token"}}
|
||||
)
|
||||
with (
|
||||
_patch_delegate_resolver(server, "delegate_test"),
|
||||
patch( # test-quality-ok: isolate authorized-server resolution so this test targets the preflight boundary
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[server]),
|
||||
),
|
||||
respx.mock as upstream,
|
||||
):
|
||||
upstream.post(server.url).mock(
|
||||
side_effect=lambda request: httpx.Response(
|
||||
200 if request.headers["Authorization"] == "Bearer operator-token" else 401
|
||||
)
|
||||
)
|
||||
outcome: Final = await asyncio.gather(
|
||||
_expired_delegate_token_preflight(["delegate_test"]), return_exceptions=True
|
||||
)
|
||||
|
||||
assert getattr(outcome[0], "status_code", None) == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("httpx_transport")
|
||||
@pytest.mark.parametrize(
|
||||
"requested, expected_status",
|
||||
[(("delegate_test",), 401), (("delegate_test", "other_server"), None)],
|
||||
ids=["single-server", "two-requested-one-reachable"],
|
||||
)
|
||||
async def test_modern_interaction_probes_legacy_delegate_only_for_a_single_requested_server(
|
||||
requested: tuple[str, ...], expected_status: int | None
|
||||
) -> None:
|
||||
"""The single-server rule follows what the caller asked for, not what it can reach, so a request
|
||||
naming two servers is not failed by one expired delegate token."""
|
||||
import respx
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server import server
|
||||
from litellm.proxy._experimental.mcp_server.contracts import OperationContext
|
||||
|
||||
target: Final = _delegate_auth_mcp_server()
|
||||
scope: Final = _delegate_scope(
|
||||
[
|
||||
(b"authorization", b"Bearer expired-upstream-token"),
|
||||
(b"mcp-protocol-version", b"2026-07-28"),
|
||||
(b"mcp-method", b"tools/call"),
|
||||
(b"mcp-name", b"delegate-add"),
|
||||
]
|
||||
)
|
||||
context: Final = OperationContext(
|
||||
_caller=UserAPIKeyAuth().model_copy(update={"mcp_forwards_caller_authorization": True}),
|
||||
mcp_servers=requested,
|
||||
oauth2_headers={"Authorization": "Bearer expired-upstream-token"},
|
||||
raw_headers={"authorization": "Bearer expired-upstream-token"},
|
||||
)
|
||||
body: Final = json.dumps(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "delegate-add",
|
||||
"arguments": {"a": 2, "b": 3},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
with (
|
||||
_patch_delegate_resolver(target, "delegate_test"),
|
||||
patch( # test-quality-ok: isolate authorized-server resolution so this test targets the preflight boundary
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[target]),
|
||||
),
|
||||
respx.mock as upstream,
|
||||
):
|
||||
upstream.post(target.url).respond(401, headers={"www-authenticate": 'Bearer error="invalid_token"'})
|
||||
outcome: Final = await asyncio.gather(
|
||||
server._preflight_modern_interaction(scope, body, context), return_exceptions=True
|
||||
)
|
||||
|
||||
assert getattr(outcome[0], "status_code", None) == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -7445,39 +7671,47 @@ async def test_delegate_not_probed_when_named_only_via_server_id():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delegate_probe_not_fanned_out_to_access_group_members():
|
||||
"""A single access-group name passes the one-target route gate but must not fan
|
||||
the delegate probe out to group-expanded member servers; the group name resolves
|
||||
to no server under admission's resolver, so no probe fires."""
|
||||
@pytest.mark.usefixtures("httpx_transport")
|
||||
async def test_delegate_probe_not_fanned_out_to_access_group_members() -> None:
|
||||
"""A single access-group name resolves to no server under admission's resolver, so the delegate probe is
|
||||
not fanned out to the group's members and one member rejecting the token cannot fail the whole group."""
|
||||
import respx
|
||||
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_check_passthrough_upstream_auth,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
||||
group_member = _delegate_auth_mcp_server()
|
||||
|
||||
rejecting: Final = _delegate_auth_mcp_server()
|
||||
accepting: Final = _delegate_auth_mcp_server("delegate-2").model_copy(
|
||||
update={"name": "delegate_two", "url": "http://upstream-two:9402/mcp"}
|
||||
)
|
||||
with (
|
||||
_patch_delegate_resolver(group_member, "delegate_test"),
|
||||
patch(
|
||||
_patch_delegate_resolver(rejecting, "delegate_test"),
|
||||
patch( # test-quality-ok: isolate authorized-server resolution so this test targets the preflight boundary
|
||||
"litellm.proxy._experimental.mcp_server.operations._get_allowed_mcp_servers",
|
||||
new=AsyncMock(return_value=[group_member]),
|
||||
new=AsyncMock(return_value=[rejecting, accepting]),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._probe_upstream_auth",
|
||||
new=AsyncMock(return_value=(401, None)),
|
||||
) as probe,
|
||||
respx.mock as upstream,
|
||||
):
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope([(b"authorization", b"Bearer bogus-token")]),
|
||||
user_api_key_auth=UserAPIKeyAuth(),
|
||||
mcp_servers=["prod_tools_group"],
|
||||
client_ip=None,
|
||||
oauth2_headers=None,
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers=None,
|
||||
upstream.post(rejecting.url).respond(401, headers={"www-authenticate": 'Bearer error="invalid_token"'})
|
||||
upstream.post(accepting.url).respond(200)
|
||||
assert (
|
||||
await _check_passthrough_upstream_auth(
|
||||
scope=_delegate_scope(
|
||||
[(b"x-litellm-api-key", b"sk-litellm-proxy-key"), (b"authorization", b"Bearer upstream-token")]
|
||||
),
|
||||
user_api_key_auth=UserAPIKeyAuth(user_id="admitted-user"),
|
||||
mcp_servers=["prod_tools_group"],
|
||||
client_ip=None,
|
||||
oauth2_headers={"Authorization": "Bearer upstream-token"},
|
||||
mcp_server_auth_headers=None,
|
||||
raw_headers={"x-litellm-api-key": "sk-litellm-proxy-key", "Authorization": "Bearer upstream-token"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
probe.assert_not_awaited()
|
||||
assert upstream.calls.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -8753,8 +8987,7 @@ async def test_get_allowed_mcp_servers_from_mcp_server_names_unknown_name_fails_
|
|||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
|
||||
"MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
|
|
@ -8812,8 +9045,7 @@ async def test_get_allowed_mcp_servers_from_mcp_server_names_known_alias_returns
|
|||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
|
||||
"MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
|
|
@ -8845,8 +9077,7 @@ async def test_get_allowed_mcp_servers_from_mcp_server_names_mixed_known_and_unk
|
|||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
|
||||
"MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[],
|
||||
):
|
||||
|
|
@ -8878,8 +9109,7 @@ async def test_get_allowed_mcp_servers_from_mcp_server_names_access_group_resolv
|
|||
]
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
|
||||
"MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_mcp_servers_from_access_groups",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["id-b"],
|
||||
):
|
||||
|
|
@ -9207,8 +9437,7 @@ async def test_get_allowed_mcp_servers_includes_active_servers_submitted_by_user
|
|||
return_value={"submitted-1": submitted_server},
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp."
|
||||
"MCPRequestHandler.get_allowed_mcp_servers",
|
||||
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.get_allowed_mcp_servers",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
|
||||
|
|
|
|||
102
uv.lock
generated
102
uv.lock
generated
|
|
@ -1657,60 +1657,70 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "4.11.0"
|
||||
version = "4.15.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bytecode" },
|
||||
{ name = "ddtrace-internal" },
|
||||
{ name = "envier" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/51/a628f0177274bab5b67c93a1558fd222babb15286a427e4e8c1d65a265b2/ddtrace-4.11.0.tar.gz", hash = "sha256:260c5b46e80565f4fd08cec2650f707627fb57cd6f8951a2d8c9e6a02b490074", size = 2422158, upload-time = "2026-07-10T08:57:53.396Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/94/f0f005257dc49a7062c6fed98a6dbcca7691e9d1db5c64e8a2d14f204cac/ddtrace-4.15.6.tar.gz", hash = "sha256:2914f646d4ae338c644b814c4ad80c963e7aadfb39d1e67b0367a15d5c4bc0db", size = 2655968, upload-time = "2026-10-08T09:46:58.392Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/1f/05dc12819ca3a3000028a522cab0025c5dd969e97dc472ac38a7d2f1ea71/ddtrace-4.11.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0bc55e48da62b6a287b36e01f5876489023178c09b60e8e01608e2e895ccdc4b", size = 7103767, upload-time = "2026-07-10T08:55:10.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/8a/efcea448bc15b4d95c48c9b476f2ddc5862a53cd4baf58fe50c64763f9d8/ddtrace-4.11.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5df47873c625993e81fabd8f6ae2ab61f66c6805cfd1a8c551a509d5b5d91062", size = 7447104, upload-time = "2026-07-10T08:55:15.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/28/89aa239e47d28900ab394a0b8e0a5549cab631c0e5977016ad9d578eaee7/ddtrace-4.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e857428cc86fe541f5b3768d7d8331e2f1cbde1a2492deedff4619109cbfd5fc", size = 8511839, upload-time = "2026-07-10T08:55:17.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/43/37a5475ade911abae592e121e852922745d842e16294c549cba4ad0f3dbe/ddtrace-4.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc60575f8f47af58e6523f8a802d1f92a4912aa7d4ba71024958421044cba61a", size = 8733381, upload-time = "2026-07-10T08:55:19.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/df/d61f71236a405480c8809522219b184cc486056c32f9cc8bf07cc91e2be9/ddtrace-4.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d65ab60bfb0b2edde6c564bd1f97b99a8ea9bb082c93b265488ba214c606d240", size = 9513521, upload-time = "2026-07-10T08:55:21.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/4f/bff08af986df419179b86fc9c73732836531c2d08ab8dcfe982a6f2e8cb8/ddtrace-4.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7ebfdee1342b5b1cdad8505e040bbfe5bdf6976e820803eaa4a1814417d6bb0e", size = 9788008, upload-time = "2026-07-10T08:55:24.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b8/2b1fd5b0374edf33ad31adb746d1d1358a44c39a45b1c2a0c4b09651d81e/ddtrace-4.11.0-cp310-cp310-win32.whl", hash = "sha256:6247a431ae3a2622a0e835357a644b1bac81719e28f21748fd7d4034c79c3ed7", size = 5739013, upload-time = "2026-07-10T08:55:27.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f5/9bdff59bdce1f80592e70633dc26678c762fff79188c9fb6004c06967708/ddtrace-4.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:9197110982756a97243906ffce7b98e6007c8258a6bbc22451d7b26e8f1b0103", size = 6326099, upload-time = "2026-07-10T08:55:28.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/36/ad0abcc97b63d821ada82cc7452c3070a71f9546f0aa81b50eda1f8ffd41/ddtrace-4.11.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b4074e538e708b0e9d7f812be15db306abbe3a6cb70046f9f786d66daa862344", size = 7105089, upload-time = "2026-07-10T08:55:31.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/3a/c0f8f70883a70ec9d4050900823ff7b52ad54dc6b2a3d29561727825410f/ddtrace-4.11.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:05f701229233ad8a4ad5d52b8ff2bfd6e2f99601a3bc04367615cd1ce4a01a80", size = 7448237, upload-time = "2026-07-10T08:55:32.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/46/18a42afab21cd718467e7d7ae3e80898569d9969d731b771b5a8ada10294/ddtrace-4.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c3a557891e91a443e6a3a9b7c31dc96b9963acada0986dab5a3d65507efa16a1", size = 8516433, upload-time = "2026-07-10T08:55:35.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b2/6367a75518f843872e13c062ac3c329f880b2f4d62ff4de0624bdcf64719/ddtrace-4.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0328bfeb4a6bdc00168a4184aea670eedc073734f6fbc6ca1abfeb6547415d2", size = 8740643, upload-time = "2026-07-10T08:55:37.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/e8/04f1d7ee4737444d45f81545ce21b01bd9d653a994997ab1e7e04b6ee947/ddtrace-4.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad6f17111746568a58da89b6db5e5d18eb07db7b8ce3be3396631f37391cff50", size = 9518626, upload-time = "2026-07-10T08:55:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/8b/314ba8a81e562a31655328061afb30d26d46ba04aca4dabd7da85c93a9e7/ddtrace-4.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb30dd327789ec1851bc42194c8db0b93a7ad708c2963d694e3622b684a085f7", size = 9795392, upload-time = "2026-07-10T08:55:42.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/a580d53c05a771bc53d33a88174e983146fdf879fe5d4f52e51ceb56ee99/ddtrace-4.11.0-cp311-cp311-win32.whl", hash = "sha256:dfe73130778ca22652ae398fc77de3643d50687deb127bf4cd2566b10778125a", size = 5737732, upload-time = "2026-07-10T08:55:45.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/83/094f5b88690f7801129b0d52a7b6a58a95b34ecbfcb337555d323c639faa/ddtrace-4.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:1241f38d52b40b516d620ed68ecd04afbc715f2c1c9f3ddae8a7b86ecf848c91", size = 6329473, upload-time = "2026-07-10T08:55:47.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/72/036d11ceca6fbdc4ef95a1aba210cb3fc72bc912703cba49cf2bc8b610c5/ddtrace-4.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3013b67e1a172eb0edd8c37559b81760a9db205a671541925ebd0127f496409b", size = 5990873, upload-time = "2026-07-10T08:55:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/e9/e50295e520f7d14ce2b5d900f7a2fe8f096d8f90e9040f8d7f43108b72ce/ddtrace-4.11.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d0eadcf7df3143bf0611f09a351040e475b5df7f512410698cd481a076bca056", size = 7101986, upload-time = "2026-07-10T08:55:56.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/c6/e8d13546a4b2cd72880d8b0823dd487956d9eb291473d7d03b6e742e5bd9/ddtrace-4.11.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:57eb0616dc5c8ff04b5c560ba6d3def69d2bc618e69ce65cc015f103fc68649e", size = 7455362, upload-time = "2026-07-10T08:55:58.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/c6/2c22d71290df36b87393794d6d803ec8ea96ee54d42286836a7ddf0dbae3/ddtrace-4.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c311109d1e0f3482b7a44126c855eeb796b186f01604ce74c54800aa5892863d", size = 8502274, upload-time = "2026-07-10T08:56:00.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/84/5afef2c9696b289afa1c608e862930c0e447489776a0133da9f2345edd27/ddtrace-4.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:695ec915640072e2917b13d317fe1696086a38924ba02cbe1dcb3918ffb0fa65", size = 8730138, upload-time = "2026-07-10T08:56:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/68/a2aff5999fa2b1053b3f50cd78297cbbe7a427f63388fb5f6c500dfb859a/ddtrace-4.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:355a288756a7a5d6b73221d28ba4536cb0271d2ea7909adab3e2ee37d62a569f", size = 9507981, upload-time = "2026-07-10T08:56:06.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/12/53bb54b3451f85dbf3c752c2d60b22f672fd55bb08d7495613a70dc87102/ddtrace-4.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c19888af09f6ad3340f2935ea5c9eb80d9298141e196fd33e544369920aefba", size = 9793730, upload-time = "2026-07-10T08:56:08.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/7e/d9a52dd0d877ebdce5eb105d9f3d4e3579391ee0106c8335d59aa97006dd/ddtrace-4.11.0-cp312-cp312-win32.whl", hash = "sha256:cd524b299bd15ed14192cb041a35e992003e0d3a9f8831062f9f28bcfacb63e2", size = 5734745, upload-time = "2026-07-10T08:56:11.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/da/bb7c609ebc368cabb01db38d7022f42a39d30207f484a5e18a306bd9b333/ddtrace-4.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:af663120d3b4116965bebbd79ce8e73a85644816a0917999e8164b6204678cb6", size = 6317182, upload-time = "2026-07-10T08:56:14.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f3/e3cf49ce0a4c377fdea8203b127984e26ac2c9cad0521fab27d61371d568/ddtrace-4.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:f0b50a1739573318b0a81fb56a8b22ed6ae6dac017b89400cbc76c17b439d9ed", size = 5980198, upload-time = "2026-07-10T08:56:16.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f8/89f2b9cb3d413c55a3d1f19a90fda76ec45abdc0ce13a7f6d4aaf10bed1a/ddtrace-4.11.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:16b9f06c841858debb3de2c4a7ce6acbd6967eaa6ce264dbd0e2a58e376b8583", size = 7094539, upload-time = "2026-07-10T08:56:18.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/98/f262ad401cdd1a8a2394299d12f31bcc037d0e2dc61d3a843f1c855c293f/ddtrace-4.11.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1cb352d7df16b864f258d4602addf23b8d29d2d416ccc1d0af8d88c614f70a01", size = 7448347, upload-time = "2026-07-10T08:56:21.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/17/c9123fc263b89934cc1c831b81d90af299d28512740abf008fd963bd006e/ddtrace-4.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:54f4cf656aa452bc37b62fa7939c5bddca751ffd4d315b3c925236d4794767ea", size = 8497865, upload-time = "2026-07-10T08:56:24.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/31/00d529b9ca80c77bf3c6aa7370615e2186fc9ead573ef56cef345e6a4dbf/ddtrace-4.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:776d99ccb01abecf7dbb4910f4a570b6ab625ed47e00bb6c0159d88d4e2ef1c8", size = 8721481, upload-time = "2026-07-10T08:56:26.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/56/759eab73938820054faa6db6eda607753d821b6322ce7679a2b9f728f8c7/ddtrace-4.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5533ce94e50412565a36ce0c8fc6ff6f7490e69b4ecff10c550482e49d5d065e", size = 9505774, upload-time = "2026-07-10T08:56:29.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a4/6ab67386244c08340c1c30affffc644b14095e59eb8d2e8d8ee1d4d46d84/ddtrace-4.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:81d147bd3d6fa067f2c72c7e32aba90d5d61ed379850896f1cc7fd1ea3437bb1", size = 9787192, upload-time = "2026-07-10T08:56:32.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/8d/44e074f927b35007e8bf3dc2b5aeb5ba5e4c97e76d4b8e994aaabddfd648/ddtrace-4.11.0-cp313-cp313-win32.whl", hash = "sha256:bcf1d3ba8c3771b8c75f0b9ecd9038ae0a44c8bb0c01d21458c405a29341518a", size = 5732179, upload-time = "2026-07-10T08:56:35.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ec/ff034010a1fd598863a15606aa7867ee6abaf4a5989b1a22e61753229432/ddtrace-4.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:d465b9f6f049fc32278cbfd74e1e593b0c3460aacb622bef0cee71e50ffc4b05", size = 6314643, upload-time = "2026-07-10T08:56:38.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/dd/ad7da7ca071c76815b16a0523875c0c061902cfd8e9ae6595577464e923e/ddtrace-4.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:00cc0fbfa6b8741477528c3fe7bc70a4a5ea439221f188e14da6907f03ed0125", size = 5976789, upload-time = "2026-07-10T08:56:41.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1b/37b9a84cc4a95d74d4358a0f78346376bc03625b6a9fa0d568a1587665ca/ddtrace-4.11.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e6f1ca84237e9f52fd71e3458262cfc407dd43337a56e4a6ba9beea88f0958c5", size = 7099136, upload-time = "2026-07-10T08:56:43.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/42/f227eeb9c5bbb519c67fe11a5326b531f4c1bbd90257e74ab8b72b5ded75/ddtrace-4.11.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:c3ddbf9a5a0cd51b5ab9bca898c85cfce73072054b9f5b577164a95065cb8331", size = 7452151, upload-time = "2026-07-10T08:56:46.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c4/35fbc0e8ef585d93f82dadae38246eb3643621f08efe11ca0c20e77e7d42/ddtrace-4.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7c435b708f4956208141c22d941ed60a1551813353f6c1cfc1a5283ec3d80cc5", size = 8507170, upload-time = "2026-07-10T08:56:49.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/99/a9681a53e9b0557497b24a4e0b1495acf2bcb74368e86129e549d6249a4a/ddtrace-4.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:26b3fd7ee97cf7fa1296eecacd393bbbef343cf8bb2d0b1ee39d40cb1d4818fa", size = 8725097, upload-time = "2026-07-10T08:56:53.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/c6/b18f02120c34abf55e28bc180e872ddbe27614559c01bbcfe5be35b5494a/ddtrace-4.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8d23d66ba9525fd9b9a61c0db8c43e64461066b1d49c75c85f639ea65776367b", size = 9516895, upload-time = "2026-07-10T08:56:56.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ed/dc73e8317c229300b0ff8513f903d16ff140eede8084b3b6f76c8646a548/ddtrace-4.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85e081e2295a77c9b38e5792a097f1df8169e1dd9b3339f820d00183e10e8b1b", size = 9792739, upload-time = "2026-07-10T08:57:00.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8e/b97984d9f43e2f36f5317034711140c3bd1fbb5d96880b1c11b9e1e8c3d3/ddtrace-4.11.0-cp314-cp314-win32.whl", hash = "sha256:c267fe4efceb71019f5e6833fdb4fbdff18a51641bdca9fceabbe579984461e4", size = 5831072, upload-time = "2026-07-10T08:57:03.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/6a/7fb18925588f645d6d777c90d388f9a640adf7b986be7457943717b74170/ddtrace-4.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bebf3cb12ac51c422ad1ed74873746b8398cce845075b234115c2f518a7a652", size = 6455126, upload-time = "2026-07-10T08:57:06.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b2/44d7f07ba94c70bd1827669d446a2e979f7efd6e4a409c469a59684d9a7b/ddtrace-4.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:f596cb2da582600e091c8174ab4f8e44bf4f05c50c03cc450e0378c516e0c6e3", size = 6130096, upload-time = "2026-07-10T08:57:09.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/c8/6b8de8b78503f54a6c19f5b4c9430825976f1a2ef1b8342f21ac47c70e22/ddtrace-4.15.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:68c18fa37cd718f2360c980bbd2928a5c2bf502a173e61a83f25e42321d5b4b8", size = 7998372, upload-time = "2026-10-08T09:44:43.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/30/b5ec13494b638453c568ced825b863c086fc7237528aa30498b4fcb6928a/ddtrace-4.15.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7d7696cf05e1675847303701c0a2f7733014f1b2cb5938ed2038e27fa32a18b2", size = 8382818, upload-time = "2026-10-08T09:44:45.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/dc/57c5d150061cf27935a2e630ca5334f3fa815d82f0585ba694821f793445/ddtrace-4.15.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66647b3c9673468e35fe0294d25015ef6f535bb540a9ce63c76c95ff2679e059", size = 9544001, upload-time = "2026-10-08T09:44:47.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7b/6651a225df4438761ddd45c21f624d453aca1508d88ed7fd4c880a3a454a/ddtrace-4.15.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4fd260a1339a2df4867f5b0f7ff9111a0f23ba86d6b402e4c3dfe2f71853cd17", size = 9794825, upload-time = "2026-10-08T09:44:49.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/8d/ffd12a1fa866068c397a1fbbebaebcd6e40f21e618650e95d93860afef44/ddtrace-4.15.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95a04ff00e401caef808689ae6bd70a9fc92d3572647f9f1bbd43dfacfa053f6", size = 10533745, upload-time = "2026-10-08T09:44:51.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/19/64e52eced9a66cbb61dd40b110dc25297b1185f1027016876ae366fcaa1e/ddtrace-4.15.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4eb19b57f1cc6ab8e13e57b8257b91f6b0e5bede753320b30e3920a28d4b1a4f", size = 10833139, upload-time = "2026-10-08T09:44:53.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ae/0d964c6eb264ec0ab797071db48221acca6990b5837f1a6d804b88b818ec/ddtrace-4.15.6-cp310-cp310-win32.whl", hash = "sha256:740a87a6504617ec7584d5afb2a6169d780571ddc2b44270274aa8291a70a03c", size = 6756230, upload-time = "2026-10-08T09:44:55.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/2c/35a05709d630b3ad6f5c05dd90d34bf784fcde93e0722f9e1cec111ec5c4/ddtrace-4.15.6-cp310-cp310-win_amd64.whl", hash = "sha256:7aa9f30da1832111de778a17a26bc2bc564f4810d573ac2d8633609c3db48306", size = 7387627, upload-time = "2026-10-08T09:44:57.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/b5/3ce5d64ed92d87388a83b52ed766866177997585e224d018922b0494a88d/ddtrace-4.15.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:b3f6e66e3b303252edce525ca573e7092e741049556ae80a395eadf0685a036f", size = 7997045, upload-time = "2026-10-08T09:44:59.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/25/b7f15af30cf446fa95bf6835e515c135cc801ddc9ec18ecd665b7a82063a/ddtrace-4.15.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6bec8671c1e0bdadd7fad2513f449c07b9bf4f847bc652aef2720f0e2f74aeaa", size = 8381818, upload-time = "2026-10-08T09:45:01.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/95/1c597a597123eb99fccd0fc0ef64ee25ad46460e158dbf258136126134c3/ddtrace-4.15.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3017a4d4fe8375ae4555b80419d5259bdc444af485bcca6ee52dcd1a41890d35", size = 9541087, upload-time = "2026-10-08T09:45:03.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/88/7e8c66d3db4739599a8092197940d211a006edca56b8fbbf9bbd34869a27/ddtrace-4.15.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:220fbb49481c6dd85866110477183b70882cb2578020c3bbbb4db2d89d0b41a8", size = 9793899, upload-time = "2026-10-08T09:45:05.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/f8/08c6b74b9fd13df49cfdb95e6a1a86e06c434994db8f4a4a3395f0fee013/ddtrace-4.15.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d50b28793f381bb53e2d7cab5ffeb97c1aa0032f436a1fd5984cb3740d75b10", size = 10534489, upload-time = "2026-10-08T09:45:07.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/6c/03cd4a5cc888f7c540fa9282d3c7c3a7675aae2c622c41668d1a765e0699/ddtrace-4.15.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a9edbd149d793b134bdaea28f40db5a199d42135f122c2be325519db55fb75", size = 10834145, upload-time = "2026-10-08T09:45:09.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/6d/ab22cf125e6618597c41b55682dc76617cb6e071ca7381aac346840d6404/ddtrace-4.15.6-cp311-cp311-win32.whl", hash = "sha256:6b6a59fc3dfb081fd7708c9431b94dd17c5148625ec84bc425852cdfbf2f214d", size = 6752572, upload-time = "2026-10-08T09:45:11.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/38/ffc989894b1d7ea7c06fb249185807c574da5b4572adb9c974fcf02ea65d/ddtrace-4.15.6-cp311-cp311-win_amd64.whl", hash = "sha256:fef3cdf314eee555c5f6b831aeeb826a7eec147e3e1ab85591154a0ad96828f9", size = 7388927, upload-time = "2026-10-08T09:45:14.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b8/1b8483fac4a4bcf1d7338d6323b2a3bde576553b111c5a288aee71224613/ddtrace-4.15.6-cp311-cp311-win_arm64.whl", hash = "sha256:4f163cc7fd9d76711c9df54273cbc2bb15933d90d1027fbf6ea3e938f042d23a", size = 7391090, upload-time = "2026-10-08T09:45:16.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/3f/010c67d812aa0950bb44aeabec69c858e5bd7d31d9fe560ae431e2a08d95/ddtrace-4.15.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c4ca75ae638e105e5c28b7f7b3e1d3762f027ab14a315530e62c71d402b49dc1", size = 7993463, upload-time = "2026-10-08T09:45:18.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e3/c42f708e8506a75621678e5aac5219b03c8d95014b70e5755287cbe81d2a/ddtrace-4.15.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2f533ad60bb9855ab47a429eeb8b7e28667647c1a482899ac6bd5c00aa525407", size = 8387610, upload-time = "2026-10-08T09:45:20.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/c5/ea86a9859f8e9b69a448c1ccbb70c51c7989c8c7bf68288356b9f53883a1/ddtrace-4.15.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e00a2943adf9cc886da3cf377b6c32001828565c877a9f0a7905e60eda527dad", size = 9514941, upload-time = "2026-10-08T09:45:22.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/af/b3bd0fdacb0d2b6baf8bd033ea00e97b06e74e3e504918ecf2f4d176ad6b/ddtrace-4.15.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfb419eecf9ab3ad774e0fa612f08814530ebd2e1f0aa10128bb0f98eb7cea2e", size = 9772538, upload-time = "2026-10-08T09:45:25.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/05/f1cb0e9007a5c0d155948726bfefe97fc5fc9d7e886a951a4d51bc1c0236/ddtrace-4.15.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f8bf477362e29742a6b282fbbfc9c6985c745ba302e4b6a8a53388911ceacae", size = 10511565, upload-time = "2026-10-08T09:45:27.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/90/9f27058af312b213abfb946ab27b334b58b5482aada8b00ce2c41bee2ee6/ddtrace-4.15.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd6c6cd99d6edcffab804755f8246f3c1ecf727bdb241ecfe94f5de31fbb484b", size = 10824106, upload-time = "2026-10-08T09:45:30.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9a/f4402bdfb563d103ece19bd56a4622accd0113dfb9ac9b118050757823f9/ddtrace-4.15.6-cp312-cp312-win32.whl", hash = "sha256:903a24868a743e035ff0f7bdc7e397a90c00fac77b551765a9ceae5537c40a44", size = 6756938, upload-time = "2026-10-08T09:45:32.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/fb/0b24d9fbcecfeb72ac2fb24db5b4a4684a0f0ecda73bb2b0c56c14282645/ddtrace-4.15.6-cp312-cp312-win_amd64.whl", hash = "sha256:57cd4ca85c5fef665fc2794c37369e39d6b9f51b1ff37ae8e68ac935360d1561", size = 7375054, upload-time = "2026-10-08T09:45:35.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/34/09763e279fb865196966cc895fb234cf12350f08fde8a284c796f13d6d34/ddtrace-4.15.6-cp312-cp312-win_arm64.whl", hash = "sha256:be11b8c8fce20b6e02d6069a53d43fc92aec9a50741e0f0dadebd913f2917ae5", size = 7376141, upload-time = "2026-10-08T09:45:37.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/0c/f0b7dfb83917ea3f9c0d072cf8cf9454550ecf26372b19be49b8cf16bbcb/ddtrace-4.15.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8a118c85925d143e88e48e4583500e95a9b88d9c1fabc96df276c086eb515603", size = 7987311, upload-time = "2026-10-08T09:45:40.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/01/f39c527b10ceef714c16a99d59429ddd8b314c7d5301bff86fab6201dfae/ddtrace-4.15.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1307ee87674c3d2d93f38bcd258b62b70a41081183e4bc94b03594c855751322", size = 8382130, upload-time = "2026-10-08T09:45:42.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/29/0c46ee3d6dcce28f8bd82c1dc658e420bed935fc0488a1ffac2853b42b0f/ddtrace-4.15.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fabb658b1ec068ab6046fb1ecaa6e6860bd40338235244c3c156c973c4957c00", size = 9507940, upload-time = "2026-10-08T09:45:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b0/77d18cb89ff7b2652d734ae00e4869ef703945a0393bc465e00878631099/ddtrace-4.15.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2185df0b730e4bb78dfe40cf61b7e242d4b81724cea4f496ca5a0ae0f5427309", size = 9764498, upload-time = "2026-10-08T09:45:47.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/06/7435d3534b646ddafaf16fced76b0dec8d239492eef1e119d4c3b3fe1259/ddtrace-4.15.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fc21e13b52290d33bcdec36644eb63eac03f49adb0b36c6d84dc4bb02b9b935", size = 10508643, upload-time = "2026-10-08T09:45:50.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/d0/65db05d5b72fcb88378edd4bf44e88b2dc0c3e8e1c3070a4630e5ec2dfdd/ddtrace-4.15.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:008a5c93db9489861394190b9a9ecffc464a9a83882792c83c005e938fc69df0", size = 10817707, upload-time = "2026-10-08T09:45:53.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/e0/b2ad40242ce8d17ba4432c4f96a7584d93852c442fcb8ac1285cbd10ae96/ddtrace-4.15.6-cp313-cp313-win32.whl", hash = "sha256:d5ca2d38c37194aee97801775193a034f7f27f2610d5075b6a550ee664b044dd", size = 6754044, upload-time = "2026-10-08T09:45:56.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/e3/7a549bc05e6b604ef3c5ad8fef19a951178d8d93ee7088660d3743e53b7b/ddtrace-4.15.6-cp313-cp313-win_amd64.whl", hash = "sha256:fc35805f2c5b64bd7a29304a5c54932792a47b1436d2550354c051870cd706b4", size = 7370629, upload-time = "2026-10-08T09:45:58.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/dc/a140b6640dd57f143e9137e8e4d04653ba9047c7ef65ff26ca03721c90ff/ddtrace-4.15.6-cp313-cp313-win_arm64.whl", hash = "sha256:03221f056c03728b01a4295ff4a3854db2dc33bcfab3131757f5eb4ca2a5a41b", size = 7378982, upload-time = "2026-10-08T09:46:01.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/de/448c66ce790d6fcd1abbeb08e8e88ef1a5b7fc990f6ec48b818b4d9a8b29/ddtrace-4.15.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:70d5dfc73d970fa1b633e0aad3b9457cb94478958976e139b1c78461a3c48b33", size = 7993200, upload-time = "2026-10-08T09:46:04.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/87/50bbd2d4fe589aa13038c1fe07defd57e63c1bc83c22b92eac13676b3219/ddtrace-4.15.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:26ead4c2956924d0574b6681d063dee6c8741dca98ed3b747c353f6252eae0d7", size = 8388799, upload-time = "2026-10-08T09:46:07.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/13/d947c88b0fdd7cddb89174f5e9116aead05a935e1406e2921c820cb7bcab/ddtrace-4.15.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e62ec9c83e9e98122f4544592a6bf7d3338e9ea7ff2e34fe4750ade2b2c3e5", size = 9519810, upload-time = "2026-10-08T09:46:09.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5a/15711bfd54778a28cca9f7988bcdd03e4d680dfceed7f4db22f6b7a06e73/ddtrace-4.15.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3684507243feb45e7438b68989aae9a7df01b7996112d7fe74846e1d5aeb3726", size = 9771503, upload-time = "2026-10-08T09:46:13.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/d4/d0e81dff83be9370b90c090828330c4b2f06d74644f331d8f6d9518bad44/ddtrace-4.15.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4882e25808cf6102015d7421d6214aa94a788ce3242fec9a1a726a41dcfd79ac", size = 10521211, upload-time = "2026-10-08T09:46:15.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/c1/e33b44ebfa4dcb80f66fb2cc99c61d9c7706edd48e5ccb5ad04bcea0d5ad/ddtrace-4.15.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5336630ed8c829333dd4195d817b3f3f8211ddf6074e1c731e884771094fc41c", size = 10826453, upload-time = "2026-10-08T09:46:19.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/2125cf7f03e9e8b68b6a9072e393a3be4af91505ada32bc65811d6965881/ddtrace-4.15.6-cp314-cp314-win32.whl", hash = "sha256:a970bc70c81361c9a27452bfd3335182d722315466109208290a1c3a4c221748", size = 6867770, upload-time = "2026-10-08T09:46:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a0/c18b1db1cf4843a3254aed03b61c1c9cbd5964bd52c18922f633c7ed2304/ddtrace-4.15.6-cp314-cp314-win_amd64.whl", hash = "sha256:e7e8b948d13737e61a8e0dc1fe082d38431a3d4fb285d23236f1c8f733ee5c67", size = 7538347, upload-time = "2026-10-08T09:46:25.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/77/6181506d7f7693aceccdc13f5a2dc6b18662ed3eedebc02043f00d502e5e/ddtrace-4.15.6-cp314-cp314-win_arm64.whl", hash = "sha256:11662a7a4e1f51c388450cd1092b57738bb32ef98401c6032754a7e220a5f6d0", size = 7574014, upload-time = "2026-10-08T09:46:28.185Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace-internal"
|
||||
version = "0.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/51/6dc02752dca5920d6627fb92b7e0611f594a823bbd57ed31167571d819bc/ddtrace_internal-0.1.0.tar.gz", hash = "sha256:7390721276ab5f174cb103088be7d0de22d452f2817c949e8bde1182ff5b961e", size = 2504, upload-time = "2026-07-15T18:35:10.062Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/93/ad/3cae600fdfc41f54653c14339ac1f125381a6fc2a823d5e685b627ff152e/ddtrace_internal-0.1.0-py3-none-any.whl", hash = "sha256:8eeac4a812e003cebafc8be35ef05103ae2b132893dfbcee00a67f4af81404a2", size = 2495, upload-time = "2026-07-15T18:35:09.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue