Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/scripts/unit_selection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ legacy_paths() {
llm-vertex-ai) echo tests/unit/llms/vertex_ai ;;
mcp-integration)
echo tests/unit/experimental_mcp_client
echo tests/unit/proxy/test_admin_mcp.py
echo tests/unit/proxy/_experimental/mcp_server
echo tests/unit/responses/mcp
echo tests/mcp_tests/test_proxy_mcp_e2e.py ;;
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/_test-unit-base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ jobs:
- name: Upload to Codecov
id: codecov-upload
continue-on-error: true
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
version: v11.3.1
use_oidc: true
directory: coverage-reports
root_dir: ${{ github.workspace }}
Expand All @@ -285,8 +286,9 @@ jobs:
- name: Upload to Codecov (retry)
if: steps.codecov-upload.outcome == 'failure'
continue-on-error: true
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
version: v11.3.1
use_oidc: true
directory: coverage-reports
root_dir: ${{ github.workspace }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/image-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jobs:
LITELLM_IMAGE: litellm-image-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v

# Scans the whole shipped artifact: OS/apk plus every language package
# baked into the image, including ones no lockfile declares (e.g. prisma's
Expand Down Expand Up @@ -125,7 +125,7 @@ jobs:
LITELLM_IMAGE: litellm-runtime-scan:${{ github.sha }}
run: |
python -m pip install "pytest==9.0.3"
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py -v
python -m pytest tests/proxy_migration_tests/test_offline_image_migration.py tests/proxy_migration_tests/test_image_bedrock_realtime_extra.py tests/proxy_migration_tests/test_image_admin_mcp.py -v

migrations-image:
name: migrations-image
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,9 @@ jobs:

- name: Upload Lens database coverage
if: steps.changes.outputs.decision != 'skip' && matrix.shard == 'proxy-behavior' && !cancelled()
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
version: v11.3.1
use_oidc: true
files: coverage-lens-postgres.xml
flags: lens-postgres
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/test-redis-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ jobs:

- name: Upload Redis coverage
if: matrix.redis-version == '5.3.1'
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5.5.4
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
version: v11.3.1
use_oidc: true
files: coverage-redis.xml
flags: redis-compat
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ COPY litellm-proxy-extras/pyproject.toml litellm-proxy-extras/
RUN uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
Expand All @@ -101,6 +102,7 @@ RUN sed -i 's/\r$//' docker/build_admin_ui.sh && chmod +x docker/build_admin_ui.
RUN uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
Expand Down
3 changes: 3 additions & 0 deletions docker/Dockerfile.non_root
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
uv sync --frozen --no-install-project --no-install-workspace --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
Expand Down Expand Up @@ -111,6 +112,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
Expand All @@ -121,6 +123,7 @@ RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--group admin-mcp \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
Expand Down
74 changes: 74 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,80 @@ This guide provides instructions for building and running the LiteLLM applicatio
- Docker
- Docker Compose

## Enable the built-in admin MCP

The images built from `Dockerfile` and `docker/Dockerfile.non_root` include the
[LiteLLM Admin MCP](https://github.com/BerriAI/liteadmin-mcp). Hosting it requires
a valid base LiteLLM Enterprise license. It is disabled by default. Set the
license and enable flag on the existing service, then restart the container:

```yaml
environment:
LITELLM_LICENSE: "<your-enterprise-license>"
LITELLM_ENABLE_ADMIN_MCP: "true"
PROXY_BASE_URL: "https://litellm.example.com"
```

An enabled connector without a valid enterprise license prevents startup with
the standard enterprise-license error. Each MCP request also checks the proxy's
current enterprise status. A base license is sufficient; no additional feature
entitlement is required. The flag being off does not require a license

Keep your existing database, authentication, configuration mount and HTTPS reverse
proxy. The MCP endpoint is `https://litellm.example.com/admin/mcp`, on the same
port as LiteLLM. If the gateway is served under a URL prefix, include that prefix
in the endpoint, for example `https://litellm.example.com/gateway/admin/mcp`

Connect an MCP client that supports Streamable HTTP and bearer headers:

```json
{
"mcpServers": {
"litellm-admin": {
"url": "https://litellm.example.com/admin/mcp",
"headers": {
"Authorization": "Bearer <your-personal-proxy-admin-key>"
}
}
}
}
```

Each caller needs a current `proxy_admin` identity, including for read operations.
The connector calls this gateway's management API in process with the caller's
credential. It does not use `LITELLM_BASE_URL` or a shared `LITELLM_API_KEY`

The integration mounts the connector's own ASGI application and uses the standard
`httpx2.ASGITransport` for calls back into the gateway. LiteLLM's outbound HTTP
handler uses `httpx`, while the connector requires `httpx2`. A small ASGI adapter
preserves the original caller's address, scheme and policy headers, which a direct
transport mount would replace with loopback defaults. It retains the complete
gateway middleware and authentication stack; it does not implement HTTP requests
or MCP protocol handling. The adapter explicitly links internal calls to their
parent's admission slot; unrelated requests still acquire their own slots. The
proxy pauses scheduled jobs, drains active requests, closes the connector, and
then cleans up shared resources, including when startup or shutdown fails

`PROXY_BASE_URL` supplies the trusted public origin for Host and Origin validation.
Set `LITELLM_MCP_PUBLIC_URL` to an HTTPS origin if the admin MCP uses a different
public hostname. Without either setting, only the connector's loopback hosts are
accepted. Keep the existing `/mcp` endpoint for the MCP gateway; enabling admin MCP
reserves the `/admin` prefix, including any existing MCP server alias named `admin`

Set `LITELLM_ADMIN_READ_ONLY=true` to disable writes, or
`LITELLM_ADMIN_TOOLS=list_keys,list_teams` to restrict the available operations.
These restrictions apply in addition to the gateway's authorization checks

Embedded deployments return complete results by default, so requests can reach
different workers or replicas. `LITELLM_ADMIN_RESPONSE_VIEW=compact` enables the
connector's paged results. Compact results, including explicit per-call requests
for them, require subsequent `read_admin_result` calls to reach the same worker

The connector source is pinned to a commit and archive checksum in the
`admin-mcp` dependency group and `uv.lock`. Updates ship with the LiteLLM image;
starting the server does not download code. For source development with Python
3.12 or later, install it with `uv sync --extra proxy --group admin-mcp`

## Building and Running the Application

To build and run the application, you will use the `docker-compose.yml` file located in the root of the project. This file is configured to use the `Dockerfile.non_root` for a secure, non-root container environment.
Expand Down
119 changes: 119 additions & 0 deletions litellm/proxy/admin_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from contextvars import ContextVar
from typing import Final
from urllib.parse import urlsplit

from fastapi import FastAPI
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.proxy.middleware.admission_control_middleware import ADMISSION_LEASE_SCOPE_KEY

_REQUEST_HEADERS: Final = frozenset(
{
b"authorization",
b"cookie",
b"content-length",
b"content-type",
b"transfer-encoding",
b"connection",
b"accept",
b"accept-encoding",
b"mcp-protocol-version",
b"mcp-session-id",
}
)


def _require_enterprise_license() -> None:
from litellm.proxy.utils import require_enterprise_license

require_enterprise_license("Hosted admin MCP")


class _CallerContext:
def __init__(self, app: ASGIApp, caller: ContextVar[Request]) -> None:
self.app = app
self.caller = caller

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
_require_enterprise_license()
token: Final = self.caller.set(Request(scope))
try:
await self.app(scope, receive, send)
finally:
self.caller.reset(token)


@asynccontextmanager
async def admin_mcp_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
enabled: Final = os.environ.get("LITELLM_ENABLE_ADMIN_MCP", "false").strip().lower()
if enabled in ("false", "0", "off", "no", ""):
yield
return
if enabled not in ("true", "1", "on", "yes"):
raise ValueError("LITELLM_ENABLE_ADMIN_MCP must be true or false")
_require_enterprise_license()

try:
import httpx2
from litellm_admin_mcp.config import ( # pyright: ignore[reportMissingTypeStubs] # upstream has no py.typed marker
Config,
env_bool,
)
from litellm_admin_mcp.gateway import ( # pyright: ignore[reportMissingTypeStubs] # upstream has no py.typed marker
Gateway,
)
from litellm_admin_mcp.server import ( # pyright: ignore[reportMissingTypeStubs] # upstream has no py.typed marker
create_http_app,
)
except ImportError as exc:
raise RuntimeError(
"Admin MCP requires Python 3.12+ and the admin-mcp dependency group. "
"Use a LiteLLM image that bundles it, or run uv sync --extra proxy --group admin-mcp."
) from exc

configured_url: Final = os.environ.get("LITELLM_MCP_PUBLIC_URL") or os.environ.get("PROXY_BASE_URL", "")
public_url: Final = urlsplit(configured_url)
config: Final = Config(
base_url="http://localhost",
public_url=f"{public_url.scheme}://{public_url.netloc}" if public_url.netloc else configured_url,
read_only=env_bool("LITELLM_ADMIN_READ_ONLY"),
allowed_tools=frozenset(
name.strip() for name in os.environ.get("LITELLM_ADMIN_TOOLS", "").split(",") if name.strip()
),
response_view=os.environ.get("LITELLM_ADMIN_RESPONSE_VIEW", "full").strip(),
schema_mode=os.environ.get("LITELLM_ADMIN_SCHEMA_MODE", "full").strip(),
)
caller: Final[ContextVar[Request]] = ContextVar("admin_mcp_caller")

async def management_api(scope: Scope, receive: Receive, send: Send) -> None:
request: Final = caller.get()
caller_headers: Final = tuple(pair for pair in request.headers.raw if pair[0] not in _REQUEST_HEADERS)
api_headers: Final = tuple(pair for pair in Headers(scope=scope).raw if pair[0] in _REQUEST_HEADERS)
headers: Final = list(caller_headers + api_headers) # mutable-ok: ASGI middleware modifies headers
gateway_scope: Final[Scope] = {
**scope,
"client": request.client,
"scheme": request.url.scheme,
"headers": headers,
ADMISSION_LEASE_SCOPE_KEY: request.scope.get(ADMISSION_LEASE_SCOPE_KEY),
}
await app(gateway_scope, receive, send)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

async with httpx2.AsyncClient(transport=httpx2.ASGITransport(app=management_api)) as client:
admin_app: Final = create_http_app(Gateway(config, client))
route: Final = Mount("/admin", app=_CallerContext(admin_app, caller), name="admin_mcp")
async with admin_app.router.lifespan_context(admin_app):
app.router.routes.insert(0, route)
try:
yield
finally:
app.router.routes.remove(route)
22 changes: 21 additions & 1 deletion litellm/proxy/middleware/admission_control_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

from litellm._logging import verbose_proxy_logger

ADMISSION_LEASE_SCOPE_KEY: Final = "litellm.admission_lease"

_EXEMPT_PATHS: Final[frozenset[str]] = frozenset(
{
"/health/liveliness",
Expand Down Expand Up @@ -130,6 +132,12 @@ def _get_metrics(self) -> AdmissionControlMetrics | None:
return self._metrics


class _AdmissionLease:
def __init__(self, state: AdmissionControlState) -> None:
self.state: Final = state
self.active: bool = True


class AdmissionControlMiddleware:
def __init__(
self,
Expand All @@ -146,6 +154,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)
return

inherited_lease: Final = scope.get(ADMISSION_LEASE_SCOPE_KEY)
if (
isinstance(inherited_lease, _AdmissionLease)
and inherited_lease.state is self.state
and inherited_lease.active
):
await self.app(scope, receive, send)
return

settings: Final = self.get_settings()
if settings is None or _get_route_path(scope) in _EXEMPT_PATHS:
await self.app(scope, receive, send)
Expand Down Expand Up @@ -178,9 +195,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
state.record_dequeue()
state.record_admission()

lease: Final = _AdmissionLease(state)
admitted_scope: Final[Scope] = {**scope, ADMISSION_LEASE_SCOPE_KEY: lease}
try:
await self.app(scope, receive, send)
await self.app(admitted_scope, receive, send)
finally:
lease.active = False
semaphore.release()
state.record_release()

Expand Down
Loading
Loading