diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 7d9e230f8ef..c484e2c1bb8 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -37348,6 +37348,20 @@ ], "title": "Dcr Server Url" }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, "id_jag_resource": { "anyOf": [ { @@ -40176,6 +40190,20 @@ ], "title": "Dcr Server Url" }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, "id_jag_resource": { "anyOf": [ { @@ -43592,6 +43620,20 @@ ], "title": "Dcr Server Url" }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, "id_jag_resource": { "anyOf": [ { @@ -47683,6 +47725,20 @@ ], "title": "Dcr Server Url" }, + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers" + }, "id_jag_resource": { "anyOf": [ { diff --git a/litellm/proxy/mcp_server/db.py b/litellm/proxy/mcp_server/db.py index 27f8b90a586..5964705a97d 100644 --- a/litellm/proxy/mcp_server/db.py +++ b/litellm/proxy/mcp_server/db.py @@ -564,7 +564,16 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: str | None) new_encryption_key=encryption_key, ) # aws_region_name and aws_service_name are NOT secrets — stored as-is - return credentials + headers: Final = credentials.get("headers") + if headers is None: + return credentials + return { + **credentials, + "headers": { + name: encrypt_value_helper(value=value, new_encryption_key=encryption_key) + for name, value in headers.items() + }, + } def _credentials_blob_to_mutable_dict(blob: str | Mapping[str, object]) -> dict[str, object]: @@ -844,7 +853,22 @@ def decrypt_credentials( exception_type="debug", return_original_value=True, ) - return credentials + headers: Final = credentials.get("headers") + if not isinstance(headers, dict): + return credentials + return { + **credentials, + "headers": { + name: decrypted + for name, value in headers.items() + if ( + decrypted := decrypt_value_helper( + value=value, key="headers", exception_type="debug", return_original_value=True + ) + ) + is not None + }, + } def _readable_mcp_servers( diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 3fa9adef913..e1cd37790b3 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -172,6 +172,11 @@ class MCPCredentials(TypedDict, total=False): Authentication value """ + headers: ReadOnly[Mapping[str, str] | None] + """ + Named secret headers sent upstream. Values are encrypted at rest. + """ + client_id: str | None """ OAuth 2.0 client identifier used when auth_type is oauth2 diff --git a/tests/unit/proxy/mcp_server/test_db_credentials.py b/tests/unit/proxy/mcp_server/test_db_credentials.py index db2572ef954..4be81cfcadb 100644 --- a/tests/unit/proxy/mcp_server/test_db_credentials.py +++ b/tests/unit/proxy/mcp_server/test_db_credentials.py @@ -10,6 +10,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work. import base64 import json +from collections.abc import Mapping from datetime import datetime, timedelta, timezone from types import SimpleNamespace from typing import Final @@ -26,6 +27,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( encrypt_secret_map, encrypt_value_helper, ) +from litellm.proxy.management_endpoints.mcp_management_endpoints import _redact_mcp_credentials from litellm.proxy.mcp_server.db import ( _decode_user_credential, _prepare_mcp_server_data, @@ -478,6 +480,51 @@ def test_client_private_key_encrypted_at_rest(): assert decrypted["id_jag_resource_client_secret"] == resource_client_secret +_DATADOG_HEADERS: Final = {"DD-API-KEY": "dd-api-secret", "DD-APPLICATION-KEY": "dd-app-secret"} + + +def test_credential_header_values_are_encrypted_and_round_trip() -> None: + encrypted: Final = encrypt_credentials({"headers": dict(_DATADOG_HEADERS)}, encryption_key=None) + assert encrypted["headers"].keys() == _DATADOG_HEADERS.keys() + assert all(secret not in json.dumps(encrypted) for secret in _DATADOG_HEADERS.values()) + assert decrypt_credentials(dict(encrypted))["headers"] == _DATADOG_HEADERS + + +def test_admin_read_drops_credential_headers_and_keeps_scopes() -> None: + server: Final = LiteLLM_MCPServerTable( + server_id="srv", + transport=MCPTransport.http, + credentials={"headers": dict(_DATADOG_HEADERS), "scopes": ["read"]}, + ) + assert _redact_mcp_credentials(server).credentials == {"scopes": ["read"]} + + +@pytest.mark.asyncio +async def test_credential_headers_are_stored_encrypted_kept_on_unrelated_update_and_replaced_on_new_headers() -> None: + table: Final = _MapTable() + prisma: Final = SimpleNamespace( + db=SimpleNamespace(litellm_mcpservertable=table, litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None))) + ) + create: Final = NewMCPServerRequest.model_validate( + {"server_id": "srv-dd", "transport": "http", "url": "https://up.example.com/mcp", "credentials": {"headers": dict(_DATADOG_HEADERS)}} + ) + await create_mcp_server(prisma, create, touched_by="test") + stored: Final = table.rows["srv-dd"]["credentials"] + assert all(secret not in stored for secret in _DATADOG_HEADERS.values()) + assert decrypt_credentials(json.loads(stored))["headers"] == _DATADOG_HEADERS + + await update_mcp_server(prisma, UpdateMCPServerRequest(server_id="srv-dd", description="renamed"), touched_by="test") + assert table.rows["srv-dd"]["credentials"] == stored + + replacement: Final = {"DD-API-KEY": "rotated-secret"} + await update_mcp_server( + prisma, + UpdateMCPServerRequest.model_validate({"server_id": "srv-dd", "credentials": {"headers": replacement}}), + touched_by="test", + ) + assert decrypt_credentials(json.loads(table.rows["srv-dd"]["credentials"]))["headers"] == replacement + + @pytest.fixture(params=["xsalsa20-poly1305", "aes-256-gcm"]) def map_algorithm(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str: monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"encryption_algorithm": request.param}) @@ -506,6 +553,10 @@ class _MapTable: async def update(self, *, where: dict[str, str], data: dict[str, object]) -> PrismaMCPServer: return await self.create(data={**self.rows[where["server_id"]], **data}) + async def find_unique(self, *, where: Mapping[str, str]) -> PrismaMCPServer | None: + row: Final = self.rows.get(where["server_id"]) + return None if row is None else _prisma_map_row(row, self.quoted) + async def find_many(self, where: object = None) -> list[PrismaMCPServer]: return [_prisma_map_row(row, self.quoted) for row in self.rows.values()] diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d14e490f606..a7749800820 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -38809,6 +38809,10 @@ export interface components { dcr_issuer?: string | null; /** Dcr Server Url */ dcr_server_url?: string | null; + /** Headers */ + headers?: { + [key: string]: string; + } | null; /** Id Jag Resource */ id_jag_resource?: string | null; /** Id Jag Resource Client Id */