mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-17 04:17:43 +00:00
Merge pull request #18850 from BerriAI/litellm_feat_mcp-registry
[feat] add mcp registry
This commit is contained in:
@@ -649,3 +649,16 @@ general_settings:
|
||||
```
|
||||
|
||||
This is useful when you want discoverability for MCP offerings without granting additional execution privileges.
|
||||
|
||||
|
||||
## Publish MCP Registry
|
||||
|
||||
If you want other systems—for example external agent frameworks such as MCP-capable IDEs running outside your network—to automatically discover the MCP servers hosted on LiteLLM, you can expose a Model Context Protocol Registry endpoint. This registry lists the built-in LiteLLM MCP server and every server you have configured, using the [official MCP Registry spec](https://github.com/modelcontextprotocol/registry).
|
||||
|
||||
1. Set `enable_mcp_registry: true` under `general_settings` in your proxy config (or DB settings) and restart the proxy.
|
||||
2. LiteLLM will serve the registry at `GET /v1/mcp/registry.json`.
|
||||
3. Each entry points to either `/mcp` (built-in server) or `/{mcp_server_name}/mcp` for your custom servers, so clients can connect directly using the advertised Streamable HTTP URL.
|
||||
|
||||
:::note Permissions still apply
|
||||
The registry only advertises server URLs. Actual access control is still enforced by LiteLLM when the client connects to `/mcp` or `/{server}/mcp`, so publishing the registry does not bypass per-key permissions.
|
||||
:::
|
||||
|
||||
@@ -36,12 +36,19 @@ from litellm._logging import verbose_logger, verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
get_server_prefix,
|
||||
validate_and_normalize_mcp_server_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
|
||||
|
||||
MCP_AVAILABLE: bool = True
|
||||
|
||||
TEMPORARY_MCP_SERVER_TTL_SECONDS = 300
|
||||
DEFAULT_MCP_REGISTRY_VERSION = "1.0.0"
|
||||
LITELLM_MCP_SERVER_NAME = "litellm-mcp-server"
|
||||
LITELLM_MCP_SERVER_DESCRIPTION = "MCP Server for LiteLLM"
|
||||
|
||||
try:
|
||||
importlib.import_module("mcp")
|
||||
except ImportError as e:
|
||||
@@ -57,6 +64,7 @@ if MCP_AVAILABLE:
|
||||
update_mcp_server,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
get_request_base_url,
|
||||
authorize_with_server,
|
||||
exchange_token_with_server,
|
||||
register_client_with_server,
|
||||
@@ -89,6 +97,66 @@ if MCP_AVAILABLE:
|
||||
server: MCPServer
|
||||
expires_at: datetime
|
||||
|
||||
def _is_public_registry_enabled() -> bool:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
|
||||
return bool(proxy_general_settings.get("enable_mcp_registry"))
|
||||
|
||||
def _build_registry_remote_url(base_url: str, path: str) -> str:
|
||||
normalized_base = base_url.rstrip("/")
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
return f"{normalized_base}{normalized_path}"
|
||||
|
||||
def _build_mcp_registry_server_name(server: MCPServer) -> str:
|
||||
if server.alias:
|
||||
return server.alias
|
||||
if server.server_name:
|
||||
return server.server_name
|
||||
return server.server_id
|
||||
|
||||
def _build_mcp_registry_entry_for_server(
|
||||
server: MCPServer, base_url: str
|
||||
) -> Dict[str, Any]:
|
||||
server_name = _build_mcp_registry_server_name(server)
|
||||
title = server_name
|
||||
description = server_name
|
||||
version = DEFAULT_MCP_REGISTRY_VERSION
|
||||
|
||||
server_prefix = get_server_prefix(server)
|
||||
if not server_prefix:
|
||||
raise ValueError("MCP server prefix is missing")
|
||||
remote_url = _build_registry_remote_url(base_url, f"/{server_prefix}/mcp")
|
||||
|
||||
return {
|
||||
"name": server_name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": remote_url,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def _build_builtin_registry_entry(base_url: str) -> Dict[str, Any]:
|
||||
remote_url = _build_registry_remote_url(base_url, "/mcp")
|
||||
return {
|
||||
"name": LITELLM_MCP_SERVER_NAME,
|
||||
"title": LITELLM_MCP_SERVER_NAME,
|
||||
"description": LITELLM_MCP_SERVER_DESCRIPTION,
|
||||
"version": DEFAULT_MCP_REGISTRY_VERSION,
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": remote_url,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
_temporary_mcp_servers: Dict[str, _TemporaryMCPServerEntry] = {}
|
||||
|
||||
def _prune_expired_temporary_mcp_servers() -> None:
|
||||
@@ -302,15 +370,42 @@ if MCP_AVAILABLE:
|
||||
access_groups_list = sorted(list(access_groups))
|
||||
return {"access_groups": access_groups_list}
|
||||
|
||||
@router.get(
|
||||
"/registry.json",
|
||||
tags=["mcp"],
|
||||
description="MCP registry endpoint. Spec: https://github.com/modelcontextprotocol/registry",
|
||||
)
|
||||
async def get_mcp_registry(request: Request):
|
||||
if not _is_public_registry_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="MCP registry is not enabled",
|
||||
)
|
||||
|
||||
base_url = get_request_base_url(request)
|
||||
registry_servers: List[Dict[str, Any]] = []
|
||||
registry_servers.append({"server": _build_builtin_registry_entry(base_url)})
|
||||
|
||||
registered_servers = list(global_mcp_server_manager.get_registry().values())
|
||||
registered_servers.sort(key=_build_mcp_registry_server_name)
|
||||
|
||||
for server in registered_servers:
|
||||
try:
|
||||
entry = _build_mcp_registry_entry_for_server(server, base_url)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug(
|
||||
f"Skipping MCP server {getattr(server, 'server_id', 'unknown')} in registry: {e}"
|
||||
)
|
||||
continue
|
||||
registry_servers.append({"server": entry})
|
||||
|
||||
return {"servers": registry_servers}
|
||||
|
||||
## FastAPI Routes
|
||||
def _get_user_mcp_management_mode() -> UserMCPManagementMode:
|
||||
proxy_general_settings: dict = {}
|
||||
try:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
)
|
||||
|
||||
mode = proxy_general_settings.get("user_mcp_management_mode")
|
||||
if mode == "view_all":
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from litellm._uuid import uuid
|
||||
import types
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from litellm._uuid import uuid
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../..")
|
||||
) # Adds the parent directory to the system path
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_MCPServerTable,
|
||||
LitellmUserRoles,
|
||||
@@ -118,6 +118,22 @@ def setup_mock_prisma_client(
|
||||
return mock_prisma_client
|
||||
|
||||
|
||||
def create_mcp_router_test_client() -> TestClient:
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def patch_proxy_general_settings(settings: dict):
|
||||
fake_proxy_server_module = types.SimpleNamespace(general_settings=settings)
|
||||
return patch.dict(
|
||||
sys.modules,
|
||||
{"litellm.proxy.proxy_server": fake_proxy_server_module},
|
||||
)
|
||||
|
||||
|
||||
class TestListMCPServers:
|
||||
"""Test suite for list MCP servers functionality"""
|
||||
|
||||
@@ -1082,6 +1098,55 @@ class TestHealthCheckServers:
|
||||
assert result[1]["server_id"] == "server-2"
|
||||
assert result[1]["status"] == "unhealthy"
|
||||
|
||||
|
||||
class TestMCPRegistryEndpoint:
|
||||
def test_registry_returns_404_when_flag_missing(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
with patch_proxy_general_settings({}):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registry_returns_404_when_flag_false(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
with patch_proxy_general_settings({"enable_mcp_registry": False}):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registry_returns_entries_when_enabled(self):
|
||||
client = create_mcp_router_test_client()
|
||||
|
||||
mock_server = generate_mock_mcp_server_config_record(
|
||||
server_id="server-123",
|
||||
name="zapier",
|
||||
url="https://zapier.example.com/mcp",
|
||||
transport="http",
|
||||
)
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.get_registry.return_value = {mock_server.server_id: mock_server}
|
||||
|
||||
with patch_proxy_general_settings({"enable_mcp_registry": True}), patch(
|
||||
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
|
||||
mock_manager,
|
||||
):
|
||||
response = client.get("/v1/mcp/registry.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["servers"]) == 2 # built-in + custom server
|
||||
|
||||
builtin_entry = data["servers"][0]["server"]
|
||||
assert builtin_entry["name"] == "litellm-mcp-server"
|
||||
assert builtin_entry["remotes"][0]["url"].endswith("/mcp")
|
||||
|
||||
custom_entry = data["servers"][1]["server"]
|
||||
assert custom_entry["name"] == "zapier"
|
||||
assert custom_entry["remotes"][0]["url"].endswith("/zapier/mcp")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_specific_servers(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user