fix(mcp): log upstream exchange when an MCP health check fails (#44125)
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / proxy-infra (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / assert-shard-coverage (push) Waiting to run
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-managed-files (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: jesus <jesus@berri.ai>
This commit is contained in:
devin-ai-integration[bot] 2026-10-16 20:58:26 -07:00 • committed by GitHub
parent 2c5c9b8d84
commit 9f9a02b646
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 95 additions and 3 deletions

View file

@ -498,6 +498,7 @@ class MCPClient:
elicitation_callback: "ElicitationCallback | None" = None,
logging_callback: Callable | None = None,
protocol_version: MCPUpstreamProtocol = "auto",
upstream_transport: httpx2.AsyncBaseTransport | None = None,
):
self.protocol_version: MCPUpstreamProtocol = TypeAdapter[MCPUpstreamProtocol](
MCPUpstreamProtocol
@ -524,6 +525,7 @@ class MCPClient:
self._sampling_callback: Callable | None = sampling_callback
self._elicitation_callback: ElicitationCallback | None = elicitation_callback
self._logging_callback: Callable | None = logging_callback
self._upstream_transport: httpx2.AsyncBaseTransport | None = upstream_transport
self._session_callbacks_override: _SessionCallbackMap | None = None
# handle the basic auth value if provided
if auth_value:
@ -610,7 +612,11 @@ class MCPClient:
return stdio_client(server_params), None
if self.transport_type == MCPTransport.sse:
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
httpx_client_factory = (
self._create_httpx_client_factory()
if self._upstream_transport is None
else self._create_httpx_client_factory(transport=self._upstream_transport)
)
return (
sse_client(
url=self.server_url,
@ -622,7 +628,11 @@ class MCPClient:
)
# HTTP transport (default)
headers = self._get_auth_headers()
httpx_client_factory = self._create_httpx_client_factory()
httpx_client_factory = (
self._create_httpx_client_factory()
if self._upstream_transport is None
else self._create_httpx_client_factory(transport=self._upstream_transport)
)
verbose_logger.debug("litellm headers for streamable_http_client: %s", headers)
http_client = httpx_client_factory(
headers=headers,

View file

@ -1970,6 +1970,7 @@ class MCPServerManager:
per_user_oauth_token_store: InvalidatableOAuthTokenStore | None = None,
per_user_token_cache: MCPPerUserTokenCache | None = None,
discovery_clock: Callable[[], float] = time.monotonic,
upstream_transport: httpx2.AsyncBaseTransport | None = None,
):
self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
self.get_mcp_server_by_id
@ -1979,6 +1980,7 @@ class MCPServerManager:
oauth_token_store=self._per_user_oauth_token_store,
token_exchanger=build_token_exchanger(),
)
self._upstream_transport: httpx2.AsyncBaseTransport | None = upstream_transport
discovery_ttl: Final = _mcp_discovery_cache_ttl()
self._prompt_discovery_cache = _DiscoveryCache[ListPromptsResult](
discovery_ttl, discovery_clock, TypeAdapter(ListPromptsResult)
@ -4348,6 +4350,7 @@ class MCPServerManager:
if resolved_server.allow_elicitation
else None
),
upstream_transport=self._upstream_transport,
)
_create_mcp_client = create_mcp_client
@ -7466,7 +7469,10 @@ class MCPServerManager:
return "ok"
# Add timeout wrapper to prevent hanging
await asyncio.wait_for(client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT)
await asyncio.wait_for(
client.run_with_session(_noop, quiet_on_error=True),
timeout=MCP_HEALTH_CHECK_TIMEOUT,
)
self._remember_upstream_initialize_instructions(server, client)
status = "healthy"
except asyncio.TimeoutError:
@ -7478,6 +7484,12 @@ class MCPServerManager:
except Exception as e:
health_check_error = str(e)
status = "unhealthy"
verbose_logger.warning(
"MCP health check failed for %s: %s%s",
server.name,
type(e).__name__,
_upstream_failure_suffix(e),
)
return LiteLLM_MCPServerTable(
server_id=server.server_id,

View file

@ -207,6 +207,7 @@ async def prepare_upstream_client(
protocol_version: MCPUpstreamProtocol,
sampling_callback: SamplingCallback | None = None,
elicitation_callback: ElicitationCallback | None = None,
upstream_transport: httpx2.AsyncBaseTransport | None = None,
) -> MCPClient:
transport: Final[MCPTransportType] = server.transport or MCPTransport.sse
server_spec: Final = None if transport == MCPTransport.stdio else to_server_spec_fail_closed(server)
@ -263,6 +264,7 @@ async def prepare_upstream_client(
server_url=server.url or "",
transport_type=transport,
protocol_version=protocol_version,
upstream_transport=upstream_transport,
auth_type=server.auth_type,
timeout=timeout,
extra_headers=resolved_headers,
@ -304,6 +306,7 @@ async def prepare_upstream_client(
server_url=server.url or "",
transport_type=transport,
protocol_version=protocol_version,
upstream_transport=upstream_transport,
auth_type=server.auth_type,
auth_value=auth_value,
auth_header_name=auth_header_name,

View file

@ -15634,6 +15634,73 @@ def _mcp_upstream(respond):
yield
@pytest.mark.asyncio
@pytest.mark.parametrize(
("responder", "expects_exchange", "expected_fragments"),
(
pytest.param(
AsyncMock(return_value=httpx2.Response(500, json={"error": "health-check-boom"})),
True,
(
"MCP health check failed for",
"upstream exchange:",
"-> HTTP 500",
'"method":"initialize"',
"health-check-boom",
),
id="upstream-http-500",
),
pytest.param(
AsyncMock(side_effect=httpx2.ConnectError("connection failed")),
False,
("MCP health check failed for",),
id="connect-error",
),
),
)
async def test_health_check_logs_upstream_exchange_only_for_http_failures(
caplog: pytest.LogCaptureFixture,
responder: AsyncMock,
expects_exchange: bool,
expected_fragments: tuple[str, ...],
) -> None:
manager: Final = MCPServerManager(upstream_transport=httpx2.MockTransport(responder))
token: Final = "health-check-secret-0123456789"
server: Final = MCPServer(
server_id="health-check-upstream",
name="health_check_upstream",
url="https://upstream.example/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.bearer_token,
authentication_token=token,
)
manager.registry[server.server_id] = server
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
result: Final = await manager.health_check_server(server.server_id)
assert result.status == "unhealthy"
warning_messages: Final = tuple(
record.getMessage()
for record in caplog.records
if record.name == "LiteLLM"
and record.levelno == logging.WARNING
and "MCP health check failed for" in record.getMessage()
)
assert len(warning_messages) == 1
warning: Final = warning_messages[0]
assert all(fragment in warning for fragment in expected_fragments)
assert ("upstream exchange:" in warning) is expects_exchange
assert not any(
record.name == "LiteLLM"
and record.levelno == logging.WARNING
and "run_with_session failed" in record.getMessage()
for record in caplog.records
)
assert token not in caplog.text
assert responder.await_args_list[0].args[0].headers["Authorization"] == f"Bearer {token}"
class _DiscoveryUpstream:
def __init__(self) -> None:
self.requests: tuple[tuple[str, str], ...] = ()