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:
Ishaan Jaffer 2026-03-09 16:22:11 -07:00
parent 2cb81d0ed4
commit 9acb0636fc
8 changed files with 242 additions and 2 deletions

View file

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

View file

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

View file

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

View file

@ -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>
);
}

View file

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

View file

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

View file

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

View file

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