feat(mcp): add admin-declared per-user fields for MCP servers

Generalizes the existing BYOK pattern (one user-provided credential per
server) to N admin-declared fields. Each field can target an HTTP header
(http/sse transports) or env var (stdio), with an optional value template
for prefixes like "Bearer {value}". Field values are encrypted at rest,
stored in the existing LiteLLM_MCPUserCredentials table with a
"type": "user_fields" discriminator so they don't collide with BYOK
strings or OAuth2 blobs.

Demo flow:
  1. Admin adds a server with one or more user fields.
  2. The user dashboard shows a red "N missing fields" badge until each
     required field has a value.
  3. Calling the server via Claude Code (or any MCP client) before saving
     returns HTTP 401 with error="user_fields_missing", the list of
     missing field descriptors, and a config_url pointing at the
     dashboard.
  4. After the user saves their values, the badge clears and tool calls
     dispatch with the user's values injected as the configured headers
     or env vars.

Backend
- New JSONB column LiteLLM_MCPServerTable.user_fields with a migration.
- MCPUserField / MCPUserFieldValuesRequest / MCPUserFieldsStatus types
  on the existing create/update/read models.
- DB helpers store/get/delete user-field values via the same encryption
  path as BYOK; a "type" discriminator keeps the three formats apart.
- New endpoints GET/POST/DELETE /v1/mcp/server/{id}/user-field-values
  and GET /v1/mcp/user-field-values (aggregated for dashboard badges).
- GET /v1/mcp/server is annotated per-caller with missing_user_field_keys.
- execute_mcp_tool enforces required fields with a friendly 401 carrying
  the dashboard config_url; the managed MCP dispatch path injects the
  resolved headers and stdio env vars.

UI
- Admin "Add MCP Server" form gains a dynamic User Fields section.
- Dashboard servers list shows a red badge with the missing-field count
  for each affected server and opens a new UserFieldsModal where the
  end-user fills in their values.
This commit is contained in:
Claude 2026-05-19 02:48:57 +00:00
parent 761c280a6e
commit e30e463927
No known key found for this signature in database
17 changed files with 2144 additions and 12 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "user_fields" JSONB NOT NULL DEFAULT '[]';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<UserFieldsModalProps> = ({
server,
open,
onClose,
onSuccess,
accessToken,
}) => {
const [values, setValues] = useState<Record<string, string>>({});
const [storedFieldKeys, setStoredFieldKeys] = useState<Set<string>>(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<string, string> = {};
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 (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={520}
closeIcon={null}
>
<div className="relative p-2">
<div className="flex items-center justify-between mb-4">
<Typography.Title level={4} className="!mb-0">
Connect {displayName}
</Typography.Title>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
aria-label="Close"
>
<CloseOutlined />
</button>
</div>
<Typography.Paragraph type="secondary" className="!mb-4">
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.
</Typography.Paragraph>
{declaredFields.length === 0 ? (
<Typography.Text type="secondary">
This server has no user fields to configure.
</Typography.Text>
) : (
<div className="space-y-4">
{declaredFields.map((field) => {
const isSaved = storedFieldKeys.has(field.field_key);
const required = field.required ?? true;
return (
<div key={field.field_key}>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-semibold text-gray-800">
{field.display_name || field.field_key}
{required && (
<span className="text-red-500 ml-1" title="Required">
*
</span>
)}
</label>
{isSaved && (
<Tag color="green" className="!text-xs !mr-0">
<CheckOutlined /> Saved
</Tag>
)}
</div>
{field.description && (
<Typography.Paragraph
type="secondary"
className="!text-xs !mb-1.5"
>
{field.description}
</Typography.Paragraph>
)}
<Input.Password
placeholder={
isSaved
? "Already saved — enter a new value to replace"
: `Enter ${field.display_name || field.field_key}`
}
value={values[field.field_key] ?? ""}
onChange={(e) =>
setValues((prev) => ({
...prev,
[field.field_key]: e.target.value,
}))
}
autoComplete="off"
/>
</div>
);
})}
</div>
)}
<div className="mt-5 bg-blue-50 rounded-lg p-3 flex items-start gap-2 text-xs text-blue-700">
<LockOutlined className="mt-0.5 flex-shrink-0" />
<span>
Your values are encrypted with the proxy&apos;s salt key and never
logged. Other users on this proxy cannot see them.
</span>
</div>
<div className="mt-4 flex gap-2 justify-end">
<button
onClick={handleClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={loading || fetching}
className="px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white rounded-md transition-colors"
>
{loading ? "Saving…" : "Save & Connect"}
</button>
</div>
</div>
</Modal>
);
};
export default UserFieldsModal;

View file

@ -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<CreateMCPServerProps> = ({
}
}
// 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<string, any> = { 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<string, any> = {
...restValues,
@ -679,6 +719,181 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
)}
{/* User fields - admin-declared per-user values (bearer tokens, etc.) */}
{transportType !== "" && (
<Collapse
className="mb-4"
items={[
{
key: "user_fields",
label: (
<span className="text-sm font-semibold text-gray-700 flex items-center gap-2">
Per-User Fields
<Tooltip title="Optional fields each end-user must fill in on the dashboard before they can use this server. Values are injected as HTTP headers (http/sse) or env vars (stdio) at request time.">
<InfoCircleOutlined className="text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
),
children: (
<Form.List name="user_fields">
{(fields, { add, remove }) => (
<>
{fields.length === 0 && (
<Typography.Paragraph
type="secondary"
className="!mb-3 text-xs"
>
Declare fields like <code>BEARER_TOKEN</code> or{" "}
<code>WORKSPACE_ID</code> 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.
</Typography.Paragraph>
)}
{fields.map(({ key, name, ...restField }) => (
<Card
size="small"
key={key}
className="!mb-3"
type="inner"
title={
<Typography.Text className="!text-xs !text-gray-500">
Field #{name + 1}
</Typography.Text>
}
extra={
<button
type="button"
aria-label="Remove field"
onClick={() => remove(name)}
className="text-gray-400 hover:text-red-600 transition-colors"
>
<DeleteOutlined />
</button>
}
>
<Form.Item
{...restField}
name={[name, "field_key"]}
label={
<span className="text-xs font-medium text-gray-700">
Field key
<Tooltip title="Unique storage key. Recommended to use SCREAMING_SNAKE_CASE.">
<InfoCircleOutlined className="ml-1.5 text-blue-400" />
</Tooltip>
</span>
}
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",
},
]}
>
<Input placeholder="BEARER_TOKEN" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "display_name"]}
label={
<span className="text-xs font-medium text-gray-700">
Display name
</span>
}
>
<Input placeholder="Gmail OAuth Token" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "description"]}
label={
<span className="text-xs font-medium text-gray-700">
Description (help text shown on dashboard)
</span>
}
>
<Input.TextArea
rows={2}
placeholder="Your personal Gmail OAuth bearer token."
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, "header_name"]}
label={
<span className="text-xs font-medium text-gray-700">
HTTP header name (http/sse)
<Tooltip title="Optional. When set, the user's value is injected as this header on outbound MCP requests.">
<InfoCircleOutlined className="ml-1.5 text-blue-400" />
</Tooltip>
</span>
}
>
<Input placeholder="Authorization" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "header_value_template"]}
label={
<span className="text-xs font-medium text-gray-700">
Header value template
<Tooltip title="Defaults to '{value}'. Use e.g. 'Bearer {value}' to prefix the user's value automatically.">
<InfoCircleOutlined className="ml-1.5 text-blue-400" />
</Tooltip>
</span>
}
>
<Input placeholder="Bearer {value}" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "env_var_name"]}
label={
<span className="text-xs font-medium text-gray-700">
Env var name (stdio)
<Tooltip title="Optional. When set, the user's value is forwarded as this env var to the spawned MCP process.">
<InfoCircleOutlined className="ml-1.5 text-blue-400" />
</Tooltip>
</span>
}
>
<Input placeholder="GITHUB_TOKEN" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "required"]}
label={
<span className="text-xs font-medium text-gray-700">
Required
</span>
}
valuePropName="checked"
initialValue={true}
>
<Switch />
</Form.Item>
</Card>
))}
<button
type="button"
onClick={() =>
add({ required: true, header_value_template: "" })
}
className="w-full border border-dashed border-gray-300 hover:border-blue-400 hover:text-blue-600 text-gray-500 text-sm py-2 rounded-md flex items-center justify-center gap-2 transition-colors"
>
<PlusOutlined /> Add user field
</button>
</>
)}
</Form.List>
),
},
]}
/>
)}
{/* BYOK toggle - only for OpenAPI */}
{transportType === TRANSPORT.OPENAPI && (
<>

View file

@ -92,6 +92,7 @@ export const mcpServerColumns = (
onByokConnect?: (server: MCPServer) => void,
onRecheckHealth?: (serverId: string) => void,
recheckingServerIds?: Set<string>,
onUserFieldsConnect?: (server: MCPServer) => void,
): ColumnDef<MCPServer>[] => [
{
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 ? (
<button
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-md bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 transition-colors"
onClick={() => onUserFieldsConnect(server)}
title={`Missing ${missing.length} required field${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
{missing.length} missing field{missing.length === 1 ? "" : "s"}
</button>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-md bg-red-50 text-red-700 border border-red-200">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
{missing.length} missing field{missing.length === 1 ? "" : "s"}
</span>
);
}
return (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
<CheckOutlined style={{ fontSize: 10 }} /> Ready
</span>
{onUserFieldsConnect && (
<button
className="text-xs text-gray-400 hover:text-blue-600 transition-colors"
onClick={() => onUserFieldsConnect(server)}
>
Update
</button>
)}
</div>
);
}
if (!server.is_byok) {
return <span className="text-gray-300 text-xs">—</span>;
}

View file

@ -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<MCPServerProps> = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const [userFieldsModalServer, setUserFieldsModalServer] = useState<MCPServer | null>(null);
const isInternalUser = userRole === "Internal User";
useEffect(() => {
@ -173,6 +175,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ 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<MCPServerProps> = ({ accessToken, userRole, userID })
accessToken={accessToken || ""}
/>
)}
{userFieldsModalServer && (
<UserFieldsModal
server={userFieldsModalServer}
open={!!userFieldsModalServer}
onClose={() => 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 || ""}
/>
)}
</div>
);
};

View file

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