mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(mcp): check OpenAPI specifications without native MCP handshakes
This commit is contained in:
parent
dca71e214b
commit
8c82c325ac
2 changed files with 144 additions and 1 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue