Skip to content
Merged
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
16 changes: 15 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,21 @@ read-only enforcement, uncertain write outcomes and sensitive-result handling.
Test schema changes with referenced request models, path/query arguments and the
MCP protocol rather than only calling an internal function.

Favor functionality over compaction. Keep complete small records, expose all
advanced parameters and preserve every sanitized field in detail reads. Test
exact reconstruction from the public index/continuations, including unknown
metadata, Unicode, null/zero/false and changing source data. A shorter first
response is not sufficient evidence of a better agent workflow. Compare complete
tasks and account for schema lookups and follow-up reads; do not claim model task
parity from deterministic protocol tests alone.

The `read_only_override` catalog field is for reviewed exceptions such as POST
reads. Do not classify an operation as read-only merely to reduce friction:
`check_key_health`, for example, can emit callback test logs.

Release a reviewed commit by updating the package version, running CI, building
with `uv build`, and attaching the wheel and source distribution to a matching
GitHub tag. Client integrations should pin a release or commit. PyPI publishing
GitHub tag. The default client launcher tracks `main` and refreshes the connector
on each start, so merged changes do not require a version bump to reach those
clients. Fixed deployments can pin a release or commit instead. PyPI publishing
can be added through the organization's trusted publishing configuration.
155 changes: 152 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ server to a client that supports local MCP processes:
"litellm-admin": {
"command": "uvx",
"args": [
"--isolated",
"--refresh-package",
"litellm-admin-mcp",
"--from",
"git+https://github.com/BerriAI/litellm-admin-mcp.git@v0.1.0",
"git+https://github.com/BerriAI/litellm-admin-mcp.git@main",
"litellm-admin-mcp"
],
"env": {
Expand All @@ -33,6 +36,20 @@ server to a client that supports local MCP processes:
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
client starts the MCP server. After a change is merged, restart the MCP connection
or your client to load it; no package version bump or manual reinstall is needed.
`--refresh-package` refreshes this connector's cache, and `--isolated` prevents an
older separately installed tool from taking precedence. See uv's
[tool environments](https://docs.astral.sh/uv/concepts/tools/) and
[cache refresh options](https://docs.astral.sh/uv/concepts/cache/).

If you already configured a release such as `@v0.1.0`, replace its `args` with the
example above once. Existing pinned configurations cannot update themselves from
a README change. Startup requires access to GitHub; a running process keeps its
current version until restarted. For a fixed deployment, replace `@main` with a
release tag or full commit SHA and omit `--refresh-package` and its package name.

Try:

- “List my teams and their budgets.”
Expand Down Expand Up @@ -62,21 +79,145 @@ ID fails discovery until the connector is updated. Tools have stable names such
as `create_key`, `add_model`, `list_teams` and `update_budget`; their arguments are
grouped into `body`, `query` and `path` to avoid ambiguous parameter names.

Set `LITELLM_ADMIN_READ_ONLY=true` to expose only GET operations. Optionally set
Set `LITELLM_ADMIN_READ_ONLY=true` to expose only reviewed read operations (including
the POST reads `get_budgets` and `get_organization_legacy`). Optionally set
`LITELLM_ADMIN_TOOLS=create_key,list_keys,list_teams` to restrict the catalog.
The gateway still enforces each caller's permissions and feature entitlements.

## Efficient results and tool discovery

The default keeps small results complete and pages large results. All 65 actions,
advanced arguments and previously accessible result fields remain available.
There are two additional read-only tools: `describe_admin_tool` and
`read_admin_result`.

### Results: complete records, then exact detail reads

Results up to 16 KB of compact JSON are returned whole. Larger results return an
index with complete small values, useful record previews, unread field counts,
and exact `read` / `next` arguments for `read_admin_result`. This uses structural
paging, not a whitelist of "important" fields or an AI-generated summary.
Unknown metadata, `null`, `0`, `false`, empty arrays and empty objects are preserved.
A preview with `complete: false` is not the entire record; read the relevant
omitted fields before drawing conclusions. For example, a user profile can stay
complete while its embedded keys and teams are read separately.

To retrieve detail, call `read_admin_result` with the returned `read` arguments.
Follow `next` to enumerate every object field or array item. Paths are JSON
Pointers supplied by the index; string chunks use Unicode code-point offsets.
To retrieve the entire original result in one response, use its `full_read`
arguments (an empty path selects the root):

```json
{"result_id": "<returned-result-id>", "path": "", "view": "full"}
```

These reads use the same saved result, even if gateway data changes. Reading a
write's result never repeats the write. **Do not repeat a create, rotate or delete
operation just to request a different response view.** Newly created/rotated keys
are always delivered inline in the full result and are never cached.

Every administrative tool also accepts `response: {"view": "full"}` to request
an inline result on its initial call. Set `LITELLM_ADMIN_RESPONSE_VIEW=full` to make
that the process default. Neither setting changes the gateway query, its scope,
or the existing secret-redaction rules.

Snapshots are bound to both the caller's credential and current admin identity,
and expire after five minutes. Each read rechecks authorization before releasing
data. The in-memory store admits at most 16 snapshots per credential and 64 MB of
serialized data in total; it never evicts an unexpired result to make room. When
full, or when a result contains recognizable credentials, the original complete
response is returned inline instead. Expired entries are removed on the next
cache access; shutdown clears the store. HTTP deployments with multiple workers
must route subsequent result reads to the same process. A missing/expired result
does not authorize repeating a write.

Collection pages target 16 KB plus index overhead, up to 20 entries by default;
large individual fields remain explicitly retrievable. Administrative calls
stream at most 16 MB from the gateway in either view, replacing the previous
2 MB limit checked after downloading the entire response. Use upstream pagination
or filters for larger queries. Explicit full reads can be large: request pages
if the client has an output limit.

### Tool search: use the client's native discovery

By default, the server publishes ordinary named MCP tools with their complete
input schemas and individual safety annotations. A client such as Codex can use
native tool search/deferred loading with this interface; no custom dispatcher or
second schema lookup is required. The client controls whether definitions enter
model context eagerly or on demand. The size of `tools/list` is not a measurement
of initial model tokens. See OpenAI's [tool search documentation](https://developers.openai.com/api/docs/guides/tools-tool-search).

For a client that loads every schema upfront, an optional fallback is available:

```sh
export LITELLM_ADMIN_SCHEMA_MODE=discovery
```

This keeps all original tool names, descriptions, required argument groups and
safety annotations, while deferring parameter detail. Before an operation, the
agent calls `describe_admin_tool` with its exact name to obtain **every** supported
argument, constraint, default, example and referenced definition, then calls the
original named tool. Execution still validates the complete gateway schema.
This mode intentionally uses permissive argument declarations; it is not native
tool search and adds a schema lookup. Prefer the default `full` schema mode when
the client already supports native tool search. The helper can also page an
unusually large schema with `response: {"view": "compact"}`.

The tool allowlist and read-only policy apply to discovery and direct execution
in both modes. Helpers cannot invoke administrative operations or arbitrary URLs.

### Client compatibility

Official documentation checked on September 23, 2026. The server uses standard
MCP tools over stdio or Streamable HTTP, so clients control native search and
deferred loading. These are documentation-based compatibility conclusions, not
end-to-end tests inside each application.

| Client | Documented discovery behavior | Suggested schema mode |
| --- | --- | --- |
| [Claude Code](https://code.claude.com/docs/en/mcp#scale-with-mcp-tool-search) | MCP tool search is on by default for supported models/providers. Tool names and server instructions load first; full definitions load when needed. | Keep the default `full`. |
| [Cursor](https://cursor.com/blog/dynamic-context-discovery#4-efficiently-loading-only-the-mcp-tools-needed) | Dynamic MCP discovery keeps definitions in files and loads relevant tools on demand. | Keep the default `full`. |
| [VS Code / GitHub Copilot](https://code.visualstudio.com/docs/agents/reference/ai-settings) | Experimental `github.copilot.chat.virtualTools.threshold` groups tools for on-demand activation; its documented default threshold is 128 tools. This connector's 67 tools alone do not cross that threshold. | Keep `full` with client-side discovery; consider `discovery` if the client eagerly loads the catalog. |
| [OpenCode](https://opencode.ai/docs/mcp-servers/) | Supports local and remote MCP servers. Its MCP, tools and configuration docs do not establish native tool search and explicitly warn about tool context cost. | `full` works as a standard MCP interface; `discovery` is the fallback to reduce eager schema loading. |

Claude Code requires a model/provider supporting `tool_reference` blocks. Its
current docs list Claude 4.5-generation models and later. It normally disables
tool search if **model traffic** uses a non-first-party `ANTHROPIC_BASE_URL`.
`ENABLE_TOOL_SEARCH=true` can override that proxy fallback only if the proxy
supports the required blocks; other provider or organization restrictions may
still apply. Pointing this MCP at a LiteLLM gateway with `LITELLM_BASE_URL` is a
separate setting and does not itself change Claude Code's model provider.

Claude Code recommends concise server instructions explaining the tool categories
and when to search. This server provides those instructions and keeps them below
its documented default 2,048-character truncation limit. Leave `alwaysLoad` unset
for this server if you want Claude Code to defer its tools.

OpenCode uses an `mcp` configuration map with `type: "local"`, a `command` array
and an `environment` object (rather than the `mcpServers` / `env` example above).
For the lightweight fallback, put `LITELLM_ADMIN_SCHEMA_MODE=discovery` in that
server's `environment`. Remote configurations support bearer `headers` and can
set `oauth: false` for this connector's API-key authentication.

Compact results and exact detail reads work independently of native tool search;
neither requires client-specific extensions or removes administrative actions.

## Host an HTTP connector

For remote clients, run the same package with Streamable HTTP:

```sh
export LITELLM_BASE_URL=https://your-gateway.example.com
export LITELLM_MCP_PUBLIC_URL=https://admin-mcp.example.com
uvx --from git+https://github.com/BerriAI/litellm-admin-mcp.git@v0.1.0 \
uvx --isolated --refresh-package litellm-admin-mcp \
--from git+https://github.com/BerriAI/litellm-admin-mcp.git@main \
litellm-admin-mcp --transport streamable-http --port 8080
```

This command loads the latest `main` on each server start. Restart the hosted
process to apply updates; connected clients can keep the same MCP URL.

Put an HTTPS reverse proxy in front of port 8080. The MCP endpoint is `/mcp` and
the process health endpoint is `/healthz`. The public URL configures accepted
Host and Origin values. One installation connects to one trusted gateway.
Expand Down Expand Up @@ -111,6 +252,9 @@ docker run --rm -p 127.0.0.1:8080:8080 \
litellm-admin-mcp
```

Docker packages the checked-out source into the image. Pull the desired revision,
rebuild the image and redeploy the container to update it.

## Using it from the LiteLLM Admin Agent

The [LiteLLM Admin Agent](https://github.com/BerriAI/litellm-admin-agent) can use
Expand Down Expand Up @@ -139,6 +283,7 @@ MCP, since endpoint availability depends on the gateway release.
uv venv --python 3.12
uv pip install -e '.[test]'
uv run --extra test pytest
uv run python scripts/benchmark_efficiency.py
uv build
docker build -t litellm-admin-mcp:smoke .
python scripts/smoke_container.py
Expand All @@ -147,6 +292,10 @@ python scripts/smoke_container.py
Tests use real MCP sessions with instrumented gateway fixtures; they do not call
an LLM or modify a production gateway. See [SECURITY.md](SECURITY.md) for the
credential boundary and [CONTRIBUTING.md](CONTRIBUTING.md) for adding tools.
The synthetic benchmark verifies exact reconstruction and reports both the first
page and the cost of retrieving every field; optional `tiktoken` installation adds
comparison token counts. It does not measure client startup context or agent
reasoning quality.

## License

Expand Down
16 changes: 16 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ to start HTTP mode with a process-wide gateway key. The gateway's live proxy-adm
role is checked for discovery, before execution and before returning results.
The caller's own key remains the execution identity and audit actor.

Large sanitized results can be retained as short-lived in-memory snapshots.
Handles are unguessable and bound to both a keyed digest of the requesting
credential and the verified principal; another admin cannot read them. Each
read checks the current role before and after access. Raw caller credentials
are not stored in the cache. Secret-returning key operations and results with
recognizable credentials bypass it and return their complete response inline.
Snapshot reads never re-execute the original operation. Expiration and capacity
limits are documented in the README; use the same server process for subsequent
reads. The existing result redaction policy is unchanged.

Full schemas remain the default, including for native client tool search. The
optional lightweight discovery mode retains individual tool names and safety
annotations. It only changes advertised parameter detail: the full live schema
still validates every call. Schema/result helpers are read-only, respect the
operation allowlist and cannot act as a generic execution dispatcher.

Read-only mode and the tool allowlist are enforced by the server, including on
direct calls to tools hidden from discovery. Model-returned provider parameters
are restricted to model and credential-name identifiers. New virtual keys are
Expand Down
76 changes: 76 additions & 0 deletions scripts/benchmark_efficiency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Synthetic response measurements; no gateway, credentials or inference calls.

Run: python scripts/benchmark_efficiency.py
Optionally install tiktoken to include o200k_base comparison counts. These are
payload measurements, not claims about startup billing or agent task quality.
"""
import json

from litellm_admin_mcp.results import ResultStore, encode, page

try:
import tiktoken
tokenizer = tiktoken.get_encoding("o200k_base")
except ImportError:
tokenizer = None


keys = [{"id": f"key-{n}", "alias": f"project-{n}", "max_budget": 100,
"spend": n / 10, "blocked": False, "expires": None,
"models": ["example-model"], "metadata": {f"setting-{i}": f"value-{n}-{i}" for i in range(30)}}
for n in range(123)]
fixtures = {
"user_with_relations": {"user_id": "example-admin", "user_info": {
"user_id": "example-admin", "user_role": "proxy_admin", "max_budget": None,
"spend": 0, "blocked": False, "metadata": {"custom_setting": "preserved"}},
"keys": keys, "teams": [{"id": f"team-{i}", "keys": keys[i:i + 8]} for i in range(14)]},
"teams_with_keys": [{"id": f"team-{i}", "max_budget": None, "spend": i,
"keys": keys[i:i + 8]} for i in range(14)],
"request_history": {"request_id": "example-request", "request": {"input": [
{"role": "user", "content": f"Message {i}: " + "Example context. " * 100} for i in range(199)]},
"response": "Example answer", "metadata": {"rare_debugging_field": False}},
}


def measure(value):
raw = encode(value)
return {"bytes": len(raw), **({"comparison_tokens": len(tokenizer.encode(raw.decode()))} if tokenizer else {})}


def reconstruct(item, token, path="", outputs=None):
outputs = outputs if outputs is not None else []
current = page(item, token, path=path)
typ = current["type"]
result = {} if typ == "object" else [] if typ == "array" else "" if typ == "string" else current["value"]
while True:
outputs.append(current)
if typ in {"object", "array"}:
for entry in current["entries"]:
value = entry["value"] if entry["complete"] else reconstruct(item, token, entry["path"], outputs)
if typ == "object":
result[entry["key"]] = value
else:
result.append(value)
elif typ == "string":
result += current["value"]
if current.get("next") is None:
return result
args = {k: v for k, v in current["next"].items() if k != "result_id"}
current = page(item, token, **args)


if __name__ == "__main__":
results = {}
for name, original in fixtures.items():
store = ResultStore()
token = store.put(name, original, "synthetic-caller", "synthetic-admin")
item = store.get(token, "synthetic-caller", "synthetic-admin")
outputs = []
assert reconstruct(item, token, outputs=outputs) == original
totals = {key: sum(measure(p)[key] for p in outputs) for key in measure(outputs[0])}
results[name] = {"original": measure(original), "first_page": measure(outputs[0]),
"complete_paged_retrieval": totals, "pages_for_every_field": len(outputs),
"exact_reconstruction": True}
print(json.dumps({"tokenizer": "o200k_base" if tokenizer else None, "synthetic_only": True,
"note": "Reading every field includes index/preview overhead; small first pages do not prove task savings.",
"results": results}, indent=2))
3 changes: 2 additions & 1 deletion src/litellm_admin_mcp/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ class Operation:
path: str
operation_id: str
summary: str
read_only_override: bool | None = None

@property
def read_only(self) -> bool:
return self.method == "GET"
return self.method == "GET" if self.read_only_override is None else self.read_only_override


OPERATIONS = tuple(Operation(**entry) for entry in json.loads(
Expand Down
Loading
Loading