diff --git a/litellm/proxy/_experimental/mcp_server/openapi_oauth2_endpoints.py b/litellm/proxy/_experimental/mcp_server/openapi_oauth2_endpoints.py index 1e8d7c138c1..b39093d465e 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_oauth2_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_oauth2_endpoints.py @@ -202,9 +202,15 @@ async def openapi_oauth2_connect( detail="Database is not configured. Cannot initiate OAuth2 flow.", ) - user_id = user_api_key_dict.user_id or user_api_key_dict.api_key or "" + # Require an explicit user_id so that credentials are scoped to users, not API keys. + # Falling back to the raw api_key would allow multiple users sharing a key to + # silently share OAuth2 credentials and orphan them when the key is rotated. + user_id = user_api_key_dict.user_id or "" if not user_id: - raise HTTPException(status_code=400, detail="Cannot determine user identity from token") + raise HTTPException( + status_code=400, + detail="user_id is required for OAuth2 flow. Ensure your API key is tied to a user.", + ) _purge_expired_states() if len(_pending_oauth2_states) >= _STATES_MAX_SIZE: @@ -379,7 +385,7 @@ async def openapi_oauth2_callback( # noqa: PLR0915 headers=token_headers, timeout=30.0, ) - response.raise_for_status() + response.raise_for_status() except httpx.HTTPStatusError as exc: verbose_proxy_logger.error( "openapi_oauth2_callback: token exchange HTTP error user=%s server=%s status=%s", @@ -555,7 +561,8 @@ async def openapi_oauth2_status( if server is None: raise HTTPException(status_code=404, detail=f"MCP server '{server_id}' not found") - user_id = user_api_key_dict.user_id or user_api_key_dict.api_key or "" + # Use only user_id (not api_key) to avoid sharing credentials across users. + user_id = user_api_key_dict.user_id or "" server_name = server.server_name or server.name or server_id if prisma_client is None or not user_id: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 2f2f84fef4c..cdabc653f83 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -9,6 +9,7 @@ import json import time import traceback import uuid +from collections import OrderedDict from datetime import datetime from typing import ( Any, @@ -67,7 +68,7 @@ from litellm.utils import Rules, client, function_setup # a brief 401 window (up to _BYOK_CRED_CACHE_TTL seconds) after a token expires at the # provider before the cache entry is evicted and a fresh DB lookup is made. Mitigating # this (e.g. by storing and checking `expires_in`) is a future improvement. -_byok_cred_cache: Dict[Tuple[str, str], Tuple[Optional[str], float]] = {} +_byok_cred_cache: OrderedDict[Tuple[str, str], Tuple[Optional[str], float]] = OrderedDict() _BYOK_CRED_CACHE_TTL = 60 # seconds _BYOK_CRED_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth @@ -103,17 +104,19 @@ def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None: def _write_byok_cred_cache( user_id: str, server_id: str, credential: Optional[str] ) -> None: - """Write a credential value to the cache, evicting the oldest entry if at capacity. + """Write a credential value to the cache with LRU eviction. - Evicts the oldest-inserted entry (FIFO) rather than clearing all at once to - avoid a thundering-herd DB spike when the cache fills under load. + Uses OrderedDict.move_to_end() so that every write (new or update) moves + the entry to the most-recently-used position. When at capacity, the + least-recently-used (oldest) entry at the front is evicted. """ cache_key = (user_id, server_id) - # Only evict when the key is new — updates to existing entries don't grow the cache. - if cache_key not in _byok_cred_cache and len(_byok_cred_cache) >= _BYOK_CRED_CACHE_MAX_SIZE: - oldest_key = next(iter(_byok_cred_cache)) - del _byok_cred_cache[oldest_key] + is_new = cache_key not in _byok_cred_cache + if is_new and len(_byok_cred_cache) >= _BYOK_CRED_CACHE_MAX_SIZE: + _byok_cred_cache.popitem(last=False) # evict LRU (front of OrderedDict) _byok_cred_cache[cache_key] = (credential, time.monotonic()) + if not is_new: + _byok_cred_cache.move_to_end(cache_key) # promote to most-recently-used # Check if MCP is available # "mcp" requires python 3.10 or higher, but several litellm users use python 3.8