Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@
os.getenv("MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH", 150)
)

# MCP OAuth2 Client Credentials Defaults
MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS = 60
MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE = 200
MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL = 3600
Comment thread
ishaan-jaff marked this conversation as resolved.
Outdated

LITELLM_UI_ALLOW_HEADERS = [
"x-litellm-semantic-filter",
"x-litellm-semantic-filter-tools",
Expand Down
44 changes: 27 additions & 17 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import resolve_mcp_auth
from litellm.proxy._experimental.mcp_server.utils import (
MCP_TOOL_PREFIX_SEPARATOR,
add_server_prefix_to_name,
Expand Down Expand Up @@ -833,7 +834,7 @@ def _build_stdio_env(

return resolved_env

def _create_mcp_client(
async def _create_mcp_client(
self,
server: MCPServer,
mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None,
Expand All @@ -843,13 +844,22 @@ def _create_mcp_client(
"""
Create an MCPClient instance for the given server.

Auth resolution (single place for all auth logic):
1. ``mcp_auth_header`` — per-request/per-user override
2. OAuth2 client_credentials token — auto-fetched and cached
3. ``server.authentication_token`` — static token from config/DB

Args:
server (MCPServer): The server configuration
mcp_auth_header: MCP auth header to be passed to the MCP server. This is optional and will be used if provided.
server: The server configuration.
mcp_auth_header: Optional per-request auth override.
extra_headers: Additional headers to forward.
stdio_env: Environment variables for stdio transport.

Returns:
MCPClient: Configured MCP client instance
Configured MCP client instance.
"""
auth_value = await resolve_mcp_auth(server, mcp_auth_header)

transport = server.transport or MCPTransport.sse

# Handle stdio transport
Expand All @@ -868,7 +878,7 @@ def _create_mcp_client(
server_url="", # Not used for stdio
transport_type=transport,
auth_type=server.auth_type,
auth_value=mcp_auth_header or server.authentication_token,
auth_value=auth_value,
timeout=60.0,
stdio_config=stdio_config,
extra_headers=extra_headers,
Expand All @@ -880,7 +890,7 @@ def _create_mcp_client(
server_url=server_url,
transport_type=transport,
auth_type=server.auth_type,
auth_value=mcp_auth_header or server.authentication_token,
auth_value=auth_value,
timeout=60.0,
extra_headers=extra_headers,
)
Expand Down Expand Up @@ -920,7 +930,7 @@ async def _get_tools_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -980,7 +990,7 @@ async def get_prompts_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -1024,7 +1034,7 @@ async def get_resources_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -1068,7 +1078,7 @@ async def get_resource_templates_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -1109,7 +1119,7 @@ async def read_resource_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -1139,7 +1149,7 @@ async def get_prompt_from_server(

stdio_env = self._build_stdio_env(server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=mcp_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -1943,7 +1953,7 @@ async def _call_regular_mcp_tool(

stdio_env = self._build_stdio_env(mcp_server, raw_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=mcp_server,
mcp_auth_header=server_auth_header,
extra_headers=extra_headers,
Expand Down Expand Up @@ -2119,8 +2129,8 @@ async def _initialize_tool_name_to_mcp_server_name_mapping(self):
Note: This now handles prefixed tool names
"""
for server in self.get_registry().values():
if server.auth_type == MCPAuth.oauth2:
# Skip OAuth2 servers for now as they may require user-specific tokens
if server.needs_user_oauth_token:
# Skip OAuth2 servers that rely on user-provided tokens
continue
tools = await self._get_tools_from_server(server)
for tool in tools:
Expand Down Expand Up @@ -2414,7 +2424,7 @@ async def health_check_server(
should_skip_health_check = False

# Skip if auth_type is oauth2
if server.auth_type == MCPAuth.oauth2:
if server.needs_user_oauth_token:
should_skip_health_check = True
# Skip if auth_type is not none and authentication_token is missing
elif (
Expand All @@ -2429,7 +2439,7 @@ async def health_check_server(
if server.static_headers:
extra_headers.update(server.static_headers)

client = self._create_mcp_client(
client = await self._create_mcp_client(
server=server,
mcp_auth_header=None,
extra_headers=extra_headers,
Expand Down
136 changes: 136 additions & 0 deletions litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""
OAuth2 client_credentials token cache for MCP servers.

Automatically fetches and refreshes access tokens for MCP servers configured
with ``client_id``, ``client_secret``, and ``token_url``.
"""

import asyncio
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union

from litellm._logging import verbose_logger
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import (
MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL,
MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE,
MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider

if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer


class MCPOAuth2TokenCache(InMemoryCache):
"""
In-memory cache for OAuth2 client_credentials tokens, keyed by server_id.

Inherits from ``InMemoryCache`` for TTL-based storage and eviction.
Adds per-server ``asyncio.Lock`` to prevent duplicate concurrent fetches.
"""

def __init__(self) -> None:
super().__init__(
max_size_in_memory=MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE,
default_ttl=MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL,
)
self._locks: Dict[str, asyncio.Lock] = {}

def _get_lock(self, server_id: str) -> asyncio.Lock:
if server_id not in self._locks:
self._locks[server_id] = asyncio.Lock()
return self._locks[server_id]

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.

Non-atomic lock creation

_get_lock() mutates self._locks with a if server_id not in self._locks: self._locks[server_id] = asyncio.Lock() check. Under concurrent calls, this check/insert can interleave and create multiple locks for the same server_id, which defeats the “single in-flight fetch per server” guarantee and can lead to duplicate token fetches. Consider making lock creation atomic (e.g., using setdefault) or initializing locks in a thread-safe way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — _get_lock() now uses self._locks.setdefault(server_id, asyncio.Lock()) which is atomic and avoids creating duplicate locks under concurrent calls.


async def async_get_token(self, server: "MCPServer") -> Optional[str]:
"""Return a valid access token, fetching or refreshing as needed.

Returns ``None`` when the server lacks client credentials config.
"""
if not server.has_client_credentials:
return None

server_id = server.server_id

# Fast path — cached token is still valid
cached = self.get_cache(server_id)
if cached is not None:
return cached

# Slow path — acquire per-server lock then double-check
async with self._get_lock(server_id):
cached = self.get_cache(server_id)
if cached is not None:
return cached

token, ttl = await self._fetch_token(server)
self.set_cache(server_id, token, ttl=ttl)
return token

async def _fetch_token(self, server: "MCPServer") -> Tuple[str, int]:
"""POST to ``token_url`` with ``grant_type=client_credentials``.

Returns ``(access_token, ttl_seconds)`` where ttl accounts for the
expiry buffer so the cache entry expires before the real token does.
"""
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)

data: Dict[str, str] = {
"grant_type": "client_credentials",
"client_id": server.client_id, # type: ignore[arg-type]
"client_secret": server.client_secret, # type: ignore[arg-type]
}
if server.scopes:
data["scope"] = " ".join(server.scopes)

verbose_logger.debug(
"Fetching OAuth2 client_credentials token for MCP server %s from %s",
server.server_id,
server.token_url,
)
Comment thread
ishaan-jaff marked this conversation as resolved.

response = await client.post(server.token_url, data=data) # type: ignore[arg-type]
Comment thread
ishaan-jaff marked this conversation as resolved.
Outdated
response.raise_for_status()
body = response.json()
Comment thread
ishaan-jaff marked this conversation as resolved.
Outdated

access_token = body.get("access_token")
if not access_token:
raise ValueError(
Comment on lines +109 to +119

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.

JSON shape assumptions

body = response.json() is assumed to be a dict (body.get(...)). If the token endpoint returns a non-object JSON payload (e.g., a list or string on some error paths), this will raise AttributeError and surface as an unexpected 500 rather than a clearer OAuth/token error. Adding an explicit isinstance(body, dict) check before accessing .get() would make failures deterministic.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — added isinstance(body, dict) check after response.json(). Non-dict responses now raise a clear ValueError with the type name instead of an unexpected AttributeError.

f"OAuth2 token response for MCP server '{server.server_id}' "
f"missing 'access_token': {body}"
)
Comment thread
ishaan-jaff marked this conversation as resolved.

expires_in = int(body.get("expires_in", 3600))
ttl = max(expires_in - MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, 0)

Comment thread
ishaan-jaff marked this conversation as resolved.
Outdated
verbose_logger.info(
"Fetched OAuth2 token for MCP server %s (expires in %ds)",
server.server_id,
expires_in,
)
return access_token, ttl

def invalidate(self, server_id: str) -> None:
"""Remove a cached token (e.g. after a 401)."""
self.delete_cache(server_id)


mcp_oauth2_token_cache = MCPOAuth2TokenCache()


async def resolve_mcp_auth(
server: "MCPServer",
mcp_auth_header: Optional[Union[str, Dict[str, str]]] = None,
) -> Optional[Union[str, Dict[str, str]]]:
"""Resolve the auth value for an MCP server.

Priority:
1. ``mcp_auth_header`` — per-request/per-user override
2. OAuth2 client_credentials token — auto-fetched and cached
3. ``server.authentication_token`` — static token from config/DB
"""
if mcp_auth_header:
return mcp_auth_header
if server.has_client_credentials:
return await mcp_oauth2_token_cache.async_get_token(server)
return server.authentication_token
11 changes: 11 additions & 0 deletions litellm/types/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import BaseModel, ConfigDict

from litellm.proxy._types import MCPAuthType, MCPTransportType
from litellm.types.mcp import MCPAuth

# MCPInfo now allows arbitrary additional fields for custom metadata
MCPInfo = Dict[str, Any]
Expand Down Expand Up @@ -54,3 +55,13 @@ class MCPServer(BaseModel):
available_on_public_internet: bool = False
updated_at: Optional[datetime] = None
model_config = ConfigDict(arbitrary_types_allowed=True)

@property
def has_client_credentials(self) -> bool:
"""True if this server has OAuth2 client_credentials config (client_id, client_secret, token_url)."""
return bool(self.client_id and self.client_secret and self.token_url)

@property
def needs_user_oauth_token(self) -> bool:
"""True if this is an OAuth2 server that relies on per-user tokens (no client_credentials)."""
return self.auth_type == MCPAuth.oauth2 and not self.has_client_credentials
Comment thread
ishaan-jaff marked this conversation as resolved.
Comment on lines +60 to +67

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.

OAuth2 detection too narrow

needs_user_oauth_token only returns true when auth_type == MCPAuth.oauth2, but auth_type is typed as Optional[MCPAuthType] (from litellm.proxy._types). If callers populate auth_type with the string literal (e.g., 'oauth2') rather than the MCPAuth enum instance, this check will be false and OAuth2 servers could incorrectly not be skipped for tool discovery / health checks. Consider normalizing/comparing against the same representation MCPAuthType uses (string literal) or casting auth_type to MCPAuth consistently at model creation.

Loading