Merge pull request #18850 from BerriAI/litellm_feat_mcp-registry

[feat] add mcp registry
This commit is contained in:
YutaSaito
2026-01-09 16:16:46 +09:00
committed by GitHub
3 changed files with 184 additions and 11 deletions
+13
View File
@@ -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):
"""