mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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
This commit is contained in:
parent
2cb81d0ed4
commit
9acb0636fc
8 changed files with 242 additions and 2 deletions
|
|
@ -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;
|
||||
|
|
@ -1123,6 +1123,7 @@ 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 endpoint, not by caller)
|
||||
approval_status: Optional[str] = None
|
||||
submitted_by: Optional[str] = None
|
||||
|
|
@ -1186,6 +1187,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
|
||||
|
|
@ -1249,6 +1251,7 @@ 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",
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ 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?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { MCPServer } from "./types";
|
||||
|
||||
interface MCPStandardsSettingsProps {
|
||||
accessToken: string | null;
|
||||
}
|
||||
|
||||
export interface RequiredFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
check: (server: MCPServer) => boolean;
|
||||
}
|
||||
|
||||
export const MCP_REQUIRED_FIELD_DEFS: RequiredFieldDef[] = [
|
||||
{
|
||||
key: "description",
|
||||
label: "Description",
|
||||
description: "Server must have a non-empty description.",
|
||||
check: (s) => !!s.description?.trim(),
|
||||
},
|
||||
{
|
||||
key: "source_url",
|
||||
label: "GitHub / Source URL",
|
||||
description: "Server must have a link to the source repository.",
|
||||
check: (s) => !!s.source_url?.trim(),
|
||||
},
|
||||
{
|
||||
key: "alias",
|
||||
label: "Alias",
|
||||
description: "Server must have a human-readable alias.",
|
||||
check: (s) => !!s.alias?.trim(),
|
||||
},
|
||||
{
|
||||
key: "auth_type",
|
||||
label: "Auth configured",
|
||||
description: "Server must have an auth type set (not 'none').",
|
||||
check: (s) => !!s.auth_type && s.auth_type !== "none",
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
label: "Server URL",
|
||||
description: "Server must have a URL configured.",
|
||||
check: (s) => !!s.url?.trim(),
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_KEY = "mcp_required_fields";
|
||||
|
||||
export default function MCPStandardsSettings({ accessToken }: MCPStandardsSettingsProps) {
|
||||
const [requiredFields, setRequiredFields] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
if (!accessToken) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const settings = await getGeneralSettingsCall(accessToken);
|
||||
const rows: Array<{ field_name: string; field_value: unknown }> = Array.isArray(settings?.data)
|
||||
? settings.data
|
||||
: [];
|
||||
const row = rows.find((r) => r.field_name === SETTINGS_KEY);
|
||||
if (row && Array.isArray(row.field_value)) {
|
||||
setRequiredFields(row.field_value as string[]);
|
||||
}
|
||||
} catch {
|
||||
// leave defaults
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const toggleField = (key: string) => {
|
||||
setRequiredFields((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!accessToken) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateConfigFieldSetting(accessToken, SETTINGS_KEY, requiredFields);
|
||||
NotificationsManager.success("Standards saved");
|
||||
} catch {
|
||||
NotificationsManager.fromBackend("Failed to save standards");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-gray-900">MCP Submission Standards</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Choose which fields are required for a submission to pass your standards. Each submission
|
||||
card in the Team MCPs tab will show a green ✓ or red ✗ for each requirement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-400">Loading…</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{MCP_REQUIRED_FIELD_DEFS.map((field) => {
|
||||
const enabled = requiredFields.includes(field.key);
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`flex items-center justify-between px-4 py-3 rounded-lg border transition-colors ${
|
||||
enabled ? "border-blue-200 bg-blue-50" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="text-sm font-medium text-gray-900">{field.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{field.description}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => toggleField(field.key)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
|
||||
enabled ? "bg-blue-500" : "bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
enabled ? "translate-x-4" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving || isLoading}
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-md transition-colors"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Standards"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,8 +12,10 @@ import {
|
|||
fetchMCPSubmissions,
|
||||
approveMCPServer,
|
||||
rejectMCPServer,
|
||||
getGeneralSettingsCall,
|
||||
} from "@/components/networking";
|
||||
import { MCPServer, MCPSubmissionsSummary } from "./types";
|
||||
import { MCP_REQUIRED_FIELD_DEFS } from "./MCPStandardsSettings";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
type MCPStatus = "active" | "pending_review" | "rejected";
|
||||
|
|
@ -139,12 +141,21 @@ type MCPServerCardProps = {
|
|||
server: MCPServer;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
requiredFields: string[];
|
||||
};
|
||||
|
||||
function MCPServerCard({ server, onApprove, onReject }: MCPServerCardProps) {
|
||||
function MCPServerCard({ server, onApprove, onReject, requiredFields }: MCPServerCardProps) {
|
||||
const approvalStatus = (server.approval_status ?? "active") as MCPStatus;
|
||||
const statusCfg = STATUS_CONFIG[approvalStatus] ?? STATUS_CONFIG["active"];
|
||||
|
||||
const checks = MCP_REQUIRED_FIELD_DEFS.filter((f) => requiredFields.includes(f.key)).map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
passed: f.check(server),
|
||||
}));
|
||||
const passCount = checks.filter((c) => c.passed).length;
|
||||
const allPassed = checks.length > 0 && passCount === checks.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -156,6 +167,15 @@ function MCPServerCard({ server, onApprove, onReject }: MCPServerCardProps) {
|
|||
<span className={`w-1.5 h-1.5 rounded-full ${statusCfg.dot}`} />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
{checks.length > 0 && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
allPassed ? "bg-green-50 text-green-700" : "bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{passCount}/{checks.length} checks
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||
{server.alias ?? server.server_name ?? server.server_id}
|
||||
|
|
@ -188,6 +208,25 @@ function MCPServerCard({ server, onApprove, onReject }: MCPServerCardProps) {
|
|||
Rejection reason: {server.review_notes}
|
||||
</p>
|
||||
)}
|
||||
{checks.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2 pt-2 border-t border-gray-100">
|
||||
{checks.map((c) => (
|
||||
<span
|
||||
key={c.key}
|
||||
className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full ${
|
||||
c.passed ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{c.passed ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<XIcon className="h-3 w-3" />
|
||||
)}
|
||||
{c.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{approvalStatus === "pending_review" && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
|
|
@ -233,6 +272,7 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
} | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [requiredFields, setRequiredFields] = useState<string[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -242,8 +282,19 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res: MCPSubmissionsSummary = await fetchMCPSubmissions(accessToken);
|
||||
const [res, settings] = await Promise.all([
|
||||
fetchMCPSubmissions(accessToken),
|
||||
getGeneralSettingsCall(accessToken).catch(() => null),
|
||||
]);
|
||||
setSummary(res);
|
||||
if (settings?.data && Array.isArray(settings.data)) {
|
||||
const row = settings.data.find(
|
||||
(r: { field_name: string; field_value: unknown }) => r.field_name === "mcp_required_fields",
|
||||
);
|
||||
if (row && Array.isArray(row.field_value)) {
|
||||
setRequiredFields(row.field_value as string[]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load submissions");
|
||||
} finally {
|
||||
|
|
@ -340,6 +391,7 @@ export function MCPSubmissionsTab({ accessToken }: MCPSubmissionsTabProps) {
|
|||
<MCPServerCard
|
||||
key={server.server_id}
|
||||
server={server}
|
||||
requiredFields={requiredFields}
|
||||
onApprove={() =>
|
||||
setConfirmAction({
|
||||
serverId: server.server_id,
|
||||
|
|
|
|||
|
|
@ -574,6 +574,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">GitHub / Source URL</span>}
|
||||
name="source_url"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="https://github.com/org/mcp-server"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={<span className="text-sm font-medium text-gray-700">Transport Type</span>}
|
||||
name="transport"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilt
|
|||
import MCPNetworkSettings from "./MCPNetworkSettings";
|
||||
import MCPDiscovery from "./mcp_discovery";
|
||||
import { ByokCredentialModal } from "./ByokCredentialModal";
|
||||
import MCPStandardsSettings from "./MCPStandardsSettings";
|
||||
|
||||
const { Text: AntdText, Title: AntdTitle } = Typography;
|
||||
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
||||
|
|
@ -344,6 +345,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<Tab>Semantic Filter</Tab>
|
||||
<Tab>Network Settings</Tab>
|
||||
{isAdminRole(userRole) && <Tab><span className="flex items-center gap-2">Team MCPs <NewBadge /></span></Tab>}
|
||||
{isAdminRole(userRole) && <Tab>Standards</Tab>}
|
||||
</div>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
|
|
@ -432,6 +434,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
<MCPSubmissionsTab accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
)}
|
||||
{isAdminRole(userRole) && (
|
||||
<TabPanel>
|
||||
<MCPStandardsSettings accessToken={accessToken} />
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -185,6 +185,9 @@ export interface MCPServer {
|
|||
byok_api_key_help_url?: string | null;
|
||||
has_user_credential?: boolean | null;
|
||||
|
||||
/** GitHub / source repository URL */
|
||||
source_url?: string | null;
|
||||
|
||||
/** BYOM (Bring Your Own MCP) submission fields */
|
||||
approval_status?: "active" | "pending_review" | "rejected" | null;
|
||||
submitted_by?: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue