feat(mcp): per-server env vars with global + per-user scopes

Adds admin-configurable environment variables on MCP servers, interpolated
into static_headers (and any other ${NAME} reference) at request time.

- Global vars: value supplied by admin, used for every user.
- Per-user vars: each user fills in their own value via the MCP Gateway
  dashboard. The proxy returns a friendly 412 with a setup URL when a
  user invokes a server before filling in their required values.

Backend
- New ``LiteLLM_MCPServerTable.env_vars`` JSON column and per-user
  ``LiteLLM_MCPUserEnvVars`` table (encrypted values).
- Interpolation helpers in ``_experimental.mcp_server.utils``
  (parse_admin_env_vars, interpolate_headers, MCPMissingUserEnvVarsError,
  build_env_var_setup_url).
- ``MCPServerManager._resolve_static_headers_with_env_vars`` merges
  globals + per-user values and raises when a required user var is
  unset. Wired into both the call_tool and list_tools paths.
- REST endpoints under ``/v1/mcp``:
  - GET/POST/DELETE ``/server/{id}/user-env-vars``
  - GET ``/user-env-vars/status`` (dashboard summary)
- MCP-protocol call_tool and REST /tools/call surface the missing-vars
  error as a user-facing message with the setup URL.

UI
- ``EnvVarsSection`` form component in the Add / Edit MCP Server modal:
  three columns (name, value, scope). Scope is a Global / Per-user
  select.
- User-facing ``UserEnvVarsModal`` for filling in per-user values.
- Dashboard server list highlights servers with missing per-user vars
  in red with a clickable missing-count chip that opens the modal.
- Deep-link ``?fill_env_vars=<server_id>`` auto-opens the modal so the
  link in the proxy's friendly error lands the user in the right place.
This commit is contained in:
Claude 2026-05-20 21:43:30 +00:00
parent f82ff7ee2a
commit 48a63341c8
No known key found for this signature in database
22 changed files with 1570 additions and 13 deletions

View file

@ -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");

View file

@ -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

View file

@ -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}}
)

View file

@ -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)

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"}

View file

@ -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 ``<Form>`` and read the
* ``env_vars`` field from the form values.
*/
const EnvVarsSection: React.FC = () => {
return (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
Environment Variables
<Tooltip
title={
<div>
<div>
Define variables that get interpolated into Static Headers via{" "}
<code>{"${NAME}"}</code> syntax.
</div>
<div className="mt-2">
<b>Global</b>: value is used for every user.
</div>
<div>
<b>Per-user</b>: 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.
</div>
</div>
}
>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
required={false}
>
<Text className="block text-xs text-gray-500 mb-2">
Reference these in Static Headers like{" "}
<code className="bg-gray-100 px-1 rounded">{"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@..."}</code>
</Text>
<Form.List name="env_vars">
{(fields, { add, remove }) => (
<div className="space-y-3">
{fields.map(({ key, name, ...restField }) => (
<Space key={key} className="flex w-full" align="baseline" size="middle">
<Form.Item
{...restField}
name={[name, "name"]}
className="flex-1"
rules={[
{ required: true, message: "Variable name is required" },
{
pattern: /^[A-Za-z_][A-Za-z0-9_]*$/,
message: "Use letters, digits, underscores; cannot start with a digit.",
},
]}
>
<Input size="large" allowClear className="rounded-lg" placeholder="DB_PROTOCOL" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
className="flex-1"
>
<Input
size="large"
allowClear
className="rounded-lg"
placeholder="postgres / placeholder for user-scoped"
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, "scope"]}
className="w-36"
initialValue="global"
rules={[{ required: true, message: "Scope required" }]}
>
<Select size="large" options={SCOPE_OPTIONS} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
className="text-gray-500 hover:text-red-500 cursor-pointer"
/>
</Space>
))}
<Button
type="dashed"
onClick={() => add({ name: "", value: "", scope: "global" })}
icon={<PlusOutlined />}
block
>
Add Environment Variable
</Button>
</div>
)}
</Form.List>
</Form.Item>
);
};
export default EnvVarsSection;

View file

@ -2,6 +2,7 @@ import React, { useEffect } from "react";
import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { MCPServer, AUTH_TYPE } from "./types";
import EnvVarsSection from "./EnvVarsSection";
const { Panel } = Collapse;
interface MCPPermissionManagementProps {
@ -42,6 +43,17 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
}));
form.setFieldValue("static_headers", staticHeaders);
}
if (Array.isArray(mcpServer.env_vars) && mcpServer.env_vars.length > 0) {
form.setFieldValue(
"env_vars",
mcpServer.env_vars.map((entry) => ({
name: entry.name,
value: entry.value ?? "",
scope: entry.scope ?? "global",
description: entry.description ?? "",
})),
);
}
if (typeof mcpServer.allow_all_keys === "boolean") {
form.setFieldValue("allow_all_keys", mcpServer.allow_all_keys);
}
@ -271,6 +283,8 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
)}
</Form.List>
</Form.Item>
<EnvVarsSection />
</div>
</Panel>
</Collapse>

View file

@ -0,0 +1,176 @@
import React, { useEffect, useState } from "react";
import { Modal, Form, Input, Button, Alert, Typography } from "antd";
import { MCPServer, MCPUserEnvVarsStatus } from "./types";
import {
getMCPUserEnvVars,
storeMCPUserEnvVars,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
const { Text, Title, Paragraph } = Typography;
interface UserEnvVarsModalProps {
server: MCPServer | null;
open: boolean;
accessToken: string | null;
onClose: () => void;
onSaved?: (status: MCPUserEnvVarsStatus) => void;
}
/**
* User-facing modal for filling in per-user MCP environment variables.
*
* Backed by GET / POST ``/v1/mcp/server/{id}/user-env-vars``. Each field
* the admin marked as ``scope=user`` shows up with the admin-supplied
* description as the placeholder.
*/
const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({
server,
open,
accessToken,
onClose,
onSaved,
}) => {
const [form] = Form.useForm();
const [status, setStatus] = useState<MCPUserEnvVarsStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!open || !server || !accessToken) {
return;
}
let cancelled = false;
setIsLoading(true);
(async () => {
try {
const fetched = await getMCPUserEnvVars(accessToken, server.server_id);
if (cancelled) return;
setStatus(fetched);
const initial: Record<string, string> = {};
for (const spec of fetched?.required ?? []) {
initial[spec.name] = spec.value ?? "";
}
form.setFieldsValue(initial);
} catch (err) {
if (!cancelled) {
NotificationsManager.fromBackend(
`Failed to load env vars: ${err instanceof Error ? err.message : String(err)}`,
);
}
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, server, accessToken, form]);
const handleSave = async (values: Record<string, string>) => {
if (!server || !accessToken) return;
setIsSaving(true);
try {
const trimmed: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
trimmed[k] = (v ?? "").trim();
}
const saved = await storeMCPUserEnvVars(accessToken, server.server_id, trimmed);
setStatus(saved);
NotificationsManager.success("Environment variables saved");
if (onSaved) onSaved(saved);
onClose();
} catch (err) {
NotificationsManager.fromBackend(
`Failed to save env vars: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setIsSaving(false);
}
};
const displayName = server?.alias || server?.server_name || server?.server_id || "MCP Server";
return (
<Modal
title={
<div>
<Title level={4} className="!mb-0">
Set your credentials for {displayName}
</Title>
<Text type="secondary" className="text-sm">
These values are stored only for you and are injected into the MCP server&apos;s
request headers when you use it.
</Text>
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={580}
destroyOnHidden
>
{status && status.required.length === 0 ? (
<Alert
type="info"
showIcon
message="This MCP server doesn't require any per-user values."
/>
) : (
<Form
form={form}
layout="vertical"
onFinish={handleSave}
disabled={isLoading || isSaving}
>
{status?.missing_count ? (
<Alert
type="warning"
showIcon
className="mb-4"
message={`${status.missing_count} required field${status.missing_count === 1 ? "" : "s"} missing`}
/>
) : null}
{(status?.required ?? []).map((spec) => (
<Form.Item
key={spec.name}
name={spec.name}
label={<span className="font-mono text-sm">{spec.name}</span>}
extra={spec.description || undefined}
rules={[{ required: true, message: `${spec.name} is required` }]}
>
<Input
size="large"
placeholder={spec.description || `Enter value for ${spec.name}`}
allowClear
/>
</Form.Item>
))}
{(status?.required ?? []).length === 0 && !isLoading && (
<Paragraph type="secondary">
No per-user variables required for this server.
</Paragraph>
)}
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isSaving}
disabled={(status?.required ?? []).length === 0}
>
Save
</Button>
</div>
</Form>
)}
</Modal>
);
};
export default UserEnvVarsModal;

View file

@ -46,6 +46,31 @@ const reduceStaticHeaders = (list: unknown): Record<string, string> => {
}, {});
};
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<string>();
const out: EnvVarEntry[] = [];
for (const entry of list) {
if (!entry || typeof entry !== "object") continue;
const name = String((entry as Record<string, unknown>).name ?? "").trim();
if (!name || seen.has(name)) continue;
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
const scope = (entry as Record<string, unknown>).scope === "user" ? "user" : "global";
out.push({
name,
value: String((entry as Record<string, unknown>).value ?? ""),
scope,
description: ((entry as Record<string, unknown>).description as string | undefined) || undefined,
});
seen.add(name);
}
return out;
};
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
userRole,
accessToken,
@ -280,6 +305,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
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);

View file

@ -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<string>,
envVarStatusByServer?: Record<string, MCPUserEnvVarsStatus>,
onSetEnvVars?: (server: MCPServer) => void,
): ColumnDef<MCPServer>[] => [
{
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 (
<div className="flex items-center gap-2">
<div
className={`flex items-center gap-2 ${
showWarning ? "border border-red-300 bg-red-50 px-2 py-1 rounded-md" : ""
}`}
>
{logoUrl ? (
<img
src={logoUrl}
@ -123,7 +132,23 @@ export const mcpServerColumns = (
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null}
<span>{name}</span>
<span className={showWarning ? "text-red-700 font-medium" : ""}>{name}</span>
{showWarning && (
<Tooltip
title={`Set ${missing} required user field${missing === 1 ? "" : "s"} before using this MCP server`}
>
<button
onClick={(e) => {
e.stopPropagation();
if (onSetEnvVars) onSetEnvVars(row.original);
}}
className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-red-100 text-red-700 border border-red-300 hover:bg-red-200 cursor-pointer"
>
<ExclamationCircleFilled />
{missing} missing field{missing === 1 ? "" : "s"}
</button>
</Tooltip>
)}
</div>
);
},

View file

@ -164,6 +164,18 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}));
}, [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<MCPServerEditProps> = ({
...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<MCPServerEditProps> = ({
// 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<MCPServerEditProps> = ({
}, {})
: ({} as Record<string, string>);
const envVars = Array.isArray(envVarsList)
? envVarsList.reduce(
(acc: Array<{ name: string; value: string; scope: "global" | "user"; description?: string }>, entry: Record<string, unknown>) => {
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<string, any>, [key, value]) => {
@ -551,6 +588,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
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

View file

@ -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<MCPServerProps> = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const [envVarsModalServer, setEnvVarsModalServer] = useState<MCPServer | null>(null);
const [envVarStatusByServer, setEnvVarStatusByServer] = useState<Record<string, MCPUserEnvVarsStatus>>({});
const isInternalUser = userRole === "Internal User";
const refetchEnvVarStatus = useCallback(async () => {
if (!accessToken) {
setEnvVarStatusByServer({});
return;
}
try {
const statuses = await listMCPUserEnvVarStatus(accessToken);
const map: Record<string, MCPUserEnvVarsStatus> = {};
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=<server_id>. 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<MCPServerProps> = ({ 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<MCPServerProps> = ({ accessToken, userRole, userID })
accessToken={accessToken || ""}
/>
)}
<UserEnvVarsModal
server={envVarsModalServer}
open={!!envVarsModalServer}
accessToken={accessToken}
onClose={() => setEnvVarsModalServer(null)}
onSaved={() => {
refetchEnvVarStatus();
}}
/>
</div>
);
};

View file

@ -228,6 +228,42 @@ export interface MCPServer {
/** Per-user OAuth token storage settings (interactive OAuth only) */
token_validation?: Record<string, any> | 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 {

View file

@ -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<MCPUserEnvVarsStatus | null> => {
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<string, string>,
): Promise<MCPUserEnvVarsStatus> => {
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<MCPUserEnvVarsStatus> => {
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<MCPUserEnvVarsStatus[]> => {
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)
// ============================================================