feat: Allow Adding MCP Servers Through LiteLLM UI (#11208)

* feat: MCP Servers with CRUD operations (#10699)

* feat: mcp CRUD operations with authn/authz

* feat: mcp server UI

* mcp server page with overview, mcp tools, and settings page

* Adding MCP Server flow

* prisma generate before test

* UI callbacks add/remove with api server refetch

* test fix: poetry run prisma

* feat: mcp server db and config connection

* fix: MCPTool filter on description when not present

* feat: mcp on UI and integrated with list tools

* feat: Update mcp server endpoint

* tests: Unit and integration tests for mcp management endpoints

* fix: docs and ensuring global_mcp_manage up to date

* ui: remove the mcp tools view

* fix: ruff lint

* fix: unit -> integration test area

* fix(ui): remove left nav menu of previous tools

---------

Co-authored-by: wagnerjt <wagnerjt@github.com>
Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>

* fix: sync DB MCP tools with in memory

* fix: sync DB MCP tools with in memory

* fix: stop using prisma.models

* fix: code qa check

* fix: import MCP

* fix: code QA checks

* fix: code QA checks

* fixes - only list tools for the specific MCP server

* fix: only list MCP tools for selected server

* fix linting error

---------

Co-authored-by: Tyler Wagner <wagnerjt@users.noreply.github.com>
Co-authored-by: wagnerjt <wagnerjt@github.com>
This commit is contained in:
Ishaan Jaff
2025-05-28 16:29:27 -07:00
committed by GitHub
co-authored by Tyler Wagner wagnerjt
parent d8415f0c39
commit 9a6d5c119e
21 changed files with 1875 additions and 80 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ jobs:
cd ..
- name: Run tests
run: |
poetry run pytest tests/test_litellm -x -vv -n 4
poetry run pytest tests/test_litellm -x -vv -n 4
+11 -2
View File
@@ -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
- PUT `/v1/mcp/server` - Updates an existing external mcp server.
- 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:
@@ -0,0 +1,247 @@
import uuid
from typing import Iterable, List, Optional, Set
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
NewMCPServerRequest,
SpecialMCPServerName,
UpdateMCPServerRequest,
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:
"""
Create a new mcp server record in the db
"""
if data.server_id is None:
data.server_id = str(uuid.uuid4())
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
async def update_mcp_server(
prisma_client: PrismaClient, data: UpdateMCPServerRequest, touched_by: str
) -> LiteLLM_MCPServerTable:
"""
Update a new mcp server record in the db
"""
mcp_server_record = await prisma_client.db.litellm_mcpservertable.update(
where={
"server_id": data.server_id,
},
data={
**data.model_dump(),
"created_by": touched_by,
"updated_by": touched_by,
},
)
return mcp_server_record
@@ -8,27 +8,41 @@ This is a Proxy
import asyncio
import json
from typing import Any, Dict, List, Optional
import uuid
from typing import Any, Dict, List, Optional, cast
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.types import CallToolResult
from mcp.types import Tool as MCPTool
from litellm._logging import verbose_logger
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPSSEServer
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
MCPAuthType,
MCPSpecVersion,
MCPSpecVersionType,
MCPTransport,
MCPTransportType,
)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
class MCPServerManager:
def __init__(self):
self.mcp_servers: List[MCPSSEServer] = []
self.registry: Dict[str, MCPServer] = {}
self.config_mcp_servers: Dict[str, MCPServer] = {}
"""
eg.
[
{
"server-1": {
"name": "zapier_mcp_server",
"url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse"
"transport": "sse",
"auth_type": "api_key",
"spec_version": "2025-03-26"
},
{
"uuid-2": {
"name": "google_drive_mcp_server",
"url": "https://actions.zapier.com/mcp/sk-ak-2ew3bofIeQIkNoeKIdXrF1Hhhp/sse"
}
@@ -42,27 +56,74 @@ class MCPServerManager:
}
"""
def get_registry(self) -> Dict[str, MCPServer]:
"""
Get the registered MCP Servers from the registry and union with the config MCP Servers
"""
return self.config_mcp_servers | self.registry
def load_servers_from_config(self, mcp_servers_config: Dict[str, Any]):
"""
Load the MCP Servers from the config
"""
verbose_logger.debug("Loading MCP Servers from config-----")
for server_name, server_config in mcp_servers_config.items():
_mcp_info: dict = server_config.get("mcp_info", None) or {}
mcp_info = MCPInfo(**_mcp_info)
mcp_info["server_name"] = server_name
self.mcp_servers.append(
MCPSSEServer(
name=server_name,
url=server_config["url"],
mcp_info=mcp_info,
)
mcp_info["description"] = server_config.get("description", None)
new_server = MCPServer(
server_id=str(uuid.uuid4()),
name=server_name,
url=server_config["url"],
# TODO: utility fn the default values
transport=server_config.get("transport", MCPTransport.sse),
spec_version=server_config.get("spec_version", MCPSpecVersion.mar_2025),
auth_type=server_config.get("auth_type", None),
mcp_info=mcp_info,
)
server_id = str(uuid.uuid4())
self.config_mcp_servers[server_id] = new_server
verbose_logger.debug(
f"Loaded MCP Servers: {json.dumps(self.mcp_servers, indent=4, default=str)}"
f"Loaded MCP Servers: {json.dumps(self.config_mcp_servers, indent=4, default=str)}"
)
self.initialize_tool_name_to_mcp_server_name_mapping()
def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
"""
Remove a server from the registry
"""
if mcp_server.alias in self.get_registry():
del self.registry[mcp_server.alias]
verbose_logger.debug(f"Removed MCP Server: {mcp_server.alias}")
elif mcp_server.server_id in self.get_registry():
del self.registry[mcp_server.server_id]
verbose_logger.debug(f"Removed MCP Server: {mcp_server.server_id}")
else:
verbose_logger.warning(
f"Server ID {mcp_server.server_id} not found in registry"
)
def add_update_server(self, mcp_server: LiteLLM_MCPServerTable):
if mcp_server.server_id not in self.get_registry():
new_server = MCPServer(
server_id=mcp_server.server_id,
name=mcp_server.alias or mcp_server.server_id,
url=mcp_server.url,
transport=cast(MCPTransportType, mcp_server.transport),
spec_version=cast(MCPSpecVersionType, mcp_server.spec_version),
auth_type=cast(MCPAuthType, mcp_server.auth_type),
mcp_info=MCPInfo(
server_name=mcp_server.alias or mcp_server.server_id,
description=mcp_server.description,
),
)
self.registry[mcp_server.server_id] = new_server
verbose_logger.debug(
f"Added MCP Server: {mcp_server.alias or mcp_server.server_id}"
)
async def list_tools(self) -> List[MCPTool]:
"""
List all tools available across all MCP Servers.
@@ -71,38 +132,54 @@ class MCPServerManager:
List[MCPTool]: Combined list of tools from all servers
"""
list_tools_result: List[MCPTool] = []
verbose_logger.debug("SSE SERVER MANAGER LISTING TOOLS")
verbose_logger.debug("SERVER MANAGER LISTING TOOLS")
for server in self.mcp_servers:
tools = await self._get_tools_from_server(server)
list_tools_result.extend(tools)
for _, server in self.get_registry().items():
try:
tools = await self._get_tools_from_server(server)
list_tools_result.extend(tools)
except Exception as e:
verbose_logger.exception(
f"Error listing tools from server {server.name}: {str(e)}"
)
return list_tools_result
async def _get_tools_from_server(self, server: MCPSSEServer) -> List[MCPTool]:
async def _get_tools_from_server(self, server: MCPServer) -> List[MCPTool]:
"""
Helper method to get tools from a single MCP server.
Args:
server (MCPSSEServer): The server to query tools from
server (MCPServer): The server to query tools from
Returns:
List[MCPTool]: List of tools available on the server
"""
verbose_logger.debug(f"Connecting to url: {server.url}")
async with sse_client(url=server.url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
verbose_logger.info("_get_tools_from_server...")
# send transport to connect to the server
if server.transport is None or server.transport == MCPTransport.sse:
async with sse_client(url=server.url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools_result = await session.list_tools()
verbose_logger.debug(f"Tools from {server.name}: {tools_result}")
tools_result = await session.list_tools()
verbose_logger.debug(f"Tools from {server.name}: {tools_result}")
# Update tool to server mapping
for tool in tools_result.tools:
self.tool_name_to_mcp_server_name_mapping[tool.name] = server.name
# Update tool to server mapping
for tool in tools_result.tools:
self.tool_name_to_mcp_server_name_mapping[
tool.name
] = server.name
return tools_result.tools
return tools_result.tools
elif server.transport == MCPTransport.http:
# TODO: implement http transport
return []
else:
# TODO: throw error on transport found or skip
return []
def initialize_tool_name_to_mcp_server_name_mapping(self):
"""
@@ -122,7 +199,7 @@ class MCPServerManager:
"""
Call list_tools for each server and update the tool name to MCP server name mapping
"""
for server in self.mcp_servers:
for server in self.get_registry().values():
tools = await self._get_tools_from_server(server)
for tool in tools:
self.tool_name_to_mcp_server_name_mapping[tool.name] = server.name
@@ -134,20 +211,50 @@ class MCPServerManager:
mcp_server = self._get_mcp_server_from_tool_name(name)
if mcp_server is None:
raise ValueError(f"Tool {name} not found")
async with sse_client(url=mcp_server.url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
return await session.call_tool(name, arguments)
elif mcp_server.transport is None or mcp_server.transport == MCPTransport.sse:
async with sse_client(url=mcp_server.url) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
return await session.call_tool(name, arguments)
elif mcp_server.transport == MCPTransport.http:
# TODO: implement http transport
raise NotImplementedError("HTTP transport is not implemented yet")
else:
return CallToolResult(content=[], isError=True)
def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPSSEServer]:
def _get_mcp_server_from_tool_name(self, tool_name: str) -> Optional[MCPServer]:
"""
Get the MCP Server from the tool name
"""
if tool_name in self.tool_name_to_mcp_server_name_mapping:
for server in self.mcp_servers:
for server in self.get_registry().values():
if server.name == self.tool_name_to_mcp_server_name_mapping[tool_name]:
return server
return None
async def _add_mcp_servers_from_db_to_in_memory_registry(self):
from litellm.proxy._experimental.mcp_server.db import get_all_mcp_servers
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
get_prisma_client_or_throw,
)
# perform authz check to filter the mcp servers user has access to
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
db_mcp_servers = await get_all_mcp_servers(prisma_client)
# ensure the global_mcp_server_manager is up to date with the db
for server in db_mcp_servers:
self.add_update_server(server)
def get_mcp_server_by_id(self, server_id: str) -> Optional[MCPServer]:
"""
Get the MCP Server from the server id
"""
for server in self.get_registry().values():
if server.server_id == server_id:
return server
return None
global_mcp_server_manager: MCPServerManager = MCPServerManager()
@@ -6,7 +6,7 @@ import asyncio
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, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import ConfigDict, ValidationError
@@ -19,20 +19,33 @@ from litellm.types.mcp_server.mcp_server_manager import MCPInfo
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 +76,6 @@ if MCP_AVAILABLE:
########################################################
############ Initialize the MCP Server #################
########################################################
router = APIRouter(
prefix="/mcp",
tags=["mcp"],
)
server: Server = Server("litellm-mcp-server")
sse: SseServerTransport = SseServerTransport("/mcp/sse/messages")
@@ -148,15 +157,15 @@ if MCP_AVAILABLE:
"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["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[
"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")
# Try managed server tool first
if name in global_mcp_server_manager.tool_name_to_mcp_server_name_mapping:
@@ -235,7 +244,12 @@ if MCP_AVAILABLE:
############ MCP Server REST API Routes #################
########################################################
@router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
async def list_tool_rest_api() -> List[ListMCPToolsRestAPIResponseObject]:
async def list_tool_rest_api(
server_id: Optional[str] = Query(
None, description="The server id to list tools for"
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> List[ListMCPToolsRestAPIResponseObject]:
"""
List all available tools with information about the server they belong to.
@@ -263,7 +277,9 @@ if MCP_AVAILABLE:
]
"""
list_tools_result: List[ListMCPToolsRestAPIResponseObject] = []
for server in global_mcp_server_manager.mcp_servers:
for server in global_mcp_server_manager.get_registry().values():
if server_id and server.server_id != server_id:
continue
try:
tools = await global_mcp_server_manager._get_tools_from_server(server)
for tool in tools:
+76
View File
@@ -817,6 +817,74 @@ class LiteLLM_ProxyModelTable(LiteLLMPydanticObjectBase):
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):
none = "none"
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 = Optional[
Literal[MCPAuth.none, 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 UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: str
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 LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
"""Represents a LiteLLM_MCPServerTable record"""
server_id: str
alias: Optional[str] = None
description: Optional[str] = None
url: str
transport: MCPTransportType
spec_version: MCPSpecVersionType
auth_type: Optional[MCPAuthType] = None
created_at: Optional[datetime] = None
created_by: Optional[str] = None
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class NewUserRequest(GenerateRequestBase):
max_budget: Optional[float] = None
user_email: Optional[str] = None
@@ -1174,6 +1242,13 @@ class TeamCallbackMetadata(LiteLLMPydanticObjectBase):
return values
class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase):
"""Represents a LiteLLM_ObjectPermissionTable record"""
object_permission_id: str
mcp_servers: List[str]
class LiteLLM_TeamTable(TeamBase):
team_id: str # type: ignore
spend: Optional[float] = None
@@ -1182,6 +1257,7 @@ class LiteLLM_TeamTable(TeamBase):
budget_reset_at: Optional[datetime] = None
model_id: Optional[int] = None
litellm_model_table: Optional[LiteLLM_ModelTable] = None
object_permission: Optional[LiteLLM_ObjectPermissionTable] = None
updated_at: Optional[datetime] = None
created_at: Optional[datetime] = None
+2
View File
@@ -149,6 +149,8 @@ class RouteChecks:
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"
@@ -0,0 +1,351 @@
"""
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.
- PUT `/v1/mcp/server` - Edits an existing mcp server.
- DELETE `/v1/mcp/server/{server_id}` - Deletes the mcp server given `server_id`.
"""
import importlib
from typing import Iterable, List, Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Response, status
from fastapi.responses import JSONResponse
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
MCP_AVAILABLE: bool = True
try:
importlib.import_module("mcp")
except ImportError as e:
verbose_logger.debug(f"MCP module not found: {e}")
MCP_AVAILABLE = False
if MCP_AVAILABLE:
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,
update_mcp_server,
)
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LitellmUserRoles,
NewMCPServerRequest,
SpecialMCPServerName,
UpdateMCPServerRequest,
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.utils import management_endpoint_wrapper
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:
global_mcp_server_manager.add_update_server(mcp_server)
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.post(
"/server",
description="Allows creation of mcp servers",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
status_code=status.HTTP_201_CREATED,
)
@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,
)
global_mcp_server_manager.add_update_server(new_mcp_server)
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,
status_code=status.HTTP_202_ACCEPTED,
)
@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}"},
)
global_mcp_server_manager.remove_server(mcp_server_record_deleted)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
pass
# TODO: Delete from virtual keys
# TODO: Delete from teams
# Update from global mcp store
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.put(
"/server",
description="Allows deleting mcp serves in the db",
dependencies=[Depends(user_api_key_auth)],
response_model=LiteLLM_MCPServerTable,
status_code=status.HTTP_202_ACCEPTED,
)
@management_endpoint_wrapper
async def edit_mcp_server(
payload: UpdateMCPServerRequest,
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",
),
):
"""
Updates the MCP Server in the db.
Parameters:
- payload: UpdateMCPServerRequest - Required. The updated mcp server data.
```
curl -X "PUT" --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 - 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 update MCP server. User is not a proxy admin. route={}".format(
"PUT /v1/mcp/server"
)
},
)
# try to update the mcp server
mcp_server_record_updated = await update_mcp_server(
prisma_client,
payload,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
if mcp_server_record_updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": f"MCP Server not found, passed server_id={payload.server_id}"
},
)
global_mcp_server_manager.add_update_server(mcp_server_record_updated)
# TODO: Enterprise: Finish audit log trail
if litellm.store_audit_logs:
pass
return mcp_server_record_updated
+19
View File
@@ -240,6 +240,9 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
from litellm.proxy.management_endpoints.key_management_endpoints import (
router as key_management_router,
)
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
router as mcp_management_router,
)
from litellm.proxy.management_endpoints.model_management_endpoints import (
_add_model_to_db,
_add_team_model_to_db,
@@ -2726,6 +2729,7 @@ class ProxyConfig:
"""
await self._init_guardrails_in_db(prisma_client=prisma_client)
await self._init_vector_stores_in_db(prisma_client=prisma_client)
await self._init_mcp_servers_in_db()
async def _init_guardrails_in_db(self, prisma_client: PrismaClient):
from litellm.proxy.guardrails.guardrail_registry import (
@@ -2781,6 +2785,20 @@ class ProxyConfig:
)
)
async def _init_mcp_servers_in_db(self):
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
try:
await global_mcp_server_manager._add_mcp_servers_from_db_to_in_memory_registry()
except Exception as e:
verbose_proxy_logger.exception(
"litellm.proxy.proxy_server.py::ProxyConfig:_init_mcp_servers_in_db - {}".format(
str(e)
)
)
def decrypt_credentials(self, credential: Union[dict, BaseModel]) -> CredentialItem:
if isinstance(credential, dict):
credential_object = CredentialItem(**credential)
@@ -8178,6 +8196,7 @@ app.include_router(fine_tuning_router)
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)
@@ -3,14 +3,22 @@ from typing import TYPE_CHECKING, Optional
from pydantic import BaseModel, ConfigDict
from typing_extensions import TypedDict
from litellm.proxy._types import MCPAuthType, MCPSpecVersionType, MCPTransportType
class MCPInfo(TypedDict, total=False):
server_name: str
description: Optional[str]
logo_url: Optional[str]
class MCPSSEServer(BaseModel):
class MCPServer(BaseModel):
server_id: str
name: str
url: str
# TODO: alter the types to be the Literal explicit
transport: MCPTransportType
spec_version: MCPSpecVersionType
auth_type: Optional[MCPAuthType] = None
mcp_info: Optional[MCPInfo] = None
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -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
+1 -1
View File
@@ -9,7 +9,7 @@ sys.path.insert(
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
MCPSSEServer,
MCPServer,
)
@@ -0,0 +1,239 @@
from datetime import datetime
from typing import List, Optional
import pytest
import uuid
from httpx import AsyncClient
import uuid
import os
from starlette import status
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
from litellm.proxy._types import MCPAuth, MCPSpecVersion, MCPSpecVersionType, MCPTransportType, MCPTransport, NewMCPServerRequest, LiteLLM_MCPServerTable
from litellm.proxy.management_endpoints.mcp_management_endpoints import does_mcp_server_exist
TEST_MASTER_KEY = os.getenv("LITELLM_MASTER_KEY", "sk-1234")
PROXY_BASE_URL = os.getenv("PROXY_BASE_URL", "http://localhost:4000")
def generate_mcpserver_record(url: Optional[str] = None,
transport: Optional[MCPTransportType] = None,
spec_version: Optional[MCPSpecVersionType] = None) -> LiteLLM_MCPServerTable:
"""
Generate a mock record for testing.
"""
now = datetime.now()
return LiteLLM_MCPServerTable(
server_id=str(uuid.uuid4()),alias="Test Server",url=url or "http://localhost.com:8080/mcp",transport=transport or MCPTransport.sse,spec_version=spec_version or MCPSpecVersion.mar_2025,created_at=now,updated_at=now,
)
# Cheers SO
def is_valid_uuid(val):
try:
uuid.UUID(str(val))
return True
except ValueError:
return False
def generate_mcpserver_create_request(
server_id: Optional[str] = None,
url: Optional[str] = None,
transport: Optional[MCPTransportType] = None,
spec_version: Optional[MCPSpecVersionType] = None) -> NewMCPServerRequest:
"""
Generate a mock create request for testing.
"""
now = datetime.now()
return NewMCPServerRequest(server_id=server_id,
alias="Test Server",url=url or "http://localhost.com:8080/mcp",transport=transport or MCPTransport.sse,spec_version=spec_version or MCPSpecVersion.mar_2025,
)
def get_http_client():
"""
Create an HTTP client for making requests to the proxy server.
"""
headers = {"Authorization": f"Bearer {TEST_MASTER_KEY}"}
# headers = {"Authorization": f"x-litellm-api-key {TEST_MASTER_KEY}"}
return AsyncClient(base_url=PROXY_BASE_URL), headers
def assert_mcp_server_record_same(mcp_server: NewMCPServerRequest, resp: LiteLLM_MCPServerTable):
"""
Assert that the mcp server record is created correctly.
"""
if mcp_server.server_id is not None:
assert resp.server_id == mcp_server.server_id
else:
assert is_valid_uuid(resp.server_id)
assert resp.alias == mcp_server.alias
assert resp.url == mcp_server.url
assert resp.description == mcp_server.description
assert resp.transport == mcp_server.transport
assert resp.spec_version == mcp_server.spec_version
assert resp.auth_type == mcp_server.auth_type
assert resp.created_at is not None
assert resp.updated_at is not None
assert resp.created_by == LITELLM_PROXY_ADMIN_NAME
assert resp.updated_by == LITELLM_PROXY_ADMIN_NAME
def test_does_mcp_server_exist():
"""
Unit Test if the MCP server exists in the list.
"""
mcp_server_records: List[LiteLLM_MCPServerTable] = [generate_mcpserver_record(), generate_mcpserver_record()]
# test all records are found
for record in mcp_server_records:
assert does_mcp_server_exist(mcp_server_records, record.server_id)
# test record not found
not_found_record = str(uuid.uuid4())
assert False == does_mcp_server_exist(mcp_server_records, not_found_record)
@pytest.mark.asyncio
async def test_create_get_delete():
"""
Integration Test mcp servers can be created and returned correctly.
1. Create a new mcp server with server id
2. Create another mcp server without server id
2.1 Verify duplicate mcp server (server id) creation fails
3. Verify first server has matching server id and second server has a new server id
4. Verify both servers are in the full mcp server list
5. Verify first server can be retrieved by server id
6. Delete both mcp servers
7. Verify both servers are no longer in the full mcp server list
8. Verify both servers cannot be retrieved by server id
"""
# client, headers = AsyncClient(base_url=PROXY_BASE_URL), headers
client, headers = get_http_client()
first_server_id = str(uuid.uuid4())
first_server = generate_mcpserver_create_request(server_id=first_server_id)
# Add new mcp server with server id
first_create_response = await client.post(
"/v1/mcp/server",
json=first_server.json(),
headers=headers,
)
# Validate that the response is as expected and the server is created
assert status.HTTP_201_CREATED == first_create_response.status_code
first_resp = LiteLLM_MCPServerTable(**first_create_response.json())
assert_mcp_server_record_same(first_server, first_resp)
# Create second mcp server without server id
second_server = generate_mcpserver_create_request()
second_create_response = await client.post(
"/v1/mcp/server",
json=second_server.json(),
headers=headers,
)
assert status.HTTP_201_CREATED == second_create_response.status_code
second_resp = LiteLLM_MCPServerTable(**second_create_response.json())
assert_mcp_server_record_same(second_server, second_resp)
# Try to create a duplicate mcp server
duplicate_create_response = await client.post(
"/v1/mcp/server",
json=first_server.json(),
headers=headers,
)
assert status.HTTP_400_BAD_REQUEST == duplicate_create_response.status_code
# Validate that the servers are in the full mcp server list
get_all_mcp_servers_response = await client.get(
"/v1/mcp/server",
headers=headers,
)
assert status.HTTP_200_OK == get_all_mcp_servers_response.status_code
mcp_servers = [
LiteLLM_MCPServerTable(**record) for record in get_all_mcp_servers_response.json()
]
assert len(mcp_servers) >= 2
assert does_mcp_server_exist(mcp_servers, first_resp.server_id)
assert does_mcp_server_exist(mcp_servers, second_resp.server_id)
# Validate that the first server can be retrieved by server id
get_mcp_server_response = await client.get(
f"/v1/mcp/server/{first_resp.server_id}",
headers=headers,
)
assert status.HTTP_200_OK == get_mcp_server_response.status_code
resp = LiteLLM_MCPServerTable(**get_mcp_server_response.json())
assert_mcp_server_record_same(first_server, resp)
# Delete the mcp servers
delete_response = await client.delete(
f"/v1/mcp/server/{first_resp.server_id}",
headers=headers,
)
assert status.HTTP_202_ACCEPTED == delete_response.status_code
delete_response = await client.delete(
f"/v1/mcp/server/{second_resp.server_id}",
headers=headers,
)
assert status.HTTP_202_ACCEPTED == delete_response.status_code
# Validate that the servers are no longer in the full list
get_all_mcp_servers_response = await client.get(
"/v1/mcp/server",
headers=headers,
)
assert status.HTTP_200_OK == get_all_mcp_servers_response.status_code
mcp_servers = [
LiteLLM_MCPServerTable(**record) for record in get_all_mcp_servers_response.json()
]
assert not does_mcp_server_exist(mcp_servers, first_resp.server_id)
assert not does_mcp_server_exist(mcp_servers, second_resp.server_id)
# Validate that both servers cannot be retrieved by server id
for server_id in [first_resp.server_id, second_resp.server_id]:
get_mcp_server_response = await client.get(
f"/v1/mcp/server/{server_id}",
headers=headers,
)
assert status.HTTP_404_NOT_FOUND == get_mcp_server_response.status_code
@pytest.mark.asyncio
async def test_edit():
"""
Integration Test mcp servers can be created and edited correctly.
1. Create a new mcp server
2. Edit the server id
3. Verify the mcp server's data is updated
"""
# client, headers = AsyncClient(base_url=PROXY_BASE_URL), headers
client, headers = get_http_client()
mcp_server_request = generate_mcpserver_create_request()
# Add new mcp server with server id
first_create_response = await client.post(
"/v1/mcp/server",
json=mcp_server_request.json(),
headers=headers,
)
# Validate that the response is as expected and the server is created
assert status.HTTP_201_CREATED == first_create_response.status_code
mcp_server_response = LiteLLM_MCPServerTable(**first_create_response.json())
assert_mcp_server_record_same(mcp_server_request, mcp_server_response)
# Update the mcp server
mcp_server_request.server_id = mcp_server_response.server_id
mcp_server_request.spec_version = MCPSpecVersion.nov_2024
mcp_server_request.transport = MCPTransport.http
mcp_server_request.description = "Some updated description"
mcp_server_request.url = "http://localhost.com:4040/mcp"
mcp_server_request.auth_type = MCPAuth.basic
# Try to edit the mcp server
updated_response = await client.put(
"/v1/mcp/server",
json=mcp_server_request.json(),
headers=headers,
)
assert status.HTTP_202_ACCEPTED == updated_response.status_code
updated_server = LiteLLM_MCPServerTable(**updated_response.json())
assert_mcp_server_record_same(mcp_server_request, updated_server)
+7 -8
View File
@@ -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";
@@ -362,7 +362,7 @@ export default function CreateKeyPage() {
<GuardrailsPanel accessToken={accessToken} userRole={userRole} />
): page == "transform-request" ? (
<TransformRequestPanel accessToken={accessToken} />
): page == "general-settings" ? (
) : page == "general-settings" ? (
<GeneralSettings
userID={userID}
userRole={userRole}
@@ -396,10 +396,10 @@ export default function CreateKeyPage() {
userRole={userRole}
token={token}
accessToken={accessToken}
allTeams={teams as Team[] ?? []}
allTeams={(teams as Team[]) ?? []}
/>
) : page == "mcp-tools" ? (
<MCPToolsViewer
) : page == "mcp-servers" ? (
<MCPServers
accessToken={accessToken}
userRole={userRole}
userID={userID}
@@ -421,10 +421,9 @@ export default function CreateKeyPage() {
userID={userID}
userRole={userRole}
accessToken={accessToken}
teams={teams as Team[] ?? []}
teams={(teams as Team[]) ?? []}
/>
) :
(
) : (
<Usage
userID={userID}
userRole={userRole}
@@ -74,7 +74,7 @@ const Sidebar: React.FC<SidebarProps> = ({
{ key: "9", page: "caching", label: "Caching", icon: <DatabaseOutlined />, roles: all_admin_roles },
{ key: "10", page: "budgets", label: "Budgets", icon: <BankOutlined />, roles: all_admin_roles },
{ key: "20", page: "transform-request", label: "API Playground", icon: <ApiOutlined />, roles: [...all_admin_roles, ...internalUserRoles] },
{ key: "18", page: "mcp-tools", label: "MCP Tools", icon: <ToolOutlined />, roles: all_admin_roles },
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined />, roles: all_admin_roles },
{ key: "19", page: "tag-management", label: "Tag Management", icon: <TagsOutlined />, roles: all_admin_roles },
{ key: "21", page: "vector-stores", label: "Vector Stores", icon: <DatabaseOutlined />, roles: all_admin_roles },
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined /> },
@@ -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,12 @@ function DataTableWrapper({
);
}
export default function MCPToolsViewer({
const MCPToolsViewer = ({
serverId,
accessToken,
userRole,
userID,
}: MCPToolsViewerProps) {
}: MCPToolsViewerProps) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedTool, setSelectedTool] = useState<MCPTool | null>(null);
const [toolResult, setToolResult] = useState<CallMCPToolResponse | null>(null);
@@ -47,7 +49,7 @@ export default function MCPToolsViewer({
queryKey: ['mcpTools'],
queryFn: () => {
if (!accessToken) throw new Error('Access Token required');
return listMCPTools(accessToken);
return listMCPTools(accessToken, serverId);
},
enabled: !!accessToken,
});
@@ -92,7 +94,7 @@ export default function MCPToolsViewer({
const searchLower = searchTerm.toLowerCase();
return (
tool.name.toLowerCase().includes(searchLower) ||
tool.description.toLowerCase().includes(searchLower) ||
(tool.description != null && tool.description.toLowerCase().includes(searchLower)) ||
tool.mcp_info.server_name.toLowerCase().includes(searchLower)
);
});
@@ -171,4 +173,6 @@ export default function MCPToolsViewer({
)}
</div>
);
}
}
export { MCPToolsViewer, MCPServers };
@@ -0,0 +1,108 @@
import React from "react";
import {
Title,
Card,
Button,
Text,
Grid,
TabGroup,
TabList,
TabPanel,
TabPanels,
Tab,
} from "@tremor/react";
import { MCPServer, handleTransport, handleAuth } from "./types";
// TODO: Move Tools viewer from index file
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<MCPServerViewProps> = ({
mcpServer,
onBack,
isEditing,
isProxyAdmin,
accessToken,
userRole,
userID,
}) => {
return (
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<Button onClick={onBack} className="mb-4">
Back
</Button>
<Title>{mcpServer.alias}</Title>
<Text className="text-gray-500 font-mono">{mcpServer.server_id}</Text>
</div>
</div>
{/* TODO: magic number for index */}
<TabGroup defaultIndex={isEditing ? 2 : 0}>
<TabList className="mb-4">
{[
<Tab key="overview">Overview</Tab>,
<Tab key="tools">MCP Tools</Tab>,
...(isProxyAdmin ? [<Tab key="settings">Settings</Tab>] : []),
]}
</TabList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Transport</Text>
<div className="mt-2">
<Title>{handleTransport(mcpServer.transport ?? undefined)}</Title>
</div>
</Card>
<Card>
<Text>Auth Type</Text>
<div className="mt-2">
<Text>{handleAuth(mcpServer.auth_type ?? undefined)}</Text>
</div>
</Card>
<Card>
<Text>Host Url</Text>
<div className="mt-2 flex flex-wrap gap-2">{mcpServer.url}</div>
</Card>
</Grid>
</TabPanel>
{/* Tool Panel */}
<TabPanel>
<MCPToolsViewer
serverId={mcpServer.server_id}
accessToken={accessToken}
userRole={userRole}
userID={userID}
/>
</TabPanel>
{/* Settings Panel */}
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Editing MCP Servers coming soon!</Title>
</div>
</Card>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
);
};
@@ -0,0 +1,449 @@
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import {
Modal,
Tooltip,
Form,
Select,
message,
Button as AntdButton,
} from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Icon,
Button,
Grid,
Col,
Title,
TextInput,
} from "@tremor/react";
import {
createMCPServer,
deleteMCPServer,
fetchMCPServers,
} from "../networking";
import {
MCPServer,
MCPServerProps,
handleAuth,
handleTransport,
} from "./types";
import { isAdminRole } from "@/utils/roles";
import { MCPServerView } from "./mcp_server_view";
const displayFriendlyId = (id: string) => {
return `${id.slice(0, 7)}...`;
};
interface CreateMCPServerProps {
userRole: string;
accessToken: string | null;
onCreateSuccess: (newMcpServer: MCPServer) => void;
}
const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
userRole,
accessToken,
onCreateSuccess,
}) => {
const [form] = Form.useForm();
const handleCreate = async (formValues: Record<string, any>) => {
try {
console.log(`formValues: ${JSON.stringify(formValues)}`);
if (accessToken != null) {
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);
}
};
// state
const [isModalVisible, setModalVisible] = useState(false);
// rendering
if (!isAdminRole(userRole)) {
return null;
}
return (
<div>
<Button className="mx-auto" onClick={() => setModalVisible(true)}>
+ Create New MCP Server
</Button>
<Modal
title="Create New MCP Server"
open={isModalVisible}
okType="primary"
width={800}
onCancel={() => setModalVisible(false)}
okButtonProps={{ style: { display: "none" } }}
>
<Form
form={form}
onFinish={handleCreate}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item
label="MCP Server Name"
name="alias"
rules={[
{ required: false, message: "Please enter a server name" },
]}
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item
label="MCP Description"
name="description"
rules={[
{
required: false,
message: "Please enter a server description",
},
]}
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item
label="MCP Server URL"
name="url"
rules={[{ required: true, message: "Please enter a server url" }]}
>
<TextInput placeholder="https://" />
</Form.Item>
<Form.Item
label="MCP Server Transport"
name="transport"
rules={[{ required: true, message: "Please enter a server url" }]}
>
<Select placeholder="MCP Transport Type">
<Select.Option value="sse">sse</Select.Option>
<Select.Option value="http">http</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="MCP Server Auth Type"
name="auth_type"
rules={[{ required: true, message: "Please enter an auth type" }]}
>
<Select placeholder="MCP Auth Type">
<Select.Option value="none">None</Select.Option>
<Select.Option value="api_key">api_key</Select.Option>
<Select.Option value="bearer_token">bearer_token</Select.Option>
<Select.Option value="basic">basic</Select.Option>
</Select>
</Form.Item>
<Form.Item
label={
<span>
MCP Version{" "}
<Tooltip title="Supported MCP Specification Version">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="spec_version"
rules={[
{ required: true, message: "Please enter a spec version" },
]}
>
<Select placeholder="MCP Version">
<Select.Option value="2025-03-26">2025-03-26</Select.Option>
<Select.Option value="2024-11-05">2024-11-05</Select.Option>
</Select>
</Form.Item>
</>
<div
style={{
textAlign: "right",
marginTop: "10px",
paddingBottom: "10px",
}}
>
<AntdButton htmlType="submit">Create MCP Server</AntdButton>
</div>
</Form>
</Modal>
</div>
);
};
interface DeleteModalProps {
isModalOpen: boolean;
title: string;
confirmDelete: () => void;
cancelDelete: () => void;
}
const DeleteModal: React.FC<DeleteModalProps> = ({
isModalOpen,
title,
confirmDelete,
cancelDelete,
}) => {
if (!isModalOpen) return null;
return (
<Modal
open={isModalOpen}
onOk={confirmDelete}
okType="danger"
onCancel={cancelDelete}
>
<Grid numItems={1} className="gap-2 w-full">
<Title>{title}</Title>
<Col numColSpan={1}>
<p>Are you sure you want to delete this MCP Server?</p>
</Col>
</Grid>
</Modal>
);
};
const MCPServers: React.FC<MCPServerProps> = ({
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<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedServerId, setSelectedServerId] = useState<string | null>(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.
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.
}
// 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);
};
if (!accessToken || !userRole || !userID) {
return (
<div className="p-6 text-center text-gray-500">
Missing required authentication parameters.
</div>
);
}
return (
<div className="w-full mx-4 h-[75vh]">
{selectedServerId ? (
<MCPServerView
mcpServer={
mcpServers.find(
(server: MCPServer) => server.server_id === selectedServerId
) || {}
}
onBack={() => setSelectedServerId(null)}
isProxyAdmin={isAdminRole(userRole)}
isEditing={editServer}
accessToken={accessToken}
userID={userID}
userRole={userRole}
/>
) : (
<div className="w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold">MCP Servers</h1>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Server ID</TableHeaderCell>
<TableHeaderCell>Server Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Transport</TableHeaderCell>
<TableHeaderCell>Auth Type</TableHeaderCell>
<TableHeaderCell>Url</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Info</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{!mcpServers || mcpServers.length == 0
? []
: mcpServers.map((mcpServer: MCPServer) => (
<TableRow key={mcpServer.server_id}>
<TableCell>
<div className="overflow-hidden">
<Tooltip title={mcpServer.server_id}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
onClick={() => {
// Add click handler
setSelectedServerId(mcpServer.server_id);
}}
>
{displayFriendlyId(mcpServer.server_id)}
</Button>
</Tooltip>
</div>
</TableCell>
<TableCell
style={{
maxWidth: "4px",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
>
{mcpServer.alias}
</TableCell>
<TableCell
style={{
maxWidth: "4px",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
>
{mcpServer.description}
</TableCell>
<TableCell
style={{
maxWidth: "4px",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
>
{handleTransport(mcpServer.transport)}
</TableCell>
<TableCell
style={{
maxWidth: "4px",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
>
{handleAuth(mcpServer.auth_type)}
</TableCell>
<TableCell>
<div className="overflow-hidden">
<Tooltip title={mcpServer.url}>
{mcpServer.url}
</Tooltip>
</div>
</TableCell>
<TableCell
style={{
maxWidth: "4px",
whiteSpace: "pre-wrap",
overflow: "hidden",
}}
>
{mcpServer.created_at
? new Date(mcpServer.created_at).toLocaleDateString()
: "N/A"}
</TableCell>
<TableCell>
{isAdminRole(userRole) ? (
<>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => {
setSelectedServerId(mcpServer.server_id);
setEditServer(true);
}}
/>
<Icon
onClick={() => handleDelete(mcpServer.server_id)}
icon={TrashIcon}
size="sm"
/>
</>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<DeleteModal
isModalOpen={isDeleteModalOpen}
title="Delete MCP Server"
confirmDelete={confirmDelete}
cancelDelete={cancelDelete}
/>
<CreateMCPServer
userRole={userRole}
accessToken={accessToken}
onCreateSuccess={createMCPServer}
/>
</div>
)}
</div>
);
};
export default MCPServers;
@@ -0,0 +1,4 @@
import { MCPToolsViewer } from "./index";
// TODO: Move Tools viewer from index file to this file
export default MCPToolsViewer;
@@ -1,3 +1,25 @@
export const TRANSPORT = {
SSE: "sse",
HTTP: "http",
};
export const handleTransport = (transport?: string): string => {
console.log(transport)
if (transport === null || transport === undefined) {
return TRANSPORT.SSE;
}
return transport;
};
export const handleAuth = (authType?: string): string => {
if (authType === null || authType === undefined) {
return "none";
}
return authType;
};
// Define the structure for tool input schema properties
export interface InputSchemaProperty {
type: string;
@@ -20,7 +42,7 @@ export interface InputSchemaProperty {
// Define the structure for a single MCP tool
export interface MCPTool {
name: string;
description: string;
description?: string;
inputSchema: InputSchema | string; // API returns string "tool_input_schema" or the actual schema
mcp_info: MCPInfo;
// Function to select a tool (added in the component)
@@ -65,7 +87,28 @@ export interface InputSchemaProperty {
// Props for the main component
export interface MCPToolsViewerProps {
serverId: string;
accessToken: string | null;
userRole: string | null;
userID: string | null;
}
}
export interface MCPServer {
server_id: string;
alias?: string | null;
description?: string | null;
url: string;
transport?: string | null;
spec_version?: string | null;
auth_type?: string | null;
created_at: string;
created_by: string;
updated_at: string;
updated_by: string;
}
export interface MCPServerProps {
accessToken: string | null;
userRole: string | null;
userID: string | null;
}
@@ -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 {
@@ -4458,13 +4465,106 @@ export const updateInternalUserSettings = async (accessToken: string, settings:
}
};
export const fetchMCPServers = async (accessToken: string) => {
try {
// Construct base URL
const url = proxyBaseUrl ? `${proxyBaseUrl}/v1/mcp/server` : `/v1/mcp/server`;
export const listMCPTools = async (accessToken: string) => {
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 createMCPServer = async (
accessToken: string,
formValues: Record<string, any> // 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
) => {
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");
}
} catch (error) {
console.error("Failed to delete key:", error);
throw error;
}
};
export const listMCPTools = async (accessToken: string, serverId: string) => {
try {
// Construct base URL
let url = proxyBaseUrl
? `${proxyBaseUrl}/mcp/tools/list`
: `/mcp/tools/list`;
? `${proxyBaseUrl}/mcp/tools/list?server_id=${serverId}`
: `/mcp/tools/list?server_id=${serverId}`;
console.log("Fetching MCP tools from:", url);