Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ LiteLLM is a unified interface for 100+ LLM providers with two main components:
- Pydantic v2 for data validation
- Async/await patterns throughout
- Type hints required for all public APIs
- **Avoid imports within methods** — place all imports at the top of the file (module-level). Inline imports inside functions/methods make dependencies harder to trace and hurt readability. The only exception is avoiding circular imports where absolutely necessary.

### Testing Strategy
- Unit tests in `tests/test_litellm/`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def __init__(
oauth2_headers: Optional[Dict[str, str]] = None,
mcp_protocol_version: Optional[str] = None,
raw_headers: Optional[Dict[str, str]] = None,
client_ip: Optional[str] = None,
):
self.user_api_key_auth = user_api_key_auth
self.mcp_auth_header = mcp_auth_header
Expand All @@ -35,3 +36,4 @@ def __init__(
self.mcp_protocol_version = mcp_protocol_version
self.oauth2_headers = oauth2_headers
self.raw_headers = raw_headers
self.client_ip = client_ip
111 changes: 98 additions & 13 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
MCPTransportType,
UserAPIKeyAuth,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.utils import ProxyLogging
from litellm.types.llms.custom_http import httpxSpecialProvider
Expand All @@ -65,20 +66,22 @@
from litellm.types.utils import CallTypes

try:
from mcp.shared.tool_name_validation import ( # type: ignore
from mcp.shared.tool_name_validation import (
validate_tool_name, # pyright: ignore[reportAssignmentType]
)
from mcp.shared.tool_name_validation import (
SEP_986_URL,
validate_tool_name,
)
except ImportError:
from pydantic import BaseModel
SEP_986_URL = "https://github.com/modelcontextprotocol/protocol/blob/main/proposals/0001-tool-name-validation.md"

class ToolNameValidationResult(BaseModel):
class _ToolNameValidationResult(BaseModel):
is_valid: bool = True
warnings: list = []

def validate_tool_name(name: str) -> ToolNameValidationResult: # type: ignore[misc]
return ToolNameValidationResult()
def validate_tool_name(name: str) -> _ToolNameValidationResult:
return _ToolNameValidationResult()


# Probe includes characters on both sides of the separator to mimic real prefixed tool names.
Expand Down Expand Up @@ -324,6 +327,9 @@ async def load_servers_from_config(
access_groups=server_config.get("access_groups", None),
static_headers=server_config.get("static_headers", None),
allow_all_keys=bool(server_config.get("allow_all_keys", False)),
available_on_public_internet=bool(
server_config.get("available_on_public_internet", False)
),
)
self.config_mcp_servers[server_id] = new_server

Expand Down Expand Up @@ -622,6 +628,9 @@ async def build_mcp_server_from_table(
allowed_tools=getattr(mcp_server, "allowed_tools", None),
disallowed_tools=getattr(mcp_server, "disallowed_tools", None),
allow_all_keys=mcp_server.allow_all_keys,
available_on_public_internet=bool(
getattr(mcp_server, "available_on_public_internet", False)
),
updated_at=getattr(mcp_server, "updated_at", None),
)
return new_server
Expand Down Expand Up @@ -696,6 +705,23 @@ async def get_allowed_mcp_servers(
verbose_logger.warning(f"Failed to get allowed MCP servers: {str(e)}.")
return allow_all_server_ids

def filter_server_ids_by_ip(
self, server_ids: List[str], client_ip: Optional[str]
) -> List[str]:
"""
Filter server IDs by client IP — external callers only see public servers.

Returns server_ids unchanged when client_ip is None (no filtering).
"""
if client_ip is None:
return server_ids
return [
sid
for sid in server_ids
if (s := self.get_mcp_server_by_id(sid)) is not None
and self._is_server_accessible_from_ip(s, client_ip)
]

async def get_tools_for_server(self, server_id: str) -> List[MCPTool]:
"""
Get the tools for a given server
Expand Down Expand Up @@ -2200,6 +2226,38 @@ def get_mcp_servers_from_ids(self, server_ids: List[str]) -> List[MCPServer]:
servers.append(server)
return servers

def _get_general_settings(self) -> Dict[str, Any]:
"""Get general_settings, importing lazily to avoid circular imports."""
from litellm.proxy.proxy_server import (
general_settings as proxy_general_settings,
)
return proxy_general_settings

def _is_server_accessible_from_ip(
Comment thread
ishaan-jaff marked this conversation as resolved.
self, server: MCPServer, client_ip: Optional[str]
) -> bool:
"""
Check if a server is accessible from the given client IP.

- If client_ip is None, no IP filtering is applied (internal callers).
- If the server has available_on_public_internet=True, it's always accessible.
- Otherwise, only internal/private IPs can access it.
"""
if client_ip is None:
return True
if server.available_on_public_internet:
return True
# Check backwards compat: litellm.public_mcp_servers
public_ids = set(litellm.public_mcp_servers or [])
if server.server_id in public_ids:
return True
# Non-public server: only accessible from internal IPs
general_settings = self._get_general_settings()
internal_networks = IPAddressUtils.parse_internal_networks(
general_settings.get("mcp_internal_ip_ranges")
)
return IPAddressUtils.is_internal_ip(client_ip, internal_networks)

def get_mcp_server_by_id(self, server_id: str) -> Optional[MCPServer]:
"""
Get the MCP Server from the server id
Expand All @@ -2212,27 +2270,54 @@ def get_mcp_server_by_id(self, server_id: str) -> Optional[MCPServer]:

def get_public_mcp_servers(self) -> List[MCPServer]:
"""
Get the public MCP servers
Get the public MCP servers (available_on_public_internet=True flag on server).
Also includes servers from litellm.public_mcp_servers for backwards compat.
"""
servers: List[MCPServer] = []
if litellm.public_mcp_servers is None:
return servers
for server_id in litellm.public_mcp_servers:
server = self.get_mcp_server_by_id(server_id)
if server:
public_ids = set(litellm.public_mcp_servers or [])
for server in self.get_registry().values():
if server.available_on_public_internet or server.server_id in public_ids:
servers.append(server)
return servers

def get_mcp_server_by_name(self, server_name: str) -> Optional[MCPServer]:
def get_mcp_server_by_name(
self, server_name: str, client_ip: Optional[str] = None
) -> Optional[MCPServer]:
"""
Comment on lines +2290 to 2293

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.

Server alias lookup regression

get_mcp_server_by_name() only matches server.server_name == server_name and ignores alias even though servers can be addressed via alias elsewhere. After this PR, multiple request paths (e.g., OAuth endpoints and dynamic MCP routes) rely on get_mcp_server_by_name(..., client_ip=...), so any client using an alias will now get a 404 even if the server exists and is allowed.

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.

what do you need me to change / edit @greptile ?

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.

You need to add alias matching to get_mcp_server_by_name(). Change:

Suggested change
def get_mcp_server_by_name(
self, server_name: str, client_ip: Optional[str] = None
) -> Optional[MCPServer]:
"""
def get_mcp_server_by_name(
self, server_name: str, client_ip: Optional[str] = None
) -> Optional[MCPServer]:
"""
Get an MCP server by name or alias, with optional IP filtering.
"""
registry = self.get_registry()
for server in registry.values():
if server.server_name == server_name or server_name in (server.alias or []):
if client_ip and not IPAddressUtils.is_private_ip(client_ip):
if not server.available_on_public_internet:
return None
return server
return None

This preserves the existing alias lookup behavior while adding IP filtering.

Get the MCP Server from the server name
Get the MCP Server from the server name.

Args:
server_name: The server name to look up.
client_ip: Optional client IP for access control. When provided,
non-public servers are hidden from external IPs.
"""
registry = self.get_registry()
for server in registry.values():
if server.server_name == server_name:
if not self._is_server_accessible_from_ip(server, client_ip):
return None
return server
return None

def get_filtered_registry(
self, client_ip: Optional[str] = None
) -> Dict[str, MCPServer]:
"""
Get registry filtered by client IP access control.

Args:
client_ip: Optional client IP. When provided, non-public servers
are hidden from external IPs. When None, returns all servers.
"""
registry = self.get_registry()
if client_ip is None:
return registry
return {
k: v
for k, v in registry.items()
if self._is_server_accessible_from_ip(v, client_ip)
}

def _generate_stable_server_id(
self,
server_name: str,
Expand Down
64 changes: 44 additions & 20 deletions litellm/proxy/_experimental/mcp_server/rest_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from litellm.proxy._experimental.mcp_server.utils import merge_mcp_headers
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.mcp import MCPAuth

Expand All @@ -28,6 +29,7 @@

if MCP_AVAILABLE:
from mcp.types import Tool as MCPTool

from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
Expand Down Expand Up @@ -76,6 +78,26 @@ def _create_tool_response_objects(tools, server_mcp_info):
for tool in tools
]

def _extract_mcp_headers_from_request(
request: Request,
mcp_request_handler_cls,
) -> tuple:
"""
Extract MCP auth headers from HTTP request.

Returns:
Tuple of (mcp_auth_header, mcp_server_auth_headers, raw_headers)
"""
headers = request.headers
raw_headers = dict(headers)
mcp_auth_header = mcp_request_handler_cls._get_mcp_auth_header_from_headers(
headers
)
mcp_server_auth_headers = (
mcp_request_handler_cls._get_mcp_server_auth_headers_from_headers(headers)
)
return mcp_auth_header, mcp_server_auth_headers, raw_headers

async def _get_tools_for_single_server(
server,
server_auth_header,
Expand Down Expand Up @@ -142,29 +164,35 @@ async def list_tool_rest_api(

auth_contexts = await build_effective_auth_contexts(user_api_key_dict)

_rest_client_ip = IPAddressUtils.get_mcp_client_ip(request)

allowed_server_ids_set = set()
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_allowed_mcp_servers(
user_api_key_auth=auth_context
user_api_key_auth=auth_context,
)
allowed_server_ids_set.update(servers)

allowed_server_ids = list(allowed_server_ids_set)
allowed_server_ids = global_mcp_server_manager.filter_server_ids_by_ip(
list(allowed_server_ids_set), _rest_client_ip
)

list_tools_result = []
error_message = None

# If server_id is specified, only query that specific server
if server_id:
if server_id not in allowed_server_ids_set:
if server_id not in allowed_server_ids:
raise HTTPException(
status_code=403,
detail={
"error": "access_denied",
"message": f"The key is not allowed to access server {server_id}",
},
)
server = global_mcp_server_manager.get_mcp_server_by_id(server_id)
server = global_mcp_server_manager.get_mcp_server_by_id(
server_id
)
if server is None:
return {
"tools": [],
Expand Down Expand Up @@ -296,21 +324,10 @@ async def call_tool_rest_api(
proxy_config=proxy_config,
)

# FIX: Extract MCP auth headers from request
# The UI sends bearer token in x-mcp-auth header and server-specific headers,
# but they weren't being extracted and passed to call_mcp_tool.
# This fix ensures auth headers are properly extracted from the HTTP request
# and passed through to the MCP server for authentication.
headers = request.headers
raw_headers_from_request = dict(headers)
mcp_auth_header = MCPRequestHandler._get_mcp_auth_header_from_headers(
headers
)
mcp_server_auth_headers = (
MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers)
# Extract MCP auth headers from request and add to data dict
mcp_auth_header, mcp_server_auth_headers, raw_headers_from_request = (
_extract_mcp_headers_from_request(request, MCPRequestHandler)
)

# Add extracted headers to data dict to pass to call_mcp_tool
if mcp_auth_header:
data["mcp_auth_header"] = mcp_auth_header
if mcp_server_auth_headers:
Expand All @@ -325,14 +342,21 @@ async def call_tool_rest_api(
# Get all auth contexts
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)

# Collect allowed server IDs from all contexts
# Collect allowed server IDs from all contexts, then apply IP filtering
_rest_client_ip = IPAddressUtils.get_mcp_client_ip(request)
allowed_server_ids_set = set()
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_allowed_mcp_servers(
user_api_key_auth=auth_context
user_api_key_auth=auth_context,
)
allowed_server_ids_set.update(servers)

allowed_server_ids_set = set(
global_mcp_server_manager.filter_server_ids_by_ip(
list(allowed_server_ids_set), _rest_client_ip
)
)

# Check if the specified server_id is allowed
if server_id not in allowed_server_ids_set:
raise HTTPException(
Expand Down
Loading
Loading