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..6f54da406f8 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260520120000_add_mcp_env_vars/migration.sql @@ -0,0 +1,20 @@ +-- 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"); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 78143fe0411..a4435cc7673 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 + // (and the URL) 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? @@ -363,6 +368,20 @@ 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]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index a6f0d145e9b..392f33ee25e 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -61,6 +61,14 @@ def _prepare_mcp_server_data( if data.static_headers is not None: data_dict["static_headers"] = safe_dumps(data.static_headers) + # Handle env_vars serialization. Pydantic models are dumped to a list of + # plain dicts so the JSON column receives ``[{name, value, scope, ...}]``. + env_vars = getattr(data, "env_vars", None) + if env_vars is not None: + data_dict["env_vars"] = safe_dumps( + [v.model_dump() if hasattr(v, "model_dump") else dict(v) for v in env_vars] + ) + # Handle mcp_info serialization if data.mcp_info is not None: data_dict["mcp_info"] = safe_dumps(data.mcp_info) @@ -937,3 +945,92 @@ async def get_mcp_submissions( rejected=rejected, items=items, ) + + +# ── Per-user MCP environment variables ──────────────────────────────────── + + +async def store_user_env_vars( + prisma_client: PrismaClient, + user_id: str, + server_id: str, + values: Dict[str, str], +) -> None: + """Persist (or overwrite) the calling user's env var values for ``server_id``. + + Values are JSON-serialised and stored encrypted in ``values_b64``. + """ + encoded = encrypt_value_helper(json.dumps(values)) + await prisma_client.db.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}, + }, + ) + + +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: + 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 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``.""" + await prisma_client.db.litellm_mcpuserenvvars.delete( + where={"user_id_server_id": {"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 bbf40f6e9ef..d57cf59d269 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -50,14 +50,19 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp 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, ) @@ -190,6 +195,25 @@ 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) or already-materialized lists. + """ + 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 + return parsed if isinstance(parsed, list) else None + if isinstance(data, list): + return data + return None + + class MCPServerManager: _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") @@ -396,6 +420,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) @@ -668,6 +693,7 @@ class MCPServerManager: static_headers_dict = _deserialize_json_dict( getattr(mcp_server, "static_headers", None) ) + env_vars_list = _deserialize_json_list(getattr(mcp_server, "env_vars", None)) credentials_dict = _deserialize_json_dict( getattr(mcp_server, "credentials", None) ) @@ -767,6 +793,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), @@ -1300,6 +1327,93 @@ class MCPServerManager: return resolved_env + async def _resolve_static_headers_with_env_vars( + self, + server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> 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. + + Raises ``MCPMissingUserEnvVarsError`` when ``static_headers`` reference + a per-user variable that the calling user has not yet supplied. This + is converted into a user-facing 412 by the REST layer. + """ + 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) + 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. + referenced = collect_env_var_references(strings=(static_headers or {}).values()) + referenced_user_vars = referenced & user_var_names + + user_values: Dict[str, str] = {} + if referenced_user_vars: + user_values = await self._load_user_env_vars(server, user_api_key_auth) + + missing = sorted( + name for name in referenced_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), + ) + + merged_vars: Dict[str, str] = {**global_values, **user_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], + ) -> Dict[str, str]: + """Best-effort lookup of the calling user's env var values for ``server``. + + Returns an empty dict when no user is available or the DB lookup + fails — callers detect missing values via name lookup, not by an + exception here. + """ + if user_api_key_auth is None: + return {} + user_id = getattr(user_api_key_auth, "user_id", None) + if not user_id: + return {} + from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 + + if prisma_client is None: + return {} + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 + get_user_env_vars, + ) + + try: + return await get_user_env_vars(prisma_client, user_id, server.server_id) + except Exception as exc: + verbose_logger.debug( + "MCPServerManager: failed to load user env vars for " + "user=%s server=%s: %s", + user_id, + server.server_id, + exc, + ) + return {} + async def _create_mcp_client( self, server: MCPServer, @@ -1429,10 +1543,13 @@ class MCPServerManager: client = None try: - if server.static_headers: + resolved_static_headers = await self._resolve_static_headers_with_env_vars( + server, user_api_key_auth + ) + 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 @@ -2675,6 +2792,7 @@ class MCPServerManager: proxy_logging_obj: Optional[ProxyLogging], host_progress_callback: Optional[Callable] = None, hook_extra_headers: Optional[Dict[str, str]] = None, + user_api_key_auth: Optional[UserAPIKeyAuth] = None, ) -> CallToolResult: """ Call a regular MCP tool using the MCP client. @@ -2754,10 +2872,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: @@ -3039,6 +3164,7 @@ class MCPServerManager: proxy_logging_obj=proxy_logging_obj, host_progress_callback=host_progress_callback, hook_extra_headers=hook_result.get("extra_headers"), + user_api_key_auth=user_api_key_auth, ) return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj) diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 7150dee10cf..8938962dac1 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -19,7 +19,10 @@ from litellm._logging import verbose_logger 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 @@ -885,6 +888,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( diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 5676aaf0d22..2da0e0d7836 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -48,6 +48,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, @@ -468,6 +469,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)}") return CallToolResult( diff --git a/litellm/proxy/_experimental/mcp_server/utils.py b/litellm/proxy/_experimental/mcp_server/utils.py index df5705c3425..f3f71fd7625 100644 --- a/litellm/proxy/_experimental/mcp_server/utils.py +++ b/litellm/proxy/_experimental/mcp_server/utils.py @@ -2,7 +2,8 @@ MCP Server Utilities """ -from typing import Any, Dict, Iterator, Mapping, Optional, Tuple +import re +from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Set, Tuple import hashlib import importlib @@ -294,6 +295,128 @@ 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 + vars_list = ", ".join(missing) + message = ( + f"MCP server '{label}' is missing the following per-user environment " + f"variable{'s' if len(missing) != 1 else ''} that you need to fill in " + f"before this server can be used: {vars_list}.\n\n" + f"Go to {setup_url} to set them, then try again." + ) + 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={server_id}" + return f"{base}{path}" if base else path + + def merge_mcp_headers( *, extra_headers: Optional[Mapping[str, str]] = None, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9337aa7c8ea..5f8f16030d1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1249,6 +1249,34 @@ class MCPApprovalStatus(str, enum.Enum): rejected = "rejected" +class MCPEnvVarScope(str, enum.Enum): + """Scope for an MCP server environment variable. + + - ``global``: value is provided by the admin and used for all users. + - ``user``: each user must provide their own value via the per-user + env-var endpoint. The admin-supplied ``value`` is treated as a + placeholder/hint and is not used at request time. + """ + + global_ = "global" + user = "user" + + +class MCPEnvVar(LiteLLMPydanticObjectBase): + """One environment variable for an MCP server. + + Variables can be interpolated into ``static_headers`` (and the server + URL) using ``${NAME}`` syntax. ``scope=global`` values are stored on + the server. ``scope=user`` values are stored per-user in + ``LiteLLM_MCPUserEnvVars`` and supplied by each user. + """ + + name: str + value: str = "" + scope: MCPEnvVarScope = MCPEnvVarScope.global_ + description: Optional[str] = None + + # MCP Proxy Request Types class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: Optional[str] = None @@ -1267,6 +1295,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): tool_name_to_description: Optional[Dict[str, str]] = None extra_headers: Optional[List[str]] = None static_headers: Optional[Dict[str, str]] = None + env_vars: Optional[List[MCPEnvVar]] = None instructions: Optional[str] = None # Stdio-specific fields command: Optional[str] = None @@ -1351,6 +1380,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): tool_name_to_description: Optional[Dict[str, str]] = None extra_headers: Optional[List[str]] = None static_headers: Optional[Dict[str, str]] = None + env_vars: Optional[List[MCPEnvVar]] = None instructions: Optional[str] = None # Stdio-specific fields command: Optional[str] = None @@ -1417,6 +1447,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): extra_headers: List[str] = Field(default_factory=list) mcp_info: Optional[MCPInfo] = None static_headers: Optional[Dict[str, str]] = None + env_vars: Optional[List[MCPEnvVar]] = None # Health check status status: Optional[Literal["healthy", "unhealthy", "unknown"]] = Field( default="unknown", @@ -1495,6 +1526,32 @@ class MCPUserCredentialListItem(LiteLLMPydanticObjectBase): connected_at: Optional[str] = None # ISO-8601 +class MCPUserEnvVarsRequest(LiteLLMPydanticObjectBase): + """Payload for storing the calling user's per-user env var values.""" + + values: Dict[str, str] + + +class MCPUserEnvVarSpec(LiteLLMPydanticObjectBase): + """Describes one per-user env var slot for the calling user.""" + + name: str + description: Optional[str] = None + value: Optional[str] = None # current value if the user has set one + is_set: bool = False + + +class MCPUserEnvVarsStatus(LiteLLMPydanticObjectBase): + """Per-user env var status for a single MCP server.""" + + server_id: str + server_name: Optional[str] = None + alias: Optional[str] = None + required: List[MCPUserEnvVarSpec] = Field(default_factory=list) + missing_count: int = 0 + setup_url: Optional[str] = None # frontend URL where the user can fill these in + + class RejectMCPServerRequest(LiteLLMPydanticObjectBase): review_notes: Optional[str] = None diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index e9d9c243e7c..15c16e87313 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -47,7 +47,10 @@ from litellm._logging import verbose_logger, verbose_proxy_logger from litellm._uuid import uuid from litellm.constants import LITELLM_PROXY_ADMIN_NAME from litellm.proxy._experimental.mcp_server.utils import ( + build_env_var_setup_url, + collect_env_var_references, get_server_prefix, + parse_admin_env_vars, ) from litellm.proxy._experimental.mcp_server.utils import ( validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload, @@ -111,14 +114,18 @@ if MCP_AVAILABLE: create_mcp_server, delete_mcp_server, delete_user_credential, + delete_user_env_vars, get_all_mcp_servers_for_user, get_mcp_server, get_mcp_servers, get_mcp_submissions, + get_user_env_vars, + get_user_env_vars_bulk, get_user_oauth_credential, list_user_oauth_credentials, reject_mcp_server, store_user_credential, + store_user_env_vars, store_user_oauth_credential, update_mcp_server, ) @@ -146,6 +153,9 @@ if MCP_AVAILABLE: MCPUserCredentialListItem, MCPUserCredentialRequest, MCPUserCredentialResponse, + MCPUserEnvVarSpec, + MCPUserEnvVarsRequest, + MCPUserEnvVarsStatus, NewMCPServerRequest, RejectMCPServerRequest, SpecialMCPServerName, @@ -2102,6 +2112,190 @@ if MCP_AVAILABLE: ) return items + # ── Per-user MCP env var endpoints ──────────────────────────────────────── + + def _compute_user_env_var_status( + *, + server: LiteLLM_MCPServerTable, + stored_values: Dict[str, str], + ) -> MCPUserEnvVarsStatus: + """Build a status object for one server given the user's stored values.""" + _, user_specs = parse_admin_env_vars(getattr(server, "env_vars", None)) + + # Limit "required" to vars that are actually referenced by static_headers. + # If an admin defined a per-user var but never used it, it's not blocking. + static_headers = getattr(server, "static_headers", None) or {} + if isinstance(static_headers, str): + try: + import json as _json + + static_headers = _json.loads(static_headers) or {} + except (ValueError, TypeError): + static_headers = {} + referenced = collect_env_var_references(strings=static_headers.values()) + user_var_names = {spec["name"] for spec in user_specs} + blocking = referenced & user_var_names + + required: List[MCPUserEnvVarSpec] = [] + missing_count = 0 + for spec in user_specs: + name = spec["name"] + if name not in blocking: + continue + value = stored_values.get(name) + is_set = bool(value) + if not is_set: + missing_count += 1 + required.append( + MCPUserEnvVarSpec( + name=name, + description=spec.get("description"), + value=value, + is_set=is_set, + ) + ) + + return MCPUserEnvVarsStatus( + server_id=server.server_id, + server_name=getattr(server, "server_name", None), + alias=getattr(server, "alias", None), + required=required, + missing_count=missing_count, + setup_url=build_env_var_setup_url(server.server_id) if required else None, + ) + + @router.get( + "/server/{server_id}/user-env-vars", + description="Return the calling user's per-user MCP env var status for this server.", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserEnvVarsStatus, + ) + @management_endpoint_wrapper + async def get_mcp_user_env_vars( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ) -> MCPUserEnvVarsStatus: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + server = await get_mcp_server(prisma_client, server_id) + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server {server_id} not found"}, + ) + stored = await get_user_env_vars(prisma_client, user_id, server_id) + return _compute_user_env_var_status(server=server, stored_values=stored) + + @router.post( + "/server/{server_id}/user-env-vars", + description="Store the calling user's per-user MCP env var values for this server.", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserEnvVarsStatus, + ) + @management_endpoint_wrapper + async def store_mcp_user_env_vars( + server_id: str, + payload: MCPUserEnvVarsRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ) -> MCPUserEnvVarsStatus: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + server = await get_mcp_server(prisma_client, server_id) + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server {server_id} not found"}, + ) + # Filter to only known per-user var names declared by the admin — + # never persist arbitrary keys the user invents. + _, user_specs = parse_admin_env_vars(getattr(server, "env_vars", None)) + allowed_names = {spec["name"] for spec in user_specs} + filtered = { + k: v for k, v in payload.values.items() if k in allowed_names and v != "" + } + await store_user_env_vars(prisma_client, user_id, server_id, filtered) + return _compute_user_env_var_status(server=server, stored_values=filtered) + + @router.delete( + "/server/{server_id}/user-env-vars", + description="Clear the calling user's per-user MCP env var values for this server.", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserEnvVarsStatus, + ) + @management_endpoint_wrapper + async def clear_mcp_user_env_vars( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ) -> MCPUserEnvVarsStatus: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + server = await get_mcp_server(prisma_client, server_id) + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server {server_id} not found"}, + ) + try: + await delete_user_env_vars(prisma_client, user_id, server_id) + except Exception: + pass # Already deleted / didn't exist + return _compute_user_env_var_status(server=server, stored_values={}) + + @router.get( + "/user-env-vars/status", + description="Per-user MCP env var status across every server the user can access. " + "Used by the dashboard to highlight servers with missing per-user vars.", + dependencies=[Depends(user_api_key_auth)], + response_model=List[MCPUserEnvVarsStatus], + ) + @management_endpoint_wrapper + async def list_mcp_user_env_var_status( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ) -> List[MCPUserEnvVarsStatus]: + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy" + ) + user_id = user_api_key_dict.user_id or "" + if not user_id: + return [] + accessible = await get_all_mcp_servers_for_user( + prisma_client, user_api_key_dict + ) + if not accessible: + return [] + server_ids = [s.server_id for s in accessible] + stored_bulk = await get_user_env_vars_bulk(prisma_client, user_id, server_ids) + statuses: List[MCPUserEnvVarsStatus] = [] + for server in accessible: + stored = stored_bulk.get(server.server_id, {}) + status_obj = _compute_user_env_var_status( + server=server, stored_values=stored + ) + if status_obj.required: + statuses.append(status_obj) + return statuses + @router.put( "/server", description="Allows deleting mcp serves in the db", diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 78143fe0411..a4435cc7673 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/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 + // (and the URL) 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? @@ -363,6 +368,20 @@ 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]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 776c7fa67a6..ec487fa5d29 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -42,6 +42,10 @@ class MCPServer(BaseModel): static_headers: Optional[Dict[str, str]] = ( None # static headers to forward to the MCP server ) + # Admin-configured env vars. Each entry is {name, value, scope, description}. + # scope=="global" values are interpolated into static_headers/URL using ${NAME}. + # scope=="user" values must be supplied per-user. + env_vars: Optional[List[Dict[str, Any]]] = None # OAuth-specific fields client_id: Optional[str] = None client_secret: Optional[str] = None diff --git a/schema.prisma b/schema.prisma index 78143fe0411..a4435cc7673 100644 --- a/schema.prisma +++ b/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 + // (and the URL) 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? @@ -363,6 +368,20 @@ 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]) +} + // Generate Tokens for Proxy model LiteLLM_VerificationToken { token String @id diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py new file mode 100644 index 00000000000..aed623dd897 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py @@ -0,0 +1,274 @@ +"""Tests for MCP env-var interpolation utilities. + +These cover the pure helpers in +``litellm.proxy._experimental.mcp_server.utils`` and do not require a DB +connection. The DB-backed per-user flow is exercised in higher-level +tests in tests/mcp_tests. +""" + +import pytest + +# Look up these names lazily on every access. Tests in this directory call +# ``importlib.reload`` on the utils module to exercise registration logic, +# which replaces ``MCPMissingUserEnvVarsError`` with a freshly-constructed +# class. A direct ``from ... import`` at module load time would freeze the +# old class object and ``pytest.raises(_u("MCPMissingUserEnvVarsError"))`` would +# stop matching the new class. Accessing the attribute through the module +# always picks up the current version. +import litellm.proxy._experimental.mcp_server.utils as _mcp_utils + + +def _u(name: str): + return getattr(_mcp_utils, name) + + +def test_parse_admin_env_vars_splits_global_and_user(): + g, u = _u("parse_admin_env_vars")( + [ + {"name": "DB_PROTOCOL", "value": "postgres", "scope": "global"}, + {"name": "DB_HOST", "value": "localhost", "scope": "global"}, + { + "name": "CORP_USERNAME", + "value": "", + "scope": "user", + "description": "Your DB username", + }, + {"name": "CORP_PASSWORD", "value": "", "scope": "user"}, + ] + ) + assert g == {"DB_PROTOCOL": "postgres", "DB_HOST": "localhost"} + assert u == [ + {"name": "CORP_USERNAME", "description": "Your DB username"}, + {"name": "CORP_PASSWORD", "description": None}, + ] + + +def test_parse_admin_env_vars_handles_none_and_empty(): + assert _u("parse_admin_env_vars")(None) == ({}, []) + assert _u("parse_admin_env_vars")([]) == ({}, []) + + +def test_parse_admin_env_vars_skips_malformed_entries(): + g, u = _u("parse_admin_env_vars")( + [ + None, + {"name": "", "value": "x"}, + {"value": "no_name"}, + {"name": "OK", "value": "v"}, + ] + ) + assert g == {"OK": "v"} + assert u == [] + + +def test_find_env_var_references(): + assert _u("find_env_var_references")("") == set() + assert _u("find_env_var_references")("plain") == set() + assert _u("find_env_var_references")("${A}") == {"A"} + assert _u("find_env_var_references")("${A}/${B}/${A}") == {"A", "B"} + # Invalid identifier patterns should not match + assert _u("find_env_var_references")("${1abc}") == set() + assert _u("find_env_var_references")("${a-b}") == set() + + +def test_collect_env_var_references(): + refs = _u("collect_env_var_references")(strings=["${A}", "static", "${B}-${C}", None]) + assert refs == {"A", "B", "C"} + + +def test_interpolate_env_vars_replaces_known_and_leaves_unknown(): + assert _u("interpolate_env_vars")("${A}://${B}/${C}", {"A": "https", "B": "host"}) == ( + "https://host/${C}" + ) + + +def test_interpolate_headers_returns_independent_copy(): + headers = {"X-Url": "${A}://x"} + out = _u("interpolate_headers")(headers, {"A": "https"}) + assert out == {"X-Url": "https://x"} + # original untouched + assert headers == {"X-Url": "${A}://x"} + + +def test_build_env_var_setup_url_includes_server_id(monkeypatch): + monkeypatch.delenv("PROXY_BASE_URL", raising=False) + url = _u("build_env_var_setup_url")("abc-123") + assert url.startswith("/ui/?page=mcp-servers") + assert "fill_env_vars=abc-123" in url + + +def test_build_env_var_setup_url_prepends_proxy_base_url(monkeypatch): + monkeypatch.setenv("PROXY_BASE_URL", "https://proxy.example.com/") + url = _u("build_env_var_setup_url")("abc-123") + assert url.startswith("https://proxy.example.com/ui/") + assert "fill_env_vars=abc-123" in url + + +def test_missing_user_env_vars_error_message_is_friendly(): + with pytest.raises(_u("MCPMissingUserEnvVarsError")) as exc_info: + raise _u("MCPMissingUserEnvVarsError")( + server_id="abc-123", + server_name="CorporateDB", + missing=["CORP_USERNAME", "CORP_PASSWORD"], + setup_url="https://proxy.example.com/ui/?page=mcp-servers&fill_env_vars=abc-123", + ) + err = exc_info.value + text = str(err) + assert "CorporateDB" in text + assert "CORP_USERNAME" in text + assert "CORP_PASSWORD" in text + assert "fill_env_vars=abc-123" in text + assert err.server_id == "abc-123" + assert err.missing == ["CORP_USERNAME", "CORP_PASSWORD"] + + +def test_missing_user_env_vars_error_singular_message(): + err = _u("MCPMissingUserEnvVarsError")( + server_id="abc", + server_name=None, + missing=["X"], + setup_url="/ui/", + ) + text = str(err) + # Singular "variable" rather than "variables" when only one is missing + assert "variable that you need to fill in" in text + # Falls back to server_id when server_name is missing + assert "abc" in text + + +# ── _resolve_static_headers_with_env_vars ──────────────────────────────── + + +@pytest.fixture +def mock_server(): + """A minimal MCPServer-like object for the static-headers resolver.""" + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + return MCPServer( + server_id="srv-1", + name="srv", + server_name="srv", + transport="http", + url="https://example.com", + static_headers={ + "X-DB-URL": "${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOST}/db", + "X-Other": "literal", + }, + env_vars=[ + {"name": "DB_PROTOCOL", "value": "postgres", "scope": "global"}, + {"name": "DB_HOST", "value": "db.local", "scope": "global"}, + { + "name": "CORP_USERNAME", + "value": "", + "scope": "user", + "description": "Your DB username", + }, + {"name": "CORP_PASSWORD", "value": "", "scope": "user"}, + ], + ) + + +@pytest.mark.asyncio +async def test_resolve_static_headers_interpolates_globals_and_user( + mock_server, monkeypatch +): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + + manager = MCPServerManager() + + # Stub the per-user lookup so we don't need a real DB. + async def fake_load_user_env_vars(server, user_api_key_auth): + return {"CORP_USERNAME": "alice", "CORP_PASSWORD": "s3cret"} + + monkeypatch.setattr(manager, "_load_user_env_vars", fake_load_user_env_vars) + + headers = await manager._resolve_static_headers_with_env_vars( + mock_server, user_api_key_auth=object() + ) + assert headers == { + "X-DB-URL": "postgres://alice:s3cret@db.local/db", + "X-Other": "literal", + } + + +@pytest.mark.asyncio +async def test_resolve_static_headers_raises_when_user_vars_missing( + mock_server, monkeypatch +): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + + manager = MCPServerManager() + + async def fake_load_user_env_vars(server, user_api_key_auth): + # User has only filled in one of the two required vars + return {"CORP_USERNAME": "alice"} + + monkeypatch.setattr(manager, "_load_user_env_vars", fake_load_user_env_vars) + + with pytest.raises(_u("MCPMissingUserEnvVarsError")) as exc: + await manager._resolve_static_headers_with_env_vars( + mock_server, user_api_key_auth=object() + ) + assert exc.value.missing == ["CORP_PASSWORD"] + assert exc.value.server_id == "srv-1" + assert "fill_env_vars=srv-1" in exc.value.setup_url + + +@pytest.mark.asyncio +async def test_resolve_static_headers_passthrough_when_no_env_vars(): + """Servers without env_vars should keep static_headers untouched.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + server = MCPServer( + server_id="srv-2", + name="srv2", + transport="http", + url="https://example.com", + static_headers={"Authorization": "Bearer admin-static"}, + env_vars=None, + ) + headers = await manager._resolve_static_headers_with_env_vars(server, None) + assert headers == {"Authorization": "Bearer admin-static"} + + +@pytest.mark.asyncio +async def test_resolve_static_headers_unreferenced_user_var_is_not_blocking( + monkeypatch, +): + """A per-user var declared by the admin but never referenced in + static_headers must not block the request — only blocking-by-use is + enforced.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + server = MCPServer( + server_id="srv-3", + name="srv3", + transport="http", + url="https://example.com", + static_headers={"X-Static": "${GLOBAL_VAR}"}, + env_vars=[ + {"name": "GLOBAL_VAR", "value": "ok", "scope": "global"}, + # User var declared but not referenced anywhere — should be ignored. + {"name": "UNUSED_USER_VAR", "value": "", "scope": "user"}, + ], + ) + + async def fake_load_user_env_vars(server, user_api_key_auth): + return {} + + monkeypatch.setattr(manager, "_load_user_env_vars", fake_load_user_env_vars) + + headers = await manager._resolve_static_headers_with_env_vars(server, object()) + assert headers == {"X-Static": "ok"} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx new file mode 100644 index 00000000000..fff99da93e4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Form, Input, Select, Space, Button, Tooltip, Typography } from "antd"; +import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; + +const { Text } = Typography; + +const SCOPE_OPTIONS = [ + { value: "global", label: "Global" }, + { value: "user", label: "Per-user" }, +]; + +/** + * Form section for admin-configured MCP environment variables. + * + * Each row has: name | value | scope. Variables can be interpolated into + * Static Headers via ${NAME}. ``scope=global`` values are used as-is. + * ``scope=user`` values are filled in by each user — the admin-entered + * value is just a placeholder/description. + * + * The parent form must render this inside a ``
`` and read the + * ``env_vars`` field from the form values. + */ +const EnvVarsSection: React.FC = () => { + return ( + + Environment Variables + +
+ Define variables that get interpolated into Static Headers via{" "} + {"${NAME}"} syntax. +
+
+ Global: value is used for every user. +
+
+ Per-user: each user fills in their own value via the + MCP Gateway dashboard. The value you enter here is shown to + the user as a placeholder/description. +
+ + } + > + +
+ + } + required={false} + > + + Reference these in Static Headers like{" "} + {"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@..."} + + + {(fields, { add, remove }) => ( +
+ {fields.map(({ key, name, ...restField }) => ( + + + + + + + + + + + ))} + + {(status?.required ?? []).length === 0 && !isLoading && ( + + No per-user variables required for this server. + + )} + +
+ + +
+ + )} + + ); +}; + +export default UserEnvVarsModal; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index f8b0141b25d..eca94f4da8e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -46,6 +46,31 @@ const reduceStaticHeaders = (list: unknown): Record => { }, {}); }; +type EnvVarEntry = { name: string; value: string; scope: "global" | "user"; description?: string }; + +/** Normalize the env_vars form list into the payload shape the backend expects. + * Drops empty rows and any with invalid identifiers. */ +const normalizeEnvVars = (list: unknown): EnvVarEntry[] => { + if (!Array.isArray(list)) return []; + const seen = new Set(); + const out: EnvVarEntry[] = []; + for (const entry of list) { + if (!entry || typeof entry !== "object") continue; + const name = String((entry as Record).name ?? "").trim(); + if (!name || seen.has(name)) continue; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue; + const scope = (entry as Record).scope === "user" ? "user" : "global"; + out.push({ + name, + value: String((entry as Record).value ?? ""), + scope, + description: ((entry as Record).description as string | undefined) || undefined, + }); + seen.add(name); + } + return out; +}; + const CreateMCPServer: React.FC = ({ userRole, accessToken, @@ -280,6 +305,7 @@ const CreateMCPServer: React.FC = ({ try { const { static_headers: staticHeadersList, + env_vars: envVarsList, stdio_config: rawStdioConfig, credentials: credentialValues, allow_all_keys: allowAllKeysRaw, @@ -293,6 +319,7 @@ const CreateMCPServer: React.FC = ({ const accessGroups = restValues.mcp_access_groups; const staticHeaders = reduceStaticHeaders(staticHeadersList); + const envVars = normalizeEnvVars(envVarsList); const credentialsPayload = credentialValues && typeof credentialValues === "object" @@ -391,10 +418,12 @@ const CreateMCPServer: React.FC = ({ available_on_public_internet: Boolean(availableOnPublicInternetRaw), delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), static_headers: staticHeaders, + env_vars: envVars, ...(tokenValidation !== null && { token_validation: tokenValidation }), }; payload.static_headers = staticHeaders; + payload.env_vars = envVars; const includeCredentials = restValues.auth_type && AUTH_TYPES_REQUIRING_CREDENTIALS.includes(restValues.auth_type); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index ea5ccf1c847..153499b2786 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { ColumnDef } from "@tanstack/react-table"; -import { MCPServer } from "./types"; +import { MCPServer, MCPUserEnvVarsStatus } from "./types"; import { Icon } from "@tremor/react"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { getMaskedAndFullUrl } from "./utils"; import { Tooltip } from "antd"; -import { CheckOutlined } from "@ant-design/icons"; +import { CheckOutlined, ExclamationCircleFilled } from "@ant-design/icons"; const HealthStatusBadge: React.FC<{ server: MCPServer; @@ -92,6 +92,8 @@ export const mcpServerColumns = ( onByokConnect?: (server: MCPServer) => void, onRecheckHealth?: (serverId: string) => void, recheckingServerIds?: Set, + envVarStatusByServer?: Record, + onSetEnvVars?: (server: MCPServer) => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -113,8 +115,15 @@ export const mcpServerColumns = ( cell: ({ row }) => { const logoUrl = row.original.mcp_info?.logo_url; const name = row.original.server_name; + const status = envVarStatusByServer?.[row.original.server_id]; + const missing = status?.missing_count ?? 0; + const showWarning = missing > 0; return ( -
+
{logoUrl ? ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : null} - {name} + {name} + {showWarning && ( + + + + )}
); }, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 9278d41c3e3..fedbb535736 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -164,6 +164,18 @@ const MCPServerEdit: React.FC = ({ })); }, [mcpServer.static_headers]); + const initialEnvVars = React.useMemo(() => { + if (!Array.isArray(mcpServer.env_vars)) { + return []; + } + return mcpServer.env_vars.map((entry) => ({ + name: entry.name, + value: entry.value ?? "", + scope: entry.scope === "user" ? "user" : "global", + description: entry.description ?? "", + })); + }, [mcpServer.env_vars]); + const initialEnvJson = React.useMemo(() => { const env = mcpServer.env ?? undefined; if (!env || Object.keys(env).length === 0) { @@ -190,13 +202,14 @@ const MCPServerEdit: React.FC = ({ ...mcpServer, transport: effectiveTransport, static_headers: initialStaticHeaders, + env_vars: initialEnvVars, extra_headers: mcpServer.extra_headers || [], oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE, token_validation_json: mcpServer.token_validation ? JSON.stringify(mcpServer.token_validation, null, 2) : undefined, }), - [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson], + [mcpServer, effectiveTransport, initialStaticHeaders, initialEnvVars, initialEnvJson], ); // Initialize cost config from existing server data @@ -377,6 +390,7 @@ const MCPServerEdit: React.FC = ({ // Ensure access groups is always a string array const { static_headers: staticHeadersList, + env_vars: envVarsList, credentials: credentialValues, stdio_config: rawStdioConfig, env_json: rawEnvJson, @@ -404,6 +418,29 @@ const MCPServerEdit: React.FC = ({ }, {}) : ({} as Record); + const envVars = Array.isArray(envVarsList) + ? envVarsList.reduce( + (acc: Array<{ name: string; value: string; scope: "global" | "user"; description?: string }>, entry: Record) => { + const name = String(entry?.name ?? "").trim(); + if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + return acc; + } + if (acc.some((existing) => existing.name === name)) { + return acc; + } + const scope = entry?.scope === "user" ? "user" : "global"; + acc.push({ + name, + value: String(entry?.value ?? ""), + scope, + description: (entry?.description as string | undefined) || undefined, + }); + return acc; + }, + [], + ) + : []; + const credentialsPayload = credentialValues && typeof credentialValues === "object" ? Object.entries(credentialValues).reduce((acc: Record, [key, value]) => { @@ -551,6 +588,7 @@ const MCPServerEdit: React.FC = ({ tool_name_to_description: Object.keys(toolNameToDescription).length > 0 ? toolNameToDescription : null, disallowed_tools: restValues.disallowed_tools || [], static_headers: staticHeaders, + env_vars: envVars, allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), // ``delegate_auth_to_upstream`` is only honored server-side for diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 72d5e4b5aa8..ef110d5f994 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -15,11 +15,13 @@ import CreateMCPServer from "./create_mcp_server"; import MCPConnect from "./mcp_connect"; import { mcpServerColumns } from "./mcp_server_columns"; import { MCPServerView } from "./mcp_server_view"; -import { DiscoverableMCPServer, MCPServer, MCPServerProps, Team } from "./types"; +import { DiscoverableMCPServer, MCPServer, MCPServerProps, MCPUserEnvVarsStatus, Team } from "./types"; import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings"; import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; import { ByokCredentialModal } from "./ByokCredentialModal"; +import UserEnvVarsModal from "./UserEnvVarsModal"; +import { listMCPUserEnvVarStatus } from "../networking"; import { getSecureItem } from "@/utils/secureStorage"; const { Text: AntdText, Title: AntdTitle } = Typography; @@ -64,8 +66,52 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); const [byokModalServer, setByokModalServer] = useState(null); + const [envVarsModalServer, setEnvVarsModalServer] = useState(null); + const [envVarStatusByServer, setEnvVarStatusByServer] = useState>({}); const isInternalUser = userRole === "Internal User"; + const refetchEnvVarStatus = useCallback(async () => { + if (!accessToken) { + setEnvVarStatusByServer({}); + return; + } + try { + const statuses = await listMCPUserEnvVarStatus(accessToken); + const map: Record = {}; + for (const s of statuses) { + map[s.server_id] = s; + } + setEnvVarStatusByServer(map); + } catch (err) { + console.warn("Failed to load MCP env-var status", err); + } + }, [accessToken]); + + useEffect(() => { + refetchEnvVarStatus(); + }, [refetchEnvVarStatus, mcpServers]); + + // Deep-link support: open the modal automatically when the URL contains + // ?fill_env_vars=. This is the link users follow from the + // friendly error returned by the proxy when a per-user var is missing. + useEffect(() => { + if (typeof window === "undefined" || !mcpServers) { + return; + } + const params = new URLSearchParams(window.location.search); + const targetId = params.get("fill_env_vars"); + if (!targetId) return; + const target = mcpServers.find((s) => s.server_id === targetId); + if (target) { + setEnvVarsModalServer(target); + // Strip the query param so the modal doesn't re-open on every render. + params.delete("fill_env_vars"); + const cleaned = params.toString(); + const newUrl = `${window.location.pathname}${cleaned ? `?${cleaned}` : ""}${window.location.hash}`; + window.history.replaceState(null, "", newUrl); + } + }, [mcpServers]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -173,8 +219,10 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) (server: MCPServer) => setByokModalServer(server), recheckServerHealth, recheckingServerIds, + envVarStatusByServer, + (server: MCPServer) => setEnvVarsModalServer(server), ), - [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds], + [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, envVarStatusByServer], ); function handleDelete(server_id: string) { @@ -461,6 +509,16 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) accessToken={accessToken || ""} /> )} + + setEnvVarsModalServer(null)} + onSaved={() => { + refetchEnvVarStatus(); + }} + />
); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 7cfe08d9ee5..33cb2599df0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -228,6 +228,42 @@ export interface MCPServer { /** Per-user OAuth token storage settings (interactive OAuth only) */ token_validation?: Record | null; token_storage_ttl_seconds?: number | null; + + /** + * Admin-configured env vars interpolated into static_headers via ${NAME}. + * Stored as a list so the UI can preserve admin-entered ordering. + */ + env_vars?: MCPEnvVar[] | null; +} + +/** One environment variable entry on an MCP server. */ +export type MCPEnvVarScope = "global" | "user"; + +export interface MCPEnvVar { + name: string; + /** For scope="global": the value used in interpolation. + * For scope="user": optional placeholder/description shown to users. */ + value: string; + scope: MCPEnvVarScope; + description?: string | null; +} + +/** One required per-user env var slot returned by the user-env-vars endpoint. */ +export interface MCPUserEnvVarSpec { + name: string; + description?: string | null; + value?: string | null; + is_set: boolean; +} + +/** Per-server per-user env var status returned by the API. */ +export interface MCPUserEnvVarsStatus { + server_id: string; + server_name?: string | null; + alias?: string | null; + required: MCPUserEnvVarSpec[]; + missing_count: number; + setup_url?: string | null; } export interface MCPServerProps { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 756348f4937..62daae9e820 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -10013,6 +10013,86 @@ export const listMCPUserCredentials = async ( return response.json(); }; +// ============================================================ +// MCP per-user env vars (/v1/mcp/server/{id}/user-env-vars) +// ============================================================ + +import type { MCPUserEnvVarsStatus } from "./mcp_tools/types"; + +export const getMCPUserEnvVars = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/user-env-vars` + : `/v1/mcp/server/${serverId}/user-env-vars`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) return null; + return response.json(); +}; + +export const storeMCPUserEnvVars = async ( + accessToken: string, + serverId: string, + values: Record, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/user-env-vars` + : `/v1/mcp/server/${serverId}/user-env-vars`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ values }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + const detail = (err as { detail?: unknown })?.detail; + const message = + typeof detail === "string" + ? detail + : (detail as { error?: string })?.error || "Failed to save env vars"; + throw new Error(message); + } + return response.json(); +}; + +export const clearMCPUserEnvVars = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/user-env-vars` + : `/v1/mcp/server/${serverId}/user-env-vars`; + const response = await fetch(url, { + method: "DELETE", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + throw new Error("Failed to clear env vars"); + } + return response.json(); +}; + +export const listMCPUserEnvVarStatus = async ( + accessToken: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/user-env-vars/status` + : `/v1/mcp/user-env-vars/status`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) return []; + return response.json(); +}; + // ============================================================ // Memory management (/v1/memory) // ============================================================