From 6d1c9b1f7b2eb68d39646751f085138f8e8f9781 Mon Sep 17 00:00:00 2001 From: ruoyu-lu Date: Mon, 7 Sep 2026 23:32:24 +1000 Subject: [PATCH 1/3] fix(mcp): probe openapi-backed servers by their spec in health checks An OpenAPI-backed server stores the REST base url in `url` and only looks like http transport, so the health check opened an MCP session against a plain REST API. The upstream answers 404, which the MCP SDK reports as "Session terminated", and the server showed as unhealthy even though every tool had loaded from its spec These servers now probe the spec: reachable and parseable means healthy, and a spec that stopped loading reports the real upstream error. Per-user auth still skips to unknown, since fetching the spec userless could 401 and report a false failure _ensure_upstream_initialize_instructions_cached already skips OpenAPI servers for the same reason, and its docstring says it mirrors health_check_server's skip rules Fixes #40079 --- .../mcp_server/mcp_server_manager.py | 42 ++++++---- .../mcp_server/test_mcp_server_manager.py | 82 +++++++++++++++++++ 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 612596bc803..8d479ac4c9d 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -6665,25 +6665,33 @@ class MCPServerManager: if not should_skip_health_check: try: - resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars( - server=server, - user_api_key_auth=None, - raise_on_missing=False, - ) - extra_headers: Final = dict(resolved_static_headers) if resolved_static_headers else {} - client: Final = await self._create_mcp_client( - server=server, - mcp_auth_header=None, - extra_headers=extra_headers, - stdio_env=None, - ) + if server.spec_path: + # url is a REST base, not an MCP endpoint, so the spec is the only thing to probe + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + load_openapi_spec_async, + ) - async def _noop(session): - return "ok" + await asyncio.wait_for(load_openapi_spec_async(server.spec_path), timeout=MCP_HEALTH_CHECK_TIMEOUT) + else: + resolved_static_headers: Final = await self._resolve_static_headers_with_env_vars( + server=server, + user_api_key_auth=None, + raise_on_missing=False, + ) + extra_headers: Final = dict(resolved_static_headers) if resolved_static_headers else {} + client: Final = await self._create_mcp_client( + server=server, + mcp_auth_header=None, + extra_headers=extra_headers, + stdio_env=None, + ) - # Add timeout wrapper to prevent hanging - await asyncio.wait_for(client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT) - self._remember_upstream_initialize_instructions(server, client) + async def _noop(session): + return "ok" + + # Add timeout wrapper to prevent hanging + await asyncio.wait_for(client.run_with_session(_noop), timeout=MCP_HEALTH_CHECK_TIMEOUT) + self._remember_upstream_initialize_instructions(server, client) status = "healthy" except asyncio.TimeoutError: health_check_error = f"Health check timed out after {MCP_HEALTH_CHECK_TIMEOUT} seconds" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 764e2bb0e99..525d7e419d3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -4493,6 +4493,88 @@ class TestMCPServerManager: assert result.status == "unhealthy" assert "OAuth discovery unavailable" in (result.health_check_error or "") + @pytest.mark.asyncio + async def test_health_check_server_openapi_spec_is_probed_instead_of_mcp_session(self): + """An OpenAPI server is healthy when its spec loads, and is never asked to speak MCP. + + Regression for #40079: these servers store the REST base url in ``url`` and only look like + http transport, so opening an MCP session against them always failed with "Session + terminated" even though every tool had loaded from the spec. + """ + manager = MCPServerManager() + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + url="http://rest.example.com", + spec_path="http://rest.example.com/openapi.json", + ) + manager.get_mcp_server_by_id = MagicMock(return_value=server) + manager._create_mcp_client = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", + new=AsyncMock(return_value={"openapi": "3.0.0", "paths": {}}), + ) as mock_load: + result = await manager.health_check_server("openapi-server") + + assert result.status == "healthy" + assert result.health_check_error is None + mock_load.assert_awaited_once_with("http://rest.example.com/openapi.json") + manager._create_mcp_client.assert_not_called() + + @pytest.mark.asyncio + async def test_health_check_server_openapi_spec_failure_is_unhealthy(self): + """A spec that stopped loading is unhealthy, and the upstream error is what gets reported.""" + manager = MCPServerManager() + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + url="http://rest.example.com", + spec_path="http://rest.example.com/openapi.json", + ) + manager.get_mcp_server_by_id = MagicMock(return_value=server) + manager._create_mcp_client = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", + new=AsyncMock(side_effect=httpx.ConnectError("Cannot connect to host rest.example.com")), + ): + result = await manager.health_check_server("openapi-server") + + assert result.status == "unhealthy" + assert result.health_check_error == "Cannot connect to host rest.example.com" + manager._create_mcp_client.assert_not_called() + + @pytest.mark.asyncio + async def test_health_check_server_openapi_spec_keeps_per_user_auth_skip(self): + """Per-user auth still wins: fetching the spec userless could 401 and report a false failure.""" + manager = MCPServerManager() + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + transport=MCPTransport.http, + auth_type=MCPAuth.bearer_token, + url="http://rest.example.com", + spec_path="http://rest.example.com/openapi.json", + ) + manager.get_mcp_server_by_id = MagicMock(return_value=server) + manager._create_mcp_client = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", + new=AsyncMock(return_value={"openapi": "3.0.0", "paths": {}}), + ) as mock_load: + result = await manager.health_check_server("openapi-server") + + assert result.status == "unknown" + assert result.health_check_error is None + mock_load.assert_not_called() + manager._create_mcp_client.assert_not_called() + @pytest.mark.asyncio async def test_health_check_server_not_found(self): """Test health check for a server that doesn't exist""" From 229f1fb6d981ed1854bce9e0fb044698b15af9a3 Mon Sep 17 00:00:00 2001 From: ruoyu-lu Date: Mon, 7 Sep 2026 23:57:13 +1000 Subject: [PATCH 2/3] test(mcp): drive the openapi health check tests through real spec files The added tests replaced the manager's own lookup and client factory with mocks, which the repo guidance calls an anti-pattern, and patched litellm internals to stand in for the spec load, which the test quality gate counts against its budget They now seed config_mcp_servers and point spec_path at a real file on disk, so nothing is faked. url is a closed port, so a regression that opens an MCP session instead reports unhealthy and the tests fail --- .../mcp_server/test_mcp_server_manager.py | 115 ++++++++---------- 1 file changed, 51 insertions(+), 64 deletions(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 525d7e419d3..80b00cf4306 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -4494,86 +4494,73 @@ class TestMCPServerManager: assert "OAuth discovery unavailable" in (result.health_check_error or "") @pytest.mark.asyncio - async def test_health_check_server_openapi_spec_is_probed_instead_of_mcp_session(self): + async def test_health_check_server_openapi_spec_is_probed_instead_of_mcp_session(self, tmp_path): """An OpenAPI server is healthy when its spec loads, and is never asked to speak MCP. Regression for #40079: these servers store the REST base url in ``url`` and only look like http transport, so opening an MCP session against them always failed with "Session - terminated" even though every tool had loaded from the spec. + terminated" even though every tool had loaded from the spec. ``url`` is a closed port, so + an MCP handshake would report unhealthy. + """ + spec = tmp_path / "openapi.json" + spec.write_text(json.dumps({"openapi": "3.0.0", "paths": {}})) + manager = MCPServerManager() + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + url="http://127.0.0.1:1", + spec_path=str(spec), + ) + manager.config_mcp_servers[server.server_id] = server + + result = await manager.health_check_server("openapi-server") + + assert result.status == "healthy" + assert result.health_check_error is None + + @pytest.mark.asyncio + async def test_health_check_server_openapi_spec_failure_is_unhealthy(self, tmp_path): + """A spec that stopped loading is unhealthy, and the upstream error is what gets reported.""" + missing = tmp_path / "openapi.json" + manager = MCPServerManager() + server = MCPServer( + server_id="openapi-server", + name="openapi-server", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + url="http://127.0.0.1:1", + spec_path=str(missing), + ) + manager.config_mcp_servers[server.server_id] = server + + result = await manager.health_check_server("openapi-server") + + assert result.status == "unhealthy" + assert result.health_check_error == f"OpenAPI spec not found at {missing}" + + @pytest.mark.asyncio + async def test_health_check_server_openapi_spec_keeps_per_user_auth_skip(self, tmp_path): + """Per-user auth still wins: fetching the spec userless could 401 and report a false failure. + + The spec file is absent, so probing it would have reported unhealthy rather than unknown. """ manager = MCPServerManager() - server = MCPServer( - server_id="openapi-server", - name="openapi-server", - transport=MCPTransport.http, - auth_type=MCPAuth.none, - url="http://rest.example.com", - spec_path="http://rest.example.com/openapi.json", - ) - manager.get_mcp_server_by_id = MagicMock(return_value=server) - manager._create_mcp_client = AsyncMock() - - with patch( - "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", - new=AsyncMock(return_value={"openapi": "3.0.0", "paths": {}}), - ) as mock_load: - result = await manager.health_check_server("openapi-server") - - assert result.status == "healthy" - assert result.health_check_error is None - mock_load.assert_awaited_once_with("http://rest.example.com/openapi.json") - manager._create_mcp_client.assert_not_called() - - @pytest.mark.asyncio - async def test_health_check_server_openapi_spec_failure_is_unhealthy(self): - """A spec that stopped loading is unhealthy, and the upstream error is what gets reported.""" - manager = MCPServerManager() - server = MCPServer( - server_id="openapi-server", - name="openapi-server", - transport=MCPTransport.http, - auth_type=MCPAuth.none, - url="http://rest.example.com", - spec_path="http://rest.example.com/openapi.json", - ) - manager.get_mcp_server_by_id = MagicMock(return_value=server) - manager._create_mcp_client = AsyncMock() - - with patch( - "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", - new=AsyncMock(side_effect=httpx.ConnectError("Cannot connect to host rest.example.com")), - ): - result = await manager.health_check_server("openapi-server") - - assert result.status == "unhealthy" - assert result.health_check_error == "Cannot connect to host rest.example.com" - manager._create_mcp_client.assert_not_called() - - @pytest.mark.asyncio - async def test_health_check_server_openapi_spec_keeps_per_user_auth_skip(self): - """Per-user auth still wins: fetching the spec userless could 401 and report a false failure.""" - manager = MCPServerManager() server = MCPServer( server_id="openapi-server", name="openapi-server", transport=MCPTransport.http, auth_type=MCPAuth.bearer_token, - url="http://rest.example.com", - spec_path="http://rest.example.com/openapi.json", + url="http://127.0.0.1:1", + spec_path=str(tmp_path / "openapi.json"), ) - manager.get_mcp_server_by_id = MagicMock(return_value=server) - manager._create_mcp_client = AsyncMock() + manager.config_mcp_servers[server.server_id] = server - with patch( - "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.load_openapi_spec_async", - new=AsyncMock(return_value={"openapi": "3.0.0", "paths": {}}), - ) as mock_load: - result = await manager.health_check_server("openapi-server") + result = await manager.health_check_server("openapi-server") assert result.status == "unknown" assert result.health_check_error is None - mock_load.assert_not_called() - manager._create_mcp_client.assert_not_called() @pytest.mark.asyncio async def test_health_check_server_not_found(self): From 79060acfc91c49e15e3c81dd49043133323c64aa Mon Sep 17 00:00:00 2001 From: Ruoyu Lu <93816781+ruoyu-lu@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:36:07 +1000 Subject: [PATCH 3/3] fix(mcp): keep local OpenAPI reads off the event loop --- .../_experimental/mcp_server/openapi_to_mcp_generator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 16f58ef5b76..4d8693317e3 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -170,8 +170,10 @@ async def load_openapi_spec_async(filepath: str) -> dict[str, Any]: r.raise_for_status() return r.json() - # fallback: local file - # Local filesystem path + return await asyncio.to_thread(_read_local_openapi_spec, filepath) + + +def _read_local_openapi_spec(filepath: str) -> dict[str, Any]: if not os.path.exists(filepath): raise FileNotFoundError(f"OpenAPI spec not found at {filepath}") with open(filepath, "r", encoding="utf-8") as f: