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:
parent
6a3b029066
commit
373e5e316b
@ -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");
|
||||
@ -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;
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
@ -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">"{serverName}"</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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user