diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260519120000_add_mcp_user_fields/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260519120000_add_mcp_user_fields/migration.sql new file mode 100644 index 00000000000..2c3ad566304 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260519120000_add_mcp_user_fields/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "user_fields" JSONB NOT NULL DEFAULT '[]'; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 78143fe0411..b16714b0f04 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -328,6 +328,11 @@ model LiteLLM_MCPServerTable { is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? + // Admin-defined per-user fields (e.g. bearer tokens, workspace IDs) that + // each end-user must supply via the dashboard before they can use the + // server. Each entry is a JSON object describing how the field is rendered + // in the dashboard and injected at request time. + user_fields Json @default("[]") source_url String? // BYOM submission lifecycle approval_status String? @default("active") diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index a6f0d145e9b..94314357d2a 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -85,6 +85,13 @@ def _prepare_mcp_server_data( # but be explicit to ensure a False value is always written to the DB). data_dict["is_byok"] = getattr(data, "is_byok", False) + # user_fields is a list of MCPUserField models. exclude_none=True will + # already have dict-ified them, but the JSONB column expects a JSON + # string when written through Prisma. + user_fields = data_dict.get("user_fields") + if user_fields is not None: + data_dict["user_fields"] = safe_dumps(user_fields) + return data_dict @@ -635,6 +642,104 @@ async def delete_user_credential( ) +# ── User-fields helpers ─────────────────────────────────────────────────────── +# +# User-fields share the credential_b64 column with BYOK and OAuth2. We tag +# the JSON payload with ``"type": "user_fields"`` so the read path can +# distinguish formats without an extra column. + + +def _decode_user_fields_payload(stored: str) -> Optional[Dict[str, str]]: + """Return the field-values dict if ``stored`` holds a user-fields payload.""" + decoded = _decode_user_credential(stored) + if decoded is None: + return None + try: + parsed = json.loads(decoded) + except (ValueError, TypeError): + return None + if not isinstance(parsed, dict) or parsed.get("type") != "user_fields": + return None + values = parsed.get("values") + if not isinstance(values, dict): + return {} + return {str(k): str(v) for k, v in values.items()} + + +async def store_user_field_values( + prisma_client: PrismaClient, + user_id: str, + server_id: str, + values: Dict[str, str], +) -> None: + """Persist the calling user's values for an MCP server's user fields. + + The full set of values is encoded into a single encrypted JSON blob in + ``LiteLLM_MCPUserCredentials.credential_b64``. A ``"type"`` discriminator + lets ``get_user_field_values`` tell user-fields rows apart from BYOK + strings and OAuth2 payloads sharing the same column. + """ + + payload = json.dumps({"type": "user_fields", "values": values}) + encoded = encrypt_value_helper(payload) + await prisma_client.db.litellm_mcpusercredentials.upsert( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}, + data={ + "create": { + "user_id": user_id, + "server_id": server_id, + "credential_b64": encoded, + }, + "update": {"credential_b64": encoded}, + }, + ) + + +async def get_user_field_values( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> Optional[Dict[str, str]]: + """Return the user's stored field values, or ``None`` if not stored. + + Returns ``None`` both when no row exists and when the row holds a + different credential type (BYOK / OAuth2) — callers should treat both + as "no user-fields configured for this user yet". + """ + + row = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + if row is None: + return None + return _decode_user_fields_payload(row.credential_b64) + + +async def delete_user_field_values( + prisma_client: PrismaClient, + user_id: str, + server_id: str, +) -> bool: + """Delete the user's stored field values. + + Only removes the row when it actually holds a user-fields payload, so a + co-located BYOK / OAuth2 credential for the same (user, server) pair is + not accidentally wiped. Returns True if a row was deleted. + """ + + row = await prisma_client.db.litellm_mcpusercredentials.find_unique( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + if row is None: + return False + if _decode_user_fields_payload(row.credential_b64) is None: + return False + await prisma_client.db.litellm_mcpusercredentials.delete( + where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}} + ) + return True + + # ── OAuth2 user-credential helpers ──────────────────────────────────────────── diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index d1b49039e8e..0f0bf128501 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -190,6 +190,25 @@ def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]: return data +def _deserialize_user_fields(data: Any) -> List[Dict[str, Any]]: + """Decode the JSON-encoded ``user_fields`` blob from the MCP server row. + + Always returns a list — falsy or malformed values become ``[]`` so callers + can iterate without a None check. + """ + if not data: + return [] + if isinstance(data, list): + return data + if isinstance(data, str): + try: + decoded = json.loads(data) + except (json.JSONDecodeError, TypeError): + return [] + return decoded if isinstance(decoded, list) else [] + return [] + + class MCPServerManager: _STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$") @@ -812,6 +831,9 @@ class MCPServerManager: is_byok=bool(getattr(mcp_server, "is_byok", False)), byok_description=getattr(mcp_server, "byok_description", None) or [], byok_api_key_help_url=getattr(mcp_server, "byok_api_key_help_url", None), + user_fields=_deserialize_user_fields( + getattr(mcp_server, "user_fields", None) + ), # AWS SigV4 fields aws_access_key_id=aws_creds.get("aws_access_key_id"), aws_secret_access_key=aws_creds.get("aws_secret_access_key"), @@ -1276,11 +1298,20 @@ class MCPServerManager: self, server: MCPServer, raw_headers: Optional[Dict[str, str]] = None, + user_field_env: Optional[Dict[str, str]] = None, ) -> Optional[Dict[str, str]]: - """Resolve stdio env values, supporting header-driven placeholders.""" + """Resolve stdio env values, supporting header-driven placeholders. - if server.transport != MCPTransport.stdio or not server.env: - return None + ``user_field_env`` carries values resolved from admin-declared + user_fields with an ``env_var_name`` set. They take precedence + over the static server.env entries so a user's stored value + always overrides any placeholder default. + """ + + if server.transport != MCPTransport.stdio: + return user_field_env or None + if not server.env: + return user_field_env or None resolved_env: Dict[str, str] = {} normalized_headers = {k.lower(): v for k, v in (raw_headers or {}).items()} @@ -1297,8 +1328,99 @@ class MCPServerManager: else: resolved_env[env_key] = env_value + if user_field_env: + resolved_env.update(user_field_env) + # Preserve the legacy contract: when an env dict is present on the + # server we always return a dict (even if empty after template + # resolution). Only return None when no env is configured at all. return resolved_env + async def _resolve_user_field_values( + self, + mcp_server: MCPServer, + user_api_key_auth: Optional["UserAPIKeyAuth"], + ) -> Dict[str, str]: + """Read the calling user's stored user-field values for ``mcp_server``. + + Returns ``{}`` when the user has no row, no user_id, or there's no + DB. The caller decides what to do with missing required fields — + ``server.execute_mcp_tool`` raises a 401 with a config_url before + we even get here, so by the time injection happens we already + know the values are present. + """ + from litellm.proxy._experimental.mcp_server.user_fields import ( + coerce_user_fields, + ) + + if not coerce_user_fields(mcp_server): + return {} + if user_api_key_auth is None or not getattr(user_api_key_auth, "user_id", None): + return {} + + # Reuse the cache and lookup function from server.py so we don't + # pay a second DB round-trip after the enforcement check populated + # the same cache key. + try: + from litellm.proxy._experimental.mcp_server.server import ( # noqa: PLC0415 + _USER_FIELDS_CACHE_TTL, + _user_fields_cache, + _write_user_fields_cache, + ) + except ImportError: + _user_fields_cache = {} # type: ignore[assignment] + _USER_FIELDS_CACHE_TTL = 60 + _write_user_fields_cache = None # type: ignore[assignment] + + user_id = user_api_key_auth.user_id or "" + cache_key = (user_id, mcp_server.server_id) + cached = _user_fields_cache.get(cache_key) if _user_fields_cache else None + if cached is not None: + values, ts = cached + if time.monotonic() - ts < _USER_FIELDS_CACHE_TTL: + return values or {} + + from litellm.proxy._experimental.mcp_server.db import get_user_field_values + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return {} + values = await get_user_field_values( + prisma_client=prisma_client, + user_id=user_id, + server_id=mcp_server.server_id, + ) + if _write_user_fields_cache is not None: + _write_user_fields_cache(user_id, mcp_server.server_id, values) + return values or {} + + async def _resolve_user_field_headers( + self, + mcp_server: MCPServer, + user_api_key_auth: Optional["UserAPIKeyAuth"], + ) -> Dict[str, str]: + from litellm.proxy._experimental.mcp_server.user_fields import ( + resolve_user_field_headers, + ) + + stored = await self._resolve_user_field_values(mcp_server, user_api_key_auth) + if not stored: + return {} + return resolve_user_field_headers(mcp_server, stored) + + async def _resolve_user_field_env( + self, + mcp_server: MCPServer, + user_api_key_auth: Optional["UserAPIKeyAuth"], + ) -> Dict[str, str]: + from litellm.proxy._experimental.mcp_server.user_fields import ( + resolve_user_field_env, + ) + + stored = await self._resolve_user_field_values(mcp_server, user_api_key_auth) + if not stored: + return {} + return resolve_user_field_env(mcp_server, stored) + async def _create_mcp_client( self, server: MCPServer, @@ -2633,6 +2755,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. @@ -2717,6 +2840,16 @@ class MCPServerManager: extra_headers = {} extra_headers.update(mcp_server.static_headers) + # User-fields: inject each declared user field's stored value either + # as a header (http/sse) or env var (stdio path; handled below). + user_field_headers = await self._resolve_user_field_headers( + mcp_server, user_api_key_auth + ) + if user_field_headers: + if extra_headers is None: + extra_headers = {} + extra_headers.update(user_field_headers) + if hook_extra_headers: if extra_headers is None: extra_headers = {} @@ -2746,7 +2879,12 @@ class MCPServerManager: if extra_headers is not None and len(extra_headers) == 0: extra_headers = None - stdio_env = self._build_stdio_env(mcp_server, raw_headers) + user_field_env = await self._resolve_user_field_env( + mcp_server, user_api_key_auth + ) + stdio_env = self._build_stdio_env( + mcp_server, raw_headers, user_field_env=user_field_env or None + ) client = await self._create_mcp_client( server=mcp_server, @@ -2923,6 +3061,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, ) # For OpenAPI tools, await outside the client context diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 0a74a92f9ce..e92c68be954 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -44,6 +44,11 @@ from litellm.proxy._experimental.mcp_server.mcp_context import ( _mcp_gateway_initialize_instructions, ) from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug +from litellm.proxy._experimental.mcp_server.user_fields import ( + build_user_fields_missing_error, + compute_missing_user_fields, + server_has_user_fields, +) from litellm.proxy._experimental.mcp_server.utils import ( LITELLM_MCP_SERVER_DESCRIPTION, LITELLM_MCP_SERVER_NAME, @@ -75,6 +80,14 @@ _byok_cred_cache: Dict[Tuple[str, str], Tuple[Optional[str], float]] = {} _BYOK_CRED_CACHE_TTL = 60 # seconds _BYOK_CRED_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth +# Short-lived in-memory cache for user-field values, mirroring the BYOK cache. +# Value is (values_dict_or_None, monotonic_timestamp). None means "we checked +# and the user has no row yet" — still cached to avoid hammering the DB on +# tool calls that will fail the required-fields check. +_user_fields_cache: Dict[Tuple[str, str], Tuple[Optional[Dict[str, str]], float]] = {} +_USER_FIELDS_CACHE_TTL = 60 # seconds, matches BYOK +_USER_FIELDS_CACHE_MAX_SIZE = 4096 + def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None: """Remove a (user_id, server_id) entry from the BYOK credential cache. @@ -94,6 +107,20 @@ def _write_byok_cred_cache( _byok_cred_cache[(user_id, server_id)] = (credential, time.monotonic()) +def _invalidate_user_fields_cache(user_id: str, server_id: str) -> None: + """Drop a cached user-fields entry after a user updates their values.""" + _user_fields_cache.pop((user_id, server_id), None) + + +def _write_user_fields_cache( + user_id: str, server_id: str, values: Optional[Dict[str, str]] +) -> None: + """Cache stored user-field values, capping total entries.""" + if len(_user_fields_cache) >= _USER_FIELDS_CACHE_MAX_SIZE: + _user_fields_cache.clear() + _user_fields_cache[(user_id, server_id)] = (values, time.monotonic()) + + # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8 # We're making this conditional import to avoid breaking users who use python 3.8. @@ -2039,6 +2066,81 @@ if MCP_AVAILABLE: }, ) + async def _get_user_field_values_cached( + mcp_server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> Tuple[Optional[str], Optional[Dict[str, str]]]: + """Return (user_id, stored_values) for the calling user. + + ``stored_values`` is None when either no user_id is available or + the user has not yet saved any values. Reads through a 60s + in-memory cache so back-to-back tool calls don't hammer the DB. + """ + user_id = (user_api_key_auth.user_id if user_api_key_auth else None) or "" + if not user_id: + return None, None + + cache_key = (user_id, mcp_server.server_id) + cached = _user_fields_cache.get(cache_key) + if cached is not None: + values, ts = cached + if time.monotonic() - ts < _USER_FIELDS_CACHE_TTL: + return user_id, values + + from litellm.proxy._experimental.mcp_server.db import get_user_field_values + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return user_id, None + + values = await get_user_field_values( + prisma_client=prisma_client, + user_id=user_id, + server_id=mcp_server.server_id, + ) + _write_user_fields_cache(user_id, mcp_server.server_id, values) + return user_id, values + + async def _enforce_user_fields( + mcp_server: MCPServer, + user_api_key_auth: Optional[UserAPIKeyAuth], + ) -> None: + """Raise a friendly 401 when required user-fields are missing. + + Looks at the server's declared ``user_fields`` and the calling + user's saved values. If any ``required`` field is unset, raises + with ``error=user_fields_missing`` and a ``config_url`` pointing + at the dashboard. The error body is what Claude Code / other MCP + clients surface to the end-user — we put the URL in both the + ``message`` and as a structured field so simple clients still + show a clickable link. + """ + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + _resolve_proxy_base_url_env, + ) + + user_id = (user_api_key_auth.user_id if user_api_key_auth else None) or "" + if not user_id: + # No identity means we can't look up stored values; treat as + # missing and let the user log in via the dashboard. + detail = build_user_fields_missing_error( + mcp_server, + compute_missing_user_fields(mcp_server, None), + _resolve_proxy_base_url_env(), + ) + raise HTTPException(status_code=401, detail=detail) + + _, stored_values = await _get_user_field_values_cached( + mcp_server, user_api_key_auth + ) + missing = compute_missing_user_fields(mcp_server, stored_values) + if not missing: + return + detail = build_user_fields_missing_error( + mcp_server, missing, _resolve_proxy_base_url_env() + ) + raise HTTPException(status_code=401, detail=detail) + async def execute_mcp_tool( # noqa: PLR0915 name: str, arguments: Dict[str, Any], @@ -2155,6 +2257,13 @@ if MCP_AVAILABLE: # External auth header supplied; still enforce user-identity check. await _check_byok_credential(mcp_server, user_api_key_auth) + # User fields: required admin-declared per-user values must be + # present before we dispatch. The helper raises a friendly 401 + # with a config_url pointing at the dashboard when any required + # field is missing. + if server_has_user_fields(mcp_server): + await _enforce_user_fields(mcp_server, user_api_key_auth) + # Check if tool exists in local registry first (for OpenAPI-based tools) # These tools are registered with their prefixed names ######################################################### diff --git a/litellm/proxy/_experimental/mcp_server/user_fields.py b/litellm/proxy/_experimental/mcp_server/user_fields.py new file mode 100644 index 00000000000..5684f004e19 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/user_fields.py @@ -0,0 +1,194 @@ +"""Helpers for resolving admin-declared MCP user fields at request time. + +User fields are per-user values (e.g. bearer tokens, workspace IDs) the +admin declares when adding an MCP server. End-users fill them in via the +dashboard; this module retrieves the stored values and injects them into +outbound MCP requests as HTTP headers (http/sse) or env vars (stdio). + +The retrieval result is cached in process so a tool call does not pay a +DB round-trip for every step. See ``_user_fields_cache`` in +``server.py`` for the cache itself; this module only contains the pure +resolution / injection logic. +""" + +from __future__ import annotations + +import time +from typing import Any, Dict, List, Optional, Tuple + +from litellm._logging import verbose_logger +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +def coerce_user_fields(server: MCPServer) -> List[Dict[str, Any]]: + """Return the server's declared user fields as a list of plain dicts. + + The column is stored as JSONB but Prisma sometimes hands it back as a + string; this normalises both shapes and silently drops malformed + entries (the admin form filters these out, but DB writes from + external tooling might not). + """ + raw = getattr(server, "user_fields", None) + if not raw: + return [] + if isinstance(raw, list): + return [e for e in raw if isinstance(e, dict)] + if isinstance(raw, str): + import json + + try: + parsed = json.loads(raw) + except (ValueError, TypeError): + return [] + if isinstance(parsed, list): + return [e for e in parsed if isinstance(e, dict)] + return [] + + +def server_has_user_fields(server: MCPServer) -> bool: + """True iff the server declares any user fields at all.""" + return bool(coerce_user_fields(server)) + + +def compute_missing_user_fields( + server: MCPServer, stored_values: Optional[Dict[str, str]] +) -> List[Dict[str, Any]]: + """Return the declared field definitions the user has yet to fill in. + + Only ``required`` fields count as missing — optional fields without a + stored value are still considered satisfied so the user can save + partial configurations. + """ + stored = stored_values or {} + missing: List[Dict[str, Any]] = [] + for entry in coerce_user_fields(server): + field_key = entry.get("field_key") + if not isinstance(field_key, str) or not field_key: + continue + if not entry.get("required", True): + continue + if not stored.get(field_key): + missing.append(entry) + return missing + + +def build_user_fields_missing_error( + server: MCPServer, + missing: List[Dict[str, Any]], + base_dashboard_url: Optional[str], +) -> Dict[str, Any]: + """Construct the FastAPI ``detail`` payload for a missing-fields 401. + + Includes a ``config_url`` pointing the end-user (or their agent) at + the dashboard page where they can fill in the missing fields. The + URL is built defensively — if no base URL is known we emit a relative + path so curl-style clients still surface something useful. + """ + server_id = server.server_id + display_name = server.alias or server.server_name or server.name or server_id + + config_path = f"/ui?page=mcp-servers&server_id={server_id}" + if base_dashboard_url: + # Strip trailing slash to avoid double-slash in the joined URL. + base = base_dashboard_url.rstrip("/") + config_url = f"{base}{config_path}" + else: + config_url = config_path + + missing_summary = [ + { + "field_key": f.get("field_key"), + "display_name": f.get("display_name") or f.get("field_key"), + "description": f.get("description"), + } + for f in missing + ] + field_names = ", ".join( + str(m["display_name"]) for m in missing_summary if m.get("display_name") + ) + plural = "fields" if len(missing) != 1 else "field" + message = ( + f"This MCP server ({display_name}) needs your {plural} before it can run: " + f"{field_names}. Open {config_url} to fill them in, then retry the tool call." + ) + return { + "error": "user_fields_missing", + "server_id": server_id, + "server_name": server.server_name or server.name, + "missing_fields": missing_summary, + "config_url": config_url, + "message": message, + } + + +def resolve_user_field_headers( + server: MCPServer, stored_values: Dict[str, str] +) -> Dict[str, str]: + """Build the HTTP header dict to inject for an http/sse MCP server. + + Each declared field with a ``header_name`` contributes one header. + ``header_value_template`` (default ``"{value}"``) lets admins inject + well-known prefixes like ``"Bearer {value}"`` without making the user + re-type them. + """ + headers: Dict[str, str] = {} + for entry in coerce_user_fields(server): + field_key = entry.get("field_key") + header_name = entry.get("header_name") + if not field_key or not header_name: + continue + value = stored_values.get(field_key) + if not value: + continue + template = entry.get("header_value_template") or "{value}" + try: + headers[header_name] = template.format(value=value) + except (KeyError, IndexError, ValueError): + # Malformed template — fall back to raw value rather than crashing. + verbose_logger.warning( + "MCP user_fields: invalid header_value_template %r for field %r " + "on server %s; falling back to raw value.", + template, + field_key, + server.server_id, + ) + headers[header_name] = value + return headers + + +def resolve_user_field_env( + server: MCPServer, stored_values: Dict[str, str] +) -> Dict[str, str]: + """Build the env-var dict to inject for a stdio MCP server.""" + env: Dict[str, str] = {} + for entry in coerce_user_fields(server): + field_key = entry.get("field_key") + env_var_name = entry.get("env_var_name") + if not field_key or not env_var_name: + continue + value = stored_values.get(field_key) + if not value: + continue + env[env_var_name] = value + return env + + +def lookup_cached_user_fields( + cache: Dict[Tuple[str, str], Tuple[Optional[Dict[str, str]], float]], + user_id: str, + server_id: str, + ttl_seconds: int, +) -> Tuple[bool, Optional[Dict[str, str]]]: + """Return (cache_hit, values) for the (user, server) cache pair. + + Pulled out so server.py can pass its own cache dict in; keeping the + cache as module-level state in server.py preserves the existing + invalidation hooks called from the management endpoints. + """ + cached = cache.get((user_id, server_id)) + if cached is None: + return False, None + values, ts = cached + if time.monotonic() - ts >= ttl_seconds: + return False, None + return True, values diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c989f5dff13..c89e6ef0f35 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1245,6 +1245,44 @@ class MCPApprovalStatus(str, enum.Enum): rejected = "rejected" +class MCPUserField(LiteLLMPydanticObjectBase): + """Describes a single admin-declared per-user field on an MCP server. + + The admin declares a list of these when creating/editing the server. + Each end-user then supplies their own value(s) via the dashboard, which + are injected at request time as either HTTP headers (for http/sse + transports) or environment variables (for stdio transport). + """ + + field_key: str # storage key, must be unique within the server + display_name: Optional[str] = None # human-readable label + description: Optional[str] = None # help text for the dashboard + required: bool = True + # HTTP injection: when non-empty, the value is added to outbound headers. + # header_value_template defaults to "{value}"; use e.g. "Bearer {value}" + # to prefix the credential automatically. + header_name: Optional[str] = None + header_value_template: Optional[str] = None + # Stdio injection: when non-empty, the value is forwarded as an env var + # to the spawned MCP process. + env_var_name: Optional[str] = None + + +class MCPUserFieldValuesRequest(LiteLLMPydanticObjectBase): + """Body for storing the calling user's values for an MCP server's user fields.""" + + values: Dict[str, str] + + +class MCPUserFieldsStatus(LiteLLMPydanticObjectBase): + """Per-server view of the calling user's user-field completeness.""" + + server_id: str + user_fields: List[MCPUserField] = Field(default_factory=list) + stored_field_keys: List[str] = Field(default_factory=list) + missing_field_keys: List[str] = Field(default_factory=list) + + # MCP Proxy Request Types class NewMCPServerRequest(LiteLLMPydanticObjectBase): server_id: Optional[str] = None @@ -1278,6 +1316,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None + user_fields: List[MCPUserField] = Field(default_factory=list) source_url: Optional[str] = None # BYOM submission fields — set by the endpoint, not by the caller. # Any caller-provided values are silently overridden before persistence. @@ -1361,6 +1400,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None + user_fields: List[MCPUserField] = Field(default_factory=list) source_url: Optional[str] = None @model_validator(mode="before") @@ -1391,6 +1431,27 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): """Represents a LiteLLM_MCPServerTable record""" + @model_validator(mode="before") + @classmethod + def _coerce_user_fields(cls, values): + """Accept ``user_fields`` as either a list or a JSON string. + + Prisma can hand JSONB columns back as a serialized string when the + row was written via ``safe_dumps`` (see ``_prepare_mcp_server_data``). + Coercing here keeps the rest of the pipeline able to assume a list. + """ + if isinstance(values, dict): + raw = values.get("user_fields") + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError): + parsed = [] + if not isinstance(parsed, list): + parsed = [] + values["user_fields"] = parsed + return values + server_id: str server_name: Optional[str] = None alias: Optional[str] = None @@ -1434,6 +1495,10 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None has_user_credential: Optional[bool] = None + user_fields: List[MCPUserField] = Field(default_factory=list) + # Computed per-request for the calling user. Populated only on the + # list/get endpoints that resolve credentials for the caller. + missing_user_field_keys: Optional[List[str]] = None source_url: Optional[str] = None # BYOM submission fields approval_status: Optional[str] = Field( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index e9d9c243e7c..0c392791d88 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -112,13 +112,17 @@ if MCP_AVAILABLE: delete_mcp_server, delete_user_credential, get_all_mcp_servers_for_user, + delete_user_field_values, + get_all_mcp_servers, get_mcp_server, get_mcp_servers, get_mcp_submissions, + get_user_field_values, get_user_oauth_credential, list_user_oauth_credentials, reject_mcp_server, store_user_credential, + store_user_field_values, store_user_oauth_credential, update_mcp_server, ) @@ -146,6 +150,9 @@ if MCP_AVAILABLE: MCPUserCredentialListItem, MCPUserCredentialRequest, MCPUserCredentialResponse, + MCPUserField, + MCPUserFieldsStatus, + MCPUserFieldValuesRequest, NewMCPServerRequest, RejectMCPServerRequest, SpecialMCPServerName, @@ -473,6 +480,47 @@ if MCP_AVAILABLE: ) -> List[LiteLLM_MCPServerTable]: return [_redact_mcp_credentials(server) for server in mcp_servers] + def _coerce_user_fields_list(raw: Any) -> List[Dict[str, Any]]: + """Normalize a server.user_fields value (JSON string or list) to a list of dicts. + + Used by the BYOK-list-annotation block and any other site that needs + to inspect declared fields without instantiating MCPUserField models. + """ + if not raw: + return [] + if isinstance(raw, list): + return [e for e in raw if isinstance(e, dict)] + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except (ValueError, TypeError): + return [] + if isinstance(parsed, list): + return [e for e in parsed if isinstance(e, dict)] + return [] + + def _has_required_user_fields(raw: Any) -> bool: + """True iff the server declares at least one required user field.""" + for entry in _coerce_user_fields_list(raw): + if entry.get("required", True): + return True + return False + + def _compute_missing_user_field_keys( + raw: Any, stored_values: Dict[str, str] + ) -> List[str]: + """Field keys the user must still supply (required + currently empty).""" + missing: List[str] = [] + for entry in _coerce_user_fields_list(raw): + field_key = entry.get("field_key") + if not isinstance(field_key, str) or not field_key: + continue + if not entry.get("required", True): + continue + if not stored_values.get(field_key): + missing.append(field_key) + return missing + def _is_restricted_virtual_key_request(user_api_key_dict: UserAPIKeyAuth) -> bool: """Best-effort detection for route-restricted virtual keys. @@ -944,26 +992,57 @@ if MCP_AVAILABLE: server.mcp_info = {} server.mcp_info["is_public"] = True - # Annotate has_user_credential for BYOK servers (single batched query) + # Annotate has_user_credential (BYOK) and missing_user_field_keys + # (user_fields) for the calling user. Both flags share the same + # storage table, so we batch a single query covering both feature + # sets. + from litellm.proxy._experimental.mcp_server.db import ( + _decode_user_credential, + _decode_user_fields_payload, + ) from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client user_id = user_api_key_dict.user_id or "" if user_id and _byok_prisma_client is not None: - byok_server_ids = [ + relevant_server_ids = [ s.server_id for s in redacted_mcp_servers if getattr(s, "is_byok", False) + or _has_required_user_fields(getattr(s, "user_fields", None)) ] - if byok_server_ids: + if relevant_server_ids: cred_rows = ( await _byok_prisma_client.db.litellm_mcpusercredentials.find_many( - where={"user_id": user_id, "server_id": {"in": byok_server_ids}} + where={ + "user_id": user_id, + "server_id": {"in": relevant_server_ids}, + } ) ) - cred_set = {r.server_id for r in cred_rows} + # Build two indexes: one for BYOK presence, one mapping + # server_id → stored user-field values. + byok_set: set = set() + user_fields_by_server: Dict[str, Dict[str, str]] = {} + for row in cred_rows: + payload = _decode_user_fields_payload(row.credential_b64) + if payload is not None: + user_fields_by_server[row.server_id] = payload + else: + # Anything that isn't a user-fields payload counts + # as a BYOK credential for has_user_credential. + decoded = _decode_user_credential(row.credential_b64) + if decoded: + byok_set.add(row.server_id) for server in redacted_mcp_servers: if getattr(server, "is_byok", False): - server.has_user_credential = server.server_id in cred_set + server.has_user_credential = server.server_id in byok_set + if _has_required_user_fields(getattr(server, "user_fields", None)): + stored = user_fields_by_server.get(server.server_id, {}) + server.missing_user_field_keys = ( + _compute_missing_user_field_keys( + getattr(server, "user_fields", None), stored + ) + ) # Virtual keys only get a sanitized discovery view. if is_restricted_virtual_key: @@ -2055,6 +2134,265 @@ if MCP_AVAILABLE: connected_at=cred.get("connected_at"), ) + # ── User-fields endpoints ───────────────────────────────────────────────── + # + # Admin-declared user fields (e.g. per-user bearer tokens) are stored on + # the MCP server row as a list of definitions. Each end-user then supplies + # their own values via these endpoints; values are injected at request + # time as either HTTP headers (http/sse) or env vars (stdio). + + def _build_user_fields_status( + server: "LiteLLM_MCPServerTable", + stored_values: Optional[Dict[str, str]], + ) -> MCPUserFieldsStatus: + """Compose the dashboard-facing per-server status object. + + ``stored_values`` is the dict of values the calling user has saved + (None when no row exists yet). ``missing_field_keys`` enumerates only + the ``required`` fields the user has yet to fill in. + """ + raw_fields = getattr(server, "user_fields", None) or [] + if isinstance(raw_fields, str): + try: + raw_fields = json.loads(raw_fields) + except (ValueError, TypeError): + raw_fields = [] + user_fields: List[MCPUserField] = [] + for entry in raw_fields: + if not isinstance(entry, dict): + continue + try: + user_fields.append(MCPUserField(**entry)) + except Exception: # noqa: BLE001 — drop malformed entries silently + continue + stored: Dict[str, str] = stored_values or {} + stored_keys = [k for k, v in stored.items() if v] + missing_keys = [ + f.field_key + for f in user_fields + if f.required and not stored.get(f.field_key) + ] + return MCPUserFieldsStatus( + server_id=server.server_id, + user_fields=user_fields, + stored_field_keys=stored_keys, + missing_field_keys=missing_keys, + ) + + @router.get( + "/server/{server_id}/user-field-values", + description=( + "Return the calling user's saved values (presence only — values are " + "never echoed back) for an MCP server's admin-declared user fields." + ), + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserFieldsStatus, + ) + @management_endpoint_wrapper + async def get_mcp_user_field_values( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + 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_field_values(prisma_client, user_id, server_id) + return _build_user_fields_status(server, stored) + + @router.post( + "/server/{server_id}/user-field-values", + description=( + "Store the calling user's values for an MCP server's admin-declared " + "user fields. Values are encrypted at rest." + ), + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserFieldsStatus, + ) + @management_endpoint_wrapper + async def store_mcp_user_field_values( + server_id: str, + payload: MCPUserFieldValuesRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + 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"}, + ) + + # Pre-compute allowed keys from the server's declared fields, then + # filter the incoming payload to only those keys. This prevents + # callers from polluting the storage blob with arbitrary keys. + raw_fields = getattr(server, "user_fields", None) or [] + if isinstance(raw_fields, str): + try: + raw_fields = json.loads(raw_fields) + except (ValueError, TypeError): + raw_fields = [] + declared_keys = { + entry.get("field_key") + for entry in raw_fields + if isinstance(entry, dict) and entry.get("field_key") + } + if not declared_keys: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "This MCP server has no user fields declared", + "server_id": server_id, + }, + ) + + # Merge with anything already stored — partial saves should preserve + # previously-supplied values rather than wipe them. + existing = await get_user_field_values(prisma_client, user_id, server_id) or {} + merged: Dict[str, str] = {**existing} + for key, value in payload.values.items(): + if key not in declared_keys: + continue + if value == "": + merged.pop(key, None) + else: + merged[key] = value + + await store_user_field_values(prisma_client, user_id, server_id, merged) + + # Invalidate the BYOK credential cache for this (user, server) pair + # so the next tool call re-reads the row. We piggyback on the + # existing cache because user-fields, BYOK, and OAuth2 all share + # the same DB row. + from litellm.proxy._experimental.mcp_server.server import ( + _invalidate_byok_cred_cache, + _invalidate_user_fields_cache, + ) + + _invalidate_byok_cred_cache(user_id, server_id) + _invalidate_user_fields_cache(user_id, server_id) + return _build_user_fields_status(server, merged) + + @router.delete( + "/server/{server_id}/user-field-values", + description="Clear all of the calling user's saved user-field values for an MCP server.", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserFieldsStatus, + ) + @management_endpoint_wrapper + async def delete_mcp_user_field_values( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + 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_field_values(prisma_client, user_id, server_id) + except RecordNotFoundError: + pass + from litellm.proxy._experimental.mcp_server.server import ( + _invalidate_byok_cred_cache, + _invalidate_user_fields_cache, + ) + + _invalidate_byok_cred_cache(user_id, server_id) + _invalidate_user_fields_cache(user_id, server_id) + return _build_user_fields_status(server, None) + + @router.get( + "/user-field-values", + description=( + "Aggregate user-fields status across every MCP server the calling user " + "can see. Used by the dashboard to render 'needs setup' badges." + ), + dependencies=[Depends(user_api_key_auth)], + response_model=List[MCPUserFieldsStatus], + ) + @management_endpoint_wrapper + async def list_mcp_user_field_values( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + 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"}, + ) + all_servers = await get_all_mcp_servers(prisma_client) + # Pre-filter to servers that actually declare user_fields, then + # batch-fetch every credential row for the calling user in one + # query. The N+1 alternative (per-server get_user_field_values) + # would scale linearly with server count. + relevant_servers: List["LiteLLM_MCPServerTable"] = [] + relevant_ids: List[str] = [] + for server in all_servers: + raw_fields = getattr(server, "user_fields", None) or [] + if isinstance(raw_fields, str): + try: + raw_fields = json.loads(raw_fields) + except (ValueError, TypeError): + raw_fields = [] + if not raw_fields: + continue + relevant_servers.append(server) + relevant_ids.append(server.server_id) + if not relevant_servers: + return [] + + # Single batched read; we only need rows for the calling user. + from litellm.proxy._experimental.mcp_server.db import ( + _decode_user_fields_payload, + ) + + cred_rows = await prisma_client.db.litellm_mcpusercredentials.find_many( + where={"user_id": user_id, "server_id": {"in": relevant_ids}} + ) + stored_by_server: Dict[str, Dict[str, str]] = {} + for row in cred_rows: + decoded = _decode_user_fields_payload(row.credential_b64) + if decoded is not None: + stored_by_server[row.server_id] = decoded + + return [ + _build_user_fields_status(server, stored_by_server.get(server.server_id)) + for server in relevant_servers + ] + @router.get( "/user-credentials", description="List all OAuth2 MCP credentials stored for the calling user", diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 78143fe0411..b16714b0f04 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -328,6 +328,11 @@ model LiteLLM_MCPServerTable { is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? + // Admin-defined per-user fields (e.g. bearer tokens, workspace IDs) that + // each end-user must supply via the dashboard before they can use the + // server. Each entry is a JSON object describing how the field is rendered + // in the dashboard and injected at request time. + user_fields Json @default("[]") source_url String? // BYOM submission lifecycle approval_status String? @default("active") diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 776c7fa67a6..4d79a3c6075 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -77,6 +77,10 @@ class MCPServer(BaseModel): is_byok: bool = False byok_description: List[str] = [] byok_api_key_help_url: Optional[str] = None + # Raw user_fields blob (list of dicts) as stored on the server. Decoded + # at request time to inject per-user headers/env-vars. Kept as plain + # dicts here to avoid a dependency on proxy/_types from this module. + user_fields: List[Dict[str, Any]] = [] created_at: Optional[datetime] = None updated_at: Optional[datetime] = None # OAuth2 flow type. Defaults to None (interactive / authorization_code). diff --git a/schema.prisma b/schema.prisma index 78143fe0411..b16714b0f04 100644 --- a/schema.prisma +++ b/schema.prisma @@ -328,6 +328,11 @@ model LiteLLM_MCPServerTable { is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? + // Admin-defined per-user fields (e.g. bearer tokens, workspace IDs) that + // each end-user must supply via the dashboard before they can use the + // server. Each entry is a JSON object describing how the field is rendered + // in the dashboard and injected at request time. + user_fields Json @default("[]") source_url String? // BYOM submission lifecycle approval_status String? @default("active") diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_user_fields.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_user_fields.py new file mode 100644 index 00000000000..f947849509a --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_user_fields.py @@ -0,0 +1,608 @@ +"""Unit tests for admin-declared MCP user-fields. + +Covers the per-user fields feature end-to-end: + - Pydantic shapes for MCPUserField on the create/update/read models + - Encrypted JSON round-trip in LiteLLM_MCPUserCredentials.credential_b64 + - Storage helpers honour the type discriminator (don't collide with BYOK / OAuth2) + - Pure helpers in user_fields.py compute the right missing-field list and + inject the right headers / env vars + - execute_mcp_tool raises the friendly 401 when required fields are absent, + and proceeds when they're present +""" + +import json +import os +from typing import Any, Dict, List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# Tests run with a fixed salt so encrypt_value_helper has a stable key. +os.environ.setdefault("LITELLM_SALT_KEY", "test-salt-for-user-fields") + +from litellm.proxy._experimental.mcp_server.db import ( + _decode_user_fields_payload, +) +from litellm.proxy._experimental.mcp_server.user_fields import ( + build_user_fields_missing_error, + coerce_user_fields, + compute_missing_user_fields, + resolve_user_field_env, + resolve_user_field_headers, + server_has_user_fields, +) +from litellm.proxy._types import ( + LiteLLM_MCPServerTable, + MCPTransport, + MCPUserField, + NewMCPServerRequest, + UpdateMCPServerRequest, +) +from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +# --------------------------------------------------------------------------- +# Pydantic shapes +# --------------------------------------------------------------------------- + + +def test_new_mcp_server_request_accepts_user_fields(): + """user_fields should serialize as a list of MCPUserField models.""" + req = NewMCPServerRequest( + server_name="gmail", + url="https://gmail.example.com", + transport=MCPTransport.http, + user_fields=[ + MCPUserField( + field_key="TOKEN", + display_name="Gmail OAuth Token", + header_name="Authorization", + header_value_template="Bearer {value}", + required=True, + ), + ], + ) + assert len(req.user_fields) == 1 + field = req.user_fields[0] + assert field.field_key == "TOKEN" + assert field.required is True + assert field.header_value_template == "Bearer {value}" + + +def test_update_mcp_server_request_user_fields_default_empty(): + req = UpdateMCPServerRequest( + server_id="s1", + server_name="gmail", + url="https://gmail.example.com", + transport=MCPTransport.http, + ) + assert req.user_fields == [] + + +def test_litellm_mcp_server_table_user_fields_default_empty(): + row = LiteLLM_MCPServerTable( + server_id="s1", + server_name="gmail", + transport=MCPTransport.http, + ) + assert row.user_fields == [] + # missing_user_field_keys is opt-in (set per-request by the list endpoint) + assert row.missing_user_field_keys is None + + +# --------------------------------------------------------------------------- +# Storage round-trip +# --------------------------------------------------------------------------- + + +def _encrypt_user_fields_blob(values: Dict[str, str]) -> str: + """Mirror what store_user_field_values writes into credential_b64.""" + payload = json.dumps({"type": "user_fields", "values": values}) + return encrypt_value_helper(payload) + + +def test_decode_user_fields_payload_round_trip(): + encoded = _encrypt_user_fields_blob({"BEARER": "tok", "WS": "ws1"}) + decoded = _decode_user_fields_payload(encoded) + assert decoded == {"BEARER": "tok", "WS": "ws1"} + + +def test_decode_user_fields_payload_rejects_oauth2_blob(): + """OAuth2 rows live in the same column — must not be misread as user-fields.""" + oauth_blob = encrypt_value_helper( + json.dumps({"type": "oauth2", "access_token": "abc"}) + ) + assert _decode_user_fields_payload(oauth_blob) is None + + +def test_decode_user_fields_payload_rejects_byok_string(): + """Plain BYOK strings must not parse as a user-fields payload.""" + byok_blob = encrypt_value_helper("plain-api-key") + assert _decode_user_fields_payload(byok_blob) is None + + +def test_decode_user_fields_payload_handles_garbage(): + assert _decode_user_fields_payload("not-base64-at-all") is None + + +# --------------------------------------------------------------------------- +# user_fields helpers +# --------------------------------------------------------------------------- + + +def _gmail_server(user_fields: Optional[List[Dict[str, Any]]] = None) -> MCPServer: + """Build a minimal MCPServer fixture for the helper tests.""" + return MCPServer( + server_id="s1", + name="Gmail", + alias="gmail-prod", + transport=MCPTransport.http, + user_fields=( + user_fields + if user_fields is not None + else [ + { + "field_key": "GMAIL_TOKEN", + "display_name": "Gmail Token", + "description": "Your Gmail OAuth bearer token", + "header_name": "Authorization", + "header_value_template": "Bearer {value}", + "required": True, + }, + { + "field_key": "WORKSPACE", + "display_name": "Workspace ID", + "header_name": "X-Workspace", + "required": False, + }, + ] + ), + ) + + +def test_coerce_user_fields_accepts_list(): + srv = _gmail_server() + assert len(coerce_user_fields(srv)) == 2 + + +def test_coerce_user_fields_accepts_json_string(): + srv = _gmail_server(user_fields=None) + # Prisma occasionally hands JSONB columns back as a string. + srv.user_fields = json.loads(json.dumps([{"field_key": "X", "required": True}])) + assert coerce_user_fields(srv) == [{"field_key": "X", "required": True}] + + +def test_coerce_user_fields_empty_when_missing(): + srv = MCPServer(server_id="s2", name="empty", transport=MCPTransport.http) + assert coerce_user_fields(srv) == [] + assert server_has_user_fields(srv) is False + + +def test_compute_missing_required_only(): + srv = _gmail_server() + missing = compute_missing_user_fields(srv, None) + # Only the required field is reported as missing. + assert [f["field_key"] for f in missing] == ["GMAIL_TOKEN"] + + +def test_compute_missing_empty_when_all_filled(): + srv = _gmail_server() + missing = compute_missing_user_fields( + srv, {"GMAIL_TOKEN": "tok", "WORKSPACE": "ws1"} + ) + assert missing == [] + + +def test_compute_missing_optional_field_unaffected(): + """Optional fields without a value should not appear in missing.""" + srv = _gmail_server() + missing = compute_missing_user_fields(srv, {"GMAIL_TOKEN": "tok"}) + assert missing == [] + + +def test_resolve_user_field_headers_applies_template(): + srv = _gmail_server() + headers = resolve_user_field_headers( + srv, {"GMAIL_TOKEN": "tok", "WORKSPACE": "ws1"} + ) + assert headers == {"Authorization": "Bearer tok", "X-Workspace": "ws1"} + + +def test_resolve_user_field_headers_skips_unset_values(): + srv = _gmail_server() + headers = resolve_user_field_headers(srv, {"WORKSPACE": "ws1"}) + # GMAIL_TOKEN has no stored value → no Authorization header. + assert headers == {"X-Workspace": "ws1"} + + +def test_resolve_user_field_headers_falls_back_on_bad_template(): + """A broken template must not crash the request path.""" + srv = _gmail_server( + user_fields=[ + { + "field_key": "TOKEN", + "header_name": "Authorization", + "header_value_template": "Bearer {unknown_placeholder}", + "required": True, + } + ] + ) + headers = resolve_user_field_headers(srv, {"TOKEN": "raw"}) + # Falls back to the raw value rather than raising. + assert headers == {"Authorization": "raw"} + + +def test_resolve_user_field_env_for_stdio(): + srv = MCPServer( + server_id="s3", + name="local", + transport=MCPTransport.stdio, + user_fields=[ + {"field_key": "GH_TOKEN", "env_var_name": "GITHUB_TOKEN", "required": True} + ], + ) + env = resolve_user_field_env(srv, {"GH_TOKEN": "ghs_xxx"}) + assert env == {"GITHUB_TOKEN": "ghs_xxx"} + + +def test_build_user_fields_missing_error_uses_proxy_base_url(): + srv = _gmail_server() + missing = compute_missing_user_fields(srv, None) + err = build_user_fields_missing_error(srv, missing, "https://proxy.example.com/") + assert err["error"] == "user_fields_missing" + assert err["server_id"] == "s1" + assert ( + err["config_url"] + == "https://proxy.example.com/ui?page=mcp-servers&server_id=s1" + ) + # Friendly message contains the URL and the field display name. + assert "Gmail Token" in err["message"] + assert err["config_url"] in err["message"] + assert err["missing_fields"][0]["field_key"] == "GMAIL_TOKEN" + + +def test_build_user_fields_missing_error_falls_back_to_relative_path(): + srv = _gmail_server() + err = build_user_fields_missing_error( + srv, compute_missing_user_fields(srv, None), None + ) + # When no base URL is known, the config_url is a relative path so it + # still surfaces something useful in error logs. + assert err["config_url"] == "/ui?page=mcp-servers&server_id=s1" + + +# --------------------------------------------------------------------------- +# execute_mcp_tool enforcement +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_enforce_user_fields_raises_when_missing(): + """When a required field is missing, the helper raises HTTP 401 with the + structured error payload Claude Code can render to the end-user.""" + from fastapi import HTTPException + + from litellm.proxy._experimental.mcp_server.server import ( + _enforce_user_fields, + _user_fields_cache, + ) + from litellm.proxy._types import UserAPIKeyAuth + + _user_fields_cache.clear() + srv = _gmail_server() + user = UserAPIKeyAuth(api_key="hashed", user_id="user-1") + + # Simulate "no stored values" by patching the cache directly so we don't + # need a live prisma_client. + _user_fields_cache[("user-1", "s1")] = (None, 1e18) # fresh entry, value=None + + with pytest.raises(HTTPException) as exc_info: + await _enforce_user_fields(srv, user) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["error"] == "user_fields_missing" + assert detail["server_id"] == "s1" + assert "GMAIL_TOKEN" in [m["field_key"] for m in detail["missing_fields"]] + assert "config_url" in detail + + +@pytest.mark.asyncio +async def test_enforce_user_fields_passes_when_all_required_present(): + from litellm.proxy._experimental.mcp_server.server import ( + _enforce_user_fields, + _user_fields_cache, + ) + from litellm.proxy._types import UserAPIKeyAuth + + _user_fields_cache.clear() + srv = _gmail_server() + user = UserAPIKeyAuth(api_key="hashed", user_id="user-2") + _user_fields_cache[("user-2", "s1")] = ({"GMAIL_TOKEN": "tok"}, 1e18) + + # Should not raise. + await _enforce_user_fields(srv, user) + + +@pytest.mark.asyncio +async def test_enforce_user_fields_no_user_id_raises(): + """A request without a user identity cannot be satisfied — surface the + same friendly error so the client knows where to send the user.""" + from fastapi import HTTPException + + from litellm.proxy._experimental.mcp_server.server import _enforce_user_fields + from litellm.proxy._types import UserAPIKeyAuth + + srv = _gmail_server() + user = UserAPIKeyAuth(api_key="hashed") # no user_id + + with pytest.raises(HTTPException) as exc_info: + await _enforce_user_fields(srv, user) + assert exc_info.value.status_code == 401 + assert exc_info.value.detail["error"] == "user_fields_missing" + + +# --------------------------------------------------------------------------- +# Manager wiring +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_build_stdio_env_merges_user_field_env_over_static(): + """Stored user_field env vars must take precedence over static server.env.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + + srv = MCPServer( + server_id="s4", + name="local", + transport=MCPTransport.stdio, + env={"GITHUB_TOKEN": "static-default", "OTHER": "keep-me"}, + ) + mgr = MCPServerManager() + merged = mgr._build_stdio_env( + srv, raw_headers=None, user_field_env={"GITHUB_TOKEN": "user-value"} + ) + assert merged == {"GITHUB_TOKEN": "user-value", "OTHER": "keep-me"} + + +@pytest.mark.asyncio +async def test_build_stdio_env_returns_user_env_when_no_static_env(): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + + srv = MCPServer(server_id="s5", name="local", transport=MCPTransport.stdio) + mgr = MCPServerManager() + merged = mgr._build_stdio_env(srv, raw_headers=None, user_field_env={"X": "1"}) + assert merged == {"X": "1"} + + +# --------------------------------------------------------------------------- +# HTTP endpoints — POST / GET / DELETE /v1/mcp/server/{id}/user-field-values +# --------------------------------------------------------------------------- + + +def _server_row_with_user_fields() -> Any: + """Mock Prisma row matching what get_mcp_server returns. + + The endpoint sees `user_fields` as a list, mirroring how Prisma decodes + JSONB in the happy path. + """ + now = "2026-05-19T00:00:00" + return MagicMock( + server_id="srv-1", + server_name="Gmail", + alias="gmail", + transport=MCPTransport.http, + url="https://gmail.example.com", + created_at=now, + updated_at=now, + is_byok=False, + byok_description=[], + byok_api_key_help_url=None, + user_fields=[ + { + "field_key": "GMAIL_TOKEN", + "display_name": "Gmail Token", + "header_name": "Authorization", + "header_value_template": "Bearer {value}", + "required": True, + }, + { + "field_key": "WORKSPACE", + "display_name": "Workspace", + "header_name": "X-Workspace", + "required": False, + }, + ], + credentials=None, + mcp_info={}, + mcp_access_groups=[], + allowed_tools=[], + tool_name_to_display_name={}, + tool_name_to_description={}, + extra_headers=[], + static_headers={}, + status="unknown", + last_health_check=None, + health_check_error=None, + command=None, + args=[], + env={}, + authorization_url=None, + token_url=None, + registration_url=None, + allow_all_keys=False, + available_on_public_internet=True, + delegate_auth_to_upstream=False, + source_url=None, + approval_status="active", + submitted_by=None, + submitted_at=None, + reviewed_at=None, + review_notes=None, + spec_path=None, + instructions=None, + created_by="admin", + updated_by="admin", + auth_type=None, + ) + + +@pytest.mark.asyncio +async def test_get_user_field_values_endpoint_reports_missing(): + """Initial GET (no stored row) should show all required fields as missing.""" + from litellm.proxy._types import UserAPIKeyAuth + + server_row = _server_row_with_user_fields() + prisma_client = MagicMock() + prisma_client.db.litellm_mcpservertable.find_unique = AsyncMock( + return_value=server_row + ) + prisma_client.db.litellm_mcpusercredentials.find_unique = AsyncMock( + return_value=None + ) + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=prisma_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_user_field_values", + AsyncMock(return_value=None), + ), + ): + # Call the endpoint function directly to skip FastAPI auth wiring. + from litellm.proxy.management_endpoints import mcp_management_endpoints as mod + + # The endpoint is closed over `router` inside `setup_mcp_management_routes`; + # exercise it via a TestClient. + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="hashed", user_id="user-1" + ) + client = TestClient(app) + res = client.get("/v1/mcp/server/srv-1/user-field-values") + assert res.status_code == 200, res.text + body = res.json() + assert body["server_id"] == "srv-1" + assert "GMAIL_TOKEN" in body["missing_field_keys"] + # Optional field is never reported as missing. + assert "WORKSPACE" not in body["missing_field_keys"] + assert body["stored_field_keys"] == [] + # Declared field descriptors are echoed back so the UI can render + # input fields without a second round-trip. + keys = {f["field_key"] for f in body["user_fields"]} + assert keys == {"GMAIL_TOKEN", "WORKSPACE"} + + +@pytest.mark.asyncio +async def test_post_user_field_values_rejects_undeclared_keys(): + """Attempting to save a key the server didn't declare must be dropped silently.""" + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.management_endpoints import mcp_management_endpoints as mod + + server_row = _server_row_with_user_fields() + prisma_client = MagicMock() + prisma_client.db.litellm_mcpservertable.find_unique = AsyncMock( + return_value=server_row + ) + + captured = {} + + async def fake_store(prisma, user_id, server_id, values): + captured["user_id"] = user_id + captured["server_id"] = server_id + captured["values"] = values + + async def fake_get(prisma, user_id, server_id): + return None # no existing values + + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=prisma_client, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.store_user_field_values", + new=fake_store, + ), + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_user_field_values", + new=fake_get, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._invalidate_byok_cred_cache" + ), + patch( + "litellm.proxy._experimental.mcp_server.server._invalidate_user_fields_cache" + ), + ): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="hashed", user_id="user-2" + ) + client = TestClient(app) + res = client.post( + "/v1/mcp/server/srv-1/user-field-values", + json={ + "values": { + "GMAIL_TOKEN": "tok", + "WORKSPACE": "ws1", + "EVIL_KEY": "should-be-dropped", + } + }, + ) + assert res.status_code == 200, res.text + assert captured["values"] == {"GMAIL_TOKEN": "tok", "WORKSPACE": "ws1"} + body = res.json() + assert body["missing_field_keys"] == [] + + +@pytest.mark.asyncio +async def test_post_user_field_values_rejects_server_with_no_declared_fields(): + """Storing values on a server that doesn't use user_fields should 400.""" + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.management_endpoints import mcp_management_endpoints as mod + + server_row = _server_row_with_user_fields() + server_row.user_fields = [] # no declared fields + prisma_client = MagicMock() + prisma_client.db.litellm_mcpservertable.find_unique = AsyncMock( + return_value=server_row + ) + + with patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=prisma_client, + ): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( + api_key="hashed", user_id="user-3" + ) + client = TestClient(app) + res = client.post( + "/v1/mcp/server/srv-1/user-field-values", json={"values": {"X": "y"}} + ) + assert res.status_code == 400 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx new file mode 100644 index 00000000000..2d77eaa7496 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx @@ -0,0 +1,243 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Input, Modal, Tag, Typography } from "antd"; +import { + CheckOutlined, + CloseOutlined, + LockOutlined, +} from "@ant-design/icons"; +import MessageManager from "@/components/molecules/message_manager"; +import { MCPServer, MCPUserField, MCPUserFieldsStatus } from "./types"; + +interface UserFieldsModalProps { + server: MCPServer; + open: boolean; + onClose: () => void; + onSuccess: (status: MCPUserFieldsStatus) => void; + accessToken: string; +} + +/** + * Dashboard modal where an end-user fills in the per-user fields declared by + * the admin for an MCP server. On submit, posts the values to + * /v1/mcp/server/{server_id}/user-field-values and returns the new status so + * the caller can clear the red badge. + */ +export const UserFieldsModal: React.FC = ({ + server, + open, + onClose, + onSuccess, + accessToken, +}) => { + const [values, setValues] = useState>({}); + const [storedFieldKeys, setStoredFieldKeys] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(false); + + const declaredFields: MCPUserField[] = server.user_fields ?? []; + const displayName = server.alias || server.server_name || "Service"; + + // When the modal opens, fetch the current status so the user can see which + // fields they've already saved (we don't echo back the values themselves). + useEffect(() => { + if (!open || !accessToken) return; + let cancelled = false; + const load = async () => { + setFetching(true); + try { + const res = await fetch( + `/v1/mcp/server/${server.server_id}/user-field-values`, + { + method: "GET", + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (!res.ok) return; + const status = (await res.json()) as MCPUserFieldsStatus; + if (!cancelled) { + setStoredFieldKeys(new Set(status.stored_field_keys ?? [])); + } + } catch (err) { + // Non-fatal; just leave storedFieldKeys empty. + } finally { + if (!cancelled) setFetching(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, [open, server.server_id, accessToken]); + + const handleClose = () => { + setValues({}); + setLoading(false); + onClose(); + }; + + const handleSave = async () => { + // Trim and drop empty strings so optional fields aren't overwritten with "". + const payloadValues: Record = {}; + for (const field of declaredFields) { + const v = values[field.field_key]; + if (typeof v === "string" && v.trim().length > 0) { + payloadValues[field.field_key] = v.trim(); + } + } + // Validate required fields the user hasn't already saved. + const missingRequired = declaredFields.filter( + (f) => + (f.required ?? true) && + !storedFieldKeys.has(f.field_key) && + !payloadValues[f.field_key], + ); + if (missingRequired.length > 0) { + MessageManager.error( + `Please fill in: ${missingRequired + .map((f) => f.display_name || f.field_key) + .join(", ")}`, + ); + return; + } + setLoading(true); + try { + const res = await fetch( + `/v1/mcp/server/${server.server_id}/user-field-values`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ values: payloadValues }), + }, + ); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.detail?.error || err?.detail || "Failed to save fields"); + } + const status = (await res.json()) as MCPUserFieldsStatus; + MessageManager.success(`Saved your fields for ${displayName}`); + onSuccess(status); + handleClose(); + } catch (e: any) { + MessageManager.error(e?.message || "Failed to save fields"); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+ + Connect {displayName} + + +
+ + + This server needs a few personal values from you before you can use it. + Your values are encrypted at rest and only sent to {displayName} on + your behalf. + + + {declaredFields.length === 0 ? ( + + This server has no user fields to configure. + + ) : ( +
+ {declaredFields.map((field) => { + const isSaved = storedFieldKeys.has(field.field_key); + const required = field.required ?? true; + return ( +
+
+ + {isSaved && ( + + Saved + + )} +
+ {field.description && ( + + {field.description} + + )} + + setValues((prev) => ({ + ...prev, + [field.field_key]: e.target.value, + })) + } + autoComplete="off" + /> +
+ ); + })} +
+ )} + +
+ + + Your values are encrypted with the proxy's salt key and never + logged. Other users on this proxy cannot see them. + +
+ +
+ + +
+
+
+ ); +}; + +export default UserFieldsModal; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index f8b0141b25d..4307789fac7 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -1,6 +1,21 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd"; -import { InfoCircleOutlined } from "@ant-design/icons"; +import { + Card, + Collapse, + Form, + Input, + Modal, + Select, + Space, + Switch, + Tooltip, + Typography, +} from "antd"; +import { + DeleteOutlined, + InfoCircleOutlined, + PlusOutlined, +} from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer, registerMCPServer } from "../networking"; import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types"; @@ -370,6 +385,31 @@ const CreateMCPServer: React.FC = ({ } } + // Strip incomplete user_fields entries (e.g. an empty row added by an + // admin who forgot to remove it). The backend validator would reject + // these with a 422; filtering client-side gives a clearer experience. + const rawUserFields = (restValues as any).user_fields; + let cleanedUserFields: any[] | undefined; + if (Array.isArray(rawUserFields)) { + cleanedUserFields = rawUserFields + .filter( + (entry: any) => + entry && typeof entry.field_key === "string" && entry.field_key.trim().length > 0, + ) + .map((entry: any) => { + const result: Record = { field_key: entry.field_key.trim() }; + if (entry.display_name) result.display_name = entry.display_name; + if (entry.description) result.description = entry.description; + if (entry.header_name) result.header_name = entry.header_name; + if (entry.header_value_template) + result.header_value_template = entry.header_value_template; + if (entry.env_var_name) result.env_var_name = entry.env_var_name; + result.required = entry.required !== false; + return result; + }); + } + (restValues as any).user_fields = cleanedUserFields ?? []; + // Prepare the payload with cost configuration and allowed tools const payload: Record = { ...restValues, @@ -679,6 +719,181 @@ const CreateMCPServer: React.FC = ({ /> )} + {/* User fields - admin-declared per-user values (bearer tokens, etc.) */} + {transportType !== "" && ( + + Per-User Fields + + + + + ), + children: ( + + {(fields, { add, remove }) => ( + <> + {fields.length === 0 && ( + + Declare fields like BEARER_TOKEN or{" "} + WORKSPACE_ID that each user must fill in + before this server works for them. The dashboard will + highlight servers with missing fields, and tool calls + will fail with a friendly error pointing the user to + the dashboard. + + )} + {fields.map(({ key, name, ...restField }) => ( + + Field #{name + 1} + + } + extra={ + + } + > + + Field key + + + + + } + rules={[ + { required: true, message: "field_key is required" }, + { + pattern: /^[A-Za-z][A-Za-z0-9_]*$/, + message: "Use letters, digits, underscores; start with a letter", + }, + ]} + > + + + + Display name + + } + > + + + + Description (help text shown on dashboard) + + } + > + + + + HTTP header name (http/sse) + + + + + } + > + + + + Header value template + + + + + } + > + + + + Env var name (stdio) + + + + + } + > + + + + Required + + } + valuePropName="checked" + initialValue={true} + > + + + + ))} + + + )} + + ), + }, + ]} + /> + )} + {/* BYOK toggle - only for OpenAPI */} {transportType === TRANSPORT.OPENAPI && ( <> diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index ea5ccf1c847..01ab4375a7e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -92,6 +92,7 @@ export const mcpServerColumns = ( onByokConnect?: (server: MCPServer) => void, onRecheckHealth?: (serverId: string) => void, recheckingServerIds?: Set, + onUserFieldsConnect?: (server: MCPServer) => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -266,6 +267,48 @@ export const mcpServerColumns = ( header: "Credential", cell: ({ row }) => { const server = row.original; + // User-fields take priority over BYOK display because the most + // critical feedback for the user is "you still need to fill this in". + const declaredUserFields = server.user_fields ?? []; + const hasUserFields = declaredUserFields.length > 0; + if (hasUserFields) { + // missing_user_field_keys is populated by the proxy for the calling + // user on the list endpoint. Falling back to an empty array assumes + // "nothing missing" so old API responses don't show a false alarm. + const missing = server.missing_user_field_keys ?? []; + if (missing.length > 0) { + return onUserFieldsConnect ? ( + + ) : ( + + + {missing.length} missing field{missing.length === 1 ? "" : "s"} + + ); + } + return ( +
+ + Ready + + {onUserFieldsConnect && ( + + )} +
+ ); + } if (!server.is_byok) { return —; } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 72d5e4b5aa8..412360a40ad 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -20,6 +20,7 @@ import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilt import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; import { ByokCredentialModal } from "./ByokCredentialModal"; +import { UserFieldsModal } from "./UserFieldsModal"; import { getSecureItem } from "@/utils/secureStorage"; const { Text: AntdText, Title: AntdTitle } = Typography; @@ -64,6 +65,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); const [byokModalServer, setByokModalServer] = useState(null); + const [userFieldsModalServer, setUserFieldsModalServer] = useState(null); const isInternalUser = userRole === "Internal User"; useEffect(() => { @@ -173,6 +175,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) (server: MCPServer) => setByokModalServer(server), recheckServerHealth, recheckingServerIds, + (server: MCPServer) => setUserFieldsModalServer(server), ), [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds], ); @@ -461,6 +464,20 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) accessToken={accessToken || ""} /> )} + {userFieldsModalServer && ( + setUserFieldsModalServer(null)} + onSuccess={() => { + // Refetch the server list so missing_user_field_keys updates + // and the red badge clears (or stays red if still incomplete). + refetch(); + setUserFieldsModalServer(null); + }} + accessToken={accessToken || ""} + /> + )} ); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 7cfe08d9ee5..a54eb002583 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -169,6 +169,33 @@ export interface MCPToolsViewerProps { extraHeaders?: string[] | null; } +/** + * One admin-declared per-user field on an MCP server. The admin lists these + * when creating the server; each end-user supplies their own values via the + * dashboard. Values get injected as HTTP headers (http/sse) or env vars (stdio) + * at request time. + */ +export interface MCPUserField { + field_key: string; + display_name?: string | null; + description?: string | null; + required?: boolean; + /** HTTP injection target (http/sse transports). */ + header_name?: string | null; + /** Defaults to "{value}". Use e.g. "Bearer {value}" to prefix. */ + header_value_template?: string | null; + /** Env var injection target (stdio transport). */ + env_var_name?: string | null; +} + +/** Response shape from /v1/mcp/server/{server_id}/user-field-values. */ +export interface MCPUserFieldsStatus { + server_id: string; + user_fields: MCPUserField[]; + stored_field_keys: string[]; + missing_field_keys: string[]; +} + export interface MCPServer { server_id: string; server_name?: string | null; @@ -215,6 +242,14 @@ export interface MCPServer { byok_api_key_help_url?: string | null; has_user_credential?: boolean | null; + /** Admin-declared per-user fields (e.g. bearer tokens) */ + user_fields?: MCPUserField[] | null; + /** + * Populated by the proxy on list/get for the calling user — the field_keys + * the user has not yet filled in. Drives the red badge on the dashboard. + */ + missing_user_field_keys?: string[] | null; + /** GitHub / source repository URL */ source_url?: string | null;