fix(mcp): check OpenAPI specifications without native MCP handshakes

This commit is contained in:
Joshua Valluru 2026-09-10 19:14:15 -07:00
parent dca71e214b
commit 8c82c325ac
2 changed files with 144 additions and 1 deletions

View file

@ -883,6 +883,27 @@ def _sanitized_error_text(exc: Exception) -> str:
return re.sub(r"https?://\S+", "<url>", str(exc))[:200]
async def _openapi_spec_health(
spec_path: str, *, timeout: float
) -> tuple[Literal["healthy", "unhealthy", "unknown"], str | None]:
"""Check specification availability, not upstream operations or user credentials."""
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import load_openapi_spec_async
if not spec_path.startswith(("http://", "https://")):
return "unknown", "OpenAPI servers have no protocol-level health probe"
try:
await asyncio.wait_for(load_openapi_spec_async(spec_path), timeout=timeout)
except asyncio.TimeoutError:
return "unhealthy", f"OpenAPI specification check timed out after {timeout} seconds"
except asyncio.CancelledError:
return "unknown", "OpenAPI specification check was cancelled"
except HTTPStatusError as exc:
return "unhealthy", f"OpenAPI specification request failed (HTTP {exc.response.status_code})"
except (httpx.RequestError, ValueError, OSError) as exc:
return "unhealthy", f"OpenAPI specification could not be loaded ({type(exc).__name__})"
return "healthy", None
def _discovery_failure_leaves_needs_unresolved(
*,
needs_authorization_url: bool,
@ -6665,7 +6686,7 @@ class MCPServerManager:
Returns:
Dict containing health check results
"""
from datetime import datetime
from datetime import datetime, timezone
server: Final = self.get_mcp_server_by_id(server_id)
if not server:
@ -6679,6 +6700,18 @@ class MCPServerManager:
last_health_check=datetime.now(),
)
if server.spec_path:
spec_status, spec_error = await _openapi_spec_health(server.spec_path, timeout=MCP_HEALTH_CHECK_TIMEOUT)
return self._build_mcp_server_table(server).model_copy(
update=MappingProxyType(
{
"status": spec_status,
"health_check_error": spec_error,
"last_health_check": datetime.now(timezone.utc),
}
)
)
status: Literal["healthy", "unhealthy", "unknown"] = "unknown"
health_check_error = None

View file

@ -4473,6 +4473,116 @@ class TestMCPServerManager:
assert len(result) == 1
assert result[0].name == "github_tool_1"
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.none, MCPAuth.bearer_token, MCPAuth.api_key, MCPAuth.oauth2])
@pytest.mark.parametrize("is_byok", [False, True])
@pytest.mark.parametrize("scheme", ["http", "https"])
async def test_openapi_health_loads_spec_without_mcp_handshake(self, respx_mock, monkeypatch, auth_type, is_byok, scheme):
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
manager = MCPServerManager()
server = MCPServer(
server_id="openapi-health",
name="openapi-health",
transport=MCPTransport.http,
url="https://rest.example.com",
spec_path=f"{scheme}://93.184.216.34/openapi.json",
auth_type=auth_type,
is_byok=is_byok,
authentication_token=None if is_byok else "shared-secret",
static_headers={"Authorization": "Bearer static-secret"},
)
manager.registry = {server.server_id: server}
route = respx_mock.get(server.spec_path).respond(200, json={"openapi": "3.0.0", "paths": {}})
result = await manager.health_check_server(server.server_id, mcp_auth_header="caller-secret")
assert result.status == "healthy"
assert result.health_check_error is None
assert result.last_health_check is not None
assert result.spec_path == server.spec_path
assert route.call_count == 1
assert "authorization" not in route.calls[0].request.headers
assert "x-api-key" not in route.calls[0].request.headers
@pytest.mark.asyncio
@pytest.mark.parametrize("auth_type", [MCPAuth.none, MCPAuth.bearer_token])
@pytest.mark.parametrize("spec_path", ["/config/openapi.json", "relative/openapi.json"])
async def test_openapi_local_spec_health_is_unknown(self, respx_mock, auth_type, spec_path):
manager = MCPServerManager()
server = MCPServer(
server_id="local-openapi-health",
name="local-openapi-health",
transport=MCPTransport.http,
url="https://rest.example.com",
spec_path=spec_path,
auth_type=auth_type,
is_byok=True,
)
manager.registry = {server.server_id: server}
result = await manager.health_check_server(server.server_id)
assert result.status == "unknown"
assert result.health_check_error == "OpenAPI servers have no protocol-level health probe"
assert result.last_health_check is not None
assert not respx_mock.calls
@pytest.mark.asyncio
@pytest.mark.parametrize(
("failure", "expected_status", "expected_error"),
[
(httpx.Response(401, text="secret response content"), "unhealthy", "OpenAPI specification request failed (HTTP 401)"),
(httpx.Response(404), "unhealthy", "OpenAPI specification request failed (HTTP 404)"),
(httpx.Response(500), "unhealthy", "OpenAPI specification request failed (HTTP 500)"),
(httpx.ConnectError("secret network details"), "unhealthy", "OpenAPI specification could not be loaded (ConnectError)"),
(httpx.Response(200, text="secret invalid JSON body"), "unhealthy", "OpenAPI specification could not be loaded (JSONDecodeError)"),
],
)
async def test_openapi_health_reports_safe_failures(self, respx_mock, monkeypatch, failure, expected_status, expected_error):
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
manager = MCPServerManager()
server = MCPServer(
server_id="failed-openapi-health",
name="failed-openapi-health",
transport=MCPTransport.http,
url="https://rest.example.com",
spec_path="https://93.184.216.34/key-secret?token=query-secret",
auth_type=MCPAuth.bearer_token,
is_byok=True,
)
manager.registry = {server.server_id: server}
route = respx_mock.get(server.spec_path).mock(side_effect=[failure])
result = await manager.health_check_server(server.server_id)
assert result.status == expected_status
assert result.health_check_error == expected_error
assert result.last_health_check is not None
assert route.call_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel", [False, True])
async def test_openapi_health_timeout_and_cancellation_cleanup(self, respx_mock, monkeypatch, cancel):
from litellm.proxy._experimental.mcp_server.mcp_server_manager import _openapi_spec_health
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
started = asyncio.Event()
cancelled = asyncio.Event()
async def slow_load(request):
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
respx_mock.get("https://93.184.216.34/slow.json").mock(side_effect=slow_load)
task = asyncio.create_task(_openapi_spec_health("https://93.184.216.34/slow.json", timeout=0.1))
await asyncio.wait_for(started.wait(), timeout=1)
if cancel:
task.cancel()
status, error = await task
assert status == ("unknown" if cancel else "unhealthy")
assert error == (
"OpenAPI specification check was cancelled"
if cancel else "OpenAPI specification check timed out after 0.1 seconds"
)
assert cancelled.is_set()
@pytest.mark.asyncio
async def test_health_check_server_healthy(self):
"""Test health check for a healthy server"""