mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
* test(e2e): add other suite covering master-key auth and health lifecycle Covers the other.* holding-pen cells that were uncovered: master-key valid_allows/invalid_denied on the admin /user/list gate, and the lifecycle probes liveness.ping, readiness.public_probe, readiness.reports_db_status, and readiness_details.authenticated_diagnostics. New tests/e2e/other/ suite on the shared ProxyClient; the health probes send no auth header to prove the public routes need no credential, and the details route is asserted to reject an anonymous caller while exposing version/db diagnostics to the master key. * test(e2e): cover block_code_execution and openai_moderation guardrails Extends the guardrails suite with two built-in guardrails registered per request (default_on=False, opted in via the chat body's guardrails selector) so neither intercepts unrelated traffic on the shared proxy. block_code_execution.pre_call.blocks: a python code block plus a run-this request is intercepted with the canned content-blocked message and the model never runs, while the same code block asked about with don't-run-it reaches the model. Verified live. openai_moderations.pre_call.blocks: a flagged prompt is rejected 400 naming the moderation policy while a benign prompt passes. The guardrail calls OpenAI's moderation API; verifying it needs an OpenAI key with moderation quota (this account currently 429s the moderation endpoint). Adds a shared create_backend_model helper and a generic register() plus per-request guardrails/max_tokens on the client so more built-ins can reuse the same path. * test(e2e): cover presidio PII masking (pre_call + post_call) Registers a presidio guardrail per request (default_on=False) with the analyzer/anonymizer bases supplied in the registration params, so the test controls its own dependency and needs no proxy restart. presidio.pre_call.masks: a repeat-verbatim request comes back with the <EMAIL_ADDRESS> placeholder and never the raw email, proving the prompt was anonymized before the model saw it. presidio.post_call.masks: with apply_to_output the model's own emitted email is masked on the way out, so the caller never receives the raw value. Both verified live against real presidio analyzer + anonymizer containers. logging_only is intentionally not covered: /spend/logs exposes no prompt messages to read back the masked log, and a logging_only run also masked the response, contradicting its contract; noted in the module docstring for a follow-up. * test(e2e): cover presidio logging_only masking via OTEL read-back Adds the third presidio cell, guardrail.presidio.logging_only.masks. The logging_only contract (mask what is logged, do not block) is verified by reading the request's gen-AI span back from the real OTEL destination: the span's gen_ai.input.messages attribute carries the <EMAIL_ADDRESS> placeholder, never the raw email, and the call itself is not blocked. Reads the trace via the shared OtelReader, promoted from logging/ to the suite root so both suites use it. The masked prompt is polled to a deadline because logging_only masks the payload asynchronously and the span can briefly export before the mask lands. Drops the throwaway chat_send in favor of the existing transport.send for the call-id capture. * fix(e2e): tolerate cross-pod guardrail sync delay in team-opt-out test Stage runs multiple gateway pods behind the shared key. POST /guardrails registers a new default-on guardrail in-process immediately only on the pod that served the create call; every other pod picks it up on its next periodic DB sync (proxy_server.py, every 30s), so the very next chat call can race a pod that has not synced yet. Poll to a 40s deadline instead of asserting on the first response, matching the existing pattern in test_budget_reset_advances_e2e.py. * test(e2e): cover a guardrail on the MCP tool-call path (content_filter pre_mcp_call) Adds guardrail.litellm_content_filter.pre_mcp_call.blocks: against the real Datadog MCP server, a content_filter guardrail configured mode=pre_mcp_call blocks a banned keyword in an MCP tool call's arguments with HTTP 400 attributed to the pre_mcp_call hook, and lets a clean argument reach the upstream server. The guardrail attaches with default_on because per-key/request guardrail selection is dropped from the synthetic MCP request the hook sees; the banned keyword is unique per run so default_on only intercepts this test's own call. mode must be pre_mcp_call - a pre_call config silently no-ops on tools/call because the event type is rewritten for call_mcp_tool. Drives the tool directly via /mcp-rest/tools/call for a deterministic check of the same pre_mcp_call enforcement the OpenAI-SDK chat path hits when a model invokes an MCP tool. * fix(e2e): mid-conversation messages test uses client.proxy not client.gateway EndpointsClient exposes .proxy after the Gateway->ProxyClient rename; the mid-conversation system test still referenced .gateway, which fails the e2e basedpyright gate. Aligns it with the rest of the harness. * test(e2e): address review on the guardrail coverage MCP tool-call guardrail: poll the banned call until the guardrail is enforced instead of asserting on the first call, so the control-plane -> data-plane guardrail sync cannot race the check into a false pass-through; add a repeat banned call after enforcement to guard against a partial-propagation state. OpenAI moderation: distinguish a moderation-endpoint 429 (rate limit / no moderation quota) from a guardrail failure, so an account-capability gap reads as such rather than as "did not block". Runs green with a moderation-capable key. * test(e2e): close partial-propagation false-pass in MCP guardrail block test The single post-block repeat call could be load-balanced back to the same already-synced data-plane pod, so the test could pass while another pod still lacked the guardrail and let the banned MCP call reach Datadog. Anchor a wait to the guardrail create time (every pod is guaranteed to have DB-synced only after a full ~30s sync interval), then require the banned call to stay blocked across several attempts; a pass-through after that window is a real leak, not a race. * test(e2e): drop xfail-style rate-limit branch from openai_moderation test OpenAI's /v1/moderations is free and returns 200 with the env key (verified directly), so the RateLimitedError branch mislabeled the failure: a 429 there is insufficient_quota (no account billing), not throttling. The branch also only printed a softer message before failing anyway, an xfail-in-disguise the e2e rules forbid. A 429 now falls through and fails loudly with the full result.
268 lines
8.1 KiB
Python
268 lines
8.1 KiB
Python
"""Client for the MCP e2e suite: admin server registration plus the api_key tool
|
|
surface.
|
|
|
|
An admin registers an upstream MCP server through the management API
|
|
(`/v1/mcp/server`, persisted in the DB) and grants a virtual key access to it via
|
|
`object_permission.mcp_servers`. Keys then reach the server through the REST bridge
|
|
the proxy exposes for api_key auth (`/mcp-rest/tools/list`, `/mcp-rest/tools/call`),
|
|
which `user_api_key_auth` gates the same way the JSON-RPC `/mcp` surface does. The
|
|
request/response bodies are co-located here because only this suite speaks MCP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, RootModel
|
|
|
|
from e2e_http import Headers, NoBody, Result, unwrap
|
|
from models import KeyGenerateBody, ObjectPermission
|
|
from proxy_client import ProxyClient
|
|
|
|
McpToolArg = str | int | float | bool | list[str] | dict[str, str]
|
|
McpToolArguments = Mapping[str, McpToolArg]
|
|
|
|
|
|
class ApiKeyHeaders(Headers):
|
|
x_litellm_api_key: str = Field(serialization_alias="x-litellm-api-key")
|
|
|
|
|
|
class McpServerNewBody(BaseModel):
|
|
server_name: str
|
|
alias: str
|
|
url: str
|
|
transport: str = "http"
|
|
auth_type: str | None = None
|
|
static_headers: dict[str, str] | None = None
|
|
allowed_tools: list[str] | None = None
|
|
|
|
|
|
class McpServerNewResponse(BaseModel):
|
|
server_id: str
|
|
|
|
|
|
class McpServerRow(BaseModel):
|
|
server_id: str
|
|
alias: str | None = None
|
|
url: str | None = None
|
|
|
|
|
|
class McpServersListResponse(RootModel[list[McpServerRow]]):
|
|
pass
|
|
|
|
|
|
class McpToolMcpInfo(BaseModel):
|
|
server_id: str | None = None
|
|
alias: str | None = None
|
|
|
|
|
|
class McpToolEntry(BaseModel):
|
|
name: str
|
|
description: str | None = None
|
|
mcp_info: McpToolMcpInfo | None = None
|
|
|
|
|
|
class McpToolsListResponse(BaseModel):
|
|
tools: list[McpToolEntry] = []
|
|
error: str | None = None
|
|
message: str | None = None
|
|
|
|
def tool_names_for_server(self, server_id: str) -> frozenset[str]:
|
|
return frozenset(
|
|
tool.name
|
|
for tool in self.tools
|
|
if tool.mcp_info is not None and tool.mcp_info.server_id == server_id
|
|
)
|
|
|
|
def tool_name_containing(self, server_id: str, needle: str) -> str | None:
|
|
needle_l = needle.lower()
|
|
for tool in self.tools:
|
|
if tool.mcp_info is None or tool.mcp_info.server_id != server_id:
|
|
continue
|
|
if needle_l in tool.name.lower() or tool.name.lower().endswith(needle_l):
|
|
return tool.name
|
|
return None
|
|
|
|
|
|
class BlockedWordSpec(BaseModel):
|
|
keyword: str
|
|
action: str = "BLOCK"
|
|
|
|
|
|
class ContentFilterMcpParams(BaseModel):
|
|
"""litellm_content_filter params scoped to the MCP tool-call hook. mode is
|
|
pre_mcp_call because a pre_call config silently no-ops on the tools/call path
|
|
(the event type is rewritten to pre_mcp_call for call_mcp_tool), and default_on
|
|
is required there because per-key/request guardrail selection is dropped from
|
|
the synthetic MCP request the hook sees."""
|
|
|
|
guardrail: str = "litellm_content_filter"
|
|
mode: str = "pre_mcp_call"
|
|
default_on: bool = True
|
|
blocked_words: list[BlockedWordSpec]
|
|
|
|
|
|
class GuardrailSpecBody(BaseModel):
|
|
guardrail_name: str
|
|
litellm_params: ContentFilterMcpParams
|
|
|
|
|
|
class GuardrailCreateBody(BaseModel):
|
|
guardrail: GuardrailSpecBody
|
|
|
|
|
|
class GuardrailCreateResponse(BaseModel):
|
|
guardrail_id: str
|
|
|
|
|
|
class McpCallToolBody(BaseModel):
|
|
name: str
|
|
arguments: dict[str, McpToolArg]
|
|
server_id: str
|
|
|
|
|
|
class McpCallContent(BaseModel):
|
|
type: str | None = None
|
|
text: str | None = None
|
|
|
|
|
|
class McpCallToolResponse(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
content: list[McpCallContent] = []
|
|
is_error: bool | None = Field(default=None, alias="isError")
|
|
|
|
@property
|
|
def first_text(self) -> str | None:
|
|
return self.content[0].text if self.content else None
|
|
|
|
@property
|
|
def all_text(self) -> str:
|
|
return "\n".join(part.text for part in self.content if part.text)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class McpClient:
|
|
proxy: ProxyClient
|
|
|
|
def register_server(
|
|
self,
|
|
*,
|
|
server_name: str,
|
|
alias: str,
|
|
url: str,
|
|
transport: str = "http",
|
|
auth_type: str | None = None,
|
|
static_headers: dict[str, str] | None = None,
|
|
allowed_tools: list[str] | None = None,
|
|
) -> str:
|
|
return unwrap(
|
|
self.proxy.transport.post(
|
|
"/v1/mcp/server",
|
|
headers=self.proxy.transport.master,
|
|
json=McpServerNewBody(
|
|
server_name=server_name,
|
|
alias=alias,
|
|
url=url,
|
|
transport=transport,
|
|
auth_type=auth_type,
|
|
static_headers=static_headers,
|
|
allowed_tools=allowed_tools,
|
|
),
|
|
response_type=McpServerNewResponse,
|
|
)
|
|
).server_id
|
|
|
|
def delete_server(self, server_id: str) -> None:
|
|
_ = self.proxy.transport.delete(
|
|
f"/v1/mcp/server/{server_id}",
|
|
headers=self.proxy.transport.master,
|
|
json=NoBody(),
|
|
response_type=NoBody,
|
|
)
|
|
|
|
def registered_servers(self) -> list[McpServerRow]:
|
|
return unwrap(
|
|
self.proxy.transport.get(
|
|
"/v1/mcp/server",
|
|
headers=self.proxy.transport.master,
|
|
params=NoBody(),
|
|
response_type=McpServersListResponse,
|
|
)
|
|
).root
|
|
|
|
def generate_key(
|
|
self,
|
|
*,
|
|
user_id: str,
|
|
mcp_servers: list[str] | None,
|
|
models: list[str] | None = None,
|
|
) -> str:
|
|
object_permission = (
|
|
ObjectPermission(mcp_servers=mcp_servers) if mcp_servers is not None else None
|
|
)
|
|
return self.proxy.generate_key(
|
|
KeyGenerateBody(
|
|
models=models if models is not None else [],
|
|
user_id=user_id,
|
|
object_permission=object_permission,
|
|
)
|
|
)
|
|
|
|
def list_tools(self, key: str) -> Result[McpToolsListResponse]:
|
|
return self.proxy.transport.get(
|
|
"/mcp-rest/tools/list",
|
|
headers=ApiKeyHeaders(x_litellm_api_key=key),
|
|
params=NoBody(),
|
|
response_type=McpToolsListResponse,
|
|
)
|
|
|
|
def register_mcp_content_filter(self, *, name: str, blocked_keyword: str) -> str:
|
|
"""Register a default-on content-filter guardrail that runs on the MCP
|
|
tool-call hook (pre_mcp_call) and blocks a single keyword. The keyword is
|
|
unique per test, so default_on only ever intercepts this test's own
|
|
banned tool call on the shared proxy."""
|
|
return unwrap(
|
|
self.proxy.transport.post(
|
|
"/guardrails",
|
|
headers=self.proxy.transport.master,
|
|
json=GuardrailCreateBody(
|
|
guardrail=GuardrailSpecBody(
|
|
guardrail_name=name,
|
|
litellm_params=ContentFilterMcpParams(
|
|
blocked_words=[BlockedWordSpec(keyword=blocked_keyword)],
|
|
),
|
|
)
|
|
),
|
|
response_type=GuardrailCreateResponse,
|
|
)
|
|
).guardrail_id
|
|
|
|
def delete_guardrail(self, guardrail_id: str) -> None:
|
|
_ = self.proxy.transport.delete(
|
|
f"/guardrails/{guardrail_id}",
|
|
headers=self.proxy.transport.master,
|
|
json=NoBody(),
|
|
response_type=NoBody,
|
|
)
|
|
|
|
def call_tool(
|
|
self,
|
|
key: str,
|
|
*,
|
|
server_id: str,
|
|
name: str,
|
|
arguments: McpToolArguments,
|
|
) -> Result[McpCallToolResponse]:
|
|
return self.proxy.transport.post(
|
|
"/mcp-rest/tools/call",
|
|
headers=ApiKeyHeaders(x_litellm_api_key=key),
|
|
json=McpCallToolBody(
|
|
name=name, arguments=dict(arguments), server_id=server_id
|
|
),
|
|
response_type=McpCallToolResponse,
|
|
)
|
|
|
|
|
|
def build_client(proxy: ProxyClient) -> McpClient:
|
|
return McpClient(proxy=proxy)
|