diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000000_add_mcp_approval_status/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000000_add_mcp_approval_status/migration.sql new file mode 100644 index 00000000000..184caef0809 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000000_add_mcp_approval_status/migration.sql @@ -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"); diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 4c6735bacd3..6e69f36b3db 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -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] = "active", ) -> 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,70 @@ 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": "asc"}, + ) + 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, + ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 0b58009fcf6..181bfb1dcbc 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -2270,7 +2270,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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d797d9c7e0a..fe407456077 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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,10 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None + # BYOM submission fields (set by endpoint, not by caller) + approval_status: Optional[str] = None + submitted_by: Optional[str] = None + submitted_at: Optional[datetime] = None @model_validator(mode="before") @classmethod @@ -1239,6 +1249,15 @@ 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 + # 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 +1274,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 ######## diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f7a4cec301b..62718488eb0 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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, @@ -689,6 +695,177 @@ 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. + """ + from datetime import datetime, timezone + + 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) + + 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 LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: + 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 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.pending_review: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": f"MCP server is not pending review (approval_status={existing.approval_status})." + }, + ) + + 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.add_server(approved) + 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)], + ) + @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.pending_review: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": f"MCP server is not pending review (approval_status={existing.approval_status})." + }, + ) + + 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, + ) + return _redact_mcp_credentials(rejected) + @router.get( "/server/{server_id}", description="Returns the mcp server info", diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 8d4bdffb2dd..36fba408b35 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -315,6 +315,14 @@ model LiteLLM_MCPServerTable { is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_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 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx new file mode 100644 index 00000000000..f620a159a9c --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx @@ -0,0 +1,375 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + SearchIcon, + CheckIcon, + XIcon, + AlertCircleIcon, + ServerIcon, +} from "lucide-react"; +import { + fetchMCPSubmissions, + approveMCPServer, + rejectMCPServer, +} from "@/components/networking"; +import { MCPServer, MCPSubmissionsSummary } from "./types"; +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 ( +
+
{value}
+
{label}
+
+ ); +} + +type ConfirmDialogProps = { + action: "approve" | "reject"; + serverName: string; + onConfirm: (reviewNotes?: string) => void; + onCancel: () => void; +}; + +function ConfirmDialog({ action, serverName, onConfirm, onCancel }: ConfirmDialogProps) { + const [reviewNotes, setReviewNotes] = useState(""); + const isApprove = action === "approve"; + return ( +
+
+
+ {isApprove ? ( + + ) : ( + + )} +
+

+ {isApprove ? "Approve MCP Server" : "Reject MCP Server"} +

+

+ Are you sure you want to {action}{" "} + "{serverName}"?{" "} + {isApprove + ? "This will make it active and available for use." + : "This will mark it as rejected."} +

+ {!isApprove && ( +