diff --git a/litellm/__init__.py b/litellm/__init__.py index 2624565c2c3..b46a165ed10 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -388,6 +388,7 @@ disable_add_prefix_to_prompt: bool = ( disable_copilot_system_to_assistant: bool = ( False # If false (default), converts all 'system' role messages to 'assistant' for GitHub Copilot compatibility. Set to true to disable this behavior. ) +public_mcp_servers: Optional[List[str]] = None public_model_groups: Optional[List[str]] = None public_agent_groups: Optional[List[str]] = None public_model_groups_links: Dict[str, str] = {} @@ -1342,7 +1343,9 @@ from .llms.watsonx.completion.transformation import IBMWatsonXAIConfig from .llms.watsonx.chat.transformation import IBMWatsonXChatConfig from .llms.watsonx.embed.transformation import IBMWatsonXEmbeddingConfig from .llms.github_copilot.chat.transformation import GithubCopilotConfig -from .llms.github_copilot.responses.transformation import GithubCopilotResponsesAPIConfig +from .llms.github_copilot.responses.transformation import ( + GithubCopilotResponsesAPIConfig, +) from .llms.nebius.chat.transformation import NebiusConfig from .llms.wandb.chat.transformation import WandbConfig from .llms.dashscope.chat.transformation import DashScopeChatConfig diff --git a/litellm/constants.py b/litellm/constants.py index b90f36ae96f..bc72e93850b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1118,6 +1118,7 @@ SECRET_MANAGER_REFRESH_INTERVAL = int( ) LITELLM_SETTINGS_SAFE_DB_OVERRIDES = [ "default_internal_user_params", + "public_mcp_servers", "public_agent_groups", "public_model_groups", "public_model_groups_links", diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index fe11349b62b..8d61ea7c704 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -214,7 +214,7 @@ class MCPClient: raise except Exception as e: error_type = type(e).__name__ - verbose_logger.error( + verbose_logger.exception( f"MCP client list_tools failed - " f"Error Type: {error_type}, " f"Error: {str(e)}, " diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 94bfb9a5002..23658b38210 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -20,6 +20,7 @@ from mcp.types import CallToolRequestParams as MCPCallToolRequestParams from mcp.types import CallToolResult from mcp.types import Tool as MCPTool +import litellm from litellm._logging import verbose_logger from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException from litellm.experimental_mcp_client.client import MCPClient @@ -385,12 +386,12 @@ class MCPServerManager: ) # Update tool name to server name mapping (for both prefixed and base names) - self.tool_name_to_mcp_server_name_mapping[ - base_tool_name - ] = server_prefix - self.tool_name_to_mcp_server_name_mapping[ - prefixed_tool_name - ] = server_prefix + self.tool_name_to_mcp_server_name_mapping[base_tool_name] = ( + server_prefix + ) + self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = ( + server_prefix + ) registered_count += 1 verbose_logger.debug( @@ -1668,11 +1669,16 @@ class MCPServerManager: return server return None - def get_mcp_servers_from_ids(self, server_ids: List[str]) -> List[MCPServer]: - servers = [] - registry = self.get_registry() - for server in registry.values(): - if server.server_id in server_ids: + def get_public_mcp_servers(self) -> List[MCPServer]: + """ + Get the public MCP servers + """ + servers: List[MCPServer] = [] + if litellm.public_mcp_servers is None: + return servers + for server_id in litellm.public_mcp_servers: + server = self.get_mcp_server_by_id(server_id) + if server: servers.append(server) return servers diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index f5a71bc486f..8c3dcbaea92 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -35,3 +35,9 @@ agent_list: litellm_settings: callbacks: ["prometheus"] + +mcp_servers: + # HTTP Streamable Server + deepwiki_mcp_1234: + url: "https://mcp.deepwiki.com/mcp" + server_id: deepwiki_mcp_id \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c0d14246f6b..d6b2d664f9f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -516,6 +516,7 @@ class LiteLLMRoutes(enum.Enum): "/.well-known/litellm-ui-config", "/public/model_hub", "/public/agent_hub", + "/public/mcp_hub", ] ) @@ -1099,6 +1100,10 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): env: Dict[str, str] = Field(default_factory=dict) +class MakeMCPServersPublicRequest(LiteLLMPydanticObjectBase): + mcp_server_ids: List[str] + + class NewUserRequestTeam(LiteLLMPydanticObjectBase): team_id: str max_budget_in_team: Optional[float] = None diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 4e8db85e5ef..ddd838a3de6 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -49,6 +49,7 @@ if MCP_AVAILABLE: from litellm.proxy._types import ( LiteLLM_MCPServerTable, LitellmUserRoles, + MakeMCPServersPublicRequest, NewMCPServerRequest, SpecialMCPServerName, UpdateMCPServerRequest, @@ -57,6 +58,7 @@ if MCP_AVAILABLE: from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_helpers.utils import management_endpoint_wrapper + from litellm.types.mcp_server.mcp_server_manager import MCPInfo def _redact_mcp_credentials( mcp_server: LiteLLM_MCPServerTable, @@ -292,13 +294,23 @@ if MCP_AVAILABLE: --header 'Authorization: Bearer your_api_key_here' ``` """ + # Use server manager to get all servers with health and team data mcp_servers = ( await global_mcp_server_manager.get_all_mcp_servers_with_health_and_teams( user_api_key_auth=user_api_key_dict ) ) - return _redact_mcp_credentials_list(mcp_servers) + redacted_mcp_servers = _redact_mcp_credentials_list(mcp_servers) + + # augment the mcp servers with public status + if litellm.public_mcp_servers is not None: + for server in redacted_mcp_servers: + if server.server_id in litellm.public_mcp_servers: + if server.mcp_info is None: + server.mcp_info = {} + server.mcp_info["is_public"] = True + return redacted_mcp_servers @router.get( "/server/{server_id}", @@ -585,3 +597,78 @@ if MCP_AVAILABLE: pass return _redact_mcp_credentials(mcp_server_record_updated) + + @router.post( + "/make_public", + description="Allows making MCP servers public for AI Hub", + dependencies=[Depends(user_api_key_auth)], + status_code=status.HTTP_202_ACCEPTED, + ) + async def make_mcp_servers_public( + request: MakeMCPServersPublicRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + Make MCP servers public for AI Hub + """ + try: + # Update the public model groups + import litellm + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy.proxy_server import proxy_config + + # Load existing config + config = await proxy_config.get_config() + # Check if user has admin permissions + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins can update public mcp servers. Your role={}".format( + user_api_key_dict.user_role + ) + }, + ) + + if litellm.public_mcp_servers is None: + litellm.public_mcp_servers = [] + + for server_id in request.mcp_server_ids: + server = global_mcp_server_manager.get_mcp_server_by_id( + server_id=server_id + ) + if server is None: + raise HTTPException( + status_code=404, + detail=f"MCP Server with ID {server_id} not found", + ) + + litellm.public_mcp_servers = request.mcp_server_ids + + # Update config with new settings + if "litellm_settings" not in config or config["litellm_settings"] is None: + config["litellm_settings"] = {} + + config["litellm_settings"][ + "public_mcp_servers" + ] = litellm.public_mcp_servers + + # Save the updated config + await proxy_config.save_config(new_config=config) + + verbose_proxy_logger.debug( + f"Updated public mcp servers to: {litellm.public_mcp_servers} by user: {user_api_key_dict.user_id}" + ) + + return { + "message": "Successfully updated public mcp servers", + "public_mcp_servers": litellm.public_mcp_servers, + "updated_by": user_api_key_dict.user_id, + } + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception(f"Error making agent public: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 159d357c2a6..3b7abbf4f0e 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -8,6 +8,8 @@ from litellm.proxy.public_endpoints.provider_create_metadata import ( get_provider_create_metadata, ) from litellm.types.agents import AgentCard +from litellm.types.mcp import MCPPublicServer +from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, ) @@ -68,6 +70,26 @@ async def get_agents(): return agent_card_list +@router.get( + "/public/mcp_hub", + tags=["[beta] MCP", "public"], + dependencies=[Depends(user_api_key_auth)], + response_model=List[MCPPublicServer], +) +async def get_mcp_servers(): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + public_mcp_servers = global_mcp_server_manager.get_public_mcp_servers() + return [ + MCPPublicServer( + **server.model_dump(), + ) + for server in public_mcp_servers + ] + + @router.get( "/public/model_hub/info", tags=["public", "model management"], diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index ce771c40d3d..884dfefb42c 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -54,6 +54,22 @@ MCPAuthType = Optional[ ] +class MCPPublicServer(BaseModel): + """ + Safe params for public MCP servers + """ + + server_id: str + name: str + alias: Optional[str] = None + server_name: Optional[str] = None + url: Optional[str] = None + transport: MCPTransportType + spec_path: Optional[str] = None + auth_type: Optional[MCPAuthType] = None + mcp_info: Optional[Dict[str, Any]] = None + + class MCPCredentials(TypedDict, total=False): auth_value: Optional[str] """ diff --git a/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx new file mode 100644 index 00000000000..29f866f8bc6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/make_mcp_public_form.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect } from "react"; +import { Modal, Form, Steps, Button, Checkbox } from "antd"; +import { Text, Title, Badge } from "@tremor/react"; +import { makeMCPPublicCall } from "./networking"; +import NotificationsManager from "./molecules/notifications_manager"; +import { MCPServerData } from "./mcp_hub_table_columns"; + +const { Step } = Steps; + +interface MakeMCPPublicFormProps { + visible: boolean; + onClose: () => void; + accessToken: string; + mcpHubData: MCPServerData[]; + onSuccess: () => void; +} + +const MakeMCPPublicForm: React.FC = ({ + visible, + onClose, + accessToken, + mcpHubData, + onSuccess, +}) => { + const [currentStep, setCurrentStep] = useState(0); + const [selectedServers, setSelectedServers] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + + const handleClose = () => { + setCurrentStep(0); + setSelectedServers(new Set()); + form.resetFields(); + onClose(); + }; + + const handleNext = () => { + if (currentStep === 0) { + if (selectedServers.size === 0) { + NotificationsManager.fromBackend("Please select at least one MCP server to make public"); + return; + } + setCurrentStep(1); + } + }; + + const handlePrevious = () => { + if (currentStep === 1) { + setCurrentStep(0); + } + }; + + const handleServerSelection = (serverId: string, checked: boolean) => { + const newSelection = new Set(selectedServers); + if (checked) { + newSelection.add(serverId); + } else { + newSelection.delete(serverId); + } + setSelectedServers(newSelection); + }; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + const allServerIds = mcpHubData.map((server) => server.server_id); + setSelectedServers(new Set(allServerIds)); + } else { + setSelectedServers(new Set()); + } + }; + + // Initialize and preselect already public servers when modal opens + useEffect(() => { + if (visible && mcpHubData.length > 0) { + // Extract server IDs from servers that are already public + const publicServerIds = mcpHubData + .filter((server) => server.mcp_info?.is_public === true) + .map((server) => server.server_id); + + // Preselect servers that are already public + setSelectedServers(new Set(publicServerIds)); + } + }, [visible]); // Only re-run when modal visibility changes, not when mcpHubData updates + + const handleSubmit = async () => { + if (selectedServers.size === 0) { + NotificationsManager.fromBackend("Please select at least one MCP server to make public"); + return; + } + + setLoading(true); + try { + const serverIdsToMakePublic = Array.from(selectedServers); + + // Make batch API call for all servers + await makeMCPPublicCall(accessToken, serverIdsToMakePublic); + + NotificationsManager.success(`Successfully made ${serverIdsToMakePublic.length} MCP server(s) public!`); + handleClose(); + onSuccess(); + } catch (error) { + console.error("Error making MCP servers public:", error); + NotificationsManager.fromBackend("Failed to make MCP servers public. Please try again."); + } finally { + setLoading(false); + } + }; + + const renderStep1Content = () => { + const allServersSelected = + mcpHubData.length > 0 && mcpHubData.every((server) => selectedServers.has(server.server_id)); + const isIndeterminate = selectedServers.size > 0 && !allServersSelected; + + return ( +
+
+ Select MCP Servers to Make Public +
+ handleSelectAll(e.target.checked)} + disabled={mcpHubData.length === 0} + > + Select All {mcpHubData.length > 0 && `(${mcpHubData.length})`} + +
+
+ + + Select the MCP servers you want to be visible on the public model hub. Users will still require a valid API key to + use these servers. + + +
+
+ {mcpHubData.length === 0 ? ( +
+ No MCP servers available. +
+ ) : ( + mcpHubData.map((server) => { + const isPublic = server.mcp_info?.is_public === true; + return ( +
+ handleServerSelection(server.server_id, e.target.checked)} + /> +
+
+ {server.server_name} + {isPublic && ( + + Public + + )} + + {server.transport} + + + {server.status || "unknown"} + +
+ + {server.description || server.url} + + {server.allowed_tools && server.allowed_tools.length > 0 && ( +
+ {server.allowed_tools.slice(0, 3).map((tool, idx) => ( + + {tool} + + ))} + {server.allowed_tools.length > 3 && ( + +{server.allowed_tools.length - 3} more + )} +
+ )} +
+
+ ); + }) + )} +
+
+ + {selectedServers.size > 0 && ( +
+ + {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} selected + +
+ )} +
+ ); + }; + + const renderStep2Content = () => { + return ( +
+ Confirm Making MCP Servers Public + +
+ + Warning: Once you make these MCP servers public, anyone who can go to the{" "} + /ui/model_hub_table will be able to know they exist on the proxy. + +
+ +
+ MCP Servers to be made public: +
+
+ {Array.from(selectedServers).map((serverId) => { + const server = mcpHubData.find((s) => s.server_id === serverId); + return ( +
+
+
+ {server?.server_name || serverId} + {server && ( + <> + + {server.transport} + + + {server.status || "unknown"} + + + )} +
+ {server?.description && ( + {server.description} + )} + {server?.url && ( + {server.url} + )} +
+
+ ); + })} +
+
+
+ +
+ + Total: {selectedServers.size} MCP server{selectedServers.size !== 1 ? "s" : ""} will be made + public + +
+
+ ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderStep1Content(); + case 1: + return renderStep2Content(); + default: + return null; + } + }; + + const renderStepButtons = () => { + return ( +
+ + +
+ {currentStep === 0 && ( + + )} + + {currentStep === 1 && ( + + )} +
+
+ ); + }; + + return ( + +
+ + + + + + {renderStepContent()} + {renderStepButtons()} +
+
+ ); +}; + +export default MakeMCPPublicForm; + diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx new file mode 100644 index 00000000000..6885fd03bb3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx @@ -0,0 +1,267 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Button, Badge, Text } from "@tremor/react"; +import { Tooltip, Tag } from "antd"; +import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; + +export interface MCPServerData { + server_id: string; + server_name: string; + alias?: string | null; + description?: string | null; + url: string; + transport: string; + auth_type: string; + credentials?: any; + created_at: string; + created_by: string; + updated_at: string; + updated_by: string; + teams: string[]; + mcp_access_groups: string[]; + allowed_tools: string[]; + extra_headers: any[]; + mcp_info: Record; + static_headers: Record; + status: string; + last_health_check?: string | null; + health_check_error?: string | null; + command?: string | null; + args: string[]; + env: Record; + [key: string]: any; +} + +export const mcpHubColumns = ( + showModal: (server: MCPServerData) => void, + copyToClipboard: (text: string) => void, + publicPage: boolean = false, +): ColumnDef[] => { + const allColumns: ColumnDef[] = [ + { + header: "Server Name", + accessorKey: "server_name", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + return ( +
+
+ {server.server_name} + + copyToClipboard(server.server_name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" + /> + +
+ {/* Show description on mobile */} +
+ {server.description || "-"} +
+
+ ); + }, + }, + { + header: "Description", + accessorKey: "description", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + return ( + + {server.description || "-"} + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "URL", + accessorKey: "url", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + return ( +
+ {server.url} + + copyToClipboard(server.url)} + className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs flex-shrink-0" + /> + +
+ ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Transport", + accessorKey: "transport", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + return ( + + {server.transport} + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Auth Type", + accessorKey: "auth_type", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + const authColor = server.auth_type === "none" ? "gray" : "green"; + + return ( + + {server.auth_type} + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Status", + accessorKey: "status", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + const statusColors: Record = { + active: "green", + inactive: "red", + unknown: "gray", + healthy: "green", + unhealthy: "red", + }; + + const color = statusColors[server.status] || "gray"; + + return ( + + {server.status || "unknown"} + + ); + }, + }, + { + header: "Tools", + accessorKey: "allowed_tools", + enableSorting: false, + cell: ({ row }) => { + const server = row.original; + const tools = server.allowed_tools || []; + + return ( +
+ + {tools.length > 0 ? `${tools.length} tool${tools.length !== 1 ? "s" : ""}` : "All tools"} + + {tools.length > 0 && ( +
+ {tools.slice(0, 2).map((tool, idx) => ( + + {tool} + + ))} + {tools.length > 2 && ( + +{tools.length - 2} + )} +
+ )} +
+ ); + }, + meta: { + className: "hidden lg:table-cell", + }, + }, + { + header: "Created By", + accessorKey: "created_by", + enableSorting: true, + sortingFn: "alphanumeric", + cell: ({ row }) => { + const server = row.original; + + return ( + + {server.created_by || "-"} + + ); + }, + meta: { + className: "hidden xl:table-cell", + }, + }, + { + header: "Public", + accessorKey: "mcp_info.is_public", + enableSorting: true, + sortingFn: (rowA, rowB) => { + const publicA = rowA.original.mcp_info?.is_public === true ? 1 : 0; + const publicB = rowB.original.mcp_info?.is_public === true ? 1 : 0; + return publicA - publicB; + }, + cell: ({ row }) => { + const server = row.original; + + return server.mcp_info?.is_public === true ? ( + + Yes + + ) : ( + + No + + ); + }, + meta: { + className: "hidden md:table-cell", + }, + }, + { + header: "Details", + id: "details", + enableSorting: false, + cell: ({ row }) => { + const server = row.original; + + return ( + + ); + }, + }, + ]; + + return allColumns; +}; + diff --git a/ui/litellm-dashboard/src/components/model_hub_table.tsx b/ui/litellm-dashboard/src/components/model_hub_table.tsx index 73ab7c85adb..87a7dd2bd69 100644 --- a/ui/litellm-dashboard/src/components/model_hub_table.tsx +++ b/ui/litellm-dashboard/src/components/model_hub_table.tsx @@ -1,13 +1,15 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { modelHubCall, modelHubPublicModelsCall, getAgentsList, getProxyBaseUrl } from "./networking"; +import { modelHubCall, modelHubPublicModelsCall, getAgentsList, getProxyBaseUrl, fetchMCPServers } from "./networking"; import { getConfigFieldSetting } from "./networking"; import { ModelDataTable } from "./model_dashboard/table"; import { modelHubColumns } from "./model_hub_table_columns"; import { agentHubColumns, AgentHubData } from "./agent_hub_table_columns"; +import { mcpHubColumns, MCPServerData } from "./mcp_hub_table_columns"; import PublicModelHub from "./public_model_hub"; import MakeModelPublicForm from "./make_model_public_form"; import MakeAgentPublicForm from "./make_agent_public_form"; +import MakeMCPPublicForm from "./make_mcp_public_form"; import ModelFilters from "./model_filters"; import UsefulLinksManagement from "./useful_links_management"; import { Card, Text, Title, Button, Badge, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; @@ -60,9 +62,16 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, const [agentLoading, setAgentLoading] = useState(true); const [selectedAgent, setSelectedAgent] = useState(null); const [isAgentModalVisible, setIsAgentModalVisible] = useState(false); + // MCP Hub state + const [mcpHubData, setMcpHubData] = useState(null); + const [mcpLoading, setMcpLoading] = useState(true); + const [selectedMcpServer, setSelectedMcpServer] = useState(null); + const [isMcpModalVisible, setIsMcpModalVisible] = useState(false); + const [isMakeMcpPublicModalVisible, setIsMakeMcpPublicModalVisible] = useState(false); const router = useRouter(); const tableRef = useRef>(null); const agentTableRef = useRef>(null); + const mcpTableRef = useRef>(null); useEffect(() => { const fetchData = async (accessToken: string) => { @@ -143,6 +152,30 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }, [publicPage, accessToken]); + // Fetch MCP Hub data + useEffect(() => { + const fetchMcpData = async () => { + if (!accessToken) { + return; + } + + try { + setMcpLoading(true); + const response = await fetchMCPServers(accessToken); + console.log("MCPHubData:", response); + setMcpHubData(response); + } catch (error) { + console.error("There was an error fetching the MCP server data", error); + } finally { + setMcpLoading(false); + } + }; + + if (!publicPage) { + fetchMcpData(); + } + }, [publicPage, accessToken]); + const showModal = (model: ModelGroupInfo) => { setSelectedModel(model); setIsModalVisible(true); @@ -153,6 +186,11 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setIsAgentModalVisible(true); }; + const showMcpModal = (server: MCPServerData) => { + setSelectedMcpServer(server); + setIsMcpModalVisible(true); + }; + const goToPublicModelPage = () => { router.replace(`/model_hub_table?key=${accessToken}`); }; @@ -175,12 +213,23 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setIsMakeAgentPublicModalVisible(true); }; + const handleMakeMcpPublicPage = () => { + if (!accessToken) { + return; + } + + // Show the modal for selecting MCP servers to make public + setIsMakeMcpPublicModalVisible(true); + }; + const handleOk = () => { setIsModalVisible(false); setIsPublicPageModalVisible(false); setSelectedModel(null); setIsAgentModalVisible(false); setSelectedAgent(null); + setIsMcpModalVisible(false); + setSelectedMcpServer(null); }; const handleCancel = () => { @@ -189,6 +238,8 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, setSelectedModel(null); setIsAgentModalVisible(false); setSelectedAgent(null); + setIsMcpModalVisible(false); + setSelectedMcpServer(null); }; const copyToClipboard = (text: string) => { @@ -252,6 +303,21 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, } }; + const handleMakeMcpPublicSuccess = () => { + // Refresh the MCP hub data after successful public operation + if (accessToken) { + const fetchMcpData = async () => { + try { + const response = await fetchMCPServers(accessToken); + setMcpHubData(response); + } catch (error) { + console.error("Error refreshing MCP server data:", error); + } + }; + fetchMcpData(); + } + }; + const handleFilteredDataChange = useCallback((newFilteredData: ModelGroupInfo[]) => { setFilteredData(newFilteredData); }, []); @@ -274,7 +340,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, AI Hub {isAdminRole(userRole || "") ? (

- Make models and agents public for developers to know what's available. + Make models, agents, and MCP servers public for developers to know what's available.

) : (

A list of all public model names personally available to you.

@@ -302,11 +368,12 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, )} - {/* Tab System for Model Hub and Agent Hub */} + {/* Tab System for Model Hub, Agent Hub, and MCP Hub */} Model Hub Agent Hub + MCP Hub @@ -371,6 +438,35 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, + + {/* MCP Hub Tab */} + + + {/* Header with Make Public Button */} + {publicPage == false && isAdminRole(userRole || "") && ( +
+ +
+ )} + + {/* MCP Server Table */} + +
+ +
+ + Showing {mcpHubData?.length || 0} MCP server{mcpHubData?.length !== 1 ? "s" : ""} + +
+
@@ -693,6 +789,229 @@ print(response.choices[0].message.content)`} )} + {/* MCP Server Details Modal */} + + {selectedMcpServer && ( +
+ {/* Server Overview */} +
+ Server Overview +
+
+ Server Name: + {selectedMcpServer.server_name} +
+
+ Server ID: +
+ {selectedMcpServer.server_id} + copyToClipboard(selectedMcpServer.server_id)} + className="cursor-pointer text-gray-500 hover:text-blue-500" + /> +
+
+ {selectedMcpServer.alias && ( +
+ Alias: + {selectedMcpServer.alias} +
+ )} +
+ Transport: + {selectedMcpServer.transport} +
+
+ Auth Type: + + {selectedMcpServer.auth_type} + +
+
+ Status: + + {selectedMcpServer.status || "unknown"} + +
+
+ {selectedMcpServer.description && ( +
+ Description: + {selectedMcpServer.description} +
+ )} +
+ + {/* Connection Details */} +
+ Connection Details +
+
+ URL: +
+ + {selectedMcpServer.url} + + copyToClipboard(selectedMcpServer.url)} + className="cursor-pointer text-gray-500 hover:text-blue-500 flex-shrink-0" + /> +
+
+ {selectedMcpServer.command && ( +
+ Command: + + {selectedMcpServer.command} + +
+ )} +
+
+ + {/* Tools */} + {selectedMcpServer.allowed_tools && selectedMcpServer.allowed_tools.length > 0 && ( +
+ Allowed Tools +
+ {selectedMcpServer.allowed_tools.map((tool, idx) => ( + + {tool} + + ))} +
+
+ )} + + {/* Teams */} + {selectedMcpServer.teams && selectedMcpServer.teams.length > 0 && ( +
+ Teams +
+ {selectedMcpServer.teams.map((team, idx) => ( + + {team} + + ))} +
+
+ )} + + {/* Access Groups */} + {selectedMcpServer.mcp_access_groups && selectedMcpServer.mcp_access_groups.length > 0 && ( +
+ Access Groups +
+ {selectedMcpServer.mcp_access_groups.map((group, idx) => ( + + {group} + + ))} +
+
+ )} + + {/* Metadata */} +
+ Metadata +
+
+ Created By: + {selectedMcpServer.created_by} +
+
+ Updated By: + {selectedMcpServer.updated_by} +
+
+ Created At: + + {new Date(selectedMcpServer.created_at).toLocaleString()} + +
+
+ Updated At: + + {new Date(selectedMcpServer.updated_at).toLocaleString()} + +
+ {selectedMcpServer.last_health_check && ( +
+ Last Health Check: + + {new Date(selectedMcpServer.last_health_check).toLocaleString()} + +
+ )} +
+ {selectedMcpServer.health_check_error && ( +
+ Health Check Error: + + {selectedMcpServer.health_check_error} + +
+ )} +
+ + {/* Usage Example */} +
+ Usage Example + + {`from fastmcp import Client +import asyncio + +# Standard MCP configuration +config = { + "mcpServers": { + "${selectedMcpServer.server_name}": { + "url": "http://localhost:4000/${selectedMcpServer.server_name}/mcp", + "headers": { + "x-litellm-api-key": "Bearer sk-1234" + } + } + } +} + +# Create a client that connects to the server +client = Client(config) + +async def main(): + async with client: + # List available tools + tools = await client.list_tools() + print(f"Available tools: {[tool.name for tool in tools]}") + + # Call a tool + response = await client.call_tool( + name="tool_name", + arguments={"arg": "value"} + ) + print(f"Response: {response}") + +if __name__ == "__main__": + asyncio.run(main())`} + +
+
+ )} +
+ {/* Make Model Public Form */} + + {/* Make MCP Public Form */} + setIsMakeMcpPublicModalVisible(false)} + accessToken={accessToken || ""} + mcpHubData={mcpHubData || []} + onSuccess={handleMakeMcpPublicSuccess} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ddb95294e92..065d665e134 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -212,11 +212,13 @@ export interface CredentialsResponse { let lastErrorTime = 0; -const handleError = async (errorData: string) => { +const handleError = async (errorData: string | any) => { const currentTime = Date.now(); if (currentTime - lastErrorTime > 60000) { // 60000 milliseconds = 60 seconds - if (errorData.includes("Authentication Error - Expired Key")) { + // Convert errorData to string if it isn't already + const errorString = typeof errorData === 'string' ? errorData : JSON.stringify(errorData); + if (errorString.includes("Authentication Error - Expired Key")) { NotificationsManager.info("UI Session Expired. Logging out."); lastErrorTime = currentTime; clearTokenCookies(); @@ -1973,6 +1975,17 @@ export const agentHubPublicModelsCall = async () => { return response.json(); }; +export const mcpHubPublicServersCall = async () => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/public/mcp_hub` : `/public/mcp_hub`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + return response.json(); +}; + export const modelHubCall = async (accessToken: string) => { /** * Get all models on proxy @@ -6592,6 +6605,36 @@ export const makeAgentsPublicCall = async (accessToken: string, agentIds: string } }; +export const makeMCPPublicCall = async (accessToken: string, mcpServerIds: string[]) => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/make_public` : `/v1/mcp/make_public`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mcp_server_ids: mcpServerIds, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("Make agents public response:", data); + return data; + } catch (error) { + console.error("Failed to make agents public:", error); + throw error; + } +}; + export const deleteGuardrailCall = async (accessToken: string, guardrailId: string) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/${guardrailId}` : `/guardrails/${guardrailId}`; diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index 00a501fa3cd..7eed7584edf 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef, useMemo } from "react"; -import { modelHubPublicModelsCall, getPublicModelHubInfo, agentHubPublicModelsCall } from "./networking"; +import { modelHubPublicModelsCall, getPublicModelHubInfo, agentHubPublicModelsCall, mcpHubPublicServersCall } from "./networking"; import { ModelDataTable } from "./model_dashboard/table"; import { ColumnDef } from "@tanstack/react-table"; import { Card, Text, Title, Button } from "@tremor/react"; @@ -62,6 +62,23 @@ interface AgentCard { [key: string]: any; } +interface MCPServerData { + server_id: string; + name: string; + alias?: string | null; + server_name: string; + url: string; + transport: string; + spec_path?: string | null; + auth_type: string; + mcp_info: { + server_name: string; + description?: string; + mcp_server_cost_info?: any; + }; + [key: string]: any; +} + interface PublicModelHubProps { accessToken?: string | null; isEmbedded?: boolean; // When true, hides navbar and adjusts layout for embedding in dashboard @@ -70,27 +87,34 @@ interface PublicModelHubProps { const PublicModelHub: React.FC = ({ accessToken, isEmbedded = false }) => { const [modelHubData, setModelHubData] = useState(null); const [agentHubData, setAgentHubData] = useState(null); + const [mcpHubData, setMcpHubData] = useState(null); const [pageTitle, setPageTitle] = useState("LiteLLM Gateway"); const [customDocsDescription, setCustomDocsDescription] = useState(null); const [litellmVersion, setLitellmVersion] = useState(""); const [usefulLinks, setUsefulLinks] = useState>({}); const [loading, setLoading] = useState(true); const [agentLoading, setAgentLoading] = useState(true); + const [mcpLoading, setMcpLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [agentSearchTerm, setAgentSearchTerm] = useState(""); + const [mcpSearchTerm, setMcpSearchTerm] = useState(""); const [selectedProviders, setSelectedProviders] = useState([]); const [selectedModes, setSelectedModes] = useState([]); const [selectedFeatures, setSelectedFeatures] = useState([]); const [selectedAgentSkills, setSelectedAgentSkills] = useState([]); + const [selectedMcpTransports, setSelectedMcpTransports] = useState([]); const [serviceStatus, setServiceStatus] = useState("I'm alive! ✓"); const [isModalVisible, setIsModalVisible] = useState(false); const [isAgentModalVisible, setIsAgentModalVisible] = useState(false); + const [isMcpModalVisible, setIsMcpModalVisible] = useState(false); const [selectedModel, setSelectedModel] = useState(null); const [selectedAgent, setSelectedAgent] = useState(null); + const [selectedMcpServer, setSelectedMcpServer] = useState(null); const [proxySettings, setProxySettings] = useState({}); const [activeTab, setActiveTab] = useState("models"); const tableRef = useRef>(null); const agentTableRef = useRef>(null); + const mcpTableRef = useRef>(null); useEffect(() => { const fetchPublicData = async () => { @@ -120,6 +144,19 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded } }; + const fetchMcpData = async () => { + try { + setMcpLoading(true); + const _mcpHubData = await mcpHubPublicServersCall(); + console.log("MCPHubData:", _mcpHubData); + setMcpHubData(_mcpHubData); + } catch (error) { + console.error("There was an error fetching the public MCP server data", error); + } finally { + setMcpLoading(false); + } + }; + const fetchPublicModelHubInfo = async () => { const publicModelHubInfo = await getPublicModelHubInfo(); console.log("Public Model Hub Info:", publicModelHubInfo); @@ -133,6 +170,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded fetchPublicData(); fetchAgentData(); + fetchMcpData(); }, []); // Clear filters when filter values change to avoid confusion @@ -186,6 +224,14 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded return Array.from(skills).sort(); }; + const getUniqueMcpTransports = (data: MCPServerData[]) => { + const transports = new Set(); + data.forEach((server) => { + if (server.transport) transports.add(server.transport); + }); + return Array.from(transports).sort(); + }; + const filteredData = useMemo(() => { if (!modelHubData) return []; @@ -311,6 +357,56 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded }); }, [agentHubData, agentSearchTerm, selectedAgentSkills]); + const filteredMcpData = useMemo(() => { + if (!mcpHubData) return []; + + let searchResults = mcpHubData; + + // Apply search if there's a search term + if (mcpSearchTerm.trim()) { + const lowercaseSearch = mcpSearchTerm.toLowerCase(); + const searchWords = lowercaseSearch.split(/\s+/); + + searchResults = mcpHubData.filter((server) => { + const serverName = server.server_name.toLowerCase(); + const serverDescription = (server.mcp_info?.description || "").toLowerCase(); + + // Check if it contains the exact search term + if (serverName.includes(lowercaseSearch) || serverDescription.includes(lowercaseSearch)) { + return true; + } + + // Check if it contains all search words + return searchWords.every((word) => serverName.includes(word) || serverDescription.includes(word)); + }); + + // Sort by relevance + searchResults = searchResults.sort((a, b) => { + const aName = a.server_name.toLowerCase(); + const bName = b.server_name.toLowerCase(); + + const aExactMatch = aName === lowercaseSearch ? 1000 : 0; + const bExactMatch = bName === lowercaseSearch ? 1000 : 0; + + const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0; + const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0; + + const aScore = aExactMatch + aStartsWith + (1000 - aName.length); + const bScore = bExactMatch + bStartsWith + (1000 - bName.length); + + return bScore - aScore; + }); + } + + // Apply transport filters + return searchResults.filter((server) => { + const matchesTransport = + selectedMcpTransports.length === 0 || selectedMcpTransports.includes(server.transport); + + return matchesTransport; + }); + }, [mcpHubData, mcpSearchTerm, selectedMcpTransports]); + const showModal = (model: ModelGroupInfo) => { setSelectedModel(model); setIsModalVisible(true); @@ -341,6 +437,21 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded setSelectedAgent(null); }; + const showMcpModal = (server: MCPServerData) => { + setSelectedMcpServer(server); + setIsMcpModalVisible(true); + }; + + const handleMcpModalOk = () => { + setIsMcpModalVisible(false); + setSelectedMcpServer(null); + }; + + const handleMcpModalCancel = () => { + setIsMcpModalVisible(false); + setSelectedMcpServer(null); + }; + const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); NotificationsManager.success("Copied to clipboard!"); @@ -712,6 +823,94 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded }, ]; + const publicMCPHubColumns = (): ColumnDef[] => [ + { + header: "Server Name", + accessorKey: "server_name", + enableSorting: true, + cell: ({ row }) => ( +
+ + + +
+ ), + size: 150, + }, + { + header: "Description", + accessorKey: "mcp_info.description", + enableSorting: false, + cell: ({ row }) => { + const description = row.original.mcp_info?.description || "-"; + const truncated = description.length > 80 ? description.substring(0, 80) + "..." : description; + return ( + + {truncated} + + ); + }, + size: 250, + }, + { + header: "URL", + accessorKey: "url", + enableSorting: false, + cell: ({ row }) => { + const url = row.original.url; + const truncated = url.length > 40 ? url.substring(0, 40) + "..." : url; + return ( + +
+ {truncated} + copyToClipboard(url)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-3 h-3" + /> +
+
+ ); + }, + size: 200, + }, + { + header: "Transport", + accessorKey: "transport", + enableSorting: true, + cell: ({ row }) => { + const transport = row.original.transport; + return ( + + {transport} + + ); + }, + size: 100, + }, + { + header: "Auth Type", + accessorKey: "auth_type", + enableSorting: true, + cell: ({ row }) => { + const authType = row.original.auth_type; + const color = authType === "none" ? "gray" : "green"; + return ( + + {authType} + + ); + }, + size: 100, + }, + ]; + return (
@@ -979,6 +1178,73 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded
)} + + {/* MCP Servers Tab */} + {mcpHubData && mcpHubData.length > 0 && ( + +
+ Available MCP Servers +
+ + {/* Filters */} +
+
+
+ Search MCP Servers: + + + +
+
+ + setMcpSearchTerm(e.target.value)} + className="border border-gray-300 rounded-lg pl-10 pr-4 py-2 w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" + /> +
+
+
+ Transport: + +
+
+ + + +
+ + Showing {filteredMcpData.length} of {mcpHubData?.length || 0} MCP servers + +
+
+ )} @@ -1512,6 +1778,176 @@ print(response.model_dump(mode='json', exclude_none=True))`; )} + + {/* MCP Server Details Modal */} + + {selectedMcpServer?.server_name || "MCP Server Details"} + {selectedMcpServer && ( + + copyToClipboard(selectedMcpServer.server_name)} + className="cursor-pointer text-gray-500 hover:text-blue-500 w-4 h-4" + /> + + )} + + } + width={1000} + open={isMcpModalVisible} + footer={null} + onOk={handleMcpModalOk} + onCancel={handleMcpModalCancel} + > + {selectedMcpServer && ( +
+ {/* Server Overview */} +
+ Server Overview +
+
+ Server Name: + {selectedMcpServer.server_name} +
+
+ Transport: + {selectedMcpServer.transport} +
+ {selectedMcpServer.alias && ( +
+ Alias: + {selectedMcpServer.alias} +
+ )} +
+ Auth Type: + + {selectedMcpServer.auth_type} + +
+
+ Description: + {selectedMcpServer.mcp_info?.description || "-"} +
+ +
+
+ + {/* Additional Info */} + {selectedMcpServer.mcp_info && Object.keys(selectedMcpServer.mcp_info).length > 0 && ( +
+ Additional Information +
+
+                      {JSON.stringify(selectedMcpServer.mcp_info, null, 2)}
+                    
+
+
+ )} + + {/* Usage Example */} +
+ Usage Example +
+
+{`# Using MCP Server with Python FastMCP
+
+from fastmcp import Client
+import asyncio
+
+# Standard MCP configuration
+config = {
+    "mcpServers": {
+        "${selectedMcpServer.server_name}": {
+            "url": "http://localhost:4000/${selectedMcpServer.server_name}/mcp",
+            "headers": {
+                "x-litellm-api-key": "Bearer sk-1234"
+            }
+        }
+    }
+}
+
+# Create a client that connects to the server
+client = Client(config)
+
+async def main():
+    async with client:
+        # List available tools
+        tools = await client.list_tools()
+        print(f"Available tools: {[tool.name for tool in tools]}")
+
+        # Call a tool
+        response = await client.call_tool(
+            name="tool_name", 
+            arguments={"arg": "value"}
+        )
+        print(f"Response: {response}")
+
+if __name__ == "__main__":
+    asyncio.run(main())`}
+                  
+
+
+ +
+
+
+ )} +
);