diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 858a993c4ec..0b43c3864ab 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -29465,17 +29465,6 @@ "client_assertion_signing_alg": { "anyOf": [ { - "enum": [ - "RS256", - "RS384", - "RS512", - "PS256", - "PS384", - "PS512", - "ES256", - "ES384", - "ES512" - ], "type": "string" }, { @@ -32177,17 +32166,6 @@ "client_assertion_signing_alg": { "anyOf": [ { - "enum": [ - "RS256", - "RS384", - "RS512", - "PS256", - "PS384", - "PS512", - "ES256", - "ES384", - "ES512" - ], "type": "string" }, { @@ -35461,17 +35439,6 @@ "client_assertion_signing_alg": { "anyOf": [ { - "enum": [ - "RS256", - "RS384", - "RS512", - "PS256", - "PS384", - "PS512", - "ES256", - "ES384", - "ES512" - ], "type": "string" }, { @@ -39322,17 +39289,6 @@ "client_assertion_signing_alg": { "anyOf": [ { - "enum": [ - "RS256", - "RS384", - "RS512", - "PS256", - "PS384", - "PS512", - "ES256", - "ES384", - "ES512" - ], "type": "string" }, { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4affa55f903..036dcb01759 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -15,6 +15,8 @@ from pydantic import ( Json, JsonValue, PositiveInt, + TypeAdapter, + ValidationError, field_validator, model_validator, ) @@ -45,6 +47,10 @@ from litellm.types.mcp import ( MCPTransportType, ) from litellm.types.mcp_server.mcp_server_manager import MCPInfo +from litellm.types.proxy.auth.jwt_algorithms import ( + APPROVED_JWT_ALGORITHMS, + ApprovedJwtAlgorithm, +) from litellm.types.proxy.carried_budget_state import ( OrgBudgetSnapshot, TeamBudgetSnapshot, @@ -1502,6 +1508,19 @@ def _reject_unsupported_per_server_oauth_discovery(values: object, require_auth_ raise _per_server_oauth_discovery_error() +_APPROVED_JWT_ALGORITHM_ADAPTER: Final = TypeAdapter(ApprovedJwtAlgorithm) + + +def _validated_client_assertion_signing_alg(credentials: MCPCredentials | None) -> MCPCredentials | None: + if credentials is None or credentials.get("client_assertion_signing_alg") is None: + return credentials + try: + _APPROVED_JWT_ALGORITHM_ADAPTER.validate_python(credentials["client_assertion_signing_alg"]) + except ValidationError as exc: + raise ValueError(f"client_assertion_signing_alg must be one of {', '.join(APPROVED_JWT_ALGORITHMS)}") from exc + return credentials + + class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: str | None = None server_name: str | None = None @@ -1565,6 +1584,11 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): description="Server-managed: set by the endpoint; caller values are overridden.", ) + @field_validator("credentials") + @classmethod + def check_client_assertion_signing_alg(cls, credentials): + return _validated_client_assertion_signing_alg(credentials) + @model_validator(mode="before") @classmethod def validate_transport_fields(cls, values): @@ -1664,6 +1688,11 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): timeout: float | None = None max_concurrent_requests: int | None = None + @field_validator("credentials") + @classmethod + def check_client_assertion_signing_alg(cls, credentials): + return _validated_client_assertion_signing_alg(credentials) + @model_validator(mode="before") @classmethod def validate_transport_fields(cls, values): diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 7960abcd083..83e719810d5 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -12,7 +12,6 @@ from pydantic import BaseModel, ConfigDict, Field from typing_extensions import TypedDict from litellm.types.llms.base import HiddenParams -from litellm.types.proxy.auth.jwt_algorithms import ApprovedJwtAlgorithm if TYPE_CHECKING: import httpx2 @@ -252,7 +251,7 @@ class MCPCredentials(TypedDict, total=False): Key id (kid) advertised in the client_assertion JWT header """ - client_assertion_signing_alg: ApprovedJwtAlgorithm | None + client_assertion_signing_alg: str | None """ Signing algorithm for the client_assertion JWT. Default: RS256 """ diff --git a/tests/integration/mcp/test_mcp_client_assertion_signing_alg.py b/tests/integration/mcp/test_mcp_client_assertion_signing_alg.py index 883c335b71d..c2c32f9866b 100644 --- a/tests/integration/mcp/test_mcp_client_assertion_signing_alg.py +++ b/tests/integration/mcp/test_mcp_client_assertion_signing_alg.py @@ -1,10 +1,12 @@ import json +import os import uuid from typing import Final +import psycopg from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa -from integration._support.client import Gateway +from integration._support.client import Gateway, eventually from integration._support.database import read_rows from integration._support.mcp import forget_mcp, mcp_peer, register_mcp @@ -85,3 +87,34 @@ def test_approved_client_assertion_signing_alg_round_trips(gateway: Gateway) -> identity: Final = str(created.json()["server_id"]) scenario.cleanups.callback(forget_mcp, gateway, identity) assert _stored_client_assertion_signing_alg(identity) == "ES256" + + +def test_server_row_with_stale_client_assertion_signing_alg_still_loads(gateway: Gateway) -> None: + """A row persisted before the allowlist (credentials.client_assertion_signing_alg = "HS256") + must keep loading with the RS256 fallback instead of disappearing on upgrade.""" + identity: Final = "stalealg" + uuid.uuid4().hex[:8] + with psycopg.connect(os.environ["DATABASE_URL"]) as connection: + connection.execute( + 'INSERT INTO "LiteLLM_MCPServerTable" (server_id, server_name, url, transport, credentials,' + " created_at, updated_at) VALUES (%s, %s, %s, %s, %s::jsonb, NOW(), NOW())", + ( + identity, + "stalealg" + uuid.uuid4().hex[:8], + "https://mcp.integration.invalid", + "http", + json.dumps({"client_assertion_signing_alg": "HS256"}), + ), + ) + try: + with mcp_peer() as peer, gateway.scenario() as scenario: + register_mcp(scenario, peer, "staletrigger" + uuid.uuid4().hex[:8]) + listed: Final = eventually( + lambda: gateway.request("GET", "/v1/mcp/server"), + lambda response: response.status_code == 200 + and any(server.get("server_id") == identity for server in response.json()), + seconds=30, + ) + assert listed.status_code == 200, listed.text + finally: + with psycopg.connect(os.environ["DATABASE_URL"]) as connection: + connection.execute('DELETE FROM "LiteLLM_MCPServerTable" WHERE server_id = %s', (identity,)) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index cfcff73b857..ec145bded45 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -1751,3 +1751,20 @@ async def test_unverified_legacy_cache_cannot_bypass_enforcement(monkeypatch): await mcp_per_user_token_cache.set("alice", "srv", "bob", 60) assert await module.resolve_user_oauth_access_token("alice", server) is None assert await mcp_per_user_token_cache.get("alice", "srv") is None + + +def test_server_table_row_with_stale_client_assertion_signing_alg_still_validates() -> None: + """Rows written before the approved-algorithm allowlist carry values like HS256 in the + credentials blob; the stored shape stays lenient so reload_servers_from_database still + parses the row and _stored_client_assertion_signing_alg falls back to RS256 instead of + the server silently disappearing on upgrade.""" + row: Final = { + "server_id": "srv-stale-alg", + "server_name": "stale_alg_server", + "url": "https://mcp.example.com", + "transport": "http", + "credentials": {"client_assertion_signing_alg": "HS256"}, + } + parsed: Final = LiteLLM_MCPServerTable.model_validate(row) + assert parsed.credentials is not None + assert parsed.credentials["client_assertion_signing_alg"] == "HS256" diff --git a/tests/test_litellm/proxy/test__types.py b/tests/test_litellm/proxy/test__types.py index f5abe0561db..9f17ddb12a3 100644 --- a/tests/test_litellm/proxy/test__types.py +++ b/tests/test_litellm/proxy/test__types.py @@ -377,3 +377,33 @@ def test_change_password_request_passwords_hidden_from_repr(): for rendered in (repr(request), str(request)): assert "hunter2hunter2" not in rendered assert "NewP@ssw0rd-2026" not in rendered + + +def test_mcp_server_requests_reject_non_approved_client_assertion_signing_alg() -> None: + """The REST boundary is strict even though the stored blob stays lenient: + a write carrying HS256/hs256/"" must 422 naming the field.""" + from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest + + for cls, base in ( + (NewMCPServerRequest, {"transport": "http", "url": "https://mcp.example.com"}), + (UpdateMCPServerRequest, {"server_id": "srv-1", "transport": "http", "url": "https://mcp.example.com"}), + ): + for alg in ("HS256", "hs256", "", "EdDSA"): + with pytest.raises(ValidationError) as exc: + cls(**base, credentials={"client_assertion_signing_alg": alg}) + assert "client_assertion_signing_alg" in str(exc.value), f"{cls.__name__} accepted {alg!r}" + + +def test_mcp_server_requests_accept_approved_client_assertion_signing_alg() -> None: + from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest + + for alg in ("ES256", "PS384", "RS256", None): + request = NewMCPServerRequest( + transport="http", + url="https://mcp.example.com", + credentials={"client_assertion_signing_alg": alg}, + ) + assert request.credentials is not None + assert request.credentials["client_assertion_signing_alg"] == alg + update = UpdateMCPServerRequest(server_id="srv-1") + assert update.credentials is None diff --git a/tests/test_litellm/types/test_mcp.py b/tests/test_litellm/types/test_mcp.py index 1e997a3a892..e714f89be03 100644 --- a/tests/test_litellm/types/test_mcp.py +++ b/tests/test_litellm/types/test_mcp.py @@ -86,28 +86,3 @@ async def test_the_hook_drops_the_slot_only_once_the_origin_changes() -> None: await hook(foreign) assert "esb-oauth" not in foreign.headers - -def test_new_mcp_server_request_rejects_non_approved_client_assertion_signing_alg() -> None: - from pydantic import ValidationError - - from litellm.proxy._types import NewMCPServerRequest - - with pytest.raises(ValidationError) as exc: - NewMCPServerRequest( - transport="http", - url="https://mcp.example.com", - credentials={"client_assertion_signing_alg": "HS256"}, - ) - assert "client_assertion_signing_alg" in str(exc.value) - - -def test_new_mcp_server_request_accepts_approved_client_assertion_signing_alg() -> None: - from litellm.proxy._types import NewMCPServerRequest - - request = NewMCPServerRequest( - transport="http", - url="https://mcp.example.com", - credentials={"client_assertion_signing_alg": "ES256"}, - ) - assert request.credentials is not None - assert request.credentials["client_assertion_signing_alg"] == "ES256" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index af40e742905..2938e65cfde 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -34841,7 +34841,7 @@ export interface components { /** Aws Session Token */ aws_session_token?: string | null; /** Client Assertion Signing Alg */ - client_assertion_signing_alg?: ("RS256" | "RS384" | "RS512" | "PS256" | "PS384" | "PS512" | "ES256" | "ES384" | "ES512") | null; + client_assertion_signing_alg?: string | null; /** Client Id */ client_id?: string | null; /** Client Private Key */