litellm/tests/unit/proxy/mcp_server/test_discoverable_endpoints.py
Mateo Wang 0a1dbc0fb4
refactor(mcp): drop the experimental label from the MCP server and client paths (#48513)
* refactor(mcp): drop the experimental label from the MCP server and client paths

Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims

pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 17:47:50 +00:00

15480 lines
635 KiB
Python

"""Tests for MCP OAuth discoverable endpoints"""
import hashlib
import json
import time
from base64 import urlsafe_b64encode
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, Request, Response
from typing_extensions import ReadOnly, TypedDict
from litellm.proxy._types import LiteLLM_MCPServerTable
from litellm.types.mcp import MCPAuth
if TYPE_CHECKING:
from collections.abc import Iterator
import httpx
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from fastapi import APIRouter
from respx import MockRouter
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.types.mcp_server.mcp_server_manager import MCPServer
class _BridgeRegistrationPayload(TypedDict, total=False):
client_name: ReadOnly[str]
grant_types: ReadOnly[list[str]]
response_types: ReadOnly[list[str]]
token_endpoint_auth_method: ReadOnly[str]
redirect_uris: ReadOnly[list[str]]
def _stored_grant(access_token="access-token", refresh_token=None, expires_in_seconds=None, expires_at=None):
credential = {"type": "oauth2", "access_token": access_token}
if refresh_token is not None:
credential["refresh_token"] = refresh_token
if expires_in_seconds is not None:
credential["expires_at"] = (datetime.now(timezone.utc) + timedelta(seconds=expires_in_seconds)).isoformat()
if expires_at is not None:
credential["expires_at"] = expires_at
return credential
@pytest.mark.asyncio
@pytest.mark.parametrize(
("fields", "egress_has_token"),
[
(None, False),
({"access_token": "", "refresh_token": "refresh-token"}, False),
({}, True),
({"expires_at": "never"}, True),
({"expires_in_seconds": 600}, True),
({"expires_in_seconds": 30}, False),
({"expires_in_seconds": -300}, False),
({"expires_in_seconds": -300, "refresh_token": ""}, False),
({"expires_in_seconds": 30, "refresh_token": "refresh-token"}, True),
({"expires_in_seconds": -300, "refresh_token": "refresh-token"}, True),
],
)
async def test_vendor_credential_state_agrees_with_egress_token_resolution(monkeypatch, fields, egress_has_token):
from litellm.proxy.mcp_server import db as mcp_db
from litellm.proxy.mcp_server import discoverable_endpoints
monkeypatch.setattr(mcp_db, "MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS", 60)
credential = _stored_grant(**fields) if fields is not None else None
read = AsyncMock(return_value=credential)
refresh = AsyncMock(return_value=_stored_grant(access_token="fresh-token", expires_in_seconds=3600))
prisma = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
monkeypatch.setattr(mcp_db, "get_user_oauth_credential", read)
monkeypatch.setattr(mcp_db, "refresh_user_oauth_token", refresh)
connect = await discoverable_endpoints._vendor_credential_state("user-1", "server-1")
read.assert_awaited_once_with(prisma, "user-1", "server-1")
refresh.assert_not_awaited()
egress = await mcp_db.resolve_valid_user_oauth_token(
user_id="user-1", server=MagicMock(), cred=credential, prisma_client=prisma
)
assert (egress is not None) is egress_has_token
assert connect == ("present" if egress_has_token else "absent")
# Fixture to mock IP address check for all MCP tests
# This prevents tests from failing due to IP-based access control
@pytest.fixture(autouse=True)
def mock_mcp_client_ip():
"""Mock IPAddressUtils.get_mcp_client_ip to return None for all tests.
This bypasses IP-based access control in tests, since the MCP server's
available_on_public_internet defaults to False and mock requests don't
have proper client IP context.
"""
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value=None,
):
yield
@pytest.fixture(autouse=True)
def isolate_global_mcp_registry(monkeypatch):
"""Restore the module-global MCP server registry after each test.
Tests here register servers on ``global_mcp_server_manager`` directly; without a
restore, entries leak into other test modules sharing the same worker and break
assertions over the full registry contents.
"""
from litellm.proxy.mcp_server.catalog import CatalogSnapshots
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setattr(global_mcp_server_manager, "catalog", CatalogSnapshots(global_mcp_server_manager))
snapshot = dict(global_mcp_server_manager.registry)
yield
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry.update(snapshot)
def _mock_callback_request(base_url: str = "http://localhost:3000/"):
"""Return a MagicMock Request for callback/authorize same-origin tests.
The callback handler only uses ``request`` to compute the proxy's own
base URL via ``get_request_base_url`` (which reads ``request.base_url``
and trusted ``X-Forwarded-*`` headers). A simple MagicMock with the
right attributes is sufficient.
"""
req = MagicMock(spec=Request)
req.client = None
req.base_url = base_url
req.headers = {}
req.cookies = {}
return req
def _unresolved_oauth_server():
from litellm.proxy._types import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id="cold-oauth-server",
name="cold_oauth_server",
server_name="cold_oauth_server",
alias="cold_oauth_server",
url="https://mcp.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id="client-id",
)
def _resolved_oauth_metadata():
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata
return MCPOAuthMetadata(
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
registration_url="https://idp.example.com/register",
scopes=["mcp.read"],
)
@pytest.mark.asyncio
async def test_authorize_resolves_cold_oauth_metadata(monkeypatch):
"""The route hands the registered server to the flow, whose deferred-discovery join resolves
the cold metadata; the redirect must land on the discovered authorization endpoint."""
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
server = _unresolved_oauth_server()
global_mcp_server_manager.registry[server.server_id] = server
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
request = _mock_callback_request("https://litellm.example.com/")
with patch.object(
global_mcp_server_manager,
"_discover_oauth_metadata_for_server",
new=AsyncMock(return_value=_resolved_oauth_metadata()),
) as discovery:
response = await discoverable_endpoints.authorize(
request=request,
client_id="client-id",
mcp_server_name=server.server_name,
redirect_uri="http://127.0.0.1:60108/callback",
)
discovery.assert_awaited_once_with(server)
assert response.status_code == 307
assert response.headers["location"].startswith("https://idp.example.com/authorize")
@pytest.mark.asyncio
async def test_token_resolves_cold_oauth_metadata():
"""The route hands the registered server to the exchange, whose deferred-discovery join
resolves the cold metadata; the exchange must post to the discovered token endpoint."""
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _unresolved_oauth_server()
global_mcp_server_manager.registry[server.server_id] = server
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
request = _mock_callback_request("https://litellm.example.com/")
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with (
patch.object(
global_mcp_server_manager,
"_discover_oauth_metadata_for_server",
new=AsyncMock(return_value=_resolved_oauth_metadata()),
) as discovery,
patch.object(
discoverable_endpoints,
"get_async_httpx_client",
new=lambda llm_provider: fake_http_client,
),
):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type="refresh_token",
client_id="client-id",
refresh_token="refresh-token",
mcp_server_name=server.server_name,
)
discovery.assert_awaited_once_with(server)
assert response.status_code == 200
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/token"
@pytest.mark.asyncio
async def test_register_resolves_cold_oauth_metadata():
"""The route hands the registered server to the registration flow, whose deferred-discovery
join resolves the cold metadata; DCR must post to the discovered registration endpoint."""
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _unresolved_oauth_server().model_copy(update={"client_id": None})
global_mcp_server_manager.registry[server.server_id] = server
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
request = _mock_callback_request("https://litellm.example.com/")
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with (
patch.object(
global_mcp_server_manager,
"_discover_oauth_metadata_for_server",
new=AsyncMock(return_value=_resolved_oauth_metadata()),
) as discovery,
patch.object(discoverable_endpoints, "read_request_body", new=AsyncMock(return_value={})),
patch.object(
discoverable_endpoints,
"get_async_httpx_client",
new=lambda llm_provider: fake_http_client,
),
):
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
discovery.assert_awaited_once_with(server)
assert response.status_code == 200
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
@pytest.mark.asyncio
async def test_register_route_bridge_missing_registration_url_joins_discovery():
"""A clientless DCR bridge whose authorize and token urls are admin-entered still relays
registration upstream: the flow must join deferred discovery for the missing registration
endpoint instead of short-circuiting to dummy credentials because authorization resolves."""
import json
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="bridge-partial-metadata",
name="bridge_partial_metadata",
server_name="bridge_partial_metadata",
alias="bridge_partial_metadata",
url="https://mcp.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth_delegate,
dcr_bridge=True,
client_id=None,
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
)
global_mcp_server_manager.registry[server.server_id] = server
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
request = _mock_callback_request("https://litellm.example.com/")
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with (
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-flow join under test stays real
global_mcp_server_manager,
"_discover_oauth_metadata_for_server",
new=AsyncMock(return_value=_resolved_oauth_metadata()),
) as discovery,
patch.object( # test-quality-ok: the MagicMock Request carries no body; this seam feeds the RFC 7591 redirect_uris
discoverable_endpoints,
"read_request_body",
new=AsyncMock(return_value={"redirect_uris": ["https://client.example.com/cb"]}),
),
patch.object( # test-quality-ok: keeps the DCR POST off the network so its target URL can be asserted
discoverable_endpoints,
"get_async_httpx_client",
new=lambda llm_provider: fake_http_client,
),
):
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
discovery.assert_awaited_once_with(server)
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
assert fake_http_client.post.await_args.kwargs["json"]["redirect_uris"] == ["https://client.example.com/cb"]
assert response.status_code == 200
assert json.loads(response.body.decode("utf-8"))["client_id"] == "generated-client"
@pytest.mark.asyncio
async def test_token_route_bridge_missing_registration_url_joins_discovery():
"""A clientless DCR bridge rebuilt with an admin-entered token url but without its discovered
registration endpoint must rejoin discovery at the exchange: the relay-vs-callback arm hinges
on the registration url, so skipping discovery would swap the client's own redirect_uri for
the gateway callback and the upstream would reject the code."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="bridge-partial-token-metadata",
name="bridge_partial_token_metadata",
server_name="bridge_partial_token_metadata",
alias="bridge_partial_token_metadata",
url="https://mcp.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.true_passthrough,
dcr_bridge=True,
client_id=None,
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
)
global_mcp_server_manager.registry[server.server_id] = server
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
request = _mock_callback_request("https://litellm.example.com/")
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with (
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-exchange join under test stays real
global_mcp_server_manager,
"_discover_oauth_metadata_for_server",
new=AsyncMock(return_value=_resolved_oauth_metadata()),
) as discovery,
patch.object( # test-quality-ok: keeps the upstream token POST off the network so its redirect_uri arm can be asserted
discoverable_endpoints,
"get_async_httpx_client",
new=lambda llm_provider: fake_http_client,
),
):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type="authorization_code",
code="upstream-code",
redirect_uri="https://client.example.com/cb",
client_id="dcr-client-id",
mcp_server_name=server.server_name,
)
discovery.assert_awaited_once_with(server)
assert response.status_code == 200
assert fake_http_client.post.await_args.kwargs["data"]["redirect_uri"] == "https://client.example.com/cb"
@pytest.fixture
def trust_xff():
"""Force ``IPAddressUtils.is_request_from_trusted_proxy`` to True.
Tests that exercise X-Forwarded-* parsing logic opt into this fixture.
The trust gate's own behaviour is covered by
``test_get_request_base_url_xff_trust_gate``.
"""
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.is_request_from_trusted_proxy",
return_value=True,
):
yield
@pytest.mark.asyncio
async def test_authorize_endpoint_includes_response_type():
"""Test that authorize endpoint includes response_type=code parameter (fixes #15684)"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
# Mock the encryption functions to avoid needing a signing key
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
# Call authorize endpoint
response = await authorize(
request=mock_request,
client_id="test_client_id",
mcp_server_name="test_oauth",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
# Verify response is a redirect
assert response.status_code == 307 # FastAPI RedirectResponse default
# Verify response_type is in the redirect URL
assert "response_type=code" in response.headers["location"]
assert "https://provider.com/oauth/authorize" in response.headers["location"]
assert "client_id=test_client_id" in response.headers["location"]
assert "scope=read+write" in response.headers["location"]
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"])
async def test_authorize_endpoint_allows_client_forwarded_modes(auth_type_value):
"""The browser-only Authorize relays the gateway authorize flow for the client-forwarded
token modes; the oauth2-only gate must let them through and redirect to the upstream IdP."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = MCPServer(
server_id="test_cf_server",
name="test_cf",
server_name="test_cf",
alias="test_cf",
transport=MCPTransport.http,
auth_type=MCPAuth(auth_type_value),
# Discovery stamps these onto the in-memory registry entry at build time.
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
response = await authorize(
request=mock_request,
client_id="dcr_client_id",
mcp_server_name="test_cf",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
assert response.status_code == 307
assert "https://provider.com/oauth/authorize" in response.headers["location"]
assert "client_id=dcr_client_id" in response.headers["location"]
@pytest.mark.asyncio
async def test_authorize_endpoint_preserves_existing_query_params():
"""Test that authorize endpoint merges OAuth params with existing query params in authorization_url"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
# Authorization URL already has query params (e.g. multi-tenant OAuth)
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize?tenant=system",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
response = await authorize(
request=mock_request,
client_id="test_client_id",
mcp_server_name="test_oauth",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
location = response.headers["location"]
# Must NOT have double '?' — existing params must be merged correctly
assert location.count("?") == 1, f"Expected exactly one '?' in URL but got {location.count('?')}: {location}"
assert "tenant=system" in location
assert "client_id=test_client_id" in location
assert "response_type=code" in location
assert "scope=read+write" in location
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_mode", ["direct", "true_passthrough", "oauth_delegate"])
@pytest.mark.parametrize(
("authorization_url", "expected_access_type", "expected_prompt"),
[
("https://accounts.google.com/o/oauth2/v2/auth", ["offline"], ["consent"]),
("https://accounts.google.com/o/oauth2/v2/auth?prompt=select_account", ["offline"], ["select_account"]),
("https://accounts.google.com/o/oauth2/v2/auth?access_type=online&prompt=none", ["online"], ["none"]),
("https://provider.example/oauth/authorize", None, None),
("https://accounts.google.com.example/oauth/authorize", None, None),
],
)
async def test_authorize_preserves_contract_with_provider_defaults(
monkeypatch: pytest.MonkeyPatch,
auth_mode: str,
authorization_url: str,
expected_access_type: list[str] | None,
expected_prompt: list[str] | None,
) -> None:
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
monkeypatch.setenv("LITELLM_SALT_KEY", "isolated-oauth-test-salt")
is_bridge: Final = auth_mode != "direct"
server: Final = _bridge_server(
auth_type=MCPAuth(auth_mode) if is_bridge else MCPAuth.oauth2,
dcr_bridge=is_bridge,
client_id=None if is_bridge else "configured-client",
authorization_url=authorization_url + ("&" if "?" in authorization_url else "?") + "scope=configured-scope",
)
response: Final = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="caller-client",
redirect_uri=_BRIDGE_CLIENT_REDIRECT if is_bridge else "http://127.0.0.1:60108/callback",
state="caller-state",
scope="requested-scope",
code_challenge="caller-challenge",
code_challenge_method="S256",
)
query: Final = parse_qs(urlparse(response.headers["location"]).query)
assert response.status_code == 307
assert query.get("access_type") == expected_access_type
assert query.get("prompt") == expected_prompt
assert query["scope"] == ["requested-scope"]
assert query["code_challenge"] == ["caller-challenge"]
assert query["code_challenge_method"] == ["S256"]
assert query["client_id"] == ["caller-client" if is_bridge else "configured-client"]
if is_bridge:
assert query["redirect_uri"] == [_BRIDGE_CLIENT_REDIRECT]
assert query["state"] == ["caller-state"]
assert "set-cookie" not in response.headers
else:
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
assert query["state"] != ["caller-state"]
assert "set-cookie" in response.headers
@pytest.mark.asyncio
async def test_authorize_endpoint_forwards_pkce_parameters():
"""Test that authorize endpoint forwards PKCE parameters (code_challenge and code_challenge_method)"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server (simulating Google OAuth)
oauth2_server = MCPServer(
server_id="google_mcp",
name="google_mcp",
server_name="google_mcp",
alias="google_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="669428968603-test.apps.googleusercontent.com",
client_secret="GOCSPX-test_secret",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
scopes=["https://www.googleapis.com/auth/drive", "openid", "email"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm-proxy.example.com/"
mock_request.headers = {}
# Mock the encryption function
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state_with_pkce"
# Call authorize endpoint with PKCE parameters
response = await authorize(
request=mock_request,
client_id="669428968603-test.apps.googleusercontent.com",
mcp_server_name="google_mcp",
redirect_uri="http://localhost:60108/callback",
state="test_client_state",
code_challenge="x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk",
code_challenge_method="S256",
)
# Verify response is a redirect
assert response.status_code == 307
# Verify PKCE parameters are included in the redirect URL
location = response.headers["location"]
assert "https://accounts.google.com/o/oauth2/v2/auth" in location
assert "code_challenge=x6YH_qgwbvOzbsHDuL1sW9gYkR9-gObUiIB5RkPwxDk" in location
assert "code_challenge_method=S256" in location
assert "client_id=669428968603-test.apps.googleusercontent.com" in location
assert "response_type=code" in location
@pytest.mark.asyncio
async def test_token_endpoint_forwards_code_verifier():
"""Test that token endpoint forwards code_verifier for PKCE flow"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="google_mcp",
name="google_mcp",
server_name="google_mcp",
alias="google_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="669428968603-test.apps.googleusercontent.com",
client_secret="GOCSPX-test_secret",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
scopes=["https://www.googleapis.com/auth/drive", "openid", "email"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm-proxy.example.com/"
mock_request.headers = {}
# Mock httpx client response
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "ya29.test_access_token",
"token_type": "Bearer",
"expires_in": 3599,
"scope": "openid email https://www.googleapis.com/auth/drive",
}
mock_response.raise_for_status = MagicMock()
# Mock the async httpx client with AsyncMock for async methods
from unittest.mock import AsyncMock
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_async_client = MagicMock()
# Use AsyncMock for the async post method
mock_async_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_async_client
# Call token endpoint with code_verifier
response = await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="4/test_authorization_code",
redirect_uri="http://localhost:60108/callback",
client_id="669428968603-test.apps.googleusercontent.com",
mcp_server_name="google_mcp",
client_secret="GOCSPX-test_secret",
code_verifier="test_code_verifier_from_client",
)
# Verify that the token endpoint was called with code_verifier
mock_async_client.post.assert_called_once()
call_args = mock_async_client.post.call_args
# Check the data parameter includes code_verifier
assert call_args[1]["data"]["code_verifier"] == "test_code_verifier_from_client"
assert call_args[1]["data"]["code"] == "4/test_authorization_code"
assert call_args[1]["data"]["client_id"] == "669428968603-test.apps.googleusercontent.com"
assert call_args[1]["data"]["client_secret"] == "GOCSPX-test_secret"
assert call_args[1]["data"]["grant_type"] == "authorization_code"
# Verify response
response_data = response.body
import json
token_data = json.loads(response_data)
assert token_data["access_token"] == "ya29.test_access_token"
assert token_data["token_type"] == "Bearer"
@pytest.mark.asyncio
@pytest.mark.parametrize("server_name", [None, "missing"])
async def test_register_client_without_mcp_server_name_returns_dummy(server_name):
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry to ensure no OAuth2 servers exist (otherwise resolver would find one)
global_mcp_server_manager.registry.clear()
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={}),
):
result = await register_client(request=mock_request, mcp_server_name=server_name)
assert result == {
"client_id": server_name or "dummy_client",
"client_secret": "dummy",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
@pytest.mark.asyncio
@pytest.mark.parametrize("use_root", [False, True])
async def test_register_client_returns_existing_server_credentials(use_root):
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="stored_server",
name="stored_server",
server_name="stored_server",
alias="stored_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="existing-client",
client_secret="existing-secret",
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={}),
):
result = await register_client(
request=mock_request, mcp_server_name=None if use_root else oauth2_server.server_name
)
finally:
global_mcp_server_manager.registry.clear()
assert result == {
"client_id": "stored_server",
"client_secret": "dummy",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
@pytest.mark.asyncio
async def test_register_client_remote_registration_success():
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
request_payload = {
"client_name": "Litellm Proxy",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "generated-client",
"client_secret": "generated-secret",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
):
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
import json
assert response.status_code == 200
payload = json.loads(response.body.decode("utf-8"))
assert payload == mock_response.json.return_value
mock_async_client.post.assert_called_once()
call_args = mock_async_client.post.call_args
assert call_args.args[0] == oauth2_server.registration_url
assert call_args.kwargs["headers"] == {
"Content-Type": "application/json",
"Accept": "application/json",
}
assert call_args.kwargs["json"]["redirect_uris"] == ["https://proxy.litellm.example/callback"]
assert call_args.kwargs["json"]["grant_types"] == request_payload["grant_types"]
assert call_args.kwargs["json"]["token_endpoint_auth_method"] == request_payload["token_endpoint_auth_method"]
@pytest.mark.asyncio
async def test_register_client_non_bridge_returns_client_redirect_not_gateway_callback():
"""Regression for the DCR self-redirect loop (#33699). A plain oauth2 DCR server relays the
gateway's own /callback upstream, which is correct for the relay leg, but the client-facing
/register response must echo the CLIENT's own redirect_uris. A Rovo-style upstream echoes back
whatever redirect_uris it was registered with (here the gateway callback); returning that
verbatim makes a spec-compliant DCR client adopt /callback as its own redirect and loop."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="rovo_like",
name="rovo_like",
server_name="rovo_like",
alias="rovo_like",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
client_redirect = "https://open-webui.example/oauth/oidc/callback"
request_payload = {
"client_name": "Open WebUI",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"redirect_uris": [client_redirect],
}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "upstream-generated-client-id",
"client_secret": "upstream-generated-secret",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
):
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
payload = json.loads(response.body.decode("utf-8"))
assert payload["redirect_uris"] == [client_redirect]
assert payload["client_id"] == "upstream-generated-client-id"
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [
"https://proxy.litellm.example/callback"
]
@pytest.mark.asyncio
async def test_register_client_admin_client_id_echoes_client_redirect_uris():
"""A server with an admin-configured client_id short-circuits registration to a placeholder
response, which must still echo the client's own redirect_uris so a DCR client does not adopt
the gateway /callback and self-redirect loop (#33699)."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="stored_server",
name="stored_server",
server_name="stored_server",
alias="stored_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="existing-client",
client_secret="existing-secret",
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
client_redirect = "https://open-webui.example/oauth/oidc/callback"
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={"redirect_uris": [client_redirect]}),
):
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
assert result == {
"client_id": "stored_server",
"client_secret": "dummy",
"redirect_uris": [client_redirect],
}
@pytest.mark.asyncio
async def test_dcr_full_loop_lands_on_client_redirect_not_gateway_callback(monkeypatch):
"""End-to-end regression for #33699. A DCR client registers, then completes /authorize and
/callback. With the fix the client registers and authorizes with its OWN redirect, so /callback
delivers the code to the client's real endpoint instead of looping back into the gateway
/callback (whose decrypt of the client's opaque state failed as 'Incorrect padding'). The
client's separate origin is trusted via MCP_TRUSTED_REDIRECT_ORIGINS."""
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
authorize_with_server,
callback,
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-33699")
monkeypatch.setenv("MCP_TRUSTED_REDIRECT_ORIGINS", "open-webui.example")
client_redirect = "https://open-webui.example/oauth/oidc/callback"
client_state = "client-opaque-state-777"
global_mcp_server_manager.registry.clear()
server = MCPServer(
server_id="rovo_like",
name="rovo_like",
server_name="rovo_like",
alias="rovo_like",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
global_mcp_server_manager.registry[server.server_id] = server
reg_request = MagicMock(spec=Request)
reg_request.base_url = "https://proxy.example.com/"
reg_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "upstream-generated-client-id",
"client_secret": "upstream-generated-secret",
"redirect_uris": ["https://proxy.example.com/callback"],
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(
return_value={
"client_name": "Open WebUI",
"redirect_uris": [client_redirect],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
}
),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
):
reg_response = await register_client(request=reg_request, mcp_server_name=server.server_name)
reg_payload = json.loads(reg_response.body.decode("utf-8"))
assert reg_payload["redirect_uris"] == [client_redirect]
registered_redirect = reg_payload["redirect_uris"][0]
authorize_request = MagicMock(spec=Request)
authorize_request.base_url = "https://proxy.example.com/"
authorize_request.headers = {}
authorize_response = await authorize_with_server(
request=authorize_request,
mcp_server=server,
client_id="upstream-generated-client-id",
redirect_uri=registered_redirect,
state=client_state,
code_challenge="challenge",
code_challenge_method="S256",
)
finally:
global_mcp_server_manager.registry.clear()
assert authorize_response.status_code == 307
location = authorize_response.headers["location"]
upstream_state = parse_qs(urlparse(location).query)["state"][0]
assert upstream_state != client_state
assert "redirect_uri=https%3A%2F%2Fproxy.example.com%2Fcallback" in location
jar = SimpleCookie()
jar.load(authorize_response.headers["set-cookie"])
cookie_name = _oauth_state_cookie_name(upstream_state)
morsel = jar[cookie_name]
callback_request = MagicMock(spec=Request)
callback_request.base_url = "https://proxy.example.com/"
callback_request.headers = {}
callback_request.cookies = {cookie_name: morsel.value}
callback_response = await callback(
request=callback_request,
code="upstream-auth-code",
state=upstream_state,
)
assert callback_response.status_code == 302
final = urlparse(callback_response.headers["location"])
assert f"{final.scheme}://{final.netloc}{final.path}" == client_redirect
final_query = parse_qs(final.query)
assert final_query["code"] == ["upstream-auth-code"]
assert final_query["state"] == [client_state]
@pytest.mark.asyncio
async def test_authorize_rejects_untrusted_cross_origin_redirect_with_allowlist_hint(monkeypatch):
"""Once the client uses its own separate-origin redirect (#33699 fix), an untrusted origin is
rejected at /authorize. The rejection must point the operator to MCP_TRUSTED_REDIRECT_ORIGINS,
the mechanism a legitimate separate-origin DCR client needs, not only to PROXY_BASE_URL."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="rovo_like",
name="rovo_like",
server_name="rovo_like",
alias="rovo_like",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="upstream-client",
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id="upstream-client",
mcp_server_name="rovo_like",
redirect_uri="https://open-webui.example/oauth/oidc/callback",
state="s",
)
finally:
global_mcp_server_manager.registry.clear()
assert exc_info.value.status_code == 400
assert "MCP_TRUSTED_REDIRECT_ORIGINS" in exc_info.value.detail["hint"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"malformed_redirect_uris",
[
"https://evil.example/cb",
["https://ok.example/cb", None],
["https://ok.example/cb", 123],
["https://ok.example/cb", {"nested": "object"}],
[""],
[],
],
)
async def test_register_client_malformed_redirect_uris_falls_back_to_gateway_callback(malformed_redirect_uris):
"""RFC 7591 redirect_uris is a non-empty array of URI strings. A client that sends any other shape
(a bare string, a list holding a non-string or empty-string element, or an empty list) must not
have that value echoed back as its redirect_uris; the register response falls back to the gateway
callback so downstream never iterates a string as URIs or leaks non-string element types (#33699)."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="stored_server",
name="stored_server",
server_name="stored_server",
alias="stored_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="existing-client",
client_secret="existing-secret",
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={"redirect_uris": malformed_redirect_uris}),
):
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
assert result["redirect_uris"] == ["https://proxy.litellm.example/callback"]
@pytest.mark.asyncio
async def test_register_client_valid_multi_redirect_uris_all_echoed():
"""A well-formed client sending several valid redirect URI strings gets all of them echoed back
unchanged, so the element-type guard does not narrow a legitimate multi-entry list (#33699)."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import register_client
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="stored_server",
name="stored_server",
server_name="stored_server",
alias="stored_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="existing-client",
client_secret="existing-secret",
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
client_redirects = ["https://app.example/cb", "http://127.0.0.1:6274/callback"]
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={"redirect_uris": client_redirects}),
):
result = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
assert result["redirect_uris"] == client_redirects
@pytest.mark.asyncio
async def test_register_client_persists_dcr_client_identity():
"""A dynamic client registration (RFC 7591) must persist the issued client_id /
client_secret / token_endpoint_auth_method and the token_url onto the server row so
autonomous refresh can authenticate as the registered client. Without persistence the
minted client_id is discarded and the refresh_token grant has no client identity.
The persist must also stamp oauth2_flow="authorization_code": only the interactive
flow reaches this persist, and without the stamp the row (client creds + token_url,
no persisted authorization_url) matches the legacy M2M inference whenever endpoint
discovery fails at registry build, flipping the server to client_credentials."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "generated-client",
"client_secret": "generated-secret",
"token_endpoint_auth_method": "client_secret_basic",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
prisma = MagicMock()
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
mock_update = AsyncMock(return_value=MagicMock())
mock_update_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="client_secret_basic",
replace_stale_client=True,
)
import json
assert response.status_code == 200
assert json.loads(response.body.decode("utf-8")) == {
**mock_response.json.return_value,
"dcr_issuer": None,
"dcr_server_url": None,
"dcr_redirect_uris": ["https://proxy.litellm.example/callback"],
}
mock_update.assert_called_once()
update_data = mock_update.call_args.kwargs["data"]
assert update_data.server_id == "remote_server"
assert update_data.token_url == "https://provider.example/oauth/token"
assert "authorization_url" not in update_data.fields_set()
assert update_data.credentials["client_id"] == "generated-client"
assert update_data.credentials["client_secret"] == "generated-secret"
assert update_data.credentials["token_endpoint_auth_method"] == "client_secret_basic"
assert update_data.credentials["redirect_uris"] == ["https://proxy.litellm.example/callback"]
assert update_data.oauth2_flow == "authorization_code"
mock_update_server.assert_called_once()
async def _register_persistence_attempted_for_auth_type(auth_type: MCPAuth) -> bool:
"""Run register_client_with_server with replace_stale_client=True for a server of ``auth_type``
and report whether the DCR result was persisted onto the server row. The client-forwarded token
modes must skip the persist even on the admin path: writing it stamps oauth2_flow and a
client_id onto a server whose contract is that the gateway stores nothing, which makes a fresh
pass-through server read as gateway-authorized. The upstream registration must still be relayed
to the browser either way, since the caller needs the minted client to run its own flow."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="pt_server",
name="pt_server",
server_name="pt_server",
alias="pt_server",
transport=MCPTransport.http,
auth_type=auth_type,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "generated-client",
"client_secret": "generated-secret",
"token_endpoint_auth_method": "none",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
prisma = MagicMock()
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
mock_update = AsyncMock(return_value=MagicMock())
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
expected_binding = (
{
"dcr_issuer": None,
"dcr_server_url": None,
"dcr_redirect_uris": ["https://proxy.litellm.example/callback"],
}
if auth_type == MCPAuth.oauth2
else {}
)
assert json.loads(response.body.decode("utf-8")) == {**mock_response.json.return_value, **expected_binding}
return mock_update.await_count > 0
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
async def test_register_client_does_not_persist_for_client_forwarded_modes(auth_type):
"""The admin Authorize path (replace_stale_client=True) must not write the DCR client onto a
true_passthrough / oauth_delegate server row: the browser still receives the registration, but
the gateway keeps no OAuth client identity for these modes."""
assert await _register_persistence_attempted_for_auth_type(auth_type) is False
@pytest.mark.asyncio
async def test_register_client_persist_discriminator_oauth2_persists():
"""Guard the no-persist assertion above against vacuity: the same helper run against a genuine
oauth2 server DOES persist, so a regression that silently disables persistence everywhere (or a
helper that never reaches the persist) fails here instead of passing both."""
assert await _register_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
@pytest.mark.asyncio
async def test_register_client_persists_only_to_its_own_row_when_another_server_shares_the_url():
"""A fresh server must mint and persist its OWN DCR client even when another server row with
the same upstream URL already holds one: both the reuse lookup and the persist are keyed by
server_id, never by URL, so OAuth client identity is not transferable between server entries.
If either side ever falls back to a URL match, this fails: the fresh server would skip the
upstream registration (adopting the sibling's client) or persist onto the wrong row."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
shared_url = "https://provider.example/mcp"
fresh_server = MCPServer(
server_id="server-b",
name="server-b",
server_name="server-b",
alias="server-b",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
url=shared_url,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
sibling_row_with_client = MagicMock(server_id="server-a", url=shared_url)
sibling_row_with_client.credentials = {"client_id": "client-a-do-not-adopt"}
own_row_without_client = LiteLLM_MCPServerTable.model_validate(fresh_server.model_dump(exclude_none=True))
own_row_without_client.credentials = {}
rows_by_server_id = {"server-a": sibling_row_with_client, "server-b": own_row_without_client}
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {"client_id": "fresh-client-b", "token_endpoint_auth_method": "none"}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
mock_update = AsyncMock(return_value=MagicMock())
async def _get_row(prisma_client, server_id):
return rows_by_server_id.get(server_id)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(side_effect=_get_row)),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=fresh_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
mock_async_client.post.assert_called_once()
body = json.loads(response.body.decode("utf-8"))
assert body["client_id"] == "fresh-client-b"
mock_update.assert_called_once()
update_data = mock_update.call_args.kwargs["data"]
assert update_data.server_id == "server-b"
assert update_data.credentials["client_id"] == "fresh-client-b"
@pytest.mark.asyncio
async def test_register_client_does_not_clobber_token_url_when_absent():
"""When the in-memory server has no token_url, the DCR persist must omit it from the
partial update rather than passing None, so exclude_unset leaves the token_url column
untouched instead of overwriting an existing value with NULL."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url=None,
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {"client_id": "generated-client"}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
prisma = MagicMock()
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
mock_update = AsyncMock(return_value=MagicMock())
mock_update_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
mock_update.assert_called_once()
update_data = mock_update.call_args.kwargs["data"]
assert update_data.credentials["client_id"] == "generated-client"
assert "token_url" not in update_data.model_fields_set
@pytest.mark.asyncio
async def test_register_client_reuses_persisted_client_id_for_non_admin_when_registry_is_stale():
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {"client_id": "persisted-client"}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_update_mcp_server = AsyncMock()
mock_update_server = AsyncMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=mock_update_mcp_server,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=False,
)
assert response["client_id"] == "remote_server"
assert oauth2_server.client_id == "persisted-client"
mock_async_client.post.assert_not_called()
mock_update_mcp_server.assert_not_called()
mock_update_server.assert_called_once_with(persisted_server)
@pytest.mark.asyncio
async def test_register_client_reuse_refreshes_request_server_when_manager_update_fails():
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {
"client_id": "persisted-client",
"client_secret": "persisted-secret",
"token_endpoint_auth_method": "client_secret_basic",
}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
mock_update_server = AsyncMock(side_effect=RuntimeError("registry update failed"))
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=False,
)
assert response["client_id"] == "remote_server"
assert oauth2_server.client_id == "persisted-client"
assert oauth2_server.client_secret == "persisted-secret"
assert oauth2_server.token_endpoint_auth_method == "client_secret_basic"
mock_async_client.post.assert_not_called()
mock_update_server.assert_called_once_with(persisted_server)
@pytest.mark.asyncio
@pytest.mark.parametrize("late_winner", [False, True])
async def test_register_client_returns_reused_client_when_concurrent_persist_wins(late_winner):
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {"client_id": "generated-client"}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {"client_id": "persisted-client"}
empty_server = persisted_server.model_copy(update={"credentials": None})
mock_get_mcp_server = AsyncMock(
side_effect=[empty_server, empty_server, persisted_server] if late_winner else [empty_server, persisted_server]
)
mock_update_mcp_server = AsyncMock(return_value=None)
mock_update_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=mock_update_mcp_server,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
assert response["client_id"] == "remote_server"
assert oauth2_server.client_id == "persisted-client"
mock_async_client.post.assert_called_once()
if late_winner:
mock_update_mcp_server.assert_awaited_once()
else:
mock_update_mcp_server.assert_not_called()
mock_update_server.assert_called_once_with(persisted_server)
def _dcr_redirect_test_server(client_id):
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=client_id,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
@pytest.mark.asyncio
async def test_register_client_re_registers_when_persisted_redirect_uri_no_longer_matches_origin():
"""A persisted DCR client is bound to the redirect_uri it was registered with. When the
proxy's resolved public origin changes, every authorize built for the reused client is
rejected IdP-side and the server is permanently stranded (GH #32473). A positive mismatch
between the recorded redirect_uris and the current callback must therefore re-register on
the admin path and persist the replacement client, with the new binding recorded and the
old client's secret/auth method cleared rather than merged into the new identity."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = _dcr_redirect_test_server(client_id="stale-client")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"client_id": "fresh-client",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {
"client_id": "stale-client",
"client_secret": "stale-secret",
"token_endpoint_auth_method": "client_secret_basic",
"redirect_uris": ["https://old-origin.example/callback"],
}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_update_mcp_server = AsyncMock(return_value=MagicMock())
mock_update_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=mock_update_mcp_server,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
mock_async_client.post.assert_called_once()
register_payload = mock_async_client.post.call_args.kwargs["json"]
assert register_payload["redirect_uris"] == ["https://proxy.litellm.example/callback"]
mock_update_mcp_server.assert_called_once()
update_data = mock_update_mcp_server.call_args.kwargs["data"]
assert update_data.credentials["client_id"] == "fresh-client"
assert update_data.credentials["redirect_uris"] == ["https://proxy.litellm.example/callback"]
assert update_data.credentials["client_secret"] is None
assert update_data.credentials["token_endpoint_auth_method"] is None
assert response.status_code == 200
assert json.loads(response.body.decode("utf-8"))["client_id"] == "fresh-client"
@pytest.mark.asyncio
async def test_register_client_grandfathers_persisted_client_without_recorded_redirect_uris():
"""Clients persisted before redirect_uris were recorded (and admin-configured clients,
which never get a recording) have nothing to compare against; treating that as a mismatch
would re-mint a client_id for every existing install on upgrade and orphan all users'
refresh tokens for those servers. A missing recording must read as a match: no DCR call,
no persistence write, existing client returned."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = _dcr_redirect_test_server(client_id="legacy-client")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {"client_id": "legacy-client"}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_update_mcp_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=mock_update_mcp_server,
),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
mock_async_client.post.assert_not_called()
mock_update_mcp_server.assert_not_called()
assert response["client_secret"] == "dummy"
assert oauth2_server.client_id == "legacy-client"
@pytest.mark.asyncio
async def test_register_client_keeps_persisted_client_when_recorded_redirect_uri_matches_origin():
"""When the recorded redirect_uris still cover the current callback the persisted client
is valid; re-registering would orphan refresh tokens for no reason."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = _dcr_redirect_test_server(client_id="kept-client")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {
"client_id": "kept-client",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_update_mcp_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=mock_update_mcp_server,
),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
mock_async_client.post.assert_not_called()
mock_update_mcp_server.assert_not_called()
assert response["client_secret"] == "dummy"
@pytest.mark.asyncio
async def test_register_client_non_admin_reuses_persisted_client_despite_redirect_mismatch():
"""Callers without replace authority (the public register routes and non-admin users) must
keep reusing the persisted client even when its recorded redirect_uris mismatch: a spoofable
forwarded origin must not let any caller re-register and orphan every user's refresh token.
Only the admin path re-registers."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth2_server = _dcr_redirect_test_server(client_id=None)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
persisted_server = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
persisted_server.credentials = {
"client_id": "persisted-client",
"redirect_uris": ["https://old-origin.example/callback"],
}
mock_get_mcp_server = AsyncMock(return_value=persisted_server)
mock_update_server = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
response = await register_client_with_server(
request=mock_request,
mcp_server=oauth2_server,
client_name="Litellm Proxy",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=False,
)
mock_async_client.post.assert_not_called()
assert oauth2_server.client_id == "persisted-client"
assert response["client_secret"] == "dummy"
@pytest.mark.asyncio
async def test_register_client_without_replace_authority_adopts_concurrently_persisted_client(monkeypatch):
"""Two users connect to a fresh config-declared server at once. Both miss the store at reuse
time and both mint a client upstream, but the loser's insert finds the row taken and must
adopt the winner's client (which the winner's browser is already authorizing under) instead of
overwriting it, and is answered with the gateway's dummy client."""
try:
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
config_server = _dcr_redirect_test_server(client_id=None)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {"client_id": "loser-client", "token_endpoint_auth_method": "none"}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
winner = {"client_id": "winner-client", "redirect_uris": ["https://proxy.litellm.example/callback"]}
mock_upsert = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=AsyncMock(side_effect=[None, winner]),
),
patch(
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
new=AsyncMock(return_value=False),
),
patch(
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
new=mock_upsert,
),
):
response = await discoverable_endpoints.register_client_with_server(
request=mock_request,
mcp_server=config_server,
client_name="Claude Code",
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=False,
)
mock_async_client.post.assert_called_once()
mock_upsert.assert_not_called()
assert config_server.client_id == "winner-client"
assert response["client_id"] == config_server.server_name
@pytest.mark.asyncio
async def test_persist_without_replace_authority_never_overwrites_a_row_client_it_cannot_apply():
"""A DB-declared server already holds a shared DCR client that this process cannot adopt (it
was bound to another server URL). A caller without replace authority must get a failure, not
fall through to update_mcp_server and overwrite the client every signed-in user depends on."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_persist_dcr_client_registration,
)
server = _dcr_redirect_test_server(client_id=None).model_copy(update={"url": "https://mine.example/mcp"})
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
row.credentials = {"client_id": "someone-elses-client", "dcr_server_url": "https://other.example/mcp"}
mock_update = AsyncMock()
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=row)),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
):
result = await _persist_dcr_client_registration(
server, {"client_id": "my-client"}, "https://proxy.litellm.example/callback", replace_stale_client=False
)
assert result == "failed"
mock_update.assert_not_called()
assert server.client_id is None
@pytest.mark.asyncio
async def test_register_client_reuses_existing_client_id_without_re_dcr():
"""A server that already has a client_id (admin-configured or previously DCR'd) must be
reused, not re-registered, even without a client_secret. A client_id is one-per-application
in OAuth and shared across users; re-minting per authorize would orphan other users' refresh
tokens by overwriting the server's client_id."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="remote_server",
name="remote_server",
server_name="remote_server",
alias="remote_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="existing-shared-client",
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
request_payload = {
"client_name": "Litellm Proxy",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock()
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
):
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
mock_async_client.post.assert_not_called()
body = response if isinstance(response, dict) else json.loads(response.body.decode("utf-8"))
assert body["client_secret"] == "dummy"
def _public_register_rig(minted):
from fastapi import Request
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
request_payload = {
"client_name": "attacker",
"grant_types": ["implicit"],
"response_types": ["token"],
"token_endpoint_auth_method": "client_secret_basic",
}
mock_response = MagicMock()
mock_response.json.return_value = minted
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
return mock_request, request_payload, mock_async_client
GATEWAY_OWNED_DCR_BODY: Final = {
"client_name": "LiteLLM Proxy",
"redirect_uris": ["https://proxy.litellm.example/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
}
@pytest.mark.asyncio
async def test_public_register_route_persists_first_dcr_client_when_origin_is_configured(monkeypatch):
"""A DB-declared oauth2 server with no client yet gets its first DCR client from whichever
client connects first, usually a non-admin user's coding tool on the unauthenticated
/{server}/register route. With PROXY_BASE_URL set the gateway knows its own callback, so that
client lands on the server row for every later user's refresh. The gateway owns what it
registers: the caller's grant types, response types, auth method and name are not sent
upstream, so an anonymous caller cannot shape or phish the shared client."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
global_mcp_server_manager.registry.clear()
oauth2_server = _dcr_redirect_test_server(client_id=None)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request, request_payload, mock_async_client = _public_register_rig(
{
"client_id": "minted-client",
"client_secret": "minted-secret",
"registration_access_token": "manage-me",
"registration_client_uri": "https://provider.example/oauth/register/minted-client",
}
)
persisted_row = LiteLLM_MCPServerTable.model_validate(oauth2_server.model_dump(exclude_none=True))
mock_update = AsyncMock(return_value=persisted_row)
mock_update_server = AsyncMock()
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=AsyncMock(return_value=persisted_row),
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
):
response = await register_client(request=mock_request, mcp_server_name=oauth2_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
assert mock_async_client.post.call_args.kwargs["json"] == GATEWAY_OWNED_DCR_BODY
mock_update.assert_called_once()
assert mock_update.call_args.kwargs["data"].credentials["client_id"] == "minted-client"
mock_update_server.assert_called_once_with(persisted_row)
body = json.loads(response.body.decode("utf-8"))
assert body["client_id"] == "minted-client"
assert not {"client_secret", "registration_access_token", "registration_client_uri", "dcr_server_url"} & set(body)
@pytest.mark.asyncio
async def test_public_register_route_persists_first_dcr_client_for_config_server_and_serves_later_callers(
monkeypatch,
):
"""The customer-visible shape of the bug: a config.yaml-declared DCR server (no
LiteLLM_MCPServerTable row) whose first connect comes from a non-admin user. The minted client
must reach the server-scoped OAuth client store, so a token refresh has a client_id and a
second connect reuses the same client instead of minting another."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.litellm.example")
global_mcp_server_manager.registry.clear()
config_server = _dcr_redirect_test_server(client_id=None)
global_mcp_server_manager.registry[config_server.server_id] = config_server
mock_request, request_payload, mock_async_client = _public_register_rig(
{"client_id": "minted-client", "token_endpoint_auth_method": "none"}
)
store = {}
def insert_if_absent(**kwargs):
if "blob" in store:
return False
store["blob"] = kwargs["credentials"]
return True
mock_create = AsyncMock(side_effect=insert_if_absent)
mock_upsert = AsyncMock()
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=AsyncMock(side_effect=lambda **kwargs: store.get("blob")),
),
patch(
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
new=mock_create,
),
patch(
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
new=mock_upsert,
),
):
first = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
second = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
mock_create.assert_called_once()
assert mock_create.call_args.kwargs["server_id"] == config_server.server_id
assert mock_create.call_args.kwargs["credentials"]["client_id"] == "minted-client"
mock_upsert.assert_not_called()
mock_async_client.post.assert_called_once()
assert json.loads(first.body.decode("utf-8"))["client_id"] == "minted-client"
assert second["client_id"] == config_server.server_name
@pytest.mark.asyncio
async def test_public_register_route_without_configured_origin_does_not_pin_the_shared_client(monkeypatch):
"""Without PROXY_BASE_URL the callback comes from the request's own Host, which an anonymous
caller can forge, so the public route keeps today's behavior: register for this caller with
the body it sent, echo the upstream response, and persist nothing. An admin's Connect (or
setting PROXY_BASE_URL) is what establishes the shared client."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
global_mcp_server_manager.registry.clear()
config_server = _dcr_redirect_test_server(client_id=None)
global_mcp_server_manager.registry[config_server.server_id] = config_server
mock_request, request_payload, mock_async_client = _public_register_rig({"client_id": "caller-client"})
mock_create = AsyncMock()
mock_upsert = AsyncMock()
mock_update = AsyncMock()
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value=request_payload),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.mcp_server.db.create_mcp_server_oauth_client_credentials_if_absent",
new=mock_create,
),
patch(
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
new=mock_upsert,
),
):
response = await register_client(request=mock_request, mcp_server_name=config_server.server_name)
finally:
global_mcp_server_manager.registry.clear()
posted = mock_async_client.post.call_args.kwargs["json"]
assert posted["grant_types"] == request_payload["grant_types"]
assert posted["client_name"] == request_payload["client_name"]
mock_create.assert_not_called()
mock_upsert.assert_not_called()
mock_update.assert_not_called()
assert json.loads(response.body.decode("utf-8"))["client_id"] == "caller-client"
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_authorize_endpoint_respects_x_forwarded_proto():
"""Test that authorize endpoint uses X-Forwarded-Proto header to construct correct redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request with http base_url but X-Forwarded-Proto: https
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm.example.com/" # HTTP
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
# Mock the encryption functions
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
# Call authorize endpoint
response = await authorize(
request=mock_request,
client_id="test_client_id",
mcp_server_name="test_oauth",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
# Verify redirect URL uses HTTPS in the redirect_uri parameter
location = response.headers["location"]
# The redirect_uri parameter sent to the OAuth provider should use HTTPS
assert (
"redirect_uri=https%3A%2F%2Flitellm.example.com%2Fcallback" in location
or "redirect_uri=https://litellm.example.com/callback" in location
)
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_token_endpoint_respects_x_forwarded_proto():
"""Test that token endpoint uses X-Forwarded-Proto header for redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="google_mcp",
name="google_mcp",
server_name="google_mcp",
alias="google_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_secret",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
scopes=["openid", "email"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request with http base_url but X-Forwarded-Proto: https
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm-proxy.example.com/" # HTTP
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
# Mock httpx client response
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "test_token",
"token_type": "Bearer",
"expires_in": 3599,
}
mock_response.raise_for_status = MagicMock()
# Mock the async httpx client
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_get_client.return_value = mock_async_client
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="test_code",
redirect_uri="http://localhost:60108/callback",
client_id="test_client_id",
mcp_server_name="google_mcp",
client_secret="test_secret",
)
# Verify that the redirect_uri sent to the provider uses HTTPS
call_args = mock_async_client.post.call_args
assert call_args[1]["data"]["redirect_uri"] == "https://litellm-proxy.example.com/callback"
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_oauth_protected_resource_respects_x_forwarded_proto():
"""Test that oauth_protected_resource_mcp uses X-Forwarded-Proto for URLs"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
oauth_protected_resource_mcp,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request with http base_url but X-Forwarded-Proto: https
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm.example.com/" # HTTP
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
# Call the endpoint
response = await oauth_protected_resource_mcp(
request=mock_request,
mcp_server_name="test_oauth",
)
# Verify response uses HTTPS URLs
assert response["authorization_servers"][0].startswith("https://litellm.example.com/")
assert response["scopes_supported"] == oauth2_server.scopes
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_oauth_authorization_server_respects_x_forwarded_proto():
"""Test that oauth_authorization_server_mcp uses X-Forwarded-Proto for URLs"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
oauth_authorization_server_mcp,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request with http base_url but X-Forwarded-Proto: https
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm.example.com/" # HTTP
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
# Call the endpoint
response = await oauth_authorization_server_mcp(
request=mock_request,
mcp_server_name="test_oauth",
)
# Verify response uses HTTPS URLs
assert response["authorization_endpoint"].startswith("https://litellm.example.com/")
assert response["token_endpoint"].startswith("https://litellm.example.com/")
assert response["registration_endpoint"].startswith("https://litellm.example.com/")
assert response["grant_types_supported"] == ["authorization_code", "refresh_token"]
assert response["scopes_supported"] == oauth2_server.scopes
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_register_client_respects_x_forwarded_proto():
"""Test that register_client uses X-Forwarded-Proto for redirect_uris"""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry to ensure no OAuth2 servers exist (otherwise resolver would find one)
global_mcp_server_manager.registry.clear()
# Mock request with http base_url but X-Forwarded-Proto: https
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://proxy.litellm.example/" # HTTP
mock_request.headers = {"X-Forwarded-Proto": "https"} # Behind HTTPS proxy
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={}),
):
result = await register_client(request=mock_request)
# Verify the redirect_uris use HTTPS
assert result == {
"client_id": "dummy_client",
"client_secret": "dummy",
"redirect_uris": ["https://proxy.litellm.example/callback"],
}
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_authorize_endpoint_respects_x_forwarded_host():
"""Test that authorize endpoint uses X-Forwarded-Host and X-Forwarded-Proto to construct correct redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_client_secret",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request simulating nginx proxy:
# Internal: http://localhost:8888/github/mcp
# External: https://proxy.example.com/github/mcp
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:8888/github/mcp"
mock_request.headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "proxy.example.com",
}
# Mock the encryption functions
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
# Call authorize endpoint
response = await authorize(
request=mock_request,
client_id="test_client_id",
mcp_server_name="test_oauth",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
# Verify redirect URL uses the forwarded host and scheme
location = response.headers["location"]
# The redirect_uri parameter should use the external URL
assert (
"redirect_uri=https%3A%2F%2Fproxy.example.com%2Fgithub%2Fmcp%2Fcallback" in location
or "redirect_uri=https://proxy.example.com/github/mcp/callback" in location
)
@pytest.mark.asyncio
@pytest.mark.usefixtures("trust_xff")
async def test_token_endpoint_respects_x_forwarded_host():
"""Test that token endpoint uses X-Forwarded-Host and X-Forwarded-Proto for redirect_uri"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="google_mcp",
name="google_mcp",
server_name="google_mcp",
alias="google_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_secret",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
scopes=["openid", "email"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
# Mock request simulating nginx proxy without port in host
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:8888/github/mcp"
mock_request.headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "proxy.example.com",
}
# Mock httpx client response
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "test_token",
"token_type": "Bearer",
"expires_in": 3599,
}
mock_response.raise_for_status = MagicMock()
# Mock the async httpx client
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_get_client.return_value = mock_async_client
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="test_code",
redirect_uri="http://localhost:60108/callback",
client_id="test_client_id",
mcp_server_name="google_mcp",
client_secret="test_secret",
)
# Verify that the redirect_uri sent to the provider uses the external URL
call_args = mock_async_client.post.call_args
assert call_args[1]["data"]["redirect_uri"] == "https://proxy.example.com/github/mcp/callback"
@pytest.mark.parametrize(
"base_url,x_forwarded_proto,x_forwarded_host,x_forwarded_port,expected_url",
[
# Case 1: No forwarded headers - use original URL as-is (no trailing slash)
(
"http://localhost:4000/",
None,
None,
None,
"http://localhost:4000",
),
# Case 2: Only X-Forwarded-Proto - change scheme only
(
"http://localhost:4000/",
"https",
None,
None,
"https://localhost:4000",
),
# Case 3: X-Forwarded-Proto + X-Forwarded-Host - change scheme and host
(
"http://localhost:4000/",
"https",
"proxy.example.com",
None,
"https://proxy.example.com",
),
# Case 4: X-Forwarded-Host with port included in host header
(
"http://localhost:4000/",
"https",
"proxy.example.com:8080",
None,
"https://proxy.example.com:8080",
),
# Case 5: X-Forwarded-Host + X-Forwarded-Port as separate headers
(
"http://localhost:4000/",
"https",
"proxy.example.com",
"8443",
"https://proxy.example.com:8443",
),
# Case 6: Only X-Forwarded-Host without proto - use original scheme
(
"http://localhost:4000/",
None,
"proxy.example.com",
None,
"http://proxy.example.com",
),
# Case 7: Only X-Forwarded-Port without host - preserves original port if present
# (This is safer behavior - X-Forwarded-Port alone is unusual)
(
"http://localhost:4000/",
None,
None,
"8443",
"http://localhost:4000", # Original port preserved when already present
),
# Case 8: Complex internal URL with path (path is preserved)
(
"http://localhost:8888/github/mcp",
"https",
"proxy.example.com",
None,
"https://proxy.example.com/github/mcp",
),
# Case 9: IPv6 address in X-Forwarded-Host (should not treat :: as port separator)
(
"http://localhost:4000/",
"https",
"[2001:db8::1]",
None,
"https://[2001:db8::1]",
),
# Case 10: IPv6 address with port
(
"http://localhost:4000/",
"https",
"[2001:db8::1]:8080",
None,
"https://[2001:db8::1]:8080",
),
# Case 11: X-Forwarded-Host already has port, X-Forwarded-Port also provided (host wins)
(
"http://localhost:4000/",
"https",
"proxy.example.com:9000",
"8443",
"https://proxy.example.com:9000",
),
# Case 12: Standard proxy setup (most common case)
(
"http://127.0.0.1:8888/",
"https",
"chatproxy.company.com",
None,
"https://chatproxy.company.com",
),
# Case 13: Internal URL already has port, X-Forwarded-Port does NOT override
# (safer behavior - preserves original port when X-Forwarded-Host not provided)
(
"http://localhost:4000/",
None,
None,
"443",
"http://localhost:4000", # Original port preserved
),
# Case 14: Original URL with existing port in netloc, X-Forwarded-Host replaces it
(
"http://internal.local:8888/",
"https",
"external.com",
None,
"https://external.com",
),
(
"http://localhost:4000/",
"https",
"proxy.example.com",
"443",
"https://proxy.example.com",
),
(
"http://localhost:4000/",
"http",
"proxy.example.com",
"80",
"http://proxy.example.com",
),
(
"http://internal.local/",
"https",
None,
"443",
"https://internal.local",
),
(
"http://localhost:4000/",
"https",
"proxy.example.com:443",
None,
"https://proxy.example.com",
),
],
)
def test_get_request_base_url_comprehensive(
base_url, x_forwarded_proto, x_forwarded_host, x_forwarded_port, expected_url
):
"""Comprehensive test for get_request_base_url with various header combinations.
These cases exercise the X-Forwarded-* parsing logic, so the trust gate
is patched True; the gate's own behaviour is covered by the
``test_get_request_base_url_xff_trust_gate`` matrix below.
"""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
mock_request = MagicMock(spec=Request)
mock_request.base_url = base_url
headers = {}
if x_forwarded_proto:
headers["X-Forwarded-Proto"] = x_forwarded_proto
if x_forwarded_host:
headers["X-Forwarded-Host"] = x_forwarded_host
if x_forwarded_port:
headers["X-Forwarded-Port"] = x_forwarded_port
def mock_get(header_name, default=None):
return headers.get(header_name, default)
mock_request.headers.get = mock_get
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.is_request_from_trusted_proxy",
return_value=True,
):
result = get_request_base_url(mock_request)
assert result == expected_url, (
f"Expected '{expected_url}' but got '{result}'\n"
f"Input: base_url={base_url}, "
f"X-Forwarded-Proto={x_forwarded_proto}, "
f"X-Forwarded-Host={x_forwarded_host}, "
f"X-Forwarded-Port={x_forwarded_port}"
)
@pytest.mark.parametrize(
"general_settings,direct_ip,expect_xff_honoured",
[
# Default: use_x_forwarded_for not set -> ignore X-Forwarded-* entirely.
({}, "127.0.0.1", False),
# XFF enabled, no trusted ranges -> still ignored (no way to tell a trusted
# reverse proxy from a direct attacker).
({"use_x_forwarded_for": True}, "127.0.0.1", False),
# XFF enabled, ranges set, but caller IP outside any range -> ignored.
(
{
"use_x_forwarded_for": True,
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
},
"203.0.113.5",
False,
),
# XFF enabled, caller in trusted range -> headers honoured.
(
{
"use_x_forwarded_for": True,
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
},
"10.0.0.7",
True,
),
# Loopback example (common dev / single-host deploy).
(
{
"use_x_forwarded_for": True,
"mcp_trusted_proxy_ranges": ["127.0.0.0/8"],
},
"127.0.0.1",
True,
),
],
)
def test_get_request_base_url_xff_trust_gate(general_settings, direct_ip, expect_xff_honoured):
"""Verify the X-Forwarded-* trust gate.
With XFF poisoning attempted, the helper must return either the literal
base_url (gate denies) or the forwarded URL (gate allows), never the
forwarded URL when the gate denies.
"""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:4000/"
mock_request.client = MagicMock()
mock_request.client.host = direct_ip
headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "attacker.example.com",
}
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
mock_request.headers.__contains__ = lambda self_, name: name in headers
with patch(
"litellm.proxy.proxy_server.general_settings",
general_settings,
create=True,
):
result = get_request_base_url(mock_request)
if expect_xff_honoured:
assert result == "https://attacker.example.com"
else:
assert result == "http://localhost:4000"
def test_xff_misconfig_warning_emitted_once(caplog):
"""Operators upgrading from the old "always trust X-Forwarded-*" behaviour
get a one-shot warning when they have ``use_x_forwarded_for`` enabled
but no ``mcp_trusted_proxy_ranges`` configured. The warning must NOT
spam every request."""
try:
from fastapi import Request
from litellm.proxy import auth as proxy_auth_pkg # noqa: F401
from litellm.proxy.auth import ip_address_utils
from litellm.proxy.mcp_server.discoverable_endpoints import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Reset the module-level one-shot flag so the test is deterministic.
ip_address_utils._warned_xff_without_trusted_ranges = False
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:4000/"
mock_request.client = MagicMock()
mock_request.client.host = "203.0.113.5"
headers = {"X-Forwarded-Host": "attacker.example.com"}
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
misconfig = {"use_x_forwarded_for": True}
import logging
with (
caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"),
patch("litellm.proxy.proxy_server.general_settings", misconfig, create=True),
):
for _ in range(3):
get_request_base_url(mock_request)
matching = [rec for rec in caplog.records if "mcp_trusted_proxy_ranges" in rec.getMessage()]
assert len(matching) == 1, (
f"expected exactly one warning, got {len(matching)}: {[r.getMessage() for r in matching]}"
)
def test_get_request_base_url_honors_proxy_base_url_env(monkeypatch):
try:
from fastapi import Request
from litellm.proxy.mcp_server.oauth_utils import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm-internal:4000/"
mock_request.client = MagicMock()
mock_request.client.host = "10.0.0.7"
headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "litellm-internal:4000",
"X-Forwarded-Port": "9999",
}
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
assert get_request_base_url(mock_request) == "https://litellm.example.com"
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com/")
assert get_request_base_url(mock_request) == "https://litellm.example.com"
def test_validate_trusted_redirect_uri_logs_diagnostic_on_rejection(caplog, monkeypatch):
try:
from fastapi import HTTPException, Request
from litellm.proxy.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
except ImportError:
pytest.skip("MCP oauth_utils not available")
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm-internal:4000/"
mock_request.client = MagicMock()
mock_request.client.host = "203.0.113.5"
headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "litellm.example.com",
"X-Forwarded-Port": "443",
"Host": "litellm-internal:4000",
}
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
import logging
with (
caplog.at_level(logging.WARNING, logger="LiteLLM"),
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
):
with pytest.raises(HTTPException) as exc_info:
validate_trusted_redirect_uri(
mock_request,
"https://litellm.example.com/ui/mcp/oauth/callback",
)
assert exc_info.value.status_code == 400
detail = exc_info.value.detail
assert isinstance(detail, dict)
assert detail.get("error") == "invalid_request"
assert "error_description" in detail
assert "redirect_uri origin" in detail["error_description"]
assert "proxy origin" in detail["error_description"]
assert "hint" in detail
matching = [r for r in caplog.records if "rejecting redirect_uri" in r.getMessage()]
assert len(matching) == 1, (
f"expected exactly one diagnostic warning, got {[r.getMessage() for r in caplog.records]}"
)
msg = matching[0].getMessage()
assert "https://litellm.example.com/ui/mcp/oauth/callback" in msg
assert "litellm-internal:4000" in msg
assert "X-Forwarded-Host" in msg
@pytest.mark.parametrize(
"direct_ip,expect_accepted",
[
("10.0.0.7", True),
("203.0.113.5", False),
],
)
def test_validate_trusted_redirect_uri_follows_the_xff_trust_gate(direct_ip, expect_accepted, monkeypatch):
try:
from fastapi import HTTPException, Request
from litellm.proxy.mcp_server.oauth_utils import (
validate_trusted_redirect_uri,
)
except ImportError:
pytest.skip("MCP oauth_utils not available")
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
monkeypatch.delenv("MCP_TRUSTED_REDIRECT_ORIGINS", raising=False)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://localhost:4000/"
mock_request.client = MagicMock()
mock_request.client.host = direct_ip
headers = {
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "proxy.example.com",
}
mock_request.headers.get = lambda name, default=None: headers.get(name, default)
mock_request.headers.__contains__ = lambda self_, name: name in headers
redirect_uri = "https://proxy.example.com/callback"
general_settings = {
"use_x_forwarded_for": True,
"mcp_trusted_proxy_ranges": ["10.0.0.0/8"],
}
with patch("litellm.proxy.proxy_server.general_settings", general_settings, create=True):
if expect_accepted:
validate_trusted_redirect_uri(mock_request, redirect_uri)
return
with pytest.raises(HTTPException) as exc_info:
validate_trusted_redirect_uri(mock_request, redirect_uri)
assert exc_info.value.status_code == 400
assert "proxy.example.com" in str(exc_info.value.detail)
@pytest.mark.parametrize(
"bad_value",
[
"litellm.example.com",
"litellm.example.com/",
"://litellm.example.com",
"ftp://litellm.example.com",
"https://",
"not a url at all",
],
)
def test_get_request_base_url_rejects_malformed_proxy_base_url(bad_value, monkeypatch, caplog):
try:
from fastapi import Request
from litellm.proxy.mcp_server import oauth_utils
from litellm.proxy.mcp_server.oauth_utils import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP oauth_utils not available")
oauth_utils._warned_invalid_proxy_base_url = None
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm-internal:4000/"
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
mock_request.headers.get = lambda name, default=None: default
monkeypatch.setenv("PROXY_BASE_URL", bad_value)
import logging
with (
caplog.at_level(logging.WARNING, logger="LiteLLM"),
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
):
result = get_request_base_url(mock_request)
assert result == "http://litellm-internal:4000", (
f"malformed PROXY_BASE_URL={bad_value!r} should be ignored, got {result!r}"
)
matching = [r for r in caplog.records if "PROXY_BASE_URL" in r.getMessage() and "ignored" in r.getMessage()]
assert len(matching) == 1, (
f"expected one diagnostic for malformed PROXY_BASE_URL, got {[r.getMessage() for r in caplog.records]}"
)
assert repr(bad_value) in matching[0].getMessage() or bad_value in matching[0].getMessage()
def test_get_request_base_url_malformed_proxy_base_url_warning_is_one_shot(monkeypatch, caplog):
try:
from fastapi import Request
from litellm.proxy.mcp_server import oauth_utils
from litellm.proxy.mcp_server.oauth_utils import (
get_request_base_url,
)
except ImportError:
pytest.skip("MCP oauth_utils not available")
oauth_utils._warned_invalid_proxy_base_url = None
mock_request = MagicMock(spec=Request)
mock_request.base_url = "http://litellm-internal:4000/"
mock_request.client = MagicMock()
mock_request.client.host = "127.0.0.1"
mock_request.headers.get = lambda name, default=None: default
monkeypatch.setenv("PROXY_BASE_URL", "litellm.example.com")
import logging
with (
caplog.at_level(logging.WARNING, logger="LiteLLM"),
patch("litellm.proxy.proxy_server.general_settings", {}, create=True),
):
for _ in range(5):
get_request_base_url(mock_request)
matching = [r for r in caplog.records if "PROXY_BASE_URL" in r.getMessage() and "ignored" in r.getMessage()]
assert len(matching) == 1, f"expected exactly one warning across 5 calls, got {len(matching)}"
# -------------------------------------------------------------------
# Tests for scopes_supported when mcp_server.scopes is None
# -------------------------------------------------------------------
@pytest.mark.asyncio
async def test_oauth_protected_resource_returns_empty_scopes_when_none():
"""
When an MCP server exists but has scopes=None (e.g. Atlassian OAuth),
scopes_supported should be [] not None.
"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
# Create an OAuth2 server with scopes=None (like Atlassian)
oauth2_server = MCPServer(
server_id="atlassian_mcp",
name="atlassian_mcp",
server_name="atlassian_mcp",
alias="atlassian_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="atlassian_client_id",
client_secret="atlassian_secret",
authorization_url="https://auth.atlassian.com/authorize",
token_url="https://auth.atlassian.com/oauth/token",
scopes=None, # Atlassian doesn't set scopes
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
response = await _build_oauth_protected_resource_response(
request=mock_request,
mcp_server_name="atlassian_mcp",
use_standard_pattern=False,
)
assert response["scopes_supported"] == []
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_oauth_protected_resource_gateway_managed_oauth2_advertises_gateway_as():
"""LIT-4864: an explicitly named gateway-managed oauth2 server (interactive or M2M)
advertises the gateway's own authorization server, so a keyless DCR client that
configured the per-server URL completes the same sign-in flow the aggregate /mcp
endpoint supports and returns with a gateway session bearer; the resource stays the
per-server URL in the requested spelling (RFC 9728 resource match). A delegate-auth
oauth2 server keeps the per-server relay authorization server (its keyless flow is
upstream PKCE via the relay), and the root-resolved unnamed legacy shape is unchanged."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
def _oauth2_server(name, **kw):
return MCPServer(
server_id=name,
name=name,
server_name=name,
alias=name,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/oauth/token",
scopes=["read"],
**kw,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
interactive = _oauth2_server("github_mcp")
relay = _oauth2_server("relay_mcp", per_server_oauth_discovery=True)
m2m = _oauth2_server("m2m_mcp", oauth2_flow="client_credentials", client_id="cid", client_secret="cs")
delegated = _oauth2_server("delegated_mcp", delegate_auth_to_upstream=True)
global_mcp_server_manager.registry.clear()
try:
for server in (interactive, relay, m2m, delegated):
global_mcp_server_manager.registry[server.server_id] = server
for name in ("github_mcp", "m2m_mcp"):
standard = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name=name, use_standard_pattern=True
)
assert standard["authorization_servers"] == ["https://litellm.example.com/mcp"], name
assert standard["resource"] == f"https://litellm.example.com/mcp/{name}"
assert standard["scopes_supported"] == ["read"]
legacy = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name=name, use_standard_pattern=False
)
assert legacy["authorization_servers"] == ["https://litellm.example.com/mcp"], name
assert legacy["resource"] == f"https://litellm.example.com/{name}/mcp"
relay_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=True
)
assert relay_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"]
relay_legacy_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=False
)
assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"]
delegated_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name="delegated_mcp", use_standard_pattern=True
)
assert delegated_response["authorization_servers"] == ["https://litellm.example.com/delegated_mcp"]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.parametrize("server_count", [0, 1, 2])
@pytest.mark.parametrize("byok_first", [True, False])
@pytest.mark.parametrize(
("base_url", "origin"),
[
("https://gateway.example.com", "https://gateway.example.com"),
("https://gateway.example.com/proxy", "https://gateway.example.com"),
("http://[::1]:4000/proxy", "http://[::1]:4000"),
],
)
def test_root_protected_resource_discovers_gateway(monkeypatch, server_count, byok_first, base_url, origin):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import byok_oauth_endpoints, discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPServer
monkeypatch.setenv("PROXY_BASE_URL", base_url)
monkeypatch.setattr(
global_mcp_server_manager,
"registry",
{
f"oauth_{index}": MCPServer(
server_id=f"oauth_{index}",
name=f"oauth_{index}",
server_name=f"oauth_{index}",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
)
for index in range(server_count)
},
)
app = FastAPI()
routers = (byok_oauth_endpoints.router, discoverable_endpoints.router)
for router in routers if byok_first else reversed(routers):
app.include_router(router)
with TestClient(app) as client:
response = client.get("/.well-known/oauth-protected-resource", params={"mcp_server_name": "oauth_0"})
assert response.status_code == 200
assert response.json() == {
"resource": origin,
"authorization_servers": [f"{base_url}/mcp"],
"scopes_supported": [],
}
authorization = client.get("/.well-known/oauth-authorization-server/mcp")
assert authorization.status_code == 200
metadata = authorization.json()
assert metadata["issuer"] == response.json()["authorization_servers"][0]
assert metadata["authorization_endpoint"] == f"{base_url}/authorize/mcp-session"
assert metadata["token_endpoint"] == f"{base_url}/token"
assert metadata["registration_endpoint"] == f"{base_url}/register"
aggregate = client.get("/.well-known/oauth-protected-resource/mcp")
assert aggregate.status_code == 200
assert aggregate.json()["resource"] == f"{base_url}/mcp"
@pytest.mark.asyncio
async def test_unnamed_protected_resource_builder_uses_gateway_origin(monkeypatch):
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
request = Request(
{"type": "http", "scheme": "https", "server": ("gateway.example.com", 443), "path": "/", "headers": []}
)
response = await _build_oauth_protected_resource_response(request, None, False)
assert response == {
"resource": "https://gateway.example.com",
"authorization_servers": ("https://gateway.example.com/mcp",),
"scopes_supported": (),
}
@pytest.mark.asyncio
async def test_oauth_authorization_server_returns_empty_scopes_when_none():
"""
When an MCP server exists but has scopes=None (e.g. Atlassian OAuth),
scopes_supported should be [] not None.
"""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
# Create an OAuth2 server with scopes=None
oauth2_server = MCPServer(
server_id="atlassian_mcp",
name="atlassian_mcp",
server_name="atlassian_mcp",
alias="atlassian_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="atlassian_client_id",
client_secret="atlassian_secret",
authorization_url="https://auth.atlassian.com/authorize",
token_url="https://auth.atlassian.com/oauth/token",
scopes=None,
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name="atlassian_mcp",
)
assert response["scopes_supported"] == []
finally:
global_mcp_server_manager.registry.clear()
# -------------------------------------------------------------------
# Tests for root-level OAuth endpoint resolution (no server name)
# -------------------------------------------------------------------
def _create_oauth2_server(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
client_id="test_client_id",
client_secret="test_client_secret",
available_on_public_internet=True,
delegate_auth_to_upstream: bool = False,
):
"""Helper to create a mock OAuth2 MCPServer."""
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id=server_id,
name=name,
server_name=server_name,
alias=alias,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=client_id,
client_secret=client_secret,
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
scopes=["read", "write"],
available_on_public_internet=available_on_public_internet,
delegate_auth_to_upstream=delegate_auth_to_upstream,
)
def _create_id_lookup_oauth2_server():
return _create_oauth2_server(
server_id="oauth-server-id",
name="oauth-server-name",
server_name="oauth-server-name",
alias="oauth-server-alias",
)
@pytest.mark.asyncio
async def test_authorize_resolves_server_by_id_when_name_lookup_fails():
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_id_lookup_oauth2_server()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
with (
patch.object(
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
) as by_name, # test-quality-ok: resolver seam
patch.object(
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
) as by_id, # test-quality-ok: resolver seam
patch.object(
discoverable_endpoints, "encrypt_value_helper", return_value="encrypted-state"
), # test-quality-ok: flow seam
):
response = await discoverable_endpoints.authorize(
request=request,
client_id=server.client_id,
mcp_server_name=server.server_id,
redirect_uri="http://localhost:62646/callback",
state="test_state",
)
assert response.status_code == 307
assert "https://provider.com/oauth/authorize" in response.headers["location"]
by_name.assert_called_once_with(server.server_id, client_ip=None)
by_id.assert_called_once_with(server.server_id, client_ip=None)
@pytest.mark.asyncio
async def test_token_endpoint_resolves_server_by_id_when_name_lookup_fails():
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_id_lookup_oauth2_server()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
response = MagicMock()
response.json.return_value = {"access_token": "token", "token_type": "Bearer"}
response.raise_for_status = MagicMock()
client = MagicMock()
client.post = AsyncMock(return_value=response)
with (
patch.object(
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
) as by_name, # test-quality-ok: resolver seam
patch.object(
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
) as by_id, # test-quality-ok: resolver seam
patch.object(
discoverable_endpoints, "get_async_httpx_client", return_value=client
), # test-quality-ok: HTTP seam
):
result = await discoverable_endpoints.token_endpoint(
request=request,
grant_type="authorization_code",
code="test_code",
redirect_uri="http://localhost:62646/callback",
client_id=server.client_id,
mcp_server_name=server.server_id,
client_secret=server.client_secret,
)
assert json.loads(result.body)["access_token"] == "token"
by_name.assert_called_once_with(server.server_id, client_ip=None)
by_id.assert_called_once_with(server.server_id, client_ip=None)
@pytest.mark.asyncio
async def test_register_client_resolves_server_by_id_when_name_lookup_fails():
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_id_lookup_oauth2_server().model_copy(
update={"client_id": None, "client_secret": None, "registration_url": "https://provider.com/oauth/register"}
)
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
response = MagicMock()
response.json.return_value = {"client_id": "registered-client", "client_secret": "registered-secret"}
response.raise_for_status = MagicMock()
client = MagicMock()
client.post = AsyncMock(return_value=response)
with (
patch.object(
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
) as by_name, # test-quality-ok: resolver seam
patch.object(
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
) as by_id, # test-quality-ok: resolver seam
patch.object(
discoverable_endpoints, "read_request_body", new=AsyncMock(return_value={})
), # test-quality-ok: request seam
patch.object(
discoverable_endpoints, "get_async_httpx_client", return_value=client
), # test-quality-ok: HTTP seam
):
result = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_id)
assert json.loads(result.body)["client_id"] == "registered-client"
by_name.assert_called_once_with(server.server_id, client_ip=None)
by_id.assert_called_once_with(server.server_id, client_ip=None)
@pytest.mark.asyncio
async def test_protected_resource_metadata_resolves_server_by_id_when_name_lookup_fails():
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_id_lookup_oauth2_server()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
with (
patch.object(
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
) as by_name, # test-quality-ok: resolver seam
patch.object(
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
) as by_id, # test-quality-ok: resolver seam
):
result = await discoverable_endpoints._build_oauth_protected_resource_response(
request=request,
mcp_server_name=server.server_id,
use_standard_pattern=True,
)
assert result["authorization_servers"] == ["https://llm.example.com/mcp"]
assert result["resource"] == f"https://llm.example.com/mcp/{server.server_id}"
by_name.assert_called_once_with(server.server_id, client_ip=None)
by_id.assert_called_once_with(server.server_id, client_ip=None)
def test_authorization_server_metadata_resolves_server_by_id_when_name_lookup_fails():
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_id_lookup_oauth2_server()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
with (
patch.object(
global_mcp_server_manager, "get_mcp_server_by_name", return_value=None
) as by_name, # test-quality-ok: resolver seam
patch.object(
global_mcp_server_manager, "get_mcp_server_by_id", return_value=server
) as by_id, # test-quality-ok: resolver seam
):
result = discoverable_endpoints._build_oauth_authorization_server_response(
request=request,
mcp_server_name=server.server_id,
)
assert result["scopes_supported"] == server.scopes
assert result["issuer"] == f"https://llm.example.com/{server.server_id}"
by_name.assert_called_once_with(server.server_id, client_ip=None)
by_id.assert_called_once_with(server.server_id, client_ip=None)
@pytest.mark.asyncio
async def test_authorize_root_resolves_single_oauth2_server():
"""When /authorize is hit without server name and exactly 1 OAuth2 server exists, resolve it."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper"
) as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
# Call /authorize WITHOUT mcp_server_name, with dummy_client as client_id
response = await authorize(
request=mock_request,
client_id="dummy_client",
mcp_server_name=None,
redirect_uri="http://localhost:62646/callback",
state="test_state",
)
# Should resolve to the single OAuth2 server and redirect
assert response.status_code == 307
location = response.headers["location"]
assert "https://provider.com/oauth/authorize" in location
assert "client_id=test_client_id" in location
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_authorize_root_fails_with_multiple_oauth2_servers():
"""When /authorize is hit without server name and multiple OAuth2 servers exist, return 404."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server1 = _create_oauth2_server(server_id="server1", name="server1", server_name="server1", alias="server1")
server2 = _create_oauth2_server(server_id="server2", name="server2", server_name="server2", alias="server2")
global_mcp_server_manager.registry[server1.server_id] = server1
global_mcp_server_manager.registry[server2.server_id] = server2
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id="dummy_client",
mcp_server_name=None,
redirect_uri="http://localhost:62646/callback",
state="test_state",
)
assert exc_info.value.status_code == 404
assert "MCP server not found" in str(exc_info.value.detail)
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_authorize_root_does_not_resolve_private_server_for_external_client():
"""Root /authorize must not auto-select an MCP server hidden from the caller IP."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value="198.51.100.10",
):
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id="dummy_client",
mcp_server_name=None,
redirect_uri="http://localhost:62646/callback",
state="test_state",
)
assert exc_info.value.status_code == 404
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_token_root_resolves_single_oauth2_server():
"""When /token is hit without server name and exactly 1 OAuth2 server exists, resolve it."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "ya29.test_token",
"token_type": "Bearer",
"expires_in": 3599,
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_get_client.return_value = mock_async_client
# Call /token WITHOUT mcp_server_name
response = await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="test_auth_code",
redirect_uri="http://localhost:62646/callback",
client_id="dummy_client",
mcp_server_name=None,
client_secret=None,
code_verifier="test_verifier",
)
# Should resolve and exchange token with the upstream server
import json
token_data = json.loads(response.body)
assert token_data["access_token"] == "ya29.test_token"
# Verify it called the correct upstream token URL
call_args = mock_async_client.post.call_args
assert call_args.args[0] == "https://provider.com/oauth/token"
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_token_root_does_not_resolve_private_server_for_external_client():
"""Root /token must not exchange codes for a hidden MCP server."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value="198.51.100.10",
):
with pytest.raises(HTTPException) as exc_info:
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="test_auth_code",
redirect_uri="http://localhost:62646/callback",
client_id="dummy_client",
mcp_server_name=None,
client_secret=None,
code_verifier="test_verifier",
)
assert exc_info.value.status_code == 404
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_register_root_does_aggregate_dcr_not_single_server_resolution():
"""Root /register is the aggregate DCR endpoint: it mints a stateless llm_dcrc_ client
from the request's redirect_uris and does NOT resolve a single configured oauth2 server
(a single-server deployment registers at /{server}/register instead)."""
import json
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={"redirect_uris": ["https://claude.ai/cb"]}),
),
patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637"),
):
response = await register_client(request=mock_request, mcp_server_name=None)
body = json.loads(response.body)
assert body["client_id"].startswith("llm_dcrc_")
assert body["client_id"] != "test_oauth"
assert body["token_endpoint_auth_method"] == "none"
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_register_root_does_not_leak_a_private_server():
"""Root /register never resolves or reveals a configured server, so a private one cannot
leak to an external caller: it always mints the aggregate DCR client instead."""
import json
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={"redirect_uris": ["https://claude.ai/cb"]}),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value="198.51.100.10",
),
patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637"),
):
response = await register_client(request=mock_request, mcp_server_name=None)
body = json.loads(response.body)
assert body["client_id"].startswith("llm_dcrc_")
assert "test_oauth" not in body["client_id"]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_discovery_root_includes_server_name_prefix():
"""When root discovery is hit and exactly 1 OAuth2 server exists, include server name in URLs."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
# Call with mcp_server_name=None (root discovery)
response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name=None,
)
# Should resolve to the single server and include its name in endpoint URLs
assert "/test_oauth/authorize" in response["authorization_endpoint"]
assert "/test_oauth/token" in response["token_endpoint"]
assert "/test_oauth/register" in response["registration_endpoint"]
assert response["scopes_supported"] == ["read", "write"]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_discovery_root_does_not_expose_private_server_for_external_client():
"""Root discovery must use caller visibility before adding server-specific metadata."""
try:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server(available_on_public_internet=False)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value="198.51.100.10",
):
authorization_response = _build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name=None,
)
resource_response = await _build_oauth_protected_resource_response(
request=mock_request,
mcp_server_name=None,
use_standard_pattern=False,
)
assert "/test_oauth/" not in authorization_response["authorization_endpoint"]
assert "/test_oauth/" not in authorization_response["token_endpoint"]
assert authorization_response["scopes_supported"] == []
assert tuple(resource_response["authorization_servers"]) == ("https://llm.example.com/mcp",)
assert resource_response["resource"] == "https://llm.example.com"
assert not resource_response["scopes_supported"]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_oauth_callback_redirects_with_state():
"""Test OAuth callback endpoint properly decodes state and redirects to client callback URL."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Mock the state decoding
mock_state_data = {
"base_url": "http://localhost:3000/ui/mcp/oauth/callback",
"original_state": "test-uuid-state-123",
"code_challenge": "test_challenge",
"code_challenge_method": "S256",
"client_redirect_uri": "http://localhost:3000/ui/mcp/oauth/callback",
}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = mock_state_data
# Call callback endpoint with code and state
response = await callback(
request=_mock_callback_request(),
code="test_authorization_code_12345",
state="encrypted_state_value",
)
# Should redirect to the client callback URL with code and original state
assert response.status_code == 302
assert "http://localhost:3000/ui/mcp/oauth/callback" in response.headers["location"]
assert "code=test_authorization_code_12345" in response.headers["location"]
assert "state=test-uuid-state-123" in response.headers["location"]
# Verify state was decoded
mock_decode.assert_called_once_with("encrypted_state_value")
@pytest.mark.asyncio
async def test_oauth_callback_preserves_client_redirect_uri_query():
"""The callback should append code/state without dropping a client's existing query."""
try:
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = {
"base_url": "http://localhost:3000/ui/mcp/oauth/callback",
"original_state": "test-uuid-state-123",
"code_challenge": "test_challenge",
"code_challenge_method": "S256",
"client_redirect_uri": ("http://localhost:3000/ui/mcp/oauth/callback?session=abc"),
}
response = await callback(
request=_mock_callback_request(),
code="test_authorization_code_12345",
state="encrypted_state_value",
)
assert response.status_code == 302
parsed_location = urlparse(response.headers["location"])
query_params = parse_qs(parsed_location.query)
assert query_params["session"] == ["abc"]
assert query_params["code"] == ["test_authorization_code_12345"]
assert query_params["state"] == ["test-uuid-state-123"]
@pytest.mark.asyncio
async def test_oauth_callback_handles_invalid_state():
"""Test OAuth callback returns error page when state decryption fails."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Mock state decoding to raise an exception
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.side_effect = Exception("Failed to decrypt state")
# Call callback endpoint with invalid state
response = await callback(
request=_mock_callback_request(),
code="test_code",
state="invalid_encrypted_state",
)
# Should return HTML error page
assert response.status_code == 200
assert "Authentication incomplete" in response.body.decode()
@pytest.mark.asyncio
async def test_oauth_callback_accepts_same_origin_ui_redirect():
"""UI OAuth flow: the callback should redirect to the proxy's own UI
origin when the encrypted state carries a same-origin client_redirect_uri."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = {
"base_url": "https://proxy.example.com/ui/mcp/oauth/callback",
"original_state": "state-123",
"code_challenge": None,
"code_challenge_method": None,
"client_redirect_uri": "https://proxy.example.com/ui/mcp/oauth/callback",
}
response = await callback(
request=_mock_callback_request(base_url="https://proxy.example.com/"),
code="auth-code-123",
state="encrypted_state",
)
assert response.status_code == 302
assert "https://proxy.example.com/ui/mcp/oauth/callback" in response.headers["location"]
assert "code=auth-code-123" in response.headers["location"]
assert "state=state-123" in response.headers["location"]
@pytest.mark.asyncio
async def test_authorize_forwards_short_state_and_round_trips_via_cookie(monkeypatch):
"""LIT-4197: the ``state`` sent to the upstream authorization server must be
a short opaque handle, not the long encrypted OAuth session (some IdPs
reject an over-long state). The session must instead ride in a per-flow
HttpOnly cookie so ``/callback`` still recovers the client's original state
and redirects back to the client's redirect_uri."""
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
authorize_with_server,
callback,
decode_state_hash,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
# Real encryption so the cookie value is a genuine encrypted session.
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-4197")
client_state = "ee230e3dfd4f19c7441941684f39c8a4e0e2c3c61a088e33403df5662b4047b8"
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
server = MCPServer(
server_id="leanix_server",
name="leanix",
server_name="leanix",
alias="leanix",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="upstream-client-id",
authorization_url="https://idp.example.com/oauth/authorize",
token_url="https://idp.example.com/oauth/token",
)
authorize_request = MagicMock(spec=Request)
authorize_request.base_url = "https://proxy.example.com/"
authorize_request.headers = {}
authorize_response = await authorize_with_server(
request=authorize_request,
mcp_server=server,
client_id="upstream-client-id",
redirect_uri=client_redirect_uri,
state=client_state,
code_challenge="challenge",
code_challenge_method="S256",
)
location = authorize_response.headers["location"]
upstream_state = parse_qs(urlparse(location).query)["state"][0]
# The upstream must receive a short handle, not the encrypted session blob.
assert len(upstream_state) <= 64
assert upstream_state != client_state
# The encrypted session rides in a per-flow HttpOnly cookie bound to it.
jar = SimpleCookie()
jar.load(authorize_response.headers["set-cookie"])
cookie_name = _oauth_state_cookie_name(upstream_state)
assert cookie_name in jar
morsel = jar[cookie_name]
assert morsel["httponly"]
assert morsel["samesite"].lower() == "lax"
assert len(morsel.value) > len(upstream_state)
session = decode_state_hash(morsel.value)
assert session["original_state"] == client_state
assert session["client_redirect_uri"] == client_redirect_uri
# /callback recovers the original state from the cookie (not the handle) and
# redirects back to the client with the client's own state.
callback_request = MagicMock(spec=Request)
callback_request.base_url = "https://proxy.example.com/"
callback_request.headers = {}
callback_request.cookies = {cookie_name: morsel.value}
callback_response = await callback(
request=callback_request,
code="upstream-auth-code",
state=upstream_state,
)
assert callback_response.status_code == 302
cb_query = parse_qs(urlparse(callback_response.headers["location"]).query)
assert callback_response.headers["location"].startswith(client_redirect_uri)
assert cb_query["code"] == ["upstream-auth-code"]
assert cb_query["state"] == [client_state]
# The one-time cookie is expired on the callback response so it cannot be replayed.
cleared = SimpleCookie()
cleared.load(callback_response.headers["set-cookie"])
assert cookie_name in cleared
assert cleared[cookie_name].value == ""
assert cleared[cookie_name]["max-age"] == "0"
@pytest.mark.asyncio
async def test_callback_error_path_reads_cookie_and_clears_it(monkeypatch):
"""LIT-4197: an IdP error routed through /callback must recover the client's
original state from the cookie (not the short handle), propagate the error to
the client's redirect_uri, and expire the one-time cookie."""
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
callback,
encode_state_with_base_url,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-4197")
client_state = "client-original-state-abc"
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
handle = "shortRelayHandle123"
encoded_state = encode_state_with_base_url(
base_url=client_redirect_uri,
original_state=client_state,
client_redirect_uri=client_redirect_uri,
)
cookie_name = _oauth_state_cookie_name(handle)
request = MagicMock(spec=Request)
request.base_url = "https://proxy.example.com/"
request.headers = {}
request.cookies = {cookie_name: encoded_state}
response = await callback(
request=request,
error="access_denied",
error_description="User declined access",
state=handle,
)
assert response.status_code == 302
location = response.headers["location"]
assert location.startswith(client_redirect_uri)
query = parse_qs(urlparse(location).query)
assert query["error"] == ["access_denied"]
# The client's own state is echoed back, recovered from the cookie.
assert query["state"] == [client_state]
cleared = SimpleCookie()
cleared.load(response.headers["set-cookie"])
assert cookie_name in cleared
assert cleared[cookie_name].value == ""
assert cleared[cookie_name]["max-age"] == "0"
def _issuer_anchored_oauth_server(issuer: str | None = "https://idp.example.com"):
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id="rfc9207_server",
name="rfc9207",
server_name="rfc9207",
alias="rfc9207",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="upstream-client-id",
issuer=issuer,
authorization_url="https://idp.example.com/oauth/authorize",
token_url="https://idp.example.com/oauth/token",
)
async def _authorize_then_callback(server, iss, monkeypatch, expected_issuer_override=..., error=None):
"""Run /authorize for ``server``, then feed the resulting flow back through /callback with the
RFC 9207 ``iss`` the authorization server supposedly returned. Returns (callback_response,
sealed_state_data)."""
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
authorize_with_server,
callback,
decode_state_hash,
encode_state_with_base_url,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-LIT-5940")
client_redirect_uri = "http://127.0.0.1:6274/oauth/callback/debug"
authorize_request = MagicMock(spec=Request)
authorize_request.base_url = "https://proxy.example.com/"
authorize_request.headers = {}
authorize_response = await authorize_with_server(
request=authorize_request,
mcp_server=server,
client_id="upstream-client-id",
redirect_uri=client_redirect_uri,
state="client-original-state-9207",
code_challenge="challenge",
code_challenge_method="S256",
)
handle = parse_qs(urlparse(authorize_response.headers["location"]).query)["state"][0]
jar = SimpleCookie()
jar.load(authorize_response.headers["set-cookie"])
cookie_name = _oauth_state_cookie_name(handle)
sealed_state = jar[cookie_name].value
if expected_issuer_override is not ...:
# A state minted before the issuer was sealed into it: same shape, key absent.
sealed_state = encode_state_with_base_url(
base_url=client_redirect_uri,
original_state="client-original-state-9207",
client_redirect_uri=client_redirect_uri,
expected_issuer=expected_issuer_override,
)
callback_request = MagicMock(spec=Request)
callback_request.base_url = "https://proxy.example.com/"
callback_request.headers = {}
callback_request.cookies = {cookie_name: sealed_state}
response = await callback(
request=callback_request,
code="upstream-auth-code" if error is None else None,
error=error,
state=handle,
iss=iss,
)
return response, decode_state_hash(sealed_state)
@pytest.mark.asyncio
async def test_authorize_seals_the_issuer_and_callback_accepts_a_matching_rfc9207_iss(monkeypatch):
"""The callback accepts the exact issuer sealed during authorization."""
from urllib.parse import parse_qs, urlparse
response, state_data = await _authorize_then_callback(
_issuer_anchored_oauth_server(),
iss="https://idp.example.com",
monkeypatch=monkeypatch,
)
assert state_data["expected_issuer"] == "https://idp.example.com"
assert response.status_code == 302
query = parse_qs(urlparse(response.headers["location"]).query)
assert query["code"] == ["upstream-auth-code"]
assert query["state"] == ["client-original-state-9207"]
@pytest.mark.asyncio
async def test_callback_rejects_authorization_response_from_a_different_issuer(monkeypatch):
"""LIT-5940 / RFC 9207 §2.4: an ``iss`` naming an authorization server we never sent the user to
is a mix-up attack, so the code must not reach the client's redirect_uri."""
response, _ = await _authorize_then_callback(
_issuer_anchored_oauth_server(),
iss="https://attacker-idp.example.com",
monkeypatch=monkeypatch,
)
assert response.status_code == 400
assert "location" not in response.headers
assert b"upstream-auth-code" not in response.body
assert b"invalid_issuer" in response.body
@pytest.mark.asyncio
@pytest.mark.parametrize("error", [None, "access_denied"])
async def test_callback_rejects_supplied_issuer_without_expected_identity(monkeypatch, error):
response, state_data = await _authorize_then_callback(
_issuer_anchored_oauth_server(issuer=None),
iss="https://unknown-idp.example.com",
monkeypatch=monkeypatch,
error=error,
)
assert state_data["expected_issuer"] is None
assert response.status_code == 400
assert "location" not in response.headers
assert b"upstream-auth-code" not in response.body
@pytest.mark.asyncio
@pytest.mark.parametrize("issuer", [None, "https://idp.example.com"])
async def test_callback_accepts_missing_unadvertised_issuer(monkeypatch, issuer):
response, _ = await _authorize_then_callback(
_issuer_anchored_oauth_server(issuer),
iss=None,
monkeypatch=monkeypatch,
)
assert response.status_code == 302
@pytest.mark.asyncio
async def test_callback_rejects_an_issuer_differing_only_outside_the_path(monkeypatch):
"""A tenant a deployment encoded in a query string is part of that issuer's identity, so the
comparison must not canonicalize it away and let another tenant's response through."""
response, state_data = await _authorize_then_callback(
_issuer_anchored_oauth_server(issuer="https://idp.example.com/?tenant=a"),
iss="https://idp.example.com/?tenant=b",
monkeypatch=monkeypatch,
)
assert state_data["expected_issuer"] == "https://idp.example.com/?tenant=a"
assert response.status_code == 400
assert "location" not in response.headers
assert b"upstream-auth-code" not in response.body
@pytest.mark.asyncio
@pytest.mark.parametrize("iss,expected_status", [(None, 302), ("https://some-idp.example.com", 400)])
async def test_callback_legacy_state_requires_absent_issuer(monkeypatch, iss, expected_status):
response, _ = await _authorize_then_callback(
_issuer_anchored_oauth_server(),
iss=iss,
monkeypatch=monkeypatch,
expected_issuer_override=None,
)
assert response.status_code == expected_status
@pytest.mark.asyncio
async def test_oauth_authorize_includes_scopes_from_server_config():
"""Test that authorize endpoint includes scopes from server configuration."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Create server with specific scopes (e.g., GitLab requires 'ai_workflows')
oauth_server = MCPServer(
server_id="gitlab_server",
name="gitlab",
server_name="gitlab",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://gitlab.com/oauth/authorize",
token_url="https://gitlab.com/oauth/token",
client_id="test_client",
scopes=["api", "read_user", "ai_workflows"], # GitLab-specific scopes
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "encrypted_state"
# Call authorize without explicit scope parameter
response = await authorize_with_server(
request=mock_request,
mcp_server=oauth_server,
client_id="test_client",
redirect_uri="http://localhost:3000/callback",
state="test_state",
code_challenge="test_challenge",
code_challenge_method="S256",
response_type="code",
scope=None, # No scope in request, should use server's scopes
)
# Should redirect with scopes from server config
assert response.status_code in (307, 302)
redirect_url = response.headers["location"]
assert (
"scope=api+read_user+ai_workflows" in redirect_url or "scope=api%20read_user%20ai_workflows" in redirect_url
)
@pytest.mark.asyncio
async def test_oauth_authorize_prefers_request_scope_over_server_config():
"""Test that explicit scope parameter takes precedence over server configuration."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
oauth_server = MCPServer(
server_id="test_server",
name="test",
server_name="test",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
client_id="test_client",
scopes=["default_scope1", "default_scope2"],
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "encrypted_state"
# Call authorize WITH explicit scope parameter
response = await authorize_with_server(
request=mock_request,
mcp_server=oauth_server,
client_id="test_client",
redirect_uri="http://localhost:3000/callback",
state="test_state",
code_challenge="test_challenge",
code_challenge_method="S256",
response_type="code",
scope="custom_scope1 custom_scope2", # Explicit scope should take precedence
)
# Should use the explicit scope, not server config
assert response.status_code in (307, 302)
redirect_url = response.headers["location"]
assert (
"scope=custom_scope1+custom_scope2" in redirect_url or "scope=custom_scope1%20custom_scope2" in redirect_url
)
assert "default_scope" not in redirect_url
@pytest.mark.asyncio
async def test_token_endpoint_refresh_token_grant():
"""Test that token endpoint supports refresh_token grant type."""
try:
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
# Clear registry
global_mcp_server_manager.registry.clear()
# Create mock OAuth2 server
oauth2_server = MCPServer(
server_id="google_mcp",
name="google_mcp",
server_name="google_mcp",
alias="google_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="test_client_id",
client_secret="test_secret",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
scopes=["openid", "email"],
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.litellm.example/"
mock_request.headers = {}
# Mock httpx client response with new tokens
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "new_access_token",
"token_type": "Bearer",
"expires_in": 3599,
"refresh_token": "new_refresh_token",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_get_client.return_value = mock_async_client
response = await token_endpoint(
request=mock_request,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="test_client_id",
mcp_server_name="google_mcp",
client_secret="test_secret",
refresh_token="rt-test",
scope="openid email",
)
# Verify the POST was called with refresh_token grant data
mock_async_client.post.assert_called_once()
call_args = mock_async_client.post.call_args
assert call_args[1]["data"]["grant_type"] == "refresh_token"
assert call_args[1]["data"]["refresh_token"] == "rt-test"
assert call_args[1]["data"]["client_id"] == "test_client_id"
assert call_args[1]["data"]["client_secret"] == "test_secret"
assert call_args[1]["data"]["scope"] == "openid email"
# Verify response contains the new tokens
import json
token_data = json.loads(response.body)
assert token_data["access_token"] == "new_access_token"
assert token_data["refresh_token"] == "new_refresh_token"
@pytest.mark.asyncio
async def test_token_endpoint_authorization_code_missing_code():
"""Test that authorization_code grant rejects missing code param."""
try:
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = MCPServer(
server_id="test_server",
name="test_server",
server_name="test_server",
alias="test_server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
token_url="https://example.com/token",
)
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://proxy.example/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code=None,
redirect_uri="https://example.com/cb",
client_id="cid",
client_secret=None,
code_verifier=None,
)
assert exc_info.value.status_code == 400
assert "code is required" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_authorize_endpoint_rejects_non_loopback_redirect_uri():
"""VERIA-57 root cause B regression. The client-supplied redirect_uri
is encrypted into the OAuth state and decoded on /callback to 302 the
user back. A non-loopback value is an open-redirect + code-theft
primitive — reject with 400 before encoding anything into state."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id="cid",
mcp_server_name="test_oauth",
redirect_uri="https://attacker.example.com/cb",
state="s",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_authorize_endpoint_accepts_ipv4_loopback_range_and_ipv6_full_form():
"""RFC 8252 §7.3 + RFC 4291: full 127.0.0.0/8 and full-form IPv6
loopback must be accepted — string match on ``127.0.0.1`` alone
would miss ``127.0.0.2`` and ``0:0:0:0:0:0:0:1``."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="test_oauth_server",
name="test_oauth",
server_name="test_oauth",
alias="test_oauth",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
for uri in (
"http://127.0.0.2:3000/cb",
"http://[0:0:0:0:0:0:0:1]:3000/cb",
"http://localhost:3000/cb",
):
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper"
) as mock_encrypt:
mock_encrypt.return_value = "mocked_encrypted_state"
response = await authorize(
request=mock_request,
client_id="cid",
mcp_server_name="test_oauth",
redirect_uri=uri,
state="s",
)
assert response.status_code == 307, f"{uri} should be accepted"
@pytest.mark.asyncio
async def test_callback_revalidates_loopback_on_decoded_base_url():
"""VERIA-57 root cause B defense-in-depth: an encrypted state minted
before the /authorize validation was added has no expiry and stays
valid. /callback must re-validate the decoded base_url so those
stale states can't be used as an open-redirect + code-theft
primitive."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = {
"base_url": "https://attacker.example.com/cb",
"original_state": "s",
"code_challenge": None,
"code_challenge_method": None,
"client_redirect_uri": "https://attacker.example.com/cb",
}
with pytest.raises(HTTPException) as exc_info:
await callback(
request=_mock_callback_request(),
code="stolen_code",
state="encrypted_stale_state",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_callback_revalidates_loopback_on_decoded_client_redirect_uri():
"""If a state contains a full client_redirect_uri, validate that exact sink."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = {
"base_url": "http://localhost:3000/cb",
"original_state": "s",
"code_challenge": None,
"code_challenge_method": None,
"client_redirect_uri": "https://attacker.example.com/cb",
}
with pytest.raises(HTTPException) as exc_info:
await callback(
request=_mock_callback_request(),
code="stolen_code",
state="encrypted_stale_state",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_callback_rejects_state_missing_redirect_uri():
"""Malformed state without a redirect target should fail with a structured 400."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
)
with patch("litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash") as mock_decode:
mock_decode.return_value = {
"original_state": "s",
"code_challenge": None,
"code_challenge_method": None,
}
with pytest.raises(HTTPException) as exc_info:
await callback(
request=_mock_callback_request(),
code="code",
state="encrypted_malformed_state",
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_token_endpoint_sets_no_store_cache_control():
"""RFC 6749 §5.1 / OAuth 2.1 draft-15 §4.1.3: the token response
contains an access token (and possibly a refresh token) — it MUST
NOT be cached by intermediaries or the client."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="t",
name="t",
server_name="t",
alias="t",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
fake_http_response = MagicMock()
fake_http_response.json.return_value = {
"access_token": "tok",
"token_type": "Bearer",
"expires_in": 3600,
}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
):
response = await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="c",
redirect_uri="http://127.0.0.1:3000/cb",
client_id="cid",
client_secret=None,
code_verifier=None,
)
assert response.headers["cache-control"] == "no-store"
assert response.headers["pragma"] == "no-cache"
async def _exchange_with_upstream_token_response(upstream_body):
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="t",
name="t",
server_name="t",
alias="t",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
fake_http_response = MagicMock()
fake_http_response.json.return_value = upstream_body
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
):
response = await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="c",
redirect_uri="http://127.0.0.1:3000/cb",
client_id="cid",
client_secret=None,
code_verifier=None,
)
return json.loads(response.body)
@pytest.mark.asyncio
async def test_token_exchange_omits_expires_in_when_upstream_omits_it():
"""A provider that issues a non-expiring token (e.g. Slack without token
rotation) returns no ``expires_in``. The exchange must mirror that and omit
``expires_in`` rather than fabricate a 1-hour TTL, so the stored credential
is treated as non-expiring instead of dying after an hour."""
body = await _exchange_with_upstream_token_response({"access_token": "tok", "token_type": "Bearer"})
assert "expires_in" not in body
@pytest.mark.asyncio
async def test_token_exchange_passes_through_upstream_expires_in():
"""When the provider does send ``expires_in`` (e.g. Slack with token
rotation), the exchange forwards the real value unchanged."""
body = await _exchange_with_upstream_token_response(
{"access_token": "tok", "token_type": "Bearer", "expires_in": 43200}
)
assert body["expires_in"] == 43200
_BRIDGE_CLIENT_REDIRECT = "https://claude.ai/api/mcp/auth_callback"
def _bridge_server(**overrides):
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
fields = {
"server_id": "bridge_srv",
"name": "bridge_srv",
"server_name": "bridge_srv",
"alias": "bridge_srv",
"transport": MCPTransport.http,
"auth_type": MCPAuth.true_passthrough,
"dcr_bridge": True,
"authorization_url": "https://provider.com/oauth/authorize",
"token_url": "https://provider.com/oauth/token",
"registration_url": "https://provider.com/oauth/register",
**overrides,
}
return MCPServer(**fields)
def _bridge_mock_request(cookie: str | None = None) -> MagicMock:
from fastapi import Request
mock_request: Final = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_request.cookies = {"token": cookie} if cookie else {}
return mock_request
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"])
async def test_authorize_bridge_relay_passes_client_params_verbatim(auth_type_value):
"""The bridge relay arm (registration relayed upstream, no admin-configured client) passes the
client's client_id, redirect_uri, state, and PKCE through verbatim: the code returns straight
to the client's own redirect URI, so the gateway sets no state cookie, injects no /callback,
and applies no gateway-side redirect trust (the upstream enforces its registered binding)."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=_bridge_server(auth_type=MCPAuth(auth_type_value)),
client_id="dcr-client-123",
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
state="client-state",
code_challenge="chal",
code_challenge_method="S256",
)
assert response.status_code == 307
location = response.headers["location"]
assert location.startswith("https://provider.com/oauth/authorize")
query = parse_qs(urlparse(location).query)
assert query["client_id"] == ["dcr-client-123"]
assert query["redirect_uri"] == [_BRIDGE_CLIENT_REDIRECT]
assert query["state"] == ["client-state"]
assert query["code_challenge"] == ["chal"]
assert query["code_challenge_method"] == ["S256"]
assert "litellm.example.com" not in location
assert "set-cookie" not in {key.lower() for key in response.headers.keys()}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"code_challenge,code_challenge_method",
[(None, None), ("chal", None), ("chal", "plain"), (None, "S256")],
)
async def test_authorize_bridge_requires_s256_pkce(code_challenge, code_challenge_method):
"""Bridge servers serve unauthenticated public clients, so the PKCE downgrade paths (missing
challenge, or a method that is not S256; RFC 7636 defaults a missing method to plain) are
rejected at the gateway on both bridge arms."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
with pytest.raises(HTTPException) as exc:
await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=_bridge_server(),
client_id="dcr-client-123",
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
state="s",
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
assert exc.value.status_code == 400
assert "S256" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_authorize_bridge_short_circuit_keeps_callback_and_redirect_trust():
"""The bridge short-circuit arm (admin-configured OAuth client, upstream only knows the
gateway callback) keeps the /callback state relay and the gateway redirect trust: a public
client redirect target is rejected unless ops allowlist it, and a trusted target still routes
through the gateway callback with the state cookie."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
short_circuit_server = _bridge_server(client_id="admin-client", registration_url=None)
with pytest.raises(HTTPException) as exc:
await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=short_circuit_server,
client_id="ignored",
redirect_uri=_BRIDGE_CLIENT_REDIRECT,
state="s",
code_challenge="chal",
code_challenge_method="S256",
)
assert exc.value.status_code in (400, 403)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
return_value="mocked_encrypted_state",
):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=short_circuit_server,
client_id="ignored",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
code_challenge="chal",
code_challenge_method="S256",
)
query = parse_qs(urlparse(response.headers["location"]).query)
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
assert query["client_id"] == ["admin-client"]
@pytest.mark.asyncio
async def test_authorize_non_bridge_client_forwarded_keeps_pre_bridge_contract():
"""A client-forwarded server without dcr_bridge keeps the pre-bridge behavior: no PKCE
requirement and the gateway /callback relay (this is the browser-only Authorize path)."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
return_value="mocked_encrypted_state",
):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=_bridge_server(dcr_bridge=None),
client_id="cid",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
)
assert response.status_code == 307
query = parse_qs(urlparse(response.headers["location"]).query)
assert query["redirect_uri"] == ["https://litellm.example.com/callback"]
async def _bridge_token_post_data(server, redirect_uri):
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
):
await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri=redirect_uri,
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
return fake_http_client.post.call_args.kwargs["data"]
@pytest.mark.asyncio
async def test_token_bridge_relay_posts_client_redirect_uri():
"""The bridge relay arm's token exchange sends the client's own redirect_uri upstream (it must
match the authorize leg) with the caller's public client_id and PKCE verifier."""
data = await _bridge_token_post_data(_bridge_server(), redirect_uri=_BRIDGE_CLIENT_REDIRECT)
assert data["redirect_uri"] == _BRIDGE_CLIENT_REDIRECT
assert data["client_id"] == "dcr-client-123"
assert data["code_verifier"] == "verifier"
assert "client_secret" not in data
@pytest.mark.asyncio
async def test_token_bridge_relay_requires_redirect_uri():
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
with pytest.raises(HTTPException) as exc:
await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=_bridge_server(),
grant_type="authorization_code",
code="auth-code",
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
assert exc.value.status_code == 400
assert "redirect_uri" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_token_non_bridge_keeps_gateway_callback():
"""Without dcr_bridge the token exchange keeps posting the gateway callback as redirect_uri,
pinning the pre-bridge contract for the browser-only Authorize path."""
data = await _bridge_token_post_data(_bridge_server(dcr_bridge=None), redirect_uri=_BRIDGE_CLIENT_REDIRECT)
assert data["redirect_uri"] == "https://litellm.example.com/callback"
def _named_as_metadata_response(server):
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip",
return_value=None,
):
return _build_oauth_authorization_server_response(
request=_bridge_mock_request(),
mcp_server_name=server.server_name,
)
finally:
global_mcp_server_manager.registry.clear()
def test_oauth_authorization_server_metadata_served_for_bridge_server():
"""Bridge servers get the gateway's AS metadata (the register, authorize, and token relays),
which is what makes the DCR front door discoverable to standard MCP clients."""
result = _named_as_metadata_response(_bridge_server())
assert result["authorization_endpoint"] == "https://litellm.example.com/bridge_srv/authorize"
assert result["token_endpoint"] == "https://litellm.example.com/bridge_srv/token"
assert result["registration_endpoint"] == "https://litellm.example.com/bridge_srv/register"
def test_oauth_authorization_server_404_for_non_bridge_client_forwarded_server():
"""Without dcr_bridge a client-forwarded server keeps 404ing AS-metadata discovery: verbatim
upstream discovery is the contract and the gateway must not advertise itself as its AS."""
with pytest.raises(HTTPException) as exc:
_named_as_metadata_response(_bridge_server(dcr_bridge=None))
assert exc.value.status_code == 404
async def _bridge_register_response(
server: "MCPServer",
request_payload: _BridgeRegistrationPayload,
replace_stale_client: bool = False,
database_connected: bool = True,
) -> tuple[Response, MagicMock, AsyncMock]:
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.json.return_value = {
"client_id": "upstream-issued-client",
"redirect_uris": request_payload.get("redirect_uris", []),
"token_endpoint_auth_method": "none",
}
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._persist_dcr_client_registration",
new_callable=AsyncMock,
) as mock_persist,
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
new_callable=AsyncMock,
return_value=False,
),
patch(
"litellm.proxy.utils.get_prisma_client_or_throw",
**({"return_value": MagicMock()} if database_connected else {"side_effect": HTTPException(status_code=500)}),
),
):
response = await register_client_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_name=request_payload.get("client_name", ""),
grant_types=request_payload.get("grant_types"),
response_types=request_payload.get("response_types"),
token_endpoint_auth_method=request_payload.get("token_endpoint_auth_method"),
replace_stale_client=replace_stale_client,
client_redirect_uris=request_payload.get("redirect_uris"),
)
return response, mock_async_client, mock_persist
@pytest.mark.asyncio
async def test_register_bridge_relay_forwards_client_redirect_uris():
"""The bridge relay arm registers the client's own redirect_uris upstream with public-client
defaults and relays the upstream response verbatim, so the upstream AS enforces the redirect
binding for that client and the auth code never transits the gateway."""
import json
response, mock_async_client, _ = await _bridge_register_response(
_bridge_server(),
{"client_name": "Claude", "redirect_uris": [_BRIDGE_CLIENT_REDIRECT]},
)
posted = mock_async_client.post.call_args.kwargs["json"]
assert posted["redirect_uris"] == [_BRIDGE_CLIENT_REDIRECT]
assert posted["grant_types"] == ["authorization_code", "refresh_token"]
assert posted["response_types"] == ["code"]
assert posted["token_endpoint_auth_method"] == "none"
payload = json.loads(response.body.decode("utf-8"))
assert payload["client_id"] == "upstream-issued-client"
@pytest.mark.asyncio
async def test_register_bridge_relay_requires_redirect_uris():
with pytest.raises(HTTPException) as exc:
await _bridge_register_response(_bridge_server(), {"client_name": "Claude"})
assert exc.value.status_code == 400
assert "redirect_uris" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_register_bridge_relay_surfaces_upstream_error_not_500():
"""A bridge relay registration the upstream rejects must surface the upstream status and its
RFC 7591 error body to the client, not a bare 500 that hides the real reason."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
error_response = MagicMock()
error_response.status_code = 400
error_response.text = '{"error":"invalid_redirect_uri","error_description":"redirect_uri not allowed"}'
error_response.json = MagicMock(
return_value={"error": "invalid_redirect_uri", "error_description": "redirect_uri not allowed"}
)
error_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
)
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=error_response)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
new_callable=AsyncMock,
return_value=False,
),
):
with pytest.raises(HTTPException) as exc:
await register_client_with_server(
request=_bridge_mock_request(),
mcp_server=_bridge_server(),
client_name="Claude",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
client_redirect_uris=[_BRIDGE_CLIENT_REDIRECT],
)
assert exc.value.status_code == 400
assert "invalid_redirect_uri" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_register_non_bridge_upstream_error_relays_status_not_500():
"""A non-bridge DCR rejection must relay the upstream status and RFC 7591 error body just like
the bridge relay arm; a raw HTTPStatusError would escape to the global handler and surface as an
opaque 500 that hides the real reason from the create-flow UI."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
error_response = MagicMock()
error_response.status_code = 400
error_response.text = '{"error":"invalid_client_metadata"}'
error_response.json = MagicMock(return_value={"error": "invalid_client_metadata"})
error_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
)
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=error_response)
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
new_callable=AsyncMock,
return_value=False,
),
):
with pytest.raises(HTTPException) as exc:
await register_client_with_server(
request=_bridge_mock_request(),
mcp_server=oauth2_server,
client_name="Claude",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
)
assert exc.value.status_code == 400
assert "invalid_client_metadata" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_register_bridge_relay_never_persists():
"""Relayed registrations belong to individual clients; persisting one as the server's own DCR
client would hand every future caller the first client's identity."""
_, _, mock_persist = await _bridge_register_response(
_bridge_server(),
{"client_name": "Claude", "redirect_uris": [_BRIDGE_CLIENT_REDIRECT]},
replace_stale_client=True,
)
mock_persist.assert_not_called()
_LAPTOP_LOOPBACK_REDIRECT: Final = "http://127.0.0.1:33418/"
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type_value", ["oauth_delegate", "true_passthrough"])
async def test_register_bridge_with_proxy_base_url_registers_gateway_client_not_laptop_loopback(
monkeypatch: pytest.MonkeyPatch, auth_type_value: Literal["oauth_delegate", "true_passthrough"]
) -> None:
"""A laptop MCP client (VS Code) registers with its own loopback redirect. With PROXY_BASE_URL set
the gateway must not relay that registration upstream: it registers its one shared client bound
to the gateway /callback, keeps it, and hands the caller a client bound to the caller's loopback.
Relaying the loopback is the customer failure on upstreams that restrict redirect domains."""
import json
from litellm.types.mcp import MCPAuth
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
response, mock_async_client, mock_persist = await _bridge_register_response(
_bridge_server(auth_type=MCPAuth(auth_type_value)),
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
)
posted: Final = mock_async_client.post.call_args.kwargs["json"]
assert posted["redirect_uris"] == ["https://litellm.example.com/callback"], posted
assert posted["client_name"] == "LiteLLM Proxy", posted
mock_persist.assert_awaited_once()
payload: Final = json.loads(response.body.decode("utf-8"))
assert payload["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT], payload
@pytest.mark.asyncio
async def test_register_bridge_without_database_still_relays_client_registration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A database-free gateway cannot keep the shared client past this process, so even with
PROXY_BASE_URL set it relays the caller's own registration instead of stranding the next authorize."""
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
_, mock_async_client, mock_persist = await _bridge_register_response(
_bridge_server(),
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
database_connected=False,
)
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT]
mock_persist.assert_not_called()
@pytest.mark.asyncio
async def test_register_bridge_without_proxy_base_url_still_relays_client_registration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Without PROXY_BASE_URL the gateway cannot keep a shared client, so the relay arm remains the
fallback and the caller's own redirect goes upstream as before."""
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
_, mock_async_client, mock_persist = await _bridge_register_response(
_bridge_server(),
{"client_name": "Visual Studio Code", "redirect_uris": [_LAPTOP_LOOPBACK_REDIRECT]},
)
assert mock_async_client.post.call_args.kwargs["json"]["redirect_uris"] == [_LAPTOP_LOOPBACK_REDIRECT]
mock_persist.assert_not_called()
def _persisted_shared_gateway_client(
server: "MCPServer", client_id: str = "shared-gateway-client"
) -> LiteLLM_MCPServerTable:
request: Final = _bridge_mock_request()
credentials: Final = {
"client_id": client_id,
"client_secret": None,
"token_endpoint_auth_method": None,
"redirect_uris": [f"{request.base_url.rstrip('/')}/callback"],
"dcr_issuer": server.issuer,
"dcr_server_url": server.url,
}
return LiteLLM_MCPServerTable.model_validate({**server.model_dump(exclude_none=True), "credentials": credentials})
@pytest.mark.asyncio
@pytest.mark.parametrize(
("shared_client_persisted", "expected_redirect_uri", "expected_client_id"),
[
(True, "https://litellm.example.com/callback", "shared-gateway-client"),
(False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"),
],
)
async def test_authorize_clientless_bridge_loads_shared_client_before_choosing_the_arm(
monkeypatch: pytest.MonkeyPatch,
shared_client_persisted: bool,
expected_redirect_uri: str,
expected_client_id: str,
) -> None:
"""This worker's catalog snapshot has no client_id. When another worker already persisted the shared
client, authorize must load it and send it with the gateway /callback upstream, never the caller's
own client_id or loopback. When no shared client exists yet (a client registered before the gateway
kept one), the caller keeps its own upstream binding through the relay."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
server: Final = _bridge_server(url="https://mcp.example.com/mcp")
persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None
prisma_client: Final = MagicMock()
mock_get_mcp_server: Final = AsyncMock(return_value=persisted)
mock_update_server: Final = AsyncMock()
with (
patch(
"litellm.proxy.utils.get_prisma_client_or_throw",
return_value=prisma_client,
),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch.object(
global_mcp_server_manager,
"update_server",
new=mock_update_server,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
return_value="mocked_encrypted_state",
),
):
response: Final = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="dcr-client-123",
redirect_uri=_LAPTOP_LOOPBACK_REDIRECT,
state="s",
code_challenge="chal",
code_challenge_method="S256",
)
location: Final = response.headers["location"]
assert location.startswith("https://provider.com/oauth/authorize"), location
query: Final = parse_qs(urlparse(location).query)
assert query["redirect_uri"] == [expected_redirect_uri], query
assert query["client_id"] == [expected_client_id], query
@pytest.mark.asyncio
@pytest.mark.parametrize(
("shared_client_persisted", "expected_redirect_uri", "expected_client_id"),
[
(True, "https://litellm.example.com/callback", "shared-gateway-client"),
(False, _LAPTOP_LOOPBACK_REDIRECT, "dcr-client-123"),
],
)
async def test_token_clientless_bridge_loads_shared_client_before_choosing_the_arm(
monkeypatch: pytest.MonkeyPatch,
shared_client_persisted: bool,
expected_redirect_uri: str,
expected_client_id: str,
) -> None:
"""The token leg mirrors authorize: with the shared client persisted it redeems the code with that
client and the gateway /callback, and with none yet it relays the caller's own client and redirect."""
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
server: Final = _bridge_server(url="https://mcp.example.com/mcp")
persisted: Final = _persisted_shared_gateway_client(server) if shared_client_persisted else None
prisma_client: Final = MagicMock()
mock_get_mcp_server: Final = AsyncMock(return_value=persisted)
mock_update_server: Final = AsyncMock()
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma_client),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch.object(
global_mcp_server_manager,
"update_server",
new=mock_update_server,
),
):
data: Final = await _bridge_token_post_data(server, redirect_uri=_LAPTOP_LOOPBACK_REDIRECT)
assert data["redirect_uri"] == expected_redirect_uri, data
assert data["client_id"] == expected_client_id, data
@pytest.mark.asyncio
@pytest.mark.parametrize(
("dcr_bridge", "expected"),
[(True, "persisted"), (False, "skipped")],
)
async def test_persist_dcr_client_for_client_forwarded_server_only_when_it_is_a_bridge(
dcr_bridge: bool,
expected: Literal["persisted", "skipped"],
) -> None:
"""A DCR bridge keeps the shared gateway client on the server row (without stamping oauth2_flow on
a client-forwarded row), while a client-forwarded server without the bridge still stores nothing."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_persist_dcr_client_registration,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
server: Final = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=dcr_bridge)
prisma: Final = MagicMock()
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=None)
mock_update: Final = AsyncMock(return_value=MagicMock())
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=prisma),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=mock_update),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False),
):
result: Final = await _persist_dcr_client_registration(
server,
{"client_id": "shared-gateway-client", "token_endpoint_auth_method": "none"},
"https://litellm.example.com/callback",
replace_stale_client=False,
)
assert result == expected
if expected == "skipped":
mock_update.assert_not_awaited()
return
written: Final = mock_update.await_args.kwargs["data"]
assert written.oauth2_flow is None, written
assert written.credentials["client_id"] == "shared-gateway-client", written
assert server.client_id == "shared-gateway-client"
@pytest.mark.asyncio
@pytest.mark.parametrize("server_has_shared_client", [False, True])
async def test_token_sealed_ephemeral_client_is_not_replaced_by_persisted_gateway_client(
monkeypatch: pytest.MonkeyPatch,
server_has_shared_client: bool,
) -> None:
"""The token leg must use the sealed ephemeral client even if this worker's catalog has a shared client."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("PROXY_BASE_URL", "https://litellm.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
server: Final = _bridge_server(
url="https://mcp.example.com/mcp",
client_id="shared-gateway-client" if server_has_shared_client else None,
client_secret="shared-secret" if server_has_shared_client else None,
token_endpoint_auth_method="client_secret_basic" if server_has_shared_client else None,
)
fake_http_response: Final = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client: Final = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
prisma_client: Final = MagicMock()
mock_get_mcp_server: Final = AsyncMock(
return_value=_persisted_shared_gateway_client(server, client_id="persisted-gateway-client")
)
mock_update_server: Final = AsyncMock()
with (
patch(
"litellm.proxy.utils.get_prisma_client_or_throw",
return_value=prisma_client,
),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=mock_get_mcp_server,
),
patch.object(
global_mcp_server_manager,
"update_server",
new=mock_update_server,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
):
await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="upstream-code",
redirect_uri="https://litellm.example.com/callback",
client_id="ephemeral-client",
client_secret="ephemeral-secret" if server_has_shared_client else None,
code_verifier="verifier",
client_token_endpoint_auth_method="client_secret_post" if server_has_shared_client else None,
sealed_client=True,
)
data: Final = fake_http_client.post.call_args.kwargs["data"]
assert data["client_id"] == "ephemeral-client", data
assert data["redirect_uri"] == "https://litellm.example.com/callback", data
if server_has_shared_client:
headers: Final = fake_http_client.post.call_args.kwargs["headers"]
assert data["client_secret"] == "ephemeral-secret", data
assert "Authorization" not in headers, headers
_BRIDGE_MASTER_KEY = "sk-bridge-producer-master-key-0123456789abcdef"
async def _exchange_for_bridge_server(server, upstream_body, key_hash, code="auth-code", fake_client_out=None):
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
fake_http_response = MagicMock()
fake_http_response.json.return_value = upstream_body
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
# The mint consumes _resolve_active_litellm_key's tagged result: an active key resolves to a
# _ResolvedKey carrying its hash; a request with no usable credential resolves to "no_active_key".
resolution = _ResolvedKey(key_hash=key_hash, key=MagicMock()) if key_hash is not None else "no_active_key"
key_resolver = AsyncMock(return_value=resolution)
if fake_client_out is not None:
fake_client_out["client"] = fake_http_client
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
new=key_resolver,
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code=code,
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
from litellm.proxy.mcp_server.discoverable_endpoints import is_bridge_authorization_code
# The key_hash path resolves the presented litellm key; the interactive SSO path recovers identity
# from the gateway authorization code instead, so it never awaits the resolver.
if server.is_oauth_delegate and server.is_dcr_bridge and not is_bridge_authorization_code(code):
key_resolver.assert_awaited_once()
else:
key_resolver.assert_not_awaited()
return response
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_mints_envelope_not_raw_token():
"""A dcr_bridge oauth_delegate token exchange returns a gateway-bound envelope, not the raw
upstream token: the response access_token opens (under the same master-key-derived keys and the
server_id) to the caller's identity and the upstream Authorization, and the raw upstream token
never appears in the bearer the client receives."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
envelope_keys_from_master_key,
resolve_bridge_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
token = body["access_token"]
assert body["token_type"] == "Bearer"
assert body["expires_in"] > 0
assert token.startswith("llm_env_")
assert "UPSTREAM-SECRET-TOKEN" not in token
assert "refresh_token" not in body
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
assert isinstance(opened, BridgeEnvelopeAdmitted)
assert opened.identity.subject_type == "key_hash"
assert opened.identity.subject == "hashed-litellm-key-77"
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
def test_bridge_authorization_code_round_trips_and_rejects_hostile_input():
"""The gateway authorization code seals and recovers the upstream code and the SSO user, and is
total over hostile input: a raw upstream code (scripted path) opens to None, and a tampered or
non-gateway value opens to None rather than raising."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
is_bridge_authorization_code,
open_bridge_authorization_code,
seal_bridge_authorization_code,
)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
sealed = seal_bridge_authorization_code(
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
)
assert is_bridge_authorization_code(sealed)
opened = open_bridge_authorization_code(sealed)
assert opened is not None
assert opened.upstream_code == "up-code"
assert opened.litellm_user_id == "sso-user-9"
assert opened.mcp_server_id == "srv-1"
assert open_bridge_authorization_code("raw-upstream-code") is None
assert open_bridge_authorization_code(sealed[:-4] + "aaaa") is None
@pytest.mark.asyncio
async def test_interactive_bridge_token_exchange_mints_user_subject_envelope():
"""An interactive dcr_bridge oauth_delegate exchange (the client presents the gateway code the
callback sealed, and NO litellm key) mints an envelope bound to the SSO-captured user: it opens
to a user_id subject, and the upstream exchange used the real upstream code recovered from the
gateway code, not the sealed wrapper."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.discoverable_endpoints import (
seal_bridge_authorization_code,
)
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
envelope_keys_from_master_key,
resolve_bridge_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
gateway_code = seal_bridge_authorization_code(
upstream_code="REAL-UPSTREAM-CODE", litellm_user_id="sso-user-42", mcp_server_id=server.server_id
)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
captured: dict = {}
response = await _exchange_for_bridge_server(
server, upstream, key_hash=None, code=gateway_code, fake_client_out=captured
)
token = json.loads(response.body)["access_token"]
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), server.server_id)
assert isinstance(opened, BridgeEnvelopeAdmitted)
assert opened.identity.subject_type == "user_id"
assert opened.identity.subject == "sso-user-42"
assert opened.upstream_authorization.get_secret_value() == "Bearer UPSTREAM-SECRET-TOKEN"
assert captured["client"].post.call_args.kwargs["data"]["code"] == "REAL-UPSTREAM-CODE"
@pytest.mark.asyncio
async def test_interactive_bridge_gateway_code_for_another_server_is_rejected_400():
"""A gateway authorization code is bound to the server it was minted for: presenting it at another
server's token endpoint is a 400, so a code cannot be replayed across a server boundary."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
seal_bridge_authorization_code,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
gateway_code = seal_bridge_authorization_code(
upstream_code="up-code", litellm_user_id="sso-user-42", mcp_server_id="a-different-server-id"
)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
with pytest.raises(HTTPException) as exc:
await _exchange_for_bridge_server(server, upstream, key_hash=None, code=gateway_code)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_interactive_bridge_authorize_seals_sso_user_into_state(monkeypatch: pytest.MonkeyPatch) -> None:
"""On the short-circuit bridge oauth_delegate arm, authorize captures the SSO user from the UI
session cookie and seals it (and the target server) into the encrypted OAuth state, so the
callback can later mint a user-bound gateway code; it still proceeds to the upstream redirect.
The access gate runs for real against a granted resolver, so its interface stays exercised."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None)
admitted = UserAPIKeyAuth(user_id="sso-user-42")
admitted.mcp_admitted_user_subject = True
captured: dict = {}
cookie: Final = _browser_session_cookie(monkeypatch, "sso-user-42")
def _capture(**kwargs):
captured.update(kwargs)
return "mocked_encrypted_state"
with (
patch(
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
new=AsyncMock(return_value=admitted),
),
patch.object(
global_mcp_server_manager,
"get_allowed_mcp_servers",
new=AsyncMock(return_value=[server.server_id]),
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encode_state_with_base_url",
side_effect=_capture,
),
):
response = await authorize_with_server(
request=_bridge_mock_request(cookie),
mcp_server=server,
client_id="ignored",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
code_challenge="chal",
code_challenge_method="S256",
)
assert captured["litellm_user_id"] == "sso-user-42"
assert captured["mcp_server_id"] == server.server_id
assert "/sso/key/generate" not in response.headers["location"]
@pytest.mark.asyncio
@pytest.mark.parametrize("user_can_reach_server", [True, False])
async def test_bridge_authorize_gates_on_the_egress_server_access_resolver(
monkeypatch: pytest.MonkeyPatch, user_can_reach_server: bool
) -> None:
"""The interactive dcr_bridge oauth_delegate authorize admits the signed-in user the way MCP
egress will and refuses with an RFC 6749 access_denied redirect when that admitted subject
cannot reach the target server, instead of minting an envelope whose every tool request would
fail-closed to an empty list (#36358). A user the resolver grants proceeds upstream unchanged."""
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="upstream-app", registration_url=None)
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
admitted = UserAPIKeyAuth(user_id="bridge-user-1")
admitted.mcp_admitted_user_subject = True
allowed = [server.server_id] if user_can_reach_server else []
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_request.cookies = {"token": _browser_session_cookie(monkeypatch, "bridge-user-1")}
client_redirect = "http://127.0.0.1:60108/callback"
try:
with (
patch(
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
new=AsyncMock(return_value=admitted),
) as mock_reload,
patch.object(
global_mcp_server_manager,
"get_allowed_mcp_servers",
new=AsyncMock(return_value=allowed),
) as mock_allowed,
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper",
return_value="mocked_encrypted_state",
),
):
response = await authorize(
request=mock_request,
client_id="dcr_client_id",
mcp_server_name="bridge_srv",
redirect_uri=client_redirect,
state="client-state-1",
code_challenge="a" * 43,
code_challenge_method="S256",
)
finally:
global_mcp_server_manager.registry.clear()
mock_reload.assert_awaited_once_with("bridge-user-1")
mock_allowed.assert_awaited_once_with(admitted)
location = response.headers["location"]
if user_can_reach_server:
assert response.status_code == 307
assert location.startswith("https://provider.com/oauth/authorize")
else:
assert response.status_code == 302
assert location.startswith(client_redirect)
query = parse_qs(urlparse(location).query)
assert query["error"] == ["access_denied"]
assert query["state"] == ["client-state-1"]
assert "bridge_srv" in query["error_description"][0]
assert "provider.com" not in location
assert "set-cookie" not in {k.lower() for k in response.headers}
@pytest.mark.asyncio
@pytest.mark.parametrize("reload_status,expect_denial", [(401, True), (500, False), (503, False)])
async def test_bridge_authorize_reload_failure_denies_or_stays_retryable(
monkeypatch: pytest.MonkeyPatch, reload_status: int, expect_denial: bool
) -> None:
"""An unknown or deactivated signed-in user denies like a missing grant (fail closed); a DB
outage keeps its retryable 503 instead of masquerading as an access denial."""
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import authorize
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="upstream-app", registration_url=None)
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_request.cookies = {"token": _browser_session_cookie(monkeypatch, "bridge-user-1")}
try:
with (
patch(
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
new=AsyncMock(side_effect=HTTPException(status_code=reload_status, detail="x")),
),
):
if expect_denial:
response = await authorize(
request=mock_request,
client_id="dcr_client_id",
mcp_server_name="bridge_srv",
redirect_uri="http://127.0.0.1:60108/callback",
state="client-state-1",
code_challenge="a" * 43,
code_challenge_method="S256",
)
assert response.status_code == 302
query = parse_qs(urlparse(response.headers["location"]).query)
assert query["error"] == ["access_denied"]
else:
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id="dcr_client_id",
mcp_server_name="bridge_srv",
redirect_uri="http://127.0.0.1:60108/callback",
state="client-state-1",
code_challenge="a" * 43,
code_challenge_method="S256",
)
assert exc_info.value.status_code == reload_status
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_interactive_bridge_authorize_without_session_redirects_to_login():
"""Without a UI session there is no identity to bind, so the short-circuit bridge oauth_delegate
authorize sends the browser through litellm login instead of proceeding to the upstream."""
from litellm.proxy.mcp_server.discoverable_endpoints import authorize_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, client_id="admin-client", registration_url=None)
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="ignored",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
code_challenge="chal",
code_challenge_method="S256",
)
assert "/sso/key/generate" in response.headers["location"]
@pytest.mark.asyncio
async def test_interactive_bridge_callback_seals_user_into_gateway_code():
"""When the OAuth state carries the captured SSO user, the callback forwards a gateway
authorization code (sealing the user and upstream code) to the client instead of the raw upstream
code, so the client's later token call can prove who signed in."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
is_bridge_authorization_code,
)
state_data = {
"original_state": "client-state",
"client_redirect_uri": "http://127.0.0.1:60108/cb",
"base_url": "http://127.0.0.1:60108/cb",
"litellm_user_id": "sso-user-42",
"mcp_server_id": "bridge_srv",
}
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
return_value="enc",
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
return_value=state_data,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
return_value="http://127.0.0.1:60108/cb",
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
assert is_bridge_authorization_code(forwarded_code)
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_fails_closed_without_litellm_identity():
"""Without a resolvable litellm identity on the token request, the exchange must not mint an
identity-less envelope. It returns an RFC 6749 §5.2-shaped invalid_request (error at the top
level, not wrapped in detail) BEFORE exchanging the upstream code, so the single-use code is not
burned and the client can retry."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
captured: dict = {}
response = await _exchange_for_bridge_server(server, upstream, key_hash=None, fake_client_out=captured)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
# identity resolution failed first, so the upstream single-use code was never exchanged (not burned)
captured["client"].post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_envelope_too_large_upstream_token_is_502():
"""An upstream token too large to seal into the envelope is an upstream-payload condition, so the
mint surfaces a 502 (as an RFC 6749 §5.2 error body, not a raised HTTPException) rather than a 500:
build_bridge_token_response returns EnvelopeTooLarge as a value, _finish_bridge_mint returns the
"too_large" failure, and _bridge_mint_error_response maps it to a truthful status."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "x" * 40000, "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_bridge_envelope_unrepresentable_upstream_lifetime_is_502():
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {
"access_token": "UPSTREAM-SECRET-TOKEN",
"token_type": "Bearer",
"expires_in": 10**30,
}
response = await _exchange_for_bridge_server(
server,
upstream,
key_hash="hashed-litellm-key-77",
)
assert response.status_code == 502
assert json.loads(response.body) == {
"error": "server_error",
"error_description": "the upstream token response reports an unrepresentable lifetime",
}
@pytest.mark.asyncio
async def test_bridge_access_envelope_never_carries_upstream_refresh_token():
"""The upstream refresh token is never sealed into the ACCESS envelope, the bearer forwarded upstream
on every tool call: the opened access grant carries no refresh token even when the upstream returned
one, and the raw refresh token never appears in the access envelope. It rides only in the separate
refresh envelope returned as the response's refresh_token, encrypted, never in plaintext."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
OpenedEnvelope,
open_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {
"access_token": "UP",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "UPSTREAM-REFRESH",
}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert "UPSTREAM-REFRESH" not in body["access_token"]
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = open_envelope(body["access_token"], keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedEnvelope)
assert opened.grant.refresh_token is None
# the refresh token rides only in the separate, encrypted refresh envelope, never in plaintext
assert body["refresh_token"].startswith("llm_refresh_")
assert "UPSTREAM-REFRESH" not in body["refresh_token"]
@pytest.mark.asyncio
async def test_bridge_refresh_grant_with_non_envelope_is_invalid_grant_before_upstream():
"""A bridge oauth_delegate client only ever holds a refresh envelope, never a raw upstream refresh
token, so a refresh_token grant carrying a bare (non-envelope) value is invalid_grant, rejected in
_prepare_bridge_refresh BEFORE any upstream exchange. Rejecting before the exchange means a bad
refresh request can never consume or rotate an upstream refresh token."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier=None,
refresh_token="client-refresh-token",
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_grant"
fake_http_client.post.assert_not_called()
def _mint_test_refresh_envelope(
server_id="bridge_srv",
key_hash="hashed-litellm-key-77",
upstream_refresh="UPSTREAM-REFRESH",
identity=None,
scope=None,
):
"""Mint a refresh envelope the way the producer does, for driving the refresh_token grant in tests.
Defaults to a key_hash subject; pass ``identity`` to seal a specific subject (e.g. a user_id), and
``scope`` to seal the scope to re-request on refresh."""
from datetime import datetime, timezone
from pydantic import SecretStr
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
build_bridge_refresh_token_response,
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
RefreshCredential,
SealedEnvelope,
key_hash_identity,
)
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
identity = identity if identity is not None else key_hash_identity(server_id=server_id, key_hash=key_hash)
sealed = build_bridge_refresh_token_response(
identity,
RefreshCredential(refresh_token=SecretStr(upstream_refresh), scope=scope),
keys,
datetime.now(timezone.utc),
)
assert isinstance(sealed, SealedEnvelope)
return sealed.token.get_secret_value()
async def _refresh_for_bridge_server(
server, refresh_envelope_value, upstream_body, revalidate_result=None, fake_client_out=None
):
"""Drive a refresh_token grant for a bridge server: the client presents ``refresh_envelope_value``,
the sealed subject re-validates to ``revalidate_result`` (``None`` when the key or user is still
active, or a failure literal like "no_active_key" when revoked/deactivated), and the upstream returns
``upstream_body``. Patching the single subject-revalidation dispatch covers both a key_hash and a
user_id refresh envelope. Returns the response; the captured client exposes the POST call so a test
can assert what refresh token was actually sent upstream."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
fake_http_response = MagicMock()
fake_http_response.json.return_value = upstream_body
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
if fake_client_out is not None:
fake_client_out["client"] = fake_http_client
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
new=AsyncMock(return_value=revalidate_result),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
return await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier=None,
refresh_token=refresh_envelope_value,
)
@pytest.mark.asyncio
async def test_bridge_mint_returns_refresh_envelope_that_opens_to_upstream_refresh():
"""When the upstream returns a refresh token, the authorization_code mint returns a refresh envelope
alongside the access envelope. The refresh envelope is a distinct llm_refresh_ credential that opens
(under the same keys and server_id) to the upstream refresh token, so the client can renew later."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
OpenedRefreshEnvelope,
open_refresh_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "R-UP"}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
refresh_env = body["refresh_token"]
assert refresh_env.startswith("llm_refresh_")
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = open_refresh_envelope(refresh_env, keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedRefreshEnvelope)
assert opened.identity.server_id == server.server_id
assert opened.refresh.refresh_token.get_secret_value() == "R-UP"
@pytest.mark.asyncio
async def test_bridge_mint_omits_refresh_envelope_when_upstream_has_no_refresh():
"""No refresh envelope is issued when the upstream returns no refresh token, so the response carries
only the access envelope; the client re-authenticates at access expiry (nothing to renew with)."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert "refresh_token" not in body
@pytest.mark.asyncio
async def test_bridge_refresh_grant_sends_unwrapped_upstream_token_and_renews():
"""A refresh_token grant carrying a valid refresh envelope renews: the exchange unwraps the envelope
and sends the REAL upstream refresh token upstream (never the envelope), then returns a fresh access
envelope. This is the flow that lets the client renew without re-authenticating."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UPSTREAM-REFRESH")
upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600}
captured: dict = {}
response = await _refresh_for_bridge_server(server, refresh_env, upstream, None, fake_client_out=captured)
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
# the upstream exchange received the unwrapped upstream refresh token, never the client's envelope
sent = captured["client"].post.call_args.kwargs["data"]
assert sent["grant_type"] == "refresh_token"
assert sent["refresh_token"] == "UPSTREAM-REFRESH"
assert not sent["refresh_token"].startswith("llm_refresh_")
@pytest.mark.asyncio
async def test_bridge_refresh_grant_rotates_refresh_envelope_wrapping_new_upstream_token():
"""When the upstream rotates the refresh token on renewal, the client receives a new refresh envelope
that wraps the NEW upstream refresh token, so the rotation is carried through faithfully."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
OpenedRefreshEnvelope,
open_refresh_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="OLD-UP-REFRESH")
upstream = {
"access_token": "NEW-ACCESS",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "NEW-UP-REFRESH",
}
response = await _refresh_for_bridge_server(server, refresh_env, upstream, None)
body = json.loads(response.body)
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = open_refresh_envelope(body["refresh_token"], keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedRefreshEnvelope)
assert opened.refresh.refresh_token.get_secret_value() == "NEW-UP-REFRESH"
@pytest.mark.asyncio
async def test_bridge_refresh_grant_with_revoked_key_is_invalid_grant_before_upstream():
"""A valid refresh envelope whose sealed litellm key has since been revoked cannot keep refreshing:
the reload gate reports no_active_key and the refresh is invalid_grant, returned BEFORE the upstream
exchange so the upstream refresh token is never consumed. Revocation kills renewal."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id)
captured: dict = {}
response = await _refresh_for_bridge_server(
server, refresh_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_grant"
captured["client"].post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_refresh_envelope_for_another_server_is_invalid_grant():
"""A refresh envelope minted for one server cannot renew against another: the sealed server_id must
match the server the refresh targets, so a cross-server refresh envelope is invalid_grant and never
reaches the upstream exchange."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
foreign_env = _mint_test_refresh_envelope(server_id="some-other-server")
captured: dict = {}
response = await _refresh_for_bridge_server(
server, foreign_env, {"access_token": "NEW"}, None, fake_client_out=captured
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_grant"
captured["client"].post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_refresh_grant_renews_a_user_subject_envelope():
"""The interactive SSO client mints a user_id-subject envelope, so its refresh envelope carries a
user subject too. Renewing it re-validates the user (still active here), unwraps the upstream refresh
token, and returns a fresh access envelope that opens back to the same user_id subject; the upstream
exchange received the real upstream refresh token, not the client's envelope."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
envelope_keys_from_master_key,
resolve_bridge_envelope,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import user_identity
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
user_env = _mint_test_refresh_envelope(
identity=user_identity(server_id=server.server_id, user_id="sso-user-42"), upstream_refresh="UP-REFRESH-USER"
)
upstream = {"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600}
captured: dict = {}
response = await _refresh_for_bridge_server(server, user_env, upstream, None, fake_client_out=captured)
assert response.status_code == 200
body = json.loads(response.body)
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = resolve_bridge_envelope(body["access_token"], keys, datetime.now(timezone.utc), server.server_id)
assert isinstance(opened, BridgeEnvelopeAdmitted)
assert opened.identity.subject_type == "user_id"
assert opened.identity.subject == "sso-user-42"
assert captured["client"].post.call_args.kwargs["data"]["refresh_token"] == "UP-REFRESH-USER"
@pytest.mark.asyncio
async def test_bridge_refresh_re_requests_the_sealed_scope_when_client_omits_it():
"""A DCR/MCP client omits scope on the refresh request, so the gateway must re-request the scope sealed
at mint; dropping it lets a stricter upstream narrow the renewed token. The upstream POST must carry
the sealed scope even though the client sent none. Regression for the dropped sealed refresh scope."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(
server_id=server.server_id, upstream_refresh="UP-REFRESH", scope="read:tools write:tools"
)
captured: dict = {}
response = await _refresh_for_bridge_server(
server,
refresh_env,
{"access_token": "NEW-ACCESS", "token_type": "Bearer", "expires_in": 3600},
None,
fake_client_out=captured,
)
assert response.status_code == 200
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "read:tools write:tools"
@pytest.mark.asyncio
async def test_bridge_refresh_re_seals_scope_when_upstream_omits_it_so_the_chain_keeps_it():
"""RFC 6749 5.1 lets an upstream omit scope in a refresh response when it is unchanged. The re-minted
refresh envelope must still seal the scope that was requested, otherwise the NEXT refresh loses it and
a stricter upstream could narrow the token. The returned refresh envelope carries the scope even though
the upstream response had none, and a second refresh off it still re-requests the scope."""
from datetime import datetime, timezone
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import (
OpenedRefreshEnvelope,
open_refresh_envelope,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(
server_id=server.server_id, upstream_refresh="UP-1", scope="mcp:read mcp:write"
)
# the upstream rotates the refresh token but OMITS scope (valid when unchanged)
upstream_no_scope = {"access_token": "NEW", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "UP-2"}
captured: dict = {}
r1 = await _refresh_for_bridge_server(server, refresh_env, upstream_no_scope, None, fake_client_out=captured)
assert r1.status_code == 200
assert captured["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
new_env = json.loads(r1.body)["refresh_token"]
opened = open_refresh_envelope(new_env, keys, datetime.now(timezone.utc))
assert isinstance(opened, OpenedRefreshEnvelope)
assert opened.refresh.scope == "mcp:read mcp:write"
captured2: dict = {}
r2 = await _refresh_for_bridge_server(server, new_env, upstream_no_scope, None, fake_client_out=captured2)
assert r2.status_code == 200
assert captured2["client"].post.call_args.kwargs["data"]["scope"] == "mcp:read mcp:write"
@pytest.mark.asyncio
async def test_bridge_refresh_grant_with_deactivated_user_is_invalid_grant_before_upstream():
"""A user_id-subject refresh envelope whose user has since been deactivated (SCIM offboarding, or
the user no longer exists) cannot keep refreshing: subject re-validation reports no_active_key and
the refresh is invalid_grant, returned BEFORE the upstream exchange. Revocation kills renewal for the
user subject exactly as it does for the key subject."""
from litellm.proxy.mcp_server.outbound_credentials.envelope import user_identity
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
user_env = _mint_test_refresh_envelope(identity=user_identity(server_id=server.server_id, user_id="gone-user"))
captured: dict = {}
response = await _refresh_for_bridge_server(
server, user_env, {"access_token": "NEW"}, "no_active_key", fake_client_out=captured
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_grant"
captured["client"].post.assert_not_called()
@pytest.mark.asyncio
async def test_revalidate_active_subject_dispatches_on_subject_type():
"""Subject re-validation routes a key_hash envelope to the key reload and a user_id envelope to the
user reload, so revocation gates renewal for either identity source through one dispatch point."""
from litellm.proxy.mcp_server.bridge_token_flow import (
_ResolvedKey,
_revalidate_active_subject,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity, user_identity
with (
patch(
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
new=AsyncMock(return_value=_ResolvedKey(key_hash="kh", key=MagicMock())),
) as key_reload,
patch(
"litellm.proxy.mcp_server.bridge_token_flow.reload_active_user_by_id",
new=AsyncMock(return_value=None),
) as user_reload,
):
assert await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh")) is None
key_reload.assert_awaited_once_with("kh")
user_reload.assert_not_awaited()
with (
patch(
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
new=AsyncMock(),
) as key_reload2,
patch(
"litellm.proxy.mcp_server.bridge_token_flow.reload_active_user_by_id",
new=AsyncMock(return_value="no_active_key"),
) as user_reload2,
):
assert await _revalidate_active_subject(user_identity(server_id="s", user_id="u42")) == "no_active_key"
user_reload2.assert_awaited_once_with("u42")
key_reload2.assert_not_awaited()
def test_upstream_refresh_credential_expired_refresh_token_is_not_sealed():
"""An upstream that reports its refresh token already elapsed (refresh_expires_in non-positive) must
not be sealed: _upstream_refresh_credential returns None so the exchange degrades to an access-only
response, mirroring how the access grant refuses an already-elapsed access token rather than capping a
dead token to the full refresh TTL. A live or unspecified lifetime still yields a credential."""
from litellm.proxy.mcp_server.bridge_token_flow import _upstream_refresh_credential
assert _upstream_refresh_credential({"access_token": "A", "refresh_token": "R", "refresh_expires_in": 0}) is None
assert _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": -5}) is None
live = _upstream_refresh_credential({"refresh_token": "R", "refresh_expires_in": 1800})
assert live is not None and live.expires_in == 1800
unspecified = _upstream_refresh_credential({"refresh_token": "R"})
assert unspecified is not None and unspecified.expires_in is None
@pytest.mark.asyncio
async def test_bridge_refresh_upstream_invalid_grant_maps_to_invalid_grant():
"""When the sealed upstream refresh token has been revoked or expired at the IdP, the upstream returns
400 invalid_grant. The bridge refresh path maps that to an RFC 6749 invalid_grant response so the OAuth
client re-runs authorization_code, rather than surfacing the opaque upstream error it cannot act on."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="LIVE-ENVELOPE-REFRESH")
error_response = MagicMock()
error_response.status_code = 400
error_response.text = '{"error": "invalid_grant", "error_description": "refresh token expired"}'
error_response.json = MagicMock(
return_value={"error": "invalid_grant", "error_description": "refresh token expired"}
)
error_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=error_response)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
new=AsyncMock(return_value=None),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier=None,
refresh_token=refresh_env,
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_grant"
@pytest.mark.asyncio
async def test_bridge_refresh_upstream_error_detection_parses_json_not_substring():
"""The upstream invalid_grant detection reads the classified RFC 6749 5.2 error code, not a substring
of the body. An upstream error whose code is not invalid_grant (here invalid_client, with the string
invalid_grant only inside error_description) must NOT be mistaken for a dead refresh token: it renders
as the classified upstream rejection rather than triggering a spurious authorization_code re-run."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
refresh_env = _mint_test_refresh_envelope(server_id=server.server_id, upstream_refresh="UP")
error_response = MagicMock()
error_response.status_code = 400
error_response.text = '{"error": "invalid_client", "error_description": "this is not an invalid_grant problem"}'
error_response.json = MagicMock(
return_value={"error": "invalid_client", "error_description": "this is not an invalid_grant problem"}
)
error_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("bad", request=MagicMock(), response=error_response)
)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=error_response)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._revalidate_active_subject",
new=AsyncMock(return_value=None),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="dcr-client-123",
client_secret=None,
code_verifier=None,
refresh_token=refresh_env,
)
assert response.status_code == 401
body = json.loads(response.body)
assert body["error"] == "invalid_client"
@pytest.mark.asyncio
async def test_revalidate_key_subject_revoked_when_owner_scim_deactivated(proxy_globals):
"""A key_hash refresh envelope whose key is still active but whose OWNING user was SCIM-deactivated must
fail closed to no_active_key, mirroring how admission's _reject_if_admitted_owner_scim_deactivated
revokes an offboarded owner's key. Without this, an offboarded user keeps renewing a live key."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="offboarded-owner"))
with (
patch(
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
new=AsyncMock(return_value=resolved),
),
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(return_value=MagicMock(metadata={"scim_active": False})),
),
):
result = await _revalidate_active_subject(key_hash_identity(server_id="s", key_hash="kh"))
assert result == "no_active_key"
@pytest.mark.asyncio
async def test_revalidate_key_subject_active_owner_renews_and_missing_owner_fails_open(proxy_globals):
"""The key-owner SCIM gate blocks only an explicit scim_active False: an active owner renews (None), and
a missing owner (get_user_object's wrapped ValueError) fails OPEN, since a key may outlive its owner
record and a transient blip must not revoke a live key."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import _ResolvedKey, _revalidate_active_subject
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
resolved = _ResolvedKey(key_hash="kh", key=MagicMock(user_id="live-owner"))
identity = key_hash_identity(server_id="s", key_hash="kh")
with (
patch(
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
new=AsyncMock(return_value=resolved),
),
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(return_value=MagicMock(metadata={"scim_active": True})),
),
):
assert await _revalidate_active_subject(identity) is None
with (
patch(
"litellm.proxy.mcp_server.bridge_token_flow._reload_active_key_by_hash",
new=AsyncMock(return_value=resolved),
),
patch(
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())),
),
):
assert await _revalidate_active_subject(identity) is None
@pytest.mark.asyncio
async def test_bridge_mint_fails_closed_before_upstream_when_master_key_unset():
"""master_key is validated BEFORE the upstream exchange (in _prepare_bridge_mint), so a
misconfigured gateway returns a 500 server_error without consuming the single-use code, avoiding
the burn-then-fail the pre-exchange phase exists to prevent. The failure is returned as an RFC 6749
error body, not raised."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
new=AsyncMock(return_value="no_active_key"),
),
patch("litellm.proxy.proxy_server.master_key", None),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
assert response.status_code == 500
assert json.loads(response.body)["error"] == "server_error"
fake_http_client.post.assert_not_called()
async def _prepare_only_bridge_exchange(resolver_result):
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request with the
identity resolver stubbed to a given tagged result, returning (response, post_mock) so a test can
assert the mapped status and that the single-use code was never exchanged."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock()
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._resolve_active_litellm_key",
new=AsyncMock(return_value=resolver_result),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
return response, fake_http_client.post
@pytest.mark.asyncio
async def test_bridge_mint_db_outage_is_503_before_upstream():
"""A DB outage while resolving identity is a retryable gateway failure, so the mint returns 503
temporarily_unavailable WITHOUT consuming the single-use code, matching how admission statuses the
same outage on the egress side. Collapsing every resolution failure to None used to blame the
client with 400 invalid_request for an infrastructure problem."""
response, post = await _prepare_only_bridge_exchange("unavailable")
assert response.status_code == 503
assert json.loads(response.body)["error"] == "temporarily_unavailable"
assert "retry shortly" in json.loads(response.body)["error_description"]
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_permanent_db_fault_is_503_without_retry_advice():
"""A query engine fault that never heals is still a 503 (the gateway is at fault, not the client), but
the description must not tell the client the database is temporarily unreachable and to retry: that
sends an operator to wait out an outage that is not one. The code stays temporarily_unavailable, the
only RFC 6749 error a client treats as a server-side 503."""
response, post = await _prepare_only_bridge_exchange("faulted")
assert response.status_code == 503
body = json.loads(response.body)
assert body["error"] == "temporarily_unavailable"
assert "temporarily unreachable" not in body["error_description"]
assert "retry shortly" not in body["error_description"]
assert "not a transient outage" in body["error_description"]
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_unresolvable_identity_is_500_before_upstream():
"""An unresolvable identity (no DB connection, or an unexpected resolution error) is a gateway
fault, so the mint returns 500 server_error before the exchange, a status distinct from both the
caller's 400 and the transient 503, matching admission's 500-vs-503 split for the same conditions."""
response, post = await _prepare_only_bridge_exchange("unresolvable")
assert response.status_code == 500
assert json.loads(response.body)["error"] == "server_error"
post.assert_not_called()
async def _exchange_for_bridge_server_with_jwt(jwt_auth_result, upstream_body=None):
"""Drive exchange_token_with_server for a bridge oauth_delegate authorization_code request whose
presented credential is JWT-shaped, with _resolve_jwt_auth stubbed to a given result. Returns
(response, post_mock) so a test can assert the minted envelope's sealed identity or the mapped
error status."""
from litellm.proxy.mcp_server.discoverable_endpoints import exchange_token_with_server
from litellm.types.mcp import MCPAuth
request = _bridge_mock_request()
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
fake_http_response = MagicMock()
fake_http_response.json.return_value = upstream_body or {
"access_token": "UP",
"token_type": "Bearer",
"expires_in": 3600,
}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.bridge_token_flow._resolve_jwt_auth",
new=AsyncMock(return_value=jwt_auth_result),
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await exchange_token_with_server(
request=request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://claude.ai/api/mcp/auth_callback",
client_id="dcr-client-123",
client_secret=None,
code_verifier="verifier",
)
return response, fake_http_client.post
@pytest.mark.asyncio
async def test_bridge_mint_unmapped_jwt_is_rejected_before_upstream():
from litellm.proxy.auth.handle_jwt import JWTIdentity
response, post = await _exchange_for_bridge_server_with_jwt(
JWTIdentity(user_id="jwt-user-5", user_object=None, agent_id=None)
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
post.assert_not_called()
@pytest.mark.asyncio
async def test_bridge_mint_jwt_mapped_to_virtual_key_seals_key_hash_subject():
from datetime import datetime, timezone
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
envelope_keys_from_master_key,
resolve_bridge_envelope,
)
response, _post = await _exchange_for_bridge_server_with_jwt(
UserAPIKeyAuth(token="mapped-key-hash-99", user_id="mapped-user")
)
assert response.status_code == 200
token = json.loads(response.body)["access_token"]
keys = envelope_keys_from_master_key(_BRIDGE_MASTER_KEY)
opened = resolve_bridge_envelope(token, keys, datetime.now(timezone.utc), "bridge_srv")
assert isinstance(opened, BridgeEnvelopeAdmitted)
assert opened.identity.subject_type == "key_hash"
assert opened.identity.subject == "mapped-key-hash-99"
@pytest.mark.asyncio
@pytest.mark.parametrize("jwt_claims", [{"client_id": "allowed"}, {"client_id": "denied"}, {}])
async def test_bridge_mint_jwt_cannot_drop_signed_client_policy(jwt_claims):
from litellm.proxy._types import UserAPIKeyAuth
settings = {
"mcp_allowed_clients": [{"alias": "Allowed", "value": "allowed"}],
"mcp_client_id_header": "x-client-id",
"litellm_jwtauth": {"mcp_client_id_jwt_field": "client_id"},
}
with patch("litellm.proxy.proxy_server.general_settings", settings):
response, post = await _exchange_for_bridge_server_with_jwt(
UserAPIKeyAuth(token="mapped-key-hash-99", jwt_claims=jwt_claims)
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
assert "signed client identity" in json.loads(response.body)["error_description"]
post.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"settings",
[
{},
{"litellm_jwtauth": {"mcp_client_id_jwt_field": "client_id"}},
{
"mcp_allowed_clients": [{"alias": "Allowed", "value": "allowed"}],
"mcp_client_id_header": "x-client-id",
},
],
)
async def test_bridge_mint_mapped_jwt_without_signed_client_policy(settings):
from litellm.proxy._types import UserAPIKeyAuth
with patch("litellm.proxy.proxy_server.general_settings", settings):
response, post = await _exchange_for_bridge_server_with_jwt(
UserAPIKeyAuth(token="mapped-key-hash-99", jwt_claims={"client_id": "allowed"})
)
assert response.status_code == 200
assert json.loads(response.body)["access_token"].startswith("llm_env_")
post.assert_awaited_once()
@pytest.mark.asyncio
async def test_bridge_mint_jwt_with_no_resolved_identity_is_400_before_upstream():
"""A JWT that resolves to nothing (or to an identity with no user_id) cannot back an envelope:
the mint returns 400 invalid_request WITHOUT consuming the single-use code upstream, matching
the no-credential path rather than hashing the raw JWT string."""
response, post = await _exchange_for_bridge_server_with_jwt(None)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
post.assert_not_called()
from litellm.proxy.auth.handle_jwt import JWTIdentity
response, post = await _exchange_for_bridge_server_with_jwt(
JWTIdentity(user_id=None, user_object=None, agent_id=None)
)
assert response.status_code == 400
assert json.loads(response.body)["error"] == "invalid_request"
post.assert_not_called()
def _jwt_auth_patches(mapped_key):
from contextlib import ExitStack
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
cache = UserApiKeyCache()
cache.set_cache(key=jwt_key_mapping_cache_key("sub", "mapped-client"), value=mapped_key.token)
cache.set_cache(key=mapped_key.token, value=mapped_key)
handler = MagicMock()
handler.litellm_jwtauth = LiteLLM_JWTAuth(virtual_key_claim_field="sub")
handler.auth_jwt = AsyncMock(return_value={"sub": "mapped-client"})
stack = ExitStack()
stack.enter_context(patch("litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": True}))
stack.enter_context(patch("litellm.proxy.proxy_server.premium_user", True))
stack.enter_context(patch("litellm.proxy.proxy_server.prisma_client", object()))
stack.enter_context(patch("litellm.proxy.proxy_server.jwt_handler", handler))
stack.enter_context(patch("litellm.proxy.proxy_server.user_api_key_cache", cache))
stack.enter_context(
patch(
"litellm.proxy.mcp_server.bridge_token_flow._key_owner_scim_deactivated",
new=AsyncMock(return_value=False),
)
)
return stack
@pytest.mark.asyncio
async def test_jwt_mapped_to_service_account_key_without_user_id_resolves():
"""A JWT mapped to a team or service-account virtual key (no user_id) is still an active
credential: _resolve_jwt_auth returns the mapped key, and the mint seals a key_hash-subject
envelope rather than 400ing with no_identity."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.mcp_server import bridge_token_flow
from litellm.types.mcp import MCPAuth
mapped_key = UserAPIKeyAuth(user_id=None, token="svc-key-hash-1")
request = _bridge_mock_request()
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
with (
_jwt_auth_patches(mapped_key),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
resolved = await bridge_token_flow._resolve_jwt_auth(request, "aaa.bbb.ccc", None)
assert isinstance(resolved, UserAPIKeyAuth)
assert resolved.token == mapped_key.token
assert resolved.api_key is None
assert resolved.user_id is None
mint = await bridge_token_flow._prepare_bridge_mint(
request=request,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
)
assert isinstance(mint, bridge_token_flow._BridgeMintReady)
assert mint.identity.subject_type == "key_hash"
assert mint.identity.subject == "svc-key-hash-1"
@pytest.mark.asyncio
async def test_jwt_mapped_to_blocked_key_is_rejected():
"""The relaxed gate is still active-state gated: a JWT mapped to a blocked virtual key resolves
to None, so the mint cannot seal an envelope under it."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.mcp_server import bridge_token_flow
mapped_key = UserAPIKeyAuth(user_id=None, token="blocked-key-hash-1", blocked=True)
request = _bridge_mock_request()
request.headers = {"x-litellm-api-key": "aaa.bbb.ccc"}
with (
_jwt_auth_patches(mapped_key),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
resolved = await bridge_token_flow._resolve_jwt_auth(request, "aaa.bbb.ccc", None)
assert resolved is None
mint = await bridge_token_flow._prepare_bridge_mint(
request=request,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
)
assert mint == "no_identity"
@pytest.mark.asyncio
async def test_master_key_at_token_endpoint_mints_key_hash_envelope():
"""The master key has no row in LiteLLM_VerificationTokenTable, but it is the proxy's root
credential: presented at the bridge /token endpoint it must mint a key_hash-subject envelope
(sealed under hash_token(master_key)) even with no database connection at all. A presented key
that is NOT the master key still hits the unresolvable gate when prisma is down, unchanged."""
from litellm.proxy._types import hash_token
from litellm.proxy.mcp_server import bridge_token_flow
from litellm.types.mcp import MCPAuth
master = "sk-test-master-key-mint-0000"
request = _bridge_mock_request()
request.headers = {"x-litellm-api-key": master}
with (
patch("litellm.proxy.proxy_server.master_key", master),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
mint = await bridge_token_flow._prepare_bridge_mint(
request=request,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
)
assert isinstance(mint, bridge_token_flow._BridgeMintReady)
assert mint.identity.subject_type == "key_hash"
assert mint.identity.subject == hash_token(master)
other = _bridge_mock_request()
other.headers = {"x-litellm-api-key": "sk-not-the-master-key"}
with (
patch("litellm.proxy.proxy_server.master_key", master),
patch("litellm.proxy.proxy_server.prisma_client", None),
):
mint = await bridge_token_flow._prepare_bridge_mint(
request=other,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
)
assert mint == "identity_unresolvable"
@pytest.mark.asyncio
async def test_bridge_mint_upstream_expired_lifetime_is_502():
"""An upstream token response reporting an already-elapsed lifetime (a parseable non-positive
expires_in) is rejected with 502 rather than sealed into an hour-long envelope around a dead
bearer. Regression for expires_in<=0 silently falling through to the 1h cap."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502():
"""A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a
(1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use
code was already consumed. Regression for classifying a sub-second remaining lifetime as expired."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert body["expires_in"] >= 0
@pytest.mark.asyncio
async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected():
"""An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never
inventing a longer life than the upstream stated); it is NOT rejected. Only an explicitly-dead
lifetime fails, so a metadata glitch on an otherwise-valid token still mints a bounded envelope."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": "not-a-number"}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 200
body = json.loads(response.body)
assert body["access_token"].startswith("llm_env_")
assert 0 < body["expires_in"] <= 3600
@pytest.mark.asyncio
async def test_bridge_reported_expires_in_does_not_overstate_jwt_exp():
"""The reported expires_in is derived from the envelope JWT's second-truncated exp (rounding the
elapsed portion up), so the client is never told the bearer lives past the point admission expires
it. Regression for the sub-second overstatement of the raw (expires_at - now) delta."""
import time
import jwt as _jwt
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 300}
before = int(time.time())
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
claims = _jwt.decode(body["access_token"].removeprefix("llm_env_"), options={"verify_signature": False})
# projecting the reported lifetime from a time no later than the mint must not exceed the JWT exp
assert before + body["expires_in"] <= claims["exp"]
def test_bridge_reported_expires_in_can_be_zero_at_jwt_exp_boundary():
from datetime import datetime, timezone
from fastapi.responses import JSONResponse
from litellm.proxy.mcp_server.discoverable_endpoints import (
_BridgeMintReady,
finish_bridge_mint,
)
from litellm.proxy.mcp_server.outbound_credentials.bridge_credentials import (
envelope_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.envelope import key_hash_identity
from litellm.types.mcp import MCPAuth
ready = _BridgeMintReady(
identity=key_hash_identity(server_id="bridge_srv", key_hash="hashed-litellm-key-77"),
keys=envelope_keys_from_master_key(_BRIDGE_MASTER_KEY),
)
response = finish_bridge_mint(
ready=ready,
mcp_server=_bridge_server(auth_type=MCPAuth.oauth_delegate),
token_response={"access_token": "UP", "expires_in": 1},
now=datetime.fromtimestamp(100.25, tz=timezone.utc),
)
assert isinstance(response, JSONResponse)
assert json.loads(response.body)["expires_in"] == 0
def test_classify_upstream_lifetime():
"""expires_in from an IdP may be an int, a float (3600.0), or a numeric string ("3600"); each
coerces to a positive number of seconds. Absent or unparseable input (bool, non-numeric, NaN/inf,
oversized) is "unspecified" so the envelope caps it, while a parseable non-positive value is
"expired": the upstream reporting an already-dead token, which the mint must reject rather than
silently give the 1h cap."""
from litellm.proxy.mcp_server.bridge_token_flow import _classify_upstream_lifetime
assert _classify_upstream_lifetime(300) == 300
assert _classify_upstream_lifetime(300.0) == 300
assert _classify_upstream_lifetime("300") == 300
assert _classify_upstream_lifetime(" 300 ") == 300
# explicit, parseable, non-positive -> the upstream says the token is already dead
assert _classify_upstream_lifetime(0) == "expired"
assert _classify_upstream_lifetime(-5) == "expired"
assert _classify_upstream_lifetime(-0.5) == "expired"
# a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor
# rather than truncating to 0 and being misread as expired
assert _classify_upstream_lifetime(0.5) == 1
assert _classify_upstream_lifetime(0.001) == 1
# a positive value >= 1 truncates toward zero (never overstating the stated lifetime)
assert _classify_upstream_lifetime(1.9) == 1
# unknown lifetime -> cap (never invent a longer life than the upstream stated)
assert _classify_upstream_lifetime(None) == "unspecified"
assert _classify_upstream_lifetime(True) == "unspecified"
assert _classify_upstream_lifetime("nope") == "unspecified"
# hostile numerics must not raise (int(float(...)) can OverflowError) -> unspecified
assert _classify_upstream_lifetime("inf") == "unspecified"
assert _classify_upstream_lifetime("1e999") == "unspecified"
assert _classify_upstream_lifetime("-inf") == "unspecified"
assert _classify_upstream_lifetime("nan") == "unspecified"
assert _classify_upstream_lifetime(float("inf")) == "unspecified"
assert _classify_upstream_lifetime(10**400) == "unspecified"
def test_bridge_grant_honors_and_rejects_upstream_lifetime():
"""The grant validator honors a positive lifetime, leaves an unknown one None for the envelope to
cap, and rejects an explicitly-expired one with "expired_lifetime" so a dead upstream token is
never sealed into an hour-long envelope."""
from litellm.proxy.mcp_server.bridge_token_flow import _bridge_grant_from_token_response
def grant(v):
return _bridge_grant_from_token_response({"access_token": "x", "expires_in": v})
assert grant(300).expires_in == 300
assert grant(120.0).expires_in == 120
# unknown lifetime backs a grant whose expires_in the envelope caps; it is not a rejection
assert grant("nope").expires_in is None
assert _bridge_grant_from_token_response({"access_token": "x"}).expires_in is None
# an explicitly already-dead lifetime is rejected, not silently capped at 1h
assert grant(0) == "expired_lifetime"
assert grant(-5) == "expired_lifetime"
@pytest.mark.asyncio
async def test_bridge_token_exchange_honors_short_float_expires_in_ttl():
"""A short float expires_in from the upstream caps the envelope TTL, so the client-held envelope
does not outlive the upstream token. Before coercion a float was dropped and the envelope
defaulted to the 1h cap (3600), which would forward a stale bearer after the upstream token
expired."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 120.0}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert json.loads(response.body)["expires_in"] <= 120
@pytest.mark.asyncio
async def test_oauth_delegate_bridge_token_exchange_missing_access_token_is_502_not_keyerror():
"""When the upstream token response has no access_token, a dcr_bridge oauth_delegate exchange
returns a clean 502 error body rather than raising a KeyError. _finish_bridge_mint asks
_bridge_grant_from_token_response for a typed grant, gets None, and returns the "no_upstream_token"
failure, which maps to 502; nothing indexes token_response["access_token"] on the bridge path."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate)
upstream = {"token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
assert response.status_code == 502
assert json.loads(response.body)["error"] == "server_error"
@pytest.mark.asyncio
async def test_true_passthrough_bridge_token_exchange_returns_raw_upstream_token():
"""Only oauth_delegate mints. A true_passthrough dcr_bridge server relays the raw upstream token
to the client, since that mode has no litellm identity to bind and the caller owns the token."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
assert not body["access_token"].startswith("llm_env_")
@pytest.mark.asyncio
async def test_non_bridge_oauth_delegate_token_exchange_returns_raw_upstream_token():
"""An oauth_delegate server without dcr_bridge keeps the pre-change contract: the raw upstream
token is returned, so flag-off behavior is byte-identical."""
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.oauth_delegate, dcr_bridge=None)
upstream = {"access_token": "UPSTREAM-SECRET-TOKEN", "token_type": "Bearer", "expires_in": 3600}
response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77")
body = json.loads(response.body)
assert body["access_token"] == "UPSTREAM-SECRET-TOKEN"
async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool:
"""Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted
to persist the exchanged token server-side. The client-forwarded token modes must not persist:
their contract is that the upstream token stays browser-held, minted/stored/refreshed nowhere."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="t",
name="t",
server_name="t",
alias="t",
transport=MCPTransport.http,
auth_type=auth_type,
client_id="cid",
client_secret="cs",
authorization_url="https://provider.com/oauth/authorize",
token_url="https://provider.com/oauth/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "refresh_token": "r", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=fake_http_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
new_callable=AsyncMock,
return_value="admin-user",
),
patch( # test-quality-ok: this control tests persistence by auth mode; write-policy behavior is covered separately
"litellm.proxy.mcp_server.discoverable_endpoints.authorize_oauth_credential_request",
new_callable=AsyncMock,
return_value="admin-user",
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._store_per_user_token_server_side",
new_callable=AsyncMock,
) as mock_store,
):
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="c",
redirect_uri="http://127.0.0.1:3000/cb",
client_id="cid",
client_secret=None,
code_verifier=None,
)
return mock_store.await_count > 0
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
async def test_token_exchange_does_not_persist_for_client_forwarded_modes(auth_type):
"""The browser-only Authorize for true_passthrough / oauth_delegate must not write the upstream
token to the DB: these modes forward a browser-held token and persist nothing server-side."""
assert await _exchange_persistence_attempted_for_auth_type(auth_type) is False
@pytest.mark.asyncio
async def test_token_exchange_persists_for_oauth2():
"""Guard the test's own discriminator: a genuine oauth2 (authorization_code) server DOES persist,
so the passthrough no-persist assertion above is meaningful and not vacuously true."""
assert await _exchange_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True
# -------------------------------------------------------------------
# OBO (token_exchange) Protected Resource Metadata: discovery must name the
# JWT-auth issuer the client SSOs with, not the gateway.
# -------------------------------------------------------------------
_OBO_RESOURCE = "https://litellm.example.com/mcp/obo_mcp"
_PATCH_ISSUERS = "litellm.proxy.mcp_server.discoverable_endpoints._jwt_auth_issuers"
def _obo_server(scopes=None):
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id="obo_mcp",
name="obo_mcp",
server_name="obo_mcp",
alias="obo_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2_token_exchange,
scopes=scopes,
)
def test_obo_protected_resource_response_names_jwt_issuers():
"""An OBO server's PRM points authorization_servers at the configured JWT issuers (the IdP that
mints and validates the subject token), with the gateway resource echoed back."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_obo_protected_resource_response,
)
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
response = _obo_protected_resource_response(_obo_server(scopes=["read"]), _OBO_RESOURCE)
assert response == {
"authorization_servers": ["https://idp.example.com"],
"resource": _OBO_RESOURCE,
"scopes_supported": ["read"],
}
def test_obo_protected_resource_response_scopes_default_empty():
"""A scopeless OBO server reports scopes_supported as [] rather than None."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_obo_protected_resource_response,
)
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
response = _obo_protected_resource_response(_obo_server(scopes=None), _OBO_RESOURCE)
assert response["scopes_supported"] == []
def test_obo_protected_resource_response_falls_back_when_no_issuer():
"""With no JWT issuer configured, the OBO branch returns None so the caller falls back to the
gateway-default PRM (discovery still works, it just can't name the IdP)."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_obo_protected_resource_response,
)
with patch(_PATCH_ISSUERS, return_value=[]):
assert _obo_protected_resource_response(_obo_server(), _OBO_RESOURCE) is None
def test_obo_protected_resource_response_ignores_non_obo_server():
"""Non-OBO servers are not handled by this branch (returns None -> gateway default)."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_obo_protected_resource_response,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
oauth2_server = MCPServer(
server_id="oauth2_mcp",
name="oauth2_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
assert _obo_protected_resource_response(oauth2_server, _OBO_RESOURCE) is None
@pytest.mark.asyncio
async def test_build_oauth_protected_resource_response_obo_end_to_end():
"""End to end through the response builder: an OBO server's PRM advertises the JWT issuer as
authorization_servers, proving the extracted branch is wired into the public discovery path."""
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry["obo_mcp"] = _obo_server(scopes=["read"])
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]):
response = await _build_oauth_protected_resource_response(
request=mock_request,
mcp_server_name="obo_mcp",
use_standard_pattern=True,
)
assert response["authorization_servers"] == ["https://idp.example.com"]
assert response["resource"] == "https://litellm.example.com/mcp/obo_mcp"
finally:
global_mcp_server_manager.registry.clear()
def _token_request(headers, path="/token"):
"""A real Starlette request with case-insensitive headers (matches production)."""
from starlette.requests import Request
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
return Request({"type": "http", "method": "POST", "path": path, "headers": raw, "query_string": b""})
@pytest.fixture
def proxy_globals():
"""Inject the cache/prisma the OAuth token endpoint resolves identity through, and restore
them afterward. These module globals are the proxy's real wiring points, so setting them is
dependency injection rather than monkeypatching a class."""
import litellm.proxy.proxy_server as ps
saved = (ps.user_api_key_cache, ps.prisma_client)
try:
yield ps
finally:
ps.user_api_key_cache, ps.prisma_client = saved
@pytest.mark.asyncio
async def test_extract_user_id_reads_x_litellm_api_key_header(proxy_globals):
"""The LiteLLM key arrives on x-litellm-api-key (what Claude Desktop/Code send), not
Authorization. Reading only Authorization dropped the identity, so the per-user token was
never stored and the egress 401'd forever. Resolution must honor x-litellm-api-key."""
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
key = "sk-alice-key"
cache = UserApiKeyCache()
await cache.async_set_cache(
hash_token(key),
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
model_type=UserAPIKeyAuth,
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
assert await extract_user_id_from_request(request) == "alice"
@pytest.mark.asyncio
async def test_extract_user_id_rehydrates_cross_replica_dict_cache(proxy_globals):
"""Cross-replica, async_get_cache hands back a serialized dict, not a UserAPIKeyAuth.
Resolution must rehydrate it; the old getattr(cached, "user_id") returned None on a dict,
which is exactly why a multi-replica gateway never found the stored token."""
from litellm.proxy._types import hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
key = "sk-alice-key"
cache = UserApiKeyCache()
cache.set_cache(hash_token(key), {"token": hash_token(key), "user_id": "alice"})
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"Authorization": f"Bearer {key}"})
assert await extract_user_id_from_request(request) == "alice"
@pytest.mark.asyncio
async def test_extract_user_id_falls_back_to_db_on_cache_miss(proxy_globals):
"""A cache miss must read the key from the DB rather than returning None; the old code did a
cache-only peek and skipped the DB, so any replica that hadn't just authenticated the key
failed to store the token."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
key = "sk-bob-key"
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="db-bob")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": key})
assert await extract_user_id_from_request(request) == "db-bob"
@pytest.mark.asyncio
async def test_extract_user_id_none_without_litellm_key(proxy_globals):
"""No LiteLLM key on the request resolves to None without consulting the resolver."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
request = _token_request({"content-type": "application/json"})
assert await extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_extract_user_id_rejects_blocked_key(proxy_globals):
"""A blocked LiteLLM key must not resolve an identity. get_key_object returns the DB row without
checking blocked/expiry (the main auth pipeline does, and the public token endpoint bypasses it),
so a revoked key could otherwise overwrite the stored per-user OAuth token for its user."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
assert await extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_extract_user_id_rejects_expired_key(proxy_globals):
"""An expired LiteLLM key must not resolve an identity, for the same reason as a blocked key."""
from datetime import datetime, timedelta, timezone
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
extract_user_id_from_request,
)
expired = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="expired-user", expires=expired)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-expired-key"})
assert await extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_returns_resolved_key_for_active_key(proxy_globals):
"""The dcr_bridge mint seals the hash of the authorizing key so admission can reload the live
record. For an active key the resolver returns exactly hash_token(key), the same value
get_key_object and the whole cache/DB layer key the record by, so the sealed reference resolves
back to this key at admission."""
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
_ResolvedKey,
)
key = "sk-alice-key"
cache = UserApiKeyCache()
await cache.async_set_cache(
hash_token(key),
UserAPIKeyAuth(token=hash_token(key), user_id="alice"),
model_type=UserAPIKeyAuth,
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
resolved = await _resolve_active_litellm_key(request)
assert isinstance(resolved, _ResolvedKey)
assert resolved.key_hash == hash_token(key)
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_resolves_key_without_user_id(proxy_globals):
"""A valid team-scoped or service-account key has no user_id but is a legitimate credential, so it
must still resolve to a hash and be able to mint a bridge envelope. Gating the resolver on user_id
presence wrongly rejected these keys with invalid_request; the active-state gate now checks only
blocked and expiry, and the key hash (not the user) is what the mint seals. The per-user token
store still gets no user for such a key, since there is none to key a stored credential by."""
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
_ResolvedKey,
extract_user_id_from_request,
)
key = "sk-team-scoped-key"
cache = UserApiKeyCache()
await cache.async_set_cache(
hash_token(key),
UserAPIKeyAuth(token=hash_token(key), user_id=None, team_id="team-x"),
model_type=UserAPIKeyAuth,
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = object()
request = _token_request({"x-litellm-api-key": f"Bearer {key}"})
resolved = await _resolve_active_litellm_key(request)
assert isinstance(resolved, _ResolvedKey)
assert resolved.key_hash == hash_token(key)
assert await extract_user_id_from_request(request) is None
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_rejects_blocked_key(proxy_globals):
"""A blocked key must not yield a hash, so no gateway-bound envelope is minted for a revoked key;
the mint fails closed with invalid_request instead."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="blocked-user", blocked=True)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-blocked-key"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_fails_closed_on_malformed_expiry(proxy_globals):
"""A key whose stored expires string does not parse must fail closed to no-hash (the mint then
returns invalid_request), not surface an unhandled 500. The active-state check runs outside the
resolver's try, so it must be total over a bad expires rather than letting datetime.fromisoformat
raise. Before the fix this raised a ValueError instead of returning None."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
class _FakePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
return UserAPIKeyAuth(token=token, user_id="u", expires="not-a-parseable-date")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FakePrisma()
request = _token_request({"x-litellm-api-key": "sk-bad-expiry-key"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_no_active_key_without_litellm_key(proxy_globals):
"""No LiteLLM key on the request yields no hash without consulting the resolver."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
request = _token_request({"content-type": "application/json"})
assert await _resolve_active_litellm_key(request) == "no_active_key"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_db_outage_is_unavailable(proxy_globals):
"""A database outage while resolving the presented key is a retryable infrastructure failure, not
the caller's fault, so the resolver reports "unavailable" (the mint statuses it 503) rather than
collapsing it to the same value as a missing credential. is_database_service_unavailable_error
classifies a connection error (an OSError) as an outage, matching admission's egress-side handling."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
class _OutagePrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
raise ConnectionError("connection refused")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _OutagePrisma()
request = _token_request({"x-litellm-api-key": "sk-during-outage"})
assert await _resolve_active_litellm_key(request) == "unavailable"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_permanent_engine_fault_is_faulted(proxy_globals):
"""A query engine that is missing or version-skewed cannot resolve any key until the deployment is
repaired, so the resolver reports "faulted" (still statused 503 by the mint) rather than "unavailable",
whose wording promises the outage is transient and asks the client to retry."""
from prisma.engine.errors import BinaryNotFoundError
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
class _FaultedPrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
raise BinaryNotFoundError("query engine binary not found")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _FaultedPrisma()
request = _token_request({"x-litellm-api-key": "sk-during-engine-fault"})
assert await _resolve_active_litellm_key(request) == "faulted"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_transport_error_over_permanent_fault_is_faulted(proxy_globals):
"""A reconnect that dies on a missing engine binary raises the transport error last, with the
BinaryNotFoundError as __context__. The binary is what blocks recovery, so the key read is "faulted",
not the "unavailable" that the outer ConnectError alone would suggest."""
import httpx
from prisma.engine.errors import BinaryNotFoundError
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
class _ReconnectFailedPrisma:
async def get_data(self, token, table_name, parent_otel_span=None, proxy_logging_obj=None):
try:
raise BinaryNotFoundError("query engine binary not found")
except BinaryNotFoundError:
raise httpx.ConnectError("All connection attempts failed")
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = _ReconnectFailedPrisma()
request = _token_request({"x-litellm-api-key": "sk-during-failed-reconnect"})
assert await _resolve_active_litellm_key(request) == "faulted"
@pytest.mark.asyncio
async def test_resolve_active_litellm_key_no_database_is_unresolvable(proxy_globals):
"""With no database connection configured the gateway cannot verify the presented key at all, so
the resolver reports "unresolvable" (the mint statuses it 500) instead of blaming the caller.
Mirrors admission, which 500s a missing prisma_client on the egress side."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
_resolve_active_litellm_key,
)
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = None
request = _token_request({"x-litellm-api-key": "sk-no-db"})
assert await _resolve_active_litellm_key(request) == "unresolvable"
def _wrapped_user_lookup_error(original: BaseException) -> ValueError:
"""Reproduce get_user_object's real exception contract (litellm/proxy/auth/auth_checks.py): it
catches every DB failure in a broad ``except`` and re-raises a bare ``ValueError``, so the original
error (a missing-user Exception or a real outage) survives only as ``__context__``. Injecting a raw
ConnectionError/Exception instead would exercise a shape production never produces and let a
chain-blind outage classifier pass. The wrapping fidelity is pinned by
test_get_user_object_wraps_db_outage_as_valueerror_preserving_context in test_auth_checks."""
try:
raise original
except BaseException:
try:
raise ValueError(f"User doesn't exist in db. Got error - {original}")
except ValueError as wrapped:
return wrapped
@pytest.mark.asyncio
async def test_reload_active_user_by_id_missing_user_is_no_active_key(proxy_globals):
"""A user_id refresh envelope whose user has been deleted must fail closed to no_active_key (the
refresh path maps it to invalid_grant), not unresolvable/500. get_user_object catches the missing row
and re-raises a bare ValueError, so a missing user must not be misclassified as a DB outage or an
opaque gateway fault."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
with patch(
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(side_effect=_wrapped_user_lookup_error(Exception())),
):
assert await reload_active_user_by_id("gone-user") == "no_active_key"
@pytest.mark.asyncio
async def test_reload_active_user_by_id_db_outage_is_unavailable(proxy_globals):
"""A transient DB outage while re-validating the user on refresh is a retryable outage, distinct from
a missing user, so the refresh path surfaces "unavailable" (a 503) rather than blaming the caller.
get_user_object wraps the outage in a bare ValueError, so this exercises the chain-aware classifier; a
raw ConnectionError would falsely pass even a chain-blind check because it is an OSError."""
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
with patch(
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(side_effect=_wrapped_user_lookup_error(ConnectionError("user database unreachable"))),
):
assert await reload_active_user_by_id("sso-user-7") == "unavailable"
@pytest.mark.asyncio
async def test_reload_active_user_by_id_permanent_engine_fault_is_faulted(proxy_globals):
"""A permanent query engine fault while re-validating the user on refresh is "faulted", not
"unavailable": both are 503s, but only the transient one may tell the client to retry. get_user_object
wraps the fault in a bare ValueError, so the classification has to read the wrapped cause."""
from prisma.engine.errors import MismatchedVersionsError
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
proxy_globals.user_api_key_cache = UserApiKeyCache()
proxy_globals.prisma_client = object()
with (
patch( # test-quality-ok: get_user_object is the DB seam that wraps the fault; same patch as the outage sibling
"litellm.proxy.auth.auth_checks.get_user_object",
new=AsyncMock(side_effect=_wrapped_user_lookup_error(MismatchedVersionsError(expected="1", got="2"))),
)
):
assert await reload_active_user_by_id("sso-user-7") == "faulted"
@pytest.mark.asyncio
async def test_load_active_user_by_id_reads_the_row_from_the_database_not_the_cache(proxy_globals):
"""JWT auth caches the user it creates before it adds that user to the JWT's team, and adding a
member never evicts the cached row, so a credential minted off the cached row refused the very first
token exchange as not a member. The database source has to read the row from the database and leave
the fresh row in the cache for the requests the credential makes next."""
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import load_active_user_by_id
cache = UserApiKeyCache()
await cache.async_set_cache(
key="fresh-jwt-user", value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=[]), model_type=LiteLLM_UserTable
)
prisma = MagicMock()
prisma.writer_db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="fresh-jwt-user", teams=["team-a"])
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = prisma
loaded = await load_active_user_by_id("fresh-jwt-user", source="database")
assert not isinstance(loaded, str)
assert loaded.teams == ["team-a"]
cached = await cache.async_get_cache(key="fresh-jwt-user", model_type=LiteLLM_UserTable)
assert cached is not None
assert cached.teams == ["team-a"]
@pytest.mark.asyncio
async def test_load_active_user_by_id_serves_a_cached_row_without_a_database_read(proxy_globals):
"""Introspection and refresh revalidation run per call, so the loader's default source is the cache: a
cached row answers without a database read, and only a caller that asks for the database row pays for
one."""
from litellm.proxy._types import LiteLLM_UserTable
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.mcp_server.bridge_token_flow import (
load_active_user_by_id,
reload_active_user_by_id,
)
cache = UserApiKeyCache()
await cache.async_set_cache(
key="cached-jwt-user",
value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=["team-a"]),
model_type=LiteLLM_UserTable,
)
prisma = MagicMock()
prisma.db.litellm_usertable.find_unique = AsyncMock(
return_value=LiteLLM_UserTable(user_id="cached-jwt-user", teams=[])
)
proxy_globals.user_api_key_cache = cache
proxy_globals.prisma_client = prisma
loaded = await load_active_user_by_id("cached-jwt-user")
assert not isinstance(loaded, str)
assert loaded.teams == ["team-a"]
assert await reload_active_user_by_id("cached-jwt-user") is None
prisma.db.litellm_usertable.find_unique.assert_not_awaited()
@pytest.mark.asyncio
async def test_token_endpoint_uses_client_secret_basic_when_configured():
"""LIT-4091: a server with token_endpoint_auth_method=client_secret_basic must send the
credentials as an HTTP Basic Authorization header and omit client_secret from the body;
providers requiring Basic rejected body credentials with invalid_client."""
import base64
from unittest.mock import AsyncMock
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="basic_mcp",
name="basic_mcp",
server_name="basic_mcp",
alias="basic_mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="basic-client",
client_secret="basic-secret",
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/oauth2/token",
token_endpoint_auth_method="client_secret_basic",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm-proxy.example.com/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.json.return_value = {
"access_token": "at",
"token_type": "Bearer",
"expires_in": 3599,
}
mock_response.raise_for_status = MagicMock()
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client"
) as mock_get_client:
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
mock_get_client.return_value = mock_async_client
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="basic-client",
mcp_server_name="basic_mcp",
client_secret="basic-secret",
code_verifier="verifier",
)
call_args = mock_async_client.post.call_args
expected = "Basic " + base64.b64encode(b"basic-client:basic-secret").decode()
assert call_args[1]["headers"]["Authorization"] == expected
assert "client_secret" not in call_args[1]["data"]
assert "client_id" not in call_args[1]["data"]
assert call_args[1]["data"]["grant_type"] == "authorization_code"
assert call_args[1]["data"]["code"] == "auth-code"
@pytest.mark.asyncio
async def test_token_endpoint_client_secret_basic_without_secret_returns_400():
"""A server configured client_secret_basic but missing its secret is a misconfiguration; the
inbound /token endpoint surfaces it as a 400 rather than silently posting a downgraded request."""
from fastapi import HTTPException, Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
global_mcp_server_manager.registry.clear()
oauth2_server = MCPServer(
server_id="basic_no_secret",
name="basic_no_secret",
server_name="basic_no_secret",
alias="basic_no_secret",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="basic-client",
client_secret=None,
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/oauth2/token",
token_endpoint_auth_method="client_secret_basic",
)
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm-proxy.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="basic-client",
mcp_server_name="basic_no_secret",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
def _access_group_none_server(server_name="access_group_server"):
"""A non-oauth2, access-group gated MCP server: no client_id, no OAuth."""
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer(
server_id=server_name,
name=server_name,
server_name=server_name,
alias=server_name,
transport=MCPTransport.http,
auth_type=MCPAuth.none,
access_groups=["eng"],
)
@pytest.mark.asyncio
async def test_authorize_endpoint_rejects_non_oauth2_server():
"""authorize() against a none-auth server returns an accurate 'does not use OAuth' 400,
not the misleading 'client_id is required' that fired before the auth_type was checked."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = _access_group_none_server()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
await authorize(
request=mock_request,
client_id=None,
mcp_server_name="access_group_server",
redirect_uri="http://127.0.0.1:60108/callback",
state="test_state",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "does not use OAuth" in detail_text
assert "client_id is required" not in detail_text
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_token_endpoint_rejects_non_oauth2_server():
"""token_endpoint() against a none-auth server returns 'does not use OAuth' 400 instead
of the misleading 'token url is not set'."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
token_endpoint,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = _access_group_none_server()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
await token_endpoint(
request=mock_request,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="some-client",
mcp_server_name="access_group_server",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "does not use OAuth" in detail_text
assert "token url is not set" not in detail_text
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_register_client_rejects_non_oauth2_server():
"""register_client() against a named none-auth server returns 'does not use OAuth' 400
instead of the misleading 'authorization url is not set'."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = _access_group_none_server()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.read_request_body",
new=AsyncMock(return_value={}),
):
await register_client(request=mock_request, mcp_server_name="access_group_server")
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "does not use OAuth" in detail_text
assert "authorization url is not set" not in detail_text
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"auth_type", (None, "none", "api_key", "bearer_token", "basic", "aws_sigv4", "authorization", "token")
)
@pytest.mark.parametrize("use_standard_pattern", (False, True))
async def test_oauth_protected_resource_for_gateway_owned_auth(auth_type, use_standard_pattern):
from starlette.requests import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
server = _access_group_none_server().model_copy(update={"auth_type": auth_type})
request = Request({"type": "http", "scheme": "https", "path": "/", "headers": [(b"host", b"litellm.example.com")]})
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
try:
response = await _build_oauth_protected_resource_response(
request=request,
mcp_server_name="access_group_server",
use_standard_pattern=use_standard_pattern,
)
resource_path = "/mcp/access_group_server" if use_standard_pattern else "/access_group_server/mcp"
assert response == {
"resource": f"https://litellm.example.com{resource_path}",
"authorization_servers": ["https://litellm.example.com/mcp"],
"scopes_supported": [],
}
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_oauth_authorization_server_404_for_non_oauth2_server():
"""Discovery must not advertise a none-auth server as an OAuth authorization server."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
server = _access_group_none_server()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with pytest.raises(HTTPException) as exc_info:
_build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name="access_group_server",
)
assert exc_info.value.status_code == 404
assert "not an OAuth authorization server" in str(exc_info.value.detail)
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_oauth_protected_resource_passthrough_none_auth_not_404():
"""Regression guard for the protected-resource auth_type gate placement: a none-auth
server that opted into OAuth pass-through must still proxy upstream metadata, it must
NOT be 404'd. The gate has to sit after the pass-through branch."""
try:
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
passthrough_server = MCPServer(
server_id="passthrough_server",
name="passthrough_server",
server_name="passthrough_server",
alias="passthrough_server",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.none,
oauth_passthrough=True,
extra_headers=["Authorization"],
)
global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.fetch_upstream_oauth_protected_resource",
new=AsyncMock(return_value={"authorization_servers": ["https://upstream-idp.example.com"]}),
):
response = await _build_oauth_protected_resource_response(
request=mock_request,
mcp_server_name="passthrough_server",
use_standard_pattern=False,
)
assert response["authorization_servers"] == ["https://upstream-idp.example.com"]
assert response["resource"].endswith("/passthrough_server/mcp")
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_oauth_protected_resource_404_for_unknown_server_name():
"""Unknown server names must not produce metadata advertising nonexistent resources."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await _build_oauth_protected_resource_response(
request=mock_request,
mcp_server_name="does_not_exist",
use_standard_pattern=True,
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_oauth_authorization_server_404_for_unknown_server_name():
"""A named authorization-server discovery request for an unknown server returns 404, not a
200 metadata document pointing at non-existent /{name}/authorize and /{name}/token."""
try:
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
except ImportError:
pytest.skip("MCP discoverable endpoints not available")
global_mcp_server_manager.registry.clear()
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
_build_oauth_authorization_server_response(
request=mock_request,
mcp_server_name="does_not_exist",
)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_store_per_user_token_server_side_invalidates_v2_token_cache():
"""A token stored by the OAuth callback (code exchange or refresh) drops the v2 per-user
token cache entry, so egress stops serving the replaced token immediately instead of
until its TTL."""
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
from litellm.proxy.mcp_server.discoverable_endpoints import (
_store_per_user_token_server_side,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="srv-cb-1",
name="cb_server",
url="https://upstream.example/mcp",
transport="http",
auth_type=MCPAuth.oauth2,
)
invalidate_mock = AsyncMock(return_value=None)
cache_set_mock = AsyncMock(return_value=None)
with (
patch(
"litellm.proxy.utils.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.mcp_server.db.store_user_oauth_credential",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.mcp_server.oauth2_token_cache.mcp_per_user_token_cache.set",
new=cache_set_mock,
),
patch.object(
manager_module.global_mcp_server_manager,
"invalidate_user_oauth_token_cache",
new=invalidate_mock,
),
):
await _store_per_user_token_server_side(
server=server,
user_id="user-cb-1",
token_response={"access_token": "fresh-tok", "expires_in": 3600},
)
invalidate_mock.assert_awaited_once_with("user-cb-1", "srv-cb-1")
cache_set_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_store_per_user_token_server_side_skips_invalidate_when_db_write_fails():
"""A failed DB write neither warms the v1 cache nor drops the v2 cache entry; the
previously stored token is still the truth."""
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
from litellm.proxy.mcp_server.discoverable_endpoints import (
_store_per_user_token_server_side,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="srv-cb-2",
name="cb_server_2",
url="https://upstream.example/mcp",
transport="http",
auth_type=MCPAuth.oauth2,
)
invalidate_mock = AsyncMock(return_value=None)
cache_set_mock = AsyncMock(return_value=None)
with (
patch(
"litellm.proxy.utils.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.mcp_server.db.store_user_oauth_credential",
new=AsyncMock(side_effect=RuntimeError("db down")),
),
patch(
"litellm.proxy.mcp_server.oauth2_token_cache.mcp_per_user_token_cache.set",
new=cache_set_mock,
),
patch.object(
manager_module.global_mcp_server_manager,
"invalidate_user_oauth_token_cache",
new=invalidate_mock,
),
):
await _store_per_user_token_server_side(
server=server,
user_id="user-cb-2",
token_response={"access_token": "fresh-tok", "expires_in": 3600},
)
invalidate_mock.assert_not_awaited()
cache_set_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_token_exchange_pairs_client_secret_with_server_client_id():
"""Re-auth regression: the register short-circuit hands the browser a placeholder
``client_secret: "dummy"``, which the browser echoes back to /token. The server-side
persisted client_id wins the resolution, so the secret must come from the same (server)
source; pairing the persisted public PKCE client (no stored secret) with the caller's
placeholder makes the IdP reject the exchange with 401 on every re-auth."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="srv-1",
name="srv-1",
server_name="srv-1",
alias="srv-1",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="persisted-client",
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"access_token": "at", "token_type": "Bearer"}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
client_id="srv-1",
client_secret="dummy",
code_verifier="verifier",
)
sent = mock_async_client.post.call_args.kwargs["data"]
assert sent["client_id"] == "persisted-client"
assert "client_secret" not in sent
@pytest.mark.asyncio
async def test_token_exchange_refresh_passes_presented_refresh_ownership():
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.proxy.mcp_server.oauth_identity_binding import RefreshTokenPresented
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
server = MCPServer(
server_id="srv-1",
name="srv-1",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
token_url="https://provider.example/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://provider.example",
audiences=["cid"],
),
)
request = MagicMock(spec=Request)
request.base_url = "https://litellm.example.com/"
request.headers = {}
response = MagicMock()
response.json.return_value = {"access_token": "at"}
response.raise_for_status = MagicMock()
client = MagicMock()
client.post = AsyncMock(return_value=response)
enforce = AsyncMock()
with (
patch( # test-quality-ok: no injection seam exists for the exchange's HTTP and identity collaborators
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=client,
),
patch( # test-quality-ok: no injection seam exists for request identity extraction
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
new=AsyncMock(return_value="user-a"),
),
patch( # test-quality-ok: captures the ownership value at the exchange boundary
"litellm.proxy.mcp_server.discoverable_endpoints.enforce_oauth_identity_binding",
new=enforce,
),
):
await exchange_token_with_server(
request=request,
mcp_server=server,
grant_type="refresh_token",
code=None,
redirect_uri=None,
client_id="cid",
client_secret=None,
code_verifier=None,
refresh_token="rt-1",
)
ownership = enforce.await_args.kwargs["refresh_ownership"]
assert isinstance(ownership, RefreshTokenPresented)
assert ownership.refresh_token == "rt-1"
@pytest.mark.asyncio
async def test_token_exchange_authorization_code_passes_no_refresh_ownership(monkeypatch):
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
seal_bridge_authorization_code,
)
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
monkeypatch.setenv("LITELLM_SALT_KEY", "identity-binding-test-salt")
server = MCPServer(
server_id="srv-1",
name="srv-1",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
token_url="https://provider.example/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://provider.example",
audiences=["cid"],
),
)
request = MagicMock(spec=Request)
request.base_url = "https://litellm.example.com/"
request.headers = {}
response = MagicMock()
response.json.return_value = {"access_token": "at"}
response.raise_for_status = MagicMock()
client = MagicMock()
client.post = AsyncMock(return_value=response)
enforce = AsyncMock()
with (
patch( # test-quality-ok: no injection seam exists for the exchange's HTTP and identity collaborators
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=client,
),
patch( # test-quality-ok: no injection seam exists for request identity extraction
"litellm.proxy.mcp_server.discoverable_endpoints.extract_user_id_from_request",
new=AsyncMock(return_value="user-a"),
),
patch( # test-quality-ok: captures the ownership value at the exchange boundary
"litellm.proxy.mcp_server.discoverable_endpoints.enforce_oauth_identity_binding",
new=enforce,
),
):
result = await exchange_token_with_server(
request=request,
mcp_server=server,
grant_type="authorization_code",
code=seal_bridge_authorization_code("auth-code", "user-a", "srv-1", "login-nonce"),
redirect_uri="https://litellm.example.com/callback",
client_id="cid",
client_secret=None,
code_verifier="test-verifier",
)
assert result.status_code == 200
assert json.loads(result.body)["access_token"] == "at"
assert enforce.await_args.kwargs["refresh_ownership"] is None
assert enforce.await_args.kwargs["expected_nonce"] == "login-nonce"
def _upstream_token_response(status_code: int, *, json_body: object = None, text_body: str = "") -> "httpx.Response":
import httpx
request = httpx.Request("POST", "https://oauth2.googleapis.com/token")
if json_body is not None:
return httpx.Response(status_code, json=json_body, request=request)
return httpx.Response(status_code, text=text_body, request=request)
async def _exchange_with_upstream_response(
upstream_response, *, server_client_id="web-client.apps.googleusercontent.com"
):
"""Run the raw (non-bridge) authorization_code exchange against a canned upstream token-endpoint
response and return what the gateway would hand the client. ``server_client_id=None`` models the
caller-supplied-credentials flow (no stored client on the server)."""
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="gcal",
name="gcal",
server_name="gcal",
alias="gcal",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=server_client_id,
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=upstream_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
return await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
client_id="web-client.apps.googleusercontent.com",
client_secret=None,
code_verifier="verifier",
)
@pytest.mark.asyncio
async def test_token_exchange_gateway_credential_rejection_is_502_with_gateway_prose():
"""When the gateway presented the server's stored client credentials and the IdP rejected them
(Google refusing a secret-less or unknown client), the fault is the operator's, not the caller's:
502 server_error with gateway-authored prose naming the code, and the IdP's own prose stays in
server logs. Before the framework this either 500ed raw or relayed provider prose verbatim."""
response = await _exchange_with_upstream_response(
_upstream_token_response(
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
)
)
assert response.status_code == 502
body = json.loads(response.body)
assert body["error"] == "server_error"
assert "invalid_client" in body["error_description"]
assert "client_id and client_secret" in body["error_description"]
assert "The OAuth client was not found." not in body["error_description"]
assert response.headers["cache-control"] == "no-store"
@pytest.mark.asyncio
async def test_token_exchange_caller_supplied_credential_rejection_relays_code():
"""When the caller supplied the client credentials themselves (no stored client on the server),
an invalid_client rejection is theirs to act on: the §5.2 code relays on the 401 that code
implies."""
response = await _exchange_with_upstream_response(
_upstream_token_response(
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
),
server_client_id=None,
)
assert response.status_code == 401
body = json.loads(response.body)
assert body == {"error": "invalid_client", "error_description": "The OAuth client was not found."}
@pytest.mark.asyncio
async def test_token_exchange_status_derives_from_error_code_not_upstream_status():
"""An upstream that pairs a caller-fault code with a server-fault status (invalid_grant on a 500)
must not produce a contradictory response: status derives from the classified fault, so the
caller sees 400 invalid_grant and knows to re-authorize rather than blaming the gateway."""
response = await _exchange_with_upstream_response(
_upstream_token_response(500, json_body={"error": "invalid_grant", "error_description": "Code expired."})
)
assert response.status_code == 400
body = json.loads(response.body)
assert body == {"error": "invalid_grant", "error_description": "Code expired."}
@pytest.mark.asyncio
async def test_token_exchange_relays_only_rfc6749_error_fields():
"""Only error / error_description / error_uri cross the gateway; any other upstream body field is
dropped so an arbitrary rejection payload cannot ride the relay to the client."""
response = await _exchange_with_upstream_response(
_upstream_token_response(
400,
json_body={
"error": "invalid_grant",
"error_description": "Code was already redeemed.",
"error_uri": "https://idp.example.com/errors/invalid_grant",
"internal_trace": "should never reach the client",
},
)
)
assert response.status_code == 400
body = json.loads(response.body)
assert set(body.keys()) == {"error", "error_description", "error_uri"}
@pytest.mark.asyncio
async def test_token_exchange_maps_out_of_contract_rejection_to_502():
"""A rejection outside the §5.2 contract (no JSON error field, or a status the token-endpoint
contract does not define) is an upstream fault; 502 keeps it from being misread as a caller
mistake while the description still names the upstream status."""
response = await _exchange_with_upstream_response(
_upstream_token_response(503, text_body="<html>upstream maintenance</html>")
)
assert response.status_code == 502
body = json.loads(response.body)
assert body["error"] == "server_error"
assert "HTTP 503" in body["error_description"]
@pytest.mark.asyncio
async def test_token_exchange_bounds_relayed_error_fields():
"""Relayed §5.2 fields are length-bounded so a hostile or broken upstream cannot bloat the
gateway response."""
response = await _exchange_with_upstream_response(
_upstream_token_response(400, json_body={"error": "invalid_request", "error_description": "x" * 5000})
)
assert response.status_code == 400
body = json.loads(response.body)
assert len(body["error_description"]) == 500
@pytest.mark.asyncio
async def test_token_exchange_200_without_access_token_is_502_not_keyerror():
"""A 200 whose body has no usable access_token used to KeyError into a 500; the raw arm now
answers 502 with the same wording as the bridge arm's no_upstream_token rejection."""
response = await _exchange_with_upstream_response(_upstream_token_response(200, json_body={"token_type": "Bearer"}))
assert response.status_code == 502
body = json.loads(response.body)
assert body["error"] == "server_error"
assert "access_token" in body["error_description"]
@pytest.mark.asyncio
async def test_token_exchange_relays_rejection_when_http_client_raises():
"""litellm's AsyncHTTPHandler.post raise_for_status()es internally and raises MaskedHTTPStatusError
at call time, so in production the rejection escapes from the post call itself rather than from the
explicit raise_for_status; the relay must catch it there too (proven live: a mock returning the
error response passed while the real proxy still 500ed)."""
import httpx
rejection = _upstream_token_response(
401, json_body={"error": "invalid_client", "error_description": "The OAuth client was not found."}
)
raising_client = MagicMock()
raising_client.post = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Client error '401 Unauthorized'", request=rejection.request, response=rejection
)
)
from fastapi import Request
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="gcal",
name="gcal",
server_name="gcal",
alias="gcal",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="web-client.apps.googleusercontent.com",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=raising_client,
):
response = await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="https://litellm.example.com/ui/mcp/oauth/callback",
client_id="web-client.apps.googleusercontent.com",
client_secret=None,
code_verifier="verifier",
)
assert response.status_code == 502
body = json.loads(response.body)
assert body["error"] == "server_error"
assert "invalid_client" in body["error_description"]
@pytest.mark.asyncio
async def test_register_relays_rejection_when_http_client_raises():
"""Same live mechanism as the token exchange: the DCR rejection escapes from the post call itself,
so the register relay must catch it there, not only from the explicit raise_for_status."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
rejection = httpx.Response(
400,
json={"error": "invalid_client_metadata"},
request=httpx.Request("POST", "https://idp.example.com/register"),
)
raising_client = MagicMock()
raising_client.post = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Client error '400 Bad Request'", request=rejection.request, response=rejection
)
)
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=raising_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
new_callable=AsyncMock,
return_value=False,
),
):
with pytest.raises(HTTPException) as exc:
await register_client_with_server(
request=_bridge_mock_request(),
mcp_server=oauth2_server,
client_name="Claude",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
)
assert exc.value.status_code == 400
assert "invalid_client_metadata" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_token_exchange_never_relays_out_of_contract_body_to_client():
"""These endpoints serve unauthenticated OAuth clients, so a non-RFC6749 upstream body (HTML
error page, proxy banner, stack trace) must stay in server logs; the client sees only the
upstream status."""
response = await _exchange_with_upstream_response(
_upstream_token_response(404, text_body="<html>Error 404 stack trace: secret internals</html>")
)
assert response.status_code == 502
body = json.loads(response.body)
assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 404"}
@pytest.mark.asyncio
async def test_register_never_relays_out_of_contract_body_to_client():
"""Same trust boundary for DCR: a non-RFC7591 rejection body is logged server-side and the
client detail names only the status."""
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
rejection = httpx.Response(
500,
text="<html>Tomcat stack trace with internals</html>",
request=httpx.Request("POST", "https://idp.example.com/register"),
)
raising_client = MagicMock()
raising_client.post = AsyncMock(
side_effect=httpx.HTTPStatusError("Server error '500'", request=rejection.request, response=rejection)
)
oauth2_server = _bridge_server(auth_type=MCPAuth.oauth2, dcr_bridge=None)
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=raising_client,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._reuse_persisted_dcr_client_if_available",
new_callable=AsyncMock,
return_value=False,
),
):
with pytest.raises(HTTPException) as exc:
await register_client_with_server(
request=_bridge_mock_request(),
mcp_server=oauth2_server,
client_name="Claude",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
)
assert exc.value.status_code == 502
assert str(exc.value.detail) == "upstream registration failed with HTTP 500"
assert "Tomcat" not in str(exc.value.detail)
@pytest.mark.asyncio
async def test_token_exchange_unreadable_body_still_renders_oauth_fault():
"""An upstream whose failure body cannot be read (unconsumed stream, lying content-encoding)
makes response.text/.json raise; the classifier must stay total so the caller still gets the
§5.2-shaped 502 instead of the opaque 500 this change set out to remove."""
import httpx
unreadable = httpx.Response(
400,
stream=httpx.ByteStream(b"\x1f\x8bnot-actually-gzip"),
headers={"content-encoding": "gzip"},
request=httpx.Request("POST", "https://oauth2.googleapis.com/token"),
)
response = await _exchange_with_upstream_response(unreadable)
assert response.status_code == 502
body = json.loads(response.body)
assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 400"}
@pytest.mark.asyncio
async def test_persist_dcr_client_for_config_server_uses_side_store():
"""A config.yaml-declared OAuth2 DCR server has no LiteLLM_MCPServerTable row, so
update_mcp_server returns None. The minted client must then persist to the server-scoped
OAuth-client store keyed by server_id (never a shadow server row), overlay onto the in-memory
server so refresh can authenticate this process, and never call update_server(None) (which
previously raised AttributeError on .approval_status, was swallowed, and reported a 200 that
persisted nothing)."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_persist_dcr_client_registration,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
config_server = MCPServer(
server_id="config_faros",
name="config_faros",
server_name="config_faros",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
client_secret=None,
authorization_url="https://provider.example/oauth/authorize",
token_url="https://provider.example/oauth/token",
registration_url="https://provider.example/oauth/register",
)
mock_upsert = AsyncMock()
mock_update_server = AsyncMock()
with (
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.update_mcp_server",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials",
new=mock_upsert,
),
patch.object(global_mcp_server_manager, "update_server", new=mock_update_server),
):
result = await _persist_dcr_client_registration(
mcp_server=config_server,
registration_response={
"client_id": "minted-client",
"client_secret": "minted-secret",
"token_endpoint_auth_method": "client_secret_basic",
},
current_redirect_uri="https://proxy.litellm.example/callback",
)
assert result == "persisted"
mock_upsert.assert_called_once()
assert mock_upsert.call_args.kwargs["server_id"] == "config_faros"
stored = mock_upsert.call_args.kwargs["credentials"]
assert stored["client_id"] == "minted-client"
assert stored["client_secret"] == "minted-secret"
assert stored["token_endpoint_auth_method"] == "client_secret_basic"
assert stored["redirect_uris"] == ["https://proxy.litellm.example/callback"]
assert config_server.client_id == "minted-client"
assert config_server.client_secret == "minted-secret"
assert config_server.token_endpoint_auth_method == "client_secret_basic"
mock_update_server.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("legacy", [False, True])
async def test_hydrate_config_server_applies_stored_dcr_client(monkeypatch, legacy):
"""On restart a config server's in-memory object has no client_id; hydration overlays the
persisted DCR client from the server-scoped store, decrypting the encrypted-at-rest blob, so the
refresh_token grant can authenticate as the registered client instead of re-authenticating."""
import litellm.proxy.common_utils.encrypt_decrypt_utils as enc
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.db import encrypt_credentials
from litellm.proxy.mcp_server.discoverable_endpoints import (
hydrate_config_server_dcr_client,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="config_faros",
name="config_faros",
server_name="config_faros",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
url="https://resource.example/mcp",
issuer="https://idp.example",
)
monkeypatch.setattr(enc, "get_salt_key", lambda: "salt-hydrate-key")
stored_blob = safe_dumps(
encrypt_credentials(
credentials={
**(
{}
if legacy
else {"dcr_issuer": "https://idp.example", "dcr_server_url": "https://resource.example/mcp"}
),
"client_id": "stored-client",
"client_secret": "stored-secret",
"token_endpoint_auth_method": "client_secret_basic",
"redirect_uris": ["https://proxy.litellm.example/callback"],
},
encryption_key="salt-hydrate-key",
)
)
assert "stored-client" not in stored_blob and "stored-secret" not in stored_blob
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=AsyncMock(return_value=stored_blob),
),
):
applied = await hydrate_config_server_dcr_client(server)
assert applied is True
assert server.client_id == "stored-client"
assert server.client_secret == "stored-secret"
assert server.token_endpoint_auth_method == "client_secret_basic"
@pytest.mark.asyncio
async def test_reuse_config_server_reads_store_with_real_crypto(monkeypatch):
"""A config-declared server (rowless) keeps its DCR client in the store, so the reuse read
resolves it from the store and decrypts the encrypted-at-rest client, mirroring the write path so
a re-authorize reuses the client instead of re-minting one."""
import litellm.proxy.common_utils.encrypt_decrypt_utils as enc
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.db import encrypt_credentials
from litellm.proxy.mcp_server.discoverable_endpoints import (
_reuse_persisted_dcr_client_if_available,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="config_faros",
name="config_faros",
server_name="config_faros",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
url="https://resource.example/mcp",
issuer="https://idp.example",
)
monkeypatch.setattr(enc, "get_salt_key", lambda: "salt-reuse-key")
blob = safe_dumps(
encrypt_credentials(
credentials={
"dcr_issuer": "https://idp.example",
"dcr_server_url": "https://resource.example/mcp",
"client_id": "stored-client",
"client_secret": "sec",
"redirect_uris": ["https://x/callback"],
},
encryption_key="salt-reuse-key",
)
)
assert "stored-client" not in blob
store_lookup = AsyncMock(return_value=blob)
with (
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=store_lookup,
),
):
result = await _reuse_persisted_dcr_client_if_available(server, current_redirect_uri="https://x/callback")
assert result is True
assert server.client_id == "stored-client"
store_lookup.assert_awaited_once()
@pytest.mark.asyncio
async def test_temp_server_is_not_persisted_to_store():
"""A rowless server that is NOT config-declared (a throwaway /server/oauth/session server) must
not leave a permanent store row on persist, and the read must never consult the store for it. Its
minted client is overlaid in memory for the session only."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_persist_dcr_client_registration,
_reuse_persisted_dcr_client_if_available,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
temp = MCPServer(
server_id="temp-uuid",
name="temp",
server_name="temp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
authorization_url="https://p.example/authorize",
token_url="https://p.example/token",
registration_url="https://p.example/register",
)
upsert = AsyncMock()
store_read = AsyncMock(return_value=None)
with (
patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False),
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch("litellm.proxy.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)),
patch("litellm.proxy.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)),
patch("litellm.proxy.mcp_server.db.upsert_mcp_server_oauth_client_credentials", new=upsert),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=store_read,
),
patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()),
):
result = await _persist_dcr_client_registration(
temp, {"client_id": "temp-client", "client_secret": "s"}, "https://x/callback"
)
reused = await _reuse_persisted_dcr_client_if_available(
MCPServer(
server_id="temp-uuid",
name="temp",
server_name="temp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
),
current_redirect_uri="https://x/callback",
)
assert result == "persisted"
assert temp.client_id == "temp-client"
upsert.assert_not_called()
store_read.assert_not_called()
assert reused is False
@pytest.mark.asyncio
async def test_hydrate_does_not_overwrite_explicit_config_client_id():
"""An explicit client_id set in config.yaml wins: hydration must not overwrite it with a stale
persisted store client, and must not even read the store when config already supplied a client."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
hydrate_config_server_dcr_client,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="config_static",
name="config_static",
server_name="config_static",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="explicit-from-config",
)
store_read = AsyncMock(return_value={"client_id": "stale-store-client", "client_secret": "x", "redirect_uris": []})
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=store_read,
),
):
applied = await hydrate_config_server_dcr_client(server)
assert applied is False
assert server.client_id == "explicit-from-config"
store_read.assert_not_called()
@pytest.mark.asyncio
async def test_reuse_does_not_inherit_store_client_when_a_row_exists():
"""Security: a server that HAS a LiteLLM_MCPServerTable row reads its DCR client only from that
row, never from the server-scoped store. server_id is caller-settable on create, so a submitted
server whose id collides with a config-declared server must not be able to load that config
server's client from the store and send it to its own token endpoint. A row that exists but has
no client_id yields no reusable client and must not fall back to the store."""
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server.discoverable_endpoints import (
_reuse_persisted_dcr_client_if_available,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
submitted = MCPServer(
server_id="collides_with_config",
name="submitted",
server_name="submitted",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id=None,
)
row_without_client = MagicMock()
row_without_client.credentials = None
row_without_client.server_id = "collides_with_config"
store_lookup = AsyncMock(
return_value={"client_id": "config-secret-client", "client_secret": "leak", "redirect_uris": []}
)
with (
patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server",
new=AsyncMock(return_value=row_without_client),
),
patch(
"litellm.proxy.mcp_server.db.get_mcp_server_oauth_client_credentials",
new=store_lookup,
),
):
result = await _reuse_persisted_dcr_client_if_available(submitted, current_redirect_uri="https://x/callback")
assert result is False
assert submitted.client_id is None
store_lookup.assert_not_called()
@pytest.mark.asyncio
async def test_load_servers_from_config_hydrates_dcr_clients():
"""load_servers_from_config must invoke DCR-client hydration so config servers pick up their
persisted client on startup; deleting the call site leaves a restarted server with no client_id
and forces re-authentication on every token expiry."""
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
hydrate_spy = AsyncMock()
with patch.object(global_mcp_server_manager, "hydrate_config_servers_dcr_clients", new=hydrate_spy):
await global_mcp_server_manager.load_servers_from_config({})
hydrate_spy.assert_awaited_once()
@pytest.mark.asyncio
async def test_reload_servers_from_database_hydrates_dcr_clients():
"""load_servers_from_config runs before the DB connects at startup, so its hydration no-ops;
reload_servers_from_database runs after the DB connects and must hydrate config servers' persisted
DCR clients too, or a fresh pod has no client_id for a config server and forces re-authentication
on the first token refresh."""
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
prisma = MagicMock()
prisma.writer_db = prisma.db
prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[])
prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
hydrate_spy = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=prisma,
),
patch.object(global_mcp_server_manager, "hydrate_config_servers_dcr_clients", new=hydrate_spy),
):
await global_mcp_server_manager.reload_servers_from_database()
hydrate_spy.assert_awaited_once()
def test_aggregate_wellknown_routes_serve_gateway_metadata():
"""Both path-appended aggregate routes serve the gateway documents. Exercises real
routing, so this also pins registration order: the parameterized
/.well-known/oauth-authorization-server/{name} route would otherwise capture the /mcp
suffix as a server name."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
app = FastAPI()
app.include_router(router)
client = TestClient(app)
prm = client.get("/.well-known/oauth-protected-resource/mcp")
asm = client.get("/.well-known/oauth-authorization-server/mcp")
assert prm.status_code == 200
assert prm.json()["resource"] == "http://testserver/mcp"
assert prm.json()["authorization_servers"] == ["http://testserver/mcp"]
assert asm.status_code == 200
assert asm.json()["issuer"] == "http://testserver/mcp"
assert asm.json()["authorization_endpoint"] == "http://testserver/authorize/mcp-session"
assert "none" in asm.json()["token_endpoint_auth_methods_supported"]
def test_as_aggregate_route_reserves_mcp_for_the_aggregate():
"""The single-segment /.well-known/oauth-authorization-server/mcp is reserved for the
aggregate even when a server is literally named ``mcp``. The aggregate protected-resource
document advertises {base}/mcp as its authorization server, so the document served here
must carry issuer {base}/mcp for the RFC 8414 issuer check to pass. Letting the per-server
row win (issuer {base}) breaks that chain, so the aggregate wins and the mcp-named server
keeps its standard two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
server_named_mcp = _create_oauth2_server(server_id="mcp_srv", name="mcp", server_name="mcp", alias="mcp")
global_mcp_server_manager.registry[server_named_mcp.server_id] = server_named_mcp
app = FastAPI()
app.include_router(router)
client = TestClient(app)
try:
asm = client.get("/.well-known/oauth-authorization-server/mcp")
assert asm.status_code == 200
# the aggregate document, whose issuer matches what the aggregate PRM advertises
assert asm.json()["issuer"] == "http://testserver/mcp"
prm = client.get("/.well-known/oauth-protected-resource/mcp")
assert prm.status_code == 200
assert prm.json()["authorization_servers"] == [asm.json()["issuer"]]
# the mcp-named server keeps its own document on the standard two-segment route
per_server = client.get("/.well-known/oauth-authorization-server/mcp/mcp")
assert per_server.status_code == 200
assert "/mcp/authorize" in per_server.json()["authorization_endpoint"]
finally:
global_mcp_server_manager.registry.clear()
def test_well_known_root_suffix_reflects_server_root_path():
"""The single path segment both the discovery routes and the 401 challenges insert for RFC
8414/9728 path insertion: empty for a root-mounted proxy or an explicit ``/``, the configured
path otherwise. Sharing this one function is what keeps the advertised resource_metadata URL
equal to the route that serves it."""
import os
from unittest.mock import patch
from litellm.proxy.mcp_server.oauth_utils import well_known_root_suffix
with patch.dict(os.environ, {"SERVER_ROOT_PATH": ""}):
assert well_known_root_suffix() == ""
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/"}):
assert well_known_root_suffix() == ""
with patch.dict(os.environ, {"SERVER_ROOT_PATH": "/litellm"}):
assert well_known_root_suffix() == "/litellm"
@pytest.mark.asyncio
async def test_root_resource_uses_gateway_without_changing_authorization_relay():
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
oauth2_server = _create_oauth2_server()
global_mcp_server_manager.registry[oauth2_server.server_id] = oauth2_server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
authorization_response = _build_oauth_authorization_server_response(request=mock_request, mcp_server_name=None)
resource_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name=None, use_standard_pattern=True
)
assert "/test_oauth/authorize" in authorization_response["authorization_endpoint"]
assert authorization_response["issuer"] == "https://llm.example.com"
assert tuple(resource_response["authorization_servers"]) == ("https://llm.example.com/mcp",)
assert resource_response["resource"] == "https://llm.example.com"
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_named_discovery_issuer_matches_protected_resource_authorization_servers():
"""RFC 8414 requires the issuer to equal the authorization server identifier the client
resolved the metadata from, which is the protected-resource authorization_servers entry."""
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_oauth_authorization_server_response,
_build_oauth_protected_resource_response,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
server = _create_oauth2_server(delegate_auth_to_upstream=True)
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
resource_response = await _build_oauth_protected_resource_response(
request=mock_request, mcp_server_name="test_oauth", use_standard_pattern=True
)
authorization_response = _build_oauth_authorization_server_response(
request=mock_request, mcp_server_name="test_oauth"
)
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
assert authorization_response["issuer"] == resource_response["authorization_servers"][0]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_openid_configuration_issuer_stays_bare_origin_for_single_oauth2_server():
"""The OIDC discovery document is served from the bare origin, so its issuer must stay the
bare origin even when root discovery resolves the one configured OAuth2 server."""
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
openid_configuration,
)
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
global_mcp_server_manager.registry.clear()
server = _create_oauth2_server()
global_mcp_server_manager.registry[server.server_id] = server
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://llm.example.com/"
mock_request.headers = {}
try:
response = await openid_configuration(mock_request)
assert response["issuer"] == "https://llm.example.com"
finally:
global_mcp_server_manager.registry.clear()
def test_gateway_dcr_flow_routing_engages_only_for_llm_dcrc_clients(monkeypatch):
"""The aggregate DCR arms engage for llm_dcrc_ client_ids (register always mints one,
authorize/token route into the aggregate flow); a non-gateway client_id keeps the
per-server behavior, and /authorize/complete exists but 400s without a valid flow."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit3637")
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit3637", raising=False)
global_mcp_server_manager.registry.clear()
app = FastAPI()
app.include_router(router)
client = TestClient(app)
registered = client.post("/register", json={"redirect_uris": ["https://claude.ai/cb"]})
assert registered.status_code == 201
assert registered.json()["client_id"].startswith("llm_dcrc_")
assert registered.json()["token_endpoint_auth_method"] == "none"
authorize_params = {
"client_id": "llm_dcrc_bogus",
"redirect_uri": "https://claude.ai/cb",
"response_type": "code",
"code_challenge": "c" * 43,
"code_challenge_method": "S256",
}
bogus_client = client.get("/authorize", params=authorize_params)
assert bogus_client.status_code == 400
assert bogus_client.json()["error"] == "invalid_client"
no_cookie = client.post("/authorize/complete", data={"flow": "h"})
assert no_cookie.status_code == 400
assert no_cookie.json()["error"] == "invalid_request"
token_response = client.post(
"/token",
data={
"grant_type": "authorization_code",
"client_id": "llm_dcrc_bogus",
"code": "x",
"redirect_uri": "https://claude.ai/cb",
"code_verifier": "v" * 43,
},
)
assert token_response.status_code == 400
assert token_response.json()["error"] == "invalid_grant"
upstream_shaped = client.post(
"/token",
data={"grant_type": "authorization_code", "client_id": "regular-upstream-client", "code": "x"},
)
assert upstream_shaped.status_code == 404
@pytest.mark.asyncio
async def test_authorize_wall_names_the_fix_for_urlless_servers():
"""LIT-4629: the authorize wall previously said only "authorization url is not set" with no
hint that spec-only servers never discover; the detail must now name both remedies (manual
Authorization URL + Token URL, or an Issuer for RFC 8414 discovery)."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-wall",
name="sheets_wall",
server_name="sheets_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Authorization URL and Token URL" in detail_text
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_token_wall_names_the_fix_for_urlless_servers():
"""The /token wall is the second stop on the same misconfiguration (LIT-4629): after an admin
fills only the Authorization URL, the code exchange dies here; the detail must name the
remedies like the authorize wall does."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-token-wall",
name="sheets_token_wall",
server_name="sheets_token_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Token URL manually" in detail_text
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_register_wall_names_the_fix_for_urlless_servers():
"""The /register wall serves the same missing-authorization-url 400 as authorize; its detail
must carry the same actionable remedies."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-register-wall",
name="sheets_register_wall",
server_name="sheets_register_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await register_client_with_server(
request=mock_request,
mcp_server=server,
client_name="client",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Authorization URL and Token URL" in detail_text
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_authorize_wall_points_at_discovery_failure_for_url_servers():
"""LIT-4658: a server WITH a url that still has no authorization_url got here because OAuth
discovery against that url failed (typically a misconfigured url); the old detail blamed
"servers with no url", sending the operator down the wrong path. The detail must now name the
discovery failure and point at the proxy logs where LIT-4658's warnings carry the reason."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="typo-url-wall",
name="typo_wall",
server_name="typo_wall",
url="https://typo-host.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "may be misconfigured" in detail_text
assert "proxy logs" in detail_text
assert "Servers with no url" not in detail_text
assert "typo-host.example.com" not in detail_text
@pytest.mark.asyncio
async def test_token_wall_points_at_discovery_failure_for_url_servers():
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="typo-url-token-wall",
name="typo_token_wall",
server_name="typo_token_wall",
url="https://typo-host.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://idp.example.com/authorize",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "token url is not configured" in detail_text
assert "may be misconfigured" in detail_text
assert "Servers with no url" not in detail_text
@pytest.mark.asyncio
async def test_authorize_wall_names_the_issuer_for_anchored_servers():
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="anchored-wall",
name="anchored_wall",
server_name="anchored_wall",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
issuer="https://idp.example.com",
issuer_is_anchored=True,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "verify the Issuer" in detail_text
assert "Servers with no url" not in detail_text
assert "idp.example.com" not in detail_text
@pytest.mark.asyncio
async def test_authorize_uses_admin_entered_github_oauth_urls_after_issuer_yield(monkeypatch):
"""GitHub MCP servers store Authorization URL and Token URL on the row. 1.99 can empty
the resolved authorization_url when a leftover issuer is treated as a pin (RFC 8414
yield). The UI authorize must still redirect to the admin-entered GitHub authorize URL
instead of 400ing that discovery against api.githubcopilot.com failed."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="ecac50c4-8eca-438a-af80-9bdebadafc69",
name="github_mcp",
alias="github_mcp",
server_name="github_mcp",
url="https://api.githubcopilot.com/mcp/",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id="github-app-client",
authorization_url=None,
token_url=None,
issuer="https://github.com",
issuer_is_anchored=True,
configured_authorization_url="https://github.com/login/oauth/authorize",
configured_token_url="https://github.com/login/oauth/access_token",
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
response = await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="github-app-client",
redirect_uri="http://127.0.0.1:60108/callback",
state="state123",
)
assert response.status_code == 307
assert "https://github.com/login/oauth/authorize" in response.headers["location"]
assert "client_id=github-app-client" in response.headers["location"]
def test_oauth_endpoints_count_admin_entered_urls_as_resolved():
"""A leftover issuer empties the resolved authorize/token fields but must not keep the
server on the deferred-discovery retry path when the admin already stored those URLs."""
from litellm.proxy.mcp_server.mcp_server_manager import (
oauth_endpoints_unresolved,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="github-configured",
name="github_mcp",
url="https://api.githubcopilot.com/mcp/",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
authorization_url=None,
token_url=None,
configured_authorization_url="https://github.com/login/oauth/authorize",
configured_token_url="https://github.com/login/oauth/access_token",
)
assert oauth_endpoints_unresolved(server) is False
@pytest.mark.asyncio
async def test_token_exchange_with_configured_token_url_never_joins_discovery(monkeypatch):
"""A server can hold an admin-entered Token URL while its Authorization URL is absent. The
token exchange must post to that stored endpoint without awaiting deferred discovery, which
can 503 against an unreachable issuer even though nothing it resolves is needed here."""
from litellm.proxy.mcp_server import (
discoverable_endpoints,
mcp_server_manager,
)
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="token-url-only",
name="token_url_only",
server_name="token_url_only",
alias="token_url_only",
url="https://mcp.example.com/mcp/",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
authorization_url=None,
token_url=None,
issuer="https://idp.example.com",
issuer_is_anchored=True,
configured_token_url="https://idp.example.com/oauth/token",
)
async def fail_discovery(_srv):
raise AssertionError("the exchange joined deferred discovery despite a stored token url")
monkeypatch.setattr(
mcp_server_manager.global_mcp_server_manager,
"ensure_oauth_metadata_discovered",
fail_discovery,
)
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
monkeypatch.setattr(
discoverable_endpoints,
"get_async_httpx_client",
lambda llm_provider: fake_http_client,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
response = await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="upstream-code",
redirect_uri="http://127.0.0.1:3000/cb",
client_id="cid",
client_secret=None,
code_verifier=None,
)
assert response.status_code == 200
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
@pytest.mark.asyncio
async def test_root_token_route_with_configured_token_url_never_joins_discovery(monkeypatch):
"""A root POST /token that falls back to the sole OAuth2 server must reach the exchange's
endpoint-gated discovery join instead of awaiting full discovery at the route: with the
token url admin-entered, a failing or slow discovery must not turn the exchange into a 503."""
from litellm.proxy.mcp_server import (
discoverable_endpoints,
mcp_server_manager,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
manager = mcp_server_manager.global_mcp_server_manager
server = MCPServer(
server_id="sole-token-url-only",
name="sole_token_url_only",
server_name="sole_token_url_only",
alias="sole_token_url_only",
url="https://mcp.example.com/mcp/",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="cid",
client_secret="cs",
issuer="https://idp.example.com",
issuer_is_anchored=True,
configured_token_url="https://idp.example.com/oauth/token",
)
saved_registry = dict(manager.registry)
manager.registry.clear()
manager.registry[server.server_id] = server
manager._set_oauth_discovery_deferred(server.server_id, True)
async def fail_discovery(_srv):
raise AssertionError("the root token route joined deferred discovery despite a stored token url")
monkeypatch.setattr(manager, "ensure_oauth_metadata_discovered", fail_discovery)
fake_http_response = MagicMock()
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
fake_http_response.raise_for_status = MagicMock()
fake_http_client = MagicMock()
fake_http_client.post = AsyncMock(return_value=fake_http_response)
monkeypatch.setattr(
discoverable_endpoints,
"get_async_httpx_client",
lambda llm_provider: fake_http_client,
)
request = _mock_callback_request("https://litellm.example.com/")
try:
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type="authorization_code",
code="upstream-code",
redirect_uri="http://127.0.0.1:3000/cb",
client_id="unregistered-dcr-client",
)
finally:
manager.registry.clear()
manager.registry.update(saved_registry)
assert response.status_code == 200
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
@pytest.mark.asyncio
async def test_bridge_authorize_relays_with_registration_url_resolved_by_deferred_discovery(monkeypatch):
"""When deferred discovery resolves a DCR-bridge server during the authorize request, the
relay-vs-short-circuit call must read the resolved server: a client that registered itself
through the front door keeps its own redirect binding instead of being routed through the
gateway callback the upstream never granted it."""
from litellm.proxy.mcp_server import mcp_server_manager
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="bridge-deferred",
name="bridge_deferred",
server_name="bridge_deferred",
alias="bridge_deferred",
url="https://mcp.example.com/mcp/",
transport=MCPTransport.http,
auth_type=MCPAuth.true_passthrough,
dcr_bridge=True,
authorization_url=None,
token_url=None,
registration_url=None,
)
resolved = server.model_copy(
update={
"authorization_url": "https://idp.example.com/oauth/authorize",
"token_url": "https://idp.example.com/oauth/token",
"registration_url": "https://idp.example.com/oauth/register",
}
)
async def resolve_discovery(_srv):
return resolved
monkeypatch.setattr(
mcp_server_manager.global_mcp_server_manager,
"ensure_oauth_metadata_discovered",
resolve_discovery,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
response = await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="front-door-client",
redirect_uri="http://127.0.0.1:60110/client-callback",
state="state456",
code_challenge="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method="S256",
)
assert response.status_code == 307
location = response.headers["location"]
assert location.startswith("https://idp.example.com/oauth/authorize")
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A60110%2Fclient-callback" in location
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
and is total over hostile input: a raw upstream code opens to None, and a tampered or
non-gateway value opens to None rather than raising, so every existing caller-supplied-client
flow is untouched."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
open_passthrough_authorization_code,
seal_passthrough_authorization_code,
)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
sealed = seal_passthrough_authorization_code(
upstream_code="up-code",
client_id="minted-77",
client_secret="mint-secret",
mcp_server_id="srv-1",
token_endpoint_auth_method="client_secret_basic",
)
opened = open_passthrough_authorization_code(sealed)
assert opened is not None
assert opened.upstream_code == "up-code"
assert opened.client_id == "minted-77"
assert opened.client_secret == "mint-secret"
assert opened.mcp_server_id == "srv-1"
assert opened.token_endpoint_auth_method == "client_secret_basic"
assert open_passthrough_authorization_code("raw-upstream-code") is None
assert open_passthrough_authorization_code(sealed[:-4] + "aaaa") is None
from litellm.proxy.mcp_server.discoverable_endpoints import (
_BRIDGE_AUTH_CODE_PREFIX,
_PASSTHROUGH_AUTH_CODE_PREFIX,
open_bridge_authorization_code,
seal_bridge_authorization_code,
)
bridge_sealed = seal_bridge_authorization_code(
upstream_code="up-code", litellm_user_id="sso-user-9", mcp_server_id="srv-1"
)
reprefixed_as_passthrough = _PASSTHROUGH_AUTH_CODE_PREFIX + bridge_sealed[len(_BRIDGE_AUTH_CODE_PREFIX) :]
reprefixed_as_bridge = _BRIDGE_AUTH_CODE_PREFIX + sealed[len(_PASSTHROUGH_AUTH_CODE_PREFIX) :]
assert open_passthrough_authorization_code(reprefixed_as_passthrough) is None
assert open_bridge_authorization_code(reprefixed_as_bridge) is None
@pytest.mark.asyncio
async def test_authorize_with_ephemeral_dcr_client_seals_client_into_state():
"""When mcp_authorize fell through to a gateway-side DCR mint, authorize_with_server seals the
minted client and the target server into the encrypted OAuth state, so the callback can bind
them into the forwarded authorization code while the gateway stores nothing."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None)
captured: dict = {}
def _capture(**kwargs):
captured.update(kwargs)
return "mocked_encrypted_state"
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.encode_state_with_base_url",
side_effect=_capture,
):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="minted-77",
redirect_uri="http://127.0.0.1:60108/callback",
state="s",
code_challenge="chal",
code_challenge_method="S256",
ephemeral_dcr_client=EphemeralDcrClient(
client_id="minted-77", client_secret="mint-secret", token_endpoint_auth_method="client_secret_basic"
),
)
assert captured["dcr_client_id"] == "minted-77"
assert captured["dcr_client_secret"] == "mint-secret"
assert captured["dcr_token_endpoint_auth_method"] == "client_secret_basic"
assert captured["mcp_server_id"] == server.server_id
assert "client_id=minted-77" in response.headers["location"]
@pytest.mark.asyncio
async def test_callback_wraps_code_into_passthrough_code_for_ephemeral_dcr_state():
"""When the OAuth state carries an ephemeral DCR client, the callback forwards a sealed
passthrough code (binding the client and the upstream code to the server) instead of the raw
upstream code, so the client's later token call can authenticate the exchange with a client the
gateway never stored."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
callback,
open_passthrough_authorization_code,
)
state_data = {
"original_state": "client-state",
"client_redirect_uri": "http://127.0.0.1:60108/cb",
"base_url": "http://127.0.0.1:60108/cb",
"mcp_server_id": "srv-1",
"dcr_client_id": "minted-77",
"dcr_client_secret": "mint-secret",
"dcr_token_endpoint_auth_method": "client_secret_basic",
}
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
return_value="enc",
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
return_value=state_data,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
return_value="http://127.0.0.1:60108/cb",
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
opened = open_passthrough_authorization_code(forwarded_code)
assert opened is not None
assert opened.upstream_code == "REAL-UPSTREAM-CODE"
assert opened.client_id == "minted-77"
assert opened.client_secret == "mint-secret"
assert opened.mcp_server_id == "srv-1"
assert opened.token_endpoint_auth_method == "client_secret_basic"
@pytest.mark.asyncio
async def test_authorize_bridge_server_with_ephemeral_client_takes_short_circuit_arm():
"""A gateway-minted client is registered against {base}/callback, so a bridge server's
authorize with an ephemeral client must run the short-circuit (gateway /callback) arm with a
relay state cookie, never the verbatim relay: relaying would send the browser's redirect_uri
to an IdP that has the gateway callback registered, stranding the flow."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
authorize_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough)
with patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY):
response = await authorize_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_id="minted-77",
redirect_uri="http://127.0.0.1:60108/callback",
state="client-state",
code_challenge="chal",
code_challenge_method="S256",
ephemeral_dcr_client=EphemeralDcrClient(client_id="minted-77", client_secret=None),
)
location = response.headers["location"]
params = parse_qs(urlparse(location).query)
assert params["redirect_uri"] == ["https://litellm.example.com/callback"]
assert params["client_id"] == ["minted-77"]
assert params["state"] != ["client-state"]
assert any(cookie.startswith("mcp_oauth_state_") for cookie in response.headers.get("set-cookie", "").split(";"))
@pytest.mark.asyncio
async def test_callback_forwards_raw_code_when_dcr_state_lacks_server_binding():
"""A state carrying a dcr client but no server id cannot produce a server-bound sealed code, so
the callback falls back to forwarding the raw upstream code instead of sealing an unbindable
one."""
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server.discoverable_endpoints import callback
state_data = {
"original_state": "client-state",
"client_redirect_uri": "http://127.0.0.1:60108/cb",
"base_url": "http://127.0.0.1:60108/cb",
"dcr_client_id": "minted-77",
}
with (
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._resolve_encoded_oauth_state",
return_value="enc",
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints.decode_state_hash",
return_value=state_data,
),
patch(
"litellm.proxy.mcp_server.discoverable_endpoints._get_validated_client_redirect_uri",
return_value="http://127.0.0.1:60108/cb",
),
patch("litellm.proxy.proxy_server.master_key", _BRIDGE_MASTER_KEY),
):
response = await callback(request=_bridge_mock_request(), code="REAL-UPSTREAM-CODE", state="relay")
forwarded_code = parse_qs(urlparse(response.headers["location"]).query)["code"][0]
assert forwarded_code == "REAL-UPSTREAM-CODE"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"auth_type_value",
[
"none",
"api_key",
"bearer_token",
"basic",
"authorization",
"oauth2",
"aws_sigv4",
"token",
"oauth2_token_exchange",
"oauth2_id_jag",
"true_passthrough",
"oauth_delegate",
],
)
@pytest.mark.parametrize("dcr_bridge", [True, False])
async def test_resolve_ephemeral_dcr_client_mint_set_is_exact(auth_type_value, dcr_bridge):
"""The full authorize-time mint decision matrix, one cell per (auth_type, dcr_bridge). The gateway
mints iff true_passthrough (any bridge) or oauth_delegate-and-not-dcr_bridge; every other mode
returns None so no non-OAuth mode ever registers an upstream client, and the interactive
oauth_delegate dcr_bridge sign-in is left to its own browser-front-door flow. The UI
gatewayMintsClientFor helper mirrors this exact set; ui/.../mcp_tools/types.test.tsx pins the
frontend side against the same table, so a divergence fails on one side or the other."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
EphemeralDcrClient,
resolve_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth(auth_type_value),
dcr_bridge=dcr_bridge,
server_id=f"matrix_{auth_type_value}_{dcr_bridge}",
server_name=f"matrix_{auth_type_value}_{dcr_bridge}",
)
expected_mint = server.is_true_passthrough or (server.is_oauth_delegate and not server.is_dcr_bridge)
mint_mock = AsyncMock(return_value=EphemeralDcrClient(client_id="minted", client_secret=None))
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.mint_ephemeral_dcr_client",
mint_mock,
):
result = await resolve_ephemeral_dcr_client(
request=_bridge_mock_request(),
mcp_server=server,
code_challenge="chal",
code_challenge_method="S256",
redirect_uri="http://127.0.0.1:9/callback",
)
if expected_mint:
mint_mock.assert_awaited_once()
assert result is not None
else:
mint_mock.assert_not_awaited()
assert result is None
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_returns_none_without_registration_endpoint():
"""A server whose upstream exposes no RFC 7591 registration endpoint cannot mint, so the
fall-through reports None and the caller keeps its existing missing_client_id failure."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, registration_url=None)
assert await mint_ephemeral_dcr_client(_bridge_mock_request(), server) is None
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_posts_rfc7591_and_returns_client():
"""The mint POSTs a public-client RFC 7591 registration bound to the gateway /callback and hands
back the upstream's client without persisting it anywhere."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_posts_srv", server_name="mint_posts_srv"
)
mock_response = MagicMock()
mock_response.text = json.dumps(
{"client_id": "minted-77", "client_secret": "mint-secret", "token_endpoint_auth_method": "client_secret_basic"}
)
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
minted = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert minted is not None
assert minted.client_id == "minted-77"
assert minted.client_secret == "mint-secret"
assert minted.token_endpoint_auth_method == "client_secret_basic"
register_data = mock_async_client.post.call_args.kwargs["json"]
assert register_data["redirect_uris"] == ["https://litellm.example.com/callback"]
assert register_data["token_endpoint_auth_method"] == "none"
assert register_data["grant_types"] == ["authorization_code", "refresh_token"]
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_reuses_minted_client_within_flow_ttl():
"""Reloading the authorize page must not spam the upstream registration endpoint with orphan
clients: within the OAuth state's lifetime a second mint for the same server and gateway origin
reuses the cached client and performs no second upstream POST."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="mint_reuse_srv", server_name="mint_reuse_srv"
)
mock_response = MagicMock()
mock_response.text = json.dumps({"client_id": "minted-77"})
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
first = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
second = await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert first is not None
assert second == first
mock_async_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_mint_ephemeral_dcr_client_single_flights_concurrent_mints():
"""Two in-flight authorize requests for the same server must not both register an upstream
client: the per-key lock makes the second waiter reuse the first mint, so exactly one upstream
POST happens."""
import asyncio
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough,
dcr_bridge=None,
server_id="mint_concurrent_srv",
server_name="mint_concurrent_srv",
)
mock_response = MagicMock()
mock_response.text = json.dumps({"client_id": "minted-77"})
mock_response.raise_for_status = MagicMock()
async def _slow_post(*args, **kwargs):
await asyncio.sleep(0.05)
return mock_response
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(side_effect=_slow_post)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
first, second = await asyncio.gather(
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
mint_ephemeral_dcr_client(_bridge_mock_request(), server),
)
assert first is not None
assert second == first
mock_async_client.post.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"payload, server_id",
[
({"unexpected": "shape"}, "mint_bad_shape_srv"),
({"client_id": ""}, "mint_empty_id_srv"),
],
)
async def test_mint_ephemeral_dcr_client_unusable_registration_response_is_502(payload, server_id):
"""An upstream registration response without a usable client_id, whether the field is missing or
an empty string, surfaces as a loud 502 instead of letting the authorize proceed with an empty
client and fail opaquely at the IdP."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(
auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id=server_id, server_name=server_id
)
mock_response = MagicMock()
mock_response.text = json.dumps(payload)
mock_response.raise_for_status = MagicMock()
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
with pytest.raises(HTTPException) as exc:
await mint_ephemeral_dcr_client(_bridge_mock_request(), server)
assert exc.value.status_code == 502
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sealed_auth_method, expects_basic_header",
[
("client_secret_basic", True),
(None, False),
],
)
async def test_token_exchange_authenticates_with_the_sealed_clients_own_auth_method(
sealed_auth_method, expects_basic_header
):
"""The id, secret, and token-endpoint auth method must come from the same source: a client
recovered from a sealed passthrough code authenticates the upstream exchange the way its own
registration was granted, not the way the server row is configured. A sealed
``client_secret_basic`` grant sends the Basic header and keeps the secret out of the body; a
sealed public client (no method) keeps the body-credential path."""
import base64
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth
server = _bridge_server(auth_type=MCPAuth.true_passthrough, dcr_bridge=None, server_id="sealed_method_srv")
upstream_request = httpx.Request("POST", server.token_url)
upstream_response = httpx.Response(
200, json={"access_token": "up-token", "token_type": "Bearer"}, request=upstream_request
)
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=upstream_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
await exchange_token_with_server(
request=_bridge_mock_request(),
mcp_server=server,
grant_type="authorization_code",
code="up-code",
redirect_uri="https://litellm.example.com/callback",
client_id="minted-77",
client_secret="mint-secret",
code_verifier="verifier",
client_token_endpoint_auth_method=sealed_auth_method,
)
sent_headers = mock_async_client.post.call_args.kwargs["headers"]
sent_body = mock_async_client.post.call_args.kwargs["data"]
if expects_basic_header:
expected = base64.b64encode(b"minted-77:mint-secret").decode()
assert sent_headers["Authorization"] == f"Basic {expected}"
assert "client_secret" not in sent_body
else:
assert "Authorization" not in sent_headers
assert sent_body["client_id"] == "minted-77"
assert sent_body["client_secret"] == "mint-secret"
# ---------------------------------------------------------------------------
# LIT-4339: RFC 8707 resource indicators on the upstream OAuth legs
# ---------------------------------------------------------------------------
def _resource_server(**overrides) -> "MCPServer":
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer
defaults = dict(
server_id="res-srv",
name="res-srv",
server_name="res-srv",
alias="res-srv",
url="https://mcp.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="gateway-client",
client_secret="gateway-secret",
authorization_url="https://idp.example.com/oauth/authorize",
token_url="https://idp.example.com/oauth/token",
)
defaults.update(overrides)
return MCPServer(**defaults)
@pytest.mark.parametrize(
"url, configured, expected",
[
("https://mcp.example.com/mcp", None, None),
("https://mcp.example.com/mcp", "", None),
("https://mcp.example.com/mcp", " ", None),
("https://mcp.example.com/mcp", "auto", "https://mcp.example.com/mcp"),
("https://mcp.example.com/mcp", "AUTO", "https://mcp.example.com/mcp"),
("https://mcp.example.com/mcp/", "auto", "https://mcp.example.com/mcp"),
("https://mcp.example.com/", "auto", "https://mcp.example.com"),
("https://MCP.Example.COM/mcp", "auto", "https://mcp.example.com/mcp"),
("HTTPS://mcp.example.com/mcp", "auto", "https://mcp.example.com/mcp"),
("https://mcp.example.com/mcp#frag", "auto", "https://mcp.example.com/mcp"),
("https://mcp.example.com:8443/server/mcp", "auto", "https://mcp.example.com:8443/server/mcp"),
# The scheme's default port is dropped, so :443/:80 never present as a different resource than
# the portless form against the strict authorization servers this feature targets.
("https://mcp.example.com:443/mcp", "auto", "https://mcp.example.com/mcp"),
("http://mcp.example.com:80/mcp", "auto", "http://mcp.example.com/mcp"),
# IPv6 authority keeps its brackets (a bare ::1:8080 would be a malformed authority).
("https://[::1]:8080/mcp", "auto", "https://[::1]:8080/mcp"),
("https://[::1]:443/mcp", "auto", "https://[::1]/mcp"),
("https://mcp.example.com/Server/MCP", "auto", "https://mcp.example.com/Server/MCP"),
("https://User:PaSs@MCP.Example.com/mcp", "auto", "https://mcp.example.com/mcp"),
("https://token@MCP.Example.com/mcp", "auto", "https://mcp.example.com/mcp"),
("https://mcp.example.com/mcp?api_key=s3cr3t", "auto", "https://mcp.example.com/mcp"),
("https://u:p@MCP.Example.com:8443/mcp/?token=abc#frag", "auto", "https://mcp.example.com:8443/mcp"),
("mcp.example.com/mcp", "auto", None),
(None, "auto", None),
("https://mcp.example.com/mcp", "api://custom-audience", "api://custom-audience"),
("https://mcp.example.com/mcp", " https://Other.example.com/RS/ ", "https://Other.example.com/RS/"),
],
)
def test_resolve_upstream_resource_tristate_and_canonicalization(url, configured, expected):
"""The knob is a tri-state: unset/blank omits the parameter, ``auto`` derives the MCP spec's
canonical server URI from the server url, and anything else is sent verbatim.
Canonicalization follows the MCP authorization spec: lowercase scheme and host, drop the scheme's
default port, drop the fragment (RFC 8707 forbids one), drop the query and userinfo (credential
hygiene), and drop a trailing slash, while preserving a non-default port and the path case. An
explicit value is never canonicalized, because it has to match what the authorization server
expects byte for byte."""
from litellm.proxy.mcp_server.oauth_utils import resolve_upstream_resource
assert resolve_upstream_resource(_resource_server(url=url, upstream_resource=configured)) == expected
@pytest.mark.parametrize(
"configured, url, expected_resource",
[
(None, "https://mcp.example.com/mcp", None),
("auto", "https://MCP.Example.com/mcp/", "https://mcp.example.com/mcp"),
("api://audience", "https://mcp.example.com/mcp", "api://audience"),
],
)
def test_build_upstream_oauth2_token_request_bundles_resource_with_client_auth(configured, url, expected_resource):
"""Every plain-OAuth2 token leg (authorization_code, refresh_token, client_credentials) builds its
request body through this one helper, so the RFC 8707 resource is resolved in the same call as the
mandatory client authentication and no leg can authenticate without also naming the resource its
sibling legs named. A leg that reverted to hand-building its body would drop the resource and
diverge from the authorize leg, which a strict authorization server rejects as invalid_target."""
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
req = build_upstream_oauth2_token_request(
_resource_server(url=url, upstream_resource=configured),
auth_method=None,
client_id="cid",
client_secret="sec",
)
assert req.body.get("resource") == expected_resource
assert req.body["client_id"] == "cid"
assert req.body["client_secret"] == "sec"
def test_build_upstream_oauth2_token_request_client_secret_basic_keeps_secret_out_of_body():
"""client_secret_basic authenticates through the Authorization header, so the secret must never
also appear in the body, while the RFC 8707 resource still rides in the body."""
import base64
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
req = build_upstream_oauth2_token_request(
_resource_server(upstream_resource="api://audience"),
auth_method="client_secret_basic",
client_id="cid",
client_secret="sec",
)
assert req.headers["Authorization"] == "Basic " + base64.b64encode(b"cid:sec").decode()
assert "client_secret" not in req.body
assert "client_id" not in req.body
assert req.body["resource"] == "api://audience"
async def _authorize_query(server) -> dict:
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with patch("litellm.proxy.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt:
mock_encrypt.return_value = "encrypted_state"
response = await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="caller-client",
redirect_uri="http://localhost:3000/callback",
state="client-state",
code_challenge="challenge",
code_challenge_method="S256",
response_type="code",
scope=None,
)
return parse_qs(urlparse(response.headers["location"]).query)
async def _token_body(server, grant_type: str) -> dict:
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
mock_request = MagicMock(spec=Request)
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"access_token": "at", "token_type": "Bearer"}
mock_async_client = MagicMock()
mock_async_client.post = AsyncMock(return_value=mock_response)
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=mock_async_client,
):
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type=grant_type,
code="auth-code" if grant_type == "authorization_code" else None,
redirect_uri="https://litellm.example.com/callback",
client_id="caller-client",
client_secret=None,
code_verifier="verifier",
refresh_token="upstream-refresh" if grant_type == "refresh_token" else None,
)
return mock_async_client.post.call_args.kwargs["data"]
@pytest.mark.asyncio
async def test_upstream_resource_unset_sends_no_resource_on_any_leg():
"""Default behavior is unchanged: with the knob unset the gateway sends no RFC 8707 resource
on the authorize leg or on either token grant, so every server working today keeps working
(notably the authorization servers that hard-reject the parameter)."""
server = _resource_server()
assert "resource" not in await _authorize_query(server)
assert "resource" not in await _token_body(server, "authorization_code")
assert "resource" not in await _token_body(server, "refresh_token")
@pytest.mark.asyncio
async def test_upstream_resource_auto_sends_same_canonical_uri_on_every_leg():
"""The cross-leg invariant. RFC 8707 requires the token request to name a resource the
authorization request already asked for, so the authorize leg and both token grants must send
an identical value; they all resolve through one helper to make that structural. Deleting the
resolve call at any single leg fails this test."""
server = _resource_server(url="https://MCP.Example.com/mcp/", upstream_resource="auto")
canonical = "https://mcp.example.com/mcp"
assert (await _authorize_query(server))["resource"] == [canonical]
assert (await _token_body(server, "authorization_code"))["resource"] == canonical
assert (await _token_body(server, "refresh_token"))["resource"] == canonical
@pytest.mark.asyncio
async def test_upstream_resource_explicit_value_is_sent_verbatim_on_every_leg():
"""An explicit identifier is never canonicalized or derived from the url; authorization servers
match the resource exactly, so an operator-supplied value goes out byte for byte."""
server = _resource_server(upstream_resource="api://7c9f-audience/.default")
assert (await _authorize_query(server))["resource"] == ["api://7c9f-audience/.default"]
assert (await _token_body(server, "authorization_code"))["resource"] == "api://7c9f-audience/.default"
assert (await _token_body(server, "refresh_token"))["resource"] == "api://7c9f-audience/.default"
@pytest.mark.asyncio
async def test_upstream_resource_auto_never_leaks_credentials_from_the_server_url():
"""A resource indicator names the resource, never the credentials used to reach it. Transport
URLs routinely carry secrets in userinfo and in the query string, and this value is published
into the authorization redirect the browser follows and into token request bodies, so neither
component may survive into the derived resource."""
server = _resource_server(
url="https://svc-account:s3cr3t@MCP.Example.com/mcp?api_key=qu3ry-s3cr3t",
upstream_resource="auto",
)
leaks = ("s3cr3t", "svc-account", "qu3ry-s3cr3t", "api_key")
query = await _authorize_query(server)
assert query["resource"] == ["https://mcp.example.com/mcp"]
assert not any(leak in query["resource"][0] for leak in leaks)
body = await _token_body(server, "authorization_code")
assert not any(leak in body["resource"] for leak in leaks)
def test_upstream_resource_auto_keeps_the_path_because_it_identifies_the_server():
"""The path is load-bearing identity and must survive canonicalization, unlike userinfo and
query which are transport concerns.
The MCP authorization spec requires the most specific URI and lists
``https://mcp.example.com/server/mcp`` as canonical "when path component is necessary to
identify individual MCP server". Two servers behind one host differ only by path, so dropping
it would collide them onto one resource identifier and bind each token to the wrong audience,
which is the exact confusion RFC 8707 exists to prevent. An operator whose path embeds a secret
sets ``upstream_resource`` explicitly instead of using ``auto``."""
from litellm.proxy.mcp_server.oauth_utils import resolve_upstream_resource
first = resolve_upstream_resource(
_resource_server(url="https://gw.example.com/team-a/mcp", upstream_resource="auto")
)
second = resolve_upstream_resource(
_resource_server(url="https://gw.example.com/team-b/mcp", upstream_resource="auto")
)
assert first == "https://gw.example.com/team-a/mcp"
assert second == "https://gw.example.com/team-b/mcp"
assert first != second
@pytest.mark.asyncio
async def test_upstream_resource_auto_without_url_omits_the_parameter():
"""A server with no url (OpenAPI spec or stdio) has nothing to derive a canonical URI from, so
``auto`` omits the parameter rather than sending an empty or malformed resource."""
server = _resource_server(url=None, upstream_resource="auto")
assert "resource" not in await _authorize_query(server)
assert "resource" not in await _token_body(server, "authorization_code")
@pytest.mark.asyncio
async def test_upstream_resource_sent_on_dcr_bridge_relay_authorize():
"""The DCR-bridge relay arm builds its own upstream authorize params, so it needs the resource
too. Without it the relayed authorize would omit the resource while the gateway's token leg
still sent one, which is itself an invalid_target."""
from litellm.proxy.mcp_server.discoverable_endpoints import (
_dcr_bridge_relays_client_registration,
)
from litellm.types.mcp import MCPAuth
server = _resource_server(
client_id=None,
client_secret=None,
auth_type=MCPAuth.oauth_delegate,
dcr_bridge=True,
registration_url="https://idp.example.com/register",
upstream_resource="auto",
)
assert _dcr_bridge_relays_client_registration(server), "test must exercise the relay arm"
query = await _authorize_query(server)
assert query["resource"] == ["https://mcp.example.com/mcp"]
assert query["client_id"] == ["caller-client"]
# ---------------------------------------------------------------------------
# Per-request root_path (SERVER_ROOT_PATHS / PerRequestRootPathMiddleware):
# one app fronting several client-visible URL path prefixes, each prefix's
# discovery documents emitting URLs under the prefix the client called
# (RFC 9728 §3 exact-match). A scalar PROXY_BASE_URL / SERVER_ROOT_PATH can
# encode at most one prefix per pod; these tests pin the N-prefix case.
# ---------------------------------------------------------------------------
@pytest.fixture
def _no_proxy_base_url(monkeypatch):
"""Discovery must derive URLs from the request in these tests, so the
scalar env overrides are cleared."""
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
@pytest.fixture
def _isolated_mcp_registry():
"""Fixture-owned registry state: snapshot the shared registry, hand the
test an empty one, restore afterwards so nothing leaks between cases."""
from litellm.proxy.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
saved = dict(global_mcp_server_manager.registry)
global_mcp_server_manager.registry.clear()
try:
yield global_mcp_server_manager.registry
finally:
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry.update(saved)
def _prefixed_discovery_client(prefixes):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.middleware.per_request_root_path_middleware import (
PerRequestRootPathMiddleware,
)
app = FastAPI()
app.include_router(router)
app.add_middleware(PerRequestRootPathMiddleware, root_paths=prefixes)
return TestClient(app)
class TestPerRequestRootPathDiscovery:
def test_prefixed_wellknown_not_routable_without_middleware(self, _isolated_mcp_registry):
"""Control: on a plain app (the only shape a scalar root_path can
express), a prefixed well-known request 404s before any discovery
builder runs — the routing gap this feature exists to close."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
_isolated_mcp_registry[server.server_id] = server
app = FastAPI()
app.include_router(router)
client = TestClient(app)
resp = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
assert resp.status_code == 404
def test_two_prefixes_one_app_each_resource_matches_the_called_url(
self, _no_proxy_base_url, _isolated_mcp_registry
):
"""The multi-origin case itself: two prefixes served by the same app,
each per-server document's ``resource`` equal to the URL its client
called — including the prefix."""
for sid, name in (("srv_a", "server_a"), ("srv_b", "server_b")):
_isolated_mcp_registry[sid] = _create_oauth2_server(server_id=sid, name=name, server_name=name, alias=name)
client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"])
resp_a = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp/server_a")
resp_b = client.get("/tenant-b/.well-known/oauth-protected-resource/mcp/server_b")
assert resp_a.status_code == 200
assert resp_a.json()["resource"] == "http://testserver/tenant-a/mcp/server_a"
assert resp_b.status_code == 200
assert resp_b.json()["resource"] == "http://testserver/tenant-b/mcp/server_b"
# Every URL the document advertises stays under the request's
# prefix, so it resolves on this same app.
for auth_server in resp_a.json()["authorization_servers"]:
assert auth_server.startswith("http://testserver/tenant-a/")
def test_unprefixed_requests_unchanged_on_the_same_app(self, _no_proxy_base_url, _isolated_mcp_registry):
"""Backward compat on the very same app: a root request emits the
document byte-identical to a deployment without the middleware."""
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
_isolated_mcp_registry[server.server_id] = server
client = _prefixed_discovery_client(["/tenant-a"])
resp = client.get("/.well-known/oauth-protected-resource/mcp/server_a")
assert resp.status_code == 200
assert resp.json()["resource"] == "http://testserver/mcp/server_a"
def test_unlisted_prefix_404s(self, _no_proxy_base_url):
client = _prefixed_discovery_client(["/tenant-a"])
assert client.get("/tenant-c/.well-known/oauth-protected-resource/mcp/server_a").status_code == 404
def test_aggregate_documents_and_as_endpoints_under_prefix(self, _no_proxy_base_url, _isolated_mcp_registry):
"""Aggregate PRM/AS documents carry the prefix, and the advertised
authorize endpoint actually resolves under it — the 404 trap that
invalidated prefixing discovery URLs without per-request routing
(#35226 review round 1)."""
client = _prefixed_discovery_client(["/tenant-a"])
prm = client.get("/tenant-a/.well-known/oauth-protected-resource/mcp")
asm = client.get("/tenant-a/.well-known/oauth-authorization-server/mcp")
assert prm.status_code == 200
assert prm.json()["resource"] == "http://testserver/tenant-a/mcp"
assert prm.json()["authorization_servers"] == ["http://testserver/tenant-a/mcp"]
assert asm.status_code == 200
assert asm.json()["issuer"] == "http://testserver/tenant-a/mcp"
assert asm.json()["authorization_endpoint"] == "http://testserver/tenant-a/authorize/mcp-session"
# The prefixed authorize URL routes to the real handler (not 404):
# under per-request root_path the whole app is reachable per-prefix,
# so discovery may advertise prefixed AS endpoints safely.
assert client.get("/tenant-a/authorize").status_code != 404
def test_passthrough_challenge_metadata_url_carries_prefix(self, _no_proxy_base_url):
"""The WWW-Authenticate resource_metadata URL a 401 advertises must
land under the request's prefix, or the client is bounced to a
document whose ``resource`` cannot match the URL it called."""
from litellm.proxy.mcp_server.oauth_utils import (
get_passthrough_resource_metadata_url,
)
def _scope(path, root_path=None):
scope = {
"type": "http",
"method": "POST",
"path": path,
"headers": [],
"scheme": "http",
"server": ("testserver", 80),
"client": ("1.2.3.4", 4444),
}
if root_path is not None:
scope["root_path"] = root_path
return scope
prefixed = get_passthrough_resource_metadata_url(
scope=_scope("/tenant-a/mcp/github", root_path="/tenant-a"),
server_name="github",
)
assert prefixed == "http://testserver/tenant-a/.well-known/oauth-protected-resource/mcp/github"
# Regression guard: no root_path → today's URL, unchanged.
bare = get_passthrough_resource_metadata_url(
scope=_scope("/mcp/github"),
server_name="github",
)
assert bare == "http://testserver/.well-known/oauth-protected-resource/mcp/github"
def test_user_oauth_challenge_url_routes_and_resource_matches_client_url(
self, _no_proxy_base_url, _isolated_mcp_registry
):
"""The reviewer's expected end-state, pinned end-to-end: an MCP endpoint
raising ``raise_user_oauth_challenge`` under a per-request prefix must
emit a resource_metadata URL the client can actually fetch, and the
document it returns must carry the same prefix the client originally
called. If either half breaks the client's discovery is dead."""
import re
from fastapi import FastAPI, HTTPException, Request
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.outbound_credentials.adapter import (
raise_user_oauth_challenge,
)
from litellm.proxy.middleware.per_request_root_path_middleware import (
PerRequestRootPathMiddleware,
get_request_root_path,
)
server = _create_oauth2_server(server_id="srv_a", name="server_a", server_name="server_a", alias="server_a")
_isolated_mcp_registry[server.server_id] = server
app = FastAPI()
app.include_router(router)
@app.post("/mcp/{name}")
def _mcp(name: str, request: Request):
try:
raise_user_oauth_challenge(server, root_path=get_request_root_path())
except HTTPException as exc:
return {"www_authenticate": exc.headers["WWW-Authenticate"]}
app.add_middleware(PerRequestRootPathMiddleware, root_paths=["/tenant-a", "/tenant-b"])
client = TestClient(app)
for prefix, mcp_url in (
("/tenant-a", "http://testserver/tenant-a/mcp/server_a"),
("/tenant-b", "http://testserver/tenant-b/mcp/server_a"),
("", "http://testserver/mcp/server_a"),
):
call = client.post(f"{prefix}/mcp/server_a")
assert call.status_code == 200, call.text
www = call.json()["www_authenticate"]
match = re.search(r'resource_metadata="([^"]+)"', www)
assert match, www
discovery = client.get(match.group(1))
# The challenge URL must route (a client that can't fetch it has
# no way to reach the resource metadata).
assert discovery.status_code == 200, (
f"challenge URL {match.group(1)} for prefix {prefix!r} 404s; "
"the client can't reach the resource metadata."
)
# And the doc's `resource` must equal the URL the client called
# (RFC 9728 §3 exact match): a mismatch bounces a strict client.
assert discovery.json()["resource"] == mcp_url, discovery.json()
def _s256(verifier: str) -> str:
return urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
_NATIVE_CLIENT_MASTER_KEY = "sk-test-salt-for-LIT-5874"
def _browser_session_cookie(
monkeypatch: pytest.MonkeyPatch,
user_id: str,
*,
signing_key: str = _NATIVE_CLIENT_MASTER_KEY,
session_key: str | None = None,
expired_session: bool = False,
expired_cookie: bool = False,
) -> str:
import jwt
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
monkeypatch.setattr(proxy_server, "master_key", signing_key)
if proxy_server.prisma_client is None:
monkeypatch.setattr(proxy_server, "prisma_client", object())
monkeypatch.setattr(proxy_server, "general_settings", {**proxy_server.general_settings, "supported_db_objects": []})
session: Final = UserAPIKeyAuth(
user_id=user_id,
expires=datetime(2000 if expired_session else 2099, 1, 1, tzinfo=timezone.utc),
)
key: Final = (
encrypt_bearer_token(session.model_dump_json(exclude_none=True), LITELLM_SESSION_TOKEN_PREFIX)
if session_key is None
else session_key
)
return jwt.encode(
{"user_id": user_id, "key": key, "login_method": "sso", "exp": 946684800 if expired_cookie else 9999999999},
signing_key,
algorithm="HS256",
)
class _BrowserSessionDatabase:
def __init__(self) -> None:
from litellm.proxy._types import UserAPIKeyAuth, hash_token
self.row: UserAPIKeyAuth | None = UserAPIKeyAuth(
token=hash_token("sk-browser-session"),
user_id="u1",
expires=datetime(2099, 1, 1, tzinfo=timezone.utc),
)
self.litellm_deprecatedverificationtoken = self
def __call__(self, **kwargs: object) -> "_BrowserSessionDatabase":
return self
async def query_first(self, query: str, *args: object) -> dict[str, object] | None:
if self.row is None:
return None
return {
**self.row.model_dump(exclude={"last_refreshed_at"}),
"team_models": None,
"team_blocked": None,
"team_members_with_roles": None,
}
async def find_first(self, **kwargs: object) -> None:
return None
@pytest.fixture
def browser_session_database(monkeypatch: pytest.MonkeyPatch) -> _BrowserSessionDatabase:
import prisma
from litellm.proxy import proxy_server
from litellm.proxy._types import hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.utils import PrismaClient
database: Final = _BrowserSessionDatabase()
monkeypatch.setattr(prisma, "Prisma", database)
for name in ("DATABASE_URL_READ_REPLICA", "IAM_TOKEN_DB_AUTH", "AZURE_POSTGRESQL_AUTH"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(
proxy_server,
"prisma_client",
PrismaClient("postgresql://local-test@localhost/browser-session", proxy_server.proxy_logging_obj),
)
cache: Final = UserApiKeyCache()
cache.set_cache(hash_token("sk-browser-session"), database.row)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
return database
def _native_client_app(monkeypatch):
"""The unauthenticated discoverable router served over TestClient with a signed UI session
cookie available, plus fakes for the two database-backed hooks the native-client flow calls."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.gateway_dcr_flow import ConsentTeam, MintedProxyCredential
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("LITELLM_SALT_KEY", _NATIVE_CLIENT_MASTER_KEY)
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", _NATIVE_CLIENT_MASTER_KEY, raising=False)
minted = []
async def fake_mint(user_id, team_id, allowed_routes=None):
minted.append((user_id, team_id))
return MintedProxyCredential(key=f"sk-cli-{len(minted)}", expires_in=3600, user_id=user_id, team_id=team_id)
async def fake_lookup(user_id):
return (ConsentTeam(team_id="team-a", team_alias="Team A"), ConsentTeam(team_id="team-b"))
monkeypatch.setattr(
"litellm.proxy.mcp_server.discoverable_endpoints.mint_proxy_credential", fake_mint
)
monkeypatch.setattr(
"litellm.proxy.mcp_server.discoverable_endpoints.lookup_consent_teams", fake_lookup
)
global_mcp_server_manager.registry.clear()
app = FastAPI()
app.include_router(router)
client = TestClient(app)
session_cookie: Final = _browser_session_cookie(monkeypatch, "u1")
return client, session_cookie, minted
@pytest.mark.parametrize(
"authorize_path,resource",
[("/authorize", None), ("/authorize/mcp-session", None), ("/authorize", "http://testserver")],
)
@pytest.mark.parametrize(
"session_state", ["valid", "revoked", "blocked", "expired", "wrong_owner", "missing_key", "no_database"]
)
def test_gateway_authorize_requires_live_browser_session(
monkeypatch: pytest.MonkeyPatch,
browser_session_database: _BrowserSessionDatabase,
authorize_path: str,
resource: str | None,
session_state: str,
) -> None:
from urllib.parse import urlparse
from litellm.proxy import proxy_server
client, _, _ = _native_client_app(monkeypatch)
database: Final = browser_session_database
assert database.row is not None
database.row = None if session_state == "revoked" else database.row.model_copy(
update={
"blocked": session_state == "blocked",
"expires": datetime(2000 if session_state == "expired" else 2099, 1, 1, tzinfo=timezone.utc),
"user_id": "another-user" if session_state == "wrong_owner" else "u1",
}
)
cookie: Final = _browser_session_cookie(
monkeypatch, "u1", session_key="" if session_state == "missing_key" else "sk-browser-session"
)
client.cookies.set("token", cookie)
if session_state == "no_database":
monkeypatch.setattr(proxy_server, "prisma_client", None)
registered: Final = client.post("/register", json={"redirect_uris": ["http://127.0.0.1/callback"]})
assert registered.status_code == 201, registered.text
response: Final = client.get(
authorize_path,
params={
"client_id": registered.json()["client_id"],
"redirect_uri": "http://127.0.0.1/callback",
"response_type": "code",
"code_challenge": _s256("v" * 43),
"code_challenge_method": "S256",
**({"resource": resource} if resource else {}),
},
follow_redirects=False,
)
if session_state == "valid" and resource:
assert response.status_code == 200 and 'name="decision"' in response.text, response.text
else:
assert response.status_code == 303, response.text
assert urlparse(response.headers["location"]).path == (
"/ui/connect" if session_state == "valid" else "/sso/key/generate"
)
@pytest.mark.parametrize("endpoint", ["flow", "complete"])
@pytest.mark.parametrize("revoked", [False, True])
def test_gateway_connect_rechecks_session_after_authorize(
monkeypatch: pytest.MonkeyPatch,
browser_session_database: _BrowserSessionDatabase,
endpoint: str,
revoked: bool,
) -> None:
from urllib.parse import parse_qs, urlparse
client, _, _ = _native_client_app(monkeypatch)
client.cookies.set("token", _browser_session_cookie(monkeypatch, "u1", session_key="sk-browser-session"))
registered: Final = client.post("/register", json={"redirect_uris": ["http://127.0.0.1/callback"]})
assert registered.status_code == 201, registered.text
authorized: Final = client.get(
"/authorize/mcp-session",
params={
"client_id": registered.json()["client_id"],
"redirect_uri": "http://127.0.0.1/callback",
"response_type": "code",
"code_challenge": _s256("v" * 43),
"code_challenge_method": "S256",
},
follow_redirects=False,
)
flow: Final = parse_qs(urlparse(authorized.headers["location"]).query)["connect_flow"][0]
if revoked:
browser_session_database.row = None
response: Final = (
client.get("/authorize/flow", params={"flow": flow})
if endpoint == "flow"
else client.post("/authorize/complete", data={"flow": flow}, follow_redirects=False)
)
assert response.status_code == (401 if revoked else 200 if endpoint == "flow" else 303), response.text
if revoked:
assert response.json()["error"] == "login_required"
assert "location" not in response.headers
elif endpoint == "complete":
assert parse_qs(urlparse(response.headers["location"]).query)["code"]
@pytest.mark.parametrize("authorize_path", ["/authorize/mcp-session", "/authorize"])
def test_gateway_authorize_routes_forward_server_scope(monkeypatch: pytest.MonkeyPatch, authorize_path: str) -> None:
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.mcp_server.gateway_dcr_flow import CONNECT_FLOW_COOKIE_PREFIX
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setattr(
global_mcp_server_manager,
"registry",
global_mcp_server_manager.registry.copy(),
)
client, session_cookie, _ = _native_client_app(monkeypatch)
server: Final = _create_oauth2_server(
server_id="scope-server-id",
name="alpha",
server_name="alpha",
alias="alpha",
)
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
redirect_uri: Final = "https://client.example/callback"
registration: Final = client.post("/register", json={"redirect_uris": [redirect_uri]})
assert registration.status_code == 201, registration.text
client_id: Final = registration.json()["client_id"]
response: Final = client.get(
authorize_path,
params={
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": "scope-test-state",
"code_challenge": _s256("scope-test-verifier"),
"code_challenge_method": "S256",
"response_type": "code",
"resource": "http://testserver/mcp",
"scope": "litellm:mcp_server:alpha",
},
cookies={"token": session_cookie},
follow_redirects=False,
)
assert response.status_code == 303, response.text
flow: Final = parse_qs(urlparse(response.headers["location"]).query)["connect_flow"][0]
flow_cookies: Final = SimpleCookie()
flow_cookies.load(response.headers["set-cookie"])
sealed_flow: Final = flow_cookies[f"{CONNECT_FLOW_COOKIE_PREFIX}{flow}"].value
decoded_flow: Final = decrypt_value_helper(sealed_flow, "gateway_connect_flow", return_original_value=False)
assert isinstance(decoded_flow, str)
flow_payload: Final = json.loads(decoded_flow)
assert flow_payload["resource_server_id"] == "scope-server-id"
assert "resource_server_ids" not in flow_payload
def _consent_flow_handle(page: str) -> str:
import re
match = re.search(r'name="flow" value="([^"]+)"', page)
assert match is not None, page
return match.group(1)
@pytest.mark.parametrize("redirect_uri", ["http://127.0.0.1:51234/callback", "https://client.example.com/callback"])
@pytest.mark.parametrize("signed_in", [True, False])
def test_root_discovery_origin_authorizes_mcp_session(monkeypatch, redirect_uri, signed_in):
from urllib.parse import parse_qs, urlparse
client, session_cookie, minted = _native_client_app(monkeypatch)
root = client.get("/.well-known/oauth-protected-resource")
assert root.status_code == 200
assert root.json()["resource"] == "http://testserver"
authorization = client.get("/.well-known/oauth-authorization-server/mcp")
assert authorization.status_code == 200
metadata = authorization.json()
registered = client.post(metadata["registration_endpoint"], json={"redirect_uris": [redirect_uri]})
assert registered.status_code == 201
if signed_in:
client.cookies.set("token", session_cookie)
response = client.get(
metadata["authorization_endpoint"],
params={
"response_type": "code",
"client_id": registered.json()["client_id"],
"redirect_uri": redirect_uri,
"state": "mcp-state",
"code_challenge": _s256("v" * 43),
"code_challenge_method": "S256",
"resource": root.json()["resource"],
},
follow_redirects=False,
)
assert response.status_code == 303
target = urlparse(response.headers["location"])
assert target.path == ("/ui/connect" if signed_in else "/sso/key/generate")
if signed_in:
flow = parse_qs(target.query)["connect_flow"][0]
described = client.get("/authorize/flow", params={"flow": flow})
assert described.status_code == 200
assert described.json()["state"] == "unscoped"
assert minted == []
@pytest.mark.parametrize("valid_client", [True, False])
def test_mcp_session_authorize_rejects_invalid_registration_or_pkce(monkeypatch, valid_client):
client, session_cookie, minted = _native_client_app(monkeypatch)
redirect_uri = "https://client.example.com/callback"
registered = client.post("/register", json={"redirect_uris": [redirect_uri]})
assert registered.status_code == 201
client.cookies.set("token", session_cookie)
response = client.get(
"/authorize/mcp-session",
params={
"client_id": registered.json()["client_id"] if valid_client else "unknown-client",
"redirect_uri": redirect_uri,
"response_type": "code",
},
follow_redirects=False,
)
assert response.status_code == 400
assert response.json()["error"] == ("invalid_request" if valid_client else "invalid_client")
assert "location" not in response.headers
assert minted == []
def test_native_client_login_walks_discovery_consent_token_refresh_and_revoke(monkeypatch):
"""The whole ``lite login --pkce`` server side over the real router: a Go CLI reads the versioned
discovery document, registers a loopback public client, the signed-in user consents to a team,
the code redeems for the ``lite login`` credential, the refresh token rotates, and revocation
kills it."""
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
client, session_cookie, minted = _native_client_app(monkeypatch)
redirect_uri = "http://127.0.0.1:51234/callback"
discovery = client.get("/.well-known/litellm-cli-auth")
assert discovery.status_code == 200
assert discovery.headers["cache-control"] == "no-store"
contract = discovery.json()
assert contract["contract_version"] == 1
assert contract["resource"] == "http://testserver"
assert contract["code_challenge_methods_supported"] == ["S256"]
assert contract["token_endpoint_auth_methods_supported"] == ["none"]
for endpoint in ("authorization_endpoint", "token_endpoint", "registration_endpoint", "revocation_endpoint"):
assert contract[endpoint].startswith("http://testserver/")
registered = client.post(
contract["registration_endpoint"],
json={
"client_name": "litellm-cli",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
},
)
assert registered.status_code == 201
client_id = registered.json()["client_id"]
verifier = "v" * 43
authorize_params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"state": "cli-state",
"code_challenge": _s256(verifier),
"code_challenge_method": "S256",
"resource": contract["resource"],
}
anonymous = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
assert anonymous.status_code == 303
login_target = urlparse(anonymous.headers["location"])
assert login_target.path == "/sso/key/generate"
assert parse_qs(login_target.query)["return_to"][0].startswith("/authorize?")
client.cookies.set("token", session_cookie)
consent = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
assert consent.status_code == 200
assert consent.headers["x-frame-options"] == "DENY"
assert consent.headers["cache-control"] == "no-store"
assert "http://127.0.0.1:51234" in consent.text
assert '<option value="team-b">team-b</option>' in consent.text
jar = SimpleCookie()
jar.load(consent.headers["set-cookie"])
assert all(morsel["httponly"] for morsel in jar.values())
denied = client.post(
"/authorize/complete",
data={"flow": _consent_flow_handle(consent.text), "decision": "deny", "team_id": "team-a"},
follow_redirects=False,
)
assert denied.status_code == 303
denied_query = parse_qs(urlparse(denied.headers["location"]).query)
assert denied.headers["location"].startswith(redirect_uri)
assert denied_query["error"] == ["access_denied"]
assert denied_query["state"] == ["cli-state"]
assert minted == []
consent_again = client.get(contract["authorization_endpoint"], params=authorize_params, follow_redirects=False)
approved = client.post(
"/authorize/complete",
data={"flow": _consent_flow_handle(consent_again.text), "decision": "approve", "team_id": "team-b"},
follow_redirects=False,
)
assert approved.status_code == 303
assert approved.headers["location"].startswith(redirect_uri)
approved_query = parse_qs(urlparse(approved.headers["location"]).query)
assert approved_query["state"] == ["cli-state"]
code = approved_query["code"][0]
token = client.post(
contract["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"code_verifier": verifier,
"resource": contract["resource"],
},
)
assert token.status_code == 200, token.text
assert token.headers["cache-control"] == "no-store"
body = token.json()
assert body["access_token"] == "sk-cli-1"
assert body["token_type"] == "Bearer"
assert body["expires_in"] == 3600
assert body["user_id"] == "u1"
assert body["team_id"] == "team-b"
assert body["refresh_token"].startswith("llm_srefresh_")
assert minted == [("u1", "team-b")]
refreshed = client.post(
contract["token_endpoint"],
data={
"grant_type": "refresh_token",
"refresh_token": body["refresh_token"],
"client_id": client_id,
"resource": contract["resource"],
},
)
assert refreshed.status_code == 200, refreshed.text
assert refreshed.json()["access_token"] == "sk-cli-2"
assert refreshed.json()["team_id"] == "team-b"
assert refreshed.json()["refresh_token"] != body["refresh_token"]
assert minted == [("u1", "team-b"), ("u1", "team-b")]
revoked = client.post(
contract["revocation_endpoint"],
data={"token": refreshed.json()["refresh_token"], "token_type_hint": "refresh_token", "client_id": client_id},
)
assert revoked.status_code == 200
assert revoked.json() == {}
after_revoke = client.post(
contract["token_endpoint"],
data={
"grant_type": "refresh_token",
"refresh_token": refreshed.json()["refresh_token"],
"client_id": client_id,
"resource": contract["resource"],
},
)
assert after_revoke.status_code == 400
assert after_revoke.json()["error"] == "invalid_grant"
stranger = client.post(
contract["revocation_endpoint"], data={"token": "whatever", "client_id": "llm_dcrc_not_a_client"}
)
assert stranger.status_code == 401
assert stranger.json()["error"] == "invalid_client"
@pytest.mark.parametrize(
"jwt_auth_enabled, virtual_key_claim_field, exchange_servable",
[(True, None, True), (False, None, False), (True, "client_id", False)],
ids=["jwt auth on", "jwt auth off", "jwts mapped to virtual keys"],
)
def test_discovery_advertises_the_exchange_grant_only_where_the_gateway_can_serve_it(
monkeypatch, jwt_auth_enabled, virtual_key_claim_field, exchange_servable
):
"""Every document a native client reads before it picks a grant (the versioned contract, the
aggregate authorization-server metadata, and the registration response) lists the RFC 8693
exchange exactly when the running proxy can serve it: JWT auth on, a database, a license, and
no JWT-to-virtual-key mapping, since the exchange would mint past the mapped key's policy."""
from litellm.caching.caching import DualCache
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
client, _session_cookie, _minted = _native_client_app(monkeypatch)
handler: Final = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(virtual_key_claim_field=virtual_key_claim_field),
)
monkeypatch.setattr("litellm.proxy.proxy_server.jwt_handler", handler)
monkeypatch.setattr(
"litellm.proxy.proxy_server.general_settings", {"enable_jwt_auth": jwt_auth_enabled, "supported_db_objects": []}
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", object())
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", True)
exchange_grant = ["urn:ietf:params:oauth:grant-type:token-exchange"] if exchange_servable else []
expected = ["authorization_code", "refresh_token", *exchange_grant]
assert client.get("/.well-known/litellm-cli-auth").json()["grant_types_supported"] == expected
assert client.get("/.well-known/oauth-authorization-server/mcp").json()["grant_types_supported"] == expected
registered = client.post("/register", json={"redirect_uris": ["http://127.0.0.1:51234/callback"]})
assert registered.status_code == 201
assert registered.json()["grant_types"] == expected
def test_native_client_authorize_without_the_proxy_resource_keeps_the_mcp_flow(monkeypatch):
"""A registered client asking for the MCP resource (or no resource) never sees the consent
page, so existing MCP clients are untouched by the native-client arm."""
client, session_cookie, minted = _native_client_app(monkeypatch)
registered = client.post("/register", json={"redirect_uris": ["http://127.0.0.1:51234/callback"]})
client.cookies.set("token", session_cookie)
for resource in (None, "http://testserver/mcp"):
params = {
"response_type": "code",
"client_id": registered.json()["client_id"],
"redirect_uri": "http://127.0.0.1:51234/callback",
"state": "s",
"code_challenge": _s256("v" * 43),
"code_challenge_method": "S256",
**({"resource": resource} if resource else {}),
}
response = client.get("/authorize", params=params, follow_redirects=False)
assert 'name="decision"' not in response.text
assert "team-b" not in response.text
assert minted == []
def test_introspect_route_requires_virtual_key_auth_and_is_advertised():
"""RFC 7662 section 2.1: introspection must not be anonymous. Pins the route-level
user_api_key_auth dependency (structure, so removing it fails here without a proxy),
and that the aggregate AS metadata advertises the endpoint for discovery."""
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.mcp_server.discoverable_endpoints import router
route = next(r for r in router.routes if isinstance(r, APIRoute) and r.path == "/introspect")
assert route.methods == {"POST"}
assert any(dependency.call is user_api_key_auth for dependency in route.dependant.dependencies)
from litellm.proxy._types import LiteLLMRoutes
assert "/introspect" in LiteLLMRoutes.mcp_routes.value
from litellm.proxy._lazy_features import LAZY_FEATURES
discoverable = next(feature for feature in LAZY_FEATURES if feature.name == "mcp_discoverable")
assert "/introspect" in discoverable.path_prefixes
app = FastAPI()
app.include_router(router)
client = TestClient(app)
asm = client.get("/.well-known/oauth-authorization-server/mcp")
assert asm.json()["introspection_endpoint"] == "http://testserver/introspect"
def test_introspect_route_answers_for_authenticated_caller(monkeypatch):
"""End-to-end over the real route with the auth dependency satisfied: a garbage token
is active false, a freshly minted session access token is active true with its claims."""
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.testclient import TestClient
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.outbound_credentials.session_credentials import (
session_keys_from_master_key,
)
from litellm.proxy.mcp_server.outbound_credentials.session_token import (
SessionPrincipal,
mint_session_token,
)
introspect_master_key = "sk-introspect-route-test"
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", introspect_master_key, raising=False)
async def fake_reload(user_id: str):
return None
monkeypatch.setattr(
"litellm.proxy.mcp_server.discoverable_endpoints.reload_active_user_by_id", fake_reload
)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth()
client = TestClient(app)
garbage = client.post("/introspect", data={"token": "llm_session_garbage"})
assert garbage.status_code == 200
assert garbage.json() == {"active": False}
minted = mint_session_token(
SessionPrincipal(user_id="u1", client_id="llm_dcrc_client"),
session_keys_from_master_key(introspect_master_key),
datetime.now(timezone.utc),
)
active = client.post("/introspect", data={"token": minted.token.get_secret_value()})
assert active.status_code == 200
assert active.json()["active"] is True
assert active.json()["sub"] == "u1"
@pytest.mark.asyncio
async def test_identity_bound_authorization_carries_nonce_and_caller_through_callback(monkeypatch):
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlparse
from fastapi import Request
from litellm.proxy.mcp_server.discoverable_endpoints import (
_oauth_state_cookie_name,
authorize_with_server,
callback,
open_bridge_authorization_code,
)
from litellm.types.mcp import MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
monkeypatch.setenv("LITELLM_SALT_KEY", "identity-binding-test-salt")
server = MCPServer(
server_id="srv",
name="srv",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
client_id="client",
authorization_url="https://idp.example.com/authorize",
token_url="https://idp.example.com/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://idp.example.com",
audiences=["client"],
),
)
request = Request(
{
"type": "http",
"scheme": "https",
"server": ("proxy.example.com", 443),
"path": "/authorize",
"query_string": b"",
"headers": [(b"authorization", b"Bearer sk-alice")],
}
)
with (
patch( # test-quality-ok: isolate authenticated request resolution from the real encrypted OAuth round trip
"litellm.proxy.mcp_server.discoverable_endpoints.authorize_oauth_credential_request",
new=AsyncMock(return_value="alice"),
),
patch( # test-quality-ok: isolate user access lookup while testing nonce and caller preservation
"litellm.proxy.mcp_server.discoverable_endpoints._user_can_reach_mcp_server",
new=AsyncMock(return_value=True),
),
):
authorized = await authorize_with_server(
request,
server,
"client",
"http://127.0.0.1:6274/callback",
state="client-state",
code_challenge="pkce-challenge",
code_challenge_method="S256",
)
query = parse_qs(urlparse(authorized.headers["location"]).query)
assert len(query["nonce"][0]) >= 32
cookies = SimpleCookie()
cookies.load(authorized.headers["set-cookie"])
name = _oauth_state_cookie_name(query["state"][0])
callback_request = Request(
{**request.scope, "path": "/callback", "headers": [(b"cookie", f"{name}={cookies[name].value}".encode())]}
)
completed = await callback(callback_request, code="upstream-code", state=query["state"][0])
returned = parse_qs(urlparse(completed.headers["location"]).query)
sealed = open_bridge_authorization_code(returned["code"][0])
assert sealed.litellm_user_id == "alice"
assert sealed.mcp_server_id == "srv"
assert sealed.upstream_code == "upstream-code"
assert sealed.oauth_nonce == query["nonce"][0]
assert returned["state"] == ["client-state"]
@pytest.mark.asyncio
async def test_enforced_login_warms_verified_token_readable_without_database_lookup(monkeypatch):
from types import SimpleNamespace
from litellm.caching.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db, mcp_server_manager
from litellm.proxy.mcp_server.discoverable_endpoints import _store_per_user_token_server_side
from litellm.proxy.mcp_server.oauth2_token_cache import mcp_per_user_token_cache
from litellm.proxy.mcp_server.oauth_identity_binding import current_binding_proof
from litellm.proxy.mcp_server.outbound_credentials.dual_cache_token_backend import (
DualCacheTokenCacheBackend,
)
from litellm.proxy.mcp_server.outbound_credentials.oauth_token_store import CachedOAuthTokenStore
from litellm.proxy.mcp_server.outbound_credentials.per_user_oauth_store import (
LazyPerUserOAuthTokenStore,
)
from litellm.proxy.mcp_server.outbound_credentials.v2_token_store import V2PerUserTokenStore
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="srv",
name="srv",
transport="http",
auth_type="oauth2",
oauth_identity_binding={
"mode": "enforce",
"issuer": "https://idp.example",
"audiences": ["client"],
"caller_field": "user_id",
"principal_claim": "sub",
},
)
proof = await current_binding_proof(server.oauth_identity_binding, "alice", "srv")
cache = DualCache()
monkeypatch.setenv("LITELLM_SALT_KEY", "test-cache-warm-encryption-salt")
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
monkeypatch.setattr(
mcp_server_manager, "global_mcp_server_manager", SimpleNamespace(invalidate_user_oauth_token_cache=AsyncMock())
)
monkeypatch.setattr(db, "store_user_oauth_credential", AsyncMock())
await _store_per_user_token_server_side(
server=server,
user_id="alice",
token_response={"access_token": "alice-token", "refresh_token": "private", "expires_in": 3600},
identity_binding_proof=proof,
)
read = AsyncMock(return_value=None)
cached = CachedOAuthTokenStore(
V2PerUserTokenStore(read),
default_ttl_seconds=300,
backend=DualCacheTokenCacheBackend(cache, mcp_per_user_token_cache._codec()),
)
store = LazyPerUserOAuthTokenStore(
lambda _: server, store_builder=lambda _: (cached, True), redis_available=lambda: True
)
token = await store.fetch("alice", "srv")
assert token is not None and token.access_token == "alice-token"
assert token.identity_binding_proof == proof
assert token.refresh_token is None
read.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("upstream_status", [401, 403])
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
@pytest.mark.parametrize("dcr_bridge", [False, True])
@pytest.mark.parametrize("flow", ["register", "mint"])
async def test_dcr_refusal_is_actionable_without_upstream_body(
upstream_status: int, auth_type: MCPAuth, dcr_bridge: bool, flow: str, monkeypatch: pytest.MonkeyPatch
) -> None:
from typing import Final
import httpx
from litellm.proxy.mcp_server.discoverable_endpoints import (
mint_ephemeral_dcr_client,
register_client_with_server,
)
server: Final = _bridge_server(
auth_type=auth_type,
dcr_bridge=dcr_bridge,
server_id=f"refused-{auth_type}-{dcr_bridge}-{flow}-{upstream_status}",
client_id=None,
)
import respx
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
with respx.mock as upstream:
registration: Final = upstream.post(server.registration_url).mock(
return_value=httpx.Response(upstream_status, text="Forbidden private upstream details")
)
operation: Final = (
mint_ephemeral_dcr_client(_bridge_mock_request(), server)
if flow == "mint"
else register_client_with_server(
request=_bridge_mock_request(),
mcp_server=server,
client_name="Test client",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
client_redirect_uris=["http://localhost:9999/callback"],
)
)
with pytest.raises(HTTPException) as exc:
await operation
assert registration.call_count == 1
assert exc.value.status_code == 403
assert f"HTTP {upstream_status}" in str(exc.value.detail)
assert "pre-registered OAuth client" in str(exc.value.detail)
assert "private upstream details" not in str(exc.value.detail)
@pytest.mark.parametrize("prefix", ["", "/tenant-a", "/tenant-b"])
@pytest.mark.parametrize(
("server_name", "pattern"),
[
("issuer_test", "mcp/{server}"),
("issuer_test", "{server}/mcp"),
("issuer_test", "{server}"),
("mcp", "mcp/{server}"),
("mcp", "{server}/mcp"),
],
)
def test_per_server_authorization_metadata_issuer_matches_discovery_path(
_no_proxy_base_url, _isolated_mcp_registry, prefix, server_name, pattern
):
server = _create_oauth2_server(server_id=server_name, name=server_name, server_name=server_name, alias=server_name)
_isolated_mcp_registry[server.server_id] = server
client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"])
path = pattern.format(server=server_name)
response = client.get(f"{prefix}/.well-known/oauth-authorization-server/{path}")
assert response.status_code == 200
metadata = response.json()
assert metadata["issuer"] == f"http://testserver{prefix}/{path}"
assert metadata["authorization_endpoint"] == f"http://testserver{prefix}/{server_name}/authorize"
assert metadata["token_endpoint"] == f"http://testserver{prefix}/{server_name}/token"
assert metadata["registration_endpoint"] == f"http://testserver{prefix}/{server_name}/register"
@pytest.mark.parametrize("prefix", ["", "/tenant-a"])
@pytest.mark.parametrize("relay", [False, True])
@pytest.mark.parametrize("pattern", ["mcp/{server}", "{server}/mcp"])
def test_named_resource_discovery_follows_matching_authorization_issuer(
_no_proxy_base_url, _isolated_mcp_registry, prefix, relay, pattern
):
server = _create_oauth2_server().model_copy(update={"per_server_oauth_discovery": relay})
_isolated_mcp_registry[server.server_id] = server
client = _prefixed_discovery_client(["/tenant-a"])
path = pattern.format(server=server.server_name)
response = client.get(f"{prefix}/.well-known/oauth-protected-resource/{path}")
assert response.status_code == 200
resource = response.json()
issuer_path = server.server_name if relay else "mcp"
assert resource["resource"] == f"http://testserver{prefix}/{path}"
assert resource["authorization_servers"] == [f"http://testserver{prefix}/{issuer_path}"]
authorization = client.get(f"{prefix}/.well-known/oauth-authorization-server/{issuer_path}")
assert authorization.status_code == 200
assert authorization.json()["issuer"] == resource["authorization_servers"][0]
@pytest.fixture
def _gateway_root_path_discovery_router(monkeypatch: pytest.MonkeyPatch) -> "Iterator[APIRouter]":
"""The ``.well-known`` routes bake ``SERVER_ROOT_PATH`` into their paths when the module is
executed, so it is re-executed under the gateway env and its namespace restored afterwards
(a second reload would hand earlier importers a different ``router`` object)."""
import importlib
from litellm.proxy.mcp_server import discoverable_endpoints
snapshot: Final = dict(vars(discoverable_endpoints))
monkeypatch.setenv("SERVER_ROOT_PATH", "/gateway")
monkeypatch.setenv("PROXY_BASE_URL", "https://llm.example.test/gateway")
try:
yield importlib.reload(discoverable_endpoints).router
finally:
added_keys: Final = [key for key in vars(discoverable_endpoints) if key not in snapshot]
for key in added_keys:
delattr(discoverable_endpoints, key)
vars(discoverable_endpoints).update(snapshot)
def test_static_root_path_authorization_discovery_preserves_issuer(
_gateway_root_path_discovery_router: "APIRouter", _isolated_mcp_registry: "dict[str, MCPServer]"
) -> None:
from fastapi import FastAPI
from fastapi.testclient import TestClient
server: Final = _create_oauth2_server(server_id="example", name="example", server_name="example", alias="example")
_isolated_mcp_registry[server.server_id] = server # rebind-ok: the fixture hands the test an empty registry to fill
app: Final = FastAPI(root_path="/gateway")
app.include_router(_gateway_root_path_discovery_router)
with TestClient(app) as client:
responses: Final = {
path: client.get(f"/.well-known/oauth-authorization-server/gateway/{path}")
for path in ("mcp/example", "example/mcp", "example", "mcp")
}
for path, response in responses.items():
assert response.status_code == 200, response.text
assert response.json()["issuer"] == f"https://llm.example.test/gateway/{path}"
assert responses["example/mcp"].json()["token_endpoint"] == "https://llm.example.test/gateway/example/token"
@pytest.fixture
def jwt_oauth_identity(monkeypatch: pytest.MonkeyPatch) -> tuple["JWTHandler", "RSAPrivateKey"]:
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_JWTAuth
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
signing_key: Final = rsa.generate_private_key(public_exponent=65537, key_size=2048)
cache: Final = UserApiKeyCache()
cache.set_cache(
"litellm_jwt_auth_keys_https://idp.example.test/jwks",
[json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key()))],
)
cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", user_email="owner@example.test"))
handler: Final = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=cache,
litellm_jwtauth=LiteLLM_JWTAuth(user_id_jwt_field="identity.user_id"),
)
monkeypatch.setenv("JWT_PUBLIC_KEY_URL", "https://idp.example.test/jwks")
monkeypatch.setenv("JWT_ISSUER", "https://idp.example.test")
monkeypatch.setenv("JWT_AUDIENCE", "litellm-proxy")
monkeypatch.setattr(proxy_server, "jwt_handler", handler)
monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": True})
monkeypatch.setattr(proxy_server, "premium_user", True)
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
prisma: Final = MagicMock()
prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
prisma.db.litellm_mcpusercredentials.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
return handler, signing_key
def _oauth_identity_jwt(
signing_key: "RSAPrivateKey",
*,
expires_in: int = 300,
audience: str = "litellm-proxy",
issuer: str = "https://idp.example.test",
owner: str | None = "jwt-owner",
scope: str = "",
claims: dict[str, object] | None = None,
) -> str:
import jwt
return jwt.encode(
{
"sub": "not-the-configured-user-id",
"identity": {"user_id": owner},
"email": "owner@example.test",
"iss": issuer,
"aud": audience,
"exp": int(time.time()) + expires_in,
"scope": scope,
**(claims or {}),
},
signing_key,
algorithm="RS256",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("header", ["Authorization", "x-litellm-api-key"])
@pytest.mark.parametrize("policy_allowed", [False, True])
@pytest.mark.parametrize("server_allowed", [False, True])
@pytest.mark.parametrize("admin", [False, True])
@pytest.mark.parametrize("owner_state", ["active", "missing", "inactive", "database_error"])
async def test_oauth_exchange_stores_token_for_validated_jwt_user(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
header: str,
policy_allowed: bool,
server_allowed: bool,
admin: bool,
owner_state: str,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import httpx
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.types.mcp_server.mcp_server_manager import MCPServer
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.custom_validate = lambda claims: policy_allowed
from litellm.proxy.mcp_server import mcp_server_manager
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(return_value=["jwt-oauth-server"] if server_allowed else [])
manager.invalidate_user_oauth_token_cache = AsyncMock()
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
bearer: Final = _oauth_identity_jwt(signing_key, scope="litellm_proxy_admin" if admin else "")
request: Final = _token_request({header: f"Bearer {bearer}"}, path="/jwt-oauth-server/token")
server: Final = MCPServer(
server_id="jwt-oauth-server",
name="jwt-oauth-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
authorization_url="https://upstream.example.test/authorize",
token_url="https://upstream.example.test/token",
client_id="registered-client",
)
import litellm
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.types.llms.custom_http import httpxSpecialProvider
def upstream_response(outbound: httpx.Request) -> httpx.Response:
assert outbound.url == server.token_url
assert bearer not in str(outbound.headers)
assert bearer.encode() not in outbound.content
return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"})
database: Final = MagicMock()
users: Final = database.db.litellm_usertable
users.find_unique = AsyncMock(return_value=None)
users.find_first = AsyncMock(return_value=None)
users.create = AsyncMock()
if owner_state in ("missing", "database_error"):
handler.user_api_key_cache.delete_cache("jwt-owner")
if owner_state == "database_error":
users.find_unique.side_effect = RuntimeError("database unavailable")
if owner_state == "inactive":
handler.user_api_key_cache.set_cache(
"jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False})
)
table: Final = database.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=None)
table.upsert = AsyncMock()
monkeypatch.setattr(proxy_server, "prisma_client", database)
monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-jwt-test-encryption-key")
clients: Final = LLMClientCache()
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport:
upstream: Final = AsyncHTTPHandler()
await upstream.client.aclose()
upstream.client = transport
clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream)
response: Final = await discoverable_endpoints.exchange_token_with_server(
request=request,
mcp_server=server,
grant_type="authorization_code",
code="upstream-code",
redirect_uri="http://localhost/callback",
client_id="registered-client",
client_secret=None,
code_verifier=None,
)
assert response.status_code == 200
assert json.loads(response.body)["access_token"] == "upstream-token"
users.create.assert_not_awaited()
if (
not server_allowed
or not policy_allowed
or owner_state in ("inactive", "database_error")
or (owner_state == "missing" and not admin)
):
table.upsert.assert_not_awaited()
return
table.upsert.assert_awaited_once()
stored: Final = table.upsert.call_args.kwargs
assert stored["where"] == {"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}}
credential: Final = stored["data"]["create"]["credential_b64"]
assert "upstream-token" not in credential
decoded: Final = decrypt_value_helper(credential, key="mcp_user_credential")
assert json.loads(decoded)["access_token"] == "upstream-token"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"rejection",
[
"expired",
"audience",
"issuer",
"signature",
"missing_user",
"unknown_user",
"disabled",
"not_premium",
"scim_inactive",
"custom_validate",
"missing_database",
],
)
@pytest.mark.parametrize("credential_write", [False, True])
async def test_oauth_jwt_identity_rejects_untrusted_or_inactive_owner(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
rejection: str,
credential_write: bool,
) -> None:
from cryptography.hazmat.primitives.asymmetric import rsa
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import mcp_server_manager
from litellm.proxy.mcp_server.bridge_token_flow import (
authorize_oauth_credential_request,
extract_user_id_from_request,
)
allowed_servers: Final = AsyncMock(return_value=["server-a"])
monkeypatch.setattr(mcp_server_manager.global_mcp_server_manager, "get_allowed_mcp_servers", allowed_servers)
handler, signing_key = jwt_oauth_identity
key: Final = (
rsa.generate_private_key(public_exponent=65537, key_size=2048) if rejection == "signature" else signing_key
)
bearer: Final = _oauth_identity_jwt(
key,
expires_in=-60 if rejection == "expired" else 300,
audience="upstream-only" if rejection == "audience" else "litellm-proxy",
issuer="https://untrusted.example.test" if rejection == "issuer" else "https://idp.example.test",
owner=None if rejection == "missing_user" else "unknown" if rejection == "unknown_user" else "jwt-owner",
)
if rejection == "disabled":
monkeypatch.setattr(proxy_server, "general_settings", {"enable_jwt_auth": False})
if rejection == "not_premium":
monkeypatch.setattr(proxy_server, "premium_user", False)
if rejection == "missing_database":
monkeypatch.setattr(proxy_server, "prisma_client", None)
if rejection == "scim_inactive":
handler.user_api_key_cache.set_cache(
"jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", metadata={"scim_active": False})
)
if rejection == "custom_validate":
handler.litellm_jwtauth.custom_validate = lambda claims: False
request: Final = _token_request({"Authorization": f"Bearer {bearer}"})
result: Final = (
await authorize_oauth_credential_request(request, "server-a")
if credential_write
else await extract_user_id_from_request(request)
)
assert result is None
allowed_servers.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("blocked", [False, True])
async def test_oauth_jwt_cannot_override_explicit_litellm_key(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
blocked: bool,
) -> None:
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
key: Final = "sk-explicit-key"
handler.user_api_key_cache.set_cache(hash_token(key), UserAPIKeyAuth(user_id="key-owner", blocked=blocked))
request: Final = _token_request(
{
"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}",
"x-litellm-api-key": key,
}
)
assert await extract_user_id_from_request(request) == (None if blocked else "key-owner")
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mapping", ["active", "blocked", "inactive_owner", "fallback", "pending", "reject", "custom_reject"]
)
async def test_oauth_jwt_uses_configured_virtual_key_owner(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
mapping: str,
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._types import UnregisteredJWTClientBehavior, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import jwt_key_mapping_cache_key
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.virtual_key_claim_field = "sub"
if mapping == "custom_reject":
handler.litellm_jwtauth.custom_validate = lambda claims: False
handler.litellm_jwtauth.unregistered_jwt_client_behavior = (
UnregisteredJWTClientBehavior.AUTO_REGISTER
if mapping == "pending"
else UnregisteredJWTClientBehavior.REJECT
if mapping == "reject"
else UnregisteredJWTClientBehavior.FALLBACK_TEAM_MAPPING
)
key_hash: Final = hash_token("sk-mapped-oauth-owner")
handler.user_api_key_cache.set_cache(
jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"),
"__NO_MAPPING__" if mapping in ("fallback", "pending", "reject") else key_hash,
)
handler.user_api_key_cache.set_cache(
key_hash, UserAPIKeyAuth(token=key_hash, user_id="mapped-owner", blocked=mapping == "blocked")
)
handler.user_api_key_cache.set_cache(
"mapped-owner", LiteLLM_UserTable(user_id="mapped-owner", metadata={"scim_active": mapping != "inactive_owner"})
)
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
expected: Final = "jwt-owner" if mapping == "fallback" else "mapped-owner" if mapping == "active" else None
assert await extract_user_id_from_request(request) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed_domain", [None, "allowed.example.test"])
async def test_oauth_jwt_respects_custom_validation_and_email_policy(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
allowed_domain: str | None,
) -> None:
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.custom_validate = lambda claims: True
handler.litellm_jwtauth.user_allowed_email_domain = allowed_domain
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
assert await extract_user_id_from_request(request) == (None if allowed_domain else "jwt-owner")
@pytest.mark.asyncio
@pytest.mark.parametrize("route_allowed", [False, True])
async def test_oauth_jwt_identity_preserves_separate_mcp_route_authorization(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
route_allowed: bool,
) -> None:
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, RoleBasedPermissions, RoleMapping
from litellm.proxy.auth.handle_jwt import JWTAuthManager
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.user_id_jwt_field = "sub"
handler.litellm_jwtauth.roles_jwt_field = "aud"
handler.litellm_jwtauth.object_id_jwt_field = "identity.user_id"
handler.litellm_jwtauth.role_mappings = [
RoleMapping(role="litellm-proxy", internal_role=LitellmUserRoles.INTERNAL_USER)
]
handler.litellm_jwtauth.enforce_rbac = True
monkeypatch.setattr(
proxy_server,
"general_settings",
{
"enable_jwt_auth": True,
"role_permissions": [
RoleBasedPermissions(
role=LitellmUserRoles.INTERNAL_USER,
routes=["mcp_routes"] if route_allowed else ["/models"],
)
],
},
)
bearer: Final = _oauth_identity_jwt(signing_key)
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/example/token")
assert await extract_user_id_from_request(request) == "jwt-owner"
admission: Final = JWTAuthManager.auth_builder(
api_key=bearer,
jwt_handler=handler,
request_data={},
general_settings=proxy_server.general_settings,
route="/mcp/example",
prisma_client=proxy_server.prisma_client,
user_api_key_cache=handler.user_api_key_cache,
parent_otel_span=None,
proxy_logging_obj=proxy_server.proxy_logging_obj,
request_method="POST",
)
if route_allowed:
assert (await admission)["user_id"] == "jwt-owner"
else:
with pytest.raises(HTTPException) as denial:
await admission
assert denial.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("identity", ["sso", "email"])
@pytest.mark.parametrize("inactive", [False, True])
@pytest.mark.parametrize("admin", [False, True])
async def test_oauth_jwt_resolves_canonical_owner_without_cached_identity(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
identity: str,
inactive: bool,
admin: bool,
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.auth.handle_jwt import JWTAuthManager
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
external_id: Final = f"external-{identity}-{inactive}-{admin}"
handler.litellm_jwtauth.user_email_jwt_field = "email"
handler.litellm_jwtauth.admin_allowed_routes = ["mcp_routes"]
owner: Final = LiteLLM_UserTable(
user_id="canonical-oauth-owner",
user_email="owner@example.test",
metadata={"scim_active": not inactive},
organization_memberships=[],
)
database: Final = MagicMock()
table: Final = database.db.litellm_usertable
table.find_unique = AsyncMock(side_effect=[None, owner if identity == "sso" else None])
table.find_first = AsyncMock(return_value=owner)
table.update = AsyncMock(return_value=owner)
monkeypatch.setattr(proxy_server, "prisma_client", database)
bearer: Final = _oauth_identity_jwt(signing_key, owner=external_id, scope="litellm_proxy_admin" if admin else "")
request: Final = _token_request({"Authorization": f"Bearer {bearer}"})
stored_owner: Final = await extract_user_id_from_request(request)
assert stored_owner == (None if inactive else external_id if admin else "canonical-oauth-owner")
assert table.find_unique.await_count == 2
if identity == "email":
table.find_first.assert_awaited_once()
if not inactive:
admission: Final = await JWTAuthManager.auth_builder(
api_key=bearer,
jwt_handler=handler,
request_data={},
general_settings=proxy_server.general_settings,
route="/mcp/example",
prisma_client=database,
user_api_key_cache=handler.user_api_key_cache,
parent_otel_span=None,
proxy_logging_obj=proxy_server.proxy_logging_obj,
)
assert stored_owner == admission["user_id"]
@pytest.mark.asyncio
async def test_oauth_jwt_identity_does_not_provision_or_synchronize_teams(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server.bridge_token_flow import extract_user_id_from_request
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.enforce_team_based_model_access = True
handler.litellm_jwtauth.team_id_default = "new-team"
handler.litellm_jwtauth.team_id_upsert = True
handler.litellm_jwtauth.sync_user_role_and_teams = True
owner: Final = LiteLLM_UserTable(user_id="jwt-owner", teams=["existing-team"])
handler.user_api_key_cache.set_cache("jwt-owner", owner)
request: Final = _token_request(
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"}, path="/example/token"
)
assert await extract_user_id_from_request(request) == "jwt-owner"
assert owner.teams == ["existing-team"]
proxy_server.prisma_client.db.litellm_teamtable.find_unique.assert_not_called()
proxy_server.prisma_client.db.litellm_teamtable.upsert.assert_not_called()
proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("state", ["active", "inactive", "missing_database"])
async def test_oauth_refresh_revalidates_the_same_active_user_rule(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
state: str,
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server.bridge_token_flow import reload_active_user_by_id
handler, _ = jwt_oauth_identity
user_id: Final = f"jwt-owner-{state}"
row: Final = LiteLLM_UserTable(user_id=user_id, metadata={"scim_active": state != "inactive"})
proxy_server.prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=row)
if state == "missing_database":
monkeypatch.setattr(proxy_server, "prisma_client", None)
expected: Final = None if state == "active" else "no_active_key" if state == "inactive" else "unresolvable"
assert await reload_active_user_by_id(user_id) == expected
if state != "missing_database":
cached: Final = handler.user_api_key_cache.get_cache(user_id, model_type=LiteLLM_UserTable)
assert cached is not None
assert cached.metadata == row.metadata
@pytest.mark.asyncio
@pytest.mark.parametrize("mapped", [False, True])
@pytest.mark.parametrize(
"state", ["allowed", "route_denied", "server_denied", "blocked", "expired", "lookup_error", "cancelled"]
)
async def test_oauth_credential_write_keeps_virtual_key_permissions(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
mapped: bool,
state: str,
) -> None:
import asyncio
from litellm.proxy._types import UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import OrganizationNotFoundError, jwt_key_mapping_cache_key
from litellm.proxy.mcp_server import mcp_server_manager
from litellm.proxy.mcp_server.bridge_token_flow import authorize_oauth_credential_request
handler, signing_key = jwt_oauth_identity
monkeypatch.setattr(
"litellm.proxy.auth.auth_checks.get_org_object",
AsyncMock(side_effect=OrganizationNotFoundError("Organization doesn't exist in db.")),
)
key: Final = "sk-oauth-permission-test"
hashed: Final = hash_token(key)
credential: Final = UserAPIKeyAuth(
token=hashed,
user_id="jwt-owner",
blocked=state == "blocked",
expires=datetime.now(timezone.utc) - timedelta(seconds=60) if state == "expired" else None,
allowed_routes=["openai_routes"] if state == "route_denied" else ["mcp_routes"],
agent_id="agent-scope",
org_id="org-scope",
end_user_id="end-user-scope",
)
handler.user_api_key_cache.set_cache(hashed, credential)
if mapped:
handler.litellm_jwtauth.virtual_key_claim_field = "sub"
handler.user_api_key_cache.set_cache(jwt_key_mapping_cache_key("sub", "not-the-configured-user-id"), hashed)
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(
return_value=[] if state == "server_denied" else ["server-a"],
side_effect=(
asyncio.CancelledError()
if state == "cancelled"
else RuntimeError("permission lookup unavailable")
if state == "lookup_error"
else None
),
)
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
bearer: Final = _oauth_identity_jwt(signing_key) if mapped else key
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path="/server-a/token")
if state == "cancelled":
with pytest.raises(asyncio.CancelledError):
await authorize_oauth_credential_request(request, "server-a")
manager.get_allowed_mcp_servers.assert_awaited_once()
return
assert await authorize_oauth_credential_request(request, "server-a") == (
"jwt-owner" if state == "allowed" else None
)
if state in ("allowed", "server_denied", "lookup_error"):
manager.get_allowed_mcp_servers.assert_awaited_once()
writer: Final = manager.get_allowed_mcp_servers.call_args.args[0]
assert (writer.user_id, writer.token, writer.org_id, writer.agent_id, writer.end_user_id) == (
"jwt-owner",
hashed,
"org-scope",
"agent-scope",
"end-user-scope",
)
@pytest.mark.asyncio
@pytest.mark.parametrize("server_id", ["team-a-server", "team-b-server"])
async def test_oauth_writer_preserves_claimed_team_instead_of_expanding_user_roster(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
server_id: str,
) -> None:
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_TeamTable, Member
from litellm.proxy.mcp_server import mcp_server_manager
from litellm.proxy.mcp_server.bridge_token_flow import authorize_oauth_credential_request
handler, signing_key = jwt_oauth_identity
handler.litellm_jwtauth.team_id_jwt_field = "team"
handler.litellm_jwtauth.team_id_upsert = True
handler.litellm_jwtauth.user_id_upsert = True
handler.litellm_jwtauth.sync_user_role_and_teams = True
handler.user_api_key_cache.set_cache("jwt-owner", LiteLLM_UserTable(user_id="jwt-owner", teams=["a", "b"]))
handler.user_api_key_cache.set_cache(
"team_id:a",
LiteLLM_TeamTable(team_id="a", models=[], members_with_roles=[Member(user_id="jwt-owner", role="user")]),
)
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(return_value=["team-a-server"])
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
bearer: Final = _oauth_identity_jwt(signing_key, claims={"team": "a"})
request: Final = _token_request({"Authorization": f"Bearer {bearer}"}, path=f"/{server_id}/token")
assert await authorize_oauth_credential_request(request, server_id) == (
"jwt-owner" if server_id == "team-a-server" else None
)
manager.get_allowed_mcp_servers.assert_awaited_once()
writer: Final = manager.get_allowed_mcp_servers.call_args.args[0]
assert writer.team_id == "a"
assert not writer.mcp_admitted_user_subject
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
proxy_server.prisma_client.db.litellm_usertable.update.assert_not_called()
assert handler.litellm_jwtauth.user_id_upsert and handler.litellm_jwtauth.team_id_upsert
assert handler.litellm_jwtauth.sync_user_role_and_teams
@pytest.mark.asyncio
async def test_oauth_write_denial_does_not_erase_identity_binding(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
) -> None:
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
_, signing_key = jwt_oauth_identity
monkeypatch.setenv("LITELLM_SALT_KEY", "oauth-identity-binding-test-salt")
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(return_value=[])
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
server: Final = MCPServer(
server_id="bound-server",
name="bound-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id="client",
token_url="https://upstream.example.test/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://upstream.example.test",
audiences=["client"],
),
)
request: Final = _token_request({"Authorization": f"Bearer {_oauth_identity_jwt(signing_key)}"})
code: Final = discoverable_endpoints.seal_bridge_authorization_code(
"upstream-code",
"another-owner",
server.server_id,
"bound-nonce",
)
with pytest.raises(HTTPException) as denied:
await discoverable_endpoints.exchange_token_with_server(
request=request,
mcp_server=server,
grant_type="authorization_code",
code=code,
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier="verifier",
)
assert denied.value.status_code == 403
assert denied.value.detail == {"error": "oauth_principal_mismatch"}
manager.get_allowed_mcp_servers.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("admin_only", [False, True])
@pytest.mark.parametrize("cimd", [False, True])
async def test_signed_oauth_callback_honors_credential_write_policy(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
admin_only: bool,
cimd: bool,
) -> None:
import httpx
import litellm
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy import proxy_server
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server: Final = MCPServer(
server_id="signed-server",
name="signed-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id=None if cimd else "client",
client_id_metadata_document_supported=cimd,
token_url="https://upstream.example.test/token",
)
monkeypatch.setattr(
proxy_server,
"general_settings",
{
"enable_jwt_auth": True,
"admin_only_routes": [f"/v1/mcp/server/{server.server_id}/oauth-user-credential"] if admin_only else [],
},
)
monkeypatch.setenv("LITELLM_SALT_KEY", "signed-oauth-test-salt")
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(return_value=[server.server_id])
manager.invalidate_user_oauth_token_cache = AsyncMock()
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
table: Final = proxy_server.prisma_client.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=None)
table.upsert = AsyncMock()
clients: Final = LLMClientCache()
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
def upstream_response(outbound: httpx.Request) -> httpx.Response:
assert outbound.url == server.token_url
assert b"code=upstream-code" in outbound.content
return httpx.Response(200, json={"access_token": "upstream-token", "token_type": "Bearer"})
async with httpx.AsyncClient(transport=httpx.MockTransport(upstream_response)) as transport:
upstream: Final = AsyncHTTPHandler()
await upstream.client.aclose()
upstream.client = transport
clients.set_cache("async_httpx_client" + httpxSpecialProvider.Oauth2Check, upstream)
response: Final = await discoverable_endpoints.exchange_token_with_server(
request=_token_request({}, path="/signed-server/token"),
mcp_server=server,
grant_type="authorization_code",
code=discoverable_endpoints.seal_bridge_authorization_code("upstream-code", "jwt-owner", server.server_id),
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier=None,
)
assert response.status_code == 200
assert json.loads(response.body)["access_token"] == "upstream-token"
if admin_only:
table.upsert.assert_not_awaited()
else:
table.upsert.assert_awaited_once()
assert table.upsert.call_args.kwargs["where"]["user_id_server_id"] == {
"user_id": "jwt-owner",
"server_id": server.server_id,
}
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
payload: Final = json.loads(
decrypt_value_helper(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"], "credential_b64")
)
if cimd:
assert payload["cimd_client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
else:
assert "cimd_client_id" not in payload
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed", [False, True])
@pytest.mark.parametrize(
"credential",
[
"jwt",
"key",
"expired_jwt",
"wrong_audience",
"bad_signature",
"malformed_jwt",
"missing_issuer",
"foreign_explicit",
"blank_explicit",
"unknown_key",
"blocked_key",
"expired_key",
"opaque_record",
"opaque_outage",
"opaque_oidc",
"opaque_custom",
"foreign_unscoped",
"foreign_configured",
"encrypted",
"invalid_encrypted",
"envelope",
"master",
],
)
async def test_identity_bound_authorize_preserves_presented_jwt_permissions(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
allowed: bool,
credential: str,
) -> None:
from datetime import datetime, timedelta, timezone
from urllib.parse import parse_qs, urlparse
import jwt
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._types import JWTIssuerConfig, MCPTransport, UserAPIKeyAuth, hash_token
from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
handler, signing_key = jwt_oauth_identity
master: Final = "browser-session-test-signing-key-123456789"
monkeypatch.setattr(proxy_server, "master_key", master)
monkeypatch.setattr(proxy_server, "user_custom_auth", (lambda: None) if credential == "opaque_custom" else None)
handler.litellm_jwtauth.oidc_userinfo_enabled = credential == "opaque_oidc"
if credential == "foreign_unscoped":
monkeypatch.delenv("JWT_ISSUER")
if credential == "foreign_configured":
handler.litellm_jwtauth.issuers = [
JWTIssuerConfig(
issuer="https://unrelated.example.test",
jwks_url="https://idp.example.test/jwks",
audience="litellm-proxy",
user_id_jwt_field="identity.user_id",
)
]
proxy_server.prisma_client.get_data = AsyncMock(
return_value=None,
side_effect=RuntimeError("database unavailable") if credential == "opaque_outage" else None,
)
handler.user_api_key_cache.set_cache("cookie-owner", LiteLLM_UserTable(user_id="cookie-owner"))
key: Final = "opaque-record" if credential == "opaque_record" else "sk-browser-gateway-key"
if credential in ("key", "blocked_key", "expired_key", "opaque_record"):
handler.user_api_key_cache.set_cache(
hash_token(key),
UserAPIKeyAuth(
token=hash_token(key),
user_id="jwt-owner",
blocked=credential in ("blocked_key", "opaque_record"),
expires=datetime.now(timezone.utc) - timedelta(seconds=60) if credential == "expired_key" else None,
),
)
monkeypatch.setenv("LITELLM_SALT_KEY", "authorize-policy-test-salt")
server: Final = MCPServer(
server_id="bound-server",
name="bound-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id="client",
authorization_url="https://upstream.example.test/authorize",
token_url="https://upstream.example.test/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://upstream.example.test",
audiences=["client"],
),
)
manager: Final = MagicMock()
# The full user roster permits the server; the presented JWT may have narrower access.
manager.get_allowed_mcp_servers = AsyncMock(
side_effect=lambda auth: [server.server_id] if allowed or auth.mcp_admitted_user_subject else [],
)
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
bearer: Final = (
key
if credential in ("key", "blocked_key", "expired_key", "opaque_record", "unknown_key")
else "opaque-bearer"
if credential in ("opaque_outage", "opaque_oidc", "opaque_custom")
else "not.a.jwt"
if credential == "malformed_jwt"
else "llm_env_invalid"
if credential == "envelope"
else "v2:gcm:invalid"
if credential == "invalid_encrypted"
else master
if credential == "master"
else ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(
LiteLLM_UserTable(user_id="jwt-owner", user_role="internal_user"),
)
if credential == "encrypted"
else jwt.encode({"iss": "https://idp.example.test"}, "wrong-signing-key-at-least-32-bytes", algorithm="HS256")
if credential == "bad_signature"
else jwt.encode({"sub": "jwt-owner"}, signing_key, algorithm="RS256")
if credential == "missing_issuer"
else _oauth_identity_jwt(
signing_key,
expires_in=-60 if credential == "expired_jwt" else 300,
audience="another-service" if credential == "wrong_audience" else "litellm-proxy",
issuer="https://unrelated.example.test"
if credential.startswith("foreign_") or credential == "blank_explicit"
else "https://idp.example.test",
)
)
cookie: Final = _browser_session_cookie(monkeypatch, "cookie-owner", signing_key=master)
response: Final = await discoverable_endpoints.authorize_with_server(
request=_token_request(
{
"Authorization": f"Bearer {bearer}",
"Cookie": f"token={cookie}",
**({"x-litellm-api-key": bearer} if credential == "foreign_explicit" else {}),
**({"x-litellm-api-key": ""} if credential == "blank_explicit" else {}),
}
),
mcp_server=server,
client_id="client",
redirect_uri="http://127.0.0.1:6274/callback",
state="client-state",
code_challenge="pkce-challenge",
code_challenge_method="S256",
)
redirect: Final = urlparse(response.headers["location"])
query: Final = parse_qs(redirect.query)
if allowed and credential in ("jwt", "key", "foreign_unscoped", "foreign_configured"):
assert redirect.hostname == "upstream.example.test"
assert query["nonce"] and response.headers.get("set-cookie")
assert all(call.args[0].user_id == "jwt-owner" for call in manager.get_allowed_mcp_servers.await_args_list)
else:
assert redirect.hostname == "127.0.0.1"
assert query["error"] == ["access_denied"]
assert query["state"] == ["client-state"]
assert "set-cookie" not in response.headers
proxy_server.prisma_client.db.litellm_mcpusercredentials.upsert.assert_not_called()
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("credential", ["none", "opaque", "foreign_jwt"])
@pytest.mark.parametrize("cookie_state", ["allowed", "server_denied", "expired", "missing", "session_expired"])
async def test_identity_bound_authorize_unrelated_bearer_uses_browser_session(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
credential: str,
cookie_state: str,
) -> None:
from urllib.parse import parse_qs, urlparse
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy import proxy_server
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints, mcp_server_manager
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthIdentityBinding, MCPServer
handler, signing_key = jwt_oauth_identity
master: Final = "browser-session-test-signing-key-123456789"
monkeypatch.setattr(proxy_server, "master_key", master)
monkeypatch.setattr(proxy_server, "user_custom_auth", None)
monkeypatch.setenv("LITELLM_SALT_KEY", "authorize-policy-test-salt")
handler.user_api_key_cache.set_cache("cookie-owner", LiteLLM_UserTable(user_id="cookie-owner"))
proxy_server.prisma_client.get_data = AsyncMock(return_value=None)
server: Final = MCPServer(
server_id="bound-server",
name="bound-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
client_id="client",
authorization_url="https://upstream.example.test/authorize",
token_url="https://upstream.example.test/token",
oauth_identity_binding=MCPOAuthIdentityBinding(
mode="enforce",
issuer="https://upstream.example.test",
audiences=["client"],
),
)
manager: Final = MagicMock()
manager.get_allowed_mcp_servers = AsyncMock(
return_value=[] if cookie_state == "server_denied" else [server.server_id]
)
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
bearer: Final = (
_oauth_identity_jwt(signing_key, issuer="https://unrelated.example.test")
if credential == "foreign_jwt"
else "unrelated-upstream-bearer"
)
cookie: Final = _browser_session_cookie(
monkeypatch,
"cookie-owner",
signing_key=master,
expired_cookie=cookie_state == "expired",
expired_session=cookie_state == "session_expired",
)
response: Final = await discoverable_endpoints.authorize_with_server(
request=_token_request(
{
**({"Authorization": f"Bearer {bearer}"} if credential != "none" else {}),
**({"Cookie": f"token={cookie}"} if cookie_state != "missing" else {}),
}
),
mcp_server=server,
client_id="client",
redirect_uri="http://127.0.0.1:6274/callback",
state="client-state",
code_challenge="pkce-challenge",
code_challenge_method="S256",
)
redirect: Final = urlparse(response.headers["location"])
query: Final = parse_qs(redirect.query)
if cookie_state == "allowed":
assert redirect.hostname == "upstream.example.test"
assert query["nonce"] and response.headers.get("set-cookie")
manager.get_allowed_mcp_servers.assert_awaited_once()
assert manager.get_allowed_mcp_servers.call_args.args[0].user_id == "cookie-owner"
elif cookie_state == "server_denied":
assert query["error"] == ["access_denied"]
assert query["state"] == ["client-state"]
else:
assert redirect.path == "/sso/key/generate"
manager.get_allowed_mcp_servers.assert_not_awaited()
proxy_server.prisma_client.db.litellm_mcpusercredentials.upsert.assert_not_called()
proxy_server.prisma_client.db.litellm_usertable.create.assert_not_called()
proxy_server.prisma_client.db.litellm_teamtable.create.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("change", ["create", "update", "delete"])
async def test_authorize_observes_committed_peer_server_changes(monkeypatch, change):
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from fastapi import HTTPException, Request
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_MCPServerTable
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
monkeypatch.setenv("LITELLM_SALT_KEY", "catalog-consistency-test-key")
stamp = datetime.now(timezone.utc)
old_server = _create_id_lookup_oauth2_server()
old_server.updated_at = stamp
row = LiteLLM_MCPServerTable(
server_id=old_server.server_id,
server_name=old_server.server_name,
alias=old_server.alias,
url="https://upstream.example/mcp",
transport="http",
auth_type="oauth2",
authorization_url="https://new-provider.example/authorize",
token_url="https://new-provider.example/token",
scopes=["read"],
credentials={"client_id": "current-client", "client_secret": "current-secret"},
created_at=stamp,
updated_at=stamp + timedelta(seconds=1),
)
read_rows = AsyncMock(return_value=[] if change == "delete" else [row])
prisma = SimpleNamespace(
db=SimpleNamespace(
litellm_mcpservertable=SimpleNamespace(find_many=read_rows),
litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None)),
)
)
prisma.writer_db = prisma.db
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(
global_mcp_server_manager, "registry", {} if change == "create" else {old_server.server_id: old_server}
)
monkeypatch.setattr(global_mcp_server_manager, "config_mcp_servers", {})
request = Request(
{"type": "http", "scheme": "https", "server": ("gateway.example", 443), "path": "/authorize", "headers": []}
)
if change == "delete":
with pytest.raises(HTTPException) as exc:
await discoverable_endpoints.authorize(
request, "http://localhost/callback", mcp_server_name=old_server.server_id
)
assert exc.value.status_code == 404
else:
response = await discoverable_endpoints.authorize(
request, "http://localhost/callback", mcp_server_name=old_server.server_id
)
assert response.status_code == 307
assert response.headers["location"].startswith("https://new-provider.example/authorize?")
assert "client_id=current-client" in response.headers["location"]
read_rows.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_server_drops_cached_upstream_oauth_metadata():
from litellm.proxy._types import LiteLLM_MCPServerTable, MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
from litellm.types.mcp_server.mcp_server_manager import MCPServer
manager = MCPServerManager()
server = MCPServer(
server_id="oauth-cache-server",
name="oauth_cache_server",
url="http://old-upstream/mcp",
transport=MCPTransport.http,
)
manager.registry[server.server_id] = server
stale_key: Final = (server.server_id, server.url)
other_key: Final = ("other-server", "http://other/mcp")
discoverable_endpoints._OAUTH_METADATA_CACHE[stale_key] = (time.time() + 300, {"iss": "old-idp"})
discoverable_endpoints._OAUTH_METADATA_CACHE[other_key] = (time.time() + 300, {"iss": "other"})
try:
await manager.update_server(
LiteLLM_MCPServerTable(
server_id=server.server_id,
server_name=server.name,
url="http://new-upstream/mcp",
transport=MCPTransport.http,
)
)
assert stale_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
assert other_key in discoverable_endpoints._OAUTH_METADATA_CACHE
finally:
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(stale_key, None)
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(other_key, None)
@pytest.mark.asyncio
async def test_metadata_fetched_before_invalidation_does_not_repopulate_the_cache():
import asyncio
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.discoverable_endpoints import (
fetch_upstream_oauth_protected_resource,
invalidate_oauth_metadata_cache,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="stale-write-server", name="stale_write", url="http://upstream/mcp", transport=MCPTransport.http
)
cache_key: Final = (server.server_id, server.url)
started: Final = asyncio.Event()
release: Final = asyncio.Event()
async def slow_get(url: str, headers: dict[str, str]) -> MagicMock:
started.set()
await release.wait()
return MagicMock(status_code=200, json=MagicMock(return_value={"authorization_servers": ["old-idp"]}))
client = MagicMock()
client.get = slow_get
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=client,
):
in_flight: Final = asyncio.create_task(fetch_upstream_oauth_protected_resource(server))
await started.wait()
invalidate_oauth_metadata_cache(server.server_id)
release.set()
assert await in_flight == {"authorization_servers": ["old-idp"]}
assert cache_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
discoverable_endpoints._prune_oauth_metadata_cache()
assert server.server_id not in discoverable_endpoints._OAUTH_METADATA_GENERATIONS
finally:
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server.server_id, None)
@pytest.mark.asyncio
async def test_fetch_waiting_on_a_lock_handoff_stays_tracked_through_invalidation():
import asyncio
from litellm.proxy._types import MCPTransport
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.discoverable_endpoints import (
fetch_upstream_oauth_protected_resource,
invalidate_oauth_metadata_cache,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="handoff-server", name="handoff", url="http://upstream/mcp", transport=MCPTransport.http
)
cache_key: Final = (server.server_id, server.url)
started: Final = asyncio.Event()
release: Final = asyncio.Event()
async def slow_get(url: str, headers: dict[str, str]) -> MagicMock:
started.set()
await release.wait()
return MagicMock(status_code=200, json=MagicMock(return_value={"authorization_servers": ["pre-save-idp"]}))
client = MagicMock()
client.get = slow_get
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
try:
with patch(
"litellm.proxy.mcp_server.discoverable_endpoints.get_async_httpx_client",
return_value=client,
):
async with discoverable_endpoints._oauth_metadata_fetch_slot(cache_key):
shared_lock: Final = discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS[cache_key]
waiting: Final = asyncio.create_task(fetch_upstream_oauth_protected_resource(server))
for _ in range(3):
await asyncio.sleep(0)
assert not started.is_set() and not waiting.done()
invalidate_oauth_metadata_cache(server.server_id)
assert discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS.get(cache_key) is shared_lock
assert discoverable_endpoints._oauth_metadata_fetch_in_flight(server.server_id)
await started.wait()
invalidate_oauth_metadata_cache(server.server_id)
release.set()
assert await waiting == {"authorization_servers": ["pre-save-idp"]}
assert cache_key not in discoverable_endpoints._OAUTH_METADATA_CACHE
assert not discoverable_endpoints._oauth_metadata_fetch_in_flight(server.server_id)
finally:
discoverable_endpoints._OAUTH_METADATA_CACHE.pop(cache_key, None)
discoverable_endpoints._OAUTH_METADATA_FETCH_LOCKS.pop(cache_key, None)
discoverable_endpoints._OAUTH_METADATA_FETCHERS.pop(cache_key, None)
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server.server_id, None)
def test_invalidating_an_idle_server_leaves_no_generation_behind():
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.discoverable_endpoints import invalidate_oauth_metadata_cache
server_ids: Final = tuple(f"churned-server-{i}" for i in range(50))
try:
for server_id in server_ids:
invalidate_oauth_metadata_cache(server_id)
assert not set(server_ids) & set(discoverable_endpoints._OAUTH_METADATA_GENERATIONS)
finally:
for server_id in server_ids:
discoverable_endpoints._OAUTH_METADATA_GENERATIONS.pop(server_id, None)
@pytest.mark.asyncio
async def test_callback_rejects_missing_advertised_issuer(monkeypatch):
server = _issuer_anchored_oauth_server().model_copy(update={"authorization_response_iss_parameter_supported": True})
response, _ = await _authorize_then_callback(server, iss=None, monkeypatch=monkeypatch)
assert response.status_code == 400
assert "location" not in response.headers
assert b"upstream-auth-code" not in response.body
@pytest.mark.parametrize(
"issuer,url",
[
("https://other.example", "https://resource.example/mcp"),
(None, "https://other.example/mcp"),
],
)
def test_persisted_client_is_not_applied_to_another_upstream(issuer, url):
from litellm.proxy.mcp_server.discoverable_endpoints import (
_apply_persisted_dcr_credentials,
_PersistedDcrCredentials,
)
server = _issuer_anchored_oauth_server(issuer).model_copy(update={"url": url, "client_id": None})
stored = _PersistedDcrCredentials.model_validate(
{
"client_id": "old-client",
"client_secret": "old-secret",
"dcr_issuer": "https://idp.example.com",
"dcr_server_url": "https://resource.example/mcp",
}
)
assert _apply_persisted_dcr_credentials(server, stored) is False
assert server.client_id is None
assert server.client_secret is None
@pytest.mark.asyncio
async def test_registration_does_not_write_client_after_upstream_edit(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", "test-mcp-registration-salt")
from prisma import models
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server.discoverable_endpoints import _persist_dcr_client_registration
server = _issuer_anchored_oauth_server().model_copy(
update={"url": "https://resource.example/mcp", "client_id": None}
)
current = models.LiteLLM_MCPServerTable.model_construct(
server_id=server.server_id,
url="https://other.example/mcp",
issuer="https://other.example",
auth_type="oauth2",
transport="http",
credentials=None,
updated_at=datetime.now(timezone.utc),
)
prisma = MagicMock()
table = AsyncMock()
table.find_unique.return_value = current
table.update.return_value = current
prisma.db.litellm_mcpservertable = table
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
result = await _persist_dcr_client_registration(
server, {"client_id": "old-issuer-client"}, "https://proxy.example/callback"
)
assert result == "failed"
table.update.assert_not_called()
table.update_many.assert_not_called()
assert server.client_id is None
@pytest.mark.parametrize(
"response_issuer", ["https://IDP.example.com", "https://idp.example.com/", "https://idp.example.com:443"]
)
def test_issuer_validation_uses_exact_identifier(response_issuer):
from litellm.proxy.mcp_server.discoverable_endpoints import _authorization_response_issuer_is_trusted
assert (
_authorization_response_issuer_is_trusted(response_issuer, {"expected_issuer": "https://idp.example.com"})
is False
)
@pytest.mark.parametrize(
"issuer,url",
[
("https://idp.example.com", "https://resource.example/mcp"),
("https://idp.example.com", "https://other-resource.example/mcp"),
],
)
def test_persisted_client_remains_usable_for_its_issuer(issuer, url, monkeypatch):
from litellm.proxy.mcp_server.discoverable_endpoints import (
_apply_persisted_dcr_credentials,
_PersistedDcrCredentials,
)
monkeypatch.setenv("LITELLM_SALT_KEY", "test-registration-binding")
server = _issuer_anchored_oauth_server(issuer).model_copy(update={"url": url, "client_id": None})
credentials = _PersistedDcrCredentials(
client_id="registered-client",
client_secret="registered-secret",
dcr_issuer=issuer,
dcr_server_url="https://resource.example/mcp",
)
assert _apply_persisted_dcr_credentials(server, credentials) is True
assert server.client_id == "registered-client"
assert server.client_secret == "registered-secret"
assert server.dcr_issuer == issuer
@pytest.mark.asyncio
async def test_callback_does_not_forward_error_from_another_issuer(monkeypatch):
response, _ = await _authorize_then_callback(
_issuer_anchored_oauth_server(),
iss="https://other.example",
monkeypatch=monkeypatch,
error="access_denied",
)
assert response.status_code == 400
assert "location" not in response.headers
assert b"invalid_issuer" in response.body
@pytest.mark.asyncio
@pytest.mark.parametrize("operation", ["authorize", "token"])
async def test_bound_client_cannot_be_sent_to_a_different_issuer(monkeypatch, operation):
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="bound-client",
name="bound-client",
transport="http",
auth_type="oauth2",
url="https://new.example/mcp",
issuer="https://new.example",
client_id="old-client",
dcr_issuer="https://old.example",
dcr_server_url="https://old.example/mcp",
)
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
http_client = MagicMock()
monkeypatch.setattr(endpoints, "get_async_httpx_client", http_client)
request = (
endpoints.authorize_with_server(
mcp_server=server,
request=MagicMock(spec=Request),
redirect_uri="http://localhost/callback",
client_id="old-client",
)
if operation == "authorize"
else endpoints.exchange_token_with_server(
mcp_server=server,
request=MagicMock(spec=Request),
grant_type="authorization_code",
code="test-code",
redirect_uri="http://localhost/callback",
client_id="old-client",
client_secret=None,
code_verifier="verifier",
)
)
with pytest.raises(HTTPException) as error:
await request
assert error.value.status_code == 400
assert "different issuer" in error.value.detail
http_client.assert_not_called()
@pytest.mark.asyncio
async def test_failed_registration_preserves_cached_credentials(monkeypatch):
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
server = _dcr_redirect_test_server("old-client").model_copy(
update={
"url": "https://new.example/mcp",
"issuer": "https://new.example",
"dcr_issuer": "https://old.example",
"client_secret": "old-secret",
}
)
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
monkeypatch.setattr(endpoints, "_reuse_persisted_dcr_client_if_available", AsyncMock(return_value=False))
response = MagicMock()
response.json.return_value = {"client_id": "new-client"}
register = AsyncMock(return_value=response)
monkeypatch.setattr(endpoints, "_post_dcr_registration", register)
persist = AsyncMock(return_value="failed")
monkeypatch.setattr(endpoints, "_persist_dcr_client_registration", persist)
request = MagicMock(spec=Request)
request.base_url = "https://gateway.example/"
request.headers = {}
with pytest.raises(HTTPException) as error:
await endpoints.register_client_with_server(
request=request,
mcp_server=server,
client_name="app",
grant_types=["authorization_code"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
assert error.value.status_code == 503
assert "could not be saved" in error.value.detail
assert server.client_id == "old-client"
assert server.client_secret == "old-secret"
register.assert_awaited_once()
persist.assert_awaited_once_with(
server, {"client_id": "new-client"}, "https://gateway.example/callback", replace_stale_client=True
)
@pytest.mark.asyncio
async def test_registration_without_database_keeps_client_in_temporary_server(monkeypatch):
from fastapi import Request
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setattr(proxy_server, "prisma_client", None)
server = _dcr_redirect_test_server(None).model_copy(
update={"issuer": "https://idp.example", "url": "https://resource.example/mcp"}
)
monkeypatch.setattr(endpoints, "_server_with_oauth_endpoints", AsyncMock(return_value=server))
upstream = MagicMock()
upstream.json.return_value = {"client_id": "temporary-client", "client_secret": "temporary-secret"}
monkeypatch.setattr(endpoints, "_post_dcr_registration", AsyncMock(return_value=upstream))
request = MagicMock(spec=Request)
request.base_url = "https://gateway.example/"
request.headers = {}
response = await endpoints.register_client_with_server(
request=request,
mcp_server=server,
client_name="app",
grant_types=["authorization_code"],
response_types=["code"],
token_endpoint_auth_method="none",
replace_stale_client=True,
)
assert response.status_code == 200
assert json.loads(response.body)["client_id"] == "temporary-client"
assert server.client_id == "temporary-client"
assert server.client_secret == "temporary-secret"
assert server.dcr_issuer == server.issuer
assert server.dcr_server_url == server.url
@pytest.mark.asyncio
@pytest.mark.parametrize("config_store", [False, True])
async def test_registration_does_not_overwrite_credentials_after_failed_identity_read(monkeypatch, config_store):
from litellm.proxy import utils
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _dcr_redirect_test_server(None)
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
monkeypatch.setattr(
db,
"get_mcp_server",
AsyncMock(return_value=None, side_effect=None if config_store else RuntimeError("unavailable")),
)
monkeypatch.setattr(
db, "get_mcp_server_oauth_client_credentials", AsyncMock(side_effect=RuntimeError("unavailable"))
)
monkeypatch.setattr(global_mcp_server_manager, "is_config_declared_server", lambda _: config_store)
update = AsyncMock()
upsert = AsyncMock()
monkeypatch.setattr(db, "update_mcp_server", update)
monkeypatch.setattr(db, "upsert_mcp_server_oauth_client_credentials", upsert)
result = await endpoints._persist_dcr_client_registration(
server, {"client_id": "new-client"}, "https://gateway.example/callback"
)
assert result == "failed"
assert server.client_id is None
update.assert_not_awaited()
upsert.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("winner_available", [False, True])
async def test_registration_losing_conditional_write_reuses_only_a_matching_winner(monkeypatch, winner_available):
from litellm.proxy import utils
from litellm.proxy._types import LiteLLM_MCPServerTable
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
server = _dcr_redirect_test_server(None).model_copy(update={"url": "https://upstream.example/mcp"})
row = LiteLLM_MCPServerTable.model_validate(server.model_dump(exclude_none=True))
winner = (
row.model_copy(
update={
"credentials": {
"client_id": "winner-client",
"redirect_uris": ["https://gateway.example/callback"],
"dcr_server_url": server.url,
}
}
)
if winner_available
else row
)
monkeypatch.setattr(utils, "get_prisma_client_or_throw", lambda _: MagicMock())
read = AsyncMock(side_effect=[row, winner])
monkeypatch.setattr(db, "get_mcp_server", read)
monkeypatch.setattr(endpoints, "_refresh_persisted_dcr_server", AsyncMock())
update = AsyncMock(return_value=None)
monkeypatch.setattr(db, "update_mcp_server", update)
result = await endpoints._persist_dcr_client_registration(
server, {"client_id": "losing-client"}, "https://gateway.example/callback"
)
assert result == ("reused" if winner_available else "failed")
assert update.await_args.kwargs["expected_updated_at"] == row.updated_at
assert server.client_id == ("winner-client" if winner_available else None)
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", (MCPAuth.true_passthrough, MCPAuth.oauth_delegate, MCPAuth.oauth2))
@pytest.mark.parametrize(
"metadata", ({"application_type": "native"}, {"application_type": "web"}, {}, {"application_type": None})
)
async def test_register_preserves_client_application_type_only_for_bridge_relay(
auth_type: MCPAuth, metadata: dict[str, object], monkeypatch: pytest.MonkeyPatch
) -> None:
import httpx
import respx
from fastapi import FastAPI
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server: Final = _bridge_server(auth_type=auth_type, server_id="application-client", alias="application-client")
app: Final = FastAPI()
app.include_router(router)
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
client_redirect: Final = "http://127.0.0.1:53682/callback"
with respx.mock as upstream:
registration: Final = upstream.post(server.registration_url).respond(
201, json={"client_id": "registered-client"}
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
) as client:
response: Final = await client.post(
f"/{server.server_id}/register",
json={"client_name": "Test client", "redirect_uris": [client_redirect], **metadata},
)
assert response.status_code == 200
assert response.json()["client_id"] == "registered-client"
assert registration.call_count == 1
posted: Final = json.loads(registration.calls[0].request.content)
expected_type: Final = metadata.get("application_type") if auth_type != MCPAuth.oauth2 else None
if expected_type is None:
assert "application_type" not in posted
else:
assert posted["application_type"] == expected_type
assert posted["redirect_uris"] == (
["https://gateway.example/callback"] if auth_type == MCPAuth.oauth2 else [client_redirect]
)
@pytest.mark.asyncio
@pytest.mark.parametrize("application_type", ("desktop", "", 1, ["native"], {"value": "native"}))
async def test_register_rejects_invalid_application_type_before_upstream(
application_type: object, monkeypatch: pytest.MonkeyPatch
) -> None:
import httpx
import respx
from fastapi import FastAPI
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server: Final = _bridge_server(server_id="invalid-application-client", alias="invalid-application-client")
app: Final = FastAPI()
app.include_router(router)
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
with respx.mock(assert_all_called=False) as upstream:
registration: Final = upstream.post(server.registration_url).respond(
201, json={"client_id": "must-not-register"}
)
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
) as client:
response: Final = await client.post(
f"/{server.server_id}/register",
json={"redirect_uris": ["http://127.0.0.1:53682/callback"], "application_type": application_type},
)
assert response.status_code == 400
assert "application_type" in response.json()["detail"]
assert registration.call_count == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("client_id", (None, "preconfigured-client"))
async def test_register_application_type_keeps_no_registration_endpoint_fallback(
client_id: str | None, monkeypatch: pytest.MonkeyPatch
) -> None:
import httpx
import respx
from fastapi import FastAPI
from litellm.proxy.mcp_server.discoverable_endpoints import router
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server: Final = _bridge_server(
auth_type=MCPAuth.oauth2,
server_id="static-client",
alias="static-client",
registration_url=None,
client_id=client_id,
)
app: Final = FastAPI()
app.include_router(router)
monkeypatch.setitem(global_mcp_server_manager.registry, server.server_id, server)
with respx.mock as upstream:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="https://gateway.example"
) as client:
response: Final = await client.post(f"/{server.server_id}/register", json={"application_type": "native"})
assert response.status_code == 200
assert response.json() == {
"client_id": server.server_id,
"client_secret": "dummy",
"redirect_uris": ["https://gateway.example/callback"],
}
assert len(upstream.calls) == 0
def _cimd_oauth_server():
from litellm.types.mcp_server.mcp_server_manager import MCPServer
return MCPServer.model_validate(
{
"server_id": "cimd-server",
"name": "cimd-server",
"server_name": "cimd-server",
"url": "https://mcp.example.com/mcp",
"transport": "http",
"auth_type": "oauth2",
"oauth2_flow": "authorization_code",
"authorization_url": "https://idp.example.com/authorize",
"token_url": "https://idp.example.com/token",
"client_id_metadata_document_supported": True,
}
)
def _cimd_request():
from starlette.requests import Request
return Request(
{
"type": "http",
"method": "GET",
"scheme": "https",
"path": "/",
"root_path": "",
"query_string": b"",
"headers": [],
"server": ("gateway.example.com", 443),
"client": ("127.0.0.1", 10000),
}
)
@pytest.mark.asyncio
async def test_cimd_registration_returns_https_identity_without_dcr(monkeypatch, respx_mock):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
response = await endpoints.register_client_with_server(
_cimd_request(), _cimd_oauth_server(), "Gateway", None, None, None
)
body = json.loads(response.body) if hasattr(response, "body") else response
assert body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
assert "client_secret" not in body
assert len(respx_mock.calls) == 0
@pytest.mark.asyncio
async def test_cimd_authorization_uses_metadata_identity_and_s256(monkeypatch):
from urllib.parse import parse_qs, urlparse
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
response = await endpoints.authorize_with_server(
_cimd_request(),
_cimd_oauth_server(),
"placeholder",
"https://gateway.example.com/ui/",
code_challenge="a" * 43,
code_challenge_method="S256",
)
params = parse_qs(urlparse(response.headers["location"]).query)
assert params["client_id"] == ["https://gateway.example.com/oauth/client-metadata.json"]
assert params["redirect_uri"] == ["https://gateway.example.com/callback"]
assert params["code_challenge_method"] == ["S256"]
@pytest.mark.asyncio
async def test_cimd_authorization_rejects_missing_pkce(monkeypatch):
from fastapi import HTTPException
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
with pytest.raises(HTTPException) as exc:
await endpoints.authorize_with_server(
_cimd_request(), _cimd_oauth_server(), "placeholder", "https://gateway.example.com/ui/"
)
assert exc.value.status_code == 400
def test_cimd_refresh_request_uses_same_identity_without_caller_secret(monkeypatch):
from litellm.proxy.mcp_server.oauth_utils import build_upstream_oauth2_token_request
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
request = build_upstream_oauth2_token_request(
_cimd_oauth_server(), auth_method=None, client_id="placeholder", client_secret="dummy"
)
assert request.body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
assert "client_secret" not in request.body
assert "Authorization" not in request.headers
@pytest.mark.parametrize(
"base", [None, "http://gateway.example.com", "invalid", "https://user:secret@gateway.example.com"]
)
@pytest.mark.asyncio
async def test_cimd_without_stable_https_origin_reports_actionable_error(monkeypatch, base, respx_mock):
from fastapi import HTTPException
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
if base is not None:
monkeypatch.setenv("PROXY_BASE_URL", base)
with pytest.raises(HTTPException) as exc:
await endpoints.register_client_with_server(_cimd_request(), _cimd_oauth_server(), "Gateway", None, None, None)
assert exc.value.status_code == 400
assert "HTTPS PROXY_BASE_URL" in str(exc.value.detail)
assert len(respx_mock.calls) == 0
@pytest.mark.parametrize("base", [None, "http://gateway.example.com"])
@pytest.mark.asyncio
async def test_cimd_without_https_origin_falls_back_to_available_dcr(monkeypatch, base, respx_mock):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
if base is not None:
monkeypatch.setenv("PROXY_BASE_URL", base)
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
assert json.loads(response.body)["client_id"] == "registered-client"
assert post.call_count == 1
@pytest.mark.asyncio
async def test_cimd_yields_to_dynamic_registration_when_the_authorization_server_offers_both(monkeypatch, respx_mock):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.oauth_utils import get_cimd_client_id
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
assert json.loads(response.body)["client_id"] == "registered-client"
assert post.call_count == 1
assert get_cimd_client_id(server) is None
@pytest.mark.asyncio
async def test_cimd_is_preferred_over_dynamic_registration_when_the_deployment_opts_in(monkeypatch, respx_mock):
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
monkeypatch.setattr(proxy_server, "general_settings", {"mcp_prefer_client_id_metadata_document": True})
server = _cimd_oauth_server().model_copy(update={"registration_url": "https://idp.example.com/register"})
post = respx_mock.post("https://idp.example.com/register").respond(201, json={"client_id": "registered-client"})
response = await endpoints.register_client_with_server(_cimd_request(), server, "Gateway", None, None, None)
body = json.loads(response.body) if hasattr(response, "body") else response
assert body["client_id"] == "https://gateway.example.com/oauth/client-metadata.json"
assert "client_secret" not in body
assert post.call_count == 0
@pytest.mark.parametrize(
"updates",
[
{"client_id": "static-client", "client_secret": "static-secret"},
{"client_id": "persisted-dcr-client", "dcr_issuer": "https://idp.example.com"},
{"client_id_metadata_document_supported": False},
{"auth_type": "oauth_delegate", "dcr_bridge": True},
{"auth_type": "true_passthrough", "dcr_bridge": True},
{"delegate_auth_to_upstream": True},
{"oauth2_flow": "client_credentials"},
{"client_secret": "configured-secret"},
{"token_endpoint_auth_method": "client_secret_basic"},
],
)
def test_cimd_preserves_existing_identity_and_other_auth_modes(monkeypatch, updates):
from litellm.proxy.mcp_server.oauth_utils import get_cimd_client_id
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
assert get_cimd_client_id(_cimd_oauth_server().model_copy(update=updates)) is None
@pytest.mark.asyncio
async def test_cimd_document_is_public_and_binds_configured_origin(monkeypatch):
import httpx
from fastapi import FastAPI
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com/proxy")
app = FastAPI()
app.include_router(endpoints.router)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://attacker.example") as client:
response = await client.get("/oauth/client-metadata.json", headers={"X-Forwarded-Host": "attacker.example"})
assert response.status_code == 200
assert response.json()["client_id"] == "https://gateway.example.com/proxy/oauth/client-metadata.json"
assert response.json()["redirect_uris"] == ["https://gateway.example.com/proxy/callback"]
assert response.json()["token_endpoint_auth_method"] == "none"
assert "client_secret" not in response.json()
assert response.headers["cache-control"] == "public, max-age=300"
@pytest.mark.asyncio
@pytest.mark.parametrize("base", [None, "https://[invalid"])
async def test_cimd_document_is_unavailable_without_configured_https_origin(monkeypatch, base):
from fastapi import HTTPException
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.delenv("PROXY_BASE_URL", raising=False)
if base is not None:
monkeypatch.setenv("PROXY_BASE_URL", base)
with pytest.raises(HTTPException) as exc:
await endpoints.oauth_client_metadata()
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_cimd_upstream_client_rejection_is_gateway_fault(monkeypatch, respx_mock):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
upstream = respx_mock.post("https://idp.example.com/token").respond(
401, json={"error": "invalid_client", "error_description": "provider-private-detail"}
)
response = await endpoints.exchange_token_with_server(
request=_cimd_request(),
mcp_server=_cimd_oauth_server(),
grant_type="authorization_code",
code="code",
redirect_uri="https://gateway.example.com/callback",
client_id="caller-placeholder",
client_secret="dummy",
code_verifier="verifier",
)
assert upstream.call_count == 1
assert response.status_code == 502
body = json.loads(response.body)
assert body["error"] == "server_error"
assert "provider-private-detail" not in body["error_description"]
@pytest.mark.asyncio
@pytest.mark.parametrize("flow", ["refresh", "authorize"])
async def test_optional_cimd_discovery_preserves_the_callers_configured_endpoint(
monkeypatch: pytest.MonkeyPatch, respx_mock: "MockRouter", flow: str
) -> None:
from urllib.parse import parse_qs
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server import mcp_server_manager as manager_module
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP", "0")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
monkeypatch.setenv("LITELLM_SALT_KEY", "cimd-test-state-signing-key")
token: Final = respx_mock.post("https://idp.example.com/token").respond(
200, json={"access_token": "upstream-access", "token_type": "Bearer", "expires_in": 3600}
)
discovery: Final = respx_mock.route().respond(503)
manager: Final = manager_module.MCPServerManager()
monkeypatch.setattr(manager_module, "global_mcp_server_manager", manager)
await manager.load_servers_from_config(
{
"manual": {
"url": "https://mcp.example.com/mcp",
"transport": "http",
"auth_type": "oauth2",
"oauth2_flow": "authorization_code",
**(
{"token_url": "https://idp.example.com/token"}
if flow == "refresh"
else {"authorization_url": "https://idp.example.com/authorize"}
),
}
}
)
server: Final = next(iter(manager.config_mcp_servers.values()))
async with manager.catalog.operation():
if flow == "refresh":
response: Final = await endpoints.exchange_token_with_server(
request=_cimd_request(),
mcp_server=server,
grant_type="refresh_token",
refresh_token="existing-refresh",
client_id="existing-client",
code=None,
redirect_uri=None,
client_secret=None,
code_verifier=None,
)
assert response.status_code == 200
assert json.loads(response.body)["access_token"] == "upstream-access"
assert parse_qs(token.calls[0].request.content.decode())["refresh_token"] == ["existing-refresh"]
else:
redirect: Final = await endpoints.authorize_with_server(
_cimd_request(),
server,
"existing-client",
"https://gateway.example.com/ui/",
code_challenge="a" * 43,
code_challenge_method="S256",
)
assert redirect.status_code == 307
assert redirect.headers["location"].startswith("https://idp.example.com/authorize?")
assert discovery.call_count > 0
@pytest.mark.asyncio
@pytest.mark.parametrize("origin", [None, "https://renamed-gateway.example.com"])
async def test_token_route_preserves_saved_cimd_grant_after_origin_change(
jwt_oauth_identity: tuple["JWTHandler", "RSAPrivateKey"],
monkeypatch: pytest.MonkeyPatch,
respx_mock: "MockRouter",
origin: str | None,
) -> None:
from types import SimpleNamespace
from urllib.parse import parse_qs
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db, mcp_server_manager
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
monkeypatch.setenv("LITELLM_SALT_KEY", "saved-cimd-route-test-salt")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
server: Final = _cimd_oauth_server()
mcp_server_manager.global_mcp_server_manager.registry[server.server_id] = server
identity: Final = "https://gateway.example.com/oauth/client-metadata.json"
table: Final = proxy_server.prisma_client.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=None)
table.upsert = AsyncMock()
await endpoints._store_per_user_token_server_side(
server,
"jwt-owner",
{"access_token": "expired", "refresh_token": "saved-refresh", "expires_in": -1},
cimd_client_id=identity,
)
async def saved_row(**kwargs):
return SimpleNamespace(credential_b64=table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
table.find_unique.side_effect = saved_row
if origin is None:
monkeypatch.delenv("PROXY_BASE_URL")
else:
monkeypatch.setenv("PROXY_BASE_URL", origin)
_, key = jwt_oauth_identity
upstream: Final = respx_mock.post(server.token_url).respond(
200, json={"access_token": "fresh", "refresh_token": "rotated", "expires_in": 3600}
)
response: Final = await endpoints.exchange_token_with_server(
_token_request({"Authorization": f"Bearer {_oauth_identity_jwt(key, scope='litellm_proxy_admin')}"}),
server,
"refresh_token",
None,
None,
identity,
None,
None,
refresh_token="saved-refresh",
)
assert response.status_code == 200
assert parse_qs(upstream.calls[0].request.content.decode())["client_id"] == [identity]
persisted: Final = await db.get_user_oauth_credential(proxy_server.prisma_client, "jwt-owner", server.server_id)
assert persisted is not None and persisted["cimd_client_id"] == identity
assert persisted["refresh_token"] == "rotated"
assert table.upsert.await_count == 2
@pytest.mark.asyncio
@pytest.mark.parametrize(
"state",
[
"matching",
"unicode_grant",
"foreign_refresh",
"missing_refresh",
"missing_grant",
"database_missing",
"database_outage",
"static_client",
"anonymous",
],
)
async def test_saved_refresh_metadata_is_bound_to_the_callers_stored_grant(
monkeypatch: pytest.MonkeyPatch,
state: str,
) -> None:
from types import SimpleNamespace
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("LITELLM_SALT_KEY", "saved-cimd-owner-test-salt")
server: Final = _cimd_oauth_server()
identity: Final = "https://original-gateway.example.com/oauth/client-metadata.json"
grant: Final = "alice-refresh-\u00e9" if state == "unicode_grant" else "alice-refresh"
database: Final = MagicMock()
table: Final = database.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=None)
table.upsert = AsyncMock()
monkeypatch.setattr(proxy_server, "prisma_client", database)
await db.store_user_oauth_credential(
database,
"alice",
server.server_id,
"expired",
refresh_token=None if state == "missing_refresh" else grant,
cimd_client_id=identity,
)
table.find_unique.reset_mock()
table.find_unique.return_value = (
None
if state == "missing_grant"
else SimpleNamespace(credential_b64=table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
)
if state == "database_missing":
monkeypatch.setattr(proxy_server, "prisma_client", None)
if state == "database_outage":
table.find_unique.side_effect = RuntimeError("database unavailable")
if state == "static_client":
server.client_id = "configured-client"
resolved: Final = await endpoints._saved_refresh_credential(
server,
None if state == "anonymous" else "alice",
"foreign-refresh" if state == "foreign_refresh" else grant,
)
if state in ("matching", "unicode_grant", "static_client"):
assert resolved is not None and resolved["cimd_client_id"] == identity
assert resolved["refresh_token"] == grant
else:
assert resolved is None
if state in ("anonymous", "database_missing"):
table.find_unique.assert_not_awaited()
else:
table.find_unique.assert_awaited_once_with(
where={"user_id_server_id": {"user_id": "alice", "server_id": server.server_id}}
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"prior_scopes, expected", [(["tools.read"], "tools.read tools.write"), (None, "tools.write"), ([], "tools.write")]
)
async def test_managed_authorize_retains_the_callers_previous_grant(
jwt_oauth_identity, monkeypatch, prior_scopes, expected
):
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-grant-test-salt")
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client", "scopes": ["tools.read"]})
global_mcp_server_manager.registry[server.server_id] = server
credential = db.encrypt_value_helper(
json.dumps({"type": "oauth2", "access_token": "old-access", "scopes": prior_scopes})
)
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
request = _token_request(
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
)
response = await endpoints.authorize_with_server(
request,
server,
"scope-client",
"http://127.0.0.1:9/cb",
scope="tools.write",
code_challenge="challenge",
code_challenge_method="S256",
)
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
assert table.find_unique.call_args.kwargs["where"] == {
"user_id_server_id": {"user_id": "jwt-owner", "server_id": server.server_id}
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"returned_scope, expected", [(None, ["tools.read", "tools.write"]), ("tools.read", ["tools.read"]), ("", [])]
)
async def test_managed_code_exchange_preserves_requested_scope_only_when_omitted(
jwt_oauth_identity,
monkeypatch,
respx_mock,
returned_scope,
expected,
):
from http.cookies import SimpleCookie
from urllib.parse import parse_qs, urlsplit
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
monkeypatch.setenv("LITELLM_SALT_KEY", "requested-scope-test-salt")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
global_mcp_server_manager.registry[server.server_id] = server
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=None)
table.upsert = AsyncMock()
request = _token_request(
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
)
started = await endpoints.authorize_with_server(
request,
server,
"scope-client",
"http://127.0.0.1:9/cb",
scope="tools.read tools.write",
code_challenge="challenge",
code_challenge_method="S256",
)
state = parse_qs(urlsplit(started.headers["location"]).query)["state"][0]
cookies = SimpleCookie()
cookies.load(started.headers["set-cookie"])
callback_request = _token_request({"Cookie": "; ".join(f"{name}={value.value}" for name, value in cookies.items())})
returned = await endpoints.callback(callback_request, code="provider-code", state=state, error=None, iss=None)
code = parse_qs(urlsplit(returned.headers["location"]).query)["code"][0]
upstream = respx_mock.post(server.token_url).respond(
200,
json={
"access_token": "new-access",
"token_type": "Bearer",
"refresh_token": "new-refresh",
**({"scope": returned_scope} if returned_scope is not None else {}),
},
)
exchanged = await endpoints.exchange_token_with_server(
request,
server,
"authorization_code",
client_id="scope-client",
code=code,
code_verifier="verifier",
redirect_uri="http://127.0.0.1:9/cb",
client_secret=None,
)
assert parse_qs(upstream.calls[0].request.content.decode())["code"] == ["provider-code"]
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
assert saved is not None and saved.get("scopes") == expected
assert json.loads(exchanged.body)["scope"] == " ".join(expected or ())
@pytest.mark.asyncio
@pytest.mark.parametrize("advertised, expected", [(True, "tools.read offline_access"), (False, "tools.read")])
async def test_managed_authorize_requests_only_selected_scope_and_supported_refresh_access(
monkeypatch, advertised, expected
):
from urllib.parse import parse_qs, urlsplit
from mcp.shared.auth import OAuthMetadata
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
monkeypatch.setenv("LITELLM_SALT_KEY", "metadata-scope-test-salt")
metadata = OAuthMetadata(
issuer="https://idp.example.com",
authorization_endpoint="https://idp.example.com/authorize",
token_endpoint="https://idp.example.com/token",
response_types_supported=["code"],
scopes_supported=["tools.read", "admin"] + (["offline_access"] if advertised else []),
)
server = _cimd_oauth_server().model_copy(update={"authorization_server_metadata": metadata.model_dump(mode="json")})
response = await endpoints.authorize_with_server(
_cimd_request(),
server,
"scope-client",
"http://127.0.0.1:9/cb",
scope="tools.read",
code_challenge="challenge",
code_challenge_method="S256",
)
assert parse_qs(urlsplit(response.headers["location"]).query)["scope"] == [expected]
@pytest.mark.asyncio
@pytest.mark.parametrize("mismatch", ["server", "issuer", "user", "mode", "tampered", "invalid_payload"])
async def test_managed_scope_code_cannot_cross_oauth_boundaries(jwt_oauth_identity, monkeypatch, respx_mock, mismatch):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
_, signing_key = jwt_oauth_identity
monkeypatch.setenv("LITELLM_SALT_KEY", "scope-boundary-test-salt")
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
payload = endpoints._ManagedAuthorizationCode(
upstream_code="provider-code",
mcp_server_id="other-server" if mismatch == "server" else server.server_id,
issuer="https://other-issuer.test" if mismatch == "issuer" else server.issuer,
requested_scope="tools.read tools.write",
scope_user_id="other-user" if mismatch == "user" else "jwt-owner",
)
code = endpoints._MANAGED_AUTH_CODE_PREFIX + (
"invalid"
if mismatch == "tampered"
else endpoints.encrypt_value_helper("{}" if mismatch == "invalid_payload" else payload.model_dump_json())
)
request = _token_request(
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
)
if mismatch == "mode":
server.auth_type = MCPAuth.true_passthrough
with pytest.raises(HTTPException) as raised:
await endpoints.exchange_token_with_server(
request,
server,
"authorization_code",
code=code,
redirect_uri="http://127.0.0.1:9/cb",
client_id="scope-client",
client_secret=None,
code_verifier="verifier",
)
assert raised.value.status_code == 400
assert respx_mock.calls.call_count == 0
@pytest.mark.asyncio
@pytest.mark.parametrize(
"scope, requested_scope, expected",
[
(None, None, ["tools.read", "tools.write"]),
("tools.read", None, ["tools.read"]),
("", None, []),
(None, "tools.write", ["tools.write"]),
("tools.read", "tools.write", ["tools.read"]),
("", "tools.write", []),
],
)
async def test_managed_refresh_retains_only_the_actual_grant(
jwt_oauth_identity, monkeypatch, respx_mock, scope, requested_scope, expected
):
from types import SimpleNamespace
from litellm.proxy import proxy_server
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
_, signing_key = jwt_oauth_identity
monkeypatch.setenv("LITELLM_SALT_KEY", "refresh-scope-test-salt")
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
server = _cimd_oauth_server().model_copy(update={"client_id": "scope-client"})
global_mcp_server_manager.registry[server.server_id] = server
credential = db.encrypt_value_helper(
json.dumps(
{
"type": "oauth2",
"access_token": "old-access",
"refresh_token": "owner-refresh",
"scopes": ["tools.read", "tools.write"],
}
)
)
table = proxy_server.prisma_client.db.litellm_mcpusercredentials
table.find_unique = AsyncMock(return_value=SimpleNamespace(credential_b64=credential))
table.upsert = AsyncMock()
upstream = respx_mock.post(server.token_url).respond(
200,
json={
"access_token": "new-access",
"token_type": "Bearer",
"refresh_token": "new-refresh",
**({"scope": scope} if scope is not None else {}),
},
)
request = _token_request(
{"Authorization": f"Bearer {_oauth_identity_jwt(signing_key, scope='litellm_proxy_admin')}"}
)
await endpoints.exchange_token_with_server(
request,
server,
"refresh_token",
code=None,
redirect_uri=None,
client_id="scope-client",
client_secret=None,
code_verifier=None,
refresh_token="owner-refresh",
scope=requested_scope,
)
saved = db.decode_oauth_payload(table.upsert.call_args.kwargs["data"]["create"]["credential_b64"])
assert saved is not None and saved.get("scopes") == expected
assert upstream.call_count == 1
from urllib.parse import parse_qs
sent = parse_qs(upstream.calls.last.request.content.decode())
assert sent.get("scope") == ([requested_scope] if requested_scope else None)
@pytest.mark.asyncio
@pytest.mark.parametrize("per_server_relay", [False, True])
async def test_managed_resource_metadata_excludes_refresh_permission(monkeypatch, per_server_relay):
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _cimd_oauth_server().model_copy(
update={
"scopes": ["tools.read", "offline_access"],
"per_server_oauth_discovery": per_server_relay,
}
)
global_mcp_server_manager.registry[server.server_id] = server
result = await endpoints._build_oauth_protected_resource_response(_cimd_request(), server.server_id, True)
assert result["scopes_supported"] == ["tools.read"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"granted, required, expected",
[
(["tools.read"], ("tools.write",), "absent"),
(["tools.read", "tools.write"], ("tools.write",), "present"),
(None, ("tools.write",), "absent"),
(["tools.read"], (), "present"),
],
)
async def test_connect_requires_the_requested_vendor_grant(monkeypatch, granted, required, expected):
from litellm.proxy.mcp_server import db
from litellm.proxy.mcp_server import discoverable_endpoints as endpoints
credential = {**_stored_grant(), "scopes": granted}
read = AsyncMock(return_value=credential)
prisma = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
monkeypatch.setattr(db, "get_user_oauth_credential", read)
assert await endpoints._vendor_credential_state("owner", "server", required) == expected
read.assert_awaited_once_with(prisma, "owner", "server")
@pytest.mark.parametrize(
("header", "expected"),
[
("alpha", ("litellm:mcp_server:alpha",)),
("alpha, beta", ("litellm:mcp_server:alpha", "litellm:mcp_server:beta")),
("alpha,nope", ()),
("", ()),
(None, ()),
],
)
def test_aggregate_protected_resource_advertises_header_server_scopes(
header: str | None, expected: tuple[str, ...]
) -> None:
"""A client that re-authenticates from cached discovery never sees the 401 challenge, so the
aggregate PRM carries the x-mcp-servers selection as scopes_supported instead."""
from litellm.proxy.mcp_server import mcp_server_manager
from litellm.proxy.mcp_server.discoverable_endpoints import (
_build_aggregate_protected_resource_response,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
servers: Final = {
name: MCPServer(
server_id=f"{name}-id",
name=name,
server_name=name,
url=f"http://{name}",
transport="http",
auth_type=MCPAuth.none,
)
for name in ("alpha", "beta")
}
manager: Final = MagicMock()
manager.get_mcp_server_by_identifier.side_effect = lambda entry, client_ip=None: servers.get(entry)
headers: Final = [(b"host", b"localhost:4000")] + (
[(b"x-mcp-servers", header.encode())] if header is not None else []
)
request: Final = Request(
{
"type": "http",
"method": "GET",
"path": "/.well-known/oauth-protected-resource/mcp",
"headers": headers,
"scheme": "http",
"server": ("localhost", 4000),
"query_string": b"",
"root_path": "",
}
)
with patch.object(mcp_server_manager, "global_mcp_server_manager", manager):
assert _build_aggregate_protected_resource_response(request)["scopes_supported"] == expected
@pytest.mark.asyncio
async def test_token_endpoint_routes_the_jwt_bearer_grant_to_the_aggregate_gateway():
"""A requesting app presents its identity provider's client_id, never a gateway DCR one, so
the aggregate dispatch keys on the grant type for RFC 7523 and hands the assertion to the
ID-JAG redeemer. A per-server token endpoint never serves that grant."""
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy.mcp_server.id_jag_grant import IdJagPrerequisites
request = _mock_callback_request("https://litellm.example.com/")
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id="0oa-requesting-app",
client_secret="app-secret",
assertion="eyJ.assertion.sig",
resource=None,
)
assert response.status_code == 400
refused = json.loads(bytes(response.body))
assert refused["error"] == "unsupported_grant_type"
unmet = IdJagPrerequisites(
jwt_auth_enabled=False,
has_database=False,
licensed=False,
maps_jwts_to_virtual_keys=False,
shared_replay_store=False,
base_url=None,
requesting_apps=(),
).refusal()
assert unmet is not None and refused["error_description"] == unmet.description
with pytest.raises(HTTPException):
await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id="0oa-requesting-app",
assertion="eyJ.assertion.sig",
mcp_server_name="no-such-server",
)
@pytest.mark.asyncio
async def test_token_endpoint_takes_the_client_id_from_http_basic_when_the_form_leaves_it_out():
"""RFC 6749 section 2.3.1: a confidential app may authenticate with HTTP Basic alone, so the
aggregate dispatch reads client_id from that header. With neither the form field nor the
header the request is a 400 invalid_request, not a 422."""
from base64 import b64encode
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
request = _mock_callback_request("https://litellm.example.com/")
request.headers = {"authorization": "Basic " + b64encode(b"0oa-requesting-app:app-secret").decode()}
with patch("litellm.proxy.proxy_server.master_key", "sk-test-salt-for-lit9670"):
response = await discoverable_endpoints.token_endpoint(
request=request,
grant_type=JWT_BEARER_GRANT_TYPE,
client_id=None,
assertion="eyJ.assertion.sig",
resource=None,
)
assert response.status_code == 400
assert json.loads(bytes(response.body))["error"] == "unsupported_grant_type"
request.headers = {}
with pytest.raises(HTTPException) as refused:
await discoverable_endpoints.token_endpoint(
request=request, grant_type=JWT_BEARER_GRANT_TYPE, client_id=None, assertion="eyJ.assertion.sig"
)
assert refused.value.status_code == 400
assert refused.value.detail["error"] == "invalid_request"
@pytest.mark.parametrize("available", [True, False])
def test_root_metadata_is_the_gateways_own_and_lists_the_jwt_bearer_grant_when_it_is_served(available):
"""With no OAuth2 MCP server resolving at the root, the bare-origin document is the gateway's
own authorization server metadata: issuer is the origin an ID-JAG names as ``aud``, the token
endpoint is ``{base}/token``, and the jwt-bearer grant (with HTTP Basic client auth) is listed
exactly when the gateway serves it, under the same gates as the aggregate ``/mcp`` document."""
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
global_mcp_server_manager.registry.clear()
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
with (
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=available), # test-quality-ok: deployment gate seam
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
):
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
assert document["issuer"] == "https://llm.example.com"
assert document["token_endpoint"] == "https://llm.example.com/token"
assert (JWT_BEARER_GRANT_TYPE in document["grant_types_supported"]) is available
assert ("client_secret_basic" in document["token_endpoint_auth_methods_supported"]) is available
assert ("none" in document["token_endpoint_auth_methods_supported"]) is available
assert "urn:ietf:params:oauth:grant-type:token-exchange" in document["grant_types_supported"]
def test_root_metadata_owned_by_a_resolved_oauth2_server_never_lists_the_gateways_grant():
"""When exactly one OAuth2 MCP server resolves at the root, the root document describes that
upstream's token endpoint, which cannot redeem an ID-JAG, so the jwt-bearer grant is not
advertised there even though the gateway serves it; a requesting app uses the aggregate
``/.well-known/oauth-authorization-server/mcp`` document in that deployment."""
from fastapi import Request
from litellm.proxy.mcp_server import discoverable_endpoints
from litellm.proxy.mcp_server.gateway_dcr_flow import JWT_BEARER_GRANT_TYPE
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
server = _create_oauth2_server()
global_mcp_server_manager.registry.clear()
global_mcp_server_manager.registry[server.server_id] = server
request = MagicMock(spec=Request)
request.base_url = "https://llm.example.com/"
request.headers = {}
try:
with (
patch.object(discoverable_endpoints, "jwt_bearer_available", return_value=True), # test-quality-ok: deployment gate seam
patch.object(discoverable_endpoints, "token_exchange_available", return_value=True), # test-quality-ok: deployment gate seam
patch.object(global_mcp_server_manager, "get_mcp_server_by_name", return_value=server), # test-quality-ok: resolver seam
):
document = discoverable_endpoints._build_oauth_authorization_server_response(request=request, mcp_server_name=None)
finally:
global_mcp_server_manager.registry.clear()
assert document["token_endpoint"] == "https://llm.example.com/test_oauth/token"
assert JWT_BEARER_GRANT_TYPE not in document["grant_types_supported"]
assert document["grant_types_supported"] == ["authorization_code", "refresh_token"]
assert document["token_endpoint_auth_methods_supported"] == ["client_secret_post"]