Skip to content
Open
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
43 changes: 38 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,48 @@ server to a client that supports local MCP processes:
"litellm-admin-mcp"
],
"env": {
"LITELLM_BASE_URL": "https://your-gateway.example.com",
"LITELLM_API_KEY": "<your-personal-proxy-admin-key>"
"LITELLM_BASE_URL": "https://your-gateway.example.com"
}
}
}
}
```

Use your client's secret storage if available, and keep its configuration private.
When your client starts the connector without a saved sign-in, the connector lists the
admin tools from its own catalog, with short schemas that point the agent at
`describe_admin_tool`, plus a `sign_in` tool, and opens nothing. The first admin tool
call opens your browser on the gateway's sign-in page instead of running. Sign in as a
proxy admin and approve the request. The call then answers that the tool has not run
yet, and your agent calls it again to run it, so this works in clients that do not
reload their tool list. Clients that do reload it after the connector announces the
change get the gateway's own tool list, without `sign_in`. A call that signs in waits
up to 40 seconds for approval and answers within a minute, so clients with a
one-minute tool timeout get its answer. If the browser window did not appear or you
need longer, it returns the sign-in link, which stays usable for five minutes, and
your agent repeats the call after you approve. When your agent runs on another
computer, such as over SSH, open the link in your own browser and approve. The page it
then opens does not load, because it points at the other computer. Give its full
address to your agent, which calls `sign_in` with it as `callback_url` to finish
signing in.

The connector saves the sign-in under `~/.config/litellm-admin-mcp/sign-ins/`
(under `$XDG_CONFIG_HOME` when set), one file per gateway, readable only by you, and
renews it before it expires, so later runs and other sessions do not ask again. To
sign out, run the same command with `logout`, for example
`LITELLM_BASE_URL=https://your-gateway.example.com uvx --from git+https://github.com/BerriAI/litellm-admin-mcp.git@main litellm-admin-mcp logout`.
It revokes the sign-in's refresh token at the gateway, so the sign-in is no longer
renewed, and removes the saved file. The access token the gateway last issued stays
valid until it expires, and `logout` prints that time. When the gateway rejects the
saved access token, the connector renews it once. A sign-in that still fails, or one
for a user who is not a proxy admin, is revoked and removed the same way, and the next
admin tool call opens the browser sign-in again. Browser sign-in needs a gateway that
publishes `/.well-known/litellm-cli-auth` (LiteLLM v1.99.0 or later), and the admin
tools also need the `admin_mcp_permissions` support described above.

To use an existing key instead, add `"LITELLM_API_KEY": "<your-personal-proxy-admin-key>"`
to `env`. The connector then never opens a browser. Use your client's secret storage
if available, and keep its configuration private.

The connector requires Python 3.12 or later; uv can install a compatible Python.

This configuration automatically checks GitHub's `main` branch whenever your
Expand Down Expand Up @@ -247,8 +280,8 @@ own personal gateway credential:
```

Client configuration varies; use a client that supports Streamable HTTP and
bearer headers. This release uses API-key authentication, not a browser OAuth
installation flow. Admin status is checked against the gateway on every request
bearer headers. Hosted HTTP mode uses API-key authentication. Browser sign-in is
only for local stdio runs. Admin status is checked against the gateway on every request
and before releasing tool results. No shared admin credential is stored by the
HTTP service.

Expand Down
14 changes: 12 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ The server targets one operator-configured gateway. A tool cannot change that
target, invent an administrative route, forward arbitrary headers, or follow an
HTTP redirect. It exposes only reviewed operations present in the gateway's spec.

Local stdio runs with the administrator's configured gateway credential. Hosted
Local stdio runs with the administrator's configured gateway credential or, when
none is configured, one from the gateway's browser sign-in. That sign-in uses PKCE
with a loopback redirect on 127.0.0.1, accepts only the pending request's state
(at the loopback listener or in an address the user pastes back as `callback_url`
when the browser runs on another computer), redeems the code only with that
request's PKCE verifier, and sends nothing to endpoints outside the configured gateway's origin. The
resulting access and refresh tokens are kept in an owner-only file (mode 0600 in a
0700 directory on macOS and Linux; Windows ignores those modes, so there the file
relies on the user profile's permissions) keyed by gateway origin. A sign-in the gateway still rejects after
one renewal is revoked and removed. Hosted
HTTP requests require each caller's own bearer credential; the server refuses
to start HTTP mode with a process-wide gateway key. The gateway's live proxy-admin
role is checked for discovery, before execution and before returning results.
Expand Down Expand Up @@ -47,7 +56,8 @@ server. The LiteLLM Admin Agent maintains its own request journal.

Terminate TLS at a trusted proxy for remote deployment. Exclude authorization
headers and request/response bodies from proxy logs. The process logs no tool
arguments or results. Credentials are neither persisted nor placed in URLs.
arguments or results. The HTTP service persists no credentials, and credentials
are never placed in URLs.

Report vulnerabilities using this repository's private Security Advisories when
available or your established private contact with Berri AI. Do not include live
Expand Down
25 changes: 20 additions & 5 deletions src/litellm_admin_mcp/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,52 @@
import logging
import sys

from mcp.server.lowlevel import NotificationOptions
from mcp.server.stdio import stdio_server
import uvicorn

from .config import Config
from .gateway import Gateway, valid_credential
from .gateway import AdminError, Gateway, valid_credential
from .server import create_http_app, create_server
from .signin import BrowserSignIn, CredentialStore, default_store_directory


async def run_stdio(config: Config):
valid_credential(config.api_key)
if config.api_key:
valid_credential(config.api_key)
async with Gateway(config) as gateway, stdio_server() as (read, write):
server = create_server(gateway)
await server.run(read, write, server.create_initialization_options())
sign_in = None if config.api_key else BrowserSignIn(config.base_url, gateway.client, CredentialStore(default_store_directory()))
server = create_server(gateway, sign_in=sign_in)
await server.run(read, write, server.create_initialization_options(NotificationOptions(tools_changed=sign_in is not None)))


async def sign_out(config: Config) -> str:
async with Gateway(config) as gateway:
return await BrowserSignIn(config.base_url, gateway.client, CredentialStore(default_store_directory())).sign_out()


def main():
parser = argparse.ArgumentParser(description="LiteLLM Admin MCP connector")
parser.add_argument("command", nargs="?", choices=["logout"],
help="logout revokes the saved browser sign-in's refresh token and removes the sign-in for LITELLM_BASE_URL")
parser.add_argument("--transport", choices=["stdio", "streamable-http"], default="stdio")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
try:
config = Config.read()
if args.transport == "stdio":
if args.command == "logout":
print(asyncio.run(sign_out(config)))
elif args.transport == "stdio":
asyncio.run(run_stdio(config))
else:
uvicorn.run(create_http_app(Gateway(config)), host=args.host, port=args.port,
log_level="warning", access_log=False)
except KeyboardInterrupt:
pass
except AdminError as exc:
parser.exit(1, f"LiteLLM Admin MCP could not {'sign out' if args.command else 'start'}: {exc}\n")
except Exception as exc:
# Transport exceptions can include response bodies/headers. Do not print them.
parser.exit(1, f"LiteLLM Admin MCP could not start ({type(exc).__name__}). Check its configuration and gateway connection.\n")
Expand Down
64 changes: 40 additions & 24 deletions src/litellm_admin_mcp/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import jsonschema
from mcp import types

from .catalog import BY_NAME, OPERATIONS
from .catalog import BY_NAME, OPERATIONS, Operation
from .config import Config
from .schema import SchemaError, discovery_schema, tool_schema
from .results import (MAX_RESULT_BYTES, PAGE_BYTES, READ_SCHEMA, RESPONSE_SCHEMA,
Expand All @@ -27,6 +27,7 @@
"describe_admin_tool": (DESCRIBE_SCHEMA, "Read the complete input schema and annotations of an enabled tool, including all advanced arguments and referenced definitions. Required before using a tool in discovery schema mode. Returns full detail by default; response.view=compact pages unusually large schemas."),
"read_admin_result": (READ_SCHEMA, "Read an exact saved result without re-executing its operation. Browse fields, array items and string chunks using returned paths/next arguments. view=full returns the entire selected value. Snapshots expire after five minutes; never repeat a write to recover its response."),
}
OFFLINE_SCHEMA = {"properties": {**dict.fromkeys(("body", "query", "path"), {}), "response": RESPONSE_SCHEMA}}


class AdminError(Exception):
Expand All @@ -35,12 +36,36 @@ def __init__(self, message: str, status: int = 400):
self.status = status


class NotAdmin(AdminError):
def __init__(self, message: str = "Only current LiteLLM proxy admins may use these tools."):
super().__init__(message, 403)


@dataclass(frozen=True)
class Authorization:
principal: str
tools: frozenset[str]


def catalog_tool(item: Operation, schema: dict) -> types.Tool:
effect = ("Read-only." if item.read_only
else "Changes gateway state; execute only when requested. Never retry an uncertain write.")
return types.Tool(name=item.name, description=f"{item.summary}. {item.method} {item.path}. {effect}",
inputSchema=schema, annotations=types.ToolAnnotations(readOnlyHint=item.read_only,
destructiveHint=not item.read_only, idempotentHint=item.read_only, openWorldHint=True))


def discovered(tool: types.Tool) -> types.Tool:
return tool.model_copy(update={"input_schema": discovery_schema(tool.input_schema, tool.name),
"description": tool.description + " Call describe_admin_tool for the complete argument schema before use."})


def helper_tools() -> dict[str, types.Tool]:
return {name: types.Tool(name=name, description=description, inputSchema=schema, annotations=types.ToolAnnotations(
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False))
for name, (schema, description) in HELPERS.items()}


def valid_credential(value: str) -> str:
if not value or len(value) > 8192 or any(not 33 <= ord(c) <= 126 for c in value):
raise AdminError("A personal LiteLLM admin credential is required.", 401)
Expand Down Expand Up @@ -144,7 +169,7 @@ async def _authorization(self, credential: str, *, tool: str | None = None,
or not isinstance(user.get("user_id"), str) or not user["user_id"]
or data.get("user_id") != user["user_id"] or user.get("blocked")
or user.get("deleted") or user.get("is_active") is False):
raise AdminError("Only current LiteLLM proxy admins may use these tools.", 403)
raise NotAdmin()
permissions = data.get("admin_mcp_permissions")
if (not isinstance(permissions, dict) or not isinstance(permissions.get("tools"), list)
or any(not isinstance(name, str) for name in permissions["tools"])):
Expand All @@ -156,17 +181,17 @@ async def _authorization(self, credential: str, *, tool: str | None = None,
async def tools(self, credential: str) -> dict[str, types.Tool]:
authorization = await self._authorization(credential)
canonical = await self._load_tools(credential)
visible = {}
for name, tool in canonical.items():
if name not in authorization.tools:
continue
visible[name] = (tool.model_copy(update={"input_schema": discovery_schema(tool.input_schema, name),
"description": tool.description + " Call describe_admin_tool for the complete argument schema before use."})
if self.config.schema_mode == "discovery" else tool)
for name, (schema, description) in HELPERS.items():
visible[name] = types.Tool(name=name, description=description, inputSchema=schema,
annotations=types.ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False))
return visible
visible = {name: discovered(tool) if self.config.schema_mode == "discovery" else tool
for name, tool in canonical.items() if name in authorization.tools}
return {**visible, **helper_tools()}

def offline_tools(self) -> dict[str, types.Tool]:
enabled = (item for item in OPERATIONS if self._enabled(item))
return {**{item.name: discovered(catalog_tool(item, OFFLINE_SCHEMA)) for item in enabled}, **helper_tools()}

def _enabled(self, item: Operation) -> bool:
return (not self.config.allowed_tools or item.name in self.config.allowed_tools) and (
item.read_only or not self.config.read_only)

async def _load_tools(self, credential: str) -> dict[str, types.Tool]:
async with self._schema_lock:
Expand All @@ -177,9 +202,7 @@ async def _load_tools(self, credential: str) -> dict[str, types.Tool]:
raise AdminError("Gateway OpenAPI schema is unavailable.", 503)
selected = {}
for item in OPERATIONS:
if self.config.allowed_tools and item.name not in self.config.allowed_tools:
continue
if self.config.read_only and not item.read_only:
if not self._enabled(item):
continue
path = spec["paths"].get(item.path, {})
operation = path.get(item.method.lower())
Expand All @@ -193,14 +216,7 @@ async def _load_tools(self, credential: str) -> dict[str, types.Tool]:
jsonschema.Draft202012Validator.check_schema(schema)
except (SchemaError, jsonschema.SchemaError, TypeError, KeyError):
raise AdminError(f"Gateway schema is unsupported for {item.name}.", 503) from None
selected[item.name] = types.Tool(
name=item.name, description=f"{item.summary}. {item.method} {item.path}. "
+ ("Read-only." if item.read_only else "Changes gateway state; execute only when requested. Never retry an uncertain write."),
inputSchema=schema, annotations=types.ToolAnnotations(
readOnlyHint=item.read_only, destructiveHint=not item.read_only,
idempotentHint=item.read_only, openWorldHint=True,
),
)
selected[item.name] = catalog_tool(item, schema)
if not selected:
raise AdminError("No compatible, enabled admin tools were found on this gateway.", 503)
self._tools = selected
Expand Down
Loading
Loading