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)
This commit is contained in:
Ishaan Jaffer 2026-03-09 15:54:52 -07:00
parent 2c738cc939
commit 8fcaa7c9fc
11 changed files with 828 additions and 12 deletions

View file

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

View file

@ -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,
)

View file

@ -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

View file

@ -1087,6 +1087,12 @@ class SpecialMCPServerName(str, enum.Enum):
all_proxy_servers = "all-proxy-mcpservers"
class MCPApprovalStatus(str, enum.Enum):
pending_review = "pending_review"
active = "active"
rejected = "rejected"
# MCP Proxy Request Types
class NewMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: Optional[str] = None
@ -1117,6 +1123,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 ########

View file

@ -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",

View file

@ -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

View file

@ -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 (
<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;
onConfirm: (reviewNotes?: string) => void;
onCancel: () => void;
};
function ConfirmDialog({ action, serverName, onConfirm, onCancel }: ConfirmDialogProps) {
const [reviewNotes, setReviewNotes] = useState("");
const isApprove = action === "approve";
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-4 ${
isApprove ? "bg-green-100" : "bg-red-100"
}`}
>
{isApprove ? (
<CheckIcon className="h-5 w-5 text-green-600" />
) : (
<AlertCircleIcon className="h-5 w-5 text-red-600" />
)}
</div>
<h3 className="text-base font-semibold text-gray-900 mb-1">
{isApprove ? "Approve MCP Server" : "Reject MCP Server"}
</h3>
<p className="text-sm text-gray-500 mb-4">
Are you sure you want to {action}{" "}
<span className="font-medium text-gray-700">&quot;{serverName}&quot;</span>?{" "}
{isApprove
? "This will make it active and available for use."
: "This will mark it as rejected."}
</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 MCPServerCardProps = {
server: MCPServer;
onApprove: () => void;
onReject: () => void;
};
function MCPServerCard({ server, onApprove, onReject }: MCPServerCardProps) {
const approvalStatus = (server.approval_status ?? "active") as MCPStatus;
const statusCfg = STATUS_CONFIG[approvalStatus] ?? STATUS_CONFIG["active"];
return (
<div className="bg-white border border-gray-200 rounded-lg p-4">
<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 mb-1">
{server.alias ?? server.server_name ?? server.server_id}
</h3>
{server.description && (
<p className="text-xs text-gray-500 mb-2 line-clamp-1">{server.description}</p>
)}
{server.url && (
<div className="flex items-center gap-1.5 mb-2">
<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-4 text-xs text-gray-500">
<span>
Transport:{" "}
<span className="font-medium text-gray-700">{server.transport ?? "sse"}</span>
</span>
<span>
Submitted by:{" "}
<span className="font-medium text-gray-700">{server.submitted_by ?? "—"}</span>
</span>
<span>
Date:{" "}
<span className="font-medium text-gray-700">{formatDate(server.submitted_at)}</span>
</span>
</div>
{approvalStatus === "rejected" && server.review_notes && (
<p className="text-xs text-red-600 mt-1">
Rejection reason: {server.review_notes}
</p>
)}
</div>
{approvalStatus === "pending_review" && (
<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"
>
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>
)}
</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";
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!accessToken) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const res: MCPSubmissionsSummary = await fetchMCPSubmissions(accessToken);
setSummary(res);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load submissions");
} finally {
setIsLoading(false);
}
}, [accessToken]);
useEffect(() => {
fetchData();
}, [fetchData]);
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);
setConfirmAction(null);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" approved`);
} catch {
NotificationsManager.fromBackend("Failed to approve MCP server");
}
}
async function handleReject(serverId: string, serverName: string, reviewNotes?: string) {
if (!accessToken) return;
try {
await rejectMCPServer(accessToken, serverId, reviewNotes);
setConfirmAction(null);
await fetchData();
NotificationsManager.success(`MCP server "${serverName}" rejected`);
} catch {
NotificationsManager.fromBackend("Failed to reject MCP server");
}
}
return (
<div className="p-6">
<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}
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",
})
}
/>
))}
</div>
{confirmAction && (
<ConfirmDialog
action={confirmAction.action}
serverName={confirmAction.serverName}
onConfirm={(reviewNotes) =>
confirmAction.action === "approve"
? handleApprove(confirmAction.serverId, confirmAction.serverName)
: handleReject(confirmAction.serverId, confirmAction.serverName, reviewNotes)
}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}

View file

@ -2,7 +2,7 @@ import React, { useState } from "react";
import { Modal, Tooltip, Form, Select, Input, Switch } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { createMCPServer, registerMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
@ -373,9 +373,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
console.log(`Payload: ${JSON.stringify(payload)}`);
if (accessToken != null) {
const response = await createMCPServer(accessToken, payload);
const isAdmin = isAdminRole(userRole);
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([]);

View file

@ -6,7 +6,8 @@ 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 { deleteMCPServer, registerMCPServer } 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 +300,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 +342,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<Tab>Connect</Tab>
<Tab>Semantic Filter</Tab>
<Tab>Network Settings</Tab>
{isAdminRole(userRole) && <Tab>Submissions</Tab>}
</div>
</TabList>
<TabPanels>
@ -410,6 +426,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
<TabPanel>
<MCPNetworkSettings accessToken={accessToken} />
</TabPanel>
{isAdminRole(userRole) && (
<TabPanel>
<MCPSubmissionsTab accessToken={accessToken} />
</TabPanel>
)}
</TabPanels>
</TabGroup>

View file

@ -184,6 +184,13 @@ export interface MCPServer {
byok_description?: string[] | null;
byok_api_key_help_url?: string | null;
has_user_credential?: boolean | 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 {
@ -211,3 +218,11 @@ export interface DiscoverMCPServersResponse {
servers: DiscoverableMCPServer[];
categories: string[];
}
export interface MCPSubmissionsSummary {
total: number;
pending_review: number;
active: number;
rejected: number;
items: MCPServer[];
}

View file

@ -6497,6 +6497,100 @@ 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}`,
"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 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 {