From 373e5e316b13fe06f21d58dd38fe546229bc22da Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 10 Mar 2026 13:58:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20BYOM=20=E2=80=94=20non-admin=20MCP?= =?UTF-8?q?=20server=20submission=20+=20admin=20review=20workflow=20(#2320?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../migration.sql | 11 + .../migration.sql | 3 + litellm/proxy/_experimental/mcp_server/db.py | 82 ++- .../mcp_server/mcp_server_manager.py | 2 +- litellm/proxy/_types.py | 45 ++ .../mcp_management_endpoints.py | 249 ++++++- litellm/proxy/proxy_server.py | 21 +- litellm/proxy/schema.prisma | 9 + .../test_mcp_management_endpoints.py | 342 +++++++++ .../src/components/guardrails.tsx | 2 +- .../mcp_tools/MCPStandardsSettings.tsx | 72 ++ .../mcp_tools/MCPSubmissionsTab.tsx | 660 ++++++++++++++++++ .../mcp_tools/create_mcp_server.tsx | 43 +- .../src/components/mcp_tools/mcp_servers.tsx | 32 +- .../src/components/mcp_tools/types.tsx | 18 + .../src/components/networking.tsx | 93 +++ 16 files changed, 1659 insertions(+), 25 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000000_add_mcp_approval_status/migration.sql create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000001_add_mcp_source_url/migration.sql create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx 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 0000000000..184caef080 --- /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-extras/litellm_proxy_extras/migrations/20260309000001_add_mcp_source_url/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000001_add_mcp_source_url/migration.sql new file mode 100644 index 0000000000..dc468b8206 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260309000001_add_mcp_source_url/migration.sql @@ -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; diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 4c6735bacd..93580b5430 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] = 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, + ) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 2bdc47bf2c..48ea3d384a 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d797d9c7e0..36790e9fea 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,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): diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f7a4cec301..14d9e6d0b2 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -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( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f3bc4b0803..e6bb3ee412 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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 = [] diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 8d4bdffb2d..721c3e404d 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index e81c6264f7..30b3be4a3e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -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) diff --git a/ui/litellm-dashboard/src/components/guardrails.tsx b/ui/litellm-dashboard/src/components/guardrails.tsx index aa31c3af61..56bba2724d 100644 --- a/ui/litellm-dashboard/src/components/guardrails.tsx +++ b/ui/litellm-dashboard/src/components/guardrails.tsx @@ -140,7 +140,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole Guardrail Garden Guardrails Test Playground - Team Guardrails + Submitted Guardrails diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx new file mode 100644 index 0000000000..fb38e39263 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPStandardsSettings.tsx @@ -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"; 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 0000000000..4ce7423f1b --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPSubmissionsTab.tsx @@ -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 ( +
+
{value}
+
{label}
+
+ ); +} + +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 ( +
+
+
+ {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." + : rejectBody} +

+ {!isApprove && ( +