mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
f82ff7ee2a
commit
48a63341c8
22 changed files with 1570 additions and 13 deletions
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
118
ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx
Normal file
118
ui/litellm-dashboard/src/components/mcp_tools/EnvVarsSection.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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;
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue