Merge pull request #34225 from BerriAI/litellm_lit4658_oauth_discovery_logging

fix(mcp): log actionable OAuth discovery failures for misconfigured server urls
This commit is contained in:
tin-berri 2026-07-23 09:51:31 -07:00 committed by GitHub
commit 712136d10d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 627 additions and 125 deletions

View file

@ -626,6 +626,35 @@ def _raise_if_not_oauth2(mcp_server: MCPServer) -> None:
)
def _endpoint_not_configured_detail(
mcp_server: MCPServer,
endpoint_label: str,
manual_remedy: str,
issuer_remedy: str,
) -> str:
"""The 400 detail for an unresolved OAuth endpoint, naming the likely cause for this server's
shape (LIT-4658): an anchored issuer whose metadata fell short, a configured (possibly
misconfigured) server url whose discovery failed, or no discovery source at all. Kept free of
URLs and issuer values because these endpoints are reachable pre-auth."""
if mcp_server.issuer_is_anchored:
return (
f"MCP server {endpoint_label} is not configured. Endpoint discovery anchored on the configured "
f"Issuer (RFC 8414) failed or its metadata did not include this endpoint; check the proxy logs "
f"for 'MCP OAuth' warnings from server load, verify the Issuer, or {manual_remedy}."
)
if mcp_server.url:
return (
f"MCP server {endpoint_label} is not configured. OAuth endpoint discovery against the configured "
f"server url did not resolve it; the url may be misconfigured. Check the proxy logs for "
f"'MCP OAuth' warnings from server load, verify the server url, or {manual_remedy}, or "
f"{issuer_remedy}."
)
return (
f"MCP server {endpoint_label} is not configured. Servers with no url (OpenAPI spec or stdio) run no "
f"resource discovery, so {manual_remedy}, or {issuer_remedy}."
)
def _raise_unless_oauth2_discovery_server(
mcp_server: Optional[MCPServer],
mcp_server_name: Optional[str],
@ -727,10 +756,11 @@ async def authorize_with_server(
if mcp_server.authorization_url is None:
raise HTTPException(
status_code=400,
detail=(
"MCP server authorization url is not configured. Servers with no url (OpenAPI "
"spec or stdio) run no resource discovery, so set Authorization URL and Token URL "
"manually, or set Issuer to discover them from the identity provider (RFC 8414)."
detail=_endpoint_not_configured_detail(
mcp_server,
"authorization url",
"set Authorization URL and Token URL manually",
"set Issuer to discover them from the identity provider (RFC 8414)",
),
)
@ -848,10 +878,11 @@ async def exchange_token_with_server(
if mcp_server.token_url is None:
raise HTTPException(
status_code=400,
detail=(
"MCP server token url is not configured. Servers with no url (OpenAPI spec or "
"stdio) run no resource discovery, so set Token URL manually, or set Issuer to "
"discover it from the identity provider (RFC 8414)."
detail=_endpoint_not_configured_detail(
mcp_server,
"token url",
"set Token URL manually",
"set Issuer to discover it from the identity provider (RFC 8414)",
),
)
@ -1560,10 +1591,11 @@ async def register_client_with_server(
if mcp_server.authorization_url is None:
raise HTTPException(
status_code=400,
detail=(
"MCP server authorization url is not configured. Servers with no url (OpenAPI "
"spec or stdio) run no resource discovery, so set Authorization URL and Token URL "
"manually, or set Issuer to discover them from the identity provider (RFC 8414)."
detail=_endpoint_not_configured_detail(
mcp_server,
"authorization url",
"set Authorization URL and Token URL manually",
"set Issuer to discover them from the identity provider (RFC 8414)",
),
)

View file

@ -13,6 +13,7 @@ import json
import os
import re
import time
from collections.abc import Sequence
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Callable, Literal, Optional, Union, cast
from urllib.parse import urlparse
@ -51,6 +52,9 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
_is_mcp_admitted_user_subject,
)
from litellm.proxy._experimental.mcp_server.elicitation_handler import (
MCP_ELICITATION_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.exceptions import (
MCPServerListError,
MCPUpstreamAuthError,
@ -60,17 +64,14 @@ from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
raise_classified_list_failure,
upstream_auth_challenge,
)
from litellm.proxy._experimental.mcp_server.elicitation_handler import (
MCP_ELICITATION_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.sampling_handler import (
MCP_SAMPLING_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPPerUserTokenCache,
mcp_per_user_token_cache,
resolve_mcp_auth,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
_redact_mcp_resource_url,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials import (
Error,
Ok,
@ -101,6 +102,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
ServerSpec,
TokenExchangeConfig,
)
from litellm.proxy._experimental.mcp_server.sampling_handler import (
MCP_SAMPLING_AVAILABLE,
)
from litellm.proxy._experimental.mcp_server.utils import (
MCP_TOOL_PREFIX_SEPARATOR,
MCPMissingUserEnvVarsError,
@ -144,11 +148,9 @@ from litellm.types.mcp_server.mcp_server_manager import (
from litellm.types.utils import CallTypes
try:
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, # pyright: ignore[reportAssignmentType]
)
except ImportError:
from pydantic import BaseModel
@ -409,6 +411,88 @@ def _restrict_discovery_to_corroborated_authorization_server(
return metadata.model_copy(update={"token_url": None, "registration_url": None})
def _redacted_origin_list(urls: Sequence[str]) -> str:
return ", ".join(_redact_mcp_resource_url(url) or "<unparseable url>" for url in urls)
def _sanitized_error_text(exc: Exception) -> str:
return re.sub(r"https?://\S+", "<url>", str(exc))[:200]
def _discovery_failure_leaves_needs_unresolved(
*,
needs_authorization_url: bool,
needs_token_url: bool,
manual_authorization_url: str | None,
manual_token_url: str | None,
) -> bool:
return (needs_authorization_url and not manual_authorization_url) or (needs_token_url and not manual_token_url)
def _warn_oauth_endpoints_unresolved(
*,
server_ref: str,
server_url: str | None,
discovery_attempted: bool,
issuer_anchored: bool,
metadata: MCPOAuthMetadata | None,
needs_authorization_url: bool,
needs_token_url: bool,
manual_authorization_url: str | None,
manual_token_url: str | None,
) -> None:
"""Log one actionable warning when a server that depends on OAuth endpoint discovery finishes a
build without the endpoints that its flows need (LIT-4658).
This is the operator-facing signal for a misconfigured server url: discovery failures themselves
are logged where they happen (``_descovery_metadata``), and this names WHICH server is affected,
which endpoints stayed unresolved after manual configuration was considered, and the remedies.
Scopes never trigger the warning on their own: scope-less metadata is normal for many servers and
warning on it every rebuild would be noise. Callers own the per-flow policy of which endpoints
are needed (client_credentials never needs authorization_url; OBO needs only token_url); the
issuer-anchored arm is excluded here because it has its own RFC 8414 §3.3 warning.
"""
if issuer_anchored:
return
unresolved = tuple(
field
for field, needed, value in (
(
"authorization_url",
needs_authorization_url,
manual_authorization_url or (metadata.authorization_url if metadata else None),
),
(
"token_url",
needs_token_url,
manual_token_url or (metadata.token_url if metadata else None),
),
)
if needed and not value
)
if not unresolved:
return
if discovery_attempted:
verbose_logger.warning(
"MCP server %s: OAuth endpoint discovery left %s unresolved (server url origin: %s). OAuth flows "
"that need them will fail with 'not configured' errors until they resolve. Check the preceding "
"'MCP OAuth' log lines for why discovery failed, verify the configured server url, or set the "
"unresolved endpoint urls manually, or set issuer to discover them from the identity provider "
"(RFC 8414)",
server_ref,
", ".join(unresolved),
_redact_mcp_resource_url(server_url) or "<no url>",
)
return
verbose_logger.warning(
"MCP server %s uses OAuth but has no discovery source (no server url or pinned issuer), and %s not "
"set manually. Set the missing endpoint urls on the server, or set issuer to discover them from the "
"identity provider (RFC 8414)",
server_ref,
" and ".join(unresolved) + (" is" if len(unresolved) == 1 else " are"),
)
def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None:
"""Drop a cached entry after the user stores or clears their env var values
so the next request reads the fresh value instead of a stale one."""
@ -885,10 +969,10 @@ def _create_sampling_callback(user_api_key_auth: Optional[Any] = None):
return None
async def _sampling_callback(context, params):
import litellm
from litellm.proxy._experimental.mcp_server.sampling_handler import (
handle_sampling_create_message,
)
import litellm
from litellm.proxy._experimental.mcp_server.server import (
get_active_auth_context,
)
@ -1285,6 +1369,15 @@ class MCPServerManager:
should_discover = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
is_discovery_auth_type or obo_needs_discovery
)
config_oauth2_flow = server_config.get("oauth2_flow", None)
needs_authorization_url = is_discovery_auth_type and config_oauth2_flow != "client_credentials"
needs_token_url = is_discovery_auth_type or obo_needs_discovery
warn_on_empty_discovery = _discovery_failure_leaves_needs_unresolved(
needs_authorization_url=needs_authorization_url,
needs_token_url=needs_token_url,
manual_authorization_url=manual_authorization_url,
manual_token_url=manual_token_url,
)
if not should_discover:
mcp_oauth_metadata = None
elif use_issuer_anchor and manual_issuer is not None:
@ -1293,6 +1386,7 @@ class MCPServerManager:
mcp_oauth_metadata = await self._descovery_metadata(
server_url=server_url,
allow_origin_fallback=is_discovery_auth_type,
warn_when_no_metadata=warn_on_empty_discovery,
)
if use_issuer_anchor:
@ -1327,7 +1421,6 @@ class MCPServerManager:
)
effective_issuer = manual_issuer or discovered_issuer
config_oauth2_flow = server_config.get("oauth2_flow", None)
if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in (
"client_credentials",
"authorization_code",
@ -1359,6 +1452,18 @@ class MCPServerManager:
"authorization-code flow."
)
_warn_oauth_endpoints_unresolved(
server_ref=server_name or server_id,
server_url=server_url,
discovery_attempted=should_discover,
issuer_anchored=use_issuer_anchor,
metadata=gated_oauth_metadata,
needs_authorization_url=needs_authorization_url,
needs_token_url=needs_token_url,
manual_authorization_url=manual_authorization_url,
manual_token_url=manual_token_url,
)
new_server = MCPServer(
server_id=server_id,
name=name_for_prefix,
@ -1486,14 +1591,12 @@ class MCPServerManager:
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
build_input_schema,
create_tool_function,
load_openapi_spec_async,
resolve_operation_params,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
get_base_url as get_openapi_base_url,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
load_openapi_spec_async,
resolve_operation_params,
)
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
@ -1682,10 +1785,20 @@ class MCPServerManager:
scopes: Optional[list[str]],
token_exchange_endpoint: Optional[str],
) -> Optional[MCPOAuthMetadata]:
obo_needs_discovery = self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url)
needs_authorization_url = (
is_discovery_auth_type and getattr(mcp_server, "oauth2_flow", None) != "client_credentials"
)
needs_token_url = is_discovery_auth_type or obo_needs_discovery
warn_on_empty_discovery = _discovery_failure_leaves_needs_unresolved(
needs_authorization_url=needs_authorization_url,
needs_token_url=needs_token_url,
manual_authorization_url=manual_authorization_url,
manual_token_url=manual_token_url,
)
has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes)
needs_discovery = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
(is_discovery_auth_type and not has_all_upstream_oauth_fields)
or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url)
(is_discovery_auth_type and not has_all_upstream_oauth_fields) or obo_needs_discovery
)
if not needs_discovery:
mcp_oauth_metadata: Optional[MCPOAuthMetadata] = None
@ -1695,24 +1808,32 @@ class MCPServerManager:
mcp_oauth_metadata = await self._descovery_metadata(
server_url=server_url, # type: ignore[arg-type]
allow_origin_fallback=is_discovery_auth_type,
)
if needs_discovery and not use_issuer_anchor and mcp_oauth_metadata is None:
verbose_logger.warning(
"MCP OAuth discovery yielded no metadata for server %s (%s); "
"OAuth endpoints/scopes stay unresolved until a rebuild succeeds",
mcp_server.server_id,
server_url,
warn_when_no_metadata=warn_on_empty_discovery,
)
if use_issuer_anchor:
return mcp_oauth_metadata
if is_discovery_auth_type:
return _restrict_discovery_to_corroborated_authorization_server(
gated_metadata = (
_restrict_discovery_to_corroborated_authorization_server(
mcp_oauth_metadata,
manual_authorization_url,
mcp_server.server_id,
bool(getattr(mcp_server, "dcr_bridge", None)),
)
return mcp_oauth_metadata
if is_discovery_auth_type
else mcp_oauth_metadata
)
_warn_oauth_endpoints_unresolved(
server_ref=mcp_server.alias or mcp_server.server_name or mcp_server.server_id,
server_url=server_url,
discovery_attempted=needs_discovery,
issuer_anchored=False,
metadata=gated_metadata,
needs_authorization_url=needs_authorization_url,
needs_token_url=needs_token_url,
manual_authorization_url=manual_authorization_url,
manual_token_url=manual_token_url,
)
return gated_metadata
async def build_mcp_server_from_table(
self,
@ -3492,6 +3613,7 @@ class MCPServerManager:
server_url: str,
*,
allow_origin_fallback: bool = True,
warn_when_no_metadata: bool = False,
) -> Optional[MCPOAuthMetadata]:
"""Discover OAuth metadata by following RFC 9728 (protected resource metadata discovery).
@ -3500,8 +3622,32 @@ class MCPServerManager:
it (a human sees the redirect), but token_exchange (OBO) sets it False so the gateway never
exchanges a subject token against an endpoint it inferred rather than one explicitly configured
or authoritatively advertised via RFC 9728 / RFC 8414.
"""
``warn_when_no_metadata`` makes an all-empty result log one WARNING with the per-step attempt
outcomes (LIT-4658), so a misconfigured server url is diagnosable from default-level logs. The
server loaders set it; the issuer-anchored resource-scopes lookup keeps it off because empty
scopes are not a fault there.
"""
metadata, attempts = await self._discover_metadata_recording_attempts(
server_url, allow_origin_fallback=allow_origin_fallback
)
if metadata is None and warn_when_no_metadata:
verbose_logger.warning(
"MCP OAuth endpoint discovery against %s found no authorization server metadata. Attempts: %s. "
"The MCP server url may be misconfigured, or the upstream may not support OAuth discovery "
"(RFC 9728 / RFC 8414)",
_redact_mcp_resource_url(server_url) or "<unparseable url>",
"; ".join(attempts) if attempts else "none recorded",
)
return metadata
async def _discover_metadata_recording_attempts(
self,
server_url: str,
*,
allow_origin_fallback: bool,
) -> tuple[MCPOAuthMetadata | None, tuple[str, ...]]:
origin = _redact_mcp_resource_url(server_url) or "<unparseable url>"
try:
client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
response = await client.get(server_url)
@ -3514,67 +3660,112 @@ class MCPServerManager:
if metadata is None and not resource_scopes and authorization_servers and response.status_code == 200:
verbose_logger.warning(
"MCP OAuth discovery for %s received 200 OK without RFC 9728 challenge and no discoverable authorization metadata.",
server_url,
origin,
)
attempts = (
f"GET {origin}: HTTP {response.status_code} (no RFC 9728 challenge)",
*(
("well-known protected-resource lookup found no authorization servers",)
if not authorization_servers
else ()
),
*(
(f"authorization server metadata fetch failed for: {_redacted_origin_list(authorization_servers)}",)
if authorization_servers and metadata is None
else ()
),
)
if metadata is None and resource_scopes:
return MCPOAuthMetadata(scopes=resource_scopes)
return MCPOAuthMetadata(scopes=resource_scopes), attempts
if metadata is not None and resource_scopes:
metadata.scopes = resource_scopes
return metadata
return metadata, attempts
except HTTPStatusError as exc:
verbose_logger.debug(
"MCP OAuth discovery for %s received status error: %s",
server_url,
exc,
)
header_value: Optional[str] = None
if exc.response is not None:
header_value = exc.response.headers.get("WWW-Authenticate") or exc.response.headers.get(
"www-authenticate"
)
resource_metadata_url, scopes = self._parse_www_authenticate_header(header_value)
authorization_servers = []
resource_scopes = None
if resource_metadata_url:
(
authorization_servers,
resource_scopes,
) = await self._fetch_oauth_metadata_from_resource(resource_metadata_url, server_url)
else:
(
authorization_servers,
resource_scopes,
) = await self._attempt_well_known_discovery(server_url)
metadata = None
used_origin_fallback = False
if allow_origin_fallback and not authorization_servers:
try:
parsed_url = urlparse(server_url)
if parsed_url.scheme and parsed_url.netloc:
authorization_servers = [f"{parsed_url.scheme}://{parsed_url.netloc}"]
used_origin_fallback = True
except Exception:
authorization_servers = []
if authorization_servers:
metadata = await self._fetch_authorization_server_metadata(authorization_servers, server_url)
if metadata is not None and used_origin_fallback:
metadata.from_origin_fallback = True
preferred_scopes = scopes or resource_scopes
if metadata is None and preferred_scopes:
metadata = MCPOAuthMetadata(scopes=preferred_scopes)
elif metadata is not None and preferred_scopes:
metadata.scopes = preferred_scopes
return metadata
return await self._discover_after_status_error(server_url, exc, allow_origin_fallback=allow_origin_fallback)
except Exception as exc: # pragma: no cover - network/transient issues
verbose_logger.debug("MCP OAuth discovery failed for %s: %s", server_url, exc)
return None
return None, (f"GET {origin}: {type(exc).__name__}: {_sanitized_error_text(exc)}",)
async def _discover_after_status_error(
self,
server_url: str,
exc: HTTPStatusError,
*,
allow_origin_fallback: bool,
) -> tuple[MCPOAuthMetadata | None, tuple[str, ...]]:
origin = _redact_mcp_resource_url(server_url) or "<unparseable url>"
verbose_logger.debug(
"MCP OAuth discovery for %s received status error: %s",
server_url,
exc,
)
header_value: Optional[str] = None
if exc.response is not None:
header_value = exc.response.headers.get("WWW-Authenticate") or exc.response.headers.get("www-authenticate")
status_attempt = (
f"GET {origin}: HTTP {exc.response.status_code}"
if exc.response is not None
else f"GET {origin}: status error"
)
resource_metadata_url, scopes = self._parse_www_authenticate_header(header_value)
authorization_servers = []
resource_scopes = None
if resource_metadata_url:
(
authorization_servers,
resource_scopes,
) = await self._fetch_oauth_metadata_from_resource(resource_metadata_url, server_url)
lookup_attempt = (
None
if authorization_servers
else "challenge-advertised resource metadata yielded no authorization servers"
)
else:
(
authorization_servers,
resource_scopes,
) = await self._attempt_well_known_discovery(server_url)
lookup_attempt = (
None
if authorization_servers
else "no challenge-advertised resource metadata; well-known protected-resource lookup found no authorization servers"
)
metadata = None
used_origin_fallback = False
if allow_origin_fallback and not authorization_servers:
try:
parsed_url = urlparse(server_url)
if parsed_url.scheme and parsed_url.netloc:
authorization_servers = [f"{parsed_url.scheme}://{parsed_url.netloc}"]
used_origin_fallback = True
except Exception:
authorization_servers = []
fallback_attempt = None
if authorization_servers:
metadata = await self._fetch_authorization_server_metadata(authorization_servers, server_url)
if metadata is not None and used_origin_fallback:
metadata.from_origin_fallback = True
if metadata is None:
fallback_attempt = (
f"origin fallback: no authorization server metadata at {origin}"
if used_origin_fallback
else f"authorization server metadata fetch failed for: {_redacted_origin_list(authorization_servers)}"
)
attempts = tuple(entry for entry in (status_attempt, lookup_attempt, fallback_attempt) if entry)
preferred_scopes = scopes or resource_scopes
if metadata is None and preferred_scopes:
return MCPOAuthMetadata(scopes=preferred_scopes), attempts
if metadata is not None and preferred_scopes:
metadata.scopes = preferred_scopes
return metadata, attempts
def _parse_www_authenticate_header(self, header_value: Optional[str]) -> tuple[Optional[str], Optional[list[str]]]:
if not header_value:

View file

@ -4,7 +4,7 @@
import os
from ipaddress import ip_address
from typing import Any, Dict, List, NoReturn, Optional
from urllib.parse import ParseResult, urlparse, urlunparse
from urllib.parse import ParseResult, urlparse, urlsplit, urlunparse, urlunsplit
from fastapi import HTTPException, Request
@ -70,6 +70,29 @@ def _origin_label(scheme: str, netloc: str) -> str:
return f"{scheme}://{netloc}" if netloc else f"{scheme}://"
def _redact_mcp_resource_url(url: Optional[str]) -> Optional[str]:
"""Reduce an MCP server URL to its origin (scheme + host + port) for logging.
Everything else is dropped: userinfo (``user:pass@``), the query string, the
fragment, and the path, because hosted MCP servers routinely embed the
credential in the path (e.g. ``/mcp/s/<token>``) and this value is persisted
in spend-log metadata that a caller who can invoke the tool can read back.
Returns None when the URL has no host to identify (nothing safe to log).
"""
if not isinstance(url, str) or not url:
return None
try:
parts = urlsplit(url)
hostname = parts.hostname
port = parts.port
except ValueError:
return None
if not hostname:
return None
netloc = f"{hostname}:{port}" if port else hostname
return urlunsplit((parts.scheme, netloc, "", "", "")) or None
def _resolve_proxy_base_url_env() -> Optional[str]:
global _warned_invalid_proxy_base_url
configured = os.environ.get("PROXY_BASE_URL", "").strip()

View file

@ -27,7 +27,6 @@ from typing import (
Union,
cast,
)
from urllib.parse import urlsplit, urlunsplit
import httpx
from fastapi import FastAPI, HTTPException
@ -59,6 +58,9 @@ from litellm.proxy._experimental.mcp_server.mcp_context import (
_mcp_gateway_server_name,
)
from litellm.proxy._experimental.mcp_server.mcp_debug import MCPDebug
from litellm.proxy._experimental.mcp_server.oauth_utils import (
_redact_mcp_resource_url,
)
from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_DESCRIPTION,
LITELLM_MCP_SERVER_NAME,
@ -106,27 +108,6 @@ _MAX_STATEFUL_SESSIONS_PER_OWNER = 100
_MCP_ROUTING_PEEK_MAX_BYTES = 4096
def _redact_mcp_resource_url(url: Optional[str]) -> Optional[str]:
"""Reduce an MCP server URL to its origin (scheme + host + port) for logging.
Everything else is dropped: userinfo (``user:pass@``), the query string, the
fragment, and the path, because hosted MCP servers routinely embed the
credential in the path (e.g. ``/mcp/s/<token>``) and this value is persisted
in spend-log metadata that a caller who can invoke the tool can read back.
Returns None when the URL has no host to identify (nothing safe to log).
"""
if not isinstance(url, str) or not url:
return None
try:
parts = urlsplit(url)
except ValueError:
return None
if not parts.hostname:
return None
netloc = f"{parts.hostname}:{parts.port}" if parts.port else parts.hostname
return urlunsplit((parts.scheme, netloc, "", "", "")) or None
def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None:
"""Remove a (user_id, server_id) entry from the BYOK credential cache.

View file

@ -8226,6 +8226,118 @@ async def test_register_wall_names_the_fix_for_urlless_servers():
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_authorize_wall_points_at_discovery_failure_for_url_servers():
"""LIT-4658: a server WITH a url that still has no authorization_url got here because OAuth
discovery against that url failed (typically a misconfigured url); the old detail blamed
"servers with no url", sending the operator down the wrong path. The detail must now name the
discovery failure and point at the proxy logs where LIT-4658's warnings carry the reason."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="typo-url-wall",
name="typo_wall",
server_name="typo_wall",
url="https://typo-host.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "may be misconfigured" in detail_text
assert "proxy logs" in detail_text
assert "Servers with no url" not in detail_text
assert "typo-host.example.com" not in detail_text
@pytest.mark.asyncio
async def test_token_wall_points_at_discovery_failure_for_url_servers():
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="typo-url-token-wall",
name="typo_token_wall",
server_name="typo_token_wall",
url="https://typo-host.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
authorization_url="https://idp.example.com/authorize",
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "token url is not configured" in detail_text
assert "may be misconfigured" in detail_text
assert "Servers with no url" not in detail_text
@pytest.mark.asyncio
async def test_authorize_wall_names_the_issuer_for_anchored_servers():
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="anchored-wall",
name="anchored_wall",
server_name="anchored_wall",
url="https://up.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
issuer="https://idp.example.com",
issuer_is_anchored=True,
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "verify the Issuer" in detail_text
assert "Servers with no url" not in detail_text
assert "idp.example.com" not in detail_text
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
and is total over hostile input: a raw upstream code opens to None, and a tampered or

View file

@ -7375,6 +7375,10 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow():
("", None),
("not a url", None),
("http://[::1", None),
# urlsplit validates the port lazily on attribute access, so a malformed port must not
# raise out of the helper: the server loaders call it while warning about exactly this
# kind of typo'd url (LIT-4658)
("https://example.com:bad/mcp", None),
],
)
def test_redact_mcp_resource_url_strips_credentials(url, expected):

View file

@ -3031,7 +3031,7 @@ class TestMCPServerManager:
registration_url="https://discovered.example.com/register",
)
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
assert server_url == "https://example.com/mcp"
# oauth2 (browser flow) keeps the origin fallback; only OBO disables it.
assert allow_origin_fallback is True
@ -5426,7 +5426,7 @@ class TestMCPServerTimestamps:
manager = MCPServerManager()
calls: list[bool] = []
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
calls.append(allow_origin_fallback)
return MCPOAuthMetadata(
scopes=None,
@ -5461,7 +5461,7 @@ class TestMCPServerTimestamps:
manager = MCPServerManager()
calls: list[str] = []
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
calls.append(server_url)
raise AssertionError("discovery must not run when token_exchange_endpoint is configured")
@ -5491,7 +5491,7 @@ class TestMCPServerTimestamps:
back to the row, so the next rebuild skips discovery instead of re-running it every time."""
manager = MCPServerManager()
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
assert server_url == "https://example.com/mcp"
assert allow_origin_fallback is False # OBO never guesses the origin
return MCPOAuthMetadata(
@ -5602,7 +5602,7 @@ class TestMCPServerTimestamps:
_dcr_bridge_relays_client_registration keys off that column."""
manager = MCPServerManager()
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
assert allow_origin_fallback is True
return MCPOAuthMetadata(
scopes=["mcp.read", "mcp.write"],
@ -5817,7 +5817,7 @@ class TestMCPServerTimestamps:
persist_discovered_endpoints=False neither the oauth2 nor the OBO write-back may fire."""
manager = MCPServerManager()
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
return MCPOAuthMetadata(
scopes=["s1"],
authorization_url="https://idp.example.com/authorize",
@ -8539,7 +8539,7 @@ class TestOBOEndpointDiscovery:
)
seen = []
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
seen.append((server_url, allow_origin_fallback))
return discovered
@ -8567,7 +8567,7 @@ class TestOBOEndpointDiscovery:
async def test_config_obo_with_configured_endpoint_skips_discovery(self):
manager = MCPServerManager()
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True):
async def fake_discovery(server_url: str, *, allow_origin_fallback: bool = True, warn_when_no_metadata: bool = False):
raise AssertionError("discovery must not run when the endpoint is configured")
manager._descovery_metadata = fake_discovery # type: ignore[attr-defined]
@ -9028,3 +9028,162 @@ class TestUrllessIssuerDiscovery:
anchored.assert_awaited_once_with("https://idp.example.com", None)
resource_rooted.assert_not_awaited()
assert built.token_url == "https://idp.example.com/token"
class TestDiscoveryFailureLogging:
"""LIT-4658: a misconfigured MCP server url must be diagnosable from default-level server logs.
Discovery failures used to die at debug level and the config-load path emitted no warning at
all, so the only operator-facing signal was the bare 400 at /authorize."""
def _connect_error_client(self, url: str) -> MagicMock:
client = MagicMock()
client.get = AsyncMock(
side_effect=httpx.ConnectError(f"[Errno 8] nodename nor servname provided for {url}")
)
return client
@pytest.mark.asyncio
async def test_descovery_metadata_warns_with_redacted_attempts_on_connect_error(self, caplog):
manager = MCPServerManager()
secret_url = "https://typo-host.example.com/mcp/s/PATHSECRET/mcp"
with (
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
return_value=self._connect_error_client(secret_url),
),
caplog.at_level(logging.WARNING, logger="LiteLLM"),
):
result = await manager._descovery_metadata(secret_url, warn_when_no_metadata=True)
assert result is None
assert "found no authorization server metadata" in caplog.text
assert "ConnectError" in caplog.text
assert "https://typo-host.example.com" in caplog.text
# hosted MCP urls embed credentials in the path; neither the url nor the exception
# text may leak it into warning-level logs
assert "PATHSECRET" not in caplog.text
@pytest.mark.asyncio
async def test_descovery_metadata_stays_silent_without_warn_flag(self, caplog):
manager = MCPServerManager()
url = "https://typo-host.example.com/mcp"
with (
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
return_value=self._connect_error_client(url),
),
caplog.at_level(logging.WARNING, logger="LiteLLM"),
):
result = await manager._descovery_metadata(url)
assert result is None
assert "found no authorization server metadata" not in caplog.text
@pytest.mark.asyncio
async def test_descovery_metadata_attempt_trail_names_each_failed_step(self, caplog):
manager = MCPServerManager()
url = "https://real-host.example.com/mcp-typo"
client = MagicMock()
client.get = AsyncMock(
return_value=httpx.Response(404, request=httpx.Request("GET", url))
)
with (
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client",
return_value=client,
),
caplog.at_level(logging.WARNING, logger="LiteLLM"),
):
result = await manager._descovery_metadata(url, warn_when_no_metadata=True)
assert result is None
assert "HTTP 404" in caplog.text
assert "well-known protected-resource lookup found no authorization servers" in caplog.text
assert "origin fallback" in caplog.text
@pytest.mark.asyncio
async def test_load_servers_from_config_warns_when_endpoints_unresolved(self, caplog):
manager = MCPServerManager()
manager._descovery_metadata = AsyncMock(return_value=None) # type: ignore[attr-defined]
config = {
"typo_server": {
"url": "https://typo.example.com/mcp",
"transport": MCPTransport.http,
"auth_type": MCPAuth.oauth2,
"oauth2_flow": "authorization_code",
}
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.load_servers_from_config(config)
assert "typo_server" in caplog.text
assert "authorization_url, token_url" in caplog.text
assert "unresolved" in caplog.text
assert "verify the configured server url" in caplog.text
@pytest.mark.asyncio
@pytest.mark.parametrize(
"extra_config",
[
{
"authorization_url": "https://idp.example.com/auth",
"token_url": "https://idp.example.com/token",
},
{
"oauth2_flow": "client_credentials",
"token_url": "https://idp.example.com/token",
"client_id": "cid",
"client_secret": "csec",
},
],
)
async def test_load_servers_from_config_silent_when_flow_needs_covered(self, caplog, extra_config):
"""Manually covered endpoints and M2M servers (which never need authorization_url) must not
warn on every reload; the warning is a misconfiguration signal, not discovery telemetry."""
manager = MCPServerManager()
manager._descovery_metadata = AsyncMock(return_value=None) # type: ignore[attr-defined]
config = {
"covered_server": {
"url": "https://up.example.com/mcp",
"transport": MCPTransport.http,
"auth_type": MCPAuth.oauth2,
"oauth2_flow": "authorization_code",
**extra_config,
}
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.load_servers_from_config(config)
assert "unresolved" not in caplog.text
assert "no discovery source" not in caplog.text
@pytest.mark.asyncio
async def test_config_server_without_discovery_source_warns_about_missing_endpoints(self, caplog):
manager = MCPServerManager()
manager._register_openapi_tools = AsyncMock() # type: ignore[attr-defined]
config = {
"spec_only": {
"spec_path": "https://example.com/openapi.yaml",
"transport": MCPTransport.http,
"auth_type": MCPAuth.oauth2,
"oauth2_flow": "authorization_code",
}
}
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.load_servers_from_config(config)
assert "no discovery source" in caplog.text
assert "authorization_url and token_url are not set manually" in caplog.text
@pytest.mark.asyncio
async def test_db_build_warns_when_discovery_fails_for_oauth2_row(self, caplog):
manager = MCPServerManager()
manager._descovery_metadata = AsyncMock(return_value=None) # type: ignore[attr-defined]
record = LiteLLM_MCPServerTable(
server_id="typo-row-1",
server_name="typo_row",
url="https://typo.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="authorization_code",
)
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
await manager.build_mcp_server_from_table(record, credentials_are_encrypted=False)
assert "typo_row" in caplog.text
assert "authorization_url, token_url" in caplog.text
assert "unresolved" in caplog.text