feat(mcp): BYOM — non-admin MCP server submission + admin review workflow (#23205)

* feat(mcp): add BYOM (Bring Your Own MCPs) submission + admin review workflow

Non-admins can now submit MCP servers for review via POST /v1/mcp/server/register.
Admins get a Submissions tab in the UI to approve or reject pending servers.
Approved servers enter the active runtime; rejected ones stay out with notes.

- DB: add approval_status, submitted_by, submitted_at, reviewed_at, review_notes
  to LiteLLM_MCPServerTable with migration
- Backend: new endpoints register, submissions, approve, reject
- reload_servers_from_database now only loads approval_status=active servers
- UI: Submissions tab with stat cards, card list, confirm dialogs; non-admin
  "Submit MCP Server" button wired to /register endpoint
- Fix get_mcp_submissions to filter by submitted_at IS NOT NULL (not submitted_by,
  which can be null for team-scoped keys without an associated user)

* feat(mcp): rename nav item to Team MCPs + add New badge

* fix(mcp): revert nav label, rename Submissions tab to Team MCPs + New badge

* feat(mcp): add MCP Standards — required fields config + CI-style checks on submissions

Adds a "Standards" tab (admin-only) to MCP Servers where admins define which
server fields are required for a submission to pass. Each submission card in
Team MCPs then shows a green ✓ or red ✗ for each required field, with a
summary "N/M checks" badge in the header — like GitHub CI status rows.

Also adds a `source_url` field (GitHub / Source URL) to the MCP server schema
so non-admins can link to the source repo when submitting a server.

- schema.prisma: add `source_url String?` to LiteLLM_MCPServerTable
- migration: 20260309000001_add_mcp_source_url
- _types.py: source_url on NewMCPServerRequest, UpdateMCPServerRequest, LiteLLM_MCPServerTable
- types.tsx: source_url on MCPServer interface
- create_mcp_server.tsx: GitHub/Source URL form field
- MCPStandardsSettings.tsx: new — toggle which fields are required (stored in general settings as mcp_required_fields)
- mcp_servers.tsx: Standards tab (admin-only)
- MCPSubmissionsTab.tsx: load required fields + CI-style check pills on each card

* refactor(mcp): move submission rules into Team MCPs tab, grouped free-form UI

Folds the Standards tab into Team MCPs. Submission Rules panel now lives at the
top of the Team MCPs tab — collapsible, shows active rules as chips when closed,
expands to a grouped checkbox editor (Documentation / Source / Connection /
Security). Removes the separate Standards tab from the nav.

MCPStandardsSettings.tsx is now constants-only (FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS,
SETTINGS_KEY) — the UI lives in MCPSubmissionsTab.

* feat(mcp): add mcp_required_fields to ConfigGeneralSettings + config/list endpoint

Registers mcp_required_fields as a proper general_settings field so the UI
can read/write it via /config/list and /config/field/update without the
"Invalid field" error. Also fixes a pre-existing pyright None-check issue
in _sync_ui_settings_to_general_settings.

* ui(mcp): GitHub-style PR checks panel on submission cards

* ui: rename Team MCPs -> Submitted Tools, Team Guardrails -> Submitted Guardrails

* address greptile review feedback (greploop iteration 1)

* fix: inline import, add approval workflow tests, rename Submitted MCPs

* fix(mcp): allow re-approval of rejected MCP server submissions

* fix(mcp): evict rejected servers from runtime; enforce mcp_required_fields on /register

* fix(mcp): sort submissions newest-first; force active status on admin-created servers

* fix(mcp): add missing mock in test, show Approve for rejected, clear submission metadata, drop spurious Content-Type

* fix(mcp/ui): show Reject for active servers; show submit form to non-admins with team-key note

* fix(mcp): conditional reload on reject; view-only admin for submissions; block admin from /register

* fix(mcp): match auth_type required-field validation to UI compliance check (reject 'none')

* fix(mcp): block view-only admin from /register; log settings failure; warn on active server reject

* fix(mcp): allow view-only admin to use /register; add _validate_mcp_required_fields tests

* fix(mcp): validate field names in mcp_required_fields; surface backend error in submit UI

* fix(mcp): fix falsy field check; add field-name validation; add take limit; document server-managed fields; close dialog on error
This commit is contained in:
Ishaan Jaff 2026-03-10 13:58:59 -07:00 committed by GitHub
parent 6a3b029066
commit 373e5e316b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1659 additions and 25 deletions

View File

@ -0,0 +1,11 @@
-- AlterTable: Add BYOM approval workflow fields to LiteLLM_MCPServerTable
ALTER TABLE "LiteLLM_MCPServerTable"
ADD COLUMN IF NOT EXISTS "approval_status" TEXT DEFAULT 'active',
ADD COLUMN IF NOT EXISTS "submitted_by" TEXT,
ADD COLUMN IF NOT EXISTS "submitted_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "reviewed_at" TIMESTAMP(3),
ADD COLUMN IF NOT EXISTS "review_notes" TEXT;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LiteLLM_MCPServerTable_approval_status_idx"
ON "LiteLLM_MCPServerTable"("approval_status");

View File

@ -0,0 +1,3 @@
-- AlterTable: Add source_url field to LiteLLM_MCPServerTable for GitHub/docs link
ALTER TABLE "LiteLLM_MCPServerTable"
ADD COLUMN IF NOT EXISTS "source_url" TEXT;

View File

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast
from litellm._logging import verbose_proxy_logger
@ -6,6 +7,8 @@ from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
MCPApprovalStatus,
MCPSubmissionsSummary,
NewMCPServerRequest,
SpecialMCPServerName,
UpdateMCPServerRequest,
@ -102,12 +105,19 @@ def encrypt_credentials(
async def get_all_mcp_servers(
prisma_client: PrismaClient,
approval_status: Optional[str] = None,
) -> List[LiteLLM_MCPServerTable]:
"""
Returns all of the mcp servers from the db
Returns mcp servers from the db, optionally filtered by approval_status.
Pass approval_status=None to return all servers regardless of approval state.
"""
try:
mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many()
where: Dict[str, Any] = {}
if approval_status is not None:
where["approval_status"] = approval_status
mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many(
where=where if where else {}
)
return [
LiteLLM_MCPServerTable(**mcp_server.model_dump())
@ -451,3 +461,71 @@ async def delete_user_credential(
await prisma_client.db.litellm_mcpusercredentials.delete(
where={"user_id_server_id": {"user_id": user_id, "server_id": server_id}}
)
async def approve_mcp_server(
prisma_client: PrismaClient,
server_id: str,
touched_by: str,
) -> LiteLLM_MCPServerTable:
"""Set approval_status=active and record reviewed_at."""
now = datetime.now(timezone.utc)
updated = await prisma_client.db.litellm_mcpservertable.update(
where={"server_id": server_id},
data={
"approval_status": MCPApprovalStatus.active,
"reviewed_at": now,
"updated_by": touched_by,
},
)
return LiteLLM_MCPServerTable(**updated.model_dump())
async def reject_mcp_server(
prisma_client: PrismaClient,
server_id: str,
touched_by: str,
review_notes: Optional[str] = None,
) -> LiteLLM_MCPServerTable:
"""Set approval_status=rejected, record reviewed_at and review_notes."""
now = datetime.now(timezone.utc)
data: Dict[str, Any] = {
"approval_status": MCPApprovalStatus.rejected,
"reviewed_at": now,
"updated_by": touched_by,
}
if review_notes is not None:
data["review_notes"] = review_notes
updated = await prisma_client.db.litellm_mcpservertable.update(
where={"server_id": server_id},
data=data,
)
return LiteLLM_MCPServerTable(**updated.model_dump())
async def get_mcp_submissions(
prisma_client: PrismaClient,
) -> MCPSubmissionsSummary:
"""
Returns all MCP servers that were submitted by non-admin users (submitted_at IS NOT NULL),
along with a summary count breakdown by approval_status.
Mirrors get_guardrail_submissions() from guardrail_endpoints.py.
"""
rows = await prisma_client.db.litellm_mcpservertable.find_many(
where={"submitted_at": {"not": None}},
order={"submitted_at": "desc"},
take=500, # safety cap; paginate if needed in a future iteration
)
items = [LiteLLM_MCPServerTable(**r.model_dump()) for r in rows]
pending = sum(1 for i in items if i.approval_status == MCPApprovalStatus.pending_review)
active = sum(1 for i in items if i.approval_status == MCPApprovalStatus.active)
rejected = sum(1 for i in items if i.approval_status == MCPApprovalStatus.rejected)
return MCPSubmissionsSummary(
total=len(items),
pending_review=pending,
active=active,
rejected=rejected,
items=items,
)

View File

@ -2297,7 +2297,7 @@ class MCPServerManager:
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
db_mcp_servers = await get_all_mcp_servers(prisma_client)
db_mcp_servers = await get_all_mcp_servers(prisma_client, approval_status="active")
verbose_logger.info(f"Found {len(db_mcp_servers)} MCP servers in database")
previous_registry = self.registry

View File

@ -1087,6 +1087,12 @@ class SpecialMCPServerName(str, enum.Enum):
all_proxy_servers = "all-proxy-mcpservers"
class MCPApprovalStatus(str, enum.Enum):
pending_review = "pending_review"
active = "active"
rejected = "rejected"
# MCP Proxy Request Types
class NewMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: Optional[str] = None
@ -1117,6 +1123,18 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
is_byok: bool = False
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
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.
approval_status: Optional[str] = Field(
None, description="Server-managed: set by the endpoint; caller values are overridden."
)
submitted_by: Optional[str] = Field(
None, description="Server-managed: set by the endpoint; caller values are overridden."
)
submitted_at: Optional[datetime] = Field(
None, description="Server-managed: set by the endpoint; caller values are overridden."
)
@model_validator(mode="before")
@classmethod
@ -1176,6 +1194,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
is_byok: bool = False
byok_description: List[str] = Field(default_factory=list)
byok_api_key_help_url: Optional[str] = None
source_url: Optional[str] = None
@model_validator(mode="before")
@classmethod
@ -1239,6 +1258,16 @@ 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
source_url: Optional[str] = None
# BYOM submission fields
approval_status: Optional[str] = Field(
default="active",
description="Approval status: 'pending_review', 'active', 'rejected'",
)
submitted_by: Optional[str] = None
submitted_at: Optional[datetime] = None
reviewed_at: Optional[datetime] = None
review_notes: Optional[str] = None
class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase):
@ -1255,6 +1284,18 @@ class MCPUserCredentialResponse(LiteLLMPydanticObjectBase):
has_credential: bool
class RejectMCPServerRequest(LiteLLMPydanticObjectBase):
review_notes: Optional[str] = None
class MCPSubmissionsSummary(LiteLLMPydanticObjectBase):
total: int
pending_review: int
active: int
rejected: int
items: List["LiteLLM_MCPServerTable"]
######## Skills API Types ########
@ -2203,6 +2244,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="If True, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.",
)
mcp_required_fields: Optional[List[str]] = Field(
None,
description="List of MCP server fields that must be filled in for a submission to pass standards checks (e.g. ['description', 'source_url', 'alias']).",
)
class ConfigYAML(LiteLLMPydanticObjectBase):

View File

@ -18,7 +18,7 @@ import importlib
import json
import os
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Iterable, List, Literal, Optional
from fastapi import (
@ -76,11 +76,14 @@ if MCP_AVAILABLE:
return _ToolNameValidationResult()
from litellm.proxy._experimental.mcp_server.db import (
approve_mcp_server,
create_mcp_server,
delete_mcp_server,
delete_user_credential,
get_all_mcp_servers_for_user,
get_mcp_server,
get_mcp_submissions,
reject_mcp_server,
store_user_credential,
update_mcp_server,
)
@ -100,9 +103,12 @@ if MCP_AVAILABLE:
LiteLLM_MCPServerTable,
LitellmUserRoles,
MakeMCPServersPublicRequest,
MCPApprovalStatus,
MCPSubmissionsSummary,
MCPUserCredentialRequest,
MCPUserCredentialResponse,
NewMCPServerRequest,
RejectMCPServerRequest,
SpecialMCPServerName,
UpdateMCPServerRequest,
UserAPIKeyAuth,
@ -155,6 +161,59 @@ if MCP_AVAILABLE:
_base_validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_server_name_fields(payload)
_VALID_MCP_REQUIRED_FIELDS: frozenset = frozenset(
NewMCPServerRequest.model_fields
)
def _validate_mcp_required_fields(payload: Any) -> None:
"""Validate submission payload against admin-configured mcp_required_fields."""
from litellm.proxy.proxy_server import (
general_settings as proxy_general_settings,
)
required_fields: Optional[List[str]] = proxy_general_settings.get(
"mcp_required_fields"
)
if not required_fields:
return
# Fail fast on unknown field names — a typo in the config would silently
# block every submission with a confusing "missing fields" error.
unknown = [f for f in required_fields if f not in _VALID_MCP_REQUIRED_FIELDS]
if unknown:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": f"mcp_required_fields contains unknown field names: {unknown}. "
"Check general_settings.mcp_required_fields in your proxy config."
},
)
# Mirror the UI's compliance checks (MCPStandardsSettings.tsx FIELD_GROUPS):
# auth_type requires a real value — "none" is treated as absent.
_AUTH_TYPE_SENTINEL = "none"
def _field_present(field_name: str) -> bool:
value = getattr(payload, field_name, None)
if value is None:
return False
# Treat empty string and empty list as absent (mirrors UI compliance check)
if isinstance(value, (str, list)) and not value:
return False
if field_name == "auth_type" and value == _AUTH_TYPE_SENTINEL:
return False
return True
missing = [f for f in required_fields if not _field_present(f)]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": f"Submission is missing required fields: {missing}. "
"Configure required fields via general_settings.mcp_required_fields."
},
)
def _is_public_registry_enabled() -> bool:
from litellm.proxy.proxy_server import (
general_settings as proxy_general_settings,
@ -689,6 +748,187 @@ if MCP_AVAILABLE:
for server_id, status in server_status_map.items()
]
@router.post(
"/server/register",
description="Submit a new MCP server for admin review (non-admin users). Mirrors POST /guardrails/register.",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
status_code=status.HTTP_201_CREATED,
)
@management_endpoint_wrapper
async def register_mcp_server(
payload: NewMCPServerRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Allow team members to submit an MCP server for admin review.
Creates the server with approval_status=pending_review.
Requires a team-scoped API key.
"""
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "PROXY_ADMIN users should use POST /v1/mcp/server to create servers directly instead of the submission workflow."
},
)
if not user_api_key_dict.team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "Registration requires an API key associated with a team. Use a team-scoped key."
},
)
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_required_fields(payload)
payload.approval_status = MCPApprovalStatus.pending_review
payload.submitted_by = user_api_key_dict.user_id
payload.submitted_at = datetime.now(timezone.utc)
try:
new_mcp_server = await create_mcp_server(
prisma_client,
payload,
touched_by=user_api_key_dict.user_id or user_api_key_dict.team_id,
)
except Exception as e:
verbose_proxy_logger.exception(f"Error registering mcp server: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Error registering mcp server: {str(e)}"},
)
# Do NOT add to runtime registry — pending servers are not active
return _redact_mcp_credentials(new_mcp_server)
@router.get(
"/server/submissions",
description="Returns all MCP servers submitted by non-admin users (admin review queue). Mirrors GET /guardrails/submissions.",
dependencies=[Depends(user_api_key_auth)],
response_model=MCPSubmissionsSummary,
)
@management_endpoint_wrapper
async def get_mcp_server_submissions(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Admin-only endpoint to view all user-submitted MCP servers pending review.
"""
if user_api_key_dict.user_role not in (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Admin access required to view MCP server submissions."},
)
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
return await get_mcp_submissions(prisma_client)
@router.put(
"/server/{server_id}/approve",
description="Approve a pending MCP server submission (admin only). Mirrors PUT /guardrails/{id}/approve.",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
)
@management_endpoint_wrapper
async def approve_mcp_server_submission(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Admin approves a pending or previously-rejected MCP server sets approval_status=active and loads it into the runtime registry.
"""
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Admin access required to approve MCP server submissions."},
)
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
existing = await get_mcp_server(prisma_client, server_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP server '{server_id}' not found."},
)
if existing.approval_status == MCPApprovalStatus.active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "MCP server is already active."},
)
approved = await approve_mcp_server(
prisma_client,
server_id,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
await global_mcp_server_manager.reload_servers_from_database()
return _redact_mcp_credentials(approved)
@router.put(
"/server/{server_id}/reject",
description="Reject a pending MCP server submission (admin only). Mirrors PUT /guardrails/{id}/reject.",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
)
@management_endpoint_wrapper
async def reject_mcp_server_submission(
server_id: str,
payload: RejectMCPServerRequest,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Admin rejects a pending MCP server sets approval_status=rejected with optional review_notes.
"""
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Admin access required to reject MCP server submissions."},
)
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
existing = await get_mcp_server(prisma_client, server_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP server '{server_id}' not found."},
)
if existing.approval_status == MCPApprovalStatus.rejected:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "MCP server is already rejected."},
)
was_active = existing.approval_status == MCPApprovalStatus.active
rejected = await reject_mcp_server(
prisma_client,
server_id,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
review_notes=payload.review_notes,
)
# Only evict from the runtime registry if the server was previously active
if was_active:
await global_mcp_server_manager.reload_servers_from_database()
return _redact_mcp_credentials(rejected)
@router.get(
"/server/{server_id}",
description="Returns the mcp server info",
@ -829,6 +1069,13 @@ if MCP_AVAILABLE:
# TODO: audit log for create
# Admin-created servers are always active — clear any submission lifecycle
# fields the caller may have provided to prevent fake entries appearing in
# the submissions queue.
payload.approval_status = MCPApprovalStatus.active
payload.submitted_by = None
payload.submitted_at = None
# Attempt to create the mcp server
try:
new_mcp_server = await create_mcp_server(

View File

@ -351,9 +351,6 @@ from litellm.proxy.management_endpoints.cache_settings_endpoints import (
from litellm.proxy.management_endpoints.callback_management_endpoints import (
router as callback_management_endpoints_router,
)
from litellm.proxy.management_endpoints.config_override_endpoints import (
router as config_override_router,
)
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_privileges,
admin_can_invite_user,
@ -361,6 +358,9 @@ from litellm.proxy.management_endpoints.common_utils import (
from litellm.proxy.management_endpoints.compliance_endpoints import (
router as compliance_router,
)
from litellm.proxy.management_endpoints.config_override_endpoints import (
router as config_override_router,
)
from litellm.proxy.management_endpoints.cost_tracking_settings import (
router as cost_tracking_settings_router,
)
@ -373,7 +373,9 @@ from litellm.proxy.management_endpoints.fallback_management_endpoints import (
from litellm.proxy.management_endpoints.internal_user_endpoints import (
router as internal_user_router,
)
from litellm.proxy.management_endpoints.internal_user_endpoints import user_update
from litellm.proxy.management_endpoints.internal_user_endpoints import (
user_update,
)
from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import (
router as jwt_key_mapping_router,
)
@ -442,7 +444,9 @@ from litellm.proxy.openai_evals_endpoints.endpoints import router as evals_route
from litellm.proxy.openai_files_endpoints.files_endpoints import (
router as openai_files_router,
)
from litellm.proxy.openai_files_endpoints.files_endpoints import set_files_config
from litellm.proxy.openai_files_endpoints.files_endpoints import (
set_files_config,
)
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
passthrough_endpoint_router,
)
@ -541,7 +545,9 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
LiteLLM_UpperboundKeyGenerateParams,
)
from litellm.types.realtime import RealtimeQueryParams
from litellm.types.router import DeploymentTypedDict
from litellm.types.router import (
DeploymentTypedDict,
)
from litellm.types.router import ModelInfo as RouterModelInfo
from litellm.types.router import (
RouterGeneralSettings,
@ -5788,6 +5794,8 @@ class ProxyStartupEvent:
_RUNTIME_GENERAL_SETTINGS_FLAGS,
)
if prisma_client is None:
return
db_record = await prisma_client.db.litellm_uisettings.find_unique(
where={"id": "ui_settings"}
)
@ -11983,6 +11991,7 @@ async def get_config_list(
"mcp_trusted_proxy_ranges": {"type": "List"},
"always_include_stream_usage": {"type": "Boolean"},
"forward_client_headers_to_llm_api": {"type": "Boolean"},
"mcp_required_fields": {"type": "List"},
}
return_val = []

View File

@ -315,6 +315,15 @@ model LiteLLM_MCPServerTable {
is_byok Boolean @default(false)
byok_description String[] @default([])
byok_api_key_help_url String?
source_url String?
// BYOM submission lifecycle
approval_status String? @default("active")
submitted_by String?
submitted_at DateTime?
reviewed_at DateTime?
review_notes String?
@@index([approval_status])
}
// Per-user BYOK credentials for MCP servers

View File

@ -1512,3 +1512,345 @@ class TestManagementPayloadValidation:
assert len(result) == 1
assert result[0]["server_id"] == "server-1"
assert result[0]["status"] == "healthy"
class TestMCPApprovalWorkflow:
"""Tests for BYOM submission: register, list submissions, approve, reject."""
@pytest.mark.asyncio
async def test_register_mcp_server_requires_team_key(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
register_mcp_server,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
)
# No team_id → should raise 400
user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER,
team_id=None,
)
with pytest.raises(HTTPException) as exc_info:
await register_mcp_server(payload=payload, user_api_key_dict=user_auth)
assert exc_info.value.status_code == 400
assert "team" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_register_mcp_server_sets_pending_review(self):
from litellm.proxy._types import MCPApprovalStatus
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
register_mcp_server,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
)
user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER,
team_id="team-123",
user_id="user-abc",
)
created_record = generate_mock_mcp_server_db_record(
alias="My Server",
url="https://example.com/mcp",
)
created_record.approval_status = MCPApprovalStatus.pending_review
created_record.submitted_by = "user-abc"
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.validate_and_normalize_mcp_server_payload",
MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.create_mcp_server",
AsyncMock(return_value=created_record),
) as mock_create,
):
result = await register_mcp_server(
payload=payload, user_api_key_dict=user_auth
)
# Endpoint sets pending_review before calling create_mcp_server
call_payload = mock_create.call_args[0][1]
assert call_payload.approval_status == MCPApprovalStatus.pending_review
assert call_payload.submitted_by == "user-abc"
assert result is not None
@pytest.mark.asyncio
async def test_get_submissions_non_admin_forbidden(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_mcp_server_submissions,
)
non_admin = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_mcp_server_submissions(user_api_key_dict=non_admin)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_get_submissions_admin_returns_summary(self):
from litellm.proxy._types import MCPSubmissionsSummary
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_mcp_server_submissions,
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
pending = generate_mock_mcp_server_db_record(alias="Pending")
pending.approval_status = "pending_review"
summary = MCPSubmissionsSummary(
total=1, pending_review=1, active=0, rejected=0, items=[pending]
)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_submissions",
AsyncMock(return_value=summary),
),
):
result = await get_mcp_server_submissions(user_api_key_dict=admin)
assert result.total == 1
assert result.pending_review == 1
@pytest.mark.asyncio
async def test_approve_non_pending_server_raises_400(self):
from litellm.proxy._types import MCPApprovalStatus
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
approve_mcp_server_submission,
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
active_server = generate_mock_mcp_server_db_record()
active_server.approval_status = MCPApprovalStatus.active
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=active_server),
),
):
with pytest.raises(HTTPException) as exc_info:
await approve_mcp_server_submission(
server_id="server-1", user_api_key_dict=admin
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_approve_pending_server_loads_into_registry(self):
from litellm.proxy._types import MCPApprovalStatus
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
approve_mcp_server_submission,
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
pending_server = generate_mock_mcp_server_db_record()
pending_server.approval_status = MCPApprovalStatus.pending_review
approved_server = generate_mock_mcp_server_db_record()
approved_server.approval_status = MCPApprovalStatus.active
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=pending_server),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.approve_mcp_server",
AsyncMock(return_value=approved_server),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
):
result = await approve_mcp_server_submission(
server_id=pending_server.server_id, user_api_key_dict=admin
)
mock_manager.reload_servers_from_database.assert_awaited_once()
assert result is not None
@pytest.mark.asyncio
async def test_reject_already_rejected_raises_400(self):
from litellm.proxy._types import MCPApprovalStatus, RejectMCPServerRequest
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
reject_mcp_server_submission,
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
rejected_server = generate_mock_mcp_server_db_record()
rejected_server.approval_status = MCPApprovalStatus.rejected
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=rejected_server),
),
):
with pytest.raises(HTTPException) as exc_info:
await reject_mcp_server_submission(
server_id="server-1",
payload=RejectMCPServerRequest(review_notes="duplicate"),
user_api_key_dict=admin,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_reject_active_server_allowed(self):
"""Admin can deactivate an already-approved server via the reject endpoint."""
from litellm.proxy._types import MCPApprovalStatus, RejectMCPServerRequest
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
reject_mcp_server_submission,
)
admin = generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN)
active_server = generate_mock_mcp_server_db_record()
active_server.approval_status = MCPApprovalStatus.active
now_rejected = generate_mock_mcp_server_db_record()
now_rejected.approval_status = MCPApprovalStatus.rejected
mock_manager = MagicMock()
mock_manager.reload_servers_from_database = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=MagicMock(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=active_server),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.reject_mcp_server",
AsyncMock(return_value=now_rejected),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
),
):
result = await reject_mcp_server_submission(
server_id=active_server.server_id,
payload=RejectMCPServerRequest(review_notes="policy violation"),
user_api_key_dict=admin,
)
assert result is not None
mock_manager.reload_servers_from_database.assert_awaited_once()
class TestValidateMCPRequiredFields:
"""Tests for _validate_mcp_required_fields."""
def test_missing_required_field_raises_400(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_validate_mcp_required_fields,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
# source_url is absent
)
with patch_proxy_general_settings({"mcp_required_fields": ["source_url"]}):
with pytest.raises(HTTPException) as exc_info:
_validate_mcp_required_fields(payload)
assert exc_info.value.status_code == 400
assert "source_url" in str(exc_info.value.detail)
def test_auth_type_sentinel_treated_as_absent(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_validate_mcp_required_fields,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
auth_type=MCPAuth.none, # sentinel value — treated as absent
)
with patch_proxy_general_settings({"mcp_required_fields": ["auth_type"]}):
with pytest.raises(HTTPException) as exc_info:
_validate_mcp_required_fields(payload)
assert exc_info.value.status_code == 400
assert "auth_type" in str(exc_info.value.detail)
def test_all_required_fields_present_passes(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_validate_mcp_required_fields,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
source_url="https://github.com/org/repo",
auth_type=MCPAuth.bearer_token,
)
with patch_proxy_general_settings(
{"mcp_required_fields": ["source_url", "auth_type"]}
):
# Should not raise
_validate_mcp_required_fields(payload)
def test_no_required_fields_configured_always_passes(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_validate_mcp_required_fields,
)
payload = NewMCPServerRequest(
alias="Minimal",
url="https://example.com/mcp",
transport=MCPTransport.sse,
)
with patch_proxy_general_settings({}):
# Should not raise when no required fields are configured
_validate_mcp_required_fields(payload)
def test_unknown_field_name_in_config_raises_500(self):
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
_validate_mcp_required_fields,
)
payload = NewMCPServerRequest(
alias="My Server",
url="https://example.com/mcp",
transport=MCPTransport.sse,
)
# "source_Url" is a typo — not a real field on NewMCPServerRequest
with patch_proxy_general_settings({"mcp_required_fields": ["source_Url"]}):
with pytest.raises(HTTPException) as exc_info:
_validate_mcp_required_fields(payload)
assert exc_info.value.status_code == 500
assert "source_Url" in str(exc_info.value.detail)

View File

@ -140,7 +140,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
<Tab>Guardrail Garden</Tab>
<Tab>Guardrails</Tab>
<Tab disabled={!accessToken || guardrailsList.length === 0}>Test Playground</Tab>
<Tab>Team Guardrails</Tab>
<Tab>Submitted Guardrails</Tab>
</TabList>
<TabPanels>

View File

@ -0,0 +1,72 @@
"use client";
import { MCPServer } from "./types";
export interface RequiredFieldDef {
key: string;
label: string;
description: string;
check: (server: MCPServer) => boolean;
}
export interface FieldGroup {
label: string;
fields: RequiredFieldDef[];
}
export const FIELD_GROUPS: FieldGroup[] = [
{
label: "Documentation",
fields: [
{
key: "description",
label: "Description",
description: "Must have a non-empty description",
check: (s) => !!s.description?.trim(),
},
{
key: "alias",
label: "Alias",
description: "Must have a display alias",
check: (s) => !!s.alias?.trim(),
},
],
},
{
label: "Source",
fields: [
{
key: "source_url",
label: "GitHub / Source URL",
description: "Must link to a source repository",
check: (s) => !!s.source_url?.trim(),
},
],
},
{
label: "Connection",
fields: [
{
key: "url",
label: "Server URL",
description: "Must have a URL configured",
check: (s) => !!s.url?.trim(),
},
],
},
{
label: "Security",
fields: [
{
key: "auth_type",
label: "Auth configured",
description: "Must use authentication (not 'none')",
check: (s) => !!s.auth_type && s.auth_type !== "none",
},
],
},
];
export const MCP_REQUIRED_FIELD_DEFS: RequiredFieldDef[] = FIELD_GROUPS.flatMap((g) => g.fields);
export const SETTINGS_KEY = "mcp_required_fields";

View File

@ -0,0 +1,660 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import {
SearchIcon,
CheckIcon,
XIcon,
AlertCircleIcon,
ServerIcon,
ChevronDownIcon,
ChevronUpIcon,
SettingsIcon,
} from "lucide-react";
import {
fetchMCPSubmissions,
approveMCPServer,
rejectMCPServer,
getGeneralSettingsCall,
updateConfigFieldSetting,
} from "@/components/networking";
import { MCPServer, MCPSubmissionsSummary } from "./types";
import { FIELD_GROUPS, MCP_REQUIRED_FIELD_DEFS, SETTINGS_KEY } from "./MCPStandardsSettings";
import NotificationsManager from "@/components/molecules/notifications_manager";
type MCPStatus = "active" | "pending_review" | "rejected";
const STATUS_CONFIG: Record<
MCPStatus,
{ label: string; bg: string; text: string; dot: string }
> = {
active: {
label: "Active",
bg: "bg-green-50",
text: "text-green-700",
dot: "bg-green-500",
},
pending_review: {
label: "Pending Review",
bg: "bg-yellow-50",
text: "text-yellow-700",
dot: "bg-yellow-500",
},
rejected: {
label: "Rejected",
bg: "bg-red-50",
text: "text-red-700",
dot: "bg-red-500",
},
};
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
try {
const d = new Date(value);
return isNaN(d.getTime()) ? value : d.toISOString().slice(0, 10);
} catch {
return value;
}
}
function StatCard({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) {
return (
<div className="bg-white border border-gray-200 rounded-lg px-4 py-3">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-xs text-gray-500 mt-0.5">{label}</div>
</div>
);
}
type ConfirmDialogProps = {
action: "approve" | "reject";
serverName: string;
isCurrentlyActive?: boolean;
onConfirm: (reviewNotes?: string) => void;
onCancel: () => void;
};
function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCancel }: ConfirmDialogProps) {
const [reviewNotes, setReviewNotes] = useState("");
const isApprove = action === "approve";
const rejectBody = isCurrentlyActive
? "This server is currently live. Rejecting it will immediately remove it from the proxy runtime."
: "This will mark the submission as rejected.";
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-4 ${
isApprove ? "bg-green-100" : "bg-red-100"
}`}
>
{isApprove ? (
<CheckIcon className="h-5 w-5 text-green-600" />
) : (
<AlertCircleIcon className="h-5 w-5 text-red-600" />
)}
</div>
<h3 className="text-base font-semibold text-gray-900 mb-1">
{isApprove ? "Approve MCP Server" : "Reject MCP Server"}
</h3>
<p className="text-sm text-gray-500 mb-4">
Are you sure you want to {action}{" "}
<span className="font-medium text-gray-700">&quot;{serverName}&quot;</span>?{" "}
{isApprove
? "This will make it active and available for use."
: rejectBody}
</p>
{!isApprove && (
<textarea
placeholder="Reason for rejection (optional)"
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
className="w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 mb-4 resize-none"
rows={3}
/>
)}
<div className="flex gap-3">
<button
type="button"
onClick={onCancel}
className="flex-1 border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium py-2 rounded-md transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm(isApprove ? undefined : reviewNotes || undefined)}
className={`flex-1 text-white text-sm font-medium py-2 rounded-md transition-colors ${
isApprove ? "bg-green-500 hover:bg-green-600" : "bg-red-500 hover:bg-red-600"
}`}
>
{isApprove ? "Approve" : "Reject"}
</button>
</div>
</div>
</div>
);
}
type SubmissionRulesPanelProps = {
requiredFields: string[];
onChange: (fields: string[]) => void;
onSave: () => Promise<void>;
isSaving: boolean;
};
function SubmissionRulesPanel({ requiredFields, onChange, onSave, isSaving }: SubmissionRulesPanelProps) {
const [expanded, setExpanded] = useState(false);
const activeLabels = MCP_REQUIRED_FIELD_DEFS.filter((f) => requiredFields.includes(f.key));
const toggle = (key: string) => {
onChange(requiredFields.includes(key) ? requiredFields.filter((k) => k !== key) : [...requiredFields, key]);
};
return (
<div className="mb-5 border border-gray-200 rounded-lg bg-white overflow-hidden">
{/* Header — always visible */}
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer select-none"
onClick={() => setExpanded((v) => !v)}
>
<div className="flex items-center gap-2">
<SettingsIcon className="h-4 w-4 text-gray-400" />
<span className="text-sm font-semibold text-gray-800">Submission Rules</span>
{activeLabels.length > 0 ? (
<span className="text-xs text-gray-500">
({activeLabels.length} required field{activeLabels.length !== 1 ? "s" : ""})
</span>
) : (
<span className="text-xs text-gray-400 italic">no rules set</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Active rule chips — collapsed view */}
{!expanded && activeLabels.length > 0 && (
<div className="flex flex-wrap gap-1.5 max-w-md">
{activeLabels.map((f) => (
<span
key={f.key}
className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-full"
>
<CheckIcon className="h-3 w-3" />
{f.label}
</span>
))}
</div>
)}
{expanded ? (
<ChevronUpIcon className="h-4 w-4 text-gray-400" />
) : (
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
{/* Expanded editor */}
{expanded && (
<div className="border-t border-gray-100 px-4 pt-4 pb-4">
<p className="text-xs text-gray-500 mb-4">
Select which fields must be filled in before a submission is considered compliant.
LiteLLM will show / for each rule on every submission card below.
</p>
<div className="grid grid-cols-2 gap-x-8 gap-y-5">
{FIELD_GROUPS.map((group) => (
<div key={group.label}>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
{group.label}
</div>
<div className="space-y-2">
{group.fields.map((field) => {
const active = requiredFields.includes(field.key);
return (
<label
key={field.key}
className="flex items-start gap-2.5 cursor-pointer group"
>
<input
type="checkbox"
checked={active}
onChange={() => toggle(field.key)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<div>
<div className="text-sm font-medium text-gray-800 group-hover:text-blue-700 transition-colors">
{field.label}
</div>
<div className="text-xs text-gray-400">{field.description}</div>
</div>
</label>
);
})}
</div>
</div>
))}
</div>
<div className="mt-5 flex items-center gap-3">
<button
type="button"
disabled={isSaving}
onClick={async () => {
await onSave();
setExpanded(false);
}}
className="px-4 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-md transition-colors"
>
{isSaving ? "Saving…" : "Save Rules"}
</button>
<button
type="button"
onClick={() => setExpanded(false)}
className="px-4 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 border border-gray-200 rounded-md hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
type MCPServerCardProps = {
server: MCPServer;
onApprove: () => void;
onReject: () => void;
requiredFields: string[];
};
function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServerCardProps) {
const approvalStatus = (server.approval_status ?? "active") as MCPStatus;
const statusCfg = STATUS_CONFIG[approvalStatus] ?? STATUS_CONFIG["active"];
const checks = MCP_REQUIRED_FIELD_DEFS.filter((f) => requiredFields.includes(f.key)).map((f) => ({
key: f.key,
label: f.label,
description: f.description,
passed: f.check(server),
}));
const passCount = checks.filter((c) => c.passed).length;
const failCount = checks.length - passCount;
const allPassed = checks.length > 0 && failCount === 0;
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
{/* Server info */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${statusCfg.bg} ${statusCfg.text}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusCfg.dot}`} />
{statusCfg.label}
</span>
</div>
<h3 className="text-sm font-semibold text-gray-900">
{server.alias ?? server.server_name ?? server.server_id}
</h3>
{server.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-1">{server.description}</p>
)}
{server.url && (
<div className="flex items-center gap-1.5 mt-1.5">
<ServerIcon className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
<code className="text-xs text-gray-500 font-mono truncate">{server.url}</code>
</div>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-400">
<span>Transport: <span className="text-gray-600">{server.transport ?? "sse"}</span></span>
<span>·</span>
<span>Submitted by: <span className="text-gray-600">{server.submitted_by ?? "—"}</span></span>
<span>·</span>
<span>{formatDate(server.submitted_at)}</span>
</div>
{approvalStatus === "rejected" && server.review_notes && (
<p className="text-xs text-red-600 mt-1.5">Rejection reason: {server.review_notes}</p>
)}
</div>
{/* Approve/Reject when no checks panel (no rules configured) */}
{checks.length === 0 && approvalStatus !== "rejected" && (
<div className="flex items-center gap-2 flex-shrink-0">
{approvalStatus !== "active" && (
<button
type="button"
onClick={onApprove}
className="text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Approve
</button>
)}
<button
type="button"
onClick={onReject}
className="text-xs border border-red-300 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-md transition-colors font-medium"
>
Reject
</button>
</div>
)}
{checks.length === 0 && approvalStatus === "rejected" && (
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={onApprove}
className="text-xs bg-green-500 hover:bg-green-600 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Re-approve
</button>
</div>
)}
</div>
</div>
{/* GitHub-style checks panel */}
{checks.length > 0 && (
<div className="border-t border-gray-200">
{/* Overall status header */}
<div
className={`flex items-center gap-3 px-4 py-3 ${
allPassed ? "bg-green-50 border-b border-green-100" : "bg-red-50 border-b border-red-100"
}`}
>
{/* Large status circle */}
<div
className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
allPassed ? "bg-green-500" : "bg-red-500"
}`}
>
{allPassed ? (
<CheckIcon className="h-4 w-4 text-white" />
) : (
<XIcon className="h-4 w-4 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-tight ${allPassed ? "text-green-800" : "text-red-800"}`}>
{allPassed
? "All checks passed"
: `${failCount} check${failCount !== 1 ? "s" : ""} failed`}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{passCount} passing, {failCount} failing
</div>
</div>
{/* Approve / Reject in header */}
<div className="flex items-center gap-2 flex-shrink-0">
{approvalStatus !== "active" && approvalStatus !== "rejected" && (
<button
type="button"
onClick={onApprove}
className="text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Approve
</button>
)}
{approvalStatus === "rejected" && (
<button
type="button"
onClick={onApprove}
className="text-xs bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Re-approve
</button>
)}
{approvalStatus !== "rejected" && (
<button
type="button"
onClick={onReject}
className="text-xs border border-red-300 text-red-600 hover:bg-red-50 bg-white px-3 py-1.5 rounded-md transition-colors font-medium"
>
Reject
</button>
)}
</div>
</div>
{/* Individual check rows */}
<div className="divide-y divide-gray-100">
{checks.map((c) => (
<div key={c.key} className="flex items-center gap-3 px-4 py-2.5">
{/* Small circle icon */}
<div
className={`w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0 ${
c.passed ? "bg-green-100" : "bg-red-100"
}`}
>
{c.passed ? (
<CheckIcon className="h-3 w-3 text-green-600" />
) : (
<XIcon className="h-3 w-3 text-red-600" />
)}
</div>
<span className={`text-sm flex-1 ${c.passed ? "text-gray-700" : "text-gray-800"}`}>
{c.label}
</span>
<span className={`text-xs ${c.passed ? "text-green-600" : "text-red-500"}`}>
{c.passed ? "Passes" : "Missing"}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
interface MCPSubmissionsTabProps {
accessToken: string | null;
}
export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
const [summary, setSummary] = useState<MCPSubmissionsSummary>({
total: 0,
pending_review: 0,
active: 0,
rejected: 0,
items: [],
});
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | MCPStatus>("all");
const [confirmAction, setConfirmAction] = useState<{
serverId: string;
serverName: string;
action: "approve" | "reject";
isCurrentlyActive?: boolean;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [requiredFields, setRequiredFields] = useState<string[]>([]);
const [isSavingRules, setIsSavingRules] = useState(false);
const fetchData = useCallback(async () => {
if (!accessToken) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const [res, settings] = await Promise.all([
fetchMCPSubmissions(accessToken),
getGeneralSettingsCall(accessToken).catch((err) => {
console.warn("MCPSubmissionsTab: failed to load general settings, compliance rules will be empty:", err);
return null;
}),
]);
setSummary(res);
if (settings?.data && Array.isArray(settings.data)) {
const row = settings.data.find(
(r: { field_name: string; field_value: unknown }) => r.field_name === SETTINGS_KEY,
);
if (row && Array.isArray(row.field_value)) {
setRequiredFields(row.field_value as string[]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load submissions");
} finally {
setIsLoading(false);
}
}, [accessToken]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSaveRules = async () => {
if (!accessToken) return;
setIsSavingRules(true);
try {
await updateConfigFieldSetting(accessToken, SETTINGS_KEY, requiredFields);
NotificationsManager.success("Submission rules saved");
} catch {
NotificationsManager.fromBackend("Failed to save submission rules");
} finally {
setIsSavingRules(false);
}
};
const filtered = summary.items.filter((s) => {
if (statusFilter !== "all" && s.approval_status !== statusFilter) return false;
if (search.trim()) {
const q = search.toLowerCase();
const name = (s.alias ?? s.server_name ?? s.server_id ?? "").toLowerCase();
const url = (s.url ?? "").toLowerCase();
return name.includes(q) || url.includes(q);
}
return true;
});
async function handleApprove(serverId: string, serverName: string) {
if (!accessToken) return;
try {
await approveMCPServer(accessToken, serverId);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" approved`);
} catch {
NotificationsManager.fromBackend("Failed to approve MCP server");
} finally {
setConfirmAction(null);
}
}
async function handleReject(serverId: string, serverName: string, reviewNotes?: string) {
if (!accessToken) return;
try {
await rejectMCPServer(accessToken, serverId, reviewNotes);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" rejected`);
} catch {
NotificationsManager.fromBackend("Failed to reject MCP server");
} finally {
setConfirmAction(null);
}
}
return (
<div className="p-6">
{/* Submission Rules panel */}
<SubmissionRulesPanel
requiredFields={requiredFields}
onChange={setRequiredFields}
onSave={handleSaveRules}
isSaving={isSavingRules}
/>
<div className="grid grid-cols-4 gap-4 mb-6">
<StatCard label="Total Submitted" value={summary.total} color="text-gray-900" />
<StatCard label="Pending Review" value={summary.pending_review} color="text-yellow-600" />
<StatCard label="Active" value={summary.active} color="text-green-600" />
<StatCard label="Rejected" value={summary.rejected} color="text-red-600" />
</div>
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1 max-w-xs">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search MCP servers..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="all">All Status</option>
<option value="pending_review">Pending Review</option>
<option value="active">Active</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="space-y-3">
{isLoading && (
<div className="text-center py-12 text-gray-500 text-sm">Loading submissions</div>
)}
{error && (
<div className="text-center py-12 text-red-600 text-sm">{error}</div>
)}
{!isLoading && !error && filtered.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm">
No MCP server submissions match your filters.
</div>
)}
{!isLoading &&
!error &&
filtered.map((server) => (
<MCPServerCard
key={server.server_id}
server={server}
requiredFields={requiredFields}
onApprove={() =>
setConfirmAction({
serverId: server.server_id,
serverName: server.alias ?? server.server_name ?? server.server_id,
action: "approve",
})
}
onReject={() =>
setConfirmAction({
serverId: server.server_id,
serverName: server.alias ?? server.server_name ?? server.server_id,
action: "reject",
isCurrentlyActive: server.approval_status === "active",
})
}
/>
))}
</div>
{confirmAction && (
<ConfirmDialog
action={confirmAction.action}
serverName={confirmAction.serverName}
isCurrentlyActive={confirmAction.isCurrentlyActive}
onConfirm={(reviewNotes) =>
confirmAction.action === "approve"
? handleApprove(confirmAction.serverId, confirmAction.serverName)
: handleReject(confirmAction.serverId, confirmAction.serverName, reviewNotes)
}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}

View File

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, Switch } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { createMCPServer, registerMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
@ -373,9 +373,15 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
console.log(`Payload: ${JSON.stringify(payload)}`);
if (accessToken != null) {
const response = await createMCPServer(accessToken, payload);
const response = isAdmin
? await createMCPServer(accessToken, payload)
: await registerMCPServer(accessToken, payload);
NotificationsManager.success("MCP Server created successfully");
NotificationsManager.success(
isAdmin
? "MCP Server created successfully"
: "MCP Server submitted for admin review"
);
form.resetFields();
setCostConfig({});
setTools([]);
@ -385,7 +391,10 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
onCreateSuccess(response);
}
} catch (error) {
NotificationsManager.fromBackend("Error creating MCP Server: " + error);
const reason = error instanceof Error ? error.message : String(error);
NotificationsManager.fromBackend(
isAdmin ? `Error creating MCP Server: ${reason}` : `Error submitting MCP Server: ${reason}`
);
} finally {
setIsLoading(false);
}
@ -461,11 +470,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
}, [isModalVisible]);
// rendering
if (!isAdminRole(userRole)) {
return null;
}
const isAdmin = isAdminRole(userRole);
// rendering
return (
<Modal
title={
@ -489,7 +496,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
objectFit: "contain",
}}
/>
<h2 className="text-xl font-semibold text-gray-900">Add New MCP Server</h2>
<h2 className="text-xl font-semibold text-gray-900">
{isAdmin ? "Add New MCP Server" : "Submit MCP Server for Review"}
</h2>
</div>
}
open={isModalVisible}
@ -510,6 +519,12 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
layout="vertical"
className="space-y-6"
>
{!isAdmin && (
<div className="rounded-md bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-800">
Your submission will be sent for admin review before it becomes active.
{" "}Note: the request must be made with a team-scoped API key.
</div>
)}
<div className="grid grid-cols-1 gap-6">
<Form.Item
label={
@ -567,6 +582,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
/>
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
name="source_url"
>
<TextInput
placeholder="https://github.com/org/mcp-server"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Transport Type</span>}
name="transport"

View File

@ -1,12 +1,14 @@
import { isAdminRole } from "@/utils/roles";
import { QuestionCircleOutlined } from "@ant-design/icons";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import NewBadge from "../common_components/NewBadge";
import { Descriptions, Modal, Select, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
import NotificationsManager from "../molecules/notifications_manager";
import { deleteMCPServer } from "../networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { DataTable } from "../view_logs/table";
import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
@ -299,11 +301,25 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
<Text className="text-tremor-content mt-1">Configure and manage your MCP servers</Text>
</div>
{isAdminRole(userRole) && (
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
<div className="flex items-center gap-2">
{isAdminRole(userRole) && (
<Button className="flex-shrink-0" onClick={() => setDiscoveryVisible(true)}>
+ Add New MCP Server
</Button>
)}
{!isAdminRole(userRole) && (
<Button
className="flex-shrink-0"
onClick={() => {
setPrefillData(null);
setModalVisible(true);
}}
variant="secondary"
>
+ Submit MCP Server
</Button>
)}
</div>
</div>
<MCPDiscovery
isVisible={isDiscoveryVisible}
@ -327,6 +343,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<Tab>Connect</Tab>
<Tab>Semantic Filter</Tab>
<Tab>Network Settings</Tab>
{isAdminRole(userRole) && <Tab><span className="flex items-center gap-2">Submitted MCPs <NewBadge /></span></Tab>}
</div>
</TabList>
<TabPanels>
@ -410,6 +427,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<TabPanel>
<MCPNetworkSettings accessToken={accessToken} />
</TabPanel>
{isAdminRole(userRole) && (
<TabPanel>
<MCPSubmissionsTab accessToken={accessToken} />
</TabPanel>
)}
</TabPanels>
</TabGroup>

View File

@ -185,6 +185,16 @@ export interface MCPServer {
byok_description?: string[] | null;
byok_api_key_help_url?: string | null;
has_user_credential?: boolean | null;
/** GitHub / source repository URL */
source_url?: string | null;
/** BYOM (Bring Your Own MCP) submission fields */
approval_status?: "active" | "pending_review" | "rejected" | null;
submitted_by?: string | null;
submitted_at?: string | null;
reviewed_at?: string | null;
review_notes?: string | null;
}
export interface MCPServerProps {
@ -212,3 +222,11 @@ export interface DiscoverMCPServersResponse {
servers: DiscoverableMCPServer[];
categories: string[];
}
export interface MCPSubmissionsSummary {
total: number;
pending_review: number;
active: number;
rejected: number;
items: MCPServer[];
}

View File

@ -6497,6 +6497,99 @@ export const deleteMCPServer = async (accessToken: string, serverId: string) =>
}
};
export const registerMCPServer = async (accessToken: string, formValues: Record<string, any>) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/register`;
const response = await fetch(url, {
method: HTTP_REQUEST.POST,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(formValues),
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error("Failed to register MCP server:", error);
throw error;
}
};
export const fetchMCPSubmissions = async (accessToken: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/submissions`;
const response = await fetch(url, {
method: HTTP_REQUEST.GET,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error("Failed to fetch MCP submissions:", error);
throw error;
}
};
export const approveMCPServer = async (accessToken: string, serverId: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/${encodeURIComponent(serverId)}/approve`;
const response = await fetch(url, {
method: HTTP_REQUEST.PUT,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error("Failed to approve MCP server:", error);
throw error;
}
};
export const rejectMCPServer = async (accessToken: string, serverId: string, reviewNotes?: string) => {
try {
const url = (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/${encodeURIComponent(serverId)}/reject`;
const response = await fetch(url, {
method: HTTP_REQUEST.PUT,
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ review_notes: reviewNotes ?? null }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
return response.json();
} catch (error) {
console.error("Failed to reject MCP server:", error);
throw error;
}
};
// Search Tools API calls
export const fetchSearchTools = async (accessToken: string) => {
try {