Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ litellm/proxy/tests/node_modules
litellm/proxy/tests/package.json
litellm/proxy/tests/package-lock.json
ui/litellm-dashboard/.next
ui/litellm-dashboard/out
ui/litellm-dashboard/node_modules
ui/litellm-dashboard/next-env.d.ts
ui/litellm-dashboard/package.json
Expand Down
23 changes: 22 additions & 1 deletion helm/litellm/templates/backend/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ spec:
{{- include "litellm.backend.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.backend.podAnnotations }}
{{- if or .Values.gateway.config.create .Values.backend.podAnnotations }}
annotations:
{{- if .Values.gateway.config.create }}
checksum/config: {{ include (print $.Template.BasePath "/gateway/configmap.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.backend.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
labels:
{{- include "litellm.backend.selectorLabels" . | nindent 8 }}
Expand All @@ -35,7 +40,17 @@ spec:
protocol: TCP
env:
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}
{{- if .Values.gateway.config.create }}
- name: CONFIG_FILE_PATH
value: /app/config/config.yaml
{{- end }}
{{- include "litellm.envFrom" .Values.backend | nindent 10 }}
{{- if .Values.gateway.config.create }}
volumeMounts:
- name: gateway-config
mountPath: /app/config/config.yaml
subPath: config.yaml
{{- end }}
{{- with .Values.backend.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
Expand All @@ -46,6 +61,12 @@ spec:
{{- end }}
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- if .Values.gateway.config.create }}
volumes:
- name: gateway-config
configMap:
name: {{ include "litellm.gateway.fullname" . }}-config
{{- end }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
Expand Down
71 changes: 65 additions & 6 deletions litellm/proxy/_experimental/mcp_server/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
LiteLLM_ObjectPermissionTable,
Expand Down Expand Up @@ -67,6 +68,17 @@ def _prepare_mcp_server_data(
# ``alias=None`` is a valid request to clear the stored alias.
if data_dict.get("alias") is None and "alias" not in fields_set:
data_dict.pop("alias", None)
# Prisma ``allowed_tools`` is a required String[]; ``null`` is invalid.
# The UI sends null to clear a whitelist — treat that as ``[]``.
if "allowed_tools" in data_dict and data_dict["allowed_tools"] is None:
data_dict["allowed_tools"] = []
# Json map fields use ``@default("{}")``; explicit null means clear overrides.
for json_map_field in (
"tool_name_to_display_name",
"tool_name_to_description",
):
if json_map_field in data_dict and data_dict[json_map_field] is None:
data_dict[json_map_field] = {}
else:
data_dict = data.model_dump(exclude_none=True)
# Ensure alias is always present in the dict (even if None)
Expand All @@ -93,13 +105,13 @@ def _prepare_mcp_server_data(
if data_dict.get("env") is not None:
data_dict["env"] = safe_dumps(data_dict["env"])

if data_dict.get("tool_name_to_display_name") is not None:
if "tool_name_to_display_name" in data_dict:
data_dict["tool_name_to_display_name"] = safe_dumps(
data_dict["tool_name_to_display_name"]
data_dict["tool_name_to_display_name"] or {}
)
if data_dict.get("tool_name_to_description") is not None:
if "tool_name_to_description" in data_dict:
data_dict["tool_name_to_description"] = safe_dumps(
data_dict["tool_name_to_description"]
data_dict["tool_name_to_description"] or {}
)

# mcp_access_groups is already List[str], no serialization needed
Expand Down Expand Up @@ -740,11 +752,14 @@ async def store_user_oauth_credential(
)


def is_oauth_credential_expired(cred: Dict[str, Any]) -> bool:
def is_oauth_credential_expired(cred: Dict[str, Any], buffer_seconds: int = 0) -> bool:
"""Return True if the OAuth2 credential's access_token has expired.

Checks the ``expires_at`` ISO-format string stored in the credential payload.
Returns False when ``expires_at`` is absent or unparseable (treat as non-expired).
With ``buffer_seconds`` > 0, a token that is still valid but expires within the
buffer is also treated as expired, so callers can refresh proactively instead of
handing back a token that may lapse mid-request.
"""
expires_at = cred.get("expires_at")
if not expires_at:
Expand All @@ -753,7 +768,7 @@ def is_oauth_credential_expired(cred: Dict[str, Any]) -> bool:
exp_dt = datetime.fromisoformat(expires_at)
if exp_dt.tzinfo is None:
exp_dt = exp_dt.replace(tzinfo=timezone.utc)
return datetime.now(timezone.utc) > exp_dt
return datetime.now(timezone.utc) + timedelta(seconds=buffer_seconds) > exp_dt
except (ValueError, TypeError):
return False

Expand Down Expand Up @@ -901,6 +916,50 @@ async def refresh_user_oauth_token(
return await get_user_oauth_credential(prisma_client, user_id, server_id)


async def resolve_valid_user_oauth_token(
user_id: str,
server: Any,
cred: Optional[Dict[str, Any]],
prisma_client: Optional[PrismaClient] = None,
) -> Optional[Dict[str, Any]]:
"""Return an OAuth2 credential whose access_token is good for the next request.

Returns the credential unchanged while its token is valid for at least
``MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS``. Only when the token is expired (or
expiring within that buffer) and a refresh_token is stored does it mint a new one
via ``refresh_user_oauth_token``. Returns None when there is no usable token
(missing token, expired with no refresh_token, or a failed refresh).

The refresh_token is only ever sent to the server's token_url inside
``refresh_user_oauth_token``; it is never exposed to the caller beyond the cred
dict it already holds. ``prisma_client`` is fetched lazily and only when a refresh
actually happens, so the valid-token path never requires a DB handle.
"""
if not cred or not cred.get("access_token"):
return None
if not is_oauth_credential_expired(
cred, buffer_seconds=MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS
):
return cred
if not cred.get("refresh_token"):
return None
if prisma_client is None:
from litellm.proxy.utils import get_prisma_client_or_throw

prisma_client = get_prisma_client_or_throw(
"Database not connected. Cannot refresh OAuth token."
)
refreshed = await refresh_user_oauth_token(
prisma_client=prisma_client,
user_id=user_id,
server=server,
cred=cred,
)
if not refreshed or not refreshed.get("access_token"):
return None
return refreshed


async def approve_mcp_server(
prisma_client: PrismaClient,
server_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,13 @@ async def exchange_token_with_server(
result = {
"access_token": access_token,
"token_type": token_response.get("token_type", "Bearer"),
"expires_in": token_response.get("expires_in", 3600),
}

if "refresh_token" in token_response and token_response["refresh_token"]:
if token_response.get("expires_in") is not None:
result["expires_in"] = token_response["expires_in"]
if token_response.get("refresh_token"):
result["refresh_token"] = token_response["refresh_token"]
if "scope" in token_response and token_response["scope"]:
if token_response.get("scope"):
result["scope"] = token_response["scope"]

# RFC 6749 §5.1: token responses must not be cached.
Expand Down
8 changes: 7 additions & 1 deletion litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2429,7 +2429,13 @@ def check_allowed_or_banned_tools(self, tool_name: str, server: MCPServer) -> bo
"""
Check if the tool is allowed or banned for the given server
"""
if server.allowed_tools:
from litellm.proxy._experimental.mcp_server.utils import (
server_applies_tool_allowlist,
)

if server_applies_tool_allowlist(server):
if not server.allowed_tools:
return False
return (
tool_name in server.allowed_tools
or f"{server.name}-{tool_name}" in server.allowed_tools
Expand Down
53 changes: 41 additions & 12 deletions litellm/proxy/_experimental/mcp_server/rest_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,10 @@ async def _get_user_oauth_extra_headers(
try:
from litellm.proxy._experimental.mcp_server.db import (
get_user_oauth_credential,
is_oauth_credential_expired,
resolve_valid_user_oauth_token,
)

prisma_client = None
if prefetched_creds is not None:
cred = prefetched_creds.get(server_id)
else:
Expand All @@ -129,13 +130,13 @@ async def _get_user_oauth_extra_headers(
cred = await get_user_oauth_credential(
prisma_client, user_id, server_id
)
cred = await resolve_valid_user_oauth_token(
user_id=user_id,
server=server,
cred=cred,
prisma_client=prisma_client,
)
if cred and cred.get("access_token"):
if is_oauth_credential_expired(cred):
verbose_logger.debug(
f"_get_user_oauth_extra_headers: token expired for "
f"user={user_id} server={server_id}"
)
return None
return {"Authorization": f"Bearer {cred['access_token']}"}
except Exception as e:
verbose_logger.warning(
Expand Down Expand Up @@ -354,8 +355,15 @@ async def _get_tools_for_single_server(
raw_headers: Optional[Dict[str, str]] = None,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
extra_headers: Optional[Dict[str, str]] = None,
apply_tool_filters: bool = True,
):
"""Helper function to get tools for a single server."""
"""Helper function to get tools for a single server.

When ``apply_tool_filters`` is False the raw server catalog is returned
without the allowed_tools/disallowed_tools gate or the per-key tool
permissions. This is the admin-only configuration view; every runtime
path keeps the default True so callable tools stay filtered.
"""
tools = await global_mcp_server_manager._get_tools_from_server(
server=server,
mcp_auth_header=server_auth_header,
Expand All @@ -365,10 +373,12 @@ async def _get_tools_for_single_server(
user_api_key_auth=user_api_key_auth,
)

# Filter tools based on allowed_tools configuration
# Only filter if allowed_tools is explicitly configured (not None and not empty)
if server.allowed_tools is not None and len(server.allowed_tools) > 0:
tools = filter_tools_by_allowed_tools(tools, server)
if not apply_tool_filters:
return _create_tool_response_objects(tools, server.mcp_info)

# Always apply allowed_tools/disallowed_tools so the blacklist is
# enforced even when no allowlist is set (matches the SSE/HTTP path).
tools = filter_tools_by_allowed_tools(tools, server)

# Filter tools based on user_api_key_auth.object_permission.mcp_tool_permissions
# This provides per-key/team/org control over which tools can be accessed
Expand Down Expand Up @@ -527,6 +537,7 @@ async def _list_tools_for_single_server(
mcp_auth_header: Optional[str],
raw_headers_from_request: dict,
user_api_key_dict: UserAPIKeyAuth,
apply_tool_filters: bool = True,
) -> dict:
"""Handle tool listing for a single server_id request."""
# Resolve a server name to its UUID if needed
Expand Down Expand Up @@ -591,6 +602,7 @@ async def _list_tools_for_single_server(
raw_headers_from_request,
user_api_key_dict,
extra_headers=user_oauth_extra_headers,
apply_tool_filters=apply_tool_filters,
)
except Exception as e:
verbose_logger.exception(f"Error getting tools from {server.name}: {e}")
Expand All @@ -611,6 +623,14 @@ async def list_tool_rest_api(
server_id: Optional[str] = Query(
None, description="The server id to list tools for"
),
include_disabled_tools: bool = Query(
False,
description=(
"Admin only. Return the full server tool catalog without the "
"allowed_tools filter or per-key tool permissions, so the MCP "
"settings UI can configure the allowlist. Ignored for non-admins."
),
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> dict:
"""
Expand Down Expand Up @@ -638,6 +658,13 @@ async def list_tool_rest_api(
)

try:
# The full catalog (allowlist filter skipped) is admin-only so the
# REST endpoint can't be used to enumerate deliberately-disabled tools.
apply_tool_filters = not (
include_disabled_tools
and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
)

# Extract auth headers from request
headers = request.headers
raw_headers_from_request = dict(headers)
Expand Down Expand Up @@ -679,6 +706,7 @@ async def list_tool_rest_api(
mcp_auth_header=mcp_auth_header,
raw_headers_from_request=raw_headers_from_request,
user_api_key_dict=user_api_key_dict,
apply_tool_filters=apply_tool_filters,
)
else:
if not allowed_server_ids:
Expand Down Expand Up @@ -736,6 +764,7 @@ async def list_tool_rest_api(
raw_headers_from_request,
user_api_key_dict,
extra_headers=user_oauth_extra_headers,
apply_tool_filters=apply_tool_filters,
)
list_tools_result.extend(tools_result)
except Exception as e:
Expand Down
Loading
Loading