diff --git a/.gitattributes b/.gitattributes index 9030923a781..5c9061f52ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -*.ipynb linguist-vendored \ No newline at end of file +*.ipynb linguist-vendored +ui/litellm-dashboard/src/lib/http/schema.d.ts linguist-generated \ No newline at end of file diff --git a/.github/workflows/check-ui-api-types.yml b/.github/workflows/check-ui-api-types.yml new file mode 100644 index 00000000000..eeb5545b15e --- /dev/null +++ b/.github/workflows/check-ui-api-types.yml @@ -0,0 +1,84 @@ +name: Check UI API Types Sync + +on: + pull_request: + paths: + - "litellm/proxy/**" + - "litellm/types/**" + - "ui/litellm-dashboard/src/lib/http/schema.d.ts" + - "ui/litellm-dashboard/scripts/gen-api-types.mjs" + - "ui/litellm-dashboard/package.json" + - "ui/litellm-dashboard/package-lock.json" + - ".github/workflows/check-ui-api-types.yml" + +permissions: + contents: read + +jobs: + check-sync: + name: Verify schema.d.ts matches the proxy OpenAPI spec + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Cache uv dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + ~/.cache/uv + .venv + key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} + restore-keys: | + ${{ runner.os }}-uv- + + - name: Install backend dependencies + run: uv sync --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router + + - name: Generate Prisma client + env: + PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache + run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma + + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/litellm-dashboard/package-lock.json + + - name: Install dashboard dependencies + working-directory: ui/litellm-dashboard + run: npm ci + + - name: Regenerate types from the live spec + working-directory: ui/litellm-dashboard + env: + LITELLM_PYTHON: "uv run --no-sync python" + run: npm run gen:api + + - name: Fail if types are stale + run: | + if ! git diff --exit-code -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then + echo "::error file=ui/litellm-dashboard/src/lib/http/schema.d.ts::Generated API types are out of sync with the proxy OpenAPI spec." + echo "" + echo "A backend route or model changed without regenerating the dashboard types." + echo "To fix, run from ui/litellm-dashboard:" + echo " npm run gen:api" + echo "then commit the updated src/lib/http/schema.d.ts." + exit 1 + fi + echo "schema.d.ts is in sync with the proxy OpenAPI spec." diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260520120000_add_mcp_env_vars/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260520120000_add_mcp_env_vars/migration.sql new file mode 100644 index 00000000000..08d35cd74a3 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260520120000_add_mcp_env_vars/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable: add admin-configured env_vars to MCP server table +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "env_vars" JSONB DEFAULT '[]'; + +-- CreateTable: per-user env var values for MCP servers +CREATE TABLE IF NOT EXISTS "LiteLLM_MCPUserEnvVars" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "values_b64" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LiteLLM_MCPUserEnvVars_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_user_id_server_id_key" ON "LiteLLM_MCPUserEnvVars"("user_id", "server_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_user_id_idx" ON "LiteLLM_MCPUserEnvVars"("user_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "LiteLLM_MCPUserEnvVars_server_id_idx" ON "LiteLLM_MCPUserEnvVars"("server_id"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index b51da83a632..e21c0016491 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -311,6 +311,11 @@ model LiteLLM_MCPServerTable { tool_name_to_description Json? @default("{}") extra_headers String[] @default([]) static_headers Json? @default("{}") + // Admin-configured environment variables interpolated into static_headers + // via ${NAME} syntax. Stored as an array of + // {name, value, scope, description}. scope is "global" (value used as-is) + // or "user" (value supplied per-user via LiteLLM_MCPUserEnvVars). + env_vars Json? @default("[]") // Health check status status String? @default("unknown") last_health_check DateTime? @@ -366,6 +371,21 @@ model LiteLLM_MCPUserCredentials { @@unique([user_id, server_id]) } +// Per-user environment variable values for MCP servers. +// values_b64 is an encrypted JSON object: {VAR_NAME: "value", ...}. +model LiteLLM_MCPUserEnvVars { + id String @id @default(uuid()) + user_id String + server_id String + values_b64 String + created_at DateTime @default(now()) + updated_at DateTime @default(now()) @updatedAt + + @@unique([user_id, server_id]) + @@index([user_id]) + @@index([server_id]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/litellm/__init__.py b/litellm/__init__.py index 8139cf8d6b5..f22971dfa13 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -16,8 +16,17 @@ import os # Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available import dotenv as _dotenv + +def _dev_env_hot_reload_enabled() -> bool: + """The proxy exports this flag when started with ``--reload``. A reloaded + worker is a fresh process that inherits the reloader's environment, so an + edited ``.env`` value stays masked by the stale inherited one unless we + let the file win; overriding makes the edit take effect on reload.""" + return os.getenv("LITELLM_DEV_ENV_HOT_RELOAD") == "True" + + if os.getenv("LITELLM_MODE", "DEV") == "DEV": - _dotenv.load_dotenv() + _dotenv.load_dotenv(override=_dev_env_hot_reload_enabled()) from typing import ( Callable, diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index aed00c060ca..c6d427e7f09 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -60,6 +60,27 @@ def to_basic_auth(auth_value: str) -> str: return base64.b64encode(auth_value.encode("utf-8")).decode() +def _strip_header_whitespace(headers: Dict[str, str]) -> Dict[str, str]: + return { + (key.strip() if isinstance(key, str) else key): ( + value.strip() if isinstance(value, str) else value + ) + for key, value in headers.items() + } + + +def _first_non_cancelled_cause(exc: BaseException) -> Optional[BaseException]: + queue: List[BaseException] = [exc] + while queue: + current = queue.pop(0) + nested = getattr(current, "exceptions", None) + if nested: + queue.extend(nested) + elif not isinstance(current, asyncio.CancelledError): + return current + return None + + TSessionResult = TypeVar("TSessionResult") @@ -335,6 +356,7 @@ class MCPClient: user input (elicitation), or send log messages. """ transport = await transport_ctx.__aenter__() + in_flight_error: Optional[BaseException] = None try: read_stream, write_stream = transport[0], transport[1] # Build session kwargs with optional callbacks @@ -360,11 +382,21 @@ class MCPClient: await session_ctx.__aexit__(None, None, None) except BaseException as e: verbose_logger.debug(f"Error during session context exit: {e}") + except BaseException as e: + in_flight_error = e + raise finally: try: await transport_ctx.__aexit__(None, None, None) - except BaseException as e: - verbose_logger.debug(f"Error during transport context exit: {e}") + except BaseException as exit_error: + verbose_logger.debug( + f"Error during transport context exit: {exit_error}" + ) + root_cause = _first_non_cancelled_cause(exit_error) + if root_cause is not None and isinstance( + in_flight_error, asyncio.CancelledError + ): + raise root_cause from in_flight_error async def run_with_session( self, operation: Callable[[ClientSession], Awaitable[TSessionResult]] @@ -426,7 +458,7 @@ class MCPClient: # update the headers with the extra headers if self.extra_headers: headers.update(self.extra_headers) - return headers + return _strip_header_whitespace(headers) def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]: """ diff --git a/litellm/llms/fal_ai/image_generation/__init__.py b/litellm/llms/fal_ai/image_generation/__init__.py index 9deeb403c46..7f3358934a7 100644 --- a/litellm/llms/fal_ai/image_generation/__init__.py +++ b/litellm/llms/fal_ai/image_generation/__init__.py @@ -7,6 +7,7 @@ from .flux_pro_v11_transformation import FalAIFluxProV11Config from .flux_pro_v11_ultra_transformation import FalAIFluxProV11UltraConfig from .flux_schnell_transformation import FalAIFluxSchnellConfig from .imagen4_transformation import FalAIImagen4Config +from .nano_banana_transformation import FalAINanoBananaConfig from .recraft_v3_transformation import FalAIRecraftV3Config from .ideogram_v3_transformation import FalAIIdeogramV3Config from .stable_diffusion_transformation import FalAIStableDiffusionConfig @@ -20,6 +21,7 @@ __all__ = [ "FalAIBaseConfig", "FalAIImageGenerationConfig", "FalAIImagen4Config", + "FalAINanoBananaConfig", "FalAIRecraftV3Config", "FalAIBriaConfig", "FalAIFluxProV11Config", @@ -45,7 +47,9 @@ def get_fal_ai_image_generation_config(model: str) -> BaseImageGenerationConfig: model_lower = model.lower() # Map model names to their corresponding configuration classes - if "imagen4" in model_lower or "imagen-4" in model_lower: + if "nano-banana" in model_lower or "gemini-25-flash-image" in model_lower: + return FalAINanoBananaConfig() + elif "imagen4" in model_lower or "imagen-4" in model_lower: return FalAIImagen4Config() elif "recraft" in model_lower: return FalAIRecraftV3Config() diff --git a/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py new file mode 100644 index 00000000000..dd4758055ac --- /dev/null +++ b/litellm/llms/fal_ai/image_generation/nano_banana_transformation.py @@ -0,0 +1,105 @@ +from typing import List, Optional + +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import OpenAIImageGenerationOptionalParams + +from .transformation import FalAIBaseConfig + + +class FalAINanoBananaConfig(FalAIBaseConfig): + """ + Configuration for Fal AI's Nano Banana / Gemini 2.5 Flash Image models. + + Serves the imagen4 deprecation migration path. The same underlying model is + exposed under two endpoints that share an identical schema: + - fal-ai/nano-banana + - fal-ai/gemini-25-flash-image + + Documentation: https://fal.ai/models/fal-ai/nano-banana + """ + + SUPPORTED_ASPECT_RATIOS: List[str] = [ + "21:9", + "16:9", + "3:2", + "4:3", + "5:4", + "1:1", + "4:5", + "3:4", + "2:3", + "9:16", + ] + + def get_complete_url( + self, + api_base: Optional[str], + api_key: Optional[str], + model: str, + optional_params: dict, + litellm_params: dict, + stream: Optional[bool] = None, + ) -> str: + base_url: str = ( + api_base or get_secret_str("FAL_AI_API_BASE") or self.DEFAULT_BASE_URL + ).rstrip("/") + endpoint = model if model.startswith("fal-ai/") else f"fal-ai/{model}" + return f"{base_url}/{endpoint}" + + def get_supported_openai_params( + self, model: str + ) -> List[OpenAIImageGenerationOptionalParams]: + return ["n", "response_format", "size"] + + def map_openai_params( + self, + non_default_params: dict, + optional_params: dict, + model: str, + drop_params: bool, + ) -> dict: + supported_params = self.get_supported_openai_params(model) + for key, value in non_default_params.items(): + if key == "response_format": + continue + elif key == "n": + if "num_images" not in optional_params: + optional_params["num_images"] = value + elif key == "size": + if "aspect_ratio" not in optional_params: + optional_params["aspect_ratio"] = self._map_aspect_ratio(value) + elif key not in optional_params and not drop_params: + raise ValueError( + f"Parameter {key} is not supported for model {model}. " + f"Supported parameters are {supported_params}. " + "Set drop_params=True to drop unsupported parameters." + ) + return optional_params + + def _map_aspect_ratio(self, size: str) -> str: + if not isinstance(size, str) or "x" not in size: + return "1:1" + try: + width, height = (int(part) for part in size.split("x")) + target = width / height + except (ValueError, ZeroDivisionError): + return "1:1" + + def ratio_of(aspect_ratio: str) -> float: + w, h = (int(part) for part in aspect_ratio.split(":")) + return w / h + + return min( + self.SUPPORTED_ASPECT_RATIOS, + key=lambda aspect_ratio: abs(ratio_of(aspect_ratio) - target), + ) + + def transform_image_generation_request( + self, + model: str, + prompt: str, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + return {"prompt": prompt, **optional_params} diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bccbce8ef15..397f96fdb1e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14014,6 +14014,22 @@ "/v1/images/generations" ] }, + "fal_ai/fal-ai/nano-banana": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "fal_ai/fal-ai/gemini-25-flash-image": { + "litellm_provider": "fal_ai", + "mode": "image_generation", + "output_cost_per_image": 0.039, + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index d7b2224eb64..0ba0181200f 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -1,16 +1,19 @@ import base64 import binascii +import hashlib import json from datetime import datetime, timedelta, timezone from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid +from litellm.constants import MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS from litellm.proxy._types import ( LiteLLM_MCPServerTable, LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, MCPApprovalStatus, + MCPEnvVarScope, MCPSubmissionsSummary, NewMCPServerRequest, SpecialMCPServerName, @@ -28,6 +31,144 @@ from litellm.types.llms.custom_http import httpxSpecialProvider from litellm.types.mcp import MCPCredentials +def _is_global_env_var_scope(scope: Any) -> bool: + """``scope="user"`` entries are placeholders the user fills in; everything + else (including a missing scope) is an admin-supplied global value.""" + return scope != MCPEnvVarScope.user and scope != "user" + + +def _encrypt_global_env_var_values(env_vars: Iterable[Dict[str, Any]]) -> None: + """Encrypt ``scope="global"`` env var values in place before persisting. + + Global values hold admin-supplied secrets (API keys, passwords) that get + interpolated into headers, so they are encrypted at rest like credentials + and the per-user ``values_b64`` column. Per-user placeholders are not + secrets and are stored verbatim. + """ + for entry in env_vars: + if not _is_global_env_var_scope(entry.get("scope")): + continue + value = entry.get("value") + if value: + entry["value"] = encrypt_value_helper(value) + + +def decrypt_global_env_var_values(env_vars: Optional[Iterable[Any]]) -> None: + """Decrypt ``scope="global"`` env var values in place after reading the DB. + + Accepts ``MCPEnvVar`` models (``LiteLLM_MCPServerTable``) or plain dicts + (raw rows / deserialized JSON). Global values are always stored encrypted, + so a value that no longer decrypts (e.g. after a salt-key change) is dropped + and a warning is logged rather than forwarding the ciphertext into upstream + ``${NAME}`` headers, where it would silently fail. + """ + if not env_vars: + return + for entry in env_vars: + is_dict = isinstance(entry, dict) + scope = entry.get("scope") if is_dict else getattr(entry, "scope", None) + if not _is_global_env_var_scope(scope): + continue + value = entry.get("value") if is_dict else getattr(entry, "value", None) + if not value: + continue + decrypted = decrypt_value_helper( + value=value, + key="mcp_global_env_var", + exception_type="debug", + return_original_value=False, + ) + if decrypted is None: + name = entry.get("name") if is_dict else getattr(entry, "name", None) + verbose_proxy_logger.warning( + "MCP global env var %s failed to decrypt (LITELLM_SALT_KEY " + "changed?); dropping it so ciphertext is not sent upstream", + name, + ) + decrypted = "" + if is_dict: + entry["value"] = decrypted + else: + entry.value = decrypted + + +def _decrypt_env_vars_on_returned_row(row: Any) -> None: + """Decrypt ``scope="global"`` env var values on a row returned by Prisma create/update. + + Prisma may hand back ``env_vars`` either as a parsed list (the common case for + JSONB columns) or as a raw JSON string (observed for some write paths). The + in-place decrypt helper only mutates iterables of dicts/models, so a string + payload would silently skip decryption and ciphertext would leak into the + registry via ``add_server``/``update_server`` (which trust the caller). + Parse the string back to a list so the in-place decrypt actually runs, and + write the decrypted list back onto the row so downstream consumers see plain + values. + """ + env_vars = getattr(row, "env_vars", None) + if env_vars is None: + return + if isinstance(env_vars, str): + try: + env_vars = json.loads(env_vars) + except (json.JSONDecodeError, TypeError): + return + if not isinstance(env_vars, list): + return + try: + setattr(row, "env_vars", env_vars) + except (AttributeError, TypeError): + pass + decrypt_global_env_var_values(env_vars) + + +def _reencrypt_global_env_var_values( + env_vars: Optional[Iterable[Any]], new_encryption_key: str +) -> Optional[List[Dict[str, Any]]]: + """Re-encrypt ``scope="global"`` env var values for master-key rotation. + + Each global value is decrypted with the current salt key and re-encrypted + under ``new_encryption_key``. Returns the rebuilt list when at least one + value was rotated, else ``None`` so the caller can skip the DB write. A + value that fails to decrypt is left untouched (and logged) so a corrupt + entry is preserved for recovery rather than overwritten. + """ + if not env_vars: + return None + if isinstance(env_vars, str): + try: + env_vars = json.loads(env_vars) + except (json.JSONDecodeError, TypeError): + return None + if not env_vars: + return None + rebuilt = [dict(v) for v in env_vars] + rotated = False + for entry in rebuilt: + if not _is_global_env_var_scope(entry.get("scope")): + continue + value = entry.get("value") + if not value: + continue + decrypted = decrypt_value_helper( + value=value, + key="mcp_global_env_var", + exception_type="debug", + return_original_value=False, + ) + if decrypted is None: + verbose_proxy_logger.warning( + "rotate_mcp_server_credentials_master_key: could not decrypt " + "global env var %s, skipping", + entry.get("name"), + ) + continue + entry["value"] = encrypt_value_helper( + decrypted, new_encryption_key=new_encryption_key + ) + rotated = True + return rebuilt if rotated else None + + def _prepare_mcp_server_data( data: Union[NewMCPServerRequest, UpdateMCPServerRequest], exclude_unset: bool = False, @@ -98,6 +239,16 @@ def _prepare_mcp_server_data( if data_dict.get("static_headers") is not None: data_dict["static_headers"] = safe_dumps(data_dict["static_headers"]) + # env_vars is read from ``data_dict`` (not ``data``) like every other JSON + # column so the exclude_unset filter is respected: a partial update that + # omits env_vars never overwrites the stored value. Global values are + # encrypted at rest before serialization. + env_vars = data_dict.get("env_vars") + if env_vars is not None: + serialized_env_vars = [dict(v) for v in env_vars] + _encrypt_global_env_var_values(serialized_env_vars) + data_dict["env_vars"] = safe_dumps(serialized_env_vars) + if data_dict.get("mcp_info") is not None: data_dict["mcp_info"] = safe_dumps(data_dict["mcp_info"]) @@ -207,10 +358,13 @@ async def get_all_mcp_servers( where=where if where else {} ) - return [ + tables = [ LiteLLM_MCPServerTable(**mcp_server.model_dump()) for mcp_server in mcp_servers ] + for table in tables: + decrypt_global_env_var_values(table.env_vars) + return tables except Exception as e: verbose_proxy_logger.debug( "litellm.proxy._experimental.mcp_server.db.py::get_all_mcp_servers - {}".format( @@ -226,14 +380,16 @@ async def get_mcp_server( """ Returns the matching mcp server from the db iff exists """ - mcp_server: Optional[LiteLLM_MCPServerTable] = ( - await prisma_client.db.litellm_mcpservertable.find_unique( - where={ - "server_id": server_id, - } - ) + mcp_server = await prisma_client.db.litellm_mcpservertable.find_unique( + where={ + "server_id": server_id, + } ) - return mcp_server + if mcp_server is None: + return None + table = LiteLLM_MCPServerTable(**mcp_server.model_dump()) + decrypt_global_env_var_values(table.env_vars) + return table async def get_mcp_servers( @@ -251,7 +407,9 @@ async def get_mcp_servers( ) final_mcp_servers: List[LiteLLM_MCPServerTable] = [] for _mcp_server in _mcp_servers: - final_mcp_servers.append(LiteLLM_MCPServerTable(**_mcp_server.model_dump())) + table = LiteLLM_MCPServerTable(**_mcp_server.model_dump()) + decrypt_global_env_var_values(table.env_vars) + final_mcp_servers.append(table) return final_mcp_servers @@ -399,6 +557,11 @@ async def delete_mcp_server( """ Delete the mcp server from the db by server_id + The server-row delete is the commit point. Per-user env var rows have no FK + cascade, so they are cleaned up afterwards on a best-effort basis: a transient + failure there leaves only orphaned rows pointing at a now-missing server and + must not turn a successful delete into a caller-visible error. + Returns the deleted mcp server record if it exists, otherwise None """ deleted_server = await prisma_client.db.litellm_mcpservertable.delete( @@ -406,6 +569,18 @@ async def delete_mcp_server( "server_id": server_id, }, ) + if deleted_server is not None: + try: + await prisma_client.db.litellm_mcpuserenvvars.delete_many( + where={"server_id": server_id} + ) + except Exception as e: + verbose_proxy_logger.warning( + "MCP server %s deleted but per-user env var cleanup failed; " + "orphaned rows can be removed on a later delete: %s", + server_id, + e, + ) return deleted_server @@ -429,6 +604,7 @@ async def create_mcp_server( data=data_dict # type: ignore ) + _decrypt_env_vars_on_returned_row(new_mcp_server) return new_mcp_server @@ -506,40 +682,52 @@ async def update_mcp_server( where={"server_id": data.server_id}, data=data_dict # type: ignore ) + _decrypt_env_vars_on_returned_row(updated_mcp_server) return updated_mcp_server async def rotate_mcp_server_credentials_master_key( prisma_client: PrismaClient, touched_by: str, new_master_key: str ): + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many() + updated = 0 for mcp_server in mcp_servers: + update_data: Dict[str, Any] = {} + credentials = mcp_server.credentials - if not credentials: + if credentials: + # Decrypt with current key first, then re-encrypt with new key + decrypted_credentials = decrypt_credentials( + credentials=cast(MCPCredentials, dict(credentials)), + ) + encrypted_credentials = encrypt_credentials( + credentials=decrypted_credentials, + encryption_key=new_master_key, + ) + update_data["credentials"] = safe_dumps(encrypted_credentials) + + rotated_env_vars = _reencrypt_global_env_var_values( + mcp_server.env_vars, new_master_key + ) + if rotated_env_vars is not None: + update_data["env_vars"] = safe_dumps(rotated_env_vars) + + if not update_data: continue - credentials_copy = dict(credentials) - # Decrypt with current key first, then re-encrypt with new key - decrypted_credentials = decrypt_credentials( - credentials=cast(MCPCredentials, credentials_copy), - ) - encrypted_credentials = encrypt_credentials( - credentials=decrypted_credentials, - encryption_key=new_master_key, - ) - - from litellm.litellm_core_utils.safe_json_dumps import safe_dumps - - serialized_credentials = safe_dumps(encrypted_credentials) - + update_data["updated_by"] = touched_by await prisma_client.db.litellm_mcpservertable.update( where={"server_id": mcp_server.server_id}, - data={ - "credentials": serialized_credentials, - "updated_by": touched_by, - }, + data=update_data, ) + updated += 1 + verbose_proxy_logger.info( + "rotate_mcp_server_credentials_master_key: rotated %d MCP server row(s)", + updated, + ) def _decode_user_credential(stored: str) -> Optional[str]: @@ -594,6 +782,8 @@ async def rotate_mcp_user_credentials_master_key( are logged and skipped so one corrupt row does not abort the rotation. """ rows = await prisma_client.db.litellm_mcpusercredentials.find_many() + rotated = 0 + skipped = 0 for row in rows: plaintext = _decode_user_credential(row.credential_b64) if plaintext is None: @@ -603,6 +793,7 @@ async def rotate_mcp_user_credentials_master_key( row.user_id, row.server_id, ) + skipped += 1 continue re_encrypted = encrypt_value_helper( plaintext, new_encryption_key=new_master_key @@ -616,6 +807,61 @@ async def rotate_mcp_user_credentials_master_key( }, data={"credential_b64": re_encrypted}, ) + rotated += 1 + verbose_proxy_logger.info( + "rotate_mcp_user_credentials_master_key: rotated %d row(s), skipped %d", + rotated, + skipped, + ) + + +async def rotate_mcp_user_env_vars_master_key( + prisma_client: PrismaClient, new_master_key: str +): + """Re-encrypt every ``LiteLLM_MCPUserEnvVars`` row with ``new_master_key``. + + Reads each ``values_b64`` blob with the current salt key and writes it back + encrypted under the new master key. Rows that fail to decrypt are logged and + skipped so one corrupt row does not abort the rotation nor overwrite values + that may still be recoverable. + """ + rows = await prisma_client.db.litellm_mcpuserenvvars.find_many() + rotated = 0 + skipped = 0 + for row in rows: + plaintext = decrypt_value_helper( + value=row.values_b64, + key="mcp_user_env_vars", + exception_type="debug", + return_original_value=False, + ) + if plaintext is None: + verbose_proxy_logger.warning( + "rotate_mcp_user_env_vars_master_key: could not decrypt env vars " + "for user_id=%s server_id=%s, skipping", + row.user_id, + row.server_id, + ) + skipped += 1 + continue + re_encrypted = encrypt_value_helper( + plaintext, new_encryption_key=new_master_key + ) + await prisma_client.db.litellm_mcpuserenvvars.update( + where={ + "user_id_server_id": { + "user_id": row.user_id, + "server_id": row.server_id, + } + }, + data={"values_b64": re_encrypted}, + ) + rotated += 1 + verbose_proxy_logger.info( + "rotate_mcp_user_env_vars_master_key: rotated %d row(s), skipped %d", + rotated, + skipped, + ) async def store_user_credential( @@ -751,11 +997,14 @@ async def store_user_oauth_credential( ) -def is_oauth_credential_expired(cred: Dict[str, Any]) -> bool: +def is_oauth_credential_expired(cred: Dict[str, Any], buffer_seconds: int = 0) -> bool: """Return True if the OAuth2 credential's access_token has expired. Checks the ``expires_at`` ISO-format string stored in the credential payload. Returns False when ``expires_at`` is absent or unparseable (treat as non-expired). + With ``buffer_seconds`` > 0, a token that is still valid but expires within the + buffer is also treated as expired, so callers can refresh proactively instead of + handing back a token that may lapse mid-request. """ expires_at = cred.get("expires_at") if not expires_at: @@ -764,7 +1013,7 @@ def is_oauth_credential_expired(cred: Dict[str, Any]) -> bool: exp_dt = datetime.fromisoformat(expires_at) if exp_dt.tzinfo is None: exp_dt = exp_dt.replace(tzinfo=timezone.utc) - return datetime.now(timezone.utc) > exp_dt + return datetime.now(timezone.utc) + timedelta(seconds=buffer_seconds) > exp_dt except (ValueError, TypeError): return False @@ -912,6 +1161,50 @@ async def refresh_user_oauth_token( return await get_user_oauth_credential(prisma_client, user_id, server_id) +async def resolve_valid_user_oauth_token( + user_id: str, + server: Any, + cred: Optional[Dict[str, Any]], + prisma_client: Optional[PrismaClient] = None, +) -> Optional[Dict[str, Any]]: + """Return an OAuth2 credential whose access_token is good for the next request. + + Returns the credential unchanged while its token is valid for at least + ``MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS``. Only when the token is expired (or + expiring within that buffer) and a refresh_token is stored does it mint a new one + via ``refresh_user_oauth_token``. Returns None when there is no usable token + (missing token, expired with no refresh_token, or a failed refresh). + + The refresh_token is only ever sent to the server's token_url inside + ``refresh_user_oauth_token``; it is never exposed to the caller beyond the cred + dict it already holds. ``prisma_client`` is fetched lazily and only when a refresh + actually happens, so the valid-token path never requires a DB handle. + """ + if not cred or not cred.get("access_token"): + return None + if not is_oauth_credential_expired( + cred, buffer_seconds=MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS + ): + return cred + if not cred.get("refresh_token"): + return None + if prisma_client is None: + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw( + "Database not connected. Cannot refresh OAuth token." + ) + refreshed = await refresh_user_oauth_token( + prisma_client=prisma_client, + user_id=user_id, + server=server, + cred=cred, + ) + if not refreshed or not refreshed.get("access_token"): + return None + return refreshed + + async def approve_mcp_server( prisma_client: PrismaClient, server_id: str, @@ -927,7 +1220,9 @@ async def approve_mcp_server( "updated_by": touched_by, }, ) - return LiteLLM_MCPServerTable(**updated.model_dump()) + table = LiteLLM_MCPServerTable(**updated.model_dump()) + decrypt_global_env_var_values(table.env_vars) + return table async def reject_mcp_server( @@ -949,7 +1244,9 @@ async def reject_mcp_server( where={"server_id": server_id}, data=data, ) - return LiteLLM_MCPServerTable(**updated.model_dump()) + table = LiteLLM_MCPServerTable(**updated.model_dump()) + decrypt_global_env_var_values(table.env_vars) + return table async def get_mcp_submissions( @@ -966,6 +1263,8 @@ async def get_mcp_submissions( take=500, # safety cap; paginate if needed in a future iteration ) items = [LiteLLM_MCPServerTable(**r.model_dump()) for r in rows] + for item in items: + decrypt_global_env_var_values(item.env_vars) pending = sum( 1 for i in items if i.approval_status == MCPApprovalStatus.pending_review @@ -980,3 +1279,121 @@ async def get_mcp_submissions( rejected=rejected, items=items, ) + + +# ── Per-user MCP environment variables ──────────────────────────────────── + + +def _decode_user_env_vars(stored: str) -> Dict[str, str]: + """Decrypt a ``values_b64`` blob and parse it as a flat ``{name: value}`` dict.""" + decrypted = decrypt_value_helper( + value=stored, + key="mcp_user_env_vars", + exception_type="debug", + return_original_value=False, + ) + if decrypted is None: + if stored: + verbose_proxy_logger.warning( + "MCP per-user env vars failed to decrypt (LITELLM_SALT_KEY " + "changed?); treating as unset so the user is prompted to " + "re-enter them rather than silently forwarding ciphertext" + ) + return {} + try: + parsed = json.loads(decrypted) + except (ValueError, TypeError): + return {} + if not isinstance(parsed, dict): + return {} + return {str(k): str(v) for k, v in parsed.items()} + + +async def get_user_env_vars( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> Dict[str, str]: + """Return the calling user's env var dict for ``server_id`` (empty if none).""" + row = await prisma_client.db.litellm_mcpuserenvvars.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + if row is None: + return {} + return _decode_user_env_vars(row.values_b64) + + +async def get_user_env_vars_bulk( + prisma_client: PrismaClient, + user_id: str, + server_ids: Iterable[str], +) -> Dict[str, Dict[str, str]]: + """Return ``{server_id: {var_name: value}}`` for one user across many servers. + + Servers with no stored row are simply absent from the result. + """ + ids = list(server_ids) + if not ids: + return {} + rows = await prisma_client.db.litellm_mcpuserenvvars.find_many( + where={"user_id": user_id, "server_id": {"in": ids}} + ) + return {row.server_id: _decode_user_env_vars(row.values_b64) for row in rows} + + +async def merge_user_env_vars( + prisma_client: PrismaClient, + user_id: str, + server_id: str, + updates: Dict[str, str], + allowed_names: Iterable[str], +) -> Dict[str, str]: + """Merge ``updates`` into the user's stored env vars for ``server_id`` and + return the resulting set. + + The read-modify-write runs inside a transaction guarded by a + ``(user_id, server_id)`` advisory lock so two concurrent writes from the + same user can't drop one update. Names outside ``allowed_names`` are pruned, + so an admin retiring a user-scoped variable also clears its stored value. + """ + allowed = set(allowed_names) + lock_key = int.from_bytes( + hashlib.blake2b(f"{user_id}:{server_id}".encode(), digest_size=8).digest(), + "big", + signed=True, + ) + async with prisma_client.db.tx() as tx: + await tx.execute_raw("SELECT pg_advisory_xact_lock($1::bigint)", lock_key) + row = await tx.litellm_mcpuserenvvars.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + existing = _decode_user_env_vars(row.values_b64) if row is not None else {} + merged = {k: v for k, v in {**existing, **updates}.items() if k in allowed} + encoded = encrypt_value_helper(json.dumps(merged)) + await tx.litellm_mcpuserenvvars.upsert( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}, + data={ + "create": { + "user_id": user_id, + "server_id": server_id, + "values_b64": encoded, + }, + "update": {"values_b64": encoded}, + }, + ) + return merged + + +async def delete_user_env_vars( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> None: + """Remove the calling user's env var values for ``server_id``. + + Uses ``delete_many`` so a missing row is a no-op; real DB errors still + propagate to the caller instead of being silently swallowed. + """ + await prisma_client.db.litellm_mcpuserenvvars.delete_many( + where={"user_id": user_id, "server_id": server_id} + ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 5fd028b3436..7048f5bf7c4 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -58,20 +58,26 @@ from litellm.proxy._experimental.mcp_server.sampling_handler import ( from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth from litellm.proxy._experimental.mcp_server.utils import ( MCP_TOOL_PREFIX_SEPARATOR, + MCPMissingUserEnvVarsError, add_server_prefix_to_name, + build_env_var_setup_url, + collect_env_var_references, compute_short_server_prefix, get_server_prefix, + interpolate_headers, is_short_mcp_tool_prefix_enabled, is_tool_name_prefixed, iter_known_server_prefixes, merge_mcp_headers, normalize_server_name, + parse_admin_env_vars, split_server_prefix_from_name, validate_mcp_server_name, ) from litellm.proxy._types import ( LiteLLM_MCPServerTable, MCPAuthType, + MCPEnvVar, MCPTransport, MCPTransportType, UserAPIKeyAuth, @@ -124,6 +130,33 @@ _AZURE_ENTRA_HOSTS = { "login.chinacloudapi.cn", # China } +# Short-lived in-memory cache for per-user MCP env var values, mirroring the +# BYOK credential cache. Keyed by (user_id, server_id); value is +# (values_dict, monotonic_timestamp). Keeps the tool-call and tool-listing +# paths off the DB on every request within the TTL window. +_user_env_vars_cache: Dict[Tuple[str, str], Tuple[Dict[str, str], float]] = {} +_USER_ENV_VARS_CACHE_TTL = 60 # seconds +_USER_ENV_VARS_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth + + +def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: + """Drop a cached entry after the user stores or clears their env var values + so the next request reads the fresh value instead of a stale one.""" + _user_env_vars_cache.pop((user_id, server_id), None) + + +def _write_user_env_vars_cache( + user_id: str, server_id: str, values: Dict[str, str] +) -> None: + cache_key = (user_id, server_id) + # Re-insert at the tail so eviction drops the oldest-written entry, not a + # freshly refreshed one, and only sheds a single entry instead of wiping the + # whole cache (which would stampede the DB). + _user_env_vars_cache.pop(cache_key, None) + if len(_user_env_vars_cache) >= _USER_ENV_VARS_CACHE_MAX_SIZE: + _user_env_vars_cache.pop(next(iter(_user_env_vars_cache)), None) + _user_env_vars_cache[cache_key] = (values, time.monotonic()) + def _should_strip_caller_authorization( mcp_server: MCPServer, @@ -295,6 +328,31 @@ def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]: return data +def _deserialize_json_list(data: Any) -> Optional[List[Dict[str, Any]]]: + """Deserialize a JSON array stored in the DB (``env_vars`` and friends). + + Returns ``None`` for empty / null / unparseable input. Accepts strings + (raw JSON), already-materialized lists of dicts, and lists of Pydantic + models (Prisma may hydrate a JSON column such as ``env_vars`` into + ``MCPEnvVar`` objects); model entries are normalized to plain dicts so + downstream consumers expecting ``List[Dict[str, Any]]`` validate. + """ + if data is None or data == "" or data == []: + return None + if isinstance(data, str): + try: + parsed = json.loads(data) + except (json.JSONDecodeError, TypeError): + return None + data = parsed + if not isinstance(data, list): + return None + return [ + item.model_dump(mode="json") if hasattr(item, "model_dump") else item + for item in data + ] + + def _create_sampling_callback(user_api_key_auth: Optional[Any] = None): """ Create a sampling callback for MCP ClientSession. @@ -456,7 +514,8 @@ class MCPServerManager: - server is OpenAPI (spec_path), - non-empty upstream instructions are already cached, - auth preconditions match health_check_server's skip rules - (per-user auth / missing static auth token), + (per-user auth / missing static auth token / static headers that + reference a per-user env var), - a prior probe attempt for this server is within MCP_HEALTH_CHECK_TIMEOUT seconds (the probe is a health-check-shaped op and already uses this knob for its inner call timeout; reusing it @@ -471,6 +530,8 @@ class MCPServerManager: return if server.requires_per_user_auth: return + if self._references_per_user_env_var(server): + return if ( server.auth_type and server.auth_type != MCPAuth.none @@ -495,8 +556,13 @@ class MCPServerManager: ) try: + resolved_static_headers = await self._resolve_static_headers_with_env_vars( + server=server, + user_api_key_auth=None, + raise_on_missing=False, + ) extra_headers: Optional[Dict[str, str]] = ( - dict(server.static_headers) if server.static_headers else None + dict(resolved_static_headers) if resolved_static_headers else None ) client = await self._create_mcp_client( server=server, @@ -656,6 +722,7 @@ class MCPServerManager: allowed_params=server_config.get("allowed_params", None), access_groups=server_config.get("access_groups", None), static_headers=server_config.get("static_headers", None), + env_vars=server_config.get("env_vars", None), allow_all_keys=bool(server_config.get("allow_all_keys", False)), available_on_public_internet=bool( server_config.get("available_on_public_internet", True) @@ -920,17 +987,41 @@ class MCPServerManager: f"Server ID {mcp_server.server_id} not found in registry" ) + def _resolve_env_vars_list( + self, + mcp_server: LiteLLM_MCPServerTable, + *, + env_vars_are_encrypted: bool, + ) -> Optional[List[Dict[str, Any]]]: + env_vars_list = _deserialize_json_list(getattr(mcp_server, "env_vars", None)) + if env_vars_are_encrypted: + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 + decrypt_global_env_var_values, + ) + + decrypt_global_env_var_values(env_vars_list) + return env_vars_list + async def build_mcp_server_from_table( self, mcp_server: LiteLLM_MCPServerTable, *, credentials_are_encrypted: bool = True, + env_vars_are_encrypted: Optional[bool] = None, ) -> MCPServer: _mcp_info: MCPInfo = mcp_server.mcp_info or {} env_dict = _deserialize_json_dict(getattr(mcp_server, "env", None)) static_headers_dict = _deserialize_json_dict( getattr(mcp_server, "static_headers", None) ) + env_vars_list = self._resolve_env_vars_list( + mcp_server, + env_vars_are_encrypted=( + credentials_are_encrypted + if env_vars_are_encrypted is None + else env_vars_are_encrypted + ), + ) credentials_dict = _deserialize_json_dict( getattr(mcp_server, "credentials", None) ) @@ -1030,6 +1121,7 @@ class MCPServerManager: mcp_info=mcp_info, extra_headers=getattr(mcp_server, "extra_headers", None), static_headers=static_headers_dict, + env_vars=env_vars_list, client_id=client_id_value or getattr(mcp_server, "client_id", None), client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), @@ -1128,7 +1220,14 @@ class MCPServerManager: return try: if mcp_server.server_id not in self.registry: - new_server = await self.build_mcp_server_from_table(mcp_server) + # Callers hand us a record returned by the db.py read/write + # helpers, which already decrypt global env var values (the + # `credentials` field is the only one still encrypted here). + # Re-decrypting plaintext would zero the values, so build with + # env_vars_are_encrypted=False. + new_server = await self.build_mcp_server_from_table( + mcp_server, env_vars_are_encrypted=False + ) self._assign_unique_short_prefix(new_server) self.registry[mcp_server.server_id] = new_server await self._maybe_register_openapi_tools(new_server) @@ -1151,7 +1250,11 @@ class MCPServerManager: return try: if mcp_server.server_id in self.registry: - new_server = await self.build_mcp_server_from_table(mcp_server) + # See add_server: db.py helpers already decrypted env var + # values, so don't decrypt them a second time here. + new_server = await self.build_mcp_server_from_table( + mcp_server, env_vars_are_encrypted=False + ) # Carry the previously-resolved short prefix across so the # tool names stay stable for clients holding cached lists. existing_prefix = self.registry[mcp_server.server_id].short_prefix @@ -1572,6 +1675,180 @@ class MCPServerManager: return resolved_env + def _references_per_user_env_var(self, server: MCPServer) -> bool: + """True when ``server.static_headers`` reference a per-user ``${NAME}`` env var. + + Such placeholders can only be filled from a calling user's stored values, + so a userless probe (health check / instructions prefetch) would forward + the literal ``${NAME}`` upstream and get rejected. Callers skip the probe + and report ``unknown`` instead of a misleading ``unhealthy``. + """ + static_headers = server.static_headers + env_vars = getattr(server, "env_vars", None) + if not static_headers or not env_vars: + return False + _global_values, user_specs = parse_admin_env_vars(env_vars) + user_var_names = {spec["name"] for spec in user_specs} + if not user_var_names: + return False + referenced = collect_env_var_references(strings=static_headers.values()) + return bool(referenced & user_var_names) + + async def _resolve_static_headers_with_env_vars( + self, + server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + *, + raise_on_missing: bool = True, + ) -> Optional[Dict[str, str]]: + """Return server.static_headers with ``${NAME}`` interpolated. + + Globals come from ``server.env_vars`` entries with ``scope=="global"``. + Per-user values come from the ``LiteLLM_MCPUserEnvVars`` row for the + calling user. + + When ``raise_on_missing`` is ``True`` (the tool-*call* path), raises + ``MCPMissingUserEnvVarsError`` if ``static_headers`` reference a per-user + variable the calling user has not yet supplied — converted into a + user-facing 412 by the REST layer. + + When ``raise_on_missing`` is ``False`` (the tool-*list* path), missing + per-user vars are non-blocking: we interpolate whatever is available and + leave unfilled ``${NAME}`` references untouched, so the server's tools + still appear in the listing. The user only hits the friendly error when + they actually invoke a tool that needs the missing value. + """ + static_headers = server.static_headers + env_vars = getattr(server, "env_vars", None) + if not static_headers and not env_vars: + return static_headers + + global_values, user_specs = parse_admin_env_vars(env_vars) + # An empty-valued global is treated as unset: it must not mask a per-user + # var the user still has to supply, nor override a value the user did + # supply. The unresolved ${NAME} is then left untouched, like any other + # undefined reference. + global_values = {name: value for name, value in global_values.items() if value} + user_var_names = {spec["name"] for spec in user_specs} + + # If no env vars are configured, return static_headers as-is. + if not global_values and not user_specs: + return static_headers + + # Figure out which user-scoped vars are actually referenced. A var that + # also carries a global value is always covered by that global (globals + # win in the merge below), so it can never be genuinely "missing" even if + # the user hasn't filled it in -- only vars without a global fallback do. + referenced = collect_env_var_references(strings=(static_headers or {}).values()) + referenced_user_vars = referenced & user_var_names + required_user_vars = { + name for name in referenced_user_vars if name not in global_values + } + + user_values: Dict[str, str] = {} + if required_user_vars: + try: + user_values = await self._load_user_env_vars(server, user_api_key_auth) + except Exception as exc: + # On the tool-call path a DB failure must surface as a real + # server error, not a misleading "set up your credentials" 412. + # On the listing path we stay best-effort and leave the + # unfilled ${NAME} references untouched so tools still appear. + if raise_on_missing: + raise + verbose_logger.warning( + "MCPServerManager: best-effort user env var load failed for " + "server=%s: %s", + server.server_id, + exc, + ) + + if raise_on_missing: + missing = sorted( + name for name in required_user_vars if not user_values.get(name) + ) + if missing: + # A cached negative must never produce a 412: cache + # invalidation is process-local, so a user who just stored + # values on another worker would otherwise be told their + # credentials are missing until the entry expires. Confirm + # against the DB before raising. + user_values = await self._load_user_env_vars( + server, user_api_key_auth, force_refresh=True + ) + missing = sorted( + name for name in required_user_vars if not user_values.get(name) + ) + if missing: + raise MCPMissingUserEnvVarsError( + server_id=server.server_id, + server_name=server.server_name or server.name, + missing=missing, + setup_url=build_env_var_setup_url(server.server_id), + ) + + # Only honor stored user values for currently user-scoped vars, and let + # admin globals win, so a stale row from when a var was user-scoped can + # never override the global value the admin set after switching it. + scoped_user_values = { + name: value for name, value in user_values.items() if name in user_var_names + } + merged_vars: Dict[str, str] = {**scoped_user_values, **global_values} + if not static_headers: + return static_headers + return interpolate_headers(static_headers, merged_vars) + + async def _load_user_env_vars( + self, + server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + *, + force_refresh: bool = False, + ) -> Dict[str, str]: + """Look up the calling user's env var values for ``server``. + + Returns an empty dict when no user is available. Results are cached in a + short-lived in-memory map keyed by (user_id, server_id) so the tool-call + and tool-listing paths avoid a DB round-trip per request within the TTL + window; the cache is invalidated when the user stores or clears values. + Pass ``force_refresh`` to bypass the cache read and re-fetch from the DB + (used before raising a "missing credentials" error so a process-local + stale entry cannot mask values stored on another worker). A missing DB + connection and any other DB error propagate so the caller can decide + between failing the request (tool-call path) and staying best-effort + (listing path); they must never be mistaken for "user has no values", + which would send the user a misleading "set up your credentials" 412. + """ + if user_api_key_auth is None: + return {} + user_id = getattr(user_api_key_auth, "user_id", None) + if not user_id: + return {} + + cache_key = (user_id, server.server_id) + if not force_refresh: + cached = _user_env_vars_cache.get(cache_key) + if cached is not None: + values, ts = cached + if time.monotonic() - ts < _USER_ENV_VARS_CACHE_TTL: + return values + + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 + + if prisma_client is None: + raise RuntimeError( + "MCP per-user env vars require a database connection, but none " + "is configured. Connect a database to your proxy to use per-user " + "MCP env vars." + ) + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 + get_user_env_vars, + ) + + values = await get_user_env_vars(prisma_client, user_id, server.server_id) + _write_user_env_vars_cache(user_id, server.server_id, values) + return values + async def _create_mcp_client( self, server: MCPServer, @@ -1732,10 +2009,17 @@ class MCPServerManager: client = None try: - if server.static_headers: + # Tool *listing* must not be blocked by missing per-user env vars — + # the server's tools should still appear so the client connects. The + # friendly "missing vars" error is raised only on the tool-*call* + # path (see _call_regular_mcp_tool). + resolved_static_headers = await self._resolve_static_headers_with_env_vars( + server, user_api_key_auth, raise_on_missing=False + ) + if resolved_static_headers: if extra_headers is None: extra_headers = {} - extra_headers.update(server.static_headers) + extra_headers.update(resolved_static_headers) # MCPJWTSigner: inject signed JWT for tools/list (list path skips pre_call_hook). # Skip entirely when the signer is not configured (avoid an unnecessary @@ -3105,10 +3389,17 @@ class MCPServerManager: continue extra_headers[header] = header_value - if mcp_server.static_headers: + # Interpolate env vars into static_headers. Raises + # MCPMissingUserEnvVarsError when the calling user has not filled in + # a required per-user variable — the REST layer converts that into + # a friendly 412 with a setup URL. + resolved_static_headers = await self._resolve_static_headers_with_env_vars( + mcp_server, user_api_key_auth + ) + if resolved_static_headers: if extra_headers is None: extra_headers = {} - extra_headers.update(mcp_server.static_headers) + extra_headers.update(resolved_static_headers) if hook_extra_headers: if extra_headers is None: @@ -3566,7 +3857,13 @@ class MCPServerManager: verbose_logger.debug( f"Building server from DB: {server.server_id} ({server.server_name})" ) - new_server = await self.build_mcp_server_from_table(server) + # raw_rows come straight from the DB, so their global env var + # values (like credentials) are still encrypted here, unlike the + # already-decrypted records add_server/update_server are handed. + # Decrypt them while building the registry entry. + new_server = await self.build_mcp_server_from_table( + server, env_vars_are_encrypted=True + ) # Carry the cached short_prefix from the previous registry entry # (if any) so the prefix is stable across reloads. if existing_server is not None and existing_server.short_prefix: @@ -3906,11 +4203,21 @@ class MCPServerManager: and not server.authentication_token ): should_skip_health_check = True + # Skip if static_headers reference a per-user env var: a userless probe + # can't fill ${NAME} and would forward the literal placeholder upstream, + # flipping the server to unhealthy even though real user calls succeed. + elif self._references_per_user_env_var(server): + should_skip_health_check = True if not should_skip_health_check: - extra_headers = {} - if server.static_headers: - extra_headers.update(server.static_headers) + resolved_static_headers = await self._resolve_static_headers_with_env_vars( + server=server, + user_api_key_auth=None, + raise_on_missing=False, + ) + extra_headers = ( + dict(resolved_static_headers) if resolved_static_headers else {} + ) client = await self._create_mcp_client( server=server, @@ -3960,6 +4267,7 @@ class MCPServerManager: extra_headers=server.extra_headers or [], mcp_info=server.mcp_info, static_headers=server.static_headers, + env_vars=self._env_vars_to_models(server.env_vars), status=status, last_health_check=datetime.now(), health_check_error=health_check_error, @@ -4033,6 +4341,14 @@ class MCPServerManager: return list_mcp_servers + @staticmethod + def _env_vars_to_models( + env_vars: Optional[List[Dict[str, Any]]], + ) -> Optional[List[MCPEnvVar]]: + if env_vars is None: + return None + return [MCPEnvVar.model_validate(env_var) for env_var in env_vars] + def _build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable: return LiteLLM_MCPServerTable( server_id=server.server_id, @@ -4053,6 +4369,7 @@ class MCPServerManager: extra_headers=server.extra_headers or [], mcp_info=server.mcp_info, static_headers=server.static_headers, + env_vars=self._env_vars_to_models(server.env_vars), status=None, # No health check performed last_health_check=None, # No health check performed health_check_error=None, diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index e20c9f3a082..725f7a335bc 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import importlib from datetime import datetime from typing import ( @@ -13,6 +14,7 @@ from typing import ( Union, ) +import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from litellm._logging import verbose_logger @@ -20,7 +22,10 @@ from litellm.proxy._experimental.mcp_server.exceptions import MCPUpstreamAuthErr from litellm.proxy._experimental.mcp_server.ui_session_utils import ( build_effective_auth_contexts, ) -from litellm.proxy._experimental.mcp_server.utils import merge_mcp_headers +from litellm.proxy._experimental.mcp_server.utils import ( + MCPMissingUserEnvVarsError, + merge_mcp_headers, +) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -41,6 +46,28 @@ router = APIRouter( tags=["mcp"], ) + +def _connection_error_message(exc: BaseException) -> str: + if isinstance(exc, httpx.LocalProtocolError): + return ( + "Failed to connect to MCP server: a request header is malformed. " + "Check static headers for leading/trailing spaces or illegal characters." + ) + if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)): + return ( + "Failed to connect to MCP server: the server is unreachable. " + "Check the URL and that the server is running." + ) + if isinstance(exc, httpx.TimeoutException): + return "Failed to connect to MCP server: the connection timed out." + if isinstance(exc, httpx.HTTPStatusError): + return ( + f"Failed to connect to MCP server: it returned HTTP " + f"{exc.response.status_code}." + ) + return "Failed to connect to MCP server. Check proxy logs for details." + + if MCP_AVAILABLE: from mcp.types import Tool as MCPTool @@ -119,9 +146,10 @@ if MCP_AVAILABLE: try: from litellm.proxy._experimental.mcp_server.db import ( get_user_oauth_credential, - is_oauth_credential_expired, + resolve_valid_user_oauth_token, ) + prisma_client = None if prefetched_creds is not None: cred = prefetched_creds.get(server_id) else: @@ -133,13 +161,13 @@ if MCP_AVAILABLE: cred = await get_user_oauth_credential( prisma_client, user_id, server_id ) + cred = await resolve_valid_user_oauth_token( + user_id=user_id, + server=server, + cred=cred, + prisma_client=prisma_client, + ) if cred and cred.get("access_token"): - if is_oauth_credential_expired(cred): - verbose_logger.debug( - f"_get_user_oauth_extra_headers: token expired for " - f"user={user_id} server={server_id}" - ) - return None return {"Authorization": f"Bearer {cred['access_token']}"} except Exception as e: verbose_logger.warning( @@ -812,6 +840,23 @@ if MCP_AVAILABLE: requested_server_id=canonical_server_id, ) return result + except MCPMissingUserEnvVarsError as e: + verbose_logger.info( + "MCP tool call missing per-user env vars: server_id=%s missing=%s", + e.server_id, + e.missing, + ) + raise HTTPException( + status_code=412, + detail={ + "error": "missing_user_env_vars", + "message": str(e), + "server_id": e.server_id, + "server_name": e.server_name, + "missing": e.missing, + "setup_url": e.setup_url, + }, + ) except BlockedPiiEntityError as e: verbose_logger.error(f"BlockedPiiEntityError in MCP tool call: {str(e)}") raise HTTPException( @@ -961,14 +1006,14 @@ if MCP_AVAILABLE: return await operation(client) - except (KeyboardInterrupt, SystemExit): + except (KeyboardInterrupt, SystemExit, asyncio.CancelledError): raise except BaseException as e: verbose_logger.error("Error in MCP operation: %s", e, exc_info=True) return { "status": "error", "error": True, - "message": "Failed to connect to MCP server. Check proxy logs for details.", + "message": _connection_error_message(e), } async def _preview_openapi_tools(spec_path: str) -> dict: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index df6cb22fda1..0477a5d3244 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -53,6 +53,7 @@ from litellm.proxy._experimental.mcp_server.utils import ( LITELLM_MCP_SERVER_DESCRIPTION, LITELLM_MCP_SERVER_NAME, LITELLM_MCP_SERVER_VERSION, + MCPMissingUserEnvVarsError, add_server_prefix_to_name, get_server_prefix, iter_known_server_prefixes, @@ -720,6 +721,16 @@ if MCP_AVAILABLE: host_progress_callback=host_progress_callback, **data, # for logging ) + except MCPMissingUserEnvVarsError as e: + verbose_logger.info( + "MCP mcp_server_tool_call missing per-user env vars: server_id=%s missing=%s", + e.server_id, + e.missing, + ) + return CallToolResult( + content=[TextContent(text=str(e), type="text")], + isError=True, + ) except BlockedPiiEntityError as e: verbose_logger.error( f"BlockedPiiEntityError in MCP tool call: {str(e)}" @@ -1311,8 +1322,7 @@ if MCP_AVAILABLE: try: from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 get_user_oauth_credential, - is_oauth_credential_expired, - refresh_user_oauth_token, + resolve_valid_user_oauth_token, ) from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( # noqa: PLC0415 _compute_per_user_token_ttl, @@ -1332,6 +1342,7 @@ if MCP_AVAILABLE: return {"Authorization": f"Bearer {cached_token}"} # ── Slow path: DB lookup ────────────────────────────────────────── + prisma_client = None if prefetched_creds is not None: cred = prefetched_creds.get(server_id) else: @@ -1349,43 +1360,17 @@ if MCP_AVAILABLE: if not cred or not cred.get("access_token"): return None - if is_oauth_credential_expired(cred): - verbose_logger.debug( - "_get_user_oauth_extra_headers_from_db: token expired for user=%s server=%s — attempting refresh", - user_id, - server_id, - ) - # Attempt token refresh; requires a DB client (not available from prefetch) - if cred.get("refresh_token"): - try: - from litellm.proxy.utils import ( # noqa: PLC0415 - get_prisma_client_or_throw, - ) - - prisma_client = get_prisma_client_or_throw( - "Database not connected. Cannot refresh OAuth token." - ) - cred = await refresh_user_oauth_token( - prisma_client=prisma_client, - user_id=user_id, - server=server, - cred=cred, - ) - except Exception as refresh_exc: - verbose_logger.warning( - "_get_user_oauth_extra_headers_from_db: refresh failed for user=%s server=%s: %s", - user_id, - server_id, - refresh_exc, - ) - cred = None - - if not cred or not cred.get("access_token"): - # Clear stale Redis/cache entry so we don't serve it again. - # Do this for both the individual and prefetch paths so the - # next request doesn't get a stale cache hit. - await mcp_per_user_token_cache.delete(user_id, server_id) - return None + cred = await resolve_valid_user_oauth_token( + user_id=user_id, + server=server, + cred=cred, + prisma_client=prisma_client, + ) + if cred is None: + # Refresh failed or token expired with no usable refresh_token — + # clear the stale Redis entry so the next request doesn't reuse it. + await mcp_per_user_token_cache.delete(user_id, server_id) + return None access_token: str = cred["access_token"] diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index b66dfa85b9c..97cfa74ea45 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -4,11 +4,23 @@ MCP Server Utilities import json import re -from typing import Any, Dict, Iterator, Mapping, Optional, Tuple, Union +from typing import ( + Any, + Dict, + Iterable, + Iterator, + List, + Mapping, + Optional, + Set, + Tuple, + Union, +) import hashlib import importlib import os +from urllib.parse import quote # Constants LITELLM_MCP_SERVER_NAME = "litellm-mcp-server" @@ -370,6 +382,130 @@ def validate_mcp_server_name( raise Exception(error_message) +class MCPMissingUserEnvVarsError(Exception): + """Raised when an MCP request can't be built because the calling user has + not supplied one or more required per-user environment variables. + + The error message is user-facing and includes a URL the user can visit + to fill them in. + """ + + def __init__( + self, + *, + server_id: str, + server_name: Optional[str], + missing: List[str], + setup_url: str, + ) -> None: + self.server_id = server_id + self.server_name = server_name + self.missing = missing + self.setup_url = setup_url + label = server_name or server_id + bullet_list = "\n".join(f"- {name}" for name in missing) + message = ( + f'Cannot connect to MCP server "{label}".\n\n' + f"Your administrator configured this server to require per-user " + f"variables, but you haven't set the following yet:\n" + f"{bullet_list}\n\n" + f"Set your credentials here:\n" + f"{setup_url}" + ) + super().__init__(message) + + +# Pattern for ``${NAME}`` substitution. Matches the standard env-var +# identifier rules — letters, digits, underscores, can't start with a digit. +_ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +def parse_admin_env_vars( + env_vars: Optional[Iterable[Any]], +) -> Tuple[Dict[str, str], List[Dict[str, Any]]]: + """Split admin-configured env var entries into globals and per-user specs. + + Accepts the raw value of ``MCPServer.env_vars`` (list of dicts or Pydantic + models). Returns: + + - ``global_values``: ``{name: value}`` for entries with ``scope=="global"``. + - ``user_specs``: list of ``{name, description}`` for entries with + ``scope=="user"`` — these are the names the user must fill in. + + Unknown / malformed entries are skipped silently. + """ + global_values: Dict[str, str] = {} + user_specs: List[Dict[str, Any]] = [] + if not env_vars: + return global_values, user_specs + for raw in env_vars: + if raw is None: + continue + if hasattr(raw, "model_dump"): + entry = raw.model_dump() + elif isinstance(raw, dict): + entry = raw + else: + continue + name = entry.get("name") + if not isinstance(name, str) or not name: + continue + scope = entry.get("scope") or "global" + if scope == "user": + user_specs.append({"name": name, "description": entry.get("description")}) + else: + value = entry.get("value") + global_values[name] = "" if value is None else str(value) + return global_values, user_specs + + +def find_env_var_references(value: str) -> Set[str]: + """Return the set of ``${NAME}`` identifiers referenced inside ``value``.""" + if not value: + return set() + return set(_ENV_VAR_PATTERN.findall(value)) + + +def collect_env_var_references(*, strings: Iterable[str]) -> Set[str]: + """Union of every ``${NAME}`` reference across a collection of strings.""" + refs: Set[str] = set() + for s in strings: + if isinstance(s, str): + refs |= find_env_var_references(s) + return refs + + +def interpolate_env_vars(value: str, variables: Mapping[str, str]) -> str: + """Replace ``${NAME}`` references in ``value`` with the matching mapping + entry. Unknown names are left untouched so callers can detect them via + ``find_env_var_references`` on the result if needed. + """ + if not value: + return value + + def _sub(match: "re.Match[str]") -> str: + name = match.group(1) + if name in variables: + return variables[name] + return match.group(0) + + return _ENV_VAR_PATTERN.sub(_sub, value) + + +def interpolate_headers( + headers: Mapping[str, str], variables: Mapping[str, str] +) -> Dict[str, str]: + """Return a copy of ``headers`` with every value passed through ``interpolate_env_vars``.""" + return {k: interpolate_env_vars(v, variables) for k, v in headers.items()} + + +def build_env_var_setup_url(server_id: str) -> str: + """The frontend URL where a user can fill in their per-user env vars.""" + base = os.environ.get("PROXY_BASE_URL", "").rstrip("/") + path = f"/ui/?page=mcp-servers&fill_env_vars={quote(server_id, safe='')}" + return f"{base}{path}" if base else path + + def merge_mcp_headers( *, extra_headers: Optional[Mapping[str, str]] = None, diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html index f27612ff54e..45de348c4d5 100644 --- a/litellm/proxy/_experimental/out/404.html +++ b/litellm/proxy/_experimental/out/404.html @@ -1 +1 @@ -
{"use strict";function t(){}function n(){}e.s(["ok",()=>t,"unreachable",()=>n],420061);let r=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,i=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,o={};function s(e,t){return((t||o).jsx?i:r).test(e)}let a=/[ \t\n\f\r]/g;function l(e){return""===e.replace(a,"")}class u{constructor(e,t){this.attribute=t,this.property=e}}u.prototype.attribute="",u.prototype.booleanish=!1,u.prototype.boolean=!1,u.prototype.commaOrSpaceSeparated=!1,u.prototype.commaSeparated=!1,u.prototype.defined=!1,u.prototype.mustUseProperty=!1,u.prototype.number=!1,u.prototype.overloadedBoolean=!1,u.prototype.property="",u.prototype.spaceSeparated=!1,u.prototype.space=void 0;let c=0,f=b(),h=b(),p=b(),d=b(),m=b(),g=b(),y=b();function b(){return 2**++c}e.s(["boolean",0,f,"booleanish",0,h,"commaOrSpaceSeparated",0,y,"commaSeparated",0,g,"number",0,d,"overloadedBoolean",0,p,"spaceSeparated",0,m],400744);var v=e.i(400744);let w=Object.keys(v);class x extends u{constructor(e,t,n,r){let i=-1;if(super(e,t),function(e,t,n){n&&(e[t]=n)}(this,"space",r),"number"==typeof n)for(;++i {"use strict";function t(){}function n(){}e.s(["ok",()=>t,"unreachable",()=>n],420061);let r=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,i=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,o={};function s(e,t){return((t||o).jsx?i:r).test(e)}let a=/[ \t\n\f\r]/g;function l(e){return""===e.replace(a,"")}class u{constructor(e,t){this.attribute=t,this.property=e}}u.prototype.attribute="",u.prototype.booleanish=!1,u.prototype.boolean=!1,u.prototype.commaOrSpaceSeparated=!1,u.prototype.commaSeparated=!1,u.prototype.defined=!1,u.prototype.mustUseProperty=!1,u.prototype.number=!1,u.prototype.overloadedBoolean=!1,u.prototype.property="",u.prototype.spaceSeparated=!1,u.prototype.space=void 0;let c=0,f=b(),h=b(),p=b(),d=b(),m=b(),g=b(),y=b();function b(){return 2**++c}e.s(["boolean",0,f,"booleanish",0,h,"commaOrSpaceSeparated",0,y,"commaSeparated",0,g,"number",0,d,"overloadedBoolean",0,p,"spaceSeparated",0,m],400744);var v=e.i(400744);let w=Object.keys(v);class x extends u{constructor(e,t,n,r){let i=-1;if(super(e,t),function(e,t,n){n&&(e[t]=n)}(this,"space",r),"number"==typeof n)for(;++i -1?E.current[y]=e:delete E.current[y]},prefixCls:f,classNames:$,styles:x,className:(0,c.default)(b,null==S?void 0:S.notice),style:w,times:g,key:h,eventKey:h,onNoticeClose:C,hovering:M&&_.length>0})))})};var b=o.forwardRef(function(e,n){var l=e.prefixCls,s=void 0===l?"rc-notification":l,c=e.container,u=e.motion,d=e.maxCount,f=e.className,p=e.style,m=e.onAllRemoved,h=e.stack,g=e.renderNotifications,v=o.useState([]),b=(0,r.default)(v,2),w=b[0],C=b[1],$=function(e){var t,r=w.find(function(t){return t.key===e});null==r||null==(t=r.onClose)||t.call(r),C(function(t){return t.filter(function(t){return t.key!==e})})};o.useImperativeHandle(n,function(){return{open:function(e){C(function(r){var n,o=(0,t.default)(r),i=o.findIndex(function(t){return t.key===e.key}),l=(0,a.default)({},e);return i>=0?(l.times=((null==(n=r[i])?void 0:n.times)||0)+1,o[i]=l):(l.times=0,o.push(l)),d>0&&o.length>d&&(o=o.slice(-d)),o})},close:function(e){$(e)},destroy:function(){C([])}}});var S=o.useState({}),E=(0,r.default)(S,2),x=E[0],k=E[1];o.useEffect(function(){var e={};w.forEach(function(t){var r=t.placement,n=void 0===r?"topRight":r;n&&(e[n]=e[n]||[],e[n].push(t))}),Object.keys(x).forEach(function(t){e[t]=e[t]||[]}),k(e)},[w]);var O=function(e){k(function(t){var r=(0,a.default)({},t);return(r[e]||[]).length||delete r[e],r})},j=o.useRef(!1);if(o.useEffect(function(){Object.keys(x).length>0?j.current=!0:j.current&&(null==m||m(),j.current=!1)},[x]),!c)return null;var T=Object.keys(x);return(0,i.createPortal)(o.createElement(o.Fragment,null,T.map(function(e){var t=x[e],r=o.createElement(y,{key:e,configList:t,placement:e,prefixCls:s,className:null==f?void 0:f(e),style:null==p?void 0:p(e),motion:u,onNoticeClose:$,onAllNoticeRemoved:O,stack:h});return g?g(r,{prefixCls:s,key:e}):r})),c)});e.i(62664);var w=e.i(697539),C=["getContainer","motion","prefixCls","maxCount","className","style","onAllRemoved","stack","renderNotifications"],$=function(){return document.body},S=0;function E(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},a=e.getContainer,i=void 0===a?$:a,l=e.motion,s=e.prefixCls,c=e.maxCount,u=e.className,d=e.style,f=e.onAllRemoved,p=e.stack,m=e.renderNotifications,h=(0,n.default)(e,C),g=o.useState(),v=(0,r.default)(g,2),y=v[0],E=v[1],x=o.useRef(),k=o.createElement(b,{container:y,ref:x,prefixCls:s,motion:l,maxCount:c,className:u,style:d,onAllRemoved:f,stack:p,renderNotifications:m}),O=o.useState([]),j=(0,r.default)(O,2),T=j[0],P=j[1],_=(0,w.useEvent)(function(e){var r=function(){for(var e={},t=arguments.length,r=Array(t),n=0;n0,!0),r[0]),i=r.index+r[0].length,r=n.exec(t);return o.push(tK(t.slice(i),i>0,!1)),o.join("")}(String(t.value))};return e.patch(t,n),e.applyData(t,n)},thematicBreak:function(e,t){let n={type:"element",tagName:"hr",properties:{},children:[]};return e.patch(t,n),e.applyData(t,n)},toml:tY,yaml:tY,definition:tY,footnoteDefinition:tY};function tY(){}let tG={}.hasOwnProperty,tZ={};function t0(e,t){e.position&&(t.position=function(e){let t=H(e),n=q(e);if(t&&n)return{start:t,end:n}}(e))}function t1(e,t){let n=t;if(e&&e.data){let t=e.data.hName,r=e.data.hChildren,i=e.data.hProperties;"string"==typeof t&&("element"===n.type?n.tagName=t:n={type:"element",tagName:t,properties:{},children:"children"in n?n.children:[n]}),"element"===n.type&&i&&Object.assign(n.properties,tB(i)),"children"in n&&n.children&&null!=r&&(n.children=r)}return n}function t2(e,t){let n=[],r=-1;for(t&&n.push({type:"text",value:"\n"});++r0,!0),r[0]),i=r.index+r[0].length,r=n.exec(t);return o.push(tK(t.slice(i),i>0,!1)),o.join("")}(String(t.value))};return e.patch(t,n),e.applyData(t,n)},thematicBreak:function(e,t){let n={type:"element",tagName:"hr",properties:{},children:[]};return e.patch(t,n),e.applyData(t,n)},toml:tY,yaml:tY,definition:tY,footnoteDefinition:tY};function tY(){}let tG={}.hasOwnProperty,tZ={};function t0(e,t){e.position&&(t.position=function(e){let t=H(e),n=q(e);if(t&&n)return{start:t,end:n}}(e))}function t1(e,t){let n=t;if(e&&e.data){let t=e.data.hName,r=e.data.hChildren,i=e.data.hProperties;"string"==typeof t&&("element"===n.type?n.tagName=t:n={type:"element",tagName:t,properties:{},children:"children"in n?n.children:[n]}),"element"===n.type&&i&&Object.assign(n.properties,tB(i)),"children"in n&&n.children&&null!=r&&(n.children=r)}return n}function t2(e,t){let n=[],r=-1;for(t&&n.push({type:"text",value:"\n"});++r