fix: enforce LiteAdmin MCP route permissions (#48080)

* fix(admin-mcp): expose and enforce credential route permissions

* fix: restore beta header and telemetry test parity

* fix(admin-mcp): align permissions with registered HTTP routes

* test(admin-mcp): verify encoded paths through HTTP authorization

* test(admin-mcp): simplify HTTP permission regression fixtures
This commit is contained in:
tin-berri 2026-10-29 19:00:04 -07:00 • committed by GitHub
parent e0778bc9dd
commit fd594ab0cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 344 additions and 29 deletions

View file

@ -238,7 +238,8 @@ impl AnthropicBeta {
Self::ComputerUse20250124
| Self::ComputerUse20251124
| Self::Context1m20250807
| Self::ThinkingBindingControls20260801 => &[],
| Self::ThinkingBindingControls20260801
| Self::ThinkingDisplayUpdates20260818 => &[],
Self::DangerousToolUse20260903 => &[BetaProvider::BedrockConverse],
Self::Effort20251124 => &[BetaProvider::VertexAi],
Self::FastMode20260201 | Self::Oauth20250420 => &[
@ -275,7 +276,6 @@ impl AnthropicBeta {
BetaProvider::Databricks,
],
Self::StructuredOutputs20251113 => &[BetaProvider::Bedrock, BetaProvider::VertexAi],
Self::ThinkingDisplayUpdates20260818 => &[BetaProvider::Databricks],
Self::ToolSearchTool20251019 => &[
BetaProvider::Anthropic,
BetaProvider::AzureAi,

View file

@ -3177,11 +3177,18 @@ from litellm.types.proxy.auth.user_api_key_auth import (
)
class AdminMCPPermissions(LiteLLMPydanticObjectBase):
model_config = ConfigDict(frozen=True)
tools: tuple[str, ...]
action_allowed: bool | None = None
class UserInfoResponse(LiteLLMPydanticObjectBase):
user_id: str | None
user_info: dict | BaseModel | None
keys: list
teams: list
admin_mcp_permissions: AdminMCPPermissions | None = None
class UserInfoV2Response(LiteLLMPydanticObjectBase):

View file

@ -1,20 +1,21 @@
import os
import re
from collections.abc import AsyncGenerator, Mapping
from collections.abc import AsyncGenerator, Iterator, Mapping
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Final
from urllib.parse import urlsplit
from urllib.parse import unquote, urlsplit
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException, Request
from fastapi.routing import APIRoute
from pydantic import TypeAdapter
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.routing import Mount
from starlette.types import ASGIApp, Receive, Scope, Send
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
from litellm.proxy._types import SpecialHeaders
from litellm.proxy._types import AdminMCPPermissions, SpecialHeaders, UserAPIKeyAuth
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.middleware.admission_control_middleware import ADMISSION_LEASE_SCOPE_KEY
_REQUEST_HEADERS: Final = frozenset(
@ -58,6 +59,91 @@ _RESERVED_KEY_HEADERS: Final = (
_SETTINGS: Final = TypeAdapter(Mapping[str, object])
_IDENTITY_MAPPINGS: Final = TypeAdapter(tuple[dict[str, object], ...] | dict[str, object] | None)
_OAUTH_MAPPINGS: Final = TypeAdapter(dict[str, str])
_ROUTE_GRANTS: Final = TypeAdapter(tuple[str, ...])
def _admin_route_allowed(auth: UserAPIKeyAuth, request: Request, method: str, path: str) -> bool:
target: Final = Request({**request.scope, "method": method, "path": path, "raw_path": path.encode()})
try:
RouteChecks.should_call_route(route=path, valid_token=auth, request=target)
except HTTPException:
return False
return True
def _admin_routes(request: Request) -> Iterator[tuple[tuple[str, str], APIRoute]]:
app: Final = request.app
if not isinstance(app, FastAPI):
return
for route in app.routes:
if not isinstance(route, APIRoute):
continue
for method in route.methods:
yield (method, route.path_format), route
def _admin_grant_paths(route: APIRoute, grant: str) -> Iterator[str]:
if route.path_regex.fullmatch(grant) is not None:
yield grant
prefix: Final = grant[:-1] if grant.endswith("*") else grant + "/"
representative: Final = re.sub(r"\{[^}]+\}", "_", route.path_format)
# A matching suffix supplies the rest of a concrete route for prefix grants,
# including grants ending inside a static segment or a path parameter.
for candidate in (prefix + representative[offset:] for offset in range(len(representative) + 1)):
if route.path_regex.fullmatch(candidate) is not None:
yield candidate
def _admin_grant_allowed(auth: UserAPIKeyAuth, request: Request, method: str, route: APIRoute, grant: str) -> bool:
return any(_admin_route_allowed(auth, request, method, path) for path in _admin_grant_paths(route, grant))
def _admin_tool_visible(auth: UserAPIKeyAuth, request: Request, method: str, route: APIRoute) -> bool:
try:
RouteChecks.check_route_enabled(route.path)
except HTTPException:
return False
if _admin_route_allowed(auth, request, method, route.path):
return True
grants: Final = _ROUTE_GRANTS.validate_python(getattr(auth, "allowed_routes", None) or ())
return bool(route.param_convertors) and any(
_admin_grant_allowed(auth, request, method, route, grant) for grant in grants if grant.startswith("/")
)
def get_admin_mcp_permissions(
auth: UserAPIKeyAuth, request: Request, tool: str | None = None, path: str | None = None
) -> AdminMCPPermissions:
try:
from litellm_admin_mcp.catalog import ( # pyright: ignore[reportMissingTypeStubs] # connector has no py.typed marker
BY_NAME,
OPERATIONS,
)
except ImportError as exc:
raise HTTPException(
status_code=503, detail="Admin MCP permissions require the admin-mcp dependency group (Python 3.12+)"
) from exc
routes: Final = dict(_admin_routes(request))
allowed: Final = tuple(
operation.name
for operation in OPERATIONS
if (route := routes.get((operation.method, operation.path))) is not None
and _admin_tool_visible(auth, request, operation.method, route)
)
if tool is None:
return AdminMCPPermissions(tools=allowed)
operation: Final = BY_NAME.get(tool)
if operation is None or tool not in allowed:
return AdminMCPPermissions(tools=allowed, action_allowed=False)
if path is None:
return AdminMCPPermissions(tools=allowed, action_allowed=True)
registered: Final = routes[(operation.method, operation.path)]
decoded_path: Final = unquote(path)
action_allowed: Final = registered.path_regex.fullmatch(decoded_path) is not None and _admin_route_allowed(
auth, request, operation.method, decoded_path
)
return AdminMCPPermissions(tools=allowed, action_allowed=action_allowed)
def _configured_key_header() -> bytes | None:

View file

@ -111,14 +111,7 @@ _TOKEN_COUNTING_ACTION: Final = "countTokens"
class RouteChecks:
@staticmethod
def should_call_route(
route: str,
valid_token: UserAPIKeyAuth,
request: Request | None = None,
):
"""
Check if management route is disabled and raise exception
"""
def check_route_enabled(route: str) -> None:
try:
from litellm_enterprise.proxy.auth.route_checks import EnterpriseRouteChecks
@ -128,6 +121,16 @@ class RouteChecks:
except Exception:
pass
@staticmethod
def should_call_route(
route: str,
valid_token: UserAPIKeyAuth,
request: Request | None = None,
):
"""
Check if management route is disabled and raise exception
"""
RouteChecks.check_route_enabled(route)
# Check if Virtual Key is allowed to call the route - Applies to all Roles
RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token, request=request)
return True

View file

@ -945,6 +945,14 @@ async def user_info(
request: Request,
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
admin_mcp: bool = fastapi.Query(
default=False, description="Include the caller's current admin MCP route permissions"
),
mcp_tool: str | None = fastapi.Query(default=None, description="Admin MCP tool to authorize for this caller"),
mcp_path: str | None = fastapi.Query(
default=None,
description="URL-encoded concrete management path used by the MCP tool, before query-string encoding",
),
):
"""
[10/07/2024]
@ -965,12 +973,23 @@ async def user_info(
user_id = _normalize_user_info_user_id(request=request, user_id=user_id)
_enforce_user_info_access(user_id=user_id, user_api_key_dict=user_api_key_dict)
from litellm.proxy.admin_mcp import get_admin_mcp_permissions
mcp_permissions: Final = (
get_admin_mcp_permissions(user_api_key_dict, request, mcp_tool, mcp_path) if admin_mcp is True else None
)
if prisma_client is None:
raise Exception(
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
)
if user_id is None and user_api_key_has_admin_view(user_api_key_dict):
return await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict)
admin_response: Final = await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict)
return (
admin_response.model_copy(update={"admin_mcp_permissions": mcp_permissions})
if mcp_permissions is not None
else admin_response
)
elif user_id is None:
user_id = user_api_key_dict.user_id
## GET USER ROW ##
@ -1009,7 +1028,11 @@ async def user_info(
),
)
return response_data
return (
response_data.model_copy(update={"admin_mcp_permissions": mcp_permissions})
if mcp_permissions is not None
else response_data
)
except Exception as e:
verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_info(): Exception occured - %s", e)
raise handle_exception_on_proxy(e)

View file

@ -251,7 +251,7 @@ e2e-dev = [
"mcp>=2.3.0,<3",
]
admin-mcp = [
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz#sha256=86b930f6706fb2da10d53d1798ee7fd14e44fb0afdef0da122cf0e6bb88fa2c3 ; python_version >= '3.12'",
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz#sha256=5803a20a00a18177f4591706956f31be311879543049b8b38143ad90bb1cb0a6 ; python_version >= '3.12'",
]
proxy-dev = [
{ include-group = "admin-mcp" },

View file

@ -1,6 +1,7 @@
import asyncio
import json
import sys
from types import SimpleNamespace
from typing import Final
import httpx2
@ -12,11 +13,11 @@ from starlette.testclient import TestClient
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
from litellm.proxy import proxy_server
from litellm.proxy._types import LiteLLM_UserTable, ProxyException, SpecialHeaders
from litellm.proxy.admin_mcp import admin_mcp_lifespan
from litellm.proxy.auth.user_api_key_auth import get_api_key, get_api_key_from_custom_header
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, ProxyException, SpecialHeaders, UserAPIKeyAuth
from litellm.proxy.admin_mcp import admin_mcp_lifespan, get_admin_mcp_permissions
from litellm.proxy.auth.user_api_key_auth import get_api_key, get_api_key_from_custom_header, user_api_key_auth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints import internal_user_endpoints
from litellm.proxy.management_endpoints import internal_user_endpoints, team_callback_endpoints
from litellm.proxy.middleware.admission_control_middleware import (
AdmissionControlMiddleware,
AdmissionControlSettings,
@ -66,12 +67,22 @@ def management_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
if credential not in {"admin-a", "admin-b", "admin-b-limited", "member", "viewer"}:
raise HTTPException(status_code=401)
user_id: Final = "admin-b" if credential == "admin-b-limited" else credential
role: Final = {"member": LitellmUserRoles.INTERNAL_USER, "viewer": LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY}.get(
user_id, LitellmUserRoles.PROXY_ADMIN
)
auth: Final = UserAPIKeyAuth(
user_id=user_id, user_role=role,
allowed_routes=["/user/info"] if credential == "admin-b-limited" else None,
)
return {
"user_id": user_id,
"user_info": {
"user_id": user_id,
"user_role": {"member": "internal_user", "viewer": "proxy_admin_viewer"}.get(user_id, "proxy_admin"),
"user_role": role.value,
},
"admin_mcp_permissions": get_admin_mcp_permissions(
auth, request, request.query_params.get("mcp_tool"), request.query_params.get("mcp_path")
).model_dump(mode="json"),
}
@app.get("/key/list", operation_id="list_keys_key_list_get")
@ -565,3 +576,168 @@ async def test_oauth2_proxy_auth_preserves_native_identity_and_peer_trust(
assert response.status_code == status, response.text
if status == 200:
assert "list_keys" in {tool["name"] for tool in response.json()["result"]["tools"]}
@pytest.fixture
def user_info_http_app(monkeypatch: pytest.MonkeyPatch, grant: str) -> FastAPI:
class EmptyDatabase:
async def query_raw(self, _: str) -> tuple[dict[str, None], ...]:
return ({"teams": None, "keys": None},)
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=EmptyDatabase()))
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", grant])
app: Final = FastAPI()
app.include_router(internal_user_endpoints.router)
app.include_router(team_callback_endpoints.router)
app.dependency_overrides[user_api_key_auth] = lambda: auth
return app
@pytest.fixture
def permissions_request() -> Request:
return Request({"type": "http", "method": "GET", "path": "/user/info", "headers": [], "app": proxy_server.app})
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
@pytest.mark.parametrize(
"routes,session,can_write",
[
(None, False, True),
([], False, True),
(["management_routes"], False, True),
(["info_routes"], False, False),
(["llm_api_routes"], False, False),
(None, True, True),
(["info_routes"], True, False),
],
)
def test_admin_mcp_uses_credential_routes_without_removing_full_access(
routes: list[str] | None, session: bool, can_write: bool, permissions_request: Request
) -> None:
auth: Final = UserAPIKeyAuth(
user_id="admin",
user_role=LitellmUserRoles.PROXY_ADMIN,
allowed_routes=routes,
is_session_token=session,
)
permissions: Final = get_admin_mcp_permissions(auth, permissions_request, "create_key", "/key/generate")
assert permissions.action_allowed is can_write
assert ("create_key" in permissions.tools) is can_write
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
@pytest.mark.parametrize(
"team_id,grant",
[
("team-a", "/team/team-a/callback"),
("team-a", "/team/team-a/*"),
("org/team-a", "/team/org/team-a/callback"),
("org/team-a", "/team/org/team-a/*"),
("org/team-a", "/team/org/team-a/call*"),
],
)
def test_admin_mcp_checks_the_concrete_resource_even_when_the_tool_is_visible(
team_id: str, grant: str, permissions_request: Request
) -> None:
auth: Final = UserAPIKeyAuth(
user_id="admin",
user_role=LitellmUserRoles.PROXY_ADMIN,
allowed_routes=["/user/info", grant],
metadata={"is_session_token": True, "user_role": "proxy_admin"},
)
own: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", f"/team/{team_id}/callback")
other: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", "/team/other/callback")
wrong_route: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", "/key/generate")
assert "get_team_callbacks" in own.tools
assert own.action_allowed is True
assert other.action_allowed is False
assert wrong_route.action_allowed is False
@pytest.mark.asyncio
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
@pytest.mark.parametrize(
"grant,path,allowed",
[
("/team/team a/callback", "/team/team%20a/callback", True),
("/team/org/team-a/callback", "/team/org%2Fteam-a/callback", True),
("/team/team%2Fa/callback", "/team/team%252Fa/callback", True),
("/team/team+a/callback", "/team/team+a/callback", True),
("/team/team-é/callback", "/team/team-%C3%A9/callback", True),
("/team/team/a/callback", "/team/team%252Fa/callback", False),
("/team/team a/callback", "/team/team+a/callback", False),
("/team/team%41/callback", "/team/team%2541/callback", True),
],
)
async def test_user_info_decodes_mcp_path_after_query_parsing(
user_info_http_app: FastAPI,
path: str,
allowed: bool,
) -> None:
async with httpx2.AsyncClient(
transport=httpx2.ASGITransport(app=user_info_http_app),
base_url="http://testserver",
params={"admin_mcp": "true", "mcp_tool": "get_team_callbacks"},
) as client:
own: Final = await client.get("/user/info", params={"mcp_path": path})
sibling: Final = await client.get("/user/info", params={"mcp_path": "/team/other/callback"})
assert own.status_code == 200, own.text
assert sibling.status_code == 200, sibling.text
assert "get_team_callbacks" in own.json()["admin_mcp_permissions"]["tools"]
assert own.json()["admin_mcp_permissions"]["action_allowed"] is allowed
assert sibling.json()["admin_mcp_permissions"]["action_allowed"] is False
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
@pytest.mark.parametrize("grant", ["/te?m/*", "/[t]eam/*", "/te*m/*"])
def test_admin_mcp_does_not_invent_glob_permissions(grant: str, permissions_request: Request) -> None:
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", grant])
permissions: Final = get_admin_mcp_permissions(
auth, permissions_request, "get_team_callbacks", "/team/team-a/callback"
)
assert "get_team_callbacks" not in permissions.tools
assert permissions.action_allowed is False
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
def test_admin_mcp_discovery_respects_disabled_management_routes(
permissions_request: Request, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(proxy_server, "premium_user", True)
monkeypatch.setenv("DISABLE_ADMIN_ENDPOINTS", "true")
auth: Final = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", "/key/key-a/reset_spend"]
)
permissions: Final = get_admin_mcp_permissions(
auth, permissions_request, "reset_key_spend", "/key/key-a/reset_spend"
)
assert permissions.tools == ()
assert permissions.action_allowed is False
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
def test_admin_mcp_preserves_http_prefix_grants(permissions_request: Request) -> None:
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", "/key/list"])
own: Final = get_admin_mcp_permissions(auth, permissions_request, "reset_key_spend", "/key/list/reset_spend")
other: Final = get_admin_mcp_permissions(auth, permissions_request, "reset_key_spend", "/key/other/reset_spend")
assert "reset_key_spend" in own.tools
assert own.action_allowed is True
assert other.action_allowed is False
@pytest.mark.asyncio
async def test_user_info_explains_missing_admin_mcp_dependency(
permissions_request: Request, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setitem(sys.modules, "litellm_admin_mcp.catalog", None)
auth: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
with pytest.raises(ProxyException, match="admin-mcp dependency group") as exc:
await internal_user_endpoints.user_info(
request=permissions_request,
user_id=None,
user_api_key_dict=auth,
admin_mcp=True,
mcp_tool=None,
mcp_path=None,
)
assert exc.value.code == "503"

View file

@ -468,7 +468,10 @@ def test_admin_mcp_survives_only_management_component_lifespans(
@application.get("/user/info")
async def user_info() -> dict[str, object]:
return {"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"}}
return {
"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"},
"admin_mcp_permissions": {"tools": ["list_teams"], "action_allowed": True},
}
if component_lifespan is not None:
application.router.lifespan_context = partial(component_lifespan, lifespan=application.router.lifespan_context)

View file

@ -147,7 +147,10 @@ async def test_proxy_shutdown_drains_active_admin_tool_before_closing_connector(
@admin_mcp_proxy.get("/user/info")
async def user_info() -> dict[str, object]:
return {"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"}}
return {
"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"},
"admin_mcp_permissions": {"tools": ["list_teams"], "action_allowed": True},
}
@admin_mcp_proxy.get("/team/list", operation_id="list_team_team_list_get")
async def list_teams() -> dict[str, object]:

View file

@ -25710,6 +25710,13 @@ export interface components {
*/
source: "admin";
};
/** AdminMCPPermissions */
AdminMCPPermissions: {
/** Action Allowed */
action_allowed?: boolean | null;
/** Tools */
tools: string[];
};
/**
* AgentCapabilities
* @description Defines optional capabilities supported by an agent.
@ -49920,6 +49927,7 @@ export interface components {
};
/** UserInfoResponse */
UserInfoResponse: {
admin_mcp_permissions?: components["schemas"]["AdminMCPPermissions"] | null;
/** Keys */
keys: unknown[];
/** Teams */
@ -76732,6 +76740,12 @@ export interface operations {
query?: {
/** @description User ID in the request parameters */
user_id?: string | null;
/** @description Include the caller's current admin MCP route permissions */
admin_mcp?: boolean;
/** @description Admin MCP tool to authorize for this caller */
mcp_tool?: string | null;
/** @description URL-encoded concrete management path used by the MCP tool, before query-string encoding */
mcp_path?: string | null;
};
header?: never;
path?: never;

8
uv.lock generated
View file

@ -5334,7 +5334,7 @@ requires-dist = [
provides-extras = ["proxy", "cli", "extra-proxy", "utils", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
[package.metadata.requires-dev]
admin-mcp = [{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" }]
admin-mcp = [{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" }]
benchmarks = [
{ name = "a2a-sdk", specifier = "==1.1.0" },
{ name = "mcp", specifier = ">=2.3.0,<3" },
@ -5431,7 +5431,7 @@ proxy-dev = [
{ name = "azure-identity", specifier = "==1.25.2" },
{ name = "backports-zstd", marker = "python_full_version < '3.14'", specifier = "==1.8.0" },
{ name = "hypercorn", specifier = "==0.17.3" },
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" },
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" },
{ name = "opentelemetry-api", specifier = "==1.45.1" },
{ name = "opentelemetry-exporter-otlp", specifier = "==1.45.1" },
{ name = "opentelemetry-instrumentation-fastapi", specifier = "==0.66b1" },
@ -5444,14 +5444,14 @@ proxy-dev = [
[[package]]
name = "litellm-admin-mcp"
version = "0.1.0"
source = { url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" }
source = { url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" }
dependencies = [
{ name = "httpx2" },
{ name = "jsonschema" },
{ name = "mcp" },
{ name = "uvicorn" },
]
sdist = { hash = "sha256:86b930f6706fb2da10d53d1798ee7fd14e44fb0afdef0da122cf0e6bb88fa2c3" }
sdist = { hash = "sha256:5803a20a00a18177f4591706956f31be311879543049b8b38143ad90bb1cb0a6" }
[package.metadata]
requires-dist = [