Merge pull request #26367 from BerriAI/litellm_/split-mcp-routes-management-vs-inference

Split MCP routes into inference vs management (unblock Admin UI on DISABLE_LLM_API_ENDPOINTS nodes)
This commit is contained in:
ryan-crabbe-berri
2026-04-23 22:05:48 -07:00
committed by GitHub
4 changed files with 143 additions and 38 deletions
+49 -36
View File
@@ -427,7 +427,8 @@ class LiteLLMRoutes(enum.Enum):
"/v1/skills/{skill_id}",
]
mcp_routes = [
# MCP tool-call / passthrough routes — data-plane. Gated by DISABLE_LLM_API_ENDPOINTS.
mcp_inference_routes = [
"/mcp",
"/mcp/",
"/mcp/{subpath}",
@@ -436,10 +437,18 @@ class LiteLLMRoutes(enum.Enum):
"/mcp/tools/call",
"/mcp-rest/tools/list",
"/mcp-rest/tools/call",
]
# MCP server CRUD routes — control-plane. Gated by DISABLE_ADMIN_ENDPOINTS.
mcp_management_routes = [
"/v1/mcp/server",
"/v1/mcp/server/{path:path}",
]
# Backwards-compat union — virtual keys may be configured with
# allowed_routes=["mcp_routes"], which should cover both halves.
mcp_routes = mcp_inference_routes + mcp_management_routes
agent_routes = [
"/v1/agents",
"/v1/agents/{agent_id}",
@@ -477,7 +486,7 @@ class LiteLLMRoutes(enum.Enum):
+ mapped_pass_through_routes
+ passthrough_routes_wildcard
+ apply_guardrail_routes
+ mcp_routes
+ mcp_inference_routes
+ litellm_native_routes
+ agent_routes
)
@@ -530,40 +539,44 @@ class LiteLLMRoutes(enum.Enum):
KeyManagementRoutes.KEY_ALIASES.value,
]
management_routes = [
# user
"/user/new",
"/user/update",
"/user/bulk_update",
"/user/delete",
"/user/info",
"/user/list",
"/user/daily/activity",
"/user/daily/activity/aggregated",
# team
"/team/new",
"/team/update",
"/team/delete",
"/team/list",
"/v2/team/list",
"/team/info",
"/team/block",
"/team/unblock",
"/team/available",
"/team/permissions_list",
"/team/permissions_update",
"/team/daily/activity",
# model
"/model/new",
"/model/update",
"/model/delete",
"/model/info",
"/jwt/key/mapping/new",
"/jwt/key/mapping/update",
"/jwt/key/mapping/delete",
"/jwt/key/mapping/list",
"/jwt/key/mapping/info",
] + key_management_routes
management_routes = (
[
# user
"/user/new",
"/user/update",
"/user/bulk_update",
"/user/delete",
"/user/info",
"/user/list",
"/user/daily/activity",
"/user/daily/activity/aggregated",
# team
"/team/new",
"/team/update",
"/team/delete",
"/team/list",
"/v2/team/list",
"/team/info",
"/team/block",
"/team/unblock",
"/team/available",
"/team/permissions_list",
"/team/permissions_update",
"/team/daily/activity",
# model
"/model/new",
"/model/update",
"/model/delete",
"/model/info",
"/jwt/key/mapping/new",
"/jwt/key/mapping/update",
"/jwt/key/mapping/delete",
"/jwt/key/mapping/list",
"/jwt/key/mapping/info",
]
+ key_management_routes
+ mcp_management_routes
)
spend_tracking_routes = [
# spend
+4 -2
View File
@@ -300,7 +300,7 @@ class RouteChecks:
return True
if RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value
route=route, allowed_routes=LiteLLMRoutes.mcp_inference_routes.value
):
return True
@@ -358,7 +358,9 @@ class RouteChecks:
"""
Check if route is a management route
"""
return route in LiteLLMRoutes.management_routes.value
return RouteChecks.check_route_access(
route=route, allowed_routes=LiteLLMRoutes.management_routes.value
)
@staticmethod
def is_info_route(route: str) -> bool:
@@ -252,6 +252,64 @@ class TestEnterpriseRouteChecksModelListExemption:
)
@patch("litellm.proxy.proxy_server.premium_user", True)
class TestEnterpriseRouteChecksMcpManagement:
"""Regression tests: MCP management routes (/v1/mcp/server*) must remain
reachable when DISABLE_LLM_API_ENDPOINTS is set on admin nodes, but must be
blocked when DISABLE_ADMIN_ENDPOINTS is set. Uses the real is_llm_api_route
/ is_management_route classifiers (not mocks)."""
@pytest.mark.parametrize(
"route",
[
"/v1/mcp/server",
"/v1/mcp/server/abc-123",
"/v1/mcp/server/abc-123/approve",
],
)
def test_mcp_management_allowed_when_llm_api_disabled(self, route):
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False):
os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None)
# Should not raise — MCP management is a management route, not llm_api.
EnterpriseRouteChecks.should_call_route(route)
@pytest.mark.parametrize(
"route",
[
"/v1/mcp/server",
"/v1/mcp/server/abc-123",
],
)
def test_mcp_management_blocked_when_admin_disabled(self, route):
with patch.dict(os.environ, {"DISABLE_ADMIN_ENDPOINTS": "true"}, clear=False):
os.environ.pop("DISABLE_LLM_API_ENDPOINTS", None)
with pytest.raises(HTTPException) as exc_info:
EnterpriseRouteChecks.should_call_route(route)
assert exc_info.value.status_code == 403
assert "Management routes are disabled for this instance." in str(
exc_info.value.detail
)
@pytest.mark.parametrize(
"route",
[
"/mcp/tools/call",
"/mcp-rest/tools/call",
],
)
def test_mcp_inference_still_blocked_when_llm_api_disabled(self, route):
with patch.dict(os.environ, {"DISABLE_LLM_API_ENDPOINTS": "true"}, clear=False):
os.environ.pop("DISABLE_ADMIN_ENDPOINTS", None)
with pytest.raises(HTTPException) as exc_info:
EnterpriseRouteChecks.should_call_route(route)
assert exc_info.value.status_code == 403
assert "LLM API routes are disabled for this instance." in str(
exc_info.value.detail
)
class TestEnterpriseRouteChecksErrorMessages:
"""Test that error messages correctly identify which feature requires Enterprise license"""
@@ -152,6 +152,38 @@ def test_virtual_key_mcp_routes_allows_v1_mcp_server_subpaths(route):
assert result is True
@pytest.mark.parametrize(
"route",
[
"/v1/mcp/server",
"/v1/mcp/server/abc-123",
"/v1/mcp/server/abc-123/approve",
],
)
def test_mcp_management_routes_classified_as_management_not_llm_api(route):
"""MCP server CRUD must be management routes, not llm_api routes, so
DISABLE_LLM_API_ENDPOINTS on admin nodes does not block the Admin UI."""
assert RouteChecks.is_llm_api_route(route=route) is False
assert RouteChecks.is_management_route(route=route) is True
@pytest.mark.parametrize(
"route",
[
"/mcp/tools/call",
"/mcp-rest/tools/call",
"/mcp/tools/list",
],
)
def test_mcp_inference_routes_classified_as_llm_api(route):
"""MCP tool-call / passthrough routes must remain llm_api routes so they
continue to be blocked by DISABLE_LLM_API_ENDPOINTS on admin nodes."""
assert RouteChecks.is_llm_api_route(route=route) is True
assert RouteChecks.is_management_route(route=route) is False
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_denied():
"""Test that virtual key is denied when route is not in the allowed LiteLLMRoutes group"""