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
3 changes: 3 additions & 0 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2650,6 +2650,9 @@ async def pre_call_tool_check(
"name": name,
"arguments": arguments,
"server_name": server_name,
"mcp_rate_limit_server_name": server.alias
or server.server_name
or server.name,
"user_api_key_auth": user_api_key_auth,
"user_api_key_user_id": (
getattr(user_api_key_auth, "user_id", None)
Expand Down
4 changes: 4 additions & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
model_config = ConfigDict(protected_namespaces=())
model_rpm_limit: Optional[dict] = None
model_tpm_limit: Optional[dict] = None
mcp_rpm_limit: Optional[Dict[str, int]] = None
guardrails: Optional[List[str]] = None
policies: Optional[List[str]] = None
prompts: Optional[List[str]] = None
Expand Down Expand Up @@ -1851,6 +1852,7 @@ class NewTeamRequest(TeamBase):
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm

model_tpm_limit: Optional[Dict[str, int]] = None
mcp_rpm_limit: Optional[Dict[str, int]] = None
team_member_budget: Optional[float] = (
None # allow user to set a budget for all team members
)
Expand Down Expand Up @@ -1920,6 +1922,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
prompts: Optional[List[str]] = None
model_rpm_limit: Optional[Dict[str, int]] = None
model_tpm_limit: Optional[Dict[str, int]] = None
mcp_rpm_limit: Optional[Dict[str, int]] = None
allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None
enforced_batch_output_expires_after: Optional[dict] = None
enforced_file_expires_after: Optional[dict] = None
Expand Down Expand Up @@ -4285,6 +4288,7 @@ class PassThroughEndpointLoggingTypedDict(TypedDict):
LiteLLM_ManagementEndpoint_MetadataFields = [
"model_rpm_limit",
"model_tpm_limit",
"mcp_rpm_limit",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: MCP rate limit bypass through key metadata updates

mcp_rpm_limit is now treated as generic key metadata, and /key/update preserves that path for key owners and delegated team members on non-budget fields. A caller with key-update access can set their key's mcp_rpm_limit to {} or a higher value and remove the admin-configured per-server key cap that get_key_mcp_rpm_limit() enforces; make this field admin-only/immutable on key updates or preserve the existing value unless the caller has key administration rights.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This finding flags intended, pre-existing behavior rather than a regression introduced here. mcp_rpm_limit is plumbed exactly like the existing per-key rate-limit metadata fields model_rpm_limit and model_tpm_limit: same LiteLLM_ManagementEndpoint_MetadataFields hoisting, same get_key_* key-over-team resolution. It is not handled any differently from those fields on /key/update.

The /key/update authorization model is deliberate (see _validate_update_key_data, the GHSA-q775-qw9r-2r4g hardening). A non-admin key owner or an authorized team member may update non-budget fields on their own key; only max_budget and spend are gated behind the admin check. Under that model, rpm_limit, tpm_limit, model_rpm_limit, and model_tpm_limit are already self-modifiable by the key owner today. mcp_rpm_limit inherits the same property, so it does not open a new bypass.

Making just this one field admin-only or immutable would be inconsistent with every other per-key rate limit, was not part of this feature's scope, and would not actually change the threat model since the same caller could still raise their own rpm_limit / model_rpm_limit. Budget and spend remain the hard, admin-only controls. If per-key rate limits should become admin-immutable, that is a separate, system-wide design change covering all rate-limit fields, not something specific to mcp_rpm_limit.

"rpm_limit_type",
"tpm_limit_type",
"enforced_params",
Expand Down
34 changes: 34 additions & 0 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,40 @@ def get_team_model_tpm_limit(
return None


def get_key_mcp_rpm_limit(
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[Dict[str, int]]:
"""
Get the per-MCP-server rpm limit for a given api key.

Priority order (returns first found):
1. Key metadata (mcp_rpm_limit)
2. Team metadata (mcp_rpm_limit)

The returned dict is keyed by MCP server name (alias if set, else the
configured server name).
"""
if user_api_key_dict.metadata:
result = user_api_key_dict.metadata.get("mcp_rpm_limit")
if result is not None:
return result

if user_api_key_dict.team_metadata:
team_limit = user_api_key_dict.team_metadata.get("mcp_rpm_limit")
if team_limit is not None:
return team_limit
Comment thread
Sameerlite marked this conversation as resolved.

return None


def get_team_mcp_rpm_limit(
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[Dict[str, int]]:
if user_api_key_dict.team_metadata:
Comment thread
Sameerlite marked this conversation as resolved.
return user_api_key_dict.team_metadata.get("mcp_rpm_limit")
return None


def get_project_model_rpm_limit(
user_api_key_dict: UserAPIKeyAuth,
) -> Optional[Dict[str, int]]:
Expand Down
92 changes: 91 additions & 1 deletion litellm/proxy/hooks/parallel_request_limiter_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
from litellm.proxy.auth.auth_utils import get_model_rate_limit_from_metadata
from litellm.types.caching import RedisPipelineIncrementOperation
from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject
from litellm.types.utils import ModelResponse, Usage
from litellm.types.utils import CallTypes, ModelResponse, Usage

if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
Expand Down Expand Up @@ -1375,6 +1375,79 @@ def _add_model_per_key_rate_limit_descriptor(
)
)

def _add_mcp_per_key_rate_limit_descriptor(
self,
user_api_key_dict: UserAPIKeyAuth,
mcp_server_name: Optional[str],
descriptors: List[RateLimitDescriptor],
) -> None:
"""
Add a per-MCP-server rpm descriptor for the API key, if a limit is
configured for the server being called.

MCP tool calls have no token usage, so only requests_per_unit is set;
tokens_per_unit stays None so the TPM reservation path is never engaged.
"""
from litellm.proxy.auth.auth_utils import get_key_mcp_rpm_limit

if not mcp_server_name or not user_api_key_dict.api_key:
return

mcp_rpm_limit = get_key_mcp_rpm_limit(user_api_key_dict)
if not mcp_rpm_limit:
return

server_rpm_limit = mcp_rpm_limit.get(mcp_server_name)
if server_rpm_limit is None:
return

descriptors.append(
RateLimitDescriptor(
key="mcp_per_key",
value=f"{user_api_key_dict.api_key}:{mcp_server_name}",
rate_limit={
"requests_per_unit": server_rpm_limit,
"tokens_per_unit": None,
"window_size": self.window_size,
},
)
)

def _add_mcp_per_team_rate_limit_descriptor(
self,
user_api_key_dict: UserAPIKeyAuth,
mcp_server_name: Optional[str],
descriptors: List[RateLimitDescriptor],
) -> None:
"""
Add a per-MCP-server rpm descriptor for the team, if a limit is
configured for the server being called.
"""
from litellm.proxy.auth.auth_utils import get_team_mcp_rpm_limit

if not mcp_server_name or not user_api_key_dict.team_id:
return

mcp_rpm_limit = get_team_mcp_rpm_limit(user_api_key_dict)
if not mcp_rpm_limit:
return

server_rpm_limit = mcp_rpm_limit.get(mcp_server_name)
if server_rpm_limit is None:
return

descriptors.append(
RateLimitDescriptor(
key="mcp_per_team",
value=f"{user_api_key_dict.team_id}:{mcp_server_name}",
rate_limit={
"requests_per_unit": server_rpm_limit,
"tokens_per_unit": None,
"window_size": self.window_size,
},
)
)

def _should_enforce_rate_limit(
self,
limit_type: Optional[str],
Expand Down Expand Up @@ -1533,6 +1606,7 @@ def _create_rate_limit_descriptors(
rpm_limit_type: Optional[str],
tpm_limit_type: Optional[str],
model_has_failures: bool,
call_type: Optional[str] = None,
) -> List[RateLimitDescriptor]:
"""
Create all rate limit descriptors for the request.
Expand Down Expand Up @@ -1653,6 +1727,21 @@ def _create_rate_limit_descriptors(
descriptors=descriptors,
)

# REST MCP calls pass the raw body through this hook before server
# resolution; only the later synthetic hook payload may carry this key.
if call_type == CallTypes.call_mcp_tool.value and "server_id" not in data:
mcp_server_name = data.get("mcp_server_name", None)
self._add_mcp_per_key_rate_limit_descriptor(
user_api_key_dict=user_api_key_dict,
mcp_server_name=mcp_server_name,
descriptors=descriptors,
)
self._add_mcp_per_team_rate_limit_descriptor(
user_api_key_dict=user_api_key_dict,
mcp_server_name=mcp_server_name,
descriptors=descriptors,
)
Comment thread
Sameerlite marked this conversation as resolved.

if (
get_team_model_rpm_limit(user_api_key_dict) is not None
or get_team_model_tpm_limit(user_api_key_dict) is not None
Expand Down Expand Up @@ -1983,6 +2072,7 @@ async def async_pre_call_hook(
rpm_limit_type=rpm_limit_type,
tpm_limit_type=tpm_limit_type,
model_has_failures=model_has_failures,
call_type=call_type,
)

# Add team model rate limits from team_metadata
Expand Down
2 changes: 2 additions & 0 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ async def new_user(
- soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
- model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- mcp_rpm_limit: Optional[dict] - Per-MCP-server rpm limit, keyed by MCP server name {"github": 100, "slack": 200}. Enforced for keys and teams only; values set on a user are stored but not enforced per user.
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
- agent_id: Optional[str] - The agent id associated with the user.
Expand Down Expand Up @@ -1427,6 +1428,7 @@ async def user_update(
- soft_budget: Optional[float] - Get alerts when user crosses given budget, doesn't block requests.
- model_max_budget: Optional[dict] - Model-specific max budget for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-budgets-to-keys)
- model_rpm_limit: Optional[float] - Model-specific rpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- mcp_rpm_limit: Optional[dict] - Per-MCP-server rpm limit, keyed by MCP server name {"github": 100, "slack": 200}. Enforced for keys and teams only; values set on a user are stored but not enforced per user.
- model_tpm_limit: Optional[float] - Model-specific tpm limit for user. [Docs](https://docs.litellm.ai/docs/proxy/users#add-model-specific-limits-to-keys)
- spend: Optional[float] - Amount spent by user. Default is 0. Will be updated by proxy whenever user is used. You can set duration as seconds ("30s"), minutes ("30m"), hours ("30h"), days ("30d"), months ("1mo").
- agent_id: Optional[str] - The agent id associated with the user.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,7 @@ async def generate_key_fn(
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
- model_rpm_limit: Optional[dict] - key-specific model rpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific rpm limit.
- model_tpm_limit: Optional[dict] - key-specific model tpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific tpm limit.
- mcp_rpm_limit: Optional[dict] - key-specific per-MCP-server rpm limit, keyed by MCP server name (alias if set, else the configured name). Example - {"github": 100, "slack": 200}. IF null or {} then no MCP-specific rpm limit.
- tpm_limit_type: Optional[str] - Type of tpm limit. Options: "best_effort_throughput" (no error if we're overallocating tpm), "guaranteed_throughput" (raise an error if we're overallocating tpm), "dynamic" (dynamically exceed limit when no 429 errors). Defaults to "best_effort_throughput".
- rpm_limit_type: Optional[str] - Type of rpm limit. Options: "best_effort_throughput" (no error if we're overallocating rpm), "guaranteed_throughput" (raise an error if we're overallocating rpm), "dynamic" (dynamically exceed limit when no 429 errors). Defaults to "best_effort_throughput".
- allowed_cache_controls: Optional[list] - List of allowed cache control values. Example - ["no-cache", "no-store"]. See all values - https://docs.litellm.ai/docs/proxy/caching#turn-on--off-caching-per-request
Expand Down Expand Up @@ -1606,6 +1607,7 @@ async def generate_service_account_key_fn(
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
- model_rpm_limit: Optional[dict] - key-specific model rpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific rpm limit.
- model_tpm_limit: Optional[dict] - key-specific model tpm limit. Example - {"text-davinci-002": 1000, "gpt-3.5-turbo": 1000}. IF null or {} then no model specific tpm limit.
- mcp_rpm_limit: Optional[dict] - key-specific per-MCP-server rpm limit, keyed by MCP server name (alias if set, else the configured name). Example - {"github": 100, "slack": 200}. IF null or {} then no MCP-specific rpm limit.
- tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
- allowed_cache_controls: Optional[list] - List of allowed cache control values. Example - ["no-cache", "no-store"]. See all values - https://docs.litellm.ai/docs/proxy/caching#turn-on--off-caching-per-request
Expand Down Expand Up @@ -2422,6 +2424,7 @@ async def update_key_fn( # noqa: PLR0915
- tpm_limit: Optional[int] - Tokens per minute limit
- rpm_limit: Optional[int] - Requests per minute limit
- model_rpm_limit: Optional[dict] - Model-specific RPM limits {"gpt-4": 100, "claude-v1": 200}
- mcp_rpm_limit: Optional[dict] - Per-MCP-server RPM limits, keyed by MCP server name {"github": 100, "slack": 200}
- model_tpm_limit: Optional[dict] - Model-specific TPM limits {"gpt-4": 100000, "claude-v1": 200000}
- tpm_limit_type: Optional[str] - TPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
- rpm_limit_type: Optional[str] - RPM rate limit type - "best_effort_throughput", "guaranteed_throughput", or "dynamic"
Expand Down Expand Up @@ -3401,6 +3404,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
model_max_budget: Optional[dict] = {},
model_rpm_limit: Optional[dict] = None,
model_tpm_limit: Optional[dict] = None,
mcp_rpm_limit: Optional[dict] = None,
guardrails: Optional[list] = None,
policies: Optional[list] = None,
prompts: Optional[list] = None,
Expand Down Expand Up @@ -3479,6 +3483,9 @@ async def generate_key_helper_fn( # noqa: PLR0915
if model_tpm_limit is not None:
metadata = metadata or {}
metadata["model_tpm_limit"] = model_tpm_limit
if mcp_rpm_limit is not None:
metadata = metadata or {}
metadata["mcp_rpm_limit"] = mcp_rpm_limit
if guardrails is not None:
metadata = metadata or {}
metadata["guardrails"] = guardrails
Expand Down
3 changes: 2 additions & 1 deletion litellm/proxy/management_endpoints/team_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -863,8 +863,9 @@ async def new_team( # noqa: PLR0915
- members_with_roles: List[{"role": "admin" or "user", "user_id": "<user-id>"}] - A list of users and their roles in the team. Get user_id when making a new user via `/user/new`.
- team_member_permissions: Optional[List[str]] - A list of routes that non-admin team members can access. example: ["/key/generate", "/key/update", "/key/delete"]
- metadata: Optional[dict] - Metadata for team, store information for team. Example metadata = {"extra_info": "some info"}
- model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit for this team - applied across all keys for this team.
- model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit for this team - applied across all keys for this team.
- model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit for this team - applied across all keys for this team.
- mcp_rpm_limit: Optional[Dict[str, int]] - Per-MCP-server RPM limit for this team, keyed by MCP server name (alias if set, else the configured name). Example: {"github": 100, "slack": 200}. Applied across all keys for this team.
- tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for this team - all keys with this team_id will have at max this TPM limit
- rpm_limit: Optional[int] - The RPM (Requests Per Minute) limit for this team - all keys associated with this team_id will have at max this RPM limit
- rpm_limit_type: Optional[Literal["guaranteed_throughput", "best_effort_throughput"]] - The type of RPM limit enforcement. Use "guaranteed_throughput" to raise an error if overallocating RPM, or "best_effort_throughput" for best effort enforcement.
Expand Down
1 change: 1 addition & 0 deletions litellm/proxy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,7 @@ def _convert_mcp_to_llm_format(self, request_obj, kwargs: dict) -> dict:
"user_api_key_request_route": kwargs.get("user_api_key_request_route"),
"mcp_tool_name": request_obj.tool_name, # Keep original for reference
"mcp_arguments": request_obj.arguments, # Keep original for reference
"mcp_server_name": kwargs.get("mcp_rate_limit_server_name"),
# Raw Bearer token from the original HTTP request — allows guardrails
# (e.g. MCPJWTSigner) to independently verify the caller's identity
# before re-signing an outbound token (FR-5 verify+re-sign).
Expand Down
Loading
Loading