mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
761c280a6e
commit
e30e463927
17 changed files with 2144 additions and 12 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "user_fields" JSONB NOT NULL DEFAULT '[]';
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#########################################################
|
||||
|
|
|
|||
194
litellm/proxy/_experimental/mcp_server/user_fields.py
Normal file
194
litellm/proxy/_experimental/mcp_server/user_fields.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue