Merge remote-tracking branch 'origin/main' into litellm_cost_shard_batches_realtime

This commit is contained in:
kerry 2026-09-21 21:34:05 +00:00
commit ebad623079
8 changed files with 487 additions and 41 deletions

View file

@ -427,7 +427,7 @@ class LLMCallSpanData:
# plain ``.get`` — no repeated ``isinstance`` guards.
raw_response: Final = payload.get("response")
response: Final = cast(Mapping[str, object], raw_response if isinstance(raw_response, dict) else {})
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response)
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response) or _ocr_choices(response)
# ``finish_reasons`` is metadata, not content, so derive it from
# ``choices_out`` before gating. The raw message/choice bodies are only
# retained when content capture is enabled (see ``capture_span_content``);
@ -752,6 +752,22 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]:
return (choice,)
def _ocr_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]:
markdowns: Final = tuple(
text for page in _dicts(response.get("pages")) if (text := as_str(page.get("markdown"))) is not None
)
if not markdowns:
return ()
message: Final[_AssistantMessage] = {
"role": "assistant",
"content": "\n\n".join(markdowns),
"refusal": None,
"tool_calls": None,
}
choice: Final[_Choice] = {"message": message, "finish_reason": None}
return (choice,)
def _responses_parts_text(parts: tuple[Mapping[str, object], ...], part_type: str, field: str) -> str | None:
texts: Final = tuple(
text for part in parts if part.get("type") == part_type if (text := as_str(part.get(field))) is not None

View file

@ -2500,9 +2500,8 @@ class MCPServerManager:
# Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so
# an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the
# entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP.
resolved_scopes = self._extract_scopes(server_config.get("scopes")) or (
gated_oauth_metadata.scopes if gated_oauth_metadata else None
)
configured_scopes = self._extract_scopes(server_config.get("scopes"))
resolved_scopes = configured_scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None)
resolved_authorization_url = manual_authorization_url or (
gated_oauth_metadata.authorization_url if gated_oauth_metadata else None
)
@ -2579,6 +2578,7 @@ class MCPServerManager:
client_secret=server_config.get("client_secret", None),
oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
scopes=resolved_scopes,
configured_scopes=tuple(configured_scopes) if configured_scopes else None,
issuer=effective_issuer,
issuer_is_anchored=use_issuer_anchor,
authorization_url=resolved_authorization_url,
@ -3055,6 +3055,18 @@ class MCPServerManager:
if scopes_value is not None:
scopes = self._extract_scopes(scopes_value)
stored_scopes: Final[object] = credentials_dict.get("scopes") if credentials_dict else None
scopes_as_objects: Final = (
cast(Sequence[object], stored_scopes) # cast-ok: list shape validated below
if isinstance(stored_scopes, list)
else ()
)
configured_scopes: Final = (
tuple(scope for scope in scopes_as_objects if isinstance(scope, str))
if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects)
else None
)
name_for_prefix: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id
mcp_info: Final[MCPInfo] = _mcp_info.copy()
@ -3129,6 +3141,7 @@ class MCPServerManager:
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
scopes=resolved_scopes,
configured_scopes=configured_scopes,
issuer=effective_issuer,
issuer_is_anchored=use_issuer_anchor,
authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None),
@ -7094,6 +7107,11 @@ class MCPServerManager:
spec_path=server.spec_path,
transport=server.transport,
auth_type=server.auth_type,
credentials=(
{"scopes": list(server.configured_scopes)} # mutable-ok: MCPCredentials requires a JSON-array list
if server.configured_scopes
else None
),
created_at=server.created_at,
updated_at=server.updated_at,
teams=[],

View file

@ -22,7 +22,14 @@ import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol
from typing import (
TYPE_CHECKING,
Annotated,
Final,
Literal,
Protocol,
cast, # noqa: TID251 # validated JSON values need explicit narrowing
)
from fastapi import (
APIRouter,
@ -628,8 +635,8 @@ if MCP_AVAILABLE:
def _preserved_admin_config_credentials(
credentials: "MCPCredentials | str | None",
) -> "dict[str, str] | None":
"""Keep only the non-secret admin-config keys, which are stored unencrypted so they lift out
) -> "dict[str, str | list[str]] | None": # mutable-ok: API response payload
"""Keep non-secret admin-config keys and scopes, which are stored unencrypted so they lift out
as plaintext; every secret and minted-token key is dropped.
Total over every stored shape: a dict is read directly, a JSON-object string is parsed, and
@ -639,15 +646,30 @@ if MCP_AVAILABLE:
parsed: object = credentials
if isinstance(credentials, str):
try:
parsed = json.loads(credentials)
parsed = cast(object, json.loads(credentials)) # cast-ok: JSON parse result is validated below
except (ValueError, TypeError):
return None
if not isinstance(parsed, dict):
return None
preserved: Final = {
key: value
for key in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS
if isinstance((value := parsed.get(key)), str) and value
parsed_credentials: Final = cast(Mapping[str, object], parsed) # cast-ok: dict shape validated above
scopes: Final[object] = parsed_credentials.get("scopes")
scopes_as_objects: Final = (
cast(Sequence[object], scopes) # cast-ok: list shape validated above
if isinstance(scopes, list)
else ()
)
preserved_scopes: Final = (
{"scopes": cast(list[str], scopes_as_objects)} # cast-ok: every scope is validated below
if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects)
else {}
)
preserved: Final = { # mutable-ok: API response payload
**{
key: value
for key in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS
if isinstance((value := parsed_credentials.get(key)), str) and value
},
**preserved_scopes,
}
return preserved or None
@ -827,7 +849,9 @@ if MCP_AVAILABLE:
if not credentials:
return False
as_dict: Final[dict[str, object]] = dict(credentials)
return any(value for key, value in as_dict.items() if key not in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS)
return any(
value for key, value in as_dict.items() if key not in MCP_ADMIN_CONFIG_CREDENTIAL_KEYS and key != "scopes"
)
def _inherit_credentials_from_existing_server(
payload: NewMCPServerRequest,

View file

@ -99,6 +99,7 @@ class MCPServer(BaseModel):
configured_authorization_url: str | None = None
configured_token_url: str | None = None
configured_registration_url: str | None = None
configured_scopes: tuple[str, ...] | None = None
# How the gateway authenticates to the upstream token endpoint. When
# "client_secret_basic" the credentials go in an HTTP Basic Authorization
# header (omitted from the body); None defaults to "client_secret_post".

View file

@ -872,6 +872,45 @@ def test_chat_choices_win_over_a_responses_output_list():
assert data.finish_reasons == ("stop",)
def _ocr_payload(pages: list[object]):
return _sample_payload(
call_type="aocr",
custom_llm_provider="mistral",
model="mistral-ocr-latest",
messages=None,
response={"object": "ocr", "model": "mistral-ocr-latest", "pages": pages, "usage_info": {"pages_processed": 2}},
)
def test_ocr_pages_become_one_assistant_choice_joined_in_page_order():
data = LLMCallSpanData.from_standard_logging_payload(
_ocr_payload([{"index": 0, "markdown": "# Invoice"}, {"index": 1, "markdown": "Total: 42"}]),
capture_content=True,
)
assert data.choices_out == (
{
"message": {"role": "assistant", "content": "# Invoice\n\nTotal: 42", "refusal": None, "tool_calls": None},
"finish_reason": None,
},
)
assert data.finish_reasons == ()
def test_ocr_output_follows_the_content_capture_gate():
data = LLMCallSpanData.from_standard_logging_payload(_ocr_payload([{"index": 0, "markdown": "# Invoice"}]))
assert data.choices_out == ()
def test_ocr_pages_without_markdown_stay_empty():
data = LLMCallSpanData.from_standard_logging_payload(
_ocr_payload([{"index": 0, "images": []}, "not-a-page"]), capture_content=True
)
assert data.choices_out == ()
def test_request_identity_prefers_canonical_team_keys():
from litellm.integrations.otel.model.payloads import RequestIdentity

View file

@ -227,6 +227,28 @@ def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_
assert attrs["langfuse.observation.type"] == "generation"
def test_langfuse_mapper_renders_an_ocr_call_with_the_page_markdown_as_output():
payload = {
"call_type": "aocr",
"custom_llm_provider": "mistral",
"model": "mistral-ocr-latest",
"messages": None,
"response": {
"object": "ocr",
"model": "mistral-ocr-latest",
"pages": [{"index": 0, "markdown": "# Invoice"}, {"index": 1, "markdown": "Total: 42"}],
"usage_info": {"pages_processed": 2},
},
}
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)
attrs = LangfuseMapper().map(data)
assert json.loads(attrs["langfuse.observation.output"]) == [
{"role": "assistant", "content": "# Invoice\n\nTotal: 42", "refusal": None, "tool_calls": None}
]
assert attrs["langfuse.observation.type"] == "generation"
# --------------------------------------------------------------------------- #
# Weave
# --------------------------------------------------------------------------- #

View file

@ -65,6 +65,8 @@ from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPAuth, MCPAuthType
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
from litellm.caching.caching import DualCache
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
import litellm
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy.utils import ProxyLogging
@ -10483,11 +10485,16 @@ def test_build_mcp_server_table_carries_oauth2_flow():
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
client_id="client-123",
client_secret="secret-xyz",
scopes=["scope:a", "scope:b"],
configured_scopes=("scope:a", "scope:b"),
)
table = manager._build_mcp_server_table(server)
assert table.oauth2_flow == "client_credentials"
assert table.credentials == {"scopes": ["scope:a", "scope:b"]}
def test_build_mcp_server_table_carries_null_oauth2_flow():
@ -10511,6 +10518,226 @@ def test_build_mcp_server_table_carries_null_oauth2_flow():
assert table.oauth2_flow is None
async def _mock_oauth_discovery(
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
*,
server_url: str,
scopes: list[str],
) -> None:
resource_metadata_url: Final[str] = "https://up.example.com/.well-known/oauth-protected-resource"
authorization_server_url: Final[str] = "https://up.example.com"
authorization_metadata_url: Final[str] = f"{authorization_server_url}/.well-known/oauth-authorization-server"
respx_mock.get(server_url).respond(
status_code=401,
headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata_url}"'},
)
respx_mock.get(resource_metadata_url).respond(
json={"authorization_servers": [authorization_server_url], "scopes_supported": scopes}
)
respx_mock.get(authorization_metadata_url).respond(
json={
"issuer": authorization_server_url,
"authorization_endpoint": f"{authorization_server_url}/authorize",
"token_endpoint": f"{authorization_server_url}/token",
}
)
clients: Final[LLMClientCache] = LLMClientCache()
monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients)
http_handler: Final[AsyncHTTPHandler] = AsyncHTTPHandler()
await http_handler.client.aclose()
http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(respx_mock.async_handler))
http_handler._owns_client = True
cache_key: Final[str] = f"async_httpx_clienttimeout_{MCP_METADATA_TIMEOUT}{httpxSpecialProvider.MCP.value}"
clients.set_cache(cache_key, http_handler)
@pytest.mark.asyncio
@pytest.mark.parametrize("discovery_on_startup", [True, False])
async def test_management_view_serves_configured_scopes_not_discovered_ones_from_db(
discovery_on_startup: bool,
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable(
server_id="discovered-scopes-db",
alias="discovered_scopes_db",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
created_at=datetime.now(),
updated_at=datetime.now(),
)
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"])
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
with patch.dict(os.environ, env, clear=True):
manager: Final[MCPServerManager] = MCPServerManager()
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
manager.registry[built.server_id] = built
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(built)
assert resolved.scopes == ["discovered.read"]
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
assert view.credentials is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("stored_scopes", "runtime_scopes"),
[
(None, ["openid"]),
([], ["openid"]),
([""], ["openid"]),
(["read", ""], ["read"]),
(["read", 7], ["read"]),
("read", ["read"]),
],
)
async def test_management_view_omits_invalid_or_absent_db_scopes(
stored_scopes: list[str | int] | str | None,
runtime_scopes: list[str],
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable.model_construct(
server_id="empty-scopes-db",
alias="empty_scopes_db",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
credentials=json.dumps({"scopes": stored_scopes}),
created_at=datetime.now(),
updated_at=datetime.now(),
)
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["openid"])
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"}
with patch.dict(os.environ, env, clear=True):
manager: Final[MCPServerManager] = MCPServerManager()
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
assert built.scopes == runtime_scopes
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(built)
assert view.credentials is None
@pytest.mark.asyncio
@pytest.mark.parametrize("discovery_on_startup", [True, False])
@pytest.mark.parametrize(
("stored_scopes", "runtime_scopes"),
[
(["calendar.read"], ["calendar.read"]),
([" "], ["discovered.read"]),
(["read", " "], ["read"]),
(["read", "read"], ["read", "read"]),
],
)
async def test_management_view_serves_explicitly_configured_scopes_from_db(
stored_scopes: list[str],
runtime_scopes: list[str],
discovery_on_startup: bool,
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable(
server_id="configured-scopes-db",
alias="configured_scopes_db",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
credentials={"scopes": stored_scopes},
created_at=datetime.now(),
updated_at=datetime.now(),
)
await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"])
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
with patch.dict(os.environ, env, clear=True):
manager: Final[MCPServerManager] = MCPServerManager()
built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
manager.registry[built.server_id] = built
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(built)
assert resolved.scopes == runtime_scopes
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
assert view.credentials == {"scopes": stored_scopes}
@pytest.mark.asyncio
@pytest.mark.parametrize("discovery_on_startup", [True, False])
@pytest.mark.parametrize(
("configured_scopes", "expected_view_scopes"),
[
(None, None),
(["calendar.read"], ["calendar.read"]),
([" "], None),
([""], None),
(["calendar.read", " "], ["calendar.read"]),
],
)
async def test_management_view_scopes_follow_yaml_config_not_discovery(
configured_scopes: list[str] | None,
expected_view_scopes: list[str] | None,
discovery_on_startup: bool,
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config: Final[dict[str, dict[str, object]]] = {
"yamlscopes": {
"url": "https://up.example.com/mcp",
"transport": MCPTransport.http,
"auth_type": MCPAuth.oauth2,
"oauth2_flow": "authorization_code",
"client_id": "cid",
"client_secret": "csec",
**({"scopes": configured_scopes} if configured_scopes is not None else {}),
}
}
await _mock_oauth_discovery(
respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]
)
env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {}
with patch.dict(os.environ, env, clear=True):
manager: Final[MCPServerManager] = MCPServerManager()
await manager.load_servers_from_config(config)
server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values()))
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(server)
assert resolved.scopes == (expected_view_scopes or ["discovered.read"])
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
assert view.credentials == ({"scopes": expected_view_scopes} if expected_view_scopes else None)
@pytest.mark.asyncio
async def test_lazy_yaml_discovery_keeps_configured_scopes_out_of_the_management_view(
respx_mock: MockRouter,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config: Final[dict[str, dict[str, object]]] = {
"lazyyamlscopes": {
"url": "https://up.example.com/mcp",
"transport": MCPTransport.http,
"auth_type": MCPAuth.oauth2,
"oauth2_flow": "authorization_code",
"client_id": "cid",
"client_secret": "csec",
}
}
await _mock_oauth_discovery(
respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]
)
with patch.dict(os.environ, {}, clear=True):
manager: Final[MCPServerManager] = MCPServerManager()
await manager.load_servers_from_config(config)
server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values()))
resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(server)
assert resolved.scopes == ["discovered.read"]
view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved)
assert view.credentials is None
@pytest.mark.asyncio
async def test_resolve_toolset_tool_permissions_single_db_fetch_across_checks():
"""The server-level and tool-level permission primitives each resolve the

View file

@ -6,7 +6,7 @@ import logging
from contextlib import ExitStack
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import List, Optional
from typing import List, Optional, cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -29,7 +29,7 @@ from litellm.proxy._types import (
UpdateMCPServerRequest,
UserAPIKeyAuth,
)
from litellm.types.mcp import MCPAuth
from litellm.types.mcp import MCPAuth, MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -834,6 +834,83 @@ class TestListMCPServers:
mock_health_result.health_check_error = None
mock_user_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch( # test-quality-ok: endpoint test must patch module globals
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch( # test-quality-ok: endpoint test must patch module globals
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=mock_server),
),
patch( # test-quality-ok: endpoint test must patch module globals
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager.health_check_server",
AsyncMock(return_value=mock_health_result),
),
patch( # test-quality-ok: endpoint test must patch module globals
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=True,
),
):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_mcp_server,
)
result = await fetch_mcp_server(
request=_make_mock_request(),
server_id="server-mal",
user_api_key_dict=mock_user_auth,
)
assert result.credentials == expected
@pytest.mark.parametrize(
"stored_credentials, expected",
[
(
{
"client_id": "cid",
"client_secret": "csecret",
"scopes": ["read", "write"],
"upstream_token_header": "esb-oauth",
},
{"scopes": ["read", "write"], "upstream_token_header": "esb-oauth"},
),
(
'{"client_id": "cid", "client_secret": "csecret", "scopes": ["read", "write"], '
'"upstream_token_header": "esb-oauth"}',
{"scopes": ["read", "write"], "upstream_token_header": "esb-oauth"},
),
(
{"client_id": "cid", "client_secret": "csecret", "scopes": []},
None,
),
(
'{"client_id": "cid", "client_secret": "csecret", "scopes": []}',
None,
),
(
{"client_id": "cid", "client_secret": "csecret", "scopes": ["read", ""]},
None,
),
(
{"client_id": "cid", "client_secret": "csecret", "scopes": "read"},
None,
),
],
)
@pytest.mark.asyncio
async def test_fetch_single_mcp_server_preserves_valid_oauth_scopes(
self, stored_credentials: object, expected: object
):
mock_server = generate_mock_mcp_server_db_record(server_id="server-scopes", alias="Scopes")
mock_server.credentials = cast(MCPCredentials, stored_credentials)
mock_health_result = generate_mock_mcp_server_db_record(server_id="server-scopes", alias="Scopes")
mock_health_result.status = "healthy"
mock_health_result.last_health_check = datetime.now()
mock_health_result.health_check_error = None
mock_user_auth = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
@ -858,11 +935,11 @@ class TestListMCPServers:
result = await fetch_mcp_server(
request=_make_mock_request(),
server_id="server-mal",
server_id="server-scopes",
user_api_key_dict=mock_user_auth,
)
assert result.credentials == expected
assert result.credentials == expected
@pytest.mark.asyncio
async def test_fetch_single_mcp_server_strips_upstream_resource_for_non_admin(self):
@ -1635,14 +1712,26 @@ class TestTemporaryMCPSessionEndpoints:
return _inherit_credentials_from_existing_server(payload)
def test_admin_config_alone_does_not_suppress_credential_inheritance(self):
"""The edit form round-trips upstream_resource, which is admin config rather than a credential.
Treating the blob as "credentials supplied" left the Authorize session with no declared app on
the exact path where this knob is configured."""
updated = self._inherit_with({"upstream_resource": "api://audience"})
@pytest.mark.parametrize(
"credentials",
[
{"upstream_resource": "api://audience"},
{"scopes": ["scope:a", "scope:b"]},
{"scopes": ["scope:edited"], "upstream_resource": "api://audience"},
{"scopes": ["scope:edited"], "upstream_token_header": "esb-oauth"},
{"scopes": []},
{"scopes": None},
],
)
def test_admin_config_alone_does_not_suppress_credential_inheritance(self, credentials: MCPCredentials):
updated = self._inherit_with(credentials, scopes=["scope:stored"])
assert updated.credentials["client_id"] == "client-123"
assert updated.credentials["client_secret"] == "secret-xyz"
assert updated.credentials == {
"client_id": "client-123",
"client_secret": "secret-xyz",
"scopes": ["scope:stored"],
**credentials,
}
def test_upstream_token_header_is_inherited_like_other_admin_config(self):
"""It is admin config rather than a credential, so a session server derived from an existing
@ -1661,11 +1750,18 @@ class TestTemporaryMCPSessionEndpoints:
assert updated.credentials["client_secret"] == "secret-xyz"
assert updated.credentials["upstream_token_header"] == "esb-oauth"
def test_supplied_credential_still_wins_over_inheritance(self):
"""A caller that supplies a real credential keeps it; inheritance must not overwrite it."""
updated = self._inherit_with({"auth_value": "caller-token"})
@pytest.mark.parametrize(
"credentials",
[
{"auth_value": "caller-token"},
{"client_id": "caller-client", "scopes": ["scope:edited"]},
{"client_secret": "caller-secret", "scopes": ["scope:edited"]},
],
)
def test_supplied_credential_still_wins_over_inheritance(self, credentials: MCPCredentials):
updated = self._inherit_with(credentials)
assert updated.credentials == {"auth_value": "caller-token"}
assert updated.credentials == credentials
def test_inheritance_carries_upstream_resource_to_the_session_server(self):
"""Without this the temporary server omits the resource indicator and the Authorize leg it
@ -2339,7 +2435,7 @@ class TestTemporaryMCPSessionEndpoints:
"client_secret": "client-secret",
"scopes": ["scope1"],
}
assert response.credentials is None
assert response.credentials == {"scopes": ["scope1"]}
@pytest.mark.asyncio
async def test_add_session_mcp_server_rejects_non_admins(self):
@ -4494,13 +4590,9 @@ class TestMCPApprovalWorkflow:
assert result.total == 1
assert result.pending_review == 1
@pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]])
@pytest.mark.asyncio
async def test_get_submissions_sanitizes_for_view_only_admin(self):
"""PROXY_ADMIN_VIEW_ONLY reviewing the submission queue must go through
the non-admin sanitizer that fetch/list endpoints use: url,
static_headers, env, env_vars, and credentials are all dropped. A
mutation swapping the gate back to the old partial-blank pattern (which
left url/static_headers/env and env-var names intact) would fail this."""
async def test_get_submissions_sanitizes_for_view_only_admin(self, allowed_routes: list[str] | None):
from litellm.proxy._types import MCPSubmissionsSummary
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_mcp_server_submissions,
@ -4508,6 +4600,7 @@ class TestMCPApprovalWorkflow:
item = _leaky_list_server()
item.approval_status = "pending_review"
item.spec_path = "https://example.com/spec.json?key=private"
summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item])
with (
@ -4521,11 +4614,15 @@ class TestMCPApprovalWorkflow:
),
):
result = await get_mcp_server_submissions(
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
user_api_key_dict=UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, allowed_routes=allowed_routes
),
)
assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0)
assert len(result.items) == 1
sanitized = result.items[0]
assert sanitized.spec_path is None
assert sanitized.url is None
assert sanitized.static_headers is None
assert sanitized.env == {}
@ -4536,11 +4633,9 @@ class TestMCPApprovalWorkflow:
assert item.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url"
assert item.static_headers == {"Authorization": "Bearer sk-secret-header"}
@pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]])
@pytest.mark.asyncio
async def test_get_submissions_full_admin_still_sees_secrets(self):
"""The view-only redaction must not over-redact for a full PROXY_ADMIN,
who needs url/static_headers/env/env_vars to review the pending
submission. Only the explicit credentials field is cleared."""
async def test_get_submissions_full_admin_preserves_review_fields(self, allowed_routes: list[str] | None):
from litellm.proxy._types import MCPSubmissionsSummary
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_mcp_server_submissions,
@ -4548,6 +4643,7 @@ class TestMCPApprovalWorkflow:
item = _leaky_list_server()
item.approval_status = "pending_review"
item.spec_path = "https://example.com/spec.json?key=private"
summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item])
with (
@ -4561,11 +4657,14 @@ class TestMCPApprovalWorkflow:
),
):
result = await get_mcp_server_submissions(
user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=allowed_routes),
)
assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0)
assert len(result.items) == 1
raw = result.items[0]
assert raw.spec_path == item.spec_path
assert raw.approval_status == "pending_review"
assert raw.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url"
assert raw.static_headers == {"Authorization": "Bearer sk-secret-header"}
assert raw.env == {"UPSTREAM_TOKEN": "sk-secret-env"}