mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
parent
2c738cc939
commit
8fcaa7c9fc
11 changed files with 828 additions and 12 deletions
|
|
@ -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");
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ########
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">"{serverName}"</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue