diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index b2ee6f26da..e72db16256 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -149,3 +149,72 @@ if MCP_AVAILABLE: proxy_config=proxy_config, ) return await call_mcp_tool(**data) + + ######################################################## + # MCP Connection testing routes + # /health -> Test if we can connect to the MCP server + # /health/tools/list -> List tools from MCP server + # For these routes users will dynamically pass the MCP connection params, they don't need to be on the MCP registry + ######################################################## + from litellm.proxy._experimental.mcp_server.server import MCPServer + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + NewMCPServerRequest, + ) + @router.post("/test/connection") + async def test_connection( + request: NewMCPServerRequest, + ): + """ + Test if we can connect to the provided MCP server before adding it + """ + try: + client = global_mcp_server_manager._create_mcp_client( + server=MCPServer( + server_id=request.server_id or "", + name=request.alias or "", + url=request.url, + transport=request.transport, + spec_version=request.spec_version, + auth_type=request.auth_type, + mcp_info=request.mcp_info, + ), + mcp_auth_header=None, + ) + + await client.connect() + except Exception as e: + verbose_logger.error(f"Error in test_connection: {e}", exc_info=True) + return {"status": "error", "message": "An internal error has occurred."} + return {"status": "ok"} + + + @router.post("/test/tools/list") + async def test_tools_list( + request: NewMCPServerRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """ + Preview tools available from MCP server before adding it + """ + try: + client = global_mcp_server_manager._create_mcp_client( + server=MCPServer( + server_id=request.server_id or "", + name=request.alias or "", + url=request.url, + transport=request.transport, + spec_version=request.spec_version, + auth_type=request.auth_type, + mcp_info=request.mcp_info, + ), + mcp_auth_header=None, + ) + list_tools_result = await client.list_tools() + except Exception as e: + verbose_logger.error(f"Error in test_tools_list: {e}", exc_info=True) + return {"status": "error", "message": "An internal error has occurred."} + return { + "tools": list_tools_result, + "error": None, + "message": "Successfully retrieved tools" + } diff --git a/litellm/proxy/proxy_config.yaml b/litellm/proxy/proxy_config.yaml index 8a160be51e..8fc44b60a6 100644 --- a/litellm/proxy/proxy_config.yaml +++ b/litellm/proxy/proxy_config.yaml @@ -6,9 +6,6 @@ model_list: litellm_params: model: openai/* -general_settings: - custom_ui_sso_sign_in_handler: "custom_hooks.custom_ui_sso_hook.custom_ui_sso_sign_in_handler" - guardrails: - guardrail_name: "bedrock-pre-guard" litellm_params: diff --git a/ui/litellm-dashboard/src/components/mcp_connection_test.tsx b/ui/litellm-dashboard/src/components/mcp_connection_test.tsx new file mode 100644 index 0000000000..792bc80571 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_connection_test.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { Typography, Space, Button, Divider, message } from 'antd'; +import { WarningOutlined, InfoCircleOutlined, CopyOutlined } from '@ant-design/icons'; +import { testMCPConnectionRequest } from "./networking"; + +const { Text } = Typography; + +interface MCPConnectionTestProps { + formValues: Record; + accessToken: string; + serverName?: string; + onClose?: () => void; + onTestComplete?: () => void; +} + +const MCPConnectionTest: React.FC = ({ + formValues, + accessToken, + serverName = "this MCP server", + onClose, + onTestComplete +}) => { + const [connectionError, setConnectionError] = React.useState(null); + const [rawRequest, setRawRequest] = React.useState(null); + const [rawResponse, setRawResponse] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(true); + const [connectionSuccess, setConnectionSuccess] = React.useState(false); + const [showDetails, setShowDetails] = React.useState(false); + + const testMCPConnection = async () => { + setIsLoading(true); + setShowDetails(false); + setConnectionError(null); + setRawRequest(null); + setRawResponse(null); + setConnectionSuccess(false); + + // Add a small delay to ensure form values are fully populated + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + console.log("Testing MCP connection with form values:", formValues); + + // Prepare the MCP server config from form values + const mcpServerConfig = { + server_id: formValues.server_id || "", + alias: formValues.alias || "", + url: formValues.url, + transport: formValues.transport, + spec_version: formValues.spec_version, + auth_type: formValues.auth_type, + mcp_info: formValues.mcp_info, + }; + + setRawRequest(mcpServerConfig); + + // Test connection + const connectionResponse = await testMCPConnectionRequest(accessToken, mcpServerConfig); + console.log("Connection test response:", connectionResponse); + + if (connectionResponse.status === "ok") { + setConnectionError(null); + setConnectionSuccess(true); + } else { + const errorMessage = connectionResponse.message || "Unknown connection error"; + setConnectionError(errorMessage); + setRawResponse(connectionResponse); + } + } catch (error) { + console.error("MCP connection test error:", error); + setConnectionError(error instanceof Error ? error.message : String(error)); + } finally { + setIsLoading(false); + if (onTestComplete) onTestComplete(); + } + }; + + React.useEffect(() => { + // Run the test once when component mounts + // Add a small timeout to ensure form values are ready + const timer = setTimeout(() => { + testMCPConnection(); + }, 200); + + return () => clearTimeout(timer); + }, []); // Empty dependency array means this runs once on mount + + const getCleanErrorMessage = (errorMsg: string) => { + if (!errorMsg) return "Unknown error"; + + const mainError = errorMsg.split('stack trace:')[0].trim(); + + const cleanedError = mainError.replace(/^(.*?)Error: /, ''); + + return cleanedError; + }; + + const connectionErrorMessage = typeof connectionError === 'string' + ? getCleanErrorMessage(connectionError) + : connectionError?.message ? getCleanErrorMessage(connectionError.message) : "Unknown error"; + + const formatMCPRequest = (mcpConfig: Record) => { + return JSON.stringify(mcpConfig, null, 2); + }; + + const isOverallSuccess = connectionSuccess && !connectionError; + + return ( +
+ {isLoading ? ( +
+
+ {/* Simple CSS spinner */} +
+
+ Testing connection to {serverName}... + +
+ ) : isOverallSuccess ? ( +
+
+
+ +
+ + Connection to {serverName} successful! + +
+ + +
+ ) : ( + <> +
+
+ + Connection to {serverName} failed +
+ +
+ Error: + {connectionErrorMessage} + + {connectionError && ( +
+ +
+ )} +
+ + {showDetails && ( +
+ Troubleshooting Details +
+                  {typeof connectionError === 'string' ? connectionError : JSON.stringify(connectionError, null, 2)}
+                
+
+ )} + +
+ MCP Server Configuration +
+                {formatMCPRequest(rawRequest || {})}
+              
+ +
+
+ + )} + +
+ + + + + +
+
+ ); +}; + +export default MCPConnectionTest; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index ac3d22f0d5..e2be2a6c62 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -12,6 +12,7 @@ import { Button, TextInput } from "@tremor/react"; import { createMCPServer } from "../networking"; import { MCPServer, MCPServerCostInfo } from "./types"; import MCPServerCostConfig from "./mcp_server_cost_config"; +import MCPConnectionStatus from "./mcp_connection_status"; import { isAdminRole } from "@/utils/roles"; @@ -32,6 +33,8 @@ const CreateMCPServer: React.FC = ({ const [form] = Form.useForm(); const [isLoading, setIsLoading] = useState(false); const [costConfig, setCostConfig] = useState({}); + const [formValues, setFormValues] = useState>({}); + const [tools, setTools] = useState([]); const handleCreate = async (formValues: Record) => { setIsLoading(true); @@ -57,6 +60,7 @@ const CreateMCPServer: React.FC = ({ message.success("MCP Server created successfully"); form.resetFields(); setCostConfig({}); + setTools([]); setModalVisible(false); onCreateSuccess(response); } @@ -73,9 +77,12 @@ const CreateMCPServer: React.FC = ({ const handleCancel = () => { form.resetFields(); setCostConfig({}); + setTools([]); setModalVisible(false); }; + + // rendering if (!isAdminRole(userRole)) { return null; @@ -121,6 +128,7 @@ const CreateMCPServer: React.FC = ({
setFormValues(allValues)} layout="vertical" className="space-y-6" > @@ -194,8 +202,8 @@ const CreateMCPServer: React.FC = ({ className="rounded-lg" size="large" > - Server-Sent Events (SSE) HTTP + Server-Sent Events (SSE) @@ -246,12 +254,21 @@ const CreateMCPServer: React.FC = ({
- {/* Cost Configuration Section */} + {/* Connection Status Section */}
+ +
+ + {/* Cost Configuration Section */} +
@@ -273,6 +290,8 @@ const CreateMCPServer: React.FC = ({ + + ); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.tsx new file mode 100644 index 0000000000..842c271654 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_connection_status.tsx @@ -0,0 +1,230 @@ +import React, { useState, useEffect } from "react"; +import { Button, message, Spin, Alert, Collapse, Badge } from "antd"; +import { CheckCircleOutlined, ExclamationCircleOutlined, ReloadOutlined, ToolOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { Card, Title, Text } from "@tremor/react"; +import { testMCPToolsListRequest } from "../networking"; + +const { Panel } = Collapse; + +interface MCPConnectionStatusProps { + accessToken: string | null; + formValues: Record; + onToolsLoaded?: (tools: any[]) => void; +} + +const MCPConnectionStatus: React.FC = ({ + accessToken, + formValues, + onToolsLoaded +}) => { + const [tools, setTools] = useState([]); + const [isLoadingTools, setIsLoadingTools] = useState(false); + const [toolsError, setToolsError] = useState(null); + const [hasShownSuccessMessage, setHasShownSuccessMessage] = useState(false); + + // Check if we have the minimum required fields to fetch tools + const canFetchTools = formValues.url && formValues.transport && formValues.auth_type && accessToken; + + const fetchTools = async () => { + if (!accessToken || !formValues.url) { + return; + } + + setIsLoadingTools(true); + setToolsError(null); + + try { + // Prepare the MCP server config from form values + const mcpServerConfig = { + server_id: formValues.server_id || "", + alias: formValues.alias || "", + url: formValues.url, + transport: formValues.transport, + spec_version: formValues.spec_version, + auth_type: formValues.auth_type, + mcp_info: formValues.mcp_info, + }; + + const toolsResponse = await testMCPToolsListRequest(accessToken, mcpServerConfig); + + if (toolsResponse.tools && !toolsResponse.error) { + setTools(toolsResponse.tools); + setToolsError(null); + onToolsLoaded?.(toolsResponse.tools); + if (toolsResponse.tools.length > 0 && !hasShownSuccessMessage) { + setHasShownSuccessMessage(true); + } + } else { + const errorMessage = toolsResponse.message || "Failed to retrieve tools list"; + setToolsError(errorMessage); + setTools([]); + onToolsLoaded?.([]); + setHasShownSuccessMessage(false); + } + } catch (error) { + console.error("Tools fetch error:", error); + setToolsError(error instanceof Error ? error.message : String(error)); + setTools([]); + onToolsLoaded?.([]); + setHasShownSuccessMessage(false); + } finally { + setIsLoadingTools(false); + } + }; + + // Auto-fetch tools when form values change and required fields are available + useEffect(() => { + if (canFetchTools) { + fetchTools(); + } else { + // Clear tools if required fields are missing + setTools([]); + setToolsError(null); + setHasShownSuccessMessage(false); + onToolsLoaded?.([]); + } + }, [formValues.url, formValues.transport, formValues.auth_type, formValues.spec_version, accessToken]); + + // Don't show anything if required fields aren't filled + if (!canFetchTools && !formValues.url) { + return null; + } + + return ( + +
+
+ + Connection Status +
+ + {!canFetchTools && formValues.url && ( +
+ + Complete required fields to test connection +
+ + Fill in URL, Transport, and Authentication to test MCP server connection + +
+ )} + + {canFetchTools && ( +
+
+
+ + {isLoadingTools + ? "Testing connection to MCP server..." + : tools.length > 0 + ? "Connection successful" + : toolsError + ? "Connection failed" + : "Ready to test connection"} + +
+ + Server: {formValues.url} + +
+ + {isLoadingTools && ( +
+ + Connecting... +
+ )} + + {!isLoadingTools && !toolsError && tools.length > 0 && ( +
+ + Connected +
+ )} + + {toolsError && ( +
+ + Failed +
+ )} +
+ + {isLoadingTools && ( +
+ + Testing connection and loading tools... +
+ )} + + {toolsError && ( + } + onClick={fetchTools} + size="small" + > + Retry + + } + /> + )} + + {!isLoadingTools && tools.length > 0 && ( + + + Available Tools + +
+ ), + children: ( +
+ {tools.map((tool, index) => ( +
+ {tool.name} + {tool.description && ( + + {tool.description} + + )} +
+ ))} +
+ ), + }, + ]} + /> + )} + + {!isLoadingTools && tools.length === 0 && !toolsError && ( +
+ + Connection successful! +
+ No tools found for this MCP server +
+ )} +
+ )} + +
+ ); +}; + +export default MCPConnectionStatus; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 8da8efa5b0..53021591ee 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -56,9 +56,35 @@ export const mcpServerColumns = ( ), }, + { + header: "Created At", + accessorKey: "created_at", + sortingFn: "datetime", + cell: ({ row }) => { + const server = row.original; + return ( + + {server.created_at ? new Date(server.created_at).toLocaleDateString() : "-"} + + ); + }, + }, + { + header: "Updated At", + accessorKey: "updated_at", + sortingFn: "datetime", + cell: ({ row }) => { + const server = row.original; + return ( + + {server.updated_at ? new Date(server.updated_at).toLocaleDateString() : "-"} + + ); + }, + }, { id: "actions", - header: "Info", + header: "Actions", cell: ({ row }) => (
void; - serverId?: string; - serverUrl?: string; - accessToken: string | null; + tools?: any[]; // Receive tools from connection component disabled?: boolean; } const MCPServerCostConfig: React.FC = ({ value = {}, onChange, - serverId, - serverUrl, - accessToken, + tools = [], disabled = false }) => { const handleDefaultCostChange = (defaultCost: number | null) => { @@ -29,13 +25,24 @@ const MCPServerCostConfig: React.FC = ({ onChange?.(updated); }; + const handleToolCostChange = (toolName: string, cost: number | null) => { + const updated = { + ...value, + tool_name_to_cost_per_query: { + ...value.tool_name_to_cost_per_query, + [toolName]: cost + } + }; + onChange?.(updated); + }; + return (
Cost Configuration - +
@@ -63,15 +70,84 @@ const MCPServerCostConfig: React.FC = ({ Set a default cost for all tool calls to this server
+ + {tools.length > 0 && ( +
+ + + + Available Tools + +
+ ), + children: ( +
+ {tools.map((tool, index) => ( +
+
+ {tool.name} + {tool.description && ( + + {tool.description} + + )} +
+
+ handleToolCostChange(tool.name, cost)} + disabled={disabled} + style={{ width: '120px' }} + addonBefore="$" + /> +
+
+ ))} +
+ ), + }, + ]} + /> +
+ )} - {value.default_cost_per_query && ( + {(value.default_cost_per_query || (value.tool_name_to_cost_per_query && Object.keys(value.tool_name_to_cost_per_query).length > 0)) && (
Cost Summary:
- - • Default cost: ${value.default_cost_per_query.toFixed(4)} per query - + {value.default_cost_per_query && ( + + • Default cost: ${value.default_cost_per_query.toFixed(4)} per query + + )} + {value.tool_name_to_cost_per_query && Object.entries(value.tool_name_to_cost_per_query).map(([toolName, cost]) => + cost !== null && cost !== undefined && ( + + • {toolName}: ${cost.toFixed(4)} per query + + ) + )}
)} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index e30888a82d..572d51cbf8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -92,14 +92,12 @@ const MCPServerEdit: React.FC = ({ mcpServer, accessToken, o
- +
Cancel diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index e87598e714..b1ab828e76 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -54,6 +54,7 @@ export interface InputSchemaProperty { // Define MCPServerCostInfo for cost tracking export interface MCPServerCostInfo { default_cost_per_query?: number | null; + tool_name_to_cost_per_query?: Record; } // Define MCP provider info diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index b37853370e..2aab6912c5 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5999,3 +5999,122 @@ export const mcpToolsCall = async (accessToken: string) => { return await response.json(); }; + +export const testMCPConnectionRequest = async ( + accessToken: string, + mcpServerConfig: Record +) => { + try { + console.log( + "Testing MCP connection with config:", + JSON.stringify(mcpServerConfig) + ); + + // Construct the URL for POST request + const url = proxyBaseUrl + ? `${proxyBaseUrl}/mcp-rest/test/connection` + : `/mcp-rest/test/connection`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + body: JSON.stringify(mcpServerConfig), + }); + + // Check for non-JSON responses first + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + const text = await response.text(); + console.error("Received non-JSON response:", text); + throw new Error( + `Received non-JSON response (${response.status}: ${response.statusText}). Check network tab for details.` + ); + } + + const data = await response.json(); + + if (!response.ok || data.status === "error") { + // Return the error response instead of throwing an error + // This allows the caller to handle the error format properly + if (data.status === "error") { + return data; // Return the full error response + } else { + return { + status: "error", + message: + data.error?.message || + `MCP connection test failed: ${response.status} ${response.statusText}`, + }; + } + } + + return data; + } catch (error) { + console.error("MCP connection test error:", error); + // For network errors or other exceptions, still throw + throw error; + } +}; + +export const testMCPToolsListRequest = async ( + accessToken: string, + mcpServerConfig: Record +) => { + try { + console.log( + "Testing MCP tools list with config:", + JSON.stringify(mcpServerConfig) + ); + + // Construct the URL for POST request + const url = proxyBaseUrl + ? `${proxyBaseUrl}/mcp-rest/test/tools/list` + : `/mcp-rest/test/tools/list`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + }, + body: JSON.stringify(mcpServerConfig), + }); + + // Check for non-JSON responses first + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { + const text = await response.text(); + console.error("Received non-JSON response:", text); + throw new Error( + `Received non-JSON response (${response.status}: ${response.statusText}). Check network tab for details.` + ); + } + + const data = await response.json(); + + if (!response.ok || data.error) { + // Return the error response instead of throwing an error + // This allows the caller to handle the error format properly + if (data.error) { + return data; // Return the full error response + } else { + return { + tools: [], + error: "request_failed", + message: + data.message || + `MCP tools list failed: ${response.status} ${response.statusText}`, + }; + } + } + + return data; + } catch (error) { + console.error("MCP tools list test error:", error); + // For network errors or other exceptions, still throw + throw error; + } +};