From 0131b00cd247745f3d89f1a6928cb89f978e3569 Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 08:39:46 -0700 Subject: [PATCH 1/7] feat: mcp CRUD operations with authn/authz --- docs/my-website/docs/mcp.md | 13 +- litellm/proxy/_experimental/mcp_server/db.py | 186 ++++++++++++ .../proxy/_experimental/mcp_server/server.py | 62 ++-- litellm/proxy/_types.py | 38 +++ litellm/proxy/auth/route_checks.py | 2 + .../mcp_management_endpoints.py | 283 ++++++++++++++++++ litellm/proxy/proxy_server.py | 2 + .../proxy/experimental/mcp_server/test_db.py | 14 + 8 files changed, 567 insertions(+), 33 deletions(-) create mode 100644 litellm/proxy/_experimental/mcp_server/db.py create mode 100644 litellm/proxy/management_endpoints/mcp_management_endpoints.py create mode 100644 tests/litellm/proxy/experimental/mcp_server/test_db.py diff --git a/docs/my-website/docs/mcp.md b/docs/my-website/docs/mcp.md index f04324f965fd..400672d1d718 100644 --- a/docs/my-website/docs/mcp.md +++ b/docs/my-website/docs/mcp.md @@ -18,10 +18,19 @@ This allows you to define tools that can be called by any MCP compatible client. #### How it works +1. Allow proxy admin users to perform create, update, and delete operations on MCP servers stored in the db. +2. Allows users to view and call tools to the MCP servers they have access to. + LiteLLM exposes the following MCP endpoints: -- `/mcp/tools/list` - List all available tools -- `/mcp/tools/call` - Call a specific tool with the provided arguments +- GET `/mcp/enabled` - Returns if MCP is enabled (python>=3.10 requirements are met) +- GET `/mcp/tools/list` - List all available tools +- POST `/mcp/tools/call` - Call a specific tool with the provided arguments +- GET `/v1/mcp/server` - Returns all of the configured mcp servers in the db filtered by requestor's access +- GET `/v1/mcp/server/{server_id}` - Returns the the specific mcp server in the db given `server_id` filtered by requestor's access +- GET `/v1/mcp/server/{server_id}/tools` - Get all the tools from the mcp server specified by the `server_id` +- POST `/v1/mcp/server` - Add a new external mcp server. +- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`. When MCP clients connect to LiteLLM they can follow this workflow: diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py new file mode 100644 index 000000000000..d6983616deab --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -0,0 +1,186 @@ +from typing import Dict, Iterable, List, Literal, Optional, Set, Union, cast + +import uuid + +from prisma.models import LiteLLM_MCPServerTable, LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable +from litellm.proxy._types import NewMCPServerRequest, SpecialMCPServerName, UserAPIKeyAuth +from litellm.proxy.utils import PrismaClient + + +async def get_all_mcp_servers(prisma_client: PrismaClient) -> List[LiteLLM_MCPServerTable]: + """ + Returns all of the mcp servers from the db + """ + mcp_servers = await prisma_client.db.litellm_mcpservertable.find_many() + + return mcp_servers + + +async def get_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]: + """ + Returns the matching mcp server from the db iff exists + """ + mcp_server: Optional[LiteLLM_MCPServerTable] = await prisma_client.db.litellm_mcpservertable.find_unique( + where={ + "server_id": server_id, + } + ) + return mcp_server + + +async def get_mcp_servers(prisma_client: PrismaClient, server_ids: Iterable[str]) -> List[LiteLLM_MCPServerTable]: + """ + Returns the matching mcp servers from the db with the server_ids + """ + mcp_servers: List[LiteLLM_MCPServerTable] = await prisma_client.db.litellm_mcpservertable.find_many( + where={ + "server_id": {"in": server_ids}, + } + ) + return mcp_servers + + +async def get_mcp_servers_by_verificationtoken(prisma_client: PrismaClient, token: str) -> List[str]: + """ + Returns the mcp servers from the db for the verification token + """ + verification_token_record: LiteLLM_TeamTable = await prisma_client.db.litellm_verificationtoken.find_unique( + where={ + "token": token, + }, + include={ + "object_permission": True, + }, + ) + + mcp_servers = [] + if verification_token_record is not None and verification_token_record.object_permission is not None: + mcp_servers = verification_token_record.object_permission.mcp_servers + return mcp_servers + + +async def get_mcp_servers_by_team(prisma_client: PrismaClient, team_id: str) -> List[str]: + """ + Returns the mcp servers from the db for the team id + """ + team_record: LiteLLM_TeamTable = await prisma_client.db.litellm_teamtable.find_unique( + where={ + "team_id": team_id, + }, + include={ + "object_permission": True, + }, + ) + + mcp_servers = [] + if team_record is not None and team_record.object_permission is not None: + mcp_servers = team_record.object_permission.mcp_servers + return mcp_servers + + +async def get_all_mcp_servers_for_user( + prisma_client: PrismaClient, + user: UserAPIKeyAuth, +) -> List[LiteLLM_MCPServerTable]: + """ + Get all the mcp servers filtered by the given user has access to. + + Following Least-Privilege Principle - the requestor should only be able to see the mcp servers that they have access to. + """ + + mcp_server_ids: Set[str] = set() + mcp_servers = [] + + # Get the mcp servers for the key + if user.api_key: + token_mcp_servers = await get_mcp_servers_by_verificationtoken(prisma_client, user.api_key) + mcp_server_ids.update(token_mcp_servers) + + # check for special team membership + if SpecialMCPServerName.all_team_servers in mcp_server_ids and user.team_id is not None: + team_mcp_servers = await get_mcp_servers_by_team(prisma_client, user.team_id) + mcp_server_ids.update(team_mcp_servers) + + if len(mcp_server_ids) > 0: + mcp_servers = await get_mcp_servers(prisma_client, mcp_server_ids) + + return mcp_servers + + +async def get_objectpermissions_for_mcp_server( + prisma_client: PrismaClient, mcp_server_id: str +) -> List[LiteLLM_ObjectPermissionTable]: + """ + Get all the object permissions records and the associated team and verficiationtoken records that have access to the mcp server + """ + object_permission_records = await prisma_client.db.litellm_objectpermissiontable.find_many( + where={ + "mcp_servers": {"has": mcp_server_id}, + }, + include={ + "teams": True, + "verification_tokens": True, + }, + ) + + return object_permission_records + + +async def get_virtualkeys_for_mcp_server(prisma_client: PrismaClient, server_id: str) -> List: + """ + Get all the virtual keys that have access to the mcp server + """ + virtual_keys = await prisma_client.db.litellm_verificationtoken.find_many( + where={ + "mcp_servers": {"has": server_id}, + }, + ) + + if virtual_keys is None: + return [] + return virtual_keys + + +async def delete_mcp_server_from_team(prisma_client: PrismaClient, server_id: str): + """ + Remove the mcp server from the team + """ + pass + + +async def delete_mcp_server_from_virtualkey(): + """ + Remove the mcp server from the virtual key + """ + pass + + +async def delete_mcp_server(prisma_client: PrismaClient, server_id: str) -> Optional[LiteLLM_MCPServerTable]: + """ + Delete the mcp server from the db by server_id + + Returns the deleted mcp server record if it exists, otherwise None + """ + deleted_server = await prisma_client.db.litellm_mcpservertable.delete( + where={ + "server_id": server_id, + }, + ) + return deleted_server + + +async def create_mcp_server(prisma_client: PrismaClient, data: NewMCPServerRequest, touched_by: str) -> LiteLLM_MCPServerTable: + if data.server_id is None: + data.server_id = str(uuid.uuid4()) + + """ + Create a new mcp server record in the db + """ + mcp_server_record = await prisma_client.db.litellm_mcpservertable.create( + data={ + **data.model_dump(), + 'created_by': touched_by, + 'updated_by': touched_by, + } + ) + return mcp_server_record diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index fe1eccb048f8..d49c9837cf89 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -6,10 +6,11 @@ from typing import Any, Dict, List, Optional, Union from anyio import BrokenResourceError -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import StreamingResponse from pydantic import ConfigDict, ValidationError +from litellm._version import version from litellm._logging import verbose_logger from litellm.constants import MCP_TOOL_NAME_PREFIX from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -19,20 +20,33 @@ from litellm.types.utils import StandardLoggingMCPToolCall from litellm.utils import client +router = APIRouter( + prefix="/mcp", + tags=["mcp"], +) + # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8 # We're making this conditional import to avoid breaking users who use python 3.8. +# TODO: Make this a util function for litellm client usage +MCP_AVAILABLE: bool = True try: from mcp.server import Server - - MCP_AVAILABLE = True except ImportError as e: verbose_logger.debug(f"MCP module not found: {e}") MCP_AVAILABLE = False - router = APIRouter( - prefix="/mcp", - tags=["mcp"], - ) + + +# Routes +@router.get( + "/enabled", + description="Returns if the MCP server is enabled", +) +def get_mcp_server_enabled() -> Dict[str, bool]: + """ + Returns if the MCP server is enabled + """ + return {"enabled": MCP_AVAILABLE} if MCP_AVAILABLE: @@ -63,10 +77,6 @@ class ListMCPToolsRestAPIResponseObject(MCPTool): ######################################################## ############ Initialize the MCP Server ################# ######################################################## - router = APIRouter( - prefix="/mcp", - tags=["mcp"], - ) server: Server = Server("litellm-mcp-server") sse: SseServerTransport = SseServerTransport("/mcp/sse/messages") @@ -93,9 +103,7 @@ async def _list_mcp_tools() -> List[MCPTool]: inputSchema=tool.input_schema, ) ) - verbose_logger.debug( - "GLOBAL MCP TOOLS: %s", global_mcp_tool_registry.list_tools() - ) + verbose_logger.debug("GLOBAL MCP TOOLS: %s", global_mcp_tool_registry.list_tools()) sse_tools: List[MCPTool] = await global_mcp_server_manager.list_tools() verbose_logger.debug("SSE TOOLS: %s", sse_tools) if sse_tools is not None: @@ -134,28 +142,20 @@ async def call_mcp_tool( Call a specific tool with the provided arguments """ if arguments is None: - raise HTTPException( - status_code=400, detail="Request arguments are required" - ) + raise HTTPException(status_code=400, detail="Request arguments are required") - standard_logging_mcp_tool_call: StandardLoggingMCPToolCall = ( - _get_standard_logging_mcp_tool_call( - name=name, - arguments=arguments, - ) - ) - litellm_logging_obj: Optional[LiteLLMLoggingObj] = kwargs.get( - "litellm_logging_obj", None + standard_logging_mcp_tool_call: StandardLoggingMCPToolCall = _get_standard_logging_mcp_tool_call( + name=name, + arguments=arguments, ) + litellm_logging_obj: Optional[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj", None) if litellm_logging_obj: - litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = ( - standard_logging_mcp_tool_call - ) + litellm_logging_obj.model_call_details["mcp_tool_call_metadata"] = standard_logging_mcp_tool_call litellm_logging_obj.model_call_details["model"] = ( f"{MCP_TOOL_NAME_PREFIX}: {standard_logging_mcp_tool_call.get('name') or ''}" ) - litellm_logging_obj.model_call_details["custom_llm_provider"] = ( - standard_logging_mcp_tool_call.get("mcp_server_name") + litellm_logging_obj.model_call_details["custom_llm_provider"] = standard_logging_mcp_tool_call.get( + "mcp_server_name" ) # Try managed server tool first @@ -306,4 +306,4 @@ async def call_tool_rest_api( notification_options=NotificationOptions(), experimental_capabilities={}, ), - ) + ) \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ce2639b3b35c..349e0851293b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -799,6 +799,44 @@ def check_potential_json_str(cls, values): pass return values +# MCP Types +class SpecialMCPServerName(str, enum.Enum): + all_team_servers = "all-team-mcpservers" + all_proxy_servers = "all-proxy-mcpservers" + + +class MCPTransport(str, enum.Enum): + sse = "sse" + http = "http" + + +class MCPSpecVersion(str, enum.Enum): + nov_2024 = "2024-11-05" + mar_2025 = "2025-03-26" + + +class MCPAuth(str, enum.Enum): + api_key = "api_key" + bearer_token = "bearer_token" + basic = "basic" + + +# MCP Literals +MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http] +MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025] +MCPAuthType = Literal[MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic] + + +# MCP Proxy Request Types +class NewMCPServerRequest(LiteLLMPydanticObjectBase): + server_id: Optional[str] = None + alias: Optional[str] = None + description: Optional[str] = None + transport: MCPTransportType = MCPTransport.sse + spec_version: MCPSpecVersionType = MCPSpecVersion.mar_2025 + auth_type: Optional[MCPAuthType] = None + url: str + class NewUserRequest(GenerateRequestBase): max_budget: Optional[float] = None diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 93bcb70a9005..5fae38cee65a 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -149,6 +149,8 @@ def non_proxy_admin_allowed_routes_check( route=route, allowed_routes=LiteLLMRoutes.self_managed_routes.value ): # routes that manage their own allowed/disallowed logic pass + elif route.startswith("/v1/mcp/"): + pass # authN/authZ handled by api itself else: user_role = "unknown" user_id = "unknown" diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py new file mode 100644 index 000000000000..aa3bdd0837e2 --- /dev/null +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -0,0 +1,283 @@ +""" +1. Allow proxy admin to perform create, update, and delete operations on MCP servers in the db. +2. Allows users to view the mcp servers they have access to. + +Endpoints here: +- GET `/v1/mcp/server` - Returns all of the configured mcp servers in the db filtered by requestor's access +- GET `/v1/mcp/server/{server_id}` - Returns the the specific mcp server in the db given `server_id` filtered by requestor's access +- GET `/v1/mcp/server/{server_id}/tools` - Get all the tools from the mcp server specified by the `server_id` +- POST `/v1/mcp/server` - Add a new external mcp server. +- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`. +""" + +from typing import Iterable, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Header, Request, Query, Response, status +from fastapi.responses import JSONResponse +from prisma.models import LiteLLM_MCPServerTable + +import litellm +from litellm._logging import verbose_proxy_logger +from litellm.constants import ( + LITELLM_PROXY_ADMIN_NAME, +) +from litellm.proxy._experimental.mcp_server.db import ( + create_mcp_server, + delete_mcp_server, + get_all_mcp_servers, + get_all_mcp_servers_for_user, + get_mcp_server, +) +from litellm.proxy._types import ( + LitellmUserRoles, + NewMCPServerRequest, + SpecialMCPServerName, + UserAPIKeyAuth, +) +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.audit_logs import create_object_audit_log +from litellm.proxy.management_helpers.utils import management_endpoint_wrapper + +router = APIRouter(prefix="/v1/mcp", tags=["mcp"]) + + +def get_prisma_client_or_throw(message: str): + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": message}, + ) + return prisma_client + + +def does_mcp_server_exist(mcp_server_records: Iterable[LiteLLM_MCPServerTable], mcp_server_id: str) -> bool: + """ + Check if the mcp server with the given id exists in the iterable of mcp servers + """ + for mcp_server_record in mcp_server_records: + if mcp_server_record.server_id == mcp_server_id: + return True + return False + + +## FastAPI Routes +@router.get( + "/server", + description="Returns the mcp server list", + dependencies=[Depends(user_api_key_auth)], + response_model=List[LiteLLM_MCPServerTable], +) +async def fetch_all_mcp_servers( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get all of the configured mcp servers for the user in the db + ``` + curl --location 'http://localhost:4000/v1/mcp/server' \ + --header 'Authorization: Bearer your_api_key_here' + ``` + """ + prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy") + + # perform authz check to filter the mcp servers user has access to + if _user_has_admin_view(user_api_key_dict): + return await get_all_mcp_servers(prisma_client) + + # Find all mcp servers the user has access to + return await get_all_mcp_servers_for_user(prisma_client, user_api_key_dict) + + +@router.get( + "/server/{server_id}", + description="Returns the mcp server info", + dependencies=[Depends(user_api_key_auth)], + response_model=LiteLLM_MCPServerTable, +) +async def fetch_mcp_server( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get the info on the mcp server specified by the `server_id` + Parameters: + - server_id: str - Required. The unique identifier of the mcp server to get info on. + ``` + curl --location 'http://localhost:4000/v1/mcp/server/server_id' \ + --header 'Authorization: Bearer your_api_key_here' + ``` + """ + prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy") + + # check to see if server exists for all users + mcp_server = await get_mcp_server(prisma_client, server_id) + if mcp_server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server with id {server_id} not found"}, + ) + + # Implement authz restriction from requested user + if _user_has_admin_view(user_api_key_dict): + return mcp_server + + # Perform authz check to filter the mcp servers user has access to + mcp_server_records = await get_all_mcp_servers_for_user(prisma_client, user_api_key_dict) + exists = does_mcp_server_exist(mcp_server_records, server_id) + + if exists: + return mcp_server + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": f"User does not have permission to view mcp server with id {server_id}. You can only view mcp servers that you have access to." + }, + ) + +@router.get( + "/server/{server_id}/tools", + description="Returns the mcp server's tools", + dependencies=[Depends(user_api_key_auth)], + response_model=LiteLLM_MCPServerTable, +) +async def fetch_mcp_server_tools( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get all the tools from the mcp server specified by the `server_id` + Parameters: + - server_id: str - Required. The unique identifier of the mcp server to get info on. + ``` + curl --location 'http://localhost:4000/v1/mcp/server/server_id/tools' \ + --header 'Authorization: Bearer your_api_key_here' + ``` + """ + # TODO: Find the mcp servers for the key and make tool call request + # TODO: implement authz restriction from requested user + # TODO: request the tools + raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="Coming soon!") + + +@router.post( + "/server", + description="Allows creation of mcp servers", + dependencies=[Depends(user_api_key_auth)], + response_model=LiteLLM_MCPServerTable, +) +@management_endpoint_wrapper +async def add_mcp_server( + payload: NewMCPServerRequest, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + litellm_changed_by: Optional[str] = Header( + None, + description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", + ), +): + """ + Allow users to add a new external mcp server. + """ + prisma_client = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy") + + # AuthZ - restrict only proxy admins to create mcp servers + if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN." + }, + ) + elif payload.server_id is not None: + # fail if the mcp server with id already exists + mcp_server = await get_mcp_server(prisma_client, payload.server_id) + if mcp_server is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": f"MCP Server with id {payload.server_id} already exists. Cannot create another."}, + ) + elif ( + SpecialMCPServerName.all_team_servers == payload.server_id + or SpecialMCPServerName.all_proxy_servers == payload.server_id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": f"MCP Server with id {payload.server_id} is special and cannot be used."}, + ) + + # TODO: audit log for create + + # attempt to create the mcp server + try: + new_mcp_server = await create_mcp_server( + prisma_client, payload, touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME + ) + except Exception as e: + verbose_proxy_logger.exception(f"Error creating mcp server: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": f"Error creating mcp server: {str(e)}"}, + ) + return new_mcp_server + + +@router.delete( + "/server/{server_id}", + description="Allows deleting mcp serves in the db", + dependencies=[Depends(user_api_key_auth)], + response_class=JSONResponse, +) +@management_endpoint_wrapper +async def remove_mcp_server( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + litellm_changed_by: Optional[str] = Header( + None, + description="The litellm-changed-by header enables tracking of actions performed by authorized users on behalf of other users, providing an audit trail for accountability", + ), +): + """ + Delete MCP Server from db and associated MCP related server entities. + + Parameters: + - server_id: str - Required. The unique identifier of the mcp server to delete. + ``` + curl -X "DELETE" --location 'http://localhost:4000/v1/mcp/server/server_id' \ + --header 'Authorization: Bearer your_api_key_here' + ``` + """ + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" + ) + + # Authz - restrict only admins to delete mcp servers + if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Call not allowed to delete MCP server. User is not a proxy admin. route={}".format( + "DELETE /v1/mcp/server" + ) + }, + ) + + # try to delete the mcp server + mcp_server_record_deleted = await delete_mcp_server(prisma_client, server_id) + + if mcp_server_record_deleted is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": f"MCP Server not found, passed server_id={server_id}"}, + ) + + # TODO: Enterprise: Finish audit log trail + if litellm.store_audit_logs: + pass + + # TODO: Delete from virtual keys + + # TODO: Delete from teams + + return Response(status_code=status.HTTP_202_ACCEPTED) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 18e1d8d98a75..8d275593717a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -241,6 +241,7 @@ def generate_feedback_box(): from litellm.proxy.management_endpoints.model_management_endpoints import ( router as model_management_router, ) +from litellm.proxy.management_endpoints.mcp_management_endpoints import router as mcp_management_router from litellm.proxy.management_endpoints.organization_endpoints import ( router as organization_router, ) @@ -8286,6 +8287,7 @@ async def get_routes(): app.include_router(credential_router) app.include_router(llm_passthrough_router) app.include_router(mcp_router) +app.include_router(mcp_management_router) app.include_router(anthropic_router) app.include_router(langfuse_router) app.include_router(pass_through_router) diff --git a/tests/litellm/proxy/experimental/mcp_server/test_db.py b/tests/litellm/proxy/experimental/mcp_server/test_db.py new file mode 100644 index 000000000000..49fbc874e32a --- /dev/null +++ b/tests/litellm/proxy/experimental/mcp_server/test_db.py @@ -0,0 +1,14 @@ +import json +import os +import sys + +import pytest + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy._experimental.mcp_server.db import get_mcp_servers_by_team + +def test_fetch_mcp_servers_by_team(): + assert True == True \ No newline at end of file From 1f8e442de5f72c23222a62f3ebe3a9b39560ce14 Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 10:02:40 -0700 Subject: [PATCH 2/7] feat: mcp server UI --- ui/litellm-dashboard/src/app/page.tsx | 19 +- .../src/components/leftnav.tsx | 1 + .../src/components/mcp_tools/index.tsx | 9 +- .../src/components/mcp_tools/mcp_servers.tsx | 414 ++++++++++++++++++ .../src/components/mcp_tools/types.tsx | 23 +- .../src/components/networking.tsx | 67 +++ 6 files changed, 522 insertions(+), 11 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 175032812925..2225e91bf8af 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -32,7 +32,7 @@ import GuardrailsPanel from "@/components/guardrails"; import TransformRequestPanel from "@/components/transform_request"; import { fetchUserModels } from "@/components/create_key_button"; import { fetchTeams } from "@/components/common_components/fetch_teams"; -import MCPToolsViewer from "@/components/mcp_tools"; +import { MCPToolsViewer, MCPServers } from "@/components/mcp_tools"; import TagManagement from "@/components/tag_management"; import VectorStoreManagement from "@/components/vector_store_management"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; @@ -360,9 +360,9 @@ export default function CreateKeyPage() { ) : page == "guardrails" ? ( - ): page == "transform-request" ? ( + ) : page == "transform-request" ? ( - ): page == "general-settings" ? ( + ) : page == "general-settings" ? ( + ) : page == "mcp-servers" ? ( + ) : page == "mcp-tools" ? ( - ) : - ( + ) : ( = ({ { key: "20", page: "transform-request", label: "API Playground", icon: , roles: [...all_admin_roles, ...internalUserRoles] }, { key: "18", page: "mcp-tools", label: "MCP Tools", icon: , roles: all_admin_roles }, { key: "19", page: "tag-management", label: "Tag Management", icon: , roles: all_admin_roles }, + { key: "29", page: "mcp-servers", label: "MCP Servers", icon: , roles: all_admin_roles }, { key: "21", page: "vector-stores", label: "Vector Stores", icon: , roles: all_admin_roles }, { key: "4", page: "usage", label: "Old Usage", icon: }, ] diff --git a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx index c403b4966801..853378e2425e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/index.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/index.tsx @@ -4,6 +4,7 @@ import { DataTable } from '../view_logs/table'; import { columns, ToolTestPanel } from './columns'; import { MCPTool, MCPToolsViewerProps, CallMCPToolResponse } from './types'; import { listMCPTools, callMCPTool } from '../networking'; +import MCPServers from './mcp_servers'; // Wrapper to handle the type mismatch between MCPTool and DataTable's expected type function DataTableWrapper({ @@ -32,11 +33,11 @@ function DataTableWrapper({ ); } -export default function MCPToolsViewer({ +const MCPToolsViewer = ({ accessToken, userRole, userID, -}: MCPToolsViewerProps) { +}: MCPToolsViewerProps) => { const [searchTerm, setSearchTerm] = useState(''); const [selectedTool, setSelectedTool] = useState(null); const [toolResult, setToolResult] = useState(null); @@ -171,4 +172,6 @@ export default function MCPToolsViewer({ )} ); -} \ No newline at end of file +} + +export { MCPToolsViewer, MCPServers }; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx new file mode 100644 index 000000000000..aa36e5f9a67e --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -0,0 +1,414 @@ +import React, { useState, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; + +import { + InformationCircleIcon, + PencilAltIcon, + PencilIcon, + RefreshIcon, + StatusOnlineIcon, + TrashIcon, +} from "@heroicons/react/outline"; + +import { + Button as Button2, + Modal, + Form, + Input, + Select as Select2, + message, + Tooltip, +} from "antd"; + +import { + Title, + Button as TremorButton, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + TextInput, + Card, + Icon, + Button, + Badge, + Col, + Text, + Grid, + Accordion, + AccordionHeader, + AccordionBody, + TabGroup, + TabList, + TabPanel, + TabPanels, + Tab, +} from "@tremor/react"; + +import { deleteMCPServer, fetchMCPServers } from "../networking"; +import { + MCPServer, + MCPServerProps, + MCPTool, + MCPToolsViewerProps, + CallMCPToolResponse, +} from "./types"; +import { isAdminRole } from "@/utils/roles"; + +const TRANSPORT = { + SSE: "sse", + HTTP: "http", +}; + +const handleTransport = (transport?: string) => { + if (transport === null || transport === undefined) { + return TRANSPORT.SSE; + } + + return transport; +}; + +const displayFriendlyId = (id: string) => { + return `${id.slice(0, 7)}...`; +}; + +const createMCPServer = (server: MCPServer) => { + return { + server_id: server.server_id, + alias: server.alias, + transport: server.transport, + sort_by: "created_at", + sort_order: "desc", + }; +}; + +const handleEdit = (server_id: string) => {}; + +interface CreateMCPServerProps { + userRole: string; + setServerCreate: (value: boolean) => void; +} + +const CreateMCPServer: React.FC = ({ + userRole, + setServerCreate, +}) => { + const [isModalAddVisible, setModalAddVisible] = useState(false); + + if (!isAdminRole(userRole)) { + return null; + } + + return ( +
+ +
+ ); +}; + +interface DeleteModalProps { + isModalOpen: boolean; + title: string; + confirmDelete: () => void; + cancelDelete: () => void; +} + +const DeleteModal: React.FC = ({ + isModalOpen, + title, + confirmDelete, + cancelDelete, +}) => { + if (!isModalOpen) return null; + + return ( +
+
+ + + {/* Modal Panel */} + + + {/* Confirmation Modal Content */} +
+
+
+
+

+ {title} +

+
+

+ Are you sure you want to delete this MCP Server? +

+
+
+
+
+
+ + +
+
+
+
+ ); +}; + +interface MCPServerViewProps { + mcpServer: MCPServer; + onBack: () => void; + isProxyAdmin: boolean; + isEditing: boolean; +} + +const MCPServerView: React.FC = ({ + mcpServer, + onBack, + isEditing, + isProxyAdmin, +}) => { + return ( +
+
+
+ + {mcpServer.alias} + {mcpServer.server_id} +
+
+
+ ); +}; + +interface FilterState { + server_id: string; + alias: string; + transport: string; + sort_by: string; + sort_order: "asc" | "desc"; +} + +const MCPServers: React.FC = ({ + accessToken, + userRole, + userID, + mcp_servers, +}) => { + const [filters, setFilters] = useState({ + server_id: "", + alias: "", + transport: "", + sort_by: "created_at", + sort_order: "desc", + }); + const [lastRefreshed, setLastRefreshed] = useState(""); + const [serverIdToDelete, setServerToDelete] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedServerId, setSelectedServerId] = useState(null); + const [editServer, setEditServer] = useState(false); + + const handleDelete = (server_id: string) => { + // Set the team to delete and open the confirmation modal + setServerToDelete(server_id); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (serverIdToDelete == null || accessToken == null) { + return; + } + + try { + await deleteMCPServer(accessToken, serverIdToDelete); + // Successfully completed the deletion. Update the state to trigger a rerender. + fetchMCPServers(accessToken); + } catch (error) { + console.error("Error deleting the mcp server:", error); + // Handle any error situations, such as displaying an error message to the user. + } + + // Close the confirmation modal and reset the serverToDelete + setIsDeleteModalOpen(false); + setServerToDelete(null); + }; + + const cancelDelete = () => { + // Close the confirmation modal and reset the serverToDelete + setIsDeleteModalOpen(false); + setServerToDelete(null); + }; + + // Query to fetch MCP tools + const { data: mcpServers, isLoading: isLoadingServers } = useQuery({ + queryKey: ["mcpServers"], + queryFn: () => { + if (!accessToken) throw new Error("Access Token required"); + return fetchMCPServers(accessToken); + }, + enabled: !!accessToken, + }); + + if (!accessToken || !userRole || !userID) { + return ( +
+ Missing required authentication parameters. +
+ ); + } + + return ( +
+ {selectedServerId ? ( + server.server_id === selectedServerId + ) || {} + } + onBack={() => setSelectedServerId(null)} + isProxyAdmin={isAdminRole(userRole)} + isEditing={editServer} + /> + ) : ( +
+
+

MCP Servers

+
+ + + + Server ID + Server Name + Description + Transport + Url + Created + Info + + + + + {!mcpServers || mcpServers.length == 0 + ? [] + : mcpServers.map((mcpServer: MCPServer) => ( + + +
+ + + +
+
+ + {mcpServer.alias} + + + {mcpServer.description} + + + {mcpServer.transport} + + +
+ + {mcpServer.url} + +
+
+ + {mcpServer.created_at + ? new Date(mcpServer.created_at).toLocaleDateString() + : "N/A"} + + + {isAdminRole(userRole) ? ( + <> + { + setSelectedServerId(mcpServer.server_id); + setEditServer(true); + }} + /> + handleDelete(mcpServer.server_id)} + icon={TrashIcon} + size="sm" + /> + + ) : null} + +
+ ))} +
+
+ + { + setEditServer(value); + }} + /> +
+ )} +
+ ); +}; + +export default MCPServers; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 7bbb76fa2372..d560714baef8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -68,4 +68,25 @@ export interface InputSchemaProperty { accessToken: string | null; userRole: string | null; userID: string | null; - } \ No newline at end of file + } + +export interface MCPServer { + server_id: string; + alias: string; + description: string; + url: string; + transport: string; + spec_version: string; + auth_type: string | null; + created_at: string; + created_by: string; + updated_at: string; + updated_by: string; +} + +export interface MCPServerProps { + mcp_servers: MCPServer[] | null; + accessToken: string | null; + userRole: string | null; + userID: string | null; +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f13baeb0b470..f7e571b18b0c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -14,6 +14,13 @@ if (isLocal != true) { console.log = function() {}; } +const HTTP_REQUEST = { + GET: "GET", + POST: "POST", + PUT: "PUT", + DELETE: "DELETE", +}; + export const DEFAULT_ORGANIZATION = "default_organization"; export interface Model { @@ -4405,6 +4412,66 @@ export const updateInternalUserSettings = async (accessToken: string, settings: } }; +export const fetchMCPServers = async (accessToken: string) => { + try { + // Construct base URL + let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`; + + console.log("Fetching MCP servers from:", url); + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("Fetched MCP servers:", data); + return data; + } catch (error) { + console.error("Failed to fetch MCP servers:", error); + throw error; + } +}; + +export const deleteMCPServer = async ( + accessToken: String, + serverId: String +) => { + try { + const url = + (proxyBaseUrl ? `${proxyBaseUrl}` : "") + `/v1/mcp/server/${serverId}`; + console.log("in deleteMCPServer:", serverId); + const response = await fetch(url, { + method: HTTP_REQUEST.DELETE, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + const data = await response.json(); + console.log(data); + return data; + // Handle success - you might want to update some state or UI based on the created key + } catch (error) { + console.error("Failed to delete key:", error); + throw error; + } +}; export const listMCPTools = async (accessToken: string) => { try { From 50be98777452f3466f1d7f1f7087fc4dd89533a5 Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 11:44:26 -0700 Subject: [PATCH 3/7] mcp server page with overview, mcp tools, and settings page --- .../components/mcp_tools/mcp_server_view.tsx | 106 ++++++++++ .../src/components/mcp_tools/mcp_servers.tsx | 187 ++++-------------- .../src/components/mcp_tools/mcp_tools.tsx | 3 + .../src/components/mcp_tools/types.tsx | 32 ++- 4 files changed, 177 insertions(+), 151 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx new file mode 100644 index 000000000000..f9de0dd2a69f --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -0,0 +1,106 @@ +import React from "react"; + +import { + Title, + Card, + Button, + Text, + Grid, + TabGroup, + TabList, + TabPanel, + TabPanels, + Tab, +} from "@tremor/react"; + +import { MCPServer, handleTransport, handleAuth } from "./types"; +import { MCPToolsViewer } from "."; + +interface MCPServerViewProps { + mcpServer: MCPServer; + onBack: () => void; + isProxyAdmin: boolean; + isEditing: boolean; + accessToken: string | null; + userRole: string | null; + userID: string | null; +} + +export const MCPServerView: React.FC = ({ + mcpServer, + onBack, + isEditing, + isProxyAdmin, + accessToken, + userRole, + userID, +}) => { + return ( +
+
+
+ + {mcpServer.alias} + {mcpServer.server_id} +
+
+ + {/* TODO: magic number for index */} + + + {[ + Overview, + MCP Tools, + ...(isProxyAdmin ? [Settings] : []), + ]} + + + + {/* Overview Panel */} + + + + Transport +
+ {handleTransport(mcpServer.transport)} +
+
+ + + Auth Type +
+ {handleAuth(mcpServer.auth_type)} +
+
+ + + Host Url +
{mcpServer.url}
+
+
+
+ + {/* Tool Panel */} + + + + + {/* Settings Panel */} + + +
+ Editing MCP Servers coming soon! +
+
+
+
+
+
+ ); +}; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index aa36e5f9a67e..d8954d2d208b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -1,101 +1,43 @@ -import React, { useState, useEffect } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; +import React, { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; -import { - InformationCircleIcon, - PencilAltIcon, - PencilIcon, - RefreshIcon, - StatusOnlineIcon, - TrashIcon, -} from "@heroicons/react/outline"; +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; -import { - Button as Button2, - Modal, - Form, - Input, - Select as Select2, - message, - Tooltip, -} from "antd"; +import { Modal, Tooltip } from "antd"; import { - Title, - Button as TremorButton, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, - TextInput, - Card, Icon, Button, - Badge, - Col, - Text, Grid, - Accordion, - AccordionHeader, - AccordionBody, - TabGroup, - TabList, - TabPanel, - TabPanels, - Tab, + Col, + Title, } from "@tremor/react"; import { deleteMCPServer, fetchMCPServers } from "../networking"; -import { - MCPServer, - MCPServerProps, - MCPTool, - MCPToolsViewerProps, - CallMCPToolResponse, -} from "./types"; +import { MCPServer, MCPServerProps } from "./types"; import { isAdminRole } from "@/utils/roles"; - -const TRANSPORT = { - SSE: "sse", - HTTP: "http", -}; - -const handleTransport = (transport?: string) => { - if (transport === null || transport === undefined) { - return TRANSPORT.SSE; - } - - return transport; -}; +import { MCPServerView } from "./mcp_server_view"; const displayFriendlyId = (id: string) => { return `${id.slice(0, 7)}...`; }; -const createMCPServer = (server: MCPServer) => { - return { - server_id: server.server_id, - alias: server.alias, - transport: server.transport, - sort_by: "created_at", - sort_order: "desc", - }; -}; - -const handleEdit = (server_id: string) => {}; - interface CreateMCPServerProps { userRole: string; - setServerCreate: (value: boolean) => void; + onCreateSuccess: () => MCPServer; } const CreateMCPServer: React.FC = ({ userRole, - setServerCreate, + onCreateSuccess, }) => { - const [isModalAddVisible, setModalAddVisible] = useState(false); + const [isModalVisible, setModalVisible] = useState(false); if (!isAdminRole(userRole)) { return null; @@ -103,9 +45,19 @@ const CreateMCPServer: React.FC = ({ return (
- + + setModalVisible(false)} + onOk={() => setModalVisible(false)} + > +
Hi
+
); }; @@ -126,73 +78,22 @@ const DeleteModal: React.FC = ({ if (!isModalOpen) return null; return ( -
-
- - - {/* Modal Panel */} - - - {/* Confirmation Modal Content */} -
-
-
-
-

- {title} -

-
-

- Are you sure you want to delete this MCP Server? -

-
-
-
-
-
- - -
-
-
-
- ); -}; - -interface MCPServerViewProps { - mcpServer: MCPServer; - onBack: () => void; - isProxyAdmin: boolean; - isEditing: boolean; -} - -const MCPServerView: React.FC = ({ - mcpServer, - onBack, - isEditing, - isProxyAdmin, -}) => { - return ( -
-
-
- - {mcpServer.alias} - {mcpServer.server_id} -
-
-
+ + + + {title} + +

+ Are you sure you want to delete this MCP Server? +

+ +
+
); }; @@ -208,7 +109,6 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID, - mcp_servers, }) => { const [filters, setFilters] = useState({ server_id: "", @@ -217,7 +117,6 @@ const MCPServers: React.FC = ({ sort_by: "created_at", sort_order: "desc", }); - const [lastRefreshed, setLastRefreshed] = useState(""); const [serverIdToDelete, setServerToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedServerId, setSelectedServerId] = useState(null); @@ -284,6 +183,9 @@ const MCPServers: React.FC = ({ onBack={() => setSelectedServerId(null)} isProxyAdmin={isAdminRole(userRole)} isEditing={editServer} + accessToken={accessToken} + userID={userID} + userRole={userRole} /> ) : (
@@ -399,12 +301,7 @@ const MCPServers: React.FC = ({ confirmDelete={confirmDelete} cancelDelete={cancelDelete} /> - { - setEditServer(value); - }} - /> +
)} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx new file mode 100644 index 000000000000..f1f151d88fc2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -0,0 +1,3 @@ +import { MCPToolsViewer } from "./index"; + +export default MCPToolsViewer; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index d560714baef8..350ae9ea9f23 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -1,3 +1,24 @@ +export const TRANSPORT = { + SSE: "sse", + HTTP: "http", +}; + +export const handleTransport = (transport?: string) => { + if (transport === null || transport === undefined) { + return TRANSPORT.SSE; + } + + return transport; +}; + +export const handleAuth = (authType?: string) => { + if (authType === null || authType === undefined) { + return "-"; + } + + return authType; +}; + // Define the structure for tool input schema properties export interface InputSchemaProperty { type: string; @@ -72,12 +93,12 @@ export interface InputSchemaProperty { export interface MCPServer { server_id: string; - alias: string; - description: string; + alias?: string | null; + description?: string | null; url: string; - transport: string; - spec_version: string; - auth_type: string | null; + transport?: string | null; + spec_version?: string | null; + auth_type?: string | null; created_at: string; created_by: string; updated_at: string; @@ -85,7 +106,6 @@ export interface MCPServer { } export interface MCPServerProps { - mcp_servers: MCPServer[] | null; accessToken: string | null; userRole: string | null; userID: string | null; From d93070bc566a7ffb45c5c8c1d42563e3787e3505 Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 12:48:56 -0700 Subject: [PATCH 4/7] Adding MCP Server flow --- litellm/proxy/_types.py | 3 +- .../components/mcp_tools/mcp_server_view.tsx | 1 + .../src/components/mcp_tools/mcp_servers.tsx | 188 +++++++++++++++--- .../src/components/mcp_tools/mcp_tools.tsx | 1 + .../src/components/mcp_tools/types.tsx | 4 +- .../src/components/networking.tsx | 39 +++- 6 files changed, 200 insertions(+), 36 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 349e0851293b..2e595a1fc67e 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -816,6 +816,7 @@ class MCPSpecVersion(str, enum.Enum): class MCPAuth(str, enum.Enum): + none = "none" api_key = "api_key" bearer_token = "bearer_token" basic = "basic" @@ -824,7 +825,7 @@ class MCPAuth(str, enum.Enum): # MCP Literals MCPTransportType = Literal[MCPTransport.sse, MCPTransport.http] MCPSpecVersionType = Literal[MCPSpecVersion.nov_2024, MCPSpecVersion.mar_2025] -MCPAuthType = Literal[MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic] +MCPAuthType = Literal[MCPAuth.none, MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic] # MCP Proxy Request Types diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index f9de0dd2a69f..b6e5c89d4044 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -14,6 +14,7 @@ import { } from "@tremor/react"; import { MCPServer, handleTransport, handleAuth } from "./types"; +// TODO: Move Tools viewer from index file import { MCPToolsViewer } from "."; interface MCPServerViewProps { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index d8954d2d208b..3b1845fac7cf 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -3,7 +3,15 @@ import { useQuery } from "@tanstack/react-query"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; -import { Modal, Tooltip } from "antd"; +import { + Modal, + Tooltip, + Form, + Select, + message, + Button as AntdButton, +} from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; import { Table, @@ -17,10 +25,23 @@ import { Grid, Col, Title, + TextInput, + Accordion, + AccordionHeader, + AccordionBody, } from "@tremor/react"; -import { deleteMCPServer, fetchMCPServers } from "../networking"; -import { MCPServer, MCPServerProps } from "./types"; +import { + createMCPServer, + deleteMCPServer, + fetchMCPServers, +} from "../networking"; +import { + MCPServer, + MCPServerProps, + handleAuth, + handleTransport, +} from "./types"; import { isAdminRole } from "@/utils/roles"; import { MCPServerView } from "./mcp_server_view"; @@ -30,15 +51,37 @@ const displayFriendlyId = (id: string) => { interface CreateMCPServerProps { userRole: string; + accessToken: string | null; onCreateSuccess: () => MCPServer; } const CreateMCPServer: React.FC = ({ userRole, + accessToken, onCreateSuccess, }) => { + const [form] = Form.useForm(); + + const handleCreate = async (formValues: Record) => { + try { + console.log(`formValues: ${JSON.stringify(formValues)}`); + + if (accessToken != null) { + const response: any = await createMCPServer(accessToken, formValues); + + message.success("MCP Server created successfully"); + form.resetFields(); + setModalVisible(false); + } + } catch (error) { + message.error("Error creating the team: " + error, 20); + } + }; + + // state const [isModalVisible, setModalVisible] = useState(false); + // rendering if (!isAdminRole(userRole)) { return null; } @@ -46,17 +89,98 @@ const CreateMCPServer: React.FC = ({ return (
setModalVisible(false)} - onOk={() => setModalVisible(false)} + okButtonProps={{ style: { display: "none" } }} > -
Hi
+
+ <> + + + + + + + + + + + + + + + + + MCP Version{" "} + + + + + } + name="spec_version" + > + + + +
+ Create MCP Server +
+
); @@ -79,21 +203,18 @@ const DeleteModal: React.FC = ({ return ( - - - {title} - -

- Are you sure you want to delete this MCP Server? -

- -
-
+ open={isModalOpen} + onOk={confirmDelete} + okType="danger" + onCancel={cancelDelete} + > + + {title} + +

Are you sure you want to delete this MCP Server?

+ +
+ ); }; @@ -110,13 +231,6 @@ const MCPServers: React.FC = ({ userRole, userID, }) => { - const [filters, setFilters] = useState({ - server_id: "", - alias: "", - transport: "", - sort_by: "created_at", - sort_order: "desc", - }); const [serverIdToDelete, setServerToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedServerId, setSelectedServerId] = useState(null); @@ -199,6 +313,7 @@ const MCPServers: React.FC = ({ Server Name Description Transport + Auth Type Url Created Info @@ -209,7 +324,7 @@ const MCPServers: React.FC = ({ {!mcpServers || mcpServers.length == 0 ? [] : mcpServers.map((mcpServer: MCPServer) => ( - +
@@ -252,7 +367,16 @@ const MCPServers: React.FC = ({ overflow: "hidden", }} > - {mcpServer.transport} + {handleTransport(mcpServer.transport)} + + + {handleAuth(mcpServer.auth_type )}
@@ -301,7 +425,7 @@ const MCPServers: React.FC = ({ confirmDelete={confirmDelete} cancelDelete={cancelDelete} /> - +
)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index f1f151d88fc2..38c537ecbcb0 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -1,3 +1,4 @@ import { MCPToolsViewer } from "./index"; +// TODO: Move Tools viewer from index file to this file export default MCPToolsViewer; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 350ae9ea9f23..23e650bdd402 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -4,7 +4,7 @@ export const TRANSPORT = { }; export const handleTransport = (transport?: string) => { - if (transport === null || transport === undefined) { + if (transport == null || transport == undefined) { return TRANSPORT.SSE; } @@ -13,7 +13,7 @@ export const handleTransport = (transport?: string) => { export const handleAuth = (authType?: string) => { if (authType === null || authType === undefined) { - return "-"; + return "none"; } return authType; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f7e571b18b0c..ee4f931ea6be 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4415,7 +4415,7 @@ export const updateInternalUserSettings = async (accessToken: string, settings: export const fetchMCPServers = async (accessToken: string) => { try { // Construct base URL - let url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`; + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`; console.log("Fetching MCP servers from:", url); @@ -4442,6 +4442,43 @@ export const fetchMCPServers = async (accessToken: string) => { } }; +export const createMCPServer = async ( + accessToken: string, + formValues: Record // Assuming formValues is an object +) => { + try { + console.log("Form Values in createMCPServer:", formValues); // Log the form values before making the API call + + const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...formValues, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + console.error("Error response from the server:", errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("API Response:", data); + return data; + // Handle success - you might want to update some state or UI based on the created key + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +}; + export const deleteMCPServer = async ( accessToken: String, serverId: String From 0edf501daf2108ccd3216f8c0194e1c001887cc7 Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 12:58:10 -0700 Subject: [PATCH 5/7] prisma generate before test --- .github/workflows/test-litellm.yml | 1 + .../src/components/mcp_tools/mcp_servers.tsx | 5 +---- ui/litellm-dashboard/src/components/mcp_tools/types.tsx | 6 +++--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-litellm.yml b/.github/workflows/test-litellm.yml index 6583844a5f95..f9b77cdac5a2 100644 --- a/.github/workflows/test-litellm.yml +++ b/.github/workflows/test-litellm.yml @@ -32,4 +32,5 @@ jobs: - name: Run tests run: | + prisma generate poetry run pytest tests/litellm -x -vv -n 4 \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 3b1845fac7cf..89578316a8dd 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -26,9 +26,6 @@ import { Col, Title, TextInput, - Accordion, - AccordionHeader, - AccordionBody, } from "@tremor/react"; import { @@ -376,7 +373,7 @@ const MCPServers: React.FC = ({ overflow: "hidden", }} > - {handleAuth(mcpServer.auth_type )} + {handleAuth(mcpServer.auth_type)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 23e650bdd402..47f2fb7de780 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -3,15 +3,15 @@ export const TRANSPORT = { HTTP: "http", }; -export const handleTransport = (transport?: string) => { - if (transport == null || transport == undefined) { +export const handleTransport = (transport?: string): string => { + if (transport === null || transport === undefined) { return TRANSPORT.SSE; } return transport; }; -export const handleAuth = (authType?: string) => { +export const handleAuth = (authType?: string): string => { if (authType === null || authType === undefined) { return "none"; } From e03471fda6475e6a4556424fa49086a5b90b338a Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 13:18:57 -0700 Subject: [PATCH 6/7] UI callbacks add/remove with api server refetch --- .../src/components/mcp_tools/mcp_servers.tsx | 65 ++++++++++++------- .../src/components/mcp_tools/types.tsx | 1 + .../src/components/networking.tsx | 4 -- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 89578316a8dd..6b5a66c11692 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; @@ -49,7 +49,7 @@ const displayFriendlyId = (id: string) => { interface CreateMCPServerProps { userRole: string; accessToken: string | null; - onCreateSuccess: () => MCPServer; + onCreateSuccess: (newMcpServer: MCPServer) => void; } const CreateMCPServer: React.FC = ({ @@ -64,11 +64,15 @@ const CreateMCPServer: React.FC = ({ console.log(`formValues: ${JSON.stringify(formValues)}`); if (accessToken != null) { - const response: any = await createMCPServer(accessToken, formValues); + const response: MCPServer = await createMCPServer( + accessToken, + formValues + ); message.success("MCP Server created successfully"); form.resetFields(); setModalVisible(false); + onCreateSuccess(response); } } catch (error) { message.error("Error creating the team: " + error, 20); @@ -143,7 +147,11 @@ const CreateMCPServer: React.FC = ({ http - + 2025-03-26 @@ -215,19 +226,30 @@ const DeleteModal: React.FC = ({ ); }; -interface FilterState { - server_id: string; - alias: string; - transport: string; - sort_by: string; - sort_order: "asc" | "desc"; -} - const MCPServers: React.FC = ({ accessToken, userRole, userID, }) => { + // Query to fetch MCP tools + const { + data: mcpServers, + isLoading: isLoadingServers, + refetch, + } = useQuery({ + queryKey: ["mcpServers"], + queryFn: () => { + if (!accessToken) throw new Error("Access Token required"); + return fetchMCPServers(accessToken); + }, + enabled: !!accessToken, + }); + + const createMCPServer = (newMcpServer: MCPServer) => { + refetch(); + }; + + // state const [serverIdToDelete, setServerToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedServerId, setSelectedServerId] = useState(null); @@ -247,7 +269,8 @@ const MCPServers: React.FC = ({ try { await deleteMCPServer(accessToken, serverIdToDelete); // Successfully completed the deletion. Update the state to trigger a rerender. - fetchMCPServers(accessToken); + message.success("Deleted MCP Server successfully"); + refetch(); } catch (error) { console.error("Error deleting the mcp server:", error); // Handle any error situations, such as displaying an error message to the user. @@ -264,16 +287,6 @@ const MCPServers: React.FC = ({ setServerToDelete(null); }; - // Query to fetch MCP tools - const { data: mcpServers, isLoading: isLoadingServers } = useQuery({ - queryKey: ["mcpServers"], - queryFn: () => { - if (!accessToken) throw new Error("Access Token required"); - return fetchMCPServers(accessToken); - }, - enabled: !!accessToken, - }); - if (!accessToken || !userRole || !userID) { return (
@@ -422,7 +435,11 @@ const MCPServers: React.FC = ({ confirmDelete={confirmDelete} cancelDelete={cancelDelete} /> - +
)}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 47f2fb7de780..539c712ef88d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -4,6 +4,7 @@ export const TRANSPORT = { }; export const handleTransport = (transport?: string): string => { + console.log(transport) if (transport === null || transport === undefined) { return TRANSPORT.SSE; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index ee4f931ea6be..2b785450fc28 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4500,10 +4500,6 @@ export const deleteMCPServer = async ( handleError(errorData); throw new Error("Network response was not ok"); } - const data = await response.json(); - console.log(data); - return data; - // Handle success - you might want to update some state or UI based on the created key } catch (error) { console.error("Failed to delete key:", error); throw error; From 2a1c83e4804406c3ae4ff93716410d77aca00d9f Mon Sep 17 00:00:00 2001 From: wagnerjt Date: Fri, 9 May 2025 13:22:29 -0700 Subject: [PATCH 7/7] test fix: poetry run prisma --- .github/workflows/test-litellm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-litellm.yml b/.github/workflows/test-litellm.yml index f9b77cdac5a2..f09b8232ee6e 100644 --- a/.github/workflows/test-litellm.yml +++ b/.github/workflows/test-litellm.yml @@ -32,5 +32,5 @@ jobs: - name: Run tests run: | - prisma generate + poetry run prisma generate poetry run pytest tests/litellm -x -vv -n 4 \ No newline at end of file