mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
commit
712136d10d
7 changed files with 627 additions and 125 deletions
|
|
@ -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)",
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue